@cosmicdrift/kumiko-renderer 0.220.0 → 0.221.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/__tests__/merge-search-params-into-initial.test.ts +45 -1
- package/src/app/__tests__/content-editors.test.tsx +2 -1
- package/src/app/__tests__/content-preview.test.tsx +22 -0
- package/src/app/__tests__/entity-list-row-action-entity-target.test.tsx +208 -1
- package/src/app/__tests__/entity-list-row-action-refetch.test.tsx +187 -1
- package/src/app/__tests__/form-schema.test.ts +9 -0
- package/src/app/__tests__/list-filter-facets.test.tsx +7 -4
- package/src/app/content-editors.tsx +3 -2
- package/src/app/content-preview.tsx +11 -3
- package/src/app/form-schema.ts +4 -6
- package/src/app/kumiko-screen.tsx +124 -35
- package/src/components/__tests__/render-edit-action-button.test.tsx +5 -0
- package/src/components/__tests__/render-edit-submit-actions.test.tsx +52 -1
- package/src/components/render-edit-action-button.tsx +4 -0
- package/src/components/render-edit.tsx +100 -65
- package/src/hooks/__tests__/use-form.test.tsx +8 -4
- package/src/hooks/use-form.ts +5 -18
- package/src/i18n.tsx +42 -0
- package/src/index.ts +2 -0
- package/src/primitives.tsx +5 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.221.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.221.0",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.221.0",
|
|
20
20
|
"react": "^19.2.6",
|
|
21
21
|
"temporal-polyfill": "^0.3.2",
|
|
22
22
|
"zod": "^4.4.3"
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"@testing-library/react": "^16.3.2",
|
|
26
26
|
"@types/react": "^19.2.14",
|
|
27
27
|
"jsdom": "^29.1.1",
|
|
28
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
28
|
+
"@cosmicdrift/kumiko-locale-de": "0.221.0"
|
|
29
29
|
},
|
|
30
30
|
"repository": {
|
|
31
31
|
"type": "git",
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { mergeSearchParamsIntoInitial } from "../app/kumiko-screen";
|
|
3
3
|
|
|
4
|
-
type FieldDef = {
|
|
4
|
+
type FieldDef = {
|
|
5
|
+
type?: string;
|
|
6
|
+
default?: unknown;
|
|
7
|
+
sensitive?: boolean;
|
|
8
|
+
options?: readonly string[];
|
|
9
|
+
};
|
|
5
10
|
|
|
6
11
|
describe("mergeSearchParamsIntoInitial", () => {
|
|
7
12
|
test("raw string param merges in as-is for a text field", () => {
|
|
@@ -77,4 +82,43 @@ describe("mergeSearchParamsIntoInitial", () => {
|
|
|
77
82
|
const result = mergeSearchParamsIntoInitial(fields, { ownerId: "user-123" });
|
|
78
83
|
expect(result["ownerId"]).toBe("user-123");
|
|
79
84
|
});
|
|
85
|
+
|
|
86
|
+
test("multiSelect coerces comma-separated searchParam to string[]", () => {
|
|
87
|
+
const fields: Record<string, FieldDef> = { roles: { type: "multiSelect" } };
|
|
88
|
+
expect(mergeSearchParamsIntoInitial(fields, { roles: "TenantAdmin" })["roles"]).toEqual([
|
|
89
|
+
"TenantAdmin",
|
|
90
|
+
]);
|
|
91
|
+
expect(mergeSearchParamsIntoInitial(fields, { roles: "Admin,User" })["roles"]).toEqual([
|
|
92
|
+
"Admin",
|
|
93
|
+
"User",
|
|
94
|
+
]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("multiSelect coerces JSON-array searchParam to string[]", () => {
|
|
98
|
+
const fields: Record<string, FieldDef> = { roles: { type: "multiSelect" } };
|
|
99
|
+
expect(
|
|
100
|
+
mergeSearchParamsIntoInitial(fields, { roles: JSON.stringify(["Admin", "Editor"]) })["roles"],
|
|
101
|
+
).toEqual(["Admin", "Editor"]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("multiSelect defaults to [] when unset", () => {
|
|
105
|
+
const fields: Record<string, FieldDef> = { roles: { type: "multiSelect" } };
|
|
106
|
+
expect(mergeSearchParamsIntoInitial(fields, {})["roles"]).toEqual([]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("multiSelect prefers JSON parse even without '[' prefix for quoted strings", () => {
|
|
110
|
+
const fields: Record<string, FieldDef> = { tags: { type: "multiSelect" } };
|
|
111
|
+
expect(
|
|
112
|
+
mergeSearchParamsIntoInitial(fields, { tags: JSON.stringify("Berlin, Germany") })["tags"],
|
|
113
|
+
).toEqual(["Berlin, Germany"]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("multiSelect filters unknown option values when options are set", () => {
|
|
117
|
+
const fields: Record<string, FieldDef> = {
|
|
118
|
+
roles: { type: "multiSelect", options: ["Admin", "User"] },
|
|
119
|
+
};
|
|
120
|
+
expect(
|
|
121
|
+
mergeSearchParamsIntoInitial(fields, { roles: JSON.stringify(["Admin", "Hacker"]) })["roles"],
|
|
122
|
+
).toEqual(["Admin"]);
|
|
123
|
+
});
|
|
80
124
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ContentEditorFormat } from "@cosmicdrift/kumiko-types/nav";
|
|
2
3
|
import { render, screen } from "@testing-library/react";
|
|
3
4
|
import type { ComponentType, ReactNode } from "react";
|
|
4
5
|
import { type CorePrimitives, type InputProps, PrimitivesProvider } from "../../primitives";
|
|
@@ -56,7 +57,7 @@ function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
|
|
|
56
57
|
return <PrimitivesProvider value={testPrimitives}>{children}</PrimitivesProvider>;
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
function Probe({ contentFormat }: { readonly contentFormat?:
|
|
60
|
+
function Probe({ contentFormat }: { readonly contentFormat?: ContentEditorFormat }): ReactNode {
|
|
60
61
|
const Editor = useContentEditor(contentFormat);
|
|
61
62
|
return (
|
|
62
63
|
<Editor
|
|
@@ -52,6 +52,7 @@ describe("substituteVariables", () => {
|
|
|
52
52
|
|
|
53
53
|
test("a name with no example value stays as the literal placeholder", () => {
|
|
54
54
|
expect(substituteVariables("Hi {{customerName}}", {})).toBe("Hi {{customerName}}");
|
|
55
|
+
expect(substituteVariables("Hi {{constructor}}", {})).toBe("Hi {{constructor}}");
|
|
55
56
|
});
|
|
56
57
|
|
|
57
58
|
test("no variables in the content → content passes through unchanged", () => {
|
|
@@ -62,6 +63,27 @@ describe("substituteVariables", () => {
|
|
|
62
63
|
});
|
|
63
64
|
|
|
64
65
|
describe("ContentPreview", () => {
|
|
66
|
+
test("rich format escapes attribute-breaking quotes in substituted values", () => {
|
|
67
|
+
function RichEditor({ value }: ContentEditorProps): ReactNode {
|
|
68
|
+
// biome-ignore lint/security/noDangerouslySetInnerHtml: XSS regression fixture for escapeHtml
|
|
69
|
+
return <div data-testid="rich" dangerouslySetInnerHTML={{ __html: value }} />;
|
|
70
|
+
}
|
|
71
|
+
render(
|
|
72
|
+
<Wrapper>
|
|
73
|
+
<ContentEditorsProvider value={{ rich: RichEditor }}>
|
|
74
|
+
<ContentPreview
|
|
75
|
+
content={'<a href="{{url}}">x</a>'}
|
|
76
|
+
variables={{ url: '" onerror="x' }}
|
|
77
|
+
contentFormat="rich"
|
|
78
|
+
/>
|
|
79
|
+
</ContentEditorsProvider>
|
|
80
|
+
</Wrapper>,
|
|
81
|
+
);
|
|
82
|
+
const html = screen.getByTestId("rich").innerHTML;
|
|
83
|
+
expect(html).toContain(""");
|
|
84
|
+
expect(html).not.toContain('onerror="x');
|
|
85
|
+
});
|
|
86
|
+
|
|
65
87
|
test("renders the format's registered editor read-only, with variables substituted", () => {
|
|
66
88
|
function RichEditor({ value, readOnly }: ContentEditorProps): ReactNode {
|
|
67
89
|
return (
|
|
@@ -10,17 +10,22 @@ import { describe, expect, test } from "bun:test";
|
|
|
10
10
|
import type {
|
|
11
11
|
EntityDefinition,
|
|
12
12
|
EntityListScreenDefinition,
|
|
13
|
+
ProjectionDetailScreenDefinition,
|
|
14
|
+
ProjectionListScreenDefinition,
|
|
15
|
+
RowActionNavigate,
|
|
13
16
|
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
14
17
|
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
15
|
-
import { render, waitFor } from "@testing-library/react";
|
|
18
|
+
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
16
19
|
import type { ComponentType, ReactNode } from "react";
|
|
17
20
|
import { DispatcherProvider } from "../../context/dispatcher-context";
|
|
18
21
|
import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
|
|
19
22
|
import { kumikoDefaultTranslations } from "../../i18n-defaults";
|
|
20
23
|
import {
|
|
24
|
+
type ButtonProps,
|
|
21
25
|
type CorePrimitives,
|
|
22
26
|
type DataTableProps,
|
|
23
27
|
type DataTableRowAction,
|
|
28
|
+
type FormProps,
|
|
24
29
|
PrimitivesProvider,
|
|
25
30
|
} from "../../primitives";
|
|
26
31
|
import type { FeatureSchema } from "../feature-schema";
|
|
@@ -192,3 +197,205 @@ describe("entityList navigate row-action with an entity-target (fw#2228)", () =>
|
|
|
192
197
|
expect(navigateCalls).toEqual([{ entity: "customer", id: "cust-7" }]);
|
|
193
198
|
});
|
|
194
199
|
});
|
|
200
|
+
|
|
201
|
+
// projectionList.runNavigate has NO row["id"] fallback (unlike EntityListBody
|
|
202
|
+
// above) — a projectionList row comes from an arbitrary query without a
|
|
203
|
+
// guaranteed "id" field, so an entity-target there requires an explicit
|
|
204
|
+
// entityId. This proves that path resolves { entity, id } correctly and
|
|
205
|
+
// still forwards `params` via setSearchParams.
|
|
206
|
+
function buildProjectionListSchema(rowAction: RowActionNavigate): FeatureSchema {
|
|
207
|
+
const listScreen: ProjectionListScreenDefinition = {
|
|
208
|
+
id: "invoice-projection-list",
|
|
209
|
+
type: "projectionList",
|
|
210
|
+
query: "billing:query:invoice:list",
|
|
211
|
+
columns: [{ field: "status", label: "Status" }],
|
|
212
|
+
rowActions: [rowAction],
|
|
213
|
+
};
|
|
214
|
+
return {
|
|
215
|
+
featureName: "billing",
|
|
216
|
+
entities: {},
|
|
217
|
+
screens: [listScreen],
|
|
218
|
+
} as FeatureSchema;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function renderProjectionListScreen(
|
|
222
|
+
schema: FeatureSchema,
|
|
223
|
+
navigateSpy: (target: NavTarget) => void,
|
|
224
|
+
setSearchParamsSpy: (params: Record<string, string | null>) => void,
|
|
225
|
+
): void {
|
|
226
|
+
render(
|
|
227
|
+
<LocaleProvider
|
|
228
|
+
resolver={createStaticLocaleResolver({ locale: "en" })}
|
|
229
|
+
fallbackBundles={[kumikoDefaultTranslations]}
|
|
230
|
+
>
|
|
231
|
+
<DispatcherProvider dispatcher={stubDispatcher()}>
|
|
232
|
+
<NavProvider
|
|
233
|
+
value={{
|
|
234
|
+
route: { screenId: "billing:invoice-projection-list" },
|
|
235
|
+
navigate: navigateSpy,
|
|
236
|
+
replace: () => {},
|
|
237
|
+
hrefFor: () => "",
|
|
238
|
+
searchParams: {},
|
|
239
|
+
setSearchParams: setSearchParamsSpy,
|
|
240
|
+
}}
|
|
241
|
+
>
|
|
242
|
+
<PrimitivesProvider value={testPrimitives}>
|
|
243
|
+
<KumikoScreen schema={schema} qn="billing:screen:invoice-projection-list" />
|
|
244
|
+
</PrimitivesProvider>
|
|
245
|
+
</NavProvider>
|
|
246
|
+
</DispatcherProvider>
|
|
247
|
+
</LocaleProvider>,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
describe("projectionList navigate row-action with an entity-target (fw#2228)", () => {
|
|
252
|
+
test("entity-target with entityId calls nav.navigate with { entity, id } and forwards params via setSearchParams", async () => {
|
|
253
|
+
capturedRowActions = undefined;
|
|
254
|
+
const navigateCalls: NavTarget[] = [];
|
|
255
|
+
const searchParamsCalls: Record<string, string | null>[] = [];
|
|
256
|
+
|
|
257
|
+
renderProjectionListScreen(
|
|
258
|
+
buildProjectionListSchema({
|
|
259
|
+
kind: "navigate",
|
|
260
|
+
id: "view",
|
|
261
|
+
label: "kumiko.actions.view",
|
|
262
|
+
entity: "invoice",
|
|
263
|
+
entityId: "invoiceId",
|
|
264
|
+
params: { pick: ["status"] },
|
|
265
|
+
}),
|
|
266
|
+
(target) => navigateCalls.push(target),
|
|
267
|
+
(params) => searchParamsCalls.push(params),
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
await waitFor(() => {
|
|
271
|
+
expect(capturedRowActions).toBeDefined();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
await requireAction("view").onTrigger({
|
|
275
|
+
id: "proj-1",
|
|
276
|
+
values: { id: "proj-1", invoiceId: "invoice-42", status: "open" },
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
expect(navigateCalls).toEqual([{ entity: "invoice", id: "invoice-42" }]);
|
|
280
|
+
expect(searchParamsCalls).toEqual([{ status: "open" }]);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// projectionDetail's header actions run through the same entity-target
|
|
285
|
+
// resolution (ProjectionDetailBody, no record["id"] fallback either) as a
|
|
286
|
+
// distinct code path from both entityList and projectionList above — this
|
|
287
|
+
// renders the real KumikoScreen → ProjectionDetailBody → RenderEdit pipeline
|
|
288
|
+
// and clicks the real action button.
|
|
289
|
+
const TestButton: ComponentType<ButtonProps> = ({ children, onClick, testId }) => (
|
|
290
|
+
<button type="button" data-testid={testId} onClick={() => void onClick?.()}>
|
|
291
|
+
{children}
|
|
292
|
+
</button>
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
const FormWithActions: ComponentType<FormProps> = ({ children, actions }) => (
|
|
296
|
+
<>
|
|
297
|
+
<div data-testid="form-body">{children}</div>
|
|
298
|
+
<div data-testid="form-actions">{actions}</div>
|
|
299
|
+
</>
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
const detailTestPrimitives: CorePrimitives = {
|
|
303
|
+
...testPrimitives,
|
|
304
|
+
Button: TestButton,
|
|
305
|
+
Form: FormWithActions,
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
function detailStubDispatcher(record: Readonly<Record<string, unknown>>): Dispatcher {
|
|
309
|
+
return {
|
|
310
|
+
write: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["write"],
|
|
311
|
+
query: (async () => ({ isSuccess: true, data: record })) as unknown as Dispatcher["query"],
|
|
312
|
+
batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
|
|
313
|
+
statusStore: {
|
|
314
|
+
getState: () => "online",
|
|
315
|
+
subscribe: () => () => {},
|
|
316
|
+
} as unknown as Dispatcher["statusStore"],
|
|
317
|
+
async *stream() {},
|
|
318
|
+
pendingWrites: () => [],
|
|
319
|
+
pendingFiles: () => [],
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function buildProjectionDetailSchema(action: RowActionNavigate): FeatureSchema {
|
|
324
|
+
const detailScreen: ProjectionDetailScreenDefinition = {
|
|
325
|
+
id: "invoice-detail",
|
|
326
|
+
type: "projectionDetail",
|
|
327
|
+
query: "billing:query:invoice:detail",
|
|
328
|
+
layout: { sections: [{ title: "s", fields: ["status"] }] },
|
|
329
|
+
actions: [action],
|
|
330
|
+
};
|
|
331
|
+
return {
|
|
332
|
+
featureName: "billing",
|
|
333
|
+
entities: {},
|
|
334
|
+
screens: [detailScreen],
|
|
335
|
+
} as FeatureSchema;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function renderProjectionDetailScreen(
|
|
339
|
+
schema: FeatureSchema,
|
|
340
|
+
record: Readonly<Record<string, unknown>>,
|
|
341
|
+
navigateSpy: (target: NavTarget) => void,
|
|
342
|
+
setSearchParamsSpy: (params: Record<string, string | null>) => void,
|
|
343
|
+
): void {
|
|
344
|
+
render(
|
|
345
|
+
<LocaleProvider
|
|
346
|
+
resolver={createStaticLocaleResolver({ locale: "en" })}
|
|
347
|
+
fallbackBundles={[kumikoDefaultTranslations]}
|
|
348
|
+
>
|
|
349
|
+
<DispatcherProvider dispatcher={detailStubDispatcher(record)}>
|
|
350
|
+
<NavProvider
|
|
351
|
+
value={{
|
|
352
|
+
route: { screenId: "billing:invoice-detail" },
|
|
353
|
+
navigate: navigateSpy,
|
|
354
|
+
replace: () => {},
|
|
355
|
+
hrefFor: () => "",
|
|
356
|
+
searchParams: {},
|
|
357
|
+
setSearchParams: setSearchParamsSpy,
|
|
358
|
+
}}
|
|
359
|
+
>
|
|
360
|
+
<PrimitivesProvider value={detailTestPrimitives}>
|
|
361
|
+
<KumikoScreen
|
|
362
|
+
schema={schema}
|
|
363
|
+
qn="billing:screen:invoice-detail"
|
|
364
|
+
entityId={String(record["id"])}
|
|
365
|
+
/>
|
|
366
|
+
</PrimitivesProvider>
|
|
367
|
+
</NavProvider>
|
|
368
|
+
</DispatcherProvider>
|
|
369
|
+
</LocaleProvider>,
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
describe("projectionDetail navigate action with an entity-target (fw#2228)", () => {
|
|
374
|
+
test("entity-target with entityId calls nav.navigate with { entity, id } and forwards params via setSearchParams", async () => {
|
|
375
|
+
const navigateCalls: NavTarget[] = [];
|
|
376
|
+
const searchParamsCalls: Record<string, string | null>[] = [];
|
|
377
|
+
|
|
378
|
+
renderProjectionDetailScreen(
|
|
379
|
+
buildProjectionDetailSchema({
|
|
380
|
+
kind: "navigate",
|
|
381
|
+
id: "view-customer",
|
|
382
|
+
label: "kumiko.actions.view",
|
|
383
|
+
entity: "customer",
|
|
384
|
+
entityId: "customerId",
|
|
385
|
+
params: { pick: ["status"] },
|
|
386
|
+
}),
|
|
387
|
+
{ id: "invoice-42", customerId: "cust-7", status: "open" },
|
|
388
|
+
(target) => navigateCalls.push(target),
|
|
389
|
+
(params) => searchParamsCalls.push(params),
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
await waitFor(() => expect(screen.queryByText("Loading…")).toBeNull());
|
|
393
|
+
|
|
394
|
+
await act(async () => {
|
|
395
|
+
fireEvent.click(screen.getByTestId("render-edit-action-view-customer"));
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
expect(navigateCalls).toEqual([{ entity: "customer", id: "cust-7" }]);
|
|
399
|
+
expect(searchParamsCalls).toEqual([{ status: "open" }]);
|
|
400
|
+
});
|
|
401
|
+
});
|
|
@@ -13,14 +13,16 @@ import { describe, expect, test } from "bun:test";
|
|
|
13
13
|
import type {
|
|
14
14
|
EntityDefinition,
|
|
15
15
|
EntityListScreenDefinition,
|
|
16
|
+
ProjectionListScreenDefinition,
|
|
16
17
|
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
17
18
|
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
18
|
-
import { act, render, waitFor } from "@testing-library/react";
|
|
19
|
+
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
19
20
|
import type { ComponentType, ReactNode } from "react";
|
|
20
21
|
import { DispatcherProvider } from "../../context/dispatcher-context";
|
|
21
22
|
import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
|
|
22
23
|
import { kumikoDefaultTranslations } from "../../i18n-defaults";
|
|
23
24
|
import {
|
|
25
|
+
type ButtonProps,
|
|
24
26
|
type CorePrimitives,
|
|
25
27
|
type DataTableProps,
|
|
26
28
|
type DataTableRowAction,
|
|
@@ -213,3 +215,187 @@ describe("entityList row-action writeHandler refetches the rows query", () => {
|
|
|
213
215
|
expect(queryCallCount).toBe(countAfterMount);
|
|
214
216
|
});
|
|
215
217
|
});
|
|
218
|
+
|
|
219
|
+
// toolbarActions render through RenderList's `toolbarEnd` slot via the real
|
|
220
|
+
// ToolbarActionView primitive, not through the DataTable's `rowActions` prop
|
|
221
|
+
// (that's what captureDataTable above captures) — a stub that ignores
|
|
222
|
+
// `toolbarEnd` never mounts the button, so this needs its own DataTable stub
|
|
223
|
+
// that renders that slot, plus a real Button primitive to click through.
|
|
224
|
+
const TestButton: ComponentType<ButtonProps> = ({ children, onClick, testId }) => (
|
|
225
|
+
<button type="button" data-testid={testId} onClick={() => void onClick?.()}>
|
|
226
|
+
{children}
|
|
227
|
+
</button>
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
const renderToolbarEnd: ComponentType<DataTableProps> = (props) => <>{props.toolbarEnd}</>;
|
|
231
|
+
|
|
232
|
+
const toolbarTestPrimitives: CorePrimitives = {
|
|
233
|
+
...testPrimitives,
|
|
234
|
+
Button: TestButton,
|
|
235
|
+
DataTable: renderToolbarEnd,
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
function buildToolbarSchema(): FeatureSchema {
|
|
239
|
+
const entity: EntityDefinition = {
|
|
240
|
+
fields: {
|
|
241
|
+
status: {
|
|
242
|
+
type: "text",
|
|
243
|
+
maxLength: 50,
|
|
244
|
+
required: false,
|
|
245
|
+
searchable: false,
|
|
246
|
+
sortable: false,
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
const listScreen: EntityListScreenDefinition = {
|
|
251
|
+
id: "unit-list",
|
|
252
|
+
type: "entityList",
|
|
253
|
+
entity: "unit",
|
|
254
|
+
columns: ["status"],
|
|
255
|
+
toolbarActions: [
|
|
256
|
+
{
|
|
257
|
+
kind: "writeHandler",
|
|
258
|
+
id: "sync",
|
|
259
|
+
label: "Sync",
|
|
260
|
+
handler: "units:write:unit:sync",
|
|
261
|
+
},
|
|
262
|
+
],
|
|
263
|
+
};
|
|
264
|
+
return {
|
|
265
|
+
featureName: "units",
|
|
266
|
+
entities: { unit: entity },
|
|
267
|
+
screens: [listScreen],
|
|
268
|
+
} as FeatureSchema;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function renderToolbarListScreen(): void {
|
|
272
|
+
render(
|
|
273
|
+
<LocaleProvider
|
|
274
|
+
resolver={createStaticLocaleResolver({ locale: "de-DE" })}
|
|
275
|
+
fallbackBundles={[kumikoDefaultTranslations]}
|
|
276
|
+
>
|
|
277
|
+
<DispatcherProvider dispatcher={stubDispatcher()}>
|
|
278
|
+
<NavProvider
|
|
279
|
+
value={{
|
|
280
|
+
route: { screenId: "units:unit-list" },
|
|
281
|
+
navigate: () => {},
|
|
282
|
+
replace: () => {},
|
|
283
|
+
hrefFor: () => "",
|
|
284
|
+
searchParams: {},
|
|
285
|
+
setSearchParams: () => {},
|
|
286
|
+
}}
|
|
287
|
+
>
|
|
288
|
+
<PrimitivesProvider value={toolbarTestPrimitives}>
|
|
289
|
+
<KumikoScreen schema={buildToolbarSchema()} qn="units:screen:unit-list" />
|
|
290
|
+
</PrimitivesProvider>
|
|
291
|
+
</NavProvider>
|
|
292
|
+
</DispatcherProvider>
|
|
293
|
+
</LocaleProvider>,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
describe("entityList toolbarAction writeHandler refetches the rows query", () => {
|
|
298
|
+
test("a successful toolbarAction write triggers exactly one refetch", async () => {
|
|
299
|
+
queryCallCount = 0;
|
|
300
|
+
writeIsSuccess = true;
|
|
301
|
+
|
|
302
|
+
renderToolbarListScreen();
|
|
303
|
+
|
|
304
|
+
const button = await waitFor(() => screen.getByTestId("render-list-toolbar-action-sync"));
|
|
305
|
+
const countAfterMount = queryCallCount;
|
|
306
|
+
expect(countAfterMount).toBeGreaterThan(0);
|
|
307
|
+
|
|
308
|
+
fireEvent.click(button);
|
|
309
|
+
|
|
310
|
+
await waitFor(() => {
|
|
311
|
+
expect(queryCallCount).toBe(countAfterMount + 1);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// Give any accidental extra refetch a chance to land before asserting
|
|
315
|
+
// there wasn't one.
|
|
316
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
317
|
+
expect(queryCallCount).toBe(countAfterMount + 1);
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// projectionList runs its own writeHandler-refetch path (ProjectionListBody,
|
|
322
|
+
// separate from EntityListBody above) — the copy-paste edit that added the
|
|
323
|
+
// refetch call there is untested without this.
|
|
324
|
+
function buildProjectionListSchema(): FeatureSchema {
|
|
325
|
+
const listScreen: ProjectionListScreenDefinition = {
|
|
326
|
+
id: "unit-projection-list",
|
|
327
|
+
type: "projectionList",
|
|
328
|
+
query: "units:query:unit:list",
|
|
329
|
+
columns: [{ field: "status", label: "Status" }],
|
|
330
|
+
rowActions: [
|
|
331
|
+
{
|
|
332
|
+
kind: "writeHandler",
|
|
333
|
+
id: "archive",
|
|
334
|
+
label: "Archive",
|
|
335
|
+
handler: "units:write:unit:archive",
|
|
336
|
+
},
|
|
337
|
+
],
|
|
338
|
+
};
|
|
339
|
+
return {
|
|
340
|
+
featureName: "units",
|
|
341
|
+
entities: {},
|
|
342
|
+
screens: [listScreen],
|
|
343
|
+
} as FeatureSchema;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function renderProjectionListScreen(): void {
|
|
347
|
+
render(
|
|
348
|
+
<LocaleProvider
|
|
349
|
+
resolver={createStaticLocaleResolver({ locale: "de-DE" })}
|
|
350
|
+
fallbackBundles={[kumikoDefaultTranslations]}
|
|
351
|
+
>
|
|
352
|
+
<DispatcherProvider dispatcher={stubDispatcher()}>
|
|
353
|
+
<NavProvider
|
|
354
|
+
value={{
|
|
355
|
+
route: { screenId: "units:unit-projection-list" },
|
|
356
|
+
navigate: () => {},
|
|
357
|
+
replace: () => {},
|
|
358
|
+
hrefFor: () => "",
|
|
359
|
+
searchParams: {},
|
|
360
|
+
setSearchParams: () => {},
|
|
361
|
+
}}
|
|
362
|
+
>
|
|
363
|
+
<PrimitivesProvider value={testPrimitives}>
|
|
364
|
+
<KumikoScreen
|
|
365
|
+
schema={buildProjectionListSchema()}
|
|
366
|
+
qn="units:screen:unit-projection-list"
|
|
367
|
+
/>
|
|
368
|
+
</PrimitivesProvider>
|
|
369
|
+
</NavProvider>
|
|
370
|
+
</DispatcherProvider>
|
|
371
|
+
</LocaleProvider>,
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
describe("projectionList row-action writeHandler refetches the rows query", () => {
|
|
376
|
+
test("a successful row-action write triggers exactly one refetch", async () => {
|
|
377
|
+
capturedRowActions = undefined;
|
|
378
|
+
queryCallCount = 0;
|
|
379
|
+
writeIsSuccess = true;
|
|
380
|
+
|
|
381
|
+
renderProjectionListScreen();
|
|
382
|
+
|
|
383
|
+
await waitFor(() => {
|
|
384
|
+
expect(capturedRowActions).toBeDefined();
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
const countAfterMount = queryCallCount;
|
|
388
|
+
expect(countAfterMount).toBeGreaterThan(0);
|
|
389
|
+
|
|
390
|
+
await act(async () => {
|
|
391
|
+
await requireArchiveAction().onTrigger({ id: "unit-1", values: { id: "unit-1" } });
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
await waitFor(() => {
|
|
395
|
+
expect(queryCallCount).toBe(countAfterMount + 1);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
399
|
+
expect(queryCallCount).toBe(countAfterMount + 1);
|
|
400
|
+
});
|
|
401
|
+
});
|
|
@@ -138,6 +138,15 @@ describe("buildFormSchema", () => {
|
|
|
138
138
|
expect(buildFormSchema(entity, screen).safeParse({ lines: undefined }).success).toBe(true);
|
|
139
139
|
});
|
|
140
140
|
|
|
141
|
+
test("required embedded list field → issue when missing (has EmbeddedListField widget)", () => {
|
|
142
|
+
const entity = entityWith({
|
|
143
|
+
lines: { type: "embedded", multiple: true, required: true, schema: {} },
|
|
144
|
+
});
|
|
145
|
+
const screen = screenWith(["lines"]);
|
|
146
|
+
const result = buildFormSchema(entity, screen).safeParse({ lines: undefined });
|
|
147
|
+
expect(result.success).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
|
|
141
150
|
test("required files field → no issue (#1925: no multi-upload widget yet, deliberately deferred)", () => {
|
|
142
151
|
const entity = entityWith({ attachments: { type: "files" } });
|
|
143
152
|
const screen = screenWith([{ field: "attachments", required: true }]);
|
|
@@ -329,9 +329,12 @@ describe("projectionList filter + facets (fw#2224)", () => {
|
|
|
329
329
|
const props = getCapturedProps();
|
|
330
330
|
if (props === undefined) throw new Error("DataTable was not rendered");
|
|
331
331
|
const facet = props.filterFacets?.[0];
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
332
|
+
const de = kumikoDefaultTranslations["de"];
|
|
333
|
+
const en = kumikoDefaultTranslations["en"];
|
|
334
|
+
if (en === undefined) throw new Error("missing en default translations");
|
|
335
|
+
const save = de?.["kumiko.actions.save"] ?? en["kumiko.actions.save"];
|
|
336
|
+
const cancel = de?.["kumiko.actions.cancel"] ?? en["kumiko.actions.cancel"];
|
|
337
|
+
expect(facet?.label).toBe(save);
|
|
338
|
+
expect(facet?.options?.[0]?.label).toBe(cancel);
|
|
336
339
|
});
|
|
337
340
|
});
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// No entry registered for a format → TextareaContentEditor, so a missing
|
|
9
9
|
// editor is never an empty panel.
|
|
10
10
|
|
|
11
|
+
import type { ContentEditorFormat } from "@cosmicdrift/kumiko-types/nav";
|
|
11
12
|
import { type ComponentType, createContext, type ReactNode, useContext } from "react";
|
|
12
13
|
import { usePrimitives } from "../primitives";
|
|
13
14
|
|
|
@@ -36,7 +37,7 @@ export type ContentEditorComponent = ComponentType<ContentEditorProps>;
|
|
|
36
37
|
* its own id via ContentEditorProps.id. */
|
|
37
38
|
export const CONTENT_EDITOR_ELEMENT_ID = "content-editor-textarea";
|
|
38
39
|
|
|
39
|
-
export type ContentEditorsMap =
|
|
40
|
+
export type ContentEditorsMap = Partial<Record<ContentEditorFormat, ContentEditorComponent>>;
|
|
40
41
|
|
|
41
42
|
const ContentEditorsContext = createContext<ContentEditorsMap>({});
|
|
42
43
|
|
|
@@ -78,7 +79,7 @@ export function TextareaContentEditor({
|
|
|
78
79
|
/** Resolves the editor for a contentFormat, falling back to the plain
|
|
79
80
|
* textarea when no clientFeature registered one. `contentFormat`
|
|
80
81
|
* undefined (collection didn't declare one) behaves like "plain". */
|
|
81
|
-
export function useContentEditor(contentFormat?:
|
|
82
|
+
export function useContentEditor(contentFormat?: ContentEditorFormat): ContentEditorComponent {
|
|
82
83
|
const map = useContext(ContentEditorsContext);
|
|
83
84
|
return map[contentFormat ?? "plain"] ?? TextareaContentEditor;
|
|
84
85
|
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// renders formatted HTML and "plain"/"markdown" render as text through the
|
|
5
5
|
// exact same component, no separate render path per format.
|
|
6
6
|
|
|
7
|
+
import type { ContentEditorFormat } from "@cosmicdrift/kumiko-types/nav";
|
|
7
8
|
import { type ReactNode, useId } from "react";
|
|
8
9
|
import { useContentEditor } from "./content-editors";
|
|
9
10
|
|
|
@@ -17,17 +18,24 @@ export function substituteVariables(
|
|
|
17
18
|
content: string,
|
|
18
19
|
variables: Readonly<Record<string, string>>,
|
|
19
20
|
): string {
|
|
20
|
-
return content.replace(VARIABLE_PATTERN, (match, name: string) =>
|
|
21
|
+
return content.replace(VARIABLE_PATTERN, (match, name: string) =>
|
|
22
|
+
Object.hasOwn(variables, name) ? (variables[name] ?? match) : match,
|
|
23
|
+
);
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
function escapeHtml(value: string): string {
|
|
24
|
-
return value
|
|
27
|
+
return value
|
|
28
|
+
.replace(/&/g, "&")
|
|
29
|
+
.replace(/</g, "<")
|
|
30
|
+
.replace(/>/g, ">")
|
|
31
|
+
.replace(/"/g, """)
|
|
32
|
+
.replace(/'/g, "'");
|
|
25
33
|
}
|
|
26
34
|
|
|
27
35
|
export type ContentPreviewProps = {
|
|
28
36
|
readonly content: string;
|
|
29
37
|
readonly variables: Readonly<Record<string, string>>;
|
|
30
|
-
readonly contentFormat?:
|
|
38
|
+
readonly contentFormat?: ContentEditorFormat;
|
|
31
39
|
};
|
|
32
40
|
|
|
33
41
|
export function ContentPreview({
|