@cosmicdrift/kumiko-renderer 0.221.0 → 0.222.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__/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/render-edit.tsx +23 -26
- package/src/components/render-field.tsx +23 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.222.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.222.0",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.222.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.222.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) => {
|
|
@@ -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
|
});
|
|
@@ -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
|
|
@@ -359,12 +359,18 @@ function FieldRendererOutput({
|
|
|
359
359
|
// App locale as default when the FormatSpec declares none of its own —
|
|
360
360
|
// otherwise locale-sensitive formats (timestamp/date/number/decimal/
|
|
361
361
|
// 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.
|
|
362
|
+
// language chosen via LocaleProvider (fw#2187). Prefer renderer.locale
|
|
363
|
+
// when set; coalesce undefined (spread override) back to appLocale (#2332).
|
|
365
364
|
return (
|
|
366
365
|
<Text testId={`field-value-${field.field}`}>
|
|
367
|
-
{applyFormatSpec(
|
|
366
|
+
{applyFormatSpec(
|
|
367
|
+
{
|
|
368
|
+
...renderer,
|
|
369
|
+
locale: (renderer as { locale?: string }).locale ?? appLocale,
|
|
370
|
+
},
|
|
371
|
+
field.value,
|
|
372
|
+
t,
|
|
373
|
+
)}
|
|
368
374
|
</Text>
|
|
369
375
|
);
|
|
370
376
|
}
|
|
@@ -520,7 +526,7 @@ function renderInput({
|
|
|
520
526
|
);
|
|
521
527
|
}
|
|
522
528
|
case "money": {
|
|
523
|
-
const currency = field.currency
|
|
529
|
+
const currency = resolveMoneyCurrency(field.value, field.currency);
|
|
524
530
|
return (
|
|
525
531
|
<Input
|
|
526
532
|
kind="money"
|
|
@@ -692,6 +698,17 @@ function numberValue(v: unknown): number | "" {
|
|
|
692
698
|
// stored-config coercion) is MAJOR units too — every producer in this repo
|
|
693
699
|
// hands rehydrateMoney's `{amount,…}` shape or a raw major-unit number, never
|
|
694
700
|
// pre-scaled minor units.
|
|
701
|
+
function resolveMoneyCurrency(value: unknown, fieldCurrency: string | undefined): string {
|
|
702
|
+
if (
|
|
703
|
+
typeof value === "object" &&
|
|
704
|
+
value !== null &&
|
|
705
|
+
typeof (value as { currency?: unknown }).currency === "string"
|
|
706
|
+
) {
|
|
707
|
+
return (value as { currency: string }).currency;
|
|
708
|
+
}
|
|
709
|
+
return fieldCurrency ?? "EUR";
|
|
710
|
+
}
|
|
711
|
+
|
|
695
712
|
function moneyMinorValue(v: unknown, currency: string): number | "" {
|
|
696
713
|
if (v === undefined || v === null || v === "") return "";
|
|
697
714
|
if (typeof v === "number") return Math.round(v * 10 ** currencyDecimals(currency));
|
|
@@ -756,7 +773,7 @@ function readOnlyDisplayText(field: EditFieldViewModel, appLocale: string): stri
|
|
|
756
773
|
return n === "" ? "—" : new Intl.NumberFormat(appLocale).format(n);
|
|
757
774
|
}
|
|
758
775
|
case "money": {
|
|
759
|
-
const currency = field.currency
|
|
776
|
+
const currency = resolveMoneyCurrency(value, field.currency);
|
|
760
777
|
const minor = moneyMinorValue(value, currency);
|
|
761
778
|
if (minor === "") return "—";
|
|
762
779
|
const major = minor / 10 ** currencyDecimals(currency);
|