@cosmicdrift/kumiko-renderer-web 0.208.2 → 0.209.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.208.2",
3
+ "version": "0.209.0",
4
4
  "description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -16,9 +16,9 @@
16
16
  "./styles.css": "./src/styles.css"
17
17
  },
18
18
  "dependencies": {
19
- "@cosmicdrift/kumiko-dispatcher-live": "0.208.2",
20
- "@cosmicdrift/kumiko-headless": "0.208.2",
21
- "@cosmicdrift/kumiko-renderer": "0.208.2",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.209.0",
20
+ "@cosmicdrift/kumiko-headless": "0.209.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.209.0",
22
22
  "@radix-ui/react-dialog": "^1.1.15",
23
23
  "@radix-ui/react-dropdown-menu": "^2.1.16",
24
24
  "@radix-ui/react-label": "^2.1.8",
@@ -64,7 +64,7 @@
64
64
  "@types/react-dom": "^19.2.3",
65
65
  "jsdom": "^29.1.1",
66
66
  "tailwindcss": "^4.3.0",
67
- "@cosmicdrift/kumiko-locale-de": "0.208.2"
67
+ "@cosmicdrift/kumiko-locale-de": "0.209.0"
68
68
  },
69
69
  "repository": {
70
70
  "type": "git",
@@ -1258,6 +1258,128 @@ describe("KumikoScreen", () => {
1258
1258
  expect(writeCalls[0]).toEqual({ type: "tasks:write:task:sync", payload: { all: true } });
1259
1259
  });
1260
1260
 
1261
+ // toolbarActions kind:"drawer" (fw#2225): mounts the referenced actionForm
1262
+ // in the Drawer primitive instead of navigating — reuses ActionFormBody
1263
+ // (no second form renderer), so submit/cancel/field-rendering all go
1264
+ // through the same RenderEdit path entityEdit/actionForm already use.
1265
+ describe("entityList toolbarActions drawer-kind (fw#2225)", () => {
1266
+ const noteForm: ActionFormScreenDefinition = {
1267
+ id: "task-note",
1268
+ type: "actionForm",
1269
+ handler: "tasks:write:task:note",
1270
+ fields: { note: { type: "text", required: true } },
1271
+ layout: { sections: [{ fields: ["note"] }] },
1272
+ };
1273
+ const screenWithDrawer: EntityListScreenDefinition = {
1274
+ id: "task-list",
1275
+ type: "entityList",
1276
+ entity: "task",
1277
+ columns: ["title"],
1278
+ toolbarActions: [
1279
+ { kind: "drawer", id: "add-note", label: "actions.addNote", screen: "task-note" },
1280
+ ],
1281
+ };
1282
+
1283
+ test("Click opens the Drawer with the referenced actionForm's fields", async () => {
1284
+ const dispatcher = makeDispatcher({
1285
+ query: (async () => ({
1286
+ isSuccess: true,
1287
+ data: { rows: [{ id: "r1", title: "x", count: 0, done: false }], nextCursor: null },
1288
+ })) as unknown as Dispatcher["query"],
1289
+ });
1290
+ const user = userEvent.setup();
1291
+ render(
1292
+ <DispatcherProvider dispatcher={dispatcher}>
1293
+ <KumikoScreen
1294
+ schema={{ ...schema, screens: [screenWithDrawer, noteForm] }}
1295
+ qn="tasks:screen:task-list"
1296
+ />
1297
+ </DispatcherProvider>,
1298
+ );
1299
+ await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
1300
+ expect(screen.queryByTestId("field-note")).toBeNull();
1301
+
1302
+ await user.click(screen.getByTestId("render-list-toolbar-action-add-note"));
1303
+ expect(screen.getByTestId("render-edit-form")).toBeTruthy();
1304
+ expect(screen.getByTestId("field-note")).toBeTruthy();
1305
+ });
1306
+
1307
+ test("Successful submit calls the actionForm's handler, closes the Drawer, and reloads the list", async () => {
1308
+ const writeCalls: { type: string; payload: unknown }[] = [];
1309
+ let queryCallCount = 0;
1310
+ const dispatcher = makeDispatcher({
1311
+ query: (async () => {
1312
+ queryCallCount += 1;
1313
+ return { isSuccess: true, data: { rows: [], nextCursor: null } };
1314
+ }) as unknown as Dispatcher["query"],
1315
+ write: (async (type: string, payload: unknown) => {
1316
+ writeCalls.push({ type, payload });
1317
+ return { isSuccess: true, data: {} };
1318
+ }) as unknown as Dispatcher["write"],
1319
+ });
1320
+ const user = userEvent.setup();
1321
+ render(
1322
+ <DispatcherProvider dispatcher={dispatcher}>
1323
+ <KumikoScreen
1324
+ schema={{ ...schema, screens: [screenWithDrawer, noteForm] }}
1325
+ qn="tasks:screen:task-list"
1326
+ />
1327
+ </DispatcherProvider>,
1328
+ );
1329
+ await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
1330
+ await user.click(screen.getByTestId("render-list-toolbar-action-add-note"));
1331
+ expect(screen.getByTestId("render-edit-form")).toBeTruthy();
1332
+ const queryCallsBeforeSubmit = queryCallCount;
1333
+
1334
+ // field-note's testId sits on the Field wrapper (label + errors +
1335
+ // control), not the <input> itself — grab the actual control to type
1336
+ // into it, same DOM-level approach as other RenderEdit field tests.
1337
+ const noteInput = screen.getByTestId("field-note").querySelector("input");
1338
+ if (noteInput === null) throw new Error("expected an <input> inside field-note");
1339
+ fireEvent.change(noteInput, { target: { value: "hello" } });
1340
+ fireEvent.click(screen.getByTestId("render-edit-submit"));
1341
+
1342
+ await waitFor(() => expect(writeCalls.length).toBe(1));
1343
+ expect(writeCalls[0]?.type).toBe("tasks:write:task:note");
1344
+ await waitFor(() => expect(screen.queryByTestId("render-edit-form")).toBeNull());
1345
+ await waitFor(() => expect(queryCallCount).toBeGreaterThan(queryCallsBeforeSubmit));
1346
+ });
1347
+
1348
+ // Mirrors kind:"navigate": access is enforced when the target renders,
1349
+ // not by hiding the toolbar button — same as a role-gated navigate
1350
+ // target still shows its button but denies the destination.
1351
+ test("User without access to the target screen: button still triggers, but sees Access denied instead of the form", async () => {
1352
+ const restrictedNoteForm: ActionFormScreenDefinition = {
1353
+ ...noteForm,
1354
+ access: { roles: ["Admin"] },
1355
+ };
1356
+ const dispatcher = makeDispatcher({
1357
+ query: (async () => ({
1358
+ isSuccess: true,
1359
+ data: { rows: [{ id: "r1", title: "x", count: 0, done: false }], nextCursor: null },
1360
+ })) as unknown as Dispatcher["query"],
1361
+ });
1362
+ const user = userEvent.setup();
1363
+ render(
1364
+ <DispatcherProvider dispatcher={dispatcher}>
1365
+ <UserRolesProvider roles={["Viewer"]}>
1366
+ <KumikoScreen
1367
+ schema={{ ...schema, screens: [screenWithDrawer, restrictedNoteForm] }}
1368
+ qn="tasks:screen:task-list"
1369
+ />
1370
+ </UserRolesProvider>
1371
+ </DispatcherProvider>,
1372
+ );
1373
+ await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
1374
+
1375
+ const button = screen.getByTestId("render-list-toolbar-action-add-note");
1376
+ await user.click(button);
1377
+
1378
+ expect(screen.getByTestId("kumiko-toolbar-drawer-access-denied")).toBeTruthy();
1379
+ expect(screen.queryByTestId("field-note")).toBeNull();
1380
+ });
1381
+ });
1382
+
1261
1383
  // Tier 2.7c: Screen-Level filter wird vom Schema in den Query-
1262
1384
  // Payload propagiert. Drei Buckets ("scheduled" / "active" / "done")
1263
1385
  // teilen sich denselben Query-Handler — der Filter unterscheidet
@@ -50,15 +50,62 @@ describe("KumikoScreen / projectionDetail", () => {
50
50
  );
51
51
 
52
52
  await waitFor(() => screen.getByTestId("render-edit-form"));
53
- const userIdInput = screen.getByTestId("field-userId").querySelector("input");
54
- expect(userIdInput?.value).toBe("user-42");
55
- expect(userIdInput?.disabled).toBe(true);
53
+ // fw#2245: projectionDetail defaults to text display — a readOnly field
54
+ // renders its value as plain text, not a disabled Input.
55
+ expect(screen.getByTestId("field-userId").textContent).toContain("user-42");
56
+ expect(screen.getByTestId("field-userId").querySelector("input")).toBeNull();
56
57
 
57
58
  // hasEditableSection() reads readOnly on every field — projectionDetail
58
59
  // forces it hard in the shim, so RenderEdit must never draw a Save button.
59
60
  expect(screen.queryByTestId("render-edit-submit")).toBeNull();
60
61
  });
