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
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "security",
|
|
3
3
|
"displayName": "Security",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.2.0",
|
|
5
5
|
"tier": "free",
|
|
6
6
|
"description": "Keeps a Domma CMS site safe: a health check of what is weak and how to fix it, sign-in lockout after repeated wrong passwords, password rules, a log of every sign-in, and blocking of the scanners that probe every site for WordPress, .env files and the like.",
|
|
7
7
|
"author": "Domma CMS",
|
|
8
|
-
"date": "2026-
|
|
8
|
+
"date": "2026-10-15",
|
|
9
9
|
"minCmsVersion": "0.83.0",
|
|
10
10
|
"icon": "shield",
|
|
11
11
|
"permissions": [
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/** Security - the screen's pure decisions (admin/lib/screen-state.js). */
|
|
2
|
+
import {test} from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import {applySettingsChanges, settingsChanges, draftKey, draftNote, draftValues, fingerprint, signinCounts, signinInResult, signinMatches, stableJson, usableDraft, withCount} from '../admin/lib/screen-state.js';
|
|
5
|
+
|
|
6
|
+
test('fingerprint ignores key order and changes with any value', () => {
|
|
7
|
+
assert.equal(stableJson({b: 1, a: [1, {d: 2, c: 3}]}), '{"a":[1,{"c":3,"d":2}],"b":1}');
|
|
8
|
+
assert.equal(fingerprint({a: 1, b: {c: 2}}), fingerprint({b: {c: 2}, a: 1}));
|
|
9
|
+
assert.notEqual(fingerprint({a: 1}), fingerprint({a: 2}));
|
|
10
|
+
assert.notEqual(fingerprint({lockout: {enabled: true}}), fingerprint({lockout: {enabled: false}}));
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('a kept draft says when the settings were saved since; an old or matching one says nothing', () => {
|
|
14
|
+
const fp = fingerprint({signinDays: 30});
|
|
15
|
+
assert.equal(draftNote({was: fp}, fp), '');
|
|
16
|
+
assert.match(draftNote({was: fp}, fingerprint({signinDays: 60})), /those later changes stay/);
|
|
17
|
+
assert.match(draftNote({was: fp, full: true}, fingerprint({signinDays: 60})), /older changes over them/);
|
|
18
|
+
assert.equal(draftNote({was: ''}, fp), '', 'a draft without was cannot know');
|
|
19
|
+
assert.equal(draftNote({was: fp}, ''), '', 'the stored settings could not be read');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('usableDraft checks what storage holds; draftValues keeps only the form\'s keys', () => {
|
|
23
|
+
assert.equal(usableDraft(null), null);
|
|
24
|
+
assert.equal(usableDraft('x'), null);
|
|
25
|
+
assert.equal(usableDraft({values: [1]}), null);
|
|
26
|
+
assert.deepEqual(usableDraft({changes: {acc: '5'}, was: 'w', at: 't'}), {changes: {acc: '5'}, was: 'w', at: 't', full: false});
|
|
27
|
+
assert.deepEqual(usableDraft({values: {acc: '5'}, was: 7}), {changes: {acc: '5'}, was: '', at: '', full: true}, 'an older full-values draft is still read');
|
|
28
|
+
assert.equal(usableDraft({changes: {}}), null, 'nothing changed: nothing to offer');
|
|
29
|
+
assert.equal(usableDraft({changes: ['x']}), null);
|
|
30
|
+
assert.deepEqual(draftValues({acc: '5', evil: 'x', lockOn: false}, ['acc', 'lockOn', 'max']), {acc: '5', lockOn: false});
|
|
31
|
+
assert.equal(draftKey(''), '');
|
|
32
|
+
assert.equal(draftKey('u1'), 'security.settings-draft.u1');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('sign-in counts agree with the filters the list uses', () => {
|
|
36
|
+
const log = [
|
|
37
|
+
{email: 'a@x.test', ip: '1.1.1.1', ok: true},
|
|
38
|
+
{email: 'a@x.test', ip: '1.1.1.1', ok: false, reason: 'password'},
|
|
39
|
+
{email: 'b@x.test', ip: '2.2.2.2', ok: false, reason: 'unknown'},
|
|
40
|
+
{email: 'b@x.test', ip: '2.2.2.2', ok: true, kind: 'reset'}
|
|
41
|
+
];
|
|
42
|
+
assert.deepEqual(signinCounts(log), {all: 4, failed: 2, ok: 1, resets: 1});
|
|
43
|
+
assert.deepEqual(signinCounts(log, {ip: '2.2.2.2'}), {all: 2, failed: 1, ok: 0, resets: 1});
|
|
44
|
+
assert.deepEqual(signinCounts(log, {query: 'A@X'}), {all: 2, failed: 1, ok: 1, resets: 0});
|
|
45
|
+
for (const r of ['all', 'failed', 'ok', 'resets']) {
|
|
46
|
+
assert.equal(log.filter(e => signinInResult(e, r) && signinMatches(e, {})).length, signinCounts(log)[r], r);
|
|
47
|
+
}
|
|
48
|
+
assert.equal(signinInResult(log[3], 'ok'), false, 'a reset\'s ok is not a sign-in');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('a count on each button; none while nothing is loaded', () => {
|
|
52
|
+
assert.equal(withCount('Failed', 3), 'Failed 3');
|
|
53
|
+
assert.equal(withCount('All', 1234), 'All 1,234');
|
|
54
|
+
assert.equal(withCount('Failed', undefined), 'Failed');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('a draft keeps only what was changed, and lays just that over the settings as they are now', () => {
|
|
58
|
+
const opened = {acc: '5', lockOn: true, days: '30', stale: '90'};
|
|
59
|
+
const typed = {...opened, days: '45'};
|
|
60
|
+
const changes = settingsChanges(typed, opened);
|
|
61
|
+
assert.deepEqual(changes, {days: '45'});
|
|
62
|
+
assert.deepEqual(settingsChanges(opened, opened), {});
|
|
63
|
+
assert.deepEqual(settingsChanges({...opened, lockOn: false}, opened), {lockOn: false}, 'a switch turned off is a change');
|
|
64
|
+
// Someone else saved stale = 120 and switched the lockout off since: both stay.
|
|
65
|
+
const now = {acc: '5', lockOn: false, days: '30', stale: '120'};
|
|
66
|
+
assert.deepEqual(applySettingsChanges(now, changes), {acc: '5', lockOn: false, days: '45', stale: '120'});
|
|
67
|
+
assert.deepEqual(applySettingsChanges(now, {evil: 'x'}), now, 'a key the form lacks is never added');
|
|
68
|
+
assert.deepEqual(applySettingsChanges(now, null), now);
|
|
69
|
+
});
|
|
@@ -250,3 +250,16 @@ no amount, reason or restock box, and OK refunded the WHOLE order. `refund()` in
|
|
|
250
250
|
slideover (`openPanel`, like Fulfil): amount (prefilled with what is left), reason, restock box (greyed out unless the
|
|
251
251
|
amount is all that is left), a Refund button. Never put form fields in `E.confirm` / `E.alert`.
|
|
252
252
|
`tests/pure.test.js` "admin: no form fields inside E.confirm" guards it across the admin views.
|
|
253
|
+
|
|
254
|
+
## 1.7.0 (2026-10-15 Tool Upgrade, second pass)
|
|
255
|
+
|
|
256
|
+
- Product and settings drafts live in `admin/lib/drafts.js` (pure, tested): one map under
|
|
257
|
+
`shopping-cart.product-drafts` and `shopping-cart.settings-draft`. Payment keys are never kept (`SECRET_KEYS`), a
|
|
258
|
+
draft carries `was`, and OK on the restore question means throw away. Pro's Downloads values are not drafted.
|
|
259
|
+
- `openPanel` takes `busy`, `discarded` and `lock`. `wireRows` handles Shift+F10 once (`keyboardTrigger: false`) and
|
|
260
|
+
leaves fields to the browser.
|
|
261
|
+
- Anything a key press leads to that may open an E.confirm runs on the next task (`kit.soon` / `deferItems`;
|
|
262
|
+
`wireRows` defers Enter and menu actions). Domma's dialog otherwise reads the same Enter as OK, and every OK here is
|
|
263
|
+
destructive.
|
|
264
|
+
- Fulfil and Refund panels are in `actionPanels`, closed by the screen teardown; their saves refuse once it has gone.
|
|
265
|
+
- The accounts test sets a Site URL in `config/site.json` and restores it (`/account/register` needs a link base).
|
|
@@ -9,6 +9,16 @@
|
|
|
9
9
|
.shp-note.is-bad { color: var(--dm-danger); }
|
|
10
10
|
.shp-spacer { flex: 1; }
|
|
11
11
|
.shp-loading { color: var(--dm-text-muted); padding: 2rem 0; }
|
|
12
|
+
/* A skeleton slot: a block of its own (a flex row would collapse the skeleton to a sliver). */
|
|
13
|
+
.shp-loading-slot { display: block; color: var(--dm-text-muted); font-size: var(--dm-text-sm); min-height: 1.5rem; }
|
|
14
|
+
.shp-sales.is-busy .shp-tiles, .shp-sales.is-busy .shp-card { opacity: .55; transition: opacity .15s; }
|
|
15
|
+
/* Drafts left unsaved with no row to show them on (a new product, one deleted since). */
|
|
16
|
+
.shp-drafts { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem; padding: .5rem .7rem; border: 1px dashed color-mix(in srgb, var(--dm-primary) 45%, var(--dm-border));
|
|
17
|
+
border-radius: var(--dm-radius-md); font-size: var(--dm-text-sm); color: var(--dm-text-muted); }
|
|
18
|
+
.shp-drafts button.shp-chip { border: 0; cursor: pointer; font: inherit; font-size: var(--dm-text-xs); font-weight: 600; }
|
|
19
|
+
.shp-drafts button.shp-chip:hover { background: color-mix(in srgb, var(--dm-primary) 24%, transparent); }
|
|
20
|
+
.shp-form.is-locked { opacity: .7; cursor: progress; }
|
|
21
|
+
.shp-restored { border-left-color: var(--dm-primary); background: color-mix(in srgb, var(--dm-primary) 8%, transparent); font-size: var(--dm-text-sm); }
|
|
12
22
|
|
|
13
23
|
/* Tabs under the banner */
|
|
14
24
|
.shp-nav { display: flex; flex-wrap: wrap; gap: .15rem; border-bottom: 1px solid var(--dm-border); }
|
|
@@ -106,6 +116,9 @@ a.shp-tile:hover { border-color: var(--dm-primary); }
|
|
|
106
116
|
.shp-mini-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
|
107
117
|
.shp-mini-main small { color: var(--dm-text-muted); font-size: var(--dm-text-xs); }
|
|
108
118
|
.shp-mini-amt { font-weight: 600; font-variant-numeric: tabular-nums; }
|
|
119
|
+
.shp-top.is-link { cursor: pointer; border-radius: var(--dm-radius-sm); }
|
|
120
|
+
.shp-top.is-link:hover, .shp-top.is-link:focus-visible { background: var(--shp-soft); outline: none; }
|
|
121
|
+
.shp-mini .shp-kebab { flex: none; }
|
|
109
122
|
.shp-top { display: grid; grid-template-columns: minmax(0, 1fr) 30% auto; gap: .75rem; align-items: center; padding: .4rem 0; }
|
|
110
123
|
.shp-top-name { display: flex; flex-direction: column; min-width: 0; }
|
|
111
124
|
.shp-top-name small { color: var(--dm-text-muted); font-size: var(--dm-text-xs); }
|
|
@@ -121,7 +134,7 @@ a.shp-tile:hover { border-color: var(--dm-primary); }
|
|
|
121
134
|
border: 1px solid var(--dm-border); border-radius: var(--dm-radius-md); padding: .75rem 1rem; }
|
|
122
135
|
.shp-steps li::before { content: counter(st); grid-row: 1 / 3; width: 2rem; height: 2rem; border-radius: 50%; display: grid; place-items: center;
|
|
123
136
|
background: var(--shp-soft); font-weight: 700; }
|
|
124
|
-
.shp-steps li.is-done::before { content: '✓'; background: var(--dm-success); color:
|
|
137
|
+
.shp-steps li.is-done::before { content: '✓'; background: color-mix(in srgb, var(--dm-success) 18%, transparent); color: var(--dm-success); }
|
|
125
138
|
.shp-steps li span { grid-column: 2; color: var(--dm-text-muted); font-size: var(--dm-text-sm); }
|
|
126
139
|
.shp-steps li .btn { grid-column: 3; grid-row: 1 / 3; }
|
|
127
140
|
|
|
@@ -158,9 +171,12 @@ a.shp-tile:hover { border-color: var(--dm-primary); }
|
|
|
158
171
|
.shp-pic, .shp-pic-add { position: relative; width: 5.5rem; height: 5.5rem; border-radius: var(--dm-radius-md); overflow: hidden; }
|
|
159
172
|
.shp-pic { cursor: grab; background: var(--shp-soft); }
|
|
160
173
|
.shp-pic img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
|
161
|
-
.shp-pic-x { position: absolute; top: .25rem; right: .25rem; width: 1.4rem; height: 1.4rem; border-radius: 50%; border:
|
|
162
|
-
cursor: pointer; line-height: 1;
|
|
163
|
-
|
|
174
|
+
.shp-pic-x, .shp-pic-more { position: absolute; top: .25rem; right: .25rem; width: 1.4rem; height: 1.4rem; border-radius: 50%; border: 1px solid var(--dm-border);
|
|
175
|
+
background: color-mix(in srgb, var(--dm-surface, var(--dm-body-bg)) 88%, transparent); color: var(--dm-text); cursor: pointer; line-height: 1; padding: 0;
|
|
176
|
+
opacity: 0; transition: opacity .15s; }
|
|
177
|
+
.shp-pic-more { right: auto; left: .25rem; }
|
|
178
|
+
.shp-pic:hover .shp-pic-x, .shp-pic-x:focus, .shp-pic:hover .shp-pic-more, .shp-pic-more:focus, .shp-pic:focus-within .shp-pic-more, .shp-pic:focus-within .shp-pic-x { opacity: 1; }
|
|
179
|
+
.shp-pic:focus-visible { outline: 2px solid var(--dm-primary); outline-offset: 2px; }
|
|
164
180
|
.shp-pic-main { position: absolute; left: .25rem; bottom: .25rem; font-size: .62rem; font-weight: 700; background: var(--dm-primary); color: var(--dm-primary-text, #fff);
|
|
165
181
|
padding: .05rem .35rem; border-radius: 999px; }
|
|
166
182
|
.shp-pic-add { border: 1.5px dashed var(--dm-border); background: none; color: var(--dm-text-muted); cursor: pointer; display: flex; flex-direction: column;
|
|
@@ -178,7 +194,7 @@ a.shp-tile:hover { border-color: var(--dm-primary); }
|
|
|
178
194
|
.shp-variants-head .form-label { margin: 0; white-space: nowrap; }
|
|
179
195
|
.shp-optname { max-width: 12rem; }
|
|
180
196
|
.shp-vtable { width: 100%; display: flex; flex-direction: column; gap: .35rem; }
|
|
181
|
-
.shp-vrow { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1.2fr) minmax(0, .9fr) 1.
|
|
197
|
+
.shp-vrow { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1.2fr) minmax(0, .9fr) 1.6rem 1.6rem; gap: .35rem; align-items: center; }
|
|
182
198
|
.shp-vrow.is-head { font-size: var(--dm-text-xs); color: var(--dm-text-muted); font-weight: 600; }
|
|
183
199
|
.shp-vrow .form-input { padding: .35rem .5rem; font-size: var(--dm-text-sm); }
|
|
184
200
|
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shop admin - kept drafts (pure; tested in ../../tests/drafts.test.js).
|
|
3
|
+
*
|
|
4
|
+
* The product editor keeps what was left unsaved, one entry per product in ONE
|
|
5
|
+
* stored map (`shopping-cart.product-drafts`, key = the product's id, `new` for
|
|
6
|
+
* one not yet added). Every change reads the map fresh, changes its own entry
|
|
7
|
+
* and writes it straight back - nothing holds a copy across an await, so a
|
|
8
|
+
* screen that has gone cannot write an old map over a newer one.
|
|
9
|
+
*
|
|
10
|
+
* A draft carries `was` - the product's updatedAt when the edits it holds
|
|
11
|
+
* began - so the restore question can say the product was changed (or
|
|
12
|
+
* deleted) since. Shop settings keep a draft too, WITHOUT the payment keys
|
|
13
|
+
* (`SECRET_KEYS` never reach storage); their `was` is a fingerprint of the
|
|
14
|
+
* settings as they were, since settings have no updatedAt.
|
|
15
|
+
*
|
|
16
|
+
* Every restore question puts the DESTRUCTIVE answer on OK ("Throw away"):
|
|
17
|
+
* Domma's E.confirm answers false for Cancel, Escape and the backdrop alike,
|
|
18
|
+
* so a false must always keep the changes.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const PRODUCT_DRAFTS_KEY = 'shopping-cart.product-drafts';
|
|
22
|
+
export const SETTINGS_DRAFT_KEY = 'shopping-cart.settings-draft';
|
|
23
|
+
export const NEW_KEY = 'new';
|
|
24
|
+
|
|
25
|
+
/** Settings that are never kept in a draft (the browser's storage is not a safe). */
|
|
26
|
+
export const SECRET_KEYS = ['stripeSecretKey', 'stripeWebhookSecret', 'paypalClientId', 'paypalSecret'];
|
|
27
|
+
|
|
28
|
+
/** The map's key for a product id ('' / null = a new product). */
|
|
29
|
+
export const draftKey = (id) => (id ? String(id) : NEW_KEY);
|
|
30
|
+
|
|
31
|
+
/** JSON with object keys sorted, so two equal values always compare equal. */
|
|
32
|
+
export function stableJson(v) {
|
|
33
|
+
if (Array.isArray(v)) return `[${v.map(stableJson).join(',')}]`;
|
|
34
|
+
if (v && typeof v === 'object') {
|
|
35
|
+
return `{${Object.keys(v).sort().filter(k => v[k] !== undefined).map(k => `${JSON.stringify(k)}:${stableJson(v[k])}`).join(',')}}`;
|
|
36
|
+
}
|
|
37
|
+
return JSON.stringify(v ?? null);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Is this a usable kept draft (this module's shape)? */
|
|
41
|
+
export function isDraft(d) {
|
|
42
|
+
return Boolean(d && typeof d === 'object' && d.values && typeof d.values === 'object' && !Array.isArray(d.values));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The stored map, whatever an older build or a broken write left there. */
|
|
46
|
+
export function readMap(raw) {
|
|
47
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
|
|
48
|
+
const out = {};
|
|
49
|
+
for (const [k, d] of Object.entries(raw)) if (isDraft(d)) out[k] = d;
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The map with `key` set to `draft` (null removes it). A new object; the input is untouched. */
|
|
54
|
+
export function withDraft(map, key, draft) {
|
|
55
|
+
const out = {...readMap(map)};
|
|
56
|
+
if (draft) out[key] = draft; else delete out[key];
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Does the draft hold anything the form does not already show? Only the form's own fields count. */
|
|
61
|
+
export function draftDiffers(draft, form) {
|
|
62
|
+
if (!isDraft(draft)) return false;
|
|
63
|
+
const keys = Object.keys(form || {});
|
|
64
|
+
const pick = (o) => Object.fromEntries(keys.map(k => [k, o?.[k]]));
|
|
65
|
+
return stableJson(pick(draft.values)) !== stableJson(pick(form));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A draft's values, limited to the form's own fields (an older build may have kept others). */
|
|
69
|
+
export function draftValues(draft, form) {
|
|
70
|
+
const out = {};
|
|
71
|
+
for (const k of Object.keys(form || {})) if (isDraft(draft) && k in draft.values) out[k] = draft.values[k];
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* What became of the record since the draft's edits began.
|
|
77
|
+
* @param {{was?: string|null, at?: string}} draft
|
|
78
|
+
* @param {string|null|undefined} now - the record's updatedAt (or fingerprint) now; null = it has gone
|
|
79
|
+
* @returns {'deleted'|'changed'|''}
|
|
80
|
+
*/
|
|
81
|
+
export function draftFate(draft, now) {
|
|
82
|
+
if (now === null) return 'deleted';
|
|
83
|
+
if (!now) return '';
|
|
84
|
+
if (draft?.was) return now !== draft.was ? 'changed' : '';
|
|
85
|
+
return '';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** E.confirm renders its message as HTML: a product's name goes in escaped. */
|
|
89
|
+
export const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
90
|
+
|
|
91
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
92
|
+
/** "15 Oct, 14:05" - local time; '' for anything unreadable. */
|
|
93
|
+
export function when(iso) {
|
|
94
|
+
const t = Date.parse(iso);
|
|
95
|
+
if (!Number.isFinite(t)) return '';
|
|
96
|
+
const d = new Date(t);
|
|
97
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
98
|
+
return `${d.getDate()} ${MONTHS[d.getMonth()]}, ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const KEEP = {title: 'Unsaved changes', confirmText: 'Throw away', cancelText: 'Keep them'};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The question a kept product draft is offered back with. OK throws it away;
|
|
105
|
+
* Cancel / Escape / backdrop keep it - put back in the form, or (`asNew`: the
|
|
106
|
+
* product has been deleted) made into a new product.
|
|
107
|
+
* @param {object} draft
|
|
108
|
+
* @param {{name?: string, updatedAt?: string}|null} product - as stored now; null when it has gone
|
|
109
|
+
* @param {{isNew?: boolean}} [opts]
|
|
110
|
+
* @returns {{text: string, asNew: boolean, options: object}}
|
|
111
|
+
*/
|
|
112
|
+
export function productDraftQuestion(draft, product, {isNew = false} = {}) {
|
|
113
|
+
const name = esc(String(product?.name || draft?.values?.name || '').trim());
|
|
114
|
+
const what = isNew ? `a new product${name ? ` (“${name}”)` : ''}` : (name ? `“${name}”` : 'this product');
|
|
115
|
+
const at = when(draft?.at);
|
|
116
|
+
const ask = `Throw away the changes you left unsaved on ${what}${at ? ` (${at})` : ''}?`;
|
|
117
|
+
if (isNew) return {asNew: false, options: KEEP, text: `${ask} Keeping them puts them back in the form.`};
|
|
118
|
+
switch (draftFate(draft, product ? (product.updatedAt || '') : null)) {
|
|
119
|
+
case 'deleted':
|
|
120
|
+
return {asNew: true, options: {...KEEP, cancelText: 'Keep them as a new product'},
|
|
121
|
+
text: `${ask} The product has been deleted since - keeping them makes a new product from them.`};
|
|
122
|
+
case 'changed':
|
|
123
|
+
return {asNew: false, options: KEEP,
|
|
124
|
+
text: `${ask} The product has been changed since${product.updatedAt ? ` (${when(product.updatedAt)})` : ''} - keeping them puts your changes over that when you save.`};
|
|
125
|
+
default:
|
|
126
|
+
return {asNew: false, options: KEEP, text: `${ask} Keeping them puts them back in the form.`};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* A deleted product's draft, made a new product's: its `was` meant the old
|
|
132
|
+
* record, so it starts empty; its options lose their ids (they belonged to the
|
|
133
|
+
* deleted product); `adopted` tells the editor the person has already said keep.
|
|
134
|
+
*/
|
|
135
|
+
export function adoptAsNew(draft, now = new Date().toISOString()) {
|
|
136
|
+
const values = {...(draft?.values || {})};
|
|
137
|
+
if (Array.isArray(values.variants)) values.variants = values.variants.map(v => ({...v, id: ''}));
|
|
138
|
+
return {values, slugTouched: Boolean(draft?.slugTouched), was: null, at: draft?.at || now, adopted: true};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The question for another new-product draft an adopted one would replace. */
|
|
142
|
+
export function replaceNewDraftQuestion(other) {
|
|
143
|
+
const name = esc(String(other?.values?.name || '').trim());
|
|
144
|
+
const at = when(other?.at);
|
|
145
|
+
return {
|
|
146
|
+
text: `There is already a new product left unsaved${name ? ` - “${name}”` : ''}${at ? ` (${at})` : ''}. Throw it away to make room for these changes?`,
|
|
147
|
+
options: {title: 'Unsaved new product', confirmText: 'Throw it away', cancelText: 'Keep both'}
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---- Settings ---------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
/** Settings values safe to keep: everything but the payment keys. */
|
|
154
|
+
export function keepableSettings(values) {
|
|
155
|
+
const out = {};
|
|
156
|
+
for (const [k, v] of Object.entries(values || {})) if (!SECRET_KEYS.includes(k)) out[k] = v;
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** A fingerprint of the stored settings (secrets left out - they arrive masked anyway). */
|
|
161
|
+
export const settingsFingerprint = (settings) => stableJson(keepableSettings(settings));
|
|
162
|
+
|
|
163
|
+
/** The settings' restore question. OK throws the draft away; anything else puts it back. */
|
|
164
|
+
export function settingsDraftQuestion(draft, nowFingerprint) {
|
|
165
|
+
const at = when(draft?.at);
|
|
166
|
+
const ask = `Throw away the changes you left unsaved in Shop settings${at ? ` (${at})` : ''}?`;
|
|
167
|
+
const changed = draftFate(draft, nowFingerprint || '') === 'changed';
|
|
168
|
+
return {
|
|
169
|
+
options: KEEP,
|
|
170
|
+
text: changed
|
|
171
|
+
? `${ask} The settings have been changed since - keeping them puts your changes over that when you save. Payment keys are never kept; type them again if you changed any.`
|
|
172
|
+
: `${ask} Keeping them puts them back in the form. Payment keys are never kept; type them again if you changed any.`
|
|
173
|
+
};
|
|
174
|
+
}
|
|
@@ -17,22 +17,27 @@ export const BASE = '/api/plugins/shopping-cart';
|
|
|
17
17
|
|
|
18
18
|
let kit = null;
|
|
19
19
|
|
|
20
|
-
/** tool-kit, plugin-chrome
|
|
20
|
+
/** tool-kit, plugin-chrome, help-popover and loading, once, with stand-ins for an older admin. */
|
|
21
21
|
export function loadKit() {
|
|
22
22
|
if (!kit) {
|
|
23
23
|
kit = Promise.all([
|
|
24
24
|
import('/admin/js/lib/tool-kit.js').catch(() => ({})),
|
|
25
25
|
import('/admin/js/lib/plugin-chrome.js').catch(() => ({})),
|
|
26
|
-
import('/admin/js/lib/help-popover.js').catch(() => ({}))
|
|
27
|
-
|
|
26
|
+
import('/admin/js/lib/help-popover.js').catch(() => ({})),
|
|
27
|
+
import('/admin/js/lib/loading.js').catch(() => ({}))
|
|
28
|
+
]).then(([tk, chrome, help, loading]) => ({
|
|
28
29
|
onceClosed: tk.onceClosed || ((so, fn) => { const c = so.close.bind(so); so.close = (...a) => { const r = c(...a); fn?.(); return r; }; }),
|
|
29
30
|
openMenuAt: tk.openMenuAt || ((el) => {
|
|
30
31
|
const r = el.getBoundingClientRect();
|
|
31
32
|
el.dispatchEvent(new MouseEvent('contextmenu', {bubbles: true, cancelable: true, clientX: r.left, clientY: r.bottom, view: window}));
|
|
32
33
|
}),
|
|
33
|
-
viewLifetime: tk.viewLifetime || (() => ({add() {}, reset() {}})),
|
|
34
|
+
viewLifetime: tk.viewLifetime || (() => ({add() { return () => {}; }, reset() {}})),
|
|
35
|
+
promptWith: tk.promptWith || ((message, options, value) => E.prompt(message, {...options, inputValue: String(value ?? '')})),
|
|
34
36
|
addBannerAction: chrome.addBannerAction || (() => null),
|
|
35
|
-
helpPopovers: help.helpPopovers || (() => () => {})
|
|
37
|
+
helpPopovers: help.helpPopovers || (() => () => {}),
|
|
38
|
+
// A skeleton while a first load runs (core's loading.js); an older core just waits.
|
|
39
|
+
whileLoading: loading.whileLoading || ((slot, promise) => (typeof promise === 'function' ? promise() : promise)),
|
|
40
|
+
SHAPES: loading.SHAPES || {}
|
|
36
41
|
}));
|
|
37
42
|
}
|
|
38
43
|
return kit;
|
|
@@ -95,11 +100,61 @@ export function fragment(html) {
|
|
|
95
100
|
return t.content.firstElementChild;
|
|
96
101
|
}
|
|
97
102
|
|
|
103
|
+
/**
|
|
104
|
+
* `fn`, run on the next task instead of now. Anything a key press leads to that may open a dialog goes through
|
|
105
|
+
* this: Domma's dialog listens for keys on the document from the moment it opens, so one opened while an Enter
|
|
106
|
+
* is still on its way up reads that same Enter as OK - and every OK here is the destructive answer (Throw away,
|
|
107
|
+
* Discard, Delete). A click does not need it, but it costs nothing, so buttons and menu items use it too.
|
|
108
|
+
*/
|
|
109
|
+
export const soon = (fn) => (...args) => { setTimeout(() => fn(...args), 0); };
|
|
110
|
+
|
|
111
|
+
/** Storage that never throws (blocked storage, private windows). */
|
|
98
112
|
export const store = {
|
|
99
|
-
get(k) { try { return S.get(k); } catch { return null; } },
|
|
100
|
-
set(k, v) { try { S.set(k, v); } catch { /* a convenience */ } }
|
|
113
|
+
get(k) { try { return S.get(k) ?? null; } catch { return null; } },
|
|
114
|
+
set(k, v) { try { S.set(k, v); } catch { /* a convenience */ } },
|
|
115
|
+
remove(k) { try { if (S.remove) S.remove(k); else S.set(k, null); } catch { /* a convenience */ } }
|
|
101
116
|
};
|
|
102
117
|
|
|
118
|
+
/**
|
|
119
|
+
* E.confirm that a screen can take back: `dismiss()` answers it with Cancel -
|
|
120
|
+
* which every question here makes the keeping, non-destructive answer - so a
|
|
121
|
+
* question about a screen the person has left does not stay up over the next
|
|
122
|
+
* one. The caller ignores the answer once its screen has gone.
|
|
123
|
+
*
|
|
124
|
+
* @returns {{answer: Promise<boolean>, dismiss: () => void}}
|
|
125
|
+
*/
|
|
126
|
+
export function ask(text, options = {}) {
|
|
127
|
+
const answer = E.confirm(text, options);
|
|
128
|
+
const box = [...document.querySelectorAll('.dm-dialog')].at(-1) || null;
|
|
129
|
+
return {
|
|
130
|
+
answer,
|
|
131
|
+
dismiss() {
|
|
132
|
+
if (!box?.isConnected) return;
|
|
133
|
+
const label = options.cancelText || 'Cancel';
|
|
134
|
+
[...box.querySelectorAll('button')].find(b => b.textContent.trim() === label)?.click();
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Once-only claims for panels, per mount: `take(key)` answers a release
|
|
141
|
+
* function, or null when that panel is already opening or open (a double
|
|
142
|
+
* click, a row clicked while its panel is still loading). Whoever takes a
|
|
143
|
+
* claim must release it if opening fails, or the panel never opens again.
|
|
144
|
+
*/
|
|
145
|
+
export function claims() {
|
|
146
|
+
const held = new Map();
|
|
147
|
+
return {
|
|
148
|
+
take(key) {
|
|
149
|
+
if (held.has(key)) return null;
|
|
150
|
+
const token = {};
|
|
151
|
+
held.set(key, token);
|
|
152
|
+
return () => { if (held.get(key) === token) held.delete(key); };
|
|
153
|
+
},
|
|
154
|
+
has: (key) => held.has(key)
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
103
158
|
/** The sidebar re-asks the Shop's badge. */
|
|
104
159
|
export function refreshBadge() {
|
|
105
160
|
try { window.dispatchEvent(new CustomEvent('dm:sidebar-count', {detail: {url: SIDEBAR_URL}})); } catch { /* no sidebar */ }
|
|
@@ -135,12 +190,22 @@ export function when(iso) {
|
|
|
135
190
|
// ---- Slideovers ---------------------------------------------------------------------
|
|
136
191
|
|
|
137
192
|
/**
|
|
138
|
-
* Open a slideover holding `html`, bound to `model`.
|
|
139
|
-
*
|
|
193
|
+
* Open a slideover holding `html`, bound to `model`. Everything is disposed
|
|
194
|
+
* and removed on close.
|
|
140
195
|
*
|
|
141
|
-
*
|
|
196
|
+
* - `dirty()`: closing (X, Escape, the backdrop, Close) asks first - OK
|
|
197
|
+
* discards, Cancel / Escape keep the panel open.
|
|
198
|
+
* - `busy()`: while true (a save on its way) the person cannot close it; only
|
|
199
|
+
* code can (`close(true)`), so the panel the save reports back to is still there.
|
|
200
|
+
* - `discarded()`: called when the PERSON closes it (after saying discard, or
|
|
201
|
+
* with nothing to lose) - not when code force-closes it (a save, the screen
|
|
202
|
+
* going) - so a kept draft is dropped only on their say-so.
|
|
203
|
+
* - `lock(on)` makes the form inert while a save that closes the panel runs, so
|
|
204
|
+
* nothing typed meanwhile is lost with it.
|
|
205
|
+
*
|
|
206
|
+
* @returns {Promise<{so, panel, close: (force?: boolean) => void, isOpen: () => boolean, lock: (on: boolean) => void}>}
|
|
142
207
|
*/
|
|
143
|
-
export async function openPanel({title, size = 'md', html, model = {}, methods = {}, dirty, cleanup = () => {}, before}) {
|
|
208
|
+
export async function openPanel({title, size = 'md', html, model = {}, methods = {}, dirty, busy, discarded, cleanup = () => {}, before}) {
|
|
144
209
|
const {onceClosed, helpPopovers} = await loadKit();
|
|
145
210
|
const so = E.slideover({title: esc(title), size, position: 'right'});
|
|
146
211
|
const panel = fragment(html);
|
|
@@ -148,17 +213,25 @@ export async function openPanel({title, size = 'md', html, model = {}, methods =
|
|
|
148
213
|
before?.(panel);
|
|
149
214
|
const disposeHelp = helpPopovers(panel);
|
|
150
215
|
const handle = M.applyBindings(model, panel, {methods});
|
|
151
|
-
|
|
216
|
+
let question = null;
|
|
217
|
+
onceClosed(so, () => { question?.dismiss(); cleanup(); handle.dispose(); disposeHelp(); });
|
|
152
218
|
const close = so.close.bind(so);
|
|
153
219
|
let closed = false;
|
|
154
|
-
let asking = false;
|
|
155
220
|
so.close = async (force) => {
|
|
156
|
-
if (!closed && force !== true
|
|
157
|
-
if (
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
221
|
+
if (!closed && force !== true) {
|
|
222
|
+
if (busy?.()) return undefined;
|
|
223
|
+
if (dirty?.()) {
|
|
224
|
+
if (question) return undefined;
|
|
225
|
+
// Not inside the key press that asked to close (Enter on Close, Escape): see soon().
|
|
226
|
+
question = {dismiss() {}};
|
|
227
|
+
await new Promise(r => setTimeout(r, 0));
|
|
228
|
+
if (closed) { question = null; return undefined; }
|
|
229
|
+
question = ask('Discard your changes?', {title: 'Unsaved changes', confirmText: 'Discard', cancelText: 'Keep editing'});
|
|
230
|
+
const ok = await question.answer;
|
|
231
|
+
question = null;
|
|
232
|
+
if (!ok || closed || busy?.()) return undefined;
|
|
233
|
+
}
|
|
234
|
+
try { discarded?.(); } catch { /* the panel still closes */ }
|
|
162
235
|
}
|
|
163
236
|
closed = true;
|
|
164
237
|
return close();
|
|
@@ -168,7 +241,12 @@ export async function openPanel({title, size = 'md', html, model = {}, methods =
|
|
|
168
241
|
});
|
|
169
242
|
I.scan(panel);
|
|
170
243
|
so.open();
|
|
171
|
-
return {
|
|
244
|
+
return {
|
|
245
|
+
so, panel,
|
|
246
|
+
close: (force) => so.close(force),
|
|
247
|
+
isOpen: () => !closed,
|
|
248
|
+
lock(on) { panel.inert = Boolean(on); panel.classList.toggle('is-locked', Boolean(on)); }
|
|
249
|
+
};
|
|
172
250
|
}
|
|
173
251
|
|
|
174
252
|
/**
|
|
@@ -212,26 +290,47 @@ export async function mountPanels(anchor, {on, ctx, info = {}}) {
|
|
|
212
290
|
return {panels, destroy: () => undo.forEach(fn => { try { fn(); } catch { /* gone */ } })};
|
|
213
291
|
}
|
|
214
292
|
|
|
293
|
+
/** Typing fields keep the browser's own right-click (paste) and their own clicks. */
|
|
294
|
+
const FIELDS = 'input, textarea, select';
|
|
295
|
+
|
|
215
296
|
/**
|
|
216
297
|
* Rows: click (or Enter) = the main action; right-click, ⋮ or Shift+F10 = the
|
|
217
|
-
* menu.
|
|
298
|
+
* menu. A right-click or click on a field inside a row is left to the
|
|
299
|
+
* browser. `scope` receives the teardown.
|
|
218
300
|
*/
|
|
219
301
|
export function wireRows(list, {items, main, openMenuAt, scope, match = '.shp-row'}) {
|
|
220
|
-
|
|
221
|
-
|
|
302
|
+
// keyboardTrigger off: Shift+F10 is answered here, once (Domma's own document listener answering it too
|
|
303
|
+
// opened the menu and closed it again at once), and the ContextMenu key is left alone (Chrome on Windows
|
|
304
|
+
// opens its own menu on key-up).
|
|
305
|
+
// Menu items run on the next task: an item picked with Enter that asks something must not hand it that Enter.
|
|
306
|
+
const menu = E.contextMenu(list, {cursor: 'glyph', match, exclude: FIELDS, inherit: false, density: 'compact', keyboardTrigger: false,
|
|
307
|
+
items: (row) => deferItems(items(row.dataset.id))});
|
|
222
308
|
const onClick = (e) => {
|
|
223
309
|
const row = e.target.closest(match);
|
|
224
|
-
if (!row) return;
|
|
225
|
-
if (e.target.closest('[data-stop]')) return;
|
|
310
|
+
if (!row || !list.contains(row)) return;
|
|
311
|
+
if (e.target.closest('[data-stop]') || e.target.closest(FIELDS)) return;
|
|
226
312
|
const kebab = e.target.closest('.shp-kebab');
|
|
227
313
|
if (kebab) { openMenuAt(kebab); return; }
|
|
228
314
|
main(row.dataset.id);
|
|
229
315
|
};
|
|
230
316
|
const onKey = (e) => {
|
|
231
317
|
const row = e.target.closest(match);
|
|
232
|
-
if (!row) return;
|
|
233
|
-
if (e.key === 'F10' && e.shiftKey) {
|
|
234
|
-
|
|
318
|
+
if (!row || !list.contains(row)) return;
|
|
319
|
+
if (e.key === 'F10' && e.shiftKey) {
|
|
320
|
+
if (e.target.closest(FIELDS)) return;
|
|
321
|
+
e.preventDefault();
|
|
322
|
+
e.stopPropagation();
|
|
323
|
+
openMenuAt(row.querySelector('.shp-kebab') || row);
|
|
324
|
+
}
|
|
325
|
+
else if (e.key === 'Enter' && e.target === row) {
|
|
326
|
+
// A link row follows itself. Otherwise the Enter stops here and the main action runs after it,
|
|
327
|
+
// so a question it opens (a kept draft's) does not read this Enter as OK.
|
|
328
|
+
if (row.matches('a[href]')) return;
|
|
329
|
+
e.preventDefault();
|
|
330
|
+
e.stopPropagation();
|
|
331
|
+
const id = row.dataset.id;
|
|
332
|
+
setTimeout(() => main(id), 0);
|
|
333
|
+
}
|
|
235
334
|
else if ((e.key === 'ArrowDown' || e.key === 'ArrowUp') && e.target === row) {
|
|
236
335
|
e.preventDefault();
|
|
237
336
|
const rows = [...list.querySelectorAll(match)].filter(r => r.offsetParent);
|
|
@@ -243,6 +342,15 @@ export function wireRows(list, {items, main, openMenuAt, scope, match = '.shp-ro
|
|
|
243
342
|
scope(() => { list.removeEventListener('click', onClick); list.removeEventListener('keydown', onKey); menu?.destroy?.(); });
|
|
244
343
|
}
|
|
245
344
|
|
|
345
|
+
/** Menu items with every action deferred (soon), submenus included. */
|
|
346
|
+
export function deferItems(list) {
|
|
347
|
+
return (list || []).map(x => ({
|
|
348
|
+
...x,
|
|
349
|
+
...(typeof x.action === 'function' ? {action: soon(x.action)} : {}),
|
|
350
|
+
...(Array.isArray(x.items) ? {items: deferItems(x.items)} : {})
|
|
351
|
+
}));
|
|
352
|
+
}
|
|
353
|
+
|
|
246
354
|
/** Copy text, and say so. */
|
|
247
355
|
export function copy(text, what = 'Copied') {
|
|
248
356
|
navigator.clipboard?.writeText(text).then(() => E.toast(`${what}.`, {type: 'success'}), () => E.toast(text));
|