webspresso 0.0.110 → 0.0.112

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.
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { defaultStorage } = require('./storage');
4
+ const { isArray, setElementBusyState } = require('./utils');
4
5
 
5
6
  /**
6
7
  * Webspresso Native HTML Form Layer & Typed Form Handle
@@ -202,12 +203,12 @@ function parseFormElement(formEl, submitterOrOptions = null) {
202
203
  function appendValue(normName, val, forceArray) {
203
204
  if (val === undefined) return;
204
205
  if (forceArray) {
205
- if (!Array.isArray(data[normName])) {
206
+ if (!isArray(data[normName])) {
206
207
  data[normName] = [];
207
208
  }
208
209
  data[normName].push(val);
209
210
  } else if (normName in data) {
210
- if (!Array.isArray(data[normName])) {
211
+ if (!isArray(data[normName])) {
211
212
  data[normName] = [data[normName]];
212
213
  }
213
214
  data[normName].push(val);
@@ -326,7 +327,7 @@ function parseFormElement(formEl, submitterOrOptions = null) {
326
327
  formData = new FormData();
327
328
  for (const [key, val] of Object.entries(data)) {
328
329
  if (val === undefined) continue;
329
- if (Array.isArray(val)) {
330
+ if (isArray(val)) {
330
331
  for (const item of val) {
331
332
  if (item === undefined) continue;
332
333
  if (typeof Blob !== 'undefined' && item instanceof Blob) {
@@ -416,27 +417,44 @@ function computeFormDirty(formEl, initialSnapshot) {
416
417
  */
