@cosmicdrift/kumiko-renderer 0.188.0 → 0.189.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.188.0",
3
+ "version": "0.189.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.188.0",
19
- "@cosmicdrift/kumiko-headless": "0.188.0",
18
+ "@cosmicdrift/kumiko-framework": "0.189.0",
19
+ "@cosmicdrift/kumiko-headless": "0.189.0",
20
20
  "react": "^19.2.6",
21
21
  "temporal-polyfill": "^0.3.2",
22
22
  "zod": "^4.4.3"
@@ -4,6 +4,7 @@ import type {
4
4
  EntityDefinition,
5
5
  EntityEditScreenDefinition,
6
6
  } from "@cosmicdrift/kumiko-framework/ui-types";
7
+ import { createFormController } from "@cosmicdrift/kumiko-headless";
7
8
  import { buildFormSchema } from "../form-schema";
8
9
 
9
10
  function screenWith(fields: readonly EditFieldSpec[]): EntityEditScreenDefinition {
@@ -40,6 +41,41 @@ describe("buildFormSchema", () => {
40
41
  }
41
42
  });
42
43
 
44
+ // kumiko-framework#1927: a bare presence issue used to render as
45
+ // "Invalid value." — pin the params.i18nKey override so the resolved
46
+ // FieldIssue points at "kumiko.validation.required" ("Pflichtfeld.")
47
+ // instead of the generic errors.validation.custom fallback.
48
+ test("required field missing → issue carries the required-field i18nKey override", () => {
49
+ const entity = entityWith({ name: { type: "text", required: true } });
50
+ const screen = screenWith(["name"]);
51
+
52
+ const result = buildFormSchema(entity, screen).safeParse({ name: "" });
53
+ expect(result.success).toBe(false);
54
+ if (result.success) return;
55
+ const issue = result.error.issues[0];
56
+ if (issue?.code !== "custom") throw new Error("expected a custom issue");
57
+ expect(issue.params).toMatchObject({ i18nKey: "kumiko.validation.required" });
58
+ });
59
+
60
+ // kumiko-framework#1927: the seam the bug actually lived in — a schema
61
+ // built here only round-trips through createFormController's validate(),
62
+ // which is what feeds FormSnapshot.errors that render-edit.tsx passes to
63
+ // RenderField. A unit test on buildFormSchema() alone can't catch a break
64
+ // in that hand-off (e.g. zodErrorToFieldIssues not honoring the override).
65
+ test("end-to-end via createFormController: required field left empty → snapshot error carries kumiko.validation.required", () => {
66
+ const entity = entityWith({ name: { type: "text", required: true } });
67
+ const screen = screenWith(["name"]);
68
+
69
+ const form = createFormController({
70
+ initial: { name: "" },
71
+ schema: buildFormSchema(entity, screen),
72
+ });
73
+
74
+ expect(form.validate()).toBe(false);
75
+ const fieldErrors = form.getSnapshot().errors["name"];
76
+ expect(fieldErrors?.[0]?.i18nKey).toBe("kumiko.validation.required");
77
+ });
78
+
43
79
  describe("required field present → no issue", () => {
44
80
  const entity = entityWith({ name: { type: "number", required: true } });
45
81
  const screen = screenWith(["name"]);
@@ -72,12 +108,24 @@ describe("buildFormSchema", () => {
72
108
  expect(result.success).toBe(true);
73
109
  });
74
110
 
75
- test("required multiSelect → no issue (no editable widget on the auto-wired path)", () => {
111
+ test("required multiSelect, empty array → issue on that field (#1925: has a combobox widget now)", () => {
76
112
  const entity = entityWith({
77
113
  tags: { type: "multiSelect", required: true, options: ["a", "b"] },
78
114
  });
79
115
  const screen = screenWith(["tags"]);
80
- expect(buildFormSchema(entity, screen).safeParse({ tags: undefined }).success).toBe(true);
116
+ const result = buildFormSchema(entity, screen).safeParse({ tags: [] });
117
+ expect(result.success).toBe(false);
118
+ if (result.success) return;
119
+ expect(result.error.issues).toHaveLength(1);
120
+ expect(result.error.issues[0]?.path).toEqual(["tags"]);
121
+ });
122
+
123
+ test("required multiSelect, non-empty array → no issue", () => {
124
+ const entity = entityWith({
125
+ tags: { type: "multiSelect", required: true, options: ["a", "b"] },
126
+ });
127
+ const screen = screenWith(["tags"]);
128
+ expect(buildFormSchema(entity, screen).safeParse({ tags: ["a"] }).success).toBe(true);
81
129
  });
82
130
 
83
131
  test("jsonb field → no issue (no editable widget on the auto-wired path)", () => {
@@ -94,6 +142,20 @@ describe("buildFormSchema", () => {
94
142
  expect(buildFormSchema(entity, screen).safeParse({ lines: undefined }).success).toBe(true);
95
143
  });
96
144
 
145
+ test("required files field → no issue (#1925: no multi-upload widget yet, deliberately deferred)", () => {
146
+ const entity = entityWith({ attachments: { type: "files" } });
147
+ const screen = screenWith([{ field: "attachments", required: true }]);
148
+ expect(buildFormSchema(entity, screen).safeParse({ attachments: undefined }).success).toBe(
149
+ true,
150
+ );
151
+ });
152
+
153
+ test("required images field → no issue (#1925: no multi-upload widget yet, deliberately deferred)", () => {
154
+ const entity = entityWith({ gallery: { type: "images" } });
155
+ const screen = screenWith([{ field: "gallery", required: true }]);
156
+ expect(buildFormSchema(entity, screen).safeParse({ gallery: undefined }).success).toBe(true);
157
+ });
158
+
97
159
  test("required money — bare number (create-form representation) → no issue", () => {
98
160
  const entity = entityWith({ price: { type: "money", required: true } });
99
161
  const screen = screenWith(["price"]);
@@ -17,9 +17,15 @@ function isPresent(value: unknown): boolean {
17
17
  // Field types without a bound, editable widget on the auto-wired
18
18
  // entityEdit path (render-field.tsx renders a read-only banner instead) —
19
19
  // a presence error on one of them would be unresolvable by the user.
20
- // Tracked in #1925 (field types without an operable widget). `money` has an
21
- // operable widget (MoneyInput) and stays out of this set.
22
- const FIELD_TYPES_WITHOUT_WIDGET = new Set(["multiSelect", "jsonb", "embedded"]);
20
+ // #1925 gave multiSelect/decimal/bigInt/tz/longText real widgets, so they
21
+ // dropped out of this set; files/images stay out of scope (deferred, no
22
+ // multi-upload widget yet) alongside jsonb/embedded (structural types with
23
+ // no editor at all). A statically-`required: true` field of one of these
24
+ // types is caught loudly at boot — validateNoWidgetRequiredField in
25
+ // packages/framework/src/engine/boot-validator/screens.ts, which mirrors
26
+ // this set (framework can't import renderer, so it can't import this
27
+ // constant directly — keep both in sync).
28
+ const FIELD_TYPES_WITHOUT_WIDGET = new Set(["jsonb", "embedded", "files", "images"]);
23
29
 
24
30
  // Client-side presence validation for the auto-wired entityEdit path —
25
31
  // checks that every rendered required field HAS a value, not that the
@@ -76,6 +82,8 @@ export function buildFormSchema(
76
82
  code: "custom",
77
83
  path: [spec.field],
78
84
  message: `"${spec.field}" is required.`,
85
+ // `params.i18nKey` override, see packages/headless/src/form/zod-bridge.ts.
86
+ params: { i18nKey: "kumiko.validation.required" },
79
87
  });
80
88
  }
81
89
  });
@@ -406,19 +406,19 @@ function EntityEditScreen({
406
406
  />
407
407
  );
408
408
  }
409
- if (screen.allowCreate === false) {
410
- // Update-only Screen ohne entityId (Direkt-URL / verirrte Navigation):
411
- // ein Create-Form würde gegen den nicht registrierten
412
- // `<entity>:create`-Handler submitten — Fehler statt Falle.
409
+ if (screen.singleton === true) {
413
410
  return (
414
- <Banner padded variant="error" testId="kumiko-screen-create-disabled">
415
- Screen <Text variant="code">{screen.id}</Text> is update-only (allowCreate: false) — open it
416
- from a row action with an entity id.
417
- </Banner>
411
+ <EntityEditSingletonBody
412
+ schema={schema}
413
+ screen={screen}
414
+ entity={entity}
415
+ {...(translate !== undefined && { translate })}
416
+ {...(onCopyLink !== undefined && { onCopyLink })}
417
+ />
418
418
  );
419
419
  }
420
420
  return (
421
- <EntityEditCreateBody
421
+ <EntityEditCreateOrDisabled
422
422
  schema={schema}
423
423
  screen={screen}
424
424
  entity={entity}
@@ -454,9 +454,14 @@ function EntityEditCreateBody({
454
454
  const navigateToList = useNavigateToListAfter(schema, screen.entity);
455
455
  const handleSubmitted = useCallback(
456
456
  (result: SubmitResult<unknown>) => {
457
- if (result.isSuccess) navigateToList();
457
+ if (!result.isSuccess) return;
458
+ if (screen.redirect !== undefined) {
459
+ nav.navigate({ screenId: lastSegment(screen.redirect) });
460
+ return;
461
+ }
462
+ navigateToList();
458
463
  },
459
- [navigateToList],
464
+ [nav, screen.redirect, navigateToList],
460
465
  );
461
466
  return (
462
467
  <RenderEdit
@@ -602,13 +607,19 @@ function EntityEditUpdateForm({
602
607
  [entityId, recordVersion],
603
608
  );
604
609
 
610
+ const nav = useNav();
605
611
  const dispatcher = useDispatcher();
606
612
  const navigateToList = useNavigateToListAfter(schema, screen.entity);
607
613
  const handleSubmitted = useCallback(
608
614
  (result: SubmitResult<unknown>) => {
609
- if (result.isSuccess) navigateToList();
615
+ if (!result.isSuccess) return;
616
+ if (screen.redirect !== undefined) {
617
+ nav.navigate({ screenId: lastSegment(screen.redirect) });
618
+ return;
619
+ }
620
+ navigateToList();
610
621
  },
611
- [navigateToList],
622
+ [nav, screen.redirect, navigateToList],
612
623
  );
613
624
  const handleDelete = useCallback(async () => {
614
625
  const res = await dispatcher.write(deleteCommand, { id: entityId });
@@ -646,6 +657,100 @@ function EntityEditUpdateForm({
646
657
  );
647
658
  }
648
659
 
660
+ // Singleton entities (`singleton: true`, exactly one record per tenant):
661
+ // EntityEditScreen reaches here only without an entityId. Resolve the
662
+ // existing record via list(limit:1) before deciding create vs update —
663
+ // otherwise every visit without an id would create another record.
664
+ function EntityEditSingletonBody({
665
+ schema,
666
+ screen,
667
+ entity,
668
+ translate,
669
+ onCopyLink,
670
+ }: {
671
+ readonly schema: FeatureSchema;
672
+ readonly screen: EntityEditScreenDefinition;
673
+ readonly entity: EntityDefinition;
674
+ readonly translate?: Translate;
675
+ readonly onCopyLink?: () => Promise<void> | void;
676
+ }): ReactNode {
677
+ const { Banner } = usePrimitives();
678
+ const t = useTranslation();
679
+ const effectiveTranslate = translate ?? t;
680
+ const listQn = entityQueryCommand(schema.featureName, screen.entity, "list");
681
+ const listQuery = useQuery<PagedRows>(listQn, { limit: 1 });
682
+
683
+ if (listQuery.loading && listQuery.data === null) {
684
+ return (
685
+ <Banner padded variant="loading" testId="kumiko-screen-loading">
686
+ Loading…
687
+ </Banner>
688
+ );
689
+ }
690
+ if (listQuery.error) {
691
+ return (
692
+ <Banner padded variant="error" testId="kumiko-screen-error">
693
+ {dispatcherErrorText(listQuery.error, effectiveTranslate)}
694
+ </Banner>
695
+ );
696
+ }
697
+ const existingId = listQuery.data?.rows[0]?.["id"] as string | undefined;
698
+ if (existingId !== undefined) {
699
+ return (
700
+ <EntityEditUpdateBody
701
+ schema={schema}
702
+ screen={screen}
703
+ entity={entity}
704
+ entityId={existingId}
705
+ {...(translate !== undefined && { translate })}
706
+ {...(onCopyLink !== undefined && { onCopyLink })}
707
+ />
708
+ );
709
+ }
710
+ return (
711
+ <EntityEditCreateOrDisabled
712
+ schema={schema}
713
+ screen={screen}
714
+ entity={entity}
715
+ {...(translate !== undefined && { translate })}
716
+ />
717
+ );
718
+ }
719
+
720
+ // Shared no-entityId tail for both the plain create path and the
721
+ // singleton path's empty-table fallback: `allowCreate: false` blocks
722
+ // both the same way, since a create submit there would hit an
723
+ // unregistered `<entity>:create` handler.
724
+ function EntityEditCreateOrDisabled({
725
+ schema,
726
+ screen,
727
+ entity,
728
+ translate,
729
+ }: {
730
+ readonly schema: FeatureSchema;
731
+ readonly screen: EntityEditScreenDefinition;
732
+ readonly entity: EntityDefinition;
733
+ readonly translate?: Translate;
734
+ }): ReactNode {
735
+ const { Banner, Text } = usePrimitives();
736
+ if (screen.allowCreate === false) {
737
+ return (
738
+ <Banner padded variant="error" testId="kumiko-screen-create-disabled">
739
+ Screen <Text variant="code">{screen.id}</Text> is update-only (allowCreate: false) — open it
740
+ from a row action with an entity id.
741
+ </Banner>
742
+ );
743
+ }
744
+ return (
745
+ <EntityEditCreateBody
746
+ schema={schema}
747
+ screen={screen}
748
+ entity={entity}
749
+ {...(translate !== undefined && { translate })}
750
+ />
751
+ );
752
+ }
753
+
649
754
  // ---- entity-list ----
650
755
 
651
756
  function entityQueryCommand(featureName: string, entity: string, verb: "list"): string {
@@ -1466,7 +1571,7 @@ function ActionFormBody({
1466
1571
  // Author entscheidet bewusst ob "stay on form" (default) oder
1467
1572
  // "back to list" (typisch bei Create-style Aktionen).
1468
1573
  if (result.isSuccess && screen.redirect !== undefined) {
1469
- nav.navigate({ screenId: screen.redirect });
1574
+ nav.navigate({ screenId: lastSegment(screen.redirect) });
1470
1575
  }
1471
1576
  },
1472
1577
  [nav, screen.redirect],
@@ -1478,7 +1583,7 @@ function ActionFormBody({
1478
1583
  const handleCancel = useMemo<(() => void) | undefined>(() => {
1479
1584
  const target = screen.cancelTarget ?? screen.redirect;
1480
1585
  if (target === undefined || target === false) return undefined;
1481
- return () => nav.navigate({ screenId: target });
1586
+ return () => nav.navigate({ screenId: lastSegment(target) });
1482
1587
  }, [nav, screen.redirect, screen.cancelTarget]);
1483
1588
  return (
1484
1589
  <RenderEdit
@@ -1,8 +1,12 @@
1
1
  // Regression #1834: field types without a dedicated widget (embedded
2
- // without embeddedListCells, jsonb, multiSelect) must render read-only
2
+ // without embeddedListCells, jsonb, files, images) must render read-only
3
3
  // instead of falling back to a text input. The old default-branch
4
4
  // fallback ran the value through stringValue() — an object/array turned
5
5
  // into "[object Object]"/"a,b", and saving that overwrote the real data.
6
+ // #1925 gave multiSelect a real combobox widget (so it dropped out of this
7
+ // list) and moved files/images in (no multi-upload widget yet, deliberately
8
+ // deferred — they used to hit the same text-input corruption via the
9
+ // default branch).
6
10
  //
7
11
  // Capture-Input/Banner instead of real primitives, same pattern as
8
12
  // render-field-app-locale.test.tsx.
@@ -86,7 +90,8 @@ function renderField(field: EditFieldViewModel): void {
86
90
  describe.each([
87
91
  ["embedded (kein embeddedListCells)", baseField({ type: "embedded", value: { note: "x" } })],
88
92
  ["jsonb", baseField({ type: "jsonb", value: { a: 1 } })],
89
- ["multiSelect", baseField({ type: "multiSelect", value: ["a", "b"] })],
93
+ ["files", baseField({ type: "files", value: ["11111111-1111-1111-1111-111111111111"] })],
94
+ ["images", baseField({ type: "images", value: ["11111111-1111-1111-1111-111111111111"] })],
90
95
  ])("RenderField — %s ohne Widget", (_name, field) => {
91
96
  test("rendert einen schreibgeschützten Banner statt eines editierbaren Text-Inputs", () => {
92
97
  renderField(field);
@@ -108,3 +113,62 @@ describe("RenderField — text bleibt weiterhin editierbar", () => {
108
113
  expect(capturedInput?.kind).toBe("text");
109
114
  });
110
115
  });
116
+
117
+ // #1925: field types that gained a real widget on the auto-wired
118
+ // entityEdit path (previously either no widget at all or a type-mismatched
119
+ // text-input fallback).
120
+ describe("RenderField — #1925 neue Widgets", () => {
121
+ test("multiSelect rendert eine Multi-Combobox statt eines Banners", () => {
122
+ renderField(
123
+ baseField({
124
+ type: "multiSelect",
125
+ value: ["a"],
126
+ options: ["a", "b"],
127
+ }),
128
+ );
129
+ expect(capturedBanner).toBeUndefined();
130
+ expect(capturedInput).toBeDefined();
131
+ expect(capturedInput?.kind).toBe("combobox");
132
+ if (capturedInput?.kind !== "combobox") return;
133
+ expect(capturedInput.multiple).toBe(true);
134
+ expect(capturedInput.value).toEqual(["a"]);
135
+ expect(capturedInput.options).toEqual([
136
+ { value: "a", label: "a" },
137
+ { value: "b", label: "b" },
138
+ ]);
139
+ });
140
+
141
+ test("decimal rendert ein number-Input statt Text (kein String-Fallback mehr)", () => {
142
+ renderField(baseField({ type: "decimal", value: 12.5 }));
143
+ expect(capturedBanner).toBeUndefined();
144
+ expect(capturedInput).toBeDefined();
145
+ expect(capturedInput?.kind).toBe("number");
146
+ if (capturedInput?.kind !== "number") return;
147
+ expect(capturedInput.value).toBe(12.5);
148
+ });
149
+
150
+ test("bigInt rendert ein number-Input statt Text (kein String-Fallback mehr)", () => {
151
+ renderField(baseField({ type: "bigInt", value: 42 }));
152
+ expect(capturedBanner).toBeUndefined();
153
+ expect(capturedInput).toBeDefined();
154
+ expect(capturedInput?.kind).toBe("number");
155
+ if (capturedInput?.kind !== "number") return;
156
+ expect(capturedInput.value).toBe(42);
157
+ });
158
+
159
+ test("tz rendert ein dediziertes tz-Input statt Text", () => {
160
+ renderField(baseField({ type: "tz", value: "Europe/Berlin" }));
161
+ expect(capturedBanner).toBeUndefined();
162
+ expect(capturedInput).toBeDefined();
163
+ expect(capturedInput?.kind).toBe("tz");
164
+ if (capturedInput?.kind !== "tz") return;
165
+ expect(capturedInput.value).toBe("Europe/Berlin");
166
+ });
167
+
168
+ test("longText rendert immer ein textarea, auch ohne explizites multiline-Flag", () => {
169
+ renderField(baseField({ type: "longText", value: "lange Notiz" }));
170
+ expect(capturedBanner).toBeUndefined();
171
+ expect(capturedInput).toBeDefined();
172
+ expect(capturedInput?.kind).toBe("textarea");
173
+ });
174
+ });
@@ -48,6 +48,14 @@ const FORM_DRAFT_SAVE = "form-draft:write:save";
48
48
  const FORM_DRAFT_DISCARD = "form-draft:write:discard";
49
49
  const FORM_DRAFT_LIST = "form-draft:query:list";
50
50
 
51
+ // Trailing-edge debounce for patch()-triggered draft saves (#1914). A single
52
+ // patch() call (VIN-decode, an extension section) should not save immediately
53
+ // per call, but patch() can also fire from inside onChange on every keystroke
54
+ // (the #1888 derived-field shape) — 500ms collapses a typing burst into one
55
+ // save instead of one per keystroke, while still saving well before a user
56
+ // abandons the tab.
57
+ const PATCH_DRAFT_SAVE_DEBOUNCE_MS = 500;
58
+
51
59
  type FormDraftBlob = {
52
60
  readonly values: Record<string, unknown>;
53
61
  readonly stepIndex: number;
@@ -345,6 +353,11 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
345
353
  // Save-Button aktiv wird wenn NUR eine Section geändert wurde) + ihren
346
354
  // Submit-Handler (läuft nach dem Entity-Write). extensionErrorKey hält den
347
355
  // i18n-Key einer fehlgeschlagenen Section-Persistierung.
356
+ //
357
+ // Not a second write path for saveDraft()'s data (#1914): deriveFormFields
358
+ // (above) skips extension sections entirely, so extension-owned field state
359
+ // never enters `fields`/`controller.getSnapshot().values` — there is no
360
+ // draft-blob-covered state left for persistExtensions() to compete over.
348
361
  const [extensionDirty, setExtensionDirty] = useState(false);
349
362
  const [extensionErrorKey, setExtensionErrorKey] = useState<string | null>(null);
350
363
  const { registry: extensionFormRegistry, runAll: runExtensionSubmits } =
@@ -439,17 +452,54 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
439
452
  () => controller.validate(scopeFieldNamesRef.current),
440
453
  [controller],
441
454
  );
455
+
456
+ // `saveDraft` (defined below) is a fresh closure every render, over that
457
+ // render's `draftKey`/`draftId` — the ref lets the debounce timer below
458
+ // always call the CURRENT one even though the timer was scheduled by an
459
+ // earlier render's patch() call. `currentStepRef` gives it the current
460
+ // wizard step without depending on `currentStep` (computed further down,
461
+ // from `filteredSections`) at the point patchAndScheduleDraftSave itself
462
+ // is defined — kept fresh where `currentStep` is computed below.
463
+ const saveDraftRef = useRef(saveDraft);
464
+ saveDraftRef.current = saveDraft;
465
+ const currentStepRef = useRef(0);
466
+ const draftSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
467
+ useEffect(() => {
468
+ return () => {
469
+ if (draftSaveTimerRef.current !== null) clearTimeout(draftSaveTimerRef.current);
470
+ };
471
+ }, []);
472
+
473
+ // patch() (controlled mode, extension sections) applies immediately —
474
+ // only the resulting draft save is debounced (#1914), so a patch() burst
475
+ // (VIN-decode filling several fields, or onChange fanning a keystroke out
476
+ // through patch(), see the #1888 test above) collapses into one save.
477
+ const patchAndScheduleDraftSave = useCallback(
478
+ (partial: Partial<TValues>) => {
479
+ controller.setValues(partial);
480
+ if (!draftEnabled) return;
481
+ if (draftSaveTimerRef.current !== null) clearTimeout(draftSaveTimerRef.current);
482
+ draftSaveTimerRef.current = setTimeout(() => {
483
+ draftSaveTimerRef.current = null;
484
+ saveDraftRef.current(currentStepRef.current);
485
+ }, PATCH_DRAFT_SAVE_DEBOUNCE_MS);
486
+ },
487
+ [controller, draftEnabled],
488
+ );
489
+
442
490
  useEffect(() => {
443
491
  const cb = onControlsReadyRef.current;
444
492
  if (cb === undefined) return;
445
493
  cb({
446
- patch: controller.setValues,
494
+ patch: patchAndScheduleDraftSave,
447
495
  validate: scopedValidate,
448
496
  getValues: () => controller.getSnapshot().values,
449
497
  });
450
- // controller is mount-lifetime-stable (see useForm's comment on its
451
- // own useMemo) this fires exactly once per RenderEdit mount.
452
- }, [controller, scopedValidate]);
498
+ // controller is mount-lifetime-stable (see useForm's comment on its own
499
+ // useMemo), same for patchAndScheduleDraftSave/scopedValidate (both
500
+ // useCallback over mount-stable deps) — this fires exactly once per
501
+ // RenderEdit mount in practice.
502
+ }, [controller, scopedValidate, patchAndScheduleDraftSave]);
453
503
 
454
504
  useEffect(() => {
455
505
  // skip: this screen does not persist a draft.
@@ -579,6 +629,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
579
629
  // stored stepIndex can point past what is currently rendered — that lands on
580
630
  // the last step instead of an empty one.
581
631
  const currentStep = Math.min(rawStep, lastStepIndex);
632
+ // Kept fresh for patchAndScheduleDraftSave's debounce timer above, which
633
+ // is defined before `currentStep` exists (it depends on `filteredSections`,
634
+ // computed further up from `vm`) and so cannot close over it directly.
635
+ currentStepRef.current = currentStep;
582
636
  const isLastWizardStep = currentStep >= lastStepIndex;
583
637
 
584
638
  // Step transitions only — never per keystroke. Deliberately not awaited: a
@@ -638,6 +692,12 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
638
692
  async function discardDraft(): Promise<void> {
639
693
  // skip: this screen does not persist a draft.
640
694
  if (!draftEnabled) return;
695
+ // A pending debounced patch-save must not fire after discard — it would
696
+ // resurrect the draft it just deleted.
697
+ if (draftSaveTimerRef.current !== null) {
698
+ clearTimeout(draftSaveTimerRef.current);
699
+ draftSaveTimerRef.current = null;
700
+ }
641
701
  // skip: create-mode, no step change happened yet — no draftId was ever
642
702
  // minted, so no row exists to discard.
643
703
  if (draftKey === undefined) return;
@@ -836,6 +896,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
836
896
  {...(formSubtitle !== undefined && { subtitle: formSubtitle })}
837
897
  actions={formActions}
838
898
  testId="render-edit-form"
899
+ stickyActions={isWizard}
839
900
  {...(screen.layout.width !== undefined && { width: screen.layout.width })}
840
901
  >
841
902
  {draftCandidates !== null && (
@@ -887,7 +948,9 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
887
948
  // @cast-boundary form-values: ExtensionSectionProps is not generic
888
949
  // over TValues; controller is mount-lifetime-stable, see onControlsReady above.
889
950
  patch={
890
- controller.setValues as (partial: Readonly<Record<string, unknown>>) => void
951
+ patchAndScheduleDraftSave as (
952
+ partial: Readonly<Record<string, unknown>>,
953
+ ) => void
891
954
  }
892
955
  validate={scopedValidate}
893
956
  />
@@ -323,7 +323,13 @@ function renderInput({
323
323
  } as const;
324
324
 
325
325
  switch (field.type) {
326
+ // decimal/bigInt are both plain numbers on the wire (fieldToZod:
327
+ // z.number() / z.number().int().safe()) — the number input's onChange
328
+ // already emits `number | undefined`, so no extra coercion is needed
329
+ // beyond what "number" already does (#1925).
326
330
  case "number":
331
+ case "decimal":
332
+ case "bigInt":
327
333
  return (
328
334
  <Input
329
335
  kind="number"
@@ -333,6 +339,34 @@ function renderInput({
333
339
  {...(field.icon !== undefined && { icon: field.icon })}
334
340
  />
335
341
  );
342
+ case "tz":
343
+ return (
344
+ <Input
345
+ kind="tz"
346
+ {...common}
347
+ value={stringValue(field.value)}
348
+ onChange={(v) => onChange(v)}
349
+ />
350
+ );
351
+ case "multiSelect": {
352
+ const rawOptions = field.options ?? [];
353
+ const labels = field.optionLabels;
354
+ const multiSelectOptions =
355
+ labels !== undefined
356
+ ? rawOptions.map((value: string) => ({ value, label: labels[value] ?? value }))
357
+ : rawOptions.map((value: string) => ({ value, label: value }));
358
+ const arrayValue = Array.isArray(field.value) ? (field.value as readonly string[]) : [];
359
+ return (
360
+ <Input
361
+ kind="combobox"
362
+ {...common}
363
+ multiple
364
+ value={arrayValue}
365
+ onChange={(v) => onChange(v)}
366
+ options={multiSelectOptions}
367
+ />
368
+ );
369
+ }
336
370
  case "money": {
337
371
  const currency = field.currency ?? "EUR";
338
372
  return (
@@ -432,14 +466,19 @@ function renderInput({
432
466
  );
433
467
  }
434
468
  // embedded (without embeddedListCells — that's embeddedList, which has
435
- // had its own EmbeddedListField widget since #1838), jsonb and
436
- // multiSelect carry objects/arrays. Without a dedicated widget these
437
- // must NOT fall through to a text input: stringValue() turns them into
438
- // "[object Object]" / "a,b", and saving that overwrites the real data
439
- // with the mangled string (#1834).
469
+ // had its own EmbeddedListField widget since #1838) and jsonb carry
470
+ // arbitrary objects; files/images carry a FileRef-UUID array and have
471
+ // no multi-upload widget yet (deliberately deferred, #1925). Without a
472
+ // dedicated widget these must NOT fall through to a text input:
473
+ // stringValue() turns them into "[object Object]" / a comma-joined
474
+ // string, and saving that overwrites the real data with the mangled
475
+ // string (#1834). A `required: true` on any of these is caught loudly
476
+ // at boot (validateNoWidgetRequiredField in the framework package)
477
+ // instead of silently failing here.
440
478
  case "embedded":
441
479
  case "jsonb":
442
- case "multiSelect":
480
+ case "files":
481
+ case "images":
443
482
  return (
444
483
  <Banner id={id} variant="info">
445
484
  {t("kumiko.field.unsupported")}
@@ -447,8 +486,11 @@ function renderInput({
447
486
  );
448
487
  default: {
449
488
  // text + unknown scalar type → text input. If TextFieldDef.multiline
450
- // is set (the view-model carries it), the renderer switches to textarea.
451
- if (field.type === "text" && field.multiline) {
489
+ // is set (the view-model carries it), the renderer switches to
490
+ // textarea. longText always renders a textarea — that's the point of
491
+ // the type — regardless of whether `multiline` is set; `multiline`
492
+ // only supplies an optional `{ rows }` override for it (#1925).
493
+ if (field.type === "longText" || (field.type === "text" && field.multiline)) {
452
494
  const rows = typeof field.multiline === "object" ? field.multiline.rows : undefined;
453
495
  return (
454
496
  <Input
@@ -408,6 +408,17 @@ export type InputProps =
408
408
  readonly required?: boolean;
409
409
  readonly hasError?: boolean;
410
410
  }
411
+ | {
412
+ readonly kind: "tz";
413
+ readonly id: string;
414
+ readonly name: string;
415
+ /** IANA zone name, e.g. "Europe/Berlin". Empty-state = "". */
416
+ readonly value: string;
417
+ readonly onChange: (v: string | undefined) => void;
418
+ readonly disabled?: boolean;
419
+ readonly required?: boolean;
420
+ readonly hasError?: boolean;
421
+ }
411
422
  | {
412
423
  readonly kind: "textarea";
413
424
  readonly id: string;
@@ -656,18 +667,14 @@ export type EmbeddedListInputProps = {
656
667
 
657
668
  export type { FormWidth };
658
669
 
659
- /** Submit-Wrapper. Web: `<form onSubmit>`, Native: View das einen
660
- * onSubmit-Callback via Button-Press triggert. `onSubmit` bekommt
661
- * eine abstrakte Signatur (keine FormEvent) damit Native-Impls das
662
- * sinnvoll füllen können.
670
+ /** Submit wrapper. Web: `<form onSubmit>`, native: a View that triggers an
671
+ * onSubmit callback via button press. `onSubmit` gets an abstract
672
+ * signature (no FormEvent) so native impls can fill it meaningfully.
663
673
  *
664
- * `title`: linker Slot der sticky-top Bar typisch der Screen-Titel
665
- * ("Neuer Eintrag", "Bestellung bearbeiten"). Wenn gesetzt, rendert
666
- * die Bar mit `justify-between` (Title links, Actions rechts).
667
- * `actions`: optionaler Slot für die primären Form-Aktionen (Save,
668
- * Cancel). Web rendert die Bar sticky-top, damit der Save-Button
669
- * bei langen Forms beim Scrollen erreichbar bleibt. Native-Impls
670
- * dürfen denselben Slot z. B. als Bottom-Bar rendern. */
674
+ * `title`: slot in the card header. `actions`: optional slot for the
675
+ * primary form actions (Save, Cancel) renders as a footer row at the
676
+ * end of the card (normal document flow). Native impls may render the
677
+ * same slot as a bottom bar instead. */
671
678
  export type FormProps = {
672
679
  readonly onSubmit: (e?: FormEvent) => void;
673
680
  readonly children: ReactNode;
@@ -681,6 +688,12 @@ export type FormProps = {
681
688
  * (`packages/types/src/screen.ts`, EditLayout.width, #1676). Native
682
689
  * impls may ignore this prop (no width constraint there). */
683
690
  readonly width?: FormWidth;
691
+ /** Pins `actions` to the viewport bottom on narrow screens (`<640px`)
692
+ * instead of normal document flow, so it stays reachable when a virtual
693
+ * keyboard shrinks the visible viewport (fw#1918). Desktop/tablet
694
+ * unaffected. Native impls may ignore this prop (already bottom-bar by
695
+ * convention there). */
696
+ readonly stickyActions?: boolean;
684
697
  };
685
698
 
686
699
  /** Titled Gruppe von Feldern. Web: `<fieldset>` + `<legend>`, Native: