@cosmicdrift/kumiko-renderer 0.233.0 → 0.235.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 +6 -4
- package/src/__tests__/i18n.test.tsx +39 -0
- package/src/app/__tests__/entity-list-row-action-icon.test.tsx +169 -0
- package/src/app/kumiko-screen.tsx +255 -50
- package/src/app/secrets-edit-body.tsx +190 -0
- package/src/components/__tests__/render-field-icon-derivation.test.tsx +119 -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 +26 -4
- package/src/components/render-field.tsx +47 -5
- package/src/components/render-list.tsx +27 -2
- package/src/i18n.tsx +4 -4
- package/src/index.ts +6 -1
- package/src/primitives.tsx +63 -1
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import type { SecretsEditScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
|
+
import type { Translate } from "@cosmicdrift/kumiko-headless";
|
|
3
|
+
import { type ReactNode, useCallback, useMemo, useState } from "react";
|
|
4
|
+
import { useDispatcher } from "../context/dispatcher-context";
|
|
5
|
+
import { useQuery } from "../hooks/use-query";
|
|
6
|
+
import { useTranslation } from "../i18n";
|
|
7
|
+
import { usePrimitives } from "../primitives";
|
|
8
|
+
import { dispatcherErrorText } from "./write-failed-error";
|
|
9
|
+
|
|
10
|
+
type SecretListRow = {
|
|
11
|
+
readonly key: string;
|
|
12
|
+
readonly redactedPreview: string | null;
|
|
13
|
+
readonly hint: string | null;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type SecretsEditBodyProps = {
|
|
17
|
+
readonly screen: SecretsEditScreenDefinition;
|
|
18
|
+
readonly translate?: Translate;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// secrets:query:list never returns plaintext (only a redacted preview), so
|
|
22
|
+
// unlike ConfigEditBody there is no server value to pre-fill a draft with —
|
|
23
|
+
// every input starts at "" and stays that way unless the user types into it.
|
|
24
|
+
export function SecretsEditBody({ screen, translate }: SecretsEditBodyProps): ReactNode {
|
|
25
|
+
const { Banner, Form, Section, Field, Input, Button, Text } = usePrimitives();
|
|
26
|
+
const t = useTranslation();
|
|
27
|
+
const effectiveTranslate = translate ?? t;
|
|
28
|
+
const dispatcher = useDispatcher();
|
|
29
|
+
const listQuery = useQuery<readonly SecretListRow[]>("secrets:query:list", {});
|
|
30
|
+
|
|
31
|
+
const [drafts, setDrafts] = useState<Readonly<Record<string, string>>>({});
|
|
32
|
+
const [submitting, setSubmitting] = useState(false);
|
|
33
|
+
const [submitError, setSubmitError] = useState<string | null>(null);
|
|
34
|
+
|
|
35
|
+
const rowsByQualifiedKey = useMemo(() => {
|
|
36
|
+
const out = new Map<string, SecretListRow>();
|
|
37
|
+
for (const row of listQuery.data ?? []) out.set(row.key, row);
|
|
38
|
+
return out;
|
|
39
|
+
}, [listQuery.data]);
|
|
40
|
+
|
|
41
|
+
const setDraft = useCallback((fieldId: string, value: string) => {
|
|
42
|
+
setDrafts((prev) => ({ ...prev, [fieldId]: value }));
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
45
|
+
const handleSubmit = useCallback(async (): Promise<void> => {
|
|
46
|
+
const commands = Object.entries(screen.secretKeys).flatMap(([fieldId, qualified]) => {
|
|
47
|
+
const value = drafts[fieldId]?.trim();
|
|
48
|
+
return value ? [{ type: "secrets:write:set", payload: { key: qualified, value } }] : [];
|
|
49
|
+
});
|
|
50
|
+
if (commands.length === 0) return;
|
|
51
|
+
setSubmitting(true);
|
|
52
|
+
setSubmitError(null);
|
|
53
|
+
const result = await dispatcher.batch(commands);
|
|
54
|
+
setSubmitting(false);
|
|
55
|
+
if (!result.isSuccess) {
|
|
56
|
+
setSubmitError(dispatcherErrorText(result.error, effectiveTranslate));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
setDrafts({});
|
|
60
|
+
await listQuery.refetch();
|
|
61
|
+
}, [dispatcher, drafts, screen.secretKeys, listQuery.refetch, effectiveTranslate]);
|
|
62
|
+
|
|
63
|
+
const handleDelete = useCallback(
|
|
64
|
+
async (qualified: string): Promise<void> => {
|
|
65
|
+
const result = await dispatcher.write("secrets:write:delete", { key: qualified });
|
|
66
|
+
if (!result.isSuccess) {
|
|
67
|
+
setSubmitError(dispatcherErrorText(result.error, effectiveTranslate));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
await listQuery.refetch();
|
|
71
|
+
},
|
|
72
|
+
[dispatcher, listQuery.refetch, effectiveTranslate],
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
if (listQuery.loading && listQuery.data === null) {
|
|
76
|
+
return (
|
|
77
|
+
<Banner padded variant="loading" testId="kumiko-screen-loading">
|
|
78
|
+
Loading…
|
|
79
|
+
</Banner>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
if (listQuery.error) {
|
|
83
|
+
return (
|
|
84
|
+
<Banner padded variant="error" testId="kumiko-screen-error">
|
|
85
|
+
{dispatcherErrorText(listQuery.error, effectiveTranslate)}
|
|
86
|
+
</Banner>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<Form
|
|
92
|
+
onSubmit={() => {
|
|
93
|
+
void handleSubmit();
|
|
94
|
+
}}
|
|
95
|
+
testId="secrets-edit-form"
|
|
96
|
+
actions={
|
|
97
|
+
<Button
|
|
98
|
+
type="submit"
|
|
99
|
+
variant="primary"
|
|
100
|
+
loading={submitting}
|
|
101
|
+
disabled={submitting}
|
|
102
|
+
testId="secrets-edit-submit"
|
|
103
|
+
>
|
|
104
|
+
{effectiveTranslate("kumiko.actions.save")}
|
|
105
|
+
</Button>
|
|
106
|
+
}
|
|
107
|
+
>
|
|
108
|
+
{submitError !== null && (
|
|
109
|
+
<Banner variant="error" testId="secrets-edit-error">
|
|
110
|
+
{submitError}
|
|
111
|
+
</Banner>
|
|
112
|
+
)}
|
|
113
|
+
{screen.sections.map((section, index) => (
|
|
114
|
+
<Section
|
|
115
|
+
key={section.title ?? `section-${index}`}
|
|
116
|
+
{...(section.title !== undefined && { title: effectiveTranslate(section.title) })}
|
|
117
|
+
>
|
|
118
|
+
{section.fields.map((fieldId) => {
|
|
119
|
+
const qualified = screen.secretKeys[fieldId];
|
|
120
|
+
if (qualified === undefined) return null;
|
|
121
|
+
const row = rowsByQualifiedKey.get(qualified);
|
|
122
|
+
const hintKey = screen.fieldHints?.[fieldId];
|
|
123
|
+
const isRequired = screen.requiredFields?.includes(fieldId) ?? false;
|
|
124
|
+
return (
|
|
125
|
+
<Field
|
|
126
|
+
key={fieldId}
|
|
127
|
+
id={fieldId}
|
|
128
|
+
label={effectiveTranslate(screen.fieldLabels[fieldId] ?? fieldId)}
|
|
129
|
+
required={isRequired}
|
|
130
|
+
testId={`field-${fieldId}`}
|
|
131
|
+
fieldAppendix={
|
|
132
|
+
<>
|
|
133
|
+
{hintKey !== undefined && (
|
|
134
|
+
<Text variant="small">{effectiveTranslate(hintKey)}</Text>
|
|
135
|
+
)}
|
|
136
|
+
{row !== undefined ? (
|
|
137
|
+
<>
|
|
138
|
+
<Text variant="small" testId={`secret-preview-${fieldId}`}>
|
|
139
|
+
{row.redactedPreview ?? effectiveTranslate("config.secrets.set")}
|
|
140
|
+
</Text>
|
|
141
|
+
<Button
|
|
142
|
+
type="button"
|
|
143
|
+
variant="danger-ghost"
|
|
144
|
+
size="sm"
|
|
145
|
+
onClick={() => handleDelete(qualified)}
|
|
146
|
+
testId={`secret-delete-${fieldId}`}
|
|
147
|
+
>
|
|
148
|
+
{effectiveTranslate("config.secrets.delete")}
|
|
149
|
+
</Button>
|
|
150
|
+
</>
|
|
151
|
+
) : (
|
|
152
|
+
<>
|
|
153
|
+
<Text variant="small" testId={`secret-not-set-${fieldId}`}>
|
|
154
|
+
{effectiveTranslate("config.secrets.notSet")}
|
|
155
|
+
</Text>
|
|
156
|
+
{isRequired && (
|
|
157
|
+
<Text variant="small" testId={`required-marker-${fieldId}`}>
|
|
158
|
+
{effectiveTranslate("config.secrets.required")}
|
|
159
|
+
</Text>
|
|
160
|
+
)}
|
|
161
|
+
</>
|
|
162
|
+
)}
|
|
163
|
+
</>
|
|
164
|
+
}
|
|
165
|
+
>
|
|
166
|
+
<Text variant="small">
|
|
167
|
+
{effectiveTranslate(
|
|
168
|
+
row !== undefined
|
|
169
|
+
? "config.secrets.replacePlaceholder"
|
|
170
|
+
: "config.secrets.placeholder",
|
|
171
|
+
)}
|
|
172
|
+
</Text>
|
|
173
|
+
<Input
|
|
174
|
+
kind="password"
|
|
175
|
+
id={fieldId}
|
|
176
|
+
name={fieldId}
|
|
177
|
+
value={drafts[fieldId] ?? ""}
|
|
178
|
+
onChange={(v) => setDraft(fieldId, v)}
|
|
179
|
+
autoComplete="new-password"
|
|
180
|
+
disabled={submitting}
|
|
181
|
+
testId={`secret-input-${fieldId}`}
|
|
182
|
+
/>
|
|
183
|
+
</Field>
|
|
184
|
+
);
|
|
185
|
+
})}
|
|
186
|
+
</Section>
|
|
187
|
+
))}
|
|
188
|
+
</Form>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Fields without a declared icon derive one from the field name — mirrors
|
|
2
|
+
// ACTION_ICON_BY_ID/resolveActionIcon (kumiko-screen.tsx) for fields. Only
|
|
3
|
+
// kind:"text" (single-line) and kind:"number" structurally carry an icon
|
|
4
|
+
// prop, so that's what these tests assert against.
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import type { EditFieldViewModel } from "@cosmicdrift/kumiko-headless";
|
|
8
|
+
import { render } from "@testing-library/react";
|
|
9
|
+
import type { ComponentType, ReactNode } from "react";
|
|
10
|
+
import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
|
|
11
|
+
import { type CorePrimitives, type InputProps, PrimitivesProvider } from "../../primitives";
|
|
12
|
+
import { RenderField } from "../render-field";
|
|
13
|
+
|
|
14
|
+
let captured: InputProps | undefined;
|
|
15
|
+
const captureInput: ComponentType<InputProps> = (props) => {
|
|
16
|
+
captured = props;
|
|
17
|
+
return null;
|
|
18
|
+
};
|
|
19
|
+
const noop = (): ReactNode => null;
|
|
20
|
+
const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
|
|
21
|
+
|
|
22
|
+
const testPrimitives: CorePrimitives = {
|
|
23
|
+
Button: noop,
|
|
24
|
+
Banner: noop,
|
|
25
|
+
Field: passChildren,
|
|
26
|
+
Input: captureInput,
|
|
27
|
+
DataTable: noop,
|
|
28
|
+
Form: noop,
|
|
29
|
+
Section: noop,
|
|
30
|
+
Card: noop,
|
|
31
|
+
Grid: noop,
|
|
32
|
+
GridCell: noop,
|
|
33
|
+
Text: noop,
|
|
34
|
+
Heading: noop,
|
|
35
|
+
Dialog: noop,
|
|
36
|
+
Modal: noop,
|
|
37
|
+
Lightbox: noop,
|
|
38
|
+
ConfigSourceBadge: noop,
|
|
39
|
+
ConfigCascadeView: noop,
|
|
40
|
+
Link: noop,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function textField(overrides: Partial<EditFieldViewModel> = {}): EditFieldViewModel {
|
|
44
|
+
return {
|
|
45
|
+
field: "name",
|
|
46
|
+
label: "Name",
|
|
47
|
+
type: "text",
|
|
48
|
+
value: "",
|
|
49
|
+
visible: true,
|
|
50
|
+
readOnly: false,
|
|
51
|
+
required: false,
|
|
52
|
+
...overrides,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function renderField(field: EditFieldViewModel): void {
|
|
57
|
+
captured = undefined;
|
|
58
|
+
render(
|
|
59
|
+
<LocaleProvider resolver={createStaticLocaleResolver({ locale: "en-US" })}>
|
|
60
|
+
<PrimitivesProvider value={testPrimitives}>
|
|
61
|
+
<RenderField field={field} onChange={() => {}} />
|
|
62
|
+
</PrimitivesProvider>
|
|
63
|
+
</LocaleProvider>,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe("RenderField — icon derivation from field name/type", () => {
|
|
68
|
+
test("field named 'email' with no declared icon renders the mail icon", () => {
|
|
69
|
+
renderField(textField({ field: "email" }));
|
|
70
|
+
expect(captured?.kind).toBe("text");
|
|
71
|
+
if (captured?.kind === "text") expect(captured.icon).toBe("mail");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("a declared field.icon overrides the derivation", () => {
|
|
75
|
+
renderField(textField({ field: "email", icon: "lock" }));
|
|
76
|
+
expect(captured?.kind).toBe("text");
|
|
77
|
+
if (captured?.kind === "text") expect(captured.icon).toBe("lock");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("a generic text field with no recognizable name gets no icon", () => {
|
|
81
|
+
renderField(textField({ field: "foo" }));
|
|
82
|
+
expect(captured?.kind).toBe("text");
|
|
83
|
+
if (captured?.kind === "text") expect(captured.icon).toBeUndefined();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("a boolean field gets no icon", () => {
|
|
87
|
+
renderField({
|
|
88
|
+
field: "isActive",
|
|
89
|
+
label: "Active",
|
|
90
|
+
type: "boolean",
|
|
91
|
+
value: false,
|
|
92
|
+
visible: true,
|
|
93
|
+
readOnly: false,
|
|
94
|
+
required: false,
|
|
95
|
+
});
|
|
96
|
+
expect(captured?.kind).toBe("boolean");
|
|
97
|
+
if (captured?.kind === "boolean") expect("icon" in captured).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("a multiline field named 'email' gets no icon (textarea has no icon slot)", () => {
|
|
101
|
+
renderField(textField({ field: "email", multiline: true }));
|
|
102
|
+
expect(captured?.kind).toBe("textarea");
|
|
103
|
+
if (captured?.kind === "textarea") expect("icon" in captured).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("a number field with no recognizable name gets no icon", () => {
|
|
107
|
+
renderField({
|
|
108
|
+
field: "quantity",
|
|
109
|
+
label: "Quantity",
|
|
110
|
+
type: "number",
|
|
111
|
+
value: 1,
|
|
112
|
+
visible: true,
|
|
113
|
+
readOnly: false,
|
|
114
|
+
required: false,
|
|
115
|
+
});
|
|
116
|
+
expect(captured?.kind).toBe("number");
|
|
117
|
+
if (captured?.kind === "number") expect(captured.icon).toBeUndefined();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -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,
|
|
@@ -922,6 +922,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
922
922
|
onCopyLink !== undefined ||
|
|
923
923
|
(actions !== undefined && actions.length > 0) ||
|
|
924
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);
|
|
925
929
|
const secondaryFormActions = (
|
|
926
930
|
<>
|
|
927
931
|
{onDelete !== undefined && (
|
|
@@ -939,21 +943,30 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
939
943
|
{onCopyLink !== undefined && (
|
|
940
944
|
<Button
|
|
941
945
|
type="button"
|
|
942
|
-
variant="link"
|
|
946
|
+
variant={iconOnlyMidActions ? "secondary" : "link"}
|
|
943
947
|
icon={linkCopied ? "check" : "link"}
|
|
944
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
|
+
})}
|
|
945
955
|
onClick={async () => {
|
|
946
956
|
await onCopyLink();
|
|
947
957
|
setLinkCopied(true);
|
|
948
958
|
}}
|
|
949
959
|
>
|
|
950
|
-
{
|
|
960
|
+
{iconOnlyMidActions
|
|
961
|
+
? null
|
|
962
|
+
: translate(linkCopied ? "kumiko.actions.copyLinkCopied" : "kumiko.actions.copyLink")}
|
|
951
963
|
</Button>
|
|
952
964
|
)}
|
|
953
965
|
{actions?.map((action) => (
|
|
954
966
|
<RenderEditActionButton
|
|
955
967
|
key={action.id}
|
|
956
968
|
action={action}
|
|
969
|
+
iconOnly={iconOnlyMidActions}
|
|
957
970
|
Button={Button}
|
|
958
971
|
Dialog={Dialog}
|
|
959
972
|
onError={setActionError}
|
|
@@ -972,6 +985,14 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
972
985
|
)}
|
|
973
986
|
</>
|
|
974
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));
|
|
975
996
|
const formActions = (
|
|
976
997
|
<>
|
|
977
998
|
{isWizard && currentStep > 0 && (
|
|
@@ -1039,7 +1060,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1039
1060
|
{...(hideSectionTitles !== true && { title: formTitle })}
|
|
1040
1061
|
{...(hideSectionTitles !== true &&
|
|
1041
1062
|
formSubtitle !== undefined && { subtitle: formSubtitle })}
|
|
1042
|
-
{...(hideActions !== true && { actions: formActions })}
|
|
1063
|
+
{...(hideActions !== true && hasFormActions && { actions: formActions })}
|
|
1043
1064
|
{...(hideActions !== true &&
|
|
1044
1065
|
hasSecondaryFormActions && { secondaryActions: secondaryFormActions })}
|
|
1045
1066
|
testId="render-edit-form"
|
|
@@ -1206,6 +1227,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1206
1227
|
key={sectionKey}
|
|
1207
1228
|
{...(sectionTitle !== undefined && { title: sectionTitle })}
|
|
1208
1229
|
{...(section.description !== undefined && { subtitle: section.description })}
|
|
1230
|
+
{...(section.icon !== undefined && { icon: section.icon })}
|
|
1209
1231
|
testId={`section-${sectionKey}`}
|
|
1210
1232
|
>
|
|
1211
1233
|
<Grid columns={section.columns}>
|