@cosmicdrift/kumiko-renderer 0.221.0 → 0.223.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/extension-form-submit.tsx +9 -4
- package/src/components/__tests__/multi-select-checkboxes.test.tsx +162 -0
- package/src/components/__tests__/render-edit-action-button.test.tsx +13 -1
- package/src/components/__tests__/render-field-money-roundtrip.test.tsx +14 -0
- package/src/components/__tests__/render-field-unit-format.test.tsx +5 -0
- package/src/components/multi-select-checkboxes.tsx +93 -0
- package/src/components/render-edit.tsx +23 -26
- package/src/components/render-field.tsx +35 -6
- package/src/i18n-defaults.ts +2 -0
- package/src/primitives.tsx +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.223.0",
|
|
4
4
|
"description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
}
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
18
|
+
"@cosmicdrift/kumiko-framework": "0.223.0",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.223.0",
|
|
20
20
|
"react": "^19.2.6",
|
|
21
21
|
"temporal-polyfill": "^0.3.2",
|
|
22
22
|
"zod": "^4.4.3"
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"@testing-library/react": "^16.3.2",
|
|
26
26
|
"@types/react": "^19.2.14",
|
|
27
27
|
"jsdom": "^29.1.1",
|
|
28
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
28
|
+
"@cosmicdrift/kumiko-locale-de": "0.223.0"
|
|
29
29
|
},
|
|
30
30
|
"repository": {
|
|
31
31
|
"type": "git",
|
|
@@ -42,13 +42,18 @@ export const ExtensionFormRegistryProvider = ExtensionFormRegistryContext.Provid
|
|
|
42
42
|
// Host-Seite (render-edit): hält die Registrierungen in einem ref, meldet den
|
|
43
43
|
// aggregierten dirty-State via onDirtyChange hoch (damit der Save-Button
|
|
44
44
|
// re-rendert) und liefert runAll() zum Ausführen aller Handler beim Submit.
|
|
45
|
-
export
|
|
45
|
+
export type ExtensionFormHostStatus = {
|
|
46
|
+
readonly anyDirty: boolean;
|
|
47
|
+
readonly hasRegistrations: boolean;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export function useExtensionFormHost(onStatusChange: (status: ExtensionFormHostStatus) => void): {
|
|
46
51
|
readonly registry: ExtensionFormRegistry;
|
|
47
52
|
readonly runAll: (ctx: ExtensionSubmitContext) => Promise<readonly ExtensionSubmitResult[]>;
|
|
48
53
|
} {
|
|
49
54
|
const regsRef = useRef<Map<string, Registration>>(new Map());
|
|
50
|
-
const
|
|
51
|
-
|
|
55
|
+
const onStatusRef = useRef(onStatusChange);
|
|
56
|
+
onStatusRef.current = onStatusChange;
|
|
52
57
|
|
|
53
58
|
const registry = useMemo<ExtensionFormRegistry>(() => {
|
|
54
59
|
const emitDirty = (): void => {
|
|
@@ -59,7 +64,7 @@ export function useExtensionFormHost(onDirtyChange: (anyDirty: boolean) => void)
|
|
|
59
64
|
break;
|
|
60
65
|
}
|
|
61
66
|
}
|
|
62
|
-
|
|
67
|
+
onStatusRef.current({ anyDirty: any, hasRegistrations: regsRef.current.size > 0 });
|
|
63
68
|
};
|
|
64
69
|
return {
|
|
65
70
|
upsert: (reg) => {
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// display: "checkboxes" on a multiSelect field renders a checkbox grid
|
|
2
|
+
// (MultiSelectCheckboxes) instead of the combobox dropdown. Style follows
|
|
3
|
+
// render-field-app-locale.test.tsx: mount RenderField under real Locale +
|
|
4
|
+
// Primitives providers with capturing stubs, then invoke the captured
|
|
5
|
+
// callbacks the way a real primitive implementation would.
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "bun:test";
|
|
8
|
+
import type { EditFieldViewModel } from "@cosmicdrift/kumiko-headless";
|
|
9
|
+
import { render } from "@testing-library/react";
|
|
10
|
+
import type { ComponentType, ReactNode } from "react";
|
|
11
|
+
import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
|
|
12
|
+
import { kumikoDefaultTranslations } from "../../i18n-defaults";
|
|
13
|
+
import {
|
|
14
|
+
type ButtonProps,
|
|
15
|
+
type CorePrimitives,
|
|
16
|
+
type GridProps,
|
|
17
|
+
type InputProps,
|
|
18
|
+
PrimitivesProvider,
|
|
19
|
+
} from "../../primitives";
|
|
20
|
+
import { RenderField } from "../render-field";
|
|
21
|
+
|
|
22
|
+
let capturedInputs: InputProps[] = [];
|
|
23
|
+
let capturedButton: ButtonProps | undefined;
|
|
24
|
+
let capturedGrid: GridProps | undefined;
|
|
25
|
+
|
|
26
|
+
const captureInput: ComponentType<InputProps> = (props) => {
|
|
27
|
+
capturedInputs.push(props);
|
|
28
|
+
return null;
|
|
29
|
+
};
|
|
30
|
+
const captureButton: ComponentType<ButtonProps> = (props) => {
|
|
31
|
+
capturedButton = props;
|
|
32
|
+
return null;
|
|
33
|
+
};
|
|
34
|
+
const captureGrid: ComponentType<GridProps> = (props) => {
|
|
35
|
+
capturedGrid = props;
|
|
36
|
+
return <>{props.children}</>;
|
|
37
|
+
};
|
|
38
|
+
const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
|
|
39
|
+
const noop = (): ReactNode => null;
|
|
40
|
+
|
|
41
|
+
const testPrimitives: CorePrimitives = {
|
|
42
|
+
Button: captureButton,
|
|
43
|
+
Banner: noop,
|
|
44
|
+
Field: passChildren,
|
|
45
|
+
Input: captureInput,
|
|
46
|
+
DataTable: noop,
|
|
47
|
+
Form: noop,
|
|
48
|
+
Section: noop,
|
|
49
|
+
Card: noop,
|
|
50
|
+
Grid: captureGrid,
|
|
51
|
+
GridCell: passChildren,
|
|
52
|
+
Text: noop,
|
|
53
|
+
Heading: noop,
|
|
54
|
+
Dialog: noop,
|
|
55
|
+
Modal: noop,
|
|
56
|
+
Lightbox: noop,
|
|
57
|
+
ConfigSourceBadge: noop,
|
|
58
|
+
ConfigCascadeView: noop,
|
|
59
|
+
Link: noop,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
function languagesField(overrides: Partial<EditFieldViewModel> = {}): EditFieldViewModel {
|
|
63
|
+
return {
|
|
64
|
+
field: "languages",
|
|
65
|
+
label: "Languages",
|
|
66
|
+
type: "multiSelect",
|
|
67
|
+
value: [],
|
|
68
|
+
visible: true,
|
|
69
|
+
readOnly: false,
|
|
70
|
+
required: false,
|
|
71
|
+
options: ["en", "de", "es"],
|
|
72
|
+
optionLabels: { en: "English", de: "German", es: "Spanish" },
|
|
73
|
+
display: "checkboxes",
|
|
74
|
+
...overrides,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let lastOnChange: unknown;
|
|
79
|
+
function renderField(field: EditFieldViewModel): void {
|
|
80
|
+
capturedInputs = [];
|
|
81
|
+
capturedButton = undefined;
|
|
82
|
+
capturedGrid = undefined;
|
|
83
|
+
lastOnChange = undefined;
|
|
84
|
+
render(
|
|
85
|
+
<LocaleProvider
|
|
86
|
+
resolver={createStaticLocaleResolver()}
|
|
87
|
+
fallbackBundles={[kumikoDefaultTranslations]}
|
|
88
|
+
>
|
|
89
|
+
<PrimitivesProvider value={testPrimitives}>
|
|
90
|
+
<RenderField field={field} onChange={(v) => (lastOnChange = v)} />
|
|
91
|
+
</PrimitivesProvider>
|
|
92
|
+
</LocaleProvider>,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
describe("RenderField — multiSelect display: checkboxes", () => {
|
|
97
|
+
test("renders one boolean checkbox per option, no combobox", () => {
|
|
98
|
+
renderField(languagesField());
|
|
99
|
+
expect(capturedInputs).toHaveLength(3);
|
|
100
|
+
for (const input of capturedInputs) {
|
|
101
|
+
expect(input.kind).toBe("boolean");
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("without display, the field still renders the combobox (back-compat)", () => {
|
|
106
|
+
renderField(languagesField({ display: undefined }));
|
|
107
|
+
expect(capturedInputs).toHaveLength(1);
|
|
108
|
+
expect(capturedInputs[0]?.kind).toBe("combobox");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("clicking one checkbox emits the full array including previously-set values, in options order", () => {
|
|
112
|
+
renderField(languagesField({ value: ["en"] }));
|
|
113
|
+
const deInput = capturedInputs.find(
|
|
114
|
+
(i) => i.kind === "boolean" && i.id === "kumiko-edit-languages-de",
|
|
115
|
+
);
|
|
116
|
+
expect(deInput?.kind).toBe("boolean");
|
|
117
|
+
if (deInput?.kind === "boolean") deInput.onChange(true);
|
|
118
|
+
expect(lastOnChange).toEqual(["en", "de"]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("unchecking a checkbox drops only that value, keeping options order", () => {
|
|
122
|
+
renderField(languagesField({ value: ["en", "de", "es"] }));
|
|
123
|
+
const deInput = capturedInputs.find(
|
|
124
|
+
(i) => i.kind === "boolean" && i.id === "kumiko-edit-languages-de",
|
|
125
|
+
);
|
|
126
|
+
if (deInput?.kind === "boolean") deInput.onChange(false);
|
|
127
|
+
expect(lastOnChange).toEqual(["en", "es"]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("select-all toggle shows 'Select all' when nothing is selected, and selects everything in options order", () => {
|
|
131
|
+
renderField(languagesField({ value: [] }));
|
|
132
|
+
expect(capturedButton?.children).toBe("Select all");
|
|
133
|
+
capturedButton?.onClick?.();
|
|
134
|
+
expect(lastOnChange).toEqual(["en", "de", "es"]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("select-all toggle flips to 'Deselect all' once everything is selected, and clears on click", () => {
|
|
138
|
+
renderField(languagesField({ value: ["en", "de", "es"] }));
|
|
139
|
+
expect(capturedButton?.children).toBe("Deselect all");
|
|
140
|
+
capturedButton?.onClick?.();
|
|
141
|
+
expect(lastOnChange).toEqual([]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("disabled (readOnly) disables every checkbox and the select-all toggle", () => {
|
|
145
|
+
renderField(languagesField({ readOnly: true }));
|
|
146
|
+
expect(capturedButton?.disabled).toBe(true);
|
|
147
|
+
for (const input of capturedInputs) {
|
|
148
|
+
expect(input.disabled).toBe(true);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("columns and maxRows pass through to the Grid primitive", () => {
|
|
153
|
+
renderField(languagesField({ columns: 2, maxRows: 3 }));
|
|
154
|
+
expect(capturedGrid?.columns).toBe(2);
|
|
155
|
+
expect(capturedGrid?.maxRows).toBe(3);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("omitting columns/maxRows keeps the default layout (no maxRows on the Grid)", () => {
|
|
159
|
+
renderField(languagesField());
|
|
160
|
+
expect(capturedGrid?.maxRows).toBeUndefined();
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -34,7 +34,16 @@ const TestDialog: ComponentType<DialogProps> = ({
|
|
|
34
34
|
{description !== undefined && (
|
|
35
35
|
<span data-testid={`${testId}-description`}>{description}</span>
|
|
36
36
|
)}
|
|
37
|
-
<button
|
|
37
|
+
<button
|
|
38
|
+
type="button"
|
|
39
|
+
data-testid={`${testId}-confirm`}
|
|
40
|
+
onClick={() => {
|
|
41
|
+
void (async () => {
|
|
42
|
+
await onConfirm();
|
|
43
|
+
onOpenChange(false);
|
|
44
|
+
})();
|
|
45
|
+
}}
|
|
46
|
+
>
|
|
38
47
|
{confirmLabel ?? "Confirm"}
|
|
39
48
|
</button>
|
|
40
49
|
<button type="button" data-testid={`${testId}-cancel`} onClick={() => onOpenChange(false)}>
|
|
@@ -98,6 +107,9 @@ describe("RenderEditActionButton", () => {
|
|
|
98
107
|
fireEvent.click(rtlScreen.getByTestId("render-edit-action-archive"));
|
|
99
108
|
fireEvent.click(rtlScreen.getByTestId("render-edit-action-archive-dialog-confirm"));
|
|
100
109
|
await waitFor(() => expect(pressed).toBe(1));
|
|
110
|
+
await waitFor(() =>
|
|
111
|
+
expect(rtlScreen.queryByTestId("render-edit-action-archive-dialog")).toBeNull(),
|
|
112
|
+
);
|
|
101
113
|
});
|
|
102
114
|
|
|
103
115
|
test("danger style forces confirm dialog even without confirm text", async () => {
|
|
@@ -192,6 +192,20 @@ describe("RenderField money round-trip (kumiko-framework#1923)", () => {
|
|
|
192
192
|
expect(result.data?.["price"]).toEqual({ amount: 500, currency: "JPY" });
|
|
193
193
|
});
|
|
194
194
|
|
|
195
|
+
test("record currency differs from entity.defaultCurrency (#1930)", () => {
|
|
196
|
+
const entity = buildEntity("EUR");
|
|
197
|
+
let payload: unknown;
|
|
198
|
+
const field = renderMoneyField(entity, { price: { amount: 500, currency: "JPY" } }, (v) => {
|
|
199
|
+
payload = v;
|
|
200
|
+
});
|
|
201
|
+
if (field.kind !== "money") throw new Error("expected money kind");
|
|
202
|
+
// Prefer the value's own currency over entity.defaultCurrency (EUR=2dp).
|
|
203
|
+
expect(field.currency).toBe("JPY");
|
|
204
|
+
expect(field.value).toBe(500);
|
|
205
|
+
field.onChange(500);
|
|
206
|
+
expect(payload).toEqual({ amount: 500, currency: "JPY" });
|
|
207
|
+
});
|
|
208
|
+
|
|
195
209
|
test("server schema strips an unexpected amountMinor key instead of rejecting the payload", () => {
|
|
196
210
|
const entity = buildEntity("USD");
|
|
197
211
|
const result = buildUpdateSchema(entity).safeParse({
|
|
@@ -99,4 +99,9 @@ describe("RenderField — App-Locale an FieldRendererOutput durchreichen (fw#218
|
|
|
99
99
|
renderUnderLocale("de-DE", livingSpaceField({ format: "number" }, 1234.5));
|
|
100
100
|
expect(captured?.children).toBe("1.234,5");
|
|
101
101
|
});
|
|
102
|
+
|
|
103
|
+
test("explicit locale: undefined still falls back to App-Locale (#2332)", () => {
|
|
104
|
+
renderUnderLocale("de-DE", livingSpaceField({ format: "number", locale: undefined }, 1234.5));
|
|
105
|
+
expect(captured?.children).toBe("1.234,5");
|
|
106
|
+
});
|
|
102
107
|
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { EditFieldViewModel } from "@cosmicdrift/kumiko-headless";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { useTranslation } from "../i18n";
|
|
4
|
+
import { usePrimitives } from "../primitives";
|
|
5
|
+
|
|
6
|
+
export type MultiSelectCheckboxOption = {
|
|
7
|
+
readonly value: string;
|
|
8
|
+
readonly label: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type MultiSelectCheckboxesProps = {
|
|
12
|
+
readonly field: EditFieldViewModel;
|
|
13
|
+
readonly id: string;
|
|
14
|
+
readonly options: readonly MultiSelectCheckboxOption[];
|
|
15
|
+
readonly value: readonly string[];
|
|
16
|
+
readonly onChange: (value: readonly string[]) => void;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// Renders MultiSelectFieldDef's `display: "checkboxes"` mode — every option
|
|
20
|
+
// as a visible checkbox plus a select-all toggle, built purely from the
|
|
21
|
+
// platform-neutral Primitives contract (Grid/GridCell/Field/Input kind
|
|
22
|
+
// "boolean", the same primitive `case "boolean"` uses in render-field.tsx).
|
|
23
|
+
// No renderer-web import here — this file must stay usable from Expo too.
|
|
24
|
+
export function MultiSelectCheckboxes({
|
|
25
|
+
field,
|
|
26
|
+
id,
|
|
27
|
+
options,
|
|
28
|
+
value,
|
|
29
|
+
onChange,
|
|
30
|
+
}: MultiSelectCheckboxesProps): ReactNode {
|
|
31
|
+
const { Grid, GridCell, Field, Input, Button } = usePrimitives();
|
|
32
|
+
const t = useTranslation();
|
|
33
|
+
const disabled = field.readOnly;
|
|
34
|
+
const selected = new Set(value);
|
|
35
|
+
const allSelected = options.length > 0 && options.every((opt) => selected.has(opt.value));
|
|
36
|
+
|
|
37
|
+
// Always emits in `field.options` order (MultiSelectFieldDef's documented
|
|
38
|
+
// ordering guarantee), never in click/selection order.
|
|
39
|
+
const emitOrdered = (next: ReadonlySet<string>): void => {
|
|
40
|
+
onChange(options.filter((opt) => next.has(opt.value)).map((opt) => opt.value));
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const toggleOption = (optionValue: string, checked: boolean): void => {
|
|
44
|
+
const next = new Set(selected);
|
|
45
|
+
if (checked) {
|
|
46
|
+
next.add(optionValue);
|
|
47
|
+
} else {
|
|
48
|
+
next.delete(optionValue);
|
|
49
|
+
}
|
|
50
|
+
emitOrdered(next);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const toggleAll = (): void => {
|
|
54
|
+
emitOrdered(allSelected ? new Set() : new Set(options.map((opt) => opt.value)));
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<>
|
|
59
|
+
<Button
|
|
60
|
+
type="button"
|
|
61
|
+
variant="secondary"
|
|
62
|
+
size="sm"
|
|
63
|
+
disabled={disabled}
|
|
64
|
+
onClick={toggleAll}
|
|
65
|
+
testId={`${id}-select-all`}
|
|
66
|
+
>
|
|
67
|
+
{allSelected
|
|
68
|
+
? t("kumiko.field.multiSelect.deselect-all")
|
|
69
|
+
: t("kumiko.field.multiSelect.select-all")}
|
|
70
|
+
</Button>
|
|
71
|
+
<Grid
|
|
72
|
+
columns={field.columns ?? 2}
|
|
73
|
+
testId={`${id}-checkboxes`}
|
|
74
|
+
{...(field.maxRows !== undefined && { maxRows: field.maxRows })}
|
|
75
|
+
>
|
|
76
|
+
{options.map((opt) => (
|
|
77
|
+
<GridCell key={opt.value}>
|
|
78
|
+
<Field id={`${id}-${opt.value}`} label={opt.label} layout="inline">
|
|
79
|
+
<Input
|
|
80
|
+
kind="boolean"
|
|
81
|
+
id={`${id}-${opt.value}`}
|
|
82
|
+
name={`${id}-${opt.value}`}
|
|
83
|
+
value={selected.has(opt.value)}
|
|
84
|
+
onChange={(checked) => toggleOption(opt.value, checked)}
|
|
85
|
+
disabled={disabled}
|
|
86
|
+
/>
|
|
87
|
+
</Field>
|
|
88
|
+
</GridCell>
|
|
89
|
+
))}
|
|
90
|
+
</Grid>
|
|
91
|
+
</>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -277,9 +277,14 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
277
277
|
// never enters `fields`/`controller.getSnapshot().values` — there is no
|
|
278
278
|
// draft-blob-covered state left for persistExtensions() to compete over.
|
|
279
279
|
const [extensionDirty, setExtensionDirty] = useState(false);
|
|
280
|
+
const [hasExtensionRegistrations, setHasExtensionRegistrations] = useState(false);
|
|
280
281
|
const [extensionErrorKey, setExtensionErrorKey] = useState<string | null>(null);
|
|
281
|
-
const { registry: extensionFormRegistry, runAll: runExtensionSubmits } =
|
|
282
|
-
|
|
282
|
+
const { registry: extensionFormRegistry, runAll: runExtensionSubmits } = useExtensionFormHost(
|
|
283
|
+
({ anyDirty, hasRegistrations }) => {
|
|
284
|
+
setExtensionDirty(anyDirty);
|
|
285
|
+
setHasExtensionRegistrations(hasRegistrations);
|
|
286
|
+
},
|
|
287
|
+
);
|
|
283
288
|
const {
|
|
284
289
|
Button,
|
|
285
290
|
Banner,
|
|
@@ -971,7 +976,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
971
976
|
{translate("kumiko.actions.next")}
|
|
972
977
|
</Button>
|
|
973
978
|
)}
|
|
974
|
-
{isFormEditable && (!isWizard || isLastWizardStep) && (
|
|
979
|
+
{(isFormEditable || hasExtensionRegistrations) && (!isWizard || isLastWizardStep) && (
|
|
975
980
|
<Button
|
|
976
981
|
type="submit"
|
|
977
982
|
disabled={(snapshot.isUnchanged && !extensionDirty) || isSubmitting || disabled}
|
|
@@ -1103,6 +1108,19 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1103
1108
|
// navigating past its step — otherwise Finish only ran the last
|
|
1104
1109
|
// mounted step's handler and silently dropped earlier steps' writes.
|
|
1105
1110
|
const stepHidden = isWizard && sectionIndex !== currentStep;
|
|
1111
|
+
const wrapWizardStep = (key: string, el: ReactNode): ReactNode => {
|
|
1112
|
+
if (!isWizard) return el;
|
|
1113
|
+
if (WizardStepGroup === undefined) {
|
|
1114
|
+
throw new Error(
|
|
1115
|
+
"RenderEdit: wizard layout requires primitives.WizardStepGroup, but none is registered.",
|
|
1116
|
+
);
|
|
1117
|
+
}
|
|
1118
|
+
return (
|
|
1119
|
+
<WizardStepGroup key={key} hidden={stepHidden}>
|
|
1120
|
+
{el}
|
|
1121
|
+
</WizardStepGroup>
|
|
1122
|
+
);
|
|
1123
|
+
};
|
|
1106
1124
|
// Off-screen wizard steps stay mounted (see comment above) but must
|
|
1107
1125
|
// not participate in native constraint validation, or the Next
|
|
1108
1126
|
// button's `type="submit"` triggers the browser's full-form check
|
|
@@ -1129,18 +1147,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1129
1147
|
validate={scopedValidate}
|
|
1130
1148
|
/>
|
|
1131
1149
|
);
|
|
1132
|
-
|
|
1133
|
-
if (WizardStepGroup === undefined) {
|
|
1134
|
-
// Both silent fallbacks are unsafe here — render-visible-all-steps or unmount-drops-registry.
|
|
1135
|
-
throw new Error(
|
|
1136
|
-
"RenderEdit: wizard layout requires primitives.WizardStepGroup, but none is registered.",
|
|
1137
|
-
);
|
|
1138
|
-
}
|
|
1139
|
-
return (
|
|
1140
|
-
<WizardStepGroup key={section.title} hidden={stepHidden}>
|
|
1141
|
-
{mount}
|
|
1142
|
-
</WizardStepGroup>
|
|
1143
|
-
);
|
|
1150
|
+
return wrapWizardStep(section.title, mount);
|
|
1144
1151
|
}
|
|
1145
1152
|
if (section.kind === "relatedList") {
|
|
1146
1153
|
// parentId is the displayed record's id — without it there's no
|
|
@@ -1198,17 +1205,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1198
1205
|
</Grid>
|
|
1199
1206
|
</Section>
|
|
1200
1207
|
);
|
|
1201
|
-
|
|
1202
|
-
if (WizardStepGroup === undefined) {
|
|
1203
|
-
throw new Error(
|
|
1204
|
-
"RenderEdit: wizard layout requires primitives.WizardStepGroup, but none is registered.",
|
|
1205
|
-
);
|
|
1206
|
-
}
|
|
1207
|
-
return (
|
|
1208
|
-
<WizardStepGroup key={sectionKey} hidden={stepHidden}>
|
|
1209
|
-
{sectionEl}
|
|
1210
|
-
</WizardStepGroup>
|
|
1211
|
-
);
|
|
1208
|
+
return wrapWizardStep(sectionKey, sectionEl);
|
|
1212
1209
|
})}
|
|
1213
1210
|
{formError !== null && (
|
|
1214
1211
|
<Banner
|
|
@@ -21,6 +21,7 @@ import { useQuery } from "../hooks/use-query";
|
|
|
21
21
|
import { useLocale, useTranslation } from "../i18n";
|
|
22
22
|
import { usePrimitives } from "../primitives";
|
|
23
23
|
import { EmbeddedListField } from "./embedded-list-field";
|
|
24
|
+
import { MultiSelectCheckboxes } from "./multi-select-checkboxes";
|
|
24
25
|
import { ReferenceCreateDialog } from "./reference-create-dialog";
|
|
25
26
|
|
|
26
27
|
// RenderField übersetzt ein EditFieldViewModel → Primitives-Baum.
|
|
@@ -359,12 +360,18 @@ function FieldRendererOutput({
|
|
|
359
360
|
// App locale as default when the FormatSpec declares none of its own —
|
|
360
361
|
// otherwise locale-sensitive formats (timestamp/date/number/decimal/
|
|
361
362
|
// bigInt/unit) fell back to Intl's runtime default instead of the app
|
|
362
|
-
// language chosen via LocaleProvider (fw#2187).
|
|
363
|
-
//
|
|
364
|
-
// further below in readOnlyDisplayText.
|
|
363
|
+
// language chosen via LocaleProvider (fw#2187). Prefer renderer.locale
|
|
364
|
+
// when set; coalesce undefined (spread override) back to appLocale (#2332).
|
|
365
365
|
return (
|
|
366
366
|
<Text testId={`field-value-${field.field}`}>
|
|
367
|
-
{applyFormatSpec(
|
|
367
|
+
{applyFormatSpec(
|
|
368
|
+
{
|
|
369
|
+
...renderer,
|
|
370
|
+
locale: (renderer as { locale?: string }).locale ?? appLocale,
|
|
371
|
+
},
|
|
372
|
+
field.value,
|
|
373
|
+
t,
|
|
374
|
+
)}
|
|
368
375
|
</Text>
|
|
369
376
|
);
|
|
370
377
|
}
|
|
@@ -508,6 +515,17 @@ function renderInput({
|
|
|
508
515
|
? rawOptions.map((value: string) => ({ value, label: labels[value] ?? value }))
|
|
509
516
|
: rawOptions.map((value: string) => ({ value, label: value }));
|
|
510
517
|
const arrayValue = Array.isArray(field.value) ? (field.value as readonly string[]) : [];
|
|
518
|
+
if (field.display === "checkboxes") {
|
|
519
|
+
return (
|
|
520
|
+
<MultiSelectCheckboxes
|
|
521
|
+
field={field}
|
|
522
|
+
id={id}
|
|
523
|
+
options={multiSelectOptions}
|
|
524
|
+
value={arrayValue}
|
|
525
|
+
onChange={(v) => onChange(v)}
|
|
526
|
+
/>
|
|
527
|
+
);
|
|
528
|
+
}
|
|
511
529
|
return (
|
|
512
530
|
<Input
|
|
513
531
|
kind="combobox"
|
|
@@ -520,7 +538,7 @@ function renderInput({
|
|
|
520
538
|
);
|
|
521
539
|
}
|
|
522
540
|
case "money": {
|
|
523
|
-
const currency = field.currency
|
|
541
|
+
const currency = resolveMoneyCurrency(field.value, field.currency);
|
|
524
542
|
return (
|
|
525
543
|
<Input
|
|
526
544
|
kind="money"
|
|
@@ -692,6 +710,17 @@ function numberValue(v: unknown): number | "" {
|
|
|
692
710
|
// stored-config coercion) is MAJOR units too — every producer in this repo
|
|
693
711
|
// hands rehydrateMoney's `{amount,…}` shape or a raw major-unit number, never
|
|
694
712
|
// pre-scaled minor units.
|
|
713
|
+
function resolveMoneyCurrency(value: unknown, fieldCurrency: string | undefined): string {
|
|
714
|
+
if (
|
|
715
|
+
typeof value === "object" &&
|
|
716
|
+
value !== null &&
|
|
717
|
+
typeof (value as { currency?: unknown }).currency === "string"
|
|
718
|
+
) {
|
|
719
|
+
return (value as { currency: string }).currency;
|
|
720
|
+
}
|
|
721
|
+
return fieldCurrency ?? "EUR";
|
|
722
|
+
}
|
|
723
|
+
|
|
695
724
|
function moneyMinorValue(v: unknown, currency: string): number | "" {
|
|
696
725
|
if (v === undefined || v === null || v === "") return "";
|
|
697
726
|
if (typeof v === "number") return Math.round(v * 10 ** currencyDecimals(currency));
|
|
@@ -756,7 +785,7 @@ function readOnlyDisplayText(field: EditFieldViewModel, appLocale: string): stri
|
|
|
756
785
|
return n === "" ? "—" : new Intl.NumberFormat(appLocale).format(n);
|
|
757
786
|
}
|
|
758
787
|
case "money": {
|
|
759
|
-
const currency = field.currency
|
|
788
|
+
const currency = resolveMoneyCurrency(value, field.currency);
|
|
760
789
|
const minor = moneyMinorValue(value, currency);
|
|
761
790
|
if (minor === "") return "—";
|
|
762
791
|
const major = minor / 10 ** currencyDecimals(currency);
|
package/src/i18n-defaults.ts
CHANGED
|
@@ -54,6 +54,8 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
54
54
|
"{count} pasted row(s) were dropped (max row count reached).",
|
|
55
55
|
"kumiko.field.embedded-list.paste-cells-unmatched":
|
|
56
56
|
"{count} cell(s) had no matching option and were left unchanged.",
|
|
57
|
+
"kumiko.field.multiSelect.select-all": "Select all",
|
|
58
|
+
"kumiko.field.multiSelect.deselect-all": "Deselect all",
|
|
57
59
|
|
|
58
60
|
"kumiko.list.search-placeholder": "Search…",
|
|
59
61
|
"kumiko.list.empty.title": "No entries yet.",
|
package/src/primitives.tsx
CHANGED
|
@@ -739,6 +739,9 @@ export type GridProps = {
|
|
|
739
739
|
readonly columns: number;
|
|
740
740
|
readonly children: ReactNode;
|
|
741
741
|
readonly testId?: string;
|
|
742
|
+
/** Rows visible before the grid becomes vertically scrollable. Omitted =
|
|
743
|
+
* the grid grows with its content and never scrolls. */
|
|
744
|
+
readonly maxRows?: number;
|
|
742
745
|
};
|
|
743
746
|
|
|
744
747
|
/** Span-Wrapper für ein Kind innerhalb eines Grid. Web: `style={{gridColumn: span N}}`,
|