@setzkasten-cms/ui 0.4.2

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,61 @@
1
+ import { createStore } from 'zustand/vanilla'
2
+
3
+ export interface RecentEntry {
4
+ readonly collection: string
5
+ readonly slug: string
6
+ readonly label: string
7
+ readonly timestamp: number
8
+ }
9
+
10
+ export interface AppState {
11
+ currentCollection: string | null
12
+ currentSlug: string | null
13
+ sidebarOpen: boolean
14
+ theme: 'light' | 'dark' | 'system'
15
+ recentEntries: RecentEntry[]
16
+ }
17
+
18
+ export interface AppActions {
19
+ navigate(collection: string, slug: string): void
20
+ toggleSidebar(): void
21
+ setTheme(theme: AppState['theme']): void
22
+ addRecentEntry(entry: Omit<RecentEntry, 'timestamp'>): void
23
+ }
24
+
25
+ export type AppStore = AppState & AppActions
26
+
27
+ const MAX_RECENT = 10
28
+
29
+ export function createAppStore() {
30
+ return createStore<AppStore>((set, get) => ({
31
+ currentCollection: null,
32
+ currentSlug: null,
33
+ sidebarOpen: true,
34
+ theme: 'system',
35
+ recentEntries: [],
36
+
37
+ navigate(collection, slug) {
38
+ set({ currentCollection: collection, currentSlug: slug })
39
+ get().addRecentEntry({ collection, slug, label: `${collection}/${slug}` })
40
+ },
41
+
42
+ toggleSidebar() {
43
+ set((state) => ({ sidebarOpen: !state.sidebarOpen }))
44
+ },
45
+
46
+ setTheme(theme) {
47
+ set({ theme })
48
+ },
49
+
50
+ addRecentEntry(entry) {
51
+ set((state) => {
52
+ const filtered = state.recentEntries.filter(
53
+ (e) => !(e.collection === entry.collection && e.slug === entry.slug),
54
+ )
55
+ return {
56
+ recentEntries: [{ ...entry, timestamp: Date.now() }, ...filtered].slice(0, MAX_RECENT),
57
+ }
58
+ })
59
+ },
60
+ }))
61
+ }
@@ -0,0 +1,111 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { createFormStore } from './form-store'
3
+ import { f } from '@setzkasten-cms/core'
4
+
5
+ describe('FormStore', () => {
6
+ const schema = {
7
+ title: f.text({ label: 'Title', required: true }),
8
+ count: f.number({ label: 'Count' }),
9
+ active: f.boolean({ label: 'Active' }),
10
+ }
11
+
12
+ it('initializes with schema and values', () => {
13
+ const store = createFormStore()
14
+ store.getState().init(schema, { title: 'Hello', count: 5, active: true })
15
+
16
+ const state = store.getState()
17
+ expect(state.schema).toBe(schema)
18
+ expect(state.values).toEqual({ title: 'Hello', count: 5, active: true })
19
+ expect(state.status).toBe('idle')
20
+ })
21
+
22
+ it('sets and gets field values', () => {
23
+ const store = createFormStore()
24
+ store.getState().init(schema, { title: 'Hello', count: 0, active: false })
25
+
26
+ store.getState().setFieldValue(['title'], 'World')
27
+ expect(store.getState().getFieldValue(['title'])).toBe('World')
28
+ expect(store.getState().values.title).toBe('World')
29
+ })
30
+
31
+ it('tracks dirty state', () => {
32
+ const store = createFormStore()
33
+ store.getState().init(schema, { title: 'Hello', count: 0, active: false })
34
+
35
+ expect(store.getState().isDirty()).toBe(false)
36
+ expect(store.getState().isFieldDirty(['title'])).toBe(false)
37
+
38
+ store.getState().setFieldValue(['title'], 'Changed')
39
+ expect(store.getState().isDirty()).toBe(true)
40
+ expect(store.getState().isFieldDirty(['title'])).toBe(true)
41
+ expect(store.getState().isFieldDirty(['count'])).toBe(false)
42
+ })
43
+
44
+ it('tracks touched fields', () => {
45
+ const store = createFormStore()
46
+ store.getState().init(schema, { title: '', count: 0, active: false })
47
+
48
+ expect(store.getState().touched.size).toBe(0)
49
+ store.getState().touchField(['title'])
50
+ expect(store.getState().touched.has('title')).toBe(true)
51
+ })
52
+
53
+ it('manages validation errors', () => {
54
+ const store = createFormStore()
55
+ store.getState().init(schema, { title: '', count: 0, active: false })
56
+
57
+ store.getState().setFieldErrors(['title'], ['Required'])
58
+ expect(store.getState().errors['title']).toEqual(['Required'])
59
+
60
+ store.getState().setFieldErrors(['title'], [])
61
+ expect(store.getState().errors['title']).toBeUndefined()
62
+ })
63
+
64
+ it('clears all errors', () => {
65
+ const store = createFormStore()
66
+ store.getState().init(schema, { title: '', count: 0, active: false })
67
+
68
+ store.getState().setFieldErrors(['title'], ['Required'])
69
+ store.getState().setFieldErrors(['count'], ['Must be positive'])
70
+ store.getState().clearErrors()
71
+ expect(store.getState().errors).toEqual({})
72
+ })
73
+
74
+ it('resets to initial values', () => {
75
+ const store = createFormStore()
76
+ store.getState().init(schema, { title: 'Original', count: 0, active: false })
77
+
78
+ store.getState().setFieldValue(['title'], 'Modified')
79
+ store.getState().touchField(['title'])
80
+ store.getState().setFieldErrors(['title'], ['Error'])
81
+ store.getState().setStatus('error', 'Something went wrong')
82
+
83
+ store.getState().reset()
84
+ const state = store.getState()
85
+ expect(state.values.title).toBe('Original')
86
+ expect(state.touched.size).toBe(0)
87
+ expect(state.errors).toEqual({})
88
+ expect(state.status).toBe('idle')
89
+ expect(state.errorMessage).toBeUndefined()
90
+ })
91
+
92
+ it('handles nested paths', () => {
93
+ const store = createFormStore()
94
+ store.getState().init(schema, { title: '', count: 0, active: false })
95
+
96
+ store.getState().setFieldValue(['nested', 'deep'], 'value')
97
+ expect(store.getState().getFieldValue(['nested', 'deep'])).toBe('value')
98
+ })
99
+
100
+ it('sets status with optional error message', () => {
101
+ const store = createFormStore()
102
+ store.getState().init(schema, { title: '', count: 0, active: false })
103
+
104
+ store.getState().setStatus('saving')
105
+ expect(store.getState().status).toBe('saving')
106
+
107
+ store.getState().setStatus('error', 'Network error')
108
+ expect(store.getState().status).toBe('error')
109
+ expect(store.getState().errorMessage).toBe('Network error')
110
+ })
111
+ })
@@ -0,0 +1,298 @@
1
+ import { produce, enableMapSet } from 'immer'
2
+
3
+ enableMapSet()
4
+ import { createStore } from 'zustand/vanilla'
5
+ import type { FieldPath, FieldRecord } from '@setzkasten-cms/core'
6
+ import { createCommandHistory, type Command, type CommandHistory } from '@setzkasten-cms/core'
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Path-based deep get/set utilities
10
+ // ---------------------------------------------------------------------------
11
+
12
+ function getIn(obj: unknown, path: FieldPath): unknown {
13
+ let current: unknown = obj
14
+ for (const key of path) {
15
+ if (current === null || current === undefined) return undefined
16
+ if (typeof current === 'object') {
17
+ current = (current as Record<string | number, unknown>)[key]
18
+ } else {
19
+ return undefined
20
+ }
21
+ }
22
+ return current
23
+ }
24
+
25
+ function setIn(obj: Record<string, unknown>, path: FieldPath, value: unknown): void {
26
+ if (path.length === 0) return
27
+
28
+ let current: Record<string | number, unknown> = obj
29
+ for (let i = 0; i < path.length - 1; i++) {
30
+ const key = path[i]!
31
+ const next = current[key]
32
+ if (next === null || next === undefined || typeof next !== 'object') {
33
+ const nextKey = path[i + 1]
34
+ current[key] = typeof nextKey === 'number' ? [] : {}
35
+ }
36
+ current = current[key] as Record<string | number, unknown>
37
+ }
38
+
39
+ const lastKey = path[path.length - 1]!
40
+ current[lastKey] = value
41
+ }
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Form state
45
+ // ---------------------------------------------------------------------------
46
+
47
+ export interface FormState {
48
+ /** The schema this form is rendering */
49
+ schema: FieldRecord | null
50
+
51
+ /** Current form values (Immer-managed) */
52
+ values: Record<string, unknown>
53
+
54
+ /** Values at load time (for dirty checking) */
55
+ initialValues: Record<string, unknown>
56
+
57
+ /** Validation errors keyed by dot-path */
58
+ errors: Record<string, string[]>
59
+
60
+ /** Field paths the user has interacted with */
61
+ touched: Set<string>
62
+
63
+ /** Form lifecycle status */
64
+ status: 'idle' | 'loading' | 'saving' | 'error'
65
+
66
+ /** Error message when status is 'error' */
67
+ errorMessage?: string
68
+ }
69
+
70
+ export interface FormActions {
71
+ /** Initialize form with schema and values. If draftValues is provided, values starts as draft but initialValues stays as saved. */
72
+ init(schema: FieldRecord, values: Record<string, unknown>, draftValues?: Record<string, unknown>): void
73
+
74
+ /** Set a field value by path */
75
+ setFieldValue(path: FieldPath, value: unknown): void
76
+
77
+ /** Get a field value by path */
78
+ getFieldValue(path: FieldPath): unknown
79
+
80
+ /** Mark a field as touched */
81
+ touchField(path: FieldPath): void
82
+
83
+ /** Check if a specific field is dirty */
84
+ isFieldDirty(path: FieldPath): boolean
85
+
86
+ /** Check if any field is dirty */
87
+ isDirty(): boolean
88
+
89
+ /** Set validation errors for a field */
90
+ setFieldErrors(path: FieldPath, errors: string[]): void
91
+
92
+ /** Clear all validation errors */
93
+ clearErrors(): void
94
+
95
+ /** Set form status */
96
+ setStatus(status: FormState['status'], errorMessage?: string): void
97
+
98
+ /** Reset form to initial values */
99
+ reset(): void
100
+
101
+ /** Undo last change */
102
+ undo(): void
103
+
104
+ /** Redo last undone change */
105
+ redo(): void
106
+
107
+ /** Whether undo is available */
108
+ canUndo(): boolean
109
+
110
+ /** Whether redo is available */
111
+ canRedo(): boolean
112
+
113
+ /** Reset to initial values and clear history (Verwerfen) */
114
+ resetToInitial(): void
115
+ }
116
+
117
+ export type FormStore = FormState & FormActions
118
+
119
+ function pathToKey(path: FieldPath): string {
120
+ return path.join('.')
121
+ }
122
+
123
+ /**
124
+ * Creates a Zustand vanilla store for form state.
125
+ * One store per open entry – not a singleton.
126
+ *
127
+ * Includes undo/redo via CommandHistory (snapshot-based, debounced).
128
+ */
129
+ export function createFormStore() {
130
+ const history = createCommandHistory()
131
+ let snapshotTimer: ReturnType<typeof setTimeout> | null = null
132
+ let pendingSnapshot: Record<string, unknown> | null = null
133
+ // Flag to prevent recording undo/redo restores as new commands
134
+ let isRestoring = false
135
+
136
+ function flushSnapshot(store: { getState(): FormStore; setState(partial: Partial<FormState>): void }) {
137
+ if (!pendingSnapshot) return
138
+ const before = pendingSnapshot
139
+ const after = structuredClone(store.getState().values)
140
+ pendingSnapshot = null
141
+
142
+ // Don't create no-op commands
143
+ if (JSON.stringify(before) === JSON.stringify(after)) return
144
+
145
+ const command: Command = {
146
+ execute() {
147
+ isRestoring = true
148
+ store.setState({ values: structuredClone(after) })
149
+ isRestoring = false
150
+ },
151
+ undo() {
152
+ isRestoring = true
153
+ store.setState({ values: structuredClone(before) })
154
+ isRestoring = false
155
+ },
156
+ describe() {
157
+ return 'Field change'
158
+ },
159
+ }
160
+ // Push command – execute() is called but state is already `after`, so it's a no-op.
161
+ // This ensures redo() gets the real execute() (not an empty stub).
162
+ history.execute(command)
163
+ }
164
+
165
+ const store = createStore<FormStore>((set, get) => ({
166
+ // State
167
+ schema: null,
168
+ values: {},
169
+ initialValues: {},
170
+ errors: {},
171
+ touched: new Set<string>(),
172
+ status: 'idle',
173
+
174
+ // Actions
175
+ init(schema, values, draftValues) {
176
+ history.clear()
177
+ pendingSnapshot = null
178
+ if (snapshotTimer) clearTimeout(snapshotTimer)
179
+ set({
180
+ schema,
181
+ values: structuredClone(draftValues ?? values),
182
+ initialValues: structuredClone(values),
183
+ errors: {},
184
+ touched: new Set(),
185
+ status: 'idle',
186
+ errorMessage: undefined,
187
+ })
188
+ },
189
+
190
+ setFieldValue(path, value) {
191
+ if (!isRestoring) {
192
+ // Capture snapshot before change (debounced)
193
+ if (!pendingSnapshot) {
194
+ pendingSnapshot = structuredClone(get().values)
195
+ }
196
+ if (snapshotTimer) clearTimeout(snapshotTimer)
197
+ snapshotTimer = setTimeout(() => flushSnapshot(store), 500)
198
+ }
199
+
200
+ set(
201
+ produce((state: FormState) => {
202
+ setIn(state.values, path, value)
203
+ }),
204
+ )
205
+ },
206
+
207
+ getFieldValue(path) {
208
+ return getIn(get().values, path)
209
+ },
210
+
211
+ touchField(path) {
212
+ set(
213
+ produce((state: FormState) => {
214
+ state.touched.add(pathToKey(path))
215
+ }),
216
+ )
217
+ },
218
+
219
+ isFieldDirty(path) {
220
+ const current = getIn(get().values, path)
221
+ const initial = getIn(get().initialValues, path)
222
+ return JSON.stringify(current) !== JSON.stringify(initial)
223
+ },
224
+
225
+ isDirty() {
226
+ const { values, initialValues } = get()
227
+ return JSON.stringify(values) !== JSON.stringify(initialValues)
228
+ },
229
+
230
+ setFieldErrors(path, errors) {
231
+ set(
232
+ produce((state: FormState) => {
233
+ const key = pathToKey(path)
234
+ if (errors.length > 0) {
235
+ state.errors[key] = errors
236
+ } else {
237
+ delete state.errors[key]
238
+ }
239
+ }),
240
+ )
241
+ },
242
+
243
+ clearErrors() {
244
+ set({ errors: {} })
245
+ },
246
+
247
+ setStatus(status, errorMessage) {
248
+ set({ status, errorMessage })
249
+ },
250
+
251
+ reset() {
252
+ const { initialValues } = get()
253
+ set({
254
+ values: structuredClone(initialValues),
255
+ errors: {},
256
+ touched: new Set(),
257
+ status: 'idle',
258
+ errorMessage: undefined,
259
+ })
260
+ },
261
+
262
+ undo() {
263
+ // Flush any pending snapshot first so we can undo it
264
+ flushSnapshot(store)
265
+ history.undo()
266
+ },
267
+
268
+ redo() {
269
+ history.redo()
270
+ },
271
+
272
+ canUndo() {
273
+ return history.canUndo() || pendingSnapshot !== null
274
+ },
275
+
276
+ canRedo() {
277
+ return history.canRedo()
278
+ },
279
+
280
+ resetToInitial() {
281
+ const { initialValues } = get()
282
+ history.clear()
283
+ pendingSnapshot = null
284
+ if (snapshotTimer) clearTimeout(snapshotTimer)
285
+ isRestoring = true
286
+ set({
287
+ values: structuredClone(initialValues),
288
+ errors: {},
289
+ touched: new Set(),
290
+ status: 'idle',
291
+ errorMessage: undefined,
292
+ })
293
+ isRestoring = false
294
+ },
295
+ }))
296
+
297
+ return store
298
+ }