@cosmicdrift/kumiko-renderer 0.290.0 → 0.292.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",
3
- "version": "0.290.0",
3
+ "version": "0.292.0",
4
4
  "description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -15,9 +15,9 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@cosmicdrift/kumiko-framework": "0.290.0",
19
- "@cosmicdrift/kumiko-headless": "0.290.0",
20
- "@cosmicdrift/kumiko-types": "0.290.0",
18
+ "@cosmicdrift/kumiko-framework": "0.292.0",
19
+ "@cosmicdrift/kumiko-headless": "0.292.0",
20
+ "@cosmicdrift/kumiko-types": "0.292.0",
21
21
  "react": "^19.2.6",
22
22
  "temporal-polyfill": "^0.3.2",
23
23
  "zod": "^4.4.3"
@@ -28,7 +28,7 @@
28
28
  "@types/react-dom": "^19.2.3",
29
29
  "jsdom": "^29.1.1",
30
30
  "react-dom": "^19.2.6",
31
- "@cosmicdrift/kumiko-locale-de": "0.290.0"
31
+ "@cosmicdrift/kumiko-locale-de": "0.292.0"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",
@@ -1,6 +1,10 @@
1
1
  import { describe, expect, spyOn, test } from "bun:test";
2
2
  import type { EntityEditScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
3
- import { mergeSearchParamsIntoInitial } from "../app/kumiko-screen";
3
+ import {
4
+ buildInitialValues,
5
+ literalCurrencyOverrides,
6
+ mergeSearchParamsIntoInitial,
7
+ } from "../app/kumiko-screen";
4
8
  import { layoutFieldNames } from "../app/layout-fields";
5
9
 
6
10
  type FieldDef = {
@@ -472,3 +476,50 @@ describe("mergeSearchParamsIntoInitial — coercion (every field URL-prefillable
472
476
  });
473
477
  });
474
478
  });
479
+
480
+ // fw#2839: an entity-less form screen passes no `defaultCurrency`, so before
481
+ // this the money branch fell through to a bare `0` the handler's zod schema
482
+ // rejected. `literalCurrencyOverrides` resolves a declared literal source with
483
+ // no query; configEdit's plain-number contract depends on getting neither.
484
+ describe("money currency sources on an entity-less form (fw#2839)", () => {
485
+ const amountField = (currency?: unknown): Record<string, FieldDef> => ({
486
+ amount: { type: "money", ...(currency !== undefined && { currency }) } as FieldDef,
487
+ });
488
+
489
+ test("a literal source seeds { amount: 0, currency } without any defaultCurrency", () => {
490
+ const fields = amountField({ kind: "literal", code: "CHF" });
491
+ const result = mergeSearchParamsIntoInitial(fields, {
492
+ searchParams: {},
493
+ urlPrefillFields: [],
494
+ moneyCurrencyOverrides: literalCurrencyOverrides(fields),
495
+ });
496
+ expect(result["amount"]).toEqual({ amount: 0, currency: "CHF" });
497
+ });
498
+
499
+ test("a literal source also carries into a URL-prefilled amount (fw#2763 path)", () => {
500
+ const fields = amountField({ kind: "literal", code: "CHF" });
501
+ const result = mergeSearchParamsIntoInitial(fields, {
502
+ searchParams: { amount: "42.50" },
503
+ urlPrefillFields: ["amount"],
504
+ moneyCurrencyOverrides: literalCurrencyOverrides(fields),
505
+ });
506
+ expect(result["amount"]).toEqual({ amount: 42.5, currency: "CHF" });
507
+ });
508
+
509
+ test("literalCurrencyOverrides ignores tenant-declared and undeclared money fields", () => {
510
+ expect(literalCurrencyOverrides(amountField({ kind: "tenant" }))).toEqual({});
511
+ expect(literalCurrencyOverrides(amountField())).toEqual({});
512
+ });
513
+
514
+ test("configEdit stays on the plain-number contract: no overrides, no currency wrapper", () => {
515
+ const fields = amountField({ kind: "literal", code: "CHF" });
516
+ expect(buildInitialValues(fields)["amount"]).toBe(0);
517
+ });
518
+
519
+ test("entityEdit is unchanged: defaultCurrency still wins where no source is declared", () => {
520
+ expect(buildInitialValues(amountField(), "USD")["amount"]).toEqual({
521
+ amount: 0,
522
+ currency: "USD",
523
+ });
524
+ });
525
+ });
@@ -183,6 +183,55 @@ describe("useListUrlState", () => {
183
183
  });
