@cosmicdrift/kumiko-renderer 0.326.1 → 0.328.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/package.json +5 -5
- package/src/app/__tests__/entity-edit-fill-height.test.tsx +118 -0
- package/src/app/__tests__/entity-list-screen-padding.test.tsx +64 -47
- package/src/app/action-form-shim.ts +2 -0
- package/src/app/kumiko-screen.tsx +274 -87
- package/src/app/projection-detail-shim.ts +1 -0
- package/src/app/projection-list-shim.ts +3 -0
- package/src/app/row-actions.ts +6 -0
- package/src/changes.json +52 -0
- package/src/components/__tests__/related-list-section.test.tsx +71 -2
- package/src/components/__tests__/render-list-toolbar-icon-only.test.tsx +2 -2
- package/src/components/__tests__/write-form-section.test.tsx +1 -1
- package/src/components/grid-cell-for-field.tsx +30 -2
- package/src/components/related-list-section.tsx +53 -29
- package/src/components/render-edit-action-button.tsx +4 -1
- package/src/components/render-edit-types.ts +10 -0
- package/src/components/render-edit.tsx +564 -393
- package/src/components/render-field.tsx +11 -2
- package/src/components/render-list.tsx +45 -13
- package/src/components/write-form-section.tsx +0 -1
- package/src/hooks/__tests__/use-query.test.tsx +149 -0
- package/src/hooks/use-list-url-state.ts +37 -1
- package/src/hooks/use-query.ts +57 -23
- package/src/i18n-defaults.ts +27 -1
- package/src/index.ts +7 -0
- package/src/inside-drawer.ts +12 -0
- package/src/page-header-slot.ts +12 -0
- package/src/primitives.tsx +137 -16
- package/src/screen-fills-height.ts +3 -0
|
@@ -21,6 +21,7 @@ import type {
|
|
|
21
21
|
import { computeEditViewModel } from "@cosmicdrift/kumiko-headless";
|
|
22
22
|
import { RenderEditActionButton } from "./render-edit-action-button";
|
|
23
23
|
import type { RenderEditProps } from "./render-edit-types";
|
|
24
|
+
import { AllFieldsRequiredProvider } from "./render-field";
|
|
24
25
|
|
|
25
26
|
export type {
|
|
26
27
|
RenderEditAction,
|
|
@@ -37,7 +38,11 @@ import { useDraftStorage } from "../context/draft-storage-context";
|
|
|
37
38
|
import { formatWhen } from "../format-when";
|
|
38
39
|
import { useForm } from "../hooks/use-form";
|
|
39
40
|
import { useTranslation } from "../i18n";
|
|
41
|
+
import { useInsideDrawer } from "../inside-drawer";
|
|
42
|
+
import { usePageHeaderSlotAvailable } from "../page-header-slot";
|
|
40
43
|
import {
|
|
44
|
+
type ActionMenuItemSpec,
|
|
45
|
+
type FormSectionNavItem,
|
|
41
46
|
STICKY_PRIMARY_ACTION_PROP,
|
|
42
47
|
type StickyPrimaryActionMarker,
|
|
43
48
|
shouldRenderActionsIconOnly,
|
|
@@ -282,6 +287,10 @@ function EditSlotMount({
|
|
|
282
287
|
);
|
|
283
288
|
}
|
|
284
289
|
|
|
290
|
+
function formSectionDomId(sectionIndex: number): string {
|
|
291
|
+
return `form-section-${sectionIndex}`;
|
|
292
|
+
}
|
|
293
|
+
|
|
285
294
|
export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
286
295
|
props: RenderEditProps<TValues, TCtx>,
|
|
287
296
|
): ReactNode {
|
|
@@ -301,11 +310,13 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
301
310
|
onDelete,
|
|
302
311
|
onCancel,
|
|
303
312
|
onReload,
|
|
313
|
+
onDirtyChange,
|
|
304
314
|
onCopyLink,
|
|
305
315
|
actions,
|
|
306
316
|
onRelatedListDrawerAction,
|
|
307
317
|
submitLabel,
|
|
308
318
|
submitVariant,
|
|
319
|
+
summary,
|
|
309
320
|
labelAppendix,
|
|
310
321
|
fieldAppendix,
|
|
311
322
|
entityId: entityIdProp,
|
|
@@ -317,6 +328,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
317
328
|
hideActions,
|
|
318
329
|
valueDisplay = "form",
|
|
319
330
|
hideSectionTitles,
|
|
331
|
+
fillScreenHeight,
|
|
320
332
|
headerRegion,
|
|
321
333
|
buildSectionActions,
|
|
322
334
|
} = props;
|
|
@@ -402,7 +414,11 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
402
414
|
StepBar,
|
|
403
415
|
WizardStepGroup,
|
|
404
416
|
Tabs,
|
|
417
|
+
PageHeader,
|
|
418
|
+
ActionOverflowMenu,
|
|
405
419
|
} = usePrimitives();
|
|
420
|
+
const pageHeaderSlotAvailable = usePageHeaderSlotAvailable();
|
|
421
|
+
const insideDrawer = useInsideDrawer();
|
|
406
422
|
|
|
407
423
|
// Both stepped layouts show one section at a time and keep the rest mounted
|
|
408
424
|
// but inert. A tabs layout on a host without the Tabs primitive has no strip
|
|
@@ -595,6 +611,13 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
595
611
|
});
|
|
596
612
|
}, [snapshot, isSubmitting]);
|
|
597
613
|
|
|
614
|
+
const onDirtyChangeRef = useRef(onDirtyChange);
|
|
615
|
+
onDirtyChangeRef.current = onDirtyChange;
|
|
616
|
+
const hasUnsavedInput = snapshot.isDirty || extensionDirty;
|
|
617
|
+
useEffect(() => {
|
|
618
|
+
onDirtyChangeRef.current?.(hasUnsavedInput);
|
|
619
|
+
}, [hasUnsavedInput]);
|
|
620
|
+
|
|
598
621
|
useEffect(() => {
|
|
599
622
|
// skip: this screen does not persist a draft.
|
|
600
623
|
if (!draftEnabled || dispatcher === undefined) return;
|
|
@@ -733,6 +756,17 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
733
756
|
[vm.sections, fieldsFilter],
|
|
734
757
|
);
|
|
735
758
|
|
|
759
|
+
const allFieldsRequired = useMemo(() => {
|
|
760
|
+
const fillable = filteredSections.flatMap((section) =>
|
|
761
|
+
section.kind === "fields"
|
|
762
|
+
? section.fields.filter((field) => field.visible && !field.readOnly)
|
|
763
|
+
: [],
|
|
764
|
+
);
|
|
765
|
+
return fillable.length > 0 && fillable.every((field) => field.required);
|
|
766
|
+
}, [filteredSections]);
|
|
767
|
+
|
|
768
|
+
const showsAllRequiredHint = allFieldsRequired && hideSectionTitles !== true && !isWizard;
|
|
769
|
+
|
|
736
770
|
// true when editable fields exist or an extension opted into composed submit (fw#2359).
|
|
737
771
|
const isFormEditable = hasEditableSection(filteredSections);
|
|
738
772
|
// A fieldless form (input-less secretMint mint, fw#2838 — the secret is
|
|
@@ -751,7 +785,9 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
751
785
|
// of stretching the panel to the bottom (fw#2778). Any other layout —
|
|
752
786
|
// multiple sections, a non-relatedList tab, stacked (non-tabs) forms —
|
|
753
787
|
// keeps normal document-flow height untouched.
|
|
754
|
-
const fillHeight =
|
|
788
|
+
const fillHeight =
|
|
789
|
+
fillScreenHeight === true ||
|
|
790
|
+
(hideSectionTitles === true && filteredSections[0]?.kind === "relatedList");
|
|
755
791
|
|
|
756
792
|
// Persistiert alle composed Extension-Sections mit der aufgelösten entityId.
|
|
757
793
|
// false = eine Section schlug fehl (ihr i18n-Key landet im Banner). Ohne
|
|
@@ -934,14 +970,15 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
934
970
|
return idx === -1 ? undefined : idx;
|
|
935
971
|
}
|
|
936
972
|
|
|
937
|
-
async function handleSubmit(): Promise<void> {
|
|
973
|
+
async function handleSubmit(options?: { readonly saveNow?: boolean }): Promise<void> {
|
|
938
974
|
// Enter in the active step triggers the native form submit (Next is
|
|
939
975
|
// type="submit" for Enter support) — on intermediate steps that means
|
|
940
976
|
// "Next", not "Save". Checked BEFORE the `disabled` guard below:
|
|
941
977
|
// `disabled` means "no input/no write", not "no navigation" — a
|
|
942
978
|
// disabled wizard must still be steppable (handleWizardNext itself
|
|
943
|
-
// blocks validate()/saveDraft() while disabled).
|
|
944
|
-
|
|
979
|
+
// blocks validate()/saveDraft() while disabled). `saveNow` is the explicit
|
|
980
|
+
// "Save and close" button and skips the step advance.
|
|
981
|
+
if (isWizard && !isLastWizardStep && options?.saveNow !== true) {
|
|
945
982
|
handleWizardNext();
|
|
946
983
|
return;
|
|
947
984
|
}
|
|
@@ -1057,21 +1094,73 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1057
1094
|
}
|
|
1058
1095
|
handleSubmitRef.current = handleSubmit;
|
|
1059
1096
|
|
|
1097
|
+
// Screen forms (entityEdit/actionForm filling the shell height) drop the card;
|
|
1098
|
+
// the title then lives in the shell header when the shell offers a slot.
|
|
1099
|
+
const isScreenForm =
|
|
1100
|
+
fillScreenHeight === true &&
|
|
1101
|
+
hideSectionTitles !== true &&
|
|
1102
|
+
headerRegion === undefined &&
|
|
1103
|
+
!insideDrawer;
|
|
1104
|
+
const titleInShell =
|
|
1105
|
+
PageHeader !== undefined &&
|
|
1106
|
+
pageHeaderSlotAvailable &&
|
|
1107
|
+
hideSectionTitles !== true &&
|
|
1108
|
+
headerRegion === undefined;
|
|
1109
|
+
const menuPageHeaderActions = titleInShell && isScreenForm && ActionOverflowMenu !== undefined;
|
|
1110
|
+
const cancelInHeaderMenu = menuPageHeaderActions && isWizard && onCancel !== undefined;
|
|
1060
1111
|
// Two groups — wizard navigation on the right/top, record actions on the
|
|
1061
1112
|
// left/below; destructive action sits outermost, farthest from the target
|
|
1062
1113
|
// action.
|
|
1063
1114
|
const hasSecondaryFormActions =
|
|
1064
|
-
onDelete !== undefined ||
|
|
1065
|
-
onCopyLink !== undefined ||
|
|
1115
|
+
(!menuPageHeaderActions && (onDelete !== undefined || onCopyLink !== undefined)) ||
|
|
1066
1116
|
(actions !== undefined && actions.length > 0) ||
|
|
1067
|
-
onCancel !== undefined;
|
|
1117
|
+
(onCancel !== undefined && !cancelInHeaderMenu);
|
|
1068
1118
|
// Teil-C collapse rule: driven by the custom `actions` group only — a
|
|
1069
1119
|
// resolved true also folds the Copy-Link button (same visual group in the
|
|
1070
1120
|
// footer), but never Delete/Cancel, which keep their text regardless.
|
|
1071
1121
|
const iconOnlyMidActions = actions !== undefined && shouldRenderActionsIconOnly(actions);
|
|
1122
|
+
const headerMenuItems: readonly ActionMenuItemSpec[] = menuPageHeaderActions
|
|
1123
|
+
? [
|
|
1124
|
+
...(onCopyLink !== undefined
|
|
1125
|
+
? [
|
|
1126
|
+
{
|
|
1127
|
+
id: "copy-link",
|
|
1128
|
+
label: translate(
|
|
1129
|
+
linkCopied ? "kumiko.actions.copyLinkCopied" : "kumiko.actions.copyLink",
|
|
1130
|
+
),
|
|
1131
|
+
icon: "link" as const,
|
|
1132
|
+
onSelect: () => {
|
|
1133
|
+
void Promise.resolve(onCopyLink()).then(() => setLinkCopied(true));
|
|
1134
|
+
},
|
|
1135
|
+
},
|
|
1136
|
+
]
|
|
1137
|
+
: []),
|
|
1138
|
+
...(cancelInHeaderMenu
|
|
1139
|
+
? [
|
|
1140
|
+
{
|
|
1141
|
+
id: "cancel",
|
|
1142
|
+
label: translate("kumiko.actions.cancel"),
|
|
1143
|
+
icon: "x" as const,
|
|
1144
|
+
onSelect: () => onCancel(),
|
|
1145
|
+
},
|
|
1146
|
+
]
|
|
1147
|
+
: []),
|
|
1148
|
+
...(onDelete !== undefined
|
|
1149
|
+
? [
|
|
1150
|
+
{
|
|
1151
|
+
id: "delete",
|
|
1152
|
+
label: translate("kumiko.actions.delete"),
|
|
1153
|
+
icon: "trash" as const,
|
|
1154
|
+
variant: "danger" as const,
|
|
1155
|
+
onSelect: () => setConfirmDeleteOpen(true),
|
|
1156
|
+
},
|
|
1157
|
+
]
|
|
1158
|
+
: []),
|
|
1159
|
+
]
|
|
1160
|
+
: [];
|
|
1072
1161
|
const secondaryFormActions = (
|
|
1073
1162
|
<>
|
|
1074
|
-
{onDelete !== undefined && (
|
|
1163
|
+
{onDelete !== undefined && !menuPageHeaderActions && (
|
|
1075
1164
|
<Button
|
|
1076
1165
|
type="button"
|
|
1077
1166
|
variant="danger-ghost"
|
|
@@ -1083,7 +1172,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1083
1172
|
{translate("kumiko.actions.delete")}
|
|
1084
1173
|
</Button>
|
|
1085
1174
|
)}
|
|
1086
|
-
{onCopyLink !== undefined && (
|
|
1175
|
+
{onCopyLink !== undefined && !menuPageHeaderActions && (
|
|
1087
1176
|
<Button
|
|
1088
1177
|
type="button"
|
|
1089
1178
|
variant={iconOnlyMidActions ? "secondary" : "link"}
|
|
@@ -1115,11 +1204,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1115
1204
|
onError={setActionError}
|
|
1116
1205
|
/>
|
|
1117
1206
|
))}
|
|
1118
|
-
{onCancel !== undefined && (
|
|
1207
|
+
{onCancel !== undefined && !cancelInHeaderMenu && (
|
|
1119
1208
|
<Button
|
|
1120
1209
|
type="button"
|
|
1121
1210
|
variant="secondary"
|
|
1122
|
-
icon="x"
|
|
1123
1211
|
onClick={() => onCancel()}
|
|
1124
1212
|
testId="render-edit-cancel"
|
|
1125
1213
|
>
|
|
@@ -1182,13 +1270,14 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1182
1270
|
(isWizard && !isLastWizardStep) ||
|
|
1183
1271
|
(showsSubmit && (!isWizard || isLastWizardStep)) ||
|
|
1184
1272
|
footerSlot !== undefined;
|
|
1273
|
+
const nextStepTitle = isWizard ? filteredSections[currentStep + 1]?.title : undefined;
|
|
1185
1274
|
const formActions = (
|
|
1186
1275
|
<>
|
|
1187
1276
|
{isWizard && currentStep > 0 && (
|
|
1188
1277
|
<Button
|
|
1189
1278
|
type="button"
|
|
1190
1279
|
variant="secondary"
|
|
1191
|
-
icon="
|
|
1280
|
+
icon="chevron-left"
|
|
1192
1281
|
onClick={handleWizardBack}
|
|
1193
1282
|
testId="render-edit-wizard-back"
|
|
1194
1283
|
>
|
|
@@ -1208,25 +1297,40 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1208
1297
|
{...(screen.slots?.footerPrimary === true && { [STICKY_PRIMARY_ACTION_PROP]: true })}
|
|
1209
1298
|
/>
|
|
1210
1299
|
)}
|
|
1300
|
+
{isWizard && !isLastWizardStep && showsSubmit && !isCreateMode && (
|
|
1301
|
+
<Button
|
|
1302
|
+
type="button"
|
|
1303
|
+
variant="ghost"
|
|
1304
|
+
disabled={isSubmitting || disabled}
|
|
1305
|
+
onClick={() => void handleSubmit({ saveNow: true })}
|
|
1306
|
+
testId="render-edit-wizard-save-close"
|
|
1307
|
+
{...{ [STICKY_PRIMARY_ACTION_PROP]: true }}
|
|
1308
|
+
>
|
|
1309
|
+
{translate("kumiko.wizard.save-and-close")}
|
|
1310
|
+
</Button>
|
|
1311
|
+
)}
|
|
1211
1312
|
{isWizard && !isLastWizardStep && (
|
|
1212
1313
|
<Button
|
|
1213
1314
|
type="submit"
|
|
1214
1315
|
variant="primary"
|
|
1215
|
-
iconEnd="
|
|
1316
|
+
iconEnd="chevron-right"
|
|
1216
1317
|
testId="render-edit-wizard-next"
|
|
1217
1318
|
>
|
|
1218
|
-
{
|
|
1319
|
+
{nextStepTitle !== undefined
|
|
1320
|
+
? translate("kumiko.wizard.next-with-title", { title: nextStepTitle })
|
|
1321
|
+
: translate("kumiko.actions.next")}
|
|
1219
1322
|
</Button>
|
|
1220
1323
|
)}
|
|
1221
1324
|
{showsSubmit && (!isWizard || isLastWizardStep) && (
|
|
1222
1325
|
<Button
|
|
1223
1326
|
type="submit"
|
|
1224
1327
|
disabled={
|
|
1225
|
-
(snapshot.isUnchanged && !extensionDirty && !isFieldless
|
|
1328
|
+
(snapshot.isUnchanged && !extensionDirty && !isFieldless && !insideDrawer) ||
|
|
1329
|
+
isSubmitting ||
|
|
1330
|
+
disabled
|
|
1226
1331
|
}
|
|
1227
1332
|
loading={isSubmitting}
|
|
1228
1333
|
variant={submitVariant ?? "primary"}
|
|
1229
|
-
icon="check"
|
|
1230
1334
|
testId="render-edit-submit"
|
|
1231
1335
|
>
|
|
1232
1336
|
{translate(submitLabel ?? (isWizard ? "kumiko.actions.finish" : "kumiko.actions.save"))}
|
|
@@ -1269,431 +1373,498 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1269
1373
|
(screen.description !== undefined ? translate(screen.description) : undefined))
|
|
1270
1374
|
: undefined;
|
|
1271
1375
|
|
|
1376
|
+
const usesFieldFlow = isScreenForm || insideDrawer;
|
|
1377
|
+
const allRequiredHint = (
|
|
1378
|
+
<Text variant="muted" testId="render-edit-all-required-hint">
|
|
1379
|
+
{translate("kumiko.form.all-required")}
|
|
1380
|
+
</Text>
|
|
1381
|
+
);
|
|
1382
|
+
const stepRail = isWizard && isScreenForm && StepBar !== undefined;
|
|
1383
|
+
const changedFieldCount = isCreate ? 0 : Object.keys(snapshot.changes).length;
|
|
1384
|
+
const unsavedCount =
|
|
1385
|
+
isCreate || !isScreenForm
|
|
1386
|
+
? 0
|
|
1387
|
+
: changedFieldCount > 0
|
|
1388
|
+
? changedFieldCount
|
|
1389
|
+
: extensionDirty
|
|
1390
|
+
? 1
|
|
1391
|
+
: 0;
|
|
1392
|
+
const sectionNavItems: readonly FormSectionNavItem[] =
|
|
1393
|
+
isScreenForm && !isStepped
|
|
1394
|
+
? filteredSections.flatMap((section, index) =>
|
|
1395
|
+
section.kind !== "extension" &&
|
|
1396
|
+
section.kind !== "relatedList" &&
|
|
1397
|
+
section.kind !== "writeForm" &&
|
|
1398
|
+
section.visible &&
|
|
1399
|
+
section.title !== undefined &&
|
|
1400
|
+
section.title !== formTitle
|
|
1401
|
+
? [{ id: formSectionDomId(index), title: section.title }]
|
|
1402
|
+
: [],
|
|
1403
|
+
)
|
|
1404
|
+
: [];
|
|
1405
|
+
const compactStepLabel = (() => {
|
|
1406
|
+
const currentTitle = filteredSections[currentStep]?.title;
|
|
1407
|
+
return currentTitle !== undefined
|
|
1408
|
+
? translate("kumiko.wizard.step-with-title", {
|
|
1409
|
+
current: currentStep + 1,
|
|
1410
|
+
total: lastStepIndex + 1,
|
|
1411
|
+
title: currentTitle,
|
|
1412
|
+
})
|
|
1413
|
+
: translate("kumiko.wizard.step", { current: currentStep + 1, total: lastStepIndex + 1 });
|
|
1414
|
+
})();
|
|
1415
|
+
|
|
1272
1416
|
return (
|
|
1273
1417
|
<ExtensionFormRegistryProvider value={extensionFormRegistry}>
|
|
1418
|
+
{titleInShell && (
|
|
1419
|
+
<PageHeader
|
|
1420
|
+
title={formTitle}
|
|
1421
|
+
{...(headerMenuItems.length > 0 &&
|
|
1422
|
+
ActionOverflowMenu !== undefined && {
|
|
1423
|
+
actions: (
|
|
1424
|
+
<ActionOverflowMenu
|
|
1425
|
+
label={translate("kumiko.list.row-actions.more")}
|
|
1426
|
+
items={headerMenuItems}
|
|
1427
|
+
testId="render-edit-header-menu"
|
|
1428
|
+
/>
|
|
1429
|
+
),
|
|
1430
|
+
})}
|
|
1431
|
+
/>
|
|
1432
|
+
)}
|
|
1274
1433
|
<Form
|
|
1275
1434
|
onSubmit={() => void handleSubmit()}
|
|
1276
|
-
{...(hideSectionTitles !== true && { title: formTitle })}
|
|
1277
|
-
{...(formSubtitle !== undefined && { subtitle: formSubtitle })}
|
|
1435
|
+
{...(hideSectionTitles !== true && !titleInShell && { title: formTitle })}
|
|
1436
|
+
{...(formSubtitle !== undefined && !stepRail && { subtitle: formSubtitle })}
|
|
1437
|
+
{...(isScreenForm && { screenForm: true })}
|
|
1438
|
+
{...(unsavedCount > 0 && { unsavedCount })}
|
|
1439
|
+
{...(sectionNavItems.length > 0 && { sectionNav: sectionNavItems })}
|
|
1440
|
+
{...(stepRail && {
|
|
1441
|
+
sideRail: (
|
|
1442
|
+
<StepBar
|
|
1443
|
+
steps={filteredSections.map((section) => section.title ?? "")}
|
|
1444
|
+
currentIndex={currentStep}
|
|
1445
|
+
compactLabel={compactStepLabel}
|
|
1446
|
+
onStepSelect={handleWizardJumpBack}
|
|
1447
|
+
orientation="vertical"
|
|
1448
|
+
heading={translate("kumiko.wizard.step", {
|
|
1449
|
+
current: currentStep + 1,
|
|
1450
|
+
total: lastStepIndex + 1,
|
|
1451
|
+
})}
|
|
1452
|
+
{...(formSubtitle !== undefined && { description: formSubtitle })}
|
|
1453
|
+
testId="render-edit-wizard-steps"
|
|
1454
|
+
compactTestId="render-edit-wizard-step-label"
|
|
1455
|
+
/>
|
|
1456
|
+
),
|
|
1457
|
+
})}
|
|
1278
1458
|
{...(hideActions !== true && hasFormActions && { actions: formActions })}
|
|
1279
1459
|
{...(hideActions !== true &&
|
|
1280
1460
|
hasSecondaryFormActions && { secondaryActions: secondaryFormActions })}
|
|
1281
1461
|
testId="render-edit-form"
|
|
1282
|
-
stickyActions={isWizard}
|
|
1462
|
+
stickyActions={isWizard || fillScreenHeight === true}
|
|
1283
1463
|
{...(screen.layout.width !== undefined && { width: screen.layout.width })}
|
|
1284
1464
|
{...(formHeaderRegion !== undefined && { headerRegion: formHeaderRegion })}
|
|
1285
1465
|
{...(titleActionMount !== undefined && { titleAction: titleActionMount })}
|
|
1286
1466
|
{...(fillHeight && { fillHeight })}
|
|
1287
1467
|
{...(hideSectionTitles === true && { chromeless: true })}
|
|
1468
|
+
{...(summary !== undefined && { summary })}
|
|
1288
1469
|
>
|
|
1289
|
-
{
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1470
|
+
<AllFieldsRequiredProvider value={showsAllRequiredHint}>
|
|
1471
|
+
{showsAllRequiredHint && !insideDrawer && allRequiredHint}
|
|
1472
|
+
{draftCandidates !== null && (
|
|
1473
|
+
<Banner
|
|
1474
|
+
variant="info"
|
|
1475
|
+
testId="render-edit-draft-picker"
|
|
1476
|
+
actions={[
|
|
1477
|
+
...draftCandidates.map((candidate) => (
|
|
1478
|
+
<Button
|
|
1479
|
+
key={candidate.id}
|
|
1480
|
+
type="button"
|
|
1481
|
+
variant="link"
|
|
1482
|
+
onClick={() => adoptDraft(candidate)}
|
|
1483
|
+
testId={`render-edit-draft-pick-${candidate.id}`}
|
|
1484
|
+
>
|
|
1485
|
+
{formatWhen(candidate.savedAt)}
|
|
1486
|
+
</Button>
|
|
1487
|
+
)),
|
|
1295
1488
|
<Button
|
|
1296
|
-
key=
|
|
1489
|
+
key="start-new"
|
|
1297
1490
|
type="button"
|
|
1298
1491
|
variant="link"
|
|
1299
|
-
onClick={() =>
|
|
1300
|
-
testId=
|
|
1492
|
+
onClick={() => setDraftCandidates(null)}
|
|
1493
|
+
testId="render-edit-draft-start-new"
|
|
1301
1494
|
>
|
|
1302
|
-
{
|
|
1303
|
-
</Button
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
{
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1495
|
+
{translate("kumiko.form.draft.start-new")}
|
|
1496
|
+
</Button>,
|
|
1497
|
+
]}
|
|
1498
|
+
>
|
|
1499
|
+
<Text>
|
|
1500
|
+
{translate(
|
|
1501
|
+
draftCandidates.length === 1
|
|
1502
|
+
? "kumiko.form.draft.resume-single"
|
|
1503
|
+
: "kumiko.form.draft.resume-multiple",
|
|
1504
|
+
)}
|
|
1505
|
+
</Text>
|
|
1506
|
+
</Banner>
|
|
1507
|
+
)}
|
|
1508
|
+
{isWizard && !stepRail && (
|
|
1509
|
+
<>
|
|
1510
|
+
{Progress !== undefined && (
|
|
1511
|
+
<Progress
|
|
1512
|
+
value={(currentStep + 1) / (lastStepIndex + 1)}
|
|
1513
|
+
testId="render-edit-wizard-progress"
|
|
1514
|
+
/>
|
|
1321
1515
|
)}
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
<Progress
|
|
1329
|
-
value={(currentStep + 1) / (lastStepIndex + 1)}
|
|
1330
|
-
testId="render-edit-wizard-progress"
|
|
1331
|
-
/>
|
|
1332
|
-
)}
|
|
1333
|
-
{(() => {
|
|
1334
|
-
const currentTitle = filteredSections[currentStep]?.title;
|
|
1335
|
-
const compactLabel =
|
|
1336
|
-
currentTitle !== undefined
|
|
1337
|
-
? translate("kumiko.wizard.step-with-title", {
|
|
1338
|
-
current: currentStep + 1,
|
|
1339
|
-
total: lastStepIndex + 1,
|
|
1340
|
-
title: currentTitle,
|
|
1341
|
-
})
|
|
1342
|
-
: translate("kumiko.wizard.step", {
|
|
1343
|
-
current: currentStep + 1,
|
|
1344
|
-
total: lastStepIndex + 1,
|
|
1345
|
-
});
|
|
1346
|
-
// No StepBar registered → keep the plain label RenderEdit
|
|
1347
|
-
// always had (additive rollout, see CorePrimitives.StepBar).
|
|
1348
|
-
if (StepBar === undefined) {
|
|
1349
|
-
return (
|
|
1350
|
-
<Text variant="small" testId="render-edit-wizard-step-label">
|
|
1351
|
-
{compactLabel}
|
|
1352
|
-
</Text>
|
|
1353
|
-
);
|
|
1354
|
-
}
|
|
1355
|
-
return (
|
|
1516
|
+
{/* No StepBar registered → keep the plain label RenderEdit always had (additive rollout, see CorePrimitives.StepBar). */}
|
|
1517
|
+
{StepBar === undefined ? (
|
|
1518
|
+
<Text variant="small" testId="render-edit-wizard-step-label">
|
|
1519
|
+
{compactStepLabel}
|
|
1520
|
+
</Text>
|
|
1521
|
+
) : (
|
|
1356
1522
|
<StepBar
|
|
1357
1523
|
steps={filteredSections.map((section) => section.title ?? "")}
|
|
1358
1524
|
currentIndex={currentStep}
|
|
1359
|
-
compactLabel={
|
|
1525
|
+
compactLabel={compactStepLabel}
|
|
1360
1526
|
onStepSelect={handleWizardJumpBack}
|
|
1361
1527
|
testId="render-edit-wizard-steps"
|
|
1362
1528
|
compactTestId="render-edit-wizard-step-label"
|
|
1363
1529
|
/>
|
|
1530
|
+
)}
|
|
1531
|
+
</>
|
|
1532
|
+
)}
|
|
1533
|
+
{isTabs &&
|
|
1534
|
+
(() => {
|
|
1535
|
+
// No Tabs primitive registered → every section renders stacked
|
|
1536
|
+
// (stepHidden below is gated on the same condition), which is the
|
|
1537
|
+
// single-layout behaviour. Degrading to one long form beats
|
|
1538
|
+
// hiding sections behind a strip that never drew.
|
|
1539
|
+
if (Tabs === undefined) return null;
|
|
1540
|
+
return (
|
|
1541
|
+
<Tabs
|
|
1542
|
+
testId="render-edit-tabs"
|
|
1543
|
+
items={filteredSections.map((section, index) => ({
|
|
1544
|
+
id: tabIdAt(section, index),
|
|
1545
|
+
label: section.title ?? "",
|
|
1546
|
+
}))}
|
|
1547
|
+
activeId={tabIdAt(filteredSections[currentStep], currentStep)}
|
|
1548
|
+
onSelect={(id) => {
|
|
1549
|
+
const index = filteredSections.findIndex((s, i) => tabIdAt(s, i) === id);
|
|
1550
|
+
// skip: the strip reported an id no section owns.
|
|
1551
|
+
if (index === -1) return;
|
|
1552
|
+
setRawStep(index);
|
|
1553
|
+
}}
|
|
1554
|
+
/>
|
|
1364
1555
|
);
|
|
1365
1556
|
})()}
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
//
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
)
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1557
|
+
{filteredSections.map((section: EditSectionViewModel, sectionIndex: number) => {
|
|
1558
|
+
// turns this section's own `actions` (RowAction[]) into
|
|
1559
|
+
// already-bound buttons via the caller-supplied builder, rendered
|
|
1560
|
+
// through the same RenderEditActionButton the top-level `actions`
|
|
1561
|
+
// prop uses (same style/confirm/icon/error handling).
|
|
1562
|
+
const sectionActions = section.actions;
|
|
1563
|
+
const sectionActionsEl: ReactNode =
|
|
1564
|
+
sectionActions !== undefined && sectionActions.length > 0
|
|
1565
|
+
? buildSectionActions?.(sectionActions)?.map((action) => (
|
|
1566
|
+
<RenderEditActionButton
|
|
1567
|
+
key={action.id}
|
|
1568
|
+
action={action}
|
|
1569
|
+
Button={Button}
|
|
1570
|
+
Dialog={Dialog}
|
|
1571
|
+
onError={setActionError}
|
|
1572
|
+
/>
|
|
1573
|
+
))
|
|
1574
|
+
: undefined;
|
|
1575
|
+
// Wizard steps and tabs stay mounted while off-screen (native
|
|
1576
|
+
// `hidden`, not unmounted) so an extension section's submit-registry
|
|
1577
|
+
// entry (useExtensionFormSubmit → registry.remove on unmount)
|
|
1578
|
+
// survives navigating past its step — otherwise Finish only ran the
|
|
1579
|
+
// last mounted step's handler and silently dropped earlier steps'
|
|
1580
|
+
// writes. For tabs this is also what makes ONE submit cover every
|
|
1581
|
+
// tab, including tabs the user never opened (fw#3134).
|
|
1582
|
+
const stepHidden = isStepped && sectionIndex !== currentStep;
|
|
1583
|
+
const wrapWizardStep = (key: string, el: ReactNode): ReactNode => {
|
|
1584
|
+
if (!isStepped) return el;
|
|
1585
|
+
if (WizardStepGroup === undefined) {
|
|
1586
|
+
throw new Error(
|
|
1587
|
+
`RenderEdit: ${screen.layout.mode} layout requires primitives.WizardStepGroup, but none is registered.`,
|
|
1588
|
+
);
|
|
1589
|
+
}
|
|
1590
|
+
return (
|
|
1591
|
+
<WizardStepGroup
|
|
1592
|
+
key={key}
|
|
1593
|
+
hidden={stepHidden}
|
|
1594
|
+
{...(hideSectionTitles === true && { inset: true })}
|
|
1595
|
+
>
|
|
1596
|
+
{el}
|
|
1597
|
+
</WizardStepGroup>
|
|
1598
|
+
);
|
|
1599
|
+
};
|
|
1600
|
+
// Off-screen wizard steps stay mounted (see comment above) but must
|
|
1601
|
+
// not participate in native constraint validation, or the Next
|
|
1602
|
+
// button's `type="submit"` triggers the browser's full-form check
|
|
1603
|
+
// — including required fields on unvisited steps, which are not
|
|
1604
|
+
// focusable while hidden, so the wizard silently stops navigating
|
|
1605
|
+
// (only a console warning, no visible error). A plain `hidden`
|
|
1606
|
+
// attribute does NOT bar descendants from constraint validation on
|
|
1607
|
+
// web — see WizardStepGroupProps for what implementations must
|
|
1608
|
+
// guarantee.
|
|
1609
|
+
if (section.kind === "extension") {
|
|
1610
|
+
const mount = (
|
|
1611
|
+
<ExtensionSectionMount
|
|
1612
|
+
key={section.title}
|
|
1613
|
+
section={section}
|
|
1614
|
+
entityName={vm.entityName}
|
|
1615
|
+
entityId={resolveExtensionEntityId(entityIdProp, vm.id)}
|
|
1616
|
+
initialValues={extensionInitialValues}
|
|
1617
|
+
values={snapshot.values}
|
|
1618
|
+
// @cast-boundary form-values: ExtensionSectionProps is not generic
|
|
1619
|
+
// over TValues; controller is mount-lifetime-stable, see onControlsReady above.
|
|
1620
|
+
patch={
|
|
1621
|
+
patchAndScheduleDraftSave as (
|
|
1622
|
+
partial: Readonly<Record<string, unknown>>,
|
|
1623
|
+
) => void
|
|
1624
|
+
}
|
|
1625
|
+
validate={scopedValidate}
|
|
1626
|
+
hideTitle={hideSectionTitles}
|
|
1627
|
+
// Tabs mode: actions move to the outer Card below — the inner
|
|
1628
|
+
// Section is flattened (rendered inside this component's own
|
|
1629
|
+
// Form) and would otherwise duplicate them.
|
|
1630
|
+
{...(hideSectionTitles !== true &&
|
|
1631
|
+
sectionActionsEl !== undefined && { actions: sectionActionsEl })}
|
|
1632
|
+
/>
|
|
1423
1633
|
);
|
|
1634
|
+
// Section always flattens to a borderless divider inside this
|
|
1635
|
+
// component's own <Form>, so tabs mode frames it with Card
|
|
1636
|
+
// instead — same reason the fields-section tabs branch below
|
|
1637
|
+
// stays on Card. No title here: the tab strip already labels the
|
|
1638
|
+
// panel (matches the non-tabs Section branch above via hideTitle).
|
|
1639
|
+
// testId stays only on the inner Section — this outer Card is
|
|
1640
|
+
// purely chrome, giving it the same testId would register two
|
|
1641
|
+
// elements under one id.
|
|
1642
|
+
const wrapped =
|
|
1643
|
+
hideSectionTitles === true ? (
|
|
1644
|
+
<Card
|
|
1645
|
+
key={section.title}
|
|
1646
|
+
{...(sectionActionsEl !== undefined && {
|
|
1647
|
+
slots: { headerActions: sectionActionsEl },
|
|
1648
|
+
})}
|
|
1649
|
+
>
|
|
1650
|
+
{mount}
|
|
1651
|
+
</Card>
|
|
1652
|
+
) : (
|
|
1653
|
+
mount
|
|
1654
|
+
);
|
|
1655
|
+
return wrapWizardStep(section.title, wrapped);
|
|
1424
1656
|
}
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
// button's `type="submit"` triggers the browser's full-form check
|
|
1434
|
-
// — including required fields on unvisited steps, which are not
|
|
1435
|
-
// focusable while hidden, so the wizard silently stops navigating
|
|
1436
|
-
// (only a console warning, no visible error). A plain `hidden`
|
|
1437
|
-
// attribute does NOT bar descendants from constraint validation on
|
|
1438
|
-
// web — see WizardStepGroupProps for what implementations must
|
|
1439
|
-
// guarantee.
|
|
1440
|
-
if (section.kind === "extension") {
|
|
1441
|
-
const mount = (
|
|
1442
|
-
<ExtensionSectionMount
|
|
1443
|
-
key={section.title}
|
|
1444
|
-
section={section}
|
|
1445
|
-
entityName={vm.entityName}
|
|
1446
|
-
entityId={resolveExtensionEntityId(entityIdProp, vm.id)}
|
|
1447
|
-
initialValues={extensionInitialValues}
|
|
1448
|
-
values={snapshot.values}
|
|
1449
|
-
// @cast-boundary form-values: ExtensionSectionProps is not generic
|
|
1450
|
-
// over TValues; controller is mount-lifetime-stable, see onControlsReady above.
|
|
1451
|
-
patch={
|
|
1452
|
-
patchAndScheduleDraftSave as (partial: Readonly<Record<string, unknown>>) => void
|
|
1453
|
-
}
|
|
1454
|
-
validate={scopedValidate}
|
|
1455
|
-
hideTitle={hideSectionTitles}
|
|
1456
|
-
// Tabs mode: actions move to the outer Card below — the inner
|
|
1457
|
-
// Section is flattened (rendered inside this component's own
|
|
1458
|
-
// Form) and would otherwise duplicate them.
|
|
1459
|
-
{...(hideSectionTitles !== true &&
|
|
1460
|
-
sectionActionsEl !== undefined && { actions: sectionActionsEl })}
|
|
1461
|
-
/>
|
|
1462
|
-
);
|
|
1463
|
-
// Section always flattens to a borderless divider inside this
|
|
1464
|
-
// component's own <Form>, so tabs mode frames it with Card
|
|
1465
|
-
// instead — same reason the fields-section tabs branch below
|
|
1466
|
-
// stays on Card. No title here: the tab strip already labels the
|
|
1467
|
-
// panel (matches the non-tabs Section branch above via hideTitle).
|
|
1468
|
-
// testId stays only on the inner Section — this outer Card is
|
|
1469
|
-
// purely chrome, giving it the same testId would register two
|
|
1470
|
-
// elements under one id.
|
|
1471
|
-
const wrapped =
|
|
1472
|
-
hideSectionTitles === true ? (
|
|
1473
|
-
<Card
|
|
1657
|
+
if (section.kind === "relatedList") {
|
|
1658
|
+
// parentId is the displayed record's id — without it there's no
|
|
1659
|
+
// parent row whose related rows could be queried (fw#2166).
|
|
1660
|
+
// Rejected at boot in wizard layouts, so no WizardStepGroup here.
|
|
1661
|
+
const parentId = resolveExtensionEntityId(entityIdProp, vm.id);
|
|
1662
|
+
if (parentId === null) return null;
|
|
1663
|
+
const mount = (
|
|
1664
|
+
<RelatedListSection
|
|
1474
1665
|
key={section.title}
|
|
1475
|
-
{
|
|
1476
|
-
|
|
1666
|
+
section={section}
|
|
1667
|
+
parentId={parentId}
|
|
1668
|
+
// @cast-boundary form-values: TValues ist strukturell ein Record.
|
|
1669
|
+
record={snapshot.values as unknown as Readonly<Record<string, unknown>>}
|
|
1670
|
+
featureName={featureName}
|
|
1671
|
+
translate={translate}
|
|
1672
|
+
hideTitle={hideSectionTitles}
|
|
1673
|
+
{...(fillScreenHeight === true && { grow: true })}
|
|
1674
|
+
// Tabs mode: actions move to the outer Card below — the
|
|
1675
|
+
// hideTitle branch of RelatedListSection has no title row to
|
|
1676
|
+
// render them into.
|
|
1677
|
+
{...(hideSectionTitles !== true &&
|
|
1678
|
+
sectionActionsEl !== undefined && { actions: sectionActionsEl })}
|
|
1679
|
+
{...(onRelatedListDrawerAction !== undefined && {
|
|
1680
|
+
onOpenDrawer: onRelatedListDrawerAction,
|
|
1477
1681
|
})}
|
|
1682
|
+
/>
|
|
1683
|
+
);
|
|
1684
|
+
// Tabs mode: the table runs full-bleed under the tab strip (board
|
|
1685
|
+
// layout); the toolbar buttons live in the list's own toolbar.
|
|
1686
|
+
return hideSectionTitles === true ? (
|
|
1687
|
+
<div
|
|
1688
|
+
key={section.title}
|
|
1689
|
+
className={fillScreenHeight === true ? "flex min-h-0 flex-1 flex-col" : undefined}
|
|
1478
1690
|
>
|
|
1479
1691
|
{mount}
|
|
1480
|
-
</
|
|
1692
|
+
</div>
|
|
1481
1693
|
) : (
|
|
1482
1694
|
mount
|
|
1483
1695
|
);
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
<RelatedListSection
|
|
1494
|
-
key={section.title}
|
|
1495
|
-
section={section}
|
|
1496
|
-
parentId={parentId}
|
|
1497
|
-
// @cast-boundary form-values: TValues ist strukturell ein Record.
|
|
1498
|
-
record={snapshot.values as unknown as Readonly<Record<string, unknown>>}
|
|
1499
|
-
featureName={featureName}
|
|
1500
|
-
translate={translate}
|
|
1501
|
-
hideTitle={hideSectionTitles}
|
|
1502
|
-
// Tabs mode: actions move to the outer Card below — the
|
|
1503
|
-
// hideTitle branch of RelatedListSection has no title row to
|
|
1504
|
-
// render them into.
|
|
1505
|
-
{...(hideSectionTitles !== true &&
|
|
1506
|
-
sectionActionsEl !== undefined && { actions: sectionActionsEl })}
|
|
1507
|
-
{...(onRelatedListDrawerAction !== undefined && {
|
|
1508
|
-
onOpenDrawer: onRelatedListDrawerAction,
|
|
1509
|
-
})}
|
|
1510
|
-
/>
|
|
1511
|
-
);
|
|
1512
|
-
// Tabs mode: the relatedList tab must sit in the same Card frame
|
|
1513
|
-
// as every other tab (fields/extension/writeForm above) — the
|
|
1514
|
-
// acceptance criterion this unification exists for. `fillHeight`
|
|
1515
|
-
// on the Card threads the same fw#2722/#2778 flex chain the head
|
|
1516
|
-
// card already carries (DefaultCard's `options.fillHeight`), so
|
|
1517
|
-
// the table still scrolls inside the panel instead of the panel
|
|
1518
|
-
// stretching to the row count.
|
|
1519
|
-
return hideSectionTitles === true ? (
|
|
1520
|
-
<Card
|
|
1521
|
-
key={section.title}
|
|
1522
|
-
options={{ fillHeight }}
|
|
1523
|
-
{...(sectionActionsEl !== undefined && {
|
|
1524
|
-
slots: { headerActions: sectionActionsEl },
|
|
1525
|
-
})}
|
|
1526
|
-
>
|
|
1527
|
-
{mount}
|
|
1528
|
-
</Card>
|
|
1529
|
-
) : (
|
|
1530
|
-
mount
|
|
1531
|
-
);
|
|
1532
|
-
}
|
|
1533
|
-
if (section.kind === "writeForm") {
|
|
1534
|
-
// Own submit button + dispatcher call, entirely independent of
|
|
1535
|
-
// this screen's (nonexistent, on projectionDetail) form submit —
|
|
1536
|
-
// rejected at boot in wizard layouts, so no WizardStepGroup here.
|
|
1537
|
-
return (
|
|
1538
|
-
<WriteFormSection
|
|
1539
|
-
key={section.title ?? `write-form-${sectionIndex}`}
|
|
1540
|
-
section={section}
|
|
1541
|
-
featureName={featureName}
|
|
1542
|
-
translate={translate}
|
|
1543
|
-
hideTitle={hideSectionTitles}
|
|
1544
|
-
onSubmitted={() => onReload?.()}
|
|
1545
|
-
{...(sectionActionsEl !== undefined && { actions: sectionActionsEl })}
|
|
1546
|
-
/>
|
|
1547
|
-
);
|
|
1548
|
-
}
|
|
1549
|
-
if (!section.visible) return null;
|
|
1550
|
-
// Titellose Sections kollidieren sonst auf key/testId — Index-Fallback.
|
|
1551
|
-
const sectionKey = section.title ?? `section-${sectionIndex}`;
|
|
1552
|
-
const renderFieldGrid = (
|
|
1553
|
-
fields: readonly EditFieldViewModel[],
|
|
1554
|
-
columns: number,
|
|
1555
|
-
gridKey: string,
|
|
1556
|
-
): ReactNode => (
|
|
1557
|
-
<Grid key={gridKey} columns={columns}>
|
|
1558
|
-
{fields.map((field: EditFieldViewModel) => (
|
|
1559
|
-
<GridCellForField
|
|
1560
|
-
key={field.field}
|
|
1561
|
-
field={disabled ? { ...field, readOnly: true } : field}
|
|
1562
|
-
columns={columns}
|
|
1563
|
-
issues={snapshot.errors[field.field]}
|
|
1564
|
-
onChange={(v) => {
|
|
1565
|
-
(controller.setField as (k: string, v: unknown) => void)(field.field, v);
|
|
1566
|
-
}}
|
|
1567
|
-
GridCell={GridCell}
|
|
1696
|
+
}
|
|
1697
|
+
if (section.kind === "writeForm") {
|
|
1698
|
+
// Own submit button + dispatcher call, entirely independent of
|
|
1699
|
+
// this screen's (nonexistent, on projectionDetail) form submit —
|
|
1700
|
+
// rejected at boot in wizard layouts, so no WizardStepGroup here.
|
|
1701
|
+
return (
|
|
1702
|
+
<WriteFormSection
|
|
1703
|
+
key={section.title ?? `write-form-${sectionIndex}`}
|
|
1704
|
+
section={section}
|
|
1568
1705
|
featureName={featureName}
|
|
1569
|
-
{
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
{...(
|
|
1573
|
-
fieldAppendix: fieldAppendix(field.field),
|
|
1574
|
-
})}
|
|
1575
|
-
allIssues={snapshot.errors}
|
|
1576
|
-
valueDisplay={valueDisplay}
|
|
1577
|
-
row={snapshot.values}
|
|
1706
|
+
translate={translate}
|
|
1707
|
+
hideTitle={hideSectionTitles}
|
|
1708
|
+
onSubmitted={() => onReload?.()}
|
|
1709
|
+
{...(sectionActionsEl !== undefined && { actions: sectionActionsEl })}
|
|
1578
1710
|
/>
|
|
1579
|
-
))}
|
|
1580
|
-
</Grid>
|
|
1581
|
-
);
|
|
1582
|
-
// Tabs mode renders one or more titled cards, so this card's actual
|
|
1583
|
-
// heading doesn't visually duplicate the short Tab strip label.
|
|
1584
|
-
// Stays `Card`, not `Section` — Section always flattens to a
|
|
1585
|
-
// borderless divider when rendered inside this component's own
|
|
1586
|
-
// `<Form>` (InsideFormContext), even with `chromeless` set, so it
|
|
1587
|
-
// cannot stand in for the tabs-mode card here.
|
|
1588
|
-
if (hideSectionTitles === true) {
|
|
1589
|
-
if (section.groups !== undefined) {
|
|
1590
|
-
const groupsGrid = (
|
|
1591
|
-
<Grid key={`${sectionKey}-groups`} columns={1}>
|
|
1592
|
-
{section.groups.map((group, groupIndex) => (
|
|
1593
|
-
<Card
|
|
1594
|
-
key={group.title}
|
|
1595
|
-
slots={{ title: group.title }}
|
|
1596
|
-
testId={`section-${sectionKey}-group-${groupIndex}`}
|
|
1597
|
-
>
|
|
1598
|
-
{renderFieldGrid(
|
|
1599
|
-
group.fields,
|
|
1600
|
-
group.columns,
|
|
1601
|
-
`${sectionKey}-group-${groupIndex}-grid`,
|
|
1602
|
-
)}
|
|
1603
|
-
</Card>
|
|
1604
|
-
))}
|
|
1605
|
-
</Grid>
|
|
1606
1711
|
);
|
|
1607
|
-
// `actions` on a `groups` section has no group of its own to
|
|
1608
|
-
// sit in and would need an extra title-less Card around the
|
|
1609
|
-
// group cards (Card-in-Card) — the boot-validator rejects that
|
|
1610
|
-
// combination in tabs mode, so `sectionActionsEl` is always
|
|
1611
|
-
// undefined here at runtime.
|
|
1612
|
-
return wrapWizardStep(sectionKey, groupsGrid);
|
|
1613
1712
|
}
|
|
1614
|
-
|
|
1615
|
-
//
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1713
|
+
if (!section.visible) return null;
|
|
1714
|
+
// Titellose Sections kollidieren sonst auf key/testId — Index-Fallback.
|
|
1715
|
+
const sectionKey = section.title ?? `section-${sectionIndex}`;
|
|
1716
|
+
const renderFieldGrid = (
|
|
1717
|
+
fields: readonly EditFieldViewModel[],
|
|
1718
|
+
columns: number,
|
|
1719
|
+
gridKey: string,
|
|
1720
|
+
): ReactNode => (
|
|
1721
|
+
<Grid key={gridKey} columns={columns} {...(usesFieldFlow && { flow: true })}>
|
|
1722
|
+
{fields.map((field: EditFieldViewModel) => (
|
|
1723
|
+
<GridCellForField
|
|
1724
|
+
key={field.field}
|
|
1725
|
+
field={disabled ? { ...field, readOnly: true } : field}
|
|
1726
|
+
columns={columns}
|
|
1727
|
+
{...(usesFieldFlow && { flow: true })}
|
|
1728
|
+
issues={snapshot.errors[field.field]}
|
|
1729
|
+
onChange={(v) => {
|
|
1730
|
+
(controller.setField as (k: string, v: unknown) => void)(field.field, v);
|
|
1731
|
+
}}
|
|
1732
|
+
GridCell={GridCell}
|
|
1733
|
+
featureName={featureName}
|
|
1734
|
+
{...(labelAppendix !== undefined && {
|
|
1735
|
+
labelAppendix: labelAppendix(field.field),
|
|
1736
|
+
})}
|
|
1737
|
+
{...(fieldAppendix !== undefined && {
|
|
1738
|
+
fieldAppendix: fieldAppendix(field.field),
|
|
1739
|
+
})}
|
|
1740
|
+
allIssues={snapshot.errors}
|
|
1741
|
+
valueDisplay={valueDisplay}
|
|
1742
|
+
row={snapshot.values}
|
|
1743
|
+
{...(!isCreate && field.field in snapshot.changes && { changed: true })}
|
|
1744
|
+
/>
|
|
1745
|
+
))}
|
|
1746
|
+
</Grid>
|
|
1747
|
+
);
|
|
1748
|
+
// Tabs mode renders one or more titled cards, so this card's actual
|
|
1749
|
+
// heading doesn't visually duplicate the short Tab strip label.
|
|
1750
|
+
// Stays `Card`, not `Section` — Section always flattens to a
|
|
1751
|
+
// borderless divider when rendered inside this component's own
|
|
1752
|
+
// `<Form>` (InsideFormContext), even with `chromeless` set, so it
|
|
1753
|
+
// cannot stand in for the tabs-mode card here.
|
|
1754
|
+
if (hideSectionTitles === true) {
|
|
1755
|
+
if (section.groups !== undefined) {
|
|
1756
|
+
const groupsGrid = (
|
|
1757
|
+
<Grid key={`${sectionKey}-groups`} columns={1}>
|
|
1758
|
+
{section.groups.map((group, groupIndex) => (
|
|
1759
|
+
<Card
|
|
1760
|
+
key={group.title}
|
|
1761
|
+
slots={{ title: group.title }}
|
|
1762
|
+
testId={`section-${sectionKey}-group-${groupIndex}`}
|
|
1763
|
+
>
|
|
1764
|
+
{renderFieldGrid(
|
|
1765
|
+
group.fields,
|
|
1766
|
+
group.columns,
|
|
1767
|
+
`${sectionKey}-group-${groupIndex}-grid`,
|
|
1768
|
+
)}
|
|
1769
|
+
</Card>
|
|
1770
|
+
))}
|
|
1771
|
+
</Grid>
|
|
1772
|
+
);
|
|
1773
|
+
// `actions` on a `groups` section has no group of its own to
|
|
1774
|
+
// sit in and would need an extra title-less Card around the
|
|
1775
|
+
// group cards (Card-in-Card) — the boot-validator rejects that
|
|
1776
|
+
// combination in tabs mode, so `sectionActionsEl` is always
|
|
1777
|
+
// undefined here at runtime.
|
|
1778
|
+
return wrapWizardStep(sectionKey, groupsGrid);
|
|
1779
|
+
}
|
|
1780
|
+
// No section title here (fw#3218) — the Tab strip right above
|
|
1781
|
+
// already names this panel, so a repeated Card title would just
|
|
1782
|
+
// duplicate it. The title row only exists to carry actions, and
|
|
1783
|
+
// then it carries only the actions, never the title text.
|
|
1784
|
+
const cardEl = (
|
|
1785
|
+
<Card
|
|
1786
|
+
key={sectionKey}
|
|
1787
|
+
{...(sectionActionsEl !== undefined && {
|
|
1788
|
+
slots: { headerActions: sectionActionsEl },
|
|
1789
|
+
})}
|
|
1790
|
+
testId={`section-${sectionKey}`}
|
|
1791
|
+
>
|
|
1792
|
+
{renderFieldGrid(section.fields, section.columns, `${sectionKey}-grid`)}
|
|
1793
|
+
</Card>
|
|
1794
|
+
);
|
|
1795
|
+
return wrapWizardStep(sectionKey, cardEl);
|
|
1796
|
+
}
|
|
1797
|
+
// Suppress the section header when it would just repeat the form
|
|
1798
|
+
// title verbatim (typical for single-section actionForms), or the
|
|
1799
|
+
// tab label right above it. Without a Tabs primitive the sections
|
|
1800
|
+
// render stacked, and then the titles are what tells them apart.
|
|
1801
|
+
const repeatsTabLabel = isTabs && isStepped;
|
|
1802
|
+
const sectionTitle =
|
|
1803
|
+
section.title === formTitle || repeatsTabLabel ? undefined : section.title;
|
|
1804
|
+
const sectionEl = (
|
|
1805
|
+
<Section
|
|
1620
1806
|
key={sectionKey}
|
|
1621
|
-
{...(
|
|
1622
|
-
|
|
1807
|
+
{...(sectionNavItems.some((item) => item.id === formSectionDomId(sectionIndex)) && {
|
|
1808
|
+
id: formSectionDomId(sectionIndex),
|
|
1623
1809
|
})}
|
|
1810
|
+
{...(sectionTitle !== undefined && { title: sectionTitle })}
|
|
1811
|
+
{...(section.description !== undefined && { subtitle: section.description })}
|
|
1812
|
+
{...(section.icon !== undefined && { icon: section.icon })}
|
|
1813
|
+
{...(sectionActionsEl !== undefined && { actions: sectionActionsEl })}
|
|
1624
1814
|
testId={`section-${sectionKey}`}
|
|
1625
1815
|
>
|
|
1816
|
+
{showsAllRequiredHint && insideDrawer && sectionIndex === 0 && allRequiredHint}
|
|
1626
1817
|
{renderFieldGrid(section.fields, section.columns, `${sectionKey}-grid`)}
|
|
1627
|
-
</
|
|
1818
|
+
</Section>
|
|
1628
1819
|
);
|
|
1629
|
-
return wrapWizardStep(sectionKey,
|
|
1630
|
-
}
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1820
|
+
return wrapWizardStep(sectionKey, sectionEl);
|
|
1821
|
+
})}
|
|
1822
|
+
{formError !== null && (
|
|
1823
|
+
<Banner
|
|
1824
|
+
variant="error"
|
|
1825
|
+
testId="render-edit-form-error"
|
|
1826
|
+
actions={
|
|
1827
|
+
formError.code === "version_conflict" && onReload !== undefined ? (
|
|
1828
|
+
<Button
|
|
1829
|
+
variant="secondary"
|
|
1830
|
+
onClick={() => {
|
|
1831
|
+
onReload();
|
|
1832
|
+
setFormError(null);
|
|
1833
|
+
}}
|
|
1834
|
+
testId="render-edit-form-error-reload"
|
|
1835
|
+
>
|
|
1836
|
+
{translate("kumiko.actions.reload")}
|
|
1837
|
+
</Button>
|
|
1838
|
+
) : undefined
|
|
1839
|
+
}
|
|
1646
1840
|
>
|
|
1647
|
-
{
|
|
1648
|
-
</
|
|
1649
|
-
)
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
{extensionErrorKey !== null && (
|
|
1675
|
-
<Banner variant="error" testId="render-edit-extension-error">
|
|
1676
|
-
<Text testId="render-edit-extension-error-key">{translate(extensionErrorKey)}</Text>
|
|
1677
|
-
</Banner>
|
|
1678
|
-
)}
|
|
1679
|
-
{actionError !== null && (
|
|
1680
|
-
<Banner variant="error" testId="render-edit-action-error">
|
|
1681
|
-
{actionError}
|
|
1682
|
-
</Banner>
|
|
1683
|
-
)}
|
|
1684
|
-
{onDelete !== undefined && (
|
|
1685
|
-
<Dialog
|
|
1686
|
-
open={confirmDeleteOpen}
|
|
1687
|
-
onOpenChange={setConfirmDeleteOpen}
|
|
1688
|
-
title={translate("kumiko.actions.delete-confirm")}
|
|
1689
|
-
confirmLabel={translate("kumiko.actions.delete")}
|
|
1690
|
-
variant="danger"
|
|
1691
|
-
onConfirm={async () => {
|
|
1692
|
-
await onDelete();
|
|
1693
|
-
}}
|
|
1694
|
-
testId="render-edit-delete-dialog"
|
|
1695
|
-
/>
|
|
1696
|
-
)}
|
|
1841
|
+
<Text testId="render-edit-form-error-key">{translate(formError.i18nKey)}</Text>
|
|
1842
|
+
</Banner>
|
|
1843
|
+
)}
|
|
1844
|
+
{extensionErrorKey !== null && (
|
|
1845
|
+
<Banner variant="error" testId="render-edit-extension-error">
|
|
1846
|
+
<Text testId="render-edit-extension-error-key">{translate(extensionErrorKey)}</Text>
|
|
1847
|
+
</Banner>
|
|
1848
|
+
)}
|
|
1849
|
+
{actionError !== null && (
|
|
1850
|
+
<Banner variant="error" testId="render-edit-action-error">
|
|
1851
|
+
{actionError}
|
|
1852
|
+
</Banner>
|
|
1853
|
+
)}
|
|
1854
|
+
{onDelete !== undefined && (
|
|
1855
|
+
<Dialog
|
|
1856
|
+
open={confirmDeleteOpen}
|
|
1857
|
+
onOpenChange={setConfirmDeleteOpen}
|
|
1858
|
+
title={translate("kumiko.actions.delete-confirm")}
|
|
1859
|
+
confirmLabel={translate("kumiko.actions.delete")}
|
|
1860
|
+
variant="danger"
|
|
1861
|
+
onConfirm={async () => {
|
|
1862
|
+
await onDelete();
|
|
1863
|
+
}}
|
|
1864
|
+
testId="render-edit-delete-dialog"
|
|
1865
|
+
/>
|
|
1866
|
+
)}
|
|
1867
|
+
</AllFieldsRequiredProvider>
|
|
1697
1868
|
</Form>
|
|
1698
1869
|
</ExtensionFormRegistryProvider>
|
|
1699
1870
|
);
|