@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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import type { ScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
3
3
|
import type { FeatureSchema } from "../feature-schema";
|
|
4
|
-
import { resolveReturnTarget, returnToParams } from "../return-to";
|
|
4
|
+
import { formatReturnTo, resolveReturnTarget, returnToParams, splitReturnTo } from "../return-to";
|
|
5
5
|
|
|
6
6
|
function schemaWith(featureName: string, screens: readonly ScreenDefinition[]): FeatureSchema {
|
|
7
7
|
return { featureName, entities: {}, screens };
|
|
@@ -156,12 +156,37 @@ describe("resolveReturnTarget", () => {
|
|
|
156
156
|
).toBeUndefined();
|
|
157
157
|
});
|
|
158
158
|
|
|
159
|
-
test("rejects entityId
|
|
159
|
+
test("rejects an entityId whose decoded form carries '?'", () => {
|
|
160
160
|
expect(
|
|
161
|
-
resolveReturnTarget("token-edit/abc
|
|
161
|
+
resolveReturnTarget("token-edit/abc%3Fx=1", "token-create", features, undefined),
|
|
162
162
|
).toBeUndefined();
|
|
163
163
|
});
|
|
164
164
|
|
|
165
|
+
test("a trailing host-state query resolves to the bare target", () => {
|
|
166
|
+
expect(
|
|
167
|
+
resolveReturnTarget("token-edit/abc?tab=keys", "token-create", features, undefined),
|
|
168
|
+
).toEqual({ screenId: "token-edit", entityId: "abc" });
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("a host-state query does not loosen validation", () => {
|
|
172
|
+
expect(
|
|
173
|
+
resolveReturnTarget("https://evil.com?tab=keys", "token-create", features, undefined),
|
|
174
|
+
).toBeUndefined();
|
|
175
|
+
expect(
|
|
176
|
+
resolveReturnTarget("//evil.com?tab=keys", "token-create", features, undefined),
|
|
177
|
+
).toBeUndefined();
|
|
178
|
+
expect(
|
|
179
|
+
resolveReturnTarget("token-edit/..?tab=keys", "token-create", features, undefined),
|
|
180
|
+
).toBeUndefined();
|
|
181
|
+
expect(
|
|
182
|
+
resolveReturnTarget("unknown-screen?tab=keys", "token-create", features, undefined),
|
|
183
|
+
).toBeUndefined();
|
|
184
|
+
expect(
|
|
185
|
+
resolveReturnTarget("admin-only?tab=keys", "token-create", features, undefined),
|
|
186
|
+
).toBeUndefined();
|
|
187
|
+
expect(resolveReturnTarget("?tab=keys", "token-create", features, undefined)).toBeUndefined();
|
|
188
|
+
});
|
|
189
|
+
|
|
165
190
|
test("rejects entityId with '#'", () => {
|
|
166
191
|
expect(
|
|
167
192
|
resolveReturnTarget("token-edit/abc#frag", "token-create", features, undefined),
|
|
@@ -253,3 +278,61 @@ describe("returnToParams", () => {
|
|
|
253
278
|
).toEqual({ returnTo: "token-edit/abc" });
|
|
254
279
|
});
|
|
255
280
|
});
|
|
281
|
+
|
|
282
|
+
describe("formatReturnTo / splitReturnTo", () => {
|
|
283
|
+
test("no host params formats exactly like before the snapshot existed", () => {
|
|
284
|
+
expect(formatReturnTo({ screenId: "token-list" })).toBe("token-list");
|
|
285
|
+
expect(formatReturnTo({ screenId: "token-list" }, {})).toBe("token-list");
|
|
286
|
+
expect(formatReturnTo({ screenId: "token-edit", entityId: "abc" })).toBe("token-edit/abc");
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("host params round-trip through the value", () => {
|
|
290
|
+
const raw = formatReturnTo(
|
|
291
|
+
{ screenId: "token-detail", entityId: "abc" },
|
|
292
|
+
{ tab: "keys", "orders.sort": "name", "orders.dir": "asc" },
|
|
293
|
+
);
|
|
294
|
+
expect(splitReturnTo(raw)).toEqual({
|
|
295
|
+
path: "token-detail/abc",
|
|
296
|
+
state: { tab: "keys", "orders.sort": "name", "orders.dir": "asc" },
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("empty param values are left out of the snapshot", () => {
|
|
301
|
+
expect(formatReturnTo({ screenId: "token-list" }, { q: "", tab: "keys" })).toBe(
|
|
302
|
+
"token-list?tab=keys",
|
|
303
|
+
);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("two nesting levels round-trip one level per split", () => {
|
|
307
|
+
const inner = formatReturnTo({ screenId: "token-list" }, { returnTo: "settings" });
|
|
308
|
+
const outer = formatReturnTo({ screenId: "token-edit", entityId: "abc" }, { returnTo: inner });
|
|
309
|
+
|
|
310
|
+
const first = splitReturnTo(outer);
|
|
311
|
+
expect(first.path).toBe("token-edit/abc");
|
|
312
|
+
expect(first.state["returnTo"]).toBe("token-list?returnTo=settings");
|
|
313
|
+
|
|
314
|
+
const second = splitReturnTo(first.state["returnTo"] as string);
|
|
315
|
+
expect(second.path).toBe("token-list");
|
|
316
|
+
expect(second.state).toEqual({ returnTo: "settings" });
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("a fourth level is dropped, the rest of the snapshot survives", () => {
|
|
320
|
+
const level3 = formatReturnTo(
|
|
321
|
+
{ screenId: "token-list" },
|
|
322
|
+
{ returnTo: formatReturnTo({ screenId: "token-detail" }, { returnTo: "settings" }) },
|
|
323
|
+
);
|
|
324
|
+
const level4 = formatReturnTo({ screenId: "token-edit" }, { returnTo: level3, tab: "keys" });
|
|
325
|
+
|
|
326
|
+
expect(splitReturnTo(level4)).toEqual({ path: "token-edit", state: { tab: "keys" } });
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("an over-long snapshot degrades to the bare target", () => {
|
|
330
|
+
expect(formatReturnTo({ screenId: "token-list" }, { q: "x".repeat(600) })).toBe("token-list");
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("returnToParams carries the host snapshot", () => {
|
|
334
|
+
expect(
|
|
335
|
+
returnToParams({ screenId: "token-list" }, { screenId: "token-create" }, { tab: "keys" }),
|
|
336
|
+
).toEqual({ returnTo: "token-list?tab=keys" });
|
|
337
|
+
});
|
|
338
|
+
});
|
|
@@ -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";
|
|
@@ -82,6 +87,8 @@ import { lastSegment, toKebab } from "./qn";
|
|
|
82
87
|
import { featureNameFromQualifiedScreenId, qualifyScreenId } from "./qualify-screen-id";
|
|
83
88
|
import { ReferenceFacetBridges, type ReferenceFacetOption } from "./reference-facet-bridge";
|
|
84
89
|
import {
|
|
90
|
+
navigateToReturn,
|
|
91
|
+
navigateToReturnOr,
|
|
85
92
|
navigateWithReturnTo,
|
|
86
93
|
type ReturnHost,
|
|
87
94
|
ReturnHostProvider,
|
|
@@ -99,7 +106,7 @@ import {
|
|
|
99
106
|
runProjectionRowNavigate,
|
|
100
107
|
stringifyNavParams,
|
|
101
108
|
} from "./row-actions";
|
|
102
|
-
import { findEditScreenFor, screenAccessAllows } from "./screen-access";
|
|
109
|
+
import { findEditScreenFor, navigateTargetAllows, screenAccessAllows } from "./screen-access";
|
|
103
110
|
import { SecretMintBody } from "./secret-mint-body";
|
|
104
111
|
import { SecretsEditBody } from "./secrets-edit-body";
|
|
105
112
|
import { dispatcherErrorText, WriteFailedError } from "./write-failed-error";
|
|
@@ -487,6 +494,63 @@ function resolveTenantCurrency(
|
|
|
487
494
|
return { status: "ready", currency: typeof raw === "string" && raw !== "" ? raw : fallback };
|
|
488
495
|
}
|
|
489
496
|
|
|
497
|
+
// An entity-less form screen has no `entity.defaultCurrency` to fall back on,
|
|
498
|
+
// so a tenant-declared field whose config query errors or returns no value
|
|
499
|
+
// lands here — same last resort as the entityEdit path's `?? "EUR"`.
|
|
500
|
+
const ACTION_FORM_CURRENCY_FALLBACK = "EUR";
|
|
501
|
+
|
|
502
|
+
// A money field naming a fixed ISO code (`currency: { kind: "literal", code }`,
|
|
503
|
+
// fw#2839) resolves with no query at all. Read structurally, same idiom as
|
|
504
|
+
// tenantCurrencyMoneyFieldNames above.
|
|
505
|
+
export function literalCurrencyOverrides(
|
|
506
|
+
fields: Readonly<Record<string, unknown>>,
|
|
507
|
+
): Readonly<Record<string, string>> {
|
|
508
|
+
const out: Record<string, string> = {};
|
|
509
|
+
for (const [name, def] of Object.entries(fields)) {
|
|
510
|
+
const shape = def as {
|
|
511
|
+
readonly type?: string;
|
|
512
|
+
readonly currency?: { readonly kind?: string; readonly code?: string };
|
|
513
|
+
};
|
|
514
|
+
const code = shape.currency?.code;
|
|
515
|
+
if (shape.type === "money" && shape.currency?.kind === "literal" && code !== undefined) {
|
|
516
|
+
out[name] = code;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return out;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Per-field currency for the money fields that declare their own source: a
|
|
523
|
+
// literal code resolves synchronously (fw#2839), a `{ kind: "tenant" }` field
|
|
524
|
+
// needs the tenant-settings config query and makes the caller hold the form
|
|
525
|
+
// until it lands (fw#2933) — seeding the fallback meanwhile would render, and
|
|
526
|
+
// on a fast click submit, the wrong currency. Callers pass the tenant field
|
|
527
|
+
// names themselves because an edit form only needs the fetch for fields the
|
|
528
|
+
// record has no stored value for.
|
|
529
|
+
function useMoneyCurrencyOverrides(
|
|
530
|
+
fields: Readonly<Record<string, unknown>>,
|
|
531
|
+
tenantFieldNames: readonly string[],
|
|
532
|
+
fallback: string,
|
|
533
|
+
): {
|
|
534
|
+
readonly overrides: Readonly<Record<string, string>> | undefined;
|
|
535
|
+
readonly loading: boolean;
|
|
536
|
+
} {
|
|
537
|
+
const query = useQuery<TenantConfigValuesResponse>(
|
|
538
|
+
"config:query:values",
|
|
539
|
+
{},
|
|
540
|
+
{ enabled: tenantFieldNames.length > 0 },
|
|
541
|
+
);
|
|
542
|
+
const resolution = resolveTenantCurrency(tenantFieldNames, query, fallback);
|
|
543
|
+
const resolvedTenantCurrency = resolution.status === "ready" ? resolution.currency : undefined;
|
|
544
|
+
const overrides = useMemo(() => {
|
|
545
|
+
const out: Record<string, string> = { ...literalCurrencyOverrides(fields) };
|
|
546
|
+
if (resolvedTenantCurrency !== undefined) {
|
|
547
|
+
for (const name of tenantFieldNames) out[name] = resolvedTenantCurrency;
|
|
548
|
+
}
|
|
549
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
550
|
+
}, [fields, tenantFieldNames, resolvedTenantCurrency]);
|
|
551
|
+
return { overrides, loading: resolution.status === "loading" };
|
|
552
|
+
}
|
|
553
|
+
|
|
490
554
|
function multiSelectOptionValues(shape: {
|
|
491
555
|
readonly options?: readonly (string | { readonly value: string })[];
|
|
492
556
|
}): ReadonlySet<string> | undefined {
|
|
@@ -857,26 +921,11 @@ function EntityEditCreateBody({
|
|
|
857
921
|
() => tenantCurrencyMoneyFieldNames(entity.fields),
|
|
858
922
|
[entity.fields],
|
|
859
923
|
);
|
|
860
|
-
const
|
|
861
|
-
|
|
862
|
-
"config:query:values",
|
|
863
|
-
{},
|
|
864
|
-
{ enabled: needsTenantCurrency },
|
|
865
|
-
);
|
|
866
|
-
const tenantCurrencyResolution = resolveTenantCurrency(
|
|
924
|
+
const { overrides: moneyCurrencyOverrides, loading: currencyLoading } = useMoneyCurrencyOverrides(
|
|
925
|
+
entity.fields,
|
|
867
926
|
tenantCurrencyFieldNames,
|
|
868
|
-
tenantCurrencyQuery,
|
|
869
927
|
entityDefaultCurrency,
|
|
870
928
|
);
|
|
871
|
-
const resolvedTenantCurrency =
|
|
872
|
-
tenantCurrencyResolution.status === "ready" ? tenantCurrencyResolution.currency : undefined;
|
|
873
|
-
const moneyCurrencyOverrides = useMemo(
|
|
874
|
-
() =>
|
|
875
|
-
resolvedTenantCurrency !== undefined
|
|
876
|
-
? Object.fromEntries(tenantCurrencyFieldNames.map((name) => [name, resolvedTenantCurrency]))
|
|
877
|
-
: undefined,
|
|
878
|
-
[tenantCurrencyFieldNames, resolvedTenantCurrency],
|
|
879
|
-
);
|
|
880
929
|
const initial = useMemo(
|
|
881
930
|
() =>
|
|
882
931
|
mergeSearchParamsIntoInitial(entity.fields, {
|
|
@@ -900,7 +949,7 @@ function EntityEditCreateBody({
|
|
|
900
949
|
const writeCommand = entityWriteCommand(schema.featureName, screen.entity, "create");
|
|
901
950
|
const navigateToList = useNavigateToListAfter(schema, screen.entity);
|
|
902
951
|
const handleCancel = useCallback(
|
|
903
|
-
() =>
|
|
952
|
+
() => navigateToReturnOr(nav, returnTarget, navigateToList),
|
|
904
953
|
[nav, returnTarget, navigateToList],
|
|
905
954
|
);
|
|
906
955
|
// Create's write-handler success payload doesn't flatly expose a parent FK
|
|
@@ -916,7 +965,7 @@ function EntityEditCreateBody({
|
|
|
916
965
|
const redirect = screen.redirect;
|
|
917
966
|
if (redirect !== undefined) {
|
|
918
967
|
if (returnToWinsOverRedirect(returnTarget, redirect, schema, appFeatures)) {
|
|
919
|
-
nav
|
|
968
|
+
navigateToReturn(nav, returnTarget);
|
|
920
969
|
} else if (typeof redirect === "string") {
|
|
921
970
|
// String form unchanged: always carries the newly created
|
|
922
971
|
// record's own id, regardless of the target screen's type. The
|
|
@@ -938,7 +987,7 @@ function EntityEditCreateBody({
|
|
|
938
987
|
nav.navigate({ screenId, ...(entityId !== undefined && { entityId }) });
|
|
939
988
|
}
|
|
940
989
|
} else {
|
|
941
|
-
|
|
990
|
+
navigateToReturnOr(nav, returnTarget, navigateToList);
|
|
942
991
|
onSaved?.();
|
|
943
992
|
}
|
|
944
993
|
},
|
|
@@ -946,7 +995,7 @@ function EntityEditCreateBody({
|
|
|
946
995
|
);
|
|
947
996
|
// Never seed a tenant-declared money field with entityDefaultCurrency while
|
|
948
997
|
// its real tenant currency is still in flight (fw#2933) — wait instead.
|
|
949
|
-
if (
|
|
998
|
+
if (currencyLoading) {
|
|
950
999
|
return (
|
|
951
1000
|
<Banner padded variant="loading" testId="kumiko-screen-loading">
|
|
952
1001
|
Loading…
|
|
@@ -1109,26 +1158,11 @@ function EntityEditUpdateForm({
|
|
|
1109
1158
|
),
|
|
1110
1159
|
[entity.fields, record],
|
|
1111
1160
|
);
|
|
1112
|
-
const
|
|
1113
|
-
|
|
1114
|
-
"config:query:values",
|
|
1115
|
-
{},
|
|
1116
|
-
{ enabled: needsTenantCurrency },
|
|
1117
|
-
);
|
|
1118
|
-
const tenantCurrencyResolution = resolveTenantCurrency(
|
|
1161
|
+
const { overrides: moneyCurrencyOverrides, loading: currencyLoading } = useMoneyCurrencyOverrides(
|
|
1162
|
+
entity.fields,
|
|
1119
1163
|
tenantCurrencyFieldNames,
|
|
1120
|
-
tenantCurrencyQuery,
|
|
1121
1164
|
entityDefaultCurrency,
|
|
1122
1165
|
);
|
|
1123
|
-
const resolvedTenantCurrency =
|
|
1124
|
-
tenantCurrencyResolution.status === "ready" ? tenantCurrencyResolution.currency : undefined;
|
|
1125
|
-
const moneyCurrencyOverrides = useMemo(
|
|
1126
|
-
() =>
|
|
1127
|
-
resolvedTenantCurrency !== undefined
|
|
1128
|
-
? Object.fromEntries(tenantCurrencyFieldNames.map((name) => [name, resolvedTenantCurrency]))
|
|
1129
|
-
: undefined,
|
|
1130
|
-
[tenantCurrencyFieldNames, resolvedTenantCurrency],
|
|
1131
|
-
);
|
|
1132
1166
|
const initial = useMemo(() => {
|
|
1133
1167
|
const out: Record<string, unknown> = {};
|
|
1134
1168
|
for (const name of Object.keys(entity.fields)) {
|
|
@@ -1311,7 +1345,7 @@ function EntityEditUpdateForm({
|
|
|
1311
1345
|
const redirect = screen.redirect;
|
|
1312
1346
|
if (redirect !== undefined) {
|
|
1313
1347
|
if (returnToWinsOverRedirect(returnTarget, redirect, schema, appFeatures)) {
|
|
1314
|
-
nav
|
|
1348
|
+
navigateToReturn(nav, returnTarget);
|
|
1315
1349
|
} else if (typeof redirect === "string") {
|
|
1316
1350
|
// String form unchanged: navigates without an entityId, same as
|
|
1317
1351
|
// before the object form existed. The object form resolves like
|
|
@@ -1334,14 +1368,14 @@ function EntityEditUpdateForm({
|
|
|
1334
1368
|
});
|
|
1335
1369
|
}
|
|
1336
1370
|
} else {
|
|
1337
|
-
|
|
1371
|
+
navigateToReturnOr(nav, returnTarget, navigateToList);
|
|
1338
1372
|
onSaved?.();
|
|
1339
1373
|
}
|
|
1340
1374
|
},
|
|
1341
1375
|
[nav, screen.redirect, schema, appFeatures, record, navigateToList, onSaved, returnTarget],
|
|
1342
1376
|
);
|
|
1343
1377
|
const handleCancel = useCallback(
|
|
1344
|
-
() =>
|
|
1378
|
+
() => navigateToReturnOr(nav, returnTarget, navigateToList),
|
|
1345
1379
|
[nav, returnTarget, navigateToList],
|
|
1346
1380
|
);
|
|
1347
1381
|
const handleDelete = useCallback(async () => {
|
|
@@ -1349,7 +1383,7 @@ function EntityEditUpdateForm({
|
|
|
1349
1383
|
if (res.isSuccess) {
|
|
1350
1384
|
// Never return onto the just-deleted record.
|
|
1351
1385
|
const target = returnTarget?.entityId !== entityId ? returnTarget : undefined;
|
|
1352
|
-
|
|
1386
|
+
navigateToReturnOr(nav, target, navigateToList);
|
|
1353
1387
|
onDeleted?.();
|
|
1354
1388
|
}
|
|
1355
1389
|
}, [dispatcher, deleteCommand, entityId, navigateToList, onDeleted, returnTarget, nav]);
|
|
@@ -1358,7 +1392,7 @@ function EntityEditUpdateForm({
|
|
|
1358
1392
|
// its real tenant currency is still in flight (fw#2933) — wait instead. A
|
|
1359
1393
|
// stored (non-empty) value is unaffected — it never reaches this branch's
|
|
1360
1394
|
// fallback because `initial` above already prefers `record[name]`.
|
|
1361
|
-
if (
|
|
1395
|
+
if (currencyLoading) {
|
|
1362
1396
|
return (
|
|
1363
1397
|
<Banner padded variant="loading" testId="kumiko-screen-loading">
|
|
1364
1398
|
Loading…
|
|
@@ -2381,6 +2415,19 @@ function ProjectionListBody({
|
|
|
2381
2415
|
[urlState.filters, facetSpecs],
|
|
2382
2416
|
);
|
|
2383
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
|
+
|
|
2384
2431
|
const queryPayload = useMemo(() => {
|
|
2385
2432
|
const payload = buildListQueryPayload({
|
|
2386
2433
|
limit,
|
|
@@ -2401,6 +2448,7 @@ function ProjectionListBody({
|
|
|
2401
2448
|
if (screen.facets !== undefined && filterPayload.length > 0) {
|
|
2402
2449
|
payload["filters"] = filterPayload;
|
|
2403
2450
|
}
|
|
2451
|
+
Object.assign(payload, dateRangePayload);
|
|
2404
2452
|
return payload;
|
|
2405
2453
|
}, [
|
|
2406
2454
|
limit,
|
|
@@ -2411,6 +2459,7 @@ function ProjectionListBody({
|
|
|
2411
2459
|
screen.filter,
|
|
2412
2460
|
screen.facets,
|
|
2413
2461
|
filterPayload,
|
|
2462
|
+
dateRangePayload,
|
|
2414
2463
|
]);
|
|
2415
2464
|
|
|
2416
2465
|
const rowsQuery = useQuery<PagedRows>(screen.query, queryPayload, { live: true });
|
|
@@ -2419,6 +2468,24 @@ function ProjectionListBody({
|
|
|
2419
2468
|
() => buildFilterFacets(resolvedFacetSpecs),
|
|
2420
2469
|
[resolvedFacetSpecs],
|
|
2421
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
|
+
);
|
|
2422
2489
|
|
|
2423
2490
|
// Entity-Targets (fw#2228) — see EntityListBody.runNavigate for why the
|
|
2424
2491
|
// resolution happens in the NavApi impl. Unlike there: NO row["id"]
|
|
@@ -2554,8 +2621,14 @@ function ProjectionListBody({
|
|
|
2554
2621
|
{...(wrappedOnRowClick !== undefined && { onRowClick: wrappedOnRowClick })}
|
|
2555
2622
|
{...(filterFacets.length > 0 && {
|
|
2556
2623
|
filterFacets,
|
|
2557
|
-
filterValues: urlState.filters,
|
|
2558
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,
|
|
2559
2632
|
onFilterReset: urlState.clearFilters,
|
|
2560
2633
|
})}
|
|
2561
2634
|
/>
|
|
@@ -3155,7 +3228,7 @@ function ProjectionDetailBody({
|
|
|
3155
3228
|
const testId = `kumiko-screen-projection-detail-metric-${field}`;
|
|
3156
3229
|
const navigate = metricNavigateSpec(metric);
|
|
3157
3230
|
const onPress =
|
|
3158
|
-
navigate !== undefined
|
|
3231
|
+
navigate !== undefined && navigateTargetAllows(navigate, appFeatures, userRoles)
|
|
3159
3232
|
? () => runMetricNavigate(nav, navigate, record, host)
|
|
3160
3233
|
: undefined;
|
|
3161
3234
|
return Metric !== undefined ? (
|
|
@@ -3281,7 +3354,7 @@ function redirectTargetsRecord(
|
|
|
3281
3354
|
}
|
|
3282
3355
|
|
|
3283
3356
|
// Type-guard form narrows `returnTarget` for the caller's subsequent
|
|
3284
|
-
// nav
|
|
3357
|
+
// navigateToReturn(nav, returnTarget) call.
|
|
3285
3358
|
function returnToWinsOverRedirect(
|
|
3286
3359
|
returnTarget: ScreenTarget | undefined,
|
|
3287
3360
|
redirect: string | ActionFormRedirect,
|
|
@@ -3291,18 +3364,6 @@ function returnToWinsOverRedirect(
|
|
|
3291
3364
|
return returnTarget !== undefined && !redirectTargetsRecord(redirect, schema, appFeatures);
|
|
3292
3365
|
}
|
|
3293
3366
|
|
|
3294
|
-
function navigateToReturnTargetOr(
|
|
3295
|
-
nav: NavApi,
|
|
3296
|
-
returnTarget: ScreenTarget | undefined,
|
|
3297
|
-
fallback: () => void,
|
|
3298
|
-
): void {
|
|
3299
|
-
if (returnTarget !== undefined) {
|
|
3300
|
-
nav.navigate(returnTarget);
|
|
3301
|
-
} else {
|
|
3302
|
-
fallback();
|
|
3303
|
-
}
|
|
3304
|
-
}
|
|
3305
|
-
|
|
3306
3367
|
// Resolves an object-form redirect to a nav target — shared by
|
|
3307
3368
|
// actionForm and entityEdit (create + update) so the id
|
|
3308
3369
|
// carries over identically regardless of which screen type triggered it.
|
|
@@ -3367,6 +3428,7 @@ function ActionFormBody({
|
|
|
3367
3428
|
}): ReactNode {
|
|
3368
3429
|
const nav = useNav();
|
|
3369
3430
|
const appFeatures = useAppFeatures();
|
|
3431
|
+
const { Banner } = usePrimitives();
|
|
3370
3432
|
// Unused when drawer-hosted — onSuccess/onCancelOverride win below first.
|
|
3371
3433
|
const returnTarget = useReturnTarget(screen.id);
|
|
3372
3434
|
const synthEntity = useMemo(() => synthesizeActionFormEntity(screen.fields), [screen.fields]);
|
|
@@ -3374,12 +3436,27 @@ function ActionFormBody({
|
|
|
3374
3436
|
const pendingHandoff = useInitialValuesHandoff(screen.id);
|
|
3375
3437
|
// A drawer-hosted form is not a navigation target, so it never takes a handoff.
|
|
3376
3438
|
const handoffValues = onSuccess === undefined ? pendingHandoff : undefined;
|
|
3439
|
+
// This screen has no entity, so a money field here declares its own currency
|
|
3440
|
+
// source (fw#2839, boot-enforced) — without one the form would seed a bare
|
|
3441
|
+
// `0` the handler's schema rejects. ACTION_FORM_CURRENCY_FALLBACK only
|
|
3442
|
+
// applies when a tenant-declared field's config query errors or holds no
|
|
3443
|
+
// value, matching the entityEdit path's behaviour (fw#2937).
|
|
3444
|
+
const tenantCurrencyFieldNames = useMemo(
|
|
3445
|
+
() => tenantCurrencyMoneyFieldNames(screen.fields),
|
|
3446
|
+
[screen.fields],
|
|
3447
|
+
);
|
|
3448
|
+
const { overrides: moneyCurrencyOverrides, loading: currencyLoading } = useMoneyCurrencyOverrides(
|
|
3449
|
+
screen.fields,
|
|
3450
|
+
tenantCurrencyFieldNames,
|
|
3451
|
+
ACTION_FORM_CURRENCY_FALLBACK,
|
|
3452
|
+
);
|
|
3377
3453
|
const initial = useMemo(
|
|
3378
3454
|
() =>
|
|
3379
3455
|
mergeSearchParamsIntoInitial(screen.fields, {
|
|
3380
3456
|
searchParams: nav.searchParams,
|
|
3381
3457
|
urlPrefillFields: screen.urlPrefillFields,
|
|
3382
3458
|
renderableFields: layoutFieldNames(synthScreen),
|
|
3459
|
+
...(moneyCurrencyOverrides !== undefined && { moneyCurrencyOverrides }),
|
|
3383
3460
|
...(initialOverrides !== undefined && { drawerOverrides: initialOverrides }),
|
|
3384
3461
|
...(handoffValues !== undefined && { handoffValues }),
|
|
3385
3462
|
}) as FormValues,
|
|
@@ -3388,6 +3465,7 @@ function ActionFormBody({
|
|
|
3388
3465
|
screen.urlPrefillFields,
|
|
3389
3466
|
nav.searchParams,
|
|
3390
3467
|
synthScreen,
|
|
3468
|
+
moneyCurrencyOverrides,
|
|
3391
3469
|
initialOverrides,
|
|
3392
3470
|
handoffValues,
|
|
3393
3471
|
],
|
|
@@ -3403,7 +3481,7 @@ function ActionFormBody({
|
|
|
3403
3481
|
// navigation, and never one to a record screen (see redirectTargetsRecord).
|
|
3404
3482
|
if (screen.redirect !== undefined) {
|
|
3405
3483
|
if (returnToWinsOverRedirect(returnTarget, screen.redirect, schema, appFeatures)) {
|
|
3406
|
-
nav
|
|
3484
|
+
navigateToReturn(nav, returnTarget);
|
|
3407
3485
|
} else {
|
|
3408
3486
|
const { screenId, entityId } = resolveRedirectTarget(
|
|
3409
3487
|
screen.redirect,
|
|
@@ -3428,8 +3506,18 @@ function ActionFormBody({
|
|
|
3428
3506
|
screen.cancelTarget ??
|
|
3429
3507
|
(screen.redirect !== undefined ? redirectScreenTarget(screen.redirect) : undefined);
|
|
3430
3508
|
if (target === undefined || target === false) return undefined;
|
|
3431
|
-
return () =>
|
|
3509
|
+
return () =>
|
|
3510
|
+
navigateToReturnOr(nav, returnTarget, () => nav.navigate({ screenId: lastSegment(target) }));
|
|
3432
3511
|
}, [nav, screen.redirect, screen.cancelTarget, onCancelOverride, returnTarget]);
|
|
3512
|
+
// Rendering the form before a tenant-declared currency lands would show the
|
|
3513
|
+
// fallback and, on a fast click, submit it (fw#2933) — hold instead.
|
|
3514
|
+
if (currencyLoading) {
|
|
3515
|
+
return (
|
|
3516
|
+
<Banner padded variant="loading" testId="kumiko-screen-loading">
|
|
3517
|
+
Loading…
|
|
3518
|
+
</Banner>
|
|
3519
|
+
);
|
|
3520
|
+
}
|
|
3433
3521
|
return (
|
|
3434
3522
|
<RenderEdit
|
|
3435
3523
|
screen={synthScreen}
|