domma-cms 1.6.55 → 1.6.57

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.
Files changed (113) hide show
  1. package/admin/css/calculator.css +1 -1
  2. package/admin/js/lib/analytics-shape.js +1 -1
  3. package/admin/js/lib/calculator-engine.js +1 -1
  4. package/admin/js/lib/calculator.js +22 -10
  5. package/admin/js/lib/form-editor-arrange.js +1 -1
  6. package/admin/js/lib/forms-arrange.js +1 -1
  7. package/admin/js/lib/notes-arrange.js +1 -1
  8. package/admin/js/lib/search-settings-arrange.js +1 -1
  9. package/admin/js/lib/seo-arrange.js +1 -0
  10. package/admin/js/lib/todo-arrange.js +1 -1
  11. package/admin/js/lib/tool-kit.js +1 -1
  12. package/admin/js/lib/tool-recovery.js +4 -4
  13. package/admin/js/templates/analytics.html +26 -14
  14. package/admin/js/templates/contacts.html +26 -5
  15. package/admin/js/templates/form-editor.html +2 -0
  16. package/admin/js/templates/notes.html +11 -8
  17. package/admin/js/templates/search.html +9 -3
  18. package/admin/js/templates/seo.html +23 -17
  19. package/admin/js/templates/todo.html +18 -14
  20. package/admin/js/views/analytics.js +10 -10
  21. package/admin/js/views/contacts.js +47 -47
  22. package/admin/js/views/form-editor.js +7 -7
  23. package/admin/js/views/form-submissions.js +2 -2
  24. package/admin/js/views/forms.js +5 -5
  25. package/admin/js/views/notes.js +6 -6
  26. package/admin/js/views/search.js +2 -2
  27. package/admin/js/views/seo.js +43 -43
  28. package/admin/js/views/todo.js +8 -8
  29. package/package.json +1 -1
  30. package/plugins/_lib/admin/mail/compose-window.js +4 -1
  31. package/plugins/_lib/admin/mail/mail.css +1 -1
  32. package/plugins/_lib/admin/mail/reader-kit.js +364 -0
  33. package/plugins/_lib/admin/mail/reader-view.js +1009 -161
  34. package/plugins/_lib/admin/mail/when-closed.js +33 -3
  35. package/plugins/_lib/mail/readRoutes.js +48 -10
  36. package/plugins/_lib/mail/unreadBadge.js +4 -1
  37. package/plugins/blog/CLAUDE.md +14 -0
  38. package/plugins/blog/admin/css/index.css +5 -0
  39. package/plugins/blog/admin/lib/drafts.js +95 -0
  40. package/plugins/blog/admin/templates/blog.html +1 -1
  41. package/plugins/blog/admin/templates/categories.html +1 -1
  42. package/plugins/blog/admin/templates/comments.html +1 -1
  43. package/plugins/blog/admin/templates/post-editor.html +5 -1
  44. package/plugins/blog/admin/views/blog.js +44 -19
  45. package/plugins/blog/admin/views/categories.js +138 -20
  46. package/plugins/blog/admin/views/comments.js +32 -23
  47. package/plugins/blog/admin/views/kit.js +232 -22
  48. package/plugins/blog/admin/views/post-editor.js +348 -178
  49. package/plugins/blog/admin/views/settings.js +17 -3
  50. package/plugins/blog/plugin.js +1 -1
  51. package/plugins/blog/plugin.json +7 -7
  52. package/plugins/blog/plugin.public.js +1 -1
  53. package/plugins/blog/tests/drafts.test.js +50 -0
  54. package/plugins/booking/CLAUDE.md +13 -0
  55. package/plugins/booking/admin/lib/drafts.js +139 -0
  56. package/plugins/booking/admin/lib/kit.js +246 -25
  57. package/plugins/booking/admin/views/booking.js +11 -2
  58. package/plugins/booking/admin/views/bookings.js +7 -2
  59. package/plugins/booking/admin/views/diary.js +13 -6
  60. package/plugins/booking/admin/views/panels.js +85 -12
  61. package/plugins/booking/admin/views/people.js +41 -2
  62. package/plugins/booking/admin/views/services.js +45 -3
  63. package/plugins/booking/admin/views/settings.js +22 -2
  64. package/plugins/booking/plugin.json +2 -2
  65. package/plugins/booking/tests/admin-drafts.test.js +67 -0
  66. package/plugins/feedback/CLAUDE.md +12 -0
  67. package/plugins/feedback/admin/css/index.css +5 -0
  68. package/plugins/feedback/admin/lib/drafts.js +145 -0
  69. package/plugins/feedback/admin/lib/kit.js +161 -5
  70. package/plugins/feedback/admin/lib/settings.js +46 -28
  71. package/plugins/feedback/admin/views/feedback.js +1 -1
  72. package/plugins/feedback/admin/views/inbox.js +110 -46
  73. package/plugins/feedback/admin/views/send.js +174 -83
  74. package/plugins/feedback/plugin.json +3 -3
  75. package/plugins/feedback/tests/drafts.test.js +93 -0
  76. package/plugins/free-tier.lock.json +75 -63
  77. package/plugins/security/CLAUDE.md +9 -0
  78. package/plugins/security/admin/lib/screen-state.js +120 -0
  79. package/plugins/security/admin/templates/security.html +15 -10
  80. package/plugins/security/admin/views/security.js +270 -132
  81. package/plugins/security/plugin.json +2 -2
  82. package/plugins/security/tests/screen-state.test.js +69 -0
  83. package/plugins/shopping-cart/CLAUDE.md +13 -0
  84. package/plugins/shopping-cart/admin/css/index.css +21 -5
  85. package/plugins/shopping-cart/admin/lib/drafts.js +174 -0
  86. package/plugins/shopping-cart/admin/lib/kit.js +136 -28
  87. package/plugins/shopping-cart/admin/views/orders.js +117 -36
  88. package/plugins/shopping-cart/admin/views/overview.js +38 -8
  89. package/plugins/shopping-cart/admin/views/products.js +317 -68
  90. package/plugins/shopping-cart/admin/views/sales.js +21 -3
  91. package/plugins/shopping-cart/admin/views/settings.js +62 -3
  92. package/plugins/shopping-cart/admin/views/shop.js +18 -3
  93. package/plugins/shopping-cart/plugin.json +3 -3
  94. package/plugins/shopping-cart/tests/accounts.test.js +11 -0
  95. package/plugins/shopping-cart/tests/drafts.test.js +118 -0
  96. package/plugins/support/CLAUDE.md +14 -0
  97. package/plugins/support/admin/css/index.css +10 -0
  98. package/plugins/support/admin/lib/drafts.js +162 -0
  99. package/plugins/support/admin/lib/kit.js +156 -16
  100. package/plugins/support/admin/lib/settings.js +52 -29
  101. package/plugins/support/admin/views/desk.js +114 -33
  102. package/plugins/support/admin/views/inbox.js +85 -26
  103. package/plugins/support/admin/views/support.js +1 -1
  104. package/plugins/support/plugin.json +3 -3
  105. package/plugins/support/tests/drafts.test.js +92 -0
  106. package/public/js/site.js +1 -1
  107. package/server/routes/api/analytics.js +5 -1
  108. package/server/routes/api/contacts.js +42 -0
  109. package/server/routes/api/forms.js +8 -0
  110. package/server/routes/api/seo.js +4 -1
  111. package/server/routes/api/todo.js +50 -10
  112. package/server/server.js +1 -1
  113. package/server/templates/page.html +1 -1
