@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 +5 -5
- package/src/__tests__/kumiko-screen.test.tsx +122 -0
- package/src/__tests__/projection-detail.test.tsx +151 -5
- package/src/__tests__/projection-list-actions.test.tsx +73 -1
- package/src/__tests__/render-edit-value-display.test.tsx +101 -0
- package/src/__tests__/render-field-renderer.test.tsx +121 -0
- package/src/layout/__tests__/shell-breadcrumb.test.ts +26 -0
- package/src/layout/shell-breadcrumb.ts +12 -1
- package/src/primitives/drawer.tsx +28 -0
- package/src/primitives/index.tsx +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
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.
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
expect(
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
+
}
|
package/src/primitives/index.tsx
CHANGED
|
@@ -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,
|