@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 +3 -3
- package/src/app/__tests__/form-schema.test.ts +64 -2
- package/src/app/form-schema.ts +11 -3
- package/src/app/kumiko-screen.tsx +120 -15
- package/src/components/__tests__/render-field-unsupported-types.test.tsx +66 -2
- package/src/components/render-edit.tsx +68 -5
- package/src/components/render-field.tsx +50 -8
- package/src/primitives.tsx +24 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "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.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "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 →
|
|
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
|
-
|
|
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"]);
|
package/src/app/form-schema.ts
CHANGED
|
@@ -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
|
-
//
|
|
21
|
-
//
|
|
22
|
-
|
|
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.
|
|
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
|
-
<
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
<
|
|
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)
|
|
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)
|
|
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,
|
|
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
|
-
["
|
|
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:
|
|
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
|
-
//
|
|
452
|
-
|
|
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
|
-
|
|
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)
|
|
436
|
-
//
|
|
437
|
-
//
|
|
438
|
-
//
|
|
439
|
-
//
|
|
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 "
|
|
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
|
|
451
|
-
|
|
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
|
package/src/primitives.tsx
CHANGED
|
@@ -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
|
|
660
|
-
* onSubmit
|
|
661
|
-
*
|
|
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`:
|
|
665
|
-
* (
|
|
666
|
-
*
|
|
667
|
-
*
|
|
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:
|