@cosmicdrift/kumiko-renderer-web 0.258.0 → 0.259.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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.259.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.259.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.259.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.259.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",
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"cmdk": "^1.1.1",
|
|
34
34
|
"lucide-react": "^1.14.0",
|
|
35
35
|
"qrcode": "^1.5.4",
|
|
36
|
+
"@types/qrcode": "^1.5.5",
|
|
36
37
|
"radix-ui": "^1.4.3",
|
|
37
38
|
"react": "^19.2.6",
|
|
38
39
|
"react-day-picker": "^10.0.0",
|
|
@@ -61,12 +62,11 @@
|
|
|
61
62
|
"@tailwindcss/cli": "^4.3.0",
|
|
62
63
|
"@testing-library/react": "^16.3.2",
|
|
63
64
|
"@testing-library/user-event": "^14.6.1",
|
|
64
|
-
"@types/qrcode": "^1.5.5",
|
|
65
65
|
"@types/react": "^19.2.14",
|
|
66
66
|
"@types/react-dom": "^19.2.3",
|
|
67
67
|
"jsdom": "^29.1.1",
|
|
68
68
|
"tailwindcss": "^4.3.0",
|
|
69
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
69
|
+
"@cosmicdrift/kumiko-locale-de": "0.259.0"
|
|
70
70
|
},
|
|
71
71
|
"repository": {
|
|
72
72
|
"type": "git",
|
|
@@ -3,11 +3,13 @@ import type { DashboardScreenDefinition } from "@cosmicdrift/kumiko-framework/ui
|
|
|
3
3
|
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
4
4
|
import type { ExtensionSectionProps, FeatureSchema } from "@cosmicdrift/kumiko-renderer";
|
|
5
5
|
import {
|
|
6
|
+
AppFeaturesProvider,
|
|
6
7
|
DashboardBodyProvider,
|
|
7
8
|
DispatcherProvider,
|
|
8
9
|
ExtensionSectionsProvider,
|
|
9
10
|
KumikoScreen,
|
|
10
11
|
NavProvider,
|
|
12
|
+
UserRolesProvider,
|
|
11
13
|
} from "@cosmicdrift/kumiko-renderer";
|
|
12
14
|
import userEvent from "@testing-library/user-event";
|
|
13
15
|
import type { ReactNode } from "react";
|
|
@@ -527,3 +529,151 @@ describe("KumikoScreen dashboard — neue Panel-Kinds", () => {
|
|
|
527
529
|
expect(kpiCall?.payload).toEqual({ tenantId: "t-2" });
|
|
528
530
|
});
|
|
529
531
|
});
|
|
532
|
+
|
|
533
|
+
const sessionsSchema: FeatureSchema = {
|
|
534
|
+
featureName: "sessions",
|
|
535
|
+
entities: {},
|
|
536
|
+
screens: [
|
|
537
|
+
{
|
|
538
|
+
id: "my-sessions",
|
|
539
|
+
type: "projectionList",
|
|
540
|
+
query: "sessions:query:user-session:mine",
|
|
541
|
+
columns: [{ field: "userAgent", label: "sessions.mine.col.userAgent" }],
|
|
542
|
+
access: { openToAll: true },
|
|
543
|
+
},
|
|
544
|
+
{
|
|
545
|
+
id: "session-list",
|
|
546
|
+
type: "projectionList",
|
|
547
|
+
query: "sessions:query:user-session:list",
|
|
548
|
+
columns: [{ field: "userAgent", label: "sessions.list.col.userAgent" }],
|
|
549
|
+
access: { roles: ["SystemAdmin"] },
|
|
550
|
+
},
|
|
551
|
+
],
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
const mfaSchema: FeatureSchema = {
|
|
555
|
+
featureName: "auth-mfa",
|
|
556
|
+
entities: {},
|
|
557
|
+
screens: [
|
|
558
|
+
{
|
|
559
|
+
id: "auth-mfa-enable",
|
|
560
|
+
type: "secretMint",
|
|
561
|
+
handler: "auth-mfa:write:enable-start",
|
|
562
|
+
fields: {},
|
|
563
|
+
layout: { sections: [] },
|
|
564
|
+
submitLabel: "mfa.enable.start",
|
|
565
|
+
reveal: { fields: [{ field: "recoveryCodes", label: "codes", display: "list" }] },
|
|
566
|
+
access: { openToAll: true },
|
|
567
|
+
},
|
|
568
|
+
{
|
|
569
|
+
id: "auth-mfa-disable",
|
|
570
|
+
type: "actionForm",
|
|
571
|
+
handler: "auth-mfa:write:disable",
|
|
572
|
+
fields: { code: { type: "text", required: true } },
|
|
573
|
+
layout: { sections: [{ fields: ["code"] }] },
|
|
574
|
+
submitLabel: "mfa.disable.submit",
|
|
575
|
+
access: { openToAll: true },
|
|
576
|
+
},
|
|
577
|
+
],
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
const mfaStatus = { query: "auth-mfa:query:user-mfa:status", field: "enabled" } as const;
|
|
581
|
+
|
|
582
|
+
const accountSecuritySchema: FeatureSchema = {
|
|
583
|
+
featureName: "account-security",
|
|
584
|
+
entities: {},
|
|
585
|
+
screens: [
|
|
586
|
+
{
|
|
587
|
+
id: "account-security",
|
|
588
|
+
type: "dashboard",
|
|
589
|
+
panels: [
|
|
590
|
+
{
|
|
591
|
+
kind: "screen",
|
|
592
|
+
id: "mfa-enable",
|
|
593
|
+
screen: "auth-mfa:screen:auth-mfa-enable",
|
|
594
|
+
visibleWhen: { ...mfaStatus, eq: false },
|
|
595
|
+
},
|
|
596
|
+
{
|
|
597
|
+
kind: "screen",
|
|
598
|
+
id: "mfa-disable",
|
|
599
|
+
screen: "auth-mfa:screen:auth-mfa-disable",
|
|
600
|
+
visibleWhen: { ...mfaStatus, eq: true },
|
|
601
|
+
},
|
|
602
|
+
{
|
|
603
|
+
kind: "screen",
|
|
604
|
+
id: "sessions",
|
|
605
|
+
screen: "sessions:screen:my-sessions",
|
|
606
|
+
label: "account-security:panel:sessions",
|
|
607
|
+
},
|
|
608
|
+
{ kind: "screen", id: "admin-sessions", screen: "sessions:screen:session-list" },
|
|
609
|
+
],
|
|
610
|
+
},
|
|
611
|
+
],
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
function renderAccountSecurity(mfaEnabled: boolean): void {
|
|
615
|
+
const dispatcher = createMockDispatcher({
|
|
616
|
+
query: (async (type: string) => {
|
|
617
|
+
if (type === mfaStatus.query) return { isSuccess: true, data: { enabled: mfaEnabled } };
|
|
618
|
+
if (type === "sessions:query:user-session:mine") {
|
|
619
|
+
return {
|
|
620
|
+
isSuccess: true,
|
|
621
|
+
data: { rows: [{ id: "s1", userAgent: "Firefox on Linux" }], nextCursor: null },
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
return {
|
|
625
|
+
isSuccess: true,
|
|
626
|
+
data: { rows: [{ id: "x", userAgent: "ADMIN ROW" }], nextCursor: null },
|
|
627
|
+
};
|
|
628
|
+
}) as unknown as Dispatcher["query"],
|
|
629
|
+
});
|
|
630
|
+
render(
|
|
631
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
632
|
+
<AppFeaturesProvider features={[accountSecuritySchema, sessionsSchema, mfaSchema]}>
|
|
633
|
+
<UserRolesProvider roles={["User"]}>
|
|
634
|
+
<BrowserNav>
|
|
635
|
+
<DashboardBodyProvider value={WebDashboardBody}>
|
|
636
|
+
<KumikoScreen
|
|
637
|
+
schema={accountSecuritySchema}
|
|
638
|
+
qn="account-security:screen:account-security"
|
|
639
|
+
/>
|
|
640
|
+
</DashboardBodyProvider>
|
|
641
|
+
</BrowserNav>
|
|
642
|
+
</UserRolesProvider>
|
|
643
|
+
</AppFeaturesProvider>
|
|
644
|
+
</DispatcherProvider>,
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
describe("KumikoScreen dashboard — screen-Panels (fw#2841)", () => {
|
|
649
|
+
afterEach(() => {
|
|
650
|
+
window.history.replaceState(null, "", "/");
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
test("bettet einen Cross-Feature-projectionList-Screen samt Label ein", async () => {
|
|
654
|
+
renderAccountSecurity(false);
|
|
655
|
+
await waitFor(() => expect(screen.getByText("Firefox on Linux")).toBeTruthy());
|
|
656
|
+
expect(screen.getByTestId("dashboard-panel-sessions")).toBeTruthy();
|
|
657
|
+
expect(screen.getByText("account-security:panel:sessions")).toBeTruthy();
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
test("visibleWhen zeigt nur das Panel, das zum Query-Wert passt", async () => {
|
|
661
|
+
renderAccountSecurity(false);
|
|
662
|
+
await waitFor(() => expect(screen.getByTestId("dashboard-panel-mfa-enable")).toBeTruthy());
|
|
663
|
+
expect(screen.queryByTestId("dashboard-panel-mfa-disable")).toBeNull();
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
test("visibleWhen mit umgekehrtem Status blendet das jeweils andere Panel ein", async () => {
|
|
667
|
+
renderAccountSecurity(true);
|
|
668
|
+
await waitFor(() => expect(screen.getByTestId("dashboard-panel-mfa-disable")).toBeTruthy());
|
|
669
|
+
expect(screen.queryByTestId("dashboard-panel-mfa-enable")).toBeNull();
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
test("ohne Zugriff auf den Ziel-Screen fällt die Kachel komplett weg (kein Access-Banner)", async () => {
|
|
673
|
+
renderAccountSecurity(false);
|
|
674
|
+
await waitFor(() => expect(screen.getByText("Firefox on Linux")).toBeTruthy());
|
|
675
|
+
expect(screen.queryByTestId("dashboard-panel-admin-sessions")).toBeNull();
|
|
676
|
+
expect(screen.queryByText("ADMIN ROW")).toBeNull();
|
|
677
|
+
expect(screen.queryByTestId("kumiko-screen-access-denied")).toBeNull();
|
|
678
|
+
});
|
|
679
|
+
});
|
|
@@ -23,6 +23,9 @@
|
|
|
23
23
|
// progress-list → { rows: { id, label, value, fraction }[] }
|
|
24
24
|
// custom → keine Query — eine über extensionSectionComponents
|
|
25
25
|
// registrierte App-Komponente holt sich ihre Daten selbst.
|
|
26
|
+
// screen → no own query; embeds another declarative screen via
|
|
27
|
+
// KumikoScreen. visibleWhen reads a flat record (live); the
|
|
28
|
+
// tile is dropped when the user can't access the target.
|
|
26
29
|
//
|
|
27
30
|
// Screen-Filter (DashboardFilterDefinition): der gewählte Wert wird unter
|
|
28
31
|
// `filter.id` in JEDE Panel-Query gemerged. useQuery refetcht automatisch
|
|
@@ -36,13 +39,17 @@ import type {
|
|
|
36
39
|
DashboardPanelDefinition,
|
|
37
40
|
DashboardProgressListPanel,
|
|
38
41
|
DashboardScreenDefinition,
|
|
42
|
+
DashboardScreenPanel,
|
|
39
43
|
DashboardStatGroupPanel,
|
|
40
44
|
DashboardStatPanel,
|
|
41
45
|
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
42
46
|
import { normalizeListColumn } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
47
|
+
import type { Translate } from "@cosmicdrift/kumiko-headless";
|
|
43
48
|
import {
|
|
44
49
|
type DashboardBodyProps,
|
|
45
50
|
extensionSectionName,
|
|
51
|
+
KumikoScreen,
|
|
52
|
+
useEmbeddedScreen,
|
|
46
53
|
useExtensionSectionComponent,
|
|
47
54
|
useNav,
|
|
48
55
|
usePrimitives,
|
|
@@ -403,7 +410,7 @@ function PanelBody({
|
|
|
403
410
|
screenId,
|
|
404
411
|
filterParams,
|
|
405
412
|
}: {
|
|
406
|
-
readonly panel: DashboardPanelDefinition
|
|
413
|
+
readonly panel: Exclude<DashboardPanelDefinition, DashboardScreenPanel>;
|
|
407
414
|
readonly label: string;
|
|
408
415
|
readonly screenId: string;
|
|
409
416
|
readonly filterParams: Readonly<Record<string, unknown>>;
|
|
@@ -434,7 +441,43 @@ function PanelBody({
|
|
|
434
441
|
return <CustomPanelBody panel={panel} screenId={screenId} filterParams={filterParams} />;
|
|
435
442
|
}
|
|
436
443
|
|
|
437
|
-
|
|
444
|
+
function ScreenPanelTile({
|
|
445
|
+
panel,
|
|
446
|
+
featureName,
|
|
447
|
+
translate,
|
|
448
|
+
}: {
|
|
449
|
+
readonly panel: DashboardScreenPanel;
|
|
450
|
+
readonly featureName: string;
|
|
451
|
+
readonly translate: Translate;
|
|
452
|
+
}): ReactNode {
|
|
453
|
+
const visibleWhen = panel.visibleWhen;
|
|
454
|
+
const visibility = useQuery<Readonly<Record<string, unknown>>>(
|
|
455
|
+
visibleWhen?.query ?? "",
|
|
456
|
+
{},
|
|
457
|
+
{ enabled: visibleWhen !== undefined, live: true },
|
|
458
|
+
);
|
|
459
|
+
const target = useEmbeddedScreen(featureName, panel.screen);
|
|
460
|
+
if (target === undefined) return null;
|
|
461
|
+
if (visibleWhen !== undefined && visibility.data?.[visibleWhen.field] !== visibleWhen.eq) {
|
|
462
|
+
return null;
|
|
463
|
+
}
|
|
464
|
+
const embedded = <KumikoScreen schema={target.schema} qn={target.qn} translate={translate} />;
|
|
465
|
+
return (
|
|
466
|
+
<div className={WIDE_PANEL} data-testid={`dashboard-panel-${panel.id}`}>
|
|
467
|
+
{panel.label !== undefined ? (
|
|
468
|
+
<SectionCard title={translate(panel.label)}>{embedded}</SectionCard>
|
|
469
|
+
) : (
|
|
470
|
+
embedded
|
|
471
|
+
)}
|
|
472
|
+
</div>
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
export function WebDashboardBody({
|
|
477
|
+
featureName,
|
|
478
|
+
screen,
|
|
479
|
+
translate,
|
|
480
|
+
}: DashboardBodyProps): ReactNode {
|
|
438
481
|
const t = useTranslation();
|
|
439
482
|
const effectiveTranslate = translate ?? t;
|
|
440
483
|
const { Text } = usePrimitives();
|
|
@@ -449,6 +492,16 @@ export function WebDashboardBody({ screen, translate }: DashboardBodyProps): Rea
|
|
|
449
492
|
{picker}
|
|
450
493
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
451
494
|
{screen.panels.map((panel) => {
|
|
495
|
+
if (panel.kind === "screen") {
|
|
496
|
+
return (
|
|
497
|
+
<ScreenPanelTile
|
|
498
|
+
key={panel.id}
|
|
499
|
+
panel={panel}
|
|
500
|
+
featureName={featureName}
|
|
501
|
+
translate={effectiveTranslate}
|
|
502
|
+
/>
|
|
503
|
+
);
|
|
504
|
+
}
|
|
452
505
|
const label = panel.kind === "custom" ? "" : effectiveTranslate(panel.label);
|
|
453
506
|
const span = panelSpanClassName(panel);
|
|
454
507
|
return (
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import { defaultPrimitives } from "../index";
|
|
4
|
+
|
|
5
|
+
const { DataTable } = defaultPrimitives;
|
|
6
|
+
|
|
7
|
+
interface InlinePadding {
|
|
8
|
+
readonly left: string | undefined;
|
|
9
|
+
readonly right: string | undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// happy-dom doesn't compute Tailwind layout, so resolve the effective inline
|
|
13
|
+
// padding the way Tailwind's cascade does: p-* < px-* < pl-*/pr-*. Variant-
|
|
14
|
+
// prefixed utilities (e.g. `[&:has([role=checkbox])]:pr-0`) only apply
|
|
15
|
+
// conditionally and are skipped.
|
|
16
|
+
function resolveInlinePadding(element: HTMLElement): InlinePadding {
|
|
17
|
+
const tokens = element.className.split(/\s+/).filter((t) => t !== "" && !t.includes(":"));
|
|
18
|
+
const paddingFor = (prefix: string): string | undefined =>
|
|
19
|
+
tokens
|
|
20
|
+
.filter((t) => t.startsWith(`${prefix}-`))
|
|
21
|
+
.at(-1)
|
|
22
|
+
?.slice(prefix.length + 1);
|
|
23
|
+
const all = paddingFor("p");
|
|
24
|
+
const x = paddingFor("px") ?? all;
|
|
25
|
+
return { left: paddingFor("pl") ?? x, right: paddingFor("pr") ?? x };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const columns = [
|
|
29
|
+
{ field: "revision", label: "Revision", type: "number", sortable: false },
|
|
30
|
+
{ field: "title", label: "Title", type: "string", sortable: true },
|
|
31
|
+
] as const;
|
|
32
|
+
|
|
33
|
+
const rows = [{ id: "r1", values: { revision: 1, title: "First" } }];
|
|
34
|
+
|
|
35
|
+
describe("DataTable header cells align with body cells", () => {
|
|
36
|
+
test.each([
|
|
37
|
+
["without sort wiring", undefined],
|
|
38
|
+
["with sort wiring", mock()],
|
|
39
|
+
] as const)(
|
|
40
|
+
"%s: every column header has the same inline padding as its cells",
|
|
41
|
+
(_, onSortChange) => {
|
|
42
|
+
render(
|
|
43
|
+
<DataTable
|
|
44
|
+
columns={columns}
|
|
45
|
+
rows={rows}
|
|
46
|
+
testId="t"
|
|
47
|
+
{...(onSortChange !== undefined && { onSortChange })}
|
|
48
|
+
/>,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
for (const col of columns) {
|
|
52
|
+
const header = resolveInlinePadding(screen.getByTestId(`column-${col.field}`));
|
|
53
|
+
const cell = resolveInlinePadding(screen.getByTestId(`cell-r1-${col.field}`));
|
|
54
|
+
expect(header.left).toBeDefined();
|
|
55
|
+
expect(header).toEqual(cell);
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
});
|
package/src/primitives/index.tsx
CHANGED
|
@@ -1877,7 +1877,7 @@ function SortableHeader({
|
|
|
1877
1877
|
data-testid={`column-${field}`}
|
|
1878
1878
|
data-sortable={sortable === true ? true : undefined}
|
|
1879
1879
|
data-highlighted={highlighted === true ? "true" : undefined}
|
|
1880
|
-
className={cn("
|
|
1880
|
+
className={cn("text-muted-foreground", highlighted === true && "bg-accent/40")}
|
|
1881
1881
|
>
|
|
1882
1882
|
{label}
|
|
1883
1883
|
</TableHead>
|
|
@@ -1893,7 +1893,7 @@ function SortableHeader({
|
|
|
1893
1893
|
data-sortable="true"
|
|
1894
1894
|
data-highlighted={highlighted === true ? "true" : undefined}
|
|
1895
1895
|
aria-sort={ariaSort}
|
|
1896
|
-
className={cn("
|
|
1896
|
+
className={cn("text-muted-foreground", highlighted === true && "bg-accent/40")}
|
|
1897
1897
|
>
|
|
1898
1898
|
<button
|
|
1899
1899
|
type="button"
|