@cosmicdrift/kumiko-renderer 0.354.0 → 0.355.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.
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { EntityDefinition, EntityEditScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
|
+
import { type EditFormMode } from "@cosmicdrift/kumiko-headless";
|
|
2
3
|
import * as z from "zod";
|
|
3
4
|
export declare function isPresent(value: unknown): boolean;
|
|
4
5
|
export declare const REQUIRED_FIELD_I18N_KEY = "kumiko.validation.required";
|
|
5
6
|
export declare const OPTION_NOT_AVAILABLE_I18N_KEY = "kumiko.validation.optionNotAvailable";
|
|
6
|
-
export declare function buildFormSchema(entity: EntityDefinition, screen: EntityEditScreenDefinition, unchangedFrom?: Readonly<Record<string, unknown>>): z.ZodType;
|
|
7
|
+
export declare function buildFormSchema(entity: EntityDefinition, screen: EntityEditScreenDefinition, verb: EditFormMode, unchangedFrom?: Readonly<Record<string, unknown>>): z.ZodType;
|
package/dist/app/form-schema.js
CHANGED
|
@@ -17,6 +17,26 @@ function isEmbeddedListField(field) {
|
|
|
17
17
|
// Renders raw to the user without a de+en default in i18n-defaults.ts.
|
|
18
18
|
export const REQUIRED_FIELD_I18N_KEY = "kumiko.validation.required";
|
|
19
19
|
export const OPTION_NOT_AVAILABLE_I18N_KEY = "kumiko.validation.optionNotAvailable";
|
|
20
|
+
function isRequiredValueMissing(spec, field, record, unchangedFrom) {
|
|
21
|
+
// Screen-spec `required` overrides the entity default, mirroring
|
|
22
|
+
// `view-model/edit.ts` — the rendered form is the reference, and a
|
|
23
|
+
// presence check stricter than the form blocks the user for nothing.
|
|
24
|
+
const entityRequired = "required" in field && field.required === true;
|
|
25
|
+
const isRequired = spec.required === undefined ? entityRequired : evalFieldCondition(spec.required, record);
|
|
26
|
+
if (!isRequired)
|
|
27
|
+
return false;
|
|
28
|
+
// Embedded LIST fields get their own EmbeddedListField grid widget
|
|
29
|
+
// (#1838) — they're fillable, so NO_WIDGET_FIELD_TYPES's "embedded"
|
|
30
|
+
// entry must not exempt them. A statically-`required: true` field
|
|
31
|
+
// with no bound widget is also caught at boot
|
|
32
|
+
// (validateNoWidgetRequiredField in boot-validator/screens.ts).
|
|
33
|
+
if (NO_WIDGET_FIELD_TYPES.includes(field.type) && !isEmbeddedListField(field))
|
|
34
|
+
return false;
|
|
35
|
+
if (isPresent(record[spec.field]))
|
|
36
|
+
return false;
|
|
37
|
+
return !(unchangedFrom !== undefined &&
|
|
38
|
+
JSON.stringify(record[spec.field]) === JSON.stringify(unchangedFrom[spec.field]));
|
|
39
|
+
}
|
|
20
40
|
// Client-side presence validation for the auto-wired entityEdit path —
|
|
21
41
|
// checks that every rendered required field HAS a value, not that the
|
|
22
42
|
// value has the right shape. Format/range/type validation stays server-
|
|
@@ -41,12 +61,15 @@ export const OPTION_NOT_AVAILABLE_I18N_KEY = "kumiko.validation.optionNotAvailab
|
|
|
41
61
|
// fields via `computeFieldStates(options.fields, …)` (form-controller.ts:163,181),
|
|
42
62
|
// fed by `deriveFormFields(screen)` in render-edit.tsx.
|
|
43
63
|
//
|
|
64
|
+
// `verb` selects which `writeExcludedFields` list is skipped.
|
|
65
|
+
//
|
|
44
66
|
// `unchangedFrom` is for payloadMode "changes" (update): a field still equal to
|
|
45
67
|
// its loaded value never reaches the payload, so the server never presence-
|
|
46
68
|
// checks it — a legacy row with a now-required empty field must stay saveable
|
|
47
69
|
// when the user edits something else.
|
|
48
|
-
export function buildFormSchema(entity, screen, unchangedFrom) {
|
|
70
|
+
export function buildFormSchema(entity, screen, verb, unchangedFrom) {
|
|
49
71
|
const fields = layoutEditFields(screen);
|
|
72
|
+
const excludedFromWrite = new Set(screen.writeExcludedFields?.[verb] ?? []);
|
|
50
73
|
return z
|
|
51
74
|
.object({})
|
|
52
75
|
.passthrough()
|
|
@@ -60,31 +83,16 @@ export function buildFormSchema(entity, screen, unchangedFrom) {
|
|
|
60
83
|
const field = entity.fields[spec.field];
|
|
61
84
|
if (!field)
|
|
62
85
|
continue;
|
|
86
|
+
// Hidden on create, read-only on update: the user cannot resolve a presence error.
|
|
87
|
+
if (excludedFromWrite.has(spec.field))
|
|
88
|
+
continue;
|
|
63
89
|
// Not operable by the user — a presence error would be unresolvable,
|
|
64
90
|
// same reason as the NO_WIDGET_FIELD_TYPES check below.
|
|
65
91
|
if (spec.readOnly !== undefined && evalFieldCondition(spec.readOnly, record))
|
|
66
92
|
continue;
|
|
67
93
|
selectFieldsToCheck[spec.field] = field;
|
|
68
|
-
|
|
69
|
-
// `view-model/edit.ts` — the rendered form is the reference, and a
|
|
70
|
-
// presence check stricter than the form blocks the user for nothing.
|
|
71
|
-
const entityRequired = "required" in field && field.required === true;
|
|
72
|
-
const isRequired = spec.required === undefined ? entityRequired : evalFieldCondition(spec.required, record);
|
|
73
|
-
if (!isRequired)
|
|
74
|
-
continue;
|
|
75
|
-
// Embedded LIST fields get their own EmbeddedListField grid widget
|
|
76
|
-
// (#1838) — they're fillable, so NO_WIDGET_FIELD_TYPES's "embedded"
|
|
77
|
-
// entry must not exempt them. A statically-`required: true` field
|
|
78
|
-
// with no bound widget is also caught at boot
|
|
79
|
-
// (validateNoWidgetRequiredField in boot-validator/screens.ts).
|
|
80
|
-
if (NO_WIDGET_FIELD_TYPES.includes(field.type) && !isEmbeddedListField(field))
|
|
81
|
-
continue;
|
|
82
|
-
if (isPresent(record[spec.field]))
|
|
83
|
-
continue;
|
|
84
|
-
if (unchangedFrom !== undefined &&
|
|
85
|
-
JSON.stringify(record[spec.field]) === JSON.stringify(unchangedFrom[spec.field])) {
|
|
94
|
+
if (!isRequiredValueMissing(spec, field, record, unchangedFrom))
|
|
86
95
|
continue;
|
|
87
|
-
}
|
|
88
96
|
ctx.addIssue({
|
|
89
97
|
code: "custom",
|
|
90
98
|
path: [spec.field],
|
|
@@ -178,6 +178,13 @@ function entityWriteCommand(featureName, entity, verb) {
|
|
|
178
178
|
function findEntityListScreen(schema, entityName) {
|
|
179
179
|
return schema.screens.find((s) => s.type === "entityList" && s.entity === entityName);
|
|
180
180
|
}
|
|
181
|
+
// Unknown or deleted record id: a localized empty state with a way back
|
|
182
|
+
// instead of a bare error banner. Composed from shared primitives so web and
|
|
183
|
+
// native render it alike.
|
|
184
|
+
function RecordNotFoundState({ translate, onBack, }) {
|
|
185
|
+
const { Card, Text, Button } = usePrimitives();
|
|
186
|
+
return (_jsxs(Card, { options: { padded: true }, testId: "kumiko-screen-record-missing", children: [_jsx(Text, { testId: "kumiko-screen-record-missing-title", children: translate("kumiko.record.not-found.title") }), _jsx(Text, { variant: "muted", children: translate("kumiko.record.not-found.hint") }), onBack !== undefined && (_jsx(Button, { variant: "secondary", onClick: onBack, testId: "kumiko-screen-record-missing-back", children: translate("kumiko.record.not-found.back") }))] }));
|
|
187
|
+
}
|
|
181
188
|
function useNavigateToListAfter(schema, entityName) {
|
|
182
189
|
const nav = useNav();
|
|
183
190
|
return useCallback(() => {
|
|
@@ -658,7 +665,7 @@ function EntityEditCreateBody({ schema, screen, entity, translate, onSaved, }) {
|
|
|
658
665
|
moneyCurrencyOverrides,
|
|
659
666
|
handoffValues,
|
|
660
667
|
]);
|
|
661
|
-
const formSchema = useMemo(() => buildFormSchema(entity, screen), [entity, screen]);
|
|
668
|
+
const formSchema = useMemo(() => buildFormSchema(entity, screen, "create"), [entity, screen]);
|
|
662
669
|
const writeCommand = entityWriteCommand(schema.featureName, screen.entity, "create");
|
|
663
670
|
const navigateToList = useNavigateToListAfter(schema, screen.entity);
|
|
664
671
|
const handleCancel = useCallback(() => navigateToReturnOr(nav, returnTarget, navigateToList), [nav, returnTarget, navigateToList]);
|
|
@@ -714,11 +721,15 @@ function EntityEditCreateBody({ schema, screen, entity, translate, onSaved, }) {
|
|
|
714
721
|
// `<feature>:write:<entity>:update` on submit. `buildPayload` shapes the
|
|
715
722
|
// snapshot into Kumiko's update-event envelope `{ id, version, changes }`.
|
|
716
723
|
function EntityEditUpdateBody({ schema, screen, entity, entityId, translate, onCopyLink, autoReloadOnSave, onDeleted, }) {
|
|
717
|
-
const { Banner
|
|
724
|
+
const { Banner } = usePrimitives();
|
|
718
725
|
const t = useTranslation();
|
|
719
726
|
const effectiveTranslate = translate ?? t;
|
|
720
727
|
const detailQn = `${toKebab(schema.featureName)}:query:${toKebab(screen.entity)}:detail`;
|
|
721
728
|
const detailQuery = useQuery(detailQn, { id: entityId });
|
|
729
|
+
const nav = useNav();
|
|
730
|
+
const navigateToList = useNavigateToListAfter(schema, screen.entity);
|
|
731
|
+
const returnTargetParam = useReturnTarget(screen.id);
|
|
732
|
+
const returnTarget = screen.singleton === true ? undefined : returnTargetParam;
|
|
722
733
|
if (detailQuery.loading && detailQuery.data === null) {
|
|
723
734
|
return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
|
|
724
735
|
}
|
|
@@ -727,7 +738,10 @@ function EntityEditUpdateBody({ schema, screen, entity, entityId, translate, onC
|
|
|
727
738
|
}
|
|
728
739
|
const record = detailQuery.data;
|
|
729
740
|
if (!record) {
|
|
730
|
-
|
|
741
|
+
const onBack = returnTarget !== undefined || findEntityListScreen(schema, screen.entity) !== undefined
|
|
742
|
+
? () => navigateToReturnOr(nav, returnTarget, navigateToList)
|
|
743
|
+
: undefined;
|
|
744
|
+
return (_jsx(RecordNotFoundState, { translate: effectiveTranslate, ...(onBack !== undefined && { onBack }) }));
|
|
731
745
|
}
|
|
732
746
|
// Record-version als React-key: bei "Neu laden" refetched detail,
|
|
733
747
|
// liefert neue version, und das Form remountet komplett. Ohne den
|
|
@@ -768,7 +782,7 @@ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onRelo
|
|
|
768
782
|
}
|
|
769
783
|
return out;
|
|
770
784
|
}, [entity.fields, entityDefaultCurrency, moneyCurrencyOverrides, record]);
|
|
771
|
-
const formSchema = useMemo(() => buildFormSchema(entity, screen, initial), [entity, screen, initial]);
|
|
785
|
+
const formSchema = useMemo(() => buildFormSchema(entity, screen, "update", initial), [entity, screen, initial]);
|
|
772
786
|
// Extension-Werte (z.B. customFields-jsonb) an extension-sections geben,
|
|
773
787
|
// damit sie beim Edit den Bestand zeigen statt write-only zu sein.
|
|
774
788
|
const extensionInitialValues = useMemo(() => {
|
|
@@ -2164,7 +2178,13 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
|
|
|
2164
2178
|
}
|
|
2165
2179
|
const record = detailQuery.data;
|
|
2166
2180
|
if (!record) {
|
|
2167
|
-
|
|
2181
|
+
const backListScreen = screen.listScreenId ??
|
|
2182
|
+
(screen.detailFor !== undefined
|
|
2183
|
+
? findEntityListScreen(schema, screen.detailFor)?.id
|
|
2184
|
+
: undefined);
|
|
2185
|
+
return (_jsx(RecordNotFoundState, { translate: effectiveTranslate, ...(backListScreen !== undefined && {
|
|
2186
|
+
onBack: () => nav.navigate({ screenId: lastSegment(backListScreen) }),
|
|
2187
|
+
}) }));
|
|
2168
2188
|
}
|
|
2169
2189
|
// Grouped into the head Card alongside title/status/metrics (fw#2713):
|
|
2170
2190
|
// these are actions on the record the head shows, not on whichever tab is
|
|
@@ -329,9 +329,15 @@ export function RenderEdit(props) {
|
|
|
329
329
|
// Submit-Config nur wenn der Caller einen writeCommand mitgibt; bei
|
|
330
330
|
// customSubmit-Pfad kommt der Form-Controller ohne Submit-Wiring,
|
|
331
331
|
// weil wir controller.submit() eh nicht rufen.
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
332
|
+
const writeVerb = isCreateMode ? "create" : "update";
|
|
333
|
+
// A write-excluded field must never reach the payload: the handler schema is
|
|
334
|
+
// z.never() for it, so even an unchanged read-only value would be rejected.
|
|
335
|
+
const omitFields = useMemo(() => [
|
|
336
|
+
...layoutEditFields(screen)
|
|
337
|
+
.filter((spec) => spec.submit === false)
|
|
338
|
+
.map((spec) => spec.field),
|
|
339
|
+
...(screen.writeExcludedFields?.[writeVerb] ?? []),
|
|
340
|
+
], [screen, writeVerb]);
|
|
335
341
|
const submitConfig = writeCommand !== undefined
|
|
336
342
|
? {
|
|
337
343
|
type: writeCommand,
|
|
@@ -611,7 +617,8 @@ export function RenderEdit(props) {
|
|
|
611
617
|
values: snapshot.values,
|
|
612
618
|
translate,
|
|
613
619
|
featureName,
|
|
614
|
-
|
|
620
|
+
mode: writeVerb,
|
|
621
|
+
}), [screen, entity, snapshot.values, translate, featureName, writeVerb]);
|
|
615
622
|
const filteredSections = useMemo(
|
|
616
623
|
// A fully-hidden "fields" section (every field in it currently
|
|
617
624
|
// condition-hidden) must not occupy a wizard step; it would render
|
|
@@ -620,11 +627,12 @@ export function RenderEdit(props) {
|
|
|
620
627
|
// run their own query / own submit), so they always pass through. A
|
|
621
628
|
// section with no fields at all (e.g. a review-only step) has
|
|
622
629
|
// `visible: fields.some(...)` = false vacuously; that's "no fields to
|
|
623
|
-
// hide", not "hidden", so it stays too (fw#1901).
|
|
630
|
+
// hide", not "hidden", so it stays too (fw#1901). Unless the create
|
|
631
|
+
// exclusion emptied it: that would leave an empty wizard step / tab.
|
|
624
632
|
() => filterEditSections(vm.sections, fieldsFilter).filter((section) => section.kind === "extension" ||
|
|
625
633
|
section.kind === "relatedList" ||
|
|
626
634
|
section.kind === "writeForm" ||
|
|
627
|
-
section.fields.length === 0 ||
|
|
635
|
+
(section.fields.length === 0 && section.emptiedByWriteExclusion !== true) ||
|
|
628
636
|
section.visible), [vm.sections, fieldsFilter]);
|
|
629
637
|
const allFieldsRequired = useMemo(() => {
|
|
630
638
|
const fillable = filteredSections.flatMap((section) => section.kind === "fields"
|
package/dist/i18n-defaults.js
CHANGED
|
@@ -60,6 +60,9 @@ export const kumikoDefaultTranslations = {
|
|
|
60
60
|
"kumiko.list.empty.title": "No entries yet.",
|
|
61
61
|
"kumiko.list.empty.hint": "Create the first one to get started.",
|
|
62
62
|
"kumiko.list.no-entries": "No entries.",
|
|
63
|
+
"kumiko.record.not-found.title": "This record was not found.",
|
|
64
|
+
"kumiko.record.not-found.hint": "It may have been deleted, or the link is no longer valid.",
|
|
65
|
+
"kumiko.record.not-found.back": "Back to list",
|
|
63
66
|
"kumiko.list.end-of-list": "— End of list —",
|
|
64
67
|
"kumiko.list.sort.label": "Sort",
|
|
65
68
|
"kumiko.list.sort.unsorted": "Unsorted",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.355.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,9 +15,9 @@
|
|
|
15
15
|
}
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
20
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
18
|
+
"@cosmicdrift/kumiko-framework": "0.355.0",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.355.0",
|
|
20
|
+
"@cosmicdrift/kumiko-types": "0.355.0",
|
|
21
21
|
"react": "^19.2.6",
|
|
22
22
|
"temporal-polyfill": "^0.3.2",
|
|
23
23
|
"zod": "^4.4.3"
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"@types/react-dom": "^19.2.3",
|
|
29
29
|
"jsdom": "^29.1.1",
|
|
30
30
|
"react-dom": "^19.2.6",
|
|
31
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
31
|
+
"@cosmicdrift/kumiko-locale-de": "0.355.0"
|
|
32
32
|
},
|
|
33
33
|
"repository": {
|
|
34
34
|
"type": "git",
|