webspresso 0.0.112 → 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 +587 -26
- package/core/client/api.js +147 -7
- package/core/client/controller.js +134 -11
- package/core/client/devtool.js +59 -33
- package/core/client/forms.js +475 -29
- package/core/client/fragments.js +397 -25
- package/core/client/index.js +13 -1
- package/core/client/server-fragments.js +102 -36
- 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 +98 -1
- 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
|
/**
|
|
@@ -435,6 +450,7 @@ function createFormHandle(formEl, options = {}) {
|
|
|
435
450
|
let baselineValues = parseFormElement(formEl).data;
|
|
436
451
|
let pending = false;
|
|
437
452
|
let dirty = false;
|
|
453
|
+
let submitted = false;
|
|
438
454
|
let inputRevision = 0;
|
|
439
455
|
let fieldErrors = {};
|
|
440
456
|
let formError = null;
|
|
@@ -443,6 +459,83 @@ function createFormHandle(formEl, options = {}) {
|
|
|
443
459
|
let draftDebounceTimer = null;
|
|
444
460
|
let resetTimer = null;
|
|
445
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
|
+
}
|
|
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
|
+
|
|
446
539
|
const beforeSubmitListeners = new Set();
|
|
447
540
|
const successListeners = new Set();
|
|
448
541
|
const errorListeners = new Set();
|
|
@@ -591,6 +684,12 @@ function createFormHandle(formEl, options = {}) {
|
|
|
591
684
|
}
|
|
592
685
|
}
|
|
593
686
|
|
|
687
|
+
function isFieldErrorVisible(fieldName) {
|
|
688
|
+
if (errorDisplayMode !== 'touched') return true;
|
|
689
|
+
if (submitted) return true;
|
|
690
|
+
return touchedFields.has(fieldName);
|
|
691
|
+
}
|
|
692
|
+
|
|
594
693
|
/**
|
|
595
694
|
* Render field errors and form-level error into the DOM with accessible ARIA bindings.
|
|
596
695
|
* Optionally focuses the first invalid control.
|
|
@@ -603,7 +702,11 @@ function createFormHandle(formEl, options = {}) {
|
|
|
603
702
|
for (let i = 0; i < errorSpans.length; i++) {
|
|
604
703
|
const span = errorSpans[i];
|
|
605
704
|
const fieldName = span.getAttribute('data-w-error');
|
|
606
|
-
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 : '';
|
|
607
710
|
span.textContent = msg;
|
|
608
711
|
if (msg) {
|
|
609
712
|
span.removeAttribute('hidden');
|
|
@@ -626,7 +729,8 @@ function createFormHandle(formEl, options = {}) {
|
|
|
626
729
|
const rawName = ctrl.getAttribute('name');
|
|
627
730
|
if (!rawName) continue;
|
|
628
731
|
const normName = rawName.endsWith('[]') ? rawName.slice(0, -2) : rawName;
|
|
629
|
-
const
|
|
732
|
+
const rawErrMsg = fieldErrors[normName] || fieldErrors[rawName];
|
|
733
|
+
const errMsg = rawErrMsg && (isFieldErrorVisible(normName) || isFieldErrorVisible(rawName)) ? rawErrMsg : '';
|
|
630
734
|
|
|
631
735
|
if (errMsg) {
|
|
632
736
|
ctrl.setAttribute('aria-invalid', 'true');
|
|
@@ -711,6 +815,8 @@ function createFormHandle(formEl, options = {}) {
|
|
|
711
815
|
*/
|
|
712
816
|
function reset() {
|
|
713
817
|
cancelDraftDebounce();
|
|
818
|
+
cancelFieldValidation();
|
|
819
|
+
revokePreviewUrls();
|
|
714
820
|
if (!formEl) return;
|
|
715
821
|
if (typeof formEl.reset === 'function') {
|
|
716
822
|
formEl.reset();
|
|
@@ -730,6 +836,8 @@ function createFormHandle(formEl, options = {}) {
|
|
|
730
836
|
}
|
|
731
837
|
}
|
|
732
838
|
dirty = false;
|
|
839
|
+
submitted = false;
|
|
840
|
+
touchedFields.clear();
|
|
733
841
|
fieldErrors = {};
|
|
734
842
|
formError = null;
|
|
735
843
|
syncErrorsToDom(false);
|
|
@@ -741,6 +849,8 @@ function createFormHandle(formEl, options = {}) {
|
|
|
741
849
|
*/
|
|
742
850
|
function clear() {
|
|
743
851
|
cancelDraftDebounce();
|
|
852
|
+
cancelFieldValidation();
|
|
853
|
+
revokePreviewUrls();
|
|
744
854
|
if (!formEl || typeof formEl.querySelectorAll !== 'function') return;
|
|
745
855
|
const controls = formEl.querySelectorAll('input, select, textarea');
|
|
746
856
|
for (let i = 0; i < controls.length; i++) {
|
|
@@ -762,6 +872,8 @@ function createFormHandle(formEl, options = {}) {
|
|
|
762
872
|
}
|
|
763
873
|
}
|
|
764
874
|
dirty = computeFormDirty(formEl, initialSnapshot);
|
|
875
|
+
submitted = false;
|
|
876
|
+
touchedFields.clear();
|
|
765
877
|
fieldErrors = {};
|
|
766
878
|
formError = null;
|
|
767
879
|
syncErrorsToDom(false);
|
|
@@ -829,9 +941,12 @@ function createFormHandle(formEl, options = {}) {
|
|
|
829
941
|
}
|
|
830
942
|
|
|
831
943
|
if (isBaseline) {
|
|
944
|
+
cancelFieldValidation();
|
|
832
945
|
initialSnapshot = captureInitialFormSnapshot(formEl);
|
|
833
946
|
baselineValues = parseFormElement(formEl).data;
|
|
834
947
|
dirty = false;
|
|
948
|
+
submitted = false;
|
|
949
|
+
touchedFields.clear();
|
|
835
950
|
fieldErrors = {};
|
|
836
951
|
formError = null;
|
|
837
952
|
lastError = null;
|
|
@@ -950,13 +1065,47 @@ function createFormHandle(formEl, options = {}) {
|
|
|
950
1065
|
}
|
|
951
1066
|
}
|
|
952
1067
|
|
|
953
|
-
// Clear previous errors before request starts
|
|
1068
|
+
// Clear previous errors before request starts, mark submitted = true
|
|
1069
|
+
cancelFieldValidation();
|
|
1070
|
+
submitted = true;
|
|
954
1071
|
fieldErrors = {};
|
|
955
1072
|
formError = null;
|
|
956
1073
|
lastError = null;
|
|
957
1074
|
syncErrorsToDom(false);
|
|
1075
|
+
const submitRevision = inputRevision;
|
|
1076
|
+
const submittedSnapshot = captureInitialFormSnapshot(formEl);
|
|
958
1077
|
setPendingState(true, submitter);
|
|
959
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
|
+
|
|
960
1109
|
try {
|
|
961
1110
|
let responseData;
|
|
962
1111
|
const reqKey = `form:${formName}:${method}:${actionUrl}`;
|
|
@@ -964,17 +1113,39 @@ function createFormHandle(formEl, options = {}) {
|
|
|
964
1113
|
responseData = await api.get(actionUrl, payloadData, {
|
|
965
1114
|
key: reqKey,
|
|
966
1115
|
policy: 'latest',
|
|
1116
|
+
signal: submitAbortController.signal,
|
|
967
1117
|
});
|
|
968
1118
|
} else {
|
|
969
1119
|
const bodyToSend = isMultipart && formData ? formData : payloadData;
|
|
970
|
-
|
|
1120
|
+
const reqOpts = {
|
|
971
1121
|
key: reqKey,
|
|
972
1122
|
policy: 'drop',
|
|
973
|
-
|
|
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
|
+
}
|
|
974
1146
|
}
|
|
975
1147
|
|
|
976
1148
|
lastData = responseData;
|
|
977
|
-
dirty = false;
|
|
978
1149
|
fieldErrors = {};
|
|
979
1150
|
formError = null;
|
|
980
1151
|
syncErrorsToDom(false);
|
|
@@ -995,18 +1166,51 @@ function createFormHandle(formEl, options = {}) {
|
|
|
995
1166
|
clearAttr === 'true' ||
|
|
996
1167
|
clearAttr === '1';
|
|
997
1168
|
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1169
|
+
const editedDuringSubmit = inputRevision !== submitRevision;
|
|
1170
|
+
|
|
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
|
+
}
|
|
1007
1182
|
|
|
1008
|
-
|
|
1009
|
-
|
|
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
|
+
}
|
|
1010
1214
|
}
|
|
1011
1215
|
|
|
1012
1216
|
// Invoke registered success hooks. Note: Any background fragment refresh failure
|
|
@@ -1025,11 +1229,12 @@ function createFormHandle(formEl, options = {}) {
|
|
|
1025
1229
|
}
|
|
1026
1230
|
|
|
1027
1231
|
// Explicit redirect behavior
|
|
1028
|
-
const
|
|
1232
|
+
const rawRedirectTarget =
|
|
1029
1233
|
submitOptions.redirectOnSuccess ||
|
|
1030
1234
|
(formEl && formEl.getAttribute('data-w-redirect')) ||
|
|
1031
1235
|
null;
|
|
1032
|
-
|
|
1236
|
+
const redirectTarget = resolveSafeRedirectUrl(rawRedirectTarget);
|
|
1237
|
+
if (redirectTarget) {
|
|
1033
1238
|
if (typeof options.navigate === 'function') {
|
|
1034
1239
|
options.navigate(redirectTarget);
|
|
1035
1240
|
} else if (typeof window !== 'undefined' && window.location) {
|
|
@@ -1050,6 +1255,15 @@ function createFormHandle(formEl, options = {}) {
|
|
|
1050
1255
|
return { ok: true, submitted: true, data: responseData, refreshOk, refreshError, hookError };
|
|
1051
1256
|
} catch (err) {
|
|
1052
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
|
+
}
|
|
1053
1267
|
if (err && err.kind === 'validation') {
|
|
1054
1268
|
fieldErrors = { ...(err.fieldErrors || {}) };
|
|
1055
1269
|
formError = err.formError || (Object.keys(fieldErrors).length === 0 ? err.message : null);
|
|
@@ -1072,26 +1286,181 @@ function createFormHandle(formEl, options = {}) {
|
|
|
1072
1286
|
if (submitOptions.throwOnError) {
|
|
1073
1287
|
throw err;
|
|
1074
1288
|
}
|
|
1075
|
-
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
|
+
};
|
|
1076
1296
|
} finally {
|
|
1297
|
+
if (activeSubmitAbort === submitAbortController) {
|
|
1298
|
+
activeSubmitAbort = null;
|
|
1299
|
+
}
|
|
1077
1300
|
setPendingState(false, submitter);
|
|
1078
1301
|
}
|
|
1079
1302
|
}
|
|
1080
1303
|
|
|
1081
|
-
|
|
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`
|
|
1082
1432
|
const onDomInput = (evt) => {
|
|
1083
1433
|
inputRevision += 1;
|
|
1084
1434
|
dirty = computeFormDirty(formEl, initialSnapshot);
|
|
1085
1435
|
const target = evt && evt.target;
|
|
1086
1436
|
if (target && target.getAttribute) {
|
|
1437
|
+
const type = (target.type || '').toLowerCase();
|
|
1087
1438
|
const rawName = target.getAttribute('name');
|
|
1439
|
+
if (type === 'file' && evt.type === 'change' && rawName) {
|
|
1440
|
+
revokePreviewUrls(rawName);
|
|
1441
|
+
}
|
|
1088
1442
|
if (rawName) {
|
|
1089
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
|
+
}
|
|
1090
1447
|
if (fieldErrors[norm] || fieldErrors[rawName]) {
|
|
1091
1448
|
delete fieldErrors[norm];
|
|
1092
1449
|
delete fieldErrors[rawName];
|
|
1093
1450
|
syncErrorsToDom(false);
|
|
1094
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
|
+
}
|
|
1095
1464
|
}
|
|
1096
1465
|
}
|
|
1097
1466
|
if (isDraftEnabled) {
|
|
@@ -1104,16 +1473,40 @@ function createFormHandle(formEl, options = {}) {
|
|
|
1104
1473
|
notifyStateChange();
|
|
1105
1474
|
};
|
|
1106
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
|
+
|
|
1107
1496
|
const onDomSubmit = (evt) => {
|
|
1108
1497
|
submit(evt);
|
|
1109
1498
|
};
|
|
1110
1499
|
|
|
1111
1500
|
const onDomReset = () => {
|
|
1112
1501
|
cancelDraftDebounce();
|
|
1502
|
+
cancelFieldValidation();
|
|
1503
|
+
revokePreviewUrls();
|
|
1113
1504
|
if (resetTimer) clearTimeout(resetTimer);
|
|
1114
1505
|
resetTimer = setTimeout(() => {
|
|
1115
1506
|
resetTimer = null;
|
|
1116
1507
|
dirty = false;
|
|
1508
|
+
submitted = false;
|
|
1509
|
+
touchedFields.clear();
|
|
1117
1510
|
fieldErrors = {};
|
|
1118
1511
|
formError = null;
|
|
1119
1512
|
syncErrorsToDom(false);
|
|
@@ -1124,6 +1517,7 @@ function createFormHandle(formEl, options = {}) {
|
|
|
1124
1517
|
if (formEl && typeof formEl.addEventListener === 'function') {
|
|
1125
1518
|
formEl.addEventListener('input', onDomInput);
|
|
1126
1519
|
formEl.addEventListener('change', onDomInput);
|
|
1520
|
+
formEl.addEventListener('focusout', onDomFocusOut);
|
|
1127
1521
|
formEl.addEventListener('submit', onDomSubmit);
|
|
1128
1522
|
formEl.addEventListener('reset', onDomReset);
|
|
1129
1523
|
}
|
|
@@ -1149,6 +1543,54 @@ function createFormHandle(formEl, options = {}) {
|
|
|
1149
1543
|
dirty = computeFormDirty(formEl, initialSnapshot);
|
|
1150
1544
|
return dirty;
|
|
1151
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,
|
|
1152
1594
|
get inputRevision() {
|
|
1153
1595
|
return inputRevision;
|
|
1154
1596
|
},
|
|
@@ -1249,6 +1691,9 @@ function createFormHandle(formEl, options = {}) {
|
|
|
1249
1691
|
},
|
|
1250
1692
|
destroy() {
|
|
1251
1693
|
cancelDraftDebounce();
|
|
1694
|
+
cancelFieldValidation();
|
|
1695
|
+
cancelUpload();
|
|
1696
|
+
revokePreviewUrls();
|
|
1252
1697
|
if (resetTimer) {
|
|
1253
1698
|
clearTimeout(resetTimer);
|
|
1254
1699
|
resetTimer = null;
|
|
@@ -1256,6 +1701,7 @@ function createFormHandle(formEl, options = {}) {
|
|
|
1256
1701
|
if (formEl && typeof formEl.removeEventListener === 'function') {
|
|
1257
1702
|
formEl.removeEventListener('input', onDomInput);
|
|
1258
1703
|
formEl.removeEventListener('change', onDomInput);
|
|
1704
|
+
formEl.removeEventListener('focusout', onDomFocusOut);
|
|
1259
1705
|
formEl.removeEventListener('submit', onDomSubmit);
|
|
1260
1706
|
formEl.removeEventListener('reset', onDomReset);
|
|
1261
1707
|
}
|