@cosmicdrift/kumiko-bundled-features 0.240.0 → 0.241.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.
Files changed (66) hide show
  1. package/package.json +9 -9
  2. package/src/admin-shell/__tests__/admin-shell-security.integration.test.ts +16 -7
  3. package/src/admin-shell/__tests__/overview-allowlist.test.ts +30 -0
  4. package/src/admin-shell/__tests__/overview-screens.boot.test.ts +31 -23
  5. package/src/admin-shell/changes.json +6 -0
  6. package/src/admin-shell/feature.ts +60 -6
  7. package/src/admin-shell/i18n.ts +0 -7
  8. package/src/admin-shell/web/client-plugin.tsx +1 -11
  9. package/src/audit/__tests__/audit-screens.boot.test.ts +26 -6
  10. package/src/audit/changes.json +7 -0
  11. package/src/audit/feature.ts +57 -7
  12. package/src/audit/handlers/list.query.ts +50 -14
  13. package/src/audit/i18n.ts +3 -18
  14. package/src/audit/web/client-plugin.tsx +1 -7
  15. package/src/audit/web/index.ts +0 -2
  16. package/src/config/__tests__/config.integration.test.ts +31 -0
  17. package/src/config/handlers/readiness.query.ts +8 -1
  18. package/src/jobs/__tests__/jobs-screens.boot.test.ts +7 -2
  19. package/src/jobs/__tests__/jobs-security.integration.test.ts +41 -2
  20. package/src/jobs/changes.json +7 -0
  21. package/src/jobs/feature.ts +101 -9
  22. package/src/jobs/handlers/list.query.ts +21 -4
  23. package/src/jobs/handlers/trigger.write.ts +16 -3
  24. package/src/jobs/i18n.ts +3 -25
  25. package/src/jobs/index.ts +1 -0
  26. package/src/jobs/web/client-plugin.tsx +1 -7
  27. package/src/jobs/web/index.ts +0 -2
  28. package/src/tenant/__tests__/tenant.integration.test.ts +32 -0
  29. package/src/tenant/handlers/list.query.ts +1 -0
  30. package/src/tier-engine/__tests__/drift.test.ts +1 -1
  31. package/src/tier-engine/__tests__/tier-engine-screens.boot.test.ts +82 -0
  32. package/src/tier-engine/changes.json +9 -1
  33. package/src/tier-engine/constants.ts +5 -5
  34. package/src/tier-engine/feature.ts +36 -10
  35. package/src/tier-engine/i18n.ts +19 -22
  36. package/src/tier-engine/web/client-plugin.tsx +5 -11
  37. package/src/tier-engine/web/index.ts +0 -1
  38. package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +99 -0
  39. package/src/user-data-rights/changes.json +7 -0
  40. package/src/user-data-rights/constants.ts +20 -6
  41. package/src/user-data-rights/feature.ts +113 -14
  42. package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +29 -71
  43. package/src/user-data-rights/web/client-plugin.tsx +19 -21
  44. package/src/user-data-rights/web/i18n.ts +6 -11
  45. package/src/user-data-rights/web/index.ts +1 -1
  46. package/src/user-data-rights/web/privacy-center-screen.tsx +32 -245
  47. package/src/user-profile/__tests__/profile-screen.boot.test.ts +97 -0
  48. package/src/user-profile/__tests__/profile-screen.test.tsx +140 -104
  49. package/src/user-profile/changes.json +7 -0
  50. package/src/user-profile/constants.ts +15 -7
  51. package/src/user-profile/feature.ts +96 -9
  52. package/src/user-profile/i18n.ts +8 -12
  53. package/src/user-profile/web/client-plugin.ts +22 -5
  54. package/src/user-profile/web/index.ts +1 -1
  55. package/src/user-profile/web/profile-screen.tsx +122 -265
  56. package/src/admin-shell/web/overview-layout.tsx +0 -69
  57. package/src/admin-shell/web/overview-query.ts +0 -15
  58. package/src/admin-shell/web/platform-overview-screen.tsx +0 -91
  59. package/src/admin-shell/web/tenant-overview-screen.tsx +0 -91
  60. package/src/audit/__tests__/audit-log-screen.test.tsx +0 -302
  61. package/src/audit/web/audit-log-detail-screen.tsx +0 -169
  62. package/src/audit/web/audit-log-screen.tsx +0 -290
  63. package/src/jobs/web/__tests__/job-runs-screen.test.tsx +0 -225
  64. package/src/jobs/web/job-run-detail-screen.tsx +0 -181
  65. package/src/jobs/web/job-runs-screen.tsx +0 -290
  66. package/src/tier-engine/web/tier-admin-screen.tsx +0 -168
