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
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Drafts in the admin's slideovers - the pure part (admin/lib/drafts.js).
3
+ */
4
+ import {test} from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import {
7
+ agoText, draftsWith, draftsWithout, fingerprint, orphanDrafts, restorePlan, restoreText, stableStringify, staleDraftKeys, usableDraft
8
+ } from '../admin/lib/drafts.js';
9
+
10
+ const NOW = Date.parse('2031-07-15T12:00:00Z');
11
+ const at = (minAgo) => new Date(NOW - minAgo * 60_000).toISOString();
12
+
13
+ test('stable JSON and fingerprints ignore key order, see every change', () => {
14
+ assert.equal(stableStringify({b: 1, a: [2, {d: 3, c: 4}]}), '{"a":[2,{"c":4,"d":3}],"b":1}');
15
+ assert.equal(fingerprint({a: 1, b: 2}), fingerprint({b: 2, a: 1}));
16
+ assert.notEqual(fingerprint({a: 1, b: 2}), fingerprint({a: 1, b: 3}));
17
+ assert.notEqual(fingerprint({closures: []}), fingerprint({closures: [{from: '2031-12-25'}]}));
18
+ assert.match(fingerprint({}), /^f[0-9a-z]+$/);
19
+ });
20
+
21
+ test('a stored draft is used only when it looks like one and is not too old', () => {
22
+ assert.equal(usableDraft(null, {now: NOW}), null);
23
+ assert.equal(usableDraft('x', {now: NOW}), null);
24
+ assert.equal(usableDraft({values: [1]}, {now: NOW}), null);
25
+ assert.equal(usableDraft({values: {a: 1}}, {now: NOW}), null, 'no date: not trusted');
26
+ assert.equal(usableDraft({values: {a: 1}, at: at(15 * 24 * 60)}, {now: NOW}), null, 'older than 14 days');
27
+ assert.deepEqual(usableDraft({values: {a: 1}, at: at(5), was: 'w1', label: 'Haircut'}, {now: NOW}),
28
+ {values: {a: 1}, at: at(5), was: 'w1', label: 'Haircut'});
29
+ assert.deepEqual(usableDraft({values: {}, at: at(5), was: 7}, {now: NOW}).was, '', 'a non-string was is none');
30
+ });
31
+
32
+ test('restore plan: nothing, put back, or ask', () => {
33
+ const d = {values: {name: 'Sam'}, at: at(3), was: '2031-07-15T10:00:00Z', label: 'Haircut'};
34
+ assert.equal(restorePlan({draft: null}), 'none');
35
+ assert.equal(restorePlan({draft: d, was: d.was, opening: {name: 'Sam'}}), 'none', 'the same as the form already holds');
36
+ assert.equal(restorePlan({draft: d, was: d.was, opening: {name: ''}}), 'auto');
37
+ assert.equal(restorePlan({draft: d, was: '2031-07-15T11:30:00Z', opening: {name: ''}}), 'changed', 'saved since');
38
+ assert.equal(restorePlan({draft: {...d, was: ''}, was: '', opening: {}, prefilled: true}), 'prefilled');
39
+ assert.equal(restorePlan({draft: {...d, was: ''}, was: 'x', opening: {}, prefilled: true}), 'changed', 'a change outranks the prefill');
40
+ });
41
+
42
+ test('the words', () => {
43
+ assert.equal(agoText(at(0.2), NOW), 'just now');
44
+ assert.equal(agoText(at(12), NOW), '12 min ago');
45
+ assert.equal(agoText(at(60 * 26), NOW), 'yesterday');
46
+ assert.equal(agoText('nonsense', NOW), 'earlier');
47
+ assert.equal(restoreText('auto', {label: 'Haircut', at: at(2), now: NOW}), 'Unsaved changes from 2 min ago put back (Haircut).');
48
+ assert.match(restoreText('changed', {label: 'Haircut', at: at(2), now: NOW}), /to “Haircut” 2 min ago, and it has been saved since.*Discard your changes, or keep them/);
49
+ assert.match(restoreText('deleted', {label: 'Room 1', at: at(2), now: NOW}), /deleted since\. Discard them, or start a new one/);
50
+ assert.match(restoreText('prefilled', {label: 'Sam', at: at(2), now: NOW, kind: 'a new booking'}), /^You started a new booking \(Sam\) 2 min ago.*Discard it and start this one/);
51
+ assert.equal(restoreText('none'), '');
52
+ });
53
+
54
+ test('the stored map: set, remove, orphans, stale', () => {
55
+ const live = {values: {}, at: at(1)};
56
+ let map = draftsWith(null, 'service:a', live);
57
+ map = draftsWith(map, 'service:gone', {...live, label: 'Old'});
58
+ map = draftsWith(map, 'service:new', live);
59
+ map = draftsWith(map, 'bookable:gone', live);
60
+ map = draftsWith(map, 'service:old', {values: {}, at: at(20 * 24 * 60)});
61
+ const before = {...map};
62
+ assert.deepEqual(Object.keys(draftsWithout(map, 'service:a')).sort(), ['bookable:gone', 'service:gone', 'service:new', 'service:old']);
63
+ assert.deepEqual(map, before, 'not changed in place');
64
+ const o = orphanDrafts(map, 'service:', ['a'], {now: NOW});
65
+ assert.deepEqual(o.map(x => [x.key, x.id, x.draft.label]), [['service:gone', 'gone', 'Old']], 'new, live and stale ones are not orphans');
66
+ assert.deepEqual(staleDraftKeys(map, {now: NOW}), ['service:old']);
67
+ });
@@ -180,3 +180,15 @@ Deleting a report in the inbox hid it from the site, but the site's outbox kept
180
180
  manager answered `/fleet/mine` (its list for the site is complete): a SENT item the manager no longer has is dropped
