@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 +5 -5
- package/src/__tests__/merge-search-params-into-initial.test.ts +52 -1
- package/src/__tests__/use-list-url-state.test.tsx +49 -0
- package/src/app/__tests__/list-daterange-facet.test.ts +165 -0
- package/src/app/__tests__/list-filter-facets.test.tsx +91 -1
- package/src/app/__tests__/projection-detail-reference-field.test.tsx +18 -2
- package/src/app/__tests__/projection-list-reference-column.test.tsx +67 -14
- package/src/app/__tests__/return-to.test.ts +86 -3
- package/src/app/kumiko-screen.tsx +150 -62
- package/src/app/list-facets.ts +155 -29
- package/src/app/projection-detail-shim.ts +1 -0
- package/src/app/return-to.tsx +91 -5
- package/src/app/screen-access.ts +34 -0
- package/src/app/secret-mint-body.tsx +26 -6
- package/src/app/secrets-edit-body.tsx +1 -0
- package/src/changes.json +12 -0
- package/src/components/embedded-list-field.tsx +3 -3
- package/src/components/render-field.tsx +25 -7
- package/src/components/render-list.tsx +9 -0
- package/src/hooks/use-list-url-state.ts +20 -1
- package/src/i18n-defaults.ts +1 -0
- package/src/i18n.tsx +11 -0
- package/src/index.ts +12 -1
- package/src/primitives.tsx +16 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "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.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
20
|
-
"@cosmicdrift/kumiko-types": "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.
|
|
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 {
|
|
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: [
|
|
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 {
|
|
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 ===
|
|
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
|
+
});
|