@cosmicdrift/kumiko-headless 0.196.0 → 0.197.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.196.0",
3
+ "version": "0.197.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.196.0",
39
+ "@cosmicdrift/kumiko-framework": "0.197.0",
40
40
  "temporal-polyfill": "^0.3.2",
41
41
  "zod": "^4.4.3"
42
42
  },
@@ -35,6 +35,15 @@ 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 () => {
39
+ const form = createFormController({
40
+ initial: { title: "hello" },
41
+ submit: { type: "app:write:task:create" },
42
+ });
43
+
44
+ await expect(form.submit()).rejects.toThrow(/submit\(\) called without a dispatcher/);
45
+ });
46
+
38
47
  test("happy path: dispatches values, returns success, rebases form", async () => {
39
48
  const disp = makeDispatcher();
40
49
  const form = createFormController({
@@ -358,8 +358,19 @@ export function createFormController<TValues extends FormValues, TCtx = unknown>
358
358
  payload = stripUntouchedEmptyStrings(submittedValues, submittedSnapshot.initial);
359
359
  }
360
360
 
361
+ // Only enforced here, right before the write actually happens — a
362
+ // form that never reaches this point (blocked by validation, or a
363
+ // clean payloadMode "changes" no-op) must not throw just because no
364
+ // dispatcher was ever wired up.
365
+ const dispatcher = submitCfg.dispatcher;
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
+ );
370
+ }
371
+
361
372
  const runWrite = async (): Promise<SubmitResult<TData>> => {
362
- const result = await submitCfg.dispatcher.write<TData>(submitCfg.type, payload);
373
+ const result = await dispatcher.write<TData>(submitCfg.type, payload);
363
374
 
364
375
  if (result.isSuccess) {
365
376
  // Rebase to the SNAPSHOT, not to the current values — see
package/src/form/types.ts CHANGED
@@ -240,7 +240,10 @@ export type FormControllerOptions<TValues extends FormValues, TCtx = unknown> =
240
240
  export type SubmitPayloadMode = "values" | "changes";
241
241
 
242
242
  export type SubmitConfig<TValues extends FormValues = FormValues> = {
243
- readonly dispatcher: Dispatcher;
243
+ // Optional so callers can default it from an ambient DispatcherProvider
244
+ // (see kumiko-renderer's useForm) instead of requiring it at every call
245
+ // site — submit() throws at write-time if it's still missing then.
246
+ readonly dispatcher?: Dispatcher;
244
247
  // Qualified write-handler name (e.g. "orders:write:order:create").
245
248
  readonly type: string;
246
249
  readonly payloadMode?: SubmitPayloadMode;
@@ -93,6 +93,47 @@ describe("createLocaleRouter inverted default (website-style)", () => {
93
93
  });
94
94
  });
95
95
 
96
+ type OfflotPage = "home" | "features";
97
+
98
+ const offlotRouter = createLocaleRouter<OfflotPage>({
99
+ defaultLocale: "es",
100
+ prefixedLocales: ["en", "de"],
101
+ routes: {
102
+ home: { es: "/", en: "/en", de: "/de" },
103
+ features: { es: "/funciones", en: "/en/features", de: "/de/funktionen" },
104
+ },
105
+ });
106
+
107
+ describe("createLocaleRouter with a third locale", () => {
108
+ test("altLocalePath with explicit targetLocale reaches any locale", () => {
109
+ expect(offlotRouter.altLocalePath("/", "de")).toBe("/de");
110
+ expect(offlotRouter.altLocalePath("/", "en")).toBe("/en");
111
+ expect(offlotRouter.altLocalePath("/en/features", "de")).toBe("/de/funktionen");
112
+ expect(offlotRouter.altLocalePath("/de/funktionen", "es")).toBe("/funciones");
113
+ });
114
+
115
+ test("altLocalePath with explicit targetLocale equal to current locale is identity", () => {
116
+ expect(offlotRouter.altLocalePath("/en/features", "en")).toBe("/en/features");
117
+ expect(offlotRouter.altLocalePath("/funciones", "es")).toBe("/funciones");
118
+ });
119
+
120
+ test("altLocalePath falls back to homePage for unknown path with explicit target", () => {
121
+ expect(offlotRouter.altLocalePath("/unknown", "de")).toBe("/de");
122
+ });
123
+
124
+ test("altLocalePath rejects a targetLocale that only resolves via the prototype chain", () => {
125
+ expect(() => offlotRouter.altLocalePath("/", "__proto__")).toThrow(/no path for page/);
126
+ expect(() => offlotRouter.altLocalePath("/", "constructor")).toThrow(/no path for page/);
127
+ expect(() => offlotRouter.altLocalePath("/", "toString")).toThrow(/no path for page/);
128
+ });
129
+
130
+ test("altLocalePath without targetLocale keeps binary toggle to prefixedLocales[0]", () => {
131
+ expect(offlotRouter.altLocalePath("/")).toBe("/en");
132
+ expect(offlotRouter.altLocalePath("/en")).toBe("/");
133
+ expect(offlotRouter.altLocalePath("/de")).toBe("/");
134
+ });
135
+ });
136
+
96
137
  describe("createLocaleRouter homePage validation", () => {
97
138
  test("throws at construction when homePage has no routes entry", () => {
98
139
  expect(() =>
@@ -17,7 +17,7 @@ export type LocaleRouter<TPage extends string> = {
17
17
  detectLang(pathname: string): string;
18
18
  publicPath(page: TPage, locale: string): string;
19
19
  resolvePage(pathname: string): TPage | undefined;
20
- altLocalePath(pathname: string): string;
20
+ altLocalePath(pathname: string, targetLocale?: string): string;
21
21
  sectionAnchor(page: TPage, locale: string, fragment: string): string;
22
22
  };
23
23
 
@@ -109,16 +109,16 @@ export function createLocaleRouter<TPage extends string>(
109
109
  function publicPath(page: TPage, locale: string): string {
110
110
  const localePaths = routes[page];
111
111
  const routePath = localePaths?.[locale];
112
- if (routePath === undefined) {
112
+ if (
113
+ localePaths === undefined ||
114
+ !Object.hasOwn(localePaths, locale) ||
115
+ routePath === undefined
116
+ ) {
113
117
  throw new Error(`locale-routing: no path for page "${String(page)}" locale "${locale}"`);
114
118
  }
115
119
  return routePath;
116
120
  }
117
121
 
118
- // Binary toggle (defaultLocale <-> prefixedLocales[0]) — altLocalePath has
119
- // no way to target a specific alt locale beyond the first prefixed one.
120
- // Fine for the current bilingual (de/en) setup; a third prefixed locale
121
- // needs altLocalePath to take an explicit targetLocale param instead.
122
122
  function otherLocale(currentLocale: string): string {
123
123
  if (currentLocale === defaultLocale) {
124
124
  return prefixedLocales[0] ?? defaultLocale;
@@ -126,14 +126,14 @@ export function createLocaleRouter<TPage extends string>(
126
126
  return defaultLocale;
127
127
  }
128
128
 
129
- function altLocalePath(pathname: string): string {
129
+ function altLocalePath(pathname: string, targetLocale?: string): string {
130
130
  const path = normalizePath(pathname);
131
131
  const resolved = pathIndex.get(path);
132
- const targetLocale = otherLocale(detectLang(pathname));
132
+ const locale = targetLocale ?? otherLocale(detectLang(pathname));
133
133
  if (resolved !== undefined) {
134
- return publicPath(resolved.page, targetLocale);
134
+ return publicPath(resolved.page, locale);
135
135
  }
136
- return publicPath(homePage, targetLocale);
136
+ return publicPath(homePage, locale);
137
137
  }
138
138
 
139
139
  function sectionAnchor(page: TPage, locale: string, fragment: string): string {