webspresso 0.0.110 → 0.0.111

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.
@@ -15,16 +15,15 @@
15
15
  */
16
16
 
17
17
  const { defaultStorage } = require('./storage');
18
+ const { isArray, isNumber } = require('./utils');
18
19
 
19
20
  const DEVTOOL_HOST_ID = 'webspresso-client-devtools-host';
20
21
 
21
- const PKG_VERSION = (() => {
22
- try {
23
- return require('../../package.json').version || '0.0.110';
24
- } catch (_) {
25
- return '0.0.110';
26
- }
27
- })();
22
+ const PKG_VERSION =
23
+ (typeof window !== 'undefined' &&
24
+ window.__WEBSPRESSO_DEV_META__ &&
25
+ window.__WEBSPRESSO_DEV_META__.version) ||
26
+ '0.0.110';
28
27
 
29
28
  /**
30
29
  * Connect to Redux DevTools browser extension if installed.
@@ -176,10 +175,61 @@ function mountDevtool(runtime, options = {}) {
176
175
  const position = options.position || 'bottom-right';
177
176
  const isRight = position.includes('right');
178
177
  const studioUrl = options.studioUrl || 'http://localhost:4567';
178
+ let metaOverrides =
179
+ options.meta && typeof options.meta === 'object' ? { ...options.meta } : {};
179
180
  let isOpen = Boolean(options.open);
180
181
  let activeTab = 'controllers'; // 'controllers' | 'fragments' | 'network' | 'storage' | 'studio'
181
182
  let selectedControllerId = null;
182
183
 
184
+ function resolveDevFeatures() {
185
+ const winMeta =
186
+ typeof window !== 'undefined' &&
187
+ window.__WEBSPRESSO_DEV_META__ &&
188
+ typeof window.__WEBSPRESSO_DEV_META__ === 'object'
189
+ ? window.__WEBSPRESSO_DEV_META__
190
+ : {};
191
+ const merged = { ...winMeta, ...metaOverrides };
192
+ const pluginsList = isArray(merged.plugins)
193
+ ? merged.plugins
194
+ : isArray(options.plugins)
195
+ ? options.plugins
196
+ : [];
197
+
198
+ const hasStudio =
199
+ options.hasStudio !== undefined
200
+ ? Boolean(options.hasStudio)
201
+ : merged.hasStudio !== undefined
202
+ ? Boolean(merged.hasStudio)
203
+ : merged.hasDb !== undefined
204
+ ? Boolean(merged.hasDb)
205
+ : Boolean(
206
+ options.studioUrl ||
207
+ pluginsList.includes('studio') ||
208
+ pluginsList.includes('db-studio')
209
+ );
210
+
211
+ const hasAdmin =
212
+ options.hasAdmin !== undefined
213
+ ? Boolean(options.hasAdmin)
214
+ : merged.hasAdmin !== undefined
215
+ ? Boolean(merged.hasAdmin)
216
+ : Boolean(
217
+ options.adminPath ||
218
+ options.adminUrl ||
219
+ pluginsList.includes('admin-panel') ||
220
+ pluginsList.includes('adminPanel') ||
221
+ pluginsList.includes('admin')
222
+ );
223
+
224
+ const adminPath = merged.adminPath || options.adminPath || options.adminUrl || '/_admin';
225
+ return {
226
+ hasStudio,
227
+ hasAdmin,
228
+ adminPath,
229
+ plugins: pluginsList,
230
+ };
231
+ }
232
+
183
233
  // Initialize Redux DevTools extension bridge if available
184
234
  const reduxBridge = setupReduxDevToolsBridge(runtime);
185
235
 
@@ -257,6 +307,8 @@ function mountDevtool(runtime, options = {}) {
257
307
  flex-direction: column;
258
308
  overflow: hidden;
259
309
  z-index: 999999;
310
+ }
311
+ .ws-drawer.ws-drawer-enter {
260
312
  animation: wsFadeIn 0.15s ease-out;
261
313
  }
262
314
  @keyframes wsFadeIn {
@@ -753,33 +805,46 @@ function mountDevtool(runtime, options = {}) {
753
805
  }
754
806
 
755
807
  function renderStudioTab() {
756
- return `
757
- <div>
758
- <div style="margin-bottom:16px;">
759
- <strong style="font-size:13px;color:#fff;">Developer Tools & Studio Integrations</strong>
760
- <p style="font-size:10px;color:#71717a;">Direct shortcuts to Webspresso's integrated developer productivity tools.</p>
761
- </div>
808
+ const features = resolveDevFeatures();
762
809
 
810
+ const studioCardHtml = features.hasStudio
811
+ ? `
763
812
  <div class="ws-card" style="margin-bottom:12px;display:flex;align-items:center;justify-content:space-between;">
764
813
  <div>
765
814
  <div style="font-weight:700;color:#fbbf24;font-size:13px;">🗄️ Webspresso DB Studio</div>
766
815
  <div style="font-size:10px;color:#a1a1aa;margin-top:2px;">Visual database browser, query runner, and table inspector.</div>
767
816
  <div style="font-size:9px;color:#71717a;margin-top:4px;">Run in terminal: <code>npx webspresso studio</code> (port 4567)</div>
768
817
  </div>
769
- <a href="${studioUrl}" target="_blank" rel="noopener noreferrer" class="ws-btn ws-btn-primary" style="text-decoration:none;">
818
+ <a href="${studioUrl}" target="_blank" rel="noopener noreferrer" class="ws-btn ws-btn-primary" id="ws-btn-open-studio" style="text-decoration:none;">
770
819
  Open DB Studio ↗
771
820
  </a>
772
821
  </div>
822
+ `
823
+ : '';
773
824
 
825
+ const adminCardHtml = features.hasAdmin
826
+ ? `
774
827
  <div class="ws-card" style="margin-bottom:12px;display:flex;align-items:center;justify-content:space-between;">
775
828
  <div>
776
829
  <div style="font-weight:700;color:#38bdf8;font-size:13px;">⚙️ Admin Panel SPA</div>
777
830
  <div style="font-size:10px;color:#a1a1aa;margin-top:2px;">Mithril.js model CRUD and staff administration cockpit.</div>
778
831
  </div>
779
- <a href="/_admin" target="_blank" rel="noopener noreferrer" class="ws-btn" style="text-decoration:none;">
780
- Open /_admin ↗
832
+ <a href="${features.adminPath}" target="_blank" rel="noopener noreferrer" class="ws-btn" id="ws-btn-open-admin" style="text-decoration:none;">
833
+ Open ${features.adminPath} ↗
781
834
  </a>
782
835
  </div>
836
+ `
837
+ : '';
838
+
839
+ return `
840
+ <div>
841
+ <div style="margin-bottom:16px;">
842
+ <strong style="font-size:13px;color:#fff;">Developer Tools & Studio Integrations</strong>
843
+ <p style="font-size:10px;color:#71717a;">Direct shortcuts to Webspresso's integrated developer productivity tools.</p>
844
+ </div>
845
+
846
+ ${studioCardHtml}
847
+ ${adminCardHtml}
783
848
 
784
849
  <div class="ws-card" style="display:flex;align-items:center;justify-content:space-between;">
785
850
  <div>
@@ -859,6 +924,9 @@ function mountDevtool(runtime, options = {}) {
859
924
  `;
860
925
  }
861
926
 
927
+ let lastRenderedHtml = '';
928
+ let wasOpen = false;
929
+
862
930
  function findInShadow(id) {
863
931
  if (shadow && typeof shadow.getElementById === 'function') {
864
932
  return shadow.getElementById(id);
@@ -866,8 +934,39 @@ function mountDevtool(runtime, options = {}) {
866
934
  return shadow && typeof shadow.querySelector === 'function' ? shadow.querySelector(`#${id}`) : null;
867
935
  }
868
936
 
869
- function updateDom() {
870
- container.innerHTML = isOpen ? renderDrawer() : renderPill();
937
+ function updateDom(force = false) {
938
+ const nextHtml = isOpen ? renderDrawer() : renderPill();
939
+ if (!force && nextHtml === lastRenderedHtml && isOpen === wasOpen) {
940
+ return;
941
+ }
942
+
943
+ // Preserve scroll positions inside drawer before updating DOM
944
+ let prevContentScrollTop = 0;
945
+ const prevContentEl =
946
+ isOpen && wasOpen && shadow && typeof shadow.querySelector === 'function'
947
+ ? shadow.querySelector('.ws-content')
948
+ : null;
949
+ if (prevContentEl) {
950
+ prevContentScrollTop = prevContentEl.scrollTop || 0;
951
+ }
952
+
953
+ lastRenderedHtml = nextHtml;
954
+ const openingTransition = isOpen && !wasOpen;
955
+ wasOpen = isOpen;
956
+
957
+ container.innerHTML = nextHtml;
958
+ if (openingTransition) {
959
+ const drawerEl = findInShadow('ws-devtool-drawer');
960
+ if (drawerEl && drawerEl.classList) {
961
+ drawerEl.classList.add('ws-drawer-enter');
962
+ }
963
+ }
964
+ if (prevContentScrollTop > 0 && shadow && typeof shadow.querySelector === 'function') {
965
+ const nextContentEl = shadow.querySelector('.ws-content');
966
+ if (nextContentEl) {
967
+ nextContentEl.scrollTop = prevContentScrollTop;
968
+ }
969
+ }
871
970
  bindEvents();
872
971
  }
873
972
 
@@ -1043,7 +1142,7 @@ function mountDevtool(runtime, options = {}) {
1043
1142
  document.addEventListener('keydown', onKeyDown);
1044
1143
 
1045
1144
  // Periodic polling to keep snapshots live
1046
- const pollIntervalMs = typeof options.pollInterval === 'number' ? options.pollInterval : 2000;
1145
+ const pollIntervalMs = isNumber(options.pollInterval) ? options.pollInterval : 2000;
1047
1146
  let pollTimer = null;
1048
1147
  if (pollIntervalMs > 0) {
1049
1148
  pollTimer = setInterval(() => {
@@ -1076,6 +1175,12 @@ function mountDevtool(runtime, options = {}) {
1076
1175
  isOpen() {
1077
1176
  return isOpen;
1078
1177
  },
1178
+ setMeta(nextMeta = {}) {
1179
+ if (nextMeta && typeof nextMeta === 'object') {
1180
+ metaOverrides = { ...metaOverrides, ...nextMeta };
1181
+ updateDom();
1182
+ }
1183
+ },
1079
1184
  getSnapshot() {
1080
1185
  return {
1081
1186
  controllers: getControllersSnapshot(),
@@ -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,11 +417,15 @@ 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')) ||
@@ -615,32 +620,32 @@ function createFormHandle(formEl, options = {}) {
615
620
  * Toggle pending state on the form and submit buttons to prevent double submissions.
616
621
  */
