@cosmicdrift/kumiko-renderer 0.334.0 → 0.336.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/content-preview.js +7 -9
- package/dist/app/extension-sections.d.ts +6 -0
- package/dist/app/form-schema.d.ts +2 -1
- package/dist/app/form-schema.js +11 -2
- package/dist/app/kumiko-screen.d.ts +5 -0
- package/dist/app/kumiko-screen.js +196 -65
- package/dist/app/nav.d.ts +5 -0
- package/dist/app/nav.js +17 -4
- package/dist/app/reference-facet-bridge.js +8 -1
- package/dist/app/row-actions.d.ts +12 -0
- package/dist/app/row-actions.js +24 -7
- package/dist/app/screen-access.js +13 -9
- package/dist/app/secret-mint-body.js +40 -6
- package/dist/app/secrets-edit-body.js +71 -44
- package/dist/components/embedded-list-field.js +2 -4
- package/dist/components/multi-select-checkboxes.js +4 -1
- package/dist/components/reference-create-dialog.js +9 -4
- package/dist/components/related-list-section.js +7 -5
- package/dist/components/render-edit-action-button.d.ts +1 -1
- package/dist/components/render-edit-action-button.js +5 -1
- package/dist/components/render-edit-logic.d.ts +2 -1
- package/dist/components/render-edit-logic.js +8 -5
- package/dist/components/render-edit-types.d.ts +10 -1
- package/dist/components/render-edit.js +189 -54
- package/dist/components/render-field.js +10 -13
- package/dist/components/render-list.js +6 -6
- package/dist/components/write-form-section.js +12 -20
- package/dist/hooks/use-reference-lookup.d.ts +7 -0
- package/dist/hooks/use-reference-lookup.js +10 -0
- package/dist/hooks/use-report-step-complete.d.ts +2 -0
- package/dist/hooks/use-report-step-complete.js +13 -0
- package/dist/i18n.js +8 -4
- package/dist/index.d.ts +6 -2
- package/dist/index.js +4 -1
- package/dist/primitives.d.ts +30 -8
- package/dist/primitives.js +5 -0
- package/dist/session/session-ended.d.ts +16 -0
- package/dist/session/session-ended.js +73 -0
- package/dist/sort-by-accessor.d.ts +6 -2
- package/dist/sort-by-accessor.js +13 -3
- package/dist/sse/live-events.d.ts +1 -1
- package/package.json +5 -5
- package/src/changes.json +54 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { evalFieldCondition, isFieldsEditSection, normalizeEditField, PROJECTION_DETAIL_ENTITY, } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
3
|
-
import { computeEditViewModel } from "@cosmicdrift/kumiko-headless";
|
|
2
|
+
import { evalFieldCondition, isFieldsEditSection, normalizeEditField, PROJECTION_DETAIL_ENTITY, sectionFieldSpecs, } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
3
|
+
import { computeEditViewModel, 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
6
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
@@ -14,7 +14,7 @@ import { useForm } from "../hooks/use-form.js";
|
|
|
14
14
|
import { useTranslation } from "../i18n.js";
|
|
15
15
|
import { useInsideDrawer } from "../inside-drawer.js";
|
|
16
16
|
import { usePageHeaderCompact, usePageHeaderSlotAvailable } from "../page-header-slot.js";
|
|
17
|
-
import { STICKY_PRIMARY_ACTION_PROP, shouldRenderActionsIconOnly, usePrimitives, } from "../primitives.js";
|
|
17
|
+
import { FOOTER_ACTION_ROLE_PROP, NARROW_LABEL_PROP, STICKY_PRIMARY_ACTION_PROP, shouldRenderActionsIconOnly, usePrimitives, } from "../primitives.js";
|
|
18
18
|
import { GridCellForField } from "./grid-cell-for-field.js";
|
|
19
19
|
import { RelatedListSection } from "./related-list-section.js";
|
|
20
20
|
import { filterEditSections, hasEditableSection, hasIssueWithoutRenderedField, resolveExtensionEntityId, shouldNotifyCaller, } from "./render-edit-logic.js";
|
|
@@ -77,7 +77,7 @@ function deriveFormFields(screen) {
|
|
|
77
77
|
// relatedList carries no `fields` for the form-condition map either.
|
|
78
78
|
if (!isFieldsEditSection(section))
|
|
79
79
|
continue;
|
|
80
|
-
for (const spec of section
|
|
80
|
+
for (const spec of sectionFieldSpecs(section)) {
|
|
81
81
|
const normalized = normalizeEditField(spec);
|
|
82
82
|
out[normalized.field] = {
|
|
83
83
|
...(normalized.visible !== undefined && {
|
|
@@ -94,16 +94,67 @@ function deriveFormFields(screen) {
|
|
|
94
94
|
}
|
|
95
95
|
return out;
|
|
96
96
|
}
|
|
97
|
+
// Dry-run parse against `schema` — NOT controller.validate(), which writes
|
|
98
|
+
// field errors into the snapshot. Issues are filtered exactly like the
|
|
99
|
+
// controller's validate(): hidden fields never count, `scope` narrows.
|
|
100
|
+
function dryRunFieldIssues(schema, snapshot, scope, includeRoot) {
|
|
101
|
+
const parsed = schema.safeParse(snapshot.values);
|
|
102
|
+
if (parsed.success)
|
|
103
|
+
return [];
|
|
104
|
+
const hiddenFields = new Set(Object.entries(snapshot.fields)
|
|
105
|
+
.filter(([, state]) => !state.visible)
|
|
106
|
+
.map(([fieldKey]) => fieldKey));
|
|
107
|
+
return relevantFieldIssues(zodErrorToFieldIssues(parsed.error), {
|
|
108
|
+
hiddenFields,
|
|
109
|
+
...(scope !== undefined && { scope }),
|
|
110
|
+
includeRoot,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
// Index-independent: indices shift when a fields section hides.
|
|
114
|
+
function wizardStepKey(section, index) {
|
|
115
|
+
const declaredId = section.kind === "fields" || section.kind === "extension" ? section.id : undefined;
|
|
116
|
+
return declaredId ?? section.title ?? `step-${index}`;
|
|
117
|
+
}
|
|
118
|
+
function isMoneyValue(value) {
|
|
119
|
+
return typeof value === "object" && value !== null && "amount" in value;
|
|
120
|
+
}
|
|
121
|
+
// The seeds buildInitialValues writes for NULL columns of an existing record.
|
|
122
|
+
function isEmptyFormValue(value) {
|
|
123
|
+
if (value === null || value === undefined || value === "" || value === 0 || value === false) {
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
if (Array.isArray(value))
|
|
127
|
+
return value.length === 0;
|
|
128
|
+
return isMoneyValue(value) && value.amount === 0;
|
|
129
|
+
}
|
|
130
|
+
// select/boolean are excluded from the "holds user data" check: they are
|
|
131
|
+
// often pre-filled (defaults, server-set values), so they say nothing about
|
|
132
|
+
// whether the step was filled in. They still count through the validation.
|
|
133
|
+
function isFieldsStepFilled(section, schema, snapshot) {
|
|
134
|
+
const fieldNames = section.fields.map((f) => f.field);
|
|
135
|
+
if (schema !== undefined && dryRunFieldIssues(schema, snapshot, fieldNames, false).length > 0) {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
return section.fields.some((f) => f.visible &&
|
|
139
|
+
!f.readOnly &&
|
|
140
|
+
f.type !== "boolean" &&
|
|
141
|
+
f.type !== "select" &&
|
|
142
|
+
!isEmptyFormValue(f.value));
|
|
143
|
+
}
|
|
97
144
|
// Resolves an extension-section's `{ react: { __component: "X" } }` marker
|
|
98
145
|
// to a registered React component via ExtensionSectionsProvider (filled in
|
|
99
146
|
// createKumikoApp from clientFeatures.extensionSectionComponents) and
|
|
100
147
|
// mounts it with the host entity name + id. Hook lives in its own
|
|
101
148
|
// component so we don't call `use*` inside vm.sections.map (rules-of-
|
|
102
149
|
// hooks would punish reordering sections between renders).
|
|
103
|
-
function ExtensionSectionMount({ section, entityName: hostEntityName, entityId, initialValues, values, patch, validate, hideTitle, actions, }) {
|
|
150
|
+
function ExtensionSectionMount({ section, entityName: hostEntityName, entityId, initialValues, values, patch, validate, hideTitle, actions, stepKey, onReportStepComplete, }) {
|
|
104
151
|
const { Banner, FillContainer, Section, Text } = usePrimitives();
|
|
105
152
|
const name = extensionSectionName(section.component);
|
|
106
153
|
const Component = useExtensionSectionComponent(name);
|
|
154
|
+
const reportStepComplete = useCallback((complete) => {
|
|
155
|
+
if (stepKey !== undefined)
|
|
156
|
+
onReportStepComplete?.(stepKey, complete);
|
|
157
|
+
}, [stepKey, onReportStepComplete]);
|
|
107
158
|
const testId = `section-extension-${section.title}`;
|
|
108
159
|
if (Component === undefined) {
|
|
109
160
|
const placeholder = (_jsx(Banner, { variant: "info", testId: `section-extension-placeholder-${section.title}`, children: _jsxs(Text, { children: ["Extension section component ", _jsx(Text, { variant: "code", children: name ?? "(no __component name)" }), " ", "not registered in clientFeatures.extensionSectionComponents."] }) }));
|
|
@@ -115,7 +166,7 @@ function ExtensionSectionMount({ section, entityName: hostEntityName, entityId,
|
|
|
115
166
|
// uses for its own hideTitle branch).
|
|
116
167
|
return hideTitle === true ? (FillContainer !== undefined ? (_jsx(FillContainer, { testId: testId, children: placeholder })) : (placeholder)) : (_jsx(Section, { title: section.title, testId: testId, children: placeholder }, section.title));
|
|
117
168
|
}
|
|
118
|
-
const mounted = (_jsx(Component, { entityName: section.entityName ?? hostEntityName, entityId: entityId, initialValues: initialValues, values: values, patch: patch, validate: validate }));
|
|
169
|
+
const mounted = (_jsx(Component, { entityName: section.entityName ?? hostEntityName, entityId: entityId, initialValues: initialValues, values: values, patch: patch, validate: validate, ...(onReportStepComplete !== undefined && { reportStepComplete }) }));
|
|
119
170
|
// Same reasoning as the placeholder branch above: tabs mode's outer Card
|
|
120
171
|
// is the only frame this content gets, no nested Section divider.
|
|
121
172
|
return hideTitle === true ? (FillContainer !== undefined ? (_jsx(FillContainer, { testId: testId, children: mounted })) : (mounted)) : (_jsx(Section, { ...(actions !== undefined && { actions }), title: section.title, testId: testId, children: mounted }));
|
|
@@ -162,7 +213,8 @@ function resolveTitleTemplate(template, values, translate, fallback) {
|
|
|
162
213
|
return translate(template, values);
|
|
163
214
|
}
|
|
164
215
|
export function RenderEdit(props) {
|
|
165
|
-
const { screen, entity, featureName, initial, writeCommand, translate: translateProp, ctx, schema, onSubmit, payloadMode = "values", buildPayload, serverFieldPathPrefix, onDelete, onCancel, onReload, onDirtyChange, onCopyLink, actions, onRelatedListDrawerAction, submitLabel, submitVariant, 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, } = props;
|
|
216
|
+
const { screen, entity, featureName, initial, writeCommand, translate: translateProp, ctx, schema, onSubmit, payloadMode = "values", buildPayload, serverFieldPathPrefix, onDelete, onCancel, onReload, onDirtyChange, onCopyLink, actions, onRelatedListDrawerAction, submitLabel, submitVariant, 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;
|
|
217
|
+
const i18nScreenId = props.i18nScreenId ?? screen.id;
|
|
166
218
|
const { customSubmit } = props;
|
|
167
219
|
// Translate-Fallback: wenn der Caller keine Translate-Fn übergibt,
|
|
168
220
|
// konsumieren wir den i18next-Context direkt. Sonst wären Field-
|
|
@@ -185,6 +237,9 @@ export function RenderEdit(props) {
|
|
|
185
237
|
const [linkCopied, setLinkCopied] = useState(false);
|
|
186
238
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
187
239
|
const [formError, setFormError] = useState(null);
|
|
240
|
+
// A rejected delete can carry several blocking reasons; the first one is the
|
|
241
|
+
// banner's headline (formError), the rest are listed beneath it.
|
|
242
|
+
const [extraDeleteIssues, setExtraDeleteIssues] = useState([]);
|
|
188
243
|
// One state for all header actions (actions?), not per-button — only one
|
|
189
244
|
// action can be in flight at a time, and this keeps the error surfaced in
|
|
190
245
|
// the shared formError-adjacent banner region instead of inside the
|
|
@@ -192,6 +247,14 @@ export function RenderEdit(props) {
|
|
|
192
247
|
// flex justify-end action bar).
|
|
193
248
|
const [actionError, setActionError] = useState(null);
|
|
194
249
|
const [rawStep, setRawStep] = useState(0);
|
|
250
|
+
const [passedStepKeys, setPassedStepKeys] = useState(() => new Set());
|
|
251
|
+
const [extensionStepComplete, setExtensionStepComplete] = useState({});
|
|
252
|
+
const reportExtensionStepComplete = useCallback((stepKey, complete) => {
|
|
253
|
+
setExtensionStepComplete((prev) => (prev[stepKey] === true) === complete ? prev : { ...prev, [stepKey]: complete });
|
|
254
|
+
}, []);
|
|
255
|
+
// Tabs have stable ids, so the active tab follows its id: a field change
|
|
256
|
+
// that hides an earlier tab must not shift the index onto another tab.
|
|
257
|
+
const [activeTabId, setActiveTabId] = useState(undefined);
|
|
195
258
|
// Create-mode draftId (issue #1913) — resumed from `sessionStorage` (web)
|
|
196
259
|
// on mount so a same-tab reload finds the right one of several parallel
|
|
197
260
|
// create-sessions on this screen; `null` until the first step change
|
|
@@ -400,9 +463,12 @@ export function RenderEdit(props) {
|
|
|
400
463
|
// on every keystroke, painting error messages while the user is still
|
|
401
464
|
// typing. `valid` can therefore legitimately diverge from what's
|
|
402
465
|
// currently rendered under the fields (the last *mutating* validate()
|
|
403
|
-
// call, e.g. from controls.validate() or submit()).
|
|
466
|
+
// call, e.g. from controls.validate() or submit()). The issues are
|
|
467
|
+
// filtered exactly like submit()'s validate: hidden fields and fields
|
|
468
|
+
// outside the `fields` scope never make the form invalid.
|
|
404
469
|
const currentSchema = schemaRef.current;
|
|
405
|
-
const valid = currentSchema === undefined
|
|
470
|
+
const valid = currentSchema === undefined ||
|
|
471
|
+
dryRunFieldIssues(currentSchema, snapshot, scopeFieldNamesRef.current, true).length === 0;
|
|
406
472
|
cb({
|
|
407
473
|
values: snapshot.values,
|
|
408
474
|
changes: snapshot.changes,
|
|
@@ -593,12 +659,45 @@ export function RenderEdit(props) {
|
|
|
593
659
|
// Clamped on read, not on write: section visibility is value-dependent, so a
|
|
594
660
|
// stored stepIndex can point past what is currently rendered — that lands on
|
|
595
661
|
// the last step instead of an empty one.
|
|
596
|
-
const
|
|
662
|
+
const activeTabIndex = isTabs && activeTabId !== undefined
|
|
663
|
+
? filteredSections.findIndex((s, i) => tabIdAt(s, i) === activeTabId)
|
|
664
|
+
: -1;
|
|
665
|
+
const currentStep = activeTabIndex !== -1 ? activeTabIndex : Math.min(rawStep, lastStepIndex);
|
|
597
666
|
// Kept fresh for patchAndScheduleDraftSave's debounce timer above, which
|
|
598
667
|
// is defined before `currentStep` exists (it depends on `filteredSections`,
|
|
599
668
|
// computed further up from `vm`) and so cannot close over it directly.
|
|
600
669
|
currentStepRef.current = currentStep;
|
|
601
670
|
const isLastWizardStep = currentStep >= lastStepIndex;
|
|
671
|
+
const wizardDoneSteps = useMemo(() => {
|
|
672
|
+
if (!isWizard || isCreateMode)
|
|
673
|
+
return undefined;
|
|
674
|
+
const snapshotForDryRun = { values: snapshot.values, fields: snapshot.fields };
|
|
675
|
+
return filteredSections.map((section, index) => {
|
|
676
|
+
if (passedStepKeys.has(wizardStepKey(section, index)))
|
|
677
|
+
return true;
|
|
678
|
+
if (section.kind === "fields")
|
|
679
|
+
return isFieldsStepFilled(section, schema, snapshotForDryRun);
|
|
680
|
+
return (section.kind === "extension" &&
|
|
681
|
+
extensionStepComplete[wizardStepKey(section, index)] === true);
|
|
682
|
+
});
|
|
683
|
+
}, [
|
|
684
|
+
isWizard,
|
|
685
|
+
isCreateMode,
|
|
686
|
+
filteredSections,
|
|
687
|
+
passedStepKeys,
|
|
688
|
+
extensionStepComplete,
|
|
689
|
+
schema,
|
|
690
|
+
snapshot.values,
|
|
691
|
+
snapshot.fields,
|
|
692
|
+
]);
|
|
693
|
+
const updateModeStepBarProps = wizardDoneSteps === undefined
|
|
694
|
+
? {}
|
|
695
|
+
: { doneSteps: wizardDoneSteps, selectableSteps: "all" };
|
|
696
|
+
function jumpToStep(index) {
|
|
697
|
+
setRawStep(index);
|
|
698
|
+
if (isTabs)
|
|
699
|
+
setActiveTabId(tabIdAt(filteredSections[index], index));
|
|
700
|
+
}
|
|
602
701
|
// Step transitions only — never per keystroke. Deliberately not awaited: a
|
|
603
702
|
// failed draft save must not block the step change.
|
|
604
703
|
//
|
|
@@ -638,11 +737,13 @@ export function RenderEdit(props) {
|
|
|
638
737
|
console.warn(`RenderEdit: draftKey "${key}" is ${key.length} chars, over the server's ${FORM_DRAFT_KEY_MAX_LENGTH}-char limit — skipping this draft save. Shorten screen.id or entity id.`);
|
|
639
738
|
return;
|
|
640
739
|
}
|
|
641
|
-
const
|
|
642
|
-
? section.fields
|
|
740
|
+
const secretFieldNames = new Set(vm.sections.flatMap((section) => section.kind === "fields"
|
|
741
|
+
? section.fields
|
|
742
|
+
.filter((f) => f.format === "password" || f.writeOnly === true)
|
|
743
|
+
.map((f) => f.field)
|
|
643
744
|
: []));
|
|
644
|
-
// A password field never enters the persisted draft blob — it would be stored in the clear and restored on resume.
|
|
645
|
-
const draftValues = Object.fromEntries(Object.entries(controller.getSnapshot().values).filter(([field]) => !
|
|
745
|
+
// A password or writeOnly field never enters the persisted draft blob — it would be stored in the clear and restored on resume.
|
|
746
|
+
const draftValues = Object.fromEntries(Object.entries(controller.getSnapshot().values).filter(([field]) => !secretFieldNames.has(field)));
|
|
646
747
|
void dispatcher
|
|
647
748
|
.write(FORM_DRAFT_SAVE, {
|
|
648
749
|
draftKey: key,
|
|
@@ -670,29 +771,45 @@ export function RenderEdit(props) {
|
|
|
670
771
|
setRawStep(next);
|
|
671
772
|
return;
|
|
672
773
|
}
|
|
673
|
-
const section = filteredSections[currentStep];
|
|
674
|
-
const fieldNames = section?.kind === "fields" ? section.fields.map((f) => f.field) : [];
|
|
675
774
|
// skip: the current step has field errors — no transition, no draft save.
|
|
676
|
-
if (
|
|
775
|
+
if (currentStepHasFieldErrors())
|
|
677
776
|
return;
|
|
777
|
+
if (!isCreateMode) {
|
|
778
|
+
const leftSection = filteredSections[currentStep];
|
|
779
|
+
if (leftSection !== undefined) {
|
|
780
|
+
const leftKey = wizardStepKey(leftSection, currentStep);
|
|
781
|
+
setPassedStepKeys((prev) => (prev.has(leftKey) ? prev : new Set(prev).add(leftKey)));
|
|
782
|
+
}
|
|
783
|
+
}
|
|
678
784
|
setRawStep(next);
|
|
679
785
|
saveDraft(next);
|
|
680
786
|
}
|
|
787
|
+
function currentStepHasFieldErrors() {
|
|
788
|
+
const section = filteredSections[currentStep];
|
|
789
|
+
const fieldNames = section?.kind === "fields" ? section.fields.map((f) => f.field) : [];
|
|
790
|
+
return fieldNames.length > 0 && !controller.validate(fieldNames);
|
|
791
|
+
}
|
|
681
792
|
function handleWizardBack() {
|
|
682
|
-
|
|
793
|
+
handleWizardStepSelect(currentStep - 1);
|
|
683
794
|
}
|
|
684
|
-
//
|
|
685
|
-
|
|
686
|
-
|
|
795
|
+
// Update mode: the record already exists and the final Save validates the
|
|
796
|
+
// whole form and jumps to the first erroring step (findFirstErroringStep), so
|
|
797
|
+
// skipping steps forward is safe; leaving the current step still goes through
|
|
798
|
+
// its validate gate.
|
|
799
|
+
function handleWizardStepSelect(targetStep) {
|
|
800
|
+
if (targetStep === currentStep)
|
|
687
801
|
return;
|
|
688
|
-
const
|
|
689
|
-
|
|
690
|
-
if (
|
|
691
|
-
setRawStep(previous);
|
|
802
|
+
const isForward = targetStep > currentStep;
|
|
803
|
+
// Create mode: a forward jump would skip the per-step validate() gate.
|
|
804
|
+
if (isForward && isCreateMode)
|
|
692
805
|
return;
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
806
|
+
if (isForward && !disabled && currentStepHasFieldErrors())
|
|
807
|
+
return;
|
|
808
|
+
const clampedTarget = Math.min(Math.max(targetStep, 0), lastStepIndex);
|
|
809
|
+
hasNavigatedRef.current = true;
|
|
810
|
+
setRawStep(clampedTarget);
|
|
811
|
+
if (!disabled)
|
|
812
|
+
saveDraft(clampedTarget);
|
|
696
813
|
}
|
|
697
814
|
async function discardDraft() {
|
|
698
815
|
// skip: this screen does not persist a draft.
|
|
@@ -823,6 +940,7 @@ export function RenderEdit(props) {
|
|
|
823
940
|
let extensionsPersisted = true;
|
|
824
941
|
if (result.isSuccess) {
|
|
825
942
|
setFormError(null);
|
|
943
|
+
setExtraDeleteIssues([]);
|
|
826
944
|
// `isNoOp: true` (payloadMode "changes", pre-filled form submitted
|
|
827
945
|
// untouched) means controller.submit() never called dispatcher.write
|
|
828
946
|
// — nothing to discard, and discarding here would delete a draft the
|
|
@@ -843,7 +961,7 @@ export function RenderEdit(props) {
|
|
|
843
961
|
if (isStepped) {
|
|
844
962
|
const step = findFirstErroringStep(Object.keys(controller.getSnapshot().errors));
|
|
845
963
|
if (step !== undefined)
|
|
846
|
-
|
|
964
|
+
jumpToStep(step);
|
|
847
965
|
}
|
|
848
966
|
}
|
|
849
967
|
else {
|
|
@@ -859,8 +977,9 @@ export function RenderEdit(props) {
|
|
|
859
977
|
if (isStepped) {
|
|
860
978
|
const step = findFirstErroringStep(issuePaths);
|
|
861
979
|
if (step !== undefined)
|
|
862
|
-
|
|
980
|
+
jumpToStep(step);
|
|
863
981
|
}
|
|
982
|
+
setExtraDeleteIssues([]);
|
|
864
983
|
setFormError(hasIssueWithoutRenderedField(issuePaths, filteredSections) ? result.error : null);
|
|
865
984
|
}
|
|
866
985
|
// skip: on entity-success-but-extension-failure the extension-error
|
|
@@ -955,14 +1074,20 @@ export function RenderEdit(props) {
|
|
|
955
1074
|
const isFieldlessSubmitForm = isFieldless && screen.entity !== PROJECTION_DETAIL_ENTITY;
|
|
956
1075
|
const showsSubmit = isFormEditable || hasExtensionRegistrations || isFieldlessSubmitForm;
|
|
957
1076
|
const footerSlot = screen.slots?.footer;
|
|
1077
|
+
// An unregistered footer component renders null, so only a resolvable one may
|
|
1078
|
+
// count as an action (else read-only screens get an empty footer strip).
|
|
1079
|
+
const FooterSlotComponent = useExtensionSectionComponent(footerSlot !== undefined ? extensionSectionName(footerSlot) : undefined);
|
|
958
1080
|
// Mirrors every branch inside formActions below — without this guard
|
|
959
1081
|
// DefaultForm renders an empty footer strip (border + padding, no content)
|
|
960
1082
|
// on read-only detail screens, since `actions` would otherwise always be
|
|
961
1083
|
// a defined (if empty) fragment.
|
|
962
|
-
const
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
1084
|
+
const showWizardBack = isWizard && currentStep > 0;
|
|
1085
|
+
const showWizardNext = isWizard && !isLastWizardStep;
|
|
1086
|
+
const showSubmit = showsSubmit && (!isWizard || isLastWizardStep);
|
|
1087
|
+
const hasFormActions = showWizardBack ||
|
|
1088
|
+
showWizardNext ||
|
|
1089
|
+
showSubmit ||
|
|
1090
|
+
FooterSlotComponent !== undefined ||
|
|
966
1091
|
writeFormFooterAction !== undefined;
|
|
967
1092
|
const nextStepTitle = isWizard ? filteredSections[currentStep + 1]?.title : undefined;
|
|
968
1093
|
const isCreate = (() => {
|
|
@@ -982,14 +1107,14 @@ export function RenderEdit(props) {
|
|
|
982
1107
|
? 1
|
|
983
1108
|
: 0;
|
|
984
1109
|
const isSettingsList = screen.layout.variant === "settings-list" && hideSectionTitles !== true;
|
|
985
|
-
const formActions = (_jsxs(_Fragment, { children: [
|
|
1110
|
+
const formActions = (_jsxs(_Fragment, { children: [showWizardBack && (_jsx(Button, { type: "button", variant: "secondary", icon: "chevron-left", onClick: handleWizardBack, testId: "render-edit-wizard-back", [FOOTER_ACTION_ROLE_PROP]: "back", children: translate("kumiko.actions.back") })), footerSlot !== undefined && (_jsx(EditSlotMount, { slot: footerSlot, slotName: "footer", screenId: screen.id, entityName: vm.entityName, entityId: resolveExtensionEntityId(entityIdProp, vm.id), values: snapshot.values, hasUnsavedChanges: hasUnsavedInput, wizardStep: isWizard ? { index: currentStep, isLast: isLastWizardStep } : undefined, ...(screen.slots?.footerPrimary === true && { [STICKY_PRIMARY_ACTION_PROP]: true }) })), isWizard && !isLastWizardStep && showsSubmit && !isCreateMode && (_jsx(Button, { type: "button", variant: "ghost", disabled: isSubmitting || disabled, onClick: () => void handleSubmit({ saveNow: true }), testId: "render-edit-wizard-save-close", [STICKY_PRIMARY_ACTION_PROP]: true, children: translate("kumiko.wizard.save-and-close") })), showWizardNext && (_jsx(Button, { type: "submit", variant: "primary", iconEnd: "chevron-right", testId: "render-edit-wizard-next", [FOOTER_ACTION_ROLE_PROP]: "primary",
|
|
1111
|
+
[NARROW_LABEL_PROP]: translate("kumiko.actions.next"), children: nextStepTitle !== undefined
|
|
986
1112
|
? translate("kumiko.wizard.next-with-title", { title: nextStepTitle })
|
|
987
|
-
: translate("kumiko.actions.next") })),
|
|
988
|
-
(!isWizard || isLastWizardStep) &&
|
|
1113
|
+
: translate("kumiko.actions.next") })), showSubmit &&
|
|
989
1114
|
footerActions?.map((action) => (_jsx(Button, { type: "button", variant: action.variant ?? "secondary", ...(action.icon !== undefined && { icon: action.icon }), disabled: isSubmitting || disabled, onClick: () => {
|
|
990
1115
|
controller.setValues(action.patch); // @cast-boundary form-values
|
|
991
1116
|
void handleSubmit();
|
|
992
|
-
}, testId: `render-edit-footer-action-${action.id}`, children: translate(action.label) }, action.id))), hasDirtyFooter && showsSubmit && unsavedCount !== undefined && unsavedCount > 0 && (_jsx(Button, { type: "button", variant: "ghost", disabled: isSubmitting || disabled, onClick: () => controller.reset(), testId: "render-edit-discard", [STICKY_PRIMARY_ACTION_PROP]: true, children: translate("kumiko.form.discard") })),
|
|
1117
|
+
}, testId: `render-edit-footer-action-${action.id}`, children: translate(action.label) }, action.id))), hasDirtyFooter && showsSubmit && unsavedCount !== undefined && unsavedCount > 0 && (_jsx(Button, { type: "button", variant: "ghost", disabled: isSubmitting || disabled, onClick: () => controller.reset(), testId: "render-edit-discard", [STICKY_PRIMARY_ACTION_PROP]: true, children: translate("kumiko.form.discard") })), showSubmit && (_jsx(Button, { type: "submit", disabled: (snapshot.isUnchanged && !extensionDirty && !isFieldless && !insideDrawer) ||
|
|
993
1118
|
isSubmitting ||
|
|
994
1119
|
disabled, loading: isSubmitting, variant: submitVariant ?? "primary", testId: "render-edit-submit", children: translate(submitLabel ??
|
|
995
1120
|
(isWizard
|
|
@@ -1013,8 +1138,8 @@ export function RenderEdit(props) {
|
|
|
1013
1138
|
: undefined;
|
|
1014
1139
|
const resolveScreenText = (suffix) => {
|
|
1015
1140
|
for (const key of [
|
|
1016
|
-
`screen:${
|
|
1017
|
-
`screen:${
|
|
1141
|
+
`screen:${i18nScreenId}.${formMode}.${suffix}`,
|
|
1142
|
+
`screen:${i18nScreenId}.${suffix}`,
|
|
1018
1143
|
]) {
|
|
1019
1144
|
const value = translate(key);
|
|
1020
1145
|
if (value !== key)
|
|
@@ -1022,14 +1147,18 @@ export function RenderEdit(props) {
|
|
|
1022
1147
|
}
|
|
1023
1148
|
return undefined;
|
|
1024
1149
|
};
|
|
1025
|
-
const plainFormTitle = resolveScreenText("title") ??
|
|
1150
|
+
const plainFormTitle = resolveScreenText("title") ?? i18nScreenId;
|
|
1026
1151
|
const formTitle = resolveTitleTemplate(screen.titleTemplate, snapshot.values, translate, plainFormTitle);
|
|
1027
|
-
// screen.description is
|
|
1028
|
-
//
|
|
1152
|
+
// In tabs mode (hideSectionTitles) screen.description is dropped — the head
|
|
1153
|
+
// card carries it — but an explicit subtitle i18n override has no other
|
|
1154
|
+
// source there, so it stays unless it merely resolves to the description.
|
|
1155
|
+
const explicitSubtitle = resolveScreenText("subtitle");
|
|
1156
|
+
const descriptionSubtitle = screen.description !== undefined ? translate(screen.description) : undefined;
|
|
1029
1157
|
const formSubtitle = hideSectionTitles !== true
|
|
1030
|
-
? (
|
|
1031
|
-
|
|
1032
|
-
|
|
1158
|
+
? (explicitSubtitle ?? descriptionSubtitle)
|
|
1159
|
+
: explicitSubtitle !== descriptionSubtitle
|
|
1160
|
+
? explicitSubtitle
|
|
1161
|
+
: undefined;
|
|
1033
1162
|
// Embedded settings-list forms (dashboard panels) have no shell header: the
|
|
1034
1163
|
// form title/subtitle move into the first section's header column instead of
|
|
1035
1164
|
// floating as a heading above the band.
|
|
@@ -1069,7 +1198,7 @@ export function RenderEdit(props) {
|
|
|
1069
1198
|
!stepRail &&
|
|
1070
1199
|
!titleInSettingsHeader && { subtitle: formSubtitle }), ...(isScreenForm && { screenForm: true }), ...(unsavedCount !== undefined &&
|
|
1071
1200
|
(unsavedCount > 0 || hasDirtyFooter) && { unsavedCount }), ...(sectionNavItems.length > 0 && { sectionNav: sectionNavItems }), ...(stepRail && {
|
|
1072
|
-
sideRail: (_jsx(StepBar, { steps: filteredSections.map((section) => section.title ?? ""), currentIndex: currentStep, compactLabel: compactStepLabel, onStepSelect:
|
|
1201
|
+
sideRail: (_jsx(StepBar, { steps: filteredSections.map((section) => section.title ?? ""), currentIndex: currentStep, compactLabel: compactStepLabel, onStepSelect: handleWizardStepSelect, ...updateModeStepBarProps, orientation: "vertical", heading: translate("kumiko.wizard.step", {
|
|
1073
1202
|
current: currentStep + 1,
|
|
1074
1203
|
total: lastStepIndex + 1,
|
|
1075
1204
|
}), ...(formSubtitle !== undefined && { description: formSubtitle }), subtitles: filteredSections.map((section) => section.kind === "fields" ? section.subtitle : undefined), ...(upNextSection !== undefined && {
|
|
@@ -1081,12 +1210,12 @@ export function RenderEdit(props) {
|
|
|
1081
1210
|
},
|
|
1082
1211
|
}), testId: "render-edit-wizard-steps", compactTestId: "render-edit-wizard-step-label" })),
|
|
1083
1212
|
}), ...(hideActions !== true && hasFormActions && { actions: formActions }), ...(hideActions !== true &&
|
|
1084
|
-
hasSecondaryFormActions && { secondaryActions: secondaryFormActions }), testId: "render-edit-form", stickyActions: isWizard || fillScreenHeight === true, ...(screen.layout.width !== undefined && { width: screen.layout.width }), ...(formHeaderRegion !== undefined && { headerRegion: formHeaderRegion }), ...(titleActionMount !== undefined && { titleAction: titleActionMount }), ...(fillHeight && { fillHeight }), ...(hideSectionTitles === true && { chromeless: true }), ...(summary !== undefined && { summary }), children: _jsxs(AllFieldsRequiredProvider, { value: showsAllRequiredHint, children: [showsAllRequiredHint && !insideDrawer && allRequiredHint, draftCandidates !== null && (_jsx(Banner, { variant: "info", testId: "render-edit-draft-picker", actions: [
|
|
1213
|
+
hasSecondaryFormActions && { secondaryActions: secondaryFormActions }), testId: "render-edit-form", stickyActions: isWizard || fillScreenHeight === true, ...(screen.layout.width !== undefined && { width: screen.layout.width }), ...(formHeaderRegion !== undefined && { headerRegion: formHeaderRegion }), ...(titleActionMount !== undefined && { titleAction: titleActionMount }), ...(fillHeight && { fillHeight }), ...(hideSectionTitles === true && { chromeless: true }), ...(summary !== undefined && { summary }), children: _jsxs(AllFieldsRequiredProvider, { value: showsAllRequiredHint, children: [leadContent, showsAllRequiredHint && !insideDrawer && allRequiredHint, draftCandidates !== null && (_jsx(Banner, { variant: "info", testId: "render-edit-draft-picker", actions: [
|
|
1085
1214
|
...draftCandidates.map((candidate) => (_jsx(Button, { type: "button", variant: "link", onClick: () => adoptDraft(candidate), testId: `render-edit-draft-pick-${candidate.id}`, children: formatWhen(candidate.savedAt) }, candidate.id))),
|
|
1086
1215
|
_jsx(Button, { type: "button", variant: "link", onClick: () => setDraftCandidates(null), testId: "render-edit-draft-start-new", children: translate("kumiko.form.draft.start-new") }, "start-new"),
|
|
1087
1216
|
], children: _jsx(Text, { children: translate(draftCandidates.length === 1
|
|
1088
1217
|
? "kumiko.form.draft.resume-single"
|
|
1089
|
-
: "kumiko.form.draft.resume-multiple") }) })), isWizard && !stepRail && (_jsxs(_Fragment, { children: [Progress !== undefined && (_jsx(Progress, { value: (currentStep + 1) / (lastStepIndex + 1), testId: "render-edit-wizard-progress" })), StepBar === undefined ? (_jsx(Text, { variant: "small", testId: "render-edit-wizard-step-label", children: compactStepLabel })) : (_jsx(StepBar, { steps: filteredSections.map((section) => section.title ?? ""), currentIndex: currentStep, compactLabel: compactStepLabel, onStepSelect:
|
|
1218
|
+
: "kumiko.form.draft.resume-multiple") }) })), isWizard && !stepRail && (_jsxs(_Fragment, { children: [Progress !== undefined && (_jsx(Progress, { value: (currentStep + 1) / (lastStepIndex + 1), testId: "render-edit-wizard-progress" })), StepBar === undefined ? (_jsx(Text, { variant: "small", testId: "render-edit-wizard-step-label", children: compactStepLabel })) : (_jsx(StepBar, { steps: filteredSections.map((section) => section.title ?? ""), currentIndex: currentStep, compactLabel: compactStepLabel, onStepSelect: handleWizardStepSelect, ...updateModeStepBarProps, testId: "render-edit-wizard-steps", compactTestId: "render-edit-wizard-step-label" }))] })), isTabs &&
|
|
1090
1219
|
(() => {
|
|
1091
1220
|
// No Tabs primitive registered → every section renders stacked
|
|
1092
1221
|
// (stepHidden below is gated on the same condition), which is the
|
|
@@ -1102,7 +1231,7 @@ export function RenderEdit(props) {
|
|
|
1102
1231
|
// skip: the strip reported an id no section owns.
|
|
1103
1232
|
if (index === -1)
|
|
1104
1233
|
return;
|
|
1105
|
-
|
|
1234
|
+
jumpToStep(index);
|
|
1106
1235
|
} }));
|
|
1107
1236
|
})(), filteredSections.map((section, sectionIndex) => {
|
|
1108
1237
|
// turns this section's own `actions` (RowAction[]) into
|
|
@@ -1142,7 +1271,11 @@ export function RenderEdit(props) {
|
|
|
1142
1271
|
const mount = (_jsx(ExtensionSectionMount, { section: section, entityName: vm.entityName, entityId: resolveExtensionEntityId(entityIdProp, vm.id), initialValues: extensionInitialValues, values: snapshot.values,
|
|
1143
1272
|
// @cast-boundary form-values: ExtensionSectionProps is not generic
|
|
1144
1273
|
// over TValues; controller is mount-lifetime-stable, see onControlsReady above.
|
|
1145
|
-
patch: patchAndScheduleDraftSave, validate: scopedValidate, hideTitle: hideSectionTitles, ...(
|
|
1274
|
+
patch: patchAndScheduleDraftSave, validate: scopedValidate, hideTitle: hideSectionTitles, ...(isWizard &&
|
|
1275
|
+
!isCreateMode && {
|
|
1276
|
+
stepKey: wizardStepKey(section, sectionIndex),
|
|
1277
|
+
onReportStepComplete: reportExtensionStepComplete,
|
|
1278
|
+
}), ...(hideSectionTitles !== true &&
|
|
1146
1279
|
sectionActionsEl !== undefined && { actions: sectionActionsEl }) }, section.title));
|
|
1147
1280
|
// Tabs mode: an unframed padded panel (the tab panel is the
|
|
1148
1281
|
// surface), so extension content neither sits flush at the shell
|
|
@@ -1239,15 +1372,17 @@ export function RenderEdit(props) {
|
|
|
1239
1372
|
id: formSectionDomId(sectionIndex),
|
|
1240
1373
|
}), ...(sectionTitle !== undefined && { title: sectionTitle }), ...(sectionSubtitle !== undefined && { subtitle: sectionSubtitle }), ...(section.icon !== undefined && { icon: section.icon }), ...(sectionActionsEl !== undefined && { actions: sectionActionsEl }), ...(isSettingsList && { layout: "settings-list" }), testId: `section-${sectionKey}`, children: [showsAllRequiredHint && insideDrawer && sectionIndex === 0 && allRequiredHint, renderFieldGrid(section.fields, section.columns, `${sectionKey}-grid`)] }, sectionKey));
|
|
1241
1374
|
return wrapWizardStep(sectionKey, sectionEl);
|
|
1242
|
-
}), formError !== null && (
|
|
1375
|
+
}), formError !== null && (_jsxs(Banner, { variant: "error", testId: "render-edit-form-error", actions: formError.code === "version_conflict" && onReload !== undefined ? (_jsx(Button, { variant: "secondary", onClick: () => {
|
|
1243
1376
|
onReload();
|
|
1244
1377
|
setFormError(null);
|
|
1245
|
-
|
|
1378
|
+
setExtraDeleteIssues([]);
|
|
1379
|
+
}, testId: "render-edit-form-error-reload", children: translate("kumiko.actions.reload") })) : undefined, children: [_jsx(Text, { testId: "render-edit-form-error-key", children: translate(formError.i18nKey, formError.i18nParams) }), extraDeleteIssues.map((issue) => (_jsx(Text, { testId: "render-edit-form-error-extra", children: translate(issue.i18nKey, issue.params) }, `${issue.path}:${issue.i18nKey}`)))] })), (snapshot.errors["(root)"] ?? []).map((issue) => (_jsx(Banner, { variant: "error", testId: "render-edit-root-issue", children: _jsx(Text, { children: translate(issue.i18nKey, issue.params) }) }, issue.i18nKey))), extensionErrorKey !== null && (_jsx(Banner, { variant: "error", testId: "render-edit-extension-error", children: _jsx(Text, { testId: "render-edit-extension-error-key", children: translate(extensionErrorKey) }) })), actionError !== null && (_jsx(Banner, { variant: "error", testId: "render-edit-action-error", children: actionError })), onDelete !== undefined && (_jsx(Dialog, { open: confirmDeleteOpen, onOpenChange: setConfirmDeleteOpen, title: translate("kumiko.actions.delete-confirm"), confirmLabel: translate("kumiko.actions.delete"), variant: "danger", onConfirm: async () => {
|
|
1246
1380
|
const rejection = await onDelete();
|
|
1247
1381
|
// A rejected delete (e.g. a preDelete hook) carries its reason in
|
|
1248
1382
|
// the field issues; the top-level key is only "validation failed".
|
|
1249
1383
|
if (rejection !== undefined) {
|
|
1250
|
-
const issue = rejection.details?.fields
|
|
1384
|
+
const [issue, ...otherIssues] = rejection.details?.fields ?? [];
|
|
1385
|
+
setExtraDeleteIssues(otherIssues);
|
|
1251
1386
|
setFormError(issue === undefined
|
|
1252
1387
|
? rejection
|
|
1253
1388
|
: { ...rejection, i18nKey: issue.i18nKey, i18nParams: issue.params });
|
|
@@ -10,7 +10,7 @@ import { screenAccessAllows } from "../app/screen-access.js";
|
|
|
10
10
|
import { useUserRoles } from "../context/user-roles-context.js";
|
|
11
11
|
import { REFERENCE_COMBOBOX_LIMIT } from "../hooks/reference-limits.js";
|
|
12
12
|
import { useQuery } from "../hooks/use-query.js";
|
|
13
|
-
import {
|
|
13
|
+
import { referenceOptionSource } from "../hooks/use-reference-lookup.js";
|
|
14
14
|
import { useLocale, useTranslation } from "../i18n.js";
|
|
15
15
|
import { usePrimitives } from "../primitives.js";
|
|
16
16
|
import { EmbeddedListField } from "./embedded-list-field.js";
|
|
@@ -83,16 +83,6 @@ function WriteOnlyTextInput({ field, id, hasError, onChange, changed, }) {
|
|
|
83
83
|
},
|
|
84
84
|
}) }));
|
|
85
85
|
}
|
|
86
|
-
// Where a reference field's option rows come from, and which of their keys
|
|
87
|
-
// holds the display text — the entity's own list handler plus `refLabelField`
|
|
88
|
-
// by default, or an author-supplied handler whose rows carry `label`
|
|
89
|
-
// (fw#2780), for targets with no readable column of their own.
|
|
90
|
-
function referenceOptionSource(field, refFeature, refEntity) {
|
|
91
|
-
if (field.refOptionsQuery !== undefined) {
|
|
92
|
-
return { queryQn: field.refOptionsQuery, labelKey: "label" };
|
|
93
|
-
}
|
|
94
|
-
return referenceLookupSource(refFeature, refEntity, field.refLabelField ?? "id");
|
|
95
|
-
}
|
|
96
86
|
// Tier 2.7e-3 + 2.1c: Reference-Input rendert eine Searchable Combobox
|
|
97
87
|
// gefüllt aus einer Live-Query auf die referenced Entity. Default-
|
|
98
88
|
// Limit: 200 — bei größeren Datasets fehlt der Tail im Dropdown
|
|
@@ -464,15 +454,22 @@ function resolveMoneyCurrency(value, fieldCurrency) {
|
|
|
464
454
|
}
|
|
465
455
|
return fieldCurrency ?? "EUR";
|
|
466
456
|
}
|
|
457
|
+
// The suffix span is absolutely positioned inside a fixed right padding sized
|
|
458
|
+
// for short symbols; a free-text sibling value longer than any real unit
|
|
459
|
+
// symbol would overflow the number.
|
|
460
|
+
const MAX_SIBLING_UNIT_LENGTH = 8;
|
|
467
461
|
// Static unit → used as-is. Sibling-field reference → read the live row
|
|
468
|
-
// value; missing/empty/non-string sibling means no suffix, never a guess.
|
|
462
|
+
// value; missing/empty/non-string/overlong sibling means no suffix, never a guess.
|
|
469
463
|
function resolveNumberUnit(unit, row) {
|
|
470
464
|
if (unit === undefined)
|
|
471
465
|
return undefined;
|
|
472
466
|
if (typeof unit === "string")
|
|
473
467
|
return unit;
|
|
474
468
|
const sibling = row?.[unit.field];
|
|
475
|
-
|
|
469
|
+
if (typeof sibling !== "string")
|
|
470
|
+
return undefined;
|
|
471
|
+
const trimmed = sibling.trim();
|
|
472
|
+
return trimmed.length > 0 && trimmed.length <= MAX_SIBLING_UNIT_LENGTH ? trimmed : undefined;
|
|
476
473
|
}
|
|
477
474
|
function moneyMinorValue(v, currency) {
|
|
478
475
|
if (v === undefined || v === null || v === "")
|
|
@@ -6,11 +6,11 @@ import { useReferenceLookup } from "../hooks/use-reference-lookup.js";
|
|
|
6
6
|
import { useTranslation } from "../i18n.js";
|
|
7
7
|
import { usePageHeaderCompact, usePageHeaderSlotAvailable } from "../page-header-slot.js";
|
|
8
8
|
import { shouldRenderActionsIconOnly, usePrimitives, } from "../primitives.js";
|
|
9
|
+
import { needsActionConfirm } from "./render-edit-action-button.js";
|
|
9
10
|
const SEARCH_DEBOUNCE_MS = 300;
|
|
10
|
-
//
|
|
11
|
-
// page header without losing the confirm dialog or busy/error surfacing.
|
|
11
|
+
// Icon-less actions are promoted too (with a "plus" fallback at the call site).
|
|
12
12
|
function isHeaderPromotableToolbarAction(action) {
|
|
13
|
-
return
|
|
13
|
+
return action.style === "primary" && !needsActionConfirm(action);
|
|
14
14
|
}
|
|
15
15
|
export function RenderList(props) {
|
|
16
16
|
const { screen, entity, rows, featureName, translate: translateProp, onRowClick, emptyState, onCreate, createLabel, searchable = false, searchPlaceholder, description, itemNounKey, searchValue, onSearchChange, sort, onSortChange, pager, onReachEnd, loadingMore, hasMore, rowActions, rowActionMode, rowGrouping, rowTone, toolbarActions, filterFacets, filterValues, onFilterChange, onFilterReset, dateRangeFacets, onDateRangeChange, chromeless, scrollBody, screenPadding, expandedRowIds, onToggleRowExpanded, renderExpandedRow, } = props;
|
|
@@ -147,7 +147,7 @@ export function RenderList(props) {
|
|
|
147
147
|
pageHeaderCompact
|
|
148
148
|
? toolbarActions?.find(isHeaderPromotableToolbarAction)
|
|
149
149
|
: undefined;
|
|
150
|
-
const promotedActionButton = promotedToolbarAction !== undefined ? (_jsx(
|
|
150
|
+
const promotedActionButton = promotedToolbarAction !== undefined ? (_jsx(ToolbarActionView, { action: { ...promotedToolbarAction, icon: promotedToolbarAction.icon ?? "plus" }, iconOnly: true, Button: Button, Dialog: Dialog, Banner: Banner })) : undefined;
|
|
151
151
|
const toolbarActionsInToolbar = toolbarActions?.filter((a) => a !== promotedToolbarAction) ?? [];
|
|
152
152
|
const hasToolbarActionsInToolbar = toolbarActionsInToolbar.length > 0;
|
|
153
153
|
const toolbarEnd = hasHeaderSlot ||
|
|
@@ -239,9 +239,9 @@ function ToolbarActionView({ action, iconOnly = false, Button, Dialog, Banner, }
|
|
|
239
239
|
}
|
|
240
240
|
};
|
|
241
241
|
const variant = action.style ?? "secondary";
|
|
242
|
-
const needsConfirm =
|
|
242
|
+
const needsConfirm = needsActionConfirm(action);
|
|
243
243
|
const showIconOnly = iconOnly && action.icon !== undefined;
|
|
244
|
-
return (_jsxs(_Fragment, { children: [_jsx(Button, { variant: variant, loading: busy, ...(action.icon !== undefined && { icon: action.icon }), size: showIconOnly ? "icon" : "sm", ...(showIconOnly && { ariaLabel: action.label }), onClick: () => {
|
|
244
|
+
return (_jsxs(_Fragment, { children: [_jsx(Button, { variant: variant, loading: busy, ...(action.icon !== undefined && { icon: action.icon }), size: showIconOnly ? "icon" : "sm", ...(showIconOnly && { ariaLabel: action.label, title: action.label }), onClick: () => {
|
|
245
245
|
if (needsConfirm) {
|
|
246
246
|
setConfirmOpen(true);
|
|
247
247
|
}
|
|
@@ -2,24 +2,13 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
2
2
|
import { I18N_KEY_PARAM } from "@cosmicdrift/kumiko-headless";
|
|
3
3
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
4
4
|
import * as z from "zod";
|
|
5
|
-
import { REQUIRED_FIELD_I18N_KEY } from "../app/form-schema.js";
|
|
5
|
+
import { isPresent, REQUIRED_FIELD_I18N_KEY } from "../app/form-schema.js";
|
|
6
6
|
import { dispatcherErrorText } from "../app/write-failed-error.js";
|
|
7
7
|
import { useForm } from "../hooks/use-form.js";
|
|
8
8
|
import { useTranslation } from "../i18n.js";
|
|
9
9
|
import { STICKY_PRIMARY_ACTION_PROP, usePrimitives } from "../primitives.js";
|
|
10
10
|
import { GridCellForField } from "./grid-cell-for-field.js";
|
|
11
|
-
|
|
12
|
-
// — duplicated because that helper walks raw EditFieldSpec + EntityDefinition,
|
|
13
|
-
// unavailable here: this section only ever sees computeEditViewModel's
|
|
14
|
-
// already-resolved EditFieldViewModel[] (required/readOnly/visible are plain
|
|
15
|
-
// booleans by the time render-edit.tsx hands the section to this component).
|
|
16
|
-
function isPresent(value) {
|
|
17
|
-
if (value === undefined || value === null || value === "")
|
|
18
|
-
return false;
|
|
19
|
-
if (Array.isArray(value) && value.length === 0)
|
|
20
|
-
return false;
|
|
21
|
-
return true;
|
|
22
|
-
}
|
|
11
|
+
import { hasIssueWithoutVisibleField } from "./render-edit-logic.js";
|
|
23
12
|
function buildWriteFormSchema(fields) {
|
|
24
13
|
return z
|
|
25
14
|
.object({})
|
|
@@ -71,11 +60,14 @@ export function WriteFormSection({ section, featureName, translate, hideTitle, o
|
|
|
71
60
|
}
|
|
72
61
|
if (result.validationBlocked)
|
|
73
62
|
return;
|
|
74
|
-
// Field-level issues
|
|
75
|
-
// GridCellForField/RenderField path RenderEdit uses) — the banner
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
63
|
+
// Field-level issues surface inline via snapshot.errors (same
|
|
64
|
+
// GridCellForField/RenderField path RenderEdit uses) — the banner stays
|
|
65
|
+
// for anything no rendered field can show (`version`, `id`, a root
|
|
66
|
+
// refine, a hidden field).
|
|
67
|
+
const issuePaths = (result.error.details?.fields ?? []).map((i) => i.path);
|
|
68
|
+
setError(hasIssueWithoutVisibleField(issuePaths, section.fields)
|
|
69
|
+
? dispatcherErrorText(result.error, effectiveTranslate)
|
|
70
|
+
: null);
|
|
79
71
|
}
|
|
80
72
|
finally {
|
|
81
73
|
setIsSubmitting(false);
|
|
@@ -106,10 +98,10 @@ export function WriteFormSection({ section, featureName, translate, hideTitle, o
|
|
|
106
98
|
return (_jsx(Card, { options: { framed: false }, slots: {
|
|
107
99
|
...(section.description !== undefined && { subtitle: section.description }),
|
|
108
100
|
headerActions: titleRowActions,
|
|
109
|
-
}, testId: `write-form-${section.title ??
|
|
101
|
+
}, testId: `write-form-${section.title ?? section.handler}`, children: content }));
|
|
110
102
|
}
|
|
111
103
|
// Routed through Section's `actions` slot (same mechanism render-edit.tsx
|
|
112
104
|
// uses via Form's `actions`) so the button gets the established right-
|
|
113
105
|
// aligned footer treatment instead of stretching full-width inline.
|
|
114
|
-
return (_jsx(Section, { ...(section.title !== undefined && { title: section.title }), ...(section.description !== undefined && { subtitle: section.description }), ...(section.icon !== undefined && { icon: section.icon }), actions: titleRowActions, testId: `write-form-${section.title ??
|
|
106
|
+
return (_jsx(Section, { ...(section.title !== undefined && { title: section.title }), ...(section.description !== undefined && { subtitle: section.description }), ...(section.icon !== undefined && { icon: section.icon }), actions: titleRowActions, testId: `write-form-${section.title ?? section.handler}`, children: content }));
|
|
115
107
|
}
|