@@ -1,290 +0,0 @@
1
- // @runtime client
2
- // Paginated tenant-scoped audit log (event store). Rows link to the
3
- // audit-log-detail screen; the screen title lives in the shell breadcrumb.
4
-
5
- import {
6
- type DataTableSort,
7
- formatWhen,
8
- sortByAccessor,
9
- useDispatcher,
10
- useNav,
11
- usePrimitives,
12
- useTranslation,
13
- } from "@cosmicdrift/kumiko-renderer";
14
- import { Filter, RotateCcw } from "lucide-react";
15
- import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
16
- import { TenantQueries } from "../../tenant/constants";
17
- import {
18
- ANONYMOUS_USER_ID,
19
- AUDIT_LOG_DETAIL_SCREEN_ID,
20
- AuditQueries,
21
- SYSTEM_ACTOR_IDS,
22
- } from "../constants";
23
-
24
- type AuditRow = {
25
- readonly id: string;
26
- readonly aggregateId: string;
27
- readonly aggregateType: string;
28
- readonly type: string;
29
- readonly createdBy: string;
30
- readonly createdAt: string;
31
- readonly payload: Record<string, unknown>;
32
- };
33
-
34
- type AuditResponse = { readonly rows: readonly AuditRow[]; readonly nextBefore: string | null };
35
-
36
- type MemberRow = {
37
- readonly userId: string;
38
- readonly email: string | null;
39
- readonly displayName: string | null;
40
- };
41
-
42
- type Filters = {
43
- readonly eventType: string;
44
- readonly from: string;
45
- readonly to: string;
46
- };
47
-
48
- type State =
49
- | { readonly kind: "loading" }
50
- | { readonly kind: "error"; readonly message: string }
51
- | {
52
- readonly kind: "ready";
53
- readonly rows: readonly AuditRow[];
54
- readonly nextBefore: string | null;
55
- readonly names: ReadonlyMap<string, string>;
56
- };
57
-
58
- const EMPTY_FILTERS: Filters = { eventType: "", from: "", to: "" };
59
-
60
- function actorLabel(
61
- createdBy: string,
62
- names: ReadonlyMap<string, string>,
63
- t: (key: string) => string,
64
- ): string {
65
- if (SYSTEM_ACTOR_IDS.has(createdBy)) return t("audit.log.actor.system");
66
- if (createdBy === ANONYMOUS_USER_ID) return t("audit.log.actor.anonymous");
67
- return names.get(createdBy) ?? t("audit.log.actor.unknown");
68
- }
69
-
70
- export function AuditLogScreen(): ReactNode {
71
- const t = useTranslation();
72
- const { Banner, Button, DataTable, Field, Input, Text } = usePrimitives();
73
- const dispatcher = useDispatcher();
74
- const nav = useNav();
75
- const [state, setState] = useState<State>({ kind: "loading" });
76
- const [before, setBefore] = useState<string | undefined>(undefined);
77
- const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS);
78
- const [sort, setSort] = useState<DataTableSort | null>(null);
79
-
80
- const filtersRef = useRef(filters);
81
- filtersRef.current = filters;
82
-
83
- // Loaded once per screen lifetime, not per page — members rarely change
84
- // mid-session and refetching on every "older" click is wasted work.
85
- const membersRef = useRef<Promise<ReadonlyMap<string, string>> | null>(null);
86
-
87
- // Tenant/dispatcher identity can change without remounting — drop the cache.
88
- const dispatcherIdentityRef = useRef(dispatcher);
89
- if (dispatcherIdentityRef.current !== dispatcher) {
90
- dispatcherIdentityRef.current = dispatcher;
91
- membersRef.current = null;
92
- }
93
-
94
- const load = useCallback(
95
- async (cursor?: string, overrideFilters?: Filters): Promise<void> => {
96
- setState({ kind: "loading" });
97
- const f = overrideFilters ?? filtersRef.current;
98
- if (!membersRef.current) {
99
- const p = dispatcher.query<readonly MemberRow[]>(TenantQueries.members, {}).then((res) => {
100
- if (!res.isSuccess) {
101
- // Soft failure: clear so a later filter/page retry can refetch
102
- // instead of freezing an empty actor column for the session.
103
- if (membersRef.current === p) membersRef.current = null;
104
- return new Map<string, string>();
105
- }
106
- return new Map(res.data.map((m) => [m.userId, m.displayName ?? m.email ?? ""] as const));
107
- });
108
- membersRef.current = p;
109
- void p.catch(() => {
110
- if (membersRef.current === p) membersRef.current = null;
111
- });
112
- }
113
- const [res, names] = await Promise.all([
114
- dispatcher.query<AuditResponse>(AuditQueries.list, {
115
- limit: 50,
116
- ...(cursor !== undefined && { before: cursor }),
117
- ...(f.eventType.trim() !== "" && { eventType: f.eventType.trim() }),
118
- ...(f.from !== "" && { from: toIsoStart(f.from) }),
119
- ...(f.to !== "" && { to: toIsoEnd(f.to) }),
120
- }),
121
- membersRef.current,
122
- ]);
123
- if (!res.isSuccess) {
124
- setState({ kind: "error", message: res.error.message });
125
- return;
126
- }
127
- setState({ kind: "ready", rows: res.data.rows, nextBefore: res.data.nextBefore, names });
128
- },
129
- [dispatcher],
130
- );
131
-
132
- // kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
133
- useEffect(() => {
134
- void load(before);
135
- }, [load, before]);
136
-
137
- if (state.kind === "loading") {
138
- return (
139
- <div className="p-6" data-testid="audit-log-screen">
140
- <Text variant="small">{t("audit.log.loading")}</Text>
141
- </div>
142
- );
143
- }
144
-
145
- if (state.kind === "error") {
146
- return (
147
- <div className="p-6" data-testid="audit-log-screen">
148
- <Banner variant="error">{state.message}</Banner>
149
- </div>
150
- );
151
- }
152
-
153
- const openDetail = (id: string): void =>
154
- nav.navigate({ screenId: AUDIT_LOG_DETAIL_SCREEN_ID, entityId: id });
155
-
156
- return (
157
- <div className="w-full" data-testid="audit-log-screen">
158
- <div className="flex flex-wrap items-end gap-4 px-6 pt-6">
159
- <div className="min-w-40 max-w-64 flex-1">
160
- <Field id="audit-filter-event" label={t("audit.log.filter.eventType")}>
161
- <Input
162
- kind="text"
163
- id="audit-filter-event"
164
- name="audit-filter-event"
165
- value={filters.eventType}
166
- onChange={(v: string) => setFilters((f) => ({ ...f, eventType: v }))}
167
- />
168
- </Field>
169
- </div>
170
- <div className="min-w-40 max-w-64 flex-1">
171
- <Field id="audit-filter-from" label={t("audit.log.filter.from")}>
172
- <Input
173
- kind="date"
174
- id="audit-filter-from"
175
- name="audit-filter-from"
176
- value={filters.from}
177
- onChange={(v) => setFilters((f) => ({ ...f, from: v ?? "" }))}
178
- />
179
- </Field>
180
- </div>
181
- <div className="min-w-40 max-w-64 flex-1">
182
- <Field id="audit-filter-to" label={t("audit.log.filter.to")}>
183
- <Input
184
- kind="date"
185
- id="audit-filter-to"
186
- name="audit-filter-to"
187
- value={filters.to}
188
- onChange={(v) => setFilters((f) => ({ ...f, to: v ?? "" }))}
189
- />
190
- </Field>
191
- </div>
192
- <div className="flex gap-2">
193
- <Button
194
- type="button"
195
- variant="primary"
196
- onClick={() => {
197
- setBefore(undefined);
198
- void load(undefined);
199
- }}
200
- testId="audit-log-apply-filters"
201
- >
202
- <Filter className="h-4 w-4" aria-hidden="true" />
203
- <span>{t("audit.log.filter.apply")}</span>
204
- </Button>
205
- <Button
206
- type="button"
207
- variant="secondary"
208
- onClick={() => {
209
- setFilters(EMPTY_FILTERS);
210
- setBefore(undefined);
211
- void load(undefined, EMPTY_FILTERS);
212
- }}
213
- testId="audit-log-reset-filters"
214
- >
215
- <RotateCcw className="h-4 w-4" aria-hidden="true" />
216
- <span>{t("audit.log.filter.reset")}</span>
217
- </Button>
218
- </div>
219
- </div>
220
-
221
- <DataTable
222
- testId="audit-log-table"
223
- columns={[
224
- { field: "when", label: t("audit.log.col.when"), type: "string", sortable: true },
225
- { field: "type", label: t("audit.log.col.type"), type: "string", sortable: true },
226
- { field: "actor", label: t("audit.log.col.actor"), type: "string", sortable: false },
227
- ]}
228
- sort={sort}
229
- onSortChange={setSort}
230
- rows={sortByAccessor(state.rows, sort, SORT_ACCESSORS).map((row) => ({
231
- id: row.id,
232
- values: {
233
- when: formatWhen(row.createdAt),
234
- type: row.type,
235
- actor: actorLabel(row.createdBy, state.names, t),
236
- },
237
- }))}
238
- onRowClick={(row) => openDetail(row.id)}
239
- rowActions={[
240
- {
241
- id: "details",
242
- label: t("audit.log.details"),
243
- style: "secondary",
244
- onTrigger: (row) => openDetail(row.id),
245
- },
246
- ]}
247
- rowActionMode="inline"
248
- emptyState={<Text variant="small">{t("audit.log.empty")}</Text>}
249
- />
250
-
251
- <div className="flex gap-2 px-6 pb-6">
252
- {before !== undefined && (
253
- <Button
254
- type="button"
255
- variant="secondary"
256
- onClick={() => setBefore(undefined)}
257
- testId="audit-log-newest"
258
- >
259
- {t("audit.log.newest")}
260
- </Button>
261
- )}
262
- {state.nextBefore !== null && (
263
- <Button
264
- type="button"
265
- variant="secondary"
266
- onClick={() => setBefore(state.nextBefore ?? undefined)}
267
- testId="audit-log-older"
268
- >
269
- {t("audit.log.older")}
270
- </Button>
271
- )}
272
- </div>
273
- </div>
274
- );
275
- }
276
-
277
- // Client-sort over the loaded page (≤50 rows). createdAt is an ISO string, so
278
- // lexicographic compare is chronological. Cross-page order stays cursor-based.
279
- const SORT_ACCESSORS: Record<string, (r: AuditRow) => string> = {
280
- when: (r) => r.createdAt,
281
- type: (r) => r.type,
282
- };
283
-
284
- function toIsoStart(date: string): string {
285
- return new Date(`${date}T00:00:00.000Z`).toISOString();
286
- }
287
-
288
- function toIsoEnd(date: string): string {
289
- return new Date(`${date}T23:59:59.999Z`).toISOString();
290
- }
@@ -1,225 +0,0 @@
1
- // @runtime client
2
- // DOM test for JobRunsScreen trigger panel (#1602).
3
- // Provider wrapper is local — renderer-web → bundled-features forbids importing
4
- // renderer-web test-utils from here.
5
-
6
- // CI runs this file in its own `bun test` process via `bun run test:dom:isolated`
7
- // (excluded from shared bunfig.dom.toml) — same #457 happy-dom accumulation class
8
- // as privacy-center / deletion-screens.
9
-
10
- import { describe, expect, test } from "bun:test";
11
- import { createStore, type Dispatcher, type DispatcherStatus } from "@cosmicdrift/kumiko-headless";
12
- import {
13
- createStaticLocaleResolver,
14
- DispatcherProvider,
15
- kumikoDefaultTranslations,
16
- type LiveEventSubscriber,
17
- LiveEventsProvider,
18
- LocaleProvider,
19
- type NavApi,
20
- NavProvider,
21
- PrimitivesProvider,
22
- TokensProvider,
23
- } from "@cosmicdrift/kumiko-renderer";
24
- import { defaultPrimitives, defaultTokens } from "@cosmicdrift/kumiko-renderer-web";
25
- import { fireEvent, render, waitFor } from "@testing-library/react";
26
- import userEvent from "@testing-library/user-event";
27
- import type { ReactNode } from "react";
28
- import { JobHandlers, JobQueries } from "../../constants";
29
- import { defaultTranslations } from "../i18n";
30
- import { JobRunsScreen } from "../job-runs-screen";
31
-
32
- if (typeof globalThis.ResizeObserver === "undefined") {
33
- globalThis.ResizeObserver = class {
34
- observe(): void {}
35
- unobserve(): void {}
36
- disconnect(): void {}
37
- } as unknown as typeof ResizeObserver;
38
- }
39
-
40
- const stubLiveEvents: LiveEventSubscriber = () => () => {};
41
- const stubTokens = {
42
- tokens: defaultTokens,
43
- mode: "light" as const,
44
- setMode: () => {},
45
- toggleMode: () => {},
46
- };
47
- const stubResolver = createStaticLocaleResolver();
48
- const stubNav: NavApi = {
49
- route: undefined,
50
- navigate: () => {},
51
- replace: () => {},
52
- hrefFor: (target) => {
53
- if (!("screenId" in target)) return "";
54
- return target.entityId !== undefined
55
- ? `/${target.screenId}/${target.entityId}`
56
- : `/${target.screenId}`;
57
- },
58
- searchParams: {},
59
- setSearchParams: () => {},
60
- };
61
-
62
- type CatalogRow = {
63
- readonly jobName: string;
64
- readonly perTenant: boolean;
65
- readonly payloadSchema: Record<string, unknown> | null;
66
- };
67
-
68
- type Fixture = {
69
- readonly catalogRows?: readonly CatalogRow[];
70
- readonly listRows?: readonly Record<string, unknown>[];
71
- };
72
-
73
- function makeDispatcher(
74
- fixture: Fixture,
75
- writes: Array<{ type: string; payload: unknown }>,
76
- queries: Array<{ type: string; payload: unknown }>,
77
- ): Dispatcher {
78
- const statusStore = createStore<DispatcherStatus>("online");
79
- const query = (async (type: string, payload: unknown) => {
80
- queries.push({ type, payload });
81
- if (type === JobQueries.catalog) {
82
- return { isSuccess: true, data: { rows: fixture.catalogRows ?? [] } };
83
- }
84
- if (type === JobQueries.list) {
85
- return { isSuccess: true, data: { rows: fixture.listRows ?? [] } };
86
- }
87
- return { isSuccess: true, data: null };
88
- }) as unknown as Dispatcher["query"];
89
- const write = (async (type: string, payload: unknown) => {
90
- writes.push({ type, payload });
91
- return {
92
- isSuccess: true,
93
- data: { jobName: (payload as { jobName: string }).jobName, bullJobId: "bull-1" },
94
- };
95
- }) as unknown as Dispatcher["write"];
96
- return {
97
- write,
98
- query,
99
- batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
100
- statusStore,
101
- async *stream() {},
102
- pendingWrites: () => [],
103
- pendingFiles: () => [],
104
- } as unknown as Dispatcher; // @cast-boundary test-stub
105
- }
106
-
107
- function renderScreen(fixture: Fixture = {}): {
108
- view: ReturnType<typeof render>;
109
- writes: Array<{ type: string; payload: unknown }>;
110
- queries: Array<{ type: string; payload: unknown }>;
111
- } {
112
- const writes: Array<{ type: string; payload: unknown }> = [];
113
- const queries: Array<{ type: string; payload: unknown }> = [];
114
- const wrapper = ({ children }: { readonly children: ReactNode }): ReactNode => (
115
- <TokensProvider value={stubTokens}>
116
- <LocaleProvider
117
- resolver={stubResolver}
118
- fallbackBundles={[defaultTranslations, kumikoDefaultTranslations]}
119
- >
120
- <PrimitivesProvider value={defaultPrimitives}>
121
- <NavProvider value={stubNav}>
122
- <LiveEventsProvider value={stubLiveEvents}>
123
- <DispatcherProvider dispatcher={makeDispatcher(fixture, writes, queries)}>
124
- {children}
125
- </DispatcherProvider>
126
- </LiveEventsProvider>
127
- </NavProvider>
128
- </PrimitivesProvider>
129
- </LocaleProvider>
130
- </TokensProvider>
131
- );
132
- return { view: render(<JobRunsScreen />, { wrapper }), writes, queries };
133
- }
134
-
135
- const REINDEX: CatalogRow = {
136
- jobName: "jobs:job:reindex-entity",
137
- perTenant: true,
138
- payloadSchema: {
139
- type: "object",
140
- properties: { entity: { type: "string" } },
141
- required: ["entity"],
142
- },
143
- };
144
-
145
- describe("JobRunsScreen — manual trigger panel", () => {
146
- test("loads catalog + list via QNs and renders translated trigger UI", async () => {
147
- const { view, queries } = renderScreen({ catalogRows: [REINDEX] });
148
- await waitFor(() => {
149
- if (view.queryByTestId("job-runs-screen") === null) throw new Error("not mounted");
150
- });
151
- expect(view.getByTestId("job-trigger-panel")).toBeTruthy();
152
- expect(view.getByTestId("job-trigger-form")).toBeTruthy();
153
- expect(view.container.textContent).not.toContain("jobs.trigger.");
154
- expect(view.container.textContent).not.toContain("jobs.runs.");
155
-
156
- await waitFor(() => {
157
- const catalogHit = queries.some((q) => q.type === JobQueries.catalog);
158
- const listHit = queries.some((q) => q.type === JobQueries.list);
159
- if (!catalogHit || !listHit) throw new Error("expected catalog + list queries");
160
- });
161
- });
162
-
163
- test("empty catalog shows empty copy, no form", async () => {
164
- const { view } = renderScreen({ catalogRows: [] });
165
- await waitFor(() => {
166
- if (view.queryByTestId("job-trigger-empty") === null) throw new Error("empty missing");
167
- });
168
- expect(view.queryByTestId("job-trigger-form")).toBeNull();
169
- });
170
-
171
- test("select job + JSON payload → jobs:write:trigger", async () => {
172
- const user = userEvent.setup();
173
- const { view, writes } = renderScreen({ catalogRows: [REINDEX] });
174
- await waitFor(() => {
175
- if (view.queryByTestId("job-trigger-form") === null) throw new Error("form missing");
176
- });
177
-
178
- await user.click(view.getByTestId("combobox-job-trigger-name"));
179
- const option = await view.findByText("jobs:job:reindex-entity");
180
- await user.click(option);
181
-
182
- await waitFor(() => {
183
- if (view.queryByTestId("job-trigger-per-tenant") === null) {
184
- throw new Error("perTenant hint missing");
185
- }
186
- });
187
- expect(view.getByTestId("job-trigger-schema-hint").textContent).toContain("entity");
188
-
189
- const payload = view.container.querySelector<HTMLTextAreaElement>("#job-trigger-payload");
190
- if (!payload) throw new Error("payload textarea missing");
191
- fireEvent.change(payload, { target: { value: '{"entity":"credit"}' } });
192
-
193
- await user.click(view.getByTestId("job-trigger-submit"));
194
-
195
- await waitFor(() => {
196
- if (writes.length === 0) throw new Error("no write yet");
197
- });
198
- expect(writes[0]).toEqual({
199
- type: JobHandlers.trigger,
200
- payload: { jobName: "jobs:job:reindex-entity", payload: { entity: "credit" } },
201
- });
202
- expect(view.getByTestId("job-trigger-success")).toBeTruthy();
203
- });
204
-
205
- test("invalid JSON payload → client error, no write", async () => {
206
- const user = userEvent.setup();
207
- const { view, writes } = renderScreen({ catalogRows: [REINDEX] });
208
- await waitFor(() => {
209
- if (view.queryByTestId("job-trigger-form") === null) throw new Error("form missing");
210
- });
211
-
212
- await user.click(view.getByTestId("combobox-job-trigger-name"));
213
- await user.click(await view.findByText("jobs:job:reindex-entity"));
214
-
215
- const payload = view.container.querySelector<HTMLTextAreaElement>("#job-trigger-payload");
216
- if (!payload) throw new Error("payload textarea missing");
217
- fireEvent.change(payload, { target: { value: "{not-json" } });
218
- await user.click(view.getByTestId("job-trigger-submit"));
219
-
220
- await waitFor(() => {
221
- if (view.queryByTestId("job-trigger-error") === null) throw new Error("error missing");
222
- });
223
- expect(writes).toEqual([]);
224
- });
225
- });
@@ -1,181 +0,0 @@
1
- // @runtime client
2
- // Single job-run detail + logs. Route entityId = run uuid.
3
-
4
- import {
5
- formatWhen,
6
- useDispatcher,
7
- useNav,
8
- usePrimitives,
9
- useTranslation,
10
- } from "@cosmicdrift/kumiko-renderer";
11
- import { FormScreenShell } from "@cosmicdrift/kumiko-renderer-web";
12
- import { type ReactNode, useCallback, useEffect, useState } from "react";
13
- import { JOB_RUNS_SCREEN_ID, JobHandlers, JobQueries } from "../constants";
14
-
15
- type LogRow = {
16
- readonly level: string;
17
- readonly message: string;
18
- readonly timestamp: string;
19
- };
20
-
21
- type JobRunDetail = {
22
- readonly id: string;
23
- readonly jobName: string;
24
- readonly status: string;
25
- readonly startedAt: string;
26
- readonly finishedAt?: string | null;
27
- readonly duration?: number | null;
28
- readonly error?: string | null;
29
- readonly logs: readonly LogRow[];
30
- };
31
-
32
- type State =
33
- | { readonly kind: "loading" }
34
- | { readonly kind: "missing" }
35
- | { readonly kind: "error"; readonly message: string }
36
- | { readonly kind: "ready"; readonly run: JobRunDetail };
37
-
38
- export function JobRunDetailScreen(): ReactNode {
39
- const t = useTranslation();
40
- const { Banner, Button, Card, Text } = usePrimitives();
41
- const dispatcher = useDispatcher();
42
- const nav = useNav();
43
- const runId = nav.route?.entityId;
44
- const [state, setState] = useState<State>({ kind: "loading" });
45
- const [retrying, setRetrying] = useState(false);
46
- const [actionError, setActionError] = useState<string | null>(null);
47
-
48
- const load = useCallback(async (): Promise<void> => {
49
- if (runId === undefined || runId === "") {
50
- setState({ kind: "missing" });
51
- return;
52
- }
53
- setState({ kind: "loading" });
54
- const res = await dispatcher.query<JobRunDetail | null>(JobQueries.details, { runId });
55
- if (!res.isSuccess) {
56
- setState({ kind: "error", message: res.error.message });
57
- return;
58
- }
59
- if (res.data === null) {
60
- setState({ kind: "missing" });
61
- return;
62
- }
63
- setState({ kind: "ready", run: res.data });
64
- }, [dispatcher, runId]);
65
-
66
- // kumiko-lint-ignore no-raw-hooks Phase-3 conversion tracked in #2312
67
- useEffect(() => {
68
- void load();
69
- }, [load]);
70
-
71
- const onRetry = async (): Promise<void> => {
72
- if (runId === undefined || runId === "") return;
73
- setActionError(null);
74
- setRetrying(true);
75
- const res = await dispatcher.write(JobHandlers.retry, { runId });
76
- setRetrying(false);
77
- if (!res.isSuccess) {
78
- setActionError(res.error.message);
79
- return;
80
- }
81
- nav.navigate({ screenId: JOB_RUNS_SCREEN_ID });
82
- };
83
-
84
- if (state.kind === "loading") {
85
- return (
86
- <FormScreenShell testId="job-run-detail-screen">
87
- <Text variant="small">{t("jobs.detail.loading")}</Text>
88
- </FormScreenShell>
89
- );
90
- }
91
-
92
- if (state.kind === "missing") {
93
- return (
94
- <FormScreenShell testId="job-run-detail-screen">
95
- <Banner variant="error">{t("jobs.detail.missing")}</Banner>
96
- </FormScreenShell>
97
- );
98
- }
99
-
100
- if (state.kind === "error") {
101
- return (
102
- <FormScreenShell testId="job-run-detail-screen">
103
- <Banner variant="error">{state.message}</Banner>
104
- </FormScreenShell>
105
- );
106
- }
107
-
108
- const { run } = state;
109
-
110
- return (
111
- <FormScreenShell testId="job-run-detail-screen" className="flex flex-col gap-6">
112
- <Card slots={{ title: run.jobName }}>
113
- <dl className="grid gap-3 text-sm">
114
- <div>
115
- <dt className="font-medium">{t("jobs.detail.field.status")}</dt>
116
- <dd data-testid="job-run-status">{run.status}</dd>
117
- </div>
118
- <div>
119
- <dt className="font-medium">{t("jobs.detail.field.started")}</dt>
120
- <dd data-testid="job-run-started">{formatWhen(run.startedAt)}</dd>
121
- </div>
122
- {run.finishedAt !== undefined && run.finishedAt !== null && (
123
- <div>
124
- <dt className="font-medium">{t("jobs.detail.field.finished")}</dt>
125
- <dd data-testid="job-run-finished">{formatWhen(run.finishedAt)}</dd>
126
- </div>
127
- )}
128
- {run.duration !== undefined && run.duration !== null && (
129
- <div>
130
- <dt className="font-medium">{t("jobs.detail.field.duration")}</dt>
131
- <dd data-testid="job-run-duration">{run.duration}</dd>
132
- </div>
133
- )}
134
- <div>
135
- <dt className="font-medium">{t("jobs.detail.field.id")}</dt>
136
- <dd>
137
- <Text variant="code">{run.id}</Text>
138
- </dd>
139
- </div>
140
- {run.error !== undefined && run.error !== null && run.error !== "" && (
141
- <div>
142
- <dt className="font-medium">{t("jobs.detail.field.error")}</dt>
143
- <dd>
144
- <Banner variant="error">{run.error}</Banner>
145
- </dd>
146
- </div>
147
- )}
148
- </dl>
149
- </Card>
150
-
151
- {run.status === "failed" && (
152
- <Button
153
- type="button"
154
- variant="primary"
155
- disabled={retrying}
156
- loading={retrying}
157
- onClick={() => void onRetry()}
158
- testId="job-run-retry"
159
- >
160
- {retrying ? t("jobs.detail.retrying") : t("jobs.detail.retry")}
161
- </Button>
162
- )}
163
-
164
- {actionError !== null && <Banner variant="error">{actionError}</Banner>}
165
-
166
- <Card slots={{ title: t("jobs.detail.logs") }}>
167
- {run.logs.length === 0 ? (
168
- <Text variant="small">{t("jobs.detail.logs.empty")}</Text>
169
- ) : (
170
- <ul className="flex flex-col gap-1 font-mono text-sm" data-testid="job-run-logs">
171
- {run.logs.map((log) => (
172
- <li key={`${log.timestamp}-${log.level}-${log.message}`}>
173
- <span className="text-muted-foreground">[{log.level}]</span> {log.message}
174
- </li>
175
- ))}
176
- </ul>
177
- )}
178
- </Card>
179
- </FormScreenShell>
180
- );
181
- }