webspresso 0.0.112 → 0.0.114
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/README.md +26 -0
- package/bin/commands/admin-password.js +1 -7
- package/bin/commands/api.js +1 -7
- package/bin/commands/new.js +1 -7
- package/bin/commands/page.js +1 -7
- package/bin/commands/seed.js +1 -7
- package/bin/commands/service.js +1 -7
- package/bin/utils/prompt.js +17 -0
- package/core/cache/tagged-lru.js +9 -3
- package/core/client/adapter/datastar.js +602 -26
- package/core/client/api.js +151 -7
- package/core/client/controller.js +172 -11
- package/core/client/devtool.js +59 -33
- package/core/client/forms.js +479 -29
- package/core/client/fragments.js +456 -27
- package/core/client/index.js +18 -1
- package/core/client/server-fragments.js +125 -40
- package/core/client/utils.js +72 -17
- package/core/console/commands.js +26 -5
- package/core/console/repl.js +7 -1
- 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/plugins/plugin-manager.js +155 -1
- 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 +113 -1
- package/package.json +12 -11
- 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 +140 -6
- package/src/dev/api-typegen.js +79 -22
- package/src/dev/typegen.js +61 -35
- package/src/dev-reload/index.js +44 -2
- package/src/file-router.js +24 -6
- package/src/routing/mount-discovered-routes.js +24 -6
- package/src/server.js +59 -3
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,86 @@ 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
|
+
let validationEpoch = 0;
|
|
473
|
+
|
|
474
|
+
let activeSubmitAbort = null;
|
|
475
|
+
let uploadEntries = [];
|
|
476
|
+
const previewUrlsByField = new Map();
|
|
477
|
+
|
|
478
|
+
function cancelFieldValidation(fieldName) {
|
|
479
|
+
if (fieldName) {
|
|
480
|
+
validationSeqByField.set(fieldName, (validationSeqByField.get(fieldName) || 0) + 1);
|
|
481
|
+
const t = validationTimersByField.get(fieldName);
|
|
482
|
+
if (t) {
|
|
483
|
+
clearTimeout(t);
|
|
484
|
+
validationTimersByField.delete(fieldName);
|
|
485
|
+
}
|
|
486
|
+
const ac = validationAbortByField.get(fieldName);
|
|
487
|
+
if (ac) {
|
|
488
|
+
try {
|
|
489
|
+
ac.abort();
|
|
490
|
+
} catch (_e) {}
|
|
491
|
+
validationAbortByField.delete(fieldName);
|
|
492
|
+
}
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
validationEpoch += 1;
|
|
496
|
+
for (const t of validationTimersByField.values()) clearTimeout(t);
|
|
497
|
+
validationTimersByField.clear();
|
|
498
|
+
for (const ac of validationAbortByField.values()) {
|
|
499
|
+
try {
|
|
500
|
+
ac.abort();
|
|
501
|
+
} catch (_e) {}
|
|
502
|
+
}
|
|
503
|
+
validationAbortByField.clear();
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function revokePreviewUrls(fieldName) {
|
|
507
|
+
const revokeUrl = (u) => {
|
|
508
|
+
if (typeof URL !== 'undefined' && typeof URL.revokeObjectURL === 'function') {
|
|
509
|
+
try {
|
|
510
|
+
URL.revokeObjectURL(u);
|
|
511
|
+
} catch (_e) {}
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
if (fieldName) {
|
|
515
|
+
const set = previewUrlsByField.get(fieldName);
|
|
516
|
+
if (set) {
|
|
517
|
+
for (const u of set) revokeUrl(u);
|
|
518
|
+
set.clear();
|
|
519
|
+
previewUrlsByField.delete(fieldName);
|
|
520
|
+
}
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
for (const set of previewUrlsByField.values()) {
|
|
524
|
+
for (const u of set) revokeUrl(u);
|
|
525
|
+
set.clear();
|
|
526
|
+
}
|
|
527
|
+
previewUrlsByField.clear();
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function createPreviewUrl(file, fieldName = '_default') {
|
|
531
|
+
if (!file || typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function') return null;
|
|
532
|
+
const url = URL.createObjectURL(file);
|
|
533
|
+
let set = previewUrlsByField.get(fieldName);
|
|
534
|
+
if (!set) {
|
|
535
|
+
set = new Set();
|
|
536
|
+
previewUrlsByField.set(fieldName, set);
|
|
537
|
+
}
|
|
538
|
+
set.add(url);
|
|
539
|
+
return url;
|
|
540
|
+
}
|
|
541
|
+
|
|
446
542
|
const beforeSubmitListeners = new Set();
|
|
447
543
|
const successListeners = new Set();
|
|
448
544
|
const errorListeners = new Set();
|
|
@@ -591,6 +687,12 @@ function createFormHandle(formEl, options = {}) {
|
|
|
591
687
|
}
|
|
592
688
|
}
|
|
593
689
|
|
|
690
|
+
function isFieldErrorVisible(fieldName) {
|
|
691
|
+
if (errorDisplayMode !== 'touched') return true;
|
|
692
|
+
if (submitted) return true;
|
|
693
|
+
return touchedFields.has(fieldName);
|
|
694
|
+
}
|
|
695
|
+
|
|
594
696
|
/**
|
|
595
697
|
* Render field errors and form-level error into the DOM with accessible ARIA bindings.
|
|
596
698
|
* Optionally focuses the first invalid control.
|
|
@@ -603,7 +705,11 @@ function createFormHandle(formEl, options = {}) {
|
|
|
603
705
|
for (let i = 0; i < errorSpans.length; i++) {
|
|
604
706
|
const span = errorSpans[i];
|
|
605
707
|
const fieldName = span.getAttribute('data-w-error');
|
|
606
|
-
const
|
|
708
|
+
const normName = fieldName && fieldName.endsWith('[]') ? fieldName.slice(0, -2) : fieldName;
|
|
709
|
+
const rawMsg = fieldName && (fieldErrors[fieldName] || (normName && fieldErrors[normName]))
|
|
710
|
+
? String(fieldErrors[fieldName] || fieldErrors[normName])
|
|
711
|
+
: '';
|
|
712
|
+
const msg = rawMsg && (isFieldErrorVisible(fieldName) || (normName && isFieldErrorVisible(normName))) ? rawMsg : '';
|
|
607
713
|
span.textContent = msg;
|
|
608
714
|
if (msg) {
|
|
609
715
|
span.removeAttribute('hidden');
|
|
@@ -626,7 +732,8 @@ function createFormHandle(formEl, options = {}) {
|
|
|
626
732
|
const rawName = ctrl.getAttribute('name');
|
|
627
733
|
if (!rawName) continue;
|
|
628
734
|
const normName = rawName.endsWith('[]') ? rawName.slice(0, -2) : rawName;
|
|
629
|
-
const
|
|
735
|
+
const rawErrMsg = fieldErrors[normName] || fieldErrors[rawName];
|
|
736
|
+
const errMsg = rawErrMsg && (isFieldErrorVisible(normName) || isFieldErrorVisible(rawName)) ? rawErrMsg : '';
|
|
630
737
|
|
|
631
738
|
if (errMsg) {
|
|
632
739
|
ctrl.setAttribute('aria-invalid', 'true');
|
|
@@ -711,6 +818,8 @@ function createFormHandle(formEl, options = {}) {
|
|
|
711
818
|
*/
|
|
712
819
|
function reset() {
|
|
713
820
|
cancelDraftDebounce();
|
|
821
|
+
cancelFieldValidation();
|
|
822
|
+
revokePreviewUrls();
|
|
714
823
|
if (!formEl) return;
|
|
715
824
|
if (typeof formEl.reset === 'function') {
|
|
716
825
|
formEl.reset();
|
|
@@ -730,6 +839,8 @@ function createFormHandle(formEl, options = {}) {
|
|
|
730
839
|
}
|
|
731
840
|
}
|
|
732
841
|
dirty = false;
|
|
842
|
+
submitted = false;
|
|
843
|
+
touchedFields.clear();
|
|
733
844
|
fieldErrors = {};
|
|
734
845
|
formError = null;
|
|
735
846
|
syncErrorsToDom(false);
|
|
@@ -741,6 +852,8 @@ function createFormHandle(formEl, options = {}) {
|
|
|
741
852
|
*/
|
|
742
853
|
function clear() {
|
|
743
854
|
cancelDraftDebounce();
|
|
855
|
+
cancelFieldValidation();
|
|
856
|
+
revokePreviewUrls();
|
|
744
857
|
if (!formEl || typeof formEl.querySelectorAll !== 'function') return;
|
|
745
858
|
const controls = formEl.querySelectorAll('input, select, textarea');
|
|
746
859
|
for (let i = 0; i < controls.length; i++) {
|
|
@@ -762,6 +875,8 @@ function createFormHandle(formEl, options = {}) {
|
|
|
762
875
|
}
|
|
763
876
|
}
|
|
764
877
|
dirty = computeFormDirty(formEl, initialSnapshot);
|
|
878
|
+
submitted = false;
|
|
879
|
+
touchedFields.clear();
|
|
765
880
|
fieldErrors = {};
|
|
766
881
|
formError = null;
|
|
767
882
|
syncErrorsToDom(false);
|
|
@@ -829,9 +944,12 @@ function createFormHandle(formEl, options = {}) {
|
|
|
829
944
|
}
|
|
830
945
|
|
|
831
946
|
if (isBaseline) {
|
|
947
|
+
cancelFieldValidation();
|
|
832
948
|
initialSnapshot = captureInitialFormSnapshot(formEl);
|
|
833
949
|
baselineValues = parseFormElement(formEl).data;
|
|
834
950
|
dirty = false;
|
|
951
|
+
submitted = false;
|
|
952
|
+
touchedFields.clear();
|
|
835
953
|
fieldErrors = {};
|
|
836
954
|
formError = null;
|
|
837
955
|
lastError = null;
|
|
@@ -950,13 +1068,47 @@ function createFormHandle(formEl, options = {}) {
|
|
|
950
1068
|
}
|
|
951
1069
|
}
|
|
952
1070
|
|
|
953
|
-
// Clear previous errors before request starts
|
|
1071
|
+
// Clear previous errors before request starts, mark submitted = true
|
|
1072
|
+
cancelFieldValidation();
|
|
1073
|
+
submitted = true;
|
|
954
1074
|
fieldErrors = {};
|
|
955
1075
|
formError = null;
|
|
956
1076
|
lastError = null;
|
|
957
1077
|
syncErrorsToDom(false);
|
|
1078
|
+
const submitRevision = inputRevision;
|
|
1079
|
+
const submittedSnapshot = captureInitialFormSnapshot(formEl);
|
|
958
1080
|
setPendingState(true, submitter);
|
|
959
1081
|
|
|
1082
|
+
// Track per-file upload state if multipart
|
|
1083
|
+
const submitAbortController = new AbortController();
|
|
1084
|
+
activeSubmitAbort = submitAbortController;
|
|
1085
|
+
if (isMultipart && formEl && typeof formEl.querySelectorAll === 'function') {
|
|
1086
|
+
const nextUploads = [];
|
|
1087
|
+
const fileInputs = formEl.querySelectorAll('input[type="file"]');
|
|
1088
|
+
for (let i = 0; i < fileInputs.length; i++) {
|
|
1089
|
+
const fi = fileInputs[i];
|
|
1090
|
+
const fName = fi.getAttribute('name') || 'file';
|
|
1091
|
+
const files = fi.files || [];
|
|
1092
|
+
for (let j = 0; j < files.length; j++) {
|
|
1093
|
+
const f = files[j];
|
|
1094
|
+
nextUploads.push({
|
|
1095
|
+
field: fName,
|
|
1096
|
+
name: f.name || 'file',
|
|
1097
|
+
size: typeof f.size === 'number' ? f.size : 0,
|
|
1098
|
+
status: 'uploading',
|
|
1099
|
+
loaded: 0,
|
|
1100
|
+
total: typeof f.size === 'number' ? f.size : 0,
|
|
1101
|
+
percent: null,
|
|
1102
|
+
error: null,
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
uploadEntries = nextUploads;
|
|
1107
|
+
if (uploadEntries.length > 0) notifyStateChange();
|
|
1108
|
+
} else {
|
|
1109
|
+
uploadEntries = [];
|
|
1110
|
+
}
|
|
1111
|
+
|
|
960
1112
|
try {
|
|
961
1113
|
let responseData;
|
|
962
1114
|
const reqKey = `form:${formName}:${method}:${actionUrl}`;
|
|
@@ -964,17 +1116,39 @@ function createFormHandle(formEl, options = {}) {
|
|
|
964
1116
|
responseData = await api.get(actionUrl, payloadData, {
|
|
965
1117
|
key: reqKey,
|
|
966
1118
|
policy: 'latest',
|
|
1119
|
+
signal: submitAbortController.signal,
|
|
967
1120
|
});
|
|
968
1121
|
} else {
|
|
969
1122
|
const bodyToSend = isMultipart && formData ? formData : payloadData;
|
|
970
|
-
|
|
1123
|
+
const reqOpts = {
|
|
971
1124
|
key: reqKey,
|
|
972
1125
|
policy: 'drop',
|
|
973
|
-
|
|
1126
|
+
signal: submitAbortController.signal,
|
|
1127
|
+
};
|
|
1128
|
+
if (isMultipart && uploadEntries.length > 0) {
|
|
1129
|
+
reqOpts.onUploadProgress = (prog) => {
|
|
1130
|
+
for (let i = 0; i < uploadEntries.length; i++) {
|
|
1131
|
+
uploadEntries[i].loaded = prog.loaded;
|
|
1132
|
+
uploadEntries[i].total = prog.total || uploadEntries[i].total;
|
|
1133
|
+
uploadEntries[i].percent = prog.percent;
|
|
1134
|
+
}
|
|
1135
|
+
if (typeof submitOptions.onUploadProgress === 'function') {
|
|
1136
|
+
submitOptions.onUploadProgress(prog, uploadEntries);
|
|
1137
|
+
}
|
|
1138
|
+
notifyStateChange();
|
|
1139
|
+
};
|
|
1140
|
+
}
|
|
1141
|
+
responseData = await api.request(method, actionUrl, bodyToSend, reqOpts);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
if (uploadEntries.length > 0) {
|
|
1145
|
+
for (let i = 0; i < uploadEntries.length; i++) {
|
|
1146
|
+
uploadEntries[i].status = 'done';
|
|
1147
|
+
if (uploadEntries[i].percent !== null) uploadEntries[i].percent = 100;
|
|
1148
|
+
}
|
|
974
1149
|
}
|
|
975
1150
|
|
|
976
1151
|
lastData = responseData;
|
|
977
|
-
dirty = false;
|
|
978
1152
|
fieldErrors = {};
|
|
979
1153
|
formError = null;
|
|
980
1154
|
syncErrorsToDom(false);
|
|
@@ -995,18 +1169,51 @@ function createFormHandle(formEl, options = {}) {
|
|
|
995
1169
|
clearAttr === 'true' ||
|
|
996
1170
|
clearAttr === '1';
|
|
997
1171
|
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1172
|
+
const editedDuringSubmit = inputRevision !== submitRevision;
|
|
1173
|
+
|
|
1174
|
+
if (!editedDuringSubmit) {
|
|
1175
|
+
dirty = false;
|
|
1176
|
+
if (shouldClear) {
|
|
1177
|
+
clear();
|
|
1178
|
+
} else if (shouldReset) {
|
|
1179
|
+
reset();
|
|
1180
|
+
} else {
|
|
1181
|
+
// Current state becomes the new clean baseline after successful save
|
|
1182
|
+
initialSnapshot = captureInitialFormSnapshot(formEl);
|
|
1183
|
+
baselineValues = parseFormElement(formEl).data;
|
|
1184
|
+
}
|
|
1007
1185
|
|
|
1008
|
-
|
|
1009
|
-
|
|
1186
|
+
if (isDraftEnabled) {
|
|
1187
|
+
clearDraft();
|
|
1188
|
+
}
|
|
1189
|
+
} else {
|
|
1190
|
+
// User edited form controls while submit was in flight:
|
|
1191
|
+
// Never wipe out the new unsubmitted edits via reset() or clear().
|
|
1192
|
+
// Update baseline to what was actually committed on the server (or empty baseline if clearOnSuccess).
|
|
1193
|
+
if (shouldClear) {
|
|
1194
|
+
const clearedSnapshot = new Map();
|
|
1195
|
+
for (const [ctrl, snap] of submittedSnapshot.entries()) {
|
|
1196
|
+
clearedSnapshot.set(ctrl, {
|
|
1197
|
+
...snap,
|
|
1198
|
+
checked: false,
|
|
1199
|
+
value: '',
|
|
1200
|
+
selectedValues: [],
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
1203
|
+
initialSnapshot = clearedSnapshot;
|
|
1204
|
+
baselineValues = {};
|
|
1205
|
+
} else if (!shouldReset) {
|
|
1206
|
+
initialSnapshot = submittedSnapshot;
|
|
1207
|
+
baselineValues = payloadData && typeof payloadData === 'object' ? { ...payloadData } : {};
|
|
1208
|
+
}
|
|
1209
|
+
dirty = computeFormDirty(formEl, initialSnapshot);
|
|
1210
|
+
if (isDraftEnabled) {
|
|
1211
|
+
if (dirty) {
|
|
1212
|
+
saveDraft();
|
|
1213
|
+
} else {
|
|
1214
|
+
clearDraft();
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1010
1217
|
}
|
|
1011
1218
|
|
|
1012
1219
|
// Invoke registered success hooks. Note: Any background fragment refresh failure
|
|
@@ -1025,11 +1232,12 @@ function createFormHandle(formEl, options = {}) {
|
|
|
1025
1232
|
}
|
|
1026
1233
|
|
|
1027
1234
|
// Explicit redirect behavior
|
|
1028
|
-
const
|
|
1235
|
+
const rawRedirectTarget =
|
|
1029
1236
|
submitOptions.redirectOnSuccess ||
|
|
1030
1237
|
(formEl && formEl.getAttribute('data-w-redirect')) ||
|
|
1031
1238
|
null;
|
|
1032
|
-
|
|
1239
|
+
const redirectTarget = resolveSafeRedirectUrl(rawRedirectTarget);
|
|
1240
|
+
if (redirectTarget) {
|
|
1033
1241
|
if (typeof options.navigate === 'function') {
|
|
1034
1242
|
options.navigate(redirectTarget);
|
|
1035
1243
|
} else if (typeof window !== 'undefined' && window.location) {
|
|
@@ -1050,6 +1258,15 @@ function createFormHandle(formEl, options = {}) {
|
|
|
1050
1258
|
return { ok: true, submitted: true, data: responseData, refreshOk, refreshError, hookError };
|
|
1051
1259
|
} catch (err) {
|
|
1052
1260
|
lastError = err || null;
|
|
1261
|
+
if (uploadEntries.length > 0) {
|
|
1262
|
+
const isCanceled = Boolean(err && err.isCanceled);
|
|
1263
|
+
for (let i = 0; i < uploadEntries.length; i++) {
|
|
1264
|
+
if (uploadEntries[i].status === 'uploading') {
|
|
1265
|
+
uploadEntries[i].status = isCanceled ? 'canceled' : 'error';
|
|
1266
|
+
uploadEntries[i].error = isCanceled ? null : (err && err.message ? err.message : 'Upload failed');
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1053
1270
|
if (err && err.kind === 'validation') {
|
|
1054
1271
|
fieldErrors = { ...(err.fieldErrors || {}) };
|
|
1055
1272
|
formError = err.formError || (Object.keys(fieldErrors).length === 0 ? err.message : null);
|
|
@@ -1072,26 +1289,182 @@ function createFormHandle(formEl, options = {}) {
|
|
|
1072
1289
|
if (submitOptions.throwOnError) {
|
|
1073
1290
|
throw err;
|
|
1074
1291
|
}
|
|
1075
|
-
return {
|
|
1292
|
+
return {
|
|
1293
|
+
ok: false,
|
|
1294
|
+
submitted: false,
|
|
1295
|
+
outcomeUnknown: Boolean(err && err.outcomeUnknown),
|
|
1296
|
+
outcome: (err && err.outcome) || 'failed',
|
|
1297
|
+
error: err,
|
|
1298
|
+
};
|
|
1076
1299
|
} finally {
|
|
1300
|
+
if (activeSubmitAbort === submitAbortController) {
|
|
1301
|
+
activeSubmitAbort = null;
|
|
1302
|
+
}
|
|
1077
1303
|
setPendingState(false, submitter);
|
|
1078
1304
|
}
|
|
1079
1305
|
}
|
|
1080
1306
|
|
|
1081
|
-
|
|
1307
|
+
function parseFieldValidateConfig(ctrl) {
|
|
1308
|
+
const ctrlAttr = ctrl && ctrl.getAttribute ? ctrl.getAttribute('data-w-validate') : null;
|
|
1309
|
+
const formAttr = formEl && formEl.getAttribute ? formEl.getAttribute('data-w-validate') : null;
|
|
1310
|
+
const raw = ctrlAttr !== null ? ctrlAttr : formAttr;
|
|
1311
|
+
if (!raw || raw === 'false') return null;
|
|
1312
|
+
const parts = String(raw).split(/[\s,|]+/).map((s) => s.trim().toLowerCase()).filter(Boolean);
|
|
1313
|
+
const onBlur = parts.includes('blur') || parts.includes('true') || raw === '';
|
|
1314
|
+
let debounceMs = null;
|
|
1315
|
+
for (const p of parts) {
|
|
1316
|
+
if (p.startsWith('debounce:')) {
|
|
1317
|
+
const ms = Number(p.slice('debounce:'.length).replace(/ms$/i, ''));
|
|
1318
|
+
debounceMs = Number.isFinite(ms) && ms >= 0 ? ms : 300;
|
|
1319
|
+
} else if (p === 'debounce') {
|
|
1320
|
+
debounceMs = 300;
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
return { onBlur, debounceMs };
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
async function validateField(fieldName, vOpts = {}) {
|
|
1327
|
+
if (!formEl || !api || !fieldName) return { ok: true, valid: true, errors: {} };
|
|
1328
|
+
const norm = fieldName.endsWith('[]') ? fieldName.slice(0, -2) : fieldName;
|
|
1329
|
+
|
|
1330
|
+
const epoch = validationEpoch;
|
|
1331
|
+
const seq = (validationSeqByField.get(norm) || 0) + 1;
|
|
1332
|
+
validationSeqByField.set(norm, seq);
|
|
1333
|
+
|
|
1334
|
+
const prevAbort = validationAbortByField.get(norm);
|
|
1335
|
+
if (prevAbort) {
|
|
1336
|
+
try {
|
|
1337
|
+
prevAbort.abort();
|
|
1338
|
+
} catch (_e) {}
|
|
1339
|
+
}
|
|
1340
|
+
const ac = new AbortController();
|
|
1341
|
+
validationAbortByField.set(norm, ac);
|
|
1342
|
+
|
|
1343
|
+
const validateUrl =
|
|
1344
|
+
vOpts.url ||
|
|
1345
|
+
(formEl.getAttribute && (formEl.getAttribute('data-w-validate-url') || formEl.getAttribute('action'))) ||
|
|
1346
|
+
(typeof window !== 'undefined' && window.location ? window.location.pathname : '/');
|
|
1347
|
+
const method = String(vOpts.method || (formEl.getAttribute && formEl.getAttribute('method')) || 'POST').toUpperCase();
|
|
1348
|
+
|
|
1349
|
+
const { data: allValues } = parseFormElement(formEl);
|
|
1350
|
+
const ctrl = formEl.querySelector ? (formEl.querySelector(`[name="${norm}"]`) || formEl.querySelector(`[name="${fieldName}"]`)) : null;
|
|
1351
|
+
const contextAttr = ctrl && ctrl.getAttribute ? ctrl.getAttribute('data-w-validate-context') : null;
|
|
1352
|
+
|
|
1353
|
+
let payload = { [norm]: allValues[norm] };
|
|
1354
|
+
if (vOpts.contextFields === '*' || contextAttr === '*') {
|
|
1355
|
+
payload = { ...allValues };
|
|
1356
|
+
} else {
|
|
1357
|
+
const ctxList = isArray(vOpts.contextFields)
|
|
1358
|
+
? vOpts.contextFields
|
|
1359
|
+
: typeof contextAttr === 'string' && contextAttr.trim()
|
|
1360
|
+
? contextAttr.split(',').map((s) => s.trim()).filter(Boolean)
|
|
1361
|
+
: [];
|
|
1362
|
+
for (const cf of ctxList) {
|
|
1363
|
+
if (Object.prototype.hasOwnProperty.call(allValues, cf)) {
|
|
1364
|
+
payload[cf] = allValues[cf];
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
try {
|
|
1370
|
+
const resp = await api.request(method, validateUrl, payload, {
|
|
1371
|
+
headers: { 'X-Webspresso-Validate-Fields': norm },
|
|
1372
|
+
signal: ac.signal,
|
|
1373
|
+
});
|
|
1374
|
+
|
|
1375
|
+
if (epoch !== validationEpoch || seq !== validationSeqByField.get(norm)) {
|
|
1376
|
+
return { ok: true, stale: true, valid: true, errors: {} };
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
const respErrors = resp && resp.errors && typeof resp.errors === 'object' ? resp.errors : {};
|
|
1380
|
+
if (respErrors[norm]) {
|
|
1381
|
+
fieldErrors[norm] = String(respErrors[norm]);
|
|
1382
|
+
} else {
|
|
1383
|
+
delete fieldErrors[norm];
|
|
1384
|
+
}
|
|
1385
|
+
syncErrorsToDom(false);
|
|
1386
|
+
notifyStateChange();
|
|
1387
|
+
return {
|
|
1388
|
+
ok: true,
|
|
1389
|
+
valid: !fieldErrors[norm],
|
|
1390
|
+
errors: fieldErrors[norm] ? { [norm]: fieldErrors[norm] } : {},
|
|
1391
|
+
};
|
|
1392
|
+
} catch (err) {
|
|
1393
|
+
if (epoch !== validationEpoch || seq !== validationSeqByField.get(norm) || (err && err.isCanceled)) {
|
|
1394
|
+
return { ok: true, stale: true, canceled: Boolean(err && err.isCanceled), errors: {} };
|
|
1395
|
+
}
|
|
1396
|
+
// If server returned a 400/422 validation envelope, extract only this field's error
|
|
1397
|
+
if (err && err.kind === 'validation' && err.fieldErrors) {
|
|
1398
|
+
if (err.fieldErrors[norm]) {
|
|
1399
|
+
fieldErrors[norm] = String(err.fieldErrors[norm]);
|
|
1400
|
+
} else {
|
|
1401
|
+
delete fieldErrors[norm];
|
|
1402
|
+
}
|
|
1403
|
+
syncErrorsToDom(false);
|
|
1404
|
+
notifyStateChange();
|
|
1405
|
+
return { ok: true, valid: !fieldErrors[norm], errors: fieldErrors[norm] ? { [norm]: fieldErrors[norm] } : {} };
|
|
1406
|
+
}
|
|
1407
|
+
// Network or 5xx error: NEVER mark the field as invalid!
|
|
1408
|
+
if (typeof formEl.dispatchEvent === 'function' && typeof CustomEvent !== 'undefined') {
|
|
1409
|
+
formEl.dispatchEvent(
|
|
1410
|
+
new CustomEvent('w-form:validation-error', {
|
|
1411
|
+
bubbles: true,
|
|
1412
|
+
detail: { field: norm, error: err },
|
|
1413
|
+
})
|
|
1414
|
+
);
|
|
1415
|
+
}
|
|
1416
|
+
return { ok: false, networkError: true, error: err };
|
|
1417
|
+
} finally {
|
|
1418
|
+
if (validationAbortByField.get(norm) === ac) {
|
|
1419
|
+
validationAbortByField.delete(norm);
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
function cancelUpload() {
|
|
1425
|
+
if (activeSubmitAbort) {
|
|
1426
|
+
try {
|
|
1427
|
+
activeSubmitAbort.abort();
|
|
1428
|
+
} catch (_e) {}
|
|
1429
|
+
activeSubmitAbort = null;
|
|
1430
|
+
return true;
|
|
1431
|
+
}
|
|
1432
|
+
return false;
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
// Listen to native submit, focusout, and input/change events on `formEl`
|
|
1082
1436
|
const onDomInput = (evt) => {
|
|
1083
1437
|
inputRevision += 1;
|
|
1084
1438
|
dirty = computeFormDirty(formEl, initialSnapshot);
|
|
1085
1439
|
const target = evt && evt.target;
|
|
1086
1440
|
if (target && target.getAttribute) {
|
|
1441
|
+
const type = (target.type || '').toLowerCase();
|
|
1087
1442
|
const rawName = target.getAttribute('name');
|
|
1443
|
+
if (type === 'file' && evt.type === 'change' && rawName) {
|
|
1444
|
+
revokePreviewUrls(rawName);
|
|
1445
|
+
}
|
|
1088
1446
|
if (rawName) {
|
|
1089
1447
|
const norm = rawName.endsWith('[]') ? rawName.slice(0, -2) : rawName;
|
|
1448
|
+
if (evt.type === 'change' && (type === 'checkbox' || type === 'radio' || target.tagName === 'SELECT')) {
|
|
1449
|
+
touchedFields.add(norm);
|
|
1450
|
+
}
|
|
1090
1451
|
if (fieldErrors[norm] || fieldErrors[rawName]) {
|
|
1091
1452
|
delete fieldErrors[norm];
|
|
1092
1453
|
delete fieldErrors[rawName];
|
|
1093
1454
|
syncErrorsToDom(false);
|
|
1094
1455
|
}
|
|
1456
|
+
if (evt.type === 'input') {
|
|
1457
|
+
const vCfg = parseFieldValidateConfig(target);
|
|
1458
|
+
if (vCfg && vCfg.debounceMs !== null) {
|
|
1459
|
+
const prevTimer = validationTimersByField.get(norm);
|
|
1460
|
+
if (prevTimer) clearTimeout(prevTimer);
|
|
1461
|
+
const t = setTimeout(() => {
|
|
1462
|
+
validationTimersByField.delete(norm);
|
|
1463
|
+
validateField(norm).catch(() => {});
|
|
1464
|
+
}, vCfg.debounceMs);
|
|
1465
|
+
validationTimersByField.set(norm, t);
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1095
1468
|
}
|
|
1096
1469
|
}
|
|
1097
1470
|
if (isDraftEnabled) {
|
|
@@ -1104,16 +1477,40 @@ function createFormHandle(formEl, options = {}) {
|
|
|
1104
1477
|
notifyStateChange();
|
|
1105
1478
|
};
|
|
1106
1479
|
|
|
1480
|
+
const onDomFocusOut = (evt) => {
|
|
1481
|
+
const target = evt && evt.target;
|
|
1482
|
+
if (!target || !target.getAttribute) return;
|
|
1483
|
+
const rawName = target.getAttribute('name');
|
|
1484
|
+
if (!rawName) return;
|
|
1485
|
+
const type = (target.type || '').toLowerCase();
|
|
1486
|
+
if (type === 'submit' || type === 'button' || type === 'reset' || type === 'hidden') return;
|
|
1487
|
+
const norm = rawName.endsWith('[]') ? rawName.slice(0, -2) : rawName;
|
|
1488
|
+
const wasTouched = touchedFields.has(norm);
|
|
1489
|
+
touchedFields.add(norm);
|
|
1490
|
+
if (!wasTouched) {
|
|
1491
|
+
syncErrorsToDom(false);
|
|
1492
|
+
notifyStateChange();
|
|
1493
|
+
}
|
|
1494
|
+
const vCfg = parseFieldValidateConfig(target);
|
|
1495
|
+
if (vCfg && vCfg.onBlur) {
|
|
1496
|
+
validateField(norm).catch(() => {});
|
|
1497
|
+
}
|
|
1498
|
+
};
|
|
1499
|
+
|
|
1107
1500
|
const onDomSubmit = (evt) => {
|
|
1108
1501
|
submit(evt);
|
|
1109
1502
|
};
|
|
1110
1503
|
|
|
1111
1504
|
const onDomReset = () => {
|
|
1112
1505
|
cancelDraftDebounce();
|
|
1506
|
+
cancelFieldValidation();
|
|
1507
|
+
revokePreviewUrls();
|
|
1113
1508
|
if (resetTimer) clearTimeout(resetTimer);
|
|
1114
1509
|
resetTimer = setTimeout(() => {
|
|
1115
1510
|
resetTimer = null;
|
|
1116
1511
|
dirty = false;
|
|
1512
|
+
submitted = false;
|
|
1513
|
+
touchedFields.clear();
|
|
1117
1514
|
fieldErrors = {};
|
|
1118
1515
|
formError = null;
|
|
1119
1516
|
syncErrorsToDom(false);
|
|
@@ -1124,6 +1521,7 @@ function createFormHandle(formEl, options = {}) {
|
|
|
1124
1521
|
if (formEl && typeof formEl.addEventListener === 'function') {
|
|
1125
1522
|
formEl.addEventListener('input', onDomInput);
|
|
1126
1523
|
formEl.addEventListener('change', onDomInput);
|
|
1524
|
+
formEl.addEventListener('focusout', onDomFocusOut);
|
|
1127
1525
|
formEl.addEventListener('submit', onDomSubmit);
|
|
1128
1526
|
formEl.addEventListener('reset', onDomReset);
|
|
1129
1527
|
}
|
|
@@ -1149,6 +1547,54 @@ function createFormHandle(formEl, options = {}) {
|
|
|
1149
1547
|
dirty = computeFormDirty(formEl, initialSnapshot);
|
|
1150
1548
|
return dirty;
|
|
1151
1549
|
},
|
|
1550
|
+
get touched() {
|
|
1551
|
+
return touchedFields.size > 0;
|
|
1552
|
+
},
|
|
1553
|
+
get touchedFields() {
|
|
1554
|
+
return Array.from(touchedFields);
|
|
1555
|
+
},
|
|
1556
|
+
get submitted() {
|
|
1557
|
+
return submitted;
|
|
1558
|
+
},
|
|
1559
|
+
isTouched(fieldName) {
|
|
1560
|
+
if (!fieldName) return touchedFields.size > 0;
|
|
1561
|
+
const norm = fieldName.endsWith('[]') ? fieldName.slice(0, -2) : fieldName;
|
|
1562
|
+
return touchedFields.has(norm) || touchedFields.has(fieldName);
|
|
1563
|
+
},
|
|
1564
|
+
touch(fieldName) {
|
|
1565
|
+
if (fieldName) {
|
|
1566
|
+
const norm = fieldName.endsWith('[]') ? fieldName.slice(0, -2) : fieldName;
|
|
1567
|
+
touchedFields.add(norm);
|
|
1568
|
+
} else if (formEl && typeof formEl.querySelectorAll === 'function') {
|
|
1569
|
+
const controls = formEl.querySelectorAll('input, select, textarea');
|
|
1570
|
+
for (let i = 0; i < controls.length; i++) {
|
|
1571
|
+
const n = controls[i].getAttribute && controls[i].getAttribute('name');
|
|
1572
|
+
if (n) touchedFields.add(n.endsWith('[]') ? n.slice(0, -2) : n);
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
syncErrorsToDom(false);
|
|
1576
|
+
notifyStateChange();
|
|
1577
|
+
return handle;
|
|
1578
|
+
},
|
|
1579
|
+
untouch(fieldName) {
|
|
1580
|
+
if (fieldName) {
|
|
1581
|
+
const norm = fieldName.endsWith('[]') ? fieldName.slice(0, -2) : fieldName;
|
|
1582
|
+
touchedFields.delete(norm);
|
|
1583
|
+
touchedFields.delete(fieldName);
|
|
1584
|
+
} else {
|
|
1585
|
+
touchedFields.clear();
|
|
1586
|
+
}
|
|
1587
|
+
syncErrorsToDom(false);
|
|
1588
|
+
notifyStateChange();
|
|
1589
|
+
return handle;
|
|
1590
|
+
},
|
|
1591
|
+
validateField,
|
|
1592
|
+
get uploads() {
|
|
1593
|
+
return uploadEntries.map((u) => ({ ...u }));
|
|
1594
|
+
},
|
|
1595
|
+
cancelUpload,
|
|
1596
|
+
createPreviewUrl,
|
|
1597
|
+
revokePreviewUrls,
|
|
1152
1598
|
get inputRevision() {
|
|
1153
1599
|
return inputRevision;
|
|
1154
1600
|
},
|
|
@@ -1249,6 +1695,9 @@ function createFormHandle(formEl, options = {}) {
|
|
|
1249
1695
|
},
|
|
1250
1696
|
destroy() {
|
|
1251
1697
|
cancelDraftDebounce();
|
|
1698
|
+
cancelFieldValidation();
|
|
1699
|
+
cancelUpload();
|
|
1700
|
+
revokePreviewUrls();
|
|
1252
1701
|
if (resetTimer) {
|
|
1253
1702
|
clearTimeout(resetTimer);
|
|
1254
1703
|
resetTimer = null;
|
|
@@ -1256,6 +1705,7 @@ function createFormHandle(formEl, options = {}) {
|
|
|
1256
1705
|
if (formEl && typeof formEl.removeEventListener === 'function') {
|
|
1257
1706
|
formEl.removeEventListener('input', onDomInput);
|
|
1258
1707
|
formEl.removeEventListener('change', onDomInput);
|
|
1708
|
+
formEl.removeEventListener('focusout', onDomFocusOut);
|
|
1259
1709
|
formEl.removeEventListener('submit', onDomSubmit);
|
|
1260
1710
|
formEl.removeEventListener('reset', onDomReset);
|
|
1261
1711
|
}
|