@cosmicdrift/kumiko-headless 0.220.1 → 0.222.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 +2 -2
- package/src/form/__tests__/submit.test.ts +67 -2
- package/src/form/__tests__/validation.test.ts +12 -0
- package/src/form/form-controller.ts +51 -9
- package/src/form/types.ts +4 -2
- package/src/format/__tests__/format.test.ts +24 -1
- package/src/format/index.ts +18 -9
- package/src/index.ts +1 -0
- package/src/locale-routing/__tests__/locale-routing.test.ts +55 -0
- package/src/locale-routing/index.ts +50 -13
- package/src/view-model/edit.ts +5 -2
- package/src/view-model/index.ts +1 -0
- package/src/view-model/list.ts +9 -1
- package/src/view-model/types.ts +3 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-headless",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.222.0",
|
|
4
4
|
"description": "Headless UI logic for Kumiko — Dispatcher contract, Form-Controller, View-Model, Nav-Resolver. Plattform- und React-frei; jeder Renderer (renderer, renderer-web, renderer-native, …) komponiert darauf.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
}
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
39
|
+
"@cosmicdrift/kumiko-framework": "0.222.0",
|
|
40
40
|
"temporal-polyfill": "^0.3.2",
|
|
41
41
|
"zod": "^4.4.3"
|
|
42
42
|
},
|
|
@@ -35,13 +35,41 @@ describe("createFormController — submit()", () => {
|
|
|
35
35
|
await expect(form.submit()).rejects.toThrow(/submit\(\) called without a `submit` config/);
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
-
test("
|
|
38
|
+
test("returns isSuccess:false when submit-config has no dispatcher — forgot the provider", async () => {
|
|
39
39
|
const form = createFormController({
|
|
40
40
|
initial: { title: "hello" },
|
|
41
41
|
submit: { type: "app:write:task:create" },
|
|
42
42
|
});
|
|
43
43
|
|
|
44
|
-
await
|
|
44
|
+
const result = await form.submit();
|
|
45
|
+
expect(result.validationBlocked).toBe(false);
|
|
46
|
+
expect(result.isSuccess).toBe(false);
|
|
47
|
+
if (result.isSuccess || result.validationBlocked) throw new Error("expected write failure");
|
|
48
|
+
expect(result.error.message).toMatch(/submit\(\) called without a dispatcher/);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("returns validationBlocked without a dispatcher when schema fails first", async () => {
|
|
52
|
+
const form = createFormController({
|
|
53
|
+
initial: { title: "" },
|
|
54
|
+
schema: z.object({ title: z.string().min(3) }),
|
|
55
|
+
submit: { type: "app:write:task:create" },
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const result = await form.submit();
|
|
59
|
+
expect(result.validationBlocked).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("returns isNoOp without a dispatcher when payloadMode changes is empty", async () => {
|
|
63
|
+
const form = createFormController({
|
|
64
|
+
initial: { title: "hello" },
|
|
65
|
+
submit: { type: "app:write:task:update", payloadMode: "changes" },
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const result = await form.submit();
|
|
69
|
+
expect(result.validationBlocked).toBe(false);
|
|
70
|
+
if (!result.validationBlocked) {
|
|
71
|
+
expect(result.isNoOp).toBe(true);
|
|
72
|
+
}
|
|
45
73
|
});
|
|
46
74
|
|
|
47
75
|
test("happy path: dispatches values, returns success, rebases form", async () => {
|
|
@@ -446,4 +474,41 @@ describe("createFormController — submit()", () => {
|
|
|
446
474
|
expect(result.validationBlocked).toBe(true);
|
|
447
475
|
expect(disp.writeSpy).not.toHaveBeenCalled();
|
|
448
476
|
});
|
|
477
|
+
|
|
478
|
+
test("cross-field refine still blocks submit when validateScope is set (#1907)", async () => {
|
|
479
|
+
const schema = z
|
|
480
|
+
.object({ title: z.string().min(1), note: z.string() })
|
|
481
|
+
.refine((v) => v.title !== v.note, { message: "title and note must differ" });
|
|
482
|
+
const disp = makeDispatcher();
|
|
483
|
+
const form = createFormController({
|
|
484
|
+
initial: { title: "same", note: "same" },
|
|
485
|
+
schema,
|
|
486
|
+
submit: {
|
|
487
|
+
dispatcher: disp,
|
|
488
|
+
type: "app:write:task:create",
|
|
489
|
+
validateScope: ["title"],
|
|
490
|
+
},
|
|
491
|
+
});
|
|
492
|
+
const result = await form.submit();
|
|
493
|
+
expect(result.validationBlocked).toBe(true);
|
|
494
|
+
expect(disp.writeSpy).not.toHaveBeenCalled();
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
test("intentional empty clear survives second submit after rebase (#1858)", async () => {
|
|
498
|
+
const schema = z.object({ title: z.string().min(1), note: z.string().optional() });
|
|
499
|
+
const disp = makeDispatcher();
|
|
500
|
+
const form = createFormController({
|
|
501
|
+
initial: { title: "t", note: "x" },
|
|
502
|
+
schema,
|
|
503
|
+
submit: { dispatcher: disp, type: "app:write:task:create" },
|
|
504
|
+
});
|
|
505
|
+
form.setField("note", "");
|
|
506
|
+
const first = await form.submit();
|
|
507
|
+
expect(first.isSuccess).toBe(true);
|
|
508
|
+
expect(disp.writeSpy.mock.calls[0]?.[1]).toMatchObject({ note: "" });
|
|
509
|
+
form.setField("title", "t2");
|
|
510
|
+
const second = await form.submit();
|
|
511
|
+
expect(second.isSuccess).toBe(true);
|
|
512
|
+
expect(disp.writeSpy.mock.calls[1]?.[1]).toMatchObject({ note: "" });
|
|
513
|
+
});
|
|
449
514
|
});
|
|
@@ -187,6 +187,18 @@ describe("createFormController — validate(scope)", () => {
|
|
|
187
187
|
expect(form.getSnapshot().errors).toEqual({});
|
|
188
188
|
});
|
|
189
189
|
|
|
190
|
+
test("nested scope path matches issue root segment (#1898)", () => {
|
|
191
|
+
const schema = z.object({
|
|
192
|
+
address: z.object({ city: z.string().min(1) }),
|
|
193
|
+
});
|
|
194
|
+
const form = createFormController({
|
|
195
|
+
initial: { address: { city: "" } },
|
|
196
|
+
schema,
|
|
197
|
+
});
|
|
198
|
+
expect(form.validate(["address.city"])).toBe(false);
|
|
199
|
+
expect(form.getSnapshot().errors["address.city"]).toBeDefined();
|
|
200
|
+
});
|
|
201
|
+
|
|
190
202
|
// Hard rule (kumiko-framework#1885): an object-level .refine() issue has
|
|
191
203
|
// path "(root)", which never matches a field name — every scoped
|
|
192
204
|
// validate() call must ignore it, and only the unscoped final-submit
|
|
@@ -84,12 +84,15 @@ function valuesDiff<TValues extends FormValues>(
|
|
|
84
84
|
function stripUntouchedEmptyStrings<TValues extends FormValues>(
|
|
85
85
|
values: TValues,
|
|
86
86
|
initial: TValues,
|
|
87
|
+
forceIncludeEmpty: ReadonlySet<string>,
|
|
87
88
|
): TValues {
|
|
88
89
|
const v = values as Record<string, unknown>; // @cast-boundary form-values
|
|
89
90
|
const ini = initial as Record<string, unknown>; // @cast-boundary form-values
|
|
90
91
|
let out: Record<string, unknown> | undefined;
|
|
91
92
|
for (const key of Object.keys(v)) {
|
|
92
|
-
|
|
93
|
+
// Value-based seed strip, except keys previously submitted as "" after a
|
|
94
|
+
// real clear — those must survive post-rebase submits (#1858).
|
|
95
|
+
if (!forceIncludeEmpty.has(key) && v[key] === "" && ini[key] === "") {
|
|
93
96
|
out ??= { ...v };
|
|
94
97
|
delete out[key];
|
|
95
98
|
}
|
|
@@ -118,6 +121,9 @@ export function createFormController<TValues extends FormValues, TCtx = unknown>
|
|
|
118
121
|
let values: TValues = { ...options.initial };
|
|
119
122
|
let initial: TValues = { ...options.initial };
|
|
120
123
|
let errors: Readonly<Record<string, readonly FieldIssue[]>> = Object.freeze({});
|
|
124
|
+
// Keys last submitted as "" (intentional clear). Survives rebase so a later
|
|
125
|
+
// submit of other fields does not drop them (#1858).
|
|
126
|
+
const forceIncludeEmpty = new Set<string>();
|
|
121
127
|
// ctx is cast-held as TCtx; `undefined` is valid when callers don't
|
|
122
128
|
// declare conditional predicates that depend on it.
|
|
123
129
|
let ctx: TCtx = (options.ctx as TCtx) ?? (undefined as TCtx);
|
|
@@ -178,9 +184,16 @@ export function createFormController<TValues extends FormValues, TCtx = unknown>
|
|
|
178
184
|
errors = Object.freeze(merged);
|
|
179
185
|
}
|
|
180
186
|
|
|
181
|
-
function runValidate(
|
|
187
|
+
function runValidate(
|
|
188
|
+
scope?: readonly string[],
|
|
189
|
+
opts?: { readonly includeRoot?: boolean },
|
|
190
|
+
): boolean {
|
|
182
191
|
// See the validate() doc comment in types.ts for the scope contract.
|
|
183
|
-
|
|
192
|
+
// Normalize nested scope entries (`address.city` → `address`) so wizard
|
|
193
|
+
// callers that pass dotted paths still match issue root segments (#1898).
|
|
194
|
+
const scopeSet =
|
|
195
|
+
scope === undefined ? undefined : new Set(scope.map((s) => s.split(".")[0] ?? s));
|
|
196
|
+
const includeRoot = opts?.includeRoot === true;
|
|
184
197
|
if (!options.schema) {
|
|
185
198
|
if (Object.keys(errors).length > 0) {
|
|
186
199
|
replaceScopedErrors(scopeSet, {});
|
|
@@ -205,7 +218,13 @@ export function createFormController<TValues extends FormValues, TCtx = unknown>
|
|
|
205
218
|
const relevantIssues = allIssues.filter((issue) => {
|
|
206
219
|
const rootField = issue.path.split(".")[0] ?? "";
|
|
207
220
|
if (hiddenFields.has(rootField)) return false;
|
|
208
|
-
|
|
221
|
+
// Root-level .refine() issues use path "(root)". Wizard step validate()
|
|
222
|
+
// still drops them (#1885); submit() passes includeRoot so RenderEdit
|
|
223
|
+
// fields={…} cannot silently skip cross-field rules (#1907).
|
|
224
|
+
const isRootIssue = issue.path === "(root)";
|
|
225
|
+
if (scopeSet && !(includeRoot && isRootIssue) && !scopeSet.has(rootField)) {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
209
228
|
return true;
|
|
210
229
|
});
|
|
211
230
|
if (relevantIssues.length === 0) {
|
|
@@ -295,6 +314,7 @@ export function createFormController<TValues extends FormValues, TCtx = unknown>
|
|
|
295
314
|
if (alreadyClean) return;
|
|
296
315
|
values = { ...initial };
|
|
297
316
|
errors = Object.freeze({});
|
|
317
|
+
forceIncludeEmpty.clear();
|
|
298
318
|
invalidate();
|
|
299
319
|
},
|
|
300
320
|
rebase: runRebase,
|
|
@@ -322,7 +342,7 @@ export function createFormController<TValues extends FormValues, TCtx = unknown>
|
|
|
322
342
|
// pattern the server-side event-dispatcher uses (passInFlight).
|
|
323
343
|
if (submitInFlight) return submitInFlight as Promise<SubmitResult<TData>>;
|
|
324
344
|
|
|
325
|
-
if (!runValidate(submitCfg.validateScope)) {
|
|
345
|
+
if (!runValidate(submitCfg.validateScope, { includeRoot: true })) {
|
|
326
346
|
return { validationBlocked: true, isSuccess: false };
|
|
327
347
|
}
|
|
328
348
|
|
|
@@ -355,7 +375,11 @@ export function createFormController<TValues extends FormValues, TCtx = unknown>
|
|
|
355
375
|
} else if (submitCfg.stripEmptySeeds === false) {
|
|
356
376
|
payload = submittedValues;
|
|
357
377
|
} else {
|
|
358
|
-
payload = stripUntouchedEmptyStrings(
|
|
378
|
+
payload = stripUntouchedEmptyStrings(
|
|
379
|
+
submittedValues,
|
|
380
|
+
submittedSnapshot.initial,
|
|
381
|
+
forceIncludeEmpty,
|
|
382
|
+
);
|
|
359
383
|
}
|
|
360
384
|
|
|
361
385
|
// Only enforced here, right before the write actually happens — a
|
|
@@ -364,15 +388,33 @@ export function createFormController<TValues extends FormValues, TCtx = unknown>
|
|
|
364
388
|
// dispatcher was ever wired up.
|
|
365
389
|
const dispatcher = submitCfg.dispatcher;
|
|
366
390
|
if (!dispatcher) {
|
|
367
|
-
throw
|
|
368
|
-
|
|
369
|
-
|
|
391
|
+
// Return SubmitResult (not throw) so RenderEdit's banner path shows
|
|
392
|
+
// the misconfiguration instead of an unhandled rejection on save.
|
|
393
|
+
return {
|
|
394
|
+
validationBlocked: false,
|
|
395
|
+
isSuccess: false,
|
|
396
|
+
isNoOp: false,
|
|
397
|
+
error: {
|
|
398
|
+
code: "misconfigured",
|
|
399
|
+
httpStatus: 500,
|
|
400
|
+
i18nKey: "errors.internal",
|
|
401
|
+
message:
|
|
402
|
+
"createFormController: submit() called without a dispatcher (dispatcher was absent when the form mounted). Pass `submit.dispatcher` explicitly, or mount a <DispatcherProvider> above this form.",
|
|
403
|
+
},
|
|
404
|
+
};
|
|
370
405
|
}
|
|
371
406
|
|
|
372
407
|
const runWrite = async (): Promise<SubmitResult<TData>> => {
|
|
373
408
|
const result = await dispatcher.write<TData>(submitCfg.type, payload);
|
|
374
409
|
|
|
375
410
|
if (result.isSuccess) {
|
|
411
|
+
// Remember intentional empty clears so post-rebase submits keep them (#1858).
|
|
412
|
+
if (payload && typeof payload === "object" && !Array.isArray(payload)) {
|
|
413
|
+
for (const [k, v] of Object.entries(payload as Record<string, unknown>)) {
|
|
414
|
+
if (v === "") forceIncludeEmpty.add(k);
|
|
415
|
+
else forceIncludeEmpty.delete(k);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
376
418
|
// Rebase to the SNAPSHOT, not to the current values — see
|
|
377
419
|
// runRebaseToSnapshot comment for why.
|
|
378
420
|
runRebaseToSnapshot(submittedValues);
|
package/src/form/types.ts
CHANGED
|
@@ -143,8 +143,10 @@ export type FormController<TValues extends FormValues> = {
|
|
|
143
143
|
// relies entirely on server-side validation via the submit() path.
|
|
144
144
|
//
|
|
145
145
|
// `scope` restricts reported issues to those field names; root-level
|
|
146
|
-
// `.refine()` issues (path `(root)`)
|
|
147
|
-
//
|
|
146
|
+
// `.refine()` issues (path `(root)`) are dropped on scoped `validate()`
|
|
147
|
+
// calls (wizard steps, #1885). `submit()` always re-includes them even
|
|
148
|
+
// when `validateScope` is set so RenderEdit `fields={…}` cannot skip
|
|
149
|
+
// cross-field rules (#1907).
|
|
148
150
|
validate(scope?: readonly string[]): boolean;
|
|
149
151
|
|
|
150
152
|
// Reverts values to `initial`, clears errors. Doesn't fire a new
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { applyFormatSpec } from "../index";
|
|
2
|
+
import { applyFormatSpec, UNIT_FORMAT_KEYS } from "../index";
|
|
3
3
|
|
|
4
4
|
describe("applyFormatSpec — priority", () => {
|
|
5
5
|
test("rendert emptyLabel für undefined/null/leer/0 (nicht den globalen ''-Collapse)", () => {
|
|
@@ -111,6 +111,14 @@ describe("applyFormatSpec — unit (fw#2187)", () => {
|
|
|
111
111
|
unitDisplay: "short",
|
|
112
112
|
}).format(12),
|
|
113
113
|
);
|
|
114
|
+
// Ratio 0.15 must NOT become 15 % — unit:"percent" is percent-points.
|
|
115
|
+
expect(applyFormatSpec({ format: "unit", unit: "percent", locale: "de-DE" }, 0.15)).toBe(
|
|
116
|
+
new Intl.NumberFormat("de-DE", {
|
|
117
|
+
style: "unit",
|
|
118
|
+
unit: "percent",
|
|
119
|
+
unitDisplay: "short",
|
|
120
|
+
}).format(0.15),
|
|
121
|
+
);
|
|
114
122
|
});
|
|
115
123
|
|
|
116
124
|
test("m2 hat kein ECMA-402-sanktioniertes Intl-Unit (RangeError für 'square-meter') — Zahl + literales Suffix", () => {
|
|
@@ -189,3 +197,18 @@ describe("applyFormatSpec — enumOption (fw#2315)", () => {
|
|
|
189
197
|
expect(applyFormatSpec(spec, "", (k) => k)).toBe("");
|
|
190
198
|
});
|
|
191
199
|
});
|
|
200
|
+
|
|
201
|
+
describe("unit format keys stay in lockstep with UnitKey", () => {
|
|
202
|
+
// Mirror of packages/types/src/screen.ts UnitKey — keep lists equal without
|
|
203
|
+
// adding a kumiko-types dependency to headless.
|
|
204
|
+
const TYPE_UNIT_KEYS = ["m2", "km", "m", "kg", "percent"] as const;
|
|
205
|
+
test("every UnitKey formats with a non-bare number", () => {
|
|
206
|
+
for (const k of TYPE_UNIT_KEYS) {
|
|
207
|
+
expect(UNIT_FORMAT_KEYS.includes(k)).toBe(true);
|
|
208
|
+
expect(applyFormatSpec({ format: "unit", unit: k }, 1)).not.toBe("1");
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
test("no extra runtime keys beyond UnitKey", () => {
|
|
212
|
+
expect([...UNIT_FORMAT_KEYS].sort()).toEqual([...TYPE_UNIT_KEYS].sort());
|
|
213
|
+
});
|
|
214
|
+
});
|
package/src/format/index.ts
CHANGED
|
@@ -97,12 +97,12 @@ const UNIT_INTL_IDS = {
|
|
|
97
97
|
// the literal suffix instead of going through `style: "unit"`.
|
|
98
98
|
const UNIT_SUFFIXES = { m2: "m²" } as const;
|
|
99
99
|
|
|
100
|
-
|
|
100
|
+
function isIntlUnit(unit: string): unit is keyof typeof UNIT_INTL_IDS {
|
|
101
|
+
return Object.hasOwn(UNIT_INTL_IDS, unit);
|
|
102
|
+
}
|
|
101
103
|
|
|
102
|
-
function
|
|
103
|
-
|
|
104
|
-
// "toString" or "constructor" would pass as a valid unit key.
|
|
105
|
-
return Object.hasOwn(UNIT_INTL_IDS, unit) || Object.hasOwn(UNIT_SUFFIXES, unit);
|
|
104
|
+
function isSuffixUnit(unit: string): unit is keyof typeof UNIT_SUFFIXES {
|
|
105
|
+
return Object.hasOwn(UNIT_SUFFIXES, unit);
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
function formatUnitCell(
|
|
@@ -112,12 +112,12 @@ function formatUnitCell(
|
|
|
112
112
|
unitDisplay: Intl.NumberFormatOptions["unitDisplay"],
|
|
113
113
|
): string {
|
|
114
114
|
const n = typeof value === "number" ? value : Number(value);
|
|
115
|
-
if (unit === undefined || !
|
|
116
|
-
if (
|
|
115
|
+
if (unit === undefined || !Number.isFinite(n)) return String(value);
|
|
116
|
+
if (isIntlUnit(unit)) {
|
|
117
117
|
try {
|
|
118
118
|
return new Intl.NumberFormat(locale, {
|
|
119
119
|
style: "unit",
|
|
120
|
-
unit: UNIT_INTL_IDS[unit
|
|
120
|
+
unit: UNIT_INTL_IDS[unit],
|
|
121
121
|
unitDisplay,
|
|
122
122
|
}).format(n);
|
|
123
123
|
} catch {
|
|
@@ -125,9 +125,18 @@ function formatUnitCell(
|
|
|
125
125
|
return formatNumberCell(n, locale);
|
|
126
126
|
}
|
|
127
127
|
}
|
|
128
|
-
|
|
128
|
+
if (isSuffixUnit(unit)) {
|
|
129
|
+
return `${formatNumberCell(n, locale)} ${UNIT_SUFFIXES[unit]}`;
|
|
130
|
+
}
|
|
131
|
+
return String(value);
|
|
129
132
|
}
|
|
130
133
|
|
|
134
|
+
/** Exported for UnitKey parity tests against @cosmicdrift/kumiko-types/screen. */
|
|
135
|
+
export const UNIT_FORMAT_KEYS = [
|
|
136
|
+
...Object.keys(UNIT_INTL_IDS),
|
|
137
|
+
...Object.keys(UNIT_SUFFIXES),
|
|
138
|
+
] as const;
|
|
139
|
+
|
|
131
140
|
export { escapeHtml, escapeHtmlAttr, escapeXml, isSafeHref, stripControlChars } from "./escape";
|
|
132
141
|
export { type HtmlValue, html, RawHtml, raw } from "./html-template";
|
|
133
142
|
export { currencyDecimals } from "./money";
|
package/src/index.ts
CHANGED
|
@@ -198,6 +198,37 @@ describe("wrapUrlLocaleResolver", () => {
|
|
|
198
198
|
expect(wrapped.locale()).toBe("de");
|
|
199
199
|
});
|
|
200
200
|
|
|
201
|
+
test("missing pathname falls back to base locale (no fake /)", () => {
|
|
202
|
+
const wrapped = wrapUrlLocaleResolver(staticBase("en"), {
|
|
203
|
+
resolvePage: (pathname) => moneyHorseRouter.resolvePage(pathname),
|
|
204
|
+
detectLang: (pathname) => moneyHorseRouter.detectLang(pathname),
|
|
205
|
+
pathname: () => undefined,
|
|
206
|
+
});
|
|
207
|
+
expect(wrapped.locale()).toBe("en");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("subscribe fires on pathname subscription", () => {
|
|
211
|
+
let pathListener: (() => void) | undefined;
|
|
212
|
+
const wrapped = wrapUrlLocaleResolver(staticBase("de"), {
|
|
213
|
+
resolvePage: () => "home",
|
|
214
|
+
detectLang: () => "en",
|
|
215
|
+
pathname: () => "/en",
|
|
216
|
+
subscribePathname: (listener) => {
|
|
217
|
+
pathListener = listener;
|
|
218
|
+
return () => {
|
|
219
|
+
pathListener = undefined;
|
|
220
|
+
};
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
let hits = 0;
|
|
224
|
+
const unsub = wrapped.subscribe(() => {
|
|
225
|
+
hits += 1;
|
|
226
|
+
});
|
|
227
|
+
pathListener?.();
|
|
228
|
+
expect(hits).toBe(1);
|
|
229
|
+
unsub();
|
|
230
|
+
});
|
|
231
|
+
|
|
201
232
|
test("unregistered path keeps base locale", () => {
|
|
202
233
|
const wrapped = wrapUrlLocaleResolver(staticBase("de"), {
|
|
203
234
|
resolvePage: (pathname) => moneyHorseRouter.resolvePage(pathname),
|
|
@@ -218,4 +249,28 @@ describe("wrapUrlLocaleResolver", () => {
|
|
|
218
249
|
expect(base.locale()).toBe("en");
|
|
219
250
|
expect(wrapped.locale()).toBe("en");
|
|
220
251
|
});
|
|
252
|
+
|
|
253
|
+
test("URL locale wins over sticky setLocale on a registered path", () => {
|
|
254
|
+
const base = staticBase("de");
|
|
255
|
+
const wrapped = wrapUrlLocaleResolver(base, {
|
|
256
|
+
resolvePage: (pathname) => moneyHorseRouter.resolvePage(pathname),
|
|
257
|
+
detectLang: (pathname) => moneyHorseRouter.detectLang(pathname),
|
|
258
|
+
pathname: () => "/en/features",
|
|
259
|
+
});
|
|
260
|
+
wrapped.setLocale?.("de");
|
|
261
|
+
expect(base.locale()).toBe("de");
|
|
262
|
+
expect(wrapped.locale()).toBe("en");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("translateFor resolves copy against the URL locale", () => {
|
|
266
|
+
const base = staticBase("de");
|
|
267
|
+
const wrapped = wrapUrlLocaleResolver(base, {
|
|
268
|
+
resolvePage: (pathname) => moneyHorseRouter.resolvePage(pathname),
|
|
269
|
+
detectLang: (pathname) => moneyHorseRouter.detectLang(pathname),
|
|
270
|
+
pathname: () => "/en/features",
|
|
271
|
+
translateFor: (locale, key) => `${locale}:${key}`,
|
|
272
|
+
});
|
|
273
|
+
expect(wrapped.translate("hello")).toBe("en:hello");
|
|
274
|
+
expect(wrapped.locale()).toBe("en");
|
|
275
|
+
});
|
|
221
276
|
});
|
|
@@ -146,36 +146,73 @@ export function createLocaleRouter<TPage extends string>(
|
|
|
146
146
|
}
|
|
147
147
|
|
|
148
148
|
export type WrapUrlLocaleResolverOptions = {
|
|
149
|
-
resolvePage: (pathname: string) =>
|
|
149
|
+
resolvePage: (pathname: string) => string | undefined;
|
|
150
150
|
detectLang: (pathname: string) => string;
|
|
151
|
-
pathname?: () => string;
|
|
151
|
+
pathname?: () => string | undefined;
|
|
152
|
+
/** Optional pathname-change subscription (browser default: popstate). */
|
|
153
|
+
subscribePathname?: (listener: () => void) => () => void;
|
|
154
|
+
/** When set, translate against the URL-resolved locale instead of base.translate
|
|
155
|
+
* (needed for fixed-T / cloneInstance resolvers that are not locale-live). */
|
|
156
|
+
translateFor?: (
|
|
157
|
+
locale: string,
|
|
158
|
+
key: string,
|
|
159
|
+
params?: Readonly<Record<string, unknown>>,
|
|
160
|
+
) => string;
|
|
152
161
|
};
|
|
153
162
|
|
|
154
|
-
function defaultBrowserPathname(): string {
|
|
163
|
+
function defaultBrowserPathname(): string | undefined {
|
|
155
164
|
const loc = (globalThis as { location?: { pathname?: string } }).location;
|
|
156
|
-
return typeof loc?.pathname === "string" ? loc.pathname :
|
|
165
|
+
return typeof loc?.pathname === "string" ? loc.pathname : undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function defaultSubscribePathname(listener: () => void): () => void {
|
|
169
|
+
const g = globalThis as {
|
|
170
|
+
addEventListener?: typeof addEventListener;
|
|
171
|
+
removeEventListener?: typeof removeEventListener;
|
|
172
|
+
};
|
|
173
|
+
if (typeof g.addEventListener !== "function" || typeof g.removeEventListener !== "function") {
|
|
174
|
+
return () => {};
|
|
175
|
+
}
|
|
176
|
+
g.addEventListener("popstate", listener);
|
|
177
|
+
return () => {
|
|
178
|
+
g.removeEventListener?.("popstate", listener);
|
|
179
|
+
};
|
|
157
180
|
}
|
|
158
181
|
|
|
159
182
|
/**
|
|
160
183
|
* Prefer URL-derived locale when `resolvePage` matches the current path;
|
|
161
184
|
* otherwise defer to `base.locale()`. Passes through setLocale and the rest of LocaleResolver.
|
|
185
|
+
* Without `translateFor`, `base.translate` must already be locale-live.
|
|
162
186
|
*/
|
|
163
187
|
export function wrapUrlLocaleResolver(
|
|
164
188
|
base: LocaleResolver,
|
|
165
189
|
options: WrapUrlLocaleResolverOptions,
|
|
166
190
|
): LocaleResolver {
|
|
167
191
|
const getPathname = options.pathname ?? defaultBrowserPathname;
|
|
192
|
+
const subscribePathname = options.subscribePathname ?? defaultSubscribePathname;
|
|
193
|
+
const resolveLocale = (): string => {
|
|
194
|
+
const path = getPathname();
|
|
195
|
+
if (path === undefined) return base.locale();
|
|
196
|
+
if (options.resolvePage(path) != null) {
|
|
197
|
+
return options.detectLang(path);
|
|
198
|
+
}
|
|
199
|
+
return base.locale();
|
|
200
|
+
};
|
|
168
201
|
return {
|
|
169
|
-
translate: (key, params) =>
|
|
202
|
+
translate: (key, params) =>
|
|
203
|
+
options.translateFor
|
|
204
|
+
? options.translateFor(resolveLocale(), key, params)
|
|
205
|
+
: base.translate(key, params),
|
|
170
206
|
timeZone: () => base.timeZone(),
|
|
171
|
-
subscribe: (listener) =>
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
}
|
|
178
|
-
return base.locale();
|
|
207
|
+
subscribe: (listener) => {
|
|
208
|
+
const unsubBase = base.subscribe(listener);
|
|
209
|
+
const unsubPath = subscribePathname(listener);
|
|
210
|
+
return () => {
|
|
211
|
+
unsubBase();
|
|
212
|
+
unsubPath();
|
|
213
|
+
};
|
|
179
214
|
},
|
|
215
|
+
setLocale: base.setLocale !== undefined ? (locale) => base.setLocale?.(locale) : undefined,
|
|
216
|
+
locale: resolveLocale,
|
|
180
217
|
};
|
|
181
218
|
}
|
package/src/view-model/edit.ts
CHANGED
|
@@ -178,7 +178,8 @@ export function computeEditViewModel<
|
|
|
178
178
|
: undefined;
|
|
179
179
|
// file/image: accept/maxSize ins ViewModel + entityType/fieldName für
|
|
180
180
|
// den Upload-POST (Endpoint validiert gegen die richtige Field-Def).
|
|
181
|
-
const isFileType =
|
|
181
|
+
const isFileType =
|
|
182
|
+
fieldDef.type === "file" || fieldDef.type === "image" || fieldDef.type === "images";
|
|
182
183
|
// ponytail: "EUR" mirrors DEFAULT_CURRENCIES[0] from
|
|
183
184
|
// framework/src/engine/field-helpers.ts — headless has no dependency
|
|
184
185
|
// on that module, so the literal is duplicated here instead of
|
|
@@ -193,7 +194,9 @@ export function computeEditViewModel<
|
|
|
193
194
|
})
|
|
194
195
|
: undefined;
|
|
195
196
|
const imageVariant =
|
|
196
|
-
fieldDef.type === "image"
|
|
197
|
+
fieldDef.type === "image" || fieldDef.type === "images"
|
|
198
|
+
? Object.keys(fileDef?.variants ?? {})[0]
|
|
199
|
+
: undefined;
|
|
197
200
|
const capture = fieldDef.type === "image" ? fileDef?.capture : undefined;
|
|
198
201
|
// Embedded-LIST field (`multiple: true`) — per-cell metadata for a
|
|
199
202
|
// renderer to draw one row per array item (invoice-positions-style
|
package/src/view-model/index.ts
CHANGED
package/src/view-model/list.ts
CHANGED
|
@@ -144,13 +144,21 @@ export function fieldLabelKey(featureName: string, entityName: string, fieldName
|
|
|
144
144
|
return `${featureName}:entity:${entityName}:field:${fieldName}`;
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
export function fieldOptionLabelKeyPrefix(
|
|
148
|
+
featureName: string,
|
|
149
|
+
entityName: string,
|
|
150
|
+
fieldName: string,
|
|
151
|
+
): string {
|
|
152
|
+
return `${fieldLabelKey(featureName, entityName, fieldName)}:option:`;
|
|
153
|
+
}
|
|
154
|
+
|
|
147
155
|
export function fieldOptionLabelKey(
|
|
148
156
|
featureName: string,
|
|
149
157
|
entityName: string,
|
|
150
158
|
fieldName: string,
|
|
151
159
|
value: string,
|
|
152
160
|
): string {
|
|
153
|
-
return `${featureName
|
|
161
|
+
return `${fieldOptionLabelKeyPrefix(featureName, entityName, fieldName)}${value}`;
|
|
154
162
|
}
|
|
155
163
|
|
|
156
164
|
// Embedded-list cell label key — one level deeper than fieldLabelKey,
|
package/src/view-model/types.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
EditFieldSpec,
|
|
3
3
|
EditSectionSpec,
|
|
4
|
+
FieldIconKey,
|
|
4
5
|
FieldRenderer,
|
|
5
6
|
ListColumnSpec,
|
|
6
7
|
PlatformComponent,
|
|
@@ -191,11 +192,8 @@ export type EditFieldViewModel = {
|
|
|
191
192
|
/** Only for `type: "image"` — forwarded to the file input's `capture`
|
|
192
193
|
* attribute so a phone opens the camera instead of the file picker. */
|
|
193
194
|
readonly capture?: "environment" | "user";
|
|
194
|
-
/**
|
|
195
|
-
|
|
196
|
-
* resolves it against the FIELD_ICONS registry; unknown keys silently
|
|
197
|
-
* fall back to "no icon". */
|
|
198
|
-
readonly icon?: string;
|
|
195
|
+
/** Closed FieldIconKey from EditFieldSpec.icon (FIELD_ICONS registry). */
|
|
196
|
+
readonly icon?: FieldIconKey;
|
|
199
197
|
/** Only set for an embedded-LIST field (`multiple: true`) — the per-cell
|
|
200
198
|
* metadata the renderer needs to draw each column/cell. Absent for a
|
|
201
199
|
* plain (non-list) embedded field. */
|