@cosmicdrift/kumiko-renderer 0.232.0 → 0.234.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.
@@ -0,0 +1,191 @@
1
+ // Editable number-field unit-of-measure suffix (static or sibling-field
2
+ // reference). Complements render-field-unit-format.test.tsx, which only
3
+ // covers the pre-existing read-only `format: "unit"` FieldRenderer path —
4
+ // this feature is display-only decoration on the editable Input widget,
5
+ // never a value conversion.
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import type {
9
+ EntityDefinition,
10
+ EntityEditScreenDefinition,
11
+ } from "@cosmicdrift/kumiko-framework/ui-types";
12
+ import { computeEditViewModel, type EditFieldViewModel } from "@cosmicdrift/kumiko-headless";
13
+ import { render } from "@testing-library/react";
14
+ import type { ComponentType, ReactNode } from "react";
15
+ import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
16
+ import { type CorePrimitives, type InputProps, PrimitivesProvider } from "../../primitives";
17
+ import { RenderField } from "../render-field";
18
+
19
+ let captured: InputProps | undefined;
20
+ const captureInput: ComponentType<InputProps> = (props) => {
21
+ captured = props;
22
+ return null;
23
+ };
24
+ const noop = (): ReactNode => null;
25
+ const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
26
+
27
+ const testPrimitives: CorePrimitives = {
28
+ Button: noop,
29
+ Banner: noop,
30
+ Field: passChildren,
31
+ Input: captureInput,
32
+ DataTable: noop,
33
+ Form: noop,
34
+ Section: noop,
35
+ Card: noop,
36
+ Grid: noop,
37
+ GridCell: noop,
38
+ Text: noop,
39
+ Heading: noop,
40
+ Dialog: noop,
41
+ Modal: noop,
42
+ Lightbox: noop,
43
+ ConfigSourceBadge: noop,
44
+ ConfigCascadeView: noop,
45
+ Link: noop,
46
+ };
47
+
48
+ function buildEntity(
49
+ mileageUnit: string | { readonly field: string } | undefined,
50
+ ): EntityDefinition {
51
+ return {
52
+ fields: {
53
+ mileage: {
54
+ type: "number",
55
+ required: false,
56
+ ...(mileageUnit !== undefined && { unit: mileageUnit }),
57
+ },
58
+ mileageUnit: { type: "text", required: false },
59
+ },
60
+ } as EntityDefinition;
61
+ }
62
+
63
+ function buildScreen(): EntityEditScreenDefinition {
64
+ return {
65
+ id: "vehicle-edit",
66
+ type: "entityEdit",
67
+ entity: "vehicle",
68
+ layout: { sections: [{ columns: 1, fields: ["mileage", "mileageUnit"] }] },
69
+ } as EntityEditScreenDefinition;
70
+ }
71
+
72
+ function mileageField(
73
+ entity: EntityDefinition,
74
+ values: Record<string, unknown>,
75
+ ): EditFieldViewModel {
76
+ const vm = computeEditViewModel({
77
+ screen: buildScreen(),
78
+ entity,
79
+ values,
80
+ translate: (key) => key,
81
+ featureName: "fleet",
82
+ });
83
+ const section = vm.sections[0];
84
+ if (section === undefined || section.kind !== "fields") {
85
+ throw new Error("expected a fields section");
86
+ }
87
+ const field = section.fields.find((f) => f.field === "mileage");
88
+ if (field === undefined) throw new Error("expected a mileage field");
89
+ return field;
90
+ }
91
+
92
+ function currentInput(): InputProps {
93
+ if (captured === undefined) throw new Error("expected an Input to be captured");
94
+ return captured;
95
+ }
96
+
97
+ function renderMileageField(
98
+ entity: EntityDefinition,
99
+ values: Record<string, unknown>,
100
+ row?: Record<string, unknown>,
101
+ ): InputProps {
102
+ captured = undefined;
103
+ render(
104
+ <LocaleProvider resolver={createStaticLocaleResolver({ locale: "en-US" })}>
105
+ <PrimitivesProvider value={testPrimitives}>
106
+ <RenderField
107
+ field={mileageField(entity, values)}
108
+ onChange={() => {}}
109
+ {...(row !== undefined && { row })}
110
+ />
111
+ </PrimitivesProvider>
112
+ </LocaleProvider>,
113
+ );
114
+ if (captured === undefined) throw new Error("mileage field did not render an Input");
115
+ return captured;
116
+ }
117
+
118
+ describe("RenderField — editable number unit suffix", () => {
119
+ test("static unit resolves onto Input.unit, value stays untouched", () => {
120
+ const field = renderMileageField(buildEntity("km"), { mileage: 58 });
121
+ expect(field.kind).toBe("number");
122
+ if (field.kind !== "number") return;
123
+ expect(field.unit).toBe("km");
124
+ expect(field.value).toBe(58);
125
+ });
126
+
127
+ test("no unit configured: Input.unit is undefined", () => {
128
+ const field = renderMileageField(buildEntity(undefined), { mileage: 58 });
129
+ expect(field.kind).toBe("number");
130
+ if (field.kind !== "number") return;
131
+ expect(field.unit).toBeUndefined();
132
+ });
133
+
134
+ test("sibling-field unit resolves from the live row and updates when the sibling changes", () => {
135
+ const entity = buildEntity({ field: "mileageUnit" });
136
+ captured = undefined;
137
+ const { rerender } = render(
138
+ <LocaleProvider resolver={createStaticLocaleResolver({ locale: "en-US" })}>
139
+ <PrimitivesProvider value={testPrimitives}>
140
+ <RenderField
141
+ field={mileageField(entity, { mileage: 58, mileageUnit: "mi" })}
142
+ onChange={() => {}}
143
+ row={{ mileage: 58, mileageUnit: "mi" }}
144
+ />
145
+ </PrimitivesProvider>
146
+ </LocaleProvider>,
147
+ );
148
+ const first = currentInput();
149
+ if (first.kind !== "number") throw new Error("expected a number Input");
150
+ expect(first.unit).toBe("mi");
151
+
152
+ captured = undefined;
153
+ rerender(
154
+ <LocaleProvider resolver={createStaticLocaleResolver({ locale: "en-US" })}>
155
+ <PrimitivesProvider value={testPrimitives}>
156
+ <RenderField
157
+ field={mileageField(entity, { mileage: 58, mileageUnit: "km" })}
158
+ onChange={() => {}}
159
+ row={{ mileage: 58, mileageUnit: "km" }}
160
+ />
161
+ </PrimitivesProvider>
162
+ </LocaleProvider>,
163
+ );
164
+ const second = currentInput();
165
+ if (second.kind !== "number") throw new Error("expected a number Input");
166
+ expect(second.unit).toBe("km");
167
+ });
168
+
169
+ test("missing sibling field: no suffix, no crash, no guessed default", () => {
170
+ const entity = buildEntity({ field: "mileageUnit" });
171
+ const field = renderMileageField(entity, { mileage: 58 }, { mileage: 58 });
172
+ expect(field.kind).toBe("number");
173
+ if (field.kind !== "number") return;
174
+ expect(field.unit).toBeUndefined();
175
+ });
176
+
177
+ test("empty-string sibling value: no suffix, no guessed default", () => {
178
+ const entity = buildEntity({ field: "mileageUnit" });
179
+ const field = renderMileageField(
180
+ entity,
181
+ { mileage: 58, mileageUnit: "" },
182
+ {
183
+ mileage: 58,
184
+ mileageUnit: "",
185
+ },
186
+ );
187
+ expect(field.kind).toBe("number");
188
+ if (field.kind !== "number") return;
189
+ expect(field.unit).toBeUndefined();
190
+ });
191
+ });
@@ -0,0 +1,146 @@
1
+ // ToolbarActionView collapses a group of >2 icon-carrying toolbar actions to
2
+ // icon-only buttons (`shouldRenderActionsIconOnly`). The button then has no
3
+ // accessible name from its children, so the label has to move to `ariaLabel`
4
+ // and must NOT stay in the children — otherwise the text renders inside a
5
+ // button sized for an icon.
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import type {
9
+ EntityDefinition,
10
+ EntityListScreenDefinition,
11
+ } from "@cosmicdrift/kumiko-framework/ui-types";
12
+ import { render, screen as rtlScreen } from "@testing-library/react";
13
+ import type { ComponentType, ReactNode } from "react";
14
+ import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
15
+ import { kumikoDefaultTranslations } from "../../i18n-defaults";
16
+ import {
17
+ type ButtonProps,
18
+ type CorePrimitives,
19
+ type DataTableProps,
20
+ PrimitivesProvider,
21
+ } from "../../primitives";
22
+ import { RenderList, type ToolbarActionButton } from "../render-list";
23
+
24
+ const TestButton: ComponentType<ButtonProps> = ({ children, onClick, testId, ariaLabel, size }) => (
25
+ <button
26
+ type="button"
27
+ data-testid={testId}
28
+ data-size={size ?? "md"}
29
+ aria-label={ariaLabel}
30
+ onClick={() => {
31
+ void onClick?.();
32
+ }}
33
+ >
34
+ {children}
35
+ </button>
36
+ );
37
+
38
+ const toolbarOnlyDataTable: ComponentType<DataTableProps> = ({ toolbarEnd }) => (
39
+ <div>{toolbarEnd}</div>
40
+ );
41
+
42
+ const noop = (): ReactNode => null;
43
+ const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
44
+
45
+ const testPrimitives: CorePrimitives = {
46
+ Button: TestButton,
47
+ Banner: passChildren,
48
+ Field: passChildren,
49
+ Input: noop,
50
+ DataTable: toolbarOnlyDataTable,
51
+ Form: passChildren,
52
+ Section: passChildren,
53
+ Card: passChildren,
54
+ Grid: passChildren,
55
+ GridCell: passChildren,
56
+ Text: passChildren,
57
+ Heading: noop,
58
+ Dialog: noop,
59
+ Modal: noop,
60
+ Lightbox: noop,
61
+ ConfigSourceBadge: noop,
62
+ ConfigCascadeView: noop,
63
+ Link: noop,
64
+ };
65
+
66
+ const entity: EntityDefinition = {
67
+ fields: {
68
+ name: { type: "text", maxLength: 50, required: false, searchable: false, sortable: false },
69
+ },
70
+ };
71
+
72
+ const listScreen: EntityListScreenDefinition = {
73
+ id: "widget-list",
74
+ type: "entityList",
75
+ entity: "widget",
76
+ columns: ["name"],
77
+ };
78
+
79
+ function toolbarAction(id: string, label: string, withIcon: boolean): ToolbarActionButton {
80
+ return {
81
+ id,
82
+ label,
83
+ onTrigger: () => {},
84
+ ...(withIcon && { icon: "archive" as const }),
85
+ };
86
+ }
87
+
88
+ function renderToolbar(actions: readonly ToolbarActionButton[]): void {
89
+ render(
90
+ <LocaleProvider
91
+ resolver={createStaticLocaleResolver({ locale: "en" })}
92
+ fallbackBundles={[kumikoDefaultTranslations]}
93
+ >
94
+ <PrimitivesProvider value={testPrimitives}>
95
+ <RenderList
96
+ screen={listScreen}
97
+ entity={entity}
98
+ rows={[]}
99
+ featureName="toolbar-fixture"
100
+ toolbarActions={actions}
101
+ />
102
+ </PrimitivesProvider>
103
+ </LocaleProvider>,
104
+ );
105
+ }
106
+
107
+ describe("RenderList toolbar actions collapse to icon-only", () => {
108
+ test("three icon actions render buttons with no visible text but an aria-label", () => {
109
+ renderToolbar([
110
+ toolbarAction("sync", "Sync", true),
111
+ toolbarAction("export", "Export", true),
112
+ toolbarAction("archive", "Archive", true),
113
+ ]);
114
+
115
+ for (const [id, label] of [
116
+ ["sync", "Sync"],
117
+ ["export", "Export"],
118
+ ["archive", "Archive"],
119
+ ] as const) {
120
+ const button = rtlScreen.getByTestId(`render-list-toolbar-action-${id}`);
121
+ expect(button.getAttribute("data-size")).toBe("icon");
122
+ expect(button.getAttribute("aria-label")).toBe(label);
123
+ expect(button.textContent).toBe("");
124
+ }
125
+ });
126
+
127
+ test("two actions stay on text buttons carrying the label as children", () => {
128
+ renderToolbar([toolbarAction("sync", "Sync", true), toolbarAction("export", "Export", true)]);
129
+
130
+ const button = rtlScreen.getByTestId("render-list-toolbar-action-sync");
131
+ expect(button.getAttribute("data-size")).toBe("md");
132
+ expect(button.textContent).toBe("Sync");
133
+ });
134
+
135
+ test("an icon-less member keeps the whole group on text buttons", () => {
136
+ renderToolbar([
137
+ toolbarAction("sync", "Sync", true),
138
+ toolbarAction("export", "Export", true),
139
+ toolbarAction("archive", "Archive", false),
140
+ ]);
141
+
142
+ const button = rtlScreen.getByTestId("render-list-toolbar-action-sync");
143
+ expect(button.getAttribute("data-size")).toBe("md");
144
+ expect(button.textContent).toBe("Sync");
145
+ });
146
+ });
@@ -93,26 +93,36 @@ export function RelatedListSection({
93
93
  }
94
94
  : undefined;
95
95
 
96
+ const content =
97
+ rowsQuery.loading && rowsQuery.data === null ? (
98
+ <Banner padded variant="loading" testId="related-list-loading">
99
+ Loading…
100
+ </Banner>
101
+ ) : rowsQuery.error ? (
102
+ <Banner padded variant="error" testId="related-list-error">
103
+ {dispatcherErrorText(rowsQuery.error, effectiveTranslate)}
104
+ </Banner>
105
+ ) : (
106
+ <RenderList
107
+ screen={listScreen}
108
+ entity={entity}
109
+ rows={rowsQuery.data?.rows ?? []}
110
+ featureName={featureName}
111
+ translate={effectiveTranslate}
112
+ {...(onRowClick !== undefined && { onRowClick })}
113
+ />
114
+ );
115
+
116
+ // hideTitle (tabs mode) → this section carries no header content of its
117
+ // own, and RenderList's DataTable already draws its own card frame — a
118
+ // Section wrapper here would only add a second, nested card (fw record-
119
+ // screen-type polish). Stacked (non-tabs) sections keep their Section
120
+ // card since they render a visible title.
121
+ if (hideTitle) return content;
122
+
96
123
  return (
97
- <Section title={hideTitle ? undefined : section.title} testId={`related-list-${section.title}`}>
98
- {rowsQuery.loading && rowsQuery.data === null ? (
99
- <Banner padded variant="loading" testId="related-list-loading">
100
- Loading…
101
- </Banner>
102
- ) : rowsQuery.error ? (
103
- <Banner padded variant="error" testId="related-list-error">
104
- {dispatcherErrorText(rowsQuery.error, effectiveTranslate)}
105
- </Banner>
106
- ) : (
107
- <RenderList
108
- screen={listScreen}
109
- entity={entity}
110
- rows={rowsQuery.data?.rows ?? []}
111
- featureName={featureName}
112
- translate={effectiveTranslate}
113
- {...(onRowClick !== undefined && { onRowClick })}
114
- />
115
- )}
124
+ <Section title={section.title} testId={`related-list-${section.title}`}>
125
+ {content}
116
126
  </Section>
117
127
  );
118
128
  }
