Embedded entity form
Host RenderEdit inside a Drawer instead of a dedicated screen — no nav
entry, no route. The form is prefilled from an externally-sourced record
(a suggestion, not this entity’s own detail query), runs in controlled mode
so the host can read live dirty/valid state and re-validate after patching
values from outside, and saves through a custom write handler instead of
the built-in CRUD create.
What it shows
Section titled “What it shows”RenderEditwithout a screen or route —screenis a plainEntityEditScreenDefinitionliteral, never passed tor.screen. Nothing aboutRenderEdittouches Nav or the router.- Create-mode prefill from an external source —
initialcomes from asuggestionprop,entityId={null}, no detail-fetch for the entity being created. - Controlled mode —
onChangereports{ dirty, valid }on every keystroke;onControlsReadyhands backpatch/validateso the Drawer’s footer can restore the suggestion’s values and re-validate without a remount or a write. customSubmitinstead of the built-in CRUD create — the client sends only the fields the user actually changed (snapshot.changes, the same delta the controlled mode reports);prospect:acceptmerges that onto the suggestion it already has server-side.- One write, two entities, atomically —
prospect:acceptcreates the prospect, stampssource/acceptedBy/acceptedAt(fields never on the edit form, so the client can’t set them), and flips the suggestion toaccepted— the same DB transaction, so a suggestion never ends up accepted without a prospect or vice versa.
Feature composition
Section titled “Feature composition”suggestion — externally-sourced draft, seeded via the standard CRUD createprospect — created only through prospect:accept, never through r.crud- A caller has a
suggestion(elsewhere seeded, e.g. by an AI extraction pipeline) and opensAcceptSuggestionDrawernext to it. - The Drawer prefills
RenderEditfrom the suggestion’s fields. - The user edits a field or two;
onChangeupdates the live status text. - Submit calls
customSubmit, which dispatchesprospect:acceptwith just{ suggestionId, changes }. - The handler merges
changesonto the suggestion, creates the prospect, and marks the suggestionaccepted. A second accept on the same suggestion is rejected (unprocessable).
When to reach for it
Section titled “When to reach for it”You have a form that belongs next to something else — a list row, an inbox
item, a reference field — not on its own page, and saving it needs more
than a single-entity CRUD write (merging server-known data, writing a
second record, stamping fields the client must not control). Use the plain
writeCommand + built-in submit path instead when the form both lives on
its own screen and a single CRUD write is enough.
Source
Section titled “Source”The feature entry point is src/feature.ts; the Drawer component is
src/web/accept-suggestion-drawer.tsx. Integration tests under
src/__tests__/ cover the merge, the atomic status flip, the double-accept
rejection, and per-role access.
Source 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):
// Embedded Entity Form Sample// Shows: RenderEdit hosted in a Drawer with no dedicated screen/route,// prefilled from an externally-sourced record (a suggestion, not this// entity's own detail query), controlled mode (onChange + validate-// without-write via onControlsReady), and a custom write handler instead// of the built-in CRUD create.//// Tables:// suggestion — an externally-sourced draft (e.g. an AI extraction),// seeded via the standard CRUD create// prospect — created only through `prospect:accept`, which merges the// caller's field-level changes onto the suggestion it// already knows server-side, stamps provenance the client// can't set itself, and flips the suggestion to accepted —// one atomic write the built-in CRUD create can't do.
import { createEntityExecutor, defineFeature } from "@cosmicdrift/kumiko-framework/engine";import { failNotFound, failUnprocessable } from "@cosmicdrift/kumiko-framework/errors";import { getTemporal } from "@cosmicdrift/kumiko-framework/time";import { z } from "zod";import { prospectEntity } from "./entities/prospect";import { suggestionEntity } from "./entities/suggestion";
export { prospectEntity } from "./entities/prospect";export { suggestionEntity } from "./entities/suggestion";
const adminWrite = { access: { roles: ["Admin"] } } as const;const openRead = { access: { openToAll: true } } as const;
const acceptChangesSchema = z.object({ name: z.string().min(1).optional(), email: z.union([z.email(), z.literal("")]).optional(), company: z.string().optional(), notes: z.string().optional(),});
export const prospectsFeature = defineFeature("prospects", (r) => { r.crud("suggestion", suggestionEntity, { write: adminWrite, read: openRead }); r.entity("prospect", prospectEntity);
const { executor: suggestionExecutor } = createEntityExecutor("suggestion", suggestionEntity); const { executor: prospectExecutor } = createEntityExecutor("prospect", prospectEntity);
// Custom handler: the client only ever sends the fields the user actually // edited (RenderEdit's onChange `changes`, same delta the controlled mode // reports) — the handler owns merging that onto the suggestion it already // has, so an untouched field never has to round-trip through the browser. r.writeHandler( "prospect:accept", z.object({ suggestionId: z.uuid(), changes: acceptChangesSchema }), async (event, ctx) => { const suggestion = await suggestionExecutor.detail( { id: event.payload.suggestionId }, event.user, ctx.db, ); if (!suggestion) return failNotFound("suggestion", event.payload.suggestionId); if (suggestion["status"] !== "pending") { return failUnprocessable("suggestion_already_processed", { status: suggestion["status"] }); }
const created = await prospectExecutor.create( { name: suggestion["name"], email: suggestion["email"], company: suggestion["company"], notes: suggestion["notes"], ...event.payload.changes, source: `suggestion:${event.payload.suggestionId}`, acceptedBy: `user:${event.user.id}`, acceptedAt: getTemporal().Now.instant().toString(), }, event.user, ctx.db, ); if (!created.isSuccess) return created;
// Same ctx.db as the create above — one transaction, so a suggestion // never ends up "accepted" without a prospect or vice versa. const flipped = await suggestionExecutor.update( { id: event.payload.suggestionId, version: suggestion["version"] as number, changes: { status: "accepted" }, }, event.user, ctx.db, ); if (!flipped.isSuccess) return flipped; return created; }, adminWrite, );
r.queryHandler( "prospect:detail", z.object({ id: z.uuid() }), async (query, ctx) => prospectExecutor.detail(query.payload, query.user, ctx.db), openRead, );});📄 On GitHub: samples/recipes/embedded-entity-form/src/feature.ts