417
418
  function createFormHandle(formEl, options = {}) {
418
419
  if (formEl && formEl.__wFormHandle) {
420
+ if (options.services && typeof formEl.__wFormHandle.__setServices === 'function') {
421
+ formEl.__wFormHandle.__setServices(options.services);
422
+ }
419
423
  return formEl.__wFormHandle;
420
424
  }
421
425
 
422
426
  const api = options.api;
423
427
  const debug = options.debug || null;
428
+ let services = options.services || null;
424
429
  const formName =
425
430
  (formEl && formEl.getAttribute && formEl.getAttribute('data-w-form')) ||
426
431
  (formEl && formEl.getAttribute && formEl.getAttribute('name')) ||
427
432
  'form';
428
433
 
429
434
  let initialSnapshot = captureInitialFormSnapshot(formEl);
435
+ let baselineValues = parseFormElement(formEl).data;
430
436
  let pending = false;
431
437
  let dirty = false;
438
+ let inputRevision = 0;
432
439
  let fieldErrors = {};
433
440
  let formError = null;
441
+ let lastError = null;
434
442
  let lastData = null;
443
+ let draftDebounceTimer = null;
444
+ let resetTimer = null;
435
445
 
446
+ const beforeSubmitListeners = new Set();
436
447
  const successListeners = new Set();
437
448
  const errorListeners = new Set();
438
449
  const stateListeners = new Set();
439
450
 
451
+ function cancelDraftDebounce() {
452
+ if (draftDebounceTimer) {
453
+ clearTimeout(draftDebounceTimer);
454
+ draftDebounceTimer = null;
455
+ }
456
+ }
457
+
440
458
  // Form draft persistence setup (sessionStorage by default, or localStorage)
441
459
  const rawDraftAttr = formEl
442
460
  ? formEl.getAttribute('data-w-persist-draft') || formEl.getAttribute('data-w-draft')
@@ -453,11 +471,18 @@ function createFormHandle(formEl, options = {}) {
453
471
  draftStorageType = 'local';
454
472
  }
455
473
 
456
- const draftKey =
474
+ const draftScope =
475
+ options.draftScope ||
476
+ (formEl && formEl.getAttribute && formEl.getAttribute('data-w-draft-scope')) ||
477
+ '';
478
+
479
+ const baseDraftKey =
457
480
  options.draftKey ||
458
481
  (formEl && (formEl.getAttribute('data-w-draft-key') || formEl.getAttribute('data-w-persist-key'))) ||
459
482
  `form_draft:${formName || 'anon'}`;
460
483
 
484
+ let draftKey = draftScope ? `${baseDraftKey}:${draftScope}` : baseDraftKey;
485
+
461
486
  const draftStorage =
462
487
  (options.storage && options.storage[draftStorageType]) ||
463
488
  (draftStorageType === 'local' ? defaultStorage.local : defaultStorage.session);
@@ -480,7 +505,22 @@ function createFormHandle(formEl, options = {}) {
480
505
  return safeData;
481
506
  }
482
507
 
508
+ function sanitizeDraftObject(raw) {
509
+ if (!raw || typeof raw !== 'object' || isArray(raw)) return null;
510
+ const clean = {};
511
+ for (const [k, v] of Object.entries(raw)) {
512
+ if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
513
+ if (v === null || v === undefined || typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') {
514
+ clean[k] = v;
515
+ } else if (isArray(v) && v.every((item) => typeof item === 'string' || typeof item === 'number' || typeof item === 'boolean')) {
516
+ clean[k] = v;
517
+ }
518
+ }
519
+ return Object.keys(clean).length > 0 ? clean : null;
520
+ }
521
+
483
522
  function saveDraft() {
523
+ cancelDraftDebounce();
484
524
  if (!formEl || !isDraftEnabled) return false;
485
525
  const draftPayload = extractDraftValues(formEl);
486
526
  return draftStorage.set(draftKey, draftPayload);
@@ -488,9 +528,15 @@ function createFormHandle(formEl, options = {}) {
488
528
 
489
529
  function restoreDraft() {
490
530
  if (!formEl || !isDraftEnabled) return false;
491
- const saved = draftStorage.get(draftKey, null);
492
- if (saved && typeof saved === 'object' && Object.keys(saved).length > 0) {
493
- fill(saved, { baseline: false });
531
+ let saved = null;
532
+ try {
533
+ saved = draftStorage.get(draftKey, null);
534
+ } catch (_e) {
535
+ saved = null;
536
+ }
537
+ const cleanDraft = sanitizeDraftObject(saved);
538
+ if (cleanDraft) {
539
+ fill(cleanDraft, { baseline: false });
494
540
  if (typeof formEl.dispatchEvent === 'function') {
495
541
  const CustomEventCtor =
496
542
  (formEl.ownerDocument && formEl.ownerDocument.defaultView && formEl.ownerDocument.defaultView.CustomEvent) ||
@@ -500,7 +546,7 @@ function createFormHandle(formEl, options = {}) {
500
546
  new CustomEventCtor('w-form:draft-restored', {
501
547
  bubbles: true,
502
548
  cancelable: true,
503
- detail: { form: handle, draft: saved, key: draftKey },
549
+ detail: { form: handle, draft: cleanDraft, key: draftKey },
504
550
  })
505
551
  );
506
552
  }
@@ -511,6 +557,7 @@ function createFormHandle(formEl, options = {}) {
511
557
  }
512
558
 
513
559
  function clearDraft() {
560
+ cancelDraftDebounce();
514
561
  if (!isDraftEnabled) return false;
515
562
  return draftStorage.remove(draftKey);
516
563
  }
@@ -520,6 +567,20 @@ function createFormHandle(formEl, options = {}) {
520
567
  return draftStorage.has(draftKey);
521
568
  }
522
569
 
570
+ function setDraftKey(nextKey, keyOpts = {}) {
571
+ cancelDraftDebounce();
572
+ if (typeof nextKey === 'string' && nextKey.trim()) {
573
+ draftKey = nextKey.trim();
574
+ if (formEl && typeof formEl.setAttribute === 'function') {
575
+ formEl.setAttribute('data-w-draft-key', draftKey);
576
+ }
577
+ if (keyOpts && keyOpts.restore === true && isDraftEnabled) {
578
+ restoreDraft();
579
+ }
580
+ }
581
+ return handle;
582
+ }
583
+
523
584
  function notifyStateChange() {
524
585
  for (const fn of stateListeners) {
525
586
  try {
@@ -615,32 +676,32 @@ function createFormHandle(formEl, options = {}) {
615
676
  * Toggle pending state on the form and submit buttons to prevent double submissions.
616
677
  */
617
678
  function setPendingState(nextPending, activeSubmitter = null) {
618
- pending = Boolean(nextPending);
679
+ const next = Boolean(nextPending);
680
+ if (pending === next) return;
681
+ pending = next;
619
682
  if (!formEl) return;
620
683
 
621
684
  if (pending) {
622
685
  formEl.setAttribute('aria-busy', 'true');
623
686
  formEl.setAttribute('data-w-pending', 'true');
687
+ formEl.setAttribute('data-w-busy', 'true');
624
688
  } else {
625
689
  formEl.removeAttribute('aria-busy');
626
690
  formEl.removeAttribute('data-w-pending');
691
+ formEl.removeAttribute('data-w-busy');
627
692
  }
628
693
 
629
694
  const submitButtons = formEl.querySelectorAll
630
695
  ? formEl.querySelectorAll('button[type="submit"], button:not([type]), input[type="submit"]')
631
696
  : [];
697
+ let submitterHandled = false;
632
698
  for (let i = 0; i < submitButtons.length; i++) {
633
699
  const btn = submitButtons[i];
634
- if (btn.hasAttribute('data-w-disabled')) continue; // Managed by controller binding
635
- if (pending) {
636
- btn.__wWasDisabled = Boolean(btn.disabled);
637
- btn.disabled = true;
638
- } else if (!btn.__wWasDisabled) {
639
- btn.disabled = false;
640
- }
700
+ if (btn === activeSubmitter) submitterHandled = true;
701
+ setElementBusyState(btn, pending);
641
702
  }
642
- if (activeSubmitter && !activeSubmitter.hasAttribute('data-w-disabled')) {
643
- activeSubmitter.disabled = pending;
703
+ if (activeSubmitter && !submitterHandled) {
704
+ setElementBusyState(activeSubmitter, pending);
644
705
  }
645
706
  notifyStateChange();
646
707
  }
@@ -649,6 +710,7 @@ function createFormHandle(formEl, options = {}) {
649
710
  * Reset form controls back to their initial default values (NOT emptying non-empty defaults).
650
711
  */
651
712
  function reset() {
713
+ cancelDraftDebounce();
652
714
  if (!formEl) return;
653
715
  if (typeof formEl.reset === 'function') {
654
716
  formEl.reset();
@@ -678,6 +740,7 @@ function createFormHandle(formEl, options = {}) {
678
740
  * Completely clear all user-editable controls to empty values (distinct from `reset()`).
679
741
  */
680
742
  function clear() {
743
+ cancelDraftDebounce();
681
744
  if (!formEl || typeof formEl.querySelectorAll !== 'function') return;
682
745
  const controls = formEl.querySelectorAll('input, select, textarea');
683
746
  for (let i = 0; i < controls.length; i++) {
@@ -714,11 +777,14 @@ function createFormHandle(formEl, options = {}) {
714
777
  * snapshot, clears `dirty`, and clears previous validation errors.
715
778
  */
716
779
  function fill(values = {}, options = {}) {
717
- if (!formEl || typeof formEl.querySelectorAll !== 'function' || !values || typeof values !== 'object') {
780
+ if (!formEl || typeof formEl.querySelectorAll !== 'function' || !values || typeof values !== 'object' || isArray(values)) {
718
781
  return handle;
719
782
  }
720
783
 
721
784
  const isBaseline = Boolean(options.baseline);
785
+ if (isBaseline) {
786
+ cancelDraftDebounce();
787
+ }
722
788
 
723
789
  for (const [key, val] of Object.entries(values)) {
724
790
  if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
@@ -734,7 +800,7 @@ function createFormHandle(formEl, options = {}) {
734
800
  if (type === 'file') continue; // Browser security prevents setting file input value
735
801
 
736
802
  if (type === 'checkbox') {
737
- if (Array.isArray(val)) {
803
+ if (isArray(val)) {
738
804
  ctrl.checked = val.map(String).includes(String(ctrl.value));
739
805
  } else {
740
806
  ctrl.checked = Boolean(val);
@@ -743,7 +809,7 @@ function createFormHandle(formEl, options = {}) {
743
809
  ctrl.checked = String(val) === String(ctrl.value);
744
810
  } else if (ctrl.tagName === 'SELECT') {
745
811
  if (ctrl.multiple) {
746
- const arr = Array.isArray(val) ? val.map(String) : [String(val)];
812
+ const arr = isArray(val) ? val.map(String) : [String(val)];
747
813
  const opts = ctrl.options || [];
748
814
  for (let j = 0; j < opts.length; j++) {
749
815
  opts[j].selected = arr.includes(String(opts[j].value));
@@ -752,6 +818,7 @@ function createFormHandle(formEl, options = {}) {
752
818
  ctrl.value = val === null || val === undefined ? '' : String(val);
753
819
  }
754
820
  } else {
821
+ if (val !== null && typeof val === 'object') continue;
755
822
  ctrl.value = val === null || val === undefined ? '' : String(val);
756
823
  }
757
824
 
@@ -763,9 +830,11 @@ function createFormHandle(formEl, options = {}) {
763
830
 
764
831
  if (isBaseline) {
765
832
  initialSnapshot = captureInitialFormSnapshot(formEl);
833
+ baselineValues = parseFormElement(formEl).data;
766
834
  dirty = false;
767
835
  fieldErrors = {};
768
836
  formError = null;
837
+ lastError = null;
769
838
  syncErrorsToDom(false);
770
839
  } else {
771
840
  dirty = computeFormDirty(formEl, initialSnapshot);
@@ -778,7 +847,9 @@ function createFormHandle(formEl, options = {}) {
778
847
 
779
848
  function setAction(url) {
780
849
  if (formEl && url) {
781
- formEl.setAttribute('action', String(url));
850
+ const nextAction = String(url);
851
+ formEl.setAttribute('action', nextAction);
852
+ formEl.__wCustomAction = nextAction;
782
853
  }
783
854
  return handle;
784
855
  }
@@ -791,6 +862,7 @@ function createFormHandle(formEl, options = {}) {
791
862
  action = action.replace(new RegExp(`:${k}\\b`, 'g'), encodeURIComponent(String(v)));
792
863
  }
793
864
  formEl.setAttribute('action', action);
865
+ formEl.__wCustomAction = action;
794
866
  return handle;
795
867
  }
796
868
 
@@ -865,9 +937,23 @@ function createFormHandle(formEl, options = {}) {
865
937
 
866
938
  const { data: payloadData, formData, isMultipart } = parseFormElement(formEl, submitter);
867
939
 
940
+ // Execute beforeSubmit hooks before initiating network request
941
+ for (const guardFn of beforeSubmitListeners) {
942
+ let allowed = true;
943
+ try {
944
+ allowed = await guardFn(payloadData, { form: handle, submitter, action: actionUrl, method });
945
+ } catch (guardErr) {
946
+ return { ok: false, submitted: false, canceled: true, reason: 'before-submit-error', error: guardErr };
947
+ }
948
+ if (allowed === false) {
949
+ return { ok: false, submitted: false, canceled: true, reason: 'before-submit' };
950
+ }
951
+ }
952
+
868
953
  // Clear previous errors before request starts
869
954
  fieldErrors = {};
870
955
  formError = null;
956
+ lastError = null;
871
957
  syncErrorsToDom(false);
872
958
  setPendingState(true, submitter);
873
959
 
@@ -916,6 +1002,7 @@ function createFormHandle(formEl, options = {}) {
916
1002
  } else {
917
1003
  // Current state becomes the new clean baseline after successful save
918
1004
  initialSnapshot = captureInitialFormSnapshot(formEl);
1005
+ baselineValues = parseFormElement(formEl).data;
919
1006
  }
920
1007
 
921
1008
  if (isDraftEnabled) {
@@ -923,9 +1010,18 @@ function createFormHandle(formEl, options = {}) {
923
1010
  }
924
1011
 
925
1012
  // Invoke registered success hooks. Note: Any background fragment refresh failure
926
- // is handled by the fragment coordinator and NEVER puts this form into validation error state!
1013
+ // or side-effect service error in onSuccess NEVER puts a succeeded form into validation error
1014
+ // state or makes the completed mutation appear rolled back!
1015
+ let hookError = null;
927
1016
  for (const listener of successListeners) {
928
- await listener(responseData, { values: payloadData, form: handle });
1017
+ try {
1018
+ await listener(responseData, { values: payloadData, form: handle, services });
1019
+ } catch (listenerErr) {
1020
+ if (!hookError) hookError = listenerErr;
1021
+ if (debug && typeof debug.trackCleanupError === 'function') {
1022
+ debug.trackCleanupError(`form_${formName}`, formName, 'form:onSuccess', listenerErr);
1023
+ }
1024
+ }
929
1025
  }
930
1026
 
931
1027
  // Explicit redirect behavior
@@ -951,8 +1047,9 @@ function createFormHandle(formEl, options = {}) {
951
1047
  }
952
1048
  }
953
1049
 
954
- return { ok: true, submitted: true, data: responseData, refreshOk, refreshError };
1050
+ return { ok: true, submitted: true, data: responseData, refreshOk, refreshError, hookError };
955
1051
  } catch (err) {
1052
+ lastError = err || null;
956
1053
  if (err && err.kind === 'validation') {
957
1054
  fieldErrors = { ...(err.fieldErrors || {}) };
958
1055
  formError = err.formError || (Object.keys(fieldErrors).length === 0 ? err.message : null);
@@ -966,7 +1063,7 @@ function createFormHandle(formEl, options = {}) {
966
1063
 
967
1064
  for (const listener of errorListeners) {
968
1065
  try {
969
- await listener(err, { values: payloadData, form: handle });
1066
+ await listener(err, { values: payloadData, form: handle, services });
970
1067
  } catch (_e) {
971
1068
  // Ignore
972
1069
  }
@@ -982,8 +1079,8 @@ function createFormHandle(formEl, options = {}) {
982
1079
  }
983
1080
 
984
1081
  // Listen to native submit and input/change events on `formEl`
985
- let draftDebounceTimer = null;
986
1082
  const onDomInput = (evt) => {
1083
+ inputRevision += 1;
987
1084
  dirty = computeFormDirty(formEl, initialSnapshot);
988
1085
  const target = evt && evt.target;
989
1086
  if (target && target.getAttribute) {
@@ -998,8 +1095,9 @@ function createFormHandle(formEl, options = {}) {
998
1095
  }
999
1096
  }
1000
1097
  if (isDraftEnabled) {
1001
- if (draftDebounceTimer) clearTimeout(draftDebounceTimer);
1098
+ cancelDraftDebounce();
1002
1099
  draftDebounceTimer = setTimeout(() => {
1100
+ draftDebounceTimer = null;
1003
1101
  saveDraft();
1004
1102
  }, 150);
1005
1103
  }
@@ -1011,7 +1109,10 @@ function createFormHandle(formEl, options = {}) {
1011
1109
  };
1012
1110
 
1013
1111
  const onDomReset = () => {
1014
- setTimeout(() => {
1112
+ cancelDraftDebounce();
1113
+ if (resetTimer) clearTimeout(resetTimer);
1114
+ resetTimer = setTimeout(() => {
1115
+ resetTimer = null;
1015
1116
  dirty = false;
1016
1117
  fieldErrors = {};
1017
1118
  formError = null;
@@ -1035,6 +1136,12 @@ function createFormHandle(formEl, options = {}) {
1035
1136
  handle = {
1036
1137
  name: formName,
1037
1138
  el: formEl,
1139
+ get services() {
1140
+ return services;
1141
+ },
1142
+ __setServices(nextServices) {
1143
+ if (nextServices) services = nextServices;
1144
+ },
1038
1145
  get pending() {
1039
1146
  return pending;
1040
1147
  },
@@ -1042,9 +1149,18 @@ function createFormHandle(formEl, options = {}) {
1042
1149
  dirty = computeFormDirty(formEl, initialSnapshot);
1043
1150
  return dirty;
1044
1151
  },
1152
+ get inputRevision() {
1153
+ return inputRevision;
1154
+ },
1155
+ get draftKey() {
1156
+ return draftKey;
1157
+ },
1045
1158
  get values() {
1046
1159
  return parseFormElement(formEl).data;
1047
1160
  },
1161
+ get baseline() {
1162
+ return { ...(baselineValues || {}) };
1163
+ },
1048
1164
  get errors() {
1049
1165
  return { ...fieldErrors };
1050
1166
  },
@@ -1054,6 +1170,9 @@ function createFormHandle(formEl, options = {}) {
1054
1170
  get formError() {
1055
1171
  return formError;
1056
1172
  },
1173
+ get lastError() {
1174
+ return lastError;
1175
+ },
1057
1176
  get lastData() {
1058
1177
  return lastData;
1059
1178
  },
@@ -1073,6 +1192,7 @@ function createFormHandle(formEl, options = {}) {
1073
1192
  saveDraft,
1074
1193
  restoreDraft,
1075
1194
  clearDraft,
1195
+ setDraftKey,
1076
1196
  get hasDraft() {
1077
1197
  return hasDraft();
1078
1198
  },
@@ -1092,12 +1212,20 @@ function createFormHandle(formEl, options = {}) {
1092
1212
  clearErrors() {
1093
1213
  fieldErrors = {};
1094
1214
  formError = null;
1215
+ lastError = null;
1095
1216
  syncErrorsToDom(false);
1096
1217
  notifyStateChange();
1097
1218
  },
1098
1219
  reset,
1099
1220
  clear,
1100
1221
  submit,
1222
+ beforeSubmit(fn) {
1223
+ if (typeof fn === 'function') {
1224
+ beforeSubmitListeners.add(fn);
1225
+ return () => beforeSubmitListeners.delete(fn);
1226
+ }
1227
+ return () => {};
1228
+ },
1101
1229
  onSuccess(fn) {
1102
1230
  if (typeof fn === 'function') {
1103
1231
  successListeners.add(fn);
@@ -1120,6 +1248,11 @@ function createFormHandle(formEl, options = {}) {
1120
1248
  return () => {};
1121
1249
  },
1122
1250
  destroy() {
1251
+ cancelDraftDebounce();
1252
+ if (resetTimer) {
1253
+ clearTimeout(resetTimer);
1254
+ resetTimer = null;
1255
+ }
1123
1256
  if (formEl && typeof formEl.removeEventListener === 'function') {
1124
1257
  formEl.removeEventListener('input', onDomInput);
1125
1258
  formEl.removeEventListener('change', onDomInput);
@@ -1129,6 +1262,7 @@ function createFormHandle(formEl, options = {}) {
1129
1262
  if (formEl) {
1130
1263
  delete formEl.__wFormHandle;
1131
1264
  }
1265
+ beforeSubmitListeners.clear();
1132
1266
  successListeners.clear();
1133
1267
  errorListeners.clear();
1134
1268
  stateListeners.clear();