@@ -9,11 +9,15 @@ import type { RenderEditAction } from "./render-edit-types";
9
9
  // to hook into like the built-in onDelete/onSubmit paths have).
10
10
  export function RenderEditActionButton({
11
11
  action,
12
+ iconOnly = false,
12
13
  Button,
13
14
  Dialog,
14
15
  onError,
15
16
  }: {
16
17
  readonly action: RenderEditAction;
18
+ /** Group-level collapse (see `shouldRenderActionsIconOnly`) — only takes
19
+ * effect when this action actually resolved an icon. */
20
+ readonly iconOnly?: boolean;
17
21
  readonly Button: ReturnType<typeof usePrimitives>["Button"];
18
22
  readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
19
23
  readonly onError: (text: string | null) => void;
@@ -37,6 +41,7 @@ export function RenderEditActionButton({
37
41
  // Same rule as RowActionWriteHandler: "danger" forces a confirm even
38
42
  // without an explicit confirm key.
39
43
  const needsConfirm = action.confirm !== undefined || action.style === "danger";
44
+ const showIconOnly = iconOnly && action.icon !== undefined;
40
45
 
41
46
  return (
42
47
  <>
@@ -44,6 +49,8 @@ export function RenderEditActionButton({
44
49
  type="button"
45
50
  variant={variant}
46
51
  loading={busy}
52
+ {...(action.icon !== undefined && { icon: action.icon })}
53
+ {...(showIconOnly && { size: "icon" as const, ariaLabel: action.label })}
47
54
  onClick={() => {
48
55
  if (needsConfirm) {
49
56
  setConfirmOpen(true);
@@ -53,7 +60,7 @@ export function RenderEditActionButton({
53
60
  }}
54
61
  testId={`render-edit-action-${action.id}`}
55
62
  >
56
- {action.label}
63
+ {showIconOnly ? null : action.label}
57
64
  </Button>
58
65
  <Dialog
59
66
  open={confirmOpen}
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  EntityDefinition,
3
3
  EntityEditScreenDefinition,
4
+ IconKey,
4
5
  } from "@cosmicdrift/kumiko-framework/ui-types";
5
6
  import type {
6
7
  FormSnapshot,
@@ -156,6 +157,10 @@ export type RenderEditAction = {
156
157
  readonly style?: "primary" | "secondary" | "danger";
157
158
  readonly confirm?: string;
158
159
  readonly confirmLabel?: string;
160
+ /** Resolved icon (author `RowAction.icon` or the id-derived default) —
161
+ * drives both the icon-left-of-text render and the icon-only collapse
162
+ * rule (see `shouldRenderActionsIconOnly`). */
163
+ readonly icon?: IconKey;
159
164
  };
160
165
 
161
166
  export type RenderEditChangeState<TValues extends FormValues> = {
@@ -36,7 +36,7 @@ import { useDraftStorage } from "../context/draft-storage-context";
36
36
  import { formatWhen } from "../format-when";
37
37
  import { useForm } from "../hooks/use-form";
38
38
  import { useTranslation } from "../i18n";
39
- import { usePrimitives } from "../primitives";
39
+ import { shouldRenderActionsIconOnly, usePrimitives } from "../primitives";
40
40
  import { RelatedListSection } from "./related-list-section";
41
41
  import {
42
42
  filterEditSections,
@@ -914,59 +914,92 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
914
914
  }
915
915
  handleSubmitRef.current = handleSubmit;
916
916
 
917
- // Sticky-top Action-Bar: Delete (links, destructive) + Cancel +
918
- // Save. Delete sitzt links abgesetzt damit die Click-Distanz zu
919
- // Save is large; red styling + confirm dialog are enough protection
920
- // gegen Fehlklicks. Save bleibt rechts (primary affordance).
921
- const formActions = (
917
+ // Two groups wizard navigation on the right/top, record actions on the
918
+ // left/below; destructive action sits outermost, farthest from the target
919
+ // action.
920
+ const hasSecondaryFormActions =
921
+ onDelete !== undefined ||
922
+ onCopyLink !== undefined ||
923
+ (actions !== undefined && actions.length > 0) ||
924
+ onCancel !== undefined;
925
+ // Teil-C collapse rule: driven by the custom `actions` group only — a
926
+ // resolved true also folds the Copy-Link button (same visual group in the
927
+ // footer), but never Delete/Cancel, which keep their text regardless.
928
+ const iconOnlyMidActions = actions !== undefined && shouldRenderActionsIconOnly(actions);
929
+ const secondaryFormActions = (
922
930
  <>
923
- {actions?.map((action) => (
924
- <RenderEditActionButton
925
- key={action.id}
926
- action={action}
927
- Button={Button}
928
- Dialog={Dialog}
929
- onError={setActionError}
930
- />
931
- ))}
931
+ {onDelete !== undefined && (
932
+ <Button
933
+ type="button"
934
+ variant="danger-ghost"
935
+ icon="trash"
936
+ testId="render-edit-delete"
937
+ disabled={disabled}
938
+ onClick={() => setConfirmDeleteOpen(true)}
939
+ >
940
+ {translate("kumiko.actions.delete")}
941
+ </Button>
942
+ )}
932
943
  {onCopyLink !== undefined && (
933
944
  <Button
934
945
  type="button"
935
- variant="secondary"
946
+ variant={iconOnlyMidActions ? "secondary" : "link"}
947
+ icon={linkCopied ? "check" : "link"}
936
948
  testId="render-edit-copy-link"
949
+ {...(iconOnlyMidActions && {
950
+ size: "icon" as const,
951
+ ariaLabel: translate(
952
+ linkCopied ? "kumiko.actions.copyLinkCopied" : "kumiko.actions.copyLink",
953
+ ),
954
+ })}
937
955
  onClick={async () => {
938
956
  await onCopyLink();
939
957
  setLinkCopied(true);
940
958
  }}
941
959
  >
942
- {translate(linkCopied ? "kumiko.actions.copyLinkCopied" : "kumiko.actions.copyLink")}
943
- </Button>
944
- )}
945
- {onDelete !== undefined && (
946
- <Button
947
- type="button"
948
- variant="danger"
949
- testId="render-edit-delete"
950
- disabled={disabled}
951
- onClick={() => setConfirmDeleteOpen(true)}
952
- >
953
- {translate("kumiko.actions.delete")}
960
+ {iconOnlyMidActions
961
+ ? null
962
+ : translate(linkCopied ? "kumiko.actions.copyLinkCopied" : "kumiko.actions.copyLink")}
954
963
  </Button>
955
964
  )}
965
+ {actions?.map((action) => (
966
+ <RenderEditActionButton
967
+ key={action.id}
968
+ action={action}
969
+ iconOnly={iconOnlyMidActions}
970
+ Button={Button}
971
+ Dialog={Dialog}
972
+ onError={setActionError}
973
+ />
974
+ ))}
956
975
  {onCancel !== undefined && (
957
976
  <Button
958
977
  type="button"
959
- variant="secondary"
978
+ variant="link"
979
+ icon="x"
960
980
  onClick={() => onCancel()}
961
981
  testId="render-edit-cancel"
962
982
  >
963
983
  {translate("kumiko.actions.cancel")}
964
984
  </Button>
965
985
  )}
986
+ </>
987
+ );
988
+ // Mirrors every branch inside formActions below — without this guard
989
+ // DefaultForm renders an empty footer strip (border + padding, no content)
990
+ // on read-only detail screens, since `actions` would otherwise always be
991
+ // a defined (if empty) fragment.
992
+ const hasFormActions =
993
+ (isWizard && currentStep > 0) ||
994
+ (isWizard && !isLastWizardStep) ||
995
+ ((isFormEditable || hasExtensionRegistrations) && (!isWizard || isLastWizardStep));
996
+ const formActions = (
997
+ <>
966
998
  {isWizard && currentStep > 0 && (
967
999
  <Button
968
1000
  type="button"
969
1001
  variant="secondary"
1002
+ icon="arrow-left"
970
1003
  onClick={handleWizardBack}
971
1004
  testId="render-edit-wizard-back"
972
1005
  >
@@ -974,7 +1007,12 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
974
1007
  </Button>
975
1008
  )}
976
1009
  {isWizard && !isLastWizardStep && (
977
- <Button type="submit" variant="primary" testId="render-edit-wizard-next">
1010
+ <Button
1011
+ type="submit"
1012
+ variant="primary"
1013
+ iconEnd="arrow-right"
1014
+ testId="render-edit-wizard-next"
1015
+ >
978
1016
  {translate("kumiko.actions.next")}
979
1017
  </Button>
980
1018
  )}
@@ -984,6 +1022,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
984
1022
  disabled={(snapshot.isUnchanged && !extensionDirty) || isSubmitting || disabled}
985
1023
  loading={isSubmitting}
986
1024
  variant="primary"
1025
+ icon="check"
987
1026
  testId="render-edit-submit"
988
1027
  >
989
1028
  {translate(submitLabel ?? (isWizard ? "kumiko.actions.finish" : "kumiko.actions.save"))}
@@ -1021,7 +1060,9 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1021
1060
  {...(hideSectionTitles !== true && { title: formTitle })}
1022
1061
  {...(hideSectionTitles !== true &&
1023
1062
  formSubtitle !== undefined && { subtitle: formSubtitle })}
1024
- {...(hideActions !== true && { actions: formActions })}
1063
+ {...(hideActions !== true && hasFormActions && { actions: formActions })}
1064
+ {...(hideActions !== true &&
1065
+ hasSecondaryFormActions && { secondaryActions: secondaryFormActions })}
1025
1066
  testId="render-edit-form"
1026
1067
  stickyActions={isWizard}
1027
1068
  {...(screen.layout.width !== undefined && { width: screen.layout.width })}
@@ -1186,6 +1227,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1186
1227
  key={sectionKey}
1187
1228
  {...(sectionTitle !== undefined && { title: sectionTitle })}
1188
1229
  {...(section.description !== undefined && { subtitle: section.description })}
1230
+ {...(section.icon !== undefined && { icon: section.icon })}
1189
1231
  testId={`section-${sectionKey}`}
1190
1232
  >
1191
1233
  <Grid columns={section.columns}>