pulse-js-framework 1.7.2 → 1.7.3
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 +8 -2
- package/runtime/async.js +619 -0
- package/runtime/devtools.js +619 -0
- package/runtime/dom.js +254 -40
- package/runtime/form.js +659 -0
- package/runtime/pulse.js +36 -3
- package/runtime/router.js +51 -5
- package/runtime/store.js +45 -0
package/runtime/form.js
ADDED
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse Form Management
|
|
3
|
+
* @module pulse-js-framework/runtime/form
|
|
4
|
+
*
|
|
5
|
+
* Reactive form handling with validation, error management,
|
|
6
|
+
* and touched state tracking.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { pulse, effect, computed, batch } from './pulse.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {Object} FieldState
|
|
13
|
+
* @property {any} value - Current field value
|
|
14
|
+
* @property {string|null} error - Validation error message
|
|
15
|
+
* @property {boolean} touched - Whether field has been interacted with
|
|
16
|
+
* @property {boolean} dirty - Whether field value differs from initial
|
|
17
|
+
* @property {boolean} valid - Whether field passes validation
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {Object} ValidationRule
|
|
22
|
+
* @property {function(any, Object): boolean|string} validate - Validation function
|
|
23
|
+
* @property {string} [message] - Default error message
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Built-in validation rules
|
|
28
|
+
*/
|
|
29
|
+
export const validators = {
|
|
30
|
+
/**
|
|
31
|
+
* Required field validation
|
|
32
|
+
* @param {string} [message='This field is required']
|
|
33
|
+
*/
|
|
34
|
+
required: (message = 'This field is required') => ({
|
|
35
|
+
validate: (value) => {
|
|
36
|
+
if (value === null || value === undefined || value === '') return message;
|
|
37
|
+
if (Array.isArray(value) && value.length === 0) return message;
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
}),
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Minimum length validation
|
|
44
|
+
* @param {number} length - Minimum length
|
|
45
|
+
* @param {string} [message] - Error message
|
|
46
|
+
*/
|
|
47
|
+
minLength: (length, message) => ({
|
|
48
|
+
validate: (value) => {
|
|
49
|
+
if (!value) return true; // Let required handle empty
|
|
50
|
+
if (String(value).length < length) {
|
|
51
|
+
return message || `Must be at least ${length} characters`;
|
|
52
|
+
}
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
}),
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Maximum length validation
|
|
59
|
+
* @param {number} length - Maximum length
|
|
60
|
+
* @param {string} [message] - Error message
|
|
61
|
+
*/
|
|
62
|
+
maxLength: (length, message) => ({
|
|
63
|
+
validate: (value) => {
|
|
64
|
+
if (!value) return true;
|
|
65
|
+
if (String(value).length > length) {
|
|
66
|
+
return message || `Must be at most ${length} characters`;
|
|
67
|
+
}
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
}),
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Email format validation
|
|
74
|
+
* @param {string} [message='Invalid email address']
|
|
75
|
+
*/
|
|
76
|
+
email: (message = 'Invalid email address') => ({
|
|
77
|
+
validate: (value) => {
|
|
78
|
+
if (!value) return true;
|
|
79
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
80
|
+
return emailRegex.test(value) || message;
|
|
81
|
+
}
|
|
82
|
+
}),
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* URL format validation
|
|
86
|
+
* @param {string} [message='Invalid URL']
|
|
87
|
+
*/
|
|
88
|
+
url: (message = 'Invalid URL') => ({
|
|
89
|
+
validate: (value) => {
|
|
90
|
+
if (!value) return true;
|
|
91
|
+
try {
|
|
92
|
+
new URL(value);
|
|
93
|
+
return true;
|
|
94
|
+
} catch {
|
|
95
|
+
return message;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}),
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Pattern (regex) validation
|
|
102
|
+
* @param {RegExp} pattern - Regex pattern
|
|
103
|
+
* @param {string} [message='Invalid format']
|
|
104
|
+
*/
|
|
105
|
+
pattern: (pattern, message = 'Invalid format') => ({
|
|
106
|
+
validate: (value) => {
|
|
107
|
+
if (!value) return true;
|
|
108
|
+
return pattern.test(String(value)) || message;
|
|
109
|
+
}
|
|
110
|
+
}),
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Minimum value validation (for numbers)
|
|
114
|
+
* @param {number} min - Minimum value
|
|
115
|
+
* @param {string} [message] - Error message
|
|
116
|
+
*/
|
|
117
|
+
min: (min, message) => ({
|
|
118
|
+
validate: (value) => {
|
|
119
|
+
if (value === null || value === undefined || value === '') return true;
|
|
120
|
+
const num = Number(value);
|
|
121
|
+
if (isNaN(num) || num < min) {
|
|
122
|
+
return message || `Must be at least ${min}`;
|
|
123
|
+
}
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
}),
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Maximum value validation (for numbers)
|
|
130
|
+
* @param {number} max - Maximum value
|
|
131
|
+
* @param {string} [message] - Error message
|
|
132
|
+
*/
|
|
133
|
+
max: (max, message) => ({
|
|
134
|
+
validate: (value) => {
|
|
135
|
+
if (value === null || value === undefined || value === '') return true;
|
|
136
|
+
const num = Number(value);
|
|
137
|
+
if (isNaN(num) || num > max) {
|
|
138
|
+
return message || `Must be at most ${max}`;
|
|
139
|
+
}
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
}),
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Custom validation function
|
|
146
|
+
* @param {function(any, Object): boolean|string} fn - Validation function
|
|
147
|
+
*/
|
|
148
|
+
custom: (fn) => ({
|
|
149
|
+
validate: fn
|
|
150
|
+
}),
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Match another field value
|
|
154
|
+
* @param {string} fieldName - Name of field to match
|
|
155
|
+
* @param {string} [message] - Error message
|
|
156
|
+
*/
|
|
157
|
+
matches: (fieldName, message) => ({
|
|
158
|
+
validate: (value, allValues) => {
|
|
159
|
+
if (value !== allValues[fieldName]) {
|
|
160
|
+
return message || `Must match ${fieldName}`;
|
|
161
|
+
}
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* @typedef {Object} FormOptions
|
|
169
|
+
* @property {boolean} [validateOnChange=true] - Validate on value change
|
|
170
|
+
* @property {boolean} [validateOnBlur=true] - Validate on blur
|
|
171
|
+
* @property {boolean} [validateOnSubmit=true] - Validate on submit
|
|
172
|
+
* @property {function(Object): void} [onSubmit] - Submit handler
|
|
173
|
+
* @property {function(Object): void} [onError] - Error handler
|
|
174
|
+
* @property {'onChange'|'onBlur'|'onSubmit'} [mode='onChange'] - Validation mode
|
|
175
|
+
*/
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Create a reactive form with validation.
|
|
179
|
+
*
|
|
180
|
+
* @template T
|
|
181
|
+
* @param {T} initialValues - Initial form values
|
|
182
|
+
* @param {Object<string, ValidationRule[]>} [validationSchema={}] - Validation rules per field
|
|
183
|
+
* @param {FormOptions} [options={}] - Form configuration
|
|
184
|
+
* @returns {Object} Form state and controls
|
|
185
|
+
*
|
|
186
|
+
* @example
|
|
187
|
+
* const { fields, handleSubmit, isValid, reset } = useForm(
|
|
188
|
+
* { email: '', password: '' },
|
|
189
|
+
* {
|
|
190
|
+
* email: [validators.required(), validators.email()],
|
|
191
|
+
* password: [validators.required(), validators.minLength(8)]
|
|
192
|
+
* },
|
|
193
|
+
* {
|
|
194
|
+
* onSubmit: (values) => console.log('Submit:', values)
|
|
195
|
+
* }
|
|
196
|
+
* );
|
|
197
|
+
*
|
|
198
|
+
* // In view
|
|
199
|
+
* el('input', { value: fields.email.value.get(), onInput: fields.email.onChange });
|
|
200
|
+
* el('span.error', fields.email.error.get());
|
|
201
|
+
*/
|
|
202
|
+
export function useForm(initialValues, validationSchema = {}, options = {}) {
|
|
203
|
+
const {
|
|
204
|
+
validateOnChange = true,
|
|
205
|
+
validateOnBlur = true,
|
|
206
|
+
validateOnSubmit = true,
|
|
207
|
+
onSubmit,
|
|
208
|
+
onError,
|
|
209
|
+
mode = 'onChange'
|
|
210
|
+
} = options;
|
|
211
|
+
|
|
212
|
+
// Create field states
|
|
213
|
+
const fields = {};
|
|
214
|
+
const fieldNames = Object.keys(initialValues);
|
|
215
|
+
|
|
216
|
+
for (const name of fieldNames) {
|
|
217
|
+
const initialValue = initialValues[name];
|
|
218
|
+
const rules = validationSchema[name] || [];
|
|
219
|
+
|
|
220
|
+
const value = pulse(initialValue);
|
|
221
|
+
const error = pulse(null);
|
|
222
|
+
const touched = pulse(false);
|
|
223
|
+
const dirty = computed(() => value.get() !== initialValue);
|
|
224
|
+
const valid = computed(() => error.get() === null);
|
|
225
|
+
|
|
226
|
+
// Validate a single field
|
|
227
|
+
const validateField = () => {
|
|
228
|
+
const currentValue = value.get();
|
|
229
|
+
const allValues = getValues();
|
|
230
|
+
|
|
231
|
+
for (const rule of rules) {
|
|
232
|
+
const result = rule.validate(currentValue, allValues);
|
|
233
|
+
if (result !== true) {
|
|
234
|
+
error.set(typeof result === 'string' ? result : rule.message || 'Invalid');
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
error.set(null);
|
|
239
|
+
return true;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
// Event handlers
|
|
243
|
+
const onChange = (eventOrValue) => {
|
|
244
|
+
const newValue = eventOrValue?.target
|
|
245
|
+
? (eventOrValue.target.type === 'checkbox'
|
|
246
|
+
? eventOrValue.target.checked
|
|
247
|
+
: eventOrValue.target.value)
|
|
248
|
+
: eventOrValue;
|
|
249
|
+
|
|
250
|
+
value.set(newValue);
|
|
251
|
+
|
|
252
|
+
if (validateOnChange && (mode === 'onChange' || touched.get())) {
|
|
253
|
+
validateField();
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const onBlur = () => {
|
|
258
|
+
touched.set(true);
|
|
259
|
+
if (validateOnBlur) {
|
|
260
|
+
validateField();
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const onFocus = () => {
|
|
265
|
+
// Could be used for analytics or UI effects
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
fields[name] = {
|
|
269
|
+
value,
|
|
270
|
+
error,
|
|
271
|
+
touched,
|
|
272
|
+
dirty,
|
|
273
|
+
valid,
|
|
274
|
+
validate: validateField,
|
|
275
|
+
onChange,
|
|
276
|
+
onBlur,
|
|
277
|
+
onFocus,
|
|
278
|
+
reset: () => {
|
|
279
|
+
batch(() => {
|
|
280
|
+
value.set(initialValue);
|
|
281
|
+
error.set(null);
|
|
282
|
+
touched.set(false);
|
|
283
|
+
});
|
|
284
|
+
},
|
|
285
|
+
setError: (msg) => error.set(msg),
|
|
286
|
+
clearError: () => error.set(null)
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Form-level state
|
|
291
|
+
const isSubmitting = pulse(false);
|
|
292
|
+
const submitCount = pulse(0);
|
|
293
|
+
|
|
294
|
+
// Computed form state
|
|
295
|
+
const isValid = computed(() => {
|
|
296
|
+
return fieldNames.every(name => fields[name].valid.get());
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
const isDirty = computed(() => {
|
|
300
|
+
return fieldNames.some(name => fields[name].dirty.get());
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const isTouched = computed(() => {
|
|
304
|
+
return fieldNames.some(name => fields[name].touched.get());
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
const errors = computed(() => {
|
|
308
|
+
const result = {};
|
|
309
|
+
for (const name of fieldNames) {
|
|
310
|
+
const err = fields[name].error.get();
|
|
311
|
+
if (err) result[name] = err;
|
|
312
|
+
}
|
|
313
|
+
return result;
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Get current form values
|
|
318
|
+
*/
|
|
319
|
+
function getValues() {
|
|
320
|
+
const values = {};
|
|
321
|
+
for (const name of fieldNames) {
|
|
322
|
+
values[name] = fields[name].value.get();
|
|
323
|
+
}
|
|
324
|
+
return values;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Set multiple values at once
|
|
329
|
+
*/
|
|
330
|
+
function setValues(newValues, shouldValidate = false) {
|
|
331
|
+
batch(() => {
|
|
332
|
+
for (const [name, value] of Object.entries(newValues)) {
|
|
333
|
+
if (fields[name]) {
|
|
334
|
+
fields[name].value.set(value);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
if (shouldValidate) {
|
|
340
|
+
validateAll();
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Set a single field value
|
|
346
|
+
*/
|
|
347
|
+
function setValue(name, value, shouldValidate = false) {
|
|
348
|
+
if (fields[name]) {
|
|
349
|
+
fields[name].value.set(value);
|
|
350
|
+
if (shouldValidate) {
|
|
351
|
+
fields[name].validate();
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Validate all fields
|
|
358
|
+
*/
|
|
359
|
+
function validateAll() {
|
|
360
|
+
let allValid = true;
|
|
361
|
+
for (const name of fieldNames) {
|
|
362
|
+
if (!fields[name].validate()) {
|
|
363
|
+
allValid = false;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return allValid;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Reset form to initial values
|
|
371
|
+
*/
|
|
372
|
+
function reset(newValues) {
|
|
373
|
+
batch(() => {
|
|
374
|
+
for (const name of fieldNames) {
|
|
375
|
+
const resetValue = newValues?.[name] ?? initialValues[name];
|
|
376
|
+
fields[name].value.set(resetValue);
|
|
377
|
+
fields[name].error.set(null);
|
|
378
|
+
fields[name].touched.set(false);
|
|
379
|
+
}
|
|
380
|
+
isSubmitting.set(false);
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Handle form submission
|
|
386
|
+
*/
|
|
387
|
+
async function handleSubmit(event) {
|
|
388
|
+
if (event?.preventDefault) {
|
|
389
|
+
event.preventDefault();
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
submitCount.update(c => c + 1);
|
|
393
|
+
|
|
394
|
+
// Mark all fields as touched
|
|
395
|
+
batch(() => {
|
|
396
|
+
for (const name of fieldNames) {
|
|
397
|
+
fields[name].touched.set(true);
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
// Validate if required
|
|
402
|
+
if (validateOnSubmit) {
|
|
403
|
+
const valid = validateAll();
|
|
404
|
+
if (!valid) {
|
|
405
|
+
if (onError) {
|
|
406
|
+
onError(errors.get());
|
|
407
|
+
}
|
|
408
|
+
return false;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
isSubmitting.set(true);
|
|
413
|
+
|
|
414
|
+
try {
|
|
415
|
+
if (onSubmit) {
|
|
416
|
+
await onSubmit(getValues());
|
|
417
|
+
}
|
|
418
|
+
return true;
|
|
419
|
+
} catch (err) {
|
|
420
|
+
if (onError) {
|
|
421
|
+
onError({ _form: err.message || 'Submit failed' });
|
|
422
|
+
}
|
|
423
|
+
return false;
|
|
424
|
+
} finally {
|
|
425
|
+
isSubmitting.set(false);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Set form-level or field errors
|
|
431
|
+
*/
|
|
432
|
+
function setErrors(errorMap) {
|
|
433
|
+
batch(() => {
|
|
434
|
+
for (const [name, message] of Object.entries(errorMap)) {
|
|
435
|
+
if (fields[name]) {
|
|
436
|
+
fields[name].error.set(message);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Clear all errors
|
|
444
|
+
*/
|
|
445
|
+
function clearErrors() {
|
|
446
|
+
batch(() => {
|
|
447
|
+
for (const name of fieldNames) {
|
|
448
|
+
fields[name].error.set(null);
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return {
|
|
454
|
+
fields,
|
|
455
|
+
isValid,
|
|
456
|
+
isDirty,
|
|
457
|
+
isTouched,
|
|
458
|
+
isSubmitting,
|
|
459
|
+
submitCount,
|
|
460
|
+
errors,
|
|
461
|
+
getValues,
|
|
462
|
+
setValues,
|
|
463
|
+
setValue,
|
|
464
|
+
validateAll,
|
|
465
|
+
reset,
|
|
466
|
+
handleSubmit,
|
|
467
|
+
setErrors,
|
|
468
|
+
clearErrors
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Create a single reactive field (for use outside of useForm)
|
|
474
|
+
*
|
|
475
|
+
* @param {any} initialValue - Initial field value
|
|
476
|
+
* @param {ValidationRule[]} [rules=[]] - Validation rules
|
|
477
|
+
* @returns {Object} Field state and controls
|
|
478
|
+
*
|
|
479
|
+
* @example
|
|
480
|
+
* const email = useField('', [validators.required(), validators.email()]);
|
|
481
|
+
*
|
|
482
|
+
* // Bind to input
|
|
483
|
+
* el('input', { value: email.value.get(), onInput: email.onChange, onBlur: email.onBlur });
|
|
484
|
+
*/
|
|
485
|
+
export function useField(initialValue, rules = []) {
|
|
486
|
+
const value = pulse(initialValue);
|
|
487
|
+
const error = pulse(null);
|
|
488
|
+
const touched = pulse(false);
|
|
489
|
+
const dirty = computed(() => value.get() !== initialValue);
|
|
490
|
+
const valid = computed(() => error.get() === null);
|
|
491
|
+
|
|
492
|
+
const validate = () => {
|
|
493
|
+
const currentValue = value.get();
|
|
494
|
+
|
|
495
|
+
for (const rule of rules) {
|
|
496
|
+
const result = rule.validate(currentValue, {});
|
|
497
|
+
if (result !== true) {
|
|
498
|
+
error.set(typeof result === 'string' ? result : rule.message || 'Invalid');
|
|
499
|
+
return false;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
error.set(null);
|
|
503
|
+
return true;
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
const onChange = (eventOrValue) => {
|
|
507
|
+
const newValue = eventOrValue?.target
|
|
508
|
+
? (eventOrValue.target.type === 'checkbox'
|
|
509
|
+
? eventOrValue.target.checked
|
|
510
|
+
: eventOrValue.target.value)
|
|
511
|
+
: eventOrValue;
|
|
512
|
+
|
|
513
|
+
value.set(newValue);
|
|
514
|
+
|
|
515
|
+
if (touched.get()) {
|
|
516
|
+
validate();
|
|
517
|
+
}
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
const onBlur = () => {
|
|
521
|
+
touched.set(true);
|
|
522
|
+
validate();
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
const reset = () => {
|
|
526
|
+
batch(() => {
|
|
527
|
+
value.set(initialValue);
|
|
528
|
+
error.set(null);
|
|
529
|
+
touched.set(false);
|
|
530
|
+
});
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
return {
|
|
534
|
+
value,
|
|
535
|
+
error,
|
|
536
|
+
touched,
|
|
537
|
+
dirty,
|
|
538
|
+
valid,
|
|
539
|
+
validate,
|
|
540
|
+
onChange,
|
|
541
|
+
onBlur,
|
|
542
|
+
reset,
|
|
543
|
+
setError: (msg) => error.set(msg),
|
|
544
|
+
clearError: () => error.set(null)
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Create a field array for dynamic lists of fields
|
|
550
|
+
*
|
|
551
|
+
* @template T
|
|
552
|
+
* @param {T[]} initialValues - Initial array of values
|
|
553
|
+
* @param {ValidationRule[]} [itemRules=[]] - Validation rules for each item
|
|
554
|
+
* @returns {Object} Field array state and controls
|
|
555
|
+
*
|
|
556
|
+
* @example
|
|
557
|
+
* const tags = useFieldArray(['tag1', 'tag2'], [validators.required()]);
|
|
558
|
+
*
|
|
559
|
+
* // Add new tag
|
|
560
|
+
* tags.append('tag3');
|
|
561
|
+
*
|
|
562
|
+
* // Remove tag
|
|
563
|
+
* tags.remove(0);
|
|
564
|
+
*
|
|
565
|
+
* // Render items
|
|
566
|
+
* tags.fields.get().forEach((field, index) => {
|
|
567
|
+
* el('input', { value: field.value.get(), onInput: field.onChange });
|
|
568
|
+
* el('button', { onClick: () => tags.remove(index) }, 'Remove');
|
|
569
|
+
* });
|
|
570
|
+
*/
|
|
571
|
+
export function useFieldArray(initialValues = [], itemRules = []) {
|
|
572
|
+
// Create field for each initial value
|
|
573
|
+
const createField = (value) => useField(value, itemRules);
|
|
574
|
+
|
|
575
|
+
const fieldsArray = pulse(initialValues.map(createField));
|
|
576
|
+
|
|
577
|
+
// Computed values
|
|
578
|
+
const values = computed(() => fieldsArray.get().map(f => f.value.get()));
|
|
579
|
+
const errors = computed(() => fieldsArray.get().map(f => f.error.get()));
|
|
580
|
+
const isValid = computed(() => fieldsArray.get().every(f => f.valid.get()));
|
|
581
|
+
|
|
582
|
+
const append = (value) => {
|
|
583
|
+
fieldsArray.update(arr => [...arr, createField(value)]);
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
const prepend = (value) => {
|
|
587
|
+
fieldsArray.update(arr => [createField(value), ...arr]);
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
const insert = (index, value) => {
|
|
591
|
+
fieldsArray.update(arr => {
|
|
592
|
+
const newArr = [...arr];
|
|
593
|
+
newArr.splice(index, 0, createField(value));
|
|
594
|
+
return newArr;
|
|
595
|
+
});
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
const remove = (index) => {
|
|
599
|
+
fieldsArray.update(arr => arr.filter((_, i) => i !== index));
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
const move = (from, to) => {
|
|
603
|
+
fieldsArray.update(arr => {
|
|
604
|
+
const newArr = [...arr];
|
|
605
|
+
const [item] = newArr.splice(from, 1);
|
|
606
|
+
newArr.splice(to, 0, item);
|
|
607
|
+
return newArr;
|
|
608
|
+
});
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
const swap = (indexA, indexB) => {
|
|
612
|
+
fieldsArray.update(arr => {
|
|
613
|
+
const newArr = [...arr];
|
|
614
|
+
[newArr[indexA], newArr[indexB]] = [newArr[indexB], newArr[indexA]];
|
|
615
|
+
return newArr;
|
|
616
|
+
});
|
|
617
|
+
};
|
|
618
|
+
|
|
619
|
+
const replace = (index, value) => {
|
|
620
|
+
fieldsArray.update(arr => {
|
|
621
|
+
const newArr = [...arr];
|
|
622
|
+
newArr[index] = createField(value);
|
|
623
|
+
return newArr;
|
|
624
|
+
});
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
const reset = (newValues = initialValues) => {
|
|
628
|
+
fieldsArray.set(newValues.map(createField));
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
const validateAll = () => {
|
|
632
|
+
// Use map instead of every to validate ALL fields (every short-circuits)
|
|
633
|
+
const results = fieldsArray.get().map(f => f.validate());
|
|
634
|
+
return results.every(r => r);
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
return {
|
|
638
|
+
fields: fieldsArray,
|
|
639
|
+
values,
|
|
640
|
+
errors,
|
|
641
|
+
isValid,
|
|
642
|
+
append,
|
|
643
|
+
prepend,
|
|
644
|
+
insert,
|
|
645
|
+
remove,
|
|
646
|
+
move,
|
|
647
|
+
swap,
|
|
648
|
+
replace,
|
|
649
|
+
reset,
|
|
650
|
+
validateAll
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
export default {
|
|
655
|
+
useForm,
|
|
656
|
+
useField,
|
|
657
|
+
useFieldArray,
|
|
658
|
+
validators
|
|
659
|
+
};
|
package/runtime/pulse.js
CHANGED
|
@@ -524,19 +524,52 @@ function flushEffects() {
|
|
|
524
524
|
context.isRunningEffects = true;
|
|
525
525
|
let iterations = 0;
|
|
526
526
|
|
|
527
|
+
// Track effect run counts to identify infinite loop culprits
|
|
528
|
+
const effectRunCounts = new Map();
|
|
529
|
+
|
|
527
530
|
try {
|
|
528
531
|
while (context.pendingEffects.size > 0 && iterations < MAX_EFFECT_ITERATIONS) {
|
|
529
532
|
iterations++;
|
|
530
533
|
const effects = [...context.pendingEffects];
|
|
531
534
|
context.pendingEffects.clear();
|
|
532
535
|
|
|
533
|
-
for (const
|
|
534
|
-
|
|
536
|
+
for (const eff of effects) {
|
|
537
|
+
// Track how many times each effect runs
|
|
538
|
+
const id = eff.id || 'unknown';
|
|
539
|
+
effectRunCounts.set(id, (effectRunCounts.get(id) || 0) + 1);
|
|
540
|
+
runEffect(eff);
|
|
535
541
|
}
|
|
536
542
|
}
|
|
537
543
|
|
|
538
544
|
if (iterations >= MAX_EFFECT_ITERATIONS) {
|
|
539
|
-
|
|
545
|
+
// Find effects that ran the most (likely causing the loop)
|
|
546
|
+
const sortedByRuns = [...effectRunCounts.entries()]
|
|
547
|
+
.sort((a, b) => b[1] - a[1])
|
|
548
|
+
.slice(0, 10);
|
|
549
|
+
|
|
550
|
+
const culpritDetails = sortedByRuns
|
|
551
|
+
.map(([id, count]) => `${id} (${count} runs)`)
|
|
552
|
+
.join(', ');
|
|
553
|
+
|
|
554
|
+
// Still pending effects
|
|
555
|
+
const stillPending = [...context.pendingEffects]
|
|
556
|
+
.map(e => e.id || 'unknown')
|
|
557
|
+
.slice(0, 5)
|
|
558
|
+
.join(', ');
|
|
559
|
+
|
|
560
|
+
const errorMsg =
|
|
561
|
+
`[Pulse] INFINITE LOOP DETECTED\n` +
|
|
562
|
+
`Maximum effect iterations (${MAX_EFFECT_ITERATIONS}) reached.\n` +
|
|
563
|
+
`Most active effects: [${culpritDetails}]\n` +
|
|
564
|
+
`Still pending: [${stillPending || 'none'}]\n` +
|
|
565
|
+
`Tip: Check for circular dependencies where effects trigger each other.`;
|
|
566
|
+
|
|
567
|
+
// Always use console.error directly to ensure visibility
|
|
568
|
+
console.error(errorMsg);
|
|
569
|
+
|
|
570
|
+
// Also log through the logger for consistency
|
|
571
|
+
log.error(errorMsg);
|
|
572
|
+
|
|
540
573
|
context.pendingEffects.clear();
|
|
541
574
|
}
|
|
542
575
|
} finally {
|