@cosmicdrift/kumiko-renderer-web 0.197.1 → 0.198.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__/create-app.test.tsx +93 -0
- package/src/__tests__/kumiko-screen.test.tsx +79 -2
- package/src/__tests__/nav-tree.test.tsx +60 -3
- package/src/__tests__/render-edit.test.tsx +44 -0
- package/src/app/create-app.tsx +55 -26
- package/src/layout/nav-tree.tsx +35 -13
- package/src/primitives/__tests__/date-parse.test.ts +12 -0
- package/src/primitives/__tests__/embedded-list-input.test.tsx +26 -0
- package/src/primitives/date-parse.ts +10 -3
- package/src/primitives/embedded-list-input.tsx +15 -1
- package/src/sse/__tests__/live-events.test.ts +99 -0
- package/src/sse/live-events.ts +16 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.198.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.198.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.198.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.198.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",
|
|
@@ -206,6 +206,99 @@ describe("createKumikoApp", () => {
|
|
|
206
206
|
);
|
|
207
207
|
});
|
|
208
208
|
|
|
209
|
+
test("custom screen without a registered clientFeatures component → console.error names feature + screen at boot (kumiko-framework#2025)", async () => {
|
|
210
|
+
// A server feature can declare a `type: "custom"` screen without the
|
|
211
|
+
// app mounting the corresponding client plugin — the screen is still
|
|
212
|
+
// reachable via URL. createKumikoApp should surface that visibly ONCE
|
|
213
|
+
// at boot instead of only showing it as a banner when the URL is
|
|
214
|
+
// opened (which nobody would see if they never visit the route).
|
|
215
|
+
const errorSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
216
|
+
|
|
217
|
+
const customScreenSchema: FeatureSchema = {
|
|
218
|
+
featureName: "delivery",
|
|
219
|
+
entities: { task: taskEntity },
|
|
220
|
+
screens: [
|
|
221
|
+
{
|
|
222
|
+
id: "delivery-log",
|
|
223
|
+
type: "custom",
|
|
224
|
+
renderer: { react: { __component: "DeliveryLog" } },
|
|
225
|
+
},
|
|
226
|
+
],
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
mountRoot();
|
|
230
|
+
await mountApp({
|
|
231
|
+
schema: customScreenSchema,
|
|
232
|
+
dispatcher: makeDispatcher(),
|
|
233
|
+
screenQn: "delivery:screen:delivery-log",
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("delivery:delivery-log"));
|
|
237
|
+
|
|
238
|
+
errorSpy.mockRestore();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("custom screen WITH a registered clientFeatures component → no boot diagnostic", async () => {
|
|
242
|
+
const errorSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
243
|
+
|
|
244
|
+
function DeliveryLog(): ReactNode {
|
|
245
|
+
return <span data-testid="delivery-log-mounted" />;
|
|
246
|
+
}
|
|
247
|
+
const customScreenSchema: FeatureSchema = {
|
|
248
|
+
featureName: "delivery",
|
|
249
|
+
entities: { task: taskEntity },
|
|
250
|
+
screens: [
|
|
251
|
+
{
|
|
252
|
+
id: "delivery-log",
|
|
253
|
+
type: "custom",
|
|
254
|
+
renderer: { react: { __component: "DeliveryLog" } },
|
|
255
|
+
},
|
|
256
|
+
],
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
mountRoot();
|
|
260
|
+
await mountApp({
|
|
261
|
+
schema: customScreenSchema,
|
|
262
|
+
dispatcher: makeDispatcher(),
|
|
263
|
+
screenQn: "delivery:screen:delivery-log",
|
|
264
|
+
clientFeatures: [{ name: "delivery", components: { "delivery-log": DeliveryLog } }],
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
expect(await screen.findByTestId("delivery-log-mounted")).toBeTruthy();
|
|
268
|
+
expect(errorSpy).not.toHaveBeenCalledWith(expect.stringContaining("delivery:delivery-log"));
|
|
269
|
+
|
|
270
|
+
errorSpy.mockRestore();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("dormant custom screen without a registered clientFeatures component → no boot diagnostic (kumiko-framework#2034)", async () => {
|
|
274
|
+
const errorSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
275
|
+
|
|
276
|
+
const dormantScreenSchema: FeatureSchema = {
|
|
277
|
+
featureName: "user-data-rights",
|
|
278
|
+
entities: { task: taskEntity },
|
|
279
|
+
screens: [
|
|
280
|
+
{
|
|
281
|
+
id: "privacy-center",
|
|
282
|
+
type: "custom",
|
|
283
|
+
renderer: { react: { __component: "PrivacyCenterScreen" } },
|
|
284
|
+
access: { openToAll: true },
|
|
285
|
+
dormant: true,
|
|
286
|
+
},
|
|
287
|
+
],
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
mountRoot();
|
|
291
|
+
await mountApp({
|
|
292
|
+
schema: dormantScreenSchema,
|
|
293
|
+
dispatcher: makeDispatcher(),
|
|
294
|
+
screenQn: "user-data-rights:screen:privacy-center",
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
expect(errorSpy).not.toHaveBeenCalledWith(expect.stringContaining("privacy-center"));
|
|
298
|
+
|
|
299
|
+
errorSpy.mockRestore();
|
|
300
|
+
});
|
|
301
|
+
|
|
209
302
|
test("clientFeatures.columnRenderers → bei Key-Kollision warnt + last-wins gewinnt", async () => {
|
|
210
303
|
// Zwei Features liefern denselben Renderer-Key — der Merge in
|
|
211
304
|
// create-app warnt und behält den späteren Eintrag (Last-Wins).
|
|
@@ -1707,7 +1707,7 @@ describe("KumikoScreen", () => {
|
|
|
1707
1707
|
expect(navigateCalls).toEqual([]);
|
|
1708
1708
|
});
|
|
1709
1709
|
|
|
1710
|
-
test("custom screen type → placeholder
|
|
1710
|
+
test("custom screen type without a registered component → error placeholder naming feature + screen (kumiko-framework#2025)", () => {
|
|
1711
1711
|
const customSchema: FeatureSchema = {
|
|
1712
1712
|
featureName: "tasks",
|
|
1713
1713
|
entities: { task: taskEntity },
|
|
@@ -1724,7 +1724,11 @@ describe("KumikoScreen", () => {
|
|
|
1724
1724
|
<KumikoScreen schema={customSchema} qn="tasks:screen:dashboard" />
|
|
1725
1725
|
</DispatcherProvider>,
|
|
1726
1726
|
);
|
|
1727
|
-
|
|
1727
|
+
const placeholder = screen.getByTestId("kumiko-screen-custom-placeholder");
|
|
1728
|
+
expect(placeholder.getAttribute("data-variant")).toBe("error");
|
|
1729
|
+
expect(placeholder.textContent).toContain("dashboard");
|
|
1730
|
+
expect(placeholder.textContent).toContain("tasks");
|
|
1731
|
+
expect(placeholder.textContent).toContain("clientFeatures");
|
|
1728
1732
|
});
|
|
1729
1733
|
|
|
1730
1734
|
// ------------------------------------------------------------------
|
|
@@ -2015,6 +2019,79 @@ describe("KumikoScreen: singleton entityEdit", () => {
|
|
|
2015
2019
|
expect(bannerText).toBe(kumikoDefaultTranslations["en"]?.["errors.access.denied"] ?? "");
|
|
2016
2020
|
expect(screen.queryByTestId("render-edit-form")).toBeNull();
|
|
2017
2021
|
});
|
|
2022
|
+
|
|
2023
|
+
// kumiko-screen#1944: EntityEditSingletonBody runs without a wrapping
|
|
2024
|
+
// entityList screen, so the create-body's default "navigate back to the
|
|
2025
|
+
// list" success handler is a silent no-op — a successful create used to
|
|
2026
|
+
// leave the form stuck on the just-submitted create values, and a second
|
|
2027
|
+
// submit created a duplicate record ("exactly one record per tenant"
|
|
2028
|
+
// broken). Create on an empty table must now switch straight to the
|
|
2029
|
+
// update form of the newly created record.
|
|
2030
|
+
test("erfolgreicher Create bei leerer Tabelle wechselt in Update-Form des neuen Records (kein zweiter Create möglich)", async () => {
|
|
2031
|
+
let created = false;
|
|
2032
|
+
const write = mock(async (type: string) => {
|
|
2033
|
+
if (type === "tasks:write:task:create") {
|
|
2034
|
+
created = true;
|
|
2035
|
+
return { isSuccess: true, data: { id: "task-1" } };
|
|
2036
|
+
}
|
|
2037
|
+
return { isSuccess: true, data: {} };
|
|
2038
|
+
});
|
|
2039
|
+
const query = mock(async (type: string) => {
|
|
2040
|
+
if (type === "tasks:query:task:list") {
|
|
2041
|
+
return created
|
|
2042
|
+
? { isSuccess: true, data: { rows: [{ id: "task-1", title: "Created title" }] } }
|
|
2043
|
+
: { isSuccess: true, data: { rows: [], nextCursor: null } };
|
|
2044
|
+
}
|
|
2045
|
+
if (type === "tasks:query:task:detail") {
|
|
2046
|
+
return {
|
|
2047
|
+
isSuccess: true,
|
|
2048
|
+
data: { id: "task-1", version: 1, title: "Created title", count: 0, done: false },
|
|
2049
|
+
};
|
|
2050
|
+
}
|
|
2051
|
+
return { isSuccess: true, data: { rows: [], nextCursor: null } };
|
|
2052
|
+
});
|
|
2053
|
+
const dispatcher = makeDispatcher({
|
|
2054
|
+
write: write as unknown as Dispatcher["write"],
|
|
2055
|
+
query: query as unknown as Dispatcher["query"],
|
|
2056
|
+
});
|
|
2057
|
+
|
|
2058
|
+
render(
|
|
2059
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
2060
|
+
<KumikoScreen schema={singletonSchema} qn="tasks:screen:task-edit" />
|
|
2061
|
+
</DispatcherProvider>,
|
|
2062
|
+
);
|
|
2063
|
+
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
|
|
2064
|
+
|
|
2065
|
+
const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
|
|
2066
|
+
fireEvent.change(titleInput, { target: { value: "Created title" } });
|
|
2067
|
+
fireEvent.click(screen.getByTestId("render-edit-submit"));
|
|
2068
|
+
|
|
2069
|
+
await waitFor(() => expect(write).toHaveBeenCalledTimes(1));
|
|
2070
|
+
// The singleton wrapper refetches its list(limit:1) query after the
|
|
2071
|
+
// create succeeds, sees the new row, and switches from the create body
|
|
2072
|
+
// to the update body — which loads the record via its own detail
|
|
2073
|
+
// query, landing on the same title through a different data path.
|
|
2074
|
+
await waitFor(() => {
|
|
2075
|
+
const input = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
|
|
2076
|
+
expect(input.value).toBe("Created title");
|
|
2077
|
+
});
|
|
2078
|
+
expect(query).toHaveBeenCalledWith(
|
|
2079
|
+
"tasks:query:task:detail",
|
|
2080
|
+
{ id: "task-1" },
|
|
2081
|
+
expect.anything(),
|
|
2082
|
+
);
|
|
2083
|
+
|
|
2084
|
+
// A second submit on the (now update-mode) form must dispatch an
|
|
2085
|
+
// update, never a second create.
|
|
2086
|
+
fireEvent.change(screen.getByTestId("field-title").querySelector("input") as HTMLInputElement, {
|
|
2087
|
+
target: { value: "Edited again" },
|
|
2088
|
+
});
|
|
2089
|
+
fireEvent.click(screen.getByTestId("render-edit-submit"));
|
|
2090
|
+
// Exactly one create (the first submit) followed by an update, never a
|
|
2091
|
+
// second create — the singleton wrapper must have flipped branches.
|
|
2092
|
+
await waitFor(() => expect(write).toHaveBeenCalledTimes(2));
|
|
2093
|
+
expect(write).toHaveBeenLastCalledWith("tasks:write:task:update", expect.anything());
|
|
2094
|
+
});
|
|
2018
2095
|
});
|
|
2019
2096
|
|
|
2020
2097
|
// --- actionForm extension-section (Wave J: Incident-Update-Timeline) ---
|
|
@@ -5,8 +5,18 @@
|
|
|
5
5
|
// 2. Active-State greift auf node mit screen wenn nav.route's
|
|
6
6
|
// screenId matcht (Standard-Sidebar-Verhalten).
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
afterAll,
|
|
10
|
+
afterEach,
|
|
11
|
+
beforeEach,
|
|
12
|
+
describe,
|
|
13
|
+
expect,
|
|
14
|
+
type Mock,
|
|
15
|
+
spyOn,
|
|
16
|
+
test,
|
|
17
|
+
} from "bun:test";
|
|
9
18
|
import type {
|
|
19
|
+
NavIconKey,
|
|
10
20
|
TargetRef,
|
|
11
21
|
TreeChildrenSubscribe,
|
|
12
22
|
TreeNode,
|
|
@@ -169,6 +179,18 @@ function expectNavIcons(container: HTMLElement, iconKeys: readonly string[]): vo
|
|
|
169
179
|
}
|
|
170
180
|
|
|
171
181
|
describe("NavTree", () => {
|
|
182
|
+
let warnSpy: Mock<typeof console.warn>;
|
|
183
|
+
beforeEach(() => {
|
|
184
|
+
warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
185
|
+
// spyOn reuses the same underlying mock across calls on the same target
|
|
186
|
+
// within a process — without an explicit clear, call history from a
|
|
187
|
+
// PRIOR test in this describe block leaks into the next test's spy.
|
|
188
|
+
warnSpy.mockClear();
|
|
189
|
+
});
|
|
190
|
+
afterAll(() => {
|
|
191
|
+
warnSpy.mockRestore();
|
|
192
|
+
});
|
|
193
|
+
|
|
172
194
|
test("Section-Header (parent ohne screen) ist statisches Label, children sichtbar", () => {
|
|
173
195
|
render(<NavTree schema={makeSchema()} testId="tree" />);
|
|
174
196
|
|
|
@@ -318,15 +340,50 @@ describe("NavTree", () => {
|
|
|
318
340
|
expectNavIcons(container, ["palette", "link", "share"]);
|
|
319
341
|
});
|
|
320
342
|
|
|
321
|
-
test("unbekannter icon-Key fällt sauber auf den Dot zurück (kein svg)", () => {
|
|
343
|
+
test("unbekannter icon-Key fällt sauber auf den Dot zurück (kein svg), aber warnt sichtbar", () => {
|
|
322
344
|
const schema = {
|
|
323
345
|
featureName: "showcase",
|
|
324
346
|
entities: {},
|
|
325
347
|
screens: [{ id: "x", type: "entityList", entity: "x", columns: [] }],
|
|
326
|
-
navs: [
|
|
348
|
+
navs: [
|
|
349
|
+
{
|
|
350
|
+
id: "x",
|
|
351
|
+
label: "X",
|
|
352
|
+
screen: "x",
|
|
353
|
+
order: 10,
|
|
354
|
+
// Deliberately unregistered — asserts the runtime fallback, not the
|
|
355
|
+
// (now compile-time) NavIconKey vocabulary.
|
|
356
|
+
icon: "does-not-exist" as NavIconKey,
|
|
357
|
+
},
|
|
358
|
+
],
|
|
327
359
|
} as FeatureSchema;
|
|
328
360
|
const { container } = render(<NavTree schema={schema} />);
|
|
329
361
|
expect(container.querySelectorAll("svg").length).toBe(0);
|
|
362
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
363
|
+
expect.stringContaining('Nav entry "showcase:nav:x" references icon "does-not-exist"'),
|
|
364
|
+
);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("bekannter icon-Key warnt NICHT", () => {
|
|
368
|
+
const schema = {
|
|
369
|
+
featureName: "showcase",
|
|
370
|
+
entities: {},
|
|
371
|
+
screens: [{ id: "dash", type: "entityList", entity: "x", columns: [] }],
|
|
372
|
+
navs: [{ id: "dash", label: "Dash", screen: "dash", order: 10, icon: "dashboard" }],
|
|
373
|
+
} as FeatureSchema;
|
|
374
|
+
render(<NavTree schema={schema} />);
|
|
375
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("kein icon gesetzt warnt NICHT (Bundled Features ohne Icon sind kein Fehler)", () => {
|
|
379
|
+
const schema = {
|
|
380
|
+
featureName: "showcase",
|
|
381
|
+
entities: {},
|
|
382
|
+
screens: [{ id: "plain", type: "entityList", entity: "x", columns: [] }],
|
|
383
|
+
navs: [{ id: "plain", label: "Plain", screen: "plain", order: 10 }],
|
|
384
|
+
} as FeatureSchema;
|
|
385
|
+
render(<NavTree schema={schema} />);
|
|
386
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
330
387
|
});
|
|
331
388
|
});
|
|
332
389
|
|
|
@@ -2512,6 +2512,13 @@ describe("RenderEdit create-mode draftId (issue #1913)", () => {
|
|
|
2512
2512
|
// No silent adopt: the form stays pristine until the user picks.
|
|
2513
2513
|
const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
|
|
2514
2514
|
expect(titleInput.value).toBe("");
|
|
2515
|
+
// #1976: single-candidate is now the normal case (auto-adopt removed) —
|
|
2516
|
+
// the banner text and the "start new" button must both resolve to real
|
|
2517
|
+
// copy, not the raw i18n keys.
|
|
2518
|
+
const picker = screen.getByTestId("render-edit-draft-picker");
|
|
2519
|
+
expect(picker.textContent).toContain("Found an open draft for this form");
|
|
2520
|
+
expect(picker.textContent).not.toContain("kumiko.form.draft.resume-single");
|
|
2521
|
+
expect(screen.getByTestId("render-edit-draft-start-new").textContent).toBe("Start new");
|
|
2515
2522
|
});
|
|
2516
2523
|
|
|
2517
2524
|
test("start-new on a one-candidate picker clears it without adopting, leaving the candidate untouched", async () => {
|
|
@@ -2587,6 +2594,10 @@ describe("RenderEdit create-mode draftId (issue #1913)", () => {
|
|
|
2587
2594
|
);
|
|
2588
2595
|
|
|
2589
2596
|
await waitFor(() => expect(screen.getByTestId("render-edit-draft-picker")).toBeTruthy());
|
|
2597
|
+
// Multiple candidates keep the plural banner copy.
|
|
2598
|
+
expect(screen.getByTestId("render-edit-draft-picker").textContent).toContain(
|
|
2599
|
+
"Found multiple open drafts",
|
|
2600
|
+
);
|
|
2590
2601
|
const pickSecond = screen.getByTestId(`render-edit-draft-pick-${screenDef.id}:new:draft-2`);
|
|
2591
2602
|
fireEvent.click(pickSecond);
|
|
2592
2603
|
|
|
@@ -3197,4 +3208,37 @@ describe("RenderEdit fields filter", () => {
|
|
|
3197
3208
|
expect(write).not.toHaveBeenCalled();
|
|
3198
3209
|
expect(screen.getByTestId("field-notes-errors")).toBeTruthy();
|
|
3199
3210
|
});
|
|
3211
|
+
|
|
3212
|
+
// #1907: `fields` naming only unknown field names (typo, renamed field)
|
|
3213
|
+
// must not collapse the validation scope to an empty array — an empty
|
|
3214
|
+
// scope filters out ALL issues and lets submit through unvalidated.
|
|
3215
|
+
test("fields naming only unknown field names falls back to unscoped and still blocks submit", async () => {
|
|
3216
|
+
const write = mock(async () => ({ isSuccess: true, data: { id: "1" } }) as never);
|
|
3217
|
+
const schema = z.object({
|
|
3218
|
+
title: z.string().min(1),
|
|
3219
|
+
notes: z.string().min(1),
|
|
3220
|
+
});
|
|
3221
|
+
|
|
3222
|
+
render(
|
|
3223
|
+
<DispatcherProvider dispatcher={makeDispatcher(write)}>
|
|
3224
|
+
<RenderEdit<FilterValues>
|
|
3225
|
+
screen={makeTwoSectionScreen()}
|
|
3226
|
+
entity={filterEntity}
|
|
3227
|
+
featureName="orders"
|
|
3228
|
+
initial={{ title: "Acme", count: 0, notes: "" }}
|
|
3229
|
+
writeCommand="order:create"
|
|
3230
|
+
schema={schema}
|
|
3231
|
+
fields={["doesNotExist"]}
|
|
3232
|
+
/>
|
|
3233
|
+
</DispatcherProvider>,
|
|
3234
|
+
);
|
|
3235
|
+
|
|
3236
|
+
const form = screen.getByTestId("render-edit-form");
|
|
3237
|
+
await act(async () => {
|
|
3238
|
+
fireEvent.submit(form);
|
|
3239
|
+
await Promise.resolve();
|
|
3240
|
+
});
|
|
3241
|
+
|
|
3242
|
+
expect(write).not.toHaveBeenCalled();
|
|
3243
|
+
});
|
|
3200
3244
|
});
|
package/src/app/create-app.tsx
CHANGED
|
@@ -332,6 +332,35 @@ export function createKumikoApp(options: CreateKumikoAppOptions = {}): { readonl
|
|
|
332
332
|
for (const f of clientFeatures) {
|
|
333
333
|
if (f.components !== undefined) Object.assign(customScreens, f.components);
|
|
334
334
|
}
|
|
335
|
+
// Fail loud at boot instead of per-URL: a mounted server feature can
|
|
336
|
+
// declare a `type: "custom"` screen that's reachable directly by URL
|
|
337
|
+
// even without nav placement — without this check, a missing client
|
|
338
|
+
// plugin only surfaces as an error placeholder to whoever happens to
|
|
339
|
+
// open that URL (kumiko-framework#2025). Screens flagged `dormant`
|
|
340
|
+
// (e.g. user-data-rights privacy-center, auth-mfa's enable screen) are
|
|
341
|
+
// registered without a self-owned r.nav on purpose — an app opts in by
|
|
342
|
+
// navving them explicitly, so a consumer that hasn't done that yet isn't
|
|
343
|
+
// missing anything (kumiko-framework#2034). Still dev-only: several
|
|
344
|
+
// bundled screens that ARE self-navved (e.g. compliance-profiles'
|
|
345
|
+
// profile-picker) have real unmounted-client-plugin bugs in existing
|
|
346
|
+
// consumer apps today (kumiko-framework#2025) — running this in
|
|
347
|
+
// production before those are fixed (tracked via infra#503) would just
|
|
348
|
+
// spam every affected app's console.
|
|
349
|
+
if (typeof process !== "undefined" && process.env.NODE_ENV !== "production") {
|
|
350
|
+
const missingCustomScreens = app.features.flatMap((f) =>
|
|
351
|
+
f.screens
|
|
352
|
+
.filter(
|
|
353
|
+
(s) => s.type === "custom" && s.dormant !== true && customScreens[s.id] === undefined,
|
|
354
|
+
)
|
|
355
|
+
.map((s) => `${f.featureName}:${s.id}`),
|
|
356
|
+
);
|
|
357
|
+
if (missingCustomScreens.length > 0) {
|
|
358
|
+
// biome-ignore lint/suspicious/noConsole: dev-only diagnostic for missing client plugins
|
|
359
|
+
console.error(
|
|
360
|
+
`[kumiko] ${missingCustomScreens.length} custom screen(s) have no registered component in clientFeatures.components — they render an error placeholder instead of the intended UI: ${missingCustomScreens.join(", ")}. Add the feature's web client plugin to clientFeatures.`,
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
335
364
|
// Column-renderer map, same last-wins semantics as customScreens —
|
|
336
365
|
// duplicate keys across features are rarely intentional, so a
|
|
337
366
|
// collision logs once instead of silently overriding a library's
|
|
@@ -353,8 +382,8 @@ export function createKumikoApp(options: CreateKumikoAppOptions = {}): { readonl
|
|
|
353
382
|
app.features.flatMap((f) => f.contentCollections ?? []),
|
|
354
383
|
);
|
|
355
384
|
|
|
356
|
-
//
|
|
357
|
-
//
|
|
385
|
+
// Aggregate editor resolvers — keyed by "featureId:action". Same
|
|
386
|
+
// last-wins semantics as columnRenderers. Warns on collision.
|
|
358
387
|
const resolvers = new Map<string, ResolverComponent>();
|
|
359
388
|
for (const f of clientFeatures) {
|
|
360
389
|
if (f.resolvers === undefined) continue;
|
|
@@ -425,10 +454,10 @@ export function createKumikoApp(options: CreateKumikoAppOptions = {}): { readonl
|
|
|
425
454
|
return { root };
|
|
426
455
|
}
|
|
427
456
|
|
|
428
|
-
// TokensBoot
|
|
429
|
-
// dark-toggle)
|
|
430
|
-
//
|
|
431
|
-
// SSoT, useSyncExternalStore
|
|
457
|
+
// TokensBoot uses the browser-backed TokensApi hook (class-based
|
|
458
|
+
// dark-toggle) and passes the value through to the shared
|
|
459
|
+
// TokensProvider. No own state — the class on <html> is the
|
|
460
|
+
// SSoT, useSyncExternalStore in the hook syncs that into React.
|
|
432
461
|
function TokensBoot({ children }: { readonly children: ReactNode }): ReactNode {
|
|
433
462
|
const api = useBrowserTokensApi();
|
|
434
463
|
return <TokensProvider value={api}>{children}</TokensProvider>;
|
|
@@ -471,9 +500,9 @@ function BrowserNavBoot({
|
|
|
471
500
|
);
|
|
472
501
|
}
|
|
473
502
|
|
|
474
|
-
//
|
|
475
|
-
// Returns undefined
|
|
476
|
-
//
|
|
503
|
+
// Finds the feature that owns a fully qualified ScreenQn.
|
|
504
|
+
// Returns undefined if the screen isn't declared in any feature schema —
|
|
505
|
+
// KumikoScreen then renders the "Screen not found" banner.
|
|
477
506
|
function findOwnerFeature(app: AppSchema, qn: string): FeatureSchema | undefined {
|
|
478
507
|
for (const feature of app.features) {
|
|
479
508
|
for (const s of feature.screens) {
|
|
@@ -496,11 +525,11 @@ function RoutedScreen({
|
|
|
496
525
|
}): ReactNode {
|
|
497
526
|
const nav = useNav();
|
|
498
527
|
|
|
499
|
-
// ScreenId
|
|
500
|
-
//
|
|
501
|
-
//
|
|
502
|
-
//
|
|
503
|
-
//
|
|
528
|
+
// ScreenId from the route is NOT qualified — nav.route.screenId
|
|
529
|
+
// comes from the URL path and is the short form ("order-list"). We
|
|
530
|
+
// need to pin it to the right feature. Strategy: iterate through all
|
|
531
|
+
// features until the matching screen decl turns up. No match →
|
|
532
|
+
// fallback feature (the one from fallbackQn).
|
|
504
533
|
const { feature, qn, entityId } = useMemo(() => {
|
|
505
534
|
if (nav.route === undefined) {
|
|
506
535
|
return {
|
|
@@ -532,17 +561,17 @@ function RoutedScreen({
|
|
|
532
561
|
>(() => {
|
|
533
562
|
if (onRowClick !== undefined) return onRowClick;
|
|
534
563
|
return (row, entityName) => {
|
|
535
|
-
//
|
|
536
|
-
//
|
|
537
|
-
//
|
|
538
|
-
//
|
|
564
|
+
// Search for the edit screen for the entity across all features —
|
|
565
|
+
// in a single-feature setup that's the same feature as the active
|
|
566
|
+
// one, in multi-feature the edit could theoretically live in a
|
|
567
|
+
// different feature (one that shares the entity).
|
|
539
568
|
for (const f of app.features) {
|
|
540
569
|
const editScreen = f.screens.find(
|
|
541
570
|
(s) => s.type === "entityEdit" && s.entity === entityName,
|
|
542
571
|
);
|
|
543
572
|
if (editScreen) {
|
|
544
|
-
// editScreen.id
|
|
545
|
-
//
|
|
573
|
+
// editScreen.id is QN form (registry-stamped); nav.navigate
|
|
574
|
+
// expects the short form. Otherwise the URL gets double-qualified.
|
|
546
575
|
nav.navigate({ screenId: lastSegment(editScreen.id), entityId: row.id });
|
|
547
576
|
return;
|
|
548
577
|
}
|
|
@@ -550,12 +579,12 @@ function RoutedScreen({
|
|
|
550
579
|
};
|
|
551
580
|
}, [onRowClick, app.features, nav]);
|
|
552
581
|
|
|
553
|
-
// Copy-
|
|
554
|
-
// absolute
|
|
555
|
-
// `navigator`/`window`
|
|
556
|
-
// platform-
|
|
557
|
-
// Silent-catch
|
|
558
|
-
//
|
|
582
|
+
// Copy-link action (Issue #912) for entityEdit update screens. Builds
|
|
583
|
+
// the absolute permalink URL from the current route + copies it —
|
|
584
|
+
// `navigator`/`window` are allowed here (renderer-web, not a
|
|
585
|
+
// platform-neutral package). No button without entityId (create-mode).
|
|
586
|
+
// Silent-catch on clipboard error (non-secure context) mirrors the
|
|
587
|
+
// existing pattern in pat-tokens-screen.tsx.
|
|
559
588
|
const effectiveOnCopyLink = useMemo<(() => Promise<void> | void) | undefined>(() => {
|
|
560
589
|
const route = nav.route;
|
|
561
590
|
if (route?.entityId === undefined) return undefined;
|
package/src/layout/nav-tree.tsx
CHANGED
|
@@ -17,7 +17,7 @@ import type {
|
|
|
17
17
|
TreeAction,
|
|
18
18
|
TreeNode,
|
|
19
19
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
20
|
-
import type { NavDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
20
|
+
import type { NavDefinition, NavIconKey } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
21
21
|
import type { NavNode, NavRegistrySlice } from "@cosmicdrift/kumiko-headless";
|
|
22
22
|
import { resolveNavigation } from "@cosmicdrift/kumiko-headless";
|
|
23
23
|
import type { AppSchema, FeatureSchema } from "@cosmicdrift/kumiko-renderer";
|
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
Hash,
|
|
49
49
|
Home,
|
|
50
50
|
KeyRound,
|
|
51
|
+
Languages,
|
|
51
52
|
Layers,
|
|
52
53
|
LayoutDashboard,
|
|
53
54
|
LayoutGrid,
|
|
@@ -106,12 +107,18 @@ import {
|
|
|
106
107
|
import { useDispatchTarget } from "./target-resolver-stub";
|
|
107
108
|
import { parseTargetFromSearchParams } from "./target-url";
|
|
108
109
|
|
|
109
|
-
// Nav-
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
|
|
110
|
+
// Nav-icon registry: a nav entry sets `icon: "<key>"` (in the r.nav decl),
|
|
111
|
+
// the renderer maps the symbolic key to a lucide component. `NavIconKey` is
|
|
112
|
+
// the closed vocabulary a feature author can write (packages/types/src/
|
|
113
|
+
// nav-icon.ts); `satisfies` below makes this map a compile-time drift
|
|
114
|
+
// guard — a key added to one without the other fails the build.
|
|
115
|
+
//
|
|
116
|
+
// `node.icon`/`TreeAction.icon` stay plain `string` at the resolved-tree
|
|
117
|
+
// layer (dynamic/provider-supplied data isn't statically known), so the
|
|
118
|
+
// runtime `Object.hasOwn` lookups below still see an unknown key on
|
|
119
|
+
// occasion — that's the defense-in-depth fallback to the dot, not the
|
|
120
|
+
// primary guard anymore.
|
|
121
|
+
const NAV_ICONS = {
|
|
115
122
|
dashboard: LayoutDashboard,
|
|
116
123
|
"layout-grid": LayoutGrid,
|
|
117
124
|
"book-open": BookOpen,
|
|
@@ -158,7 +165,13 @@ const NAV_ICONS: Readonly<Record<string, typeof Folder>> = {
|
|
|
158
165
|
rocket: Rocket,
|
|
159
166
|
// Was imported but never registered — `icon: "plus"` silently fell back.
|
|
160
167
|
plus: Plus,
|
|
161
|
-
|
|
168
|
+
languages: Languages,
|
|
169
|
+
} as const satisfies Readonly<Record<NavIconKey, typeof Folder>>;
|
|
170
|
+
|
|
171
|
+
// Widened alias for the two lookup sites below, which index by the plain
|
|
172
|
+
// `string` icon key of the resolved NavNode/TreeAction tree — not the
|
|
173
|
+
// closed NavIconKey union NAV_ICONS itself is typed against.
|
|
174
|
+
const NAV_ICON_LOOKUP: Readonly<Record<string, typeof Folder | undefined>> = NAV_ICONS;
|
|
162
175
|
|
|
163
176
|
export type NavTreeProps = {
|
|
164
177
|
// Akzeptiert beide Shapes — AppSchema (multi-feature) oder
|
|
@@ -336,8 +349,17 @@ function NavLeadingIcon({
|
|
|
336
349
|
label?: string;
|
|
337
350
|
}): ReactNode {
|
|
338
351
|
const iconKey = expanded && node.icon === "folder" ? "folder-open" : node.icon;
|
|
339
|
-
const
|
|
340
|
-
|
|
352
|
+
const isKnownIcon = iconKey !== undefined && Object.hasOwn(NAV_ICONS, iconKey);
|
|
353
|
+
const NavIcon = iconKey !== undefined && isKnownIcon ? NAV_ICON_LOOKUP[iconKey] : undefined;
|
|
354
|
+
useEffect(() => {
|
|
355
|
+
if (iconKey !== undefined && !isKnownIcon) {
|
|
356
|
+
// biome-ignore lint/suspicious/noConsole: diagnostic for unregistered nav icon keys
|
|
357
|
+
console.warn(
|
|
358
|
+
`[kumiko] Nav entry "${node.qualifiedName}" references icon "${iconKey}", which is not ` +
|
|
359
|
+
`registered in NAV_ICONS — falling back to the dot indicator. Check the icon key spelling.`,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
}, [iconKey, isKnownIcon, node.qualifiedName]);
|
|
341
363
|
if (NavIcon !== undefined) return <NavIcon aria-hidden="true" className="shrink-0" />;
|
|
342
364
|
const initial = label?.trim().charAt(0).toUpperCase();
|
|
343
365
|
return (
|
|
@@ -526,10 +548,10 @@ function useNavNodeState(node: NavNode, collapsed: ReadonlySet<string>): NavNode
|
|
|
526
548
|
};
|
|
527
549
|
}
|
|
528
550
|
|
|
529
|
-
// Action
|
|
530
|
-
//
|
|
551
|
+
// Action icon lookup: registered NAV_ICONS key → Lucide icon, otherwise the
|
|
552
|
+
// raw string as text (provider convention, no boot-fail on an unknown key).
|
|
531
553
|
function ActionGlyph({ icon }: { readonly icon: string }): ReactNode {
|
|
532
|
-
const Icon = Object.hasOwn(NAV_ICONS, icon) ?
|
|
554
|
+
const Icon = Object.hasOwn(NAV_ICONS, icon) ? NAV_ICON_LOOKUP[icon] : undefined;
|
|
533
555
|
if (Icon !== undefined) return <Icon aria-hidden className="size-3.5" />;
|
|
534
556
|
return (
|
|
535
557
|
<span aria-hidden className="text-xs">
|
|
@@ -171,4 +171,16 @@ describe("formatDatePlaceholder", () => {
|
|
|
171
171
|
test("en-GB → day/month/year pattern with slash separator", () => {
|
|
172
172
|
expect(formatDatePlaceholder("en-GB", enPlaceholderLetters)).toBe("DD/MM/YYYY");
|
|
173
173
|
});
|
|
174
|
+
|
|
175
|
+
// #1879: a missing i18n key falls back to the raw, multi-character key
|
|
176
|
+
// string (see i18n.tsx) — the placeholder must clamp to one char per slot
|
|
177
|
+
// instead of repeating the whole fallback key.
|
|
178
|
+
test("multi-character letters (e.g. raw i18n fallback key) clamp to first code point", () => {
|
|
179
|
+
const rawKeyLetters = {
|
|
180
|
+
year: "kumiko.field.dateField.placeholderYear",
|
|
181
|
+
month: "kumiko.field.dateField.placeholderMonth",
|
|
182
|
+
day: "kumiko.field.dateField.placeholderDay",
|
|
183
|
+
};
|
|
184
|
+
expect(formatDatePlaceholder("de-DE", rawKeyLetters)).toBe("kk.kk.kkkk");
|
|
185
|
+
});
|
|
174
186
|
});
|
|
@@ -281,6 +281,32 @@ describe("EmbeddedListInput — issue rendering", () => {
|
|
|
281
281
|
).toBeTruthy();
|
|
282
282
|
});
|
|
283
283
|
|
|
284
|
+
// #1876: a long validation message in a narrow cell must wrap instead of
|
|
285
|
+
// widening the whole `min-w-max` desktop table into horizontal scroll.
|
|
286
|
+
test("cellIssues in the desktop table are width-constrained and wrap", () => {
|
|
287
|
+
const rows = [{ description: "A", quantity: 1, amount: 100 }];
|
|
288
|
+
renderWithLocale(
|
|
289
|
+
<EmbeddedListInput
|
|
290
|
+
{...baseProps({
|
|
291
|
+
rows,
|
|
292
|
+
cellIssues: {
|
|
293
|
+
"0.description": [
|
|
294
|
+
issue(
|
|
295
|
+
"lines.0.description",
|
|
296
|
+
"This is a deliberately long validation message that must wrap instead of widening the table",
|
|
297
|
+
),
|
|
298
|
+
],
|
|
299
|
+
},
|
|
300
|
+
})}
|
|
301
|
+
/>,
|
|
302
|
+
);
|
|
303
|
+
const desktop = within(screen.getByTestId("lines-desktop"));
|
|
304
|
+
const container = desktop.getByTestId("lines-cell-0-description-errors");
|
|
305
|
+
expect(container.className).toContain("max-w-[16rem]");
|
|
306
|
+
expect(container.className).toContain("whitespace-normal");
|
|
307
|
+
expect(container.className).toContain("break-words");
|
|
308
|
+
});
|
|
309
|
+
|
|
284
310
|
test("rowIssues render under the matching row", () => {
|
|
285
311
|
const rows = [{ description: "A", quantity: 1, amount: 100 }];
|
|
286
312
|
renderWithLocale(
|
|
@@ -105,15 +105,22 @@ function localeDateOrder(locale: string): readonly DateSlot[] {
|
|
|
105
105
|
// from formatToParts — nothing hardcoded per locale. `letters` is one
|
|
106
106
|
// character per slot (from i18n); repeated to the slot's digit count
|
|
107
107
|
// (day/month 2, year 4).
|
|
108
|
+
// Clamps each slot letter to its first code point — `letters` normally comes
|
|
109
|
+
// from i18n and can fall back to the raw, multi-character translation key
|
|
110
|
+
// when a lookup misses, which would otherwise blow up the placeholder length.
|
|
111
|
+
function firstCodePoint(s: string): string {
|
|
112
|
+
return [...s][0] ?? "?";
|
|
113
|
+
}
|
|
114
|
+
|
|
108
115
|
export function formatDatePlaceholder(
|
|
109
116
|
locale: string,
|
|
110
117
|
letters: { readonly year: string; readonly month: string; readonly day: string },
|
|
111
118
|
): string {
|
|
112
119
|
return localeDateParts(locale)
|
|
113
120
|
.map((part) => {
|
|
114
|
-
if (part.type === "year") return letters.year.repeat(4);
|
|
115
|
-
if (part.type === "month") return letters.month.repeat(2);
|
|
116
|
-
if (part.type === "day") return letters.day.repeat(2);
|
|
121
|
+
if (part.type === "year") return firstCodePoint(letters.year).repeat(4);
|
|
122
|
+
if (part.type === "month") return firstCodePoint(letters.month).repeat(2);
|
|
123
|
+
if (part.type === "day") return firstCodePoint(letters.day).repeat(2);
|
|
117
124
|
return part.value;
|
|
118
125
|
})
|
|
119
126
|
.join("");
|
|
@@ -121,14 +121,27 @@ function parsePasteGrid(text: string): readonly (readonly string[])[] {
|
|
|
121
121
|
function IssueMessages({
|
|
122
122
|
issues,
|
|
123
123
|
testId,
|
|
124
|
+
constrainWidth,
|
|
124
125
|
}: {
|
|
125
126
|
readonly issues: readonly FieldIssue[] | undefined;
|
|
126
127
|
readonly testId?: string;
|
|
128
|
+
// Table cells sit inside a `min-w-max` table (see EmbeddedListInput below):
|
|
129
|
+
// a long, unbounded message there widens the whole table instead of
|
|
130
|
+
// wrapping, forcing horizontal scroll. Only the desktop table-cell path
|
|
131
|
+
// needs this — mobile cards and full-width row/list issues wrap naturally.
|
|
132
|
+
readonly constrainWidth?: boolean;
|
|
127
133
|
}): ReactNode {
|
|
128
134
|
const t = useTranslation();
|
|
129
135
|
if (issues === undefined || issues.length === 0) return null;
|
|
130
136
|
return (
|
|
131
|
-
<div
|
|
137
|
+
<div
|
|
138
|
+
role="alert"
|
|
139
|
+
data-testid={testId}
|
|
140
|
+
className={cn(
|
|
141
|
+
"text-xs text-destructive",
|
|
142
|
+
constrainWidth === true && "max-w-[16rem] whitespace-normal break-words",
|
|
143
|
+
)}
|
|
144
|
+
>
|
|
132
145
|
{issues.map((issue) => (
|
|
133
146
|
<div key={`${issue.path}:${issue.code}`}>{t(issue.i18nKey, issue.params)}</div>
|
|
134
147
|
))}
|
|
@@ -554,6 +567,7 @@ export function EmbeddedListInput({
|
|
|
554
567
|
<IssueMessages
|
|
555
568
|
issues={issues}
|
|
556
569
|
testId={testIdFor(`cell-${rowIndex}-${column.field}-errors`)}
|
|
570
|
+
constrainWidth
|
|
557
571
|
/>
|
|
558
572
|
</TableCell>
|
|
559
573
|
);
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import type { LiveEvent } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
+
import { createEventSourceLiveEvents } from "../live-events";
|
|
4
|
+
|
|
5
|
+
// happy-dom doesn't provide EventSource, and this module only needs
|
|
6
|
+
// `typeof window !== "undefined"` to unlock — no real DOM required. Stub
|
|
7
|
+
// both globals directly instead of pulling in the project's DOM test config.
|
|
8
|
+
class FakeEventSource {
|
|
9
|
+
static instances: FakeEventSource[] = [];
|
|
10
|
+
private readonly listeners = new Map<string, Set<(e: MessageEvent) => void>>();
|
|
11
|
+
|
|
12
|
+
constructor(readonly url: string) {
|
|
13
|
+
FakeEventSource.instances.push(this);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
addEventListener(type: string, listener: (e: MessageEvent) => void): void {
|
|
17
|
+
let set = this.listeners.get(type);
|
|
18
|
+
if (!set) {
|
|
19
|
+
set = new Set();
|
|
20
|
+
this.listeners.set(type, set);
|
|
21
|
+
}
|
|
22
|
+
set.add(listener);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
close(): void {}
|
|
26
|
+
|
|
27
|
+
dispatch(entityName: string, data: unknown): void {
|
|
28
|
+
const event = { data: JSON.stringify(data) } as MessageEvent;
|
|
29
|
+
for (const listener of this.listeners.get(entityName) ?? []) listener(event);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
|
34
|
+
const originalEventSource = Object.getOwnPropertyDescriptor(globalThis, "EventSource");
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
FakeEventSource.instances.length = 0;
|
|
38
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only global stub
|
|
39
|
+
(globalThis as any).window = globalThis;
|
|
40
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only global stub
|
|
41
|
+
(globalThis as any).EventSource = FakeEventSource;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
if (originalWindow) Object.defineProperty(globalThis, "window", originalWindow);
|
|
46
|
+
else delete (globalThis as { window?: unknown }).window;
|
|
47
|
+
if (originalEventSource) Object.defineProperty(globalThis, "EventSource", originalEventSource);
|
|
48
|
+
else delete (globalThis as { EventSource?: unknown }).EventSource;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
function entityEvent(overrides: Partial<LiveEvent["data"]> = {}): LiveEvent["data"] {
|
|
52
|
+
return {
|
|
53
|
+
id: "e1",
|
|
54
|
+
aggregateType: "invoice",
|
|
55
|
+
version: 1,
|
|
56
|
+
payload: {},
|
|
57
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
58
|
+
...overrides,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe("createEventSourceLiveEvents", () => {
|
|
63
|
+
test("any verb — including business verbs and the auto-verb 'forgotten' — triggers the entity listener", () => {
|
|
64
|
+
const liveEvents = createEventSourceLiveEvents();
|
|
65
|
+
const received: LiveEvent[] = [];
|
|
66
|
+
const unsubscribe = liveEvents("invoice", (event) => received.push(event));
|
|
67
|
+
|
|
68
|
+
const source = FakeEventSource.instances.at(-1);
|
|
69
|
+
expect(source).toBeDefined();
|
|
70
|
+
// The server now names every frame after the entity, not the verb — a
|
|
71
|
+
// business verb like "archived" or the auto-verb "forgotten" never
|
|
72
|
+
// needed its own listener because no verb-specific listener exists.
|
|
73
|
+
source?.dispatch("invoice", entityEvent({ id: "archived-1" }));
|
|
74
|
+
source?.dispatch("invoice", entityEvent({ id: "forgotten-1" }));
|
|
75
|
+
|
|
76
|
+
expect(received).toHaveLength(2);
|
|
77
|
+
expect(received[0]?.type).toBe("invoice");
|
|
78
|
+
expect(received[1]?.data.id).toBe("forgotten-1");
|
|
79
|
+
|
|
80
|
+
unsubscribe();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("a subscriber for one entity does not receive another entity's frame", () => {
|
|
84
|
+
const liveEvents = createEventSourceLiveEvents();
|
|
85
|
+
const invoiceEvents: LiveEvent[] = [];
|
|
86
|
+
const userEvents: LiveEvent[] = [];
|
|
87
|
+
const unsubInvoice = liveEvents("invoice", (event) => invoiceEvents.push(event));
|
|
88
|
+
const unsubUser = liveEvents("user", (event) => userEvents.push(event));
|
|
89
|
+
|
|
90
|
+
const source = FakeEventSource.instances.at(-1);
|
|
91
|
+
source?.dispatch("invoice", entityEvent({ aggregateType: "invoice" }));
|
|
92
|
+
|
|
93
|
+
expect(invoiceEvents).toHaveLength(1);
|
|
94
|
+
expect(userEvents).toHaveLength(0);
|
|
95
|
+
|
|
96
|
+
unsubInvoice();
|
|
97
|
+
unsubUser();
|
|
98
|
+
});
|
|
99
|
+
});
|
package/src/sse/live-events.ts
CHANGED
|
@@ -3,15 +3,13 @@ import type { LiveEvent, LiveEventSubscriber } from "@cosmicdrift/kumiko-rendere
|
|
|
3
3
|
// EventSource-backed Live-Events für den Web-Renderer. Der shared
|
|
4
4
|
// Layer konsumiert nur das `LiveEventSubscriber`-Interface; diese Datei
|
|
5
5
|
// liefert eine Factory die intern eine EventSource auf /api/sse aufbaut,
|
|
6
|
-
// pro
|
|
7
|
-
// subscriptions routet.
|
|
6
|
+
// pro Entity EINEN addEventListener verdrahtet (Server benennt den Frame
|
|
7
|
+
// nach dem aggregateType, siehe sse-route.ts) und subscriptions routet.
|
|
8
8
|
//
|
|
9
9
|
// Verbindungs-Lifecycle: lazy beim ersten subscribe, close wenn der
|
|
10
10
|
// letzte unsubscribe feuert. Mehrere Consumer teilen sich dieselbe
|
|
11
11
|
// EventSource, sparen CPU + Server-Load.
|
|
12
12
|
|
|
13
|
-
const VERBS = ["created", "updated", "deleted", "restored"] as const;
|
|
14
|
-
|
|
15
13
|
type EntitySubscriber = {
|
|
16
14
|
readonly entityName: string;
|
|
17
15
|
readonly listener: (event: LiveEvent) => void;
|
|
@@ -34,18 +32,21 @@ export function createEventSourceLiveEvents(
|
|
|
34
32
|
|
|
35
33
|
const subscribers = new Set<EntitySubscriber>();
|
|
36
34
|
let source: EventSource | undefined;
|
|
37
|
-
const
|
|
35
|
+
const wiredEntities = new Set<string>();
|
|
38
36
|
|
|
39
|
-
|
|
37
|
+
// `subscribers` is a flat Set across all entities — the browser-side
|
|
38
|
+
// addEventListener(entityName, ...) gate below only filters which frames
|
|
39
|
+
// arrive at all, not which subscriber a given frame is for. Without this
|
|
40
|
+
// filter, an `invoice` subscriber would also fire on every `user` frame.
|
|
41
|
+
const handleEvent = (raw: string): void => {
|
|
40
42
|
let parsed: LiveEvent["data"];
|
|
41
43
|
try {
|
|
42
44
|
parsed = JSON.parse(raw) as LiveEvent["data"];
|
|
43
45
|
} catch {
|
|
44
46
|
// skip: malformed SSE payload, drop it rather than crash all subscribers
|
|
45
|
-
// zu crashen und alle anderen subscribers mitzureißen.
|
|
46
47
|
return;
|
|
47
48
|
}
|
|
48
|
-
const event: LiveEvent = { type, data: parsed };
|
|
49
|
+
const event: LiveEvent = { type: parsed.aggregateType, data: parsed };
|
|
49
50
|
for (const sub of subscribers) {
|
|
50
51
|
if (sub.entityName === parsed.aggregateType) sub.listener(event);
|
|
51
52
|
}
|
|
@@ -62,14 +63,12 @@ export function createEventSourceLiveEvents(
|
|
|
62
63
|
const ensureListenersForEntity = (entityName: string): void => {
|
|
63
64
|
// skip: not connected yet, listeners get wired once ensureConnected runs
|
|
64
65
|
if (source === undefined) return;
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
wiredTypes.add(type);
|
|
72
|
-
}
|
|
66
|
+
// skip: already wired for this entity
|
|
67
|
+
if (wiredEntities.has(entityName)) return;
|
|
68
|
+
source.addEventListener(entityName, (e) => {
|
|
69
|
+
handleEvent((e as MessageEvent).data);
|
|
70
|
+
});
|
|
71
|
+
wiredEntities.add(entityName);
|
|
73
72
|
};
|
|
74
73
|
|
|
75
74
|
const closeIfEmpty = (): void => {
|
|
@@ -79,7 +78,7 @@ export function createEventSourceLiveEvents(
|
|
|
79
78
|
if (source === undefined) return;
|
|
80
79
|
source.close();
|
|
81
80
|
source = undefined;
|
|
82
|
-
|
|
81
|
+
wiredEntities.clear();
|
|
83
82
|
};
|
|
84
83
|
|
|
85
84
|
return (entityName, listener) => {
|