617
622
  function setPendingState(nextPending, activeSubmitter = null) {
618
- pending = Boolean(nextPending);
623
+ const next = Boolean(nextPending);
624
+ if (pending === next) return;
625
+ pending = next;
619
626
  if (!formEl) return;
620
627
 
621
628
  if (pending) {
622
629
  formEl.setAttribute('aria-busy', 'true');
623
630
  formEl.setAttribute('data-w-pending', 'true');
631
+ formEl.setAttribute('data-w-busy', 'true');
624
632
  } else {
625
633
  formEl.removeAttribute('aria-busy');
626
634
  formEl.removeAttribute('data-w-pending');
635
+ formEl.removeAttribute('data-w-busy');
627
636
  }
628
637
 
629
638
  const submitButtons = formEl.querySelectorAll
630
639
  ? formEl.querySelectorAll('button[type="submit"], button:not([type]), input[type="submit"]')
631
640
  : [];
641
+ let submitterHandled = false;
632
642
  for (let i = 0; i < submitButtons.length; i++) {
633
643
  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
- }
644
+ if (btn === activeSubmitter) submitterHandled = true;
645
+ setElementBusyState(btn, pending);
641
646
  }
642
- if (activeSubmitter && !activeSubmitter.hasAttribute('data-w-disabled')) {
643
- activeSubmitter.disabled = pending;
647
+ if (activeSubmitter && !submitterHandled) {
648
+ setElementBusyState(activeSubmitter, pending);
644
649
  }
645
650
  notifyStateChange();
646
651
  }
