@cosmicdrift/kumiko-renderer 0.291.0 → 0.293.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__/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/kumiko-screen.tsx +48 -4
- package/src/app/list-facets.ts +155 -29
- package/src/app/screen-access.ts +34 -0
- package/src/changes.json +6 -0
- 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 +8 -0
- 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.293.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.293.0",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.293.0",
|
|
20
|
+
"@cosmicdrift/kumiko-types": "0.293.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.293.0"
|
|
32
32
|
},
|
|
33
33
|
"repository": {
|
|
34
34
|
"type": "git",
|
|
@@ -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
|
+
});
|
|
@@ -50,8 +50,9 @@ import { useDispatcher, useOptionalDispatcher } from "../context/dispatcher-cont
|
|
|
50
50
|
import { useUserRoles } from "../context/user-roles-context";
|
|
51
51
|
import { type ListSort, useListUrlState } from "../hooks/use-list-url-state";
|
|
52
52
|
import { type UseQueryResult, useQuery } from "../hooks/use-query";
|
|
53
|
-
import { useTranslation } from "../i18n";
|
|
53
|
+
import { useOptionalTimeZone, useTranslation } from "../i18n";
|
|
54
54
|
import {
|
|
55
|
+
type DataTableDateRangeFacet,
|
|
55
56
|
type DataTableFacet,
|
|
56
57
|
type DataTableRowAction,
|
|
57
58
|
statusToneForValue,
|
|
@@ -66,10 +67,14 @@ import type { FeatureSchema } from "./feature-schema";
|
|
|
66
67
|
import { buildFormSchema } from "./form-schema";
|
|
67
68
|
import { layoutFieldNames } from "./layout-fields";
|
|
68
69
|
import {
|
|
70
|
+
buildDateRangePayload,
|
|
69
71
|
buildFilterFacets,
|
|
70
72
|
buildFilterPayload,
|
|
73
|
+
clampDateRange,
|
|
71
74
|
mergeReferenceFacetOptions,
|
|
72
75
|
type ResolvedFacetSpec,
|
|
76
|
+
readDateRange,
|
|
77
|
+
resolveDateRangeFacets,
|
|
73
78
|
resolveProjectionFacetSpecs,
|
|
74
79
|
} from "./list-facets";
|
|
75
80
|
import { type NavApi, type ScreenTarget, useInitialValuesHandoff, useNav } from "./nav";
|
|
@@ -101,7 +106,7 @@ import {
|
|
|
101
106
|
runProjectionRowNavigate,
|
|
102
107
|
stringifyNavParams,
|
|
103
108
|
} from "./row-actions";
|
|
104
|
-
import { findEditScreenFor, screenAccessAllows } from "./screen-access";
|
|
109
|
+
import { findEditScreenFor, navigateTargetAllows, screenAccessAllows } from "./screen-access";
|
|
105
110
|
import { SecretMintBody } from "./secret-mint-body";
|
|
106
111
|
import { SecretsEditBody } from "./secrets-edit-body";
|
|
107
112
|
import { dispatcherErrorText, WriteFailedError } from "./write-failed-error";
|
|
@@ -2410,6 +2415,19 @@ function ProjectionListBody({
|
|
|
2410
2415
|
[urlState.filters, facetSpecs],
|
|
2411
2416
|
);
|
|
2412
2417
|
|
|
2418
|
+
// dateRange facets (fw#3104) bypass `filters` entirely — their two bounds
|
|
2419
|
+
// become the top-level query params the facet declares, resolved from a
|
|
2420
|
+
// calendar date to an instant in the viewer's zone.
|
|
2421
|
+
const timeZone = useOptionalTimeZone();
|
|
2422
|
+
const dateRangeSpecs = useMemo(
|
|
2423
|
+
() => resolveDateRangeFacets(screen.facets, effectiveTranslate),
|
|
2424
|
+
[screen.facets, effectiveTranslate],
|
|
2425
|
+
);
|
|
2426
|
+
const dateRangePayload = useMemo(
|
|
2427
|
+
() => buildDateRangePayload(dateRangeSpecs, urlState.filters, timeZone),
|
|
2428
|
+
[dateRangeSpecs, urlState.filters, timeZone],
|
|
2429
|
+
);
|
|
2430
|
+
|
|
2413
2431
|
const queryPayload = useMemo(() => {
|
|
2414
2432
|
const payload = buildListQueryPayload({
|
|
2415
2433
|
limit,
|
|
@@ -2430,6 +2448,7 @@ function ProjectionListBody({
|
|
|
2430
2448
|
if (screen.facets !== undefined && filterPayload.length > 0) {
|
|
2431
2449
|
payload["filters"] = filterPayload;
|
|
2432
2450
|
}
|
|
2451
|
+
Object.assign(payload, dateRangePayload);
|
|
2433
2452
|
return payload;
|
|
2434
2453
|
}, [
|
|
2435
2454
|
limit,
|
|
@@ -2440,6 +2459,7 @@ function ProjectionListBody({
|
|
|
2440
2459
|
screen.filter,
|
|
2441
2460
|
screen.facets,
|
|
2442
2461
|
filterPayload,
|
|
2462
|
+
dateRangePayload,
|
|
2443
2463
|
]);
|
|
2444
2464
|
|
|
2445
2465
|
const rowsQuery = useQuery<PagedRows>(screen.query, queryPayload, { live: true });
|
|
@@ -2448,6 +2468,24 @@ function ProjectionListBody({
|
|
|
2448
2468
|
() => buildFilterFacets(resolvedFacetSpecs),
|
|
2449
2469
|
[resolvedFacetSpecs],
|
|
2450
2470
|
);
|
|
2471
|
+
const dateRangeFacets = useMemo<DataTableDateRangeFacet[]>(
|
|
2472
|
+
() =>
|
|
2473
|
+
dateRangeSpecs.map((spec) => ({
|
|
2474
|
+
field: spec.field,
|
|
2475
|
+
label: spec.label,
|
|
2476
|
+
...readDateRange(urlState.filters, spec.field),
|
|
2477
|
+
})),
|
|
2478
|
+
[dateRangeSpecs, urlState.filters],
|
|
2479
|
+
);
|
|
2480
|
+
const handleDateRangeChange = useCallback(
|
|
2481
|
+
(field: string, bound: "from" | "to", value: string) => {
|
|
2482
|
+
urlState.setDateRange(
|
|
2483
|
+
field,
|
|
2484
|
+
clampDateRange(readDateRange(urlState.filters, field), bound, value),
|
|
2485
|
+
);
|
|
2486
|
+
},
|
|
2487
|
+
[urlState.setDateRange, urlState.filters],
|
|
2488
|
+
);
|
|
2451
2489
|
|
|
2452
2490
|
// Entity-Targets (fw#2228) — see EntityListBody.runNavigate for why the
|
|
2453
2491
|
// resolution happens in the NavApi impl. Unlike there: NO row["id"]
|
|
@@ -2583,8 +2621,14 @@ function ProjectionListBody({
|
|
|
2583
2621
|
{...(wrappedOnRowClick !== undefined && { onRowClick: wrappedOnRowClick })}
|
|
2584
2622
|
{...(filterFacets.length > 0 && {
|
|
2585
2623
|
filterFacets,
|
|
2586
|
-
filterValues: urlState.filters,
|
|
2587
2624
|
onFilterChange: urlState.setFilter,
|
|
2625
|
+
})}
|
|
2626
|
+
{...(dateRangeFacets.length > 0 && {
|
|
2627
|
+
dateRangeFacets,
|
|
2628
|
+
onDateRangeChange: handleDateRangeChange,
|
|
2629
|
+
})}
|
|
2630
|
+
{...((filterFacets.length > 0 || dateRangeFacets.length > 0) && {
|
|
2631
|
+
filterValues: urlState.filters,
|
|
2588
2632
|
onFilterReset: urlState.clearFilters,
|
|
2589
2633
|
})}
|
|
2590
2634
|
/>
|
|
@@ -3184,7 +3228,7 @@ function ProjectionDetailBody({
|
|
|
3184
3228
|
const testId = `kumiko-screen-projection-detail-metric-${field}`;
|
|
3185
3229
|
const navigate = metricNavigateSpec(metric);
|
|
3186
3230
|
const onPress =
|
|
3187
|
-
navigate !== undefined
|
|
3231
|
+
navigate !== undefined && navigateTargetAllows(navigate, appFeatures, userRoles)
|
|
3188
3232
|
? () => runMetricNavigate(nav, navigate, record, host)
|
|
3189
3233
|
: undefined;
|
|
3190
3234
|
return Metric !== undefined ? (
|
package/src/app/list-facets.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import type { ListFacetSpec } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
5
5
|
import { parseRefTarget } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
6
6
|
import type { Translate } from "@cosmicdrift/kumiko-headless";
|
|
7
|
+
import { Temporal } from "temporal-polyfill";
|
|
7
8
|
import type { DataTableFacet } from "../primitives";
|
|
8
9
|
|
|
9
10
|
// One resolved facet, independent of where the type info came from — an
|
|
@@ -94,40 +95,165 @@ export function resolveProjectionFacetSpecs(
|
|
|
94
95
|
): ResolvedFacetSpec[] {
|
|
95
96
|
if (facets === undefined) return [];
|
|
96
97
|
const tr = (label: string): string => (isFacetI18nKey(label) ? translate(label) : label);
|
|
97
|
-
|
|
98
|
+
// dateRange carries no option list and no `filters` entry — it resolves
|
|
99
|
+
// through resolveDateRangeFacets into its own toolbar control instead.
|
|
100
|
+
return facets.flatMap((facet): ResolvedFacetSpec[] => {
|
|
101
|
+
if (facet.type === "dateRange") return [];
|
|
98
102
|
if (facet.type === "select") {
|
|
99
|
-
return
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
103
|
+
return [
|
|
104
|
+
{
|
|
105
|
+
field: facet.field,
|
|
106
|
+
type: "select",
|
|
107
|
+
label: tr(facet.label),
|
|
108
|
+
options: facet.options.map((opt) => ({
|
|
109
|
+
value: opt.value,
|
|
110
|
+
label: tr(opt.label),
|
|
111
|
+
})),
|
|
112
|
+
},
|
|
113
|
+
];
|
|
108
114
|
}
|
|
109
115
|
if (facet.type === "boolean") {
|
|
110
|
-
return
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
116
|
+
return [
|
|
117
|
+
{
|
|
118
|
+
field: facet.field,
|
|
119
|
+
type: "boolean",
|
|
120
|
+
label: tr(facet.label),
|
|
121
|
+
options: [
|
|
122
|
+
{ value: "true", label: tr(facet.trueLabel) },
|
|
123
|
+
{ value: "false", label: tr(facet.falseLabel) },
|
|
124
|
+
],
|
|
125
|
+
},
|
|
126
|
+
];
|
|
119
127
|
}
|
|
120
128
|
const target = parseRefTarget(facet.entity, featureName);
|
|
121
|
-
return
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
129
|
+
return [
|
|
130
|
+
{
|
|
131
|
+
field: facet.field,
|
|
132
|
+
type: "reference",
|
|
133
|
+
label: tr(facet.label),
|
|
134
|
+
options: [],
|
|
135
|
+
reference: {
|
|
136
|
+
refEntity: target.entityName,
|
|
137
|
+
refFeature: target.featureName,
|
|
138
|
+
labelField: facet.labelField ?? "id",
|
|
139
|
+
},
|
|
130
140
|
},
|
|
131
|
-
|
|
141
|
+
];
|
|
132
142
|
});
|
|
133
143
|
}
|
|
144
|
+
|
|
145
|
+
// --- dateRange facet (fw#3104) ---
|
|
146
|
+
|
|
147
|
+
// A resolved dateRange facet. Kept out of ResolvedFacetSpec because it shares
|
|
148
|
+
// nothing with the option-dropdown facets: no options, no `filters` entry —
|
|
149
|
+
// the two bounds travel as named top-level query params.
|
|
150
|
+
export type ResolvedDateRangeFacet = {
|
|
151
|
+
readonly field: string;
|
|
152
|
+
readonly label: string;
|
|
153
|
+
readonly fromParam: string;
|
|
154
|
+
readonly toParam: string;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
export type DateRangeBound = "from" | "to";
|
|
158
|
+
|
|
159
|
+
export type DateRangeValue = { readonly from: string; readonly to: string };
|
|
160
|
+
|
|
161
|
+
export function resolveDateRangeFacets(
|
|
162
|
+
facets: readonly ListFacetSpec[] | undefined,
|
|
163
|
+
translate: Translate,
|
|
164
|
+
): ResolvedDateRangeFacet[] {
|
|
165
|
+
if (facets === undefined) return [];
|
|
166
|
+
return facets.flatMap((facet): ResolvedDateRangeFacet[] =>
|
|
167
|
+
facet.type === "dateRange"
|
|
168
|
+
? [
|
|
169
|
+
{
|
|
170
|
+
field: facet.field,
|
|
171
|
+
label: isFacetI18nKey(facet.label) ? translate(facet.label) : facet.label,
|
|
172
|
+
fromParam: facet.params.from,
|
|
173
|
+
toParam: facet.params.to,
|
|
174
|
+
},
|
|
175
|
+
]
|
|
176
|
+
: [],
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// The bounds live in the same `<screenId>.f.<key>` URL namespace the option
|
|
181
|
+
// facets use, so setFilter's page-reset and clearFilters cover them for free.
|
|
182
|
+
// buildFilterPayload drops them again: a dotted key resolves to no facet
|
|
183
|
+
// type, which is its "unknown field" case.
|
|
184
|
+
export function dateRangeUrlField(field: string, bound: DateRangeBound): string {
|
|
185
|
+
return `${field}.${bound}`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function readDateRange(
|
|
189
|
+
urlFilters: Readonly<Record<string, readonly string[]>>,
|
|
190
|
+
field: string,
|
|
191
|
+
): DateRangeValue {
|
|
192
|
+
return {
|
|
193
|
+
from: urlFilters[dateRangeUrlField(field, "from")]?.[0] ?? "",
|
|
194
|
+
to: urlFilters[dateRangeUrlField(field, "to")]?.[0] ?? "",
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Keeps from <= to by pushing the opposite bound along, so an inverted range
|
|
199
|
+
// can't reach the server and trip a handler's `from <= to` refine (a 422 the
|
|
200
|
+
// user would see as a broken list, not as a correction).
|
|
201
|
+
export function clampDateRange(
|
|
202
|
+
current: DateRangeValue,
|
|
203
|
+
bound: DateRangeBound,
|
|
204
|
+
value: string,
|
|
205
|
+
): DateRangeValue {
|
|
206
|
+
if (bound === "from") {
|
|
207
|
+
const to = current.to !== "" && value !== "" && value > current.to ? value : current.to;
|
|
208
|
+
return { from: value, to };
|
|
209
|
+
}
|
|
210
|
+
const from = current.from !== "" && value !== "" && value < current.from ? value : current.from;
|
|
211
|
+
return { from, to: value };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// A calendar date covers a whole day in the viewer's zone: "from the 14th"
|
|
215
|
+
// starts at that day's first instant, "to the 14th" includes everything up to
|
|
216
|
+
// its last. Going through the next day's start keeps both ends right across a
|
|
217
|
+
// DST boundary, where the day is not 24h long.
|
|
218
|
+
function dayBoundInstant(date: string, bound: DateRangeBound, timeZone: string): string | null {
|
|
219
|
+
let plainDate: Temporal.PlainDate;
|
|
220
|
+
try {
|
|
221
|
+
plainDate = Temporal.PlainDate.from(date);
|
|
222
|
+
} catch {
|
|
223
|
+
// Half-typed or hand-crafted URL value — drop the bound rather than send
|
|
224
|
+
// something the handler's ISO-datetime schema would reject.
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
const start = plainDate.toZonedDateTime(timeZone).toInstant();
|
|
228
|
+
return bound === "from"
|
|
229
|
+
? start.toString()
|
|
230
|
+
: plainDate
|
|
231
|
+
.add({ days: 1 })
|
|
232
|
+
.toZonedDateTime(timeZone)
|
|
233
|
+
.toInstant()
|
|
234
|
+
.subtract({
|
|
235
|
+
nanoseconds: 1,
|
|
236
|
+
})
|
|
237
|
+
.toString();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function buildDateRangePayload(
|
|
241
|
+
specs: readonly ResolvedDateRangeFacet[],
|
|
242
|
+
urlFilters: Readonly<Record<string, readonly string[]>>,
|
|
243
|
+
timeZone: string,
|
|
244
|
+
): Record<string, string> {
|
|
245
|
+
const out: Record<string, string> = {};
|
|
246
|
+
for (const spec of specs) {
|
|
247
|
+
const range = readDateRange(urlFilters, spec.field);
|
|
248
|
+
for (const [bound, param] of [
|
|
249
|
+
["from", spec.fromParam],
|
|
250
|
+
["to", spec.toParam],
|
|
251
|
+
] as const) {
|
|
252
|
+
const value = range[bound];
|
|
253
|
+
if (value === "") continue;
|
|
254
|
+
const instant = dayBoundInstant(value, bound, timeZone);
|
|
255
|
+
if (instant !== null) out[param] = instant;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return out;
|
|
259
|
+
}
|
package/src/app/screen-access.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
FeatureSchema,
|
|
5
5
|
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
6
6
|
import { isOpenToAllGranted } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
7
|
+
import { lastSegment } from "./qn";
|
|
7
8
|
|
|
8
9
|
// Minimal role-gate for the screen-render path (#1203 — nav filtering via
|
|
9
10
|
// filterByAccess in workspace-shell.tsx hid role-gated screens from the
|
|
@@ -23,6 +24,39 @@ export function screenAccessAllows(
|
|
|
23
24
|
return access.roles.some((role) => userRoles.includes(role));
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
// A navigate target (metric click) declares no access rule of its own — the
|
|
28
|
+
// destination screen already does, so the jump is offered exactly when the
|
|
29
|
+
// user may open where it lands (solon#424). No second copy of the rule to
|
|
30
|
+
// drift from the screen's own.
|
|
31
|
+
export function navigateTargetAllows(
|
|
32
|
+
navigate: { readonly screen?: string; readonly entity?: string },
|
|
33
|
+
appFeatures: readonly FeatureSchema[],
|
|
34
|
+
userRoles: readonly string[] | undefined,
|
|
35
|
+
): boolean {
|
|
36
|
+
const { screen, entity } = navigate;
|
|
37
|
+
// Neither set — stays on the current screen (tab switch), already allowed.
|
|
38
|
+
if (screen === undefined && entity === undefined) return true;
|
|
39
|
+
// KumikoScreen rendered outside createKumikoApp has no screen registry to
|
|
40
|
+
// resolve against; gating on an empty one would hide every jump, not the
|
|
41
|
+
// forbidden ones.
|
|
42
|
+
if (appFeatures.length === 0) return true;
|
|
43
|
+
for (const feature of appFeatures) {
|
|
44
|
+
for (const candidate of feature.screens) {
|
|
45
|
+
const isTarget =
|
|
46
|
+
entity !== undefined
|
|
47
|
+
? candidate.detailFor === entity
|
|
48
|
+
: lastSegment(candidate.id) === screen;
|
|
49
|
+
// First match IS the destination: short ids are globally unique
|
|
50
|
+
// (validateScreenShortIdCollisions) and both the router and
|
|
51
|
+
// resolveTarget take the first hit.
|
|
52
|
+
if (isTarget) return screenAccessAllows(candidate.access, userRoles);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// Unresolvable target — the boot-validator doesn't check metric navigate
|
|
56
|
+
// targets, so this is a typo'd dead jump. Don't offer it.
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
26
60
|
// Searches all mounted features, and access is part of the predicate so a
|
|
27
61
|
// role-gated first match can't hide an accessible second one.
|
|
28
62
|
export function findEditScreenFor(
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.292.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "A projectionDetail metric is only clickable when its navigate target is reachable",
|
|
6
|
+
"detail": "`MetricNavigate` declares no access rule of its own, so a metric could jump to a screen the current user is not allowed to open — the destination rendered its access-denied banner, or the target's query rejected the request server-side, after the user had already clicked. The metric now derives its gate from the destination: `navigate.screen` is resolved by short id, `navigate.entity` through the screen declaring `detailFor`, and the screen's own `access` decides whether the metric renders clickable at all. No second copy of the rule on the metric, so it cannot drift from the screen's. A `navigate` with only `tab` set stays on the record and is unaffected.\nTwo consequences worth knowing. A navigate target that resolves to no registered screen is treated as denied — the boot-validator does not check metric navigate targets, so a typo'd target used to render a clickable metric that went nowhere and now renders a plain one. And the gate needs the app-wide screen registry `createKumikoApp` provides; a `KumikoScreen` rendered outside it sees no registry and every metric stays clickable, exactly as before."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.291.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -18,6 +18,7 @@ import type { ListSort } from "../hooks/use-list-url-state";
|
|
|
18
18
|
import { type ReferenceLookupMap, useReferenceLookup } from "../hooks/use-reference-lookup";
|
|
19
19
|
import { useTranslation } from "../i18n";
|
|
20
20
|
import {
|
|
21
|
+
type DataTableDateRangeFacet,
|
|
21
22
|
type DataTableFacet,
|
|
22
23
|
type DataTableRowAction,
|
|
23
24
|
type DataTableRowActionMode,
|
|
@@ -110,6 +111,10 @@ export type RenderListProps = {
|
|
|
110
111
|
readonly onFilterChange?: (field: string, values: readonly string[]) => void;
|
|
111
112
|
/** Reset aller aktiven Facets. */
|
|
112
113
|
readonly onFilterReset?: () => void;
|
|
114
|
+
/** Time-range filters (fw#3104) — forwarded to the DataTable. */
|
|
115
|
+
readonly dateRangeFacets?: readonly DataTableDateRangeFacet[];
|
|
116
|
+
/** One bound changed (set URL state + refetch). */
|
|
117
|
+
readonly onDateRangeChange?: (field: string, bound: "from" | "to", value: string) => void;
|
|
113
118
|
/** Forwarded to `DataTableProps.chromeless` — drops the table's own card
|
|
114
119
|
* frame for a host that already provides one (relatedList in a tabs-mode
|
|
115
120
|
* section, fw#2722). Default false. */
|
|
@@ -175,6 +180,8 @@ export function RenderList(props: RenderListProps): ReactNode {
|
|
|
175
180
|
filterValues,
|
|
176
181
|
onFilterChange,
|
|
177
182
|
onFilterReset,
|
|
183
|
+
dateRangeFacets,
|
|
184
|
+
onDateRangeChange,
|
|
178
185
|
chromeless,
|
|
179
186
|
scrollBody,
|
|
180
187
|
screenPadding,
|
|
@@ -392,6 +399,8 @@ export function RenderList(props: RenderListProps): ReactNode {
|
|
|
392
399
|
{...(filterValues !== undefined && { filterValues })}
|
|
393
400
|
{...(onFilterChange !== undefined && { onFilterChange })}
|
|
394
401
|
{...(onFilterReset !== undefined && { onFilterReset })}
|
|
402
|
+
{...(dateRangeFacets !== undefined && { dateRangeFacets })}
|
|
403
|
+
{...(onDateRangeChange !== undefined && { onDateRangeChange })}
|
|
395
404
|
{...(chromeless !== undefined && { chromeless })}
|
|
396
405
|
{...(scrollBody !== undefined && { scrollBody })}
|
|
397
406
|
{...(screenPadding !== undefined && { screenPadding })}
|
|
@@ -48,6 +48,14 @@ export type ListUrlStateApi = ListUrlState & {
|
|
|
48
48
|
readonly setFilter: (field: string, values: readonly string[]) => void;
|
|
49
49
|
/** Löscht alle aktiven Faceted-Filter (Reset-Button). */
|
|
50
50
|
readonly clearFilters: () => void;
|
|
51
|
+
/** Sets both bounds of a dateRange facet in ONE params update — two
|
|
52
|
+
* setFilter calls would overwrite each other, since the second still sees
|
|
53
|
+
* the old searchParams. An empty string clears that bound. Resets the page
|
|
54
|
+
* like setFilter. */
|
|
55
|
+
readonly setDateRange: (
|
|
56
|
+
field: string,
|
|
57
|
+
next: { readonly from: string; readonly to: string },
|
|
58
|
+
) => void;
|
|
51
59
|
};
|
|
52
60
|
|
|
53
61
|
// `.` als Trenner: lesbar (`?orders.sort=name`), kollidiert nicht mit
|
|
@@ -142,11 +150,22 @@ export function useListUrlState(screenId: string): ListUrlStateApi {
|
|
|
142
150
|
[nav, screenId],
|
|
143
151
|
);
|
|
144
152
|
|
|
153
|
+
const setDateRange = useCallback(
|
|
154
|
+
(field: string, next: { readonly from: string; readonly to: string }) => {
|
|
155
|
+
nav.setSearchParams({
|
|
156
|
+
[key(screenId, `f.${field}.from`)]: next.from === "" ? null : next.from,
|
|
157
|
+
[key(screenId, `f.${field}.to`)]: next.to === "" ? null : next.to,
|
|
158
|
+
[key(screenId, "page")]: null,
|
|
159
|
+
});
|
|
160
|
+
},
|
|
161
|
+
[nav, screenId],
|
|
162
|
+
);
|
|
163
|
+
|
|
145
164
|
const clearFilters = useCallback(() => {
|
|
146
165
|
const updates: Record<string, string | null> = { [key(screenId, "page")]: null };
|
|
147
166
|
for (const field of Object.keys(filters)) updates[key(screenId, `f.${field}`)] = null;
|
|
148
167
|
nav.setSearchParams(updates);
|
|
149
168
|
}, [nav, screenId, filters]);
|
|
150
169
|
|
|
151
|
-
return { sort, q, page, filters, setSort, setQ, setPage, setFilter, clearFilters };
|
|
170
|
+
return { sort, q, page, filters, setSort, setQ, setPage, setFilter, clearFilters, setDateRange };
|
|
152
171
|
}
|
package/src/i18n-defaults.ts
CHANGED
|
@@ -67,6 +67,7 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
67
67
|
"kumiko.list.related-list-truncated":
|
|
68
68
|
"Showing the first {count} entries. More are available but not loaded — this list does not paginate.",
|
|
69
69
|
"kumiko.reference.system-tenant": "System",
|
|
70
|
+
"kumiko.reference.system-user": "System",
|
|
70
71
|
|
|
71
72
|
"kumiko.pager.status": "{from}–{to} of {total}",
|
|
72
73
|
"kumiko.pager.previousPage": "Previous page",
|
package/src/i18n.tsx
CHANGED
|
@@ -205,6 +205,17 @@ export function useOptionalLocale(): string | undefined {
|
|
|
205
205
|
return ctx === undefined ? undefined : ctx.resolver.locale();
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
+
/** Non-throwing time-zone read — "UTC" outside LocaleProvider, matching
|
|
209
|
+
* createStaticLocaleResolver's own default. */
|
|
210
|
+
export function useOptionalTimeZone(): string {
|
|
211
|
+
const ctx = useContext(LocaleContext);
|
|
212
|
+
return useSyncExternalStore(
|
|
213
|
+
(onStoreChange) => (ctx ? ctx.resolver.subscribe(onStoreChange) : () => {}),
|
|
214
|
+
() => (ctx ? ctx.resolver.timeZone() : "UTC"),
|
|
215
|
+
() => (ctx ? ctx.resolver.timeZone() : "UTC"),
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
208
219
|
/** Non-throwing translate — undefined outside LocaleProvider. */
|
|
209
220
|
export function useOptionalTranslation():
|
|
210
221
|
| ((key: string, params?: Readonly<Record<string, unknown>>) => string)
|
package/src/index.ts
CHANGED
|
@@ -64,6 +64,13 @@ export type {
|
|
|
64
64
|
export { isAppSchema, toAppSchema } from "./app/feature-schema";
|
|
65
65
|
export type { KumikoScreenProps } from "./app/kumiko-screen";
|
|
66
66
|
export { KumikoScreen, qualifyNavId, qualifyScreenId } from "./app/kumiko-screen";
|
|
67
|
+
export type { DateRangeBound, DateRangeValue, ResolvedDateRangeFacet } from "./app/list-facets";
|
|
68
|
+
export {
|
|
69
|
+
buildDateRangePayload,
|
|
70
|
+
clampDateRange,
|
|
71
|
+
readDateRange,
|
|
72
|
+
resolveDateRangeFacets,
|
|
73
|
+
} from "./app/list-facets";
|
|
67
74
|
export type {
|
|
68
75
|
NavApi,
|
|
69
76
|
NavProviderProps,
|
|
@@ -188,6 +195,7 @@ export type {
|
|
|
188
195
|
CardProps,
|
|
189
196
|
CardSlots,
|
|
190
197
|
CorePrimitives,
|
|
198
|
+
DataTableDateRangeFacet,
|
|
191
199
|
DataTableFacet,
|
|
192
200
|
DataTableProps,
|
|
193
201
|
DataTableRowAction,
|
package/src/primitives.tsx
CHANGED
|
@@ -572,6 +572,18 @@ export type DataTableFacet = {
|
|
|
572
572
|
readonly options: readonly { readonly value: string; readonly label: string }[];
|
|
573
573
|
};
|
|
574
574
|
|
|
575
|
+
// Time-range filter slot in the toolbar (fw#3104): two date inputs
|
|
576
|
+
// (from/to) instead of an options dropdown. Values are `YYYY-MM-DD` or ""
|
|
577
|
+
// (bound open) — the caller converts them into query params.
|
|
578
|
+
export type DataTableDateRangeFacet = {
|
|
579
|
+
/** The facet's namespace (its date field) — a key, not a filter value. */
|
|
580
|
+
readonly field: string;
|
|
581
|
+
/** Translated label ("When"). */
|
|
582
|
+
readonly label: string;
|
|
583
|
+
readonly from: string;
|
|
584
|
+
readonly to: string;
|
|
585
|
+
};
|
|
586
|
+
|
|
575
587
|
export type DataTableProps = {
|
|
576
588
|
readonly columns: readonly ListColumnViewModel[];
|
|
577
589
|
readonly rows: readonly ListRowViewModel[];
|
|
@@ -622,6 +634,10 @@ export type DataTableProps = {
|
|
|
622
634
|
/** Reset-Button: löscht alle aktiven Facets. Renderer zeigt ihn nur
|
|
623
635
|
* wenn mindestens ein Facet aktiv ist. */
|
|
624
636
|
readonly onFilterReset?: () => void;
|
|
637
|
+
/** Time-range filters beside the facet dropdowns (fw#3104). */
|
|
638
|
+
readonly dateRangeFacets?: readonly DataTableDateRangeFacet[];
|
|
639
|
+
/** One bound changed ("" clears it) → caller writes URL state + refetch. */
|
|
640
|
+
readonly onDateRangeChange?: (field: string, bound: "from" | "to", value: string) => void;
|
|
625
641
|
/** Pagination-State + Callback. Wenn gesetzt, rendert der Renderer
|
|
626
642
|
* einen Pager unter der Tabelle (Web: Footer-Bar mit ← 1 ... N →).
|
|
627
643
|
* total/limit/page sind 1-basiert für die UI; Server-Translation
|