@getmicdrop/svelte-components 5.3.13 → 5.3.14

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,386 @@
1
+ import { writable, derived, get } from "svelte/store";
2
+ import { showToast } from "./toaster";
3
+
4
+ /**
5
+ * @typedef {Object} FormStoreOptions
6
+ * @property {Object} initialData - Initial form data
7
+ * @property {string} [endpoint] - API endpoint for saving
8
+ * @property {string} [successMessage] - Message to show on success
9
+ * @property {string} [errorMessage] - Message to show on error
10
+ * @property {Function} [validate] - Validation function returning { isValid, errors }
11
+ * @property {Function} [transformData] - Transform data before saving
12
+ * @property {Function} [onSuccess] - Callback after successful save
13
+ * @property {Function} [onError] - Callback after error
14
+ * @property {boolean} [showToasts=true] - Show toast notifications
15
+ */
16
+
17
+ /**
18
+ * Creates a comprehensive form store with state management, dirty tracking,
19
+ * validation, and save handling.
20
+ *
21
+ * @param {FormStoreOptions} options - Configuration options
22
+ * @returns {Object} Form store with all state and handlers
23
+ *
24
+ * @example
25
+ * const form = createFormStore({
26
+ * initialData: { name: '', email: '' },
27
+ * endpoint: '/api/profile',
28
+ * validate: (data) => {
29
+ * const errors = {};
30
+ * if (!data.name) errors.name = 'Name is required';
31
+ * return { isValid: Object.keys(errors).length === 0, errors };
32
+ * }
33
+ * });
34
+ *
35
+ * // In Svelte component:
36
+ * <input bind:value={$form.data.name} on:input={form.checkDirty} />
37
+ * <button on:click={form.submit} disabled={$form.isLoading}>Save</button>
38
+ */
39
+ export function createFormStore(options = {}) {
40
+ const {
41
+ initialData = {},
42
+ endpoint = "",
43
+ successMessage = "Changes saved successfully",
44
+ errorMessage = "Failed to save changes",
45
+ validate,
46
+ transformData,
47
+ onSuccess,
48
+ onError,
49
+ showToasts = true,
50
+ } = options;
51
+
52
+ // Deep clone initial data to prevent mutations
53
+ const cloneData = (data) => JSON.parse(JSON.stringify(data));
54
+
55
+ // Core state stores
56
+ const data = writable(cloneData(initialData));
57
+ const initial = writable(cloneData(initialData));
58
+ const errors = writable({});
59
+ const isLoading = writable(false);
60
+ const isSuccess = writable(false);
61
+ const saveError = writable(null);
62
+ const showErrors = writable(false);
63
+
64
+ // Derived states
65
+ const isDirty = derived(
66
+ [data, initial],
67
+ ([$data, $initial]) => JSON.stringify($data) !== JSON.stringify($initial)
68
+ );
69
+
70
+ const isValid = derived(
71
+ errors,
72
+ ($errors) => countErrors($errors) === 0
73
+ );
74
+
75
+ const errorCount = derived(
76
+ errors,
77
+ ($errors) => countErrors($errors)
78
+ );
79
+
80
+ const errorList = derived(
81
+ errors,
82
+ ($errors) => flattenErrors($errors)
83
+ );
84
+
85
+ /**
86
+ * Count total errors in a nested error object
87
+ */
88
+ function countErrors(errorObj) {
89
+ let count = 0;
90
+ const traverse = (obj) => {
91
+ if (!obj || typeof obj !== 'object') return;
92
+ Object.values(obj).forEach(value => {
93
+ if (typeof value === 'string' && value) {
94
+ count++;
95
+ } else if (typeof value === 'object') {
96
+ traverse(value);
97
+ }
98
+ });
99
+ };
100
+ traverse(errorObj);
101
+ return count;
102
+ }
103
+
104
+ /**
105
+ * Flatten nested errors into array of { field, message, section }
106
+ */
107
+ function flattenErrors(errorObj) {
108
+ const list = [];
109
+ const traverse = (obj, section = '') => {
110
+ if (!obj || typeof obj !== 'object') return;
111
+ Object.entries(obj).forEach(([key, value]) => {
112
+ if (typeof value === 'string' && value) {
113
+ list.push({ field: key, message: value, section });
114
+ } else if (typeof value === 'object') {
115
+ traverse(value, key);
116
+ }
117
+ });
118
+ };
119
+ traverse(errorObj);
120
+ return list;
121
+ }
122
+
123
+ /**
124
+ * Update form data (partial update)
125
+ */
126
+ function updateData(updates) {
127
+ data.update(current => ({ ...current, ...updates }));
128
+ }
129
+
130
+ /**
131
+ * Update a specific section of form data
132
+ */
133
+ function updateSection(section, updates) {
134
+ data.update(current => ({
135
+ ...current,
136
+ [section]: { ...current[section], ...updates }
137
+ }));
138
+ }
139
+
140
+ /**
141
+ * Update a specific field
142
+ */
143
+ function updateField(section, field, value) {
144
+ data.update(current => ({
145
+ ...current,
146
+ [section]: { ...current[section], [field]: value }
147
+ }));
148
+ }
149
+
150
+ /**
151
+ * Check if form is dirty (call after data changes)
152
+ */
153
+ function checkDirty() {
154
+ // Dirty state is computed automatically via derived store
155
+ // Reset success state when dirty
156
+ const dirty = get(isDirty);
157
+ if (dirty) {
158
+ isSuccess.set(false);
159
+ }
160
+ return dirty;
161
+ }
162
+
163
+ /**
164
+ * Run validation and update errors
165
+ * @returns {boolean} Whether form is valid
166
+ */
167
+ function runValidation() {
168
+ if (!validate) {
169
+ errors.set({});
170
+ return true;
171
+ }
172
+
173
+ const currentData = get(data);
174
+ const result = validate(currentData);
175
+
176
+ if (typeof result === 'boolean') {
177
+ // Simple boolean validation
178
+ return result;
179
+ }
180
+
181
+ // Object-based validation { isValid, errors }
182
+ errors.set(result.errors || {});
183
+ return result.isValid;
184
+ }
185
+
186
+ /**
187
+ * Clear all errors
188
+ */
189
+ function clearErrors() {
190
+ errors.set({});
191
+ showErrors.set(false);
192
+ }
193
+
194
+ /**
195
+ * Clear error for specific field
196
+ */
197
+ function clearFieldError(section, field) {
198
+ errors.update(current => {
199
+ if (current[section]) {
200
+ const updated = { ...current };
201
+ updated[section] = { ...current[section] };
202
+ delete updated[section][field];
203
+ return updated;
204
+ }
205
+ return current;
206
+ });
207
+ }
208
+
209
+ /**
210
+ * Set error for specific field
211
+ */
212
+ function setFieldError(section, field, message) {
213
+ errors.update(current => ({
214
+ ...current,
215
+ [section]: { ...current[section], [field]: message }
216
+ }));
217
+ }
218
+
219
+ /**
220
+ * Submit the form
221
+ * @param {string} [customEndpoint] - Override default endpoint
222
+ * @returns {Promise<boolean>} Success status
223
+ */
224
+ async function submit(customEndpoint = null) {
225
+ showErrors.set(true);
226
+
227
+ // Run validation
228
+ const valid = runValidation();
229
+ if (!valid) {
230
+ return false;
231
+ }
232
+
233
+ const targetEndpoint = customEndpoint || endpoint;
234
+ if (!targetEndpoint) {
235
+ console.error("No endpoint specified for form save");
236
+ return false;
237
+ }
238
+
239
+ isLoading.set(true);
240
+ isSuccess.set(false);
241
+ saveError.set(null);
242
+
243
+ try {
244
+ let submitData = get(data);
245
+
246
+ // Transform data if transformer provided
247
+ if (transformData) {
248
+ submitData = transformData(submitData);
249
+ }
250
+
251
+ const res = await fetch(targetEndpoint, {
252
+ method: "POST",
253
+ headers: { "Content-Type": "application/json" },
254
+ body: JSON.stringify(submitData),
255
+ });
256
+
257
+ if (res.ok) {
258
+ isSuccess.set(true);
259
+ // Update initial data to current (form is now clean)
260
+ initial.set(cloneData(get(data)));
261
+
262
+ if (showToasts && successMessage) {
263
+ showToast(successMessage, "success");
264
+ }
265
+ onSuccess?.();
266
+ return true;
267
+ } else {
268
+ const errorData = await res.json().catch(() => ({}));
269
+ const errorMsg = errorData.message || errorMessage;
270
+ saveError.set(errorMsg);
271
+
272
+ if (showToasts) {
273
+ showToast(errorMsg, "error");
274
+ }
275
+ onError?.(errorMsg);
276
+ return false;
277
+ }
278
+ } catch (err) {
279
+ const errorMsg = err.message || "Something went wrong";
280
+ saveError.set(errorMsg);
281
+
282
+ if (showToasts) {
283
+ showToast(errorMsg, "error");
284
+ }
285
+ onError?.(errorMsg);
286
+ return false;
287
+ } finally {
288
+ isLoading.set(false);
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Reset form to initial data
294
+ */
295
+ function reset() {
296
+ data.set(cloneData(get(initial)));
297
+ errors.set({});
298
+ showErrors.set(false);
299
+ isSuccess.set(false);
300
+ saveError.set(null);
301
+ }
302
+
303
+ /**
304
+ * Reset form to new initial data
305
+ */
306
+ function resetTo(newInitialData) {
307
+ const cloned = cloneData(newInitialData);
308
+ initial.set(cloned);
309
+ data.set(cloned);
310
+ errors.set({});
311
+ showErrors.set(false);
312
+ isSuccess.set(false);
313
+ saveError.set(null);
314
+ }
315
+
316
+ /**
317
+ * Reset only the success state
318
+ */
319
+ function resetSuccess() {
320
+ isSuccess.set(false);
321
+ }
322
+
323
+ /**
324
+ * Mark form as saved (update initial to match current)
325
+ */
326
+ function markSaved() {
327
+ initial.set(cloneData(get(data)));
328
+ isSuccess.set(true);
329
+ }
330
+
331
+ // Create a subscribable store that combines all state
332
+ const combinedStore = derived(
333
+ [data, errors, isLoading, isSuccess, saveError, isDirty, isValid, errorCount, showErrors],
334
+ ([$data, $errors, $isLoading, $isSuccess, $saveError, $isDirty, $isValid, $errorCount, $showErrors]) => ({
335
+ data: $data,
336
+ errors: $errors,
337
+ isLoading: $isLoading,
338
+ isSuccess: $isSuccess,
339
+ saveError: $saveError,
340
+ isDirty: $isDirty,
341
+ isValid: $isValid,
342
+ errorCount: $errorCount,
343
+ showErrors: $showErrors,
344
+ })
345
+ );
346
+
347
+ return {
348
+ // Subscribable combined store
349
+ subscribe: combinedStore.subscribe,
350
+
351
+ // Individual stores (for direct subscription)
352
+ data,
353
+ errors,
354
+ isLoading,
355
+ isSuccess,
356
+ saveError,
357
+ isDirty,
358
+ isValid,
359
+ errorCount,
360
+ errorList,
361
+ showErrors,
362
+
363
+ // Actions
364
+ updateData,
365
+ updateSection,
366
+ updateField,
367
+ checkDirty,
368
+ runValidation,
369
+ clearErrors,
370
+ clearFieldError,
371
+ setFieldError,
372
+ submit,
373
+ reset,
374
+ resetTo,
375
+ resetSuccess,
376
+ markSaved,
377
+
378
+ // Convenience getters
379
+ get currentData() { return get(data); },
380
+ get currentErrors() { return get(errors); },
381
+ get loading() { return get(isLoading); },
382
+ get success() { return get(isSuccess); },
383
+ get dirty() { return get(isDirty); },
384
+ get valid() { return get(isValid); },
385
+ };
386
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=createFormStore.spec.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"createFormStore.spec.d.ts","sourceRoot":"","sources":["../../src/lib/stores/createFormStore.spec.js"],"names":[],"mappings":""}