61
62
 
63
+ // fw#2245 Teil 4: synthesizeProjectionDetailEntity (projection-detail-shim.ts)
64
+ // stamps every field as type:"text" — the shim has no access to the query's
65
+ // real field types. field.renderer (Teil 1) is the only way this screen
66
+ // type reaches real per-type formatting; without it a timestamp field would
67
+ // render its raw ISO string. Mirrors the sessions bundled feature's actual
68
+ // session-detail screen (feature.ts).
69
+ test("field.renderer formats a value past the shim's synthesized type:'text' field", async () => {
70
+ const timestampScreen: ProjectionDetailScreenDefinition = {
71
+ ...detailScreen,
72
+ layout: {
73
+ sections: [
74
+ {
75
+ title: "Session",
76
+ fields: ["userId", { field: "createdAt", renderer: { format: "timestamp" } }],
77
+ },
78
+ ],
79
+ },
80
+ };
81
+ const timestampSchema: FeatureSchema = {
82
+ featureName: "sessions",
83
+ entities: {},
84
+ screens: [timestampScreen],
85
+ };
86
+ const dispatcher: Dispatcher = createMockDispatcher({
87
+ query: (async () => ({
88
+ isSuccess: true,
89
+ data: { userId: "user-42", createdAt: "2026-07-01T00:00:00Z" },
90
+ })) as unknown as Dispatcher["query"],
91
+ });
92
+
93
+ render(
94
+ <DispatcherProvider dispatcher={dispatcher}>
95
+ <KumikoScreen
96
+ schema={timestampSchema}
97
+ qn="sessions:screen:session-detail"
98
+ entityId="sess-1"
99
+ />
100
+ </DispatcherProvider>,
101
+ );
102
+
103
+ await waitFor(() => screen.getByTestId("render-edit-form"));
104
+ const rendered = screen.getByTestId("field-value-createdAt").textContent;
105
+ expect(rendered).not.toBe("2026-07-01T00:00:00Z");
106
+ expect(rendered).not.toBe("");
107
+ });
108
+
62
109
  // synthesizeProjectionDetailScreen rebuilds `layout` from `sections` alone
