@cosmicdrift/kumiko-headless 0.1.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 +37 -0
- package/src/contracts/__tests__/contracts.test.ts +123 -0
- package/src/contracts/asset.ts +48 -0
- package/src/contracts/index.ts +23 -0
- package/src/contracts/locale.ts +33 -0
- package/src/contracts/primitives.ts +158 -0
- package/src/dispatcher/__tests__/contract.test.ts +160 -0
- package/src/dispatcher/index.ts +14 -0
- package/src/dispatcher/types.ts +194 -0
- package/src/form/__tests__/conditional-fields.test.ts +177 -0
- package/src/form/__tests__/form-controller.test.ts +195 -0
- package/src/form/__tests__/submit.test.ts +315 -0
- package/src/form/__tests__/validation.test.ts +124 -0
- package/src/form/form-controller.ts +333 -0
- package/src/form/index.ts +15 -0
- package/src/form/types.ts +264 -0
- package/src/form/zod-bridge.ts +75 -0
- package/src/index.ts +81 -0
- package/src/nav/__tests__/resolve.test.ts +202 -0
- package/src/nav/index.ts +8 -0
- package/src/nav/resolve.ts +77 -0
- package/src/nav/types.ts +61 -0
- package/src/store/__tests__/create-store.test.ts +139 -0
- package/src/store/__tests__/equality.test.ts +66 -0
- package/src/store/create-store.ts +44 -0
- package/src/store/equality.ts +34 -0
- package/src/store/index.ts +3 -0
- package/src/store/types.ts +27 -0
- package/src/view-model/__tests__/edit.test.ts +242 -0
- package/src/view-model/__tests__/list.test.ts +139 -0
- package/src/view-model/edit.ts +164 -0
- package/src/view-model/index.ts +19 -0
- package/src/view-model/list.ts +158 -0
- package/src/view-model/types.ts +150 -0
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cosmicdrift/kumiko-headless",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"license": "BUSL-1.1",
|
|
6
|
+
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/cosmicdriftgamestudio/kumiko-framework.git",
|
|
10
|
+
"directory": "packages/headless"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/cosmicdriftgamestudio/kumiko-framework/issues"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://kumiko.so",
|
|
16
|
+
"type": "module",
|
|
17
|
+
"kumiko": {
|
|
18
|
+
"runtime": "client"
|
|
19
|
+
},
|
|
20
|
+
"exports": {
|
|
21
|
+
".": "./src/index.ts",
|
|
22
|
+
"./dispatcher": "./src/dispatcher/index.ts"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@cosmicdrift/kumiko-framework": "workspace:*",
|
|
26
|
+
"zod": "^4.3.6"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"registry": "https://registry.npmjs.org",
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"src",
|
|
34
|
+
"README.md",
|
|
35
|
+
"LICENSE"
|
|
36
|
+
]
|
|
37
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { describe, expect, test, vi } from "vitest";
|
|
2
|
+
import type {
|
|
3
|
+
AssetResolver,
|
|
4
|
+
ButtonProps,
|
|
5
|
+
LocaleResolver,
|
|
6
|
+
PrimitiveCommonProps,
|
|
7
|
+
PrimitivesContract,
|
|
8
|
+
SelectProps,
|
|
9
|
+
TextInputProps,
|
|
10
|
+
} from "../index";
|
|
11
|
+
|
|
12
|
+
// These aren't unit tests — they're compile-time contract guards in
|
|
13
|
+
// runtime clothing. Building a small fake that satisfies each contract
|
|
14
|
+
// ensures a future refactor that drops or renames a required field
|
|
15
|
+
// breaks here loudly instead of in some downstream renderer.
|
|
16
|
+
|
|
17
|
+
describe("Asset / Locale / Primitives contracts", () => {
|
|
18
|
+
test("AssetResolver — a minimal in-memory resolver compiles and runs", () => {
|
|
19
|
+
const table = new Map<string, { uri: string; alt?: string }>([
|
|
20
|
+
["app:asset:logo", { uri: "/static/logo.svg", alt: "Kumiko" }],
|
|
21
|
+
]);
|
|
22
|
+
const resolver: AssetResolver = {
|
|
23
|
+
resolve(qn) {
|
|
24
|
+
const found = table.get(qn);
|
|
25
|
+
return found ? { uri: found.uri, alt: found.alt } : null;
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
expect(resolver.resolve("app:asset:logo")?.uri).toBe("/static/logo.svg");
|
|
30
|
+
expect(resolver.resolve("app:asset:missing")).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("LocaleResolver — subscribe/unsubscribe + translate with params", () => {
|
|
34
|
+
const listeners = new Set<() => void>();
|
|
35
|
+
const resolver: LocaleResolver = {
|
|
36
|
+
translate(key, params) {
|
|
37
|
+
// Minimal translation: "errors.value.minimum" + {min:3} → "≥3"
|
|
38
|
+
if (key === "errors.value.minimum" && params?.["min"] !== undefined) {
|
|
39
|
+
return `≥${params["min"]}`;
|
|
40
|
+
}
|
|
41
|
+
return key;
|
|
42
|
+
},
|
|
43
|
+
locale: () => "de-AT",
|
|
44
|
+
timeZone: () => "Europe/Vienna",
|
|
45
|
+
subscribe(listener) {
|
|
46
|
+
listeners.add(listener);
|
|
47
|
+
return () => listeners.delete(listener);
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
expect(resolver.translate("errors.value.minimum", { min: 3 })).toBe("≥3");
|
|
52
|
+
expect(resolver.locale()).toBe("de-AT");
|
|
53
|
+
|
|
54
|
+
const listener = vi.fn();
|
|
55
|
+
const unsubscribe = resolver.subscribe(listener);
|
|
56
|
+
for (const l of listeners) l();
|
|
57
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
58
|
+
unsubscribe();
|
|
59
|
+
for (const l of listeners) l();
|
|
60
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("PrimitivesContract — a fake renderer's primitives satisfy the type", () => {
|
|
64
|
+
// The renderer-side wiring creates a concrete object where each
|
|
65
|
+
// member is a component. From ui-core's vantage they're opaque
|
|
66
|
+
// values typed as `unknown` — we only care that all 11 keys are
|
|
67
|
+
// present.
|
|
68
|
+
const primitives: PrimitivesContract = {
|
|
69
|
+
TextInput: { kind: "text-input" },
|
|
70
|
+
NumberInput: { kind: "number-input" },
|
|
71
|
+
Select: { kind: "select" },
|
|
72
|
+
Toggle: { kind: "toggle" },
|
|
73
|
+
DatePicker: { kind: "date-picker" },
|
|
74
|
+
Button: { kind: "button" },
|
|
75
|
+
Modal: { kind: "modal" },
|
|
76
|
+
Toast: { kind: "toast" },
|
|
77
|
+
Badge: { kind: "badge" },
|
|
78
|
+
Card: { kind: "card" },
|
|
79
|
+
Icon: { kind: "icon" },
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// Exhaustiveness — missing a key means the contract drifted without
|
|
83
|
+
// updating this test (and, by implication, every primitives-* impl).
|
|
84
|
+
expect(Object.keys(primitives)).toHaveLength(11);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("PrimitiveCommonProps propagate to concrete props (TextInput, Select, Button)", () => {
|
|
88
|
+
// Compile-time assertion: PrimitiveCommonProps members narrow correctly
|
|
89
|
+
// on each derived prop type. Runtime side just checks the object
|
|
90
|
+
// shape we'd hand to a primitive component.
|
|
91
|
+
const textProps: TextInputProps = {
|
|
92
|
+
id: "title",
|
|
93
|
+
name: "title",
|
|
94
|
+
disabled: false,
|
|
95
|
+
readOnly: false,
|
|
96
|
+
required: true,
|
|
97
|
+
label: "Title",
|
|
98
|
+
value: "hello",
|
|
99
|
+
onChange: () => {},
|
|
100
|
+
type: "text",
|
|
101
|
+
};
|
|
102
|
+
const selectProps: SelectProps<"a" | "b"> = {
|
|
103
|
+
label: "Pick one",
|
|
104
|
+
value: "a",
|
|
105
|
+
onChange: () => {},
|
|
106
|
+
options: [
|
|
107
|
+
{ value: "a", label: "A" },
|
|
108
|
+
{ value: "b", label: "B" },
|
|
109
|
+
],
|
|
110
|
+
};
|
|
111
|
+
const buttonProps: ButtonProps = {
|
|
112
|
+
label: "Save",
|
|
113
|
+
onPress: () => {},
|
|
114
|
+
variant: "primary",
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const common: PrimitiveCommonProps = { label: "Title" };
|
|
118
|
+
expect(common.label).toBe("Title");
|
|
119
|
+
expect(textProps.value).toBe("hello");
|
|
120
|
+
expect(selectProps.options).toHaveLength(2);
|
|
121
|
+
expect(buttonProps.variant).toBe("primary");
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Asset-Resolver — the layer between "qualified asset name" and "URL the
|
|
2
|
+
// renderer can point an <img>/<Image> at". An asset (logo, illustration,
|
|
3
|
+
// icon bundle) is declared by a feature with a qualified name
|
|
4
|
+
// ("admin:asset:hero-logo") and resolved at render-time against the
|
|
5
|
+
// current tenant / theme / platform.
|
|
6
|
+
//
|
|
7
|
+
// Why a resolver indirection:
|
|
8
|
+
// - Tenant-theming (different logo per tenant) without the feature
|
|
9
|
+
// knowing what tenant it's rendering for.
|
|
10
|
+
// - Dark-mode / light-mode variants picked up from the current theme
|
|
11
|
+
// context.
|
|
12
|
+
// - Web vs mobile different asset bundles (e.g. PNG vs SVG).
|
|
13
|
+
//
|
|
14
|
+
// The app registers a concrete resolver on startup and hands it to the
|
|
15
|
+
// renderer via context. Feature code calls `useAsset("admin:asset:logo")`
|
|
16
|
+
// (renderer-side hook) → internally `AssetResolver.resolve(qn)`.
|
|
17
|
+
|
|
18
|
+
// Render-context the resolver may consult. Everything here is optional;
|
|
19
|
+
// the default resolver ignores fields it doesn't use.
|
|
20
|
+
export type AssetResolveContext = {
|
|
21
|
+
readonly tenantId?: string;
|
|
22
|
+
readonly theme?: "light" | "dark";
|
|
23
|
+
readonly platform?: "web" | "ios" | "android";
|
|
24
|
+
readonly locale?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type AssetResolution = {
|
|
28
|
+
// Final URL / URI — web uses https://, Expo uses local file:// or
|
|
29
|
+
// remote https://, SSR may get a data: URI for inlined critical
|
|
30
|
+
// assets.
|
|
31
|
+
readonly uri: string;
|
|
32
|
+
// Optional pre-computed dimensions so the renderer can reserve layout
|
|
33
|
+
// space without a first-paint reflow (fixes the image-loads-then-
|
|
34
|
+
// layout-jumps UX on mobile).
|
|
35
|
+
readonly width?: number;
|
|
36
|
+
readonly height?: number;
|
|
37
|
+
// Alt-text to apply when the asset renders into an <img>. The i18n
|
|
38
|
+
// layer resolved it to a string before the resolver returned.
|
|
39
|
+
readonly alt?: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type AssetResolver = {
|
|
43
|
+
// Returns null when the asset isn't known; renderer shows a fallback
|
|
44
|
+
// (missing-image placeholder) instead of crashing. Kumiko's standard
|
|
45
|
+
// renderer logs a warn when it sees null so features catch typos in
|
|
46
|
+
// the asset qualified name early.
|
|
47
|
+
resolve(qn: string, ctx?: AssetResolveContext): AssetResolution | null;
|
|
48
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
AssetResolution,
|
|
3
|
+
AssetResolveContext,
|
|
4
|
+
AssetResolver,
|
|
5
|
+
} from "./asset";
|
|
6
|
+
export type { LocaleResolver } from "./locale";
|
|
7
|
+
export type {
|
|
8
|
+
BadgeProps,
|
|
9
|
+
ButtonProps,
|
|
10
|
+
CardProps,
|
|
11
|
+
DatePickerProps,
|
|
12
|
+
IconProps,
|
|
13
|
+
ModalProps,
|
|
14
|
+
NumberInputProps,
|
|
15
|
+
PrimitiveCommonProps,
|
|
16
|
+
PrimitivesContract,
|
|
17
|
+
SelectOption,
|
|
18
|
+
SelectProps,
|
|
19
|
+
TextInputProps,
|
|
20
|
+
ToastIntent,
|
|
21
|
+
ToastProps,
|
|
22
|
+
ToggleProps,
|
|
23
|
+
} from "./primitives";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Locale-Resolver — translation lookup + language/region info the
|
|
2
|
+
// renderer needs. Sits in front of i18next (or whatever i18n library
|
|
3
|
+
// the app wires up). ui-core calls it generically so form-controller,
|
|
4
|
+
// view-model, and nav-resolver all produce already-translated strings
|
|
5
|
+
// to hand to the renderer.
|
|
6
|
+
//
|
|
7
|
+
// The framework already has an i18n layer (@cosmicdrift/kumiko-framework/i18n) that
|
|
8
|
+
// features register translations against. This contract is what ui-core
|
|
9
|
+
// consumes — the app's entrypoint instantiates the resolver from a
|
|
10
|
+
// framework i18next instance and passes it to the renderer via context.
|
|
11
|
+
|
|
12
|
+
export type LocaleResolver = {
|
|
13
|
+
// Resolve an i18n key to a localized string. `params` are interpolated
|
|
14
|
+
// (`{name}` → params.name) — same semantics as i18next t().
|
|
15
|
+
translate(key: string, params?: Readonly<Record<string, unknown>>): string;
|
|
16
|
+
// Current locale in BCP-47 form (e.g. "de-AT", "en-US"). Feature code
|
|
17
|
+
// that does manual formatting (e.g. a custom number renderer) reads
|
|
18
|
+
// this instead of calling translate() on a placeholder.
|
|
19
|
+
locale(): string;
|
|
20
|
+
// Preferred time-zone for the current user. Time-zone-aware fields
|
|
21
|
+
// (Kumiko's locatedTimestamp) render in this zone unless the entity
|
|
22
|
+
// overrides it with a per-row locatedBy reference.
|
|
23
|
+
timeZone(): string;
|
|
24
|
+
// Subscribe to locale/timezone changes. Hooks (useTranslation) rely
|
|
25
|
+
// on this so a user switching language mid-session triggers a
|
|
26
|
+
// re-render across every subscribed consumer.
|
|
27
|
+
subscribe(listener: () => void): () => void;
|
|
28
|
+
// Optional — when present, the resolver is stateful and callers (like
|
|
29
|
+
// a language-picker UI) can trigger a locale change that will be
|
|
30
|
+
// broadcast via `subscribe`. Static resolvers (e.g. server-side
|
|
31
|
+
// render with a fixed locale) omit it.
|
|
32
|
+
setLocale?(locale: string): void;
|
|
33
|
+
};
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Primitives-Contract — the minimal set of UI building blocks every
|
|
2
|
+
// renderer must provide. A "primitive" is a unit the standard-renderers
|
|
3
|
+
// (entityList, entityEdit, etc.) call into without knowing which library
|
|
4
|
+
// implements it. On web that's shadcn; on mobile it's react-native-paper;
|
|
5
|
+
// both packages (primitives-shadcn, primitives-paper) implement this
|
|
6
|
+
// contract and the app wires one of them into the renderer via context.
|
|
7
|
+
//
|
|
8
|
+
// Why a contract here (in ui-core) instead of in each renderer:
|
|
9
|
+
// ui-core's view-model builder and form-controller produce metadata
|
|
10
|
+
// (field-type, validation-state, label, placeholder) that the renderer
|
|
11
|
+
// translates into primitive invocations. Having the contract shape in
|
|
12
|
+
// ui-core means the metadata can carry references to primitives by name
|
|
13
|
+
// ("text-input", "select") and the translation is a pure map-lookup on
|
|
14
|
+
// the renderer side, not a switch statement that re-derives UI intent
|
|
15
|
+
// from entity-schema.
|
|
16
|
+
//
|
|
17
|
+
// Runtime-free: every primitive is expressed as its input contract only
|
|
18
|
+
// (what props it takes). Concrete components live in each primitives-*
|
|
19
|
+
// package and depend on React / React Native / etc.; ui-core never
|
|
20
|
+
// imports them.
|
|
21
|
+
|
|
22
|
+
// Common props every primitive accepts. Renderers pass down the
|
|
23
|
+
// current field-state (visible/readonly/required from FormController)
|
|
24
|
+
// through these — a primitive that honours them doesn't need to know
|
|
25
|
+
// about Kumiko's form-controller, it just renders according to flags.
|
|
26
|
+
export type PrimitiveCommonProps = {
|
|
27
|
+
readonly id?: string;
|
|
28
|
+
readonly name?: string;
|
|
29
|
+
readonly disabled?: boolean;
|
|
30
|
+
readonly readOnly?: boolean;
|
|
31
|
+
readonly required?: boolean;
|
|
32
|
+
// Accessible label / placeholder / helper text. These are already
|
|
33
|
+
// localized strings — the renderer resolves i18n keys via useTranslation
|
|
34
|
+
// before handing them to the primitive.
|
|
35
|
+
readonly label?: string;
|
|
36
|
+
readonly placeholder?: string;
|
|
37
|
+
readonly helperText?: string;
|
|
38
|
+
// One or more issue messages to display. Localised; the renderer
|
|
39
|
+
// picked them up from FormSnapshot.errors and ran them through
|
|
40
|
+
// i18n.
|
|
41
|
+
readonly errors?: readonly string[];
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type TextInputProps = PrimitiveCommonProps & {
|
|
45
|
+
readonly value: string;
|
|
46
|
+
readonly onChange: (next: string) => void;
|
|
47
|
+
readonly onBlur?: () => void;
|
|
48
|
+
readonly type?: "text" | "email" | "url" | "tel" | "password";
|
|
49
|
+
readonly maxLength?: number;
|
|
50
|
+
readonly autoComplete?: string;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type NumberInputProps = PrimitiveCommonProps & {
|
|
54
|
+
readonly value: number | null;
|
|
55
|
+
readonly onChange: (next: number | null) => void;
|
|
56
|
+
readonly onBlur?: () => void;
|
|
57
|
+
readonly min?: number;
|
|
58
|
+
readonly max?: number;
|
|
59
|
+
readonly step?: number;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export type SelectOption<TValue extends string = string> = {
|
|
63
|
+
readonly value: TValue;
|
|
64
|
+
readonly label: string;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export type SelectProps<TValue extends string = string> = PrimitiveCommonProps & {
|
|
68
|
+
readonly value: TValue | null;
|
|
69
|
+
readonly onChange: (next: TValue | null) => void;
|
|
70
|
+
readonly options: readonly SelectOption<TValue>[];
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export type ToggleProps = PrimitiveCommonProps & {
|
|
74
|
+
readonly value: boolean;
|
|
75
|
+
readonly onChange: (next: boolean) => void;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export type DatePickerProps = PrimitiveCommonProps & {
|
|
79
|
+
// ISO-8601 date string (YYYY-MM-DD) or null. The primitive translates
|
|
80
|
+
// to/from the native date widget on each platform; ui-core stays
|
|
81
|
+
// platform-free by using the serialized string form.
|
|
82
|
+
readonly value: string | null;
|
|
83
|
+
readonly onChange: (next: string | null) => void;
|
|
84
|
+
readonly minDate?: string;
|
|
85
|
+
readonly maxDate?: string;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// Container primitives — layout / feedback that renderers compose but
|
|
89
|
+
// don't directly bind to a form field. Props stay minimal: anything that
|
|
90
|
+
// feels like "more variants" (destructive button, soft button, ghost
|
|
91
|
+
// button) gets a `variant` prop the primitive interprets.
|
|
92
|
+
|
|
93
|
+
export type ButtonProps = {
|
|
94
|
+
readonly onPress: () => void;
|
|
95
|
+
readonly label: string;
|
|
96
|
+
readonly variant?: "primary" | "secondary" | "destructive" | "ghost";
|
|
97
|
+
readonly disabled?: boolean;
|
|
98
|
+
readonly loading?: boolean;
|
|
99
|
+
// Left/right icons addressed by name through the Icon primitive.
|
|
100
|
+
readonly leftIcon?: string;
|
|
101
|
+
readonly rightIcon?: string;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export type ModalProps = {
|
|
105
|
+
readonly open: boolean;
|
|
106
|
+
readonly onClose: () => void;
|
|
107
|
+
readonly title?: string;
|
|
108
|
+
readonly description?: string;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export type ToastIntent = "info" | "success" | "warning" | "error";
|
|
112
|
+
export type ToastProps = {
|
|
113
|
+
readonly intent: ToastIntent;
|
|
114
|
+
readonly title: string;
|
|
115
|
+
readonly description?: string;
|
|
116
|
+
readonly onDismiss?: () => void;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export type BadgeProps = {
|
|
120
|
+
readonly label: string;
|
|
121
|
+
readonly intent?: ToastIntent;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export type CardProps = {
|
|
125
|
+
readonly title?: string;
|
|
126
|
+
readonly description?: string;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// Icons are referenced by string name so ui-core doesn't own an icon set.
|
|
130
|
+
// primitives-shadcn maps "check" → `<Check />` from lucide-react;
|
|
131
|
+
// primitives-paper maps "check" → `<Icon source="check" />`. The set of
|
|
132
|
+
// names is deliberately open — renderer-level code can introduce feature-
|
|
133
|
+
// specific icons without a ui-core round-trip.
|
|
134
|
+
export type IconProps = {
|
|
135
|
+
readonly name: string;
|
|
136
|
+
readonly size?: number;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// The full contract. A primitives package implements each member — the
|
|
140
|
+
// renderer consumes them from a single context object (PrimitivesProvider).
|
|
141
|
+
// Adding a new primitive to the contract is a breaking change for all
|
|
142
|
+
// primitives-* packages; removing one is not (renderers just stop using
|
|
143
|
+
// it). Keep the surface small: something that only one or two renderers
|
|
144
|
+
// need (masked input, signature pad, chart) should live in the feature
|
|
145
|
+
// module, not here.
|
|
146
|
+
export type PrimitivesContract<TPrimitive = unknown> = {
|
|
147
|
+
readonly TextInput: TPrimitive;
|
|
148
|
+
readonly NumberInput: TPrimitive;
|
|
149
|
+
readonly Select: TPrimitive;
|
|
150
|
+
readonly Toggle: TPrimitive;
|
|
151
|
+
readonly DatePicker: TPrimitive;
|
|
152
|
+
readonly Button: TPrimitive;
|
|
153
|
+
readonly Modal: TPrimitive;
|
|
154
|
+
readonly Toast: TPrimitive;
|
|
155
|
+
readonly Badge: TPrimitive;
|
|
156
|
+
readonly Card: TPrimitive;
|
|
157
|
+
readonly Icon: TPrimitive;
|
|
158
|
+
};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { createStore } from "../../store";
|
|
3
|
+
import type {
|
|
4
|
+
BatchResult,
|
|
5
|
+
Command,
|
|
6
|
+
Dispatcher,
|
|
7
|
+
DispatcherStatus,
|
|
8
|
+
PendingFile,
|
|
9
|
+
PendingWrite,
|
|
10
|
+
QueryResult,
|
|
11
|
+
WriteResult,
|
|
12
|
+
} from "../types";
|
|
13
|
+
|
|
14
|
+
// A minimal fake dispatcher — proves the Dispatcher interface is sufficient
|
|
15
|
+
// to implement a synchronous in-memory client, and pins the public shape of
|
|
16
|
+
// WriteResult / QueryResult / BatchResult so a PR that renames or reshapes
|
|
17
|
+
// a field breaks this file loud.
|
|
18
|
+
//
|
|
19
|
+
// The fake records every call, so the test can assert on the exact
|
|
20
|
+
// sequence without re-exercising real HTTP. Block 2's form-controller uses
|
|
21
|
+
// this fake as its default test double.
|
|
22
|
+
function createFakeDispatcher(options?: {
|
|
23
|
+
readonly writeResponses?: Record<string, WriteResult>;
|
|
24
|
+
readonly queryResponses?: Record<string, QueryResult>;
|
|
25
|
+
}): Dispatcher & {
|
|
26
|
+
readonly calls: ReadonlyArray<{
|
|
27
|
+
kind: "write" | "query" | "batch";
|
|
28
|
+
type?: string;
|
|
29
|
+
payload?: unknown;
|
|
30
|
+
}>;
|
|
31
|
+
setStatus(next: DispatcherStatus): void;
|
|
32
|
+
} {
|
|
33
|
+
const calls: Array<{ kind: "write" | "query" | "batch"; type?: string; payload?: unknown }> = [];
|
|
34
|
+
const statusStore = createStore<DispatcherStatus>("online");
|
|
35
|
+
const pendingWritesStore: PendingWrite[] = [];
|
|
36
|
+
const pendingFilesStore: PendingFile[] = [];
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
calls,
|
|
40
|
+
async write<TData>(type: string, payload: unknown) {
|
|
41
|
+
calls.push({ kind: "write", type, payload });
|
|
42
|
+
const response = options?.writeResponses?.[type];
|
|
43
|
+
return (response ?? {
|
|
44
|
+
isSuccess: true,
|
|
45
|
+
data: { id: `fake-${calls.length}` },
|
|
46
|
+
}) as WriteResult<TData>;
|
|
47
|
+
},
|
|
48
|
+
async query<TData>(type: string, payload: unknown) {
|
|
49
|
+
calls.push({ kind: "query", type, payload });
|
|
50
|
+
const response = options?.queryResponses?.[type];
|
|
51
|
+
return (response ?? { isSuccess: true, data: null }) as QueryResult<TData>;
|
|
52
|
+
},
|
|
53
|
+
async batch(commands) {
|
|
54
|
+
calls.push({ kind: "batch", payload: commands });
|
|
55
|
+
const results: WriteResult[] = commands.map((_, i) => ({
|
|
56
|
+
isSuccess: true as const,
|
|
57
|
+
data: { id: `fake-batch-${i}` },
|
|
58
|
+
}));
|
|
59
|
+
const result: BatchResult = { isSuccess: true, results };
|
|
60
|
+
return result;
|
|
61
|
+
},
|
|
62
|
+
statusStore,
|
|
63
|
+
pendingWrites: () => pendingWritesStore,
|
|
64
|
+
pendingFiles: () => pendingFilesStore,
|
|
65
|
+
setStatus(next) {
|
|
66
|
+
statusStore.setState(next);
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe("Dispatcher contract", () => {
|
|
72
|
+
test("write() records the call and returns the configured response", async () => {
|
|
73
|
+
const disp = createFakeDispatcher({
|
|
74
|
+
writeResponses: {
|
|
75
|
+
"app:write:task:create": { isSuccess: true, data: { id: "t-1", title: "hello" } },
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const result = await disp.write("app:write:task:create", { title: "hello" });
|
|
80
|
+
|
|
81
|
+
expect(result.isSuccess).toBe(true);
|
|
82
|
+
if (result.isSuccess) {
|
|
83
|
+
expect((result.data as { id: string }).id).toBe("t-1");
|
|
84
|
+
}
|
|
85
|
+
expect(disp.calls).toHaveLength(1);
|
|
86
|
+
expect(disp.calls[0]).toEqual({
|
|
87
|
+
kind: "write",
|
|
88
|
+
type: "app:write:task:create",
|
|
89
|
+
payload: { title: "hello" },
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("write() failure shape carries code + message + optional field issues", async () => {
|
|
94
|
+
const disp = createFakeDispatcher({
|
|
95
|
+
writeResponses: {
|
|
96
|
+
"app:write:task:create": {
|
|
97
|
+
isSuccess: false,
|
|
98
|
+
error: {
|
|
99
|
+
code: "validation_error",
|
|
100
|
+
httpStatus: 400,
|
|
101
|
+
i18nKey: "errors.validation.failed",
|
|
102
|
+
message: "Validation failed",
|
|
103
|
+
details: {
|
|
104
|
+
fields: [
|
|
105
|
+
{ path: "title", code: "too_small", i18nKey: "errors.validation.too_small" },
|
|
106
|
+
],
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const result = await disp.write("app:write:task:create", { title: "" });
|
|
114
|
+
|
|
115
|
+
expect(result.isSuccess).toBe(false);
|
|
116
|
+
if (!result.isSuccess) {
|
|
117
|
+
expect(result.error.code).toBe("validation_error");
|
|
118
|
+
expect(result.error.details?.fields?.[0]?.path).toBe("title");
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("batch() returns one result per command, in order", async () => {
|
|
123
|
+
const disp = createFakeDispatcher();
|
|
124
|
+
const commands: readonly Command[] = [
|
|
125
|
+
{ type: "app:write:task:create", payload: { title: "a" } },
|
|
126
|
+
{ type: "app:write:task:create", payload: { title: "b" } },
|
|
127
|
+
{ type: "app:write:task:create", payload: { title: "c" } },
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
const result = await disp.batch(commands);
|
|
131
|
+
|
|
132
|
+
expect(result.isSuccess).toBe(true);
|
|
133
|
+
if (result.isSuccess) {
|
|
134
|
+
expect(result.results).toHaveLength(3);
|
|
135
|
+
for (const r of result.results) {
|
|
136
|
+
expect(r.isSuccess).toBe(true);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("statusStore fires on transitions and unsubscribes cleanly", () => {
|
|
142
|
+
const disp = createFakeDispatcher();
|
|
143
|
+
const seen: DispatcherStatus[] = [];
|
|
144
|
+
|
|
145
|
+
const unsubscribe = disp.statusStore.subscribe(() => seen.push(disp.statusStore.getSnapshot()));
|
|
146
|
+
disp.setStatus("offline");
|
|
147
|
+
disp.setStatus("syncing");
|
|
148
|
+
unsubscribe();
|
|
149
|
+
disp.setStatus("online");
|
|
150
|
+
|
|
151
|
+
expect(seen).toEqual(["offline", "syncing"]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("pending queues are empty by default (live-dispatcher semantic)", () => {
|
|
155
|
+
const disp = createFakeDispatcher();
|
|
156
|
+
|
|
157
|
+
expect(disp.pendingWrites()).toEqual([]);
|
|
158
|
+
expect(disp.pendingFiles()).toEqual([]);
|
|
159
|
+
});
|
|
160
|
+
});
|