Skip to content

Record Detail Layout

Register a projectionDetail screen as a tabbed record view (“Akte”): a header, a metrics band, and a tabbed layout instead of one long stacked form.

  • header (title/subtitle/status) and metrics (each with its own i18n label) rendered from the columns of the screen’s own query row
  • layout.mode: "tabs" with two relatedList tabs (order items, payments) and one fields tab (master data) — each relatedList runs its own query
  • a header action with showOnTabs: ["payments"], shown only while that tab is active
  • boot-validator catching two common author mistakes: a metrics entry with an empty label, and a tabs layout with fewer than two sections

Feature entry point: src/feature.ts.

Terminal window
cd samples/recipes/record-detail-layout
bun test

The feature entry point — embedded straight from the source file, so the code here is exactly what runs. Multi-file samples keep their remaining files next to it on GitHub (link below):

// Record Detail Layout Showcase — a projectionDetail screen with the
// "Akte" (case file) layout:
// 1. `header` renders the query row's customer/order/status columns above
// the layout — the identity a support agent scans first.
// 2. `metrics` renders a labeled band of key numbers from the same row —
// each metric carries its own i18n `label`, there is no fallback to the
// raw column name.
// 3. `layout.mode: "tabs"` splits the record into four tabs: two
// `relatedList` sections (own query, own pagination, mounts only when
// active), one `fields` section (master data from the detail row), and
// one `extension` section (a plugin-mounted component, fw#3234 padding-
// parity check — every tab kind must sit in the same Card padding, no
// section carries extra chrome of its own).
// 4. The payments tab's emptyState picks its text from the order record through `variants`.
// 5. A screen-level `actions` entry (fw#3234) renders next to the header,
// not inside any tab's Card. `inlineActions` keeps download-invoice a visible
// button next to it where the page header would otherwise fold it into the menu.
//
// 6. `showOnTabs` limits the record-payment action to the payments tab.
//
// projectionDetail has no entity — it binds to an explicit query instead, so
// every field name below (header, metrics, fields) is a column of that
// query's row, not an entity field.
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
import { z } from "zod";
const rowIdSchema = z.object({ id: z.string() });
export function createOrderDeskFeature(): FeatureDefinition {
return defineFeature("order-desk", (r) => {
r.systemScope();
r.queryHandler({
name: "order:detail",
schema: rowIdSchema,
handler: async (query) => ({
id: query.payload.id,
customerName: "Aiko Tanaka",
orderNumber: "SO-10482",
status: "processing",
totalAmount: "€ 1,248.00",
outstandingAmount: "€ 312.00",
itemCount: "3",
placedAt: "2026-08-21",
shippingAddress: "Karl-Marx-Straße 12, 12043 Berlin",
billingAddress: "Karl-Marx-Straße 12, 12043 Berlin",
salesRep: "Jonas Weber",
}),
access: { roles: ["Support", "Admin"] },
});
r.queryHandler({
name: "order:items",
schema: rowIdSchema,
handler: async () => ({
rows: [
{ sku: "KB-100", description: "Mechanical keyboard", quantity: 1, unitPrice: 148 },
{ sku: "MS-220", description: "Wireless mouse", quantity: 2, unitPrice: 45 },
{ sku: "PAD-01", description: "Desk mat", quantity: 1, unitPrice: 22 },
],
}),
access: { roles: ["Support", "Admin"] },
});
r.queryHandler({
name: "order:payments",
schema: rowIdSchema,
handler: async () => ({
rows: [
{ paidAt: "2026-08-21", amount: 936, method: "card", status: "settled" },
{ paidAt: "2026-08-25", amount: 312, method: "invoice", status: "pending" },
],
}),
access: { roles: ["Support", "Admin"] },
});
r.screen({
id: "order-detail",
type: "projectionDetail",
query: "order-desk:query:order:detail",
header: {
title: "customerName",
// Parts are joined by a separator; `format` formats the raw field value.
subtitle: [
"orderNumber",
{ field: "placedAt", format: { format: "date", dateStyle: "medium" } },
],
status: "status",
},
metrics: [
{ field: "totalAmount", label: "order-desk.metric.totalAmount" },
{ field: "outstandingAmount", label: "order-desk.metric.outstandingAmount" },
{ field: "itemCount", label: "order-desk.metric.itemCount" },
{ field: "placedAt", label: "order-desk.metric.placedAt" },
],
layout: {
mode: "tabs",
sections: [
{
id: "items",
kind: "relatedList",
title: "order-desk.tab.items",
query: "order-desk:query:order:items",
columns: [
"sku",
"description",
"quantity",
{ field: "unitPrice", renderer: { format: "currency", symbol: "€" } },
],
},
{
id: "payments",
kind: "relatedList",
title: "order-desk.tab.payments",
query: "order-desk:query:order:payments",
columns: [
"paidAt",
{ field: "amount", renderer: { format: "currency", symbol: "€" } },
"method",
"status",
],
emptyState: {
title: "order-desk.payments.empty",
variants: [
{
when: { field: "status", eq: "pending" },
title: "order-desk.payments.empty-pending",
},
],
},
},
{
id: "details",
kind: "fields",
title: "order-desk.tab.details",
fields: ["shippingAddress", "billingAddress", "salesRep"],
},
{
id: "internal-note",
kind: "extension",
title: "order-desk.tab.internalNote",
component: { react: { __component: "OrderInternalNote" } },
entityName: "order",
},
],
},
actions: [
{
kind: "navigate",
id: "refresh-order",
label: "order-desk.action.refreshOrder",
screen: "order-detail",
icon: "refresh",
},
{
kind: "navigate",
id: "download-invoice",
label: "order-desk.action.downloadInvoice",
screen: "order-detail",
icon: "download",
},
{
kind: "navigate",
id: "record-payment",
label: "order-desk.action.recordPayment",
screen: "order-detail",
icon: "plus",
showOnTabs: ["payments"],
},
],
inlineActions: ["download-invoice"],
access: { roles: ["Support", "Admin"] },
});
});
}

📄 On GitHub: samples/recipes/record-detail-layout/src/feature.ts