@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;
@@ -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
- // Screen-spec `required` overrides the entity default, mirroring
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, Text } = usePrimitives();
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
- return (_jsxs(Banner, { padded: true, variant: "error", testId: "kumiko-screen-record-missing", children: ["Record ", _jsx(Text, { variant: "code", children: entityId }), " not found."] }));
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
- return (_jsx(Banner, { padded: true, variant: "error", testId: "kumiko-screen-record-missing", children: screen.singleton === true ? ("Record not found.") : (_jsxs(_Fragment, { children: ["Record ", _jsx(Text, { variant: "code", children: entityId }), " not found."] })) }));
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 omitFields = useMemo(() => layoutEditFields(screen)
333
- .filter((spec) => spec.submit === false)
334
- .map((spec) => spec.field), [screen]);
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
- }), [screen, entity, snapshot.values, translate, featureName]);
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"
@@ -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.354.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.354.0",
19
- "@cosmicdrift/kumiko-headless": "0.354.0",
20
- "@cosmicdrift/kumiko-types": "0.354.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.354.0"
31
+ "@cosmicdrift/kumiko-locale-de": "0.355.0"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",