@cosmicdrift/kumiko-bundled-features 0.224.2 → 0.225.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.
@@ -0,0 +1,198 @@
1
+ // @runtime test
2
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
3
+ import type { DispatcherError } from "@cosmicdrift/kumiko-headless";
4
+ import {
5
+ createStaticLocaleResolver,
6
+ type ExtensionSectionProps,
7
+ LocaleProvider,
8
+ PrimitivesProvider,
9
+ } from "@cosmicdrift/kumiko-renderer";
10
+ import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
11
+ import { render, screen } from "@testing-library/react";
12
+ import type { ReactNode } from "react";
13
+ import { CapOverviewQueries } from "../../constants";
14
+ import type { CapUsageTone, CapUsageWithMeta } from "../../types";
15
+ import { CapCardsPanel } from "../cap-cards-panel";
16
+
17
+ type CapsUsageResponse = { readonly rows: readonly CapUsageWithMeta[] };
18
+
19
+ type QueryState = {
20
+ readonly data: CapsUsageResponse | null;
21
+ readonly loading: boolean;
22
+ readonly error: DispatcherError | null;
23
+ };
24
+
25
+ let queryState: QueryState = { data: { rows: [] }, loading: false, error: null };
26
+
27
+ const useQuerySpy = mock((_type: string, _params: unknown) => ({
28
+ ...queryState,
29
+ refetch: mock(async () => {}),
30
+ }));
31
+
32
+ const actualRenderer = await import("@cosmicdrift/kumiko-renderer");
33
+ mock.module("@cosmicdrift/kumiko-renderer", () => ({
34
+ ...actualRenderer,
35
+ useQuery: useQuerySpy,
36
+ }));
37
+
38
+ // The real StatusBadge bakes `tone` into a computed Tailwind class string and
39
+ // exposes it nowhere in the DOM — asserting TONE_TO_STATUS through it would
40
+ // mean matching class substrings. Surface the prop as data-tone instead.
41
+ const actualRendererWeb = await import("@cosmicdrift/kumiko-renderer-web");
42
+ mock.module("@cosmicdrift/kumiko-renderer-web", () => ({
43
+ ...actualRendererWeb,
44
+ StatusBadge: ({
45
+ tone,
46
+ children,
47
+ }: {
48
+ readonly tone: string;
49
+ readonly children: ReactNode;
50
+ }): ReactNode => <span data-tone={tone}>{children}</span>,
51
+ }));
52
+
53
+ function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
54
+ return (
55
+ <LocaleProvider resolver={createStaticLocaleResolver()}>
56
+ <PrimitivesProvider value={defaultPrimitives}>{children}</PrimitivesProvider>
57
+ </LocaleProvider>
58
+ );
59
+ }
60
+
61
+ function renderPanel(filterParams: ExtensionSectionProps["filterParams"]): void {
62
+ render(
63
+ <Wrapper>
64
+ <CapCardsPanel
65
+ entityName="cap-overview-dashboard"
66
+ entityId={null}
67
+ filterParams={filterParams}
68
+ />
69
+ </Wrapper>,
70
+ );
71
+ }
72
+
73
+ function capRow(overrides: Partial<CapUsageWithMeta> = {}): CapUsageWithMeta {
74
+ return {
75
+ id: "cap-1",
76
+ label: "cap.label",
77
+ tone: "default",
78
+ percent: 0,
79
+ used: 0,
80
+ limit: 100,
81
+ fraction: 0,
82
+ ...overrides,
83
+ };
84
+ }
85
+
86
+ beforeEach(() => {
87
+ queryState = { data: { rows: [] }, loading: false, error: null };
88
+ useQuerySpy.mockClear();
89
+ });
90
+
91
+ describe("CapCardsPanel", () => {
92
+ test("renders one card per row, with the row's label and used/limit value", () => {
93
+ queryState = {
94
+ data: {
95
+ rows: [
96
+ capRow({ id: "c1", label: "Storage", used: 3, limit: 10 }),
97
+ capRow({ id: "c2", label: "Seats", used: 7, limit: 20 }),
98
+ ],
99
+ },
100
+ loading: false,
101
+ error: null,
102
+ };
103
+
104
+ renderPanel(undefined);
105
+
106
+ expect(screen.getAllByTestId("cap-card")).toHaveLength(2);
107
+ expect(screen.getByText("Storage")).toBeTruthy();
108
+ expect(screen.getByText("Seats")).toBeTruthy();
109
+ expect(screen.getByText("3 / 10")).toBeTruthy();
110
+ expect(screen.getByText("7 / 20")).toBeTruthy();
111
+ });
112
+
113
+ test("maps the default tone to the muted StatusBadge tone", () => {
114
+ queryState = {
115
+ data: { rows: [capRow({ tone: "default", percent: 12 })] },
116
+ loading: false,
117
+ error: null,
118
+ };
119
+
120
+ renderPanel(undefined);
121
+
122
+ expect(screen.getByText("12%").getAttribute("data-tone")).toBe("muted");
123
+ });
124
+
125
+ test("maps the warn tone to the warn StatusBadge tone", () => {
126
+ queryState = {
127
+ data: { rows: [capRow({ tone: "warn", percent: 80 })] },
128
+ loading: false,
129
+ error: null,
130
+ };
131
+
132
+ renderPanel(undefined);
133
+
134
+ expect(screen.getByText("80%").getAttribute("data-tone")).toBe("warn");
135
+ });
136
+
137
+ test("maps the danger tone to the critical StatusBadge tone", () => {
138
+ const tone: CapUsageTone = "danger";
139
+ queryState = { data: { rows: [capRow({ tone, percent: 99 })] }, loading: false, error: null };
140
+
141
+ renderPanel(undefined);
142
+
143
+ expect(screen.getByText("99%").getAttribute("data-tone")).toBe("critical");
144
+ });
145
+
146
+ test("empty rows without loading show the empty state and no cards", () => {
147
+ queryState = { data: { rows: [] }, loading: false, error: null };
148
+
149
+ renderPanel(undefined);
150
+
151
+ expect(screen.getByTestId("cap-cards-panel-empty")).toBeTruthy();
152
+ expect(screen.queryAllByTestId("cap-card")).toHaveLength(0);
153
+ });
154
+
155
+ test("a query error shows the error banner with the message and no cards", () => {
156
+ queryState = {
157
+ data: null,
158
+ loading: false,
159
+ error: {
160
+ code: "internal",
161
+ httpStatus: 500,
162
+ i18nKey: "kumiko.error.internal",
163
+ message: "caps usage failed",
164
+ },
165
+ };
166
+
167
+ renderPanel(undefined);
168
+
169
+ expect(screen.getByTestId("cap-cards-panel-error").textContent).toContain("caps usage failed");
170
+ expect(screen.queryAllByTestId("cap-card")).toHaveLength(0);
171
+ });
172
+
173
+ test("a string tenantId in filterParams is passed through to useQuery", () => {
174
+ renderPanel({ tenantId: "t-42" });
175
+
176
+ expect(useQuerySpy).toHaveBeenCalledWith(CapOverviewQueries.capsUsage, { tenantId: "t-42" });
177
+ });
178
+
179
+ test("missing or non-string tenantId in filterParams omits the tenantId key", () => {
180
+ renderPanel(undefined);
181
+ const [, undefinedFilterArgs] = useQuerySpy.mock.calls[0] as [string, Record<string, unknown>];
182
+ expect("tenantId" in undefinedFilterArgs).toBe(false);
183
+
184
+ useQuerySpy.mockClear();
185
+ renderPanel({ tenantId: 123 });
186
+ const [, numericTenantIdArgs] = useQuerySpy.mock.calls[0] as [string, Record<string, unknown>];
187
+ expect("tenantId" in numericTenantIdArgs).toBe(false);
188
+ });
189
+
190
+ test("loading with empty rows shows the loading text instead of the empty state", () => {
191
+ queryState = { data: null, loading: true, error: null };
192
+
193
+ renderPanel(undefined);
194
+
195
+ expect(screen.getByText("cap-overview.cards.loading")).toBeTruthy();
196
+ expect(screen.queryByTestId("cap-cards-panel-empty")).toBeNull();
197
+ });
198
+ });
@@ -0,0 +1,62 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ createStaticLocaleResolver,
4
+ LocaleProvider,
5
+ PrimitivesProvider,
6
+ } from "@cosmicdrift/kumiko-renderer";
7
+ import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
8
+ import { render, screen } from "@testing-library/react";
9
+ import type { ReactNode } from "react";
10
+ import type { CapUsage } from "../../types";
11
+ import { CapUsageBar } from "../cap-usage-bar";
12
+
13
+ function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
14
+ return (
15
+ <LocaleProvider resolver={createStaticLocaleResolver()}>
16
+ <PrimitivesProvider value={defaultPrimitives}>{children}</PrimitivesProvider>
17
+ </LocaleProvider>
18
+ );
19
+ }
20
+
21
+ function fillClass(): string | undefined {
22
+ return screen.getByTestId("cap-usage-bar").firstElementChild?.className;
23
+ }
24
+
25
+ describe("CapUsageBar", () => {
26
+ test("renders the danger fill class when usage is at or over the limit", () => {
27
+ const usage: CapUsage = { used: 120, limit: 100, fraction: 1.2 };
28
+ render(
29
+ <Wrapper>
30
+ <CapUsageBar usage={usage} showLabel={false} />
31
+ </Wrapper>,
32
+ );
33
+
34
+ expect(fillClass()).toContain("bg-status-critical");
35
+ expect(fillClass()).not.toContain("bg-primary");
36
+ });
37
+
38
+ test("renders the default fill class when usage is well under the limit", () => {
39
+ const usage: CapUsage = { used: 4, limit: 100, fraction: 0.04 };
40
+ render(
41
+ <Wrapper>
42
+ <CapUsageBar usage={usage} showLabel={false} />
43
+ </Wrapper>,
44
+ );
45
+
46
+ expect(fillClass()).toContain("bg-primary");
47
+ expect(fillClass()).not.toContain("bg-status-critical");
48
+ });
49
+
50
+ test("renders the warn fill class when usage is in the warn zone", () => {
51
+ const usage: CapUsage = { used: 85, limit: 100, fraction: 0.85 };
52
+ render(
53
+ <Wrapper>
54
+ <CapUsageBar usage={usage} showLabel={false} />
55
+ </Wrapper>,
56
+ );
57
+
58
+ expect(fillClass()).toContain("bg-status-warn");
59
+ expect(fillClass()).not.toContain("bg-primary");
60
+ expect(fillClass()).not.toContain("bg-status-critical");
61
+ });
62
+ });
@@ -0,0 +1,99 @@
1
+ // @runtime client
2
+ import type { ExtensionSectionProps } from "@cosmicdrift/kumiko-renderer";
3
+ import { usePrimitives, useQuery, useTranslation } from "@cosmicdrift/kumiko-renderer";
4
+ import {
5
+ EmptyState,
6
+ StatCard,
7
+ StatusBadge,
8
+ type StatusTone,
9
+ } from "@cosmicdrift/kumiko-renderer-web";
10
+ import { Database, FileText, Gauge, Hash, Mail, Users } from "lucide-react";
11
+ import type { ComponentType, ReactNode } from "react";
12
+ import { CapOverviewQueries } from "../constants";
13
+ import type { CapIconKey, CapUsageTone, CapUsageWithMeta } from "../types";
14
+ import { CapUsageBar } from "./cap-usage-bar";
15
+
16
+ type CapsUsageResponse = { readonly rows: readonly CapUsageWithMeta[] };
17
+
18
+ // caps:usage's wire tone stays "default"/"warn"/"danger" (matches the
19
+ // server-side thresholds literally); mapped here to the renderer's actual
20
+ // StatusTone vocabulary instead of inventing new CSS ("danger" has no
21
+ // StatusBadge tone equivalent — "critical" is the closer semantic fit).
22
+ const TONE_TO_STATUS: Record<CapUsageTone, StatusTone> = {
23
+ default: "muted",
24
+ warn: "warn",
25
+ danger: "critical",
26
+ };
27
+
28
+ // Matches CapIconKey (types.ts) 1:1 — extend both together.
29
+ const CAP_ICONS: Record<CapIconKey, ComponentType<{ className?: string }>> = {
30
+ file: FileText,
31
+ hash: Hash,
32
+ database: Database,
33
+ users: Users,
34
+ mail: Mail,
35
+ gauge: Gauge,
36
+ };
37
+
38
+ export function CapCardsPanel({ filterParams }: ExtensionSectionProps): ReactNode {
39
+ const t = useTranslation();
40
+ const { Banner } = usePrimitives();
41
+ const tenantId =
42
+ typeof filterParams?.["tenantId"] === "string" ? filterParams["tenantId"] : undefined;
43
+ const query = useQuery<CapsUsageResponse | null>(CapOverviewQueries.capsUsage, {
44
+ ...(tenantId !== undefined && { tenantId }),
45
+ });
46
+
47
+ if (query.error !== null) {
48
+ return (
49
+ <Banner variant="error" testId="cap-cards-panel-error">
50
+ {query.error.message}
51
+ </Banner>
52
+ );
53
+ }
54
+
55
+ const rows = query.data?.rows ?? [];
56
+
57
+ if (!query.loading && rows.length === 0) {
58
+ return <EmptyState title={t("cap-overview.cards.empty")} testId="cap-cards-panel-empty" />;
59
+ }
60
+
61
+ return (
62
+ <div
63
+ // Arbitrary bracket values (grid-cols-[repeat(...)]) never compiled:
64
+ // styles.css's Tailwind v4 @source scan only covers renderer-web/src,
65
+ // renderer/src and samples/**/src (see its comment) — bundled-features
66
+ // isn't scanned, so a class unique to this file has no generated rule
67
+ // and silently no-ops (the grid fell back to one implicit column,
68
+ // stacking every card full-width). Reusing the exact classes
69
+ // dashboard-body.tsx's own panel grid already uses guarantees the
70
+ // rule exists, since that file IS in the scanned surface.
71
+ className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"
72
+ data-testid="cap-cards-panel"
73
+ >
74
+ {query.loading && rows.length === 0 && (
75
+ <div className="text-sm text-muted-foreground">{t("cap-overview.cards.loading")}</div>
76
+ )}
77
+ {rows.map((cap) => {
78
+ const Icon = cap.icon !== undefined ? CAP_ICONS[cap.icon] : undefined;
79
+ return (
80
+ <StatCard
81
+ key={cap.id}
82
+ icon={Icon !== undefined ? <Icon className="size-4" /> : undefined}
83
+ label={t(cap.label)}
84
+ value={`${cap.used} / ${cap.limit}`}
85
+ {...(cap.accentColor !== undefined && { accentColor: cap.accentColor })}
86
+ testId="cap-card"
87
+ >
88
+ <div className="flex flex-col gap-2">
89
+ <div className="flex items-center justify-between gap-2">
90
+ <StatusBadge tone={TONE_TO_STATUS[cap.tone]}>{`${cap.percent}%`}</StatusBadge>
91
+ </div>
92
+ <CapUsageBar usage={cap} showLabel={false} />
93
+ </div>
94
+ </StatCard>
95
+ );
96
+ })}
97
+ </div>
98
+ );
99
+ }
@@ -0,0 +1,38 @@
1
+ // @runtime client
2
+ import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
3
+ import type { ReactNode } from "react";
4
+ import type { CapUsage } from "../types";
5
+ import { computeTone } from "../usage-math";
6
+
7
+ export function CapUsageBar({
8
+ usage,
9
+ showLabel = true,
10
+ }: {
11
+ readonly usage: CapUsage;
12
+ // The cap-cards-panel StatCard already shows used/limit as its headline
13
+ // value — the bar's own label would just repeat it there.
14
+ readonly showLabel?: boolean;
15
+ }): ReactNode {
16
+ const { Progress, Text, Banner } = usePrimitives();
17
+ const t = useTranslation();
18
+ return (
19
+ <div className="flex flex-col gap-1">
20
+ {Progress ? (
21
+ <Progress
22
+ value={usage.fraction}
23
+ tone={computeTone(usage.fraction)}
24
+ testId="cap-usage-bar"
25
+ />
26
+ ) : (
27
+ // Progress is typed optional only so partial CorePrimitives test
28
+ // doubles keep compiling — production always registers it via
29
+ // defaultPrimitives. If it's ever missing, say so instead of
30
+ // silently dropping the bar (it's the requested content, not decoration).
31
+ <Banner variant="error" testId="cap-usage-bar-missing-primitive">
32
+ {t("cap-overview.errors.progressPrimitiveMissing")}
33
+ </Banner>
34
+ )}
35
+ {showLabel && <Text variant="small">{`${usage.used} / ${usage.limit}`}</Text>}
36
+ </div>
37
+ );
38
+ }
@@ -0,0 +1,20 @@
1
+ // @runtime client
2
+ import type { ColumnRendererProps } from "@cosmicdrift/kumiko-renderer";
3
+ import type { ReactNode } from "react";
4
+ import type { CapUsage } from "../types";
5
+ import { CapUsageBar } from "./cap-usage-bar";
6
+
7
+ function isCapUsage(value: unknown): value is CapUsage {
8
+ return (
9
+ typeof value === "object" &&
10
+ value !== null &&
11
+ typeof (value as { used?: unknown }).used === "number" &&
12
+ typeof (value as { limit?: unknown }).limit === "number" &&
13
+ typeof (value as { fraction?: unknown }).fraction === "number"
14
+ );
15
+ }
16
+
17
+ export function CapUsageCell({ value }: ColumnRendererProps): ReactNode {
18
+ if (!isCapUsage(value)) return null;
19
+ return <CapUsageBar usage={value} />;
20
+ }
@@ -0,0 +1,27 @@
1
+ // @runtime client
2
+ import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
3
+ import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
4
+ import {
5
+ CAP_CARDS_PANEL_COMPONENT,
6
+ CAP_OVERVIEW_FEATURE,
7
+ CAP_USAGE_CELL_COMPONENT,
8
+ } from "../constants";
9
+ import { CapCardsPanel } from "./cap-cards-panel";
10
+ import { CapUsageCell } from "./cap-usage-cell";
11
+
12
+ export type CapOverviewClientOptions = {
13
+ readonly translations?: TranslationsByLocale;
14
+ };
15
+
16
+ export function capOverviewClient(options?: CapOverviewClientOptions): ClientFeatureDefinition {
17
+ return {
18
+ name: CAP_OVERVIEW_FEATURE,
19
+ columnRenderers: {
20
+ [CAP_USAGE_CELL_COMPONENT]: CapUsageCell,
21
+ },
22
+ extensionSectionComponents: {
23
+ [CAP_CARDS_PANEL_COMPONENT]: CapCardsPanel,
24
+ },
25
+ ...(options?.translations !== undefined && { translations: options.translations }),
26
+ };
27
+ }
@@ -0,0 +1,10 @@
1
+ // @runtime client
2
+ // Public exports for the browser side of the cap-overview feature.
3
+ // Consumed via `@cosmicdrift/kumiko-bundled-features/cap-overview/web` —
4
+ // the server side (createCapOverviewFeature) lives under
5
+ // `@cosmicdrift/kumiko-bundled-features/cap-overview` and has no React deps.
6
+
7
+ export { CapCardsPanel } from "./cap-cards-panel";
8
+ export { CapUsageBar } from "./cap-usage-bar";
9
+ export { CapUsageCell } from "./cap-usage-cell";
10
+ export { type CapOverviewClientOptions, capOverviewClient } from "./client-plugin";