@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
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// Dispatcher Contract — the interface every client-side Dispatcher implements.
|
|
2
|
+
// Two implementations ship with Kumiko:
|
|
3
|
+
//
|
|
4
|
+
// - dispatcher-live: HTTP-only, always online. Writes go straight to
|
|
5
|
+
// /api/write or /api/batch; a failed network call is a failed write.
|
|
6
|
+
// Used on the web by default and on mobile when the app is "cockpit"-
|
|
7
|
+
// style (admin dashboards, back-office UIs).
|
|
8
|
+
//
|
|
9
|
+
// - dispatcher-savable: Local-first with an outbound queue. Writes land
|
|
10
|
+
// in a local store, the queue syncs when the network is back. Used on
|
|
11
|
+
// mobile and for PWA-style web apps.
|
|
12
|
+
//
|
|
13
|
+
// The UI code never reaches for either implementation directly — it takes
|
|
14
|
+
// a `Dispatcher` through a provider/context and calls `write` / `query` /
|
|
15
|
+
// `batch`. A feature-module can therefore be rendered identically on a
|
|
16
|
+
// live-online admin screen and on a mobile app that edits offline; the
|
|
17
|
+
// dispatcher choice is an app-level concern, not a feature-level one.
|
|
18
|
+
//
|
|
19
|
+
// Result shape: `{ isSuccess: true, data } | { isSuccess: false, error }`.
|
|
20
|
+
// Pattern matches the server's WriteResult but does not import it — this
|
|
21
|
+
// package stays free of @cosmicdrift/kumiko-framework so it runs in any JS runtime
|
|
22
|
+
// (browser, Expo/React Native, Web Worker). The HTTP dispatcher adapts
|
|
23
|
+
// the server response to this shape; the savable dispatcher synthesizes
|
|
24
|
+
// it from its own state machine. The shape is the boundary, not the
|
|
25
|
+
// internal detail.
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Result envelopes
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
// A validation failure issue pinned to a specific payload field. Paths follow
|
|
32
|
+
// the same dotted convention the server uses (see kumiko errors/classes.ts),
|
|
33
|
+
// so form-controllers can map `tasks.2.title` back to the right sub-line's
|
|
34
|
+
// input without any translation.
|
|
35
|
+
import type { Store } from "../store";
|
|
36
|
+
|
|
37
|
+
export type FieldIssue = {
|
|
38
|
+
readonly path: string;
|
|
39
|
+
readonly code: string;
|
|
40
|
+
readonly i18nKey: string;
|
|
41
|
+
readonly params?: Readonly<Record<string, unknown>>;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// Everything the UI needs to show or retry a failed call. `code` + `httpStatus`
|
|
45
|
+
// are the structured hooks (Toast picks icon/colour, Form-Controller filters
|
|
46
|
+
// field-level failures); `message` is the fallback for logs + generic toasts.
|
|
47
|
+
// `details.fields` is populated for validation errors — other error classes
|
|
48
|
+
// leave it undefined and callers treat the failure as a toast.
|
|
49
|
+
export type DispatcherError = {
|
|
50
|
+
readonly code: string;
|
|
51
|
+
readonly httpStatus: number;
|
|
52
|
+
readonly i18nKey: string;
|
|
53
|
+
readonly i18nParams?: Readonly<Record<string, unknown>>;
|
|
54
|
+
readonly message: string;
|
|
55
|
+
readonly details?: {
|
|
56
|
+
readonly fields?: readonly FieldIssue[];
|
|
57
|
+
} & Record<string, unknown>;
|
|
58
|
+
// Self-service deep-link to docs.kumiko.so/errors/<reason>. Always set on
|
|
59
|
+
// server-side errors (computed from details.reason or code by KumikoError).
|
|
60
|
+
// Optional only because client-synthesized errors (network drop, savable-
|
|
61
|
+
// queue rejection before transport) can't compute a meaningful docs URL.
|
|
62
|
+
readonly docsUrl?: string;
|
|
63
|
+
// Server-assigned id for log correlation; missing on client-synthesized
|
|
64
|
+
// errors (network drop, savable-queue rejection before transport).
|
|
65
|
+
readonly requestId?: string;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export type WriteResult<TData = unknown> =
|
|
69
|
+
| { readonly isSuccess: true; readonly data: TData }
|
|
70
|
+
| { readonly isSuccess: false; readonly error: DispatcherError };
|
|
71
|
+
|
|
72
|
+
export type QueryResult<TData = unknown> =
|
|
73
|
+
| { readonly isSuccess: true; readonly data: TData }
|
|
74
|
+
| { readonly isSuccess: false; readonly error: DispatcherError };
|
|
75
|
+
|
|
76
|
+
// Batch returns an array of per-command results plus the index of the first
|
|
77
|
+
// failure (if any). Matches the server's BatchResult so callers can inspect
|
|
78
|
+
// partial progress before the rollback in a failed batch.
|
|
79
|
+
export type BatchResult =
|
|
80
|
+
| { readonly isSuccess: true; readonly results: readonly WriteResult[] }
|
|
81
|
+
| {
|
|
82
|
+
readonly isSuccess: false;
|
|
83
|
+
readonly error: DispatcherError;
|
|
84
|
+
readonly failedIndex: number;
|
|
85
|
+
readonly results: readonly WriteResult[];
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Commands
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
// One entry in a batch. `type` is the qualified handler name
|
|
93
|
+
// (`feature:write:entity:action`). `payload` is the handler's input — shape
|
|
94
|
+
// is declared by the feature author's zod schema on the server, so this
|
|
95
|
+
// stays generic here. Nested-write payloads (parent + hasMany children in a
|
|
96
|
+
// single object) travel through as-is; the server expands them.
|
|
97
|
+
export type Command = {
|
|
98
|
+
readonly type: string;
|
|
99
|
+
readonly payload: unknown;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Status + pending queues
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
// "online" — transport reachable, writes/queries go through synchronously.
|
|
107
|
+
// "offline" — transport unreachable; savable queues, live fails immediately.
|
|
108
|
+
// "syncing" — savable's catch-up window after reconnect (queue draining).
|
|
109
|
+
// live-dispatcher never reports "syncing" — nothing to catch up.
|
|
110
|
+
export type DispatcherStatus = "online" | "offline" | "syncing";
|
|
111
|
+
|
|
112
|
+
// What the UI shows as "N changes waiting" badges. Both entries hold enough
|
|
113
|
+
// context for a user-facing list ("Task 'Buy milk' – retry? / discard?");
|
|
114
|
+
// the dispatcher itself drives the retry/sync — this is read-only for
|
|
115
|
+
// rendering.
|
|
116
|
+
export type PendingWrite = {
|
|
117
|
+
readonly id: string;
|
|
118
|
+
readonly type: string;
|
|
119
|
+
readonly payload: unknown;
|
|
120
|
+
// Optimistic snapshot of when the user clicked submit, so the UI can show
|
|
121
|
+
// "3 min ago" without reaching for the internal queue's timestamps.
|
|
122
|
+
readonly queuedAt: string;
|
|
123
|
+
readonly attempts: number;
|
|
124
|
+
readonly lastError?: DispatcherError;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export type PendingFile = {
|
|
128
|
+
readonly id: string;
|
|
129
|
+
readonly fileName: string;
|
|
130
|
+
readonly sizeBytes: number;
|
|
131
|
+
readonly queuedAt: string;
|
|
132
|
+
readonly attempts: number;
|
|
133
|
+
readonly progress?: number; // 0..1 when uploading; undefined when queued
|
|
134
|
+
readonly lastError?: DispatcherError;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// Call-site options
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
export type WriteOpts = {
|
|
142
|
+
// Client-generated idempotency key — server dedupes and returns the
|
|
143
|
+
// cached result on retry. Required for any write a user can trigger
|
|
144
|
+
// twice (double-click submits, connection-retry in savable).
|
|
145
|
+
readonly requestId?: string;
|
|
146
|
+
// Abort handle — the UI cancels a stale submit when the screen changes
|
|
147
|
+
// or the user presses Escape. Live-dispatcher passes this through to
|
|
148
|
+
// fetch; savable-dispatcher removes the entry from its queue.
|
|
149
|
+
readonly signal?: AbortSignal;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export type QueryOpts = {
|
|
153
|
+
readonly signal?: AbortSignal;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// The contract
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
// Every client-side dispatcher implements this interface. Hooks
|
|
161
|
+
// (`useMutation`, `useCommand`) take it via provider/context; features don't
|
|
162
|
+
// know or care which concrete dispatcher they run against. Adding a new
|
|
163
|
+
// dispatcher (e.g. a server-sent dispatcher for SSR prefetch) means
|
|
164
|
+
// implementing this interface — no changes to feature code.
|
|
165
|
+
export type Dispatcher = {
|
|
166
|
+
write<TData = unknown>(
|
|
167
|
+
type: string,
|
|
168
|
+
payload: unknown,
|
|
169
|
+
opts?: WriteOpts,
|
|
170
|
+
): Promise<WriteResult<TData>>;
|
|
171
|
+
|
|
172
|
+
query<TData = unknown>(
|
|
173
|
+
type: string,
|
|
174
|
+
payload: unknown,
|
|
175
|
+
opts?: QueryOpts,
|
|
176
|
+
): Promise<QueryResult<TData>>;
|
|
177
|
+
|
|
178
|
+
batch(commands: readonly Command[], opts?: WriteOpts): Promise<BatchResult>;
|
|
179
|
+
|
|
180
|
+
// --- Status ---
|
|
181
|
+
|
|
182
|
+
// Subscribe/Emit-Store für Online/Offline/Syncing-Transitions. Konsumenten
|
|
183
|
+
// greifen direkt mit `useStore(dispatcher.statusStore)` zu — keine eigenen
|
|
184
|
+
// status()/subscribe()-Wrapper, der Store ist die ganze API.
|
|
185
|
+
// `Store` (read-only): UI darf den Status NICHT setzen, das ist
|
|
186
|
+
// Dispatcher-intern. Live-Dispatcher hält intern eine WritableStore-Ref,
|
|
187
|
+
// exportiert aber nur den Read-View.
|
|
188
|
+
readonly statusStore: Store<DispatcherStatus>;
|
|
189
|
+
|
|
190
|
+
// --- Pending queues (only meaningful for savable; live returns []) ---
|
|
191
|
+
|
|
192
|
+
pendingWrites(): readonly PendingWrite[];
|
|
193
|
+
pendingFiles(): readonly PendingFile[];
|
|
194
|
+
};
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { createFormController } from "../form-controller";
|
|
4
|
+
|
|
5
|
+
describe("conditional fields — FieldState resolution", () => {
|
|
6
|
+
test("unlisted fields get the default {visible:true, readonly:false, required:false}", () => {
|
|
7
|
+
const form = createFormController({ initial: { title: "hello" } });
|
|
8
|
+
const snap = form.getSnapshot();
|
|
9
|
+
|
|
10
|
+
// No rules declared → snapshot.fields is empty. Renderer treats
|
|
11
|
+
// missing keys as defaults.
|
|
12
|
+
expect(snap.fields).toEqual({});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("static boolean condition resolves once at snapshot time", () => {
|
|
16
|
+
const form = createFormController({
|
|
17
|
+
initial: { title: "" },
|
|
18
|
+
fields: {
|
|
19
|
+
title: { visible: true, readonly: true, required: true },
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
expect(form.getSnapshot().fields["title"]).toEqual({
|
|
24
|
+
visible: true,
|
|
25
|
+
readonly: true,
|
|
26
|
+
required: true,
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("predicate condition gets current values", () => {
|
|
31
|
+
const form = createFormController<{ type: "a" | "b"; extra: string }>({
|
|
32
|
+
initial: { type: "a", extra: "" },
|
|
33
|
+
fields: {
|
|
34
|
+
extra: {
|
|
35
|
+
// "extra" is only required when type === "b"
|
|
36
|
+
required: (values) => values.type === "b",
|
|
37
|
+
visible: (values) => values.type === "b",
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
expect(form.getSnapshot().fields["extra"]).toEqual({
|
|
43
|
+
visible: false,
|
|
44
|
+
readonly: false,
|
|
45
|
+
required: false,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
form.setField("type", "b");
|
|
49
|
+
|
|
50
|
+
expect(form.getSnapshot().fields["extra"]).toEqual({
|
|
51
|
+
visible: true,
|
|
52
|
+
readonly: false,
|
|
53
|
+
required: true,
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("predicate reads ctx for cross-cutting conditions (tenant, role)", () => {
|
|
58
|
+
type Ctx = { readonly isAdmin: boolean };
|
|
59
|
+
const form = createFormController<{ title: string }, Ctx>({
|
|
60
|
+
initial: { title: "" },
|
|
61
|
+
fields: {
|
|
62
|
+
title: {
|
|
63
|
+
readonly: (_values, ctx) => !ctx.isAdmin,
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
ctx: { isAdmin: false },
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(form.getSnapshot().fields["title"]?.readonly).toBe(true);
|
|
70
|
+
|
|
71
|
+
form.setCtx({ isAdmin: true });
|
|
72
|
+
|
|
73
|
+
expect(form.getSnapshot().fields["title"]?.readonly).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("setCtx: predicate re-reads ctx on the next snapshot (not captured-at-create)", () => {
|
|
77
|
+
// Regression guard: a future "cache predicates for perf" refactor could
|
|
78
|
+
// bind the ctx into the predicate at create-time instead of calling it
|
|
79
|
+
// fresh each buildSnapshot. If that happens, setCtx would fire a
|
|
80
|
+
// notification but the predicate would still see the old ctx. This
|
|
81
|
+
// test asserts the predicate actually reads the new ctx value after
|
|
82
|
+
// setCtx — the notification alone isn't enough.
|
|
83
|
+
type Ctx = { readonly role: "user" | "admin" };
|
|
84
|
+
let lastCtxSeen: string | null = null;
|
|
85
|
+
const form = createFormController<{ title: string }, Ctx>({
|
|
86
|
+
initial: { title: "" },
|
|
87
|
+
fields: {
|
|
88
|
+
title: {
|
|
89
|
+
readonly: (_v, ctx) => {
|
|
90
|
+
lastCtxSeen = ctx.role;
|
|
91
|
+
return ctx.role !== "admin";
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
ctx: { role: "user" },
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Force a snapshot build so the predicate runs once.
|
|
99
|
+
form.getSnapshot();
|
|
100
|
+
expect(lastCtxSeen).toBe("user");
|
|
101
|
+
|
|
102
|
+
form.setCtx({ role: "admin" });
|
|
103
|
+
form.getSnapshot();
|
|
104
|
+
|
|
105
|
+
// The predicate must have been re-called AND observed the new ctx.
|
|
106
|
+
expect(lastCtxSeen).toBe("admin");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("setCtx produces a new snapshot and notifies listeners", () => {
|
|
110
|
+
const form = createFormController<{ title: string }, { readonly role: string }>({
|
|
111
|
+
initial: { title: "" },
|
|
112
|
+
fields: {
|
|
113
|
+
title: { readonly: (_v, ctx) => ctx.role !== "admin" },
|
|
114
|
+
},
|
|
115
|
+
ctx: { role: "user" },
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const before = form.getSnapshot();
|
|
119
|
+
form.setCtx({ role: "admin" });
|
|
120
|
+
|
|
121
|
+
expect(form.getSnapshot()).not.toBe(before);
|
|
122
|
+
expect(form.getSnapshot().fields["title"]?.readonly).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("hidden fields are excluded from validate() — required:false for hidden works as expected", () => {
|
|
126
|
+
// A hidden field with required:true on its schema should NOT fail
|
|
127
|
+
// validation. The user isn't shown the field, so they can't satisfy
|
|
128
|
+
// it — forcing them to is a UX footgun.
|
|
129
|
+
const schema = z.object({
|
|
130
|
+
type: z.enum(["a", "b"]),
|
|
131
|
+
extra: z.string().min(1), // required in schema
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const form = createFormController<{ type: "a" | "b"; extra: string }>({
|
|
135
|
+
initial: { type: "a", extra: "" },
|
|
136
|
+
schema,
|
|
137
|
+
fields: {
|
|
138
|
+
extra: {
|
|
139
|
+
// Hidden when type !== "b"
|
|
140
|
+
visible: (v) => v.type === "b",
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// type="a" → extra hidden → even with empty `extra`, validate() succeeds.
|
|
146
|
+
expect(form.validate()).toBe(true);
|
|
147
|
+
|
|
148
|
+
form.setField("type", "b");
|
|
149
|
+
// type="b" → extra visible → empty value now fails.
|
|
150
|
+
expect(form.validate()).toBe(false);
|
|
151
|
+
expect(form.getSnapshot().errors["extra"]).toBeDefined();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("snapshot.fields resolves after each setField call (live-reactive)", () => {
|
|
155
|
+
let predicateCalls = 0;
|
|
156
|
+
const form = createFormController<{ type: "a" | "b"; extra: string }>({
|
|
157
|
+
initial: { type: "a", extra: "" },
|
|
158
|
+
fields: {
|
|
159
|
+
extra: {
|
|
160
|
+
visible: (v) => {
|
|
161
|
+
predicateCalls++;
|
|
162
|
+
return v.type === "b";
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
form.getSnapshot(); // build 1
|
|
169
|
+
form.setField("type", "b"); // build 2
|
|
170
|
+
form.getSnapshot(); // still build 2 (identity preserved)
|
|
171
|
+
form.getSnapshot();
|
|
172
|
+
|
|
173
|
+
// Snapshot is cached — predicate only runs on mutator-driven rebuilds,
|
|
174
|
+
// not on every getSnapshot().
|
|
175
|
+
expect(predicateCalls).toBeLessThanOrEqual(3); // initial + 1 mutation + possibly 1 buffer
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { describe, expect, test, vi } from "vitest";
|
|
2
|
+
import { createFormController } from "../form-controller";
|
|
3
|
+
|
|
4
|
+
describe("createFormController — core state machine", () => {
|
|
5
|
+
test("initial state: values === initial, no changes, not dirty, no errors", () => {
|
|
6
|
+
const form = createFormController({ initial: { title: "hello", count: 3 } });
|
|
7
|
+
const snap = form.getSnapshot();
|
|
8
|
+
|
|
9
|
+
expect(snap.values).toEqual({ title: "hello", count: 3 });
|
|
10
|
+
expect(snap.initial).toEqual({ title: "hello", count: 3 });
|
|
11
|
+
expect(snap.changes).toEqual({});
|
|
12
|
+
expect(snap.isDirty).toBe(false);
|
|
13
|
+
expect(snap.isUnchanged).toBe(true);
|
|
14
|
+
expect(snap.errors).toEqual({});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("setField: tracks the single-field change and flips isDirty", () => {
|
|
18
|
+
const form = createFormController({ initial: { title: "hello", count: 3 } });
|
|
19
|
+
|
|
20
|
+
form.setField("title", "world");
|
|
21
|
+
const snap = form.getSnapshot();
|
|
22
|
+
|
|
23
|
+
expect(snap.values.title).toBe("world");
|
|
24
|
+
expect(snap.values.count).toBe(3);
|
|
25
|
+
expect(snap.initial.title).toBe("hello"); // baseline unchanged
|
|
26
|
+
expect(snap.changes).toEqual({ title: "world" });
|
|
27
|
+
expect(snap.isDirty).toBe(true);
|
|
28
|
+
expect(snap.isUnchanged).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("setField to same value is a no-op: no new snapshot, no listener fires", () => {
|
|
32
|
+
const form = createFormController({ initial: { title: "hello" } });
|
|
33
|
+
const before = form.getSnapshot();
|
|
34
|
+
const listener = vi.fn();
|
|
35
|
+
form.subscribe(listener);
|
|
36
|
+
|
|
37
|
+
form.setField("title", "hello");
|
|
38
|
+
|
|
39
|
+
expect(form.getSnapshot()).toBe(before); // identity preserved
|
|
40
|
+
expect(listener).not.toHaveBeenCalled();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("typing back to the initial value clears the change from `changes`", () => {
|
|
44
|
+
const form = createFormController({ initial: { title: "hello" } });
|
|
45
|
+
|
|
46
|
+
form.setField("title", "world");
|
|
47
|
+
expect(form.getSnapshot().changes).toEqual({ title: "world" });
|
|
48
|
+
|
|
49
|
+
form.setField("title", "hello");
|
|
50
|
+
const snap = form.getSnapshot();
|
|
51
|
+
expect(snap.changes).toEqual({});
|
|
52
|
+
expect(snap.isDirty).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("setValues: bulk update fires one notify, one snapshot rebuild", () => {
|
|
56
|
+
const form = createFormController({ initial: { a: 1, b: 2, c: 3 } });
|
|
57
|
+
const listener = vi.fn();
|
|
58
|
+
form.subscribe(listener);
|
|
59
|
+
|
|
60
|
+
form.setValues({ a: 10, b: 20 });
|
|
61
|
+
|
|
62
|
+
expect(form.getSnapshot().changes).toEqual({ a: 10, b: 20 });
|
|
63
|
+
expect(listener).toHaveBeenCalledTimes(1); // not 2
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("setValues with no effective changes is a no-op", () => {
|
|
67
|
+
const form = createFormController({ initial: { a: 1, b: 2 } });
|
|
68
|
+
const before = form.getSnapshot();
|
|
69
|
+
const listener = vi.fn();
|
|
70
|
+
form.subscribe(listener);
|
|
71
|
+
|
|
72
|
+
form.setValues({ a: 1 });
|
|
73
|
+
|
|
74
|
+
expect(form.getSnapshot()).toBe(before);
|
|
75
|
+
expect(listener).not.toHaveBeenCalled();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("subscribe/unsubscribe: listener stops firing after unsubscribe", () => {
|
|
79
|
+
const form = createFormController({ initial: { title: "hello" } });
|
|
80
|
+
const listener = vi.fn();
|
|
81
|
+
const unsubscribe = form.subscribe(listener);
|
|
82
|
+
|
|
83
|
+
form.setField("title", "world");
|
|
84
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
85
|
+
|
|
86
|
+
unsubscribe();
|
|
87
|
+
form.setField("title", "again");
|
|
88
|
+
expect(listener).toHaveBeenCalledTimes(1); // still 1
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("getSnapshot returns stable reference between mutators", () => {
|
|
92
|
+
const form = createFormController({ initial: { title: "hello" } });
|
|
93
|
+
|
|
94
|
+
const a = form.getSnapshot();
|
|
95
|
+
const b = form.getSnapshot();
|
|
96
|
+
expect(a).toBe(b); // identity compare — required for useSyncExternalStore
|
|
97
|
+
|
|
98
|
+
form.setField("title", "world");
|
|
99
|
+
const c = form.getSnapshot();
|
|
100
|
+
expect(c).not.toBe(a); // mutation produced a new snapshot
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("snapshot is frozen — mutating it throws in strict mode", () => {
|
|
104
|
+
const form = createFormController({ initial: { title: "hello" } });
|
|
105
|
+
const snap = form.getSnapshot();
|
|
106
|
+
|
|
107
|
+
expect(() => {
|
|
108
|
+
(snap as unknown as { values: unknown }).values = {};
|
|
109
|
+
}).toThrow();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("reset: restores values to initial and clears errors", () => {
|
|
113
|
+
const form = createFormController({ initial: { title: "hello", count: 3 } });
|
|
114
|
+
form.setField("title", "world");
|
|
115
|
+
form.setErrors({ title: [{ path: "title", code: "bad", i18nKey: "x" }] });
|
|
116
|
+
|
|
117
|
+
form.reset();
|
|
118
|
+
const snap = form.getSnapshot();
|
|
119
|
+
|
|
120
|
+
expect(snap.values).toEqual({ title: "hello", count: 3 });
|
|
121
|
+
expect(snap.isDirty).toBe(false);
|
|
122
|
+
expect(snap.errors).toEqual({});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("reset is a no-op when already clean and error-free", () => {
|
|
126
|
+
const form = createFormController({ initial: { title: "hello" } });
|
|
127
|
+
const before = form.getSnapshot();
|
|
128
|
+
const listener = vi.fn();
|
|
129
|
+
form.subscribe(listener);
|
|
130
|
+
|
|
131
|
+
form.reset();
|
|
132
|
+
|
|
133
|
+
expect(form.getSnapshot()).toBe(before);
|
|
134
|
+
expect(listener).not.toHaveBeenCalled();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("rebase: current values become the new baseline, changes collapse to {}", () => {
|
|
138
|
+
const form = createFormController({ initial: { title: "hello" } });
|
|
139
|
+
form.setField("title", "world");
|
|
140
|
+
expect(form.getSnapshot().changes).toEqual({ title: "world" });
|
|
141
|
+
|
|
142
|
+
form.rebase();
|
|
143
|
+
const snap = form.getSnapshot();
|
|
144
|
+
|
|
145
|
+
expect(snap.values.title).toBe("world");
|
|
146
|
+
expect(snap.initial.title).toBe("world"); // baseline updated
|
|
147
|
+
expect(snap.changes).toEqual({});
|
|
148
|
+
expect(snap.isDirty).toBe(false);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("setErrors + clearErrors(path): targeted removal leaves other errors alone", () => {
|
|
152
|
+
const form = createFormController({ initial: { a: "", b: "" } });
|
|
153
|
+
form.setErrors({
|
|
154
|
+
a: [{ path: "a", code: "required", i18nKey: "x" }],
|
|
155
|
+
b: [{ path: "b", code: "required", i18nKey: "x" }],
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
form.clearErrors("a");
|
|
159
|
+
const snap = form.getSnapshot();
|
|
160
|
+
|
|
161
|
+
expect(snap.errors["a"]).toBeUndefined();
|
|
162
|
+
expect(snap.errors["b"]).toBeDefined();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("clearErrors() without path wipes everything", () => {
|
|
166
|
+
const form = createFormController({ initial: { a: "" } });
|
|
167
|
+
form.setErrors({ a: [{ path: "a", code: "required", i18nKey: "x" }] });
|
|
168
|
+
|
|
169
|
+
form.clearErrors();
|
|
170
|
+
|
|
171
|
+
expect(form.getSnapshot().errors).toEqual({});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("mutating the input object after create does not bleed into controller state", () => {
|
|
175
|
+
const initial = { title: "hello" };
|
|
176
|
+
const form = createFormController({ initial });
|
|
177
|
+
|
|
178
|
+
initial.title = "mutated-from-outside";
|
|
179
|
+
|
|
180
|
+
expect(form.getSnapshot().initial.title).toBe("hello");
|
|
181
|
+
expect(form.getSnapshot().values.title).toBe("hello");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("deleted field via setValues({ key: undefined }) still counts as a change", () => {
|
|
185
|
+
// Covers the both-sides iteration in valuesDiff — a field cleared from
|
|
186
|
+
// the current values still diverges from initial and shows up in changes.
|
|
187
|
+
const form = createFormController({ initial: { title: "hello" } });
|
|
188
|
+
form.setValues({ title: undefined });
|
|
189
|
+
|
|
190
|
+
const snap = form.getSnapshot();
|
|
191
|
+
expect(snap.isDirty).toBe(true);
|
|
192
|
+
expect("title" in snap.changes).toBe(true);
|
|
193
|
+
expect(snap.changes["title"]).toBeUndefined();
|
|
194
|
+
});
|
|
195
|
+
});
|