webspresso 0.0.111 → 0.0.113
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/core/client/adapter/datastar.js +700 -50
- package/core/client/api.js +147 -7
- package/core/client/controller.js +254 -22
- package/core/client/devtool.js +59 -33
- package/core/client/forms.js +598 -38
- package/core/client/fragments.js +644 -112
- package/core/client/host.js +5 -1
- package/core/client/index.js +13 -1
- package/core/client/lite.js +1 -1
- package/core/client/server-fragments.js +145 -37
- package/core/client/utils.js +72 -17
- package/core/http/pipeline.js +82 -1
- package/core/openapi/build-from-api-routes.js +5 -5
- package/core/orm/json-fields.js +7 -6
- package/core/orm/schema-helpers.js +18 -15
- package/core/orm/utils/nanoid.js +7 -8
- package/core/services/validator.js +12 -1
- package/core/validation/apply.js +3 -0
- package/core/validation/index.js +25 -0
- package/core/validation/zod-introspect.js +187 -0
- package/index.d.ts +110 -2
- package/package.json +9 -8
- package/plugins/mcp/providers/services.js +2 -2
- package/src/api/define-api.js +2 -0
- package/src/api/validation-i18n.js +6 -2
- package/src/bundle/index.js +67 -3
- package/src/dev/api-typegen.js +79 -22
- package/src/dev/typegen.js +61 -35
- package/src/file-router.js +24 -6
- package/src/routing/mount-discovered-routes.js +24 -6
package/core/client/forms.js
CHANGED
|
@@ -23,33 +23,48 @@ const { isArray, setElementBusyState } = require('./utils');
|
|
|
23
23
|
* @param {string} target
|
|
24
24
|
* @returns {boolean}
|
|
25
25
|
*/
|
|
26
|
-
function
|
|
27
|
-
if (!target || typeof target !== 'string') return
|
|
26
|
+
function resolveSafeRedirectUrl(target) {
|
|
27
|
+
if (!target || typeof target !== 'string') return null;
|
|
28
28
|
const trimmed = target.trim();
|
|
29
|
-
if (!trimmed) return
|
|
29
|
+
if (!trimmed) return null;
|
|
30
30
|
|
|
31
31
|
// Block control characters
|
|
32
|
-
if (/[\u0000-\u001F\u007F-\u009F]/.test(trimmed)) return
|
|
32
|
+
if (/[\u0000-\u001F\u007F-\u009F]/.test(trimmed)) return null;
|
|
33
33
|
|
|
34
34
|
// Block protocol-relative URLs (//example.com) and backslash traversal (/\ or \\)
|
|
35
35
|
if (trimmed.startsWith('//') || trimmed.startsWith('/\\') || trimmed.startsWith('\\')) {
|
|
36
|
-
return
|
|
36
|
+
return null;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
// Relative path starting with single slash (e.g. /dashboard)
|
|
40
40
|
if (trimmed.startsWith('/')) {
|
|
41
|
-
|
|
41
|
+
try {
|
|
42
|
+
const parsed = new URL(trimmed, 'http://localhost');
|
|
43
|
+
if (parsed.origin !== 'http://localhost' || !parsed.pathname.startsWith('/') || parsed.pathname.startsWith('//')) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
42
50
|
}
|
|
43
51
|
|
|
44
52
|
// Absolute URL: MUST explicitly parse as http: or https:
|
|
45
53
|
try {
|
|
46
54
|
const parsed = new URL(trimmed);
|
|
47
|
-
|
|
55
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
return parsed.href;
|
|
48
59
|
} catch {
|
|
49
|
-
return
|
|
60
|
+
return null;
|
|
50
61
|
}
|
|
51
62
|
}
|
|
52
63
|
|
|
64
|
+
function isSafeRedirectUrl(target) {
|
|
65
|
+
return resolveSafeRedirectUrl(target) !== null;
|
|
66
|
+
}
|
|
67
|
+
|
|
53
68
|
let errorIdSeq = 0;
|
|
54
69
|
|
|
55
70
|
/**
|
|
@@ -432,16 +447,107 @@ function createFormHandle(formEl, options = {}) {
|
|
|
432
447
|
'form';
|
|
433
448
|
|
|
434
449
|
let initialSnapshot = captureInitialFormSnapshot(formEl);
|
|
450
|
+
let baselineValues = parseFormElement(formEl).data;
|
|
435
451
|
let pending = false;
|
|
436
452
|
let dirty = false;
|
|
453
|
+
let submitted = false;
|
|
454
|
+
let inputRevision = 0;
|
|
437
455
|
let fieldErrors = {};
|
|
438
456
|
let formError = null;
|
|
457
|
+
let lastError = null;
|
|
439
458
|
let lastData = null;
|
|
459
|
+
let draftDebounceTimer = null;
|
|
460
|
+
let resetTimer = null;
|
|
461
|
+
|
|
462
|
+
const touchedFields = new Set();
|
|
463
|
+
const errorDisplayMode =
|
|
464
|
+
options.errorDisplay === 'touched' ||
|
|
465
|
+
(formEl && formEl.getAttribute && formEl.getAttribute('data-w-error-mode') === 'touched')
|
|
466
|
+
? 'touched'
|
|
467
|
+
: 'always';
|
|
468
|
+
|
|
469
|
+
const validationSeqByField = new Map();
|
|
470
|
+
const validationTimersByField = new Map();
|
|
471
|
+
const validationAbortByField = new Map();
|
|
472
|
+
|
|
473
|
+
let activeSubmitAbort = null;
|
|
474
|
+
let uploadEntries = [];
|
|
475
|
+
const previewUrlsByField = new Map();
|
|
476
|
+
|
|
477
|
+
function cancelFieldValidation(fieldName) {
|
|
478
|
+
if (fieldName) {
|
|
479
|
+
const t = validationTimersByField.get(fieldName);
|
|
480
|
+
if (t) {
|
|
481
|
+
clearTimeout(t);
|
|
482
|
+
validationTimersByField.delete(fieldName);
|
|
483
|
+
}
|
|
484
|
+
const ac = validationAbortByField.get(fieldName);
|
|
485
|
+
if (ac) {
|
|
486
|
+
try {
|
|
487
|
+
ac.abort();
|
|
488
|
+
} catch (_e) {}
|
|
489
|
+
validationAbortByField.delete(fieldName);
|
|
490
|
+
}
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
for (const t of validationTimersByField.values()) clearTimeout(t);
|
|
494
|
+
validationTimersByField.clear();
|
|
495
|
+
for (const ac of validationAbortByField.values()) {
|
|
496
|
+
try {
|
|
497
|
+
ac.abort();
|
|
498
|
+
} catch (_e) {}
|
|
499
|
+
}
|
|
500
|
+
validationAbortByField.clear();
|
|
501
|
+
}
|
|
440
502
|
|
|
503
|
+
function revokePreviewUrls(fieldName) {
|
|
504
|
+
const revokeUrl = (u) => {
|
|
505
|
+
if (typeof URL !== 'undefined' && typeof URL.revokeObjectURL === 'function') {
|
|
506
|
+
try {
|
|
507
|
+
URL.revokeObjectURL(u);
|
|
508
|
+
} catch (_e) {}
|
|
509
|
+
}
|
|
510
|
+
};
|
|
511
|
+
if (fieldName) {
|
|
512
|
+
const set = previewUrlsByField.get(fieldName);
|
|
513
|
+
if (set) {
|
|
514
|
+
for (const u of set) revokeUrl(u);
|
|
515
|
+
set.clear();
|
|
516
|
+
previewUrlsByField.delete(fieldName);
|
|
517
|
+
}
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
for (const set of previewUrlsByField.values()) {
|
|
521
|
+
for (const u of set) revokeUrl(u);
|
|
522
|
+
set.clear();
|
|
523
|
+
}
|
|
524
|
+
previewUrlsByField.clear();
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function createPreviewUrl(file, fieldName = '_default') {
|
|
528
|
+
if (!file || typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function') return null;
|
|
529
|
+
const url = URL.createObjectURL(file);
|
|
530
|
+
let set = previewUrlsByField.get(fieldName);
|
|
531
|
+
if (!set) {
|
|
532
|
+
set = new Set();
|
|
533
|
+
previewUrlsByField.set(fieldName, set);
|
|
534
|
+
}
|
|
535
|
+
set.add(url);
|
|
536
|
+
return url;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const beforeSubmitListeners = new Set();
|
|
441
540
|
const successListeners = new Set();
|
|
442
541
|
const errorListeners = new Set();
|
|
443
542
|
const stateListeners = new Set();
|
|
444
543
|
|
|
544
|
+
function cancelDraftDebounce() {
|
|
545
|
+
if (draftDebounceTimer) {
|
|
546
|
+
clearTimeout(draftDebounceTimer);
|
|
547
|
+
draftDebounceTimer = null;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
445
551
|
// Form draft persistence setup (sessionStorage by default, or localStorage)
|
|
446
552
|
const rawDraftAttr = formEl
|
|
447
553
|
? formEl.getAttribute('data-w-persist-draft') || formEl.getAttribute('data-w-draft')
|
|
@@ -458,11 +564,18 @@ function createFormHandle(formEl, options = {}) {
|
|
|
458
564
|
draftStorageType = 'local';
|
|
459
565
|
}
|
|
460
566
|
|
|
461
|
-
const
|
|
567
|
+
const draftScope =
|
|
568
|
+
options.draftScope ||
|
|
569
|
+
(formEl && formEl.getAttribute && formEl.getAttribute('data-w-draft-scope')) ||
|
|
570
|
+
'';
|
|
571
|
+
|
|
572
|
+
const baseDraftKey =
|
|
462
573
|
options.draftKey ||
|
|
463
574
|
(formEl && (formEl.getAttribute('data-w-draft-key') || formEl.getAttribute('data-w-persist-key'))) ||
|
|
464
575
|
`form_draft:${formName || 'anon'}`;
|
|
465
576
|
|
|
577
|
+
let draftKey = draftScope ? `${baseDraftKey}:${draftScope}` : baseDraftKey;
|
|
578
|
+
|
|
466
579
|
const draftStorage =
|
|
467
580
|
(options.storage && options.storage[draftStorageType]) ||
|
|
468
581
|
(draftStorageType === 'local' ? defaultStorage.local : defaultStorage.session);
|
|
@@ -485,7 +598,22 @@ function createFormHandle(formEl, options = {}) {
|
|
|
485
598
|
return safeData;
|
|
486
599
|
}
|
|
487
600
|
|
|
601
|
+
function sanitizeDraftObject(raw) {
|
|
602
|
+
if (!raw || typeof raw !== 'object' || isArray(raw)) return null;
|
|
603
|
+
const clean = {};
|
|
604
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
605
|
+
if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
|
|
606
|
+
if (v === null || v === undefined || typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') {
|
|
607
|
+
clean[k] = v;
|
|
608
|
+
} else if (isArray(v) && v.every((item) => typeof item === 'string' || typeof item === 'number' || typeof item === 'boolean')) {
|
|
609
|
+
clean[k] = v;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
return Object.keys(clean).length > 0 ? clean : null;
|
|
613
|
+
}
|
|
614
|
+
|
|
488
615
|
function saveDraft() {
|
|
616
|
+
cancelDraftDebounce();
|
|
489
617
|
if (!formEl || !isDraftEnabled) return false;
|
|
490
618
|
const draftPayload = extractDraftValues(formEl);
|
|
491
619
|
return draftStorage.set(draftKey, draftPayload);
|
|
@@ -493,9 +621,15 @@ function createFormHandle(formEl, options = {}) {
|
|
|
493
621
|
|
|
494
622
|
function restoreDraft() {
|
|
495
623
|
if (!formEl || !isDraftEnabled) return false;
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
624
|
+
let saved = null;
|
|
625
|
+
try {
|
|
626
|
+
saved = draftStorage.get(draftKey, null);
|
|
627
|
+
} catch (_e) {
|
|
628
|
+
saved = null;
|
|
629
|
+
}
|
|
630
|
+
const cleanDraft = sanitizeDraftObject(saved);
|
|
631
|
+
if (cleanDraft) {
|
|
632
|
+
fill(cleanDraft, { baseline: false });
|
|
499
633
|
if (typeof formEl.dispatchEvent === 'function') {
|
|
500
634
|
const CustomEventCtor =
|
|
501
635
|
(formEl.ownerDocument && formEl.ownerDocument.defaultView && formEl.ownerDocument.defaultView.CustomEvent) ||
|
|
@@ -505,7 +639,7 @@ function createFormHandle(formEl, options = {}) {
|
|
|
505
639
|
new CustomEventCtor('w-form:draft-restored', {
|
|
506
640
|
bubbles: true,
|
|
507
641
|
cancelable: true,
|
|
508
|
-
detail: { form: handle, draft:
|
|
642
|
+
detail: { form: handle, draft: cleanDraft, key: draftKey },
|
|
509
643
|
})
|
|
510
644
|
);
|
|
511
645
|
}
|
|
@@ -516,6 +650,7 @@ function createFormHandle(formEl, options = {}) {
|
|
|
516
650
|
}
|
|
517
651
|
|
|
518
652
|
function clearDraft() {
|
|
653
|
+
cancelDraftDebounce();
|
|
519
654
|
if (!isDraftEnabled) return false;
|
|
520
655
|
return draftStorage.remove(draftKey);
|
|
521
656
|
}
|
|
@@ -525,6 +660,20 @@ function createFormHandle(formEl, options = {}) {
|
|
|
525
660
|
return draftStorage.has(draftKey);
|
|
526
661
|
}
|
|
527
662
|
|
|
663
|
+
function setDraftKey(nextKey, keyOpts = {}) {
|
|
664
|
+
cancelDraftDebounce();
|
|
665
|
+
if (typeof nextKey === 'string' && nextKey.trim()) {
|
|
666
|
+
draftKey = nextKey.trim();
|
|
667
|
+
if (formEl && typeof formEl.setAttribute === 'function') {
|
|
668
|
+
formEl.setAttribute('data-w-draft-key', draftKey);
|
|
669
|
+
}
|
|
670
|
+
if (keyOpts && keyOpts.restore === true && isDraftEnabled) {
|
|
671
|
+
restoreDraft();
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
return handle;
|
|
675
|
+
}
|
|
676
|
+
|
|
528
677
|
function notifyStateChange() {
|
|
529
678
|
for (const fn of stateListeners) {
|
|
530
679
|
try {
|
|
@@ -535,6 +684,12 @@ function createFormHandle(formEl, options = {}) {
|
|
|
535
684
|
}
|
|
536
685
|
}
|
|
537
686
|
|
|
687
|
+
function isFieldErrorVisible(fieldName) {
|
|
688
|
+
if (errorDisplayMode !== 'touched') return true;
|
|
689
|
+
if (submitted) return true;
|
|
690
|
+
return touchedFields.has(fieldName);
|
|
691
|
+
}
|
|
692
|
+
|
|
538
693
|
/**
|
|
539
694
|
* Render field errors and form-level error into the DOM with accessible ARIA bindings.
|
|
540
695
|
* Optionally focuses the first invalid control.
|
|
@@ -547,7 +702,11 @@ function createFormHandle(formEl, options = {}) {
|
|
|
547
702
|
for (let i = 0; i < errorSpans.length; i++) {
|
|
548
703
|
const span = errorSpans[i];
|
|
549
704
|
const fieldName = span.getAttribute('data-w-error');
|
|
550
|
-
const
|
|
705
|
+
const normName = fieldName && fieldName.endsWith('[]') ? fieldName.slice(0, -2) : fieldName;
|
|
706
|
+
const rawMsg = fieldName && (fieldErrors[fieldName] || (normName && fieldErrors[normName]))
|
|
707
|
+
? String(fieldErrors[fieldName] || fieldErrors[normName])
|
|
708
|
+
: '';
|
|
709
|
+
const msg = rawMsg && (isFieldErrorVisible(fieldName) || (normName && isFieldErrorVisible(normName))) ? rawMsg : '';
|
|
551
710
|
span.textContent = msg;
|
|
552
711
|
if (msg) {
|
|
553
712
|
span.removeAttribute('hidden');
|
|
@@ -570,7 +729,8 @@ function createFormHandle(formEl, options = {}) {
|
|
|
570
729
|
const rawName = ctrl.getAttribute('name');
|
|
571
730
|
if (!rawName) continue;
|
|
572
731
|
const normName = rawName.endsWith('[]') ? rawName.slice(0, -2) : rawName;
|
|
573
|
-
const
|
|
732
|
+
const rawErrMsg = fieldErrors[normName] || fieldErrors[rawName];
|
|
733
|
+
const errMsg = rawErrMsg && (isFieldErrorVisible(normName) || isFieldErrorVisible(rawName)) ? rawErrMsg : '';
|
|
574
734
|
|
|
575
735
|
if (errMsg) {
|
|
576
736
|
ctrl.setAttribute('aria-invalid', 'true');
|
|
@@ -654,6 +814,9 @@ function createFormHandle(formEl, options = {}) {
|
|
|
654
814
|
* Reset form controls back to their initial default values (NOT emptying non-empty defaults).
|
|
655
815
|
*/
|
|
656
816
|
function reset() {
|
|
817
|
+
cancelDraftDebounce();
|
|
818
|
+
cancelFieldValidation();
|
|
819
|
+
revokePreviewUrls();
|
|
657
820
|
if (!formEl) return;
|
|
658
821
|
if (typeof formEl.reset === 'function') {
|
|
659
822
|
formEl.reset();
|
|
@@ -673,6 +836,8 @@ function createFormHandle(formEl, options = {}) {
|
|
|
673
836
|
}
|
|
674
837
|
}
|
|
675
838
|
dirty = false;
|
|
839
|
+
submitted = false;
|
|
840
|
+
touchedFields.clear();
|
|
676
841
|
fieldErrors = {};
|
|
677
842
|
formError = null;
|
|
678
843
|
syncErrorsToDom(false);
|
|
@@ -683,6 +848,9 @@ function createFormHandle(formEl, options = {}) {
|
|
|
683
848
|
* Completely clear all user-editable controls to empty values (distinct from `reset()`).
|
|
684
849
|
*/
|
|
685
850
|
function clear() {
|
|
851
|
+
cancelDraftDebounce();
|
|
852
|
+
cancelFieldValidation();
|
|
853
|
+
revokePreviewUrls();
|
|
686
854
|
if (!formEl || typeof formEl.querySelectorAll !== 'function') return;
|
|
687
855
|
const controls = formEl.querySelectorAll('input, select, textarea');
|
|
688
856
|
for (let i = 0; i < controls.length; i++) {
|
|
@@ -704,6 +872,8 @@ function createFormHandle(formEl, options = {}) {
|
|
|
704
872
|
}
|
|
705
873
|
}
|
|
706
874
|
dirty = computeFormDirty(formEl, initialSnapshot);
|
|
875
|
+
submitted = false;
|
|
876
|
+
touchedFields.clear();
|
|
707
877
|
fieldErrors = {};
|
|
708
878
|
formError = null;
|
|
709
879
|
syncErrorsToDom(false);
|
|
@@ -719,11 +889,14 @@ function createFormHandle(formEl, options = {}) {
|
|
|
719
889
|
* snapshot, clears `dirty`, and clears previous validation errors.
|
|
720
890
|
*/
|
|
721
891
|
function fill(values = {}, options = {}) {
|
|
722
|
-
if (!formEl || typeof formEl.querySelectorAll !== 'function' || !values || typeof values !== 'object') {
|
|
892
|
+
if (!formEl || typeof formEl.querySelectorAll !== 'function' || !values || typeof values !== 'object' || isArray(values)) {
|
|
723
893
|
return handle;
|
|
724
894
|
}
|
|
725
895
|
|
|
726
896
|
const isBaseline = Boolean(options.baseline);
|
|
897
|
+
if (isBaseline) {
|
|
898
|
+
cancelDraftDebounce();
|
|
899
|
+
}
|
|
727
900
|
|
|
728
901
|
for (const [key, val] of Object.entries(values)) {
|
|
729
902
|
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
|
|
@@ -757,6 +930,7 @@ function createFormHandle(formEl, options = {}) {
|
|
|
757
930
|
ctrl.value = val === null || val === undefined ? '' : String(val);
|
|
758
931
|
}
|
|
759
932
|
} else {
|
|
933
|
+
if (val !== null && typeof val === 'object') continue;
|
|
760
934
|
ctrl.value = val === null || val === undefined ? '' : String(val);
|
|
761
935
|
}
|
|
762
936
|
|
|
@@ -767,10 +941,15 @@ function createFormHandle(formEl, options = {}) {
|
|
|
767
941
|
}
|
|
768
942
|
|
|
769
943
|
if (isBaseline) {
|
|
944
|
+
cancelFieldValidation();
|
|
770
945
|
initialSnapshot = captureInitialFormSnapshot(formEl);
|
|
946
|
+
baselineValues = parseFormElement(formEl).data;
|
|
771
947
|
dirty = false;
|
|
948
|
+
submitted = false;
|
|
949
|
+
touchedFields.clear();
|
|
772
950
|
fieldErrors = {};
|
|
773
951
|
formError = null;
|
|
952
|
+
lastError = null;
|
|
774
953
|
syncErrorsToDom(false);
|
|
775
954
|
} else {
|
|
776
955
|
dirty = computeFormDirty(formEl, initialSnapshot);
|
|
@@ -783,7 +962,9 @@ function createFormHandle(formEl, options = {}) {
|
|
|
783
962
|
|
|
784
963
|
function setAction(url) {
|
|
785
964
|
if (formEl && url) {
|
|
786
|
-
|
|
965
|
+
const nextAction = String(url);
|
|
966
|
+
formEl.setAttribute('action', nextAction);
|
|
967
|
+
formEl.__wCustomAction = nextAction;
|
|
787
968
|
}
|
|
788
969
|
return handle;
|
|
789
970
|
}
|
|
@@ -796,6 +977,7 @@ function createFormHandle(formEl, options = {}) {
|
|
|
796
977
|
action = action.replace(new RegExp(`:${k}\\b`, 'g'), encodeURIComponent(String(v)));
|
|
797
978
|
}
|
|
798
979
|
formEl.setAttribute('action', action);
|
|
980
|
+
formEl.__wCustomAction = action;
|
|
799
981
|
return handle;
|
|
800
982
|
}
|
|
801
983
|
|
|
@@ -870,12 +1052,60 @@ function createFormHandle(formEl, options = {}) {
|
|
|
870
1052
|
|
|
871
1053
|
const { data: payloadData, formData, isMultipart } = parseFormElement(formEl, submitter);
|
|
872
1054
|
|
|
873
|
-
//
|
|
1055
|
+
// Execute beforeSubmit hooks before initiating network request
|
|
1056
|
+
for (const guardFn of beforeSubmitListeners) {
|
|
1057
|
+
let allowed = true;
|
|
1058
|
+
try {
|
|
1059
|
+
allowed = await guardFn(payloadData, { form: handle, submitter, action: actionUrl, method });
|
|
1060
|
+
} catch (guardErr) {
|
|
1061
|
+
return { ok: false, submitted: false, canceled: true, reason: 'before-submit-error', error: guardErr };
|
|
1062
|
+
}
|
|
1063
|
+
if (allowed === false) {
|
|
1064
|
+
return { ok: false, submitted: false, canceled: true, reason: 'before-submit' };
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// Clear previous errors before request starts, mark submitted = true
|
|
1069
|
+
cancelFieldValidation();
|
|
1070
|
+
submitted = true;
|
|
874
1071
|
fieldErrors = {};
|
|
875
1072
|
formError = null;
|
|
1073
|
+
lastError = null;
|
|
876
1074
|
syncErrorsToDom(false);
|
|
1075
|
+
const submitRevision = inputRevision;
|
|
1076
|
+
const submittedSnapshot = captureInitialFormSnapshot(formEl);
|
|
877
1077
|
setPendingState(true, submitter);
|
|
878
1078
|
|
|
1079
|
+
// Track per-file upload state if multipart
|
|
1080
|
+
const submitAbortController = new AbortController();
|
|
1081
|
+
activeSubmitAbort = submitAbortController;
|
|
1082
|
+
if (isMultipart && formEl && typeof formEl.querySelectorAll === 'function') {
|
|
1083
|
+
const nextUploads = [];
|
|
1084
|
+
const fileInputs = formEl.querySelectorAll('input[type="file"]');
|
|
1085
|
+
for (let i = 0; i < fileInputs.length; i++) {
|
|
1086
|
+
const fi = fileInputs[i];
|
|
1087
|
+
const fName = fi.getAttribute('name') || 'file';
|
|
1088
|
+
const files = fi.files || [];
|
|
1089
|
+
for (let j = 0; j < files.length; j++) {
|
|
1090
|
+
const f = files[j];
|
|
1091
|
+
nextUploads.push({
|
|
1092
|
+
field: fName,
|
|
1093
|
+
name: f.name || 'file',
|
|
1094
|
+
size: typeof f.size === 'number' ? f.size : 0,
|
|
1095
|
+
status: 'uploading',
|
|
1096
|
+
loaded: 0,
|
|
1097
|
+
total: typeof f.size === 'number' ? f.size : 0,
|
|
1098
|
+
percent: null,
|
|
1099
|
+
error: null,
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
uploadEntries = nextUploads;
|
|
1104
|
+
if (uploadEntries.length > 0) notifyStateChange();
|
|
1105
|
+
} else {
|
|
1106
|
+
uploadEntries = [];
|
|
1107
|
+
}
|
|
1108
|
+
|
|
879
1109
|
try {
|
|
880
1110
|
let responseData;
|
|
881
1111
|
const reqKey = `form:${formName}:${method}:${actionUrl}`;
|
|
@@ -883,17 +1113,39 @@ function createFormHandle(formEl, options = {}) {
|
|
|
883
1113
|
responseData = await api.get(actionUrl, payloadData, {
|
|
884
1114
|
key: reqKey,
|
|
885
1115
|
policy: 'latest',
|
|
1116
|
+
signal: submitAbortController.signal,
|
|
886
1117
|
});
|
|
887
1118
|
} else {
|
|
888
1119
|
const bodyToSend = isMultipart && formData ? formData : payloadData;
|
|
889
|
-
|
|
1120
|
+
const reqOpts = {
|
|
890
1121
|
key: reqKey,
|
|
891
1122
|
policy: 'drop',
|
|
892
|
-
|
|
1123
|
+
signal: submitAbortController.signal,
|
|
1124
|
+
};
|
|
1125
|
+
if (isMultipart && uploadEntries.length > 0) {
|
|
1126
|
+
reqOpts.onUploadProgress = (prog) => {
|
|
1127
|
+
for (let i = 0; i < uploadEntries.length; i++) {
|
|
1128
|
+
uploadEntries[i].loaded = prog.loaded;
|
|
1129
|
+
uploadEntries[i].total = prog.total || uploadEntries[i].total;
|
|
1130
|
+
uploadEntries[i].percent = prog.percent;
|
|
1131
|
+
}
|
|
1132
|
+
if (typeof submitOptions.onUploadProgress === 'function') {
|
|
1133
|
+
submitOptions.onUploadProgress(prog, uploadEntries);
|
|
1134
|
+
}
|
|
1135
|
+
notifyStateChange();
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
responseData = await api.request(method, actionUrl, bodyToSend, reqOpts);
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
if (uploadEntries.length > 0) {
|
|
1142
|
+
for (let i = 0; i < uploadEntries.length; i++) {
|
|
1143
|
+
uploadEntries[i].status = 'done';
|
|
1144
|
+
if (uploadEntries[i].percent !== null) uploadEntries[i].percent = 100;
|
|
1145
|
+
}
|
|
893
1146
|
}
|
|
894
1147
|
|
|
895
1148
|
lastData = responseData;
|
|
896
|
-
dirty = false;
|
|
897
1149
|
fieldErrors = {};
|
|
898
1150
|
formError = null;
|
|
899
1151
|
syncErrorsToDom(false);
|
|
@@ -914,17 +1166,51 @@ function createFormHandle(formEl, options = {}) {
|
|
|
914
1166
|
clearAttr === 'true' ||
|
|
915
1167
|
clearAttr === '1';
|
|
916
1168
|
|
|
917
|
-
|
|
918
|
-
clear();
|
|
919
|
-
} else if (shouldReset) {
|
|
920
|
-
reset();
|
|
921
|
-
} else {
|
|
922
|
-
// Current state becomes the new clean baseline after successful save
|
|
923
|
-
initialSnapshot = captureInitialFormSnapshot(formEl);
|
|
924
|
-
}
|
|
1169
|
+
const editedDuringSubmit = inputRevision !== submitRevision;
|
|
925
1170
|
|
|
926
|
-
if (
|
|
927
|
-
|
|
1171
|
+
if (!editedDuringSubmit) {
|
|
1172
|
+
dirty = false;
|
|
1173
|
+
if (shouldClear) {
|
|
1174
|
+
clear();
|
|
1175
|
+
} else if (shouldReset) {
|
|
1176
|
+
reset();
|
|
1177
|
+
} else {
|
|
1178
|
+
// Current state becomes the new clean baseline after successful save
|
|
1179
|
+
initialSnapshot = captureInitialFormSnapshot(formEl);
|
|
1180
|
+
baselineValues = parseFormElement(formEl).data;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
if (isDraftEnabled) {
|
|
1184
|
+
clearDraft();
|
|
1185
|
+
}
|
|
1186
|
+
} else {
|
|
1187
|
+
// User edited form controls while submit was in flight:
|
|
1188
|
+
// Never wipe out the new unsubmitted edits via reset() or clear().
|
|
1189
|
+
// Update baseline to what was actually committed on the server (or empty baseline if clearOnSuccess).
|
|
1190
|
+
if (shouldClear) {
|
|
1191
|
+
const clearedSnapshot = new Map();
|
|
1192
|
+
for (const [ctrl, snap] of submittedSnapshot.entries()) {
|
|
1193
|
+
clearedSnapshot.set(ctrl, {
|
|
1194
|
+
...snap,
|
|
1195
|
+
checked: false,
|
|
1196
|
+
value: '',
|
|
1197
|
+
selectedValues: [],
|
|
1198
|
+
});
|
|
1199
|
+
}
|
|
1200
|
+
initialSnapshot = clearedSnapshot;
|
|
1201
|
+
baselineValues = {};
|
|
1202
|
+
} else if (!shouldReset) {
|
|
1203
|
+
initialSnapshot = submittedSnapshot;
|
|
1204
|
+
baselineValues = payloadData && typeof payloadData === 'object' ? { ...payloadData } : {};
|
|
1205
|
+
}
|
|
1206
|
+
dirty = computeFormDirty(formEl, initialSnapshot);
|
|
1207
|
+
if (isDraftEnabled) {
|
|
1208
|
+
if (dirty) {
|
|
1209
|
+
saveDraft();
|
|
1210
|
+
} else {
|
|
1211
|
+
clearDraft();
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
928
1214
|
}
|
|
929
1215
|
|
|
930
1216
|
// Invoke registered success hooks. Note: Any background fragment refresh failure
|
|
@@ -943,11 +1229,12 @@ function createFormHandle(formEl, options = {}) {
|
|
|
943
1229
|
}
|
|
944
1230
|
|
|
945
1231
|
// Explicit redirect behavior
|
|
946
|
-
const
|
|
1232
|
+
const rawRedirectTarget =
|
|
947
1233
|
submitOptions.redirectOnSuccess ||
|
|
948
1234
|
(formEl && formEl.getAttribute('data-w-redirect')) ||
|
|
949
1235
|
null;
|
|
950
|
-
|
|
1236
|
+
const redirectTarget = resolveSafeRedirectUrl(rawRedirectTarget);
|
|
1237
|
+
if (redirectTarget) {
|
|
951
1238
|
if (typeof options.navigate === 'function') {
|
|
952
1239
|
options.navigate(redirectTarget);
|
|
953
1240
|
} else if (typeof window !== 'undefined' && window.location) {
|
|
@@ -967,6 +1254,16 @@ function createFormHandle(formEl, options = {}) {
|
|
|
967
1254
|
|
|
968
1255
|
return { ok: true, submitted: true, data: responseData, refreshOk, refreshError, hookError };
|
|
969
1256
|
} catch (err) {
|
|
1257
|
+
lastError = err || null;
|
|
1258
|
+
if (uploadEntries.length > 0) {
|
|
1259
|
+
const isCanceled = Boolean(err && err.isCanceled);
|
|
1260
|
+
for (let i = 0; i < uploadEntries.length; i++) {
|
|
1261
|
+
if (uploadEntries[i].status === 'uploading') {
|
|
1262
|
+
uploadEntries[i].status = isCanceled ? 'canceled' : 'error';
|
|
1263
|
+
uploadEntries[i].error = isCanceled ? null : (err && err.message ? err.message : 'Upload failed');
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
970
1267
|
if (err && err.kind === 'validation') {
|
|
971
1268
|
fieldErrors = { ...(err.fieldErrors || {}) };
|
|
972
1269
|
formError = err.formError || (Object.keys(fieldErrors).length === 0 ? err.message : null);
|
|
@@ -989,44 +1286,227 @@ function createFormHandle(formEl, options = {}) {
|
|
|
989
1286
|
if (submitOptions.throwOnError) {
|
|
990
1287
|
throw err;
|
|
991
1288
|
}
|
|
992
|
-
return {
|
|
1289
|
+
return {
|
|
1290
|
+
ok: false,
|
|
1291
|
+
submitted: false,
|
|
1292
|
+
outcomeUnknown: Boolean(err && err.outcomeUnknown),
|
|
1293
|
+
outcome: (err && err.outcome) || 'failed',
|
|
1294
|
+
error: err,
|
|
1295
|
+
};
|
|
993
1296
|
} finally {
|
|
1297
|
+
if (activeSubmitAbort === submitAbortController) {
|
|
1298
|
+
activeSubmitAbort = null;
|
|
1299
|
+
}
|
|
994
1300
|
setPendingState(false, submitter);
|
|
995
1301
|
}
|
|
996
1302
|
}
|
|
997
1303
|
|
|
998
|
-
|
|
999
|
-
|
|
1304
|
+
function parseFieldValidateConfig(ctrl) {
|
|
1305
|
+
const ctrlAttr = ctrl && ctrl.getAttribute ? ctrl.getAttribute('data-w-validate') : null;
|
|
1306
|
+
const formAttr = formEl && formEl.getAttribute ? formEl.getAttribute('data-w-validate') : null;
|
|
1307
|
+
const raw = ctrlAttr !== null ? ctrlAttr : formAttr;
|
|
1308
|
+
if (!raw || raw === 'false') return null;
|
|
1309
|
+
const parts = String(raw).split(/[\s,|]+/).map((s) => s.trim().toLowerCase()).filter(Boolean);
|
|
1310
|
+
const onBlur = parts.includes('blur') || parts.includes('true') || raw === '';
|
|
1311
|
+
let debounceMs = null;
|
|
1312
|
+
for (const p of parts) {
|
|
1313
|
+
if (p.startsWith('debounce:')) {
|
|
1314
|
+
const ms = Number(p.slice('debounce:'.length).replace(/ms$/i, ''));
|
|
1315
|
+
debounceMs = Number.isFinite(ms) && ms >= 0 ? ms : 300;
|
|
1316
|
+
} else if (p === 'debounce') {
|
|
1317
|
+
debounceMs = 300;
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
return { onBlur, debounceMs };
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
async function validateField(fieldName, vOpts = {}) {
|
|
1324
|
+
if (!formEl || !api || !fieldName) return { ok: true, valid: true, errors: {} };
|
|
1325
|
+
const norm = fieldName.endsWith('[]') ? fieldName.slice(0, -2) : fieldName;
|
|
1326
|
+
|
|
1327
|
+
const seq = (validationSeqByField.get(norm) || 0) + 1;
|
|
1328
|
+
validationSeqByField.set(norm, seq);
|
|
1329
|
+
|
|
1330
|
+
const prevAbort = validationAbortByField.get(norm);
|
|
1331
|
+
if (prevAbort) {
|
|
1332
|
+
try {
|
|
1333
|
+
prevAbort.abort();
|
|
1334
|
+
} catch (_e) {}
|
|
1335
|
+
}
|
|
1336
|
+
const ac = new AbortController();
|
|
1337
|
+
validationAbortByField.set(norm, ac);
|
|
1338
|
+
|
|
1339
|
+
const validateUrl =
|
|
1340
|
+
vOpts.url ||
|
|
1341
|
+
(formEl.getAttribute && (formEl.getAttribute('data-w-validate-url') || formEl.getAttribute('action'))) ||
|
|
1342
|
+
(typeof window !== 'undefined' && window.location ? window.location.pathname : '/');
|
|
1343
|
+
const method = String(vOpts.method || (formEl.getAttribute && formEl.getAttribute('method')) || 'POST').toUpperCase();
|
|
1344
|
+
|
|
1345
|
+
const { data: allValues } = parseFormElement(formEl);
|
|
1346
|
+
const ctrl = formEl.querySelector ? (formEl.querySelector(`[name="${norm}"]`) || formEl.querySelector(`[name="${fieldName}"]`)) : null;
|
|
1347
|
+
const contextAttr = ctrl && ctrl.getAttribute ? ctrl.getAttribute('data-w-validate-context') : null;
|
|
1348
|
+
|
|
1349
|
+
let payload = { [norm]: allValues[norm] };
|
|
1350
|
+
if (vOpts.contextFields === '*' || contextAttr === '*') {
|
|
1351
|
+
payload = { ...allValues };
|
|
1352
|
+
} else {
|
|
1353
|
+
const ctxList = isArray(vOpts.contextFields)
|
|
1354
|
+
? vOpts.contextFields
|
|
1355
|
+
: typeof contextAttr === 'string' && contextAttr.trim()
|
|
1356
|
+
? contextAttr.split(',').map((s) => s.trim()).filter(Boolean)
|
|
1357
|
+
: [];
|
|
1358
|
+
for (const cf of ctxList) {
|
|
1359
|
+
if (Object.prototype.hasOwnProperty.call(allValues, cf)) {
|
|
1360
|
+
payload[cf] = allValues[cf];
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
try {
|
|
1366
|
+
const resp = await api.request(method, validateUrl, payload, {
|
|
1367
|
+
headers: { 'X-Webspresso-Validate-Fields': norm },
|
|
1368
|
+
signal: ac.signal,
|
|
1369
|
+
});
|
|
1370
|
+
|
|
1371
|
+
if (seq !== validationSeqByField.get(norm)) {
|
|
1372
|
+
return { ok: true, stale: true, valid: true, errors: {} };
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
const respErrors = resp && resp.errors && typeof resp.errors === 'object' ? resp.errors : {};
|
|
1376
|
+
if (respErrors[norm]) {
|
|
1377
|
+
fieldErrors[norm] = String(respErrors[norm]);
|
|
1378
|
+
} else {
|
|
1379
|
+
delete fieldErrors[norm];
|
|
1380
|
+
}
|
|
1381
|
+
syncErrorsToDom(false);
|
|
1382
|
+
notifyStateChange();
|
|
1383
|
+
return {
|
|
1384
|
+
ok: true,
|
|
1385
|
+
valid: !fieldErrors[norm],
|
|
1386
|
+
errors: fieldErrors[norm] ? { [norm]: fieldErrors[norm] } : {},
|
|
1387
|
+
};
|
|
1388
|
+
} catch (err) {
|
|
1389
|
+
if (seq !== validationSeqByField.get(norm) || (err && err.isCanceled)) {
|
|
1390
|
+
return { ok: true, stale: true, canceled: Boolean(err && err.isCanceled), errors: {} };
|
|
1391
|
+
}
|
|
1392
|
+
// If server returned a 400/422 validation envelope, extract only this field's error
|
|
1393
|
+
if (err && err.kind === 'validation' && err.fieldErrors) {
|
|
1394
|
+
if (err.fieldErrors[norm]) {
|
|
1395
|
+
fieldErrors[norm] = String(err.fieldErrors[norm]);
|
|
1396
|
+
} else {
|
|
1397
|
+
delete fieldErrors[norm];
|
|
1398
|
+
}
|
|
1399
|
+
syncErrorsToDom(false);
|
|
1400
|
+
notifyStateChange();
|
|
1401
|
+
return { ok: true, valid: !fieldErrors[norm], errors: fieldErrors[norm] ? { [norm]: fieldErrors[norm] } : {} };
|
|
1402
|
+
}
|
|
1403
|
+
// Network or 5xx error: NEVER mark the field as invalid!
|
|
1404
|
+
if (typeof formEl.dispatchEvent === 'function' && typeof CustomEvent !== 'undefined') {
|
|
1405
|
+
formEl.dispatchEvent(
|
|
1406
|
+
new CustomEvent('w-form:validation-error', {
|
|
1407
|
+
bubbles: true,
|
|
1408
|
+
detail: { field: norm, error: err },
|
|
1409
|
+
})
|
|
1410
|
+
);
|
|
1411
|
+
}
|
|
1412
|
+
return { ok: false, networkError: true, error: err };
|
|
1413
|
+
} finally {
|
|
1414
|
+
if (validationAbortByField.get(norm) === ac) {
|
|
1415
|
+
validationAbortByField.delete(norm);
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
function cancelUpload() {
|
|
1421
|
+
if (activeSubmitAbort) {
|
|
1422
|
+
try {
|
|
1423
|
+
activeSubmitAbort.abort();
|
|
1424
|
+
} catch (_e) {}
|
|
1425
|
+
activeSubmitAbort = null;
|
|
1426
|
+
return true;
|
|
1427
|
+
}
|
|
1428
|
+
return false;
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
// Listen to native submit, focusout, and input/change events on `formEl`
|
|
1000
1432
|
const onDomInput = (evt) => {
|
|
1433
|
+
inputRevision += 1;
|
|
1001
1434
|
dirty = computeFormDirty(formEl, initialSnapshot);
|
|
1002
1435
|
const target = evt && evt.target;
|
|
1003
1436
|
if (target && target.getAttribute) {
|
|
1437
|
+
const type = (target.type || '').toLowerCase();
|
|
1004
1438
|
const rawName = target.getAttribute('name');
|
|
1439
|
+
if (type === 'file' && evt.type === 'change' && rawName) {
|
|
1440
|
+
revokePreviewUrls(rawName);
|
|
1441
|
+
}
|
|
1005
1442
|
if (rawName) {
|
|
1006
1443
|
const norm = rawName.endsWith('[]') ? rawName.slice(0, -2) : rawName;
|
|
1444
|
+
if (evt.type === 'change' && (type === 'checkbox' || type === 'radio' || target.tagName === 'SELECT')) {
|
|
1445
|
+
touchedFields.add(norm);
|
|
1446
|
+
}
|
|
1007
1447
|
if (fieldErrors[norm] || fieldErrors[rawName]) {
|
|
1008
1448
|
delete fieldErrors[norm];
|
|
1009
1449
|
delete fieldErrors[rawName];
|
|
1010
1450
|
syncErrorsToDom(false);
|
|
1011
1451
|
}
|
|
1452
|
+
if (evt.type === 'input') {
|
|
1453
|
+
const vCfg = parseFieldValidateConfig(target);
|
|
1454
|
+
if (vCfg && vCfg.debounceMs !== null) {
|
|
1455
|
+
const prevTimer = validationTimersByField.get(norm);
|
|
1456
|
+
if (prevTimer) clearTimeout(prevTimer);
|
|
1457
|
+
const t = setTimeout(() => {
|
|
1458
|
+
validationTimersByField.delete(norm);
|
|
1459
|
+
validateField(norm).catch(() => {});
|
|
1460
|
+
}, vCfg.debounceMs);
|
|
1461
|
+
validationTimersByField.set(norm, t);
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1012
1464
|
}
|
|
1013
1465
|
}
|
|
1014
1466
|
if (isDraftEnabled) {
|
|
1015
|
-
|
|
1467
|
+
cancelDraftDebounce();
|
|
1016
1468
|
draftDebounceTimer = setTimeout(() => {
|
|
1469
|
+
draftDebounceTimer = null;
|
|
1017
1470
|
saveDraft();
|
|
1018
1471
|
}, 150);
|
|
1019
1472
|
}
|
|
1020
1473
|
notifyStateChange();
|
|
1021
1474
|
};
|
|
1022
1475
|
|
|
1476
|
+
const onDomFocusOut = (evt) => {
|
|
1477
|
+
const target = evt && evt.target;
|
|
1478
|
+
if (!target || !target.getAttribute) return;
|
|
1479
|
+
const rawName = target.getAttribute('name');
|
|
1480
|
+
if (!rawName) return;
|
|
1481
|
+
const type = (target.type || '').toLowerCase();
|
|
1482
|
+
if (type === 'submit' || type === 'button' || type === 'reset' || type === 'hidden') return;
|
|
1483
|
+
const norm = rawName.endsWith('[]') ? rawName.slice(0, -2) : rawName;
|
|
1484
|
+
const wasTouched = touchedFields.has(norm);
|
|
1485
|
+
touchedFields.add(norm);
|
|
1486
|
+
if (!wasTouched) {
|
|
1487
|
+
syncErrorsToDom(false);
|
|
1488
|
+
notifyStateChange();
|
|
1489
|
+
}
|
|
1490
|
+
const vCfg = parseFieldValidateConfig(target);
|
|
1491
|
+
if (vCfg && vCfg.onBlur) {
|
|
1492
|
+
validateField(norm).catch(() => {});
|
|
1493
|
+
}
|
|
1494
|
+
};
|
|
1495
|
+
|
|
1023
1496
|
const onDomSubmit = (evt) => {
|
|
1024
1497
|
submit(evt);
|
|
1025
1498
|
};
|
|
1026
1499
|
|
|
1027
1500
|
const onDomReset = () => {
|
|
1028
|
-
|
|
1501
|
+
cancelDraftDebounce();
|
|
1502
|
+
cancelFieldValidation();
|
|
1503
|
+
revokePreviewUrls();
|
|
1504
|
+
if (resetTimer) clearTimeout(resetTimer);
|
|
1505
|
+
resetTimer = setTimeout(() => {
|
|
1506
|
+
resetTimer = null;
|
|
1029
1507
|
dirty = false;
|
|
1508
|
+
submitted = false;
|
|
1509
|
+
touchedFields.clear();
|
|
1030
1510
|
fieldErrors = {};
|
|
1031
1511
|
formError = null;
|
|
1032
1512
|
syncErrorsToDom(false);
|
|
@@ -1037,6 +1517,7 @@ function createFormHandle(formEl, options = {}) {
|
|
|
1037
1517
|
if (formEl && typeof formEl.addEventListener === 'function') {
|
|
1038
1518
|
formEl.addEventListener('input', onDomInput);
|
|
1039
1519
|
formEl.addEventListener('change', onDomInput);
|
|
1520
|
+
formEl.addEventListener('focusout', onDomFocusOut);
|
|
1040
1521
|
formEl.addEventListener('submit', onDomSubmit);
|
|
1041
1522
|
formEl.addEventListener('reset', onDomReset);
|
|
1042
1523
|
}
|
|
@@ -1062,9 +1543,66 @@ function createFormHandle(formEl, options = {}) {
|
|
|
1062
1543
|
dirty = computeFormDirty(formEl, initialSnapshot);
|
|
1063
1544
|
return dirty;
|
|
1064
1545
|
},
|
|
1546
|
+
get touched() {
|
|
1547
|
+
return touchedFields.size > 0;
|
|
1548
|
+
},
|
|
1549
|
+
get touchedFields() {
|
|
1550
|
+
return Array.from(touchedFields);
|
|
1551
|
+
},
|
|
1552
|
+
get submitted() {
|
|
1553
|
+
return submitted;
|
|
1554
|
+
},
|
|
1555
|
+
isTouched(fieldName) {
|
|
1556
|
+
if (!fieldName) return touchedFields.size > 0;
|
|
1557
|
+
const norm = fieldName.endsWith('[]') ? fieldName.slice(0, -2) : fieldName;
|
|
1558
|
+
return touchedFields.has(norm) || touchedFields.has(fieldName);
|
|
1559
|
+
},
|
|
1560
|
+
touch(fieldName) {
|
|
1561
|
+
if (fieldName) {
|
|
1562
|
+
const norm = fieldName.endsWith('[]') ? fieldName.slice(0, -2) : fieldName;
|
|
1563
|
+
touchedFields.add(norm);
|
|
1564
|
+
} else if (formEl && typeof formEl.querySelectorAll === 'function') {
|
|
1565
|
+
const controls = formEl.querySelectorAll('input, select, textarea');
|
|
1566
|
+
for (let i = 0; i < controls.length; i++) {
|
|
1567
|
+
const n = controls[i].getAttribute && controls[i].getAttribute('name');
|
|
1568
|
+
if (n) touchedFields.add(n.endsWith('[]') ? n.slice(0, -2) : n);
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
syncErrorsToDom(false);
|
|
1572
|
+
notifyStateChange();
|
|
1573
|
+
return handle;
|
|
1574
|
+
},
|
|
1575
|
+
untouch(fieldName) {
|
|
1576
|
+
if (fieldName) {
|
|
1577
|
+
const norm = fieldName.endsWith('[]') ? fieldName.slice(0, -2) : fieldName;
|
|
1578
|
+
touchedFields.delete(norm);
|
|
1579
|
+
touchedFields.delete(fieldName);
|
|
1580
|
+
} else {
|
|
1581
|
+
touchedFields.clear();
|
|
1582
|
+
}
|
|
1583
|
+
syncErrorsToDom(false);
|
|
1584
|
+
notifyStateChange();
|
|
1585
|
+
return handle;
|
|
1586
|
+
},
|
|
1587
|
+
validateField,
|
|
1588
|
+
get uploads() {
|
|
1589
|
+
return uploadEntries.map((u) => ({ ...u }));
|
|
1590
|
+
},
|
|
1591
|
+
cancelUpload,
|
|
1592
|
+
createPreviewUrl,
|
|
1593
|
+
revokePreviewUrls,
|
|
1594
|
+
get inputRevision() {
|
|
1595
|
+
return inputRevision;
|
|
1596
|
+
},
|
|
1597
|
+
get draftKey() {
|
|
1598
|
+
return draftKey;
|
|
1599
|
+
},
|
|
1065
1600
|
get values() {
|
|
1066
1601
|
return parseFormElement(formEl).data;
|
|
1067
1602
|
},
|
|
1603
|
+
get baseline() {
|
|
1604
|
+
return { ...(baselineValues || {}) };
|
|
1605
|
+
},
|
|
1068
1606
|
get errors() {
|
|
1069
1607
|
return { ...fieldErrors };
|
|
1070
1608
|
},
|
|
@@ -1074,6 +1612,9 @@ function createFormHandle(formEl, options = {}) {
|
|
|
1074
1612
|
get formError() {
|
|
1075
1613
|
return formError;
|
|
1076
1614
|
},
|
|
1615
|
+
get lastError() {
|
|
1616
|
+
return lastError;
|
|
1617
|
+
},
|
|
1077
1618
|
get lastData() {
|
|
1078
1619
|
return lastData;
|
|
1079
1620
|
},
|
|
@@ -1093,6 +1634,7 @@ function createFormHandle(formEl, options = {}) {
|
|
|
1093
1634
|
saveDraft,
|
|
1094
1635
|
restoreDraft,
|
|
1095
1636
|
clearDraft,
|
|
1637
|
+
setDraftKey,
|
|
1096
1638
|
get hasDraft() {
|
|
1097
1639
|
return hasDraft();
|
|
1098
1640
|
},
|
|
@@ -1112,12 +1654,20 @@ function createFormHandle(formEl, options = {}) {
|
|
|
1112
1654
|
clearErrors() {
|
|
1113
1655
|
fieldErrors = {};
|
|
1114
1656
|
formError = null;
|
|
1657
|
+
lastError = null;
|
|
1115
1658
|
syncErrorsToDom(false);
|
|
1116
1659
|
notifyStateChange();
|
|
1117
1660
|
},
|
|
1118
1661
|
reset,
|
|
1119
1662
|
clear,
|
|
1120
1663
|
submit,
|
|
1664
|
+
beforeSubmit(fn) {
|
|
1665
|
+
if (typeof fn === 'function') {
|
|
1666
|
+
beforeSubmitListeners.add(fn);
|
|
1667
|
+
return () => beforeSubmitListeners.delete(fn);
|
|
1668
|
+
}
|
|
1669
|
+
return () => {};
|
|
1670
|
+
},
|
|
1121
1671
|
onSuccess(fn) {
|
|
1122
1672
|
if (typeof fn === 'function') {
|
|
1123
1673
|
successListeners.add(fn);
|
|
@@ -1140,15 +1690,25 @@ function createFormHandle(formEl, options = {}) {
|
|
|
1140
1690
|
return () => {};
|
|
1141
1691
|
},
|
|
1142
1692
|
destroy() {
|
|
1693
|
+
cancelDraftDebounce();
|
|
1694
|
+
cancelFieldValidation();
|
|
1695
|
+
cancelUpload();
|
|
1696
|
+
revokePreviewUrls();
|
|
1697
|
+
if (resetTimer) {
|
|
1698
|
+
clearTimeout(resetTimer);
|
|
1699
|
+
resetTimer = null;
|
|
1700
|
+
}
|
|
1143
1701
|
if (formEl && typeof formEl.removeEventListener === 'function') {
|
|
1144
1702
|
formEl.removeEventListener('input', onDomInput);
|
|
1145
1703
|
formEl.removeEventListener('change', onDomInput);
|
|
1704
|
+
formEl.removeEventListener('focusout', onDomFocusOut);
|
|
1146
1705
|
formEl.removeEventListener('submit', onDomSubmit);
|
|
1147
1706
|
formEl.removeEventListener('reset', onDomReset);
|
|
1148
1707
|
}
|
|
1149
1708
|
if (formEl) {
|
|
1150
1709
|
delete formEl.__wFormHandle;
|
|
1151
1710
|
}
|
|
1711
|
+
beforeSubmitListeners.clear();
|
|
1152
1712
|
successListeners.clear();
|
|
1153
1713
|
errorListeners.clear();
|
|
1154
1714
|
stateListeners.clear();
|