@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.
- package/LICENSE +37 -0
- package/dist/index.d.ts +271 -0
- package/dist/index.js +2936 -0
- package/package.json +41 -0
- package/src/adapters/proxy-asset-store.ts +210 -0
- package/src/adapters/proxy-content-repository.ts +259 -0
- package/src/components/admin-app.tsx +275 -0
- package/src/components/collection-view.tsx +103 -0
- package/src/components/entry-form.tsx +76 -0
- package/src/components/entry-list.tsx +119 -0
- package/src/components/page-builder.tsx +1134 -0
- package/src/components/toast.tsx +48 -0
- package/src/fields/array-field-renderer.tsx +101 -0
- package/src/fields/boolean-field-renderer.tsx +28 -0
- package/src/fields/field-renderer.tsx +60 -0
- package/src/fields/icon-field-renderer.tsx +130 -0
- package/src/fields/image-field-renderer.tsx +266 -0
- package/src/fields/number-field-renderer.tsx +38 -0
- package/src/fields/object-field-renderer.tsx +41 -0
- package/src/fields/override-field-renderer.tsx +48 -0
- package/src/fields/select-field-renderer.tsx +42 -0
- package/src/fields/text-field-renderer.tsx +313 -0
- package/src/hooks/use-field.ts +82 -0
- package/src/hooks/use-save.ts +46 -0
- package/src/index.ts +34 -0
- package/src/providers/setzkasten-provider.tsx +80 -0
- package/src/stores/app-store.ts +61 -0
- package/src/stores/form-store.test.ts +111 -0
- package/src/stores/form-store.ts +298 -0
- package/src/styles/admin.css +2017 -0
|
@@ -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
|
+
}
|