63
110
  // (structural readOnly:true proof) — a naive rebuild would drop sibling
64
111
  // layout fields like `width` (#1676).
@@ -128,6 +175,106 @@ describe("KumikoScreen / projectionDetail", () => {
128
175
 
129
176
  await waitFor(() => screen.getByTestId("kumiko-screen-record-missing"));
130
177
  });
178
+
179
+ // fw#2245: a projectionDetail has no write path — its footer is Cancel-only
180
+ // and defaults to shown (pre-fw#2245 behavior) so existing screens without
181
+ // an explicit opt-in keep working; `hideActions: true` turns it off without
182
+ // losing back-navigation (shell-breadcrumb.ts also resolves listScreenId
183
+ // for this screen type, independent of the footer — see shell-breadcrumb.test.ts).
184
+ test("shows Cancel by default when listScreenId is set", async () => {
185
+ const withListScreen: ProjectionDetailScreenDefinition = {
186
+ ...detailScreen,
187
+ listScreenId: "session-list",
188
+ };
189
+ const withListSchema: FeatureSchema = {
190
+ featureName: "sessions",
191
+ entities: {},
192
+ screens: [withListScreen],
193
+ };
194
+ const dispatcher: Dispatcher = createMockDispatcher({
195
+ query: (async () => ({
196
+ isSuccess: true,
197
+ data: { userId: "user-42", createdAt: "2026-07-01T00:00:00Z" },
198
+ })) as unknown as Dispatcher["query"],
199
+ });
200
+
201
+ render(
202
+ <DispatcherProvider dispatcher={dispatcher}>
203
+ <KumikoScreen
204
+ schema={withListSchema}
205
+ qn="sessions:screen:session-detail"
206
+ entityId="sess-1"
207
+ />
208
+ </DispatcherProvider>,
209
+ );
210
+
211
+ await waitFor(() => screen.getByTestId("render-edit-cancel"));
212
+ });
213
+
214
+ test("hideActions:true hides the Cancel button", async () => {
215
+ const hiddenActionsScreen: ProjectionDetailScreenDefinition = {
216
+ ...detailScreen,
217
+ listScreenId: "session-list",
218
+ hideActions: true,
219
+ };
220
+ const hiddenActionsSchema: FeatureSchema = {
221
+ featureName: "sessions",
222
+ entities: {},
223
+ screens: [hiddenActionsScreen],
224
+ };
225
+ const dispatcher: Dispatcher = createMockDispatcher({
226
+ query: (async () => ({
227
+ isSuccess: true,
228
+ data: { userId: "user-42", createdAt: "2026-07-01T00:00:00Z" },
229
+ })) as unknown as Dispatcher["query"],
230
+ });
231
+
232
+ render(
233
+ <DispatcherProvider dispatcher={dispatcher}>
234
+ <KumikoScreen
235
+ schema={hiddenActionsSchema}
236
+ qn="sessions:screen:session-detail"
237
+ entityId="sess-1"
238
+ />
239
+ </DispatcherProvider>,
240
+ );
241
+
242
+ await waitFor(() => screen.getByTestId("render-edit-form"));
243
+ expect(screen.queryByTestId("render-edit-cancel")).toBeNull();
244
+ });
245
+
246
+ test("valueDisplay:'form' opts back into the disabled-Input look", async () => {
247
+ const formDisplayScreen: ProjectionDetailScreenDefinition = {
248
+ ...detailScreen,
249
+ valueDisplay: "form",
250
+ };
251
+ const formDisplaySchema: FeatureSchema = {
252
+ featureName: "sessions",
253
+ entities: {},
254
+ screens: [formDisplayScreen],
255
+ };
256
+ const dispatcher: Dispatcher = createMockDispatcher({
257
+ query: (async () => ({
258
+ isSuccess: true,
259
+ data: { userId: "user-42", createdAt: "2026-07-01T00:00:00Z" },
260
+ })) as unknown as Dispatcher["query"],
261
+ });
262
+
263
+ render(
264
+ <DispatcherProvider dispatcher={dispatcher}>
265
+ <KumikoScreen
266
+ schema={formDisplaySchema}
267
+ qn="sessions:screen:session-detail"
268
+ entityId="sess-1"
269
+ />
270
+ </DispatcherProvider>,
271
+ );
272
+
273
+ await waitFor(() => screen.getByTestId("render-edit-form"));
274
+ const userIdInput = screen.getByTestId("field-userId").querySelector("input");
275
+ expect(userIdInput?.value).toBe("user-42");
276
+ expect(userIdInput?.disabled).toBe(true);
277
+ });
131
278
  });