@@ -24,6 +24,8 @@ export async function mount(root, ctx) {
24
24
  <button type="button" class="btn btn-sm btn-outline" data-act="csv"><span data-icon="download" data-icon-size="13"></span> CSV</button>
25
25
  </div>
26
26
  <p class="shp-muted" data-part="span"></p>
27
+ <div class="shp-loading-slot" data-part="loading">Loading…</div>
28
+ <p class="shp-error" data-part="error" hidden></p>
27
29
  <div class="shp-tiles" data-part="tiles"></div>
28
30
  <section class="shp-card shp-chart-card"><header><h3>Takings</h3><span class="shp-muted" data-part="chart-total"></span></header><div class="shp-chart" data-part="chart"></div></section>
29
31
  <div class="shp-sales-cols">
@@ -97,14 +99,30 @@ export async function mount(root, ctx) {
97
99
  I.scan(root);
98
100
  }
99
101
 
102
+ // The first load runs under a skeleton; a new range or grouping keeps the figures on screen until the next arrive.
103
+ let first = true;
104
+ let asked = 0;
100
105
  async function load() {
106
+ const mine = ++asked;
107
+ root.querySelector('.shp-sales').classList.toggle('is-busy', !first);
101
108
  try {
102
- report = await api.sales(q);
103
- if (!here()) return;
109
+ const res = first ? await kit.whileLoading(part('loading'), api.sales(q), kit.SHAPES.tiles) : await api.sales(q);
110
+ // Gone, or a later range was asked for meanwhile: this answer is not wanted.
111
+ if (!here() || mine !== asked) return;
112
+ report = res;
113
+ part('error').hidden = true;
104
114
  try { sessionStorage.setItem('shop-sales', JSON.stringify({range: q.range, by: q.by})); } catch { /* private mode */ }
105
115
  render();
106
116
  } catch (err) {
107
- part('tiles').textContent = `Sales could not load: ${err.message || err}`;
117
+ if (!here() || mine !== asked) return;
118
+ part('error').hidden = false;
119
+ part('error').textContent = `Sales could not load: ${err.message || err}`;
120
+ } finally {
121
+ if (here() && mine === asked) {
122
+ first = false;
123
+ part('loading').hidden = true;
124
+ root.querySelector('.shp-sales').classList.remove('is-busy');
125
+ }
108
126
  }
109
127
  }
110
128
 
@@ -3,9 +3,16 @@
3
3
  * Payment, each field explained in place. Secrets arrive masked and are never
4
4
  * shown; leaving a mask as it is keeps the stored key. "Test" tries the keys
5
5
  * typed (before saving) against Stripe or PayPal.
6
+ *
7
+ * What is typed is kept as a draft while the panel is open - WITHOUT the
8
+ * payment keys, which never reach the browser's storage - so leaving the
9
+ * screen loses nothing; opening the settings again asks whether to throw
10
+ * those changes away (OK) or keep them (Cancel / Escape), and says when the
11
+ * settings have been changed since.
6
12
  */
7
13
 
8
- import {api, copy, esc, moneyIn, moneyOut, openPanel, setCurrency} from '../lib/kit.js';
14
+ import {api, ask, copy, esc, moneyIn, moneyOut, openPanel, setCurrency, store} from '../lib/kit.js';
15
+ import {SETTINGS_DRAFT_KEY, draftDiffers, draftValues, isDraft, keepableSettings, settingsDraftQuestion, settingsFingerprint, stableJson} from '../lib/drafts.js';
9
16
  import {CURRENCIES} from '/plugins/shopping-cart/public/lib/money.js';
10
17
  import {COUNTRIES} from '/plugins/shopping-cart/public/lib/orders.js';
11
18
 
@@ -19,9 +26,16 @@ const MONEY_KEYS = ['shippingFlat', 'freeShippingOver'];
19
26
  const BOOL_KEYS = ['showSearch', 'showCategories', 'floatingCart', 'showStockLeft', 'accounts', 'allowCollection', 'pricesIncludeTax', 'taxShipping',
20
27
  'stripeEnabled', 'paypalEnabled', 'offlineEnabled'];
21
28
 
29
+ /**
30
+ * Open the settings panel. `ctx.here()` says whether the Shop screen is still
31
+ * up; the returned promise resolves with the panel handle (null if it never
32
+ * opened), so the caller can close it when the screen goes.
33
+ */
22
34
  export async function openSettings(ctx, section = 'store') {
35
+ const here = ctx.here || (() => true);
23
36
  let info;
24
- try { info = await api.settings.get(); } catch (err) { E.toast(`Settings did not load: ${err.message || err}`, {type: 'error'}); return; }
37
+ try { info = await api.settings.get(); } catch (err) { if (here()) E.toast(`Settings did not load: ${err.message || err}`, {type: 'error'}); return null; }
38
+ if (!here()) return null;
25
39
  const s = info.settings;
26
40
  const f = {};
27
41
  for (const [k, v] of Object.entries(s)) f[k] = M.observable(MONEY_KEYS.includes(k) ? moneyIn(v) : Array.isArray(v) ? [...v] : v);
@@ -30,6 +44,37 @@ export async function openSettings(ctx, section = 'store') {
30
44
  const testing = M.observable('');
31
45
  const snapshot = () => JSON.stringify(Object.fromEntries(Object.entries(f).map(([k, o]) => [k, o.peek()])));
32
46
  const opened = snapshot();
47
+ const formValues = () => keepableSettings(Object.fromEntries(Object.entries(f).map(([k, o]) => [k, o.peek()])));
48
+ const recordValues = formValues();
49
+ const recordJson = stableJson(recordValues);
50
+ let was = settingsFingerprint(s);
51
+
52
+ // A kept draft: asked about first; removed only once the person has answered (leaving while asked keeps it).
53
+ const kept = store.get(SETTINGS_DRAFT_KEY);
54
+ let restored = false;
55
+ if (isDraft(kept) && draftDiffers(kept, recordValues)) {
56
+ const q = settingsDraftQuestion(kept, was);
57
+ const question = ask(q.text, q.options);
58
+ ctx.scope?.(() => question.dismiss());
59
+ const throwAway = await question.answer;
60
+ if (!here()) return null;
61
+ if (throwAway) store.remove(SETTINGS_DRAFT_KEY);
62
+ else {
63
+ for (const [k, v] of Object.entries(draftValues(kept, recordValues))) f[k].value = Array.isArray(v) ? [...v] : v;
64
+ was = kept.was ?? was; // a picked-up draft keeps its own `was`
65
+ restored = true;
66
+ }
67
+ } else if (kept) store.remove(SETTINGS_DRAFT_KEY);
68
+
69
+ // Rewritten on every change while open; never holds a payment key.
70
+ let drafting = true;
71
+ const keepDraft = () => {
72
+ if (!drafting) return;
73
+ const v = formValues();
74
+ if (stableJson(v) === recordJson) store.remove(SETTINGS_DRAFT_KEY);
75
+ else store.set(SETTINGS_DRAFT_KEY, {values: v, was, at: new Date().toISOString()});
76
+ };
77
+ const stopDraft = M.effect(() => { Object.values(f).forEach(o => o.value); keepDraft(); });
33
78
 
34
79
  const model = {
35
80
  ...f, tab, saving, testing,
@@ -54,6 +99,7 @@ export async function openSettings(ctx, section = 'store') {
54
99
 
55
100
  const check = (k, label, help = '') => `<label class="shp-check"><input type="checkbox" data-bool="${k}"> ${label}${help ? ` <span data-help="${esc(help)}"></span>` : ''}</label>`;
56
101
  const html = `<div class="shp-form shp-settings">
102
+ ${restored ? '<p class="shp-callout shp-restored">These are the changes you left unsaved (payment keys are never kept). Save to keep them, or close and discard them.</p>' : ''}
57
103
  <div class="shp-seg shp-settings-nav" role="tablist">
58
104
  ${SECTIONS.map(x => `<button type="button" role="tab" data-tab="${x.key}" data-bind-class="tab.value === '${x.key}' && 'is-on'"><span data-icon="${x.icon}" data-icon-size="13"></span> ${x.label}</button>`).join('')}
59
105
  </div>
@@ -192,6 +238,9 @@ export async function openSettings(ctx, section = 'store') {
192
238
  html,
193
239
  model,
194
240
  dirty: () => snapshot() !== opened,
241
+ busy: () => saving.peek(),
242
+ discarded: () => { drafting = false; store.remove(SETTINGS_DRAFT_KEY); },
243
+ cleanup: () => { drafting = false; stopDraft(); },
195
244
  before: (el) => {
196
245
  for (const box of el.querySelectorAll('[data-bool]')) {
197
246
  box.checked = Boolean(f[box.dataset.bool].peek());
@@ -218,6 +267,7 @@ export async function openSettings(ctx, section = 'store') {
218
267
  testStripe: () => test('stripe'),
219
268
  testPaypal: () => test('paypal'),
220
269
  async save() {
270
+ if (saving.peek() || !panel?.isOpen()) return;
221
271
  const out = {};
222
272
  for (const [k, o] of Object.entries(f)) out[k] = o.peek();
223
273
  for (const k of MONEY_KEYS) {
@@ -229,21 +279,30 @@ export async function openSettings(ctx, section = 'store') {
229
279
  out.columns = Number(out.columns);
230
280
  out.taxRate = Number(out.taxRate) || 0;
231
281
  out.lowStockAt = Number(out.lowStockAt) || 0;
282
+ // The panel closes after a save, so the form is locked while it runs: nothing typed meanwhile is lost.
232
283
  saving.value = true;
284
+ panel.lock(true);
285
+ const sent = formValues();
233
286
  try {
234
287
  const r = await api.settings.save(out);
235
288
  Object.assign(ctx.settings, r.settings);
236
289
  setCurrency(r.settings.currency);
290
+ drafting = false;
291
+ if (stableJson(store.get(SETTINGS_DRAFT_KEY)?.values) === stableJson(sent)) store.remove(SETTINGS_DRAFT_KEY);
237
292
  E.toast('Shop settings saved.', {type: 'success'});
238
293
  const moved = r.settings.currency !== s.currency || r.settings.shopPath !== s.shopPath;
294
+ saving.value = false;
239
295
  panel.close(true);
240
- if (moved) location.reload();
296
+ if (moved && here()) location.reload();
241
297
  } catch (err) {
242
298
  E.toast(`Not saved: ${err.message || err}`, {type: 'error', duration: 8000});
243
299
  } finally {
244
300
  saving.value = false;
301
+ if (panel.isOpen()) panel.lock(false);
245
302
  }
246
303
  }
247
304
  }
248
305
  });
306
+ if (!here()) panel.close(true);
307
+ return panel;
249
308
  }
@@ -12,7 +12,7 @@
12
12
  * Settings are behind the banner's cog, in a slideover.
13
13
  */
14
14
 
15
- import {api, loadKit, setCurrency, store} from '../lib/kit.js';
15
+ import {api, claims, loadKit, setCurrency, store} from '../lib/kit.js';
16
16
 
17
17
  const ROUTE = /^#\/plugins\/shopping-cart(\/|$)/;
18
18
  const V = '1.6.2';
@@ -51,6 +51,7 @@ export const shopView = {
51
51
  try {
52
52
  [settingsRes, ext] = await Promise.all([api.settings.get(), api.extensions().catch(() => ({tabs: []}))]);
53
53
  } catch (err) {
54
+ if (!here()) return;
54
55
  root.innerHTML = '';
55
56
  const p = document.createElement('p');
56
57
  p.className = 'shp-error';
@@ -100,12 +101,26 @@ export const shopView = {
100
101
  root.append(nav, body);
101
102
  I.scan(nav);
102
103
 
104
+ // The settings panel: one at a time, closed when the screen goes.
105
+ const once = claims();
106
+ let settingsPanel = null;
107
+ scope(() => { try { settingsPanel?.close(true); } catch { /* gone */ } });
103
108
  const ctx = {
104
109
  kit, scope, here, root: body, container, settings, settingsInfo: settingsRes, id, extensions: ext,
105
110
  go(t, i = '') { location.hash = `#/plugins/shopping-cart${t && t !== 'overview' ? `/${t}` : ''}${i ? `/${encodeURIComponent(i)}` : ''}`; },
106
111
  async openSettings(section) {
107
- const m = await import(`/plugins/shopping-cart/admin/views/settings.js?v=${V}`);
108
- m.openSettings(ctx, section);
112
+ if (settingsPanel?.isOpen()) return;
113
+ const release = once.take('settings');
114
+ if (!release) return;
115
+ try {
116
+ const m = await import(`/plugins/shopping-cart/admin/views/settings.js?v=${V}`);
117
+ if (!here()) return;
118
+ settingsPanel = await m.openSettings(ctx, section);
119
+ } catch (err) {
120
+ if (here()) E.toast(`Settings could not open: ${err.message || err}`, {type: 'error'});
121
+ } finally {
122
+ release();
123
+ }
109
124
  }
110
125
  };
111
126
 
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "shopping-cart",
3
3
  "displayName": "Shopping Cart",
4
- "version": "1.6.3",
4
+ "version": "1.7.0",
5
5
  "tier": "free",
6
6
  "description": "Sell from your site: products with options, stock and pictures, a storefront and a cart that follows shoppers from page to page, and checkout with Stripe, PayPal or payment offline. Orders to fulfil in the sidebar, refunds from the order.",
7
7
  "author": "Domma CMS",
8
- "date": "2026-10-13",
8
+ "date": "2026-10-15",
9
9
  "icon": "shopping-bag",
10
10
  "permissions": [
11
11
  {
@@ -45,7 +45,7 @@
45
45
  ],
46
46
  "views": {
47
47
  "plugin-shopping-cart": {
48
- "entry": "shopping-cart/admin/views/shop.js?v=1.6.2",
48
+ "entry": "shopping-cart/admin/views/shop.js?v=1.7.0",
49
49
  "exportName": "shopView"
50
50
  }
51
51
  }
@@ -2,6 +2,8 @@
2
2
  * Customer accounts against the real user and collection services in the domma-cms checkout `make test` runs in.
3
3
  * The test's authenticate reads `x-user` (a real user id) the way core's reads a token; mail is caught with
4
4
  * setAccountMailer. Every user it makes, and its collections, are removed; config/plugins.json is restored.
5
+ * Emailed links need a trusted address (1.6.1: never a request's Host), so the test gives the site a Site URL
6
+ * (config/site.json baseUrl) and puts the file back afterwards.
5
7
  */
6
8
  import {test, before, after} from 'node:test';
7
9
  import assert from 'node:assert/strict';
@@ -14,9 +16,12 @@ import {deleteUser, getUserByEmail, getUserById} from '../../../server/services/
14
16
  import {checkResetToken} from '../../../server/services/passwordReset.js';
15
17
 
16
18
  const PLUGINS = path.resolve('config/plugins.json');
19
+ const SITE = path.resolve('config/site.json');
20
+ const SITE_URL = 'https://shop.example.test';
17
21
  const DIRS = ['shop-products', 'shop-orders'].map(c => path.resolve('content/collections', c));
18
22
  const EMAILS = ['acc-ann@example.test', 'acc-bo@example.test'];
19
23
  let saved;
24
+ let savedSite;
20
25
  let app;
21
26
  const mail = [];
22
27
 
@@ -26,6 +31,10 @@ async function cleanUsers() {
26
31
 
27
32
  before(async () => {
28
33
  saved = await fs.readFile(PLUGINS, 'utf8').catch(() => null);
34
+ savedSite = await fs.readFile(SITE, 'utf8').catch(() => null);
35
+ let site = {};
36
+ try { site = JSON.parse(savedSite || '{}'); } catch { /* replaced below, restored after */ }
37
+ await fs.writeFile(SITE, JSON.stringify({...site, baseUrl: SITE_URL}, null, 2) + '\n');
29
38
  for (const d of DIRS) await fs.rm(d, {recursive: true, force: true});
30
39
  await cleanUsers();
31
40
  setAccountMailer((m) => { mail.push(m); });
@@ -50,6 +59,8 @@ after(async () => {
50
59
  for (const d of DIRS) await fs.rm(d, {recursive: true, force: true});
51
60
  if (saved === null) await fs.rm(PLUGINS, {force: true});
52
61
  else await fs.writeFile(PLUGINS, saved);
62
+ if (savedSite === null) await fs.rm(SITE, {force: true});
63
+ else await fs.writeFile(SITE, savedSite);
53
64
  });
54
65
 
55
66
  const call = async (method, url, body, user = null) => {
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Kept drafts (admin/lib/drafts.js) - pure, no DOM.
3
+ */
4
+ import {test} from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import {readFileSync} from 'node:fs';
7
+ import {fileURLToPath} from 'node:url';
8
+ import {
9
+ NEW_KEY, esc, SECRET_KEYS, adoptAsNew, draftDiffers, draftFate, draftKey, draftValues, isDraft, keepableSettings, productDraftQuestion,
10
+ readMap, replaceNewDraftQuestion, settingsDraftQuestion, settingsFingerprint, stableJson, withDraft
11
+ } from '../admin/lib/drafts.js';
12
+
13
+ const T0 = '2026-10-15T10:00:00.000Z';
14
+ const T1 = '2026-10-15T11:00:00.000Z';
15
+
16
+ test('drafts: keys, the stored map cleaned, one entry changed at a time', () => {
17
+ assert.equal(draftKey(''), NEW_KEY);
18
+ assert.equal(draftKey(null), NEW_KEY);
19
+ assert.equal(draftKey('abc'), 'abc');
20
+ assert.equal(esc('<a href="x">&</a>'), '&lt;a href=&quot;x&quot;&gt;&amp;&lt;/a&gt;');
21
+ assert.deepEqual(readMap(null), {});
22
+ assert.deepEqual(readMap([1, 2]), {});
23
+ assert.deepEqual(readMap({a: {values: {name: 'x'}}, b: 'junk', c: {values: []}}), {a: {values: {name: 'x'}}});
24
+ const raw = {a: {values: {name: 'x'}}};
25
+ const next = withDraft(raw, 'b', {values: {name: 'y'}});
26
+ assert.deepEqual(Object.keys(next).sort(), ['a', 'b']);
27
+ assert.deepEqual(Object.keys(raw), ['a'], 'the input is left alone');
28
+ assert.deepEqual(Object.keys(withDraft(next, 'a', null)), ['b']);
29
+ assert.equal(isDraft({values: {}}), true);
30
+ assert.equal(isDraft({values: null}), false);
31
+ });
32
+
33
+ test('drafts: compared by the form\'s own fields, keys in any order', () => {
34
+ assert.equal(stableJson({b: 1, a: [1, {d: 2, c: 3}]}), stableJson({a: [1, {c: 3, d: 2}], b: 1}));
35
+ const form = {name: 'Mug', price: '12.00'};
36
+ assert.equal(draftDiffers({values: {price: '12.00', name: 'Mug', old: 'x'}}, form), false, 'a field the form no longer has is ignored');
37
+ assert.equal(draftDiffers({values: {name: 'Mug!', price: '12.00'}}, form), true);
38
+ assert.equal(draftDiffers(null, form), false);
39
+ assert.deepEqual(draftValues({values: {name: 'A', old: 1}}, form), {name: 'A'});
40
+ });
41
+
42
+ test('drafts: what became of the record since', () => {
43
+ assert.equal(draftFate({was: T0}, null), 'deleted');
44
+ assert.equal(draftFate({was: T0}, T0), '');
45
+ assert.equal(draftFate({was: T0}, T1), 'changed');
46
+ assert.equal(draftFate({was: null}, T1), '', 'no `was` (a new record) is never "changed"');
47
+ assert.equal(draftFate({was: T0}, ''), '', 'a record with no updatedAt cannot be judged');
48
+ });
49
+
50
+ test('drafts: the restore question puts the throwing away on OK, the keeping on Cancel', () => {
51
+ const d = {values: {name: 'Mug'}, was: T0, at: T1};
52
+ const same = productDraftQuestion(d, {name: 'Mug', updatedAt: T0});
53
+ assert.match(same.text, /^Throw away the changes you left unsaved on “Mug”/);
54
+ assert.equal(same.options.confirmText, 'Throw away');
55
+ assert.equal(same.options.cancelText, 'Keep them');
56
+ assert.equal(same.asNew, false);
57
+ assert.doesNotMatch(same.text, /changed since/);
58
+
59
+ const changed = productDraftQuestion(d, {name: 'Mug', updatedAt: T1});
60
+ assert.match(changed.text, /has been changed since/);
61
+ assert.equal(changed.asNew, false);
62
+
63
+ const gone = productDraftQuestion(d, null);
64
+ assert.match(gone.text, /deleted since/);
65
+ assert.equal(gone.asNew, true);
66
+ assert.equal(gone.options.confirmText, 'Throw away');
67
+ assert.match(gone.options.cancelText, /new product/);
68
+
69
+ const fresh = productDraftQuestion({values: {name: 'Tote'}, was: null, at: T1}, null, {isNew: true});
70
+ assert.match(fresh.text, /a new product \(“Tote”\)/);
71
+ assert.equal(fresh.asNew, false, 'a new product\'s draft is never "deleted since"');
72
+ });
73
+
74
+ test('drafts: names reach the questions escaped (E.confirm renders HTML)', () => {
75
+ const evil = 'Mug <img src=x onerror=alert(1)> & "Co"';
76
+ const d = {values: {name: evil}, was: T0, at: T1};
77
+ for (const q of [productDraftQuestion(d, {name: evil, updatedAt: T0}), productDraftQuestion(d, null),
78
+ productDraftQuestion(d, null, {isNew: true}), replaceNewDraftQuestion(d)]) {
79
+ assert.doesNotMatch(q.text, /<img/);
80
+ assert.match(q.text, /Mug &lt;img src=x onerror=alert\(1\)&gt; &amp; &quot;Co&quot;/);
81
+ }
82
+ });
83
+
84
+ test('drafts: a deleted product\'s draft adopted as new starts with no `was` and no option ids', () => {
85
+ const d = {values: {name: 'Mug', variants: [{id: 'v1', name: 'S'}]}, slugTouched: true, was: T0, at: T1};
86
+ const a = adoptAsNew(d, '2026-10-15T12:00:00.000Z');
87
+ assert.equal(a.was, null);
88
+ assert.equal(a.adopted, true);
89
+ assert.equal(a.at, T1, 'keeps when it was left');
90
+ assert.deepEqual(a.values.variants, [{id: '', name: 'S'}]);
91
+ assert.equal(d.values.variants[0].id, 'v1', 'the original is left alone');
92
+ const q = replaceNewDraftQuestion({values: {name: 'Tote'}, at: T1});
93
+ assert.match(q.text, /already a new product left unsaved - “Tote”/);
94
+ assert.equal(q.options.confirmText, 'Throw it away');
95
+ assert.equal(q.options.cancelText, 'Keep both');
96
+ });
97
+
98
+ test('drafts: settings drafts never hold a payment key', () => {
99
+ const s = {shopTitle: 'Shop', stripeSecretKey: 'sk_test_x', stripeWebhookSecret: 'whsec_x', paypalClientId: 'id', paypalSecret: 'sec', taxRate: 20};
100
+ const kept = keepableSettings(s);
101
+ for (const k of SECRET_KEYS) assert.equal(k in kept, false, k);
102
+ assert.deepEqual(kept, {shopTitle: 'Shop', taxRate: 20});
103
+ assert.equal(settingsFingerprint(s), settingsFingerprint({...s, stripeSecretKey: '••••••••'}), 'a masked key does not count as a change');
104
+ const was = settingsFingerprint(s);
105
+ assert.doesNotMatch(settingsDraftQuestion({values: {}, was, at: T1}, was).text, /changed since/);
106
+ const q = settingsDraftQuestion({values: {}, was, at: T1}, settingsFingerprint({...s, taxRate: 5}));
107
+ assert.match(q.text, /changed since/);
108
+ assert.equal(q.options.confirmText, 'Throw away');
109
+ });
110
+
111
+ test('admin: no restore question makes Cancel (false) the throwing away', () => {
112
+ // E.confirm answers false for Cancel, Escape and the backdrop alike - so "discard" must always be OK.
113
+ for (const f of ['products.js', 'settings.js']) {
114
+ const src = readFileSync(fileURLToPath(new URL(`../admin/views/${f}`, import.meta.url)), 'utf8');
115
+ assert.doesNotMatch(src, /cancelText:\s*'(Discard|Throw away)/, f);
116
+ assert.doesNotMatch(src, /E\.prompt\([^)]*(inputValue|defaultValue)/, `${f}: a starting value goes in through promptWith`);
117
+ }
118
+ });
@@ -140,3 +140,17 @@ every id on that list - so a request Domma deleted left its screenshots on the s
140
140
  narrows `files` with `keepFiles(files, remote, items)` (PURE, tested): only ids in a message of a request the manager
141
141
  still holds (its `/fleet/mine` is complete for the site) or named by an outbox item. The next prune (older than a day)
142
142
  removes the rest. Found on the dcm click-through.
143
+
144
+ ## 1.2.0 (2026-10-15 Tool Upgrade, second pass)
145
+
146
+ - Panels keep drafts under one key per panel and record (`support.draft.*`, `admin/lib/drafts.js`, tested), holding only
147
+ the changed fields, with `was` a fingerprint of the record. The restore question puts "Throw away" on OK, so Cancel,
148
+ Escape and the backdrop keep; a draft goes only after an answer; leaving closes panels and keeps drafts. Picked files
149
+ are never kept in a draft.
150
+ - `kit.guardPanel` must be called after `openPanel`. It can still write after its panel closed: `rebase()` keeps
151
+ what is left unsent, `settle()` removes the draft once a save/send succeeded - call it on every success, or leaving
152
+ mid-send keeps a draft of text already sent (and invites a second send).
153
+ - `rowKeys` stops Enter/Space and runs the row's main action on the next tick: Domma's confirm adds its document
154
+ keydown while the opening Enter is still bubbling and would read it as OK = Throw away.
155
+ - Opening a request: only a 404 counts as deleted; any other failure toasts and keeps the draft. The desk's file picker
156
+ has `drop(ids)` - remove only the attachments that went with the message.
@@ -112,3 +112,13 @@
112
112
  .sp-row { flex-wrap: wrap; }
113
113
  .sp-msg { max-width: 100%; }
114
114
  }
115
+
116
+ /* The first load's skeleton slot (admin/js/lib/loading.js): a block, never a flex row, or it collapses. */
117
+ .sp-loading { display: block; margin: .5rem 0; color: var(--dm-text-secondary); font-size: var(--dm-font-size-sm); }
118
+ /* A panel saving before it closes is locked (inert): show it. */
119
+ .sp-form.is-saving, .sp-detail.is-saving { opacity: .65; cursor: progress; }
120
+ /* A message's menu button: quiet until the message is hovered or focused. */
121
+ .sp-msg-head small { margin-left: auto; }
122
+ .sp-msg-head .sp-msg-kebab { padding: 0 .3rem; opacity: .55; }
123
+ .sp-msg:hover .sp-msg-kebab, .sp-msg:focus-within .sp-msg-kebab { opacity: 1; }
124
+ .sp-msg:focus-visible { outline: 2px solid var(--dm-primary); outline-offset: 1px; }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Support - kept drafts (PURE: no DOM, no globals; tested in tests/drafts.test.js).
3
+ *
4
+ * Every panel that takes typing keeps what was typed and not yet saved under its
5
+ * own storage key (one key per panel and record - never a shared map, so work
6
+ * finishing after a screen has gone cannot write a stale copy of someone else's).
7
+ *
8
+ * A draft is `{data, was, at}`: the form's values, the fingerprint of the record
9
+ * when the edits began (empty for something new), and when it was last typed in.
10
+ * `was` lets the "unsaved changes from last time" question say the record has
11
+ * changed - or gone - since.
12
+ *
13
+ * The question's DESTRUCTIVE answer is always OK ("Throw away"): Domma's E.confirm
14
+ * answers false for Cancel, Escape and a backdrop click alike, so false must KEEP.
15
+ *
16
+ * Support carries its own copy of Feedback's module (either plugin can be installed
17
+ * alone), plus what a conversation needs: `ticketPrint` and `afterSend`.
18
+ *
19
+ * @module support/admin/lib/drafts
20
+ */
21
+
22
+ /** Storage keys. */
23
+ export const DRAFT_KEYS = {
24
+ /** A new request, not yet sent (a site). */
25
+ compose: 'support.draft.compose',
26
+ /** A message being written on one of this site's requests (by ref). */
27
+ reply: (ref) => `support.draft.reply.${ref}`,
28
+ /** Domma's reply and notes on a request in the inbox (by entry id). */
29
+ answer: (id) => `support.draft.answer.${id}`,
30
+ settings: 'support.draft.settings'
31
+ };
32
+
33
+ /** JSON with object keys sorted, so two equal values always compare equal. */
34
+ export function stableJson(v) {
35
+ if (Array.isArray(v)) return `[${v.map(stableJson).join(',')}]`;
36
+ if (v && typeof v === 'object') {
37
+ return `{${Object.keys(v).sort().filter(k => v[k] !== undefined).map(k => `${JSON.stringify(k)}:${stableJson(v[k])}`).join(',')}}`;
38
+ }
39
+ return JSON.stringify(v ?? null);
40
+ }
41
+
42
+ /** Do two form states differ? */
43
+ export const differs = (a, b) => stableJson(a) !== stableJson(b);
44
+
45
+ /**
46
+ * Only what was changed from the saved values: a kept draft holds these, so
47
+ * picking it up later puts back the person's own edits and leaves every field
48
+ * they did not touch as the record has it NOW (someone else's change survives).
49
+ */
50
+ export function changedFields(values = {}, baseline = {}) {
51
+ return Object.fromEntries(Object.entries(values).filter(([k, v]) => stableJson(v) !== stableJson(baseline[k])));
52
+ }
53
+
54
+ /** A draft as kept. */
55
+ export function makeDraft(data, was = '', at = new Date().toISOString()) {
56
+ return {data, was: String(was || ''), at};
57
+ }
58
+
59
+ /** Is this something this module wrote (and not an older build's leftovers)? */
60
+ export function isDraft(d) {
61
+ return Boolean(d && typeof d === 'object' && d.data && typeof d.data === 'object' && !Array.isArray(d.data));
62
+ }
63
+
64
+ /** The draft's values over a form's defaults - only the keys the form has, as strings. */
65
+ export function draftValues(draft, defaults) {
66
+ const out = {...defaults};
67
+ if (!isDraft(draft)) return out;
68
+ for (const k of Object.keys(defaults)) {
69
+ if (draft.data[k] !== undefined && draft.data[k] !== null) out[k] = String(draft.data[k]);
70
+ }
71
+ return out;
72
+ }
73
+
74
+ /**
75
+ * A request's fingerprint for `was`: its conversation (how many messages, the
76
+ * latest) and - for the inbox - its internal notes. "Changed since" then means
77
+ * someone has written since the draft began, or the notes moved.
78
+ */
79
+ export function ticketPrint(t, {notes = false} = {}) {
80
+ if (!t) return '';
81
+ const messages = Array.isArray(t.messages) ? t.messages : [];
82
+ return stableJson({count: messages.length, last: messages.at(-1)?.id || '', ...(notes && {notes: String(t.notes || '')})});
83
+ }
84
+
85
+ /**
86
+ * What is left in a reply box once a message has gone: nothing, if it still
87
+ * holds just what was sent; what was typed AFTER it, if the person carried on
88
+ * at the end while it was sending; everything, if they changed the text itself.
89
+ */
90
+ export function afterSend(current, sent) {
91
+ const c = String(current ?? '');
92
+ const s = String(sent ?? '');
93
+ if (c === s) return '';
94
+ if (s && c.startsWith(s)) return c.slice(s.length).replace(/^\s+/, '');
95
+ return c;
96
+ }
97
+
98
+ /** The settings' fingerprint for `was`: the values the panel loaded. */
99
+ export const settingsPrint = (values = {}) => stableJson(values);
100
+
101
+ /**
102
+ * What became of the record since the draft's edits began.
103
+ * @param {{was?: string}} draft
104
+ * @param {string|null} now - the record's fingerprint now; null when it has been deleted
105
+ * @returns {'deleted'|'changed'|''}
106
+ */
107
+ export function draftFate(draft, now) {
108
+ if (now === null) return 'deleted';
109
+ return draft?.was && now && draft.was !== now ? 'changed' : '';
110
+ }
111
+
112
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
113
+ /** "15 Oct, 14:02" - local time. */
114
+ export function shortWhen(iso) {
115
+ const t = Date.parse(iso);
116
+ if (!Number.isFinite(t)) return '';
117
+ const d = new Date(t);
118
+ const p = (n) => String(n).padStart(2, '0');
119
+ return `${d.getDate()} ${MONTHS[d.getMonth()]}, ${p(d.getHours())}:${p(d.getMinutes())}`;
120
+ }
121
+
122
+ /** E.confirm options for every restore question: OK throws away; Cancel, Escape and the backdrop keep. */
123
+ export const RESTORE_CONFIRM = {title: 'Unsaved changes', confirmText: 'Throw away', cancelText: 'Keep them'};
124
+
125
+ /** E.confirm options for closing a panel with changes: Cancel (or Escape) keeps the panel open. */
126
+ export const DISCARD_CONFIRM = {title: 'Unsaved changes', confirmText: 'Discard', cancelText: 'Keep editing'};
127
+
128
+ /**
129
+ * The question a kept draft on an existing record is offered back with.
130
+ *
131
+ * @param {object} draft
132
+ * @param {string|null} now - the record's fingerprint now (null: deleted)
133
+ * @param {{what: string, changedAt?: string, changed?: string}} o - `what`: "your reply to “Site down”"; `changed`: how a change is said
134
+ * @returns {{text: string, fate: string, options: object}}
135
+ */
136
+ export function draftQuestion(draft, now, {what, changedAt = '', changed = 'It has been changed since'}) {
137
+ const at = shortWhen(draft?.at);
138
+ const ask = `Throw away the changes you left unsaved on ${what}${at ? ` (${at})` : ''}?`;
139
+ const fate = draftFate(draft, now);
140
+ if (fate === 'deleted') {
141
+ return {fate, options: {...RESTORE_CONFIRM, cancelText: 'Copy them'},
142
+ text: `${ask} It has been deleted since, so they can no longer be saved - keeping them copies them, so you can paste them elsewhere.`};
143
+ }
144
+ if (fate === 'changed') {
145
+ const since = shortWhen(changedAt);
146
+ return {fate, options: RESTORE_CONFIRM,
147
+ text: `${ask} ${changed}${since ? ` (${since})` : ''} - keeping them puts your changes over that.`};
148
+ }
149
+ return {fate, options: RESTORE_CONFIRM, text: `${ask} Keeping them puts them back in the form.`};
150
+ }
151
+
152
+ /** The question for something new that was never sent (no record, so nothing to change). */
153
+ export function newDraftQuestion(draft, what = 'the request') {
154
+ const at = shortWhen(draft?.at);
155
+ return {options: RESTORE_CONFIRM, text: `Throw away ${what} you left unsent${at ? ` (${at})` : ''}? Keeping it puts it back in the form.`};
156
+ }
157
+
158
+ /** Plain text of a draft, for copying when its record has gone. */
159
+ export function draftText(draft) {
160
+ if (!isDraft(draft)) return '';
161
+ return Object.entries(draft.data).filter(([, v]) => typeof v === 'string' && v.trim()).map(([k, v]) => `${k}:\n${v}`).join('\n\n');
162
+ }