@cosmicdrift/kumiko-headless 0.220.0 → 0.221.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-headless",
3
- "version": "0.220.0",
3
+ "version": "0.221.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.220.0",
39
+ "@cosmicdrift/kumiko-framework": "0.221.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("throws when submit-config has no dispatcher — forgot the provider", async () => {
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 expect(form.submit()).rejects.toThrow(/submit\(\) called without a dispatcher/);
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 () => {
@@ -178,7 +178,7 @@ export function createFormController<TValues extends FormValues, TCtx = unknown>
178
178
  errors = Object.freeze(merged);
179
179
  }
180
180
 
181
- function runValidate(scope?: readonly string[]): boolean {
181
+ function runValidate(scope?: readonly (keyof TValues & string)[]): boolean {
182
182
  // See the validate() doc comment in types.ts for the scope contract.
183
183
  const scopeSet = scope === undefined ? undefined : new Set(scope);
184
184
  if (!options.schema) {
@@ -364,9 +364,20 @@ export function createFormController<TValues extends FormValues, TCtx = unknown>
364
364
  // dispatcher was ever wired up.
365
365
  const dispatcher = submitCfg.dispatcher;
366
366
  if (!dispatcher) {
367
- throw new Error(
368
- "createFormController: submit() called without a dispatcher. Pass `submit.dispatcher` explicitly, or mount a <DispatcherProvider> above this form.",
369
- );
367
+ // Return SubmitResult (not throw) so RenderEdit's banner path shows
368
+ // the misconfiguration instead of an unhandled rejection on save.
369
+ return {
370
+ validationBlocked: false,
371
+ isSuccess: false,
372
+ isNoOp: false,
373
+ error: {
374
+ code: "misconfigured",
375
+ httpStatus: 500,
376
+ i18nKey: "errors.internal",
377
+ message:
378
+ "createFormController: submit() called without a dispatcher (dispatcher was absent when the form mounted). Pass `submit.dispatcher` explicitly, or mount a <DispatcherProvider> above this form.",
379
+ },
380
+ };
370
381
  }
371
382
 
372
383
  const runWrite = async (): Promise<SubmitResult<TData>> => {
package/src/form/types.ts CHANGED
@@ -145,7 +145,7 @@ export type FormController<TValues extends FormValues> = {
145
145
  // `scope` restricts reported issues to those field names; root-level
146
146
  // `.refine()` issues (path `(root)`) never match a scope and are dropped
147
147
  // by design — omit `scope` (as submit() does) to see them.
148
- validate(scope?: readonly string[]): boolean;
148
+ validate(scope?: readonly (keyof TValues & string)[]): boolean;
149
149
 
150
150
  // Reverts values to `initial`, clears errors. Doesn't fire a new
151
151
  // "initial" baseline — to adopt the current values as the new baseline
@@ -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
+ });
@@ -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
- type UnitKey = keyof typeof UNIT_INTL_IDS | keyof typeof UNIT_SUFFIXES;
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 isUnitKey(unit: string): unit is UnitKey {
103
- // Object.hasOwn, not `in` — `in` walks the prototype chain, so
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 || !isUnitKey(unit) || !Number.isFinite(n)) return String(value);
116
- if (Object.hasOwn(UNIT_INTL_IDS, unit)) {
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 as keyof typeof UNIT_INTL_IDS],
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
- return `${formatNumberCell(n, locale)} ${UNIT_SUFFIXES[unit as keyof typeof UNIT_SUFFIXES]}`;
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
@@ -110,6 +110,7 @@ export {
110
110
  embeddedCellOptionLabelKey,
111
111
  fieldLabelKey,
112
112
  fieldOptionLabelKey,
113
+ fieldOptionLabelKeyPrefix,
113
114
  groupEmbeddedListIssues,
114
115
  roundDerivedCellValue,
115
116
  sumEmbeddedListColumn,
@@ -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) => unknown;
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) => base.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) => base.subscribe(listener),
172
- setLocale: base.setLocale !== undefined ? (locale) => base.setLocale?.(locale) : undefined,
173
- locale: () => {
174
- const path = getPathname();
175
- if (options.resolvePage(path) !== undefined) {
176
- return options.detectLang(path);
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
  }
@@ -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 = fieldDef.type === "file" || fieldDef.type === "image";
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" ? Object.keys(fileDef?.variants ?? {})[0] : undefined;
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
@@ -18,6 +18,7 @@ export {
18
18
  embeddedCellOptionLabelKey,
19
19
  fieldLabelKey,
20
20
  fieldOptionLabelKey,
21
+ fieldOptionLabelKeyPrefix,
21
22
  } from "./list";
22
23
  export type {
23
24
  EditExtensionSectionViewModel,
@@ -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}:entity:${entityName}:field:${fieldName}:option:${value}`;
161
+ return `${fieldOptionLabelKeyPrefix(featureName, entityName, fieldName)}${value}`;
154
162
  }
155
163
 
156
164
  // Embedded-list cell label key — one level deeper than fieldLabelKey,
@@ -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
- /** Prefix-icon key from `EditFieldSpec.icon`, passed through unchanged
195
- * (no i18n — it's a symbolic key, not a display string). The renderer
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. */