@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.235.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.235.0",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.235.0",
|
|
20
20
|
"react": "^19.2.6",
|
|
21
21
|
"temporal-polyfill": "^0.3.2",
|
|
22
22
|
"zod": "^4.4.3"
|
|
@@ -24,8 +24,10 @@
|
|
|
24
24
|
"devDependencies": {
|
|
25
25
|
"@testing-library/react": "^16.3.2",
|
|
26
26
|
"@types/react": "^19.2.14",
|
|
27
|
+
"@types/react-dom": "^19.2.3",
|
|
27
28
|
"jsdom": "^29.1.1",
|
|
28
|
-
"
|
|
29
|
+
"react-dom": "^19.2.6",
|
|
30
|
+
"@cosmicdrift/kumiko-locale-de": "0.235.0"
|
|
29
31
|
},
|
|
30
32
|
"repository": {
|
|
31
33
|
"type": "git",
|
|
@@ -2,12 +2,14 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import type { LocaleResolver } from "@cosmicdrift/kumiko-headless";
|
|
3
3
|
import { act, render, renderHook } from "@testing-library/react";
|
|
4
4
|
import type { ReactNode } from "react";
|
|
5
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
5
6
|
import {
|
|
6
7
|
createStaticLocaleResolver,
|
|
7
8
|
LocaleProvider,
|
|
8
9
|
type TranslationsByLocale,
|
|
9
10
|
translationsByLocaleFromKeys,
|
|
10
11
|
useLocale,
|
|
12
|
+
useOptionalTranslation,
|
|
11
13
|
useTranslation,
|
|
12
14
|
} from "../i18n";
|
|
13
15
|
|
|
@@ -171,3 +173,40 @@ describe("useLocale", () => {
|
|
|
171
173
|
expect(result.current.locale()).toBe("de");
|
|
172
174
|
});
|
|
173
175
|
});
|
|
176
|
+
describe("useTranslation — SSR", () => {
|
|
177
|
+
// kumiko-framework#2595: getServerSnapshot hardcoded "en", so any
|
|
178
|
+
// server render came out English regardless of the resolver's actual
|
|
179
|
+
// locale — untestable via renderToStaticMarkup until getServerSnapshot
|
|
180
|
+
// read the resolver instead of a constant.
|
|
181
|
+
test(`renderToStaticMarkup with a non-English resolver renders the resolver's locale, not "en"`, () => {
|
|
182
|
+
const resolver = createStaticLocaleResolver({ locale: "de" });
|
|
183
|
+
const bundles: TranslationsByLocale[] = [{ de: { greet: "Hallo" }, en: { greet: "Hello" } }];
|
|
184
|
+
function Probe(): ReactNode {
|
|
185
|
+
const t = useTranslation();
|
|
186
|
+
return <span>{t("greet")}</span>;
|
|
187
|
+
}
|
|
188
|
+
const html = renderToStaticMarkup(
|
|
189
|
+
<LocaleProvider resolver={resolver} fallbackBundles={bundles}>
|
|
190
|
+
<Probe />
|
|
191
|
+
</LocaleProvider>,
|
|
192
|
+
);
|
|
193
|
+
expect(html).toContain("Hallo");
|
|
194
|
+
expect(html).not.toContain("Hello");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test(`renderToStaticMarkup via useOptionalTranslation resolves the resolver's locale, not "en"`, () => {
|
|
198
|
+
const resolver = createStaticLocaleResolver({ locale: "de" });
|
|
199
|
+
const bundles: TranslationsByLocale[] = [{ de: { greet: "Hallo" }, en: { greet: "Hello" } }];
|
|
200
|
+
function Probe(): ReactNode {
|
|
201
|
+
const t = useOptionalTranslation();
|
|
202
|
+
return <span>{t?.("greet")}</span>;
|
|
203
|
+
}
|
|
204
|
+
const html = renderToStaticMarkup(
|
|
205
|
+
<LocaleProvider resolver={resolver} fallbackBundles={bundles}>
|
|
206
|
+
<Probe />
|
|
207
|
+
</LocaleProvider>,
|
|
208
|
+
);
|
|
209
|
+
expect(html).toContain("Hallo");
|
|
210
|
+
expect(html).not.toContain("Hello");
|
|
211
|
+
});
|
|
212
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// fw-ui-defaults: RowAction had no icon field at all, so every framework-
|
|
2
|
+
// generated action button rendered as a same-looking text button. This
|
|
3
|
+
// renders the real entityList pipeline (KumikoScreen → EntityListBody) and
|
|
4
|
+
// asserts on the resolved `icon` KumikoScreen attaches to each
|
|
5
|
+
// DataTableRowAction — the id-derived default (ACTION_ICON_BY_ID in
|
|
6
|
+
// kumiko-screen.tsx) for an action with no declared icon, and the declared
|
|
7
|
+
// icon overriding that default for one that has it.
|
|
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 { NavProvider } from "../nav";
|
|
29
|
+
|
|
30
|
+
function stubDispatcher(): Dispatcher {
|
|
31
|
+
return {
|
|
32
|
+
write: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["write"],
|
|
33
|
+
query: (async () => ({
|
|
34
|
+
isSuccess: true,
|
|
35
|
+
data: { rows: [{ id: "row-1" }], nextCursor: null, total: 1 },
|
|
36
|
+
})) as unknown as Dispatcher["query"],
|
|
37
|
+
batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
|
|
38
|
+
statusStore: {
|
|
39
|
+
getState: () => "online",
|
|
40
|
+
subscribe: () => () => {},
|
|
41
|
+
} as unknown as Dispatcher["statusStore"],
|
|
42
|
+
async *stream() {},
|
|
43
|
+
pendingWrites: () => [],
|
|
44
|
+
pendingFiles: () => [],
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let capturedRowActions: readonly DataTableRowAction[] | undefined;
|
|
49
|
+
const captureDataTable: ComponentType<DataTableProps> = (props) => {
|
|
50
|
+
capturedRowActions = props.rowActions;
|
|
51
|
+
return null;
|
|
52
|
+
};
|
|
53
|
+
const noop = (): ReactNode => null;
|
|
54
|
+
const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
|
|
55
|
+
|
|
56
|
+
const testPrimitives: CorePrimitives = {
|
|
57
|
+
Button: noop,
|
|
58
|
+
Banner: passChildren,
|
|
59
|
+
Field: passChildren,
|
|
60
|
+
Input: noop,
|
|
61
|
+
DataTable: captureDataTable,
|
|
62
|
+
Form: passChildren,
|
|
63
|
+
Section: passChildren,
|
|
64
|
+
Card: passChildren,
|
|
65
|
+
Grid: passChildren,
|
|
66
|
+
GridCell: passChildren,
|
|
67
|
+
Text: passChildren,
|
|
68
|
+
Heading: noop,
|
|
69
|
+
Dialog: noop,
|
|
70
|
+
Modal: noop,
|
|
71
|
+
Lightbox: noop,
|
|
72
|
+
ConfigSourceBadge: noop,
|
|
73
|
+
ConfigCascadeView: noop,
|
|
74
|
+
Link: noop,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
function buildSchema(): FeatureSchema {
|
|
78
|
+
const entity: EntityDefinition = {
|
|
79
|
+
fields: {
|
|
80
|
+
name: { type: "text", maxLength: 50, required: false, searchable: false, sortable: false },
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
const listScreen: EntityListScreenDefinition = {
|
|
84
|
+
id: "widget-list",
|
|
85
|
+
type: "entityList",
|
|
86
|
+
entity: "widget",
|
|
87
|
+
columns: ["name"],
|
|
88
|
+
rowActions: [
|
|
89
|
+
// No declared icon — must fall back to ACTION_ICON_BY_ID["delete"].
|
|
90
|
+
{
|
|
91
|
+
id: "delete",
|
|
92
|
+
label: "Delete",
|
|
93
|
+
handler: "icon-fixture:write:widget:delete",
|
|
94
|
+
},
|
|
95
|
+
// Declared icon on an id that DOES have an id-derived default
|
|
96
|
+
// ("edit" -> pencil) — the declared icon must win.
|
|
97
|
+
{
|
|
98
|
+
kind: "navigate",
|
|
99
|
+
id: "edit",
|
|
100
|
+
label: "Edit",
|
|
101
|
+
screen: "widget-edit",
|
|
102
|
+
icon: "archive",
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
};
|
|
106
|
+
return {
|
|
107
|
+
featureName: "icon-fixture",
|
|
108
|
+
entities: { widget: entity },
|
|
109
|
+
screens: [listScreen],
|
|
110
|
+
} as FeatureSchema;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function renderListScreen(): void {
|
|
114
|
+
render(
|
|
115
|
+
<LocaleProvider
|
|
116
|
+
resolver={createStaticLocaleResolver({ locale: "en" })}
|
|
117
|
+
fallbackBundles={[kumikoDefaultTranslations]}
|
|
118
|
+
>
|
|
119
|
+
<DispatcherProvider dispatcher={stubDispatcher()}>
|
|
120
|
+
<NavProvider
|
|
121
|
+
value={{
|
|
122
|
+
route: { screenId: "icon-fixture:screen:widget-list" },
|
|
123
|
+
navigate: () => {},
|
|
124
|
+
replace: () => {},
|
|
125
|
+
hrefFor: () => "",
|
|
126
|
+
searchParams: {},
|
|
127
|
+
setSearchParams: () => {},
|
|
128
|
+
}}
|
|
129
|
+
>
|
|
130
|
+
<PrimitivesProvider value={testPrimitives}>
|
|
131
|
+
<KumikoScreen schema={buildSchema()} qn="icon-fixture:screen:widget-list" />
|
|
132
|
+
</PrimitivesProvider>
|
|
133
|
+
</NavProvider>
|
|
134
|
+
</DispatcherProvider>
|
|
135
|
+
</LocaleProvider>,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function requireAction(id: string): DataTableRowAction {
|
|
140
|
+
const action = capturedRowActions?.find((a) => a.id === id);
|
|
141
|
+
if (!action) throw new Error(`expected the '${id}' row action to be captured`);
|
|
142
|
+
return action;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
describe("entityList rowActions resolve an icon (fw-ui-defaults)", () => {
|
|
146
|
+
test("an action with no declared icon derives it from its id ('delete' -> trash)", async () => {
|
|
147
|
+
capturedRowActions = undefined;
|
|
148
|
+
|
|
149
|
+
renderListScreen();
|
|
150
|
+
|
|
151
|
+
await waitFor(() => {
|
|
152
|
+
expect(capturedRowActions).toBeDefined();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
expect(requireAction("delete").icon).toBe("trash");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("a declared icon overrides the id-derived default ('edit' would derive pencil)", async () => {
|
|
159
|
+
capturedRowActions = undefined;
|
|
160
|
+
|
|
161
|
+
renderListScreen();
|
|
162
|
+
|
|
163
|
+
await waitFor(() => {
|
|
164
|
+
expect(capturedRowActions).toBeDefined();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
expect(requireAction("edit").icon).toBe("archive");
|
|
168
|
+
});
|
|
169
|
+
});
|