181
181
  (`droppedByManager`, PURE, tested); waiting/refused items and an unreachable manager keep everything. The files go
182
182
  at the next prune (older than a day, no longer named). Found on the dcm click-through.
183
+
184
+ ## 1.2.0 (2026-10-15 Tool Upgrade, second pass)
185
+
186
+ - Panels keep drafts under one key per panel and record (`feedback.draft.*`, `admin/lib/drafts.js`, tested), holding only
187
+ the changed fields, with `was` a fingerprint of the record. The restore question puts "Throw away" on OK, so Cancel,
188
+ Escape and the backdrop keep; a draft goes only after an answer; leaving closes panels and keeps drafts. Picked files
189
+ are never kept in a draft.
190
+ - `kit.guardPanel` must be called after `openPanel`. It can still write after its panel closed: `rebase()` keeps
191
+ what is left unsent, `settle()` removes the draft once a save/send succeeded - call it on every success, or leaving
192
+ mid-send keeps a draft of text already sent (and invites a second send).
193
+ - `rowKeys` stops Enter/Space and runs the row's main action on the next tick: Domma's confirm adds its document
194
+ keydown while the opening Enter is still bubbling and would read it as OK = Throw away.
@@ -93,3 +93,8 @@
93
93
  .fb-grid3 { grid-template-columns: 1fr; }
94
94
  .fb-row { flex-wrap: wrap; }
95
95
  }