132
279
 
133
280
  // fw#2166: `relatedList` sections run their own query against the shown
@@ -187,8 +334,7 @@ describe("KumikoScreen / projectionDetail relatedList section (fw#2166)", () =>
187
334
 
188
335
  // The shim's isFieldsEditSection flip must not regress the structural
189
336
  // readOnly proof from the first test above.
190
- const userIdInput = screen.getByTestId("field-userId").querySelector("input");
191
- expect(userIdInput?.disabled).toBe(true);
337
+ expect(screen.getByTestId("field-userId").querySelector("input")).toBeNull();
192
338
  expect(screen.queryByTestId("render-edit-submit")).toBeNull();
193
339
 
194
340
  const relatedCall = calls.find((c) => c.type === "sessions:query:user-session:payments");
@@ -1,5 +1,8 @@
1
1
  import { describe, expect, mock, test } from "bun:test";
2
- import type { ProjectionListScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import type {
3
+ ActionFormScreenDefinition,
4
+ ProjectionListScreenDefinition,
5
+ } from "@cosmicdrift/kumiko-framework/ui-types";
3
6
  import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
4
7
  import type { FeatureSchema } from "@cosmicdrift/kumiko-renderer";
5
8
  import { DispatcherProvider, KumikoScreen } from "@cosmicdrift/kumiko-renderer";
@@ -129,3 +132,72 @@ describe("projectionList writeHandler-Actions", () => {
129
132
  expect(await screen.findByText("maintenance sync failed")).toBeTruthy();
130
133
  });
131
134
  });
