@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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
|
132
|
+
const locale = targetLocale ?? otherLocale(detectLang(pathname));
|
|
133
133
|
if (resolved !== undefined) {
|
|
134
|
-
return publicPath(resolved.page,
|
|
134
|
+
return publicPath(resolved.page, locale);
|
|
135
135
|
}
|
|
136
|
-
return publicPath(homePage,
|
|
136
|
+
return publicPath(homePage, locale);
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
function sectionAnchor(page: TPage, locale: string, fragment: string): string {
|