@cosmicdrift/kumiko-renderer-web 0.269.2 → 0.273.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__/create-app.test.tsx +7 -2
- package/src/__tests__/dashboard-screen.test.tsx +3 -3
- package/src/__tests__/kumiko-screen.test.tsx +8 -4
- package/src/__tests__/projection-detail-record-layout.test.tsx +95 -0
- package/src/__tests__/projection-detail.test.tsx +4 -5
- package/src/__tests__/render-edit.test.tsx +89 -6
- package/src/__tests__/return-to-singleton.test.tsx +144 -0
- package/src/__tests__/return-to.test.tsx +551 -0
- package/src/__tests__/workspace-shell.test.tsx +33 -9
- package/src/app/__tests__/first-open-screen-qn.test.ts +12 -2
- package/src/app/create-app.tsx +15 -3
- package/src/layout/shell-breadcrumb.ts +4 -41
- package/src/primitives/__tests__/textarea-submit-shortcut.test.tsx +1 -1
- package/src/primitives/index.tsx +3 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.273.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.273.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.273.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.273.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",
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
"@types/react-dom": "^19.2.3",
|
|
67
67
|
"jsdom": "^29.1.1",
|
|
68
68
|
"tailwindcss": "^4.3.0",
|
|
69
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
69
|
+
"@cosmicdrift/kumiko-locale-de": "0.273.0"
|
|
70
70
|
},
|
|
71
71
|
"repository": {
|
|
72
72
|
"type": "git",
|
|
@@ -183,7 +183,12 @@ describe("createKumikoApp", () => {
|
|
|
183
183
|
const openFeature: FeatureSchema = {
|
|
184
184
|
featureName: "tasks",
|
|
185
185
|
entities: { task: taskEntity },
|
|
186
|
-
screens: [
|
|
186
|
+
screens: [
|
|
187
|
+
{
|
|
188
|
+
...listScreen,
|
|
189
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
190
|
+
},
|
|
191
|
+
],
|
|
187
192
|
navs: [{ id: "task-list", label: "tasks:nav.task-list", screen: "tasks:screen:task-list" }],
|
|
188
193
|
};
|
|
189
194
|
const schema: AppSchema = { features: [restrictedFeature, openFeature] };
|
|
@@ -282,7 +287,7 @@ describe("createKumikoApp", () => {
|
|
|
282
287
|
id: "privacy-center",
|
|
283
288
|
type: "custom",
|
|
284
289
|
renderer: { react: { __component: "PrivacyCenterScreen" } },
|
|
285
|
-
access: { openToAll:
|
|
290
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
286
291
|
dormant: true,
|
|
287
292
|
},
|
|
288
293
|
],
|
|
@@ -539,7 +539,7 @@ const sessionsSchema: FeatureSchema = {
|
|
|
539
539
|
type: "projectionList",
|
|
540
540
|
query: "sessions:query:user-session:mine",
|
|
541
541
|
columns: [{ field: "userAgent", label: "sessions.mine.col.userAgent" }],
|
|
542
|
-
access: { openToAll:
|
|
542
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
543
543
|
},
|
|
544
544
|
{
|
|
545
545
|
id: "session-list",
|
|
@@ -563,7 +563,7 @@ const mfaSchema: FeatureSchema = {
|
|
|
563
563
|
layout: { sections: [] },
|
|
564
564
|
submitLabel: "mfa.enable.start",
|
|
565
565
|
reveal: { fields: [{ field: "recoveryCodes", label: "codes", display: "list" }] },
|
|
566
|
-
access: { openToAll:
|
|
566
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
567
567
|
},
|
|
568
568
|
{
|
|
569
569
|
id: "auth-mfa-disable",
|
|
@@ -572,7 +572,7 @@ const mfaSchema: FeatureSchema = {
|
|
|
572
572
|
fields: { code: { type: "text", required: true } },
|
|
573
573
|
layout: { sections: [{ fields: ["code"] }] },
|
|
574
574
|
submitLabel: "mfa.disable.submit",
|
|
575
|
-
access: { openToAll:
|
|
575
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
576
576
|
},
|
|
577
577
|
],
|
|
578
578
|
};
|
|
@@ -948,7 +948,8 @@ describe("KumikoScreen", () => {
|
|
|
948
948
|
await waitFor(() => expect(navigateCalls.length).toBe(1));
|
|
949
949
|
expect(navigateCalls[0]).toEqual({ screenId: "task-edit" });
|
|
950
950
|
// params werden zu Strings serialisiert (URL-Layer kennt nur Strings).
|
|
951
|
-
|
|
951
|
+
// returnTo: "task-list" — this KumikoScreen has no outer host, so it becomes its own.
|
|
952
|
+
expect(searchParamUpdates).toEqual([{ taskId: "r1", returnTo: "task-list" }]);
|
|
952
953
|
// Reihenfolge-Pin: erst navigate, dann setSearchParams.
|
|
953
954
|
expect(calls.map((c) => c.kind)).toEqual(["navigate", "setSearchParams"]);
|
|
954
955
|
});
|
|
@@ -1072,7 +1073,9 @@ describe("KumikoScreen", () => {
|
|
|
1072
1073
|
await user.click(screen.getByTestId("row-r1-action-edit"));
|
|
1073
1074
|
await waitFor(() => expect(navigateCalls.length).toBe(1));
|
|
1074
1075
|
expect(navigateCalls[0]).toEqual({ screenId: "task-edit", entityId: "r1" });
|
|
1075
|
-
|
|
1076
|
+
// No declared params, but returnTo still attaches — the id itself never
|
|
1077
|
+
// leaks into search params (that's the point above).
|
|
1078
|
+
expect(searchParamUpdates).toEqual([{ returnTo: "task-list" }]);
|
|
1076
1079
|
});
|
|
1077
1080
|
|
|
1078
1081
|
// JSON-Schema-Fall (window.__KUMIKO_SCHEMA__): Declarative entityId: "id"
|
|
@@ -1195,7 +1198,7 @@ describe("KumikoScreen", () => {
|
|
|
1195
1198
|
expect(navigateCalls[0]).toEqual({ screenId: "task-approve" });
|
|
1196
1199
|
});
|
|
1197
1200
|
|
|
1198
|
-
test("entityList rowActions kind=navigate ohne params: setSearchParams
|
|
1201
|
+
test("entityList rowActions kind=navigate ohne eigene params: setSearchParams trägt nur returnTo", async () => {
|
|
1199
1202
|
const navigateCalls: { screenId: string }[] = [];
|
|
1200
1203
|
const searchParamUpdates: Record<string, string | null>[] = [];
|
|
1201
1204
|
const memoryNav = {
|
|
@@ -1241,7 +1244,8 @@ describe("KumikoScreen", () => {
|
|
|
1241
1244
|
|
|
1242
1245
|
await user.click(screen.getByTestId("row-r1-action-view"));
|
|
1243
1246
|
await waitFor(() => expect(navigateCalls.length).toBe(1));
|
|
1244
|
-
|
|
1247
|
+
// No declared params, but returnTo still attaches.
|
|
1248
|
+
expect(searchParamUpdates).toEqual([{ returnTo: "task-list" }]);
|
|
1245
1249
|
});
|
|
1246
1250
|
|
|
1247
1251
|
// toolbarActions: Schema-Form (kind: navigate | writeHandler) →
|
|
@@ -401,6 +401,101 @@ describe("KumikoScreen / projectionDetail — record header + metrics band", ()
|
|
|
401
401
|
});
|
|
402
402
|
});
|
|
403
403
|
|
|
404
|
+
describe("KumikoScreen / projectionDetail — header actions sit in the title row", () => {
|
|
405
|
+
const headerActionsScreen: ProjectionDetailScreenDefinition = {
|
|
406
|
+
...baseScreen,
|
|
407
|
+
header: { title: "tenantName", subtitle: "address", status: "state" },
|
|
408
|
+
metrics: ["balance"],
|
|
409
|
+
actions: [
|
|
410
|
+
{
|
|
411
|
+
kind: "navigate",
|
|
412
|
+
id: "open-tenant",
|
|
413
|
+
label: "actions.openTenant",
|
|
414
|
+
entity: "tenant",
|
|
415
|
+
entityId: "tenantId",
|
|
416
|
+
},
|
|
417
|
+
],
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
function nearestCommonAncestor(a: Element, b: Element): Element {
|
|
421
|
+
const ancestorsOfA = new Set<Element>();
|
|
422
|
+
for (let el: Element | null = a; el !== null; el = el.parentElement) ancestorsOfA.add(el);
|
|
423
|
+
for (let el: Element | null = b; el !== null; el = el.parentElement) {
|
|
424
|
+
if (ancestorsOfA.has(el)) return el;
|
|
425
|
+
}
|
|
426
|
+
throw new Error("title and action share no common ancestor");
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
test("title and action buttons share a header container that excludes the metrics band", async () => {
|
|
430
|
+
const dispatcher = dispatcherReturning({ ...rowData, tenantId: "tenant-9" });
|
|
431
|
+
|
|
432
|
+
render(
|
|
433
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
434
|
+
<KumikoScreen
|
|
435
|
+
schema={schemaFor(headerActionsScreen)}
|
|
436
|
+
qn="rentals:screen:rent-detail"
|
|
437
|
+
entityId="rent-1"
|
|
438
|
+
/>
|
|
439
|
+
</DispatcherProvider>,
|
|
440
|
+
);
|
|
441
|
+
|
|
442
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
443
|
+
const title = screen.getByTestId("kumiko-screen-projection-detail-title");
|
|
444
|
+
const actionButton = screen.getByTestId("render-edit-action-open-tenant");
|
|
445
|
+
const metricsBand = screen.getByTestId("kumiko-screen-projection-detail-metrics");
|
|
446
|
+
|
|
447
|
+
const headerContainer = nearestCommonAncestor(title, actionButton);
|
|
448
|
+
expect(headerContainer.contains(metricsBand)).toBe(false);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test("the metrics band follows the subtitle and the header row in DOM order", async () => {
|
|
452
|
+
const dispatcher = dispatcherReturning({ ...rowData, tenantId: "tenant-9" });
|
|
453
|
+
|
|
454
|
+
render(
|
|
455
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
456
|
+
<KumikoScreen
|
|
457
|
+
schema={schemaFor(headerActionsScreen)}
|
|
458
|
+
qn="rentals:screen:rent-detail"
|
|
459
|
+
entityId="rent-1"
|
|
460
|
+
/>
|
|
461
|
+
</DispatcherProvider>,
|
|
462
|
+
);
|
|
463
|
+
|
|
464
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
465
|
+
const title = screen.getByTestId("kumiko-screen-projection-detail-title");
|
|
466
|
+
const actionButton = screen.getByTestId("render-edit-action-open-tenant");
|
|
467
|
+
const subtitle = screen.getByTestId("kumiko-screen-projection-detail-subtitle");
|
|
468
|
+
const metricsBand = screen.getByTestId("kumiko-screen-projection-detail-metrics");
|
|
469
|
+
const headerContainer = nearestCommonAncestor(title, actionButton);
|
|
470
|
+
|
|
471
|
+
expect(
|
|
472
|
+
(subtitle.compareDocumentPosition(metricsBand) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0,
|
|
473
|
+
).toBe(true);
|
|
474
|
+
expect(
|
|
475
|
+
(headerContainer.compareDocumentPosition(metricsBand) & Node.DOCUMENT_POSITION_FOLLOWING) !==
|
|
476
|
+
0,
|
|
477
|
+
).toBe(true);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test("the title + status-badge container is vertically centered", async () => {
|
|
481
|
+
const dispatcher = dispatcherReturning({ ...rowData, tenantId: "tenant-9" });
|
|
482
|
+
|
|
483
|
+
render(
|
|
484
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
485
|
+
<KumikoScreen
|
|
486
|
+
schema={schemaFor(headerActionsScreen)}
|
|
487
|
+
qn="rentals:screen:rent-detail"
|
|
488
|
+
entityId="rent-1"
|
|
489
|
+
/>
|
|
490
|
+
</DispatcherProvider>,
|
|
491
|
+
);
|
|
492
|
+
|
|
493
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
494
|
+
const title = screen.getByTestId("kumiko-screen-projection-detail-title");
|
|
495
|
+
expect(title.parentElement?.className).toContain("items-center");
|
|
496
|
+
});
|
|
497
|
+
});
|
|
498
|
+
|
|
404
499
|
describe("KumikoScreen / projectionDetail — metric tiles render through the Metric primitive", () => {
|
|
405
500
|
const metricsScreen: ProjectionDetailScreenDefinition = {
|
|
406
501
|
...baseScreen,
|
|
@@ -633,7 +633,7 @@ describe("KumikoScreen / projectionDetail extension section (solon#264)", () =>
|
|
|
633
633
|
expect(screen.queryByTestId("field-userId")).toBeNull();
|
|
634
634
|
});
|
|
635
635
|
|
|
636
|
-
test("layout.mode: 'tabs' with a header —
|
|
636
|
+
test("layout.mode: 'tabs' with a header — the head card plus one card for the active tab's fields", async () => {
|
|
637
637
|
const tabsHeaderScreen: ProjectionDetailScreenDefinition = {
|
|
638
638
|
...detailScreen,
|
|
639
639
|
header: { title: "userId" },
|
|
@@ -679,10 +679,9 @@ describe("KumikoScreen / projectionDetail extension section (solon#264)", () =>
|
|
|
679
679
|
);
|
|
680
680
|
|
|
681
681
|
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
682
|
-
//
|
|
683
|
-
//
|
|
684
|
-
|
|
685
|
-
expect(container.querySelectorAll('[data-slot="card"]')).toHaveLength(1);
|
|
682
|
+
// Head card plus one titled card for the fields tab — checked via the
|
|
683
|
+
// card's structural marker, not a Tailwind class string.
|
|
684
|
+
expect(container.querySelectorAll('[data-slot="card"]')).toHaveLength(2);
|
|
686
685
|
});
|
|
687
686
|
|
|
688
687
|
test("layout.mode: 'tabs' with countField — tab label carries the record's count, sections without it stay unchanged", async () => {
|
|
@@ -204,10 +204,9 @@ describe("RenderEdit", () => {
|
|
|
204
204
|
|
|
205
205
|
// Fall 2: tabs layout sets hideSectionTitles (kumiko-screen.tsx's
|
|
206
206
|
// ProjectionDetailBody, the only real caller) to blank out each section's
|
|
207
|
-
// OWN title — the active tab already names it.
|
|
208
|
-
//
|
|
209
|
-
|
|
210
|
-
test("tabs layout (hideSectionTitles) keeps the section title hidden but still shows the screen's own subtitle", () => {
|
|
207
|
+
// OWN title — the active tab already names it. The head card also carries
|
|
208
|
+
// screen.description as its subtitle, so it must not render again here.
|
|
209
|
+
test("tabs layout (hideSectionTitles) keeps the section title hidden and drops the screen's own subtitle", () => {
|
|
211
210
|
const entity = {
|
|
212
211
|
fields: { email: { type: "text", required: true } },
|
|
213
212
|
} as unknown as EntityDefinition;
|
|
@@ -236,9 +235,93 @@ describe("RenderEdit", () => {
|
|
|
236
235
|
|
|
237
236
|
expect(screen.queryByTestId("render-edit-form-title")).toBeNull();
|
|
238
237
|
expect(screen.queryByTestId("section-Contact-title")).toBeNull();
|
|
239
|
-
expect(screen.
|
|
240
|
-
|
|
238
|
+
expect(screen.queryByTestId("render-edit-form-subtitle")).toBeNull();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// S2: a fields-kind section's `groups` splits it into its own titled cards
|
|
242
|
+
// (tabs mode only — the branch that renders `groups` at all lives behind
|
|
243
|
+
// `hideSectionTitles`) instead of one flat grid.
|
|
244
|
+
test("a section with two groups renders two titled cards in tabs mode, each with its own fields", () => {
|
|
245
|
+
const entity = {
|
|
246
|
+
fields: {
|
|
247
|
+
email: { type: "text", required: true },
|
|
248
|
+
plan: { type: "text" },
|
|
249
|
+
},
|
|
250
|
+
} as unknown as EntityDefinition;
|
|
251
|
+
const screenDef: EntityEditScreenDefinition = {
|
|
252
|
+
id: "orders:screen:order-detail",
|
|
253
|
+
type: "entityEdit",
|
|
254
|
+
entity: "order",
|
|
255
|
+
layout: {
|
|
256
|
+
mode: "tabs",
|
|
257
|
+
sections: [
|
|
258
|
+
{
|
|
259
|
+
id: "overview",
|
|
260
|
+
title: "Overview",
|
|
261
|
+
fields: [],
|
|
262
|
+
groups: [
|
|
263
|
+
{ title: "Contact", fields: [{ field: "email" }] },
|
|
264
|
+
{ title: "Billing", fields: [{ field: "plan" }] },
|
|
265
|
+
],
|
|
266
|
+
},
|
|
267
|
+
],
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
render(
|
|
271
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
272
|
+
<RenderEdit
|
|
273
|
+
screen={screenDef}
|
|
274
|
+
entity={entity}
|
|
275
|
+
featureName="orders"
|
|
276
|
+
initial={{ email: "", plan: "" } as never}
|
|
277
|
+
writeCommand="order:create"
|
|
278
|
+
hideSectionTitles
|
|
279
|
+
/>
|
|
280
|
+
</DispatcherProvider>,
|
|
241
281
|
);
|
|
282
|
+
|
|
283
|
+
const contactCard = screen.getByTestId("section-Overview-group-0");
|
|
284
|
+
const billingCard = screen.getByTestId("section-Overview-group-1");
|
|
285
|
+
expect(contactCard.querySelector("h3")?.textContent).toBe("Contact");
|
|
286
|
+
expect(billingCard.querySelector("h3")?.textContent).toBe("Billing");
|
|
287
|
+
expect(within(contactCard).getByTestId("field-email")).toBeTruthy();
|
|
288
|
+
expect(within(billingCard).getByTestId("field-plan")).toBeTruthy();
|
|
289
|
+
expect(within(contactCard).queryByTestId("field-plan")).toBeNull();
|
|
290
|
+
|
|
291
|
+
// Default columns per group is 2 (--grid-cols CSS var), unless overridden.
|
|
292
|
+
const contactGrid = contactCard.querySelector(".grid") as HTMLElement | null;
|
|
293
|
+
expect(contactGrid?.style.getPropertyValue("--grid-cols")).toBe("repeat(2, minmax(0, 1fr))");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("a tabs-mode section without groups renders a single card", () => {
|
|
297
|
+
const entity = {
|
|
298
|
+
fields: { email: { type: "text", required: true } },
|
|
299
|
+
} as unknown as EntityDefinition;
|
|
300
|
+
const screenDef: EntityEditScreenDefinition = {
|
|
301
|
+
id: "orders:screen:order-detail",
|
|
302
|
+
type: "entityEdit",
|
|
303
|
+
entity: "order",
|
|
304
|
+
layout: {
|
|
305
|
+
mode: "tabs",
|
|
306
|
+
sections: [{ id: "overview", title: "Overview", fields: [{ field: "email" }] }],
|
|
307
|
+
},
|
|
308
|
+
};
|
|
309
|
+
render(
|
|
310
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
311
|
+
<RenderEdit
|
|
312
|
+
screen={screenDef}
|
|
313
|
+
entity={entity}
|
|
314
|
+
featureName="orders"
|
|
315
|
+
initial={{ email: "" } as never}
|
|
316
|
+
writeCommand="order:create"
|
|
317
|
+
hideSectionTitles
|
|
318
|
+
/>
|
|
319
|
+
</DispatcherProvider>,
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
expect(screen.getByTestId("section-Overview")).toBeTruthy();
|
|
323
|
+
expect(screen.queryByTestId("section-Overview-group-0")).toBeNull();
|
|
324
|
+
expect(within(screen.getByTestId("section-Overview")).getByTestId("field-email")).toBeTruthy();
|
|
242
325
|
});
|
|
243
326
|
|
|
244
327
|
// End-to-end-Routing: ein `type:"locatedTimestamp"`-Entity-Feld muss durch
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Split out of return-to.test.tsx: the singleton create->update path fires
|
|
2
|
+
// fireEvent.change immediately followed by a click right after an async load
|
|
3
|
+
// settles — the #457 shared single-process happy-dom pattern. Isolated the
|
|
4
|
+
// same way as kumiko-screen-singleton-entity-edit.test.tsx (bunfig.ci-dom.toml,
|
|
5
|
+
// pathIgnorePatterns in bunfig.dom.toml).
|
|
6
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
7
|
+
import type {
|
|
8
|
+
DashboardScreenDefinition,
|
|
9
|
+
EntityDefinition,
|
|
10
|
+
EntityEditScreenDefinition,
|
|
11
|
+
FeatureSchema,
|
|
12
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
13
|
+
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
14
|
+
import {
|
|
15
|
+
AppFeaturesProvider,
|
|
16
|
+
DashboardBodyProvider,
|
|
17
|
+
DispatcherProvider,
|
|
18
|
+
KumikoScreen,
|
|
19
|
+
NavProvider,
|
|
20
|
+
qualifyScreenId,
|
|
21
|
+
useNav,
|
|
22
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
23
|
+
import type { ReactNode } from "react";
|
|
24
|
+
import { WebDashboardBody } from "../app/dashboard-body";
|
|
25
|
+
import { useBrowserNavApi } from "../app/nav";
|
|
26
|
+
import { createMockDispatcher, fireEvent, render, screen, waitFor } from "./test-utils";
|
|
27
|
+
|
|
28
|
+
const settingEntity: EntityDefinition = {
|
|
29
|
+
fields: { title: { type: "text", required: false, searchable: false, sortable: false } },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const singletonScreen: EntityEditScreenDefinition = {
|
|
33
|
+
id: "settings-edit",
|
|
34
|
+
type: "entityEdit",
|
|
35
|
+
entity: "setting",
|
|
36
|
+
singleton: true,
|
|
37
|
+
layout: { sections: [{ fields: ["title"] }] },
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const dashboardScreen: DashboardScreenDefinition = {
|
|
41
|
+
id: "home",
|
|
42
|
+
type: "dashboard",
|
|
43
|
+
panels: [{ kind: "screen", id: "singleton", screen: "settings-edit" }],
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const schema: FeatureSchema = {
|
|
47
|
+
featureName: "settingsfeat",
|
|
48
|
+
entities: { setting: settingEntity },
|
|
49
|
+
screens: [dashboardScreen, singletonScreen],
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function RouterScreen({ appSchema }: { readonly appSchema: FeatureSchema }): ReactNode {
|
|
53
|
+
const nav = useNav();
|
|
54
|
+
if (nav.route === undefined) return null;
|
|
55
|
+
const qn = qualifyScreenId(appSchema.featureName, nav.route.screenId);
|
|
56
|
+
return (
|
|
57
|
+
<KumikoScreen
|
|
58
|
+
schema={appSchema}
|
|
59
|
+
qn={qn}
|
|
60
|
+
{...(nav.route.entityId !== undefined && { entityId: nav.route.entityId })}
|
|
61
|
+
/>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function BrowserNavRoot({ appSchema }: { readonly appSchema: FeatureSchema }): ReactNode {
|
|
66
|
+
const nav = useBrowserNavApi({ hasWorkspaces: false });
|
|
67
|
+
return (
|
|
68
|
+
<NavProvider value={nav}>
|
|
69
|
+
<RouterScreen appSchema={appSchema} />
|
|
70
|
+
</NavProvider>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function App({
|
|
75
|
+
appSchema,
|
|
76
|
+
dispatcher,
|
|
77
|
+
}: {
|
|
78
|
+
readonly appSchema: FeatureSchema;
|
|
79
|
+
readonly dispatcher: Dispatcher;
|
|
80
|
+
}): ReactNode {
|
|
81
|
+
return (
|
|
82
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
83
|
+
<AppFeaturesProvider features={[appSchema]}>
|
|
84
|
+
<DashboardBodyProvider value={WebDashboardBody}>
|
|
85
|
+
<BrowserNavRoot appSchema={appSchema} />
|
|
86
|
+
</DashboardBodyProvider>
|
|
87
|
+
</AppFeaturesProvider>
|
|
88
|
+
</DispatcherProvider>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
describe("returnTo — singleton entityEdit ignores it", () => {
|
|
93
|
+
test("reached with ?returnTo=home, saving stays on the singleton screen", async () => {
|
|
94
|
+
let created = false;
|
|
95
|
+
const write = mock(async (type: string) => {
|
|
96
|
+
if (type === "settingsfeat:write:setting:create") {
|
|
97
|
+
created = true;
|
|
98
|
+
return { isSuccess: true, data: { id: "s1" } };
|
|
99
|
+
}
|
|
100
|
+
return { isSuccess: true, data: {} };
|
|
101
|
+
});
|
|
102
|
+
const query = mock(async (type: string) => {
|
|
103
|
+
if (type === "settingsfeat:query:setting:list") {
|
|
104
|
+
return created
|
|
105
|
+
? { isSuccess: true, data: { rows: [{ id: "s1", title: "Saved title" }] } }
|
|
106
|
+
: { isSuccess: true, data: { rows: [], nextCursor: null } };
|
|
107
|
+
}
|
|
108
|
+
if (type === "settingsfeat:query:setting:detail") {
|
|
109
|
+
return {
|
|
110
|
+
isSuccess: true,
|
|
111
|
+
data: { id: "s1", version: 1, title: "Saved title" },
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return { isSuccess: true, data: { rows: [], nextCursor: null } };
|
|
115
|
+
});
|
|
116
|
+
const dispatcher = createMockDispatcher({
|
|
117
|
+
write: write as unknown as Dispatcher["write"],
|
|
118
|
+
query: query as unknown as Dispatcher["query"],
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
window.history.replaceState(null, "", "/settings-edit?returnTo=home");
|
|
122
|
+
render(<App appSchema={schema} dispatcher={dispatcher} />);
|
|
123
|
+
|
|
124
|
+
await waitFor(() => expect(screen.getByTestId("field-title")).toBeTruthy());
|
|
125
|
+
const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
|
|
126
|
+
fireEvent.change(titleInput, { target: { value: "Saved title" } });
|
|
127
|
+
await waitFor(() => {
|
|
128
|
+
const button = screen.getByTestId("render-edit-submit") as HTMLButtonElement;
|
|
129
|
+
expect(button.disabled).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
fireEvent.click(screen.getByTestId("render-edit-submit"));
|
|
132
|
+
|
|
133
|
+
await waitFor(() => expect(write).toHaveBeenCalledTimes(1));
|
|
134
|
+
// The singleton wrapper flips to the update form of the newly created
|
|
135
|
+
// record instead of navigating anywhere — returnTo must not interfere.
|
|
136
|
+
await waitFor(() => {
|
|
137
|
+
const input = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
|
|
138
|
+
expect(input.value).toBe("Saved title");
|
|
139
|
+
});
|
|
140
|
+
expect(window.location.pathname).toBe("/settings-edit");
|
|
141
|
+
|
|
142
|
+
window.history.replaceState(null, "", "/");
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
// Exercises the real navigation stack end to end (real useBrowserNavApi),
|
|
2
|
+
// asserting on window.location instead of a captured NavTarget.
|
|
3
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
4
|
+
import type {
|
|
5
|
+
DashboardScreenDefinition,
|
|
6
|
+
EntityDefinition,
|
|
7
|
+
FeatureSchema,
|
|
8
|
+
ScreenDefinition,
|
|
9
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
10
|
+
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
11
|
+
import {
|
|
12
|
+
AppFeaturesProvider,
|
|
13
|
+
DashboardBodyProvider,
|
|
14
|
+
DispatcherProvider,
|
|
15
|
+
KumikoScreen,
|
|
16
|
+
NavProvider,
|
|
17
|
+
qualifyScreenId,
|
|
18
|
+
UserRolesProvider,
|
|
19
|
+
useNav,
|
|
20
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
21
|
+
import userEvent from "@testing-library/user-event";
|
|
22
|
+
import type { ReactNode } from "react";
|
|
23
|
+
import { WebDashboardBody } from "../app/dashboard-body";
|
|
24
|
+
import { useBrowserNavApi } from "../app/nav";
|
|
25
|
+
import { createMockDispatcher, fireEvent, render, screen, waitFor } from "./test-utils";
|
|
26
|
+
|
|
27
|
+
const tokenEntity: EntityDefinition = {
|
|
28
|
+
fields: { name: { type: "text", required: false, searchable: false, sortable: false } },
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const dashboardScreen: DashboardScreenDefinition = {
|
|
32
|
+
id: "settings",
|
|
33
|
+
type: "dashboard",
|
|
34
|
+
panels: [
|
|
35
|
+
{ kind: "screen", id: "list", screen: "token-list" },
|
|
36
|
+
{ kind: "screen", id: "stats", screen: "token-stats" },
|
|
37
|
+
],
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const listScreen: ScreenDefinition = {
|
|
41
|
+
id: "token-list",
|
|
42
|
+
type: "entityList",
|
|
43
|
+
entity: "token",
|
|
44
|
+
columns: ["name"],
|
|
45
|
+
toolbarActions: [
|
|
46
|
+
{ kind: "navigate", id: "create", label: "create", screen: "token-create" },
|
|
47
|
+
{ kind: "navigate", id: "action", label: "action", screen: "token-action" },
|
|
48
|
+
],
|
|
49
|
+
rowActions: [{ kind: "navigate", id: "open", label: "open", screen: "token-open" }],
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const secretMintScreen: ScreenDefinition = {
|
|
53
|
+
id: "token-create",
|
|
54
|
+
type: "secretMint",
|
|
55
|
+
handler: "tokens:write:token-secret:mint",
|
|
56
|
+
fields: { label: { type: "text", required: false, searchable: false, sortable: false } },
|
|
57
|
+
layout: { sections: [{ fields: ["label"] }] },
|
|
58
|
+
reveal: { fields: [{ field: "secret", label: "secret" }] },
|
|
59
|
+
redirect: "token-list",
|
|
60
|
+
cancelTarget: "token-list",
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const tokenActionScreen: ScreenDefinition = {
|
|
64
|
+
id: "token-action",
|
|
65
|
+
type: "actionForm",
|
|
66
|
+
handler: "tokens:write:token:action",
|
|
67
|
+
fields: {},
|
|
68
|
+
layout: { sections: [] },
|
|
69
|
+
redirect: "token-list",
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const tokenOpenScreen: ScreenDefinition = {
|
|
73
|
+
id: "token-open",
|
|
74
|
+
type: "actionForm",
|
|
75
|
+
handler: "tokens:write:token:open",
|
|
76
|
+
fields: {},
|
|
77
|
+
layout: { sections: [] },
|
|
78
|
+
redirect: { screen: "token-edit", idFrom: "id" },
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const tokenEditScreen: ScreenDefinition = {
|
|
82
|
+
id: "token-edit",
|
|
83
|
+
type: "entityEdit",
|
|
84
|
+
entity: "token",
|
|
85
|
+
layout: { sections: [{ fields: ["name"] }] },
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// A second, distinct record screen for the same entity/id — lets returnTo
|
|
89
|
+
// name a record screen that is NOT the one being deleted (delete-guard test).
|
|
90
|
+
// Also carries a relatedList section for the host-threading test (18/19
|
|
91
|
+
// cover entityList/projectionList; this one covers relatedList).
|
|
92
|
+
const tokenDetailScreen: ScreenDefinition = {
|
|
93
|
+
id: "token-detail",
|
|
94
|
+
type: "projectionDetail",
|
|
95
|
+
query: "tokens:query:token:detail-view",
|
|
96
|
+
layout: {
|
|
97
|
+
sections: [
|
|
98
|
+
{ fields: ["name"] },
|
|
99
|
+
{
|
|
100
|
+
kind: "relatedList",
|
|
101
|
+
title: "Related",
|
|
102
|
+
query: "tokens:query:token:related",
|
|
103
|
+
columns: ["name"],
|
|
104
|
+
toolbarActions: [
|
|
105
|
+
{
|
|
106
|
+
kind: "navigate",
|
|
107
|
+
id: "related-action",
|
|
108
|
+
label: "related-action",
|
|
109
|
+
screen: "token-action",
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const tokenStatsScreen: ScreenDefinition = {
|
|
118
|
+
id: "token-stats",
|
|
119
|
+
type: "projectionList",
|
|
120
|
+
query: "tokens:query:token:stats",
|
|
121
|
+
columns: [{ field: "name", label: "name" }],
|
|
122
|
+
toolbarActions: [
|
|
123
|
+
{ kind: "navigate", id: "stats-action", label: "stats-action", screen: "token-action" },
|
|
124
|
+
],
|
|
125
|
+
rowActions: [
|
|
126
|
+
{ kind: "navigate", id: "stats-row-action", label: "stats-row-action", screen: "token-action" },
|
|
127
|
+
],
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const adminOnlyScreen: ScreenDefinition = {
|
|
131
|
+
id: "admin-only",
|
|
132
|
+
type: "custom",
|
|
133
|
+
renderer: { react: "stub" },
|
|
134
|
+
access: { roles: ["Admin"] },
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const schema: FeatureSchema = {
|
|
138
|
+
featureName: "tokens",
|
|
139
|
+
entities: { token: tokenEntity },
|
|
140
|
+
screens: [
|
|
141
|
+
dashboardScreen,
|
|
142
|
+
listScreen,
|
|
143
|
+
secretMintScreen,
|
|
144
|
+
tokenActionScreen,
|
|
145
|
+
tokenOpenScreen,
|
|
146
|
+
tokenEditScreen,
|
|
147
|
+
tokenDetailScreen,
|
|
148
|
+
tokenStatsScreen,
|
|
149
|
+
adminOnlyScreen,
|
|
150
|
+
],
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
function makeDispatcher(): Dispatcher {
|
|
154
|
+
return createMockDispatcher({
|
|
155
|
+
query: (async (type: string) => {
|
|
156
|
+
if (type === "tokens:query:token:list") {
|
|
157
|
+
return {
|
|
158
|
+
isSuccess: true,
|
|
159
|
+
data: { rows: [{ id: "tok-1", name: "Token 1" }], nextCursor: null, total: 1 },
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
// Distinct row data from the entityList above — both panels render on
|
|
163
|
+
// the same dashboard, and duplicate text/ids would break `getByText`/
|
|
164
|
+
// `getByTestId` uniqueness in the other tests.
|
|
165
|
+
if (type === "tokens:query:token:stats") {
|
|
166
|
+
return {
|
|
167
|
+
isSuccess: true,
|
|
168
|
+
data: { rows: [{ id: "stat-1", name: "Stat A" }], nextCursor: null, total: 1 },
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
if (type === "tokens:query:token:detail") {
|
|
172
|
+
return { isSuccess: true, data: { id: "tok-1", version: 1, name: "Token 1" } };
|
|
173
|
+
}
|
|
174
|
+
if (type === "tokens:query:token:related") {
|
|
175
|
+
return {
|
|
176
|
+
isSuccess: true,
|
|
177
|
+
data: { rows: [{ id: "rel-1", name: "Related A" }], nextCursor: null },
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
return { isSuccess: true, data: {} };
|
|
181
|
+
}) as unknown as Dispatcher["query"],
|
|
182
|
+
write: (async (command: string) => {
|
|
183
|
+
if (command === "tokens:write:token-secret:mint") {
|
|
184
|
+
return { isSuccess: true, data: { secret: "shh-secret" } };
|
|
185
|
+
}
|
|
186
|
+
if (command === "tokens:write:token:open") {
|
|
187
|
+
return { isSuccess: true, data: { id: "tok-1" } };
|
|
188
|
+
}
|
|
189
|
+
if (command === "tokens:write:token:create") {
|
|
190
|
+
return { isSuccess: true, data: { id: "new-tok" } };
|
|
191
|
+
}
|
|
192
|
+
return { isSuccess: true, data: {} };
|
|
193
|
+
}) as unknown as Dispatcher["write"],
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function RouterScreen({ appSchema }: { readonly appSchema: FeatureSchema }): ReactNode {
|
|
198
|
+
const nav = useNav();
|
|
199
|
+
if (nav.route === undefined) return null;
|
|
200
|
+
const qn = qualifyScreenId(appSchema.featureName, nav.route.screenId);
|
|
201
|
+
return (
|
|
202
|
+
<KumikoScreen
|
|
203
|
+
schema={appSchema}
|
|
204
|
+
qn={qn}
|
|
205
|
+
{...(nav.route.entityId !== undefined && { entityId: nav.route.entityId })}
|
|
206
|
+
/>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function BrowserNavRoot({ appSchema }: { readonly appSchema: FeatureSchema }): ReactNode {
|
|
211
|
+
const nav = useBrowserNavApi({ hasWorkspaces: false });
|
|
212
|
+
return (
|
|
213
|
+
<NavProvider value={nav}>
|
|
214
|
+
<RouterScreen appSchema={appSchema} />
|
|
215
|
+
</NavProvider>
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function App({
|
|
220
|
+
appSchema,
|
|
221
|
+
dispatcher,
|
|
222
|
+
}: {
|
|
223
|
+
readonly appSchema: FeatureSchema;
|
|
224
|
+
readonly dispatcher: Dispatcher;
|
|
225
|
+
}): ReactNode {
|
|
226
|
+
return (
|
|
227
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
228
|
+
<AppFeaturesProvider features={[appSchema]}>
|
|
229
|
+
<UserRolesProvider roles={["User"]}>
|
|
230
|
+
<DashboardBodyProvider value={WebDashboardBody}>
|
|
231
|
+
<BrowserNavRoot appSchema={appSchema} />
|
|
232
|
+
</DashboardBodyProvider>
|
|
233
|
+
</UserRolesProvider>
|
|
234
|
+
</AppFeaturesProvider>
|
|
235
|
+
</DispatcherProvider>
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function renderApp(): void {
|
|
240
|
+
render(<App appSchema={schema} dispatcher={makeDispatcher()} />);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function waitForEnabled(testId: string): Promise<HTMLButtonElement> {
|
|
244
|
+
const btn = screen.getByTestId(testId) as HTMLButtonElement;
|
|
245
|
+
await waitFor(() => expect(btn.disabled).toBe(false));
|
|
246
|
+
return btn;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
describe("returnTo", () => {
|
|
250
|
+
afterEach(() => {
|
|
251
|
+
window.history.replaceState(null, "", "/");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("1. embedded list toolbar create sets returnTo=settings; cancel returns to settings", async () => {
|
|
255
|
+
const user = userEvent.setup();
|
|
256
|
+
window.history.replaceState(null, "", "/settings");
|
|
257
|
+
renderApp();
|
|
258
|
+
|
|
259
|
+
await waitFor(() => expect(screen.getByText("Token 1")).toBeTruthy());
|
|
260
|
+
await user.click(screen.getByTestId("render-list-toolbar-action-create"));
|
|
261
|
+
|
|
262
|
+
expect(window.location.pathname).toBe("/token-create");
|
|
263
|
+
expect(window.location.search).toBe("?returnTo=settings");
|
|
264
|
+
|
|
265
|
+
await user.click(screen.getByTestId("render-edit-cancel"));
|
|
266
|
+
expect(window.location.pathname).toBe("/settings");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("2. embedded list toolbar create → fill + submit reveals the secret → confirm returns to settings", async () => {
|
|
270
|
+
const user = userEvent.setup();
|
|
271
|
+
window.history.replaceState(null, "", "/settings");
|
|
272
|
+
renderApp();
|
|
273
|
+
|
|
274
|
+
await waitFor(() => expect(screen.getByText("Token 1")).toBeTruthy());
|
|
275
|
+
await user.click(screen.getByTestId("render-list-toolbar-action-create"));
|
|
276
|
+
expect(window.location.pathname).toBe("/token-create");
|
|
277
|
+
|
|
278
|
+
const input = screen.getByTestId("field-label").querySelector("input") as HTMLInputElement;
|
|
279
|
+
fireEvent.change(input, { target: { value: "My token" } });
|
|
280
|
+
const submitBtn = await waitForEnabled("render-edit-submit");
|
|
281
|
+
await user.click(submitBtn);
|
|
282
|
+
|
|
283
|
+
await waitFor(() =>
|
|
284
|
+
expect(screen.getByTestId("kumiko-screen-secret-mint-reveal")).toBeTruthy(),
|
|
285
|
+
);
|
|
286
|
+
await user.click(screen.getByTestId("kumiko-screen-secret-mint-confirm"));
|
|
287
|
+
|
|
288
|
+
expect(window.location.pathname).toBe("/settings");
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("3. embedded list toolbar action → submit returns to settings", async () => {
|
|
292
|
+
const user = userEvent.setup();
|
|
293
|
+
window.history.replaceState(null, "", "/settings");
|
|
294
|
+
renderApp();
|
|
295
|
+
|
|
296
|
+
await waitFor(() => expect(screen.getByText("Token 1")).toBeTruthy());
|
|
297
|
+
await user.click(screen.getByTestId("render-list-toolbar-action-action"));
|
|
298
|
+
expect(window.location.pathname).toBe("/token-action");
|
|
299
|
+
expect(window.location.search).toBe("?returnTo=settings");
|
|
300
|
+
|
|
301
|
+
const submitBtn = await waitForEnabled("render-edit-submit");
|
|
302
|
+
await user.click(submitBtn);
|
|
303
|
+
|
|
304
|
+
await waitFor(() => expect(window.location.pathname).toBe("/settings"));
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("3b. embedded list toolbar action → cancel returns to settings", async () => {
|
|
308
|
+
const user = userEvent.setup();
|
|
309
|
+
window.history.replaceState(null, "", "/settings");
|
|
310
|
+
renderApp();
|
|
311
|
+
|
|
312
|
+
await waitFor(() => expect(screen.getByText("Token 1")).toBeTruthy());
|
|
313
|
+
await user.click(screen.getByTestId("render-list-toolbar-action-action"));
|
|
314
|
+
expect(window.location.pathname).toBe("/token-action");
|
|
315
|
+
|
|
316
|
+
await user.click(screen.getByTestId("render-edit-cancel"));
|
|
317
|
+
expect(window.location.pathname).toBe("/settings");
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("4. + New (default create) on embedded list → token-edit create → save returns to settings", async () => {
|
|
321
|
+
const user = userEvent.setup();
|
|
322
|
+
window.history.replaceState(null, "", "/settings");
|
|
323
|
+
renderApp();
|
|
324
|
+
|
|
325
|
+
await waitFor(() => expect(screen.getByText("Token 1")).toBeTruthy());
|
|
326
|
+
await user.click(screen.getByTestId("render-list-create"));
|
|
327
|
+
|
|
328
|
+
expect(window.location.pathname).toBe("/token-edit");
|
|
329
|
+
expect(window.location.search).toBe("?returnTo=settings");
|
|
330
|
+
|
|
331
|
+
const input = screen.getByTestId("field-name").querySelector("input") as HTMLInputElement;
|
|
332
|
+
fireEvent.change(input, { target: { value: "New Token" } });
|
|
333
|
+
const submitBtn = await waitForEnabled("render-edit-submit");
|
|
334
|
+
await user.click(submitBtn);
|
|
335
|
+
|
|
336
|
+
await waitFor(() => expect(window.location.pathname).toBe("/settings"));
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("5. row action with a record-targeting redirect lands on token-edit/<id>, not settings", async () => {
|
|
340
|
+
const user = userEvent.setup();
|
|
341
|
+
window.history.replaceState(null, "", "/settings");
|
|
342
|
+
renderApp();
|
|
343
|
+
|
|
344
|
+
await waitFor(() => expect(screen.getByText("Token 1")).toBeTruthy());
|
|
345
|
+
await user.click(screen.getByTestId("row-tok-1-action-open"));
|
|
346
|
+
|
|
347
|
+
expect(window.location.pathname).toBe("/token-open");
|
|
348
|
+
expect(window.location.search).toBe("?returnTo=settings");
|
|
349
|
+
|
|
350
|
+
const submitBtn = await waitForEnabled("render-edit-submit");
|
|
351
|
+
await user.click(submitBtn);
|
|
352
|
+
|
|
353
|
+
await waitFor(() => expect(window.location.pathname).toBe("/token-edit/tok-1"));
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("6. direct navigation to token-create (no returnTo) → cancel falls back to cancelTarget", async () => {
|
|
357
|
+
const user = userEvent.setup();
|
|
358
|
+
window.history.replaceState(null, "", "/token-create");
|
|
359
|
+
renderApp();
|
|
360
|
+
|
|
361
|
+
const cancelBtn = await screen.findByTestId("render-edit-cancel");
|
|
362
|
+
await user.click(cancelBtn);
|
|
363
|
+
expect(window.location.pathname).toBe("/token-list");
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test.each([
|
|
367
|
+
"unknown-screen",
|
|
368
|
+
"//evil.com",
|
|
369
|
+
"https://evil.com",
|
|
370
|
+
"tokens:screen:settings",
|
|
371
|
+
"admin-only",
|
|
372
|
+
"token-create",
|
|
373
|
+
"settings/x/y",
|
|
374
|
+
])("7. manipulated returnTo=%s falls back to cancelTarget", async (value) => {
|
|
375
|
+
const user = userEvent.setup();
|
|
376
|
+
window.history.replaceState(null, "", `/token-create?returnTo=${encodeURIComponent(value)}`);
|
|
377
|
+
renderApp();
|
|
378
|
+
|
|
379
|
+
const cancelBtn = await screen.findByTestId("render-edit-cancel");
|
|
380
|
+
await user.click(cancelBtn);
|
|
381
|
+
expect(window.location.pathname).toBe("/token-list");
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test("8. non-embedded token-list toolbar create sets returnTo=token-list, cancel returns there", async () => {
|
|
385
|
+
const user = userEvent.setup();
|
|
386
|
+
window.history.replaceState(null, "", "/token-list");
|
|
387
|
+
renderApp();
|
|
388
|
+
|
|
389
|
+
await waitFor(() => expect(screen.getByText("Token 1")).toBeTruthy());
|
|
390
|
+
await user.click(screen.getByTestId("render-list-toolbar-action-create"));
|
|
391
|
+
|
|
392
|
+
expect(window.location.pathname).toBe("/token-create");
|
|
393
|
+
expect(window.location.search).toBe("?returnTo=token-list");
|
|
394
|
+
|
|
395
|
+
await user.click(screen.getByTestId("render-edit-cancel"));
|
|
396
|
+
expect(window.location.pathname).toBe("/token-list");
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test("9. direct navigation to token-action (no returnTo) → submit falls back to its own redirect", async () => {
|
|
400
|
+
const user = userEvent.setup();
|
|
401
|
+
window.history.replaceState(null, "", "/token-action");
|
|
402
|
+
renderApp();
|
|
403
|
+
|
|
404
|
+
const submitBtn = await waitForEnabled("render-edit-submit");
|
|
405
|
+
await user.click(submitBtn);
|
|
406
|
+
|
|
407
|
+
await waitFor(() => expect(window.location.pathname).toBe("/token-list"));
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test("10. direct navigation to token-action (no returnTo) → cancel falls back to its own redirect", async () => {
|
|
411
|
+
const user = userEvent.setup();
|
|
412
|
+
window.history.replaceState(null, "", "/token-action");
|
|
413
|
+
renderApp();
|
|
414
|
+
|
|
415
|
+
const cancelBtn = await screen.findByTestId("render-edit-cancel");
|
|
416
|
+
await user.click(cancelBtn);
|
|
417
|
+
expect(window.location.pathname).toBe("/token-list");
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("11. direct navigation to token-create (no returnTo) → submit → reveal → confirm falls back to redirect", async () => {
|
|
421
|
+
const user = userEvent.setup();
|
|
422
|
+
window.history.replaceState(null, "", "/token-create");
|
|
423
|
+
renderApp();
|
|
424
|
+
|
|
425
|
+
const input = await screen.findByTestId("field-label");
|
|
426
|
+
fireEvent.change(input.querySelector("input") as HTMLInputElement, {
|
|
427
|
+
target: { value: "My token" },
|
|
428
|
+
});
|
|
429
|
+
const submitBtn = await waitForEnabled("render-edit-submit");
|
|
430
|
+
await user.click(submitBtn);
|
|
431
|
+
|
|
432
|
+
await waitFor(() =>
|
|
433
|
+
expect(screen.getByTestId("kumiko-screen-secret-mint-reveal")).toBeTruthy(),
|
|
434
|
+
);
|
|
435
|
+
await user.click(screen.getByTestId("kumiko-screen-secret-mint-confirm"));
|
|
436
|
+
|
|
437
|
+
expect(window.location.pathname).toBe("/token-list");
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test("12. direct navigation to token-edit create (no returnTo) → save falls back to the entity's list", async () => {
|
|
441
|
+
const user = userEvent.setup();
|
|
442
|
+
window.history.replaceState(null, "", "/token-edit");
|
|
443
|
+
renderApp();
|
|
444
|
+
|
|
445
|
+
const input = await screen.findByTestId("field-name");
|
|
446
|
+
fireEvent.change(input.querySelector("input") as HTMLInputElement, {
|
|
447
|
+
target: { value: "New Token" },
|
|
448
|
+
});
|
|
449
|
+
const submitBtn = await waitForEnabled("render-edit-submit");
|
|
450
|
+
await user.click(submitBtn);
|
|
451
|
+
|
|
452
|
+
await waitFor(() => expect(window.location.pathname).toBe("/token-list"));
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
test("13. entityEdit update with returnTo=settings → save returns to settings", async () => {
|
|
456
|
+
const user = userEvent.setup();
|
|
457
|
+
window.history.replaceState(null, "", "/token-edit/tok-1?returnTo=settings");
|
|
458
|
+
renderApp();
|
|
459
|
+
|
|
460
|
+
const input = await screen.findByTestId("field-name");
|
|
461
|
+
await waitFor(() =>
|
|
462
|
+
expect((input.querySelector("input") as HTMLInputElement).value).toBe("Token 1"),
|
|
463
|
+
);
|
|
464
|
+
fireEvent.change(input.querySelector("input") as HTMLInputElement, {
|
|
465
|
+
target: { value: "Renamed" },
|
|
466
|
+
});
|
|
467
|
+
const submitBtn = await waitForEnabled("render-edit-submit");
|
|
468
|
+
await user.click(submitBtn);
|
|
469
|
+
|
|
470
|
+
await waitFor(() => expect(window.location.pathname).toBe("/settings"));
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
test("14. entityEdit update with returnTo=settings → cancel returns to settings", async () => {
|
|
474
|
+
const user = userEvent.setup();
|
|
475
|
+
window.history.replaceState(null, "", "/token-edit/tok-1?returnTo=settings");
|
|
476
|
+
renderApp();
|
|
477
|
+
|
|
478
|
+
const cancelBtn = await screen.findByTestId("render-edit-cancel");
|
|
479
|
+
await user.click(cancelBtn);
|
|
480
|
+
expect(window.location.pathname).toBe("/settings");
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
test("15. entityEdit update with returnTo=settings → delete returns to settings", async () => {
|
|
484
|
+
const user = userEvent.setup();
|
|
485
|
+
window.history.replaceState(null, "", "/token-edit/tok-1?returnTo=settings");
|
|
486
|
+
renderApp();
|
|
487
|
+
|
|
488
|
+
await screen.findByTestId("field-name");
|
|
489
|
+
await user.click(screen.getByTestId("render-edit-delete"));
|
|
490
|
+
await user.click(screen.getByTestId("render-edit-delete-dialog-confirm"));
|
|
491
|
+
|
|
492
|
+
await waitFor(() => expect(window.location.pathname).toBe("/settings"));
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
test("16. delete-guard: returnTo pointing at the just-deleted record falls back to the list", async () => {
|
|
496
|
+
const user = userEvent.setup();
|
|
497
|
+
window.history.replaceState(null, "", "/token-edit/tok-1?returnTo=token-detail/tok-1");
|
|
498
|
+
renderApp();
|
|
499
|
+
|
|
500
|
+
await screen.findByTestId("field-name");
|
|
501
|
+
await user.click(screen.getByTestId("render-edit-delete"));
|
|
502
|
+
await user.click(screen.getByTestId("render-edit-delete-dialog-confirm"));
|
|
503
|
+
|
|
504
|
+
await waitFor(() => expect(window.location.pathname).toBe("/token-list"));
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test("17. manipulated entityId in the real URL (returnTo=token-edit/%2e%2e) → cancel falls back to cancelTarget", async () => {
|
|
508
|
+
const user = userEvent.setup();
|
|
509
|
+
window.history.replaceState(null, "", "/token-create?returnTo=token-edit/%2e%2e");
|
|
510
|
+
renderApp();
|
|
511
|
+
|
|
512
|
+
const cancelBtn = await screen.findByTestId("render-edit-cancel");
|
|
513
|
+
await user.click(cancelBtn);
|
|
514
|
+
expect(window.location.pathname).toBe("/token-list");
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
test("18. projectionList embedded in the dashboard: toolbar navigate carries returnTo=settings", async () => {
|
|
518
|
+
const user = userEvent.setup();
|
|
519
|
+
window.history.replaceState(null, "", "/settings");
|
|
520
|
+
renderApp();
|
|
521
|
+
|
|
522
|
+
await user.click(await screen.findByTestId("render-list-toolbar-action-stats-action"));
|
|
523
|
+
|
|
524
|
+
expect(window.location.pathname).toBe("/token-action");
|
|
525
|
+
expect(window.location.search).toBe("?returnTo=settings");
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
test("19. projectionList embedded in the dashboard: row action navigate carries returnTo=settings", async () => {
|
|
529
|
+
const user = userEvent.setup();
|
|
530
|
+
window.history.replaceState(null, "", "/settings");
|
|
531
|
+
renderApp();
|
|
532
|
+
|
|
533
|
+
await user.click(await screen.findByTestId("row-stat-1-action-stats-row-action"));
|
|
534
|
+
|
|
535
|
+
expect(window.location.pathname).toBe("/token-action");
|
|
536
|
+
expect(window.location.search).toBe("?returnTo=settings");
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
test("20. relatedList toolbar navigate (in a projectionDetail) sets returnTo=<detail>/<id> and its parentParam prefill", async () => {
|
|
540
|
+
const user = userEvent.setup();
|
|
541
|
+
window.history.replaceState(null, "", "/token-detail/tok-1");
|
|
542
|
+
renderApp();
|
|
543
|
+
|
|
544
|
+
await user.click(await screen.findByTestId("render-list-toolbar-action-related-action"));
|
|
545
|
+
|
|
546
|
+
expect(window.location.pathname).toBe("/token-action");
|
|
547
|
+
const params = new URLSearchParams(window.location.search);
|
|
548
|
+
expect(params.get("returnTo")).toBe("token-detail/tok-1");
|
|
549
|
+
expect(params.get("id")).toBe("tok-1");
|
|
550
|
+
});
|
|
551
|
+
});
|
|
@@ -74,7 +74,7 @@ function ws(
|
|
|
74
74
|
} = {},
|
|
75
75
|
): WorkspaceSchema {
|
|
76
76
|
const access = options.openToAll
|
|
77
|
-
? ({ openToAll:
|
|
77
|
+
? ({ openToAll: { reason: "test handler callable by any signed-in test user" } } as const)
|
|
78
78
|
: options.roles !== undefined
|
|
79
79
|
? ({ roles: options.roles } as const)
|
|
80
80
|
: undefined;
|
|
@@ -121,9 +121,18 @@ describe("filterByAccess", () => {
|
|
|
121
121
|
|
|
122
122
|
test("sorts by order then insertion order", () => {
|
|
123
123
|
const list = [
|
|
124
|
-
ws("c", {
|
|
125
|
-
|
|
126
|
-
|
|
124
|
+
ws("c", {
|
|
125
|
+
openToAll: true,
|
|
126
|
+
order: 3,
|
|
127
|
+
}),
|
|
128
|
+
ws("a", {
|
|
129
|
+
openToAll: true,
|
|
130
|
+
order: 1,
|
|
131
|
+
}),
|
|
132
|
+
ws("b", {
|
|
133
|
+
openToAll: true,
|
|
134
|
+
order: 2,
|
|
135
|
+
}),
|
|
127
136
|
ws("d", { openToAll: true }), // no order — sorts last
|
|
128
137
|
];
|
|
129
138
|
expect(filterByAccess(list, []).map((w) => w.definition.id)).toEqual(["a", "b", "c", "d"]);
|
|
@@ -133,7 +142,10 @@ describe("filterByAccess", () => {
|
|
|
133
142
|
describe("resolveDefaultId", () => {
|
|
134
143
|
const visible = [
|
|
135
144
|
ws("admin", { openToAll: true }),
|
|
136
|
-
ws("dispatch", {
|
|
145
|
+
ws("dispatch", {
|
|
146
|
+
openToAll: true,
|
|
147
|
+
isDefault: true,
|
|
148
|
+
}),
|
|
137
149
|
ws("driver", { openToAll: true }),
|
|
138
150
|
];
|
|
139
151
|
|
|
@@ -234,8 +246,14 @@ describe("WorkspaceSwitcher", () => {
|
|
|
234
246
|
renderWithSidebar(
|
|
235
247
|
<WorkspaceSwitcher
|
|
236
248
|
workspaces={[
|
|
237
|
-
ws("admin", {
|
|
238
|
-
|
|
249
|
+
ws("admin", {
|
|
250
|
+
label: "Admin",
|
|
251
|
+
openToAll: true,
|
|
252
|
+
}),
|
|
253
|
+
ws("driver", {
|
|
254
|
+
label: "Driver",
|
|
255
|
+
openToAll: true,
|
|
256
|
+
}),
|
|
239
257
|
]}
|
|
240
258
|
activeId="admin"
|
|
241
259
|
onSelect={() => {}}
|
|
@@ -253,8 +271,14 @@ describe("WorkspaceSwitcher", () => {
|
|
|
253
271
|
renderWithSidebar(
|
|
254
272
|
<WorkspaceSwitcher
|
|
255
273
|
workspaces={[
|
|
256
|
-
ws("admin", {
|
|
257
|
-
|
|
274
|
+
ws("admin", {
|
|
275
|
+
label: "Admin",
|
|
276
|
+
openToAll: true,
|
|
277
|
+
}),
|
|
278
|
+
ws("driver", {
|
|
279
|
+
label: "Driver",
|
|
280
|
+
openToAll: true,
|
|
281
|
+
}),
|
|
258
282
|
]}
|
|
259
283
|
activeId="admin"
|
|
260
284
|
onSelect={onSelect}
|
|
@@ -31,7 +31,12 @@ describe("firstOpenScreenQn", () => {
|
|
|
31
31
|
const features: readonly FeatureSchema[] = [
|
|
32
32
|
feature({
|
|
33
33
|
featureName: "auth-mfa",
|
|
34
|
-
screens: [
|
|
34
|
+
screens: [
|
|
35
|
+
customScreen({
|
|
36
|
+
id: "auth-mfa-enable",
|
|
37
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
38
|
+
}),
|
|
39
|
+
],
|
|
35
40
|
// No nav entry — this is auth-mfa's dormant custom-screen convention.
|
|
36
41
|
}),
|
|
37
42
|
feature({
|
|
@@ -58,7 +63,12 @@ describe("firstOpenScreenQn", () => {
|
|
|
58
63
|
const features: readonly FeatureSchema[] = [
|
|
59
64
|
feature({
|
|
60
65
|
featureName: "auth-mfa",
|
|
61
|
-
screens: [
|
|
66
|
+
screens: [
|
|
67
|
+
customScreen({
|
|
68
|
+
id: "auth-mfa-enable",
|
|
69
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
70
|
+
}),
|
|
71
|
+
],
|
|
62
72
|
}),
|
|
63
73
|
];
|
|
64
74
|
expect(firstOpenScreenQn(features)).toBeUndefined();
|
package/src/app/create-app.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createLiveDispatcher } from "@cosmicdrift/kumiko-dispatcher-live";
|
|
2
2
|
import type { TreeChildrenSubscribe } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { isOpenToAllGranted } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
3
4
|
import type {
|
|
4
5
|
Dispatcher,
|
|
5
6
|
ListRowViewModel,
|
|
@@ -28,10 +29,12 @@ import {
|
|
|
28
29
|
mergeTranslations,
|
|
29
30
|
type NavApi,
|
|
30
31
|
NavProvider,
|
|
32
|
+
navigateWithReturnTo,
|
|
31
33
|
PrimitivesProvider,
|
|
32
34
|
type PrimitivesRegistry,
|
|
33
35
|
type QualifiedContentCollection,
|
|
34
36
|
qualifyScreenId,
|
|
37
|
+
type ReturnHost,
|
|
35
38
|
TokensProvider,
|
|
36
39
|
type TranslationsByLocale,
|
|
37
40
|
toAppSchema,
|
|
@@ -236,7 +239,7 @@ export function firstOpenScreenQn(features: readonly FeatureSchema[]): string |
|
|
|
236
239
|
for (const feature of features) {
|
|
237
240
|
const openScreen = feature.screens.find(
|
|
238
241
|
(s) =>
|
|
239
|
-
(s.access === undefined ||
|
|
242
|
+
(s.access === undefined || isOpenToAllGranted(s.access)) &&
|
|
240
243
|
navScreenQns.has(qualifyScreenId(feature.featureName, s.id)),
|
|
241
244
|
);
|
|
242
245
|
if (openScreen !== undefined) return qualifyScreenId(feature.featureName, openScreen.id);
|
|
@@ -637,14 +640,23 @@ function RoutedScreen({
|
|
|
637
640
|
(s) => s.type === "entityEdit" && s.entity === entityName,
|
|
638
641
|
);
|
|
639
642
|
if (editScreen) {
|
|
643
|
+
// No ReturnHostProvider out here — derive the host from this memo's own qn/entityId.
|
|
644
|
+
const host: ReturnHost = {
|
|
645
|
+
screenId: lastSegment(qn),
|
|
646
|
+
...(entityId !== undefined && { entityId }),
|
|
647
|
+
};
|
|
640
648
|
// editScreen.id is already short form; lastSegment is a no-op
|
|
641
649
|
// safety net here, kept for symmetry with the other call sites.
|
|
642
|
-
|
|
650
|
+
navigateWithReturnTo(
|
|
651
|
+
nav,
|
|
652
|
+
{ screenId: lastSegment(editScreen.id), entityId: row.id },
|
|
653
|
+
host,
|
|
654
|
+
);
|
|
643
655
|
return;
|
|
644
656
|
}
|
|
645
657
|
}
|
|
646
658
|
};
|
|
647
|
-
}, [app, activeScreen, onRowClick, nav]);
|
|
659
|
+
}, [app, activeScreen, onRowClick, nav, qn, entityId]);
|
|
648
660
|
|
|
649
661
|
// Copy-link action (Issue #912) for entityEdit update screens. Builds
|
|
650
662
|
// the absolute permalink URL from the current route + copies it —
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
|
+
import { resolveNavParentScreen } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
3
|
import { lastSegment } from "@cosmicdrift/kumiko-renderer";
|
|
3
4
|
|
|
4
5
|
export type BreadcrumbCrumb = {
|
|
@@ -10,51 +11,13 @@ export function screenTitleKey(screenShortId: string): string {
|
|
|
10
11
|
return `screen:${screenShortId}.title`;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// that has to grow when a new screen type adopts the field.
|
|
16
|
-
function explicitListScreenId(screen: ScreenDefinition): string | undefined {
|
|
17
|
-
switch (screen.type) {
|
|
18
|
-
case "custom":
|
|
19
|
-
case "projectionDetail":
|
|
20
|
-
case "entityEdit":
|
|
21
|
-
case "actionForm":
|
|
22
|
-
case "secretMint":
|
|
23
|
-
return screen.listScreenId;
|
|
24
|
-
default:
|
|
25
|
-
return undefined;
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// Shared by the breadcrumb (this file) and NavTree's active-marker fallback
|
|
30
|
-
// (nav-tree.tsx) — both need "which list screen does this detail belong to",
|
|
31
|
-
// so this is the one place that answers it. An explicit `listScreenId` wins
|
|
32
|
-
// over the heuristics below (rowAction target / same-entity entityList): a
|
|
33
|
-
// screen author who declares it is opting out of the guess.
|
|
14
|
+
// Delegates to resolveNavParentScreen, supplying the feature-qualified-id
|
|
15
|
+
// normalization the client-side schema needs.
|
|
34
16
|
function resolveParentScreen(
|
|
35
17
|
screens: readonly ScreenDefinition[],
|
|
36
18
|
detail: ScreenDefinition,
|
|
37
19
|
): ScreenDefinition | undefined {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const listFromExplicit = ((): ScreenDefinition | undefined => {
|
|
41
|
-
const explicitId = explicitListScreenId(detail);
|
|
42
|
-
return explicitId !== undefined
|
|
43
|
-
? screens.find((s) => lastSegment(s.id) === explicitId)
|
|
44
|
-
: undefined;
|
|
45
|
-
})();
|
|
46
|
-
|
|
47
|
-
const listFromRowAction = screens.find((s) => {
|
|
48
|
-
if (s.type !== "entityList") return false;
|
|
49
|
-
return (s.rowActions ?? []).some((a) => a.kind === "navigate" && a.screen === detailScreenId);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
const listFromEntity =
|
|
53
|
-
detail.type === "entityEdit"
|
|
54
|
-
? screens.find((s) => s.type === "entityList" && s.entity === detail.entity)
|
|
55
|
-
: undefined;
|
|
56
|
-
|
|
57
|
-
return listFromExplicit ?? listFromRowAction ?? listFromEntity;
|
|
20
|
+
return resolveNavParentScreen(screens, detail, (s) => lastSegment(s.id));
|
|
58
21
|
}
|
|
59
22
|
|
|
60
23
|
/** The short id of {screenId}'s parent list screen, resolved via the same
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Ctrl/Cmd+Enter submits a textarea
|
|
2
2
|
// (InputProps.onSubmitShortcut) instead of inserting a newline. Plain Enter
|
|
3
3
|
// keeps inserting a newline — asserted here via the keydown event's
|
|
4
4
|
// defaultPrevented state, since fireEvent.keyDown does not itself simulate
|
package/src/primitives/index.tsx
CHANGED
|
@@ -2289,9 +2289,8 @@ function FormSections({
|
|
|
2289
2289
|
}
|
|
2290
2290
|
|
|
2291
2291
|
// Title/subtitle block shared by DefaultForm's card and chromeless layouts —
|
|
2292
|
-
//
|
|
2293
|
-
//
|
|
2294
|
-
// and must keep rendering in tabs/chromeless mode, just without card chrome.
|
|
2292
|
+
// RenderEdit already withholds screen.description from `subtitle` in tabs
|
|
2293
|
+
// mode (chromeless), so whatever arrives here still renders, just without card chrome.
|
|
2295
2294
|
function FormTitleBlock({
|
|
2296
2295
|
title,
|
|
2297
2296
|
subtitle,
|
|
@@ -2694,7 +2693,7 @@ function DefaultGrid({ columns, children, testId, maxRows }: GridProps): ReactNo
|
|
|
2694
2693
|
// maxRows/scrolling don't apply — the row just wraps.
|
|
2695
2694
|
if (columns === "auto") {
|
|
2696
2695
|
return (
|
|
2697
|
-
<div data-testid={testId} className="flex flex-wrap gap-4">
|
|
2696
|
+
<div data-testid={testId} className="flex flex-wrap items-center gap-4">
|
|
2698
2697
|
{children}
|
|
2699
2698
|
</div>
|
|
2700
2699
|
);
|