@cosmicdrift/kumiko-renderer 0.211.0 → 0.213.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 +194 -0
- package/src/app/kumiko-screen.tsx +107 -62
- package/src/components/__tests__/render-field-unit-format.test.tsx +102 -0
- package/src/components/render-field.tsx +12 -1
- package/src/i18n.tsx +26 -28
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.213.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.213.0",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.213.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.213.0"
|
|
29
29
|
},
|
|
30
30
|
"repository": {
|
|
31
31
|
"type": "git",
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// fw#2228: RowActionNavigate can now name an ObjectTarget (`entity`) instead
|
|
2
|
+
// of a ScreenTarget (`screen`). This renders the real entityList pipeline
|
|
3
|
+
// (KumikoScreen → EntityListBody) and asserts a click on the entity-target
|
|
4
|
+
// row action calls nav.navigate with `{ entity, id }` — resolution to an
|
|
5
|
+
// actual screen is the NavApi impl's job (renderer-web's resolveTarget), not
|
|
6
|
+
// this platform-neutral package's. Mirrors
|
|
7
|
+
// entity-list-row-action-kumiko-actions-view.test.tsx's harness.
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import type {
|
|
11
|
+
EntityDefinition,
|
|
12
|
+
EntityListScreenDefinition,
|
|
13
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
14
|
+
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
15
|
+
import { render, waitFor } from "@testing-library/react";
|
|
16
|
+
import type { ComponentType, ReactNode } from "react";
|
|
17
|
+
import { DispatcherProvider } from "../../context/dispatcher-context";
|
|
18
|
+
import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
|
|
19
|
+
import { kumikoDefaultTranslations } from "../../i18n-defaults";
|
|
20
|
+
import {
|
|
21
|
+
type CorePrimitives,
|
|
22
|
+
type DataTableProps,
|
|
23
|
+
type DataTableRowAction,
|
|
24
|
+
PrimitivesProvider,
|
|
25
|
+
} from "../../primitives";
|
|
26
|
+
import type { FeatureSchema } from "../feature-schema";
|
|
27
|
+
import { KumikoScreen } from "../kumiko-screen";
|
|
28
|
+
import type { NavTarget } from "../nav";
|
|
29
|
+
import { NavProvider } from "../nav";
|
|
30
|
+
|
|
31
|
+
function stubDispatcher(): Dispatcher {
|
|
32
|
+
return {
|
|
33
|
+
write: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["write"],
|
|
34
|
+
query: (async () => ({
|
|
35
|
+
isSuccess: true,
|
|
36
|
+
data: {
|
|
37
|
+
rows: [{ id: "invoice-42", customerId: "cust-7", status: "open" }],
|
|
38
|
+
nextCursor: null,
|
|
39
|
+
total: 1,
|
|
40
|
+
},
|
|
41
|
+
})) as unknown as Dispatcher["query"],
|
|
42
|
+
batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
|
|
43
|
+
statusStore: {
|
|
44
|
+
getState: () => "online",
|
|
45
|
+
subscribe: () => () => {},
|
|
46
|
+
} as unknown as Dispatcher["statusStore"],
|
|
47
|
+
async *stream() {},
|
|
48
|
+
pendingWrites: () => [],
|
|
49
|
+
pendingFiles: () => [],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let capturedRowActions: readonly DataTableRowAction[] | undefined;
|
|
54
|
+
const captureDataTable: ComponentType<DataTableProps> = (props) => {
|
|
55
|
+
capturedRowActions = props.rowActions;
|
|
56
|
+
return null;
|
|
57
|
+
};
|
|
58
|
+
const noop = (): ReactNode => null;
|
|
59
|
+
const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
|
|
60
|
+
|
|
61
|
+
const testPrimitives: CorePrimitives = {
|
|
62
|
+
Button: noop,
|
|
63
|
+
Banner: passChildren,
|
|
64
|
+
Field: passChildren,
|
|
65
|
+
Input: noop,
|
|
66
|
+
DataTable: captureDataTable,
|
|
67
|
+
Form: passChildren,
|
|
68
|
+
Section: passChildren,
|
|
69
|
+
Card: passChildren,
|
|
70
|
+
Grid: passChildren,
|
|
71
|
+
GridCell: passChildren,
|
|
72
|
+
Text: passChildren,
|
|
73
|
+
Heading: noop,
|
|
74
|
+
Dialog: noop,
|
|
75
|
+
Modal: noop,
|
|
76
|
+
Lightbox: noop,
|
|
77
|
+
ConfigSourceBadge: noop,
|
|
78
|
+
ConfigCascadeView: noop,
|
|
79
|
+
Link: noop,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
function buildSchema(rowAction: EntityListScreenDefinition["rowActions"]): FeatureSchema {
|
|
83
|
+
const entity: EntityDefinition = {
|
|
84
|
+
fields: {
|
|
85
|
+
customerId: {
|
|
86
|
+
type: "text",
|
|
87
|
+
maxLength: 50,
|
|
88
|
+
required: false,
|
|
89
|
+
searchable: false,
|
|
90
|
+
sortable: false,
|
|
91
|
+
},
|
|
92
|
+
status: { type: "text", maxLength: 50, required: false, searchable: false, sortable: false },
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
const listScreen: EntityListScreenDefinition = {
|
|
96
|
+
id: "invoice-list",
|
|
97
|
+
type: "entityList",
|
|
98
|
+
entity: "invoice",
|
|
99
|
+
columns: ["status"],
|
|
100
|
+
rowActions: rowAction,
|
|
101
|
+
};
|
|
102
|
+
return {
|
|
103
|
+
featureName: "billing",
|
|
104
|
+
entities: { invoice: entity },
|
|
105
|
+
screens: [listScreen],
|
|
106
|
+
} as FeatureSchema;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function renderListScreen(schema: FeatureSchema, navigateSpy: (target: NavTarget) => void): void {
|
|
110
|
+
render(
|
|
111
|
+
<LocaleProvider
|
|
112
|
+
resolver={createStaticLocaleResolver({ locale: "en" })}
|
|
113
|
+
fallbackBundles={[kumikoDefaultTranslations]}
|
|
114
|
+
>
|
|
115
|
+
<DispatcherProvider dispatcher={stubDispatcher()}>
|
|
116
|
+
<NavProvider
|
|
117
|
+
value={{
|
|
118
|
+
route: { screenId: "billing:invoice-list" },
|
|
119
|
+
navigate: navigateSpy,
|
|
120
|
+
replace: () => {},
|
|
121
|
+
hrefFor: () => "",
|
|
122
|
+
searchParams: {},
|
|
123
|
+
setSearchParams: () => {},
|
|
124
|
+
}}
|
|
125
|
+
>
|
|
126
|
+
<PrimitivesProvider value={testPrimitives}>
|
|
127
|
+
<KumikoScreen schema={schema} qn="billing:screen:invoice-list" />
|
|
128
|
+
</PrimitivesProvider>
|
|
129
|
+
</NavProvider>
|
|
130
|
+
</DispatcherProvider>
|
|
131
|
+
</LocaleProvider>,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function requireAction(id: string): DataTableRowAction {
|
|
136
|
+
const action = capturedRowActions?.find((a) => a.id === id);
|
|
137
|
+
if (!action) throw new Error(`expected the '${id}' row action to be captured`);
|
|
138
|
+
return action;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
describe("entityList navigate row-action with an entity-target (fw#2228)", () => {
|
|
142
|
+
test("clicking the row action calls nav.navigate with { entity, id }, not a screenId", async () => {
|
|
143
|
+
capturedRowActions = undefined;
|
|
144
|
+
const navigateCalls: NavTarget[] = [];
|
|
145
|
+
|
|
146
|
+
renderListScreen(
|
|
147
|
+
buildSchema([
|
|
148
|
+
{ kind: "navigate", id: "view", label: "kumiko.actions.view", entity: "invoice" },
|
|
149
|
+
]),
|
|
150
|
+
(target) => navigateCalls.push(target),
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
await waitFor(() => {
|
|
154
|
+
expect(capturedRowActions).toBeDefined();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
await requireAction("view").onTrigger({
|
|
158
|
+
id: "invoice-42",
|
|
159
|
+
values: { id: "invoice-42", customerId: "cust-7", status: "open" },
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
expect(navigateCalls).toHaveLength(1);
|
|
163
|
+
expect(navigateCalls[0]).toEqual({ entity: "invoice", id: "invoice-42" });
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("an explicit entityId field-name still overrides the row['id'] default", async () => {
|
|
167
|
+
capturedRowActions = undefined;
|
|
168
|
+
const navigateCalls: NavTarget[] = [];
|
|
169
|
+
|
|
170
|
+
renderListScreen(
|
|
171
|
+
buildSchema([
|
|
172
|
+
{
|
|
173
|
+
kind: "navigate",
|
|
174
|
+
id: "view-customer",
|
|
175
|
+
label: "kumiko.actions.view",
|
|
176
|
+
entity: "customer",
|
|
177
|
+
entityId: "customerId",
|
|
178
|
+
},
|
|
179
|
+
]),
|
|
180
|
+
(target) => navigateCalls.push(target),
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
await waitFor(() => {
|
|
184
|
+
expect(capturedRowActions).toBeDefined();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
await requireAction("view-customer").onTrigger({
|
|
188
|
+
id: "invoice-42",
|
|
189
|
+
values: { id: "invoice-42", customerId: "cust-7" },
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
expect(navigateCalls).toEqual([{ entity: "customer", id: "cust-7" }]);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
@@ -1297,23 +1297,34 @@ function EntityListBody({
|
|
|
1297
1297
|
// nicht auseinanderdriften.
|
|
1298
1298
|
const runNavigate = useCallback(
|
|
1299
1299
|
(action: RowActionNavigate, row: ListRowViewModel) => {
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1300
|
+
if (action.entity !== undefined) {
|
|
1301
|
+
// Entity-Targets (fw#2228) lösen sich erst in der NavApi-Impl gegen
|
|
1302
|
+
// ALLE Features auf (resolveTarget in renderer-web/nav.tsx) — dieses
|
|
1303
|
+
// Package kennt nur `schema` (das eine aufrufende Feature) und kann
|
|
1304
|
+
// detailFor cross-feature nicht selbst nachschlagen.
|
|
1305
|
+
const explicit =
|
|
1306
|
+
action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : undefined;
|
|
1307
|
+
const id = explicit ?? String(row.values["id"] ?? "");
|
|
1308
|
+
nav.navigate({ entity: action.entity, id });
|
|
1309
|
+
} else if (action.screen !== undefined) {
|
|
1310
|
+
// Default entityId für entityEdit-Targets: row["id"] wenn kein expliziter
|
|
1311
|
+
// entityId-Feldname gesetzt ist. Nur für Targets DERSELBEN Entity — sonst
|
|
1312
|
+
// bekäme ein Cross-Entity-Edit-Screen die falsche row.id injiziert.
|
|
1313
|
+
const targetIsEntityEdit = schema.screens.some(
|
|
1314
|
+
(s) =>
|
|
1315
|
+
s.type === "entityEdit" &&
|
|
1316
|
+
s.entity === screen.entity &&
|
|
1317
|
+
lastSegment(s.id) === action.screen,
|
|
1318
|
+
);
|
|
1319
|
+
const explicit =
|
|
1320
|
+
action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : undefined;
|
|
1321
|
+
const fallback = targetIsEntityEdit ? String(row.values["id"] ?? "") : undefined;
|
|
1322
|
+
const entityId = explicit ?? fallback;
|
|
1323
|
+
nav.navigate({
|
|
1324
|
+
screenId: action.screen,
|
|
1325
|
+
...(entityId !== undefined && entityId !== "" && { entityId }),
|
|
1326
|
+
});
|
|
1327
|
+
}
|
|
1317
1328
|
const params =
|
|
1318
1329
|
action.params !== undefined ? evalRowExtractor(action.params, row.values) : undefined;
|
|
1319
1330
|
if (params !== undefined) {
|
|
@@ -1658,12 +1669,24 @@ function ProjectionListBody({
|
|
|
1658
1669
|
|
|
1659
1670
|
const runNavigate = useCallback(
|
|
1660
1671
|
(action: RowActionNavigate, row: ListRowViewModel) => {
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1672
|
+
if (action.entity !== undefined) {
|
|
1673
|
+
// Entity-Targets (fw#2228) — siehe EntityListBody.runNavigate für die
|
|
1674
|
+
// Begründung, warum die Auflösung in der NavApi-Impl passiert. Anders
|
|
1675
|
+
// als dort: KEIN row["id"]-Fallback — projectionList-Rows kommen aus
|
|
1676
|
+
// einer beliebigen Query-Projection ohne garantiertes "id"-Feld
|
|
1677
|
+
// (gleiche Begründung wie beim screen-Target unten). Der Boot-
|
|
1678
|
+
// Validator erzwingt deshalb einen expliziten entityId für
|
|
1679
|
+
// projectionList-entity-Targets.
|
|
1680
|
+
const id = action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : "";
|
|
1681
|
+
nav.navigate({ entity: action.entity, id });
|
|
1682
|
+
} else if (action.screen !== undefined) {
|
|
1683
|
+
const entityId =
|
|
1684
|
+
action.entityId !== undefined ? String(row.values[action.entityId] ?? "") : undefined;
|
|
1685
|
+
nav.navigate({
|
|
1686
|
+
screenId: action.screen,
|
|
1687
|
+
...(entityId !== undefined && entityId !== "" && { entityId }),
|
|
1688
|
+
});
|
|
1689
|
+
}
|
|
1667
1690
|
const params =
|
|
1668
1691
|
action.params !== undefined ? evalRowExtractor(action.params, row.values) : undefined;
|
|
1669
1692
|
if (params !== undefined) {
|
|
@@ -1963,46 +1986,68 @@ function ProjectionDetailBody({
|
|
|
1963
1986
|
continue;
|
|
1964
1987
|
}
|
|
1965
1988
|
if (action.kind === "navigate") {
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
const targetIsEntityEditSameEntity =
|
|
1974
|
-
screen.detailFor !== undefined &&
|
|
1975
|
-
appFeatures.some((feature) =>
|
|
1976
|
-
feature.screens.some(
|
|
1977
|
-
(s) =>
|
|
1978
|
-
s.type === "entityEdit" &&
|
|
1979
|
-
s.entity === screen.detailFor &&
|
|
1980
|
-
lastSegment(s.id) === action.screen,
|
|
1981
|
-
),
|
|
1982
|
-
);
|
|
1983
|
-
const fallback = targetIsEntityEditSameEntity ? String(record["id"] ?? "") : undefined;
|
|
1984
|
-
const navEntityId = explicit ?? fallback;
|
|
1985
|
-
const targetScreen = action.screen;
|
|
1986
|
-
out.push({
|
|
1987
|
-
id: action.id,
|
|
1988
|
-
label: effectiveTranslate(action.label),
|
|
1989
|
-
...(action.style !== undefined && { style: action.style }),
|
|
1990
|
-
onPress: () => {
|
|
1991
|
-
nav.navigate({
|
|
1992
|
-
screenId: targetScreen,
|
|
1993
|
-
...(navEntityId !== undefined && navEntityId !== "" && { entityId: navEntityId }),
|
|
1994
|
-
});
|
|
1995
|
-
const params =
|
|
1996
|
-
action.params !== undefined ? evalRowExtractor(action.params, record) : undefined;
|
|
1997
|
-
if (params !== undefined) {
|
|
1998
|
-
const stringified: Record<string, string | null> = {};
|
|
1999
|
-
for (const [k, v] of Object.entries(params)) {
|
|
2000
|
-
stringified[k] = v === null || v === undefined ? null : String(v);
|
|
2001
|
-
}
|
|
2002
|
-
nav.setSearchParams(stringified);
|
|
1989
|
+
const runParams = (): void => {
|
|
1990
|
+
const params =
|
|
1991
|
+
action.params !== undefined ? evalRowExtractor(action.params, record) : undefined;
|
|
1992
|
+
if (params !== undefined) {
|
|
1993
|
+
const stringified: Record<string, string | null> = {};
|
|
1994
|
+
for (const [k, v] of Object.entries(params)) {
|
|
1995
|
+
stringified[k] = v === null || v === undefined ? null : String(v);
|
|
2003
1996
|
}
|
|
2004
|
-
|
|
2005
|
-
|
|
1997
|
+
nav.setSearchParams(stringified);
|
|
1998
|
+
}
|
|
1999
|
+
};
|
|
2000
|
+
if (action.entity !== undefined) {
|
|
2001
|
+
// Entity-Targets (fw#2228) — Auflösung passiert in der NavApi-Impl
|
|
2002
|
+
// (siehe EntityListBody.runNavigate), nicht hier. KEIN record["id"]-
|
|
2003
|
+
// Fallback: record kommt aus einer beliebigen Detail-Query ohne
|
|
2004
|
+
// garantiertes "id"-Feld, der Boot-Validator erzwingt deshalb einen
|
|
2005
|
+
// expliziten entityId für projectionDetail-entity-Targets.
|
|
2006
|
+
const targetEntity = action.entity;
|
|
2007
|
+
const id = action.entityId !== undefined ? String(record[action.entityId] ?? "") : "";
|
|
2008
|
+
out.push({
|
|
2009
|
+
id: action.id,
|
|
2010
|
+
label: effectiveTranslate(action.label),
|
|
2011
|
+
...(action.style !== undefined && { style: action.style }),
|
|
2012
|
+
onPress: () => {
|
|
2013
|
+
nav.navigate({ entity: targetEntity, id });
|
|
2014
|
+
runParams();
|
|
2015
|
+
},
|
|
2016
|
+
});
|
|
2017
|
+
} else if (action.screen !== undefined) {
|
|
2018
|
+
// Default entityId for an entityEdit target of the SAME entity
|
|
2019
|
+
// (screen.detailFor plays entity's role here, like screen.entity
|
|
2020
|
+
// does for entityList's runNavigate) — without this fallback the
|
|
2021
|
+
// target opens an empty create-form instead of the shown record,
|
|
2022
|
+
// silently. Searched cross-feature, consistent with editScreen above.
|
|
2023
|
+
const explicit =
|
|
2024
|
+
action.entityId !== undefined ? String(record[action.entityId] ?? "") : undefined;
|
|
2025
|
+
const targetIsEntityEditSameEntity =
|
|
2026
|
+
screen.detailFor !== undefined &&
|
|
2027
|
+
appFeatures.some((feature) =>
|
|
2028
|
+
feature.screens.some(
|
|
2029
|
+
(s) =>
|
|
2030
|
+
s.type === "entityEdit" &&
|
|
2031
|
+
s.entity === screen.detailFor &&
|
|
2032
|
+
lastSegment(s.id) === action.screen,
|
|
2033
|
+
),
|
|
2034
|
+
);
|
|
2035
|
+
const fallback = targetIsEntityEditSameEntity ? String(record["id"] ?? "") : undefined;
|
|
2036
|
+
const navEntityId = explicit ?? fallback;
|
|
2037
|
+
const targetScreen = action.screen;
|
|
2038
|
+
out.push({
|
|
2039
|
+
id: action.id,
|
|
2040
|
+
label: effectiveTranslate(action.label),
|
|
2041
|
+
...(action.style !== undefined && { style: action.style }),
|
|
2042
|
+
onPress: () => {
|
|
2043
|
+
nav.navigate({
|
|
2044
|
+
screenId: targetScreen,
|
|
2045
|
+
...(navEntityId !== undefined && navEntityId !== "" && { entityId: navEntityId }),
|
|
2046
|
+
});
|
|
2047
|
+
runParams();
|
|
2048
|
+
},
|
|
2049
|
+
});
|
|
2050
|
+
}
|
|
2006
2051
|
continue;
|
|
2007
2052
|
}
|
|
2008
2053
|
// writeHandler — same dispatch/refetch/failure-surfacing pattern as
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// fw#2187: readOnly fields with an explicit `field.renderer: { format: "unit" }`
|
|
2
|
+
// must render through applyFormatSpec's unit case, and default to the
|
|
3
|
+
// LocaleProvider's App-Locale when the FormatSpec itself carries no `locale`
|
|
4
|
+
// — same posture as render-field-app-locale.test.tsx for money/date inputs,
|
|
5
|
+
// but for the FieldRendererOutput (readOnly + declared renderer) path, which
|
|
6
|
+
// that file doesn't cover.
|
|
7
|
+
//
|
|
8
|
+
// Capture-Text instead of a real primitive, same pattern as
|
|
9
|
+
// render-field-app-locale.test.tsx (Capture-Input there).
|
|
10
|
+
|
|
11
|
+
import { describe, expect, test } from "bun:test";
|
|
12
|
+
import 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, PrimitivesProvider, type TextProps } from "../../primitives";
|
|
17
|
+
import { RenderField } from "../render-field";
|
|
18
|
+
|
|
19
|
+
let captured: TextProps | undefined;
|
|
20
|
+
const captureText: ComponentType<TextProps> = (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: noop,
|
|
32
|
+
DataTable: noop,
|
|
33
|
+
Form: noop,
|
|
34
|
+
Section: noop,
|
|
35
|
+
Card: noop,
|
|
36
|
+
Grid: noop,
|
|
37
|
+
GridCell: noop,
|
|
38
|
+
Text: captureText,
|
|
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 livingSpaceField(
|
|
49
|
+
renderer: EditFieldViewModel["renderer"],
|
|
50
|
+
value: unknown = 58,
|
|
51
|
+
): EditFieldViewModel {
|
|
52
|
+
return {
|
|
53
|
+
field: "livingSpace",
|
|
54
|
+
label: "Wohnfläche",
|
|
55
|
+
type: "decimal",
|
|
56
|
+
value,
|
|
57
|
+
visible: true,
|
|
58
|
+
readOnly: true,
|
|
59
|
+
required: false,
|
|
60
|
+
renderer,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function renderUnderLocale(locale: string, field: EditFieldViewModel): void {
|
|
65
|
+
captured = undefined;
|
|
66
|
+
render(
|
|
67
|
+
<LocaleProvider resolver={createStaticLocaleResolver({ locale })}>
|
|
68
|
+
<PrimitivesProvider value={testPrimitives}>
|
|
69
|
+
<RenderField field={field} onChange={() => {}} />
|
|
70
|
+
</PrimitivesProvider>
|
|
71
|
+
</LocaleProvider>,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe("RenderField — unit-FormatSpec (fw#2187)", () => {
|
|
76
|
+
test("m2 rendert Zahl + m²-Suffix, locale-formatiert über das App-Locale", () => {
|
|
77
|
+
renderUnderLocale("de-DE", livingSpaceField({ format: "unit", unit: "m2" }));
|
|
78
|
+
expect(captured?.children).toBe("58 m²");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("ein anderes App-Locale ändert die Zahl-Formatierung (en-US: Punkt statt Komma)", () => {
|
|
82
|
+
renderUnderLocale("en-US", livingSpaceField({ format: "unit", unit: "m2" }, 58.5));
|
|
83
|
+
expect(captured?.children).toBe("58.5 m²");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("CLDR-sanktionierte Unit (km) rendert über Intl.NumberFormat(style:'unit')", () => {
|
|
87
|
+
renderUnderLocale("en-US", livingSpaceField({ format: "unit", unit: "km" }, 3));
|
|
88
|
+
expect(captured?.children).toBe("3 km");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("explizites renderer.locale gewinnt gegen das App-Locale", () => {
|
|
92
|
+
renderUnderLocale("en-US", livingSpaceField({ format: "unit", unit: "m2", locale: "de-DE" }));
|
|
93
|
+
expect(captured?.children).toBe("58 m²");
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("RenderField — App-Locale an FieldRendererOutput durchreichen (fw#2187)", () => {
|
|
98
|
+
test("number-FormatSpec ohne eigenes locale bekommt das App-Locale (de-DE, Komma statt Punkt)", () => {
|
|
99
|
+
renderUnderLocale("de-DE", livingSpaceField({ format: "number" }, 1234.5));
|
|
100
|
+
expect(captured?.children).toBe("1.234,5");
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -106,6 +106,7 @@ export function RenderField({
|
|
|
106
106
|
<FieldRendererOutput
|
|
107
107
|
field={field}
|
|
108
108
|
renderer={field.renderer}
|
|
109
|
+
appLocale={appLocale}
|
|
109
110
|
{...(row !== undefined && { row })}
|
|
110
111
|
/>
|
|
111
112
|
) : field.type === "embedded" && field.embeddedListCells !== undefined ? (
|
|
@@ -340,10 +341,12 @@ function FieldRendererOutput({
|
|
|
340
341
|
field,
|
|
341
342
|
renderer,
|
|
342
343
|
row,
|
|
344
|
+
appLocale,
|
|
343
345
|
}: {
|
|
344
346
|
readonly field: EditFieldViewModel;
|
|
345
347
|
readonly renderer: FieldRenderer;
|
|
346
348
|
readonly row?: Readonly<Record<string, unknown>>;
|
|
349
|
+
readonly appLocale: string;
|
|
347
350
|
}): ReactNode {
|
|
348
351
|
const { Text } = usePrimitives();
|
|
349
352
|
const componentName =
|
|
@@ -352,8 +355,16 @@ function FieldRendererOutput({
|
|
|
352
355
|
: undefined;
|
|
353
356
|
const Component = useColumnRenderer(componentName);
|
|
354
357
|
if (isFormatSpec(renderer)) {
|
|
358
|
+
// App locale as default when the FormatSpec declares none of its own —
|
|
359
|
+
// otherwise locale-sensitive formats (timestamp/date/number/decimal/
|
|
360
|
+
// bigInt/unit) fell back to Intl's runtime default instead of the app
|
|
361
|
+
// language chosen via LocaleProvider (fw#2187). An explicit
|
|
362
|
+
// `renderer.locale` still wins, same pattern as dateLocale vs. appLocale
|
|
363
|
+
// further below in readOnlyDisplayText.
|
|
355
364
|
return (
|
|
356
|
-
<Text testId={`field-value-${field.field}`}>
|
|
365
|
+
<Text testId={`field-value-${field.field}`}>
|
|
366
|
+
{applyFormatSpec({ locale: appLocale, ...renderer }, field.value)}
|
|
367
|
+
</Text>
|
|
357
368
|
);
|
|
358
369
|
}
|
|
359
370
|
if (componentName !== undefined) {
|
package/src/i18n.tsx
CHANGED
|
@@ -1,19 +1,18 @@
|
|
|
1
|
-
// Locale
|
|
2
|
-
//
|
|
3
|
-
// @cosmicdrift/kumiko-headless:
|
|
4
|
-
//
|
|
1
|
+
// Locale handling for React consumers of the Kumiko renderer. A thin layer
|
|
2
|
+
// around the platform-agnostic `LocaleResolver` contract from
|
|
3
|
+
// @cosmicdrift/kumiko-headless: provider, hooks, a default no-op resolver,
|
|
4
|
+
// and a fallback-bundle merge for feature-supplied translations.
|
|
5
5
|
//
|
|
6
|
-
//
|
|
7
|
-
// 1.
|
|
8
|
-
// (
|
|
9
|
-
// 2. Feature
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// Session die Sprache umschalten ohne Reload.
|
|
6
|
+
// Architecture:
|
|
7
|
+
// 1. The app supplies exactly one `LocaleResolver` via `<LocaleProvider>`
|
|
8
|
+
// (or none at all → the default resolver returns keys as-is).
|
|
9
|
+
// 2. Feature plugins may bring fallback bundles: when the app resolver
|
|
10
|
+
// can't resolve a key, `useTranslation` tries the plugin bundles.
|
|
11
|
+
// This keeps feature UI independent of the app's own i18next instance
|
|
12
|
+
// and works out of the box, while staying fully overridable.
|
|
13
|
+
// 3. Re-render on locale change via `useSyncExternalStore` on the
|
|
14
|
+
// resolver's `subscribe()` — app code can switch language mid-session
|
|
15
|
+
// without a reload.
|
|
17
16
|
|
|
18
17
|
import type { LocaleResolver } from "@cosmicdrift/kumiko-headless";
|
|
19
18
|
import {
|
|
@@ -78,14 +77,13 @@ const EMPTY_FALLBACK_BUNDLES: readonly TranslationsByLocale[] = [];
|
|
|
78
77
|
|
|
79
78
|
export type LocaleProviderProps = {
|
|
80
79
|
readonly resolver: LocaleResolver;
|
|
81
|
-
/**
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
80
|
+
/** Default bundles supplied by feature plugins. Lookup order per key:
|
|
81
|
+
* (1) app resolver, (2) these bundles in array order, (3) key as-is.
|
|
82
|
+
* Apps can thus override individual keys without swapping out whole
|
|
83
|
+
* feature bundles. */
|
|
85
84
|
readonly fallbackBundles?: readonly TranslationsByLocale[];
|
|
86
|
-
/**
|
|
87
|
-
*
|
|
88
|
-
* Default: `"en"`. */
|
|
85
|
+
/** Falls back to fallbackLocale when neither the current-locale nor the
|
|
86
|
+
* key lookup hits in a plugin bundle. Default: `"en"`. */
|
|
89
87
|
readonly fallbackLocale?: string;
|
|
90
88
|
readonly children: ReactNode;
|
|
91
89
|
};
|
|
@@ -96,12 +94,12 @@ export function LocaleProvider({
|
|
|
96
94
|
fallbackLocale = "en",
|
|
97
95
|
children,
|
|
98
96
|
}: LocaleProviderProps): ReactNode {
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
97
|
+
// Without memoization every re-render of the provider (e.g. because an
|
|
98
|
+
// ancestor component re-renders) builds a new context-value object —
|
|
99
|
+
// every consumer of useTranslation()/useLocale() then sees a new `ctx`
|
|
100
|
+
// reference and, even with useCallback memoization, a new `t`.
|
|
101
|
+
// Consequence: `t` in a useEffect dependency array triggers an infinite
|
|
102
|
+
// loop (see admin-shell Overview screens, prod incident).
|
|
105
103
|
const value = useMemo(
|
|
106
104
|
() => ({ resolver, fallbackBundles, fallbackLocale }),
|
|
107
105
|
[resolver, fallbackBundles, fallbackLocale],
|