@cosmicdrift/kumiko-renderer 0.197.1 → 0.199.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.197.1",
3
+ "version": "0.199.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.197.1",
19
- "@cosmicdrift/kumiko-headless": "0.197.1",
18
+ "@cosmicdrift/kumiko-framework": "0.199.0",
19
+ "@cosmicdrift/kumiko-headless": "0.199.0",
20
20
  "react": "^19.2.6",
21
21
  "temporal-polyfill": "^0.3.2",
22
22
  "zod": "^4.4.3"
@@ -198,7 +198,7 @@ export function KumikoScreen({
198
198
  case "configEdit":
199
199
  return <ConfigEditBody schema={schema} screen={screen} translate={translate} />;
200
200
  case "custom":
201
- return <CustomScreenBody screenId={screen.id} />;
201
+ return <CustomScreenBody screenId={screen.id} featureName={schema.featureName} />;
202
202
  }
203
203
  }
204
204
 
@@ -228,16 +228,27 @@ function DashboardScreenBody({
228
228
  // Lookup-Body für custom-screens: schaut die Component aus dem
229
229
  // CustomScreens-Context (gefüttert von clientFeatures.components in
230
230
  // createKumikoApp). Wenn weder Provider gemounted noch screenId
231
- // registriert ist, fällt es auf einen Banner zurück Apps die das
232
- // sehen wissen sofort: "Component fehlt im clientFeatures.components".
233
- function CustomScreenBody({ screenId }: { readonly screenId: string }): ReactNode {
231
+ // registriert ist, fällt es auf einen error-Banner zurück statt leer zu
232
+ // rendern der Screen ist per URL erreichbar sobald das Server-Feature
233
+ // gemountet ist, unabhängig davon ob ein Client-Plugin existiert
234
+ // (kumiko-framework#2025).
235
+ function CustomScreenBody({
236
+ screenId,
237
+ featureName,
238
+ }: {
239
+ readonly screenId: string;
240
+ readonly featureName: string;
241
+ }): ReactNode {
234
242
  const { Banner, Text } = usePrimitives();
235
243
  const Component = useCustomScreenComponent(screenId);
236
244
  if (Component === undefined) {
237
245
  return (
238
- <Banner padded variant="info" testId="kumiko-screen-custom-placeholder">
239
- Custom screen <Text variant="code">{screenId}</Text> hat keine Component im{" "}
240
- <Text variant="code">clientFeatures.components</Text>.
246
+ <Banner padded variant="error" testId="kumiko-screen-custom-placeholder">
247
+ Custom screen <Text variant="code">{screenId}</Text> (Feature{" "}
248
+ <Text variant="code">{featureName}</Text>) hat keine Component in{" "}
249
+ <Text variant="code">clientFeatures.components</Text> — das Web-Client-Plugin des Features
250
+ (siehe <Text variant="code">{featureName}/web/client-plugin.tsx</Text>) fehlt in{" "}
251
+ <Text variant="code">clientFeatures</Text>.
241
252
  </Banner>
242
253
  );
243
254
  }
@@ -438,11 +449,20 @@ function EntityEditCreateBody({
438
449
  screen,
439
450
  entity,
440
451
  translate,
452
+ onSaved,
441
453
  }: {
442
454
  readonly schema: FeatureSchema;
443
455
  readonly screen: EntityEditScreenDefinition;
444
456
  readonly entity: EntityDefinition;
445
457
  readonly translate?: Translate;
458
+ // Singleton screens (kumiko-screen#1944) run without a wrapping
459
+ // entityList screen, so `navigateToList` below is a silent no-op there —
460
+ // without this callback, a successful create left the form stuck showing
461
+ // the just-submitted values, and a second submit created a duplicate
462
+ // record. Wired by EntityEditSingletonBody to re-run its list(limit:1)
463
+ // query, which flips the branch to EntityEditUpdateBody once it sees the
464
+ // new row.
465
+ readonly onSaved?: () => void;
446
466
  }): ReactNode {
447
467
  const nav = useNav();
448
468
  const initial = useMemo(
@@ -470,8 +490,9 @@ function EntityEditCreateBody({
470
490
  return;
471
491
  }
472
492
  navigateToList();
493
+ onSaved?.();
473
494
  },
474
- [nav, screen.redirect, navigateToList],
495
+ [nav, screen.redirect, navigateToList, onSaved],
475
496
  );
476
497
  return (
477
498
  <RenderEdit
@@ -500,6 +521,8 @@ function EntityEditUpdateBody({
500
521
  entityId,
501
522
  translate,
502
523
  onCopyLink,
524
+ autoReloadOnSave,
525
+ onDeleted,
503
526
  }: {
504
527
  readonly schema: FeatureSchema;
505
528
  readonly screen: EntityEditScreenDefinition;
@@ -507,6 +530,18 @@ function EntityEditUpdateBody({
507
530
  readonly entityId: string;
508
531
  readonly translate?: Translate;
509
532
  readonly onCopyLink?: () => Promise<void> | void;
533
+ // Singleton screens (kumiko-screen#1944): `navigateToList` below is a
534
+ // no-op there (no wrapping entityList screen), so a successful update
535
+ // left the form holding the now-stale `recordVersion` it captured at
536
+ // mount — the next submit hit a version conflict. Set by
537
+ // EntityEditSingletonBody to re-fetch this body's own detail query after
538
+ // a successful save, remounting the form (via the version-keyed `key`
539
+ // below) with the fresh version.
540
+ readonly autoReloadOnSave?: boolean;
541
+ // Wired by EntityEditSingletonBody to re-run its list(limit:1) query
542
+ // after a successful delete, so the branch flips back to the create form
543
+ // instead of the update form continuing to display the deleted record.
544
+ readonly onDeleted?: () => void;
510
545
  }): ReactNode {
511
546
  const { Banner, Text } = usePrimitives();
512
547
  const t = useTranslation();
@@ -554,6 +589,8 @@ function EntityEditUpdateBody({
554
589
  onReload={detailQuery.refetch}
555
590
  {...(translate !== undefined && { translate })}
556
591
  {...(onCopyLink !== undefined && { onCopyLink })}
592
+ {...(autoReloadOnSave === true && { onSaved: () => void detailQuery.refetch() })}
593
+ {...(onDeleted !== undefined && { onDeleted })}
557
594
  />
558
595
  );
559
596
  }
@@ -567,6 +604,8 @@ function EntityEditUpdateForm({
567
604
  onReload,
568
605
  translate,
569
606
  onCopyLink,
607
+ onSaved,
608
+ onDeleted,
570
609
  }: {
571
610
  readonly schema: FeatureSchema;
572
611
  readonly screen: EntityEditScreenDefinition;
@@ -576,6 +615,10 @@ function EntityEditUpdateForm({
576
615
  readonly onReload: () => Promise<void> | void;
577
616
  readonly translate?: Translate;
578
617
  readonly onCopyLink?: () => Promise<void> | void;
618
+ // See EntityEditUpdateBody's autoReloadOnSave/onDeleted docs — both are
619
+ // no-ops unless the caller (singleton screens) wires them.
620
+ readonly onSaved?: () => void;
621
+ readonly onDeleted?: () => void;
579
622
  }): ReactNode {
580
623
  // Seed the form with the server values for the entity's declared
581
624
  // fields; anything else (id, tenant_id, created_at…) stays out of
@@ -628,13 +671,17 @@ function EntityEditUpdateForm({
628
671
  return;
629
672
  }
630
673
  navigateToList();
674
+ onSaved?.();
631
675
  },
632
- [nav, screen.redirect, navigateToList],
676
+ [nav, screen.redirect, navigateToList, onSaved],
633
677
  );
634
678
  const handleDelete = useCallback(async () => {
635
679
  const res = await dispatcher.write(deleteCommand, { id: entityId });
636
- if (res.isSuccess) navigateToList();
637
- }, [dispatcher, deleteCommand, entityId, navigateToList]);
680
+ if (res.isSuccess) {
681
+ navigateToList();
682
+ onDeleted?.();
683
+ }
684
+ }, [dispatcher, deleteCommand, entityId, navigateToList, onDeleted]);
638
685
 
639
686
  return (
640
687
  <RenderEdit
@@ -715,6 +762,8 @@ function EntityEditSingletonBody({
715
762
  entityId={existingId}
716
763
  {...(translate !== undefined && { translate })}
717
764
  {...(onCopyLink !== undefined && { onCopyLink })}
765
+ autoReloadOnSave
766
+ onDeleted={() => void listQuery.refetch()}
718
767
  />
719
768
  );
720
769
  }
@@ -724,6 +773,7 @@ function EntityEditSingletonBody({
724
773
  screen={screen}
725
774
  entity={entity}
726
775
  {...(translate !== undefined && { translate })}
776
+ onSaved={() => void listQuery.refetch()}
727
777
  />
728
778
  );
729
779
  }
@@ -737,11 +787,13 @@ function EntityEditCreateOrDisabled({
737
787
  screen,
738
788
  entity,
739
789
  translate,
790
+ onSaved,
740
791
  }: {
741
792
  readonly schema: FeatureSchema;
742
793
  readonly screen: EntityEditScreenDefinition;
743
794
  readonly entity: EntityDefinition;
744
795
  readonly translate?: Translate;
796
+ readonly onSaved?: () => void;
745
797
  }): ReactNode {
746
798
  const { Banner, Text } = usePrimitives();
747
799
  if (screen.allowCreate === false) {
@@ -758,6 +810,7 @@ function EntityEditCreateOrDisabled({
758
810
  screen={screen}
759
811
  entity={entity}
760
812
  {...(translate !== undefined && { translate })}
813
+ {...(onSaved !== undefined && { onSaved })}
761
814
  />
762
815
  );
763
816
  }
@@ -1229,12 +1282,16 @@ function EntityListBody({
1229
1282
  ? (row: ListRowViewModel) => onRowClick(row, screen.entity)
1230
1283
  : undefined;
1231
1284
 
1232
- // Searchable-Default: explizite Author-Wahl gewinnt, sonst auto-on
1233
- // wenn die Entity searchable Felder hat (sonst wäre die Toolbar-Bar
1234
- // ein toter Slot Server-Search-Index hat eh nichts zum Filtern).
1285
+ // Searchable default: explicit author choice wins, otherwise auto-on when
1286
+ // the entity has searchable fields (dead toolbar slot otherwise — the
1287
+ // server search index has nothing to filter). Gated on
1288
+ // schema.searchAdapterMissing !== true regardless of source (explicit or
1289
+ // auto) — a search bar the server can't serve would 422 at request-time
1290
+ // (#2032); matches the boot-time check in api/server.ts (#2051).
1235
1291
  const searchable =
1236
- screen.searchable ??
1237
- Object.values(entity.fields).some((f) => "searchable" in f && f.searchable === true);
1292
+ schema.searchAdapterMissing !== true &&
1293
+ (screen.searchable ??
1294
+ Object.values(entity.fields).some((f) => "searchable" in f && f.searchable === true));
1238
1295
 
1239
1296
  // Pager-Props nur bei pagination="pages" zusammenstellen. Server-
1240
1297
  // total kommt async — bis es da ist, rendert RenderList die Tabelle
@@ -392,6 +392,55 @@ describe("EmbeddedListField — paste coercion", () => {
392
392
  ]);
393
393
  });
394
394
  });
395
+
396
+ // kumiko-framework#1838: a naive `replace(",", ".")` on a DE-locale paste
397
+ // with a thousands separator turned "1.234,56" into "1.234.56" → NaN →
398
+ // the cell silently cleared instead of keeping the pasted amount.
399
+ test("pastes a DE-locale money value with a thousands separator (1.234,56)", () => {
400
+ const rows = [{ product: "p1", unit: "pcs", quantity: 1, unitPrice: 100, amount: 100 }];
401
+ let lastValue: unknown;
402
+ renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
403
+ lastValue = v;
404
+ });
405
+ // Column index 3 = "unitPrice".
406
+ captured?.onPasteCells?.(0, 3, [["1.234,56"]]);
407
+ const result = lastValue as readonly Record<string, unknown>[];
408
+ expect(result[0]?.["unitPrice"]).toBe(123456);
409
+ });
410
+
411
+ test("pastes an en-US-locale money value with a thousands separator (1,234.56)", () => {
412
+ const rows = [{ product: "p1", unit: "pcs", quantity: 1, unitPrice: 100, amount: 100 }];
413
+ let lastValue: unknown;
414
+ renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
415
+ lastValue = v;
416
+ });
417
+ captured?.onPasteCells?.(0, 3, [["1,234.56"]]);
418
+ const result = lastValue as readonly Record<string, unknown>[];
419
+ expect(result[0]?.["unitPrice"]).toBe(123456);
420
+ });
421
+
422
+ test("an unparseable money paste leaves the cell unchanged and surfaces a listIssue instead of clearing it", async () => {
423
+ const rows = [{ product: "p1", unit: "pcs", quantity: 1, unitPrice: 100, amount: 100 }];
424
+ let lastValue: unknown;
425
+ renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
426
+ lastValue = v;
427
+ });
428
+ act(() => {
429
+ captured?.onPasteCells?.(0, 3, [["not-a-number"]]);
430
+ });
431
+ const result = lastValue as readonly Record<string, unknown>[];
432
+ expect(result[0]?.["unitPrice"]).toBe(100);
433
+ await waitFor(() => {
434
+ expect(captured?.listIssues).toEqual([
435
+ {
436
+ path: "lines",
437
+ code: "paste-cells-unmatched",
438
+ i18nKey: "kumiko.field.embedded-list.paste-cells-unmatched",
439
+ params: { count: 1 },
440
+ },
441
+ ]);
442
+ });
443
+ });
395
444
  });
396
445
 
397
446
  describe("EmbeddedListField — reference column populated via useQuery", () => {
@@ -55,6 +55,30 @@ function withRecomputedDerived(
55
55
  return result;
56
56
  }
57
57
 
58
+ // Locale-aware decimal parse for pasted text: strips space/NBSP thousands
59
+ // separators outright, then treats whichever of "," or "." appears LAST in
60
+ // the string as the decimal separator and strips the other one — handles
61
+ // both "1.234,56" (de) and "1,234.56" (en-US) pastes. Returns undefined for
62
+ // anything that still doesn't parse as a finite number.
63
+ function parseLocaleAwareDecimal(raw: string): number | undefined {
64
+ const stripped = raw.replace(/[\s ]/g, "");
65
+ if (stripped === "") return undefined;
66
+ const lastComma = stripped.lastIndexOf(",");
67
+ const lastDot = stripped.lastIndexOf(".");
68
+ const decimalIndex = Math.max(lastComma, lastDot);
69
+ if (decimalIndex === -1) {
70
+ const n = Number(stripped);
71
+ return Number.isFinite(n) ? n : undefined;
72
+ }
73
+ const integerPart = stripped.slice(0, decimalIndex).replace(/[.,]/g, "");
74
+ const fractionPart = stripped.slice(decimalIndex + 1);
75
+ if (!/^\d*$/.test(integerPart) || !/^\d*$/.test(fractionPart)) return undefined;
76
+ const n = Number(
77
+ `${integerPart === "" ? "0" : integerPart}.${fractionPart === "" ? "0" : fractionPart}`,
78
+ );
79
+ return Number.isFinite(n) ? n : undefined;
80
+ }
81
+
58
82
  function coerceCellValue(column: EmbeddedListColumn, text: string, currency: string): unknown {
59
83
  switch (column.type) {
60
84
  case "text":
@@ -73,9 +97,13 @@ function coerceCellValue(column: EmbeddedListColumn, text: string, currency: str
73
97
  // currencyDecimals) — a hardcoded ×100 here diverged for zero-/three-
74
98
  // decimal currencies (JPY, BHD, ...), landing a pasted value 100x off
75
99
  // from the same value typed by hand (kumiko-framework#1972).
100
+ // parseLocaleAwareDecimal handles both "1.234,56" (de) and
101
+ // "1,234.56" (en-US) thousands/decimal conventions — a naive
102
+ // `replace(",", ".")` mangled DE-locale pastes with thousands
103
+ // separators into NaN (kumiko-framework#1838).
76
104
  if (text.trim() === "") return undefined;
77
- const n = Number(text.replace(",", "."));
78
- return Number.isFinite(n) ? Math.round(n * 10 ** currencyDecimals(currency)) : undefined;
105
+ const n = parseLocaleAwareDecimal(text);
106
+ return n === undefined ? undefined : Math.round(n * 10 ** currencyDecimals(currency));
79
107
  }
80
108
  case "boolean":
81
109
  return ["true", "1", "yes", "y", "ja"].includes(text.trim().toLowerCase());
@@ -263,11 +291,14 @@ export function EmbeddedListField({
263
291
  const column = columns[columnIndex + gridColOffset];
264
292
  if (column === undefined) return;
265
293
  const coerced = coerceCellValue(column, text, field.embeddedListCurrency ?? "EUR");
266
- const isUnmatchedChoice =
267
- (column.type === "select" || column.type === "reference") &&
294
+ // Also covers an unparseable money paste (e.g. mangled thousands
295
+ // separators) the cell must stay unchanged instead of silently
296
+ // clearing to undefined (kumiko-framework#1838).
297
+ const isUnmatchedOrUnparseable =
298
+ (column.type === "select" || column.type === "reference" || column.type === "money") &&
268
299
  coerced === undefined &&
269
300
  text.trim() !== "";
270
- if (isUnmatchedChoice) {
301
+ if (isUnmatchedOrUnparseable) {
271
302
  unmatchedCells += 1;
272
303
  return;
273
304
  }
@@ -415,11 +415,15 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
415
415
  // freezes it on first render) — safe because a section's field-name set is
416
416
  // value-independent, so it matches filterEditSections(vm.sections, fieldsFilter).
417
417
  // undefined = unscoped, since scoping would silently drop root-level .refine() issues.
418
- const scopeFieldNames = useMemo(
419
- () =>
420
- fieldsFilter === undefined ? undefined : fieldsFilter.filter((f) => Object.hasOwn(fields, f)),
421
- [fieldsFilter, fields],
422
- );
418
+ // A `fieldsFilter` that matches nothing in `fields` (typo, renamed field)
419
+ // must fall back to unscoped too — an empty scope array would filter out
420
+ // ALL validation issues (kumiko-framework#1907), letting submit() through
421
+ // unvalidated on a form that also renders no sections.
422
+ const scopeFieldNames = useMemo(() => {
423
+ if (fieldsFilter === undefined) return undefined;
424
+ const matched = fieldsFilter.filter((f) => Object.hasOwn(fields, f));
425
+ return matched.length === 0 ? undefined : matched;
426
+ }, [fieldsFilter, fields]);
423
427
 
424
428
  // Submit-Config nur wenn der Caller einen writeCommand mitgibt; bei
425
429
  // customSubmit-Pfad kommt der Form-Controller ohne Submit-Wiring,
@@ -1097,7 +1101,13 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1097
1101
  </Button>,
1098
1102
  ]}
1099
1103
  >
1100
- <Text>{translate("kumiko.form.draft.resume-multiple")}</Text>
1104
+ <Text>
1105
+ {translate(
1106
+ draftCandidates.length === 1
1107
+ ? "kumiko.form.draft.resume-single"
1108
+ : "kumiko.form.draft.resume-multiple",
1109
+ )}
1110
+ </Text>
1101
1111
  </Banner>
1102
1112
  )}
1103
1113
  {isWizard && (
@@ -162,6 +162,9 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
162
162
  "kumiko.form.extension.save-failed": "Ein Zusatzfeld konnte nicht gespeichert werden.",
163
163
  "kumiko.form.draft.resume-multiple":
164
164
  "Mehrere offene Entwürfe für dieses Formular gefunden. Welchen möchtest du fortsetzen?",
165
+ "kumiko.form.draft.resume-single":
166
+ "Ein offener Entwurf für dieses Formular gefunden. Möchtest du ihn fortsetzen?",
167
+ "kumiko.form.draft.start-new": "Neu beginnen",
165
168
 
166
169
  // Validation — Default-Reason-Codes aus dem Framework. App-Code
167
170
  // kann eigene Codes via Validation-Hooks reinwerfen; die hier sind
@@ -343,6 +346,9 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
343
346
  "kumiko.form.extension.save-failed": "A custom field could not be saved.",
344
347
  "kumiko.form.draft.resume-multiple":
345
348
  "Found multiple open drafts for this form. Which one do you want to resume?",
349
+ "kumiko.form.draft.resume-single":
350
+ "Found an open draft for this form. Do you want to resume it?",
351
+ "kumiko.form.draft.start-new": "Start new",
346
352
 
347
353
  "kumiko.validation.required": "Required.",
348
354
  "kumiko.validation.invalid": "Invalid value.",
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  // eigenen Bootstrap schreiben will. Normale Samples gehen über
10
10
  // @cosmicdrift/kumiko-renderer-web/createKumikoApp, das alle Provider verdrahtet.
11
11
 
12
+ export { synthesizeActionFormEntity, synthesizeActionFormScreen } from "./app/action-form-shim";
12
13
  export type { AppFeaturesProviderProps } from "./app/app-features-context";
13
14
  export { AppFeaturesProvider, useAppFeatures } from "./app/app-features-context";
14
15
  export type {