135
+
136
+ // toolbarActions kind:"drawer" (fw#2225) — same shared ToolbarDrawerHost as
137
+ // entityList, wired into ProjectionListBody's toolbarActions builder.
138
+ describe("projectionList toolbarActions drawer-kind (fw#2225)", () => {
139
+ const noteForm: ActionFormScreenDefinition = {
140
+ id: "maintenance-note",
141
+ type: "actionForm",
142
+ handler: "status:write:maintenance:note",
143
+ fields: { note: { type: "text", required: true } },
144
+ layout: { sections: [{ fields: ["note"] }] },
145
+ };
146
+ const screenWithDrawer: ProjectionListScreenDefinition = {
147
+ ...projectionScreen,
148
+ toolbarActions: [
149
+ {
150
+ kind: "drawer",
151
+ id: "add-note",
152
+ label: "status:action:add-note",
153
+ screen: "maintenance-note",
154
+ },
155
+ ],
156
+ };
157
+ const drawerSchema: FeatureSchema = {
158
+ featureName: "status",
159
+ entities: {},
160
+ screens: [screenWithDrawer, noteForm],
161
+ };
162
+
163
+ test("Click opens the Drawer; submit dispatches the handler, closes the Drawer, and reloads the list", async () => {
164
+ let queryCallCount = 0;
165
+ const write = mock(async (_type: string, _payload: unknown) => ({
166
+ isSuccess: true,
167
+ data: {},
168
+ }));
169
+ const dispatcher: Dispatcher = {
170
+ ...createMockDispatcher({
171
+ query: (async () => {
172
+ queryCallCount += 1;
173
+ return {
174
+ isSuccess: true,
175
+ data: { rows: [{ id: "m1", name: "DB-Upgrade" }], nextCursor: null },
176
+ };
177
+ }) as unknown as Dispatcher["query"],
178
+ }),
179
+ write: write as unknown as Dispatcher["write"],
180
+ };
181
+ render(
182
+ <DispatcherProvider dispatcher={dispatcher}>
183
+ <KumikoScreen schema={drawerSchema} qn="status:screen:maintenance-list" />
184
+ </DispatcherProvider>,
185
+ );
186
+ await waitFor(() => expect(screen.getByText("DB-Upgrade")).toBeTruthy());
187
+ expect(screen.queryByTestId("field-note")).toBeNull();
188
+
189
+ fireEvent.click(screen.getByTestId("render-list-toolbar-action-add-note"));
190
+ expect(screen.getByTestId("render-edit-form")).toBeTruthy();
191
+ const queryCallsBeforeSubmit = queryCallCount;
192
+
193
+ const noteInput = screen.getByTestId("field-note").querySelector("input");
194
+ if (noteInput === null) throw new Error("expected an <input> inside field-note");
195
+ fireEvent.change(noteInput, { target: { value: "hello" } });
196
+ fireEvent.click(screen.getByTestId("render-edit-submit"));
197
+
198
+ await waitFor(() => expect(write).toHaveBeenCalledTimes(1));
199
+ expect(write.mock.calls[0]?.[0]).toBe("status:write:maintenance:note");
200
+ await waitFor(() => expect(screen.queryByTestId("render-edit-form")).toBeNull());
201
+ await waitFor(() => expect(queryCallCount).toBeGreaterThan(queryCallsBeforeSubmit));
202
+ });
203
+ });
@@ -0,0 +1,101 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type {
3
+ EntityDefinition,
4
+ EntityEditScreenDefinition,
5
+ } from "@cosmicdrift/kumiko-framework/ui-types";
6
+ import { RenderEdit } from "@cosmicdrift/kumiko-renderer";
7
+ import { render, screen } from "./test-utils";
8
+
9
+ // fw#2245 Teil 2: `RenderEditProps.valueDisplay` — "text" renders a
10
+ // field.readOnly field as formatted plain text instead of a disabled Input.
11
+ // Confined by construction: the prop defaults to "form" (old behavior)
12
+ // at this generic component, ProjectionDetailBody is the only caller that
13
+ // passes "text" by default (kumiko-screen.tsx) — every other RenderEdit
14
+ // caller in the ecosystem keeps the disabled-Input look unless it opts in.
15
+
16
+ const accountEntity = {
17
+ fields: {
18
+ name: { type: "text" },
19
+ active: { type: "boolean" },
20
+ balance: { type: "money" },
21
+ },
22
+ } as unknown as EntityDefinition;
23
+
24
+ function makeScreen(): EntityEditScreenDefinition {
25
+ return {
26
+ id: "accounts:screen:account-detail",
27
+ type: "entityEdit",
28
+ entity: "account",
29
+ layout: {
30
+ sections: [
31
+ {
32
+ fields: [
33
+ "name",
34
+ { field: "active", readOnly: true },
35
+ { field: "balance", readOnly: true },
36
+ ],
37
+ },
38
+ ],
39
+ },
40
+ };
41
+ }
42
+
43
+ const noopSubmit = async (): Promise<{
44
+ readonly isSuccess: true;
45
+ readonly validationBlocked: false;
46
+ readonly data: undefined;
47
+ }> => ({ isSuccess: true, validationBlocked: false, data: undefined });
48
+
49
+ describe("RenderEdit valueDisplay (fw#2245)", () => {
50
+ test('default ("form") keeps readOnly fields as disabled Inputs — unaffected callers stay unchanged', () => {
51
+ render(
52
+ <RenderEdit
53
+ screen={makeScreen()}
54
+ entity={accountEntity}
55
+ featureName="accounts"
56
+ initial={{ name: "Ada", active: true, balance: { amount: 12.5 } }}
57
+ customSubmit={noopSubmit}
58
+ />,
59
+ );
60
+
61
+ const activeInput = screen.getByTestId("field-active").querySelector("input");
62
+ expect(activeInput).not.toBeNull();
63
+ expect(activeInput?.disabled).toBe(true);
64
+ });
65
+
66
+ test('"text" renders readOnly fields as formatted plain text, no Input in the DOM', () => {
67
+ render(
68
+ <RenderEdit
69
+ screen={makeScreen()}
70
+ entity={accountEntity}
71
+ featureName="accounts"
72
+ initial={{ name: "Ada", active: true, balance: { amount: 12.5 } }}
73
+ customSubmit={noopSubmit}
74
+ valueDisplay="text"
75
+ />,
76
+ );
77
+
78
+ expect(screen.getByTestId("field-active").querySelector("input")).toBeNull();
79
+ expect(screen.getByTestId("field-value-active").textContent).toBe("✓");
80
+
81
+ expect(screen.getByTestId("field-balance").querySelector("input")).toBeNull();
82
+ expect(screen.getByTestId("field-value-balance").textContent).toContain("12.50");
83
+ });
84
+
85
+ test('"text" still leaves editable (non-readOnly) fields as live Inputs', () => {
86
+ render(
87
+ <RenderEdit
88
+ screen={makeScreen()}
89
+ entity={accountEntity}
90
+ featureName="accounts"
91
+ initial={{ name: "Ada", active: true, balance: { amount: 12.5 } }}
92
+ customSubmit={noopSubmit}
93
+ valueDisplay="text"
94
+ />,
95
+ );
96
+
97
+ const nameInput = screen.getByTestId("field-name").querySelector("input");
98
+ expect(nameInput).not.toBeNull();
99
+ expect(nameInput?.value).toBe("Ada");
100
+ });
101
+ });
@@ -0,0 +1,121 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type {
3
+ EditFieldSpec,
4
+ EntityDefinition,
5
+ EntityEditScreenDefinition,
6
+ } from "@cosmicdrift/kumiko-framework/ui-types";
7
+ import {
8
+ type ColumnRendererProps,
9
+ ColumnRenderersProvider,
10
+ RenderEdit,
11
+ } from "@cosmicdrift/kumiko-renderer";
12
+ import type { ReactElement, ReactNode } from "react";
13
+ import { render, screen } from "./test-utils";
14
+
15
+ // fw#2245 Teil 1: `EditFieldSpec.renderer` (kumiko-types/src/screen.ts) is
16
+ // validated and survives into the ViewModel (headless/view-model/edit.ts)
17
+ // but render-field.tsx never read it — the same FieldRenderer mechanism
18
+ // that already works for relatedList/entityList columns (render-list.tsx,
19
+ // see render-list-column-renderer.test.tsx) was a no-op for header fields.
20
+
21
+ const invoiceEntity = {
22
+ fields: {
23
+ title: { type: "text" },
24
+ status: { type: "text" },
25
+ },
26
+ } as unknown as EntityDefinition;
27
+
28
+ function makeScreen(statusField: EditFieldSpec): EntityEditScreenDefinition {
29
+ return {
30
+ id: "invoices:screen:invoice-detail",
31
+ type: "entityEdit",
32
+ entity: "invoice",
33
+ layout: {
34
+ sections: [{ fields: ["title", statusField] }],
35
+ },
36
+ };
37
+ }
38
+
39
+ const noopSubmit = async (): Promise<{
40
+ readonly isSuccess: true;
41
+ readonly validationBlocked: false;
42
+ readonly data: undefined;
43
+ }> => ({ isSuccess: true, validationBlocked: false, data: undefined });
44
+
45
+ function StatusBadge({ value, row, column }: ColumnRendererProps): ReactNode {
46
+ return (
47
+ <span data-testid="status-badge">
48
+ <span data-testid="status-badge-value">{String(value)}</span>
49
+ <span data-testid="status-badge-field">{column.field}</span>
50
+ <span data-testid="status-badge-row-title">{String(row["title"] ?? "")}</span>
51
+ </span>
52
+ );
53
+ }
54
+
55
+ function withRenderers(ui: ReactNode, map: Record<string, typeof StatusBadge>): ReactElement {
56
+ return <ColumnRenderersProvider value={map}>{ui}</ColumnRenderersProvider>;
57
+ }
58
+
59
+ describe("RenderField — field.renderer on a readOnly head field (fw#2245)", () => {
60
+ test("FormatSpec renderer formats the value instead of a disabled Input", () => {
61
+ render(
62
+ <RenderEdit
63
+ screen={makeScreen({
64
+ field: "status",
65
+ readOnly: true,
66
+ renderer: { format: "currency", symbol: "€" },
67
+ })}
68
+ entity={invoiceEntity}
69
+ featureName="invoices"
70
+ initial={{ title: "Alpha", status: "42" }}
71
+ customSubmit={noopSubmit}
72
+ />,
73
+ );
74
+
75
+ expect(screen.getByTestId("field-value-status").textContent).toBe("42 €");
76
+ expect(screen.getByTestId("field-status").querySelector("input")).toBeNull();
77
+ });
78
+
79
+ test("__component renderer mounts the registry component with value+row+column", () => {
80
+ render(
81
+ withRenderers(
82
+ <RenderEdit
83
+ screen={makeScreen({
84
+ field: "status",
85
+ readOnly: true,
86
+ renderer: { react: { __component: "StatusBadge" } },
87
+ })}
88
+ entity={invoiceEntity}
89
+ featureName="invoices"
90
+ initial={{ title: "Alpha", status: "shipped" }}
91
+ customSubmit={noopSubmit}
92
+ />,
93
+ { StatusBadge },
94
+ ),
95
+ );
96
+
97
+ expect(screen.getByTestId("status-badge")).toBeTruthy();
98
+ expect(screen.getByTestId("status-badge-value").textContent).toBe("shipped");
99
+ expect(screen.getByTestId("status-badge-field").textContent).toBe("status");
100
+ expect(screen.getByTestId("status-badge-row-title").textContent).toBe("Alpha");
101
+ });
102
+
103
+ // A renderer isn't an editable widget — applying it to an editable field
104
+ // would silently make that field un-editable. The gate is `field.readOnly`,
105
+ // not "renderer is set".
106
+ test("renderer on an editable field is ignored — the field keeps its editable Input", () => {
107
+ render(
108
+ <RenderEdit
109
+ screen={makeScreen({ field: "status", renderer: { format: "currency", symbol: "€" } })}
110
+ entity={invoiceEntity}
111
+ featureName="invoices"
112
+ initial={{ title: "Alpha", status: "42" }}
113
+ customSubmit={noopSubmit}
114
+ />,
115
+ );
116
+
117
+ const input = screen.getByTestId("field-status").querySelector("input");
118
+ expect(input).not.toBeNull();
119
+ expect(input?.value).toBe("42");
120
+ });
121
+ });
@@ -76,6 +76,32 @@ describe("resolveDetailBreadcrumb", () => {
76
76
  ]);