96
+
97
+ /* The first load's skeleton slot (admin/js/lib/loading.js): a block, never a flex row, or it collapses. */
98
+ .fb-loading { display: block; margin: .5rem 0; color: var(--dm-text-secondary); font-size: var(--dm-font-size-sm); }
99
+ /* A panel saving before it closes is locked (inert): show it. */
100
+ .fb-form.is-saving, .fb-detail.is-saving { opacity: .65; cursor: progress; }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Feedback - 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
+ * @module feedback/admin/lib/drafts
17
+ */
18
+
19
+ /** Storage keys. */
20
+ export const DRAFT_KEYS = {
21
+ compose: 'feedback.draft.compose',
22
+ report: (id) => `feedback.draft.report.${id}`,
23
+ settings: 'feedback.draft.settings'
24
+ };
25
+
26
+ /** JSON with object keys sorted, so two equal values always compare equal. */
27
+ export function stableJson(v) {
28
+ if (Array.isArray(v)) return `[${v.map(stableJson).join(',')}]`;
29
+ if (v && typeof v === 'object') {
30
+ return `{${Object.keys(v).sort().filter(k => v[k] !== undefined).map(k => `${JSON.stringify(k)}:${stableJson(v[k])}`).join(',')}}`;
31
+ }
32
+ return JSON.stringify(v ?? null);
33
+ }
34
+
35
+ /** Do two form states differ? */
36
+ export const differs = (a, b) => stableJson(a) !== stableJson(b);
37
+
38
+ /**
39
+ * Only what was changed from the saved values: a kept draft holds these, so
40
+ * picking it up later puts back the person's own edits and leaves every field
41
+ * they did not touch as the record has it NOW (someone else's change survives).
42
+ */
43
+ export function changedFields(values = {}, baseline = {}) {
44
+ return Object.fromEntries(Object.entries(values).filter(([k, v]) => stableJson(v) !== stableJson(baseline[k])));
45
+ }
46
+
47
+ /** A draft as kept. */
48
+ export function makeDraft(data, was = '', at = new Date().toISOString()) {
49
+ return {data, was: String(was || ''), at};
50
+ }
51
+
52
+ /** Is this something this module wrote (and not an older build's leftovers)? */
53
+ export function isDraft(d) {
54
+ return Boolean(d && typeof d === 'object' && d.data && typeof d.data === 'object' && !Array.isArray(d.data));
55
+ }
56
+
57
+ /** The draft's values over a form's defaults - only the keys the form has, as strings. */
58
+ export function draftValues(draft, defaults) {
59
+ const out = {...defaults};
60
+ if (!isDraft(draft)) return out;
61
+ for (const k of Object.keys(defaults)) {
62
+ if (draft.data[k] !== undefined && draft.data[k] !== null) out[k] = String(draft.data[k]);
63
+ }
64
+ return out;
65
+ }
66
+
67
+ /** What the inbox panel edits of a report: status, reply, notes. */
68
+ export const reportForm = (r = {}) => ({status: String(r.status || ''), reply: String(r.reply || ''), notes: String(r.notes || '')});
69
+
70
+ /**
71
+ * A report's fingerprint for `was`. Opening a new report acknowledges it, so
72
+ * `new` counts as `acknowledged` - otherwise every draft started on a new
73
+ * report would come back "changed since".
74
+ */
75
+ export function reportPrint(r) {
76
+ if (!r) return '';
77
+ const f = reportForm(r);
78
+ return stableJson({...f, status: f.status === 'new' ? 'acknowledged' : f.status});
79
+ }
80
+
81
+ /** The settings' fingerprint for `was`: the values the panel loaded. */
82
+ export const settingsPrint = (values = {}) => stableJson(values);
83
+
84
+ /**
85
+ * What became of the record since the draft's edits began.
86
+ * @param {{was?: string}} draft
87
+ * @param {string|null} now - the record's fingerprint now; null when it has been deleted
88
+ * @returns {'deleted'|'changed'|''}
89
+ */
90
+ export function draftFate(draft, now) {
91
+ if (now === null) return 'deleted';
92
+ return draft?.was && now && draft.was !== now ? 'changed' : '';
93
+ }
94
+
95
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
96
+ /** "15 Oct, 14:02" - local time. */
97
+ export function shortWhen(iso) {
98
+ const t = Date.parse(iso);
99
+ if (!Number.isFinite(t)) return '';
100
+ const d = new Date(t);
101
+ const p = (n) => String(n).padStart(2, '0');
102
+ return `${d.getDate()} ${MONTHS[d.getMonth()]}, ${p(d.getHours())}:${p(d.getMinutes())}`;
103
+ }
104
+
105
+ /** E.confirm options for every restore question: OK throws away; Cancel, Escape and the backdrop keep. */
106
+ export const RESTORE_CONFIRM = {title: 'Unsaved changes', confirmText: 'Throw away', cancelText: 'Keep them'};
107
+
108
+ /** E.confirm options for closing a panel with changes: Cancel (or Escape) keeps the panel open. */
109
+ export const DISCARD_CONFIRM = {title: 'Unsaved changes', confirmText: 'Discard', cancelText: 'Keep editing'};
110
+
111
+ /**
112
+ * The question a kept draft on an existing record is offered back with.
113
+ *
114
+ * @param {object} draft
115
+ * @param {string|null} now - the record's fingerprint now (null: deleted)
116
+ * @param {{what: string, changedAt?: string}} o - `what`: "the report “Login broken”"
117
+ * @returns {{text: string, fate: string, options: object}}
118
+ */
119
+ export function draftQuestion(draft, now, {what, changedAt = ''}) {
120
+ const at = shortWhen(draft?.at);
121
+ const ask = `Throw away the changes you left unsaved on ${what}${at ? ` (${at})` : ''}?`;
122
+ const fate = draftFate(draft, now);
123
+ if (fate === 'deleted') {
124
+ return {fate, options: {...RESTORE_CONFIRM, cancelText: 'Copy them'},
125
+ text: `${ask} It has been deleted since, so they can no longer be saved - keeping them copies them, so you can paste them elsewhere.`};
126
+ }
127
+ if (fate === 'changed') {
128
+ const since = shortWhen(changedAt);
129
+ return {fate, options: RESTORE_CONFIRM,
130
+ text: `${ask} It has been changed since${since ? ` (${since})` : ''} - keeping them puts your changes over that.`};
131
+ }
132
+ return {fate, options: RESTORE_CONFIRM, text: `${ask} Keeping them puts them back in the form.`};
133
+ }
134
+
135
+ /** The question for something new that was never sent (no record, so nothing to change). */
136
+ export function newDraftQuestion(draft, what = 'the feedback') {
137
+ const at = shortWhen(draft?.at);
138
+ return {options: RESTORE_CONFIRM, text: `Throw away ${what} you left unsent${at ? ` (${at})` : ''}? Keeping it puts it back in the form.`};
139
+ }
140
+
141
+ /** Plain text of a draft, for copying when its record has gone. */
142
+ export function draftText(draft) {
143
+ if (!isDraft(draft)) return '';
144
+ return Object.entries(draft.data).filter(([, v]) => typeof v === 'string' && v.trim()).map(([k, v]) => `${k}:\n${v}`).join('\n\n');
145
+ }
@@ -7,20 +7,22 @@
7
7
  */
8
8
 
9
9
  import {fmtDate, fmtDateTime} from '/plugins/_lib/admin/ui/dates.js';
10
+ import {DISCARD_CONFIRM, changedFields, differs, isDraft, makeDraft} from './drafts.js';
10
11
 
11
12
  export const SIDEBAR_URL = '#/plugins/feedback';
12
13
  export const BASE = '/api/plugins/feedback';
13
14
 
14
15
  let kit = null;
15
16
 
16
- /** tool-kit, plugin-chrome and help-popover, once, with stand-ins for an older admin. */
17
+ /** tool-kit, plugin-chrome, help-popover and loading, once, with stand-ins for an older admin. */
17
18
  export function loadKit() {
18
19
  if (!kit) {
19
20
  kit = Promise.all([
20
21
  import('/admin/js/lib/tool-kit.js').catch(() => ({})),
21
22
  import('/admin/js/lib/plugin-chrome.js').catch(() => ({})),
22
- import('/admin/js/lib/help-popover.js').catch(() => ({}))
23
- ]).then(([tk, chrome, help]) => ({
23
+ import('/admin/js/lib/help-popover.js').catch(() => ({})),
24
+ import('/admin/js/lib/loading.js').catch(() => ({}))
25
+ ]).then(([tk, chrome, help, loading]) => ({
24
26
  onceClosed: tk.onceClosed || ((so, fn) => { const c = so.close.bind(so); so.close = (...a) => { const r = c(...a); fn?.(); return r; }; }),
25
27
  openMenuAt: tk.openMenuAt || ((el) => {
26
28
  const r = el.getBoundingClientRect();
@@ -28,7 +30,10 @@ export function loadKit() {
28
30
  }),
29
31
  viewLifetime: tk.viewLifetime || (() => ({add() {}, reset() {}})),
30
32
  addBannerAction: chrome.addBannerAction || (() => null),
31
- helpPopovers: help.helpPopovers || (() => () => {})
33
+ helpPopovers: help.helpPopovers || (() => () => {}),
34
+ // A skeleton in a list's loading slot for its first load (an older admin: the slot's own text).
35
+ whileLoading: loading.whileLoading || ((slot, promise) => promise),
36
+ SHAPES: loading.SHAPES || {}
32
37
  }));
33
38
  }
34
39
  return kit;
@@ -167,7 +172,8 @@ export function fragment(html) {
167
172
 
168
173
  export const store = {
169
174
  get(k) { try { return S.get(k); } catch { return null; } },
170
- set(k, v) { try { S.set(k, v); } catch { /* a convenience */ } }
175
+ set(k, v) { try { S.set(k, v); } catch { /* a convenience */ } },
176
+ remove(k) { try { if (S.remove) S.remove(k); else S.set(k, null); } catch { /* storage blocked */ } }
171
177
  };
172
178
 
173
179
  /** The sidebar re-asks the Feedback badge. */
@@ -208,3 +214,153 @@ export async function openPanel({title, size = 'md', html, model = {}, methods =
208
214
  so.open();
209
215
  return {so, panel, close: () => so.close()};
210
216
  }
217
+
218
+ // ---------------------------------------------------------------------------------
219
+ // Panels: one of a kind at a time, closed when the screen goes, and unsaved work kept
220
+ // ---------------------------------------------------------------------------------
221
+
222
+ /**
223
+ * The panels one mount has open, by kind ('compose', 'detail', ...). `claim()`
224
+ * before opening (false: one is already open or opening - no double-open), `set()`
225
+ * once it is open, `release()` when it closes or fails to open (or it could never
226
+ * open again until a reload), `leave()` from the view's teardown.
227
+ */
228
+ export function panelSet() {
229
+ const open = new Map();
230
+ return {
231
+ claim(kind) { if (open.has(kind)) return false; open.set(kind, {}); return true; },
232
+ set(kind, entry) { if (open.has(kind)) open.set(kind, entry); },
233
+ get: (kind) => open.get(kind),
234
+ release(kind) { open.delete(kind); },
235
+ /** The screen has gone: close every panel, keeping any drafts. */
236
+ leave() {
237
+ const all = [...open.values()];
238
+ open.clear();
239
+ for (const e of all) { try { e.close?.('leave'); } catch { /* already gone */ } }
240
+ }
241
+ };
242
+ }
243
+
244
+ /**
245
+ * Offer a kept draft back before its panel opens. The draft is removed only
246
+ * once the person has answered "Throw away"; leaving while asked keeps it.
247
+ *
248
+ * @param {string} key
249
+ * @param {{question: (draft) => {text: string, options: object}, here: () => boolean}} o
250
+ * @returns {Promise<{state: 'none'|'discard'|'keep'|'gone', draft?: object, fate?: string}>}
251
+ */
252
+ export async function offerDraft(key, {question, here}) {
253
+ const draft = store.get(key);
254
+ if (!isDraft(draft)) return {state: 'none'};
255
+ const q = question(draft);
256
+ // E.confirm renders HTML: the question carries titles people typed.
257
+ const ok = await E.confirm(esc(q.text), q.options);
258
+ if (!here()) return {state: 'gone'};
259
+ if (ok) { store.remove(key); return {state: 'discard'}; }
260
+ return {state: 'keep', draft, fate: q.fate || ''};
261
+ }
262
+
263
+ /**
264
+ * Unsaved work in an open panel. Call it AFTER openPanel: its close guard wraps
265
+ * over the one kit.onceClosed put there.
266
+ *
267
+ * - while the form differs from `baseline` a draft is kept under `key`
268
+ * ({data: the changed fields only, was, at}); when it matches again the draft goes;
269
+ * - closing with changes asks first; Cancel (or Escape) keeps the panel open;
270
+ * - close('leave') - the screen has gone - closes without asking and KEEPS the draft;
271
+ * - close(true) - after a save - closes without asking.
272
+ *
273
+ * @param {{so, panel}} p - from openPanel
274
+ * @param {object} o
275
+ * @param {string} o.key
276
+ * @param {() => object} o.values - the form now (read observables with .value: it is tracked)
277
+ * @param {object} o.baseline - what is saved
278
+ * @param {string} [o.was] - the record's fingerprint when the edits began (a picked-up draft's own)
279
+ * @param {() => boolean} [o.extraDirty] - unsaved work no draft can keep (files picked)
280
+ * @param {(how) => void} [o.onClosed]
281
+ * @returns {{dirty: () => boolean, rebase: (baseline: object, was?: string) => void, settle: () => void, lock: (on: boolean) => void, close: (how?) => Promise}}
282
+ */
283
+ export function guardPanel(p, {key, values, baseline, was = '', extraDirty = () => false, onClosed = () => {}}) {
284
+ const base = M.observable(baseline);
285
+ let wasNow = was;
286
+ let closed = false;
287
+ let asking = false;
288
+ const keep = (v, b, settled = false) => {
289
+ // Once closed only a settle writes: leaving mid-save kept the form, and the request's answer decides what stays.
290
+ if (closed && !settled) return;
291
+ if (v && differs(v, b)) store.set(key, makeDraft(changedFields(v, b), wasNow));
292
+ else store.remove(key);
293
+ };
294
+ const stop = M.effect(() => keep(values(), base.value));
295
+ const dirty = () => differs(values(), base.peek()) || extraDirty();
296
+ const close = p.so.close.bind(p.so);
297
+ const finish = (how) => { closed = true; stop(); if (how !== 'leave') store.remove(key); onClosed(how); return close(); };
298
+ p.so.close = async (how) => {
299
+ if (closed) return undefined;
300
+ if (how !== 'leave' && how !== true && dirty()) {
301
+ if (asking) return undefined;
302
+ asking = true;
303
+ const ok = await E.confirm('Discard your changes?', DISCARD_CONFIRM);
304
+ asking = false;
305
+ // Left (or saved) while asked: that close has already happened.
306
+ if (!ok || closed) return undefined;
307
+ }
308
+ return finish(how);
309
+ };
310
+ return {
311
+ dirty,
312
+ // Written here as well as by the effect (an observable may not announce an equal value) - and
313
+ // after the panel has closed too: a reply box left while sending keeps only what was not sent.
314
+ rebase(next, nextWas) { if (nextWas !== undefined) wasNow = nextWas; base.value = next; keep(values(), next, true); },
315
+ /** A save succeeded: nothing is left unsaved, whether or not the panel was left meanwhile. */
316
+ settle() { keep(null, base.peek(), true); },
317
+ /** A panel that closes after saving is locked while it saves, so nothing typed meanwhile is lost. */
318
+ lock(on) { p.panel.inert = Boolean(on); p.panel.classList.toggle('is-saving', Boolean(on)); },
319
+ close: (how) => p.so.close(how)
320
+ };
321
+ }
322
+
323
+ /** Copy text, with a toast either way. */
324
+ export async function copyText(text, what = 'Copied') {
325
+ try {
326
+ await navigator.clipboard.writeText(String(text ?? ''));
327
+ E.toast(`${what}.`, {type: 'success'});
328
+ return true;
329
+ } catch {
330
+ E.toast('Your browser would not let us copy that.', {type: 'warning'});
331
+ return false;
332
+ }
333
+ }
334
+
335
+ /**
336
+ * A list's ways into its row menu besides right-click: the row's ⋮ button and
337
+ * Shift+F10 (never the ContextMenu key - Chrome on Windows opens its own on key-up).
338
+ * Click, Enter or Space on the row does `primary`.
339
+ *
340
+ * @returns {Function} removes the listeners
341
+ */
342
+ export function rowKeys(listEl, {rowSel, kebabSel, openMenuAt, primary}) {
343
+ const onClick = (e) => {
344
+ const row = e.target.closest(rowSel);
345
+ if (!row) return;
346
+ const kebab = e.target.closest(kebabSel);
347
+ if (kebab) { e.stopPropagation(); openMenuAt(kebab); return; }
348
+ primary(row.dataset.id);
349
+ };
350
+ // Enter must not reach a dialog the main action opens: Domma's confirm takes Enter as OK, and its
351
+ // listener goes on while this key is still bubbling - a restore question would be answered unseen.
352
+ const onKey = (e) => {
353
+ const row = e.target.closest(rowSel);
354
+ if (!row) return;
355
+ if (e.key === 'F10' && e.shiftKey) { e.preventDefault(); openMenuAt(row); }
356
+ else if ((e.key === 'Enter' || e.key === ' ') && e.target === row) {
357
+ e.preventDefault();
358
+ e.stopPropagation();
359
+ const id = row.dataset.id;
360
+ setTimeout(() => primary(id), 0);
361
+ }
362
+ };
363
+ listEl.addEventListener('click', onClick);
364
+ listEl.addEventListener('keydown', onKey);
365
+ return () => { listEl.removeEventListener('click', onClick); listEl.removeEventListener('keydown', onKey); };
366
+ }
@@ -9,7 +9,8 @@
9
9
  *
10
10
  * @module feedback/admin/lib/settings
11
11
  */
12
- import {BASE, esc, openPanel} from './kit.js';
12
+ import {BASE, esc, guardPanel, offerDraft, openPanel, panelSet} from './kit.js';
13
+ import {DRAFT_KEYS, draftQuestion, draftValues, settingsPrint} from './drafts.js';
13
14
 
14
15
  /** Ranges - lib/shape.js SETTING_RULES, which the server applies again. */
15
16
  const RULES = {
@@ -38,22 +39,39 @@ const wholeIn = (v, {min, max}) => { const n = Number(v); return String(v).trim(
38
39
  *
39
40
  * @param {object} $container
40
41
  * @param {object} kit - loadKit()
42
+ * @param {{scope: Function, here: () => boolean}} view - the mount's teardown and "still on screen"
41
43
  */
42
- export async function addSettingsCog($container, kit) {
44
+ export async function addSettingsCog($container, kit, {scope, here}) {
43
45
  let got = null;
44
46
  // Silent: a site user who is not an administrator gets 403 here, and needs no cog and no error.
45
47
  try { got = await H.request('GET', `${BASE}/settings`, null, {silent: true}); } catch { return; }
46
- if (!got?.side) return;
47
- kit.addBannerAction($container, {id: 'feedback-settings', icon: 'settings', title: 'Feedback settings', onClick: () => openSettings()});
48
+ if (!got?.side || !here()) return;
49
+ // The panel belongs to this mount: leaving the screen closes it, keeping what was typed.
50
+ const panels = panelSet();
51
+ scope(() => panels.leave());
52
+ kit.addBannerAction($container, {id: 'feedback-settings', icon: 'settings', title: 'Feedback settings', onClick: () => openSettings({panels, here})});
48
53
  }
49
54
 
50
- export async function openSettings() {
55
+ export async function openSettings({panels = panelSet(), here = () => true} = {}) {
56
+ if (!panels.claim('settings')) return;
57
+ let opened = false;
58
+ try { opened = await showSettings(panels, here); } catch (err) { E.toast(`Settings not opened: ${err?.message || err}`, {type: 'error'}); }
59
+ if (!opened) panels.release('settings');
60
+ }
61
+
62
+ async function showSettings(panels, here) {
51
63
  let got;
52
- try { got = await H.get(`${BASE}/settings`); } catch (err) { E.toast(`Settings not loaded: ${esc(err?.message || err)}`, {type: 'error'}); return; }
64
+ // A failed load opens nothing - there is no form that could save over the server's values.
65
+ try { got = await H.get(`${BASE}/settings`); } catch (err) { E.toast(`Settings not loaded: ${err?.message || err}`, {type: 'error'}); return false; }
66
+ if (!here()) return false;
53
67
  const fields = FIELDS[got.side] || [];
54
- const f = Object.fromEntries(fields.map(x => [x.key, M.observable(String(got.settings?.[x.key] ?? ''))]));
68
+ const loaded = Object.fromEntries(fields.map(x => [x.key, String(got.settings?.[x.key] ?? '')]));
69
+ const offer = await offerDraft(DRAFT_KEYS.settings, {here,
70
+ question: (d) => draftQuestion(d, settingsPrint(loaded), {what: 'the Feedback settings'})});
71
+ if (offer.state === 'gone') return false;
72
+ const start = offer.state === 'keep' ? draftValues(offer.draft, loaded) : loaded;
73
+ const f = Object.fromEntries(fields.map(x => [x.key, M.observable(start[x.key])]));
55
74
  const values = () => Object.fromEntries(fields.map(x => [x.key, Number(f[x.key].peek())]));
56
- const opened = JSON.stringify(values());
57
75
  const saving = M.observable(false);
58
76
  const bad = Object.fromEntries(fields.map(x => [`${x.key}Bad`, M.computed(() => !wholeIn(f[x.key].value, RULES[x.key]))]));
59
77
  const model = {...f, ...bad, saving, cannot: M.computed(() => saving.value || Object.values(bad).some(b => b.value))};
@@ -70,38 +88,38 @@ export async function openSettings() {
70
88
  <button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="cannot.value">Save</button>
71
89
  </div>
72
90
  </div>`;
73
- let panel = null;
74
- let closed = false;
75
- let asking = false;
91
+ let guard = null;
76
92
  const methods = {
77
- cancel: () => panel?.so.close(),
93
+ cancel: () => guard?.close(),
78
94
  async save() {
79
95
  if (model.cannot.peek()) return;
80
96
  saving.value = true;
97
+ // The panel closes once saved: locked meanwhile, so nothing typed during the request is lost.
98
+ guard?.lock(true);
81
99
  try {
82
100
  await H.put(`${BASE}/settings`, values());
101
+ guard?.settle();
83
102
  E.toast('Feedback settings saved.', {type: 'success'});
84
- panel.so.close(true);
103
+ guard?.close(true);
85
104
  } catch (err) {
86
- E.toast(`Not saved: ${esc(err?.message || err)}`, {type: 'error'});
105
+ guard?.lock(false);
106
+ E.toast(`Not saved: ${err?.message || err}`, {type: 'error'});
87
107
  } finally {
88
108
  saving.value = false;
89
109
  }
90
110
  }
91
111
  };
92
- panel = await openPanel({title: 'Feedback settings', size: 'sm', html, model, methods});
93
- // Closing with a change asks first - once (onceClosed calls close() again, argument-less).
94
- const close = panel.so.close.bind(panel.so);
95
- panel.so.close = async (force) => {
96
- if (!closed && force !== true && JSON.stringify(values()) !== opened) {
97
- if (asking) return undefined;
98
- asking = true;
99
- const ok = await E.confirm('Discard what you have entered?');
100
- asking = false;
101
- if (!ok) return undefined;
102
- }
103
- closed = true;
104
- return close();
105
- };
112
+ const panel = await openPanel({title: 'Feedback settings', size: 'sm', html, model, methods,
113
+ cleanup: () => [...Object.values(bad), model.cannot].forEach(c => c.dispose?.())});
114
+ // Closing with a change asks first; leaving the screen keeps the change as a draft.
115
+ guard = guardPanel(panel, {
116
+ key: DRAFT_KEYS.settings,
117
+ values: () => Object.fromEntries(fields.map(x => [x.key, String(f[x.key].value)])),
118
+ baseline: loaded,
119
+ was: offer.state === 'keep' ? offer.draft.was : settingsPrint(loaded),
120
+ onClosed: () => panels.release('settings')
121
+ });
122
+ panels.set('settings', {close: (how) => guard.close(how)});
106
123
  panel.panel.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); methods.save(); } });
124
+ return true;
107
125
  }
@@ -58,7 +58,7 @@ export const feedbackView = {
58
58
  }
59
59
  if (!here()) return;
60
60
  // The cog (1.1.7) - this side's settings, for those who may change them. Never blocks the screen.
61
- addSettingsCog($container, kit).catch(() => {});
61
+ addSettingsCog($container, kit, {scope, here}).catch(() => {});
62
62
 
63
63
  const load = async (screen, into, ctx) => {
64
64
  const mod = await import(`/plugins/feedback/admin/views/${screen}.js?v=${V}`);