@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.220.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.220.0",
19
- "@cosmicdrift/kumiko-headless": "0.220.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.220.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 = { type?: string; default?: unknown; sensitive?: boolean };
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?: string }): ReactNode {
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("&quot;");
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
- expect(facet?.label).not.toBe("kumiko.actions.save");
333
- expect(facet?.options?.[0]?.label).not.toBe("kumiko.actions.cancel");
334
- expect(typeof facet?.label).toBe("string");
335
- expect(facet?.label.length).toBeGreaterThan(0);
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 = Readonly<Record<string, ContentEditorComponent>>;
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?: string): ContentEditorComponent {
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) => variables[name] ?? match);
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.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
27
+ return value
28
+ .replace(/&/g, "&amp;")
29
+ .replace(/</g, "&lt;")
30
+ .replace(/>/g, "&gt;")
31
+ .replace(/"/g, "&quot;")
32
+ .replace(/'/g, "&#39;");
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?: string;
38
+ readonly contentFormat?: ContentEditorFormat;
31
39
  };
32
40
 
33
41
  export function ContentPreview({