Skip to content

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.

  • RenderEdit without a screen or routescreen is a plain EntityEditScreenDefinition literal, never passed to r.screen. Nothing about RenderEdit touches Nav or the router.
  • Create-mode prefill from an external sourceinitial comes from a suggestion prop, entityId={null}, no detail-fetch for the entity being created.
  • Controlled modeonChange reports { dirty, valid } on every keystroke; onControlsReady hands back patch/validate so the Drawer’s footer can restore the suggestion’s values and re-validate without a remount or a write.
  • customSubmit instead 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:accept merges that onto the suggestion it already has server-side.
  • One write, two entities, atomicallyprospect:accept creates the prospect, stamps source/acceptedBy/acceptedAt (fields never on the edit form, so the client can’t set them), and flips the suggestion to accepted — the same DB transaction, so a suggestion never ends up accepted without a prospect or vice versa.
suggestion — externally-sourced draft, seeded via the standard CRUD create
prospect — created only through prospect:accept, never through r.crud
  1. A caller has a suggestion (elsewhere seeded, e.g. by an AI extraction pipeline) and opens AcceptSuggestionDrawer next to it.
  2. The Drawer prefills RenderEdit from the suggestion’s fields.
  3. The user edits a field or two; onChange updates the live status text.
  4. Submit calls customSubmit, which dispatches prospect:accept with just { suggestionId, changes }.
  5. The handler merges changes onto the suggestion, creates the prospect, and marks the suggestion accepted. A second accept on the same suggestion is rejected (unprocessable).

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.

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.


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