@cosmicdrift/kumiko-renderer 0.332.0 → 0.334.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/config-edit-shim.d.ts +7 -0
- package/dist/app/config-edit-shim.js +39 -0
- package/dist/app/facet-count-bridge.d.ts +10 -0
- package/dist/app/facet-count-bridge.js +20 -0
- package/dist/app/kumiko-screen.js +103 -42
- package/dist/app/list-facets.d.ts +14 -2
- package/dist/app/list-facets.js +74 -0
- package/dist/app/query-state-banners.d.ts +8 -0
- package/dist/app/query-state-banners.js +14 -0
- package/dist/app/row-actions.js +50 -40
- package/dist/app/secret-mint-body.js +3 -3
- package/dist/app/secrets-edit-body.js +46 -17
- package/dist/components/grid-cell-for-field.d.ts +7 -2
- package/dist/components/grid-cell-for-field.js +2 -2
- package/dist/components/query-options-select.d.ts +12 -3
- package/dist/components/query-options-select.js +45 -2
- package/dist/components/related-list-section.js +41 -8
- package/dist/components/render-edit-types.d.ts +24 -1
- package/dist/components/render-edit.js +100 -28
- package/dist/components/render-field.d.ts +7 -1
- package/dist/components/render-field.js +32 -4
- package/dist/components/render-list.d.ts +4 -1
- package/dist/components/render-list.js +2 -2
- package/dist/components/write-form-section.d.ts +5 -1
- package/dist/components/write-form-section.js +17 -5
- package/dist/context/embedded-screen-context.d.ts +5 -0
- package/dist/context/embedded-screen-context.js +12 -0
- package/dist/hooks/use-list-url-state.d.ts +2 -1
- package/dist/hooks/use-list-url-state.js +23 -9
- package/dist/i18n-defaults.js +31 -5
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/primitives.d.ts +95 -13
- package/package.json +5 -5
- package/src/changes.json +7 -0
|
@@ -6,6 +6,7 @@ import { AllFieldsRequiredProvider } from "./render-field.js";
|
|
|
6
6
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
7
7
|
import { ExtensionFormRegistryProvider, useExtensionFormHost, } from "../app/extension-form-submit.js";
|
|
8
8
|
import { extensionSectionName, useExtensionSectionComponent } from "../app/extension-sections.js";
|
|
9
|
+
import { layoutEditFields } from "../app/layout-fields.js";
|
|
9
10
|
import { useOptionalDispatcher } from "../context/dispatcher-context.js";
|
|
10
11
|
import { useDraftStorage } from "../context/draft-storage-context.js";
|
|
11
12
|
import { formatWhen } from "../format-when.js";
|
|
@@ -145,8 +146,23 @@ function formSectionDomId(sectionIndex) {
|
|
|
145
146
|
export function resolveRecordTitle(value) {
|
|
146
147
|
return typeof value === "string" && value.trim() !== "" ? value : undefined;
|
|
147
148
|
}
|
|
149
|
+
const TITLE_PLACEHOLDER_PATTERN = /\{(\w+)\}/g;
|
|
150
|
+
// Falls back to the plain title while any placeholder value is still empty,
|
|
151
|
+
// so the header never shows "Add " mid-typing.
|
|
152
|
+
function resolveTitleTemplate(template, values, translate, fallback) {
|
|
153
|
+
if (template === undefined)
|
|
154
|
+
return fallback;
|
|
155
|
+
const placeholders = [...translate(template).matchAll(TITLE_PLACEHOLDER_PATTERN)].map((match) => match[1] ?? "");
|
|
156
|
+
const isFilled = (name) => {
|
|
157
|
+
const value = values[name];
|
|
158
|
+
return value !== undefined && value !== null && String(value).trim() !== "";
|
|
159
|
+
};
|
|
160
|
+
if (!placeholders.every(isFilled))
|
|
161
|
+
return fallback;
|
|
162
|
+
return translate(template, values);
|
|
163
|
+
}
|
|
148
164
|
export function RenderEdit(props) {
|
|
149
|
-
const { screen, entity, featureName, initial, writeCommand, translate: translateProp, ctx, schema, onSubmit, payloadMode = "values", buildPayload, serverFieldPathPrefix, onDelete, onCancel, onReload, onDirtyChange, onCopyLink, actions, onRelatedListDrawerAction, submitLabel, submitVariant, summary, labelAppendix, fieldAppendix, entityId: entityIdProp, extensionInitialValues, onChange, onControlsReady, fields: fieldsFilter, disabled = false, hideActions, valueDisplay = "form", hideSectionTitles, fillScreenHeight, headerRegion, buildSectionActions, } = 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;
|
|
150
166
|
const { customSubmit } = props;
|
|
151
167
|
// Translate-Fallback: wenn der Caller keine Translate-Fn übergibt,
|
|
152
168
|
// konsumieren wir den i18next-Context direkt. Sonst wären Field-
|
|
@@ -239,11 +255,15 @@ export function RenderEdit(props) {
|
|
|
239
255
|
// Submit-Config nur wenn der Caller einen writeCommand mitgibt; bei
|
|
240
256
|
// customSubmit-Pfad kommt der Form-Controller ohne Submit-Wiring,
|
|
241
257
|
// weil wir controller.submit() eh nicht rufen.
|
|
258
|
+
const omitFields = useMemo(() => layoutEditFields(screen)
|
|
259
|
+
.filter((spec) => spec.submit === false)
|
|
260
|
+
.map((spec) => spec.field), [screen]);
|
|
242
261
|
const submitConfig = writeCommand !== undefined
|
|
243
262
|
? {
|
|
244
263
|
type: writeCommand,
|
|
245
264
|
payloadMode,
|
|
246
265
|
...(buildPayload !== undefined && { buildPayload }),
|
|
266
|
+
...(omitFields.length > 0 && { omitFields }),
|
|
247
267
|
...(serverFieldPathPrefix !== undefined && { serverFieldPathPrefix }),
|
|
248
268
|
...(scopeFieldNames !== undefined && { validateScope: scopeFieldNames }),
|
|
249
269
|
}
|
|
@@ -549,6 +569,11 @@ export function RenderEdit(props) {
|
|
|
549
569
|
// keeps normal document-flow height untouched.
|
|
550
570
|
const fillHeight = fillScreenHeight === true ||
|
|
551
571
|
(hideSectionTitles === true && filteredSections[0]?.kind === "relatedList");
|
|
572
|
+
// A lone writeForm tab is the whole page, so its submit joins this form's
|
|
573
|
+
// footer (pinned to the window edge like every other screen's save)
|
|
574
|
+
// instead of sitting in the section's title row.
|
|
575
|
+
const isLoneWriteFormTab = hideSectionTitles === true && filteredSections[0]?.kind === "writeForm";
|
|
576
|
+
const [writeFormFooterAction, setWriteFormFooterAction] = useState(undefined);
|
|
552
577
|
// Persistiert alle composed Extension-Sections mit der aufgelösten entityId.
|
|
553
578
|
// false = eine Section schlug fehl (ihr i18n-Key landet im Banner). Ohne
|
|
554
579
|
// Entity-Kontext (create-mode ohne route-id) gibt es nichts zu schreiben.
|
|
@@ -852,10 +877,7 @@ export function RenderEdit(props) {
|
|
|
852
877
|
handleSubmitRef.current = handleSubmit;
|
|
853
878
|
// Screen forms (entityEdit/actionForm filling the shell height) drop the card;
|
|
854
879
|
// the title then lives in the shell header when the shell offers a slot.
|
|
855
|
-
const isScreenForm = fillScreenHeight === true &&
|
|
856
|
-
hideSectionTitles !== true &&
|
|
857
|
-
headerRegion === undefined &&
|
|
858
|
-
!insideDrawer;
|
|
880
|
+
const isScreenForm = fillScreenHeight === true && hideSectionTitles !== true && !insideDrawer;
|
|
859
881
|
const titleInShell = PageHeader !== undefined &&
|
|
860
882
|
pageHeaderSlotAvailable &&
|
|
861
883
|
hideSectionTitles !== true &&
|
|
@@ -940,13 +962,41 @@ export function RenderEdit(props) {
|
|
|
940
962
|
const hasFormActions = (isWizard && currentStep > 0) ||
|
|
941
963
|
(isWizard && !isLastWizardStep) ||
|
|
942
964
|
(showsSubmit && (!isWizard || isLastWizardStep)) ||
|
|
943
|
-
footerSlot !== undefined
|
|
965
|
+
footerSlot !== undefined ||
|
|
966
|
+
writeFormFooterAction !== undefined;
|
|
944
967
|
const nextStepTitle = isWizard ? filteredSections[currentStep + 1]?.title : undefined;
|
|
968
|
+
const isCreate = (() => {
|
|
969
|
+
const id = resolveExtensionEntityId(entityIdProp, vm.id);
|
|
970
|
+
return id == null || id === "";
|
|
971
|
+
})();
|
|
972
|
+
const changedFieldCount = Object.keys(snapshot.changes).length;
|
|
973
|
+
// Edit-mode screen forms always carry the dirty status (0 = quiet "No unsaved
|
|
974
|
+
// changes"); wizards keep the status-only-when-dirty behaviour.
|
|
975
|
+
const hasDirtyFooter = !isWizard &&
|
|
976
|
+
(dirtyFooter === true || (isScreenForm && !isCreate && headerRegion === undefined));
|
|
977
|
+
const unsavedCount = !hasDirtyFooter
|
|
978
|
+
? undefined
|
|
979
|
+
: changedFieldCount > 0
|
|
980
|
+
? changedFieldCount
|
|
981
|
+
: extensionDirty
|
|
982
|
+
? 1
|
|
983
|
+
: 0;
|
|
984
|
+
const isSettingsList = screen.layout.variant === "settings-list" && hideSectionTitles !== true;
|
|
945
985
|
const formActions = (_jsxs(_Fragment, { children: [isWizard && currentStep > 0 && (_jsx(Button, { type: "button", variant: "secondary", icon: "chevron-left", onClick: handleWizardBack, testId: "render-edit-wizard-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: snapshot.isDirty || extensionDirty, 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") })), isWizard && !isLastWizardStep && (_jsx(Button, { type: "submit", variant: "primary", iconEnd: "chevron-right", testId: "render-edit-wizard-next", children: nextStepTitle !== undefined
|
|
946
986
|
? translate("kumiko.wizard.next-with-title", { title: nextStepTitle })
|
|
947
|
-
: translate("kumiko.actions.next") })), showsSubmit &&
|
|
987
|
+
: translate("kumiko.actions.next") })), showsSubmit &&
|
|
988
|
+
(!isWizard || isLastWizardStep) &&
|
|
989
|
+
footerActions?.map((action) => (_jsx(Button, { type: "button", variant: action.variant ?? "secondary", ...(action.icon !== undefined && { icon: action.icon }), disabled: isSubmitting || disabled, onClick: () => {
|
|
990
|
+
controller.setValues(action.patch); // @cast-boundary form-values
|
|
991
|
+
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") })), showsSubmit && (!isWizard || isLastWizardStep) && (_jsx(Button, { type: "submit", disabled: (snapshot.isUnchanged && !extensionDirty && !isFieldless && !insideDrawer) ||
|
|
948
993
|
isSubmitting ||
|
|
949
|
-
disabled, loading: isSubmitting, variant: submitVariant ?? "primary", testId: "render-edit-submit", children: translate(submitLabel ??
|
|
994
|
+
disabled, loading: isSubmitting, variant: submitVariant ?? "primary", testId: "render-edit-submit", children: translate(submitLabel ??
|
|
995
|
+
(isWizard
|
|
996
|
+
? "kumiko.actions.finish"
|
|
997
|
+
: hasDirtyFooter
|
|
998
|
+
? "kumiko.form.saveChanges"
|
|
999
|
+
: "kumiko.actions.save")) })), writeFormFooterAction] }));
|
|
950
1000
|
// Title + Subtitle, create/edit-bewusst. i18n-Keys (mode = "create"|"edit"):
|
|
951
1001
|
// screen:<id>.<mode>.title / .<mode>.subtitle
|
|
952
1002
|
// Fallback-Kette: mode-spezifisch → generisch (screen:<id>.title/.subtitle).
|
|
@@ -957,10 +1007,6 @@ export function RenderEdit(props) {
|
|
|
957
1007
|
// prose, same as it does for an unknown key (fw#2723 review). Same slot
|
|
958
1008
|
// section.description already fills for a section. Falls back to
|
|
959
1009
|
// undefined (no subtitle) when neither is set.
|
|
960
|
-
const isCreate = (() => {
|
|
961
|
-
const id = resolveExtensionEntityId(entityIdProp, vm.id);
|
|
962
|
-
return id == null || id === "";
|
|
963
|
-
})();
|
|
964
1010
|
const formMode = isCreate ? "create" : "edit";
|
|
965
1011
|
const recordTitle = !isCreate && screen.recordTitleField !== undefined
|
|
966
1012
|
? resolveRecordTitle(initial[screen.recordTitleField])
|
|
@@ -976,24 +1022,22 @@ export function RenderEdit(props) {
|
|
|
976
1022
|
}
|
|
977
1023
|
return undefined;
|
|
978
1024
|
};
|
|
979
|
-
const
|
|
1025
|
+
const plainFormTitle = resolveScreenText("title") ?? screen.id;
|
|
1026
|
+
const formTitle = resolveTitleTemplate(screen.titleTemplate, snapshot.values, translate, plainFormTitle);
|
|
980
1027
|
// screen.description is head-card copy, not tab content — in tabs mode
|
|
981
1028
|
// (hideSectionTitles) the head card already carries title/subtitle/status.
|
|
982
1029
|
const formSubtitle = hideSectionTitles !== true
|
|
983
1030
|
? (resolveScreenText("subtitle") ??
|
|
984
1031
|
(screen.description !== undefined ? translate(screen.description) : undefined))
|
|
985
1032
|
: undefined;
|
|
1033
|
+
// Embedded settings-list forms (dashboard panels) have no shell header: the
|
|
1034
|
+
// form title/subtitle move into the first section's header column instead of
|
|
1035
|
+
// floating as a heading above the band.
|
|
1036
|
+
const titleInSettingsHeader = isSettingsList && !titleInShell;
|
|
986
1037
|
const usesFieldFlow = isScreenForm || insideDrawer;
|
|
987
1038
|
const allRequiredHint = (_jsx(Text, { variant: "muted", testId: "render-edit-all-required-hint", children: translate("kumiko.form.all-required") }));
|
|
988
1039
|
const stepRail = isWizard && isScreenForm && StepBar !== undefined;
|
|
989
|
-
const
|
|
990
|
-
const unsavedCount = isCreate || !isScreenForm
|
|
991
|
-
? 0
|
|
992
|
-
: changedFieldCount > 0
|
|
993
|
-
? changedFieldCount
|
|
994
|
-
: extensionDirty
|
|
995
|
-
? 1
|
|
996
|
-
: 0;
|
|
1040
|
+
const upNextSection = screen.layout.wizard?.aside?.upNext === true ? filteredSections[currentStep + 1] : undefined;
|
|
997
1041
|
const sectionNavItems = isScreenForm && !isStepped
|
|
998
1042
|
? filteredSections.flatMap((section, index) => section.kind !== "extension" &&
|
|
999
1043
|
section.kind !== "relatedList" &&
|
|
@@ -1019,11 +1063,23 @@ export function RenderEdit(props) {
|
|
|
1019
1063
|
!pageHeaderCompact &&
|
|
1020
1064
|
ActionOverflowMenu !== undefined && {
|
|
1021
1065
|
actions: (_jsx(ActionOverflowMenu, { label: translate("kumiko.list.row-actions.more"), items: headerMenuItems, testId: "render-edit-header-menu" })),
|
|
1022
|
-
}) })), _jsx(Form, { onSubmit: () => void handleSubmit(), ...(hideSectionTitles !== true &&
|
|
1066
|
+
}) })), _jsx(Form, { onSubmit: () => void handleSubmit(), ...(hideSectionTitles !== true &&
|
|
1067
|
+
!titleInShell &&
|
|
1068
|
+
!titleInSettingsHeader && { title: formTitle }), ...(formSubtitle !== undefined &&
|
|
1069
|
+
!stepRail &&
|
|
1070
|
+
!titleInSettingsHeader && { subtitle: formSubtitle }), ...(isScreenForm && { screenForm: true }), ...(unsavedCount !== undefined &&
|
|
1071
|
+
(unsavedCount > 0 || hasDirtyFooter) && { unsavedCount }), ...(sectionNavItems.length > 0 && { sectionNav: sectionNavItems }), ...(stepRail && {
|
|
1023
1072
|
sideRail: (_jsx(StepBar, { steps: filteredSections.map((section) => section.title ?? ""), currentIndex: currentStep, compactLabel: compactStepLabel, onStepSelect: handleWizardJumpBack, orientation: "vertical", heading: translate("kumiko.wizard.step", {
|
|
1024
1073
|
current: currentStep + 1,
|
|
1025
1074
|
total: lastStepIndex + 1,
|
|
1026
|
-
}), ...(formSubtitle !== undefined && { description: formSubtitle }),
|
|
1075
|
+
}), ...(formSubtitle !== undefined && { description: formSubtitle }), subtitles: filteredSections.map((section) => section.kind === "fields" ? section.subtitle : undefined), ...(upNextSection !== undefined && {
|
|
1076
|
+
upNext: {
|
|
1077
|
+
heading: translate("kumiko.wizard.up-next"),
|
|
1078
|
+
title: upNextSection.title ?? "",
|
|
1079
|
+
...(upNextSection.kind === "fields" &&
|
|
1080
|
+
upNextSection.subtitle !== undefined && { subtitle: upNextSection.subtitle }),
|
|
1081
|
+
},
|
|
1082
|
+
}), testId: "render-edit-wizard-steps", compactTestId: "render-edit-wizard-step-label" })),
|
|
1027
1083
|
}), ...(hideActions !== true && hasFormActions && { actions: formActions }), ...(hideActions !== true &&
|
|
1028
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: [
|
|
1029
1085
|
...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))),
|
|
@@ -1119,19 +1175,29 @@ export function RenderEdit(props) {
|
|
|
1119
1175
|
// Own submit button + dispatcher call, entirely independent of
|
|
1120
1176
|
// this screen's (nonexistent, on projectionDetail) form submit —
|
|
1121
1177
|
// rejected at boot in wizard layouts, so no WizardStepGroup here.
|
|
1122
|
-
return (_jsx(WriteFormSection, { section: section, featureName: featureName, translate: translate, hideTitle: hideSectionTitles, onSubmitted: () => onReload?.(), ...(sectionActionsEl !== undefined && { actions: sectionActionsEl }) }, section.title ?? `write-form-${sectionIndex}`));
|
|
1178
|
+
return (_jsx(WriteFormSection, { section: section, featureName: featureName, translate: translate, hideTitle: hideSectionTitles, onSubmitted: () => onReload?.(), ...(isLoneWriteFormTab && { onFooterAction: setWriteFormFooterAction }), ...(sectionActionsEl !== undefined && { actions: sectionActionsEl }) }, section.title ?? `write-form-${sectionIndex}`));
|
|
1123
1179
|
}
|
|
1124
1180
|
if (!section.visible)
|
|
1125
1181
|
return null;
|
|
1126
1182
|
// Titellose Sections kollidieren sonst auf key/testId — Index-Fallback.
|
|
1127
1183
|
const sectionKey = section.title ?? `section-${sectionIndex}`;
|
|
1128
|
-
const renderFieldGrid = (fields, columns, gridKey) => (_jsx(Grid, { columns: columns, ...(usesFieldFlow && { flow: true }), children: fields.map((field) => (_jsx(GridCellForField, { field: disabled ? { ...field, readOnly: true } : field, columns: columns, ...(usesFieldFlow && { flow: true }),
|
|
1184
|
+
const renderFieldGrid = (fields, columns, gridKey) => (_jsx(Grid, { columns: columns, ...(isSettingsList ? { list: true } : usesFieldFlow && { flow: true }), children: fields.map((field) => (_jsx(GridCellForField, { field: disabled ? { ...field, readOnly: true } : field, columns: columns, ...(!isSettingsList && usesFieldFlow && { flow: true }), ...(isSettingsList && {
|
|
1185
|
+
layout: "row",
|
|
1186
|
+
accent: fieldAccent?.(field.field) === true,
|
|
1187
|
+
}), issues: snapshot.errors[field.field], onChange: (v) => {
|
|
1129
1188
|
controller.setField(field.field, v);
|
|
1189
|
+
if (validateOnChange === true)
|
|
1190
|
+
controller.validate([field.field]);
|
|
1130
1191
|
}, GridCell: GridCell, featureName: featureName, ...(labelAppendix !== undefined && {
|
|
1131
1192
|
labelAppendix: labelAppendix(field.field),
|
|
1132
1193
|
}), ...(fieldAppendix !== undefined && {
|
|
1133
1194
|
fieldAppendix: fieldAppendix(field.field),
|
|
1134
|
-
}),
|
|
1195
|
+
}), ...(fieldAppendixPlacement !== undefined && {
|
|
1196
|
+
appendixPlacement: fieldAppendixPlacement,
|
|
1197
|
+
}), ...(fieldDescription !== undefined && {
|
|
1198
|
+
description: fieldDescription(field.field),
|
|
1199
|
+
}), allIssues: snapshot.errors, valueDisplay: valueDisplay, row: snapshot.values, ...((!isCreate || markChangedFields) &&
|
|
1200
|
+
field.field in snapshot.changes && { changed: true }) }, field.field))) }, gridKey));
|
|
1135
1201
|
// Tabs mode: the tab panel is the surface, so field sections render
|
|
1136
1202
|
// as unframed padded panels; group cards inside stay framed.
|
|
1137
1203
|
// Stays `Card`, not `Section` — Section always flattens to a
|
|
@@ -1162,10 +1228,16 @@ export function RenderEdit(props) {
|
|
|
1162
1228
|
// tab label right above it. Without a Tabs primitive the sections
|
|
1163
1229
|
// render stacked, and then the titles are what tells them apart.
|
|
1164
1230
|
const repeatsTabLabel = isTabs && isStepped;
|
|
1165
|
-
const
|
|
1231
|
+
const headerFallback = titleInSettingsHeader && sectionIndex === 0;
|
|
1232
|
+
const sectionTitle = headerFallback
|
|
1233
|
+
? (section.title ?? formTitle)
|
|
1234
|
+
: section.title === formTitle || repeatsTabLabel
|
|
1235
|
+
? undefined
|
|
1236
|
+
: section.title;
|
|
1237
|
+
const sectionSubtitle = section.description ?? (headerFallback ? formSubtitle : undefined);
|
|
1166
1238
|
const sectionEl = (_jsxs(Section, { ...(sectionNavItems.some((item) => item.id === formSectionDomId(sectionIndex)) && {
|
|
1167
1239
|
id: formSectionDomId(sectionIndex),
|
|
1168
|
-
}), ...(sectionTitle !== undefined && { title: sectionTitle }), ...(
|
|
1240
|
+
}), ...(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));
|
|
1169
1241
|
return wrapWizardStep(sectionKey, sectionEl);
|
|
1170
1242
|
}), formError !== null && (_jsx(Banner, { variant: "error", testId: "render-edit-form-error", actions: formError.code === "version_conflict" && onReload !== undefined ? (_jsx(Button, { variant: "secondary", onClick: () => {
|
|
1171
1243
|
onReload();
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type EditFieldViewModel, type FieldIssue } from "@cosmicdrift/kumiko-headless";
|
|
2
2
|
import { type ReactNode } from "react";
|
|
3
|
+
import { type FieldProps } from "../primitives.js";
|
|
3
4
|
export type RenderFieldProps = {
|
|
4
5
|
readonly field: EditFieldViewModel;
|
|
5
6
|
readonly issues?: readonly FieldIssue[];
|
|
@@ -14,6 +15,9 @@ export type RenderFieldProps = {
|
|
|
14
15
|
/** Optionaler Zusatz-Inhalt der nach dem Input gerendert wird (z.B.
|
|
15
16
|
* ConfigCascade). */
|
|
16
17
|
readonly fieldAppendix?: ReactNode;
|
|
18
|
+
readonly appendixPlacement?: FieldProps["appendixPlacement"];
|
|
19
|
+
readonly description?: ReactNode;
|
|
20
|
+
readonly status?: FieldProps["status"];
|
|
17
21
|
/** Flat issues-by-path map (FormSnapshot.errors) — only relevant for
|
|
18
22
|
* type:"embedded" with embeddedListCells, to bucket row-/cell-issues
|
|
19
23
|
* (`${field}.${rowIndex}` / `${field}.${rowIndex}.${cellField}`).
|
|
@@ -33,6 +37,8 @@ export type RenderFieldProps = {
|
|
|
33
37
|
readonly row?: Readonly<Record<string, unknown>>;
|
|
34
38
|
/** Marks the field as modified against the loaded record (edit mode only). */
|
|
35
39
|
readonly changed?: boolean;
|
|
40
|
+
readonly layout?: FieldProps["layout"];
|
|
41
|
+
readonly accent?: boolean;
|
|
36
42
|
};
|
|
37
43
|
export declare const AllFieldsRequiredProvider: import("react").Provider<boolean>;
|
|
38
|
-
export declare function RenderField({ field, issues, onChange, featureName, labelAppendix, fieldAppendix, allIssues, valueDisplay, row, changed, }: RenderFieldProps): ReactNode;
|
|
44
|
+
export declare function RenderField({ field, issues, onChange, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay, row, changed, layout, accent, }: RenderFieldProps): ReactNode;
|
|
@@ -19,7 +19,7 @@ import { QueryOptionsSelect } from "./query-options-select.js";
|
|
|
19
19
|
import { ReferenceCreateDialog } from "./reference-create-dialog.js";
|
|
20
20
|
const AllFieldsRequiredContext = createContext(false);
|
|
21
21
|
export const AllFieldsRequiredProvider = AllFieldsRequiredContext.Provider;
|
|
22
|
-
export function RenderField({ field, issues, onChange, featureName, labelAppendix, fieldAppendix, allIssues, valueDisplay = "form", row, changed, }) {
|
|
22
|
+
export function RenderField({ field, issues, onChange, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay = "form", row, changed, layout, accent, }) {
|
|
23
23
|
const { Field, Input, Banner, Text, JsonView } = usePrimitives();
|
|
24
24
|
// App-Locale (i18n) für money/date-Inputs — sonst fielen sie auf
|
|
25
25
|
// navigator.language (Browser-Sprache) zurück statt der gewählten
|
|
@@ -42,7 +42,7 @@ export function RenderField({ field, issues, onChange, featureName, labelAppendi
|
|
|
42
42
|
// Reference-Field rendert eine eigene Component — sie nutzt
|
|
43
43
|
// useQuery() für den Live-Lookup, also muss sie als React-
|
|
44
44
|
// Komponente gemountet werden (nicht als pure render-Call).
|
|
45
|
-
const control = field.readOnly && field.renderer !== undefined ? (_jsx(FieldRendererOutput, { field: field, renderer: field.renderer, appLocale: appLocale, ...(row !== undefined && { row }) })) : field.type === "embedded" && field.embeddedListCells !== undefined ? (_jsx(EmbeddedListField, { field: field, id: id, onChange: onChange, allIssues: allIssues ?? {}, featureName: featureName ?? "" })) : field.type === "reference" ? (readOnlyText ? (_jsx(ReadOnlyReferenceValue, { field: field, featureName: featureName ?? "" })) : (_jsx(ReferenceInput, { field: field, id: id, hasError: hasError, onChange: onChange, Input: Input, featureName: featureName ?? "" }))) : field.type === "select" && field.selectOptionsQuery !== undefined && !readOnlyText ? (_jsx(QueryOptionsSelect, { field: field, query: field.selectOptionsQuery.query, payload: field.selectOptionsQuery.payload, id: id, hasError: hasError, onChange: onChange })) : readOnlyText && !isComplexFieldType(field.type) ? (_jsx(Text, { testId: `field-value-${field.field}`, children: readOnlyDisplayText(field, appLocale) })) : (renderInput({
|
|
45
|
+
const control = field.readOnly && field.renderer !== undefined ? (_jsx(FieldRendererOutput, { field: field, renderer: field.renderer, appLocale: appLocale, ...(row !== undefined && { row }) })) : field.type === "embedded" && field.embeddedListCells !== undefined ? (_jsx(EmbeddedListField, { field: field, id: id, onChange: onChange, allIssues: allIssues ?? {}, featureName: featureName ?? "" })) : field.type === "reference" ? (readOnlyText ? (_jsx(ReadOnlyReferenceValue, { field: field, featureName: featureName ?? "" })) : (_jsx(ReferenceInput, { field: field, id: id, hasError: hasError, onChange: onChange, Input: Input, featureName: featureName ?? "" }))) : field.type === "select" && field.selectOptionsQuery !== undefined && !readOnlyText ? (_jsx(QueryOptionsSelect, { field: field, query: field.selectOptionsQuery.query, payload: field.selectOptionsQuery.payload, row: row ?? { [field.field]: field.value }, id: id, hasError: hasError, onChange: onChange })) : readOnlyText && !isComplexFieldType(field.type) ? (_jsx(Text, { testId: `field-value-${field.field}`, children: readOnlyDisplayText(field, appLocale, t) })) : field.type === "text" && field.writeOnly === true && !readOnlyText ? (_jsx(WriteOnlyTextInput, { field: field, id: id, hasError: hasError, onChange: onChange, changed: changed === true })) : (renderInput({
|
|
46
46
|
field,
|
|
47
47
|
id,
|
|
48
48
|
hasError,
|
|
@@ -55,7 +55,33 @@ export function RenderField({ field, issues, onChange, featureName, labelAppendi
|
|
|
55
55
|
t,
|
|
56
56
|
row,
|
|
57
57
|
}));
|
|
58
|
-
return (_jsx(Field, { id: id, label: field.label, required: field.required && field.type !== "boolean" && !allFieldsRequired, ...(issues !== undefined && { issues }), ...(labelAppendix !== undefined && { labelAppendix }), ...(fieldAppendix !== undefined && { fieldAppendix }), ...(changed === true && { changed }), testId: `field-${field.field}`, children: control }));
|
|
58
|
+
return (_jsx(Field, { id: id, label: field.label, required: field.required && field.type !== "boolean" && !allFieldsRequired, ...(issues !== undefined && { issues }), ...(labelAppendix !== undefined && { labelAppendix }), ...(fieldAppendix !== undefined && { fieldAppendix }), ...(appendixPlacement !== undefined && { appendixPlacement }), ...(description !== undefined && { description }), ...(status !== undefined && { status }), ...(changed === true && { changed }), ...(layout !== undefined && { layout }), ...(accent === true && { accent }), testId: `field-${field.field}`, children: control }));
|
|
59
|
+
}
|
|
60
|
+
// Wire value of a writeOnly field is `true` (set) / `null` (empty), never the
|
|
61
|
+
// secret. Typing sets a new value; an untouched or emptied input submits
|
|
62
|
+
// nothing / "" which the server reads as "keep". `null` after a loaded `true`
|
|
63
|
+
// (changed) means "remove on save".
|
|
64
|
+
function WriteOnlyTextInput({ field, id, hasError, onChange, changed, }) {
|
|
65
|
+
const { Input } = usePrimitives();
|
|
66
|
+
const t = useTranslation();
|
|
67
|
+
const isSet = field.value === true;
|
|
68
|
+
const isMarkedForRemoval = field.value === null && changed;
|
|
69
|
+
if (isMarkedForRemoval) {
|
|
70
|
+
return (_jsx(Input, { kind: "password", id: id, name: field.field, disabled: true, hasError: hasError, value: "", onChange: () => { }, autoComplete: "new-password", placeholder: t("kumiko.field.writeOnly.willRemove"), testId: `field-write-only-removal-${field.field}`, trailingAction: {
|
|
71
|
+
icon: "undo",
|
|
72
|
+
label: t("kumiko.field.writeOnly.undo"),
|
|
73
|
+
onPress: () => onChange(true),
|
|
74
|
+
} }));
|
|
75
|
+
}
|
|
76
|
+
return (_jsx(Input, { kind: "password", id: id, name: field.field, disabled: field.readOnly, required: field.required && !isSet, hasError: hasError, value: typeof field.value === "string" ? field.value : "", onChange: (v) => onChange(v), autoComplete: "new-password", ...(isSet && { placeholder: t("kumiko.field.writeOnly.setPlaceholder") }), ...(isSet &&
|
|
77
|
+
!field.required &&
|
|
78
|
+
!field.readOnly && {
|
|
79
|
+
trailingAction: {
|
|
80
|
+
icon: "clear",
|
|
81
|
+
label: t("kumiko.field.writeOnly.remove"),
|
|
82
|
+
onPress: () => onChange(null),
|
|
83
|
+
},
|
|
84
|
+
}) }));
|
|
59
85
|
}
|
|
60
86
|
// Where a reference field's option rows come from, and which of their keys
|
|
61
87
|
// holds the display text — the entity's own list handler plus `refLabelField`
|
|
@@ -488,10 +514,12 @@ function locatedValue(v) {
|
|
|
488
514
|
// covered here — it needs a live label lookup, see ReadOnlyReferenceValue.
|
|
489
515
|
// `isComplexFieldType` types aren't covered either — callers keep those on
|
|
490
516
|
// `renderInput`'s existing Banner fallback.
|
|
491
|
-
function readOnlyDisplayText(field, appLocale) {
|
|
517
|
+
function readOnlyDisplayText(field, appLocale, t) {
|
|
492
518
|
const { type, value } = field;
|
|
493
519
|
if (value === undefined || value === null || value === "")
|
|
494
520
|
return "—";
|
|
521
|
+
if (type === "text" && field.writeOnly === true)
|
|
522
|
+
return t("kumiko.field.writeOnly.set");
|
|
495
523
|
if (type === "text" && field.format === "password")
|
|
496
524
|
return "••••••••";
|
|
497
525
|
switch (type) {
|
|
@@ -2,7 +2,7 @@ import type { EntityDefinition, EntityListScreenDefinition, IconKey } from "@cos
|
|
|
2
2
|
import type { ListRowViewModel, Translate } from "@cosmicdrift/kumiko-headless";
|
|
3
3
|
import { type ReactNode } from "react";
|
|
4
4
|
import type { ListSort } from "../hooks/use-list-url-state.js";
|
|
5
|
-
import { type DataTableDateRangeFacet, type DataTableFacet, type DataTableRowAction, type DataTableRowActionMode } from "../primitives.js";
|
|
5
|
+
import { type DataTableDateRangeFacet, type DataTableFacet, type DataTableProps, type DataTableRowAction, type DataTableRowActionMode, type DataTableRowGrouping } from "../primitives.js";
|
|
6
6
|
export type RenderListProps = {
|
|
7
7
|
readonly screen: EntityListScreenDefinition;
|
|
8
8
|
readonly entity: EntityDefinition;
|
|
@@ -65,6 +65,9 @@ export type RenderListProps = {
|
|
|
65
65
|
* KumikoScreen derives it from the actions themselves; without it the
|
|
66
66
|
* DataTable falls back to its adaptive default. */
|
|
67
67
|
readonly rowActionMode?: DataTableRowActionMode;
|
|
68
|
+
/** Forwarded to `DataTableProps.rowGrouping` / `rowTone`. */
|
|
69
|
+
readonly rowGrouping?: DataTableRowGrouping;
|
|
70
|
+
readonly rowTone?: DataTableProps["rowTone"];
|
|
68
71
|
/** Toolbar-Aktionen im List-Header — Resolved-Form (KumikoScreen baut
|
|
69
72
|
* das aus EntityListScreenDefinition.toolbarActions: navigate-target
|
|
70
73
|
* → useNav, handler-QN → dispatcher-Call). RenderList rendert die
|
|
@@ -13,7 +13,7 @@ function isHeaderPromotableToolbarAction(action) {
|
|
|
13
13
|
return (action.style === "primary" && action.confirm === undefined && action.confirmRequired === false);
|
|
14
14
|
}
|
|
15
15
|
export function RenderList(props) {
|
|
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, toolbarActions, filterFacets, filterValues, onFilterChange, onFilterReset, dateRangeFacets, onDateRangeChange, chromeless, scrollBody, screenPadding, expandedRowIds, onToggleRowExpanded, renderExpandedRow, } = props;
|
|
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;
|
|
17
17
|
// Wie RenderEdit: Translate-Fallback aus dem i18next-Context, sonst
|
|
18
18
|
// wären Column-Header raw i18n-Keys.
|
|
19
19
|
const t = useTranslation();
|
|
@@ -168,7 +168,7 @@ export function RenderList(props) {
|
|
|
168
168
|
const resolvedTitle = translate(titleKey);
|
|
169
169
|
const toolbarTitle = resolvedTitle === titleKey ? screen.id : resolvedTitle;
|
|
170
170
|
// ListSort = DataTableSort (use-list-url-state aliased) — kein Cast nötig.
|
|
171
|
-
return (_jsxs(_Fragment, { children: [createInPageHeader && PageHeader !== undefined && _jsx(PageHeader, { actions: createButton }), promotedActionButton !== undefined && PageHeader !== undefined && (_jsx(PageHeader, { actions: promotedActionButton })), referenceColumns.map((rc) => (_jsx(ReferenceLookupBridge, { field: rc.field, refEntity: rc.refEntity, labelField: rc.labelField, featureName: rc.refFeature, onMap: handleLookupMap }, rc.field))), _jsx(DataTable, { columns: enrichedVm.columns, rows: enrichedVm.rows, toolbarTitle: toolbarTitle, ...(onRowClick !== undefined && { onRowClick }), ...(composedEmptyState !== undefined && { emptyState: composedEmptyState }), ...(toolbarStart !== undefined && { toolbarStart }), ...(description !== undefined && !searchable && { toolbarDescription: description }), ...(toolbarEnd !== undefined && { toolbarEnd }), ...(sort !== undefined && { sort }), ...(onSortChange !== undefined && { onSortChange }), ...(pager !== undefined && { pager }), ...(onReachEnd !== undefined && { onReachEnd }), ...(loadingMore !== undefined && { loadingMore }), ...(hasMore !== undefined && { hasMore }), ...(itemNoun !== undefined && { itemNoun }), ...(rowActions !== undefined && { rowActions }), ...(rowActionMode !== undefined && { rowActionMode }), ...(filterFacets !== undefined && { filterFacets }), ...(filterValues !== undefined && { filterValues }), ...(onFilterChange !== undefined && { onFilterChange }), ...(onFilterReset !== undefined && { onFilterReset }), ...(dateRangeFacets !== undefined && { dateRangeFacets }), ...(onDateRangeChange !== undefined && { onDateRangeChange }), ...(chromeless !== undefined && { chromeless }), ...(scrollBody !== undefined && { scrollBody }), ...(screenPadding !== undefined && { screenPadding }), ...(expandedRowIds !== undefined && { expandedRowIds }), ...(onToggleRowExpanded !== undefined && { onToggleRowExpanded }), ...(renderExpandedRow !== undefined && { renderExpandedRow }), testId: "render-list-table" })] }));
|
|
171
|
+
return (_jsxs(_Fragment, { children: [createInPageHeader && PageHeader !== undefined && _jsx(PageHeader, { actions: createButton }), promotedActionButton !== undefined && PageHeader !== undefined && (_jsx(PageHeader, { actions: promotedActionButton })), referenceColumns.map((rc) => (_jsx(ReferenceLookupBridge, { field: rc.field, refEntity: rc.refEntity, labelField: rc.labelField, featureName: rc.refFeature, onMap: handleLookupMap }, rc.field))), _jsx(DataTable, { columns: enrichedVm.columns, rows: enrichedVm.rows, toolbarTitle: toolbarTitle, ...(onRowClick !== undefined && { onRowClick }), ...(composedEmptyState !== undefined && { emptyState: composedEmptyState }), ...(toolbarStart !== undefined && { toolbarStart }), ...(description !== undefined && !searchable && { toolbarDescription: description }), ...(toolbarEnd !== undefined && { toolbarEnd }), ...(sort !== undefined && { sort }), ...(onSortChange !== undefined && { onSortChange }), ...(pager !== undefined && { pager }), ...(onReachEnd !== undefined && { onReachEnd }), ...(loadingMore !== undefined && { loadingMore }), ...(hasMore !== undefined && { hasMore }), ...(itemNoun !== undefined && { itemNoun }), ...(rowActions !== undefined && { rowActions }), ...(rowActionMode !== undefined && { rowActionMode }), ...(rowGrouping !== undefined && { rowGrouping }), ...(rowTone !== undefined && { rowTone }), ...(filterFacets !== undefined && { filterFacets }), ...(filterValues !== undefined && { filterValues }), ...(onFilterChange !== undefined && { onFilterChange }), ...(onFilterReset !== undefined && { onFilterReset }), ...(dateRangeFacets !== undefined && { dateRangeFacets }), ...(onDateRangeChange !== undefined && { onDateRangeChange }), ...(chromeless !== undefined && { chromeless }), ...(scrollBody !== undefined && { scrollBody }), ...(screenPadding !== undefined && { screenPadding }), ...(expandedRowIds !== undefined && { expandedRowIds }), ...(onToggleRowExpanded !== undefined && { onToggleRowExpanded }), ...(renderExpandedRow !== undefined && { renderExpandedRow }), testId: "render-list-table" })] }));
|
|
172
172
|
}
|
|
173
173
|
// Header-Slot in der Listen-Toolbar (toolbarEnd): `screen.slots.header` ist
|
|
174
174
|
// eine PlatformComponent (`{ react: { __component: "X" } }`), aufgelöst über
|
|
@@ -13,5 +13,9 @@ export type WriteFormSectionProps = {
|
|
|
13
13
|
* rendered alongside (before) the section's own submit button in the
|
|
14
14
|
* title-row actions slot. */
|
|
15
15
|
readonly actions?: ReactNode;
|
|
16
|
+
/** Set when this section is a whole tab: the submit button is handed to
|
|
17
|
+
* the host form's footer (pinned like every other screen's save) instead
|
|
18
|
+
* of the section's title row. Called with undefined on unmount. */
|
|
19
|
+
readonly onFooterAction?: (action: ReactNode | undefined) => void;
|
|
16
20
|
};
|
|
17
|
-
export declare function WriteFormSection({ section, featureName, translate, hideTitle, onSubmitted, actions, }: WriteFormSectionProps): ReactNode;
|
|
21
|
+
export declare function WriteFormSection({ section, featureName, translate, hideTitle, onSubmitted, actions, onFooterAction, }: WriteFormSectionProps): ReactNode;
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { I18N_KEY_PARAM } from "@cosmicdrift/kumiko-headless";
|
|
3
|
-
import { useMemo, useState } from "react";
|
|
3
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
4
4
|
import * as z from "zod";
|
|
5
5
|
import { 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
|
-
import { usePrimitives } from "../primitives.js";
|
|
9
|
+
import { STICKY_PRIMARY_ACTION_PROP, usePrimitives } from "../primitives.js";
|
|
10
10
|
import { GridCellForField } from "./grid-cell-for-field.js";
|
|
11
11
|
// Same "has a value" rule as buildFormSchema's isPresent (app/form-schema.ts)
|
|
12
12
|
// — duplicated because that helper walks raw EditFieldSpec + EntityDefinition,
|
|
@@ -45,7 +45,7 @@ function buildWriteFormSchema(fields) {
|
|
|
45
45
|
// would render a second, invalid nested <form> inside the host's own form
|
|
46
46
|
// element — so this mounts its own independent useForm() controller and
|
|
47
47
|
// submits via a plain button click instead of native form submission.
|
|
48
|
-
export function WriteFormSection({ section, featureName, translate, hideTitle, onSubmitted, actions, }) {
|
|
48
|
+
export function WriteFormSection({ section, featureName, translate, hideTitle, onSubmitted, actions, onFooterAction, }) {
|
|
49
49
|
const { Section, Card, Grid, GridCell, Button, Banner } = usePrimitives();
|
|
50
50
|
const t = useTranslation();
|
|
51
51
|
const effectiveTranslate = translate ?? t;
|
|
@@ -85,8 +85,20 @@ export function WriteFormSection({ section, featureName, translate, hideTitle, o
|
|
|
85
85
|
// type="button" (not "submit") is load-bearing: this section is deliberately
|
|
86
86
|
// NOT a nested <form> (see the component doc above), so a "submit" type
|
|
87
87
|
// would instead trigger the host RenderEdit's own form submit.
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
// The footer copy of the button outlives this render, so it calls the
|
|
89
|
+
// latest handleSubmit through a ref instead of capturing a stale closure.
|
|
90
|
+
const handleSubmitRef = useRef(handleSubmit);
|
|
91
|
+
handleSubmitRef.current = handleSubmit;
|
|
92
|
+
const submitLabel = section.submitLabel ?? effectiveTranslate("kumiko.actions.save");
|
|
93
|
+
const submitButton = (_jsx(Button, { type: "button", variant: "primary", disabled: isSubmitting, loading: isSubmitting, onClick: () => void handleSubmitRef.current(), testId: "write-form-section-submit", [STICKY_PRIMARY_ACTION_PROP]: true, children: submitLabel }));
|
|
94
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: submitButton is rebuilt every render; isSubmitting and submitLabel are everything it shows.
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (onFooterAction === undefined)
|
|
97
|
+
return;
|
|
98
|
+
onFooterAction(submitButton);
|
|
99
|
+
return () => onFooterAction(undefined);
|
|
100
|
+
}, [onFooterAction, isSubmitting, submitLabel]);
|
|
101
|
+
const titleRowActions = onFooterAction !== undefined ? (actions) : actions !== undefined ? (_jsxs(_Fragment, { children: [actions, submitButton] })) : (submitButton);
|
|
90
102
|
// Section always flattens to a borderless divider when rendered inside
|
|
91
103
|
// RenderEdit's own <Form> in tabs mode (hideTitle) — an unframed Card panel
|
|
92
104
|
// stands in, like render-edit.tsx's tabs-mode fields/extension branches.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, useContext } from "react";
|
|
3
|
+
// A host that already frames and pads the embedded screen (dashboard panel
|
|
4
|
+
// with `chromeless`) marks the subtree, so list screens drop their own
|
|
5
|
+
// screen padding and card frame instead of stacking it on the host's.
|
|
6
|
+
const EmbeddedScreenContext = createContext(false);
|
|
7
|
+
export function EmbeddedScreenProvider({ children }) {
|
|
8
|
+
return _jsx(EmbeddedScreenContext, { value: true, children: children });
|
|
9
|
+
}
|
|
10
|
+
export function useIsEmbeddedScreen() {
|
|
11
|
+
return useContext(EmbeddedScreenContext);
|
|
12
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { DataTableSort, DataTableSortDir } from "../primitives.js";
|
|
2
|
+
export declare const NO_FILTER_URL_VALUE = "~";
|
|
2
3
|
export type ListSortDir = DataTableSortDir;
|
|
3
4
|
export type ListSort = DataTableSort;
|
|
4
5
|
export type ListUrlState = {
|
|
@@ -40,4 +41,4 @@ export type ListUrlStateApi = ListUrlState & {
|
|
|
40
41
|
}) => void;
|
|
41
42
|
};
|
|
42
43
|
export declare const PAGE_SIZE_OPTIONS: readonly number[];
|
|
43
|
-
export declare function useListUrlState(screenId: string): ListUrlStateApi;
|
|
44
|
+
export declare function useListUrlState(screenId: string, defaultFilters?: Readonly<Record<string, readonly string[]>>): ListUrlStateApi;
|
|
@@ -8,12 +8,18 @@
|
|
|
8
8
|
// <screenId>.dir — "asc" | "desc"
|
|
9
9
|
// <screenId>.q — search term (URL-encoded)
|
|
10
10
|
// <screenId>.page — 1-based page number (nur bei pagination="pages")
|
|
11
|
-
// <screenId>.f.<field> — Faceted-Filter: comma-joined selected values
|
|
11
|
+
// <screenId>.f.<field> — Faceted-Filter: comma-joined selected values;
|
|
12
|
+
// NO_FILTER_URL_VALUE = "no filter", chosen on purpose
|
|
13
|
+
// for a field that has a declared default
|
|
12
14
|
//
|
|
13
15
|
// Schreibt mit setSearchParams (replaceState — kein push), damit
|
|
14
16
|
// Sort/Filter-Toggles nicht die Browser-History fluten.
|
|
15
17
|
import { useCallback, useMemo } from "react";
|
|
16
18
|
import { useNav } from "../app/nav.js";
|
|
19
|
+
// A facet with a `defaultFilters` entry falls back to that default whenever
|
|
20
|
+
// its URL key is absent. Clearing it therefore has to leave a marker, or the
|
|
21
|
+
// default would come straight back.
|
|
22
|
+
export const NO_FILTER_URL_VALUE = "~";
|
|
17
23
|
// `.` als Trenner: lesbar (`?orders.sort=name`), kollidiert nicht mit
|
|
18
24
|
// üblichen Field-Namen (kebab- oder camelCase ohne Punkt). Boot-Validator
|
|
19
25
|
// pinnt screen.id ohne Punkt — siehe boot-validator entityList Section.
|
|
@@ -36,7 +42,7 @@ function parsePage(value) {
|
|
|
36
42
|
const n = Number.parseInt(value, 10);
|
|
37
43
|
return Number.isFinite(n) && n > 0 ? n : 1;
|
|
38
44
|
}
|
|
39
|
-
export function useListUrlState(screenId) {
|
|
45
|
+
export function useListUrlState(screenId, defaultFilters) {
|
|
40
46
|
const nav = useNav();
|
|
41
47
|
const params = nav.searchParams;
|
|
42
48
|
const sort = useMemo(() => {
|
|
@@ -60,10 +66,17 @@ export function useListUrlState(screenId) {
|
|
|
60
66
|
continue;
|
|
61
67
|
// ponytail: comma-join — Facet-Werte sind select-Options/booleans
|
|
62
68
|
// ohne Komma; upgrade auf repeated-keys falls je Komma-Werte nötig.
|
|
63
|
-
out[field] = v.split(",").filter((s) => s !== "");
|
|
69
|
+
out[field] = v === NO_FILTER_URL_VALUE ? [] : v.split(",").filter((s) => s !== "");
|
|
70
|
+
}
|
|
71
|
+
for (const [field, values] of Object.entries(defaultFilters ?? {})) {
|
|
72
|
+
if (!Object.hasOwn(out, field))
|
|
73
|
+
out[field] = values;
|
|
64
74
|
}
|
|
65
75
|
return out;
|
|
66
|
-
}, [params, filterPrefix]);
|
|
76
|
+
}, [params, filterPrefix, defaultFilters]);
|
|
77
|
+
const clearedFilterValue = useCallback((field) => defaultFilters !== undefined && Object.hasOwn(defaultFilters, field)
|
|
78
|
+
? NO_FILTER_URL_VALUE
|
|
79
|
+
: null, [defaultFilters]);
|
|
67
80
|
const setSort = useCallback((next) => {
|
|
68
81
|
// Atomares Update: bei jedem Sort-Wechsel resetten wir auch die
|
|
69
82
|
// Page (sonst wäre der User auf "Seite 5 von alter Sortierung"
|
|
@@ -97,10 +110,10 @@ export function useListUrlState(screenId) {
|
|
|
97
110
|
}, [nav, screenId]);
|
|
98
111
|
const setFilter = useCallback((field, values) => {
|
|
99
112
|
nav.setSearchParams({
|
|
100
|
-
[key(screenId, `f.${field}`)]: values.length === 0 ?
|
|
113
|
+
[key(screenId, `f.${field}`)]: values.length === 0 ? clearedFilterValue(field) : values.join(","),
|
|
101
114
|
[key(screenId, "page")]: null,
|
|
102
115
|
});
|
|
103
|
-
}, [nav, screenId]);
|
|
116
|
+
}, [nav, screenId, clearedFilterValue]);
|
|
104
117
|
const setDateRange = useCallback((field, next) => {
|
|
105
118
|
nav.setSearchParams({
|
|
106
119
|
[key(screenId, `f.${field}.from`)]: next.from === "" ? null : next.from,
|
|
@@ -110,10 +123,11 @@ export function useListUrlState(screenId) {
|
|
|
110
123
|
}, [nav, screenId]);
|
|
111
124
|
const clearFilters = useCallback(() => {
|
|
112
125
|
const updates = { [key(screenId, "page")]: null };
|
|
113
|
-
for (const field of Object.keys(filters))
|
|
114
|
-
updates[key(screenId, `f.${field}`)] =
|
|
126
|
+
for (const field of Object.keys(filters)) {
|
|
127
|
+
updates[key(screenId, `f.${field}`)] = clearedFilterValue(field);
|
|
128
|
+
}
|
|
115
129
|
nav.setSearchParams(updates);
|
|
116
|
-
}, [nav, screenId, filters]);
|
|
130
|
+
}, [nav, screenId, filters, clearedFilterValue]);
|
|
117
131
|
return {
|
|
118
132
|
sort,
|
|
119
133
|
q,
|