77
77
  });
78
78
 
79
+ // fw#2245: projectionDetail has no entity for `listFromEntity` to pair
80
+ // against — listScreenId is its only back-navigation source, same role it
81
+ // plays for `custom` above. Matters once a screen hides its RenderEdit
82
+ // action-bar footer (hideActions) and has no entityList rowAction either.
83
+ test("projectionDetail uses listScreenId", () => {
84
+ const screens: ScreenDefinition[] = [
85
+ {
86
+ id: "session-list",
87
+ type: "projectionList",
88
+ query: "sessions:query:user-session:list",
89
+ columns: ["userId"],
90
+ },
91
+ {
92
+ id: "session-detail",
93
+ type: "projectionDetail",
94
+ query: "sessions:query:user-session:detail",
95
+ listScreenId: "session-list",
96
+ layout: { sections: [{ fields: ["userId"] }] },
97
+ },
98
+ ];
99
+ expect(resolveDetailBreadcrumb(screens, "session-detail", t)).toEqual([
100
+ { label: "screen:session-list.title", screenId: "session-list" },
101
+ { label: "screen:session-detail.title" },
102
+ ]);
103
+ });
104
+
79
105
  test("unknown screen returns undefined", () => {
80
106
  expect(resolveDetailBreadcrumb([], "missing", t)).toBeUndefined();
81
107
  });
