@cosmicdrift/kumiko-bundled-features 0.224.2 → 0.226.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 +11 -9
- package/src/admin-shell/__tests__/admin-shell.boot.test.ts +66 -0
- package/src/admin-shell/feature.ts +30 -3
- package/src/admin-shell/i18n.ts +2 -0
- package/src/cap-overview/__tests__/cap-overview-security.integration.test.ts +207 -0
- package/src/cap-overview/__tests__/cap-overview.boot.test.ts +120 -0
- package/src/cap-overview/__tests__/usage-math.test.ts +60 -0
- package/src/cap-overview/changes.json +8 -0
- package/src/cap-overview/constants.ts +26 -0
- package/src/cap-overview/feature.ts +77 -0
- package/src/cap-overview/handlers/caps-usage.query.ts +99 -0
- package/src/cap-overview/handlers/tenant-caps-list.query.ts +230 -0
- package/src/cap-overview/handlers/tenant-options.query.ts +33 -0
- package/src/cap-overview/i18n.ts +26 -0
- package/src/cap-overview/index.ts +20 -0
- package/src/cap-overview/screens.ts +101 -0
- package/src/cap-overview/types.ts +58 -0
- package/src/cap-overview/usage-math.ts +32 -0
- package/src/cap-overview/web/__tests__/cap-cards-panel.test.tsx +244 -0
- package/src/cap-overview/web/__tests__/cap-usage-bar.test.tsx +76 -0
- package/src/cap-overview/web/cap-cards-panel.tsx +109 -0
- package/src/cap-overview/web/cap-usage-bar.tsx +47 -0
- package/src/cap-overview/web/cap-usage-cell.tsx +20 -0
- package/src/cap-overview/web/client-plugin.tsx +27 -0
- package/src/cap-overview/web/index.ts +10 -0
- package/src/form-draft/__tests__/cleanup.integration.test.ts +51 -20
- package/src/notes-history/__tests__/notes-section.test.tsx +22 -11
- package/src/notes-history/web/notes-section.tsx +2 -2
- package/src/sessions/handlers/detail.query.ts +11 -0
- package/src/sessions/handlers/list.query.ts +14 -0
|
@@ -0,0 +1,244 @@
|
|
|
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
|
+
function nullCapRow(overrides: Partial<CapUsageWithMeta> = {}): CapUsageWithMeta {
|
|
87
|
+
return {
|
|
88
|
+
id: "cap-null",
|
|
89
|
+
label: "cap.label.null",
|
|
90
|
+
tone: "default",
|
|
91
|
+
percent: null,
|
|
92
|
+
used: null,
|
|
93
|
+
limit: 100,
|
|
94
|
+
fraction: 0,
|
|
95
|
+
...overrides,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
beforeEach(() => {
|
|
100
|
+
queryState = { data: { rows: [] }, loading: false, error: null };
|
|
101
|
+
useQuerySpy.mockClear();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("CapCardsPanel", () => {
|
|
105
|
+
test("renders one card per row, with the row's label and used/limit value", () => {
|
|
106
|
+
queryState = {
|
|
107
|
+
data: {
|
|
108
|
+
rows: [
|
|
109
|
+
capRow({ id: "c1", label: "Storage", used: 3, limit: 10 }),
|
|
110
|
+
capRow({ id: "c2", label: "Seats", used: 7, limit: 20 }),
|
|
111
|
+
],
|
|
112
|
+
},
|
|
113
|
+
loading: false,
|
|
114
|
+
error: null,
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
renderPanel(undefined);
|
|
118
|
+
|
|
119
|
+
expect(screen.getAllByTestId("cap-card")).toHaveLength(2);
|
|
120
|
+
expect(screen.getByText("Storage")).toBeTruthy();
|
|
121
|
+
expect(screen.getByText("Seats")).toBeTruthy();
|
|
122
|
+
expect(screen.getByText("3 / 10")).toBeTruthy();
|
|
123
|
+
expect(screen.getByText("7 / 20")).toBeTruthy();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("maps the default tone to the muted StatusBadge tone", () => {
|
|
127
|
+
queryState = {
|
|
128
|
+
data: { rows: [capRow({ tone: "default", percent: 12 })] },
|
|
129
|
+
loading: false,
|
|
130
|
+
error: null,
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
renderPanel(undefined);
|
|
134
|
+
|
|
135
|
+
expect(screen.getByText("12%").getAttribute("data-tone")).toBe("muted");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("maps the warn tone to the warn StatusBadge tone", () => {
|
|
139
|
+
queryState = {
|
|
140
|
+
data: { rows: [capRow({ tone: "warn", percent: 80 })] },
|
|
141
|
+
loading: false,
|
|
142
|
+
error: null,
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
renderPanel(undefined);
|
|
146
|
+
|
|
147
|
+
expect(screen.getByText("80%").getAttribute("data-tone")).toBe("warn");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("maps the danger tone to the critical StatusBadge tone", () => {
|
|
151
|
+
const tone: CapUsageTone = "danger";
|
|
152
|
+
queryState = { data: { rows: [capRow({ tone, percent: 99 })] }, loading: false, error: null };
|
|
153
|
+
|
|
154
|
+
renderPanel(undefined);
|
|
155
|
+
|
|
156
|
+
expect(screen.getByText("99%").getAttribute("data-tone")).toBe("critical");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("empty rows without loading show the empty state and no cards", () => {
|
|
160
|
+
queryState = { data: { rows: [] }, loading: false, error: null };
|
|
161
|
+
|
|
162
|
+
renderPanel(undefined);
|
|
163
|
+
|
|
164
|
+
expect(screen.getByTestId("cap-cards-panel-empty")).toBeTruthy();
|
|
165
|
+
expect(screen.queryAllByTestId("cap-card")).toHaveLength(0);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("a query error shows the error banner with the message and no cards", () => {
|
|
169
|
+
queryState = {
|
|
170
|
+
data: null,
|
|
171
|
+
loading: false,
|
|
172
|
+
error: {
|
|
173
|
+
code: "internal",
|
|
174
|
+
httpStatus: 500,
|
|
175
|
+
i18nKey: "kumiko.error.internal",
|
|
176
|
+
message: "caps usage failed",
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
renderPanel(undefined);
|
|
181
|
+
|
|
182
|
+
expect(screen.getByTestId("cap-cards-panel-error").textContent).toContain("caps usage failed");
|
|
183
|
+
expect(screen.queryAllByTestId("cap-card")).toHaveLength(0);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("a string tenantId in filterParams is passed through to useQuery", () => {
|
|
187
|
+
renderPanel({ tenantId: "t-42" });
|
|
188
|
+
|
|
189
|
+
expect(useQuerySpy).toHaveBeenCalledWith(CapOverviewQueries.capsUsage, { tenantId: "t-42" });
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("missing or non-string tenantId in filterParams omits the tenantId key", () => {
|
|
193
|
+
renderPanel(undefined);
|
|
194
|
+
const [, undefinedFilterArgs] = useQuerySpy.mock.calls[0] as [string, Record<string, unknown>];
|
|
195
|
+
expect("tenantId" in undefinedFilterArgs).toBe(false);
|
|
196
|
+
|
|
197
|
+
useQuerySpy.mockClear();
|
|
198
|
+
renderPanel({ tenantId: 123 });
|
|
199
|
+
const [, numericTenantIdArgs] = useQuerySpy.mock.calls[0] as [string, Record<string, unknown>];
|
|
200
|
+
expect("tenantId" in numericTenantIdArgs).toBe(false);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("a card with used: null shows the em-dash value and not-measured text, no bar or percent badge", () => {
|
|
204
|
+
queryState = {
|
|
205
|
+
data: { rows: [nullCapRow()] },
|
|
206
|
+
loading: false,
|
|
207
|
+
error: null,
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
renderPanel(undefined);
|
|
211
|
+
|
|
212
|
+
expect(screen.getByTestId("cap-card").textContent).toContain("—");
|
|
213
|
+
expect(screen.getByTestId("cap-usage-not-measured")).toBeTruthy();
|
|
214
|
+
expect(screen.queryByTestId("cap-usage-bar")).toBeNull();
|
|
215
|
+
expect(screen.queryByText("null%")).toBeNull();
|
|
216
|
+
expect(screen.queryByText("0%")).toBeNull();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("a null-usage card and a measured card in the same panel each render their own state", () => {
|
|
220
|
+
queryState = {
|
|
221
|
+
data: {
|
|
222
|
+
rows: [capRow({ id: "c1", label: "Storage", used: 3, limit: 10 }), nullCapRow()],
|
|
223
|
+
},
|
|
224
|
+
loading: false,
|
|
225
|
+
error: null,
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
renderPanel(undefined);
|
|
229
|
+
|
|
230
|
+
expect(screen.getAllByTestId("cap-card")).toHaveLength(2);
|
|
231
|
+
expect(screen.getByText("3 / 10")).toBeTruthy();
|
|
232
|
+
expect(screen.getByTestId("cap-usage-not-measured")).toBeTruthy();
|
|
233
|
+
expect(screen.getAllByTestId("cap-usage-bar")).toHaveLength(1);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("loading with empty rows shows the loading text instead of the empty state", () => {
|
|
237
|
+
queryState = { data: null, loading: true, error: null };
|
|
238
|
+
|
|
239
|
+
renderPanel(undefined);
|
|
240
|
+
|
|
241
|
+
expect(screen.getByText("cap-overview.cards.loading")).toBeTruthy();
|
|
242
|
+
expect(screen.queryByTestId("cap-cards-panel-empty")).toBeNull();
|
|
243
|
+
});
|
|
244
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
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 not-measured text instead of a bar when used is null", () => {
|
|
27
|
+
const usage: CapUsage = { used: null, limit: 100, fraction: 0 };
|
|
28
|
+
render(
|
|
29
|
+
<Wrapper>
|
|
30
|
+
<CapUsageBar usage={usage} showLabel={false} />
|
|
31
|
+
</Wrapper>,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
expect(screen.queryByTestId("cap-usage-bar")).toBeNull();
|
|
35
|
+
expect(screen.getByTestId("cap-usage-not-measured").textContent).toBe(
|
|
36
|
+
"cap-overview.notMeasured",
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("renders the danger fill class when usage is at or over the limit", () => {
|
|
41
|
+
const usage: CapUsage = { used: 120, limit: 100, fraction: 1.2 };
|
|
42
|
+
render(
|
|
43
|
+
<Wrapper>
|
|
44
|
+
<CapUsageBar usage={usage} showLabel={false} />
|
|
45
|
+
</Wrapper>,
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
expect(fillClass()).toContain("bg-status-critical");
|
|
49
|
+
expect(fillClass()).not.toContain("bg-primary");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("renders the default fill class when usage is well under the limit", () => {
|
|
53
|
+
const usage: CapUsage = { used: 4, limit: 100, fraction: 0.04 };
|
|
54
|
+
render(
|
|
55
|
+
<Wrapper>
|
|
56
|
+
<CapUsageBar usage={usage} showLabel={false} />
|
|
57
|
+
</Wrapper>,
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
expect(fillClass()).toContain("bg-primary");
|
|
61
|
+
expect(fillClass()).not.toContain("bg-status-critical");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("renders the warn fill class when usage is in the warn zone", () => {
|
|
65
|
+
const usage: CapUsage = { used: 85, limit: 100, fraction: 0.85 };
|
|
66
|
+
render(
|
|
67
|
+
<Wrapper>
|
|
68
|
+
<CapUsageBar usage={usage} showLabel={false} />
|
|
69
|
+
</Wrapper>,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
expect(fillClass()).toContain("bg-status-warn");
|
|
73
|
+
expect(fillClass()).not.toContain("bg-primary");
|
|
74
|
+
expect(fillClass()).not.toContain("bg-status-critical");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
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
|
+
// Narrows `percent` to non-null alongside `used` — the query handler only
|
|
39
|
+
// ever sets both or neither, but the type doesn't encode that link.
|
|
40
|
+
function isMeasured(
|
|
41
|
+
cap: CapUsageWithMeta,
|
|
42
|
+
): cap is CapUsageWithMeta & { used: number; percent: number } {
|
|
43
|
+
return cap.used !== null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function CapCardsPanel({ filterParams }: ExtensionSectionProps): ReactNode {
|
|
47
|
+
const t = useTranslation();
|
|
48
|
+
const { Banner } = usePrimitives();
|
|
49
|
+
const tenantId =
|
|
50
|
+
typeof filterParams?.["tenantId"] === "string" ? filterParams["tenantId"] : undefined;
|
|
51
|
+
const query = useQuery<CapsUsageResponse | null>(CapOverviewQueries.capsUsage, {
|
|
52
|
+
...(tenantId !== undefined && { tenantId }),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
if (query.error !== null) {
|
|
56
|
+
return (
|
|
57
|
+
<Banner variant="error" testId="cap-cards-panel-error">
|
|
58
|
+
{query.error.message}
|
|
59
|
+
</Banner>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const rows = query.data?.rows ?? [];
|
|
64
|
+
|
|
65
|
+
if (!query.loading && rows.length === 0) {
|
|
66
|
+
return <EmptyState title={t("cap-overview.cards.empty")} testId="cap-cards-panel-empty" />;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<div
|
|
71
|
+
// Arbitrary bracket values (grid-cols-[repeat(...)]) never compiled:
|
|
72
|
+
// styles.css's Tailwind v4 @source scan only covers renderer-web/src,
|
|
73
|
+
// renderer/src and samples/**/src (see its comment) — bundled-features
|
|
74
|
+
// isn't scanned, so a class unique to this file has no generated rule
|
|
75
|
+
// and silently no-ops (the grid fell back to one implicit column,
|
|
76
|
+
// stacking every card full-width). Reusing the exact classes
|
|
77
|
+
// dashboard-body.tsx's own panel grid already uses guarantees the
|
|
78
|
+
// rule exists, since that file IS in the scanned surface.
|
|
79
|
+
className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"
|
|
80
|
+
data-testid="cap-cards-panel"
|
|
81
|
+
>
|
|
82
|
+
{query.loading && rows.length === 0 && (
|
|
83
|
+
<div className="text-sm text-muted-foreground">{t("cap-overview.cards.loading")}</div>
|
|
84
|
+
)}
|
|
85
|
+
{rows.map((cap) => {
|
|
86
|
+
const Icon = cap.icon !== undefined ? CAP_ICONS[cap.icon] : undefined;
|
|
87
|
+
return (
|
|
88
|
+
<StatCard
|
|
89
|
+
key={cap.id}
|
|
90
|
+
icon={Icon !== undefined ? <Icon className="size-4" /> : undefined}
|
|
91
|
+
label={t(cap.label)}
|
|
92
|
+
value={isMeasured(cap) ? `${cap.used} / ${cap.limit}` : "—"}
|
|
93
|
+
{...(cap.accentColor !== undefined && { accentColor: cap.accentColor })}
|
|
94
|
+
testId="cap-card"
|
|
95
|
+
>
|
|
96
|
+
<div className="flex flex-col gap-2">
|
|
97
|
+
{isMeasured(cap) && (
|
|
98
|
+
<div className="flex items-center justify-between gap-2">
|
|
99
|
+
<StatusBadge tone={TONE_TO_STATUS[cap.tone]}>{`${cap.percent}%`}</StatusBadge>
|
|
100
|
+
</div>
|
|
101
|
+
)}
|
|
102
|
+
<CapUsageBar usage={cap} showLabel={false} />
|
|
103
|
+
</div>
|
|
104
|
+
</StatCard>
|
|
105
|
+
);
|
|
106
|
+
})}
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
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
|
+
|
|
19
|
+
if (usage.used === null) {
|
|
20
|
+
return (
|
|
21
|
+
<Text variant="small" testId="cap-usage-not-measured">
|
|
22
|
+
{t("cap-overview.notMeasured")}
|
|
23
|
+
</Text>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div className="flex flex-col gap-1">
|
|
29
|
+
{Progress ? (
|
|
30
|
+
<Progress
|
|
31
|
+
value={usage.fraction}
|
|
32
|
+
tone={computeTone(usage.fraction)}
|
|
33
|
+
testId="cap-usage-bar"
|
|
34
|
+
/>
|
|
35
|
+
) : (
|
|
36
|
+
// Progress is typed optional only so partial CorePrimitives test
|
|
37
|
+
// doubles keep compiling — production always registers it via
|
|
38
|
+
// defaultPrimitives. If it's ever missing, say so instead of
|
|
39
|
+
// silently dropping the bar (it's the requested content, not decoration).
|
|
40
|
+
<Banner variant="error" testId="cap-usage-bar-missing-primitive">
|
|
41
|
+
{t("cap-overview.errors.progressPrimitiveMissing")}
|
|
42
|
+
</Banner>
|
|
43
|
+
)}
|
|
44
|
+
{showLabel && <Text variant="small">{`${usage.used} / ${usage.limit}`}</Text>}
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -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
|
+
if (typeof value !== "object" || value === null) return false;
|
|
9
|
+
const used = (value as { used?: unknown }).used;
|
|
10
|
+
return (
|
|
11
|
+
(typeof used === "number" || used === null) &&
|
|
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";
|
|
@@ -244,12 +244,17 @@ describe("form-draft cleanup job — FileRef release (#1915)", () => {
|
|
|
244
244
|
|
|
245
245
|
await dispatchCleanup();
|
|
246
246
|
|
|
247
|
-
|
|
248
|
-
|
|
247
|
+
// Draft deletion and FileRef release are two separate awaited phases in
|
|
248
|
+
// cleanup.job.ts — the whole batch is deleted first, then released row
|
|
249
|
+
// by row. Waiting on draftExists only proves the delete phase finished,
|
|
250
|
+
// not the (real DB + provider) release; wait on the actual release
|
|
251
|
+
// effect instead, or this assertion races the still-in-flight release.
|
|
252
|
+
await waitFor(() => {
|
|
253
|
+
expect(providerA.keys()).not.toContain(key);
|
|
249
254
|
});
|
|
255
|
+
expect(await draftExists("wizard:stale-with-photo")).toBe(false);
|
|
250
256
|
expect(await draftExists("wizard:stale-no-photo")).toBe(false);
|
|
251
257
|
expect(await draftExists("wizard:fresh")).toBe(true);
|
|
252
|
-
expect(providerA.keys()).not.toContain(key);
|
|
253
258
|
});
|
|
254
259
|
|
|
255
260
|
test("resolves each stale row's own tenant provider — never the job's SYSTEM_TENANT_ID run-tenant, never another row's tenant", async () => {
|
|
@@ -267,15 +272,18 @@ describe("form-draft cleanup job — FileRef release (#1915)", () => {
|
|
|
267
272
|
|
|
268
273
|
await dispatchCleanup();
|
|
269
274
|
|
|
270
|
-
|
|
271
|
-
|
|
275
|
+
// Same two-phase timing as the single-tenant release test above: wait on
|
|
276
|
+
// each provider's actual release, not on draft deletion (which finishes
|
|
277
|
+
// for the whole batch before either row's release even starts).
|
|
278
|
+
await waitFor(() => {
|
|
279
|
+
expect(providerA.keys()).not.toContain(keyA);
|
|
272
280
|
});
|
|
273
|
-
await waitFor(
|
|
274
|
-
expect(
|
|
281
|
+
await waitFor(() => {
|
|
282
|
+
expect(providerB.keys()).not.toContain(keyB);
|
|
275
283
|
});
|
|
276
284
|
|
|
277
|
-
expect(
|
|
278
|
-
expect(
|
|
285
|
+
expect(await draftExists("wizard:tenant-a-stale")).toBe(false);
|
|
286
|
+
expect(await draftExists("wizard:tenant-b-stale")).toBe(false);
|
|
279
287
|
// Cross-tenant guard: neither key ever lands in the other tenant's provider.
|
|
280
288
|
expect(providerA.keys()).not.toContain(keyB);
|
|
281
289
|
expect(providerB.keys()).not.toContain(keyA);
|
|
@@ -286,15 +294,28 @@ describe("form-draft cleanup job — FileRef release (#1915)", () => {
|
|
|
286
294
|
await providerA.write(forgedKey, new Uint8Array([9, 9, 9]), "image/jpeg");
|
|
287
295
|
// Deliberately no seedFileRef — the stale draft's `values` claims a
|
|
288
296
|
// storageKey its owner never actually uploaded.
|
|
289
|
-
|
|
290
297
|
await saveDraft("wizard:stale-forged", { photo: fileRefPointer(forgedKey) });
|
|
291
|
-
await backdate("wizard:stale-forged",
|
|
298
|
+
await backdate("wizard:stale-forged", 40);
|
|
299
|
+
|
|
300
|
+
// Release sentinel: a real, legitimately-owned FileRef backdated less
|
|
301
|
+
// than the forged row, so selectStaleDraftsBatch's oldest-first order
|
|
302
|
+
// processes the forged row first. Releases are awaited sequentially in
|
|
303
|
+
// order within one job run, so observing the sentinel's release proves
|
|
304
|
+
// the forged row's (earlier) release turn already ran and correctly did
|
|
305
|
+
// nothing — a plain draftExists wait can't prove that, only that the
|
|
306
|
+
// delete phase (which precedes all releases) has finished.
|
|
307
|
+
const sentinelKey = "tenant-a/vehicle/forged-sentinel.jpg";
|
|
308
|
+
await providerA.write(sentinelKey, new Uint8Array([7]), "image/jpeg");
|
|
309
|
+
await seedFileRef(sentinelKey);
|
|
310
|
+
await saveDraft("wizard:stale-forged-sentinel", { photo: fileRefPointer(sentinelKey) });
|
|
311
|
+
await backdate("wizard:stale-forged-sentinel", 31);
|
|
292
312
|
|
|
293
313
|
await dispatchCleanup();
|
|
294
314
|
|
|
295
|
-
await waitFor(
|
|
296
|
-
expect(
|
|
315
|
+
await waitFor(() => {
|
|
316
|
+
expect(providerA.keys()).not.toContain(sentinelKey);
|
|
297
317
|
});
|
|
318
|
+
expect(await draftExists("wizard:stale-forged")).toBe(false);
|
|
298
319
|
expect(providerA.keys()).toContain(forgedKey);
|
|
299
320
|
});
|
|
300
321
|
|
|
@@ -304,18 +325,28 @@ describe("form-draft cleanup job — FileRef release (#1915)", () => {
|
|
|
304
325
|
await seedFileRef(key);
|
|
305
326
|
// The photo was uploaded when the domain entity was originally created —
|
|
306
327
|
// long before this editing session's draft. backdate() (below) also
|
|
307
|
-
// ages the draft row to
|
|
328
|
+
// ages the draft row to 40 days, so the file must be aged further still
|
|
308
329
|
// to model "predates the draft" rather than "same moment".
|
|
309
330
|
await backdateFileRef(key, 60);
|
|
310
331
|
|
|
311
332
|
await saveDraft("wizard:stale-with-prefilled-photo", { photo: fileRefPointer(key) });
|
|
312
|
-
await backdate("wizard:stale-with-prefilled-photo",
|
|
333
|
+
await backdate("wizard:stale-with-prefilled-photo", 40);
|
|
334
|
+
|
|
335
|
+
// Release sentinel — see the forged-draft-value test above for why a
|
|
336
|
+
// plain draftExists wait can't prove this row's release was actually
|
|
337
|
+
// (correctly) skipped, only that it hasn't been attempted yet.
|
|
338
|
+
const sentinelKey = "tenant-a/vehicle/prefilled-sentinel.jpg";
|
|
339
|
+
await providerA.write(sentinelKey, new Uint8Array([7]), "image/jpeg");
|
|
340
|
+
await seedFileRef(sentinelKey);
|
|
341
|
+
await saveDraft("wizard:stale-with-prefilled-sentinel", { photo: fileRefPointer(sentinelKey) });
|
|
342
|
+
await backdate("wizard:stale-with-prefilled-sentinel", 31);
|
|
313
343
|
|
|
314
344
|
await dispatchCleanup();
|
|
315
345
|
|
|
316
|
-
await waitFor(
|
|
317
|
-
expect(
|
|
346
|
+
await waitFor(() => {
|
|
347
|
+
expect(providerA.keys()).not.toContain(sentinelKey);
|
|
318
348
|
});
|
|
349
|
+
expect(await draftExists("wizard:stale-with-prefilled-photo")).toBe(false);
|
|
319
350
|
// The live domain entity this draft was editing still references `key`.
|
|
320
351
|
expect(providerA.keys()).toContain(key);
|
|
321
352
|
});
|
|
@@ -336,9 +367,9 @@ describe("form-draft cleanup job — FileRef release (#1915)", () => {
|
|
|
336
367
|
|
|
337
368
|
await dispatchCleanup();
|
|
338
369
|
|
|
339
|
-
await waitFor(
|
|
340
|
-
expect(
|
|
370
|
+
await waitFor(() => {
|
|
371
|
+
expect(providerA.keys()).not.toContain(key);
|
|
341
372
|
});
|
|
342
|
-
expect(
|
|
373
|
+
expect(await draftExists("wizard:new:create-mode-stale")).toBe(false);
|
|
343
374
|
});
|
|
344
375
|
});
|