@@ -734,7 +739,7 @@ function createFormHandle(formEl, options = {}) {
734
739
  if (type === 'file') continue; // Browser security prevents setting file input value
735
740
 
736
741
  if (type === 'checkbox') {
737
- if (Array.isArray(val)) {
742
+ if (isArray(val)) {
738
743
  ctrl.checked = val.map(String).includes(String(ctrl.value));
739
744
  } else {
740
745
  ctrl.checked = Boolean(val);
@@ -743,7 +748,7 @@ function createFormHandle(formEl, options = {}) {
743
748
  ctrl.checked = String(val) === String(ctrl.value);
744
749
  } else if (ctrl.tagName === 'SELECT') {
745
750
  if (ctrl.multiple) {
746
- const arr = Array.isArray(val) ? val.map(String) : [String(val)];
751
+ const arr = isArray(val) ? val.map(String) : [String(val)];
747
752
  const opts = ctrl.options || [];
748
753
  for (let j = 0; j < opts.length; j++) {
749
754
  opts[j].selected = arr.includes(String(opts[j].value));
@@ -923,9 +928,18 @@ function createFormHandle(formEl, options = {}) {
923
928
  }
924
929
 
925
930
  // 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!
931
+ // or side-effect service error in onSuccess NEVER puts a succeeded form into validation error
932
+ // state or makes the completed mutation appear rolled back!
933
+ let hookError = null;
927
934
  for (const listener of successListeners) {
928
- await listener(responseData, { values: payloadData, form: handle });
935
+ try {
936
+ await listener(responseData, { values: payloadData, form: handle, services });
937
+ } catch (listenerErr) {
938
+ if (!hookError) hookError = listenerErr;
939
+ if (debug && typeof debug.trackCleanupError === 'function') {
940
+ debug.trackCleanupError(`form_${formName}`, formName, 'form:onSuccess', listenerErr);
941
+ }
942
+ }
929
943
  }
930
944
 
931
945
  // Explicit redirect behavior
@@ -951,7 +965,7 @@ function createFormHandle(formEl, options = {}) {
951
965
  }
952
966
  }
953
967
 
954
- return { ok: true, submitted: true, data: responseData, refreshOk, refreshError };
968
+ return { ok: true, submitted: true, data: responseData, refreshOk, refreshError, hookError };
955
969
  } catch (err) {
956
970
  if (err && err.kind === 'validation') {
957
971
  fieldErrors = { ...(err.fieldErrors || {}) };
@@ -966,7 +980,7 @@ function createFormHandle(formEl, options = {}) {
966
980
 
967
981
  for (const listener of errorListeners) {
968
982
  try {
969
- await listener(err, { values: payloadData, form: handle });
983
+ await listener(err, { values: payloadData, form: handle, services });
970
984
  } catch (_e) {
971
985
  // Ignore
972
986
  }
@@ -1035,6 +1049,12 @@ function createFormHandle(formEl, options = {}) {
1035
1049
  handle = {
1036
1050
  name: formName,
1037
1051
  el: formEl,
1052
+ get services() {
1053
+ return services;
1054
+ },
1055
+ __setServices(nextServices) {
1056
+ if (nextServices) services = nextServices;
1057
+ },
1038
1058
  get pending() {
1039
1059
  return pending;
1040
1060
  },