@cosmicdrift/kumiko-renderer-web 0.220.1 → 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 +5 -5
- package/src/__tests__/form-action-bar.test.tsx +1 -1
- package/src/__tests__/kumiko-screen.test.tsx +60 -5
- package/src/__tests__/nav-tree.test.tsx +6 -14
- package/src/__tests__/primitives.test.tsx +83 -3
- package/src/__tests__/render-edit.test.tsx +8 -14
- package/src/__tests__/render-list-unit-locale.test.tsx +125 -0
- package/src/app/__tests__/document-lang-sync.test.tsx +43 -0
- package/src/app/client-plugin.tsx +2 -1
- package/src/app/document-lang-sync.tsx +14 -8
- package/src/app/plain-content-editor.tsx +6 -2
- package/src/lib/__tests__/download.test.ts +3 -3
- package/src/lib/__tests__/download.test.tsx +30 -0
- package/src/primitives/__tests__/data-table-logic.test.ts +27 -0
- package/src/primitives/__tests__/date-parse.test.ts +6 -3
- package/src/primitives/__tests__/embedded-list-input.test.tsx +21 -0
- package/src/primitives/date-parse.ts +2 -0
- package/src/primitives/index.tsx +55 -33
- package/src/ui/sheet.tsx +2 -15
- package/src/widgets/__tests__/feed-list.test.tsx +3 -0
- package/src/widgets/__tests__/infinity-list.test.tsx +17 -9
- package/src/widgets/__tests__/widgets.test.tsx +27 -0
- package/src/widgets/charts.tsx +46 -27
- package/src/widgets/drawer.tsx +22 -14
- package/src/widgets/infinity-list.tsx +41 -5
- package/src/widgets/sheet-parts.tsx +62 -0
- package/src/widgets/upload-zone.tsx +5 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.222.0",
|
|
4
4
|
"description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
"./styles.css": "./src/styles.css"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
19
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.222.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.222.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.222.0",
|
|
22
22
|
"@radix-ui/react-dialog": "^1.1.15",
|
|
23
23
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
24
24
|
"@radix-ui/react-label": "^2.1.8",
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"@types/react-dom": "^19.2.3",
|
|
65
65
|
"jsdom": "^29.1.1",
|
|
66
66
|
"tailwindcss": "^4.3.0",
|
|
67
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
67
|
+
"@cosmicdrift/kumiko-locale-de": "0.222.0"
|
|
68
68
|
},
|
|
69
69
|
"repository": {
|
|
70
70
|
"type": "git",
|
|
@@ -26,7 +26,7 @@ describe("DefaultForm Action-Footer", () => {
|
|
|
26
26
|
expect(actions.className).toContain("border-t");
|
|
27
27
|
// Footer sits in the form shell (buttons align with the field edge).
|
|
28
28
|
expect(actions.closest(".max-w-full")).toBeTruthy();
|
|
29
|
-
//
|
|
29
|
+
// Title is its own heading above — not inside the action bar.
|
|
30
30
|
expect(actions.textContent).not.toContain("Titel");
|
|
31
31
|
expect(screen.getByTestId("f-title").textContent).toBe("Titel");
|
|
32
32
|
});
|
|
@@ -1570,7 +1570,7 @@ describe("KumikoScreen", () => {
|
|
|
1570
1570
|
expect(writeCalls[0]?.payload).toEqual({ title: "New Task", priority: 1 });
|
|
1571
1571
|
});
|
|
1572
1572
|
|
|
1573
|
-
test("actionForm mit redirect: nach success →
|
|
1573
|
+
test("actionForm mit redirect auf entityList: nach success → navigate ohne entityId (#2419)", async () => {
|
|
1574
1574
|
const navigateCalls: NavTarget[] = [];
|
|
1575
1575
|
const dispatcher = makeDispatcher({
|
|
1576
1576
|
write: (async () => ({
|
|
@@ -1613,9 +1613,9 @@ describe("KumikoScreen", () => {
|
|
|
1613
1613
|
fireEvent.change(titleInput, { target: { value: "go" } });
|
|
1614
1614
|
fireEvent.click(screen.getByTestId("render-edit-submit"));
|
|
1615
1615
|
await waitFor(() => expect(navigateCalls.length).toBe(1));
|
|
1616
|
-
// #
|
|
1617
|
-
|
|
1618
|
-
expect(navigateCalls[0]
|
|
1616
|
+
// #2419: entityList targets ignore entityId — do not attach a handler id.
|
|
1617
|
+
expect(navigateCalls[0]).toEqual({ screenId: "task-list" });
|
|
1618
|
+
expect(Object.hasOwn(navigateCalls[0] ?? {}, "entityId")).toBe(false);
|
|
1619
1619
|
});
|
|
1620
1620
|
|
|
1621
1621
|
test("actionForm mit Cross-Feature-QN als redirect: navigiert per Short-Id (#1946)", async () => {
|
|
@@ -1661,7 +1661,62 @@ describe("KumikoScreen", () => {
|
|
|
1661
1661
|
fireEvent.change(titleInput, { target: { value: "go" } });
|
|
1662
1662
|
fireEvent.click(screen.getByTestId("render-edit-submit"));
|
|
1663
1663
|
await waitFor(() => expect(navigateCalls.length).toBe(1));
|
|
1664
|
-
|
|
1664
|
+
// Cross-feature QN is not in this feature's schema.screens — keep prior
|
|
1665
|
+
// behavior without entityId (#2419).
|
|
1666
|
+
expect(navigateCalls[0]).toEqual({ screenId: "statement-upload-list" });
|
|
1667
|
+
expect(Object.hasOwn(navigateCalls[0] ?? {}, "entityId")).toBe(false);
|
|
1668
|
+
});
|
|
1669
|
+
|
|
1670
|
+
test("actionForm mit redirect auf entityEdit: hängt entityId an (#2419)", async () => {
|
|
1671
|
+
const navigateCalls: NavTarget[] = [];
|
|
1672
|
+
const dispatcher = makeDispatcher({
|
|
1673
|
+
write: (async () => ({
|
|
1674
|
+
isSuccess: true,
|
|
1675
|
+
data: { id: "x" },
|
|
1676
|
+
})) as unknown as Dispatcher["write"],
|
|
1677
|
+
});
|
|
1678
|
+
const memoryNav = {
|
|
1679
|
+
route: { screenId: "quick-add" },
|
|
1680
|
+
navigate: (target: NavTarget) => {
|
|
1681
|
+
if ("screenId" in target) navigateCalls.push(target);
|
|
1682
|
+
},
|
|
1683
|
+
replace: () => undefined,
|
|
1684
|
+
hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
|
|
1685
|
+
searchParams: {},
|
|
1686
|
+
setSearchParams: () => undefined,
|
|
1687
|
+
};
|
|
1688
|
+
const editScreen: EntityEditScreenDefinition = {
|
|
1689
|
+
id: "task-edit",
|
|
1690
|
+
type: "entityEdit",
|
|
1691
|
+
entity: "task",
|
|
1692
|
+
layout: { sections: [{ title: "x", fields: ["title"] }] },
|
|
1693
|
+
};
|
|
1694
|
+
const actionScreen: ActionFormScreenDefinition = {
|
|
1695
|
+
id: "quick-add",
|
|
1696
|
+
type: "actionForm",
|
|
1697
|
+
handler: "tasks:write:task:quick-add",
|
|
1698
|
+
fields: { title: { type: "text", required: true } },
|
|
1699
|
+
layout: { sections: [{ title: "x", fields: ["title"] }] },
|
|
1700
|
+
redirect: "task-edit",
|
|
1701
|
+
};
|
|
1702
|
+
|
|
1703
|
+
const { NavProvider } = await import("@cosmicdrift/kumiko-renderer");
|
|
1704
|
+
render(
|
|
1705
|
+
<NavProvider value={memoryNav}>
|
|
1706
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
1707
|
+
<KumikoScreen
|
|
1708
|
+
schema={{ ...schema, screens: [actionScreen, editScreen, listScreen] }}
|
|
1709
|
+
qn="tasks:screen:quick-add"
|
|
1710
|
+
/>
|
|
1711
|
+
</DispatcherProvider>
|
|
1712
|
+
</NavProvider>,
|
|
1713
|
+
);
|
|
1714
|
+
|
|
1715
|
+
const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
|
|
1716
|
+
fireEvent.change(titleInput, { target: { value: "go" } });
|
|
1717
|
+
fireEvent.click(screen.getByTestId("render-edit-submit"));
|
|
1718
|
+
await waitFor(() => expect(navigateCalls.length).toBe(1));
|
|
1719
|
+
expect(navigateCalls[0]).toEqual({ screenId: "task-edit", entityId: "x" });
|
|
1665
1720
|
});
|
|
1666
1721
|
|
|
1667
1722
|
// #2416: entityEdit-create passes the newly created record's id through to
|
|
@@ -360,22 +360,11 @@ describe("NavTree", () => {
|
|
|
360
360
|
order: 20,
|
|
361
361
|
icon: "shield-check",
|
|
362
362
|
},
|
|
363
|
+
{ id: "cap-list", label: "Caps", screen: "cap-list", order: 60, icon: "gauge" },
|
|
363
364
|
],
|
|
364
365
|
} as FeatureSchema;
|
|
365
366
|
const { container } = render(<NavTree schema={schema} />);
|
|
366
|
-
expectNavIcons(container, ["send", "shield-check"]);
|
|
367
|
-
expect(warnSpy).not.toHaveBeenCalled();
|
|
368
|
-
});
|
|
369
|
-
|
|
370
|
-
test("gauge (cap-list nav) resolves to an icon without missing-icon path", () => {
|
|
371
|
-
const schema = {
|
|
372
|
-
featureName: "cap-counter",
|
|
373
|
-
entities: {},
|
|
374
|
-
screens: [{ id: "cap-list", type: "entityList", entity: "x", columns: [] }],
|
|
375
|
-
navs: [{ id: "cap-list", label: "Caps", screen: "cap-list", order: 60, icon: "gauge" }],
|
|
376
|
-
} as FeatureSchema;
|
|
377
|
-
const { container } = render(<NavTree schema={schema} />);
|
|
378
|
-
expectNavIcons(container, ["gauge"]);
|
|
367
|
+
expectNavIcons(container, ["send", "shield-check", "gauge"]);
|
|
379
368
|
expect(warnSpy).not.toHaveBeenCalled();
|
|
380
369
|
});
|
|
381
370
|
|
|
@@ -383,7 +372,10 @@ describe("NavTree", () => {
|
|
|
383
372
|
const schema = {
|
|
384
373
|
featureName: "showcase",
|
|
385
374
|
entities: {},
|
|
386
|
-
screens: [
|
|
375
|
+
screens: [
|
|
376
|
+
{ id: "x", type: "entityList", entity: "x", columns: [] },
|
|
377
|
+
{ id: "cap-list", type: "entityList", entity: "x", columns: [] },
|
|
378
|
+
],
|
|
387
379
|
navs: [
|
|
388
380
|
{
|
|
389
381
|
id: "x",
|
|
@@ -13,7 +13,7 @@ import userEvent from "@testing-library/user-event";
|
|
|
13
13
|
import type { ReactNode } from "react";
|
|
14
14
|
import { defaultPrimitives, END_LABEL_MIN_ROWS } from "../primitives";
|
|
15
15
|
import { PageSection, Stack } from "../primitives/layout";
|
|
16
|
-
import { fireEvent, render, screen } from "./test-utils";
|
|
16
|
+
import { fireEvent, render, screen, waitFor } from "./test-utils";
|
|
17
17
|
|
|
18
18
|
const { Button, Banner, Field, Input, DataTable, Form, Text, Heading, Dialog, Card } =
|
|
19
19
|
defaultPrimitives;
|
|
@@ -193,7 +193,16 @@ describe("Input kind mapping", () => {
|
|
|
193
193
|
|
|
194
194
|
test('kind="text" icon="not-a-real-key": unknown key → no icon, no padding', () => {
|
|
195
195
|
render(
|
|
196
|
-
<Input
|
|
196
|
+
<Input
|
|
197
|
+
id="i"
|
|
198
|
+
name="i"
|
|
199
|
+
kind="text"
|
|
200
|
+
value=""
|
|
201
|
+
icon={
|
|
202
|
+
"not-a-real-key" as unknown as import("@cosmicdrift/kumiko-framework/ui-types").FieldIconKey
|
|
203
|
+
}
|
|
204
|
+
onChange={() => {}}
|
|
205
|
+
/>,
|
|
197
206
|
);
|
|
198
207
|
const input = screen.getByRole("textbox");
|
|
199
208
|
expect(input.className).not.toContain("pl-8");
|
|
@@ -201,7 +210,18 @@ describe("Input kind mapping", () => {
|
|
|
201
210
|
});
|
|
202
211
|
|
|
203
212
|
test('kind="text" icon="constructor": prototype-chain key → no icon, no padding', () => {
|
|
204
|
-
render(
|
|
213
|
+
render(
|
|
214
|
+
<Input
|
|
215
|
+
id="i"
|
|
216
|
+
name="i"
|
|
217
|
+
kind="text"
|
|
218
|
+
value=""
|
|
219
|
+
icon={
|
|
220
|
+
"constructor" as unknown as import("@cosmicdrift/kumiko-framework/ui-types").FieldIconKey
|
|
221
|
+
}
|
|
222
|
+
onChange={() => {}}
|
|
223
|
+
/>,
|
|
224
|
+
);
|
|
205
225
|
const input = screen.getByRole("textbox");
|
|
206
226
|
expect(input.className).not.toContain("pl-8");
|
|
207
227
|
expect(document.querySelector("svg[aria-hidden='true']")).toBeNull();
|
|
@@ -838,6 +858,34 @@ describe("DataTable", () => {
|
|
|
838
858
|
expect(screen.queryByTestId("row-r1-action-delete-dialog")).not.toBeNull();
|
|
839
859
|
});
|
|
840
860
|
|
|
861
|
+
// Regression (solon e2e/cost-category.spec.ts, fw): unconditional
|
|
862
|
+
// preventDefault() in onSelect blocked Radix' auto-close, so the
|
|
863
|
+
// dropdown stayed in the DOM after the confirm flow (role="menu" +
|
|
864
|
+
// body.style.pointerEvents="none" overlay lock never released), even
|
|
865
|
+
// though the write itself had already succeeded.
|
|
866
|
+
test("Kebab: nach Confirm+onTrigger ist das Dropdown-Menu geschlossen (kein Overlay-Lock)", async () => {
|
|
867
|
+
const user = userEvent.setup();
|
|
868
|
+
const onTrigger = mock();
|
|
869
|
+
render(
|
|
870
|
+
<DataTable
|
|
871
|
+
columns={cols}
|
|
872
|
+
rows={rows}
|
|
873
|
+
testId="dt"
|
|
874
|
+
rowActions={[
|
|
875
|
+
{ id: "a", label: "Archive", onTrigger: mock() },
|
|
876
|
+
{ id: "b", label: "Duplicate", onTrigger: mock() },
|
|
877
|
+
{ id: "delete", label: "Delete", style: "danger", onTrigger },
|
|
878
|
+
]}
|
|
879
|
+
/>,
|
|
880
|
+
);
|
|
881
|
+
await user.click(screen.getByTestId("row-r1-actions-menu"));
|
|
882
|
+
await user.click(screen.getByTestId("row-r1-action-delete"));
|
|
883
|
+
await user.click(screen.getByTestId("row-r1-action-delete-dialog-confirm"));
|
|
884
|
+
await waitFor(() => expect(onTrigger).toHaveBeenCalledWith(rows[0]));
|
|
885
|
+
await waitFor(() => expect(screen.queryByRole("menu")).toBeNull());
|
|
886
|
+
expect(document.body.style.pointerEvents).not.toBe("none");
|
|
887
|
+
});
|
|
888
|
+
|
|
841
889
|
test("confirmLabel separat vom label: Dialog-Button zeigt confirmLabel", async () => {
|
|
842
890
|
const user = userEvent.setup();
|
|
843
891
|
render(
|
|
@@ -1044,6 +1092,38 @@ describe("DataTable", () => {
|
|
|
1044
1092
|
expect((screen.getByTestId("edit-name") as HTMLInputElement).value).toBe("Alice");
|
|
1045
1093
|
});
|
|
1046
1094
|
|
|
1095
|
+
test("plain cell still fires onRowClick when onCellChange is set (stopPropagation only on editable)", () => {
|
|
1096
|
+
const onRowClick = mock();
|
|
1097
|
+
const onCellChange = mock();
|
|
1098
|
+
const cols = [
|
|
1099
|
+
{
|
|
1100
|
+
field: "name",
|
|
1101
|
+
label: "Name",
|
|
1102
|
+
type: "string",
|
|
1103
|
+
sortable: false,
|
|
1104
|
+
},
|
|
1105
|
+
{
|
|
1106
|
+
field: "edit",
|
|
1107
|
+
label: "Edit",
|
|
1108
|
+
type: "string",
|
|
1109
|
+
sortable: false,
|
|
1110
|
+
renderer: { react: { __component: "EditableSwatch" } },
|
|
1111
|
+
},
|
|
1112
|
+
] as const;
|
|
1113
|
+
render(
|
|
1114
|
+
<ColumnRenderersProvider value={{ EditableSwatch }}>
|
|
1115
|
+
<DataTable
|
|
1116
|
+
columns={cols}
|
|
1117
|
+
rows={[{ id: "r1", values: { name: "Alice", edit: "x" } }]}
|
|
1118
|
+
onRowClick={onRowClick}
|
|
1119
|
+
onCellChange={onCellChange}
|
|
1120
|
+
/>
|
|
1121
|
+
</ColumnRenderersProvider>,
|
|
1122
|
+
);
|
|
1123
|
+
fireEvent.click(screen.getByTestId("cell-r1-name"));
|
|
1124
|
+
expect(onRowClick).toHaveBeenCalled();
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1047
1127
|
test("format-spec renderer ignores onCellChange — no crash, plain formatted text", () => {
|
|
1048
1128
|
const onCellChange = mock();
|
|
1049
1129
|
const cols = [
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from "@cosmicdrift/kumiko-renderer";
|
|
17
17
|
import { type ReactNode, useCallback, useState } from "react";
|
|
18
18
|
import { z } from "zod";
|
|
19
|
+
import { PATCH_DRAFT_SAVE_DEBOUNCE_MS } from "../../../renderer/src/components/render-edit";
|
|
19
20
|
import {
|
|
20
21
|
act,
|
|
21
22
|
createFakeDraftStorage,
|
|
@@ -1307,14 +1308,11 @@ describe("RenderEdit wizard mode", () => {
|
|
|
1307
1308
|
expect(screen.queryByTestId("field-title-errors")).toBeNull();
|
|
1308
1309
|
});
|
|
1309
1310
|
|
|
1310
|
-
//
|
|
1311
|
-
//
|
|
1312
|
-
//
|
|
1313
|
-
//
|
|
1314
|
-
//
|
|
1315
|
-
// issues from every validate() call via its own `hiddenFields` set
|
|
1316
|
-
// (form-controller.ts:200), independent of scope; this test protects
|
|
1317
|
-
// that existing guard, not the fieldNames narrowing added here.
|
|
1311
|
+
// Regression for form-controller.ts's `hiddenFields` guard: a required
|
|
1312
|
+
// field currently hidden by its own condition (e.g. vatId when not a
|
|
1313
|
+
// company) must not block wizard "Next". This is independent of any
|
|
1314
|
+
// handleWizardNext fieldNames narrowing — those paths are covered by
|
|
1315
|
+
// the fully-hidden-section test below.
|
|
1318
1316
|
test("Weiter is not blocked by a required field that's currently hidden by its own condition", async () => {
|
|
1319
1317
|
const companyEntity = {
|
|
1320
1318
|
fields: {
|
|
@@ -1590,10 +1588,6 @@ describe("RenderEdit wizard mode", () => {
|
|
|
1590
1588
|
});
|
|
1591
1589
|
|
|
1592
1590
|
describe("RenderEdit wizard draft", () => {
|
|
1593
|
-
// Mirrors render-edit.tsx's own PATCH_DRAFT_SAVE_DEBOUNCE_MS (not
|
|
1594
|
-
// exported) — tests below wait out a full window to catch stragglers.
|
|
1595
|
-
const PATCH_DRAFT_SAVE_DEBOUNCE_MS = 500;
|
|
1596
|
-
|
|
1597
1591
|
function makeDraftWizardScreen(draft: boolean): EntityEditScreenDefinition {
|
|
1598
1592
|
return {
|
|
1599
1593
|
id: "orders:screen:order-wizard-draft",
|
|
@@ -2109,7 +2103,7 @@ describe("RenderEdit wizard draft", () => {
|
|
|
2109
2103
|
// second burst) would slip past an assertion taken right after the
|
|
2110
2104
|
// first save. Wait out a full extra debounce window to prove there is
|
|
2111
2105
|
// no straggler.
|
|
2112
|
-
await new Promise((resolve) => setTimeout(resolve, PATCH_DRAFT_SAVE_DEBOUNCE_MS +
|
|
2106
|
+
await new Promise((resolve) => setTimeout(resolve, PATCH_DRAFT_SAVE_DEBOUNCE_MS + 1000));
|
|
2113
2107
|
expect(savesSoFar()).toBe(before + 1);
|
|
2114
2108
|
});
|
|
2115
2109
|
|
|
@@ -2191,7 +2185,7 @@ describe("RenderEdit wizard draft", () => {
|
|
|
2191
2185
|
// Wait past the debounce window the patch() call armed. If discard
|
|
2192
2186
|
// hadn't cleared the timer, a straggling save would land here and
|
|
2193
2187
|
// resurrect the just-discarded draft.
|
|
2194
|
-
await new Promise((resolve) => setTimeout(resolve, PATCH_DRAFT_SAVE_DEBOUNCE_MS +
|
|
2188
|
+
await new Promise((resolve) => setTimeout(resolve, PATCH_DRAFT_SAVE_DEBOUNCE_MS + 1000));
|
|
2195
2189
|
|
|
2196
2190
|
expect(calls.filter((c) => c === "form-draft:write:save").length).toBe(savesAtSubmit);
|
|
2197
2191
|
expect(store.current).toBeNull();
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// fw#2437: DataTableCell must thread the app locale (LocaleProvider) into
|
|
2
|
+
// `format: "unit"` / Intl.NumberFormat cells instead of falling back to the
|
|
3
|
+
// runtime's default locale — list vs. detail view could otherwise disagree
|
|
4
|
+
// ("72.5 m²" vs "72,5 m²") for the same value.
|
|
5
|
+
//
|
|
6
|
+
// DoD-Test: rendering the SAME `{ format: "unit", unit: "m2" }` column under
|
|
7
|
+
// two locales must produce two DIFFERENT cell labels — not just prove the
|
|
8
|
+
// unit-format key exists.
|
|
9
|
+
|
|
10
|
+
import { describe, expect, test } from "bun:test";
|
|
11
|
+
import type {
|
|
12
|
+
EntityDefinition,
|
|
13
|
+
EntityListScreenDefinition,
|
|
14
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
15
|
+
import {
|
|
16
|
+
createStaticLocaleResolver,
|
|
17
|
+
type LiveEventSubscriber,
|
|
18
|
+
LiveEventsProvider,
|
|
19
|
+
LocaleProvider,
|
|
20
|
+
type NavApi,
|
|
21
|
+
NavProvider,
|
|
22
|
+
PrimitivesProvider,
|
|
23
|
+
RenderList,
|
|
24
|
+
TokensProvider,
|
|
25
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
26
|
+
import { cleanup, render } from "@testing-library/react";
|
|
27
|
+
import type { ReactElement, ReactNode } from "react";
|
|
28
|
+
import { defaultPrimitives } from "../primitives";
|
|
29
|
+
import { defaultTokens } from "../tokens";
|
|
30
|
+
|
|
31
|
+
const stubNav: NavApi = {
|
|
32
|
+
route: undefined,
|
|
33
|
+
navigate: () => {},
|
|
34
|
+
replace: () => {},
|
|
35
|
+
hrefFor: () => "",
|
|
36
|
+
searchParams: {},
|
|
37
|
+
setSearchParams: () => {},
|
|
38
|
+
};
|
|
39
|
+
const stubLiveEvents: LiveEventSubscriber = () => () => {};
|
|
40
|
+
const stubTokens = {
|
|
41
|
+
tokens: defaultTokens,
|
|
42
|
+
mode: "dark" as const,
|
|
43
|
+
setMode: () => {},
|
|
44
|
+
toggleMode: () => {},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Same all-text synthesized entity shape as projection-list-shim.ts /
|
|
48
|
+
// related-list-section.tsx — a projectionList/relatedList column has no real
|
|
49
|
+
// EntityDefinition, only a number-typed pseudo-field per column.
|
|
50
|
+
const areaColumnEntity = {
|
|
51
|
+
fields: { area: { type: "number" } },
|
|
52
|
+
} as unknown as EntityDefinition;
|
|
53
|
+
|
|
54
|
+
const unitScreen: EntityListScreenDefinition = {
|
|
55
|
+
id: "",
|
|
56
|
+
type: "entityList",
|
|
57
|
+
entity: "__projection__",
|
|
58
|
+
columns: [{ field: "area", renderer: { format: "unit", unit: "m2" } }],
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const numberScreen: EntityListScreenDefinition = {
|
|
62
|
+
id: "",
|
|
63
|
+
type: "entityList",
|
|
64
|
+
entity: "__projection__",
|
|
65
|
+
columns: [{ field: "area" }],
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
function renderAreaListUnderLocale(
|
|
69
|
+
locale: string,
|
|
70
|
+
areaValue: number,
|
|
71
|
+
screen: EntityListScreenDefinition,
|
|
72
|
+
): string | null {
|
|
73
|
+
cleanup();
|
|
74
|
+
function Wrapper({ children }: { readonly children: ReactNode }): ReactElement {
|
|
75
|
+
return (
|
|
76
|
+
<TokensProvider value={stubTokens}>
|
|
77
|
+
<LocaleProvider resolver={createStaticLocaleResolver({ locale })}>
|
|
78
|
+
<PrimitivesProvider value={defaultPrimitives}>
|
|
79
|
+
<NavProvider value={stubNav}>
|
|
80
|
+
<LiveEventsProvider value={stubLiveEvents}>{children}</LiveEventsProvider>
|
|
81
|
+
</NavProvider>
|
|
82
|
+
</PrimitivesProvider>
|
|
83
|
+
</LocaleProvider>
|
|
84
|
+
</TokensProvider>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
const result = render(
|
|
88
|
+
<RenderList
|
|
89
|
+
screen={screen}
|
|
90
|
+
entity={areaColumnEntity}
|
|
91
|
+
rows={[{ id: "r1", area: areaValue }]}
|
|
92
|
+
featureName="listing"
|
|
93
|
+
/>,
|
|
94
|
+
{ wrapper: Wrapper },
|
|
95
|
+
);
|
|
96
|
+
return result.getByTestId("cell-r1-area").textContent;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
describe("RenderList — format:unit-FormatSpec threads the app locale (fw#2437)", () => {
|
|
100
|
+
test("dieselbe m2-Spalte unter zwei Locales rendert zwei verschiedene Beschriftungen", () => {
|
|
101
|
+
expect(renderAreaListUnderLocale("de-DE", 72.5, unitScreen)).toBe("72,5 m²");
|
|
102
|
+
expect(renderAreaListUnderLocale("en-US", 72.5, unitScreen)).toBe("72.5 m²");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("ein renderer.locale gewinnt weiterhin vor dem App-Locale", () => {
|
|
106
|
+
const screen: EntityListScreenDefinition = {
|
|
107
|
+
id: "",
|
|
108
|
+
type: "entityList",
|
|
109
|
+
entity: "__projection__",
|
|
110
|
+
columns: [{ field: "area", renderer: { format: "unit", unit: "m2", locale: "en-US" } }],
|
|
111
|
+
};
|
|
112
|
+
expect(renderAreaListUnderLocale("de-DE", 72.5, screen)).toBe("72.5 m²");
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("RenderList — default number-column threads the app locale (fw#2437)", () => {
|
|
117
|
+
test("kein expliziter renderer: number-Zelle folgt dem App-Locale, nicht der Runtime-Locale", () => {
|
|
118
|
+
expect(renderAreaListUnderLocale("de-DE", 1234.5, numberScreen)).toBe(
|
|
119
|
+
new Intl.NumberFormat("de-DE").format(1234.5),
|
|
120
|
+
);
|
|
121
|
+
expect(renderAreaListUnderLocale("en-US", 1234.5, numberScreen)).toBe(
|
|
122
|
+
new Intl.NumberFormat("en-US").format(1234.5),
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { LocaleResolver } from "@cosmicdrift/kumiko-headless";
|
|
3
|
+
import { render, waitFor } from "@testing-library/react";
|
|
4
|
+
import { DocumentLangSync } from "../document-lang-sync";
|
|
5
|
+
|
|
6
|
+
function resolverWithLocale(initial: string): {
|
|
7
|
+
readonly resolver: LocaleResolver;
|
|
8
|
+
setLocale(next: string): void;
|
|
9
|
+
} {
|
|
10
|
+
let locale = initial;
|
|
11
|
+
const listeners = new Set<() => void>();
|
|
12
|
+
return {
|
|
13
|
+
resolver: {
|
|
14
|
+
translate: (key: string) => key,
|
|
15
|
+
locale: () => locale,
|
|
16
|
+
timeZone: () => "UTC",
|
|
17
|
+
subscribe: (listener: () => void) => {
|
|
18
|
+
listeners.add(listener);
|
|
19
|
+
return () => listeners.delete(listener);
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
setLocale(next: string) {
|
|
23
|
+
locale = next;
|
|
24
|
+
for (const listener of listeners) listener();
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("DocumentLangSync", () => {
|
|
30
|
+
test("sets document.documentElement.lang from the resolver and updates on locale change", async () => {
|
|
31
|
+
const { resolver, setLocale } = resolverWithLocale("de-DE");
|
|
32
|
+
render(<DocumentLangSync resolver={resolver} />);
|
|
33
|
+
|
|
34
|
+
await waitFor(() => {
|
|
35
|
+
expect(document.documentElement.lang).toBe("de-DE");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
setLocale("en-US");
|
|
39
|
+
await waitFor(() => {
|
|
40
|
+
expect(document.documentElement.lang).toBe("en-US");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
QualifiedContentCollection,
|
|
18
18
|
TranslationsByLocale,
|
|
19
19
|
} from "@cosmicdrift/kumiko-renderer";
|
|
20
|
+
import type { ContentEditorFormat } from "@cosmicdrift/kumiko-types/nav";
|
|
20
21
|
import type { ComponentType, ReactNode } from "react";
|
|
21
22
|
|
|
22
23
|
/** What a `navProvidersFromCollections` factory returns: the providers plus
|
|
@@ -97,7 +98,7 @@ export type ClientFeatureDefinition = {
|
|
|
97
98
|
* a plain textarea when no clientFeature registered one for that
|
|
98
99
|
* format. Same last-wins semantics as columnRenderers. Public component
|
|
99
100
|
* contract: `{ value, onChange, variables, readOnly }`. */
|
|
100
|
-
readonly contentEditors?:
|
|
101
|
+
readonly contentEditors?: Partial<Record<ContentEditorFormat, ContentEditorComponent>>;
|
|
101
102
|
|
|
102
103
|
/** Editor-Resolver-Komponenten pro featureId:action-Key. Wenn ein
|
|
103
104
|
* TreeNode mit target angeklickt wird, schlägt der EditorPanel das
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
// Keeps document.documentElement.lang in sync with
|
|
2
|
-
// Screen readers and the browser's built-in translate
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// access to the resolver — learns the active language.
|
|
1
|
+
// Keeps document.documentElement.lang (+ dataset.kumikoLocale) in sync with
|
|
2
|
+
// the active locale. Screen readers and the browser's built-in translate
|
|
3
|
+
// offer read lang; dispatcher-live reads dataset.kumikoLocale for X-Locale
|
|
4
|
+
// (see dispatcher-live/src/locale.ts) so a static <html lang> cannot spoof
|
|
5
|
+
// the active UI language.
|
|
7
6
|
//
|
|
8
7
|
// Lives here, not in @cosmicdrift/kumiko-renderer: that package is DOM-free
|
|
9
8
|
// by design (Renderer-Boundaries Guard enforces it — web/native both
|
|
@@ -14,11 +13,18 @@
|
|
|
14
13
|
import type { LocaleResolver } from "@cosmicdrift/kumiko-headless";
|
|
15
14
|
import { useEffect } from "react";
|
|
16
15
|
|
|
16
|
+
function syncDocumentLocale(locale: string): void {
|
|
17
|
+
document.documentElement.lang = locale;
|
|
18
|
+
// Dedicated marker for dispatcher-live — static <html lang> must not
|
|
19
|
+
// look like an active UI locale (kumiko-framework#2334).
|
|
20
|
+
document.documentElement.dataset["kumikoLocale"] = locale;
|
|
21
|
+
}
|
|
22
|
+
|
|
17
23
|
export function DocumentLangSync({ resolver }: { readonly resolver: LocaleResolver }): null {
|
|
18
24
|
useEffect(() => {
|
|
19
|
-
|
|
25
|
+
syncDocumentLocale(resolver.locale());
|
|
20
26
|
return resolver.subscribe(() => {
|
|
21
|
-
|
|
27
|
+
syncDocumentLocale(resolver.locale());
|
|
22
28
|
});
|
|
23
29
|
}, [resolver]);
|
|
24
30
|
return null;
|
|
@@ -53,7 +53,7 @@ export function PlainContentEditor({
|
|
|
53
53
|
};
|
|
54
54
|
|
|
55
55
|
return (
|
|
56
|
-
<div>
|
|
56
|
+
<div className="rounded-md border border-input bg-transparent">
|
|
57
57
|
<TextareaContentEditor
|
|
58
58
|
id={id}
|
|
59
59
|
value={value}
|
|
@@ -61,7 +61,11 @@ export function PlainContentEditor({
|
|
|
61
61
|
variables={variables}
|
|
62
62
|
readOnly={readOnly}
|
|
63
63
|
/>
|
|
64
|
-
|
|
64
|
+
{variables.length > 0 && (
|
|
65
|
+
<div className="flex flex-wrap gap-1 border-t border-input p-1">
|
|
66
|
+
<VariableChips variables={variables} onInsert={insertAtCaret} disabled={readOnly} />
|
|
67
|
+
</div>
|
|
68
|
+
)}
|
|
65
69
|
</div>
|
|
66
70
|
);
|
|
67
71
|
}
|
|
@@ -6,9 +6,7 @@ function stubDispatcher(result: QueryResult<{ url?: string }>): Dispatcher {
|
|
|
6
6
|
return { query: async () => result } as unknown as Dispatcher;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
//
|
|
10
|
-
// src/__tests__/download.test.tsx duplicate was removed — this helper
|
|
11
|
-
// already covers both bunfig (node) and bunfig.dom.toml (happy-dom):
|
|
9
|
+
// Covers both bunfig (node) and bunfig.dom.toml (happy-dom):
|
|
12
10
|
// - node env: install a minimal fake window for the duration of the test,
|
|
13
11
|
// then remove it again so later suites are unaffected.
|
|
14
12
|
// - dom env: patch only location.assign on the real window — swapping the
|
|
@@ -56,6 +54,7 @@ describe("postWithDownload", () => {
|
|
|
56
54
|
});
|
|
57
55
|
|
|
58
56
|
test("passes the dispatcher error through when not successful", async () => {
|
|
57
|
+
tracked = trackAssignedUrl();
|
|
59
58
|
const dispatcherError: DispatcherError = {
|
|
60
59
|
code: "boom",
|
|
61
60
|
httpStatus: 500,
|
|
@@ -65,6 +64,7 @@ describe("postWithDownload", () => {
|
|
|
65
64
|
const dispatcher = stubDispatcher({ isSuccess: false, error: dispatcherError });
|
|
66
65
|
const err = await postWithDownload(dispatcher, "files.signedUrl", {});
|
|
67
66
|
expect(err).toEqual(dispatcherError);
|
|
67
|
+
expect(tracked.url()).toBeUndefined();
|
|
68
68
|
});
|
|
69
69
|
|
|
70
70
|
test("returns download_url_missing error when url is absent", async () => {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// happy-dom coverage for postWithDownload's window.location.assign path.
|
|
2
|
+
// bunfig.toml (unit `test`) ignores *.test.tsx; bunfig.dom.toml picks this up.
|
|
3
|
+
|
|
4
|
+
import { afterEach, describe, expect, spyOn, test } from "bun:test";
|
|
5
|
+
import type { Dispatcher, QueryResult } from "@cosmicdrift/kumiko-headless";
|
|
6
|
+
import { postWithDownload } from "../download";
|
|
7
|
+
|
|
8
|
+
function stubDispatcher(result: QueryResult<{ url?: string }>): Dispatcher {
|
|
9
|
+
return { query: async () => result } as unknown as Dispatcher;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("postWithDownload (happy-dom)", () => {
|
|
13
|
+
let assignSpy: ReturnType<typeof spyOn> | undefined;
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
assignSpy?.mockRestore();
|
|
17
|
+
assignSpy = undefined;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("assigns the url on the real window", async () => {
|
|
21
|
+
assignSpy = spyOn(window.location, "assign").mockImplementation(() => {});
|
|
22
|
+
const err = await postWithDownload(
|
|
23
|
+
stubDispatcher({ isSuccess: true, data: { url: "https://cdn.example.com/f.pdf" } }),
|
|
24
|
+
"files.signedUrl",
|
|
25
|
+
{},
|
|
26
|
+
);
|
|
27
|
+
expect(err).toBeNull();
|
|
28
|
+
expect(assignSpy).toHaveBeenCalledWith("https://cdn.example.com/f.pdf");
|
|
29
|
+
});
|
|
30
|
+
});
|