@cosmicdrift/kumiko-renderer 0.354.1 → 0.356.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/dist/app/form-schema.d.ts +2 -1
- package/dist/app/form-schema.js +28 -20
- package/dist/app/kumiko-screen.js +33 -48
- package/dist/components/render-edit-types.d.ts +5 -0
- package/dist/components/render-edit.js +22 -9
- package/dist/components/use-action-menu-items.d.ts +10 -0
- package/dist/components/use-action-menu-items.js +47 -0
- package/dist/i18n-defaults.js +3 -0
- package/package.json +5 -5
- package/src/changes.json +5 -0
|
@@ -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],
|
|
@@ -8,8 +8,9 @@ import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "rea
|
|
|
8
8
|
import { QueryOptionLabel, resolveOptionsQueryPayload, } from "../components/query-options-select.js";
|
|
9
9
|
import { extractCreatedId, extractIdField } from "../components/reference-create-dialog.js";
|
|
10
10
|
import { RenderEdit, resolveRecordTitle, } from "../components/render-edit.js";
|
|
11
|
-
import { needsActionConfirm, RenderEditActionButton,
|
|
11
|
+
import { needsActionConfirm, RenderEditActionButton, } from "../components/render-edit-action-button.js";
|
|
12
12
|
import { RenderList } from "../components/render-list.js";
|
|
13
|
+
import { useActionMenuItems } from "../components/use-action-menu-items.js";
|
|
13
14
|
import { useDispatcher, useOptionalDispatcher } from "../context/dispatcher-context.js";
|
|
14
15
|
import { useIsEmbeddedScreen } from "../context/embedded-screen-context.js";
|
|
15
16
|
import { useUserRoles } from "../context/user-roles-context.js";
|
|
@@ -178,6 +179,13 @@ function entityWriteCommand(featureName, entity, verb) {
|
|
|
178
179
|
function findEntityListScreen(schema, entityName) {
|
|
179
180
|
return schema.screens.find((s) => s.type === "entityList" && s.entity === entityName);
|
|
180
181
|
}
|
|
182
|
+
// Unknown or deleted record id: a localized empty state with a way back
|
|
183
|
+
// instead of a bare error banner. Composed from shared primitives so web and
|
|
184
|
+
// native render it alike.
|
|
185
|
+
function RecordNotFoundState({ translate, onBack, }) {
|
|
186
|
+
const { Card, Text, Button } = usePrimitives();
|
|
187
|
+
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") }))] }));
|
|
188
|
+
}
|
|
181
189
|
function useNavigateToListAfter(schema, entityName) {
|
|
182
190
|
const nav = useNav();
|
|
183
191
|
return useCallback(() => {
|
|
@@ -658,7 +666,7 @@ function EntityEditCreateBody({ schema, screen, entity, translate, onSaved, }) {
|
|
|
658
666
|
moneyCurrencyOverrides,
|
|
659
667
|
handoffValues,
|
|
660
668
|
]);
|
|
661
|
-
const formSchema = useMemo(() => buildFormSchema(entity, screen), [entity, screen]);
|
|
669
|
+
const formSchema = useMemo(() => buildFormSchema(entity, screen, "create"), [entity, screen]);
|
|
662
670
|
const writeCommand = entityWriteCommand(schema.featureName, screen.entity, "create");
|
|
663
671
|
const navigateToList = useNavigateToListAfter(schema, screen.entity);
|
|
664
672
|
const handleCancel = useCallback(() => navigateToReturnOr(nav, returnTarget, navigateToList), [nav, returnTarget, navigateToList]);
|
|
@@ -714,11 +722,15 @@ function EntityEditCreateBody({ schema, screen, entity, translate, onSaved, }) {
|
|
|
714
722
|
// `<feature>:write:<entity>:update` on submit. `buildPayload` shapes the
|
|
715
723
|
// snapshot into Kumiko's update-event envelope `{ id, version, changes }`.
|
|
716
724
|
function EntityEditUpdateBody({ schema, screen, entity, entityId, translate, onCopyLink, autoReloadOnSave, onDeleted, }) {
|
|
717
|
-
const { Banner
|
|
725
|
+
const { Banner } = usePrimitives();
|
|
718
726
|
const t = useTranslation();
|
|
719
727
|
const effectiveTranslate = translate ?? t;
|
|
720
728
|
const detailQn = `${toKebab(schema.featureName)}:query:${toKebab(screen.entity)}:detail`;
|
|
721
729
|
const detailQuery = useQuery(detailQn, { id: entityId });
|
|
730
|
+
const nav = useNav();
|
|
731
|
+
const navigateToList = useNavigateToListAfter(schema, screen.entity);
|
|
732
|
+
const returnTargetParam = useReturnTarget(screen.id);
|
|
733
|
+
const returnTarget = screen.singleton === true ? undefined : returnTargetParam;
|
|
722
734
|
if (detailQuery.loading && detailQuery.data === null) {
|
|
723
735
|
return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
|
|
724
736
|
}
|
|
@@ -727,7 +739,10 @@ function EntityEditUpdateBody({ schema, screen, entity, entityId, translate, onC
|
|
|
727
739
|
}
|
|
728
740
|
const record = detailQuery.data;
|
|
729
741
|
if (!record) {
|
|
730
|
-
|
|
742
|
+
const onBack = returnTarget !== undefined || findEntityListScreen(schema, screen.entity) !== undefined
|
|
743
|
+
? () => navigateToReturnOr(nav, returnTarget, navigateToList)
|
|
744
|
+
: undefined;
|
|
745
|
+
return (_jsx(RecordNotFoundState, { translate: effectiveTranslate, ...(onBack !== undefined && { onBack }) }));
|
|
731
746
|
}
|
|
732
747
|
// Record-version als React-key: bei "Neu laden" refetched detail,
|
|
733
748
|
// liefert neue version, und das Form remountet komplett. Ohne den
|
|
@@ -768,7 +783,7 @@ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onRelo
|
|
|
768
783
|
}
|
|
769
784
|
return out;
|
|
770
785
|
}, [entity.fields, entityDefaultCurrency, moneyCurrencyOverrides, record]);
|
|
771
|
-
const formSchema = useMemo(() => buildFormSchema(entity, screen, initial), [entity, screen, initial]);
|
|
786
|
+
const formSchema = useMemo(() => buildFormSchema(entity, screen, "update", initial), [entity, screen, initial]);
|
|
772
787
|
// Extension-Werte (z.B. customFields-jsonb) an extension-sections geben,
|
|
773
788
|
// damit sie beim Edit den Bestand zeigen statt write-only zu sein.
|
|
774
789
|
const extensionInitialValues = useMemo(() => {
|
|
@@ -863,7 +878,8 @@ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onRelo
|
|
|
863
878
|
sameEntityScreenId,
|
|
864
879
|
entityId,
|
|
865
880
|
]);
|
|
866
|
-
const headerActions = useMemo(() => buildSectionActions(screen.actions ?? []), [buildSectionActions, screen.actions]);
|
|
881
|
+
const headerActions = useMemo(() => buildSectionActions((screen.actions ?? []).filter((a) => a.placement !== "menu")), [buildSectionActions, screen.actions]);
|
|
882
|
+
const headerMenuActions = useMemo(() => buildSectionActions((screen.actions ?? []).filter((a) => a.placement === "menu")), [buildSectionActions, screen.actions]);
|
|
867
883
|
const handleSubmitted = useCallback((result) => {
|
|
868
884
|
if (!result.isSuccess)
|
|
869
885
|
return;
|
|
@@ -912,7 +928,7 @@ function EntityEditUpdateForm({ schema, screen, entity, entityId, record, onRelo
|
|
|
912
928
|
entityId: entityId,
|
|
913
929
|
// Stored customFields for the extension section, so editing shows the
|
|
914
930
|
// persisted values instead of behaving write-only.
|
|
915
|
-
extensionInitialValues: extensionInitialValues, schema: formSchema, writeCommand: writeCommand, payloadMode: "changes", buildPayload: buildPayload, serverFieldPathPrefix: `${UPDATE_CHANGES_KEY}.`, onSubmit: handleSubmitted, ...(screen.allowDelete !== false && { onDelete: handleDelete }), onCancel: handleCancel, onReload: () => void onReload(), ...(screenFillsHeight(screen) && { fillScreenHeight: true }), ...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel }), ...(translate !== undefined && { translate }), ...(onCopyLink !== undefined && { onCopyLink }), ...(headerActions !== undefined && { actions: headerActions }), buildSectionActions: buildSectionActions }), _jsx(DrawerHost, { schema: schema, drawerAction: drawerAction, drawerScreen: drawerScreen, ...(drawerInitialValues !== undefined && { drawerInitialValues }), userRoles: userRoles, ...(translate !== undefined && { translate }), onClose: closeDrawer, onSuccess: () => {
|
|
931
|
+
extensionInitialValues: extensionInitialValues, schema: formSchema, writeCommand: writeCommand, payloadMode: "changes", buildPayload: buildPayload, serverFieldPathPrefix: `${UPDATE_CHANGES_KEY}.`, onSubmit: handleSubmitted, ...(screen.allowDelete !== false && { onDelete: handleDelete }), onCancel: handleCancel, onReload: () => void onReload(), ...(screenFillsHeight(screen) && { fillScreenHeight: true }), ...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel }), ...(translate !== undefined && { translate }), ...(onCopyLink !== undefined && { onCopyLink }), ...(headerActions !== undefined && { actions: headerActions }), ...(headerMenuActions !== undefined && { menuActions: headerMenuActions }), buildSectionActions: buildSectionActions }), _jsx(DrawerHost, { schema: schema, drawerAction: drawerAction, drawerScreen: drawerScreen, ...(drawerInitialValues !== undefined && { drawerInitialValues }), userRoles: userRoles, ...(translate !== undefined && { translate }), onClose: closeDrawer, onSuccess: () => {
|
|
916
932
|
closeDrawer();
|
|
917
933
|
void onReload();
|
|
918
934
|
} })] }));
|
|
@@ -1880,47 +1896,10 @@ function primaryHeaderAction(actions) {
|
|
|
1880
1896
|
// no button of its own to carry one.
|
|
1881
1897
|
function HeaderActionsBar({ actions, Button, Dialog, ActionOverflowMenu, onError, collapseAfterPrimary = false, compact = false, PageHeader, }) {
|
|
1882
1898
|
const t = useTranslation();
|
|
1883
|
-
const
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
// same writeHandler twice.
|
|
1887
|
-
const [busyActionId, setBusyActionId] = useState(null);
|
|
1888
|
-
const trigger = async (action) => {
|
|
1889
|
-
onError(null);
|
|
1890
|
-
setBusyActionId(action.id);
|
|
1891
|
-
try {
|
|
1892
|
-
await action.onPress();
|
|
1893
|
-
}
|
|
1894
|
-
catch (e) {
|
|
1895
|
-
onError(e instanceof Error ? e.message : String(e));
|
|
1896
|
-
}
|
|
1897
|
-
finally {
|
|
1898
|
-
setBusyActionId(null);
|
|
1899
|
-
}
|
|
1900
|
-
};
|
|
1901
|
-
const toMenuItem = (action) => ({
|
|
1902
|
-
id: action.id,
|
|
1903
|
-
label: action.label,
|
|
1904
|
-
...(action.icon !== undefined && { icon: action.icon }),
|
|
1905
|
-
variant: action.style === "danger" ? "danger" : "default",
|
|
1906
|
-
...(busyActionId !== null && { disabled: true }),
|
|
1907
|
-
onSelect: () => {
|
|
1908
|
-
if (needsActionConfirm(action)) {
|
|
1909
|
-
setPendingAction(action);
|
|
1910
|
-
}
|
|
1911
|
-
else {
|
|
1912
|
-
void trigger(action);
|
|
1913
|
-
}
|
|
1914
|
-
},
|
|
1899
|
+
const { toMenuItem, confirmDialog: pendingActionDialog } = useActionMenuItems({
|
|
1900
|
+
Dialog,
|
|
1901
|
+
onError,
|
|
1915
1902
|
});
|
|
1916
|
-
const pendingActionDialog = pendingAction !== null && (_jsx(RenderEditActionConfirmDialog, { action: pendingAction, open: true, onOpenChange: (open) => {
|
|
1917
|
-
if (!open)
|
|
1918
|
-
setPendingAction(null);
|
|
1919
|
-
}, onConfirm: async () => {
|
|
1920
|
-
const action = pendingAction;
|
|
1921
|
-
setPendingAction(null);
|
|
1922
|
-
await trigger(action);
|
|
1923
|
-
}, Dialog: Dialog }));
|
|
1924
1903
|
if (compact && PageHeader !== undefined) {
|
|
1925
1904
|
const primary = primaryHeaderAction(actions);
|
|
1926
1905
|
// A bare icon for a destructive or confirm-gated action (e.g. "x" for terminate)
|
|
@@ -2164,7 +2143,13 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
|
|
|
2164
2143
|
}
|
|
2165
2144
|
const record = detailQuery.data;
|
|
2166
2145
|
if (!record) {
|
|
2167
|
-
|
|
2146
|
+
const backListScreen = screen.listScreenId ??
|
|
2147
|
+
(screen.detailFor !== undefined
|
|
2148
|
+
? findEntityListScreen(schema, screen.detailFor)?.id
|
|
2149
|
+
: undefined);
|
|
2150
|
+
return (_jsx(RecordNotFoundState, { translate: effectiveTranslate, ...(backListScreen !== undefined && {
|
|
2151
|
+
onBack: () => nav.navigate({ screenId: lastSegment(backListScreen) }),
|
|
2152
|
+
}) }));
|
|
2168
2153
|
}
|
|
2169
2154
|
// Grouped into the head Card alongside title/status/metrics (fw#2713):
|
|
2170
2155
|
// these are actions on the record the head shows, not on whichever tab is
|
|
@@ -64,6 +64,11 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
|
|
|
64
64
|
* same split as `onCopyLink`) — RenderEdit only wires the button, its
|
|
65
65
|
* busy state and its confirm dialog. */
|
|
66
66
|
readonly actions?: readonly RenderEditAction[];
|
|
67
|
+
/** Record actions declared with `placement: "menu"`, resolved like `actions`.
|
|
68
|
+
* Rendered in the header "…" menu ahead of copy-link/delete; without a
|
|
69
|
+
* header menu (drawer, card form, shell without a header slot) they fall
|
|
70
|
+
* back to buttons next to `actions`. */
|
|
71
|
+
readonly menuActions?: readonly RenderEditAction[];
|
|
67
72
|
/** Opens a relatedList section row's drawer-kind action (fw#2710).
|
|
68
73
|
* RenderEdit has no `schema` to resolve the target actionForm itself —
|
|
69
74
|
* the caller (ProjectionDetailBody, which does have schema) supplies the
|
|
@@ -3,6 +3,7 @@ import { evalFieldCondition, isFieldsEditSection, normalizeEditField, PROJECTION
|
|
|
3
3
|
import { computeEditViewModel, groupIssuesByPath, relevantFieldIssues, zodErrorToFieldIssues, } from "@cosmicdrift/kumiko-headless";
|
|
4
4
|
import { RenderEditActionButton } from "./render-edit-action-button.js";
|
|
5
5
|
import { AllFieldsRequiredProvider } from "./render-field.js";
|
|
6
|
+
import { useActionMenuItems } from "./use-action-menu-items.js";
|
|
6
7
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
7
8
|
import { ExtensionFormRegistryProvider, useExtensionFormHost, } from "../app/extension-form-submit.js";
|
|
8
9
|
import { extensionSectionName, useExtensionSectionComponent } from "../app/extension-sections.js";
|
|
@@ -223,7 +224,7 @@ function resolveTitleTemplate(template, values, translate, fallback) {
|
|
|
223
224
|
return translate(template, values);
|
|
224
225
|
}
|
|
225
226
|
export function RenderEdit(props) {
|
|
226
|
-
const { screen, entity, featureName, initial, writeCommand, translate: translateProp, ctx, schema, onSubmit, payloadMode = "values", buildPayload, serverFieldPathPrefix, onDelete, onCancel, onReload, onDirtyChange, onCopyLink, actions, onRelatedListDrawerAction, submitLabel, submitVariant, submitWithoutChanges, footerActions, summary, labelAppendix, fieldAppendix, fieldAppendixPlacement, fieldDescription, fieldAccent, markChangedFields = false, entityId: entityIdProp, extensionInitialValues, onChange, onControlsReady, fields: fieldsFilter, disabled = false, hideActions, valueDisplay = "form", hideSectionTitles, fillScreenHeight, dirtyFooter, validateOnChange, headerRegion, buildSectionActions, leadContent, } = props;
|
|
227
|
+
const { screen, entity, featureName, initial, writeCommand, translate: translateProp, ctx, schema, onSubmit, payloadMode = "values", buildPayload, serverFieldPathPrefix, onDelete, onCancel, onReload, onDirtyChange, onCopyLink, actions, menuActions, onRelatedListDrawerAction, submitLabel, submitVariant, submitWithoutChanges, footerActions, summary, labelAppendix, fieldAppendix, fieldAppendixPlacement, fieldDescription, fieldAccent, markChangedFields = false, entityId: entityIdProp, extensionInitialValues, onChange, onControlsReady, fields: fieldsFilter, disabled = false, hideActions, valueDisplay = "form", hideSectionTitles, fillScreenHeight, dirtyFooter, validateOnChange, headerRegion, buildSectionActions, leadContent, } = props;
|
|
227
228
|
const i18nScreenId = props.i18nScreenId ?? screen.id;
|
|
228
229
|
const { customSubmit } = props;
|
|
229
230
|
// Translate-Fallback: wenn der Caller keine Translate-Fn übergibt,
|
|
@@ -300,6 +301,7 @@ export function RenderEdit(props) {
|
|
|
300
301
|
setHasExtensionRegistrations(hasRegistrations);
|
|
301
302
|
});
|
|
302
303
|
const { Button, Banner, Card, Dialog, Form, Section, Grid, GridCell, Text, Progress, StepBar, WizardStepGroup, Tabs, PageHeader, ActionOverflowMenu, } = usePrimitives();
|
|
304
|
+
const { toMenuItem: menuActionToItem, confirmDialog: menuActionConfirmDialog } = useActionMenuItems({ Dialog, onError: setActionError });
|
|
303
305
|
const pageHeaderSlotAvailable = usePageHeaderSlotAvailable();
|
|
304
306
|
const pageHeaderCompact = usePageHeaderCompact();
|
|
305
307
|
const insideDrawer = useInsideDrawer();
|
|
@@ -329,9 +331,15 @@ export function RenderEdit(props) {
|
|
|
329
331
|
// Submit-Config nur wenn der Caller einen writeCommand mitgibt; bei
|
|
330
332
|
// customSubmit-Pfad kommt der Form-Controller ohne Submit-Wiring,
|
|
331
333
|
// weil wir controller.submit() eh nicht rufen.
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
334
|
+
const writeVerb = isCreateMode ? "create" : "update";
|
|
335
|
+
// A write-excluded field must never reach the payload: the handler schema is
|
|
336
|
+
// z.never() for it, so even an unchanged read-only value would be rejected.
|
|
337
|
+
const omitFields = useMemo(() => [
|
|
338
|
+
...layoutEditFields(screen)
|
|
339
|
+
.filter((spec) => spec.submit === false)
|
|
340
|
+
.map((spec) => spec.field),
|
|
341
|
+
...(screen.writeExcludedFields?.[writeVerb] ?? []),
|
|
342
|
+
], [screen, writeVerb]);
|
|
335
343
|
const submitConfig = writeCommand !== undefined
|
|
336
344
|
? {
|
|
337
345
|
type: writeCommand,
|
|
@@ -611,7 +619,8 @@ export function RenderEdit(props) {
|
|
|
611
619
|
values: snapshot.values,
|
|
612
620
|
translate,
|
|
613
621
|
featureName,
|
|
614
|
-
|
|
622
|
+
mode: writeVerb,
|
|
623
|
+
}), [screen, entity, snapshot.values, translate, featureName, writeVerb]);
|
|
615
624
|
const filteredSections = useMemo(
|
|
616
625
|
// A fully-hidden "fields" section (every field in it currently
|
|
617
626
|
// condition-hidden) must not occupy a wizard step; it would render
|
|
@@ -620,11 +629,12 @@ export function RenderEdit(props) {
|
|
|
620
629
|
// run their own query / own submit), so they always pass through. A
|
|
621
630
|
// section with no fields at all (e.g. a review-only step) has
|
|
622
631
|
// `visible: fields.some(...)` = false vacuously; that's "no fields to
|
|
623
|
-
// hide", not "hidden", so it stays too (fw#1901).
|
|
632
|
+
// hide", not "hidden", so it stays too (fw#1901). Unless the create
|
|
633
|
+
// exclusion emptied it: that would leave an empty wizard step / tab.
|
|
624
634
|
() => filterEditSections(vm.sections, fieldsFilter).filter((section) => section.kind === "extension" ||
|
|
625
635
|
section.kind === "relatedList" ||
|
|
626
636
|
section.kind === "writeForm" ||
|
|
627
|
-
section.fields.length === 0 ||
|
|
637
|
+
(section.fields.length === 0 && section.emptiedByWriteExclusion !== true) ||
|
|
628
638
|
section.visible), [vm.sections, fieldsFilter]);
|
|
629
639
|
const allFieldsRequired = useMemo(() => {
|
|
630
640
|
const fillable = filteredSections.flatMap((section) => section.kind === "fields"
|
|
@@ -1033,6 +1043,7 @@ export function RenderEdit(props) {
|
|
|
1033
1043
|
// action.
|
|
1034
1044
|
const hasSecondaryFormActions = (!menuPageHeaderActions && (onDelete !== undefined || onCopyLink !== undefined)) ||
|
|
1035
1045
|
(actions !== undefined && actions.length > 0) ||
|
|
1046
|
+
(!menuPageHeaderActions && menuActions !== undefined && menuActions.length > 0) ||
|
|
1036
1047
|
(onCancel !== undefined && !cancelInHeaderMenu);
|
|
1037
1048
|
// Teil-C collapse rule: driven by the custom `actions` group only — a
|
|
1038
1049
|
// resolved true also folds the Copy-Link button (same visual group in the
|
|
@@ -1040,6 +1051,7 @@ export function RenderEdit(props) {
|
|
|
1040
1051
|
const iconOnlyMidActions = actions !== undefined && shouldRenderActionsIconOnly(actions);
|
|
1041
1052
|
const headerMenuItems = menuPageHeaderActions
|
|
1042
1053
|
? [
|
|
1054
|
+
...(menuActions ?? []).map(menuActionToItem),
|
|
1043
1055
|
...(onCopyLink !== undefined
|
|
1044
1056
|
? [
|
|
1045
1057
|
{
|
|
@@ -1083,7 +1095,8 @@ export function RenderEdit(props) {
|
|
|
1083
1095
|
setLinkCopied(true);
|
|
1084
1096
|
}, children: iconOnlyMidActions
|
|
1085
1097
|
? null
|
|
1086
|
-
: translate(linkCopied ? "kumiko.actions.copyLinkCopied" : "kumiko.actions.copyLink") })), actions?.map((action) => (_jsx(RenderEditActionButton, { action: action, iconOnly: iconOnlyMidActions, Button: Button, Dialog: Dialog, onError: setActionError }, action.id))),
|
|
1098
|
+
: translate(linkCopied ? "kumiko.actions.copyLinkCopied" : "kumiko.actions.copyLink") })), actions?.map((action) => (_jsx(RenderEditActionButton, { action: action, iconOnly: iconOnlyMidActions, Button: Button, Dialog: Dialog, onError: setActionError }, action.id))), !menuPageHeaderActions &&
|
|
1099
|
+
menuActions?.map((action) => (_jsx(RenderEditActionButton, { action: action, Button: Button, Dialog: Dialog, onError: setActionError }, action.id))), onCancel !== undefined && !cancelInHeaderMenu && (_jsx(Button, { type: "button", variant: "secondary", onClick: () => onCancel(), testId: "render-edit-cancel", children: translate("kumiko.actions.cancel") }))] }));
|
|
1087
1100
|
const headerSlot = screen.slots?.header;
|
|
1088
1101
|
const headerSlotMount = headerSlot !== undefined ? (_jsx(EditSlotMount, { slot: headerSlot, slotName: "header", screenId: screen.id, entityName: vm.entityName, entityId: resolveExtensionEntityId(entityIdProp, vm.id), values: snapshot.values })) : undefined;
|
|
1089
1102
|
// A function headerRegion places the slot itself (projectionDetail puts it
|
|
@@ -1219,7 +1232,7 @@ export function RenderEdit(props) {
|
|
|
1219
1232
|
})
|
|
1220
1233
|
: translate("kumiko.wizard.step", { current: currentStep + 1, total: lastStepIndex + 1 });
|
|
1221
1234
|
})();
|
|
1222
|
-
return (_jsxs(ExtensionFormRegistryProvider, { value: extensionFormRegistry, children: [titleInShell && (_jsx(PageHeader, { title: formTitle, ...(recordTitle !== undefined && { recordTitle }), ...(headerMenuItems.length > 0 &&
|
|
1235
|
+
return (_jsxs(ExtensionFormRegistryProvider, { value: extensionFormRegistry, children: [menuActionConfirmDialog, titleInShell && (_jsx(PageHeader, { title: formTitle, ...(recordTitle !== undefined && { recordTitle }), ...(headerMenuItems.length > 0 &&
|
|
1223
1236
|
pageHeaderCompact && { overflowItems: headerMenuItems }), ...(headerMenuItems.length > 0 &&
|
|
1224
1237
|
!pageHeaderCompact &&
|
|
1225
1238
|
ActionOverflowMenu !== undefined && {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import type { ActionMenuItemSpec, usePrimitives } from "../primitives.js";
|
|
3
|
+
import type { RenderEditAction } from "./render-edit-types.js";
|
|
4
|
+
export declare function useActionMenuItems({ Dialog, onError, }: {
|
|
5
|
+
readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
|
|
6
|
+
readonly onError: (text: string | null) => void;
|
|
7
|
+
}): {
|
|
8
|
+
readonly toMenuItem: (action: RenderEditAction) => ActionMenuItemSpec;
|
|
9
|
+
readonly confirmDialog: ReactNode;
|
|
10
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { needsActionConfirm, RenderEditActionConfirmDialog } from "./render-edit-action-button.js";
|
|
4
|
+
// A menu item has no button of its own to carry RenderEditActionButton's busy
|
|
5
|
+
// and confirm state, so this hook owns both for every item it creates.
|
|
6
|
+
export function useActionMenuItems({ Dialog, onError, }) {
|
|
7
|
+
const [pendingAction, setPendingAction] = useState(null);
|
|
8
|
+
// Without it the menu can be reopened mid-write and fire the same writeHandler twice.
|
|
9
|
+
const [busyActionId, setBusyActionId] = useState(null);
|
|
10
|
+
const trigger = async (action) => {
|
|
11
|
+
onError(null);
|
|
12
|
+
setBusyActionId(action.id);
|
|
13
|
+
try {
|
|
14
|
+
await action.onPress();
|
|
15
|
+
}
|
|
16
|
+
catch (e) {
|
|
17
|
+
onError(e instanceof Error ? e.message : String(e));
|
|
18
|
+
}
|
|
19
|
+
finally {
|
|
20
|
+
setBusyActionId(null);
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
const toMenuItem = (action) => ({
|
|
24
|
+
id: action.id,
|
|
25
|
+
label: action.label,
|
|
26
|
+
...(action.icon !== undefined && { icon: action.icon }),
|
|
27
|
+
variant: action.style === "danger" ? "danger" : "default",
|
|
28
|
+
...(busyActionId !== null && { disabled: true }),
|
|
29
|
+
onSelect: () => {
|
|
30
|
+
if (needsActionConfirm(action)) {
|
|
31
|
+
setPendingAction(action);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
void trigger(action);
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
const confirmDialog = pendingAction !== null && (_jsx(RenderEditActionConfirmDialog, { action: pendingAction, open: true, onOpenChange: (open) => {
|
|
39
|
+
if (!open)
|
|
40
|
+
setPendingAction(null);
|
|
41
|
+
}, onConfirm: async () => {
|
|
42
|
+
const action = pendingAction;
|
|
43
|
+
setPendingAction(null);
|
|
44
|
+
await trigger(action);
|
|
45
|
+
}, Dialog: Dialog }));
|
|
46
|
+
return { toMenuItem, confirmDialog };
|
|
47
|
+
}
|
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.356.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.356.0",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.356.0",
|
|
20
|
+
"@cosmicdrift/kumiko-types": "0.356.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.356.0"
|
|
32
32
|
},
|
|
33
33
|
"repository": {
|
|
34
34
|
"type": "git",
|