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.
What it shows
Section titled “What it shows”header(title/subtitle/status) andmetrics(each with its own i18nlabel) rendered from the columns of the screen’s own query rowlayout.mode: "tabs"with tworelatedListtabs (order items, payments) and onefieldstab (master data) — eachrelatedListruns its own query- a header action with
showOnTabs: ["payments"], shown only while that tab is active - boot-validator catching two common author mistakes: a
metricsentry with an emptylabel, and atabslayout with fewer than two sections
Source
Section titled “Source”Feature entry point: src/feature.ts.
cd samples/recipes/record-detail-layoutbun testSource code
Section titled “Source code”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