domma-cms 1.6.56 → 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/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
|
@@ -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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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.
|
|
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-
|
|
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.
|
|
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>'), '<a href="x">&</a>');
|
|
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 <img src=x onerror=alert\(1\)> & "Co"/);
|
|
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
|
+
}
|