184
184
  });
185
185
 
186
+ // fw#3104: the date-range facet lives in the same .f. namespace so
187
+ // clearFilters covers it — but both bounds have to land in ONE params
188
+ // update, or the second call overwrites the first.
189
+ test("setDateRange schreibt beide Grenzen atomar + resettet page", () => {
190
+ const nav = makeNav({ "orders.page": "4" });
191
+ const { result } = renderHook(() => useListUrlState("orders"), { wrapper: wrapper(nav) });
192
+ act(() => {
193
+ result.current.setDateRange("createdAt", { from: "2020-06-10", to: "2020-06-14" });
194
+ });
195
+ expect(nav.captures).toHaveLength(1);
196
+ expect(nav.captures[0]).toEqual({
197
+ "orders.f.createdAt.from": "2020-06-10",
198
+ "orders.f.createdAt.to": "2020-06-14",
199
+ "orders.page": null,
200
+ });
201
+ });
202
+
203
+ test("setDateRange mit leerer Grenze löscht nur diesen Key", () => {
204
+ const nav = makeNav({
205
+ "orders.f.createdAt.from": "2020-06-10",
206
+ "orders.f.createdAt.to": "2020-06-14",
207
+ });
208
+ const { result } = renderHook(() => useListUrlState("orders"), { wrapper: wrapper(nav) });
209
+ act(() => {
210
+ result.current.setDateRange("createdAt", { from: "", to: "2020-06-14" });
211
+ });
212
+ expect(nav.captures[0]).toEqual({
213
+ "orders.f.createdAt.from": null,
214
+ "orders.f.createdAt.to": "2020-06-14",
215
+ "orders.page": null,
216
+ });
217
+ });
218
+
219
+ test("clearFilters löscht auch die Zeitraum-Grenzen", () => {
220
+ const nav = makeNav({
221
+ "orders.f.createdAt.from": "2020-06-10",
222
+ "orders.f.createdAt.to": "2020-06-14",
223
+ });
224
+ const { result } = renderHook(() => useListUrlState("orders"), { wrapper: wrapper(nav) });
225
+ act(() => {
226
+ result.current.clearFilters();
227
+ });
228
+ expect(nav.captures[0]).toEqual({
229
+ "orders.page": null,
230
+ "orders.f.createdAt.from": null,
231
+ "orders.f.createdAt.to": null,
232
+ });
233
+ });
234
+
186
235
  test("clearFilters löscht alle aktiven .f.<field>-Keys", () => {
187
236
  const nav = makeNav({
188
237
  "orders.f.status": "draft",
@@ -0,0 +1,165 @@
1
+ // fw#3104: a dateRange facet turns two calendar dates into the two query
2
+ // params it declares. The conversion is the whole risk surface — a calendar
3
+ // date has no time, `createdAt` is an instant, and "to the 14th" has to mean
4
+ // "through the end of the 14th" in the viewer's zone, not "at 00:00".
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import type { ListFacetSpec } from "@cosmicdrift/kumiko-framework/ui-types";
8
+ import { Temporal } from "temporal-polyfill";
9
+ import {
10
+ buildDateRangePayload,
11
+ clampDateRange,
12
+ readDateRange,
13
+ resolveDateRangeFacets,
14
+ resolveProjectionFacetSpecs,
15
+ } from "../list-facets";
16
+
17
+ const VIENNA = "Europe/Vienna";
18
+
19
+ const auditFacets: readonly ListFacetSpec[] = [
20
+ {
21
+ type: "dateRange",
22
+ field: "createdAt",
23
+ label: "audit.log.col.when",
24
+ params: { from: "from", to: "to" },
25
+ },
26
+ ];
27
+
28
+ const specs = resolveDateRangeFacets(auditFacets, (key) => key);
29
+
30
+ function payload(
31
+ filters: Record<string, readonly string[]>,
32
+ timeZone = VIENNA,
33
+ ): Record<string, string> {
34
+ return buildDateRangePayload(specs, filters, timeZone);
35
+ }
36
+
37
+ describe("dateRange facet resolution", () => {
38
+ test("resolves the declared param names, not a from/to convention", () => {
39
+ const resolved = resolveDateRangeFacets(
40
+ [
41
+ {
42
+ type: "dateRange",
43
+ field: "occurredAt",
44
+ label: "when",
45
+ params: { from: "since", to: "until" },
46
+ },
47
+ ],
48
+ (key) => key,
49
+ );
50
+ expect(resolved).toEqual([
51
+ { field: "occurredAt", label: "when", fromParam: "since", toParam: "until" },
52
+ ]);
53
+ });
54
+
55
+ test("stays out of the option-dropdown facets (no `filters` entry)", () => {
56
+ expect(resolveProjectionFacetSpecs(auditFacets, (key) => key, "audit")).toEqual([]);
57
+ });
58
+
59
+ test("reads both bounds out of the shared f.<field> URL namespace", () => {
60
+ expect(
61
+ readDateRange(
62
+ { "createdAt.from": ["2020-06-10"], "createdAt.to": ["2020-06-14"] },
63
+ "createdAt",
64
+ ),
65
+ ).toEqual({ from: "2020-06-10", to: "2020-06-14" });
66
+ expect(readDateRange({}, "createdAt")).toEqual({ from: "", to: "" });
67
+ });
68
+ });
69
+
70
+ describe("dateRange facet → query params", () => {
71
+ test("both bounds travel under the declared param names", () => {
72
+ expect(payload({ "createdAt.from": ["2020-06-10"], "createdAt.to": ["2020-06-14"] })).toEqual({
73
+ from: "2020-06-09T22:00:00Z",
74
+ to: "2020-06-14T21:59:59.999999999Z",
75
+ });
76
+ });
77
+
78
+ test("open interval: only `from` set sends only that param", () => {
79
+ expect(payload({ "createdAt.from": ["2020-06-10"] })).toEqual({ from: "2020-06-09T22:00:00Z" });
80
+ });
81
+
82
+ test("open interval: only `to` set sends only that param", () => {
83
+ expect(payload({ "createdAt.to": ["2020-06-14"] })).toEqual({
84
+ to: "2020-06-14T21:59:59.999999999Z",
85
+ });
86
+ });
87
+
88
+ test("no bounds set sends nothing", () => {
89
+ expect(payload({})).toEqual({});
90
+ });
91
+
92
+ test("a half-typed or hand-crafted URL value is dropped, not sent", () => {
93
+ expect(payload({ "createdAt.from": ["2020-06"], "createdAt.to": ["nonsense"] })).toEqual({});
94
+ });
95
+
96
+ test("bounds follow the viewer's zone", () => {
97
+ expect(payload({ "createdAt.from": ["2020-06-10"] }, "UTC")).toEqual({
98
+ from: "2020-06-10T00:00:00Z",
99
+ });
100
+ });
101
+
102
+ test("DST: the day the clock jumps forward is still bounded by its own edges", () => {
103
+ // 2020-03-29 is 23h long in Vienna — a naive +24h end-of-day would spill
104
+ // an hour into the 30th.
105
+ expect(payload({ "createdAt.from": ["2020-03-29"], "createdAt.to": ["2020-03-29"] })).toEqual({
106
+ from: "2020-03-28T23:00:00Z",
107
+ to: "2020-03-29T21:59:59.999999999Z",
108
+ });
109
+ });
110
+ });
111
+
112
+ describe("dateRange facet timezone edge (issue #3104)", () => {
113
+ test("an event at 23:59 of the `to` day falls inside the range", () => {
114
+ const { from, to } = payload({
115
+ "createdAt.from": ["2020-06-14"],
116
+ "createdAt.to": ["2020-06-14"],
117
+ });
118
+ if (from === undefined || to === undefined) throw new Error("expected both bounds");
119
+ const lateEvent = Temporal.ZonedDateTime.from(`2020-06-14T23:59:00[${VIENNA}]`).toInstant();
120
+ expect(Temporal.Instant.compare(lateEvent, Temporal.Instant.from(from))).toBeGreaterThan(0);
121
+ // The handler filters with `lte`, so the last instant of the day counts.
122
+ expect(Temporal.Instant.compare(lateEvent, Temporal.Instant.from(to))).toBeLessThanOrEqual(0);
123
+ });
124
+
125
+ test("the next day's first moment falls outside", () => {
126
+ const { to } = payload({ "createdAt.to": ["2020-06-14"] });
127
+ if (to === undefined) throw new Error("expected a `to` bound");
128
+ const nextDay = Temporal.ZonedDateTime.from(`2020-06-15T00:00:00[${VIENNA}]`).toInstant();
129
+ expect(Temporal.Instant.compare(nextDay, Temporal.Instant.from(to))).toBeGreaterThan(0);
130
+ });
131
+ });
132
+
133
+ describe("clampDateRange keeps from <= to", () => {
134
+ test("a `from` past the current `to` pushes `to` along", () => {
135
+ expect(clampDateRange({ from: "2020-06-01", to: "2020-06-10" }, "from", "2020-06-20")).toEqual({
136
+ from: "2020-06-20",
137
+ to: "2020-06-20",
138
+ });
139
+ });
140
+
141
+ test("a `to` before the current `from` pulls `from` along", () => {
142
+ expect(clampDateRange({ from: "2020-06-10", to: "2020-06-20" }, "to", "2020-06-01")).toEqual({
143
+ from: "2020-06-01",
144
+ to: "2020-06-01",
145
+ });
146
+ });
147
+
148
+ test("a valid bound leaves the other alone", () => {
149
+ expect(clampDateRange({ from: "2020-06-01", to: "2020-06-20" }, "from", "2020-06-05")).toEqual({
150
+ from: "2020-06-05",
151
+ to: "2020-06-20",
152
+ });
153
+ });
154
+
155
+ test("clearing a bound never drags the other one with it", () => {
156
+ expect(clampDateRange({ from: "2020-06-10", to: "2020-06-20" }, "from", "")).toEqual({
157
+ from: "",
158
+ to: "2020-06-20",
159
+ });
160
+ expect(clampDateRange({ from: "2020-06-10", to: "2020-06-20" }, "to", "")).toEqual({
161
+ from: "2020-06-10",
162
+ to: "",
163
+ });
164
+ });
165
+ });
@@ -106,10 +106,11 @@ function renderScreen(
106
106
  schema: FeatureSchema,
107
107
  qn: string,
108
108
  dispatcher: Dispatcher = stubDispatcher(),
109
+ timeZone = "UTC",
109
110
  ): void {
110
111
  render(
111
112
  <LocaleProvider
112
- resolver={createStaticLocaleResolver({ locale: "de-DE" })}
113
+ resolver={createStaticLocaleResolver({ locale: "de-DE", timeZone })}
113
114
  fallbackBundles={[kumikoDefaultTranslations]}
114
115
  >
115
116
  <DispatcherProvider dispatcher={dispatcher}>
@@ -426,3 +427,92 @@ describe("projectionList filter + facets (fw#2224)", () => {
426
427
  });
427
428
  });
428
429
  });
430
+
431
+ // fw#3104: a dateRange facet is the first facet that does NOT travel in
432
+ // payload.filters — its two bounds go out as the top-level params the screen
433
+ // declares. A screen may declare it as its only facet, so the wiring must not
434
+ // hang off the option-facet path.
435
+ describe("projectionList dateRange facet (fw#3104)", () => {
436
+ const dateRangeScreen: ProjectionListScreenDefinition = {
437
+ id: "event-list",
438
+ type: "projectionList",
439
+ query: "ledger:query:event:list",
440
+ columns: ["createdAt"],
441
+ facets: [
442
+ {
443
+ field: "createdAt",
444
+ type: "dateRange",
445
+ label: "When",
446
+ params: { from: "from", to: "to" },
447
+ },
448
+ ],
449
+ };
450
+ const schema: FeatureSchema = {
451
+ featureName: "ledger",
452
+ entities: {},
453
+ screens: [dateRangeScreen],
454
+ } as FeatureSchema;
455
+
456
+ test("reaches the DataTable as a dateRange facet, not as a filter dropdown", async () => {
457
+ queryCalls = [];
458
+ capturedProps = undefined;
459
+ renderScreen(schema, "ledger:screen:event-list");
460
+
461
+ await waitFor(() => expect(capturedProps).toBeDefined());
462
+ const props = getCapturedProps();
463
+ if (props === undefined) throw new Error("DataTable was not rendered");
464
+ expect(props.dateRangeFacets).toEqual([
465
+ { field: "createdAt", label: "When", from: "", to: "" },
466
+ ]);
467
+ expect(props.filterFacets).toBeUndefined();
468
+ // Reset still has to be reachable on a dateRange-only screen.
469
+ expect(props.onFilterReset).toBeDefined();
470
+ expect(queryCalls[0]?.payload).not.toHaveProperty("from");
471
+ });
472
+
473
+ test("picking a bound sends it as the declared query param, in the viewer's zone", async () => {
474
+ queryCalls = [];
475
+ capturedProps = undefined;
476
+ renderScreen(schema, "ledger:screen:event-list", stubDispatcher(), "Europe/Vienna");
477
+
478
+ await waitFor(() => expect(capturedProps).toBeDefined());
479
+ const before = queryCalls.length;
480
+ await act(async () => {
481
+ getCapturedProps()?.onDateRangeChange?.("createdAt", "to", "2020-06-14");
482
+ });
483
+ await waitFor(() => expect(queryCalls.length).toBeGreaterThan(before));
484
+ const payload = queryCalls[queryCalls.length - 1]?.payload;
485
+ // Open interval: only the bound the user picked travels.
486
+ expect(payload).toMatchObject({ to: "2020-06-14T21:59:59.999999999Z" });
487
+ expect(payload).not.toHaveProperty("from");
488
+ expect(payload).not.toHaveProperty("filters");
489
+ expect(getCapturedProps()?.dateRangeFacets).toEqual([
490
+ { field: "createdAt", label: "When", from: "", to: "2020-06-14" },
491
+ ]);
492
+ });
493
+
494
+ test("a `from` past the current `to` is clamped before it can reach the handler's refine", async () => {
495
+ queryCalls = [];
496
+ capturedProps = undefined;
497
+ renderScreen(schema, "ledger:screen:event-list", stubDispatcher(), "Europe/Vienna");
498
+
499
+ await waitFor(() => expect(capturedProps).toBeDefined());
500
+ await act(async () => {
501
+ getCapturedProps()?.onDateRangeChange?.("createdAt", "to", "2020-06-14");
502
+ });
503
+ await act(async () => {
504
+ getCapturedProps()?.onDateRangeChange?.("createdAt", "from", "2020-06-20");
505
+ });
506
+ await waitFor(() =>
507
+ expect(getCapturedProps()?.dateRangeFacets).toEqual([
508
+ { field: "createdAt", label: "When", from: "2020-06-20", to: "2020-06-20" },
509
+ ]),
510
+ );
511
+ const payload = queryCalls[queryCalls.length - 1]?.payload as Record<string, string>;
512
+ const from = payload["from"];
513
+ const to = payload["to"];
514
+ expect(from).toBeString();
515
+ expect(to).toBeString();
516
+ expect((from ?? "") <= (to ?? "")).toBe(true);
517
+ });
518
+ });
@@ -18,6 +18,8 @@ import { KumikoScreen } from "../kumiko-screen";
18
18
  import type { NavApi } from "../nav";
19
19
  import { NavProvider } from "../nav";
20
20
 
21
+ const SYSTEM_USER_ID = "00000000-0000-0000-0000-000000000000";
22
+
21
23
  const noop = (): ReactNode => null;
22
24
  const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
23
25
  // passChildren would drop testId — ReadOnlyReferenceValue/readOnlyDisplayText
@@ -55,7 +57,7 @@ function stubDispatcher(): Dispatcher {
55
57
  if (type === "sessions:query:session:detail") {
56
58
  return {
57
59
  isSuccess: true,
58
- data: { id: "sess-1", userId: "u-1", ip: "10.0.0.1" },
60
+ data: { id: "sess-1", userId: "u-1", actedBy: SYSTEM_USER_ID, ip: "10.0.0.1" },
59
61
  };
60
62
  }
61
63
  if (type === "user:query:user:list") {
@@ -84,7 +86,13 @@ const detailScreen: ProjectionDetailScreenDefinition = {
84
86
  layout: {
85
87
  sections: [
86
88
  {
87
- fields: [{ field: "userId", refEntity: "user:user", refLabelField: "displayName" }, "ip"],
89
+ fields: [
90
+ { field: "userId", refEntity: "user:user", refLabelField: "displayName" },
91
+ // Mirrors the audit-log detail actor field (fw#3103) — a system
92
+ // write has no read_users row for the bulk lookup to find.
93
+ { field: "actedBy", refEntity: "user:user", refLabelField: "displayName" },
94
+ "ip",
95
+ ],
88
96
  },
89
97
  ],
90
98
  },
@@ -137,6 +145,14 @@ describe("projectionDetail reference field resolves labels (fw#2662)", () => {
137
145
  });
138
146
  });
139
147
 
148
+ test("SYSTEM_USER_ID resolves to the system label instead of the raw id (fw#3103)", async () => {
149
+ const { getByTestId } = renderDetailScreen();
150
+
151
+ await waitFor(() => {
152
+ expect(getByTestId("field-value-actedBy").textContent).toBe("System");
153
+ });
154
+ });
155
+
140
156
  test("non-regression: a field without refEntity metadata renders its raw value unchanged", async () => {
141
157
  const { getByTestId } = renderDetailScreen();
142
158
 
@@ -6,7 +6,10 @@
6
6
  // text back off the reference column's injected runtime renderer.
7
7
 
8
8
  import { describe, expect, test } from "bun:test";
9
- import type { ProjectionListScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
9
+ import type {
10
+ ListColumnSpec,
11
+ ProjectionListScreenDefinition,
12
+ } from "@cosmicdrift/kumiko-framework/ui-types";
10
13
  import type { Dispatcher, RuntimeRenderer } from "@cosmicdrift/kumiko-headless";
11
14
  import { render, waitFor } from "@testing-library/react";
12
15
  import type { ComponentType, ReactNode } from "react";
@@ -21,6 +24,9 @@ import { NavProvider } from "../nav";
21
24
 
22
25
  const SYSTEM_TENANT_ID = "00000000-0000-4000-8000-000000000000";
23
26
  const REAL_TENANT_ID = "11111111-1111-4111-8111-111111111111";
27
+ const SYSTEM_USER_ID = "00000000-0000-0000-0000-000000000000";
28
+ const REAL_USER_ID = "22222222-2222-4222-8222-222222222222";
29
+ const DELETED_USER_ID = "33333333-3333-4333-8333-333333333333";
24
30
 
25
31
  let capturedProps: DataTableProps | undefined;
26
32
  const captureDataTable: ComponentType<DataTableProps> = (props) => {
@@ -61,8 +67,9 @@ function stubDispatcher(): Dispatcher {
61
67
  isSuccess: true,
62
68
  data: {
63
69
  rows: [
64
- { id: "row-1", tenantId: REAL_TENANT_ID },
65
- { id: "row-2", tenantId: SYSTEM_TENANT_ID },
70
+ { id: "row-1", tenantId: REAL_TENANT_ID, createdBy: REAL_USER_ID },
71
+ { id: "row-2", tenantId: SYSTEM_TENANT_ID, createdBy: SYSTEM_USER_ID },
72
+ { id: "row-3", tenantId: REAL_TENANT_ID, createdBy: DELETED_USER_ID },
66
73
  ],
67
74
  nextCursor: null,
68
75
  },
@@ -74,6 +81,12 @@ function stubDispatcher(): Dispatcher {
74
81
  data: { rows: [{ id: REAL_TENANT_ID, name: "Acme Inc" }], nextCursor: null },
75
82
  };
76
83
  }
84
+ if (type === "user:query:user:list") {
85
+ return {
86
+ isSuccess: true,
87
+ data: { rows: [{ id: REAL_USER_ID, displayName: "Ada Lovelace" }], nextCursor: null },
88
+ };
89
+ }
77
90
  return { isSuccess: true, data: { rows: [], nextCursor: null } };
78
91
  }) as unknown as Dispatcher["query"],
79
92
  batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
@@ -95,6 +108,21 @@ function buildSchema(screen: ProjectionListScreenDefinition): FeatureSchema {
95
108
  } as FeatureSchema;
96
109
  }
97
110
 
111
+ const TENANT_COLUMN: ListColumnSpec = {
112
+ field: "tenantId",
113
+ label: "delivery.log.col.tenantId",
114
+ refEntity: "tenant:tenant",
115
+ refLabelField: "name",
116
+ };
117
+
118
+ // Mirrors the audit-log actor column (fw#3103).
119
+ const ACTOR_COLUMN: ListColumnSpec = {
120
+ field: "createdBy",
121
+ label: "audit.log.col.actor",
122
+ refEntity: "user:user",
123
+ refLabelField: "displayName",
124
+ };
125
+
98
126
  const staticNav: NavApi = {
99
127
  route: { screenId: "delivery:screen:log" },
100
128
  navigate: () => {},
@@ -104,19 +132,12 @@ const staticNav: NavApi = {
104
132
  setSearchParams: () => {},
105
133
  };
106
134
 
107
- function renderLogScreen(): void {
135
+ function renderLogScreen(column: ListColumnSpec = TENANT_COLUMN): void {
108
136
  const screen: ProjectionListScreenDefinition = {
109
137
  id: "log",
110
138
  type: "projectionList",
111
139
  query: "delivery:query:log:list",
112
- columns: [
113
- {
114
- field: "tenantId",
115
- label: "delivery.log.col.tenantId",
116
- refEntity: "tenant:tenant",
117
- refLabelField: "name",
118
- },
119
- ],
140
+ columns: [column],
120
141
  };
121
142
  render(
122
143
  <LocaleProvider
@@ -134,8 +155,8 @@ function renderLogScreen(): void {
134
155
  );
135
156
  }
136
157
 
137
- function referenceRenderer(): RuntimeRenderer {
138
- const col = getCapturedProps()?.columns.find((c) => c.field === "tenantId");
158
+ function referenceRenderer(field = "tenantId"): RuntimeRenderer {
159
+ const col = getCapturedProps()?.columns.find((c) => c.field === field);
139
160
  if (typeof col?.renderer !== "function") throw new Error("reference column has no renderer yet");
140
161
  return col.renderer as RuntimeRenderer;
141
162
  }
@@ -159,3 +180,35 @@ describe("projectionList reference column resolves labels (fw#2662)", () => {
159
180
  });
160
181
  });
161
182
  });
183
+
184
+ describe("actor column resolves display names (fw#3103)", () => {
185
+ const actorCell = (id: string): unknown => referenceRenderer("createdBy")(id, { createdBy: id });
186
+
187
+ test("a real user id resolves to the display name", async () => {
188
+ capturedProps = undefined;
189
+ renderLogScreen(ACTOR_COLUMN);
190
+
191
+ await waitFor(() => {
192
+ expect(actorCell(REAL_USER_ID)).toBe("Ada Lovelace");
193
+ });
194
+ });
195
+
196
+ test("SYSTEM_USER_ID resolves to the system label", async () => {
197
+ capturedProps = undefined;
198
+ renderLogScreen(ACTOR_COLUMN);
199
+
200
+ await waitFor(() => {
201
+ expect(actorCell(SYSTEM_USER_ID)).toBe("System");
202
+ });
203
+ });
204
+
205
+ test("an unresolvable actor (deleted user) falls back to the raw id without throwing", async () => {
206
+ capturedProps = undefined;
207
+ renderLogScreen(ACTOR_COLUMN);
208
+
209
+ await waitFor(() => {
210
+ expect(actorCell(REAL_USER_ID)).toBe("Ada Lovelace");
211
+ });
212
+ expect(actorCell(DELETED_USER_ID)).toBe(DELETED_USER_ID);
213
+ });
214
+ });