@cosmicdrift/kumiko-renderer-web 0.31.0 → 0.32.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 +1 -1
- package/src/__tests__/config-cascade.test.tsx +157 -0
- package/src/__tests__/kumiko-screen.test.tsx +221 -3
- package/src/__tests__/nav-workspace-preserve.test.tsx +167 -0
- package/src/__tests__/tokens-theme-persist.test.tsx +79 -0
- package/src/app/nav.tsx +20 -9
- package/src/components/config-cascade.tsx +93 -21
- package/src/components/config-source-badge.tsx +11 -9
- package/src/primitives/dialog.tsx +4 -1
- package/src/primitives/index.tsx +14 -0
- package/src/tokens.ts +49 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.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>",
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// ConfigCascadeView — Regression zu den Prod-UX-Bugs 2026-06-07
|
|
2
|
+
// (publicstatus Bugs 7+8): (7) Source-Badges zeigten ROHE i18n-Keys
|
|
3
|
+
// ("config.source.default"), weil kein Bundle die Keys kannte; (8) ein
|
|
4
|
+
// Tenant-Admin sah ALLE Cascade-Ebenen (System/App-Override/Computed)
|
|
5
|
+
// obwohl er nur die Tenant-Ebene beeinflussen kann. Jetzt: Keys leben
|
|
6
|
+
// als kumiko.config.* in kumikoDefaultTranslations, und Nicht-System-
|
|
7
|
+
// Screens kollabieren alles oberhalb des Screen-Scopes zu EINER
|
|
8
|
+
// neutralen "Preset"-Zeile.
|
|
9
|
+
|
|
10
|
+
import { describe, expect, test } from "bun:test";
|
|
11
|
+
import type { ConfigCascade, ConfigCascadeLevel } from "@cosmicdrift/kumiko-framework/engine";
|
|
12
|
+
import userEvent from "@testing-library/user-event";
|
|
13
|
+
import { ConfigCascadeView } from "../components/config-cascade";
|
|
14
|
+
import { render, screen } from "./test-utils";
|
|
15
|
+
|
|
16
|
+
function level(overrides: Partial<ConfigCascadeLevel> & { source: ConfigCascadeLevel["source"] }) {
|
|
17
|
+
return {
|
|
18
|
+
label: overrides.source,
|
|
19
|
+
value: undefined,
|
|
20
|
+
isActive: false,
|
|
21
|
+
hasValue: false,
|
|
22
|
+
...overrides,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Tenant-Scope-Key wie ihn buildCascade liefert: tenant-row + alle
|
|
27
|
+
// Operator-Ebenen + default.
|
|
28
|
+
function tenantCascade(overrides?: {
|
|
29
|
+
tenantValue?: string;
|
|
30
|
+
systemActive?: boolean;
|
|
31
|
+
}): ConfigCascade {
|
|
32
|
+
const tenantHasValue = overrides?.tenantValue !== undefined;
|
|
33
|
+
const systemActive = overrides?.systemActive === true;
|
|
34
|
+
return {
|
|
35
|
+
value: overrides?.tenantValue ?? (systemActive ? "system-smtp" : "fallback"),
|
|
36
|
+
source: tenantHasValue ? "tenant-row" : systemActive ? "system-row" : "default",
|
|
37
|
+
levels: [
|
|
38
|
+
level({
|
|
39
|
+
source: "tenant-row",
|
|
40
|
+
value: overrides?.tenantValue,
|
|
41
|
+
hasValue: tenantHasValue,
|
|
42
|
+
isActive: tenantHasValue,
|
|
43
|
+
}),
|
|
44
|
+
level({
|
|
45
|
+
source: "system-row",
|
|
46
|
+
value: systemActive ? "system-smtp" : undefined,
|
|
47
|
+
hasValue: systemActive,
|
|
48
|
+
isActive: !tenantHasValue && systemActive,
|
|
49
|
+
}),
|
|
50
|
+
level({ source: "app-override" }),
|
|
51
|
+
level({ source: "computed" }),
|
|
52
|
+
level({
|
|
53
|
+
source: "default",
|
|
54
|
+
value: "fallback",
|
|
55
|
+
hasValue: true,
|
|
56
|
+
isActive: !tenantHasValue && !systemActive,
|
|
57
|
+
}),
|
|
58
|
+
],
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe("ConfigCascadeView — i18n (Bug 7)", () => {
|
|
63
|
+
test("Source-Badges zeigen übersetzte Labels, keine rohen Keys", async () => {
|
|
64
|
+
const user = userEvent.setup();
|
|
65
|
+
const view = render(
|
|
66
|
+
<ConfigCascadeView cascade={tenantCascade({ tenantValue: "acme" })} screenScope="tenant" />,
|
|
67
|
+
);
|
|
68
|
+
// Collapsed-Header: aktive Ebene = Tenant.
|
|
69
|
+
expect(view.container.textContent).toContain("Tenant");
|
|
70
|
+
expect(view.container.textContent).not.toContain("config.source");
|
|
71
|
+
|
|
72
|
+
await user.click(screen.getByRole("button"));
|
|
73
|
+
expect(view.container.textContent).not.toContain("config.source");
|
|
74
|
+
expect(view.container.textContent).not.toContain("config.cascade");
|
|
75
|
+
// activeMarker übersetzt.
|
|
76
|
+
expect(view.container.textContent).toContain("active");
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("ConfigCascadeView — Scope-Filter (Bug 8)", () => {
|
|
81
|
+
test("screenScope=tenant: Operator-Ebenen sind unsichtbar, EIN Preset-Fallback bleibt", async () => {
|
|
82
|
+
const user = userEvent.setup();
|
|
83
|
+
const view = render(<ConfigCascadeView cascade={tenantCascade()} screenScope="tenant" />);
|
|
84
|
+
await user.click(screen.getByRole("button"));
|
|
85
|
+
|
|
86
|
+
// Sichtbar: Tenant-Zeile + genau eine neutrale Preset-Zeile.
|
|
87
|
+
expect(view.container.textContent).toContain("Tenant");
|
|
88
|
+
expect(view.container.textContent).toContain("Preset");
|
|
89
|
+
// Unsichtbar: alles was nur der Operator steuert.
|
|
90
|
+
expect(view.container.textContent).not.toContain("System");
|
|
91
|
+
expect(view.container.textContent).not.toContain("App override");
|
|
92
|
+
expect(view.container.textContent).not.toContain("Computed");
|
|
93
|
+
// Der deklarierte Default erscheint als Preset-Wert, nicht als
|
|
94
|
+
// eigene "Default"-Ebene.
|
|
95
|
+
expect(view.container.textContent).toContain("fallback");
|
|
96
|
+
expect(view.container.textContent).not.toContain("Default");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("screenScope=tenant mit aktivem System-Wert: Preset zeigt den effektiven Wert, nicht die Quelle", async () => {
|
|
100
|
+
const user = userEvent.setup();
|
|
101
|
+
const view = render(
|
|
102
|
+
<ConfigCascadeView cascade={tenantCascade({ systemActive: true })} screenScope="tenant" />,
|
|
103
|
+
);
|
|
104
|
+
// Collapsed-Header leakt die Operator-Quelle nicht …
|
|
105
|
+
expect(view.container.textContent).toContain("Preset");
|
|
106
|
+
expect(view.container.textContent).toContain("system-smtp");
|
|
107
|
+
expect(view.container.textContent).not.toContain("System");
|
|
108
|
+
|
|
109
|
+
// … und expanded genauso: effektiver Wert sichtbar, Quelle neutral.
|
|
110
|
+
await user.click(screen.getAllByRole("button")[0] as HTMLElement);
|
|
111
|
+
expect(view.container.textContent).toContain("system-smtp");
|
|
112
|
+
expect(view.container.textContent).not.toContain("System");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("screenScope=system: Operator sieht weiterhin die volle Cascade", async () => {
|
|
116
|
+
const user = userEvent.setup();
|
|
117
|
+
const view = render(
|
|
118
|
+
<ConfigCascadeView cascade={tenantCascade({ systemActive: true })} screenScope="system" />,
|
|
119
|
+
);
|
|
120
|
+
await user.click(screen.getByRole("button"));
|
|
121
|
+
expect(view.container.textContent).toContain("System");
|
|
122
|
+
expect(view.container.textContent).toContain("App override");
|
|
123
|
+
expect(view.container.textContent).toContain("Computed");
|
|
124
|
+
expect(view.container.textContent).toContain("Default");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("Reset-Button erscheint nur bei eigener Überschreibung und nennt den Scope übersetzt", async () => {
|
|
128
|
+
const user = userEvent.setup();
|
|
129
|
+
const resets: { key: string; scope: string }[] = [];
|
|
130
|
+
render(
|
|
131
|
+
<ConfigCascadeView
|
|
132
|
+
cascade={tenantCascade({ tenantValue: "acme" })}
|
|
133
|
+
screenScope="tenant"
|
|
134
|
+
qualifiedKey="branding.title"
|
|
135
|
+
onReset={(key, scope) => resets.push({ key, scope })}
|
|
136
|
+
/>,
|
|
137
|
+
);
|
|
138
|
+
await user.click(screen.getByRole("button"));
|
|
139
|
+
const reset = screen.getByText("Reset override (Tenant)");
|
|
140
|
+
await user.click(reset);
|
|
141
|
+
expect(resets).toEqual([{ key: "branding.title", scope: "tenant" }]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("ohne eigene Überschreibung: kein Reset-Button", async () => {
|
|
145
|
+
const user = userEvent.setup();
|
|
146
|
+
const view = render(
|
|
147
|
+
<ConfigCascadeView
|
|
148
|
+
cascade={tenantCascade()}
|
|
149
|
+
screenScope="tenant"
|
|
150
|
+
qualifiedKey="branding.title"
|
|
151
|
+
onReset={() => undefined}
|
|
152
|
+
/>,
|
|
153
|
+
);
|
|
154
|
+
await user.click(screen.getByRole("button"));
|
|
155
|
+
expect(view.queryByText("Reset override (Tenant)")).toBeNull();
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -348,18 +348,95 @@ describe("KumikoScreen", () => {
|
|
|
348
348
|
});
|
|
349
349
|
});
|
|
350
350
|
|
|
351
|
+
// Regression zum Prod-Bug 2026-06-07 (Bug 4): dispatcher.write wirft
|
|
352
|
+
// nicht — ein Failure-Result wurde verworfen, der Confirm-Dialog
|
|
353
|
+
// schloss kommentarlos und der User sah "nichts passiert". Jetzt:
|
|
354
|
+
// Dialog schließt UND ein destructive-Toast zeigt den Fehlertext.
|
|
355
|
+
test("entityList rowActions writeHandler: fehlgeschlagener Write → Fehler-Toast statt stillem Dialog-Close", async () => {
|
|
356
|
+
const dispatcher = makeDispatcher({
|
|
357
|
+
query: (async () => ({
|
|
358
|
+
isSuccess: true,
|
|
359
|
+
data: {
|
|
360
|
+
rows: [{ id: "r1", title: "Alpha", count: 1, done: false }],
|
|
361
|
+
nextCursor: null,
|
|
362
|
+
},
|
|
363
|
+
})) as unknown as Dispatcher["query"],
|
|
364
|
+
write: (async () => ({
|
|
365
|
+
isSuccess: false,
|
|
366
|
+
error: {
|
|
367
|
+
code: "conflict",
|
|
368
|
+
httpStatus: 409,
|
|
369
|
+
// Key absichtlich in keinem Bundle — dispatcherErrorText muss
|
|
370
|
+
// auf error.message zurückfallen.
|
|
371
|
+
i18nKey: "tasks.errors.delete-conflict",
|
|
372
|
+
message: "Version conflict for entity r1",
|
|
373
|
+
},
|
|
374
|
+
})) as unknown as Dispatcher["write"],
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
const screenWithDelete: EntityListScreenDefinition = {
|
|
378
|
+
id: "task-list",
|
|
379
|
+
type: "entityList",
|
|
380
|
+
entity: "task",
|
|
381
|
+
columns: ["title"],
|
|
382
|
+
rowActions: [
|
|
383
|
+
{
|
|
384
|
+
id: "delete",
|
|
385
|
+
label: "actions.delete",
|
|
386
|
+
handler: "tasks:write:task:delete",
|
|
387
|
+
confirm: "actions.delete-confirm",
|
|
388
|
+
style: "danger",
|
|
389
|
+
},
|
|
390
|
+
],
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
const { ToastProvider } = await import("../primitives/toast");
|
|
394
|
+
const user = userEvent.setup();
|
|
395
|
+
render(
|
|
396
|
+
<ToastProvider>
|
|
397
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
398
|
+
<KumikoScreen
|
|
399
|
+
schema={{ ...schema, screens: [screenWithDelete] }}
|
|
400
|
+
qn="tasks:screen:task-list"
|
|
401
|
+
/>
|
|
402
|
+
</DispatcherProvider>
|
|
403
|
+
</ToastProvider>,
|
|
404
|
+
);
|
|
405
|
+
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
|
|
406
|
+
|
|
407
|
+
// danger erzwingt den Confirm-Dialog.
|
|
408
|
+
await user.click(screen.getByTestId("row-r1-action-delete"));
|
|
409
|
+
expect(await screen.findByTestId("row-r1-action-delete-dialog")).toBeTruthy();
|
|
410
|
+
|
|
411
|
+
await user.click(screen.getByTestId("row-r1-action-delete-dialog-confirm"));
|
|
412
|
+
|
|
413
|
+
// Dialog schließt — aber NICHT kommentarlos: der Fehlertext ist da.
|
|
414
|
+
await waitFor(() => expect(screen.queryByTestId("row-r1-action-delete-dialog")).toBeNull());
|
|
415
|
+
expect(await screen.findByText("Version conflict for entity r1")).toBeTruthy();
|
|
416
|
+
});
|
|
417
|
+
|
|
351
418
|
// Tier 2.7e-1: rowAction kind="navigate" — Click ruft nav.navigate
|
|
352
419
|
// mit screen-id, ggf. mit URL-Search-Params aus params(row).
|
|
353
|
-
|
|
420
|
+
// Reihenfolge ist Teil des Contracts: navigate ZUERST, dann
|
|
421
|
+
// setSearchParams — pushState trägt keine Query, vorher gesetzte
|
|
422
|
+
// Params kleben sonst an der alten URL (actionForm-Prefill leer).
|
|
423
|
+
test("entityList rowActions kind=navigate: Click → nav.navigate, DANN setSearchParams", async () => {
|
|
424
|
+
const calls: { kind: "navigate" | "setSearchParams"; value: unknown }[] = [];
|
|
354
425
|
const navigateCalls: { screenId: string }[] = [];
|
|
355
426
|
const searchParamUpdates: Record<string, string | null>[] = [];
|
|
356
427
|
const memoryNav = {
|
|
357
428
|
route: { screenId: "task-list" },
|
|
358
|
-
navigate: (target: { screenId: string }) =>
|
|
429
|
+
navigate: (target: { screenId: string }) => {
|
|
430
|
+
calls.push({ kind: "navigate", value: target });
|
|
431
|
+
navigateCalls.push(target);
|
|
432
|
+
},
|
|
359
433
|
replace: () => undefined,
|
|
360
434
|
hrefFor: (t: { screenId: string }) => `/${t.screenId}`,
|
|
361
435
|
searchParams: {},
|
|
362
|
-
setSearchParams: (u: Record<string, string | null>) =>
|
|
436
|
+
setSearchParams: (u: Record<string, string | null>) => {
|
|
437
|
+
calls.push({ kind: "setSearchParams", value: u });
|
|
438
|
+
searchParamUpdates.push(u);
|
|
439
|
+
},
|
|
363
440
|
};
|
|
364
441
|
const dispatcher = makeDispatcher({
|
|
365
442
|
query: (async () => ({
|
|
@@ -406,6 +483,68 @@ describe("KumikoScreen", () => {
|
|
|
406
483
|
expect(navigateCalls[0]).toEqual({ screenId: "task-edit" });
|
|
407
484
|
// params werden zu Strings serialisiert (URL-Layer kennt nur Strings).
|
|
408
485
|
expect(searchParamUpdates).toEqual([{ taskId: "r1", priority: "5" }]);
|
|
486
|
+
// Reihenfolge-Pin: erst navigate, dann setSearchParams.
|
|
487
|
+
expect(calls.map((c) => c.kind)).toEqual(["navigate", "setSearchParams"]);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
// entityId-Variante: entityEdit-Targets brauchen die Id als PFAD-
|
|
491
|
+
// Segment (route.entityId), nicht als Search-Param — sonst öffnet
|
|
492
|
+
// der Edit-Screen im Create-Mode (Prod-Bug 2026-06-07, Bug 3).
|
|
493
|
+
test("entityList rowActions kind=navigate mit entityId: Id landet im NavTarget, nicht in den Search-Params", async () => {
|
|
494
|
+
const navigateCalls: { screenId: string; entityId?: string }[] = [];
|
|
495
|
+
const searchParamUpdates: Record<string, string | null>[] = [];
|
|
496
|
+
const memoryNav = {
|
|
497
|
+
route: { screenId: "task-list" },
|
|
498
|
+
navigate: (target: { screenId: string; entityId?: string }) => navigateCalls.push(target),
|
|
499
|
+
replace: () => undefined,
|
|
500
|
+
hrefFor: (t: { screenId: string }) => `/${t.screenId}`,
|
|
501
|
+
searchParams: {},
|
|
502
|
+
setSearchParams: (u: Record<string, string | null>) => searchParamUpdates.push(u),
|
|
503
|
+
};
|
|
504
|
+
const dispatcher = makeDispatcher({
|
|
505
|
+
query: (async () => ({
|
|
506
|
+
isSuccess: true,
|
|
507
|
+
data: {
|
|
508
|
+
rows: [{ id: "r1", title: "Alpha", count: 1, done: false }],
|
|
509
|
+
nextCursor: null,
|
|
510
|
+
},
|
|
511
|
+
})) as unknown as Dispatcher["query"],
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
const screenWithEdit: EntityListScreenDefinition = {
|
|
515
|
+
id: "task-list",
|
|
516
|
+
type: "entityList",
|
|
517
|
+
entity: "task",
|
|
518
|
+
columns: ["title"],
|
|
519
|
+
rowActions: [
|
|
520
|
+
{
|
|
521
|
+
kind: "navigate",
|
|
522
|
+
id: "edit",
|
|
523
|
+
label: "actions.edit",
|
|
524
|
+
screen: "task-edit",
|
|
525
|
+
entityId: (row) => String(row["id"] ?? ""),
|
|
526
|
+
},
|
|
527
|
+
],
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
const { NavProvider } = await import("@cosmicdrift/kumiko-renderer");
|
|
531
|
+
const user = userEvent.setup();
|
|
532
|
+
render(
|
|
533
|
+
<NavProvider value={memoryNav}>
|
|
534
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
535
|
+
<KumikoScreen
|
|
536
|
+
schema={{ ...schema, screens: [screenWithEdit] }}
|
|
537
|
+
qn="tasks:screen:task-list"
|
|
538
|
+
/>
|
|
539
|
+
</DispatcherProvider>
|
|
540
|
+
</NavProvider>,
|
|
541
|
+
);
|
|
542
|
+
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
|
|
543
|
+
|
|
544
|
+
await user.click(screen.getByTestId("row-r1-action-edit"));
|
|
545
|
+
await waitFor(() => expect(navigateCalls.length).toBe(1));
|
|
546
|
+
expect(navigateCalls[0]).toEqual({ screenId: "task-edit", entityId: "r1" });
|
|
547
|
+
expect(searchParamUpdates).toEqual([]);
|
|
409
548
|
});
|
|
410
549
|
|
|
411
550
|
test("entityList rowActions kind=navigate ohne params: setSearchParams wird NICHT gerufen", async () => {
|
|
@@ -746,6 +885,85 @@ describe("KumikoScreen", () => {
|
|
|
746
885
|
expect(navigateCalls[0]).toEqual({ screenId: "task-list" });
|
|
747
886
|
});
|
|
748
887
|
|
|
888
|
+
// cancelTarget (Bug-Bash 2026-06-07, Bug 9): redirect erzeugte
|
|
889
|
+
// automatisch einen Abbrechen-Button mit demselben Ziel wie der
|
|
890
|
+
// Submit-Redirect — auf Single-Action-Screens ("Test-Mail senden")
|
|
891
|
+
// semantisch leer. `cancelTarget: false` schaltet ihn ab,
|
|
892
|
+
// `cancelTarget: "<screen>"` entkoppelt ihn vom redirect.
|
|
893
|
+
test("actionForm mit redirect: Abbrechen-Button existiert (historisches Default-Verhalten)", () => {
|
|
894
|
+
const actionScreen: ActionFormScreenDefinition = {
|
|
895
|
+
id: "quick-add",
|
|
896
|
+
type: "actionForm",
|
|
897
|
+
handler: "tasks:write:task:quick-add",
|
|
898
|
+
fields: { title: { type: "text", required: true } },
|
|
899
|
+
layout: { sections: [{ title: "x", fields: ["title"] }] },
|
|
900
|
+
redirect: "task-list",
|
|
901
|
+
};
|
|
902
|
+
render(
|
|
903
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
904
|
+
<KumikoScreen
|
|
905
|
+
schema={{ ...schema, screens: [actionScreen, listScreen] }}
|
|
906
|
+
qn="tasks:screen:quick-add"
|
|
907
|
+
/>
|
|
908
|
+
</DispatcherProvider>,
|
|
909
|
+
);
|
|
910
|
+
expect(screen.getByTestId("render-edit-cancel")).toBeTruthy();
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
test("actionForm mit cancelTarget=false: KEIN Abbrechen-Button trotz redirect", () => {
|
|
914
|
+
const actionScreen: ActionFormScreenDefinition = {
|
|
915
|
+
id: "quick-add",
|
|
916
|
+
type: "actionForm",
|
|
917
|
+
handler: "tasks:write:task:quick-add",
|
|
918
|
+
fields: { title: { type: "text", required: true } },
|
|
919
|
+
layout: { sections: [{ title: "x", fields: ["title"] }] },
|
|
920
|
+
redirect: "task-list",
|
|
921
|
+
cancelTarget: false,
|
|
922
|
+
};
|
|
923
|
+
render(
|
|
924
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
925
|
+
<KumikoScreen
|
|
926
|
+
schema={{ ...schema, screens: [actionScreen, listScreen] }}
|
|
927
|
+
qn="tasks:screen:quick-add"
|
|
928
|
+
/>
|
|
929
|
+
</DispatcherProvider>,
|
|
930
|
+
);
|
|
931
|
+
expect(screen.queryByTestId("render-edit-cancel")).toBeNull();
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
test("actionForm mit cancelTarget-Screen: Abbrechen navigiert dorthin, auch ohne redirect", async () => {
|
|
935
|
+
const navigateCalls: { screenId: string }[] = [];
|
|
936
|
+
const memoryNav = {
|
|
937
|
+
route: { screenId: "quick-add" },
|
|
938
|
+
navigate: (target: { screenId: string }) => navigateCalls.push(target),
|
|
939
|
+
replace: () => undefined,
|
|
940
|
+
hrefFor: (t: { screenId: string }) => `/${t.screenId}`,
|
|
941
|
+
searchParams: {},
|
|
942
|
+
setSearchParams: () => undefined,
|
|
943
|
+
};
|
|
944
|
+
const actionScreen: ActionFormScreenDefinition = {
|
|
945
|
+
id: "quick-add",
|
|
946
|
+
type: "actionForm",
|
|
947
|
+
handler: "tasks:write:task:quick-add",
|
|
948
|
+
fields: { title: { type: "text", required: true } },
|
|
949
|
+
layout: { sections: [{ title: "x", fields: ["title"] }] },
|
|
950
|
+
cancelTarget: "task-list",
|
|
951
|
+
};
|
|
952
|
+
const { NavProvider } = await import("@cosmicdrift/kumiko-renderer");
|
|
953
|
+
render(
|
|
954
|
+
<NavProvider value={memoryNav}>
|
|
955
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
956
|
+
<KumikoScreen
|
|
957
|
+
schema={{ ...schema, screens: [actionScreen, listScreen] }}
|
|
958
|
+
qn="tasks:screen:quick-add"
|
|
959
|
+
/>
|
|
960
|
+
</DispatcherProvider>
|
|
961
|
+
</NavProvider>,
|
|
962
|
+
);
|
|
963
|
+
fireEvent.click(screen.getByTestId("render-edit-cancel"));
|
|
964
|
+
expect(navigateCalls).toEqual([{ screenId: "task-list" }]);
|
|
965
|
+
});
|
|
966
|
+
|
|
749
967
|
// Tier 2.7e-2: URL-Search-Params füllen die actionForm initial values.
|
|
750
968
|
// Use-case: rowAction kind=navigate setzt `?taskId=r1`, das actionForm
|
|
751
969
|
// sieht es beim Mount und pre-fillt das title-Feld.
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
//
|
|
2
|
+
// useBrowserNavApi({ hasWorkspaces }) — NavTarget-Contract: workspaceId
|
|
3
|
+
// weglassen = aktueller Workspace bleibt. Regression zum Prod-Bug
|
|
4
|
+
// 2026-06-07 (publicstatus Bugs 3/5): navigate({ screenId }) aus einem
|
|
5
|
+
// Workspace heraus erzeugte `/<screenId>`, parsePath las das Screen-
|
|
6
|
+
// Segment als Workspace-Id, WorkspaceShell revertete sofort auf den
|
|
7
|
+
// Default-Screen — Edit-/Toolbar-Aktionen wirkten tot.
|
|
8
|
+
//
|
|
9
|
+
|
|
10
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
11
|
+
import { NavProvider, useNav } from "@cosmicdrift/kumiko-renderer";
|
|
12
|
+
import { act, fireEvent, render, screen } from "@testing-library/react";
|
|
13
|
+
import type { ReactNode } from "react";
|
|
14
|
+
import { useBrowserNavApi } from "../app/nav";
|
|
15
|
+
|
|
16
|
+
function WorkspaceNav({ children }: { readonly children: ReactNode }): ReactNode {
|
|
17
|
+
const api = useBrowserNavApi({ hasWorkspaces: true });
|
|
18
|
+
return <NavProvider value={api}>{children}</NavProvider>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function Probe(): React.ReactElement {
|
|
22
|
+
const nav = useNav();
|
|
23
|
+
return (
|
|
24
|
+
<div>
|
|
25
|
+
<span data-testid="workspace-id">{nav.route?.workspaceId ?? "(none)"}</span>
|
|
26
|
+
<span data-testid="screen-id">{nav.route?.screenId ?? "(none)"}</span>
|
|
27
|
+
<span data-testid="entity-id">{nav.route?.entityId ?? "(none)"}</span>
|
|
28
|
+
<span data-testid="href-edit">
|
|
29
|
+
{nav.hrefFor({ screenId: "component-edit", entityId: "c1" })}
|
|
30
|
+
</span>
|
|
31
|
+
<button
|
|
32
|
+
type="button"
|
|
33
|
+
data-testid="go-edit"
|
|
34
|
+
onClick={() => nav.navigate({ screenId: "component-edit", entityId: "c1" })}
|
|
35
|
+
>
|
|
36
|
+
edit
|
|
37
|
+
</button>
|
|
38
|
+
<button
|
|
39
|
+
type="button"
|
|
40
|
+
data-testid="go-form"
|
|
41
|
+
onClick={() => nav.navigate({ screenId: "maintenance-schedule-form" })}
|
|
42
|
+
>
|
|
43
|
+
form
|
|
44
|
+
</button>
|
|
45
|
+
<button
|
|
46
|
+
type="button"
|
|
47
|
+
data-testid="go-other-workspace"
|
|
48
|
+
onClick={() => nav.navigate({ workspaceId: "visual", screenId: "tree" })}
|
|
49
|
+
>
|
|
50
|
+
switch
|
|
51
|
+
</button>
|
|
52
|
+
<button
|
|
53
|
+
type="button"
|
|
54
|
+
data-testid="replace-form"
|
|
55
|
+
onClick={() => nav.replace({ screenId: "maintenance-schedule-form" })}
|
|
56
|
+
>
|
|
57
|
+
replace
|
|
58
|
+
</button>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe("useBrowserNavApi({ hasWorkspaces: true }) — Workspace-Erhalt ohne explizite workspaceId", () => {
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
window.history.replaceState(null, "", "/admin/component-list");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("navigate({ screenId, entityId }) erbt den aktuellen Workspace → '/admin/component-edit/c1'", () => {
|
|
69
|
+
render(
|
|
70
|
+
<WorkspaceNav>
|
|
71
|
+
<Probe />
|
|
72
|
+
</WorkspaceNav>,
|
|
73
|
+
);
|
|
74
|
+
act(() => {
|
|
75
|
+
fireEvent.click(screen.getByTestId("go-edit"));
|
|
76
|
+
});
|
|
77
|
+
expect(window.location.pathname).toBe("/admin/component-edit/c1");
|
|
78
|
+
expect(screen.getByTestId("workspace-id").textContent).toBe("admin");
|
|
79
|
+
expect(screen.getByTestId("screen-id").textContent).toBe("component-edit");
|
|
80
|
+
expect(screen.getByTestId("entity-id").textContent).toBe("c1");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("navigate({ screenId }) (Toolbar-Fall 'Wartung planen') bleibt im Workspace", () => {
|
|
84
|
+
render(
|
|
85
|
+
<WorkspaceNav>
|
|
86
|
+
<Probe />
|
|
87
|
+
</WorkspaceNav>,
|
|
88
|
+
);
|
|
89
|
+
act(() => {
|
|
90
|
+
fireEvent.click(screen.getByTestId("go-form"));
|
|
91
|
+
});
|
|
92
|
+
expect(window.location.pathname).toBe("/admin/maintenance-schedule-form");
|
|
93
|
+
expect(screen.getByTestId("screen-id").textContent).toBe("maintenance-schedule-form");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("explizite workspaceId gewinnt weiterhin (WorkspaceSwitcher-Fall)", () => {
|
|
97
|
+
render(
|
|
98
|
+
<WorkspaceNav>
|
|
99
|
+
<Probe />
|
|
100
|
+
</WorkspaceNav>,
|
|
101
|
+
);
|
|
102
|
+
act(() => {
|
|
103
|
+
fireEvent.click(screen.getByTestId("go-other-workspace"));
|
|
104
|
+
});
|
|
105
|
+
expect(window.location.pathname).toBe("/visual/tree");
|
|
106
|
+
expect(screen.getByTestId("workspace-id").textContent).toBe("visual");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("replace erbt den Workspace symmetrisch zu navigate", () => {
|
|
110
|
+
render(
|
|
111
|
+
<WorkspaceNav>
|
|
112
|
+
<Probe />
|
|
113
|
+
</WorkspaceNav>,
|
|
114
|
+
);
|
|
115
|
+
act(() => {
|
|
116
|
+
fireEvent.click(screen.getByTestId("replace-form"));
|
|
117
|
+
});
|
|
118
|
+
expect(window.location.pathname).toBe("/admin/maintenance-schedule-form");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("hrefFor erbt den Workspace — Anchor-Links zeigen nicht aus dem Workspace raus", () => {
|
|
122
|
+
render(
|
|
123
|
+
<WorkspaceNav>
|
|
124
|
+
<Probe />
|
|
125
|
+
</WorkspaceNav>,
|
|
126
|
+
);
|
|
127
|
+
expect(screen.getByTestId("href-edit").textContent).toBe("/admin/component-edit/c1");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("ohne aktuelle Route (URL an der Root) bleibt das Target unverändert", () => {
|
|
131
|
+
window.history.replaceState(null, "", "/");
|
|
132
|
+
render(
|
|
133
|
+
<WorkspaceNav>
|
|
134
|
+
<Probe />
|
|
135
|
+
</WorkspaceNav>,
|
|
136
|
+
);
|
|
137
|
+
act(() => {
|
|
138
|
+
fireEvent.click(screen.getByTestId("go-form"));
|
|
139
|
+
});
|
|
140
|
+
// Kein Workspace zum Erben — formatPath bleibt flach; WorkspaceShell
|
|
141
|
+
// löst die Default-Workspace-Auflösung wie bisher selbst.
|
|
142
|
+
expect(window.location.pathname).toBe("/maintenance-schedule-form");
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("useBrowserNavApi ohne Workspaces — Injection bleibt aus", () => {
|
|
147
|
+
beforeEach(() => {
|
|
148
|
+
window.history.replaceState(null, "", "/task-list");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
function FlatNav({ children }: { readonly children: ReactNode }): ReactNode {
|
|
152
|
+
const api = useBrowserNavApi({ hasWorkspaces: false });
|
|
153
|
+
return <NavProvider value={api}>{children}</NavProvider>;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
test("navigate({ screenId }) bleibt flach: '/maintenance-schedule-form'", () => {
|
|
157
|
+
render(
|
|
158
|
+
<FlatNav>
|
|
159
|
+
<Probe />
|
|
160
|
+
</FlatNav>,
|
|
161
|
+
);
|
|
162
|
+
act(() => {
|
|
163
|
+
fireEvent.click(screen.getByTestId("go-form"));
|
|
164
|
+
});
|
|
165
|
+
expect(window.location.pathname).toBe("/maintenance-schedule-form");
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Theme-Persistenz (Bug-Bash 2026-06-07, Bug 13): Der Toggle setzte
|
|
2
|
+
// nur die .dark-Class — ohne localStorage-Persist + Restore war die
|
|
3
|
+
// Wahl nach jedem Reload weg, was sich für User als "dark/light geht
|
|
4
|
+
// nicht" anfühlte. Der FOUC-Schutz (Inline-Script in der Host-HTML)
|
|
5
|
+
// ist App-Sache; hier wird die JS-Seite gepinnt.
|
|
6
|
+
|
|
7
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
8
|
+
import { act, render, screen } from "@testing-library/react";
|
|
9
|
+
import type { ReactNode } from "react";
|
|
10
|
+
import { applyStoredThemeMode, THEME_STORAGE_KEY, useBrowserTokensApi } from "../tokens";
|
|
11
|
+
|
|
12
|
+
function Probe(): ReactNode {
|
|
13
|
+
const api = useBrowserTokensApi();
|
|
14
|
+
return (
|
|
15
|
+
<div>
|
|
16
|
+
<span data-testid="mode">{api.mode}</span>
|
|
17
|
+
<button type="button" data-testid="toggle" onClick={() => api.toggleMode()}>
|
|
18
|
+
toggle
|
|
19
|
+
</button>
|
|
20
|
+
<button type="button" data-testid="set-light" onClick={() => api.setMode("light")}>
|
|
21
|
+
light
|
|
22
|
+
</button>
|
|
23
|
+
</div>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("useBrowserTokensApi — Theme-Persistenz", () => {
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
window.localStorage.removeItem(THEME_STORAGE_KEY);
|
|
30
|
+
document.documentElement.classList.remove("dark");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("toggleMode persistiert die Wahl in localStorage", () => {
|
|
34
|
+
render(<Probe />);
|
|
35
|
+
expect(screen.getByTestId("mode").textContent).toBe("light");
|
|
36
|
+
|
|
37
|
+
act(() => {
|
|
38
|
+
screen.getByTestId("toggle").click();
|
|
39
|
+
});
|
|
40
|
+
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
|
41
|
+
expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toBe("dark");
|
|
42
|
+
|
|
43
|
+
act(() => {
|
|
44
|
+
screen.getByTestId("toggle").click();
|
|
45
|
+
});
|
|
46
|
+
expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toBe("light");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("setMode persistiert ebenfalls", () => {
|
|
50
|
+
render(<Probe />);
|
|
51
|
+
act(() => {
|
|
52
|
+
screen.getByTestId("set-light").click();
|
|
53
|
+
});
|
|
54
|
+
expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toBe("light");
|
|
55
|
+
expect(document.documentElement.classList.contains("dark")).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("applyStoredThemeMode restored die gespeicherte Wahl (Reload-Simulation)", () => {
|
|
59
|
+
// "Reload": Class weg (frisches HTML), aber localStorage hat dark.
|
|
60
|
+
window.localStorage.setItem(THEME_STORAGE_KEY, "dark");
|
|
61
|
+
document.documentElement.classList.remove("dark");
|
|
62
|
+
|
|
63
|
+
applyStoredThemeMode();
|
|
64
|
+
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("applyStoredThemeMode ohne gespeicherte Wahl lässt den HTML-Default stehen", () => {
|
|
68
|
+
document.documentElement.classList.add("dark");
|
|
69
|
+
applyStoredThemeMode();
|
|
70
|
+
// Kein gespeicherter Wert → nichts anfassen.
|
|
71
|
+
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("applyStoredThemeMode ignoriert kaputte Werte", () => {
|
|
75
|
+
window.localStorage.setItem(THEME_STORAGE_KEY, "neon");
|
|
76
|
+
applyStoredThemeMode();
|
|
77
|
+
expect(document.documentElement.classList.contains("dark")).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
});
|
package/src/app/nav.tsx
CHANGED
|
@@ -185,17 +185,28 @@ export function useBrowserNavApi(options?: {
|
|
|
185
185
|
const basePath = useMemo(() => normalizeBasePath(options?.basePath), [options?.basePath]);
|
|
186
186
|
const searchParams = useMemo(() => parseSearchParams(search), [search]);
|
|
187
187
|
const inAppPath = useMemo(() => stripBasePath(path, basePath), [path, basePath]);
|
|
188
|
-
return useMemo<NavApi>(
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
188
|
+
return useMemo<NavApi>(() => {
|
|
189
|
+
const route = inAppPath === undefined ? undefined : parsePath(inAppPath, hasWorkspaces);
|
|
190
|
+
// NavTarget-Contract: workspaceId weglassen = aktueller Workspace
|
|
191
|
+
// bleibt. formatPath kennt die aktuelle Route nicht — ohne Injection
|
|
192
|
+
// landet `/screen-id` in parsePath(hasWorkspaces) als workspaceId,
|
|
193
|
+
// WorkspaceShell sieht einen unbekannten Workspace und revertet
|
|
194
|
+
// sofort auf den Default-Screen ("Klick tut nichts"-Prod-Bug).
|
|
195
|
+
const inCurrentWorkspace = (target: NavTarget): NavTarget =>
|
|
196
|
+
hasWorkspaces && target.workspaceId === undefined && route?.workspaceId !== undefined
|
|
197
|
+
? { ...target, workspaceId: route.workspaceId }
|
|
198
|
+
: target;
|
|
199
|
+
return {
|
|
200
|
+
route,
|
|
201
|
+
navigate: (target) =>
|
|
202
|
+
pushPath(prependBasePath(formatPath(inCurrentWorkspace(target)), basePath)),
|
|
203
|
+
replace: (target) =>
|
|
204
|
+
replacePath(prependBasePath(formatPath(inCurrentWorkspace(target)), basePath)),
|
|
205
|
+
hrefFor: (target) => prependBasePath(formatPath(inCurrentWorkspace(target)), basePath),
|
|
194
206
|
searchParams,
|
|
195
207
|
setSearchParams: applySearchParamUpdates,
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
);
|
|
208
|
+
};
|
|
209
|
+
}, [inAppPath, hasWorkspaces, basePath, searchParams]);
|
|
199
210
|
}
|
|
200
211
|
|
|
201
212
|
// ---- KumikoLink (Anchor-basiert, nur Web) ----
|
|
@@ -9,13 +9,13 @@ import type { ReactNode } from "react";
|
|
|
9
9
|
import { useState } from "react";
|
|
10
10
|
|
|
11
11
|
const SOURCE_I18N_KEY: Record<ConfigValueSource, string> = {
|
|
12
|
-
"user-row": "config.source.user",
|
|
13
|
-
"tenant-row": "config.source.tenant",
|
|
14
|
-
"system-row": "config.source.system",
|
|
15
|
-
"app-override": "config.source.appOverride",
|
|
16
|
-
computed: "config.source.computed",
|
|
17
|
-
default: "config.source.default",
|
|
18
|
-
missing: "config.source.missing",
|
|
12
|
+
"user-row": "kumiko.config.source.user",
|
|
13
|
+
"tenant-row": "kumiko.config.source.tenant",
|
|
14
|
+
"system-row": "kumiko.config.source.system",
|
|
15
|
+
"app-override": "kumiko.config.source.appOverride",
|
|
16
|
+
computed: "kumiko.config.source.computed",
|
|
17
|
+
default: "kumiko.config.source.default",
|
|
18
|
+
missing: "kumiko.config.source.missing",
|
|
19
19
|
};
|
|
20
20
|
|
|
21
21
|
const SOURCE_COLORS: Record<ConfigValueSource, string> = {
|
|
@@ -28,13 +28,32 @@ const SOURCE_COLORS: Record<ConfigValueSource, string> = {
|
|
|
28
28
|
missing: "text-red-500 bg-red-50 border-red-200",
|
|
29
29
|
};
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
// Fallback-Reihenfolge der Cascade, spezifischste Quelle zuerst.
|
|
32
|
+
// Index-Vergleich gegen die Screen-Scope-Quelle entscheidet, welche
|
|
33
|
+
// Ebenen ein Nicht-Operator sehen darf.
|
|
34
|
+
const SOURCE_ORDER: readonly ConfigValueSource[] = [
|
|
35
|
+
"user-row",
|
|
36
|
+
"tenant-row",
|
|
37
|
+
"system-row",
|
|
38
|
+
"app-override",
|
|
39
|
+
"computed",
|
|
40
|
+
"default",
|
|
41
|
+
"missing",
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
function SourceBadge({
|
|
45
|
+
source,
|
|
46
|
+
labelKey,
|
|
47
|
+
}: {
|
|
48
|
+
source: ConfigValueSource;
|
|
49
|
+
labelKey?: string;
|
|
50
|
+
}): ReactNode {
|
|
32
51
|
const t = useTranslation();
|
|
33
52
|
return (
|
|
34
53
|
<span
|
|
35
54
|
className={`inline-flex items-center rounded border px-1.5 py-0.5 text-xs font-medium ${SOURCE_COLORS[source]}`}
|
|
36
55
|
>
|
|
37
|
-
{t(SOURCE_I18N_KEY[source])}
|
|
56
|
+
{t(labelKey ?? SOURCE_I18N_KEY[source])}
|
|
38
57
|
</span>
|
|
39
58
|
);
|
|
40
59
|
}
|
|
@@ -50,6 +69,47 @@ function scopeToSource(scope: ConfigScope): ConfigValueSource {
|
|
|
50
69
|
return "system-row";
|
|
51
70
|
}
|
|
52
71
|
|
|
72
|
+
// Eine Cascade-Zeile in Anzeige-Form: Ebenen oberhalb des Screen-Scopes
|
|
73
|
+
// werden für Nicht-Operator-Screens zu EINER neutralen "Vorgabe"-Zeile
|
|
74
|
+
// kollabiert — der Wert bleibt sichtbar, die Operator-Quelle nicht.
|
|
75
|
+
type DisplayLevel = {
|
|
76
|
+
readonly level: ConfigCascadeLevel;
|
|
77
|
+
readonly badgeSource: ConfigValueSource;
|
|
78
|
+
readonly badgeLabelKey?: string;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
function toDisplayLevels(
|
|
82
|
+
levels: readonly ConfigCascadeLevel[],
|
|
83
|
+
screenScopeSource: ConfigValueSource,
|
|
84
|
+
): readonly DisplayLevel[] {
|
|
85
|
+
// System-Screens sind Operator-Sicht — volle Cascade inkl.
|
|
86
|
+
// app-override/computed/default bleibt sichtbar.
|
|
87
|
+
if (screenScopeSource === "system-row") {
|
|
88
|
+
return levels.map((level) => ({ level, badgeSource: level.source }));
|
|
89
|
+
}
|
|
90
|
+
const scopeIdx = SOURCE_ORDER.indexOf(screenScopeSource);
|
|
91
|
+
const own = levels.filter((l) => SOURCE_ORDER.indexOf(l.source) <= scopeIdx);
|
|
92
|
+
const higher = levels.filter((l) => SOURCE_ORDER.indexOf(l.source) > scopeIdx);
|
|
93
|
+
// Genau eine Fallback-Zeile: die aktive höhere Ebene (deren Wert der
|
|
94
|
+
// User effektiv bekommt), sonst der deklarierte Default/Missing.
|
|
95
|
+
const fallback =
|
|
96
|
+
higher.find((l) => l.isActive) ??
|
|
97
|
+
higher.find((l) => l.source === "default" || l.source === "missing");
|
|
98
|
+
const ownRows: DisplayLevel[] = own.map((level) => ({ level, badgeSource: level.source }));
|
|
99
|
+
if (fallback === undefined) return ownRows;
|
|
100
|
+
return [
|
|
101
|
+
...ownRows,
|
|
102
|
+
{
|
|
103
|
+
level: fallback,
|
|
104
|
+
badgeSource: "default",
|
|
105
|
+
// Neutral "Vorgabe" statt System/Override/Computed — der Screen-
|
|
106
|
+
// Scope kann diese Ebenen weder setzen noch zurücksetzen, die
|
|
107
|
+
// Quelle ist für ihn Operator-Interna.
|
|
108
|
+
badgeLabelKey: "kumiko.config.cascade.preset",
|
|
109
|
+
},
|
|
110
|
+
];
|
|
111
|
+
}
|
|
112
|
+
|
|
53
113
|
type ConfigCascadeViewProps = {
|
|
54
114
|
readonly cascade: ConfigCascade;
|
|
55
115
|
readonly screenScope: ConfigScope;
|
|
@@ -71,9 +131,10 @@ export function ConfigCascadeView({
|
|
|
71
131
|
// the screen.
|
|
72
132
|
if (!Array.isArray(cascade?.levels)) return null;
|
|
73
133
|
|
|
74
|
-
const activeLevel = cascade.levels.find((l) => l.isActive);
|
|
75
134
|
const screenScopeSource = scopeToSource(screenScope);
|
|
76
|
-
const
|
|
135
|
+
const displayLevels = toDisplayLevels(cascade.levels, screenScopeSource);
|
|
136
|
+
const activeDisplay = displayLevels.find((d) => d.level.isActive);
|
|
137
|
+
const hasOverride = activeDisplay?.level.source === screenScopeSource;
|
|
77
138
|
|
|
78
139
|
return (
|
|
79
140
|
<div className="mt-1 text-xs">
|
|
@@ -83,22 +144,27 @@ export function ConfigCascadeView({
|
|
|
83
144
|
className="flex items-center gap-1 text-gray-500 hover:text-gray-700 cursor-pointer"
|
|
84
145
|
>
|
|
85
146
|
<span className="text-[10px]">{expanded ? "▼" : "▶"}</span>
|
|
86
|
-
{
|
|
147
|
+
{activeDisplay ? (
|
|
87
148
|
<>
|
|
88
|
-
<SourceBadge
|
|
149
|
+
<SourceBadge
|
|
150
|
+
source={activeDisplay.badgeSource}
|
|
151
|
+
{...(activeDisplay.badgeLabelKey !== undefined && {
|
|
152
|
+
labelKey: activeDisplay.badgeLabelKey,
|
|
153
|
+
})}
|
|
154
|
+
/>
|
|
89
155
|
<span className="text-gray-400">
|
|
90
|
-
{formatValue(
|
|
156
|
+
{formatValue(activeDisplay.level.value, activeDisplay.level.hasValue)}
|
|
91
157
|
</span>
|
|
92
158
|
</>
|
|
93
159
|
) : (
|
|
94
|
-
<span className="text-gray-400">{t("config.cascade.noValue")}</span>
|
|
160
|
+
<span className="text-gray-400">{t("kumiko.config.cascade.noValue")}</span>
|
|
95
161
|
)}
|
|
96
162
|
</button>
|
|
97
163
|
|
|
98
164
|
{expanded ? (
|
|
99
165
|
<div className="mt-1 flex flex-col gap-0.5 pl-3 border-l-2 border-gray-100">
|
|
100
|
-
{
|
|
101
|
-
<CascadeLevelRow key={level.source}
|
|
166
|
+
{displayLevels.map((display) => (
|
|
167
|
+
<CascadeLevelRow key={display.level.source} display={display} />
|
|
102
168
|
))}
|
|
103
169
|
|
|
104
170
|
{hasOverride && onReset && qualifiedKey ? (
|
|
@@ -107,7 +173,9 @@ export function ConfigCascadeView({
|
|
|
107
173
|
onClick={() => onReset(qualifiedKey, screenScope)}
|
|
108
174
|
className="mt-1 self-start text-[10px] text-orange-500 hover:text-orange-700 cursor-pointer underline"
|
|
109
175
|
>
|
|
110
|
-
{t("config.cascade.resetTo", {
|
|
176
|
+
{t("kumiko.config.cascade.resetTo", {
|
|
177
|
+
scope: t(SOURCE_I18N_KEY[screenScopeSource]),
|
|
178
|
+
})}
|
|
111
179
|
</button>
|
|
112
180
|
) : null}
|
|
113
181
|
</div>
|
|
@@ -116,16 +184,20 @@ export function ConfigCascadeView({
|
|
|
116
184
|
);
|
|
117
185
|
}
|
|
118
186
|
|
|
119
|
-
function CascadeLevelRow({
|
|
187
|
+
function CascadeLevelRow({ display }: { display: DisplayLevel }): ReactNode {
|
|
120
188
|
const t = useTranslation();
|
|
189
|
+
const { level } = display;
|
|
121
190
|
return (
|
|
122
191
|
<div
|
|
123
192
|
className={`flex items-center gap-1.5 ${level.isActive ? "font-medium" : "text-gray-400"}`}
|
|
124
193
|
>
|
|
125
|
-
<SourceBadge
|
|
194
|
+
<SourceBadge
|
|
195
|
+
source={display.badgeSource}
|
|
196
|
+
{...(display.badgeLabelKey !== undefined && { labelKey: display.badgeLabelKey })}
|
|
197
|
+
/>
|
|
126
198
|
<span>{formatValue(level.value, level.hasValue)}</span>
|
|
127
199
|
{level.isActive ? (
|
|
128
|
-
<span className="text-[10px] text-gray-400">{t("config.cascade.activeMarker")}</span>
|
|
200
|
+
<span className="text-[10px] text-gray-400">{t("kumiko.config.cascade.activeMarker")}</span>
|
|
129
201
|
) : null}
|
|
130
202
|
</div>
|
|
131
203
|
);
|
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import type { ConfigValueSource } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
2
3
|
import type { ReactNode } from "react";
|
|
3
4
|
|
|
4
|
-
const SOURCE_CONFIG: Record<ConfigValueSource, {
|
|
5
|
-
"user-row": {
|
|
6
|
-
"tenant-row": {
|
|
7
|
-
"system-row": {
|
|
8
|
-
"app-override": {
|
|
9
|
-
computed: {
|
|
10
|
-
default: {
|
|
11
|
-
missing: {
|
|
5
|
+
const SOURCE_CONFIG: Record<ConfigValueSource, { labelKey: string; bg: string; text: string }> = {
|
|
6
|
+
"user-row": { labelKey: "kumiko.config.source.user", bg: "#dbeafe", text: "#1e40af" },
|
|
7
|
+
"tenant-row": { labelKey: "kumiko.config.source.tenant", bg: "#dcfce7", text: "#166534" },
|
|
8
|
+
"system-row": { labelKey: "kumiko.config.source.system", bg: "#f3e8ff", text: "#6b21a8" },
|
|
9
|
+
"app-override": { labelKey: "kumiko.config.source.appOverride", bg: "#ffedd5", text: "#9a3412" },
|
|
10
|
+
computed: { labelKey: "kumiko.config.source.computed", bg: "#ccfbf1", text: "#115e59" },
|
|
11
|
+
default: { labelKey: "kumiko.config.source.default", bg: "#f3f4f6", text: "#4b5563" },
|
|
12
|
+
missing: { labelKey: "kumiko.config.source.missing", bg: "#fee2e2", text: "#991b1b" },
|
|
12
13
|
};
|
|
13
14
|
|
|
14
15
|
export function ConfigSourceBadge({ source }: { readonly source: ConfigValueSource }): ReactNode {
|
|
16
|
+
const t = useTranslation();
|
|
15
17
|
const cfg = SOURCE_CONFIG[source];
|
|
16
18
|
|
|
17
19
|
return (
|
|
@@ -30,7 +32,7 @@ export function ConfigSourceBadge({ source }: { readonly source: ConfigValueSour
|
|
|
30
32
|
whiteSpace: "nowrap",
|
|
31
33
|
}}
|
|
32
34
|
>
|
|
33
|
-
{cfg.
|
|
35
|
+
{t(cfg.labelKey)}
|
|
34
36
|
</span>
|
|
35
37
|
);
|
|
36
38
|
}
|
|
@@ -36,9 +36,12 @@ export function DefaultDialog({
|
|
|
36
36
|
setLoading(true);
|
|
37
37
|
try {
|
|
38
38
|
await onConfirm();
|
|
39
|
-
onOpenChange(false);
|
|
40
39
|
} finally {
|
|
41
40
|
setLoading(false);
|
|
41
|
+
// Auch bei rejected onConfirm schließen — ein offen hängendes Modal
|
|
42
|
+
// ohne Botschaft wirkt eingefroren; der Fehler selbst wird vom
|
|
43
|
+
// onConfirm-Pfad surfaced (Row-Actions: Toast).
|
|
44
|
+
onOpenChange(false);
|
|
42
45
|
}
|
|
43
46
|
}
|
|
44
47
|
|
package/src/primitives/index.tsx
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
type TextProps,
|
|
30
30
|
useColumnRenderer,
|
|
31
31
|
useTranslation,
|
|
32
|
+
WriteFailedError,
|
|
32
33
|
} from "@cosmicdrift/kumiko-renderer";
|
|
33
34
|
import * as LabelPrimitive from "@radix-ui/react-label";
|
|
34
35
|
import { cva } from "class-variance-authority";
|
|
@@ -53,6 +54,7 @@ import {
|
|
|
53
54
|
DropdownMenuTrigger,
|
|
54
55
|
} from "./dropdown-menu";
|
|
55
56
|
import { MoneyInput } from "./money-input";
|
|
57
|
+
import { useToast } from "./toast";
|
|
56
58
|
|
|
57
59
|
// ---- Button ----
|
|
58
60
|
|
|
@@ -599,10 +601,22 @@ function needsConfirm(action: DataTableRowAction): boolean {
|
|
|
599
601
|
|
|
600
602
|
function useRowActionTrigger(row: ListRowViewModel) {
|
|
601
603
|
const [busy, setBusy] = useState(false);
|
|
604
|
+
const { toast } = useToast();
|
|
605
|
+
const t = useTranslation();
|
|
602
606
|
const triggerNow = async (action: DataTableRowAction): Promise<void> => {
|
|
603
607
|
setBusy(true);
|
|
604
608
|
try {
|
|
605
609
|
await action.onTrigger(row);
|
|
610
|
+
} catch (e) {
|
|
611
|
+
// Surfacing statt schlucken: ein verschluckter Write-Fehler sah für
|
|
612
|
+
// den User wie "nichts passiert" aus (Prod-Bug 2026-06-07).
|
|
613
|
+
const docsUrl = e instanceof WriteFailedError ? e.dispatcherError.docsUrl : undefined;
|
|
614
|
+
toast({
|
|
615
|
+
title: t("kumiko.rowAction.failed"),
|
|
616
|
+
description: e instanceof Error ? e.message : String(e),
|
|
617
|
+
variant: "destructive",
|
|
618
|
+
...(docsUrl !== undefined && { docsUrl }),
|
|
619
|
+
});
|
|
606
620
|
} finally {
|
|
607
621
|
setBusy(false);
|
|
608
622
|
}
|
package/src/tokens.ts
CHANGED
|
@@ -16,9 +16,20 @@ import { useSyncExternalStore } from "react";
|
|
|
16
16
|
// reiner Notification-Bus (Tick-Counter), den setMode/toggleMode bei
|
|
17
17
|
// jedem Class-Wechsel hochzählen. So bleibt die DOM-Klasse die einzige
|
|
18
18
|
// Wahrheit — readCurrentMode liest sie frisch bei jedem getSnapshot.
|
|
19
|
+
//
|
|
20
|
+
// Persistenz: die Wahl landet in localStorage (THEME_STORAGE_KEY) und
|
|
21
|
+
// wird beim ersten Hook-Mount restored — ohne das war der Toggle nach
|
|
22
|
+
// jedem Reload weg ("dark/light geht nicht", Prod-Bug 2026-06-07).
|
|
23
|
+
// Gegen FOUC gehört zusätzlich ein synchrones Inline-Script in die
|
|
24
|
+
// Host-HTML, VOR dem Stylesheet-Link:
|
|
25
|
+
//
|
|
26
|
+
// <script>try{if(localStorage.getItem("kumiko:theme")==="dark")
|
|
27
|
+
// document.documentElement.classList.add("dark")}catch(e){}</script>
|
|
19
28
|
|
|
20
29
|
const themeTick = createStore(0);
|
|
21
30
|
|
|
31
|
+
export const THEME_STORAGE_KEY = "kumiko:theme";
|
|
32
|
+
|
|
22
33
|
function readCurrentMode(): ThemeMode {
|
|
23
34
|
if (typeof document === "undefined") return "dark";
|
|
24
35
|
return document.documentElement.classList.contains("dark") ? "dark" : "light";
|
|
@@ -28,11 +39,46 @@ function notifyThemeChange(): void {
|
|
|
28
39
|
themeTick.setState((t) => t + 1);
|
|
29
40
|
}
|
|
30
41
|
|
|
42
|
+
function persistMode(mode: ThemeMode): void {
|
|
43
|
+
try {
|
|
44
|
+
window.localStorage.setItem(THEME_STORAGE_KEY, mode);
|
|
45
|
+
} catch {
|
|
46
|
+
// skip: localStorage kann werfen (Private-Mode/Quota) — Theme bleibt
|
|
47
|
+
// dann sessionbasiert, der Class-Toggle hat trotzdem funktioniert.
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Liest die persistierte Theme-Wahl und setzt die `.dark`-Class. Wird
|
|
52
|
+
* beim ersten useBrowserTokensApi-Mount aufgerufen; das Inline-Script
|
|
53
|
+
* in der Host-HTML (siehe Header-Kommentar) macht dasselbe synchron
|
|
54
|
+
* vor dem ersten Paint. */
|
|
55
|
+
export function applyStoredThemeMode(): void {
|
|
56
|
+
if (typeof document === "undefined") return;
|
|
57
|
+
let stored: string | null = null;
|
|
58
|
+
try {
|
|
59
|
+
stored = window.localStorage.getItem(THEME_STORAGE_KEY);
|
|
60
|
+
} catch {
|
|
61
|
+
// skip: localStorage kann werfen (Private-Mode) — ohne gespeicherte
|
|
62
|
+
// Wahl bleibt der Server-/HTML-Default stehen.
|
|
63
|
+
}
|
|
64
|
+
if (stored !== "dark" && stored !== "light") return;
|
|
65
|
+
document.documentElement.classList.toggle("dark", stored === "dark");
|
|
66
|
+
notifyThemeChange();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let storedModeApplied = false;
|
|
70
|
+
|
|
31
71
|
/** Hook der eine TokensApi für den Browser baut. Wird von
|
|
32
72
|
* createKumikoApp genutzt; App-Code der einen eigenen Token-State
|
|
33
73
|
* braucht (z.B. User-Präferenz aus localStorage) kann selber
|
|
34
74
|
* `<TokensProvider value={...}>` mounten. */
|
|
35
75
|
export function useBrowserTokensApi(): TokensApi {
|
|
76
|
+
// Einmal pro Page-Load: gespeicherte Wahl anwenden. Lazy statt
|
|
77
|
+
// Modul-Side-Effect, damit Import ohne DOM (SSR/Tests) safe bleibt.
|
|
78
|
+
if (!storedModeApplied && typeof document !== "undefined") {
|
|
79
|
+
storedModeApplied = true;
|
|
80
|
+
applyStoredThemeMode();
|
|
81
|
+
}
|
|
36
82
|
const mode = useSyncExternalStore(themeTick.subscribe, readCurrentMode, () => "dark" as const);
|
|
37
83
|
return {
|
|
38
84
|
tokens: cssVarTokens,
|
|
@@ -40,11 +86,13 @@ export function useBrowserTokensApi(): TokensApi {
|
|
|
40
86
|
setMode: (next) => {
|
|
41
87
|
if (typeof document === "undefined") return;
|
|
42
88
|
document.documentElement.classList.toggle("dark", next === "dark");
|
|
89
|
+
persistMode(next);
|
|
43
90
|
notifyThemeChange();
|
|
44
91
|
},
|
|
45
92
|
toggleMode: () => {
|
|
46
93
|
if (typeof document === "undefined") return;
|
|
47
|
-
document.documentElement.classList.toggle("dark");
|
|
94
|
+
const nowDark = document.documentElement.classList.toggle("dark");
|
|
95
|
+
persistMode(nowDark ? "dark" : "light");
|
|
48
96
|
notifyThemeChange();
|
|
49
97
|
},
|
|
50
98
|
};
|