@cosmicdrift/kumiko-renderer 0.270.0 → 0.274.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 +4 -4
- package/src/app/__tests__/action-form-field-labels.test.tsx +92 -0
- package/src/app/__tests__/action-form-shim.test.ts +21 -0
- package/src/app/__tests__/entity-list-row-action-entity-target.test.tsx +12 -1
- package/src/app/__tests__/list-filter-facets.test.tsx +90 -2
- package/src/app/__tests__/projection-detail-actions.test.tsx +13 -1
- package/src/app/__tests__/return-to.test.ts +255 -0
- package/src/app/__tests__/row-default-edit-action.test.tsx +1 -1
- package/src/app/action-form-shim.ts +4 -2
- package/src/app/kumiko-screen.tsx +402 -276
- package/src/app/list-facets.ts +70 -27
- package/src/app/reference-facet-bridge.tsx +58 -0
- package/src/app/return-to.tsx +128 -0
- package/src/app/row-actions.ts +184 -25
- package/src/app/secret-mint-body.tsx +6 -4
- package/src/components/__tests__/related-list-section.test.tsx +611 -5
- package/src/components/related-list-section.tsx +100 -18
- package/src/components/render-edit.tsx +76 -33
- package/src/index.ts +9 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.274.0",
|
|
4
4
|
"description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
}
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
18
|
+
"@cosmicdrift/kumiko-framework": "0.274.0",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.274.0",
|
|
20
20
|
"react": "^19.2.6",
|
|
21
21
|
"temporal-polyfill": "^0.3.2",
|
|
22
22
|
"zod": "^4.4.3"
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"@types/react-dom": "^19.2.3",
|
|
28
28
|
"jsdom": "^29.1.1",
|
|
29
29
|
"react-dom": "^19.2.6",
|
|
30
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
30
|
+
"@cosmicdrift/kumiko-locale-de": "0.274.0"
|
|
31
31
|
},
|
|
32
32
|
"repository": {
|
|
33
33
|
"type": "git",
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// fieldLabels overrides a field's label per screen, via the same
|
|
2
|
+
// label-resolution pipeline as entityEdit (see action-form-shim.ts).
|
|
3
|
+
|
|
4
|
+
import { describe, expect, test } from "bun:test";
|
|
5
|
+
import type { ActionFormScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
6
|
+
import { render } from "@testing-library/react";
|
|
7
|
+
import type { ReactNode } from "react";
|
|
8
|
+
import { RenderEdit } from "../../components/render-edit";
|
|
9
|
+
import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
|
|
10
|
+
import { kumikoDefaultTranslations } from "../../i18n-defaults";
|
|
11
|
+
import { type CorePrimitives, type FieldProps, PrimitivesProvider } from "../../primitives";
|
|
12
|
+
import { synthesizeActionFormEntity, synthesizeActionFormScreen } from "../action-form-shim";
|
|
13
|
+
|
|
14
|
+
const noop = (): ReactNode => null;
|
|
15
|
+
const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
|
|
16
|
+
|
|
17
|
+
function renderActionFormCapturingFieldLabels(screen: ActionFormScreenDefinition): {
|
|
18
|
+
labels: Record<string, string>;
|
|
19
|
+
} {
|
|
20
|
+
const labels: Record<string, string> = {};
|
|
21
|
+
const capturingField = (props: FieldProps): ReactNode => {
|
|
22
|
+
if (props.testId !== undefined) labels[props.testId] = props.label;
|
|
23
|
+
return props.children;
|
|
24
|
+
};
|
|
25
|
+
const primitives: CorePrimitives = {
|
|
26
|
+
Button: noop,
|
|
27
|
+
Banner: noop,
|
|
28
|
+
Field: capturingField,
|
|
29
|
+
Input: noop,
|
|
30
|
+
DataTable: noop,
|
|
31
|
+
Form: passChildren,
|
|
32
|
+
Section: passChildren,
|
|
33
|
+
Card: passChildren,
|
|
34
|
+
Grid: passChildren,
|
|
35
|
+
GridCell: passChildren,
|
|
36
|
+
Text: passChildren,
|
|
37
|
+
Heading: noop,
|
|
38
|
+
Dialog: noop,
|
|
39
|
+
Modal: noop,
|
|
40
|
+
Lightbox: noop,
|
|
41
|
+
ConfigSourceBadge: noop,
|
|
42
|
+
ConfigCascadeView: noop,
|
|
43
|
+
Link: noop,
|
|
44
|
+
};
|
|
45
|
+
render(
|
|
46
|
+
<LocaleProvider
|
|
47
|
+
resolver={createStaticLocaleResolver({ locale: "en-US" })}
|
|
48
|
+
fallbackBundles={[kumikoDefaultTranslations]}
|
|
49
|
+
>
|
|
50
|
+
<PrimitivesProvider value={primitives}>
|
|
51
|
+
<RenderEdit
|
|
52
|
+
screen={synthesizeActionFormScreen(screen)}
|
|
53
|
+
entity={synthesizeActionFormEntity(screen.fields)}
|
|
54
|
+
featureName="shop"
|
|
55
|
+
initial={{ dueAt: "" }}
|
|
56
|
+
/>
|
|
57
|
+
</PrimitivesProvider>
|
|
58
|
+
</LocaleProvider>,
|
|
59
|
+
);
|
|
60
|
+
return { labels };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe("RenderEdit — ActionFormScreenDefinition.fieldLabels", () => {
|
|
64
|
+
test("a field named in fieldLabels renders the screen's own label", () => {
|
|
65
|
+
const screen: ActionFormScreenDefinition = {
|
|
66
|
+
id: "reschedule",
|
|
67
|
+
type: "actionForm",
|
|
68
|
+
handler: "shop:write:reschedule",
|
|
69
|
+
fields: { dueAt: { type: "date" } },
|
|
70
|
+
fieldLabels: { dueAt: "shop:reschedule.dueAt" },
|
|
71
|
+
layout: { sections: [{ fields: ["dueAt"] }] },
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const { labels } = renderActionFormCapturingFieldLabels(screen);
|
|
75
|
+
|
|
76
|
+
expect(labels["field-dueAt"]).toBe("shop:reschedule.dueAt");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("without fieldLabels, the field renders the default __action-form__ convention label", () => {
|
|
80
|
+
const screen: ActionFormScreenDefinition = {
|
|
81
|
+
id: "reschedule",
|
|
82
|
+
type: "actionForm",
|
|
83
|
+
handler: "shop:write:reschedule",
|
|
84
|
+
fields: { dueAt: { type: "date" } },
|
|
85
|
+
layout: { sections: [{ fields: ["dueAt"] }] },
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const { labels } = renderActionFormCapturingFieldLabels(screen);
|
|
89
|
+
|
|
90
|
+
expect(labels["field-dueAt"]).toBe("shop:entity:__action-form__:field:dueAt");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -69,6 +69,27 @@ describe("synthesizeActionFormScreen", () => {
|
|
|
69
69
|
});
|
|
70
70
|
expect("slots" in withoutSlots).toBe(false);
|
|
71
71
|
});
|
|
72
|
+
|
|
73
|
+
test("carries fieldLabels through so RenderEdit resolves the screen's own label override", () => {
|
|
74
|
+
const withFieldLabels = synthesizeActionFormScreen({
|
|
75
|
+
id: "reschedule",
|
|
76
|
+
type: "actionForm",
|
|
77
|
+
handler: "shop:write:reschedule",
|
|
78
|
+
layout: { sections: [{ title: "Reschedule", fields: ["dueAt"] }] },
|
|
79
|
+
fields: { dueAt: { type: "date" } },
|
|
80
|
+
fieldLabels: { dueAt: "shop:reschedule.dueAt" },
|
|
81
|
+
});
|
|
82
|
+
expect(withFieldLabels.fieldLabels).toEqual({ dueAt: "shop:reschedule.dueAt" });
|
|
83
|
+
|
|
84
|
+
const withoutFieldLabels = synthesizeActionFormScreen({
|
|
85
|
+
id: "reschedule",
|
|
86
|
+
type: "actionForm",
|
|
87
|
+
handler: "shop:write:reschedule",
|
|
88
|
+
layout: { sections: [{ title: "Reschedule", fields: ["dueAt"] }] },
|
|
89
|
+
fields: { dueAt: { type: "date" } },
|
|
90
|
+
});
|
|
91
|
+
expect("fieldLabels" in withoutFieldLabels).toBe(false);
|
|
92
|
+
});
|
|
72
93
|
});
|
|
73
94
|
|
|
74
95
|
describe("synthesizeSecretMintConfirmScreen (fw#2838)", () => {
|
|
@@ -22,6 +22,7 @@ import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
|
|
|
22
22
|
import { kumikoDefaultTranslations } from "../../i18n-defaults";
|
|
23
23
|
import {
|
|
24
24
|
type ButtonProps,
|
|
25
|
+
type CardProps,
|
|
25
26
|
type CorePrimitives,
|
|
26
27
|
type DataTableProps,
|
|
27
28
|
type DataTableRowAction,
|
|
@@ -62,6 +63,16 @@ const captureDataTable: ComponentType<DataTableProps> = (props) => {
|
|
|
62
63
|
};
|
|
63
64
|
const noop = (): ReactNode => null;
|
|
64
65
|
const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
|
|
66
|
+
// Card's title/subtitle/headerActions arrive as `slots` instead of raw
|
|
67
|
+
// JSX children.
|
|
68
|
+
const cardWithSlots: ComponentType<CardProps> = ({ slots, children }) => (
|
|
69
|
+
<>
|
|
70
|
+
{slots?.title}
|
|
71
|
+
{slots?.subtitle}
|
|
72
|
+
{slots?.headerActions}
|
|
73
|
+
{children}
|
|
74
|
+
</>
|
|
75
|
+
);
|
|
65
76
|
|
|
66
77
|
const testPrimitives: CorePrimitives = {
|
|
67
78
|
Button: noop,
|
|
@@ -71,7 +82,7 @@ const testPrimitives: CorePrimitives = {
|
|
|
71
82
|
DataTable: captureDataTable,
|
|
72
83
|
Form: passChildren,
|
|
73
84
|
Section: passChildren,
|
|
74
|
-
Card:
|
|
85
|
+
Card: cardWithSlots,
|
|
75
86
|
Grid: passChildren,
|
|
76
87
|
GridCell: passChildren,
|
|
77
88
|
Text: passChildren,
|
|
@@ -102,13 +102,17 @@ function StatefulNav({ children }: { readonly children: ReactNode }): ReactNode
|
|
|
102
102
|
return <NavProvider value={value}>{children}</NavProvider>;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
function renderScreen(
|
|
105
|
+
function renderScreen(
|
|
106
|
+
schema: FeatureSchema,
|
|
107
|
+
qn: string,
|
|
108
|
+
dispatcher: Dispatcher = stubDispatcher(),
|
|
109
|
+
): void {
|
|
106
110
|
render(
|
|
107
111
|
<LocaleProvider
|
|
108
112
|
resolver={createStaticLocaleResolver({ locale: "de-DE" })}
|
|
109
113
|
fallbackBundles={[kumikoDefaultTranslations]}
|
|
110
114
|
>
|
|
111
|
-
<DispatcherProvider dispatcher={
|
|
115
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
112
116
|
<StatefulNav>
|
|
113
117
|
<PrimitivesProvider value={testPrimitives}>
|
|
114
118
|
<KumikoScreen schema={schema} qn={qn} />
|
|
@@ -119,6 +123,27 @@ function renderScreen(schema: FeatureSchema, qn: string): void {
|
|
|
119
123
|
);
|
|
120
124
|
}
|
|
121
125
|
|
|
126
|
+
function stubDispatcherByType(
|
|
127
|
+
responses: Readonly<Record<string, { rows: readonly Record<string, unknown>[] }>>,
|
|
128
|
+
): Dispatcher {
|
|
129
|
+
return {
|
|
130
|
+
write: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["write"],
|
|
131
|
+
query: (async (type: string, payload: unknown) => {
|
|
132
|
+
queryCalls.push({ type, payload });
|
|
133
|
+
const rows = responses[type]?.rows ?? [];
|
|
134
|
+
return { isSuccess: true, data: { rows, nextCursor: null } };
|
|
135
|
+
}) as unknown as Dispatcher["query"],
|
|
136
|
+
batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
|
|
137
|
+
statusStore: {
|
|
138
|
+
getState: () => "online",
|
|
139
|
+
subscribe: () => () => {},
|
|
140
|
+
} as unknown as Dispatcher["statusStore"],
|
|
141
|
+
async *stream() {},
|
|
142
|
+
pendingWrites: () => [],
|
|
143
|
+
pendingFiles: () => [],
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
122
147
|
describe("entityList facets — unchanged after the shared buildFilterFacets/buildFilterPayload extraction (fw#2224)", () => {
|
|
123
148
|
test("a filterable boolean field renders as a facet, and toggling it sends a coerced boolean in payload.filters", async () => {
|
|
124
149
|
queryCalls = [];
|
|
@@ -337,4 +362,67 @@ describe("projectionList filter + facets (fw#2224)", () => {
|
|
|
337
362
|
expect(facet?.label).toBe(save);
|
|
338
363
|
expect(facet?.options?.[0]?.label).toBe(cancel);
|
|
339
364
|
});
|
|
365
|
+
|
|
366
|
+
test("a reference facet loads its options from the referenced entity's list query, and selecting one sends an id filter", async () => {
|
|
367
|
+
queryCalls = [];
|
|
368
|
+
capturedProps = undefined;
|
|
369
|
+
const dispatcher = stubDispatcherByType({
|
|
370
|
+
"ledger:query:member:list": { rows: [] },
|
|
371
|
+
"ledger:query:tenant:list": {
|
|
372
|
+
rows: [
|
|
373
|
+
{ id: "t1", name: "Acme" },
|
|
374
|
+
{ id: "t2", name: "Globex" },
|
|
375
|
+
],
|
|
376
|
+
},
|
|
377
|
+
});
|
|
378
|
+
const screen: ProjectionListScreenDefinition = {
|
|
379
|
+
id: "member-list",
|
|
380
|
+
type: "projectionList",
|
|
381
|
+
query: "ledger:query:member:list",
|
|
382
|
+
columns: ["tenantId"],
|
|
383
|
+
facets: [
|
|
384
|
+
{
|
|
385
|
+
field: "tenantId",
|
|
386
|
+
type: "reference",
|
|
387
|
+
label: "Tenant",
|
|
388
|
+
entity: "tenant",
|
|
389
|
+
labelField: "name",
|
|
390
|
+
},
|
|
391
|
+
],
|
|
392
|
+
};
|
|
393
|
+
const schema: FeatureSchema = {
|
|
394
|
+
featureName: "ledger",
|
|
395
|
+
entities: {},
|
|
396
|
+
screens: [screen],
|
|
397
|
+
} as FeatureSchema;
|
|
398
|
+
|
|
399
|
+
renderScreen(schema, "ledger:screen:member-list", dispatcher);
|
|
400
|
+
|
|
401
|
+
await waitFor(() =>
|
|
402
|
+
expect(queryCalls.some((c) => c.type === "ledger:query:tenant:list")).toBe(true),
|
|
403
|
+
);
|
|
404
|
+
await waitFor(() => expect(getCapturedProps()?.filterFacets?.[0]?.options).toHaveLength(2));
|
|
405
|
+
const props = getCapturedProps();
|
|
406
|
+
if (props === undefined) throw new Error("DataTable was not rendered");
|
|
407
|
+
expect(props.filterFacets).toEqual([
|
|
408
|
+
{
|
|
409
|
+
field: "tenantId",
|
|
410
|
+
label: "Tenant",
|
|
411
|
+
options: [
|
|
412
|
+
{ value: "t1", label: "Acme" },
|
|
413
|
+
{ value: "t2", label: "Globex" },
|
|
414
|
+
],
|
|
415
|
+
},
|
|
416
|
+
]);
|
|
417
|
+
|
|
418
|
+
const countBeforeSelect = queryCalls.length;
|
|
419
|
+
await act(async () => {
|
|
420
|
+
props.onFilterChange?.("tenantId", ["t1"]);
|
|
421
|
+
});
|
|
422
|
+
await waitFor(() => expect(queryCalls.length).toBeGreaterThan(countBeforeSelect));
|
|
423
|
+
const lastPayload = queryCalls[queryCalls.length - 1]?.payload;
|
|
424
|
+
expect(lastPayload).toMatchObject({
|
|
425
|
+
filters: [{ field: "tenantId", op: "in", value: ["t1"] }],
|
|
426
|
+
});
|
|
427
|
+
});
|
|
340
428
|
});
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
type ActionOverflowMenuProps,
|
|
27
27
|
type BannerProps,
|
|
28
28
|
type ButtonProps,
|
|
29
|
+
type CardProps,
|
|
29
30
|
type CorePrimitives,
|
|
30
31
|
type DialogProps,
|
|
31
32
|
type FormProps,
|
|
@@ -40,6 +41,17 @@ import { NavProvider } from "../nav";
|
|
|
40
41
|
const noop = (): ReactNode => null;
|
|
41
42
|
const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
|
|
42
43
|
|
|
44
|
+
// Card's title/subtitle/headerActions arrive as `slots`; this stub renders
|
|
45
|
+
// them so tests asserting on header-action content still see it.
|
|
46
|
+
const CardWithSlots: ComponentType<CardProps> = ({ slots, children }) => (
|
|
47
|
+
<>
|
|
48
|
+
{slots?.title}
|
|
49
|
+
{slots?.subtitle}
|
|
50
|
+
{slots?.headerActions}
|
|
51
|
+
{children}
|
|
52
|
+
</>
|
|
53
|
+
);
|
|
54
|
+
|
|
43
55
|
const TestButton: ComponentType<ButtonProps> = ({ children, onClick, testId }) => (
|
|
44
56
|
<button type="button" data-testid={testId} onClick={() => void onClick?.()}>
|
|
45
57
|
{children}
|
|
@@ -130,7 +142,7 @@ const testPrimitives: CorePrimitives = {
|
|
|
130
142
|
DataTable: noop,
|
|
131
143
|
Form: FormWithActions,
|
|
132
144
|
Section: passChildren,
|
|
133
|
-
Card:
|
|
145
|
+
Card: CardWithSlots,
|
|
134
146
|
Grid: passChildren,
|
|
135
147
|
GridCell: passChildren,
|
|
136
148
|
Text: passChildren,
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
3
|
+
import type { FeatureSchema } from "../feature-schema";
|
|
4
|
+
import { resolveReturnTarget, returnToParams } from "../return-to";
|
|
5
|
+
|
|
6
|
+
function schemaWith(featureName: string, screens: readonly ScreenDefinition[]): FeatureSchema {
|
|
7
|
+
return { featureName, entities: {}, screens };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const listScreen: ScreenDefinition = {
|
|
11
|
+
id: "token-list",
|
|
12
|
+
type: "entityList",
|
|
13
|
+
entity: "token",
|
|
14
|
+
columns: ["name"],
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const editScreen: ScreenDefinition = {
|
|
18
|
+
id: "token-edit",
|
|
19
|
+
type: "entityEdit",
|
|
20
|
+
entity: "token",
|
|
21
|
+
layout: { sections: [{ fields: ["name"] }] },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const detailScreen: ScreenDefinition = {
|
|
25
|
+
id: "token-detail",
|
|
26
|
+
type: "projectionDetail",
|
|
27
|
+
query: "tokens:query:token:detail",
|
|
28
|
+
layout: { sections: [{ fields: ["name"] }] },
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const singletonDetailScreen: ScreenDefinition = {
|
|
32
|
+
id: "my-settings",
|
|
33
|
+
type: "projectionDetail",
|
|
34
|
+
query: "tokens:query:settings:mine",
|
|
35
|
+
singleton: true,
|
|
36
|
+
layout: { sections: [{ fields: ["name"] }] },
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const adminOnlyScreen: ScreenDefinition = {
|
|
40
|
+
id: "admin-only",
|
|
41
|
+
type: "custom",
|
|
42
|
+
renderer: { react: "stub" },
|
|
43
|
+
access: { roles: ["Admin"] },
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const features: readonly FeatureSchema[] = [
|
|
47
|
+
schemaWith("tokens", [
|
|
48
|
+
listScreen,
|
|
49
|
+
editScreen,
|
|
50
|
+
detailScreen,
|
|
51
|
+
singletonDetailScreen,
|
|
52
|
+
adminOnlyScreen,
|
|
53
|
+
]),
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
describe("resolveReturnTarget", () => {
|
|
57
|
+
test("valid short id resolves to a ScreenTarget", () => {
|
|
58
|
+
expect(resolveReturnTarget("token-list", "token-create", features, undefined)).toEqual({
|
|
59
|
+
screenId: "token-list",
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("with entityId on an entityEdit screen", () => {
|
|
64
|
+
expect(resolveReturnTarget("token-edit/abc-123", "token-create", features, undefined)).toEqual({
|
|
65
|
+
screenId: "token-edit",
|
|
66
|
+
entityId: "abc-123",
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("with entityId on a projectionDetail screen", () => {
|
|
71
|
+
expect(
|
|
72
|
+
resolveReturnTarget("token-detail/abc-123", "token-create", features, undefined),
|
|
73
|
+
).toEqual({ screenId: "token-detail", entityId: "abc-123" });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("accepts a '%'-containing entityId that decodes to a safe value", () => {
|
|
77
|
+
expect(resolveReturnTarget("token-edit/abc%41", "token-create", features, undefined)).toEqual({
|
|
78
|
+
screenId: "token-edit",
|
|
79
|
+
entityId: "abc%41",
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("undefined raw value", () => {
|
|
84
|
+
expect(resolveReturnTarget(undefined, "token-create", features, undefined)).toBeUndefined();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("empty string", () => {
|
|
88
|
+
expect(resolveReturnTarget("", "token-create", features, undefined)).toBeUndefined();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("rejects an unknown screen id", () => {
|
|
92
|
+
expect(
|
|
93
|
+
resolveReturnTarget("unknown-screen", "token-create", features, undefined),
|
|
94
|
+
).toBeUndefined();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("rejects a qualified screen id ('feature:screen:x')", () => {
|
|
98
|
+
expect(
|
|
99
|
+
resolveReturnTarget("tokens:screen:token-list", "token-create", features, undefined),
|
|
100
|
+
).toBeUndefined();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("rejects a protocol-relative value ('//evil.com')", () => {
|
|
104
|
+
expect(resolveReturnTarget("//evil.com", "token-create", features, undefined)).toBeUndefined();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("rejects an absolute URL ('https://evil.com')", () => {
|
|
108
|
+
expect(
|
|
109
|
+
resolveReturnTarget("https://evil.com", "token-create", features, undefined),
|
|
110
|
+
).toBeUndefined();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("rejects more than two segments ('a/b/c')", () => {
|
|
114
|
+
expect(resolveReturnTarget("a/b/c", "token-create", features, undefined)).toBeUndefined();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("rejects a trailing slash ('x/')", () => {
|
|
118
|
+
expect(resolveReturnTarget("token-list/", "token-create", features, undefined)).toBeUndefined();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("rejects a leading slash ('/x')", () => {
|
|
122
|
+
expect(resolveReturnTarget("/token-list", "token-create", features, undefined)).toBeUndefined();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("rejects a role-gated screen when roles are missing", () => {
|
|
126
|
+
expect(resolveReturnTarget("admin-only", "token-create", features, undefined)).toBeUndefined();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("accepts a role-gated screen when the role matches", () => {
|
|
130
|
+
expect(resolveReturnTarget("admin-only", "token-create", features, ["Admin"])).toEqual({
|
|
131
|
+
screenId: "admin-only",
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("rejects the own screen (self)", () => {
|
|
136
|
+
expect(
|
|
137
|
+
resolveReturnTarget("token-create", "token-create", features, undefined),
|
|
138
|
+
).toBeUndefined();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("rejects an entityId on a list screen", () => {
|
|
142
|
+
expect(
|
|
143
|
+
resolveReturnTarget("token-list/abc-123", "token-create", features, undefined),
|
|
144
|
+
).toBeUndefined();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("rejects entityId '..'", () => {
|
|
148
|
+
expect(
|
|
149
|
+
resolveReturnTarget("token-edit/..", "token-create", features, undefined),
|
|
150
|
+
).toBeUndefined();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("rejects entityId '.'", () => {
|
|
154
|
+
expect(
|
|
155
|
+
resolveReturnTarget("token-edit/.", "token-create", features, undefined),
|
|
156
|
+
).toBeUndefined();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("rejects entityId with '?'", () => {
|
|
160
|
+
expect(
|
|
161
|
+
resolveReturnTarget("token-edit/abc?x=1", "token-create", features, undefined),
|
|
162
|
+
).toBeUndefined();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("rejects entityId with '#'", () => {
|
|
166
|
+
expect(
|
|
167
|
+
resolveReturnTarget("token-edit/abc#frag", "token-create", features, undefined),
|
|
168
|
+
).toBeUndefined();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("rejects entityId with '\\\\'", () => {
|
|
172
|
+
expect(
|
|
173
|
+
resolveReturnTarget("token-edit/abc\\def", "token-create", features, undefined),
|
|
174
|
+
).toBeUndefined();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("rejects entityId with a space", () => {
|
|
178
|
+
expect(
|
|
179
|
+
resolveReturnTarget("token-edit/abc def", "token-create", features, undefined),
|
|
180
|
+
).toBeUndefined();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("rejects a percent-encoded entityId that decodes to '..'", () => {
|
|
184
|
+
expect(
|
|
185
|
+
resolveReturnTarget("token-edit/%2e%2e", "token-create", features, undefined),
|
|
186
|
+
).toBeUndefined();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("rejects a percent-encoded entityId that decodes to '.'", () => {
|
|
190
|
+
expect(
|
|
191
|
+
resolveReturnTarget("token-edit/%2E", "token-create", features, undefined),
|
|
192
|
+
).toBeUndefined();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("rejects a percent-encoded entityId that decodes to a slash", () => {
|
|
196
|
+
expect(
|
|
197
|
+
resolveReturnTarget("token-edit/%2Fx", "token-create", features, undefined),
|
|
198
|
+
).toBeUndefined();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("rejects a malformed percent-encoded entityId", () => {
|
|
202
|
+
expect(
|
|
203
|
+
resolveReturnTarget("token-edit/%E0%A4%A", "token-create", features, undefined),
|
|
204
|
+
).toBeUndefined();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("rejects a percent-encoded entityId that decodes to whitespace", () => {
|
|
208
|
+
expect(
|
|
209
|
+
resolveReturnTarget("token-edit/a%20b", "token-create", features, undefined),
|
|
210
|
+
).toBeUndefined();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("rejects a non-singleton projectionDetail without an entityId", () => {
|
|
214
|
+
expect(
|
|
215
|
+
resolveReturnTarget("token-detail", "token-create", features, undefined),
|
|
216
|
+
).toBeUndefined();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("accepts a singleton projectionDetail without an entityId", () => {
|
|
220
|
+
expect(resolveReturnTarget("my-settings", "token-create", features, undefined)).toEqual({
|
|
221
|
+
screenId: "my-settings",
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe("returnToParams", () => {
|
|
227
|
+
test("no host -> {}", () => {
|
|
228
|
+
expect(returnToParams(undefined, { screenId: "token-list" })).toEqual({});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("target equals the host (self) -> {}", () => {
|
|
232
|
+
expect(returnToParams({ screenId: "token-list" }, { screenId: "token-list" })).toEqual({});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("target equals the host including entityId -> {}", () => {
|
|
236
|
+
expect(
|
|
237
|
+
returnToParams(
|
|
238
|
+
{ screenId: "token-edit", entityId: "abc" },
|
|
239
|
+
{ screenId: "token-edit", entityId: "abc" },
|
|
240
|
+
),
|
|
241
|
+
).toEqual({});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("different target -> returnTo param", () => {
|
|
245
|
+
expect(returnToParams({ screenId: "token-list" }, { screenId: "token-create" })).toEqual({
|
|
246
|
+
returnTo: "token-list",
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("host with entityId formats as 'screenId/entityId'", () => {
|
|
251
|
+
expect(
|
|
252
|
+
returnToParams({ screenId: "token-edit", entityId: "abc" }, { screenId: "token-create" }),
|
|
253
|
+
).toEqual({ returnTo: "token-edit/abc" });
|
|
254
|
+
});
|
|
255
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//
|
|
1
|
+
// entityList and projectionList rowActions
|
|
2
2
|
// get a default "Edit" row action for free when the row's entity has a
|
|
3
3
|
// visible entityEdit screen — same cross-feature resolution as
|
|
4
4
|
// projectionDetail's header defaultEditAction (fw#2166), reused here via
|
|
@@ -50,9 +50,11 @@ export function synthesizeActionFormScreen(
|
|
|
50
50
|
layout: screen.layout,
|
|
51
51
|
...(screen.description !== undefined && { description: screen.description }),
|
|
52
52
|
...(screen.access !== undefined && { access: screen.access }),
|
|
53
|
-
// Only ActionFormScreenDefinition carries slots
|
|
54
|
-
//
|
|
53
|
+
// Only ActionFormScreenDefinition carries slots/fieldLabels; `in` narrows
|
|
54
|
+
// without a cast since SecretMintScreenDefinition has neither.
|
|
55
55
|
...("slots" in screen && screen.slots !== undefined && { slots: screen.slots }),
|
|
56
|
+
...("fieldLabels" in screen &&
|
|
57
|
+
screen.fieldLabels !== undefined && { fieldLabels: screen.fieldLabels }),
|
|
56
58
|
};
|
|
57
59
|
}
|
|
58
60
|
|