@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.
@@ -0,0 +1,66 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { shallowEqual } from "../equality";
3
+
4
+ describe("shallowEqual — primitive cases", () => {
5
+ test("Object.is-equal primitives are equal", () => {
6
+ expect(shallowEqual(1, 1)).toBe(true);
7
+ expect(shallowEqual("a", "a")).toBe(true);
8
+ expect(shallowEqual(true, true)).toBe(true);
9
+ expect(shallowEqual(null, null)).toBe(true);
10
+ expect(shallowEqual(undefined, undefined)).toBe(true);
11
+ });
12
+
13
+ test("NaN equals NaN (Object.is semantics)", () => {
14
+ expect(shallowEqual(Number.NaN, Number.NaN)).toBe(true);
15
+ });
16
+
17
+ test("differing primitives are not equal", () => {
18
+ expect(shallowEqual(1, 2)).toBe(false);
19
+ expect(shallowEqual("a", "b")).toBe(false);
20
+ expect(shallowEqual(0, -0)).toBe(false); // Object.is distinguishes
21
+ });
22
+
23
+ test("primitive vs object is not equal", () => {
24
+ expect(shallowEqual(1, { 0: 1 })).toBe(false);
25
+ expect(shallowEqual(null, {})).toBe(false);
26
+ expect(shallowEqual({}, null)).toBe(false);
27
+ });
28
+ });
29
+
30
+ describe("shallowEqual — object cases", () => {
31
+ test("same reference is equal", () => {
32
+ const o = { a: 1 };
33
+ expect(shallowEqual(o, o)).toBe(true);
34
+ });
35
+
36
+ test("same keys + same values → equal", () => {
37
+ expect(shallowEqual({ a: 1, b: 2 }, { a: 1, b: 2 })).toBe(true);
38
+ });
39
+
40
+ test("same keys, different value → not equal", () => {
41
+ expect(shallowEqual({ a: 1, b: 2 }, { a: 1, b: 3 })).toBe(false);
42
+ });
43
+
44
+ test("different number of keys → not equal", () => {
45
+ expect(shallowEqual({ a: 1 }, { a: 1, b: 2 })).toBe(false);
46
+ expect(shallowEqual({ a: 1, b: 2 }, { a: 1 })).toBe(false);
47
+ });
48
+
49
+ test("same count, different key names → not equal", () => {
50
+ expect(shallowEqual({ a: 1 }, { b: 1 })).toBe(false);
51
+ });
52
+
53
+ test("nested object: only top-level refs compared (shallow)", () => {
54
+ const inner = { x: 1 };
55
+ // Same nested ref → equal at top level.
56
+ expect(shallowEqual({ inner }, { inner })).toBe(true);
57
+ // Equivalent but distinct nested ref → NOT equal (shallow doesn't recurse).
58
+ expect(shallowEqual({ inner: { x: 1 } }, { inner: { x: 1 } })).toBe(false);
59
+ });
60
+
61
+ test("arrays compared as objects: same length + same values → equal", () => {
62
+ expect(shallowEqual([1, 2, 3], [1, 2, 3])).toBe(true);
63
+ expect(shallowEqual([1, 2, 3], [1, 2, 4])).toBe(false);
64
+ expect(shallowEqual([1, 2], [1, 2, 3])).toBe(false);
65
+ });
66
+ });
@@ -0,0 +1,44 @@
1
+ import type { WritableStore } from "./types";
2
+
3
+ // Subscribe/Emit primitive matching React's useSyncExternalStore.
4
+ // Single canonical implementation — every stateful controller in
5
+ // ui-core/renderer land composes on top of this instead of rolling
6
+ // its own listener-set + notify-loop.
7
+ //
8
+ // Reentrancy semantics use the Set's documented iteration behavior:
9
+ // listeners deleted DURING the notify-loop but BEFORE their turn are
10
+ // skipped; listeners deleting themselves AFTER firing don't break the
11
+ // loop (Set iteration tolerates concurrent mutation of already-visited
12
+ // entries). MDN: "callbackFn is not invoked for values deleted before
13
+ // being visited." This is exactly the contract callers expect.
14
+ //
15
+ // Caveat — Function-valued stores: setState detects the reducer-form via
16
+ // `typeof next === "function"`. If T itself is a function type
17
+ // (`createStore<() => string>(...)`), there is no way to tell a "new
18
+ // value that happens to be a function" apart from a "reducer producing a
19
+ // new function". Same trap React's useState has. Workaround: wrap the
20
+ // new function in a reducer that ignores prev:
21
+ // store.setState(() => myNewFn);
22
+ // In practice, function-valued stores are rare — feature controllers
23
+ // hold values, not callbacks.
24
+
25
+ export function createStore<T>(initial: T): WritableStore<T> {
26
+ let snapshot = initial;
27
+ const listeners = new Set<() => void>();
28
+
29
+ return {
30
+ getSnapshot: () => snapshot,
31
+ subscribe: (listener) => {
32
+ listeners.add(listener);
33
+ return () => {
34
+ listeners.delete(listener);
35
+ };
36
+ },
37
+ setState: (next) => {
38
+ const nextValue = typeof next === "function" ? (next as (prev: T) => T)(snapshot) : next;
39
+ if (Object.is(nextValue, snapshot)) return;
40
+ snapshot = nextValue;
41
+ for (const listener of listeners) listener();
42
+ },
43
+ };
44
+ }
@@ -0,0 +1,34 @@
1
+ // Shallow equality — Object.is on each own enumerable key. The
2
+ // recommended Equality-Arg for useStoreSelector when the selector
3
+ // returns a plain object/array literal (`s => ({ a: s.a, b: s.b })`),
4
+ // which would otherwise produce a fresh reference each render and
5
+ // trip useSyncExternalStore's identity check.
6
+ //
7
+ // Mirrors Zustand's `shallow` and react-redux's `shallowEqual` — same
8
+ //8-line shape, no recursion. Apps that need deep equality reach for
9
+ // their own helper.
10
+
11
+ // Signature is `(a: unknown, b: unknown)` not `<T>(a: T, b: T)` because the
12
+ // function legitimately compares cross-type values (primitive vs. object,
13
+ // null vs. {}, etc.) and returns false for mismatches. A generic `<T>`
14
+ // would force callers into type-gymnastics for those cases. Contra-variance
15
+ // ensures this still satisfies `(a: S, b: S) => boolean` slots like
16
+ // useStoreSelector's `equals` arg.
17
+ export function shallowEqual(a: unknown, b: unknown): boolean {
18
+ if (Object.is(a, b)) return true;
19
+ if (typeof a !== "object" || a === null) return false;
20
+ if (typeof b !== "object" || b === null) return false;
21
+
22
+ const keysA = Object.keys(a);
23
+ const keysB = Object.keys(b);
24
+ if (keysA.length !== keysB.length) return false;
25
+
26
+ for (const key of keysA) {
27
+ if (!Object.hasOwn(b, key)) return false;
28
+ if (!Object.is((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])) {
29
+ // @cast-boundary generic-record
30
+ return false;
31
+ }
32
+ }
33
+ return true;
34
+ }
@@ -0,0 +1,3 @@
1
+ export { createStore } from "./create-store";
2
+ export { shallowEqual } from "./equality";
3
+ export type { Store, WritableStore } from "./types";
@@ -0,0 +1,27 @@
1
+ // Store contract — Pull-Style Subscribe/Emit matching React's
2
+ // useSyncExternalStore signature. The single canonical shape for
3
+ // every stateful controller in ui-core/renderer land (form,
4
+ // dispatcher-status, locale, token, nav, lifecycle).
5
+ //
6
+ // Rationale in docs/plans/ui-store.md. Not a new state-management
7
+ // decision — ui-decisions.md already picked Subscribe/Emit; this
8
+ // module just materializes the pattern instead of rolling a new
9
+ // Set<() => void> per controller.
10
+
11
+ export type Store<T> = {
12
+ // Current snapshot. Referentially stable across no-op setState calls
13
+ // (see createStore's Object.is gate) so useSyncExternalStore doesn't
14
+ // re-render consumers when nothing actually changed.
15
+ getSnapshot(): T;
16
+ // Subscribe listener, returns unsubscribe. Listener receives no
17
+ // payload — it reads via getSnapshot(). Direct match for
18
+ // useSyncExternalStore's contract.
19
+ subscribe(listener: () => void): () => void;
20
+ };
21
+
22
+ export type WritableStore<T> = Store<T> & {
23
+ // Update with a new value or a reducer function. If the resulting
24
+ // snapshot is Object.is-equal to the current one, listeners are NOT
25
+ // notified — re-render prevention at the source.
26
+ setState(next: T | ((prev: T) => T)): void;
27
+ };
@@ -0,0 +1,242 @@
1
+ import type {
2
+ EntityDefinition,
3
+ EntityEditScreenDefinition,
4
+ } from "@cosmicdrift/kumiko-framework/ui-types";
5
+ import { describe, expect, test } from "vitest";
6
+ import { computeEditViewModel } from "../edit";
7
+
8
+ const orderEntity = {
9
+ fields: {
10
+ customerName: { type: "text", required: true },
11
+ notes: { type: "text" },
12
+ vatExempt: { type: "boolean" },
13
+ vatReason: { type: "text" },
14
+ },
15
+ } as unknown as EntityDefinition;
16
+
17
+ function editScreen(
18
+ layout: EntityEditScreenDefinition["layout"],
19
+ overrides?: Partial<EntityEditScreenDefinition>,
20
+ ): EntityEditScreenDefinition {
21
+ return {
22
+ id: "orders:screen:order-edit",
23
+ type: "entityEdit",
24
+ entity: "order",
25
+ layout,
26
+ ...overrides,
27
+ };
28
+ }
29
+
30
+ const translate = (key: string) => key;
31
+
32
+ describe("computeEditViewModel", () => {
33
+ test("flat screen: single section, string-form fields → resolved field view models", () => {
34
+ const vm = computeEditViewModel({
35
+ screen: editScreen({
36
+ sections: [{ title: "Hauptdaten", fields: ["customerName", "notes"] }],
37
+ }),
38
+ entity: orderEntity,
39
+ values: { customerName: "Acme", notes: "VIP" },
40
+ translate,
41
+ featureName: "orders",
42
+ });
43
+
44
+ expect(vm.sections).toHaveLength(1);
45
+ const section = vm.sections[0];
46
+ expect(section?.title).toBe("Hauptdaten");
47
+ expect(section?.columns).toBe(1); // default
48
+ expect(section?.fields).toEqual([
49
+ {
50
+ field: "customerName",
51
+ label: "orders:entity:order:field:customerName",
52
+ type: "text",
53
+ value: "Acme",
54
+ visible: true,
55
+ readOnly: false,
56
+ required: true, // from entity
57
+ },
58
+ {
59
+ field: "notes",
60
+ label: "orders:entity:order:field:notes",
61
+ type: "text",
62
+ value: "VIP",
63
+ visible: true,
64
+ readOnly: false,
65
+ required: false,
66
+ },
67
+ ]);
68
+ });
69
+
70
+ test("section.columns override is respected; defaults to 1 when absent", () => {
71
+ const vm = computeEditViewModel({
72
+ screen: editScreen({
73
+ sections: [
74
+ { title: "Main", columns: 2, fields: ["customerName"] },
75
+ { title: "Notes", fields: ["notes"] },
76
+ ],
77
+ }),
78
+ entity: orderEntity,
79
+ values: {},
80
+ translate,
81
+ featureName: "orders",
82
+ });
83
+
84
+ expect(vm.sections[0]?.columns).toBe(2);
85
+ expect(vm.sections[1]?.columns).toBe(1);
86
+ });
87
+
88
+ test("visible predicate evaluated against current values (live-reactive)", () => {
89
+ const screen = editScreen({
90
+ sections: [
91
+ {
92
+ title: "VAT",
93
+ fields: [
94
+ "vatExempt",
95
+ {
96
+ field: "vatReason",
97
+ // Only visible when VAT is exempt.
98
+ visible: (data) => (data as { vatExempt?: boolean }).vatExempt === true,
99
+ required: (data) => (data as { vatExempt?: boolean }).vatExempt === true,
100
+ },
101
+ ],
102
+ },
103
+ ],
104
+ });
105
+
106
+ const hidden = computeEditViewModel({
107
+ screen,
108
+ entity: orderEntity,
109
+ values: { vatExempt: false },
110
+ translate,
111
+ featureName: "orders",
112
+ });
113
+ const reasonHidden = hidden.sections[0]?.fields[1];
114
+ expect(reasonHidden?.visible).toBe(false);
115
+ expect(reasonHidden?.required).toBe(false);
116
+
117
+ const shown = computeEditViewModel({
118
+ screen,
119
+ entity: orderEntity,
120
+ values: { vatExempt: true },
121
+ translate,
122
+ featureName: "orders",
123
+ });
124
+ const reasonShown = shown.sections[0]?.fields[1];
125
+ expect(reasonShown?.visible).toBe(true);
126
+ expect(reasonShown?.required).toBe(true);
127
+ });
128
+
129
+ test("readonly predicate receives ctx and evaluates per snapshot", () => {
130
+ const screen = editScreen({
131
+ sections: [
132
+ {
133
+ title: "x",
134
+ fields: [
135
+ {
136
+ field: "customerName",
137
+ readOnly: (_d, ctx) => (ctx as { isAdmin: boolean }).isAdmin === false,
138
+ },
139
+ ],
140
+ },
141
+ ],
142
+ });
143
+
144
+ const nonAdmin = computeEditViewModel({
145
+ screen,
146
+ entity: orderEntity,
147
+ values: { customerName: "A" },
148
+ ctx: { isAdmin: false },
149
+ translate,
150
+ featureName: "orders",
151
+ });
152
+ expect(nonAdmin.sections[0]?.fields[0]?.readOnly).toBe(true);
153
+
154
+ const admin = computeEditViewModel({
155
+ screen,
156
+ entity: orderEntity,
157
+ values: { customerName: "A" },
158
+ ctx: { isAdmin: true },
159
+ translate,
160
+ featureName: "orders",
161
+ });
162
+ expect(admin.sections[0]?.fields[0]?.readOnly).toBe(false);
163
+ });
164
+
165
+ test("screen-level required override wins over entity-level required", () => {
166
+ // customerName is required:true on the entity. Screen marks it
167
+ // required:false — a short-form wizard might collect less up-front.
168
+ const vm = computeEditViewModel({
169
+ screen: editScreen({
170
+ sections: [
171
+ {
172
+ title: "x",
173
+ fields: [{ field: "customerName", required: () => false }],
174
+ },
175
+ ],
176
+ }),
177
+ entity: orderEntity,
178
+ values: {},
179
+ translate,
180
+ featureName: "orders",
181
+ });
182
+
183
+ expect(vm.sections[0]?.fields[0]?.required).toBe(false);
184
+ });
185
+
186
+ test("id is extracted from values or null on create (no existing row)", () => {
187
+ const create = computeEditViewModel({
188
+ screen: editScreen({ sections: [{ title: "x", fields: ["customerName"] }] }),
189
+ entity: orderEntity,
190
+ values: { customerName: "new" },
191
+ translate,
192
+ featureName: "orders",
193
+ });
194
+ expect(create.id).toBeNull();
195
+
196
+ const edit = computeEditViewModel({
197
+ screen: editScreen({ sections: [{ title: "x", fields: ["customerName"] }] }),
198
+ entity: orderEntity,
199
+ values: { id: "o-1", customerName: "existing" },
200
+ translate,
201
+ featureName: "orders",
202
+ });
203
+ expect(edit.id).toBe("o-1");
204
+ });
205
+
206
+ test("unknown field in a section throws — no silent-ignore of a typo", () => {
207
+ expect(() =>
208
+ computeEditViewModel({
209
+ screen: editScreen({ sections: [{ title: "x", fields: ["ghost"] }] }),
210
+ entity: orderEntity,
211
+ values: {},
212
+ translate,
213
+ featureName: "orders",
214
+ }),
215
+ ).toThrow(/unknown field "ghost"/);
216
+ });
217
+
218
+ test("slots pass through for the renderer to mount", () => {
219
+ const slots = { header: { react: "H" } };
220
+ const vm = computeEditViewModel({
221
+ screen: editScreen({ sections: [{ title: "x", fields: ["customerName"] }] }, { slots }),
222
+ entity: orderEntity,
223
+ values: {},
224
+ translate,
225
+ featureName: "orders",
226
+ });
227
+ expect(vm.slots).toBe(slots);
228
+ });
229
+
230
+ test("span on field-spec propagates to the view model", () => {
231
+ const vm = computeEditViewModel({
232
+ screen: editScreen({
233
+ sections: [{ title: "x", columns: 3, fields: [{ field: "customerName", span: 2 }] }],
234
+ }),
235
+ entity: orderEntity,
236
+ values: {},
237
+ translate,
238
+ featureName: "orders",
239
+ });
240
+ expect(vm.sections[0]?.fields[0]?.span).toBe(2);
241
+ });
242
+ });
@@ -0,0 +1,139 @@
1
+ import type {
2
+ EntityDefinition,
3
+ EntityListScreenDefinition,
4
+ } from "@cosmicdrift/kumiko-framework/ui-types";
5
+ import { describe, expect, test, vi } from "vitest";
6
+ import { computeListViewModel } from "../list";
7
+
8
+ // Minimal EntityDefinition-shape. ui-core's view-model only reads
9
+ // entity.fields and per-field metadata; tests stay untyped-casted via
10
+ // `as unknown as EntityDefinition` so we don't pull the entire framework
11
+ // FieldDefinition union into fixtures.
12
+ const taskEntity = {
13
+ fields: {
14
+ title: { type: "text", required: true, sortable: true },
15
+ done: { type: "boolean" },
16
+ priority: { type: "number", sortable: true },
17
+ },
18
+ } as unknown as EntityDefinition;
19
+
20
+ function listScreen(columns: EntityListScreenDefinition["columns"]): EntityListScreenDefinition {
21
+ return {
22
+ id: "tasks:screen:task-list",
23
+ type: "entityList",
24
+ entity: "task",
25
+ columns,
26
+ };
27
+ }
28
+
29
+ // Fake translate passes the key through so tests can assert on the
30
+ // key-composition convention without wiring i18next.
31
+ const translate = (key: string) => key;
32
+
33
+ describe("computeListViewModel", () => {
34
+ test("string columns expand to field-name + resolved label + type", () => {
35
+ const vm = computeListViewModel({
36
+ screen: listScreen(["title", "done"]),
37
+ entity: taskEntity,
38
+ rows: [],
39
+ translate,
40
+ featureName: "tasks",
41
+ });
42
+
43
+ expect(vm.columns).toEqual([
44
+ {
45
+ field: "title",
46
+ label: "tasks:entity:task:field:title",
47
+ type: "text",
48
+ sortable: true,
49
+ },
50
+ {
51
+ field: "done",
52
+ label: "tasks:entity:task:field:done",
53
+ type: "boolean",
54
+ sortable: false,
55
+ },
56
+ ]);
57
+ });
58
+
59
+ test("object-form column carries renderer through to the view model", () => {
60
+ const fmt = (v: unknown) => `${v} !`;
61
+ const vm = computeListViewModel({
62
+ screen: listScreen([{ field: "title", renderer: fmt }]),
63
+ entity: taskEntity,
64
+ rows: [],
65
+ translate,
66
+ featureName: "tasks",
67
+ });
68
+
69
+ expect(vm.columns[0]?.renderer).toBe(fmt);
70
+ });
71
+
72
+ test("rows map to { id, values } with id pulled from the row", () => {
73
+ const vm = computeListViewModel({
74
+ screen: listScreen(["title"]),
75
+ entity: taskEntity,
76
+ rows: [
77
+ { id: "t-1", title: "first" },
78
+ { id: "t-2", title: "second" },
79
+ ],
80
+ translate,
81
+ featureName: "tasks",
82
+ });
83
+
84
+ expect(vm.rows).toEqual([
85
+ { id: "t-1", values: { id: "t-1", title: "first" } },
86
+ { id: "t-2", values: { id: "t-2", title: "second" } },
87
+ ]);
88
+ expect(vm.isEmpty).toBe(false);
89
+ });
90
+
91
+ test("empty rows list flags isEmpty for the renderer's 'no results' state", () => {
92
+ const vm = computeListViewModel({
93
+ screen: listScreen(["title"]),
94
+ entity: taskEntity,
95
+ rows: [],
96
+ translate,
97
+ featureName: "tasks",
98
+ });
99
+ expect(vm.isEmpty).toBe(true);
100
+ });
101
+
102
+ test("unknown field reference throws — stale rename caught at render-time, not silently", () => {
103
+ expect(() =>
104
+ computeListViewModel({
105
+ screen: listScreen(["doesNotExist"]),
106
+ entity: taskEntity,
107
+ rows: [],
108
+ translate,
109
+ featureName: "tasks",
110
+ }),
111
+ ).toThrow(/unknown field "doesNotExist"/);
112
+ });
113
+
114
+ test("translate is called with the expected i18n-key per field", () => {
115
+ const spy = vi.fn((key: string) => `T:${key}`);
116
+ computeListViewModel({
117
+ screen: listScreen(["title", "priority"]),
118
+ entity: taskEntity,
119
+ rows: [],
120
+ translate: spy,
121
+ featureName: "tasks",
122
+ });
123
+
124
+ expect(spy).toHaveBeenCalledWith("tasks:entity:task:field:title");
125
+ expect(spy).toHaveBeenCalledWith("tasks:entity:task:field:priority");
126
+ });
127
+
128
+ test("slots pass through unchanged for the renderer to mount", () => {
129
+ const slots = { header: { react: { component: "HeaderRef" } } };
130
+ const vm = computeListViewModel({
131
+ screen: { ...listScreen(["title"]), slots },
132
+ entity: taskEntity,
133
+ rows: [],
134
+ translate,
135
+ featureName: "tasks",
136
+ });
137
+ expect(vm.slots).toBe(slots);
138
+ });
139
+ });