@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.
- package/package.json +4 -4
- package/src/app/__tests__/entity-list-row-action-entity-target.test.tsx +5 -2
- package/src/app/__tests__/entity-list-row-action-icon.test.tsx +169 -0
- package/src/app/__tests__/projection-detail-actions.test.tsx +10 -6
- package/src/app/kumiko-screen.tsx +247 -44
- package/src/components/__tests__/render-edit-submit-actions.test.tsx +3 -1
- package/src/components/__tests__/render-field-icon-derivation.test.tsx +119 -0
- package/src/components/__tests__/render-field-number-unit.test.tsx +191 -0
- package/src/components/__tests__/render-list-toolbar-icon-only.test.tsx +146 -0
- package/src/components/related-list-section.tsx +29 -19
- package/src/components/render-edit-action-button.tsx +8 -1
- package/src/components/render-edit-types.ts +5 -0
- package/src/components/render-edit.tsx +73 -31
- package/src/components/render-field.tsx +71 -11
- package/src/components/render-list.tsx +27 -2
- package/src/i18n-defaults.ts +2 -0
- package/src/index.ts +6 -1
- package/src/primitives.tsx +79 -6
|
@@ -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={
|
|
98
|
-
{
|
|
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
|
-
//
|
|
918
|
-
//
|
|
919
|
-
//
|
|
920
|
-
|
|
921
|
-
|
|
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
|
-
{
|
|
924
|
-
<
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
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
|
-
{
|
|
943
|
-
|
|
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="
|
|
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
|
|
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}>
|