@cosmicdrift/kumiko-renderer-web 0.173.0 → 0.174.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/__tests__/primitives.test.tsx +31 -1
- package/src/__tests__/reference-create.test.tsx +67 -0
- package/src/__tests__/workspace-shell.test.tsx +17 -0
- package/src/primitives/__tests__/date-parse.test.ts +26 -0
- package/src/primitives/date-parse.ts +6 -1
- package/src/primitives/index.tsx +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.174.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.174.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.174.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.174.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",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { describe, expect, mock, test } from "bun:test";
|
|
11
11
|
import userEvent from "@testing-library/user-event";
|
|
12
|
-
import { defaultPrimitives } from "../primitives";
|
|
12
|
+
import { defaultPrimitives, END_LABEL_MIN_ROWS } from "../primitives";
|
|
13
13
|
import { PageSection, Stack } from "../primitives/layout";
|
|
14
14
|
import { fireEvent, render, screen } from "./test-utils";
|
|
15
15
|
|
|
@@ -483,6 +483,8 @@ describe("DataTable", () => {
|
|
|
483
483
|
id: `r${i}`,
|
|
484
484
|
values: { name: `A${i}` },
|
|
485
485
|
}));
|
|
486
|
+
const rowsOfCount = (n: number) =>
|
|
487
|
+
Array.from({ length: n }, (_, i) => ({ id: `r${i}`, values: { name: `A${i}` } }));
|
|
486
488
|
|
|
487
489
|
test("ohne onReachEnd: kein Sentinel im DOM", () => {
|
|
488
490
|
render(<DataTable columns={cols} rows={oneRow} testId="dt" />);
|
|
@@ -551,6 +553,34 @@ describe("DataTable", () => {
|
|
|
551
553
|
expect(screen.getByTestId("dt-sentinel").textContent).toBe("");
|
|
552
554
|
});
|
|
553
555
|
|
|
556
|
+
test(`hasMore=false + ${END_LABEL_MIN_ROWS - 1} Zeilen: kein End-Marker (Grenzfall unter Threshold)`, () => {
|
|
557
|
+
render(
|
|
558
|
+
<DataTable
|
|
559
|
+
columns={cols}
|
|
560
|
+
rows={rowsOfCount(END_LABEL_MIN_ROWS - 1)}
|
|
561
|
+
testId="dt"
|
|
562
|
+
onReachEnd={mock()}
|
|
563
|
+
loadingMore={false}
|
|
564
|
+
hasMore={false}
|
|
565
|
+
/>,
|
|
566
|
+
);
|
|
567
|
+
expect(screen.queryByTestId("dt-sentinel-end")).toBeNull();
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
test(`hasMore=false + ${END_LABEL_MIN_ROWS} Zeilen: End-Marker (Grenzfall auf Threshold)`, () => {
|
|
571
|
+
render(
|
|
572
|
+
<DataTable
|
|
573
|
+
columns={cols}
|
|
574
|
+
rows={rowsOfCount(END_LABEL_MIN_ROWS)}
|
|
575
|
+
testId="dt"
|
|
576
|
+
onReachEnd={mock()}
|
|
577
|
+
loadingMore={false}
|
|
578
|
+
hasMore={false}
|
|
579
|
+
/>,
|
|
580
|
+
);
|
|
581
|
+
expect(screen.getByTestId("dt-sentinel-end")).not.toBeNull();
|
|
582
|
+
});
|
|
583
|
+
|
|
554
584
|
test("hasMore=false + de-Locale: Marker kommt aus i18n statt hartcodiert", async () => {
|
|
555
585
|
const { LocaleProvider, createStaticLocaleResolver, kumikoDefaultTranslations } =
|
|
556
586
|
await import("@cosmicdrift/kumiko-renderer");
|
|
@@ -178,4 +178,71 @@ describe("Reference-field create-in-place (#1681)", () => {
|
|
|
178
178
|
expect(dispatcher.writes).toHaveLength(0);
|
|
179
179
|
expect(screen.getByTestId("combobox-kumiko-edit-assignee").textContent).not.toContain("widget");
|
|
180
180
|
});
|
|
181
|
+
|
|
182
|
+
// The three negative gates that decide whether the "+ Create" footer
|
|
183
|
+
// renders at all (render-field.tsx ReferenceInput.canCreate). A regression
|
|
184
|
+
// here silently shows a create button the server would reject.
|
|
185
|
+
describe("create-footer gates: no combobox-*-create in the DOM", () => {
|
|
186
|
+
test("allowCreate: false on the target screen", async () => {
|
|
187
|
+
const noCreateSchema: FeatureSchema = {
|
|
188
|
+
...catalogSchema,
|
|
189
|
+
screens: [{ ...widgetCreateScreen, allowCreate: false }],
|
|
190
|
+
};
|
|
191
|
+
render(
|
|
192
|
+
<AppFeaturesProvider features={[tasksSchema, noCreateSchema]}>
|
|
193
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
194
|
+
<KumikoScreen schema={tasksSchema} qn="tasks:screen:task-edit" />
|
|
195
|
+
</DispatcherProvider>
|
|
196
|
+
</AppFeaturesProvider>,
|
|
197
|
+
);
|
|
198
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
199
|
+
await userEvent.setup().click(screen.getByTestId("combobox-kumiko-edit-assignee"));
|
|
200
|
+
expect(screen.queryByTestId("combobox-kumiko-edit-assignee-create")).toBeNull();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("target screen gated by a role the user doesn't have (no UserRolesProvider mounted)", async () => {
|
|
204
|
+
const roleGatedSchema: FeatureSchema = {
|
|
205
|
+
...catalogSchema,
|
|
206
|
+
screens: [{ ...widgetCreateScreen, access: { roles: ["Admin"] } }],
|
|
207
|
+
};
|
|
208
|
+
render(
|
|
209
|
+
<AppFeaturesProvider features={[tasksSchema, roleGatedSchema]}>
|
|
210
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
211
|
+
<KumikoScreen schema={tasksSchema} qn="tasks:screen:task-edit" />
|
|
212
|
+
</DispatcherProvider>
|
|
213
|
+
</AppFeaturesProvider>,
|
|
214
|
+
);
|
|
215
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
216
|
+
await userEvent.setup().click(screen.getByTestId("combobox-kumiko-edit-assignee"));
|
|
217
|
+
expect(screen.queryByTestId("combobox-kumiko-edit-assignee-create")).toBeNull();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("field.readOnly on the reference field itself", async () => {
|
|
221
|
+
const readOnlySchema: FeatureSchema = {
|
|
222
|
+
...tasksSchema,
|
|
223
|
+
screens: [
|
|
224
|
+
{
|
|
225
|
+
...taskEditScreen,
|
|
226
|
+
layout: {
|
|
227
|
+
sections: [
|
|
228
|
+
{
|
|
229
|
+
title: "Basics",
|
|
230
|
+
fields: ["title", { field: "assignee", readOnly: true }, "tags"],
|
|
231
|
+
},
|
|
232
|
+
],
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
],
|
|
236
|
+
};
|
|
237
|
+
render(
|
|
238
|
+
<AppFeaturesProvider features={[readOnlySchema, catalogSchema]}>
|
|
239
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
240
|
+
<KumikoScreen schema={readOnlySchema} qn="tasks:screen:task-edit" />
|
|
241
|
+
</DispatcherProvider>
|
|
242
|
+
</AppFeaturesProvider>,
|
|
243
|
+
);
|
|
244
|
+
await waitFor(() => screen.getByTestId("render-edit-form"));
|
|
245
|
+
expect(screen.queryByTestId("combobox-kumiko-edit-assignee-create")).toBeNull();
|
|
246
|
+
});
|
|
247
|
+
});
|
|
181
248
|
});
|
|
@@ -443,6 +443,23 @@ describe("WorkspaceShell", () => {
|
|
|
443
443
|
expect(innerMain?.classList.contains("flex-1")).toBe(true);
|
|
444
444
|
expect(innerMain?.classList.contains("overflow-auto")).toBe(true);
|
|
445
445
|
});
|
|
446
|
+
|
|
447
|
+
// #1692: default (no fill) must not force viewport-lock classes
|
|
448
|
+
test("without fill does not apply h-svh or min-h-0", () => {
|
|
449
|
+
renderShell(
|
|
450
|
+
<WorkspaceShell
|
|
451
|
+
brand={<div>Brand</div>}
|
|
452
|
+
schema={schema}
|
|
453
|
+
user={{ id: "u1", roles: ["admin"] }}
|
|
454
|
+
>
|
|
455
|
+
<div>content</div>
|
|
456
|
+
</WorkspaceShell>,
|
|
457
|
+
);
|
|
458
|
+
const root = document.querySelector('[data-slot="sidebar-wrapper"]');
|
|
459
|
+
expect(root?.classList.contains("h-svh")).toBe(false);
|
|
460
|
+
const inset = document.querySelector('[data-slot="sidebar-inset"]');
|
|
461
|
+
expect(inset?.classList.contains("min-h-0")).toBe(false);
|
|
462
|
+
});
|
|
446
463
|
});
|
|
447
464
|
|
|
448
465
|
// ---------------------------------------------------------------------------
|
|
@@ -36,6 +36,17 @@ describe("parseIso", () => {
|
|
|
36
36
|
expect(parseIso("0026-04-25")).toBeUndefined();
|
|
37
37
|
expect(parseIso("26-04-25")).toBeUndefined();
|
|
38
38
|
});
|
|
39
|
+
|
|
40
|
+
test("year above 9999 → undefined (Temporal.toString() would switch to signed extended ISO)", () => {
|
|
41
|
+
expect(parseIso("10000-04-25")).toBeUndefined();
|
|
42
|
+
expect(parseIso("999999-04-25")).toBeUndefined();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("year 9999 is still accepted (upper boundary)", () => {
|
|
46
|
+
const d = parseIso("9999-04-25");
|
|
47
|
+
expect(d).toBeDefined();
|
|
48
|
+
expect(toIso(d as Temporal.PlainDate)).toBe("9999-04-25");
|
|
49
|
+
});
|
|
39
50
|
});
|
|
40
51
|
|
|
41
52
|
describe("toIso", () => {
|
|
@@ -80,6 +91,10 @@ describe("parseTypedDate", () => {
|
|
|
80
91
|
if (d !== undefined) expect(toIso(d)).toBe("2026-04-25");
|
|
81
92
|
});
|
|
82
93
|
|
|
94
|
+
test("six-digit year → undefined, not a signed extended-ISO wire value", () => {
|
|
95
|
+
expect(parseTypedDate("25.04.999999", "de-DE")).toBeUndefined();
|
|
96
|
+
});
|
|
97
|
+
|
|
83
98
|
test("partial/invalid input → undefined", () => {
|
|
84
99
|
expect(parseTypedDate("", "de-DE")).toBeUndefined();
|
|
85
100
|
expect(parseTypedDate("25.04", "de-DE")).toBeUndefined();
|
|
@@ -108,4 +123,15 @@ describe("formatDateForInput", () => {
|
|
|
108
123
|
expect(roundtrip).toBeDefined();
|
|
109
124
|
if (roundtrip !== undefined) expect(toIso(roundtrip)).toBe("2026-04-25");
|
|
110
125
|
});
|
|
126
|
+
|
|
127
|
+
// #1659: y/m/d locale branch of localeDateOrder (ISO-like)
|
|
128
|
+
test("ja-JP locale (y/m/d order), re-parseable", () => {
|
|
129
|
+
const formatted = formatDateForInput(
|
|
130
|
+
Temporal.PlainDate.from({ year: 2026, month: 4, day: 25 }),
|
|
131
|
+
"ja-JP",
|
|
132
|
+
);
|
|
133
|
+
const roundtrip = parseTypedDate(formatted, "ja-JP");
|
|
134
|
+
expect(roundtrip).toBeDefined();
|
|
135
|
+
if (roundtrip !== undefined) expect(toIso(roundtrip)).toBe("2026-04-25");
|
|
136
|
+
});
|
|
111
137
|
});
|
|
@@ -37,7 +37,11 @@ export function parseIso(v: string): Temporal.PlainDate | undefined {
|
|
|
37
37
|
Number.isNaN(d) ||
|
|
38
38
|
// Reject 2-digit years — matches the pre-PlainDate behavior where
|
|
39
39
|
// `new Date`'s 1900+y quirk made a round-trip check fail on them.
|
|
40
|
-
y < 100
|
|
40
|
+
y < 100 ||
|
|
41
|
+
// Reject years outside 0000-9999 — Temporal.toString() switches to the
|
|
42
|
+
// signed extended ISO format above that range (e.g. "+010000-04-25"),
|
|
43
|
+
// breaking the "unchanged ISO string" wire contract (toIso below).
|
|
44
|
+
y > 9999
|
|
41
45
|
) {
|
|
42
46
|
return undefined;
|
|
43
47
|
}
|
|
@@ -106,6 +110,7 @@ export function parseTypedDate(input: string, locale: string): Temporal.PlainDat
|
|
|
106
110
|
else d = val;
|
|
107
111
|
});
|
|
108
112
|
if (y < 100) y += 2000;
|
|
113
|
+
if (y > 9999) return undefined;
|
|
109
114
|
|
|
110
115
|
return makePlainDate(y, m, d);
|
|
111
116
|
}
|
package/src/primitives/index.tsx
CHANGED
|
@@ -1020,7 +1020,7 @@ function RowActionsKebab({
|
|
|
1020
1020
|
// Server-Side-Render, kein observer wenn hasMore=false — dann zeigt
|
|
1021
1021
|
// der Sentinel nur den End-of-list-Hinweis.
|
|
1022
1022
|
// ponytail: Short lists do not need an end marker.
|
|
1023
|
-
const END_LABEL_MIN_ROWS = 20;
|
|
1023
|
+
export const END_LABEL_MIN_ROWS = 20;
|
|
1024
1024
|
|
|
1025
1025
|
function InfiniteSentinel({
|
|
1026
1026
|
onReachEnd,
|