@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.
@@ -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 = hideSectionTitles === true && filteredSections[0]?.kind === "relatedList";
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
- if (isWizard && !isLastWizardStep) {
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="arrow-left"
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="arrow-right"
1316
+ iconEnd="chevron-right"
1216
1317
  testId="render-edit-wizard-next"
1217
1318
  >
1218
- {translate("kumiko.actions.next")}
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) || isSubmitting || disabled
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
- {draftCandidates !== null && (
1290
- <Banner
1291
- variant="info"
1292
- testId="render-edit-draft-picker"
1293
- actions={[
1294
- ...draftCandidates.map((candidate) => (
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={candidate.id}
1489
+ key="start-new"
1297
1490
  type="button"
1298
1491
  variant="link"
1299
- onClick={() => adoptDraft(candidate)}
1300
- testId={`render-edit-draft-pick-${candidate.id}`}
1492
+ onClick={() => setDraftCandidates(null)}
1493
+ testId="render-edit-draft-start-new"
1301
1494
  >
1302
- {formatWhen(candidate.savedAt)}
1303
- </Button>
1304
- )),
1305
- <Button
1306
- key="start-new"
1307
- type="button"
1308
- variant="link"
1309
- onClick={() => setDraftCandidates(null)}
1310
- testId="render-edit-draft-start-new"
1311
- >
1312
- {translate("kumiko.form.draft.start-new")}
1313
- </Button>,
1314
- ]}
1315
- >
1316
- <Text>
1317
- {translate(
1318
- draftCandidates.length === 1
1319
- ? "kumiko.form.draft.resume-single"
1320
- : "kumiko.form.draft.resume-multiple",
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
- </Text>
1323
- </Banner>
1324
- )}
1325
- {isWizard && (
1326
- <>
1327
- {Progress !== undefined && (
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={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
- {isTabs &&
1369
- (() => {
1370
- // No Tabs primitive registered → every section renders stacked
1371
- // (stepHidden below is gated on the same condition), which is the
1372
- // single-layout behaviour. Degrading to one long form beats
1373
- // hiding sections behind a strip that never drew.
1374
- if (Tabs === undefined) return null;
1375
- return (
1376
- <Tabs
1377
- testId="render-edit-tabs"
1378
- items={filteredSections.map((section, index) => ({
1379
- id: tabIdAt(section, index),
1380
- label: section.title ?? "",
1381
- }))}
1382
- activeId={tabIdAt(filteredSections[currentStep], currentStep)}
1383
- onSelect={(id) => {
1384
- const index = filteredSections.findIndex((s, i) => tabIdAt(s, i) === id);
1385
- // skip: the strip reported an id no section owns.
1386
- if (index === -1) return;
1387
- setRawStep(index);
1388
- }}
1389
- />
1390
- );
1391
- })()}
1392
- {filteredSections.map((section: EditSectionViewModel, sectionIndex: number) => {
1393
- // turns this section's own `actions` (RowAction[]) into
1394
- // already-bound buttons via the caller-supplied builder, rendered
1395
- // through the same RenderEditActionButton the top-level `actions`
1396
- // prop uses (same style/confirm/icon/error handling).
1397
- const sectionActions = section.actions;
1398
- const sectionActionsEl: ReactNode =
1399
- sectionActions !== undefined && sectionActions.length > 0
1400
- ? buildSectionActions?.(sectionActions)?.map((action) => (
1401
- <RenderEditActionButton
1402
- key={action.id}
1403
- action={action}
1404
- Button={Button}
1405
- Dialog={Dialog}
1406
- onError={setActionError}
1407
- />
1408
- ))
1409
- : undefined;
1410
- // Wizard steps and tabs stay mounted while off-screen (native
1411
- // `hidden`, not unmounted) so an extension section's submit-registry
1412
- // entry (useExtensionFormSubmit → registry.remove on unmount)
1413
- // survives navigating past its step — otherwise Finish only ran the
1414
- // last mounted step's handler and silently dropped earlier steps'
1415
- // writes. For tabs this is also what makes ONE submit cover every
1416
- // tab, including tabs the user never opened (fw#3134).
1417
- const stepHidden = isStepped && sectionIndex !== currentStep;
1418
- const wrapWizardStep = (key: string, el: ReactNode): ReactNode => {
1419
- if (!isStepped) return el;
1420
- if (WizardStepGroup === undefined) {
1421
- throw new Error(
1422
- `RenderEdit: ${screen.layout.mode} layout requires primitives.WizardStepGroup, but none is registered.`,
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
- return (
1426
- <WizardStepGroup key={key} hidden={stepHidden}>
1427
- {el}
1428
- </WizardStepGroup>
1429
- );
1430
- };
1431
- // Off-screen wizard steps stay mounted (see comment above) but must
1432
- // not participate in native constraint validation, or the Next
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
- {...(sectionActionsEl !== undefined && {
1476
- slots: { headerActions: sectionActionsEl },
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
- </Card>
1692
+ </div>
1481
1693
  ) : (
1482
1694
  mount
1483
1695
  );
1484
- return wrapWizardStep(section.title, wrapped);
1485
- }
1486
- if (section.kind === "relatedList") {
1487
- // parentId is the displayed record's id — without it there's no
1488
- // parent row whose related rows could be queried (fw#2166).
1489
- // Rejected at boot in wizard layouts, so no WizardStepGroup here.
1490
- const parentId = resolveExtensionEntityId(entityIdProp, vm.id);
1491
- if (parentId === null) return null;
1492
- const mount = (
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
- {...(labelAppendix !== undefined && {
1570
- labelAppendix: labelAppendix(field.field),
1571
- })}
1572
- {...(fieldAppendix !== undefined && {
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
- // No section title here (fw#3218) — the Tab strip right above
1615
- // already names this panel, so a repeated Card title would just
1616
- // duplicate it. The title row only exists to carry actions, and
1617
- // then it carries only the actions, never the title text.
1618
- const cardEl = (
1619
- <Card
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
- {...(sectionActionsEl !== undefined && {
1622
- slots: { headerActions: sectionActionsEl },
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
- </Card>
1818
+ </Section>
1628
1819
  );
1629
- return wrapWizardStep(sectionKey, cardEl);
1630
- }
1631
- // Suppress the section header when it would just repeat the form
1632
- // title verbatim (typical for single-section actionForms), or the
1633
- // tab label right above it. Without a Tabs primitive the sections
1634
- // render stacked, and then the titles are what tells them apart.
1635
- const repeatsTabLabel = isTabs && isStepped;
1636
- const sectionTitle =
1637
- section.title === formTitle || repeatsTabLabel ? undefined : section.title;
1638
- const sectionEl = (
1639
- <Section
1640
- key={sectionKey}
1641
- {...(sectionTitle !== undefined && { title: sectionTitle })}
1642
- {...(section.description !== undefined && { subtitle: section.description })}
1643
- {...(section.icon !== undefined && { icon: section.icon })}
1644
- {...(sectionActionsEl !== undefined && { actions: sectionActionsEl })}
1645
- testId={`section-${sectionKey}`}
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
- {renderFieldGrid(section.fields, section.columns, `${sectionKey}-grid`)}
1648
- </Section>
1649
- );
1650
- return wrapWizardStep(sectionKey, sectionEl);
1651
- })}
1652
- {formError !== null && (
1653
- <Banner
1654
- variant="error"
1655
- testId="render-edit-form-error"
1656
- actions={
1657
- formError.code === "version_conflict" && onReload !== undefined ? (
1658
- <Button
1659
- variant="secondary"
1660
- onClick={() => {
1661
- onReload();
1662
- setFormError(null);
1663
- }}
1664
- testId="render-edit-form-error-reload"
1665
- >
1666
- {translate("kumiko.actions.reload")}
1667
- </Button>
1668
- ) : undefined
1669
- }
1670
- >
1671
- <Text testId="render-edit-form-error-key">{translate(formError.i18nKey)}</Text>
1672
- </Banner>
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
  );