@cosmicdrift/kumiko-renderer 0.290.0 → 0.291.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/app/__tests__/return-to.test.ts +86 -3
- package/src/app/kumiko-screen.tsx +102 -58
- package/src/app/projection-detail-shim.ts +1 -0
- package/src/app/return-to.tsx +91 -5
- package/src/app/secret-mint-body.tsx +26 -6
- package/src/app/secrets-edit-body.tsx +1 -0
- package/src/changes.json +6 -0
- package/src/components/embedded-list-field.tsx +3 -3
- package/src/components/render-field.tsx +25 -7
- package/src/index.ts +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.291.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.291.0",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.291.0",
|
|
20
|
+
"@cosmicdrift/kumiko-types": "0.291.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.291.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
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -82,6 +82,8 @@ import { lastSegment, toKebab } from "./qn";
|
|
|
82
82
|
import { featureNameFromQualifiedScreenId, qualifyScreenId } from "./qualify-screen-id";
|
|
83
83
|
import { ReferenceFacetBridges, type ReferenceFacetOption } from "./reference-facet-bridge";
|
|
84
84
|
import {
|
|
85
|
+
navigateToReturn,
|
|
86
|
+
navigateToReturnOr,
|
|
85
87
|
navigateWithReturnTo,
|
|
86
88
|
type ReturnHost,
|
|
87
89
|
ReturnHostProvider,
|
|
@@ -487,6 +489,63 @@ function resolveTenantCurrency(
|
|
|
487
489
|
return { status: "ready", currency: typeof raw === "string" && raw !== "" ? raw : fallback };
|
|
488
490
|
}
|
|
489
491
|
|
|
492
|
+
// An entity-less form screen has no `entity.defaultCurrency` to fall back on,
|
|
493
|
+
// so a tenant-declared field whose config query errors or returns no value
|
|
494
|
+
// lands here — same last resort as the entityEdit path's `?? "EUR"`.
|
|
495
|
+
const ACTION_FORM_CURRENCY_FALLBACK = "EUR";
|
|
496
|
+
|
|
497
|
+
// A money field naming a fixed ISO code (`currency: { kind: "literal", code }`,
|
|
498
|
+
// fw#2839) resolves with no query at all. Read structurally, same idiom as
|
|
499
|
+
// tenantCurrencyMoneyFieldNames above.
|
|
500
|
+
export function literalCurrencyOverrides(
|
|
501
|
+
fields: Readonly<Record<string, unknown>>,
|
|
502
|
+
): Readonly<Record<string, string>> {
|
|
503
|
+
const out: Record<string, string> = {};
|
|
504
|
+
for (const [name, def] of Object.entries(fields)) {
|
|
505
|
+
const shape = def as {
|
|
506
|
+
readonly type?: string;
|
|
507
|
+
readonly currency?: { readonly kind?: string; readonly code?: string };
|
|
508
|
+
};
|
|
509
|
+
const code = shape.currency?.code;
|
|
510
|
+
if (shape.type === "money" && shape.currency?.kind === "literal" && code !== undefined) {
|
|
511
|
+
out[name] = code;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return out;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Per-field currency for the money fields that declare their own source: a
|
|
518
|
+
// literal code resolves synchronously (fw#2839), a `{ kind: "tenant" }` field
|
|
519
|
+
// needs the tenant-settings config query and makes the caller hold the form
|
|
520
|
+
// until it lands (fw#2933) — seeding the fallback meanwhile would render, and
|
|
521
|
+
// on a fast click submit, the wrong currency. Callers pass the tenant field
|
|
522
|
+
// names themselves because an edit form only needs the fetch for fields the
|
|
523
|
+
// record has no stored value for.
|
|
524
|
+
function useMoneyCurrencyOverrides(
|
|
525
|
+
fields: Readonly<Record<string, unknown>>,
|
|
526
|
+
tenantFieldNames: readonly string[],
|
|
527
|
+
fallback: string,
|
|
528
|
+
): {
|
|
529
|
+
readonly overrides: Readonly<Record<string, string>> | undefined;
|
|
530
|
+
readonly loading: boolean;
|
|
531
|
+
} {
|
|
532
|
+
const query = useQuery<TenantConfigValuesResponse>(
|
|
533
|
+
"config:query:values",
|
|
534
|
+
{},
|
|
535
|
+
{ enabled: tenantFieldNames.length > 0 },
|
|
536
|
+
);
|
|
537
|
+
const resolution = resolveTenantCurrency(tenantFieldNames, query, fallback);
|
|
538
|
+
const resolvedTenantCurrency = resolution.status === "ready" ? resolution.currency : undefined;
|
|
539
|
+
const overrides = useMemo(() => {
|
|
540
|
+
const out: Record<string, string> = { ...literalCurrencyOverrides(fields) };
|
|
541
|
+
if (resolvedTenantCurrency !== undefined) {
|
|
542
|
+
for (const name of tenantFieldNames) out[name] = resolvedTenantCurrency;
|
|
543
|
+
}
|
|
544
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
545
|
+
}, [fields, tenantFieldNames, resolvedTenantCurrency]);
|
|
546
|
+
return { overrides, loading: resolution.status === "loading" };
|
|
547
|
+
}
|
|
548
|
+
|
|
490
549
|
function multiSelectOptionValues(shape: {
|
|
491
550
|
readonly options?: readonly (string | { readonly value: string })[];
|
|
492
551
|
}): ReadonlySet<string> | undefined {
|
|
@@ -857,26 +916,11 @@ function EntityEditCreateBody({
|
|
|
857
916
|
() => tenantCurrencyMoneyFieldNames(entity.fields),
|
|
858
917
|
[entity.fields],
|
|
859
918
|
);
|
|
860
|
-
const
|
|
861
|
-
|
|
862
|
-
"config:query:values",
|
|
863
|
-
{},
|
|
864
|
-
{ enabled: needsTenantCurrency },
|
|
865
|
-
);
|
|
866
|
-
const tenantCurrencyResolution = resolveTenantCurrency(
|
|
919
|
+
const { overrides: moneyCurrencyOverrides, loading: currencyLoading } = useMoneyCurrencyOverrides(
|
|
920
|
+
entity.fields,
|
|
867
921
|
tenantCurrencyFieldNames,
|
|
868
|
-
tenantCurrencyQuery,
|
|
869
922
|
entityDefaultCurrency,
|
|
870
923
|
);
|
|
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
924
|
const initial = useMemo(
|
|
881
925
|
() =>
|
|
882
926
|
mergeSearchParamsIntoInitial(entity.fields, {
|
|
@@ -900,7 +944,7 @@ function EntityEditCreateBody({
|
|
|
900
944
|
const writeCommand = entityWriteCommand(schema.featureName, screen.entity, "create");
|
|
901
945
|
const navigateToList = useNavigateToListAfter(schema, screen.entity);
|
|
902
946
|
const handleCancel = useCallback(
|
|
903
|
-
() =>
|
|
947
|
+
() => navigateToReturnOr(nav, returnTarget, navigateToList),
|
|
904
948
|
[nav, returnTarget, navigateToList],
|
|
905
949
|
);
|
|
906
950
|
// Create's write-handler success payload doesn't flatly expose a parent FK
|
|
@@ -916,7 +960,7 @@ function EntityEditCreateBody({
|
|
|
916
960
|
const redirect = screen.redirect;
|
|
917
961
|
if (redirect !== undefined) {
|
|
918
962
|
if (returnToWinsOverRedirect(returnTarget, redirect, schema, appFeatures)) {
|
|
919
|
-
nav
|
|
963
|
+
navigateToReturn(nav, returnTarget);
|
|
920
964
|
} else if (typeof redirect === "string") {
|
|
921
965
|
// String form unchanged: always carries the newly created
|
|
922
966
|
// record's own id, regardless of the target screen's type. The
|
|
@@ -938,7 +982,7 @@ function EntityEditCreateBody({
|
|
|
938
982
|
nav.navigate({ screenId, ...(entityId !== undefined && { entityId }) });
|
|
939
983
|
}
|
|
940
984
|
} else {
|
|
941
|
-
|
|
985
|
+
navigateToReturnOr(nav, returnTarget, navigateToList);
|
|
942
986
|
onSaved?.();
|
|
943
987
|
}
|
|
944
988
|
},
|
|
@@ -946,7 +990,7 @@ function EntityEditCreateBody({
|
|
|
946
990
|
);
|
|
947
991
|
// Never seed a tenant-declared money field with entityDefaultCurrency while
|
|
948
992
|
// its real tenant currency is still in flight (fw#2933) — wait instead.
|
|
949
|
-
if (
|
|
993
|
+
if (currencyLoading) {
|
|
950
994
|
return (
|
|
951
995
|
<Banner padded variant="loading" testId="kumiko-screen-loading">
|
|
952
996
|
Loading…
|
|
@@ -1109,26 +1153,11 @@ function EntityEditUpdateForm({
|
|
|
1109
1153
|
),
|
|
1110
1154
|
[entity.fields, record],
|
|
1111
1155
|
);
|
|
1112
|
-
const
|
|
1113
|
-
|
|
1114
|
-
"config:query:values",
|
|
1115
|
-
{},
|
|
1116
|
-
{ enabled: needsTenantCurrency },
|
|
1117
|
-
);
|
|
1118
|
-
const tenantCurrencyResolution = resolveTenantCurrency(
|
|
1156
|
+
const { overrides: moneyCurrencyOverrides, loading: currencyLoading } = useMoneyCurrencyOverrides(
|
|
1157
|
+
entity.fields,
|
|
1119
1158
|
tenantCurrencyFieldNames,
|
|
1120
|
-
tenantCurrencyQuery,
|
|
1121
1159
|
entityDefaultCurrency,
|
|
1122
1160
|
);
|
|
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
1161
|
const initial = useMemo(() => {
|
|
1133
1162
|
const out: Record<string, unknown> = {};
|
|
1134
1163
|
for (const name of Object.keys(entity.fields)) {
|
|
@@ -1311,7 +1340,7 @@ function EntityEditUpdateForm({
|
|
|
1311
1340
|
const redirect = screen.redirect;
|
|
1312
1341
|
if (redirect !== undefined) {
|
|
1313
1342
|
if (returnToWinsOverRedirect(returnTarget, redirect, schema, appFeatures)) {
|
|
1314
|
-
nav
|
|
1343
|
+
navigateToReturn(nav, returnTarget);
|
|
1315
1344
|
} else if (typeof redirect === "string") {
|
|
1316
1345
|
// String form unchanged: navigates without an entityId, same as
|
|
1317
1346
|
// before the object form existed. The object form resolves like
|
|
@@ -1334,14 +1363,14 @@ function EntityEditUpdateForm({
|
|
|
1334
1363
|
});
|
|
1335
1364
|
}
|
|
1336
1365
|
} else {
|
|
1337
|
-
|
|
1366
|
+
navigateToReturnOr(nav, returnTarget, navigateToList);
|
|
1338
1367
|
onSaved?.();
|
|
1339
1368
|
}
|
|
1340
1369
|
},
|
|
1341
1370
|
[nav, screen.redirect, schema, appFeatures, record, navigateToList, onSaved, returnTarget],
|
|
1342
1371
|
);
|
|
1343
1372
|
const handleCancel = useCallback(
|
|
1344
|
-
() =>
|
|
1373
|
+
() => navigateToReturnOr(nav, returnTarget, navigateToList),
|
|
1345
1374
|
[nav, returnTarget, navigateToList],
|
|
1346
1375
|
);
|
|
1347
1376
|
const handleDelete = useCallback(async () => {
|
|
@@ -1349,7 +1378,7 @@ function EntityEditUpdateForm({
|
|
|
1349
1378
|
if (res.isSuccess) {
|
|
1350
1379
|
// Never return onto the just-deleted record.
|
|
1351
1380
|
const target = returnTarget?.entityId !== entityId ? returnTarget : undefined;
|
|
1352
|
-
|
|
1381
|
+
navigateToReturnOr(nav, target, navigateToList);
|
|
1353
1382
|
onDeleted?.();
|
|
1354
1383
|
}
|
|
1355
1384
|
}, [dispatcher, deleteCommand, entityId, navigateToList, onDeleted, returnTarget, nav]);
|
|
@@ -1358,7 +1387,7 @@ function EntityEditUpdateForm({
|
|
|
1358
1387
|
// its real tenant currency is still in flight (fw#2933) — wait instead. A
|
|
1359
1388
|
// stored (non-empty) value is unaffected — it never reaches this branch's
|
|
1360
1389
|
// fallback because `initial` above already prefers `record[name]`.
|
|
1361
|
-
if (
|
|
1390
|
+
if (currencyLoading) {
|
|
1362
1391
|
return (
|
|
1363
1392
|
<Banner padded variant="loading" testId="kumiko-screen-loading">
|
|
1364
1393
|
Loading…
|
|
@@ -3281,7 +3310,7 @@ function redirectTargetsRecord(
|
|
|
3281
3310
|
}
|
|
3282
3311
|
|
|
3283
3312
|
// Type-guard form narrows `returnTarget` for the caller's subsequent
|
|
3284
|
-
// nav
|
|
3313
|
+
// navigateToReturn(nav, returnTarget) call.
|
|
3285
3314
|
function returnToWinsOverRedirect(
|
|
3286
3315
|
returnTarget: ScreenTarget | undefined,
|
|
3287
3316
|
redirect: string | ActionFormRedirect,
|
|
@@ -3291,18 +3320,6 @@ function returnToWinsOverRedirect(
|
|
|
3291
3320
|
return returnTarget !== undefined && !redirectTargetsRecord(redirect, schema, appFeatures);
|
|
3292
3321
|
}
|
|
3293
3322
|
|
|
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
3323
|
// Resolves an object-form redirect to a nav target — shared by
|
|
3307
3324
|
// actionForm and entityEdit (create + update) so the id
|
|
3308
3325
|
// carries over identically regardless of which screen type triggered it.
|
|
@@ -3367,6 +3384,7 @@ function ActionFormBody({
|
|
|
3367
3384
|
}): ReactNode {
|
|
3368
3385
|
const nav = useNav();
|
|
3369
3386
|
const appFeatures = useAppFeatures();
|
|
3387
|
+
const { Banner } = usePrimitives();
|
|
3370
3388
|
// Unused when drawer-hosted — onSuccess/onCancelOverride win below first.
|
|
3371
3389
|
const returnTarget = useReturnTarget(screen.id);
|
|
3372
3390
|
const synthEntity = useMemo(() => synthesizeActionFormEntity(screen.fields), [screen.fields]);
|
|
@@ -3374,12 +3392,27 @@ function ActionFormBody({
|
|
|
3374
3392
|
const pendingHandoff = useInitialValuesHandoff(screen.id);
|
|
3375
3393
|
// A drawer-hosted form is not a navigation target, so it never takes a handoff.
|
|
3376
3394
|
const handoffValues = onSuccess === undefined ? pendingHandoff : undefined;
|
|
3395
|
+
// This screen has no entity, so a money field here declares its own currency
|
|
3396
|
+
// source (fw#2839, boot-enforced) — without one the form would seed a bare
|
|
3397
|
+
// `0` the handler's schema rejects. ACTION_FORM_CURRENCY_FALLBACK only
|
|
3398
|
+
// applies when a tenant-declared field's config query errors or holds no
|
|
3399
|
+
// value, matching the entityEdit path's behaviour (fw#2937).
|
|
3400
|
+
const tenantCurrencyFieldNames = useMemo(
|
|
3401
|
+
() => tenantCurrencyMoneyFieldNames(screen.fields),
|
|
3402
|
+
[screen.fields],
|
|
3403
|
+
);
|
|
3404
|
+
const { overrides: moneyCurrencyOverrides, loading: currencyLoading } = useMoneyCurrencyOverrides(
|
|
3405
|
+
screen.fields,
|
|
3406
|
+
tenantCurrencyFieldNames,
|
|
3407
|
+
ACTION_FORM_CURRENCY_FALLBACK,
|
|
3408
|
+
);
|
|
3377
3409
|
const initial = useMemo(
|
|
3378
3410
|
() =>
|
|
3379
3411
|
mergeSearchParamsIntoInitial(screen.fields, {
|
|
3380
3412
|
searchParams: nav.searchParams,
|
|
3381
3413
|
urlPrefillFields: screen.urlPrefillFields,
|
|
3382
3414
|
renderableFields: layoutFieldNames(synthScreen),
|
|
3415
|
+
...(moneyCurrencyOverrides !== undefined && { moneyCurrencyOverrides }),
|
|
3383
3416
|
...(initialOverrides !== undefined && { drawerOverrides: initialOverrides }),
|
|
3384
3417
|
...(handoffValues !== undefined && { handoffValues }),
|
|
3385
3418
|
}) as FormValues,
|
|
@@ -3388,6 +3421,7 @@ function ActionFormBody({
|
|
|
3388
3421
|
screen.urlPrefillFields,
|
|
3389
3422
|
nav.searchParams,
|
|
3390
3423
|
synthScreen,
|
|
3424
|
+
moneyCurrencyOverrides,
|
|
3391
3425
|
initialOverrides,
|
|
3392
3426
|
handoffValues,
|
|
3393
3427
|
],
|
|
@@ -3403,7 +3437,7 @@ function ActionFormBody({
|
|
|
3403
3437
|
// navigation, and never one to a record screen (see redirectTargetsRecord).
|
|
3404
3438
|
if (screen.redirect !== undefined) {
|
|
3405
3439
|
if (returnToWinsOverRedirect(returnTarget, screen.redirect, schema, appFeatures)) {
|
|
3406
|
-
nav
|
|
3440
|
+
navigateToReturn(nav, returnTarget);
|
|
3407
3441
|
} else {
|
|
3408
3442
|
const { screenId, entityId } = resolveRedirectTarget(
|
|
3409
3443
|
screen.redirect,
|
|
@@ -3428,8 +3462,18 @@ function ActionFormBody({
|
|
|
3428
3462
|
screen.cancelTarget ??
|
|
3429
3463
|
(screen.redirect !== undefined ? redirectScreenTarget(screen.redirect) : undefined);
|
|
3430
3464
|
if (target === undefined || target === false) return undefined;
|
|
3431
|
-
return () =>
|
|
3465
|
+
return () =>
|
|
3466
|
+
navigateToReturnOr(nav, returnTarget, () => nav.navigate({ screenId: lastSegment(target) }));
|
|
3432
3467
|
}, [nav, screen.redirect, screen.cancelTarget, onCancelOverride, returnTarget]);
|
|
3468
|
+
// Rendering the form before a tenant-declared currency lands would show the
|
|
3469
|
+
// fallback and, on a fast click, submit it (fw#2933) — hold instead.
|
|
3470
|
+
if (currencyLoading) {
|
|
3471
|
+
return (
|
|
3472
|
+
<Banner padded variant="loading" testId="kumiko-screen-loading">
|
|
3473
|
+
Loading…
|
|
3474
|
+
</Banner>
|
|
3475
|
+
);
|
|
3476
|
+
}
|
|
3433
3477
|
return (
|
|
3434
3478
|
<RenderEdit
|
|
3435
3479
|
screen={synthScreen}
|
|
@@ -60,6 +60,7 @@ export function synthesizeProjectionDetailScreen(
|
|
|
60
60
|
if (!isFieldsEditSection(section)) return section;
|
|
61
61
|
return {
|
|
62
62
|
...section,
|
|
63
|
+
// kumiko-lint-ignore section-fields-raw rewrites both sources in place — the groups branch below does the same for groups[].fields
|
|
63
64
|
fields: section.fields.map(forceReadOnly),
|
|
64
65
|
...(section.groups !== undefined && {
|
|
65
66
|
groups: section.groups.map((group) => ({
|
package/src/app/return-to.tsx
CHANGED
|
@@ -11,13 +11,72 @@ import { screenAccessAllows } from "./screen-access";
|
|
|
11
11
|
// and a copied/shared URL.
|
|
12
12
|
export const RETURN_TO_PARAM = "returnTo";
|
|
13
13
|
|
|
14
|
+
// Screen levels one returnTo value may carry: the host itself plus its own
|
|
15
|
+
// nested chain. Beyond that the host's returnTo is left out of the snapshot.
|
|
16
|
+
const MAX_RETURN_DEPTH = 3;
|
|
17
|
+
// Serialized host snapshot longer than this is dropped whole — the bare
|
|
18
|
+
// target still resolves, only the restored state is lost.
|
|
19
|
+
const MAX_RETURN_STATE_LENGTH = 512;
|
|
20
|
+
|
|
21
|
+
const NO_RETURN_STATE: Readonly<Record<string, string>> = Object.freeze({});
|
|
22
|
+
|
|
14
23
|
export type ReturnHost = {
|
|
15
24
|
readonly screenId: string;
|
|
16
25
|
readonly entityId?: string;
|
|
17
26
|
};
|
|
18
27
|
|
|
19
|
-
export
|
|
20
|
-
|
|
28
|
+
export type ReturnTo = {
|
|
29
|
+
readonly path: string;
|
|
30
|
+
readonly state: Readonly<Record<string, string>>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Splits `<screenId>[/<entityId>][?<host-params>]`. One `URLSearchParams`
|
|
34
|
+
* pass per nesting level — a deeper level stays encoded inside a value and
|
|
35
|
+
* is only decoded when that level is reached. */
|
|
36
|
+
export function splitReturnTo(raw: string): ReturnTo {
|
|
37
|
+
const queryStart = raw.indexOf("?");
|
|
38
|
+
if (queryStart === -1) return { path: raw, state: NO_RETURN_STATE };
|
|
39
|
+
return {
|
|
40
|
+
path: raw.slice(0, queryStart),
|
|
41
|
+
state: Object.fromEntries(new URLSearchParams(raw.slice(queryStart + 1))),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Saturates at MAX_RETURN_DEPTH — callers only ask whether the cap is
|
|
46
|
+
// reached, and an unbounded walk would follow attacker-sized nesting.
|
|
47
|
+
function returnToDepth(raw: string): number {
|
|
48
|
+
let depth = 1;
|
|
49
|
+
let current = raw;
|
|
50
|
+
while (depth < MAX_RETURN_DEPTH) {
|
|
51
|
+
const nested = splitReturnTo(current).state[RETURN_TO_PARAM];
|
|
52
|
+
if (nested === undefined) return depth;
|
|
53
|
+
depth += 1;
|
|
54
|
+
current = nested;
|
|
55
|
+
}
|
|
56
|
+
return depth;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Both caps degrade to the pre-snapshot value instead of failing, so a value
|
|
60
|
+
// that hits one still navigates.
|
|
61
|
+
function returnStateSnapshot(hostParams: Readonly<Record<string, string>> | undefined): string {
|
|
62
|
+
if (hostParams === undefined) return "";
|
|
63
|
+
const snapshot = new URLSearchParams();
|
|
64
|
+
for (const [key, value] of Object.entries(hostParams)) {
|
|
65
|
+
if (value === "") continue;
|
|
66
|
+
if (key === RETURN_TO_PARAM && returnToDepth(value) >= MAX_RETURN_DEPTH) continue;
|
|
67
|
+
snapshot.set(key, value);
|
|
68
|
+
}
|
|
69
|
+
const serialized = snapshot.toString();
|
|
70
|
+
return serialized.length > MAX_RETURN_STATE_LENGTH ? "" : serialized;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function formatReturnTo(
|
|
74
|
+
host: ReturnHost,
|
|
75
|
+
hostParams?: Readonly<Record<string, string>>,
|
|
76
|
+
): string {
|
|
77
|
+
const path = host.entityId !== undefined ? `${host.screenId}/${host.entityId}` : host.screenId;
|
|
78
|
+
const state = returnStateSnapshot(hostParams);
|
|
79
|
+
return state === "" ? path : `${path}?${state}`;
|
|
21
80
|
}
|
|
22
81
|
|
|
23
82
|
// {} when target IS host — pushPath no-ops on the same path, so the param
|
|
@@ -25,12 +84,13 @@ export function formatReturnTo(host: ReturnHost): string {
|
|
|
25
84
|
export function returnToParams(
|
|
26
85
|
host: ReturnHost | undefined,
|
|
27
86
|
target: ScreenTarget,
|
|
87
|
+
hostParams?: Readonly<Record<string, string>>,
|
|
28
88
|
): Readonly<Record<string, string>> {
|
|
29
89
|
if (host === undefined) return {};
|
|
30
90
|
if (lastSegment(target.screenId) === host.screenId && target.entityId === host.entityId) {
|
|
31
91
|
return {};
|
|
32
92
|
}
|
|
33
|
-
return { [RETURN_TO_PARAM]: formatReturnTo(host) };
|
|
93
|
+
return { [RETURN_TO_PARAM]: formatReturnTo(host, hostParams) };
|
|
34
94
|
}
|
|
35
95
|
|
|
36
96
|
export function navigateWithReturnTo(
|
|
@@ -39,16 +99,42 @@ export function navigateWithReturnTo(
|
|
|
39
99
|
host: ReturnHost | undefined,
|
|
40
100
|
params?: Readonly<Record<string, string | null>>,
|
|
41
101
|
): void {
|
|
102
|
+
// Snapshot before navigating — nav.searchParams still describes the host.
|
|
103
|
+
const returnParams = returnToParams(host, target, nav.searchParams);
|
|
42
104
|
nav.navigate(target);
|
|
43
105
|
const merged: Record<string, string | null> = {
|
|
44
106
|
...(params ?? {}),
|
|
45
|
-
...
|
|
107
|
+
...returnParams,
|
|
46
108
|
};
|
|
47
109
|
if (Object.keys(merged).length > 0) {
|
|
48
110
|
nav.setSearchParams(merged);
|
|
49
111
|
}
|
|
50
112
|
}
|
|
51
113
|
|
|
114
|
+
/** Jumps back to an already-validated returnTo target and restores the host
|
|
115
|
+
* search params carried in the same value. The host's own returnTo travels
|
|
116
|
+
* with them, so a chain unwinds one level per jump. */
|
|
117
|
+
export function navigateToReturn(nav: NavApi, target: ScreenTarget): void {
|
|
118
|
+
const raw = nav.searchParams[RETURN_TO_PARAM];
|
|
119
|
+
const state = raw === undefined ? NO_RETURN_STATE : splitReturnTo(raw).state;
|
|
120
|
+
nav.navigate(target);
|
|
121
|
+
// The explicit null drops this screen's own returnTo on nav impls whose
|
|
122
|
+
// navigate keeps the query.
|
|
123
|
+
nav.setSearchParams({ ...state, [RETURN_TO_PARAM]: state[RETURN_TO_PARAM] ?? null });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function navigateToReturnOr(
|
|
127
|
+
nav: NavApi,
|
|
128
|
+
target: ScreenTarget | undefined,
|
|
129
|
+
fallback: () => void,
|
|
130
|
+
): void {
|
|
131
|
+
if (target === undefined) {
|
|
132
|
+
fallback();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
navigateToReturn(nav, target);
|
|
136
|
+
}
|
|
137
|
+
|
|
52
138
|
// "%" is left alone — route entityIds come raw from the path and can carry it.
|
|
53
139
|
const UNSAFE_ENTITY_ID_PATTERN = /[\\?#\s]/;
|
|
54
140
|
|
|
@@ -61,7 +147,7 @@ export function resolveReturnTarget(
|
|
|
61
147
|
userRoles: readonly string[] | undefined,
|
|
62
148
|
): ScreenTarget | undefined {
|
|
63
149
|
if (raw === undefined || raw === "") return undefined;
|
|
64
|
-
const segments = raw.split("/");
|
|
150
|
+
const segments = splitReturnTo(raw).path.split("/");
|
|
65
151
|
if (segments.length !== 1 && segments.length !== 2) return undefined;
|
|
66
152
|
if (segments.some((segment) => segment === "")) return undefined;
|
|
67
153
|
const [screenId, entityId] = segments;
|
|
@@ -10,11 +10,15 @@ import {
|
|
|
10
10
|
synthesizeSecretMintConfirmScreen,
|
|
11
11
|
} from "./action-form-shim";
|
|
12
12
|
import type { FeatureSchema } from "./feature-schema";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
buildInitialValues,
|
|
15
|
+
literalCurrencyOverrides,
|
|
16
|
+
mergeSearchParamsIntoInitial,
|
|
17
|
+
} from "./kumiko-screen";
|
|
14
18
|
import { layoutFieldNames } from "./layout-fields";
|
|
15
19
|
import { useInitialValuesHandoff, useNav } from "./nav";
|
|
16
20
|
import { lastSegment } from "./qn";
|
|
17
|
-
import { useReturnTarget } from "./return-to";
|
|
21
|
+
import { navigateToReturnOr, useReturnTarget } from "./return-to";
|
|
18
22
|
|
|
19
23
|
export type SecretMintBodyProps = {
|
|
20
24
|
readonly schema: FeatureSchema;
|
|
@@ -71,6 +75,11 @@ export function SecretMintBody({ schema, screen, translate }: SecretMintBodyProp
|
|
|
71
75
|
searchParams: nav.searchParams,
|
|
72
76
|
urlPrefillFields: screen.urlPrefillFields,
|
|
73
77
|
renderableFields: layoutFieldNames(synthScreen),
|
|
78
|
+
// A mint form has no entity either, so its money fields name their own
|
|
79
|
+
// currency source (fw#2839). Only the literal form is resolved here:
|
|
80
|
+
// `{ kind: "tenant" }` would need the config query this screen never
|
|
81
|
+
// runs, and a one-time secret mint has no money use case to justify it.
|
|
82
|
+
moneyCurrencyOverrides: literalCurrencyOverrides(screen.fields),
|
|
74
83
|
...(handoffValues !== undefined && { handoffValues }),
|
|
75
84
|
}) as FormValues,
|
|
76
85
|
[screen.fields, screen.urlPrefillFields, nav.searchParams, synthScreen, handoffValues],
|
|
@@ -91,7 +100,14 @@ export function SecretMintBody({ schema, screen, translate }: SecretMintBodyProp
|
|
|
91
100
|
[screen, confirm],
|
|
92
101
|
);
|
|
93
102
|
const confirmInitial = useMemo(
|
|
94
|
-
() =>
|
|
103
|
+
() =>
|
|
104
|
+
confirm !== undefined
|
|
105
|
+
? (buildInitialValues(
|
|
106
|
+
confirm.fields,
|
|
107
|
+
undefined,
|
|
108
|
+
literalCurrencyOverrides(confirm.fields),
|
|
109
|
+
) as FormValues)
|
|
110
|
+
: undefined,
|
|
95
111
|
[confirm],
|
|
96
112
|
);
|
|
97
113
|
|
|
@@ -114,7 +130,8 @@ export function SecretMintBody({ schema, screen, translate }: SecretMintBodyProp
|
|
|
114
130
|
const handleCancel = useMemo<(() => void) | undefined>(() => {
|
|
115
131
|
const target = screen.cancelTarget ?? screen.redirect;
|
|
116
132
|
if (target === undefined || target === false) return undefined;
|
|
117
|
-
return () =>
|
|
133
|
+
return () =>
|
|
134
|
+
navigateToReturnOr(nav, returnTarget, () => nav.navigate({ screenId: lastSegment(target) }));
|
|
118
135
|
}, [nav, screen.redirect, screen.cancelTarget, returnTarget]);
|
|
119
136
|
|
|
120
137
|
// Ends the reveal phase for both paths (the bare acknowledge button, and a
|
|
@@ -125,8 +142,11 @@ export function SecretMintBody({ schema, screen, translate }: SecretMintBodyProp
|
|
|
125
142
|
const finishMint = useCallback(() => {
|
|
126
143
|
setRevealed(null);
|
|
127
144
|
carriedRef.current = {};
|
|
128
|
-
|
|
129
|
-
|
|
145
|
+
const redirect = screen.redirect;
|
|
146
|
+
if (redirect !== undefined) {
|
|
147
|
+
navigateToReturnOr(nav, returnTarget, () =>
|
|
148
|
+
nav.navigate({ screenId: lastSegment(redirect) }),
|
|
149
|
+
);
|
|
130
150
|
} else {
|
|
131
151
|
setDone(true);
|
|
132
152
|
}
|
|
@@ -118,6 +118,7 @@ export function SecretsEditBody({ screen, translate }: SecretsEditBodyProps): Re
|
|
|
118
118
|
key={section.title ?? `section-${index}`}
|
|
119
119
|
{...(section.title !== undefined && { title: effectiveTranslate(section.title) })}
|
|
120
120
|
>
|
|
121
|
+
{/* kumiko-lint-ignore section-fields-raw secretsEdit section fields are field-id strings, not EditFieldSpec */}
|
|
121
122
|
{section.fields.map((fieldId) => {
|
|
122
123
|
const qualified = screen.secretKeys[fieldId];
|
|
123
124
|
if (qualified === undefined) return null;
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.291.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "returnTo restores the host screen's search params and nests return chains",
|
|
6
|
+
"detail": "A `returnTo` value may now carry the host's search params as a nested query (`token-detail/abc?tab=keys&orders.sort=name`), so jumping back lands on the tab, filter and sort the user left — `use-list-url-state` keys and `layout.mode: \"tabs\"` included, with no per-key allowlist to keep in sync. The host's own `returnTo` travels inside that snapshot, so a chain (list → edit → action form → back → save) unwinds one level per jump instead of losing its tail. Two syntactic caps bound the URL: at most three screen levels per value, and a snapshot over 512 characters is dropped whole. Both degrade to the previous bare value rather than failing, and a value without a snapshot is byte-identical to before, so existing links keep working. Target validation is unchanged: `resolveReturnTarget` sees only the path part and still rejects anything that is not an accessible in-app screen."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.290.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -151,7 +151,7 @@ export function EmbeddedListField({
|
|
|
151
151
|
const referenceQueries = referenceCells.map((cell) => {
|
|
152
152
|
const refFeature = cell.refFeature ?? featureName;
|
|
153
153
|
const refEntity = cell.refEntity ?? "";
|
|
154
|
-
const qn = `${toKebab(refFeature)}:query:${toKebab(refEntity)}:list`;
|
|
154
|
+
const qn = cell.refOptionsQuery ?? `${toKebab(refFeature)}:query:${toKebab(refEntity)}:list`;
|
|
155
155
|
// biome-ignore lint/correctness/useHookAtTopLevel: referenceCells comes from the entity-schema definition — fixed for the screen's lifetime, not a real conditional-hook risk.
|
|
156
156
|
return useQuery<{ rows: ReadonlyArray<Record<string, unknown>> }>(qn, {
|
|
157
157
|
limit: REFERENCE_COMBOBOX_LIMIT,
|
|
@@ -165,10 +165,10 @@ export function EmbeddedListField({
|
|
|
165
165
|
if (cell.type === "reference") {
|
|
166
166
|
const idx = referenceCells.indexOf(cell);
|
|
167
167
|
const query = referenceQueries[idx];
|
|
168
|
-
const
|
|
168
|
+
const labelKey = cell.refOptionsQuery !== undefined ? "label" : (cell.refLabelField ?? "id");
|
|
169
169
|
const referenceOptions = (query?.data?.rows ?? []).map((row) => ({
|
|
170
170
|
value: String(row["id"] ?? ""),
|
|
171
|
-
label: String(row[
|
|
171
|
+
label: String(row[labelKey] ?? row["id"] ?? ""),
|
|
172
172
|
}));
|
|
173
173
|
return {
|
|
174
174
|
field: cell.field,
|
|
@@ -167,6 +167,24 @@ export function RenderField({
|
|
|
167
167
|
);
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
// Where a reference field's option rows come from, and which of their keys
|
|
171
|
+
// holds the display text — the entity's own list handler plus `refLabelField`
|
|
172
|
+
// by default, or an author-supplied handler whose rows carry `label`
|
|
173
|
+
// (fw#2780), for targets with no readable column of their own.
|
|
174
|
+
function referenceOptionSource(
|
|
175
|
+
field: Pick<EditFieldViewModel, "refLabelField" | "refOptionsQuery">,
|
|
176
|
+
refFeature: string,
|
|
177
|
+
refEntity: string,
|
|
178
|
+
): { readonly queryQn: string; readonly labelKey: string } {
|
|
179
|
+
if (field.refOptionsQuery !== undefined) {
|
|
180
|
+
return { queryQn: field.refOptionsQuery, labelKey: "label" };
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
queryQn: `${toKebab(refFeature)}:query:${toKebab(refEntity)}:list`,
|
|
184
|
+
labelKey: field.refLabelField ?? "id",
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
170
188
|
// Tier 2.7e-3 + 2.1c: Reference-Input rendert eine Searchable Combobox
|
|
171
189
|
// gefüllt aus einer Live-Query auf die referenced Entity. Default-
|
|
172
190
|
// Limit: 200 — bei größeren Datasets fehlt der Tail im Dropdown
|
|
@@ -200,12 +218,13 @@ function ReferenceInput({
|
|
|
200
218
|
const { Banner } = usePrimitives();
|
|
201
219
|
const refEntity = field.refEntity ?? "";
|
|
202
220
|
const refFeature = field.refFeature ?? featureName;
|
|
203
|
-
const labelField = field.refLabelField ?? "id";
|
|
204
221
|
const isMultiple = field.refMultiple === true;
|
|
205
222
|
// Tier 2.7e Cross-Feature: refFeature kann ≠ featureName sein
|
|
206
223
|
// (z.B. items.assignee → users:query:user:list). Default ist
|
|
207
224
|
// same-feature, kommt aus dem ViewModel (parseRefTarget).
|
|
208
|
-
|
|
225
|
+
// fw#2780: refOptionsQuery replaces that source with an author-supplied
|
|
226
|
+
// handler whose rows already carry a composed `label`.
|
|
227
|
+
const { queryQn, labelKey } = referenceOptionSource(field, refFeature, refEntity);
|
|
209
228
|
// Issue #1681: "+ Neu" in der Combobox öffnet den Create-Screen der
|
|
210
229
|
// referenced entity als Dialog, statt die aktuelle Form zu verlassen.
|
|
211
230
|
// refFeature kann ein anderes Feature als das aktuell gerenderte sein
|
|
@@ -276,10 +295,10 @@ function ReferenceInput({
|
|
|
276
295
|
const rows = queryResult.data?.rows ?? [];
|
|
277
296
|
return rows.map((row) => {
|
|
278
297
|
const idVal = String(row["id"] ?? "");
|
|
279
|
-
const label = String(row[
|
|
298
|
+
const label = String(row[labelKey] ?? idVal);
|
|
280
299
|
return { value: idVal, label };
|
|
281
300
|
});
|
|
282
|
-
}, [queryResult.data,
|
|
301
|
+
}, [queryResult.data, labelKey]);
|
|
283
302
|
// Single: value ist String/null; Multi: Array. Coerce auf das was
|
|
284
303
|
// der Combobox-Mode erwartet, damit Storage-Drift (Server liefert
|
|
285
304
|
// alten String wo jetzt Array erwartet wird) keine Crash auslöst.
|
|
@@ -431,9 +450,8 @@ function ReadOnlyReferenceValue({
|
|
|
431
450
|
const t = useTranslation();
|
|
432
451
|
const refEntity = field.refEntity ?? "";
|
|
433
452
|
const refFeature = field.refFeature ?? featureName;
|
|
434
|
-
const labelField = field.refLabelField ?? "id";
|
|
435
453
|
const isMultiple = field.refMultiple === true;
|
|
436
|
-
const queryQn =
|
|
454
|
+
const { queryQn, labelKey } = referenceOptionSource(field, refFeature, refEntity);
|
|
437
455
|
const queryResult = useQuery<{ rows: ReadonlyArray<Record<string, unknown>> }>(queryQn, {
|
|
438
456
|
limit: REFERENCE_COMBOBOX_LIMIT,
|
|
439
457
|
});
|
|
@@ -454,7 +472,7 @@ function ReadOnlyReferenceValue({
|
|
|
454
472
|
const labels = ids.map((id) => {
|
|
455
473
|
if (systemLabel !== undefined && id === systemLabel.id) return t(systemLabel.labelKey);
|
|
456
474
|
const row = rows.find((r) => String(r["id"] ?? "") === id);
|
|
457
|
-
return row !== undefined ? String(row[
|
|
475
|
+
return row !== undefined ? String(row[labelKey] ?? id) : id;
|
|
458
476
|
});
|
|
459
477
|
return <Text testId={`field-value-${field.field}`}>{labels.join(", ")}</Text>;
|
|
460
478
|
}
|
package/src/index.ts
CHANGED
|
@@ -82,12 +82,15 @@ export {
|
|
|
82
82
|
useNavigateWithInitialValues,
|
|
83
83
|
} from "./app/nav";
|
|
84
84
|
export { lastSegment } from "./app/qn";
|
|
85
|
-
export type { ReturnHost } from "./app/return-to";
|
|
85
|
+
export type { ReturnHost, ReturnTo } from "./app/return-to";
|
|
86
86
|
export {
|
|
87
|
+
navigateToReturn,
|
|
88
|
+
navigateToReturnOr,
|
|
87
89
|
navigateWithReturnTo,
|
|
88
90
|
RETURN_TO_PARAM,
|
|
89
91
|
ReturnHostProvider,
|
|
90
92
|
resolveReturnTarget,
|
|
93
|
+
splitReturnTo,
|
|
91
94
|
useReturnHost,
|
|
92
95
|
useReturnTarget,
|
|
93
96
|
} from "./app/return-to";
|