@@ -33,7 +33,18 @@ export function resolveDetailBreadcrumb(
33
33
  ? screens.find((s) => lastSegment(s.id) === detail.listScreenId)
34
34
  : undefined;
35
35
 
36
- const list = listFromRowAction ?? listFromEntity ?? listFromCustomParent;
36
+ // projectionDetail has no entity to pair with an entityList (unlike
37
+ // entityEdit above) — `listScreenId` is its only back-navigation source.
38
+ // Needed so the breadcrumb still offers "back" when a screen hides its
39
+ // RenderEdit action-bar footer (fw#2245's `hideActions`) and isn't
40
+ // reachable via a listFromRowAction either.
41
+ const listFromProjectionDetailParent =
42
+ detail.type === "projectionDetail" && detail.listScreenId !== undefined
43
+ ? screens.find((s) => lastSegment(s.id) === detail.listScreenId)
44
+ : undefined;
45
+
46
+ const list =
47
+ listFromRowAction ?? listFromEntity ?? listFromCustomParent ?? listFromProjectionDetailParent;
37
48
  if (list === undefined) {
38
49
  return [{ label: t(screenTitleKey(detailScreenId)) }];
39
50
  }
@@ -0,0 +1,28 @@
1
+ // Bare content shell for hosting self-contained widgets (own submit/cancel
2
+ // buttons) in a slide-in side panel — same "no footer buttons of its own"
3
+ // contract as DefaultModal, delegating to the richer widgets/drawer.tsx
4
+ // Drawer (resize/backdrop/side are widget-only concerns, not exposed
5
+ // through the platform-neutral CorePrimitives.Drawer contract).
6
+
7
+ import type { DrawerProps } from "@cosmicdrift/kumiko-renderer";
8
+ import type { ReactNode } from "react";
9
+ import { Drawer } from "../widgets/drawer";
10
+
11
+ export function DefaultDrawer({
12
+ open,
13
+ onOpenChange,
14
+ title,
15
+ children,
16
+ testId,
17
+ }: DrawerProps): ReactNode {
18
+ return (
19
+ <Drawer open={open} onOpenChange={onOpenChange} title={title} testId={testId}>
20
+ {/* React re-parents portal content into the enclosing React tree for
21
+ event bubbling (it only escapes the DOM tree, not the fiber tree) —
22
+ without stopping it here, submitting the hosted actionForm would
23
+ also bubble into an ancestor <form>'s onSubmit if the drawer was
24
+ opened from inside one (same fix as DefaultModal, fw#1681). */}
25
+ <div onSubmit={(e) => e.stopPropagation()}>{children}</div>
26
+ </Drawer>
27
+ );
28
+ }
@@ -89,6 +89,7 @@ import { StepBar } from "../widgets/step-bar";
89
89
  import { ComboboxInput } from "./combobox";
90
90
  import { DateInput } from "./date-input";
91
91
  import { DefaultDialog } from "./dialog";
92
+ import { DefaultDrawer } from "./drawer";
92
93
  import {
93
94
  DropdownMenu,
94
95
  DropdownMenuCheckboxItem,
@@ -2043,6 +2044,7 @@ export const defaultPrimitives: CorePrimitives = {
2043
2044
  Heading: DefaultHeading,
2044
2045
  Dialog: DefaultDialog,
2045
2046
  Modal: DefaultModal,
2047
+ Drawer: DefaultDrawer,
2046
2048
  Lightbox: DefaultLightbox,
2047
2049
  ConfigSourceBadge: DefaultConfigSourceBadge,
2048
2050
  ConfigCascadeView: DefaultConfigCascadeView,