@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.258.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.258.0",
20
- "@cosmicdrift/kumiko-headless": "0.258.0",
21
- "@cosmicdrift/kumiko-renderer": "0.258.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.258.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
- export function WebDashboardBody({ screen, translate }: DashboardBodyProps): ReactNode {
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
+ });
@@ -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("px-4 text-muted-foreground", highlighted === true && "bg-accent/40")}
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("px-4 text-muted-foreground", highlighted === true && "bg-accent/40")}
1896
+ className={cn("text-muted-foreground", highlighted === true && "bg-accent/40")}
1897
1897
  >
1898
1898
  <button
1899
1899
  type="button"