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.
- package/admin/css/calculator.css +1 -1
- package/admin/js/lib/analytics-shape.js +1 -1
- package/admin/js/lib/calculator-engine.js +1 -1
- package/admin/js/lib/calculator.js +22 -10
- package/admin/js/lib/form-editor-arrange.js +1 -1
- package/admin/js/lib/forms-arrange.js +1 -1
- package/admin/js/lib/notes-arrange.js +1 -1
- package/admin/js/lib/search-settings-arrange.js +1 -1
- package/admin/js/lib/seo-arrange.js +1 -0
- package/admin/js/lib/todo-arrange.js +1 -1
- package/admin/js/lib/tool-kit.js +1 -1
- package/admin/js/lib/tool-recovery.js +4 -4
- package/admin/js/templates/analytics.html +26 -14
- package/admin/js/templates/contacts.html +26 -5
- package/admin/js/templates/form-editor.html +2 -0
- package/admin/js/templates/notes.html +11 -8
- package/admin/js/templates/search.html +9 -3
- package/admin/js/templates/seo.html +23 -17
- package/admin/js/templates/todo.html +18 -14
- package/admin/js/views/analytics.js +10 -10
- package/admin/js/views/contacts.js +47 -47
- package/admin/js/views/form-editor.js +7 -7
- package/admin/js/views/form-submissions.js +2 -2
- package/admin/js/views/forms.js +5 -5
- package/admin/js/views/notes.js +6 -6
- package/admin/js/views/search.js +2 -2
- package/admin/js/views/seo.js +43 -43
- package/admin/js/views/todo.js +8 -8
- package/package.json +1 -1
- package/plugins/_lib/admin/mail/compose-window.js +4 -1
- package/plugins/_lib/admin/mail/mail.css +1 -1
- package/plugins/_lib/admin/mail/reader-kit.js +364 -0
- package/plugins/_lib/admin/mail/reader-view.js +1009 -161
- package/plugins/_lib/admin/mail/when-closed.js +33 -3
- package/plugins/_lib/mail/readRoutes.js +48 -10
- package/plugins/_lib/mail/unreadBadge.js +4 -1
- package/plugins/blog/CLAUDE.md +14 -0
- package/plugins/blog/admin/css/index.css +5 -0
- package/plugins/blog/admin/lib/drafts.js +95 -0
- package/plugins/blog/admin/templates/blog.html +1 -1
- package/plugins/blog/admin/templates/categories.html +1 -1
- package/plugins/blog/admin/templates/comments.html +1 -1
- package/plugins/blog/admin/templates/post-editor.html +5 -1
- package/plugins/blog/admin/views/blog.js +44 -19
- package/plugins/blog/admin/views/categories.js +138 -20
- package/plugins/blog/admin/views/comments.js +32 -23
- package/plugins/blog/admin/views/kit.js +232 -22
- package/plugins/blog/admin/views/post-editor.js +348 -178
- package/plugins/blog/admin/views/settings.js +17 -3
- package/plugins/blog/plugin.js +1 -1
- package/plugins/blog/plugin.json +7 -7
- package/plugins/blog/plugin.public.js +1 -1
- package/plugins/blog/tests/drafts.test.js +50 -0
- package/plugins/booking/CLAUDE.md +13 -0
- package/plugins/booking/admin/lib/drafts.js +139 -0
- package/plugins/booking/admin/lib/kit.js +246 -25
- package/plugins/booking/admin/views/booking.js +11 -2
- package/plugins/booking/admin/views/bookings.js +7 -2
- package/plugins/booking/admin/views/diary.js +13 -6
- package/plugins/booking/admin/views/panels.js +85 -12
- package/plugins/booking/admin/views/people.js +41 -2
- package/plugins/booking/admin/views/services.js +45 -3
- package/plugins/booking/admin/views/settings.js +22 -2
- package/plugins/booking/plugin.json +2 -2
- package/plugins/booking/tests/admin-drafts.test.js +67 -0
- package/plugins/feedback/CLAUDE.md +12 -0
- package/plugins/feedback/admin/css/index.css +5 -0
- package/plugins/feedback/admin/lib/drafts.js +145 -0
- package/plugins/feedback/admin/lib/kit.js +161 -5
- package/plugins/feedback/admin/lib/settings.js +46 -28
- package/plugins/feedback/admin/views/feedback.js +1 -1
- package/plugins/feedback/admin/views/inbox.js +110 -46
- package/plugins/feedback/admin/views/send.js +174 -83
- package/plugins/feedback/plugin.json +3 -3
- package/plugins/feedback/tests/drafts.test.js +93 -0
- package/plugins/free-tier.lock.json +75 -63
- package/plugins/security/CLAUDE.md +9 -0
- package/plugins/security/admin/lib/screen-state.js +120 -0
- package/plugins/security/admin/templates/security.html +15 -10
- package/plugins/security/admin/views/security.js +270 -132
- package/plugins/security/plugin.json +2 -2
- package/plugins/security/tests/screen-state.test.js +69 -0
- package/plugins/shopping-cart/CLAUDE.md +13 -0
- package/plugins/shopping-cart/admin/css/index.css +21 -5
- package/plugins/shopping-cart/admin/lib/drafts.js +174 -0
- package/plugins/shopping-cart/admin/lib/kit.js +136 -28
- package/plugins/shopping-cart/admin/views/orders.js +117 -36
- package/plugins/shopping-cart/admin/views/overview.js +38 -8
- package/plugins/shopping-cart/admin/views/products.js +317 -68
- package/plugins/shopping-cart/admin/views/sales.js +21 -3
- package/plugins/shopping-cart/admin/views/settings.js +62 -3
- package/plugins/shopping-cart/admin/views/shop.js +18 -3
- package/plugins/shopping-cart/plugin.json +3 -3
- package/plugins/shopping-cart/tests/accounts.test.js +11 -0
- package/plugins/shopping-cart/tests/drafts.test.js +118 -0
- package/plugins/support/CLAUDE.md +14 -0
- package/plugins/support/admin/css/index.css +10 -0
- package/plugins/support/admin/lib/drafts.js +162 -0
- package/plugins/support/admin/lib/kit.js +156 -16
- package/plugins/support/admin/lib/settings.js +52 -29
- package/plugins/support/admin/views/desk.js +114 -33
- package/plugins/support/admin/views/inbox.js +85 -26
- package/plugins/support/admin/views/support.js +1 -1
- package/plugins/support/plugin.json +3 -3
- package/plugins/support/tests/drafts.test.js +92 -0
- package/public/js/site.js +1 -1
- package/server/routes/api/analytics.js +5 -1
- package/server/routes/api/contacts.js +42 -0
- package/server/routes/api/forms.js +8 -0
- package/server/routes/api/seo.js +4 -1
- package/server/routes/api/todo.js +50 -10
- package/server/server.js +1 -1
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
74
|
-
let closed = false;
|
|
75
|
-
let asking = false;
|
|
91
|
+
let guard = null;
|
|
76
92
|
const methods = {
|
|
77
|
-
cancel: () =>
|
|
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
|
-
|
|
103
|
+
guard?.close(true);
|
|
85
104
|
} catch (err) {
|
|
86
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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}`);
|