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
|
@@ -23,10 +23,24 @@ export const settingsView = {
|
|
|
23
23
|
const kit = await import(`./kit.js?v=${ASSET_VERSION}`);
|
|
24
24
|
const add = kit.viewScope('settings', /^#\/plugins\/blog\/settings\b/);
|
|
25
25
|
|
|
26
|
+
const here = add.here;
|
|
26
27
|
const host = $container.find('#settings-form').get(0);
|
|
27
28
|
if (!host) return;
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
const panel = await kit.settingsPanel();
|
|
30
|
+
if (!here()) { panel.dispose(); return; }
|
|
31
|
+
host.appendChild(panel.el);
|
|
32
|
+
// Left with unsaved changes: kept as a draft, offered back next time
|
|
33
|
+
// (here or in the cog's slideover - it is one form).
|
|
34
|
+
// Keeping it twice (a reload the person then cancelled, then leaving) is harmless.
|
|
35
|
+
const onUnload = (e) => { if (panel.isDirty()) { kit.keepSettingsDraft(panel); e.preventDefault(); e.returnValue = ''; } };
|
|
36
|
+
window.addEventListener('beforeunload', onUnload);
|
|
37
|
+
add(() => {
|
|
38
|
+
window.removeEventListener('beforeunload', onUnload);
|
|
39
|
+
if (kit.keepSettingsDraft(panel)) {
|
|
40
|
+
E.toast('Your unsaved settings are kept - open Blog settings again to pick them up.', {type: 'info'});
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
add(panel.dispose);
|
|
44
|
+
await kit.offerSettingsDraft(panel, here);
|
|
31
45
|
}
|
|
32
46
|
};
|
package/plugins/blog/plugin.js
CHANGED
|
@@ -393,7 +393,7 @@ export default async function blogPlugin(fastify, options) {
|
|
|
393
393
|
});
|
|
394
394
|
const html = await renderBlogPage(
|
|
395
395
|
fileURLToPath(new URL('./templates/post.html', import.meta.url)),
|
|
396
|
-
{ post: page.html, assetV: '1.
|
|
396
|
+
{ post: page.html, assetV: '1.10.0' },
|
|
397
397
|
{ title: post.title || 'Preview', description: post.excerpt || '', ogImage: safeImageUrl(post.featuredImage),
|
|
398
398
|
usedComponents: page.usedComponents, urlPath: `${basePath}/${post.slug}`, noindex: true }
|
|
399
399
|
);
|
package/plugins/blog/plugin.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "blog",
|
|
3
3
|
"displayName": "Blog",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.10.0",
|
|
5
5
|
"tier": "free",
|
|
6
6
|
"description": "Public-facing blog with posts, categories and comments - layouts and listings built from editable blocks.",
|
|
7
7
|
"author": "Domma CMS",
|
|
8
|
-
"date": "2026-10-
|
|
8
|
+
"date": "2026-10-15",
|
|
9
9
|
"icon": "file-text",
|
|
10
10
|
"admin": {
|
|
11
11
|
"css": [
|
|
@@ -79,23 +79,23 @@
|
|
|
79
79
|
],
|
|
80
80
|
"views": {
|
|
81
81
|
"plugin-blog-posts": {
|
|
82
|
-
"entry": "blog/admin/views/blog.js?v=
|
|
82
|
+
"entry": "blog/admin/views/blog.js?v=11",
|
|
83
83
|
"exportName": "blogView"
|
|
84
84
|
},
|
|
85
85
|
"plugin-blog-post-editor": {
|
|
86
|
-
"entry": "blog/admin/views/post-editor.js?v=
|
|
86
|
+
"entry": "blog/admin/views/post-editor.js?v=11",
|
|
87
87
|
"exportName": "postEditorView"
|
|
88
88
|
},
|
|
89
89
|
"plugin-blog-categories": {
|
|
90
|
-
"entry": "blog/admin/views/categories.js?v=
|
|
90
|
+
"entry": "blog/admin/views/categories.js?v=10",
|
|
91
91
|
"exportName": "categoriesView"
|
|
92
92
|
},
|
|
93
93
|
"plugin-blog-comments": {
|
|
94
|
-
"entry": "blog/admin/views/comments.js?v=
|
|
94
|
+
"entry": "blog/admin/views/comments.js?v=11",
|
|
95
95
|
"exportName": "commentsView"
|
|
96
96
|
},
|
|
97
97
|
"plugin-blog-settings": {
|
|
98
|
-
"entry": "blog/admin/views/settings.js?v=
|
|
98
|
+
"entry": "blog/admin/views/settings.js?v=10",
|
|
99
99
|
"exportName": "settingsView"
|
|
100
100
|
}
|
|
101
101
|
}
|
|
@@ -24,7 +24,7 @@ const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
|
24
24
|
const TEMPLATES_DIR = path.join(__dirname, 'templates');
|
|
25
25
|
|
|
26
26
|
/** Bumped with the plugin, so a release is never served a stale script. */
|
|
27
|
-
const ASSET_V = '1.
|
|
27
|
+
const ASSET_V = '1.10.0';
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* Above this many posts a listing pages on the server instead: the browser
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Blog - kept drafts: versions, how a draft stands, the restore question (admin/lib/drafts.js). */
|
|
2
|
+
import {test} from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import {draftState, fingerprint, restoreQuestion, RESTORE_ANSWERS, stableJson, versionOf} from '../admin/lib/drafts.js';
|
|
5
|
+
|
|
6
|
+
test('stable JSON ignores key order; a fingerprint follows the value', () => {
|
|
7
|
+
assert.equal(stableJson({b: 1, a: [1, {d: 2, c: 3}]}), stableJson({a: [1, {c: 3, d: 2}], b: 1}));
|
|
8
|
+
assert.equal(fingerprint({a: 1, b: 2}), fingerprint({b: 2, a: 1}));
|
|
9
|
+
assert.notEqual(fingerprint({a: 1}), fingerprint({a: 2}));
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test('a version is the updatedAt, else a fingerprint of the fields that matter', () => {
|
|
13
|
+
assert.equal(versionOf({updatedAt: '2026-10-15T10:00:00Z', title: 'x'}), 'u:2026-10-15T10:00:00Z');
|
|
14
|
+
const cat = {name: 'News', slug: 'news', description: '', posts: 3};
|
|
15
|
+
const keys = ['name', 'slug', 'description'];
|
|
16
|
+
assert.equal(versionOf(cat, keys), versionOf({...cat, posts: 9}, keys));
|
|
17
|
+
assert.notEqual(versionOf(cat, keys), versionOf({...cat, name: 'Updates'}, keys));
|
|
18
|
+
assert.equal(versionOf(null), '');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('a draft stands same, changed or deleted against the record now', () => {
|
|
22
|
+
const rec = {updatedAt: '2026-10-15T10:00:00Z'};
|
|
23
|
+
assert.equal(draftState(null, rec), 'none');
|
|
24
|
+
assert.equal(draftState({id: 'p1'}, rec), 'none');
|
|
25
|
+
assert.equal(draftState({id: null, values: {}}, null), 'same');
|
|
26
|
+
assert.equal(draftState({id: 'p1', values: {}, was: 'u:2026-10-15T10:00:00Z'}, rec), 'same');
|
|
27
|
+
assert.equal(draftState({id: 'p1', values: {}, was: 'u:2026-10-14T10:00:00Z'}, rec), 'changed');
|
|
28
|
+
assert.equal(draftState({id: 'p1', values: {}, was: 'u:x'}, null), 'deleted');
|
|
29
|
+
const c = {name: 'A'};
|
|
30
|
+
assert.equal(draftState({id: 'c', values: {}, was: versionOf(c, ['name'])}, {name: 'A', n: 4}, ['name']), 'same');
|
|
31
|
+
assert.equal(draftState({id: 'c', values: {}, was: versionOf(c, ['name'])}, {name: 'B'}, ['name']), 'changed');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('a draft kept before `was` falls back to its time', () => {
|
|
35
|
+
const rec = {updatedAt: '2026-10-15T10:00:00Z'};
|
|
36
|
+
assert.equal(draftState({id: 'p', values: {}, at: '2026-10-14T00:00:00Z'}, rec), 'changed');
|
|
37
|
+
assert.equal(draftState({id: 'p', values: {}, at: '2026-10-16T00:00:00Z'}, rec), 'same');
|
|
38
|
+
assert.equal(draftState({id: 'p', values: {}}, rec), 'same');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('the restore question makes OK the destructive answer and says what happened since', () => {
|
|
42
|
+
assert.equal(restoreQuestion({what: 'post', isNew: true, state: 'same'}),
|
|
43
|
+
'Throw away the changes you left unsaved to a new post?');
|
|
44
|
+
assert.match(restoreQuestion({what: 'post', name: 'Hello', state: 'changed'}),
|
|
45
|
+
/^Throw away the changes you left unsaved to the post "Hello"\? It has been saved since/);
|
|
46
|
+
assert.match(restoreQuestion({what: 'category', name: 'News', state: 'deleted'}),
|
|
47
|
+
/deleted since - keeping yours starts it again as a new category\.$/);
|
|
48
|
+
assert.equal(restoreQuestion({what: 'settings', state: 'same'}), 'Throw away the changes you left unsaved to the settings?');
|
|
49
|
+
assert.deepEqual({...RESTORE_ANSWERS}, {confirmText: 'Throw away', cancelText: 'Keep them'});
|
|
50
|
+
});
|
|
@@ -180,3 +180,16 @@ only to roles that hold it.
|
|
|
180
180
|
turn them back into plain data for the dirty snapshot and the save. A "from" after the "to" still pulls "to" along.
|
|
181
181
|
- Icons in rows added after binding are drawn by `I.scan` after a flush (`rescan`), because new rows arrive after
|
|
182
182
|
the panel's first scan.
|
|
183
|
+
|
|
184
|
+
## 1.7.0 (2026-10-15 Tool Upgrade, second pass)
|
|
185
|
+
|
|
186
|
+
- Slideovers: `openPanel({ctx, release, draft})` - the panel goes with the screen (`ctx.hold`), `claim(key)` stops it
|
|
187
|
+
opening twice (released if building the panel throws - it is built inside try/catch), and `methods.save` is locked
|
|
188
|
+
while it runs. `draft: {key, label, kind, values, apply(values, panel), was, prefilled, initial}` keeps typed work
|
|
189
|
+
per user (`booking.drafts.<user>`, 14 days, rules in `admin/lib/drafts.js`, tests/admin-drafts.test.js); `was` is
|
|
190
|
+
`updatedAt`, or `fingerprint()` for settings; a draft taken "as new" from a deleted record starts with `was: ''` and
|
|
191
|
+
waits while another new-record draft exists. `ask()` is an E.confirm that answers 'no' when the screen goes.
|
|
192
|
+
- Restore questions put Discard on the CONFIRM button: Escape, the backdrop or leaving keep the draft (Domma's confirm
|
|
193
|
+
cannot tell Escape from Cancel). Booking Pro's sections are not drafted - the section contract cannot put values back.
|
|
194
|
+
- First loads use core skeletons through `kit.whileLoading` (`.bk-loading-slot`) on the host, Bookings and the Diary.
|
|
195
|
+
- No ContextMenu-key handler in wireRows: Windows Chrome opens its own menu on that key too. Shift+F10 only.
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Booking admin - drafts, the pure part (no DOM, no storage; tested in
|
|
3
|
+
* tests/admin-drafts.test.js). kit.js `openPanel({draft})` does the rest.
|
|
4
|
+
*
|
|
5
|
+
* A draft is what a slideover form held when it was left (the screen changed,
|
|
6
|
+
* the tab closed): `{values, at, was, label}`. `was` is the record as the form
|
|
7
|
+
* began - its `updatedAt`, or a `fingerprint()` of it when it has none (the
|
|
8
|
+
* settings) - so the next opening can say the record changed since. All of a
|
|
9
|
+
* person's drafts live in one stored map, keyed `service:<id|new>`,
|
|
10
|
+
* `bookable:<id|new>`, `new-booking`, `note:<booking id>`, `settings`.
|
|
11
|
+
*
|
|
12
|
+
* @module booking/admin/lib/drafts
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** A draft older than this is not offered back. */
|
|
16
|
+
export const DRAFT_MAX_DAYS = 14;
|
|
17
|
+
|
|
18
|
+
/** JSON with object keys sorted, so equal data is equal text. */
|
|
19
|
+
export function stableStringify(value) {
|
|
20
|
+
if (Array.isArray(value)) return `[${value.map(v => stableStringify(v === undefined ? null : v)).join(',')}]`;
|
|
21
|
+
if (value && typeof value === 'object') {
|
|
22
|
+
return `{${Object.keys(value).sort().filter(k => value[k] !== undefined)
|
|
23
|
+
.map(k => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`;
|
|
24
|
+
}
|
|
25
|
+
return JSON.stringify(value ?? null);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A short, stable fingerprint of some data (FNV-1a over its stable JSON). */
|
|
29
|
+
export function fingerprint(value) {
|
|
30
|
+
const s = stableStringify(value);
|
|
31
|
+
let h = 0x811c9dc5;
|
|
32
|
+
for (let i = 0; i < s.length; i++) {
|
|
33
|
+
h ^= s.charCodeAt(i);
|
|
34
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
35
|
+
}
|
|
36
|
+
return `f${h.toString(36)}${s.length.toString(36)}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A stored draft, if it is one a form can use: `values` a plain object, not
|
|
41
|
+
* older than `maxAgeDays`. Anything else (an older build's, a hand-edited
|
|
42
|
+
* store) is not trusted.
|
|
43
|
+
*
|
|
44
|
+
* @returns {{values: object, at: string, was: string, label: string}|null}
|
|
45
|
+
*/
|
|
46
|
+
export function usableDraft(raw, {now = Date.now(), maxAgeDays = DRAFT_MAX_DAYS} = {}) {
|
|
47
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
48
|
+
const v = raw.values;
|
|
49
|
+
if (!v || typeof v !== 'object' || Array.isArray(v)) return null;
|
|
50
|
+
const at = Date.parse(raw.at);
|
|
51
|
+
if (!Number.isFinite(at) || now - at > maxAgeDays * 86_400_000) return null;
|
|
52
|
+
return {values: v, at: String(raw.at), was: typeof raw.was === 'string' ? raw.was : '', label: typeof raw.label === 'string' ? raw.label : ''};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* What to do with a stored draft when a form opens.
|
|
57
|
+
*
|
|
58
|
+
* 'none' no draft, or it holds just what the form opened with
|
|
59
|
+
* 'auto' put it back and say so - the record is as it was
|
|
60
|
+
* 'changed' ask: the record was saved by someone (or somewhere) since
|
|
61
|
+
* 'prefilled' ask: the form was opened for something specific (a contact,
|
|
62
|
+
* a time clicked in the Diary), which the draft would replace
|
|
63
|
+
*
|
|
64
|
+
* @param {{draft: object|null, was?: string, opening?: object, prefilled?: boolean}} o
|
|
65
|
+
* `was` - the record as the form opens now ('' for a new one); `opening` -
|
|
66
|
+
* the values the form opened with.
|
|
67
|
+
*/
|
|
68
|
+
export function restorePlan({draft, was = '', opening, prefilled = false}) {
|
|
69
|
+
if (!draft) return 'none';
|
|
70
|
+
if (opening !== undefined && stableStringify(draft.values) === stableStringify(opening)) return 'none';
|
|
71
|
+
if ((draft.was || '') !== (was || '')) return 'changed';
|
|
72
|
+
return prefilled ? 'prefilled' : 'auto';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** "3 min ago", "yesterday", "2 days ago". */
|
|
76
|
+
export function agoText(at, now = Date.now()) {
|
|
77
|
+
const t = Date.parse(at);
|
|
78
|
+
if (!Number.isFinite(t)) return 'earlier';
|
|
79
|
+
const s = Math.max(0, (now - t) / 1000);
|
|
80
|
+
if (s < 60) return 'just now';
|
|
81
|
+
if (s < 3600) return `${Math.round(s / 60)} min ago`;
|
|
82
|
+
if (s < 86400) return `${Math.round(s / 3600)} h ago`;
|
|
83
|
+
const d = Math.round(s / 86400);
|
|
84
|
+
return d === 1 ? 'yesterday' : `${d} days ago`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The question (plain text - the caller escapes it) for a plan that asks, or
|
|
89
|
+
* the toast for 'auto'.
|
|
90
|
+
*
|
|
91
|
+
* @param {'auto'|'changed'|'prefilled'|'deleted'} plan
|
|
92
|
+
* @param {{label?: string, at?: string, now?: number, kind?: string}} o - `kind` names the form for 'prefilled' ("a new booking")
|
|
93
|
+
*/
|
|
94
|
+
export function restoreText(plan, {label = '', at = '', now = Date.now(), kind = 'something'} = {}) {
|
|
95
|
+
const to = label ? ` to “${label}”` : '';
|
|
96
|
+
const ago = agoText(at, now);
|
|
97
|
+
if (plan === 'auto') return `Unsaved changes from ${ago} put back${label ? ` (${label})` : ''}.`;
|
|
98
|
+
// The questions put DISCARD on the confirm button: Escape (and leaving) is Cancel, which keeps the work.
|
|
99
|
+
if (plan === 'changed') return `You left unsaved changes${to} ${ago}, and it has been saved since - by someone else, or in another tab. Discard your changes, or keep them over it (check them before saving)?`;
|
|
100
|
+
if (plan === 'deleted') return `You left unsaved changes${to} ${ago}, but it has been deleted since. Discard them, or start a new one from them?`;
|
|
101
|
+
if (plan === 'prefilled') return `You started ${kind}${label ? ` (${label})` : ''} ${ago} and did not finish it. Discard it and start this one, or carry on with it?`;
|
|
102
|
+
return '';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The map with `key` set to `draft` (a new object - stored maps are not changed in place). */
|
|
106
|
+
export function draftsWith(map, key, draft) {
|
|
107
|
+
return {...(map && typeof map === 'object' && !Array.isArray(map) ? map : {}), [key]: draft};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The map without `key`. */
|
|
111
|
+
export function draftsWithout(map, key) {
|
|
112
|
+
const out = {...(map && typeof map === 'object' && !Array.isArray(map) ? map : {})};
|
|
113
|
+
delete out[key];
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Drafts under `prefix` (e.g. 'service:') whose record is gone - not 'new' and
|
|
119
|
+
* not one of `liveIds`. Unusable drafts are skipped.
|
|
120
|
+
*
|
|
121
|
+
* @returns {{key: string, id: string, draft: object}[]}
|
|
122
|
+
*/
|
|
123
|
+
export function orphanDrafts(map, prefix, liveIds, opts) {
|
|
124
|
+
const live = new Set(liveIds);
|
|
125
|
+
const out = [];
|
|
126
|
+
for (const [key, raw] of Object.entries(map && typeof map === 'object' ? map : {})) {
|
|
127
|
+
if (!key.startsWith(prefix)) continue;
|
|
128
|
+
const id = key.slice(prefix.length);
|
|
129
|
+
if (!id || id === 'new' || live.has(id)) continue;
|
|
130
|
+
const draft = usableDraft(raw, opts);
|
|
131
|
+
if (draft) out.push({key, id, draft});
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Stored drafts past their age, to drop. */
|
|
137
|
+
export function staleDraftKeys(map, opts) {
|
|
138
|
+
return Object.entries(map && typeof map === 'object' ? map : {}).filter(([, raw]) => !usableDraft(raw, opts)).map(([k]) => k);
|
|
139
|
+
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import {fmtDate, fmtDateTime} from '/plugins/_lib/admin/ui/dates.js';
|
|
11
|
+
import {draftsWith, draftsWithout, restorePlan, restoreText, staleDraftKeys, usableDraft} from './drafts.js';
|
|
11
12
|
|
|
12
13
|
export {fmtDate, fmtDateTime};
|
|
13
14
|
|
|
@@ -17,14 +18,15 @@ export const V = '1.4.0';
|
|
|
17
18
|
|
|
18
19
|
let kit = null;
|
|
19
20
|
|
|
20
|
-
/** tool-kit, plugin-chrome
|
|
21
|
+
/** tool-kit, plugin-chrome, help-popover and loading, once, with stand-ins for an older admin. */
|
|
21
22
|
export function loadKit() {
|
|
22
23
|
if (!kit) {
|
|
23
24
|
kit = Promise.all([
|
|
24
25
|
import('/admin/js/lib/tool-kit.js').catch(() => ({})),
|
|
25
26
|
import('/admin/js/lib/plugin-chrome.js').catch(() => ({})),
|
|
26
|
-
import('/admin/js/lib/help-popover.js').catch(() => ({}))
|
|
27
|
-
|
|
27
|
+
import('/admin/js/lib/help-popover.js').catch(() => ({})),
|
|
28
|
+
import('/admin/js/lib/loading.js').catch(() => ({}))
|
|
29
|
+
]).then(([tk, chrome, help, loading]) => ({
|
|
28
30
|
onceClosed: tk.onceClosed || ((so, fn) => { const c = so.close.bind(so); so.close = (...a) => { const r = c(...a); fn?.(); return r; }; }),
|
|
29
31
|
openMenuAt: tk.openMenuAt || ((el) => {
|
|
30
32
|
const r = el.getBoundingClientRect();
|
|
@@ -32,7 +34,11 @@ export function loadKit() {
|
|
|
32
34
|
}),
|
|
33
35
|
viewLifetime: tk.viewLifetime || (() => ({add() {}, reset() {}})),
|
|
34
36
|
addBannerAction: chrome.addBannerAction || (() => null),
|
|
35
|
-
helpPopovers: help.helpPopovers || (() => () => {})
|
|
37
|
+
helpPopovers: help.helpPopovers || (() => () => {}),
|
|
38
|
+
// A skeleton in a loading slot while the first load runs (core 1.x); without it the slot's own
|
|
39
|
+
// "Loading…" text shows, as before.
|
|
40
|
+
whileLoading: loading.whileLoading || ((slot, p) => (typeof p === 'function' ? p() : p)),
|
|
41
|
+
SHAPES: loading.SHAPES || {}
|
|
36
42
|
}));
|
|
37
43
|
}
|
|
38
44
|
return kit;
|
|
@@ -151,42 +157,255 @@ export function when(iso) {
|
|
|
151
157
|
|
|
152
158
|
// ---- Slideovers ---------------------------------------------------------------------
|
|
153
159
|
|
|
160
|
+
/** Panels open (or opening) now, by key - so a double click opens one, not two. */
|
|
161
|
+
const claimed = new Set();
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Take `key` for a panel about to open. Null when it is already open or on its
|
|
165
|
+
* way (the caller does nothing); else the release, which openPanel's `release`
|
|
166
|
+
* option calls when the panel closes - call it yourself if the panel never opens.
|
|
167
|
+
*
|
|
168
|
+
* @returns {(() => void)|null}
|
|
169
|
+
*/
|
|
170
|
+
export function claim(key) {
|
|
171
|
+
if (claimed.has(key)) return null;
|
|
172
|
+
claimed.add(key);
|
|
173
|
+
let done = false;
|
|
174
|
+
return () => { if (!done) { done = true; claimed.delete(key); } };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* E.confirm that goes when the screen does: leaving while it is open answers
|
|
179
|
+
* it "no" (its Cancel is pressed) instead of leaving it over another screen.
|
|
180
|
+
* Callers check the screen (or panel) is still there before acting on a "no".
|
|
181
|
+
*/
|
|
182
|
+
export function ask(message, options = {}, ctx = null) {
|
|
183
|
+
const answer = Promise.resolve(E.confirm(message, options));
|
|
184
|
+
const overlay = [...document.querySelectorAll('.dm-dialog-overlay')].at(-1);
|
|
185
|
+
const unhold = ctx?.hold?.(() => { try { overlay?.querySelector('.dm-dialog-cancel')?.click(); } catch { /* gone */ } });
|
|
186
|
+
answer.finally(() => unhold?.()).catch(() => {});
|
|
187
|
+
return answer;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---- Drafts (the stored side; the decisions are lib/drafts.js) -----------------------
|
|
191
|
+
|
|
192
|
+
/** One stored map per signed-in person: a shared browser never offers one person's work to another. */
|
|
193
|
+
function draftsKey() {
|
|
194
|
+
let user = '';
|
|
195
|
+
try { user = S.get('auth_user')?.id || ''; } catch { /* storage blocked */ }
|
|
196
|
+
return `booking.drafts.${user || 'anyone'}`;
|
|
197
|
+
}
|
|
198
|
+
function readDrafts() {
|
|
199
|
+
const map = store.get(draftsKey());
|
|
200
|
+
return map && typeof map === 'object' && !Array.isArray(map) ? map : {};
|
|
201
|
+
}
|
|
202
|
+
export function getDraft(key) { return usableDraft(readDrafts()[key]); }
|
|
203
|
+
export function allDrafts() { return readDrafts(); }
|
|
204
|
+
export function putDraft(key, draft) {
|
|
205
|
+
let map = readDrafts();
|
|
206
|
+
for (const k of staleDraftKeys(map)) map = draftsWithout(map, k);
|
|
207
|
+
store.set(draftsKey(), draftsWith(map, key, draft));
|
|
208
|
+
}
|
|
209
|
+
export function dropDraft(key) {
|
|
210
|
+
const map = readDrafts();
|
|
211
|
+
if (key in map) store.set(draftsKey(), draftsWithout(map, key));
|
|
212
|
+
}
|
|
213
|
+
|
|
154
214
|
/**
|
|
155
215
|
* Open a slideover holding `html`, bound to `model`. Closing with something
|
|
156
216
|
* changed asks first (once). Everything is disposed and removed on close.
|
|
157
217
|
*
|
|
158
|
-
*
|
|
218
|
+
* - `ctx` (the screen's): the panel goes when the screen does (`ctx.hold`),
|
|
219
|
+
* keeping its draft, and it does not open at all if the screen went while
|
|
220
|
+
* it was getting ready (`ctx.here`).
|
|
221
|
+
* - `release` (from claim()) is called when it closes.
|
|
222
|
+
* - `methods.save` is wrapped: the form is locked (inert) while it runs, so
|
|
223
|
+
* nothing typed during a save that then closes the panel is lost; it is
|
|
224
|
+
* unlocked - focus back where it was - when the panel stays open.
|
|
225
|
+
* - `draft: {key, label, kind, values(), apply(values, panel), was, prefilled, initial}`
|
|
226
|
+
* keeps what was typed (on every change, and when the tab closes) while the
|
|
227
|
+
* panel is dirty, and offers it back the next time this form opens - put
|
|
228
|
+
* back with a toast when the record is as it was, asked about when it was
|
|
229
|
+
* saved since (`was` differs) or the form was opened for something specific
|
|
230
|
+
* (`prefilled`). A draft is removed after a save, a discard, a "no" to the
|
|
231
|
+
* question - never while the question is open, and never written again
|
|
232
|
+
* once the panel has closed. A restored draft keeps its own `was`.
|
|
233
|
+
* `label` (text, or a function) names it in the question; `kind` ("a new booking") is for 'prefilled'.
|
|
234
|
+
* `initial`: a draft to put back straight away (one of a deleted record,
|
|
235
|
+
* started again as new - its old key is the caller's to drop).
|
|
236
|
+
*
|
|
237
|
+
* @returns {Promise<{so, panel, close: (force?: boolean) => void}|null>} null when the screen went first
|
|
159
238
|
*/
|
|
160
|
-
export async function openPanel({title, size = 'md', html, model = {}, methods = {}, dirty, cleanup = () => {}, before, after
|
|
239
|
+
export async function openPanel({title, size = 'md', html, model = {}, methods = {}, dirty, cleanup = () => {}, before, after,
|
|
240
|
+
ctx = null, release = () => {}, draft = null}) {
|
|
161
241
|
const {onceClosed, helpPopovers} = await loadKit();
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
so
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
onceClosed(so, () => { cleanup(); handle.dispose(); disposeHelp(); });
|
|
170
|
-
const close = so.close.bind(so);
|
|
242
|
+
if (ctx?.here && !ctx.here()) { release(); return null; }
|
|
243
|
+
|
|
244
|
+
let so = null;
|
|
245
|
+
let panel = null;
|
|
246
|
+
let handle = null;
|
|
247
|
+
let disposeHelp = null;
|
|
248
|
+
let up = false; // onceClosed is wired: closing tidies (and releases) from here on
|
|
171
249
|
let closed = false;
|
|
250
|
+
let saving = false;
|
|
251
|
+
// Drafts are written only once any stored one has been dealt with (restored, or answered).
|
|
252
|
+
let drafting = false;
|
|
253
|
+
let draftWas = draft?.was || '';
|
|
254
|
+
const keepDraft = () => {
|
|
255
|
+
if (!draft || !drafting || closed) return;
|
|
256
|
+
if (dirty?.()) {
|
|
257
|
+
const label = typeof draft.label === 'function' ? draft.label() : draft.label;
|
|
258
|
+
putDraft(draft.key, {values: draft.values(), at: new Date().toISOString(), was: draftWas, label: label || ''});
|
|
259
|
+
} else dropDraft(draft.key);
|
|
260
|
+
};
|
|
261
|
+
const queueKeep = () => queueMicrotask(keepDraft); // data-model handles the same event first
|
|
262
|
+
const onUnload = () => keepDraft();
|
|
263
|
+
let unhold = null;
|
|
264
|
+
|
|
265
|
+
const wrapped = {...methods};
|
|
266
|
+
if (methods.save) {
|
|
267
|
+
wrapped.save = async (...args) => {
|
|
268
|
+
if (saving || closed) return undefined;
|
|
269
|
+
saving = true;
|
|
270
|
+
const focus = document.activeElement;
|
|
271
|
+
panel.inert = true;
|
|
272
|
+
try {
|
|
273
|
+
return await methods.save(...args);
|
|
274
|
+
} finally {
|
|
275
|
+
saving = false;
|
|
276
|
+
if (!closed) {
|
|
277
|
+
panel.inert = false;
|
|
278
|
+
if (focus && panel.contains(focus)) { try { focus.focus(); } catch { /* gone */ } }
|
|
279
|
+
queueKeep(); // saved and still open (the staff note): the draft goes with the changes
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Anything that throws while the panel is built must not keep the claim, or leave a half-built
|
|
286
|
+
// slideover: it is undone and released, and the error goes on to the caller.
|
|
287
|
+
try {
|
|
288
|
+
so = E.slideover({title: esc(title), size, position: 'right'});
|
|
289
|
+
panel = fragment(html);
|
|
290
|
+
so.element.appendChild(panel);
|
|
291
|
+
before?.(panel);
|
|
292
|
+
disposeHelp = helpPopovers(panel);
|
|
293
|
+
handle = M.applyBindings(model, panel, {methods: wrapped});
|
|
294
|
+
after?.(panel);
|
|
295
|
+
onceClosed(so, () => {
|
|
296
|
+
cleanup(); handle.dispose(); disposeHelp(); release();
|
|
297
|
+
window.removeEventListener('beforeunload', onUnload);
|
|
298
|
+
unhold?.();
|
|
299
|
+
});
|
|
300
|
+
up = true;
|
|
301
|
+
} catch (err) {
|
|
302
|
+
closed = true;
|
|
303
|
+
try { handle?.dispose(); } catch { /* half built */ }
|
|
304
|
+
try { disposeHelp?.(); } catch { /* half built */ }
|
|
305
|
+
try { cleanup(); } catch { /* half built */ }
|
|
306
|
+
try { so?.destroy?.(); } catch { /* gone */ }
|
|
307
|
+
try { so?.element?.remove(); } catch { /* gone */ }
|
|
308
|
+
release();
|
|
309
|
+
throw err;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const close = so.close.bind(so);
|
|
172
313
|
let asking = false;
|
|
173
314
|
so.close = async (force) => {
|
|
174
|
-
if (
|
|
315
|
+
if (closed) return close();
|
|
316
|
+
if (force !== true && saving) return undefined;
|
|
317
|
+
if (force !== true && dirty?.()) {
|
|
175
318
|
if (asking) return undefined;
|
|
176
319
|
asking = true;
|
|
177
|
-
const ok = await
|
|
320
|
+
const ok = await ask('Discard your changes?', {}, ctx);
|
|
178
321
|
asking = false;
|
|
179
|
-
if (!ok) return undefined;
|
|
322
|
+
if (!ok || closed) return undefined;
|
|
180
323
|
}
|
|
324
|
+
if (draft && drafting) dropDraft(draft.key);
|
|
181
325
|
closed = true;
|
|
182
326
|
return close();
|
|
183
327
|
};
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
328
|
+
try {
|
|
329
|
+
// The screen going (another tab, another Tool, a remount): keep the draft, take the panel away.
|
|
330
|
+
if (ctx?.hold) {
|
|
331
|
+
unhold = ctx.hold(() => {
|
|
332
|
+
if (closed) return;
|
|
333
|
+
keepDraft();
|
|
334
|
+
closed = true;
|
|
335
|
+
close();
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
panel.addEventListener('keydown', (e) => {
|
|
339
|
+
if ((e.ctrlKey || e.metaKey) && (e.key === 'Enter' || e.key === 's') && wrapped.save) { e.preventDefault(); e.stopPropagation(); wrapped.save(); }
|
|
340
|
+
});
|
|
341
|
+
if (draft) {
|
|
342
|
+
panel.addEventListener('input', queueKeep);
|
|
343
|
+
panel.addEventListener('change', queueKeep);
|
|
344
|
+
panel.addEventListener('click', queueKeep); // rows added or removed, slot buttons
|
|
345
|
+
window.addEventListener('beforeunload', onUnload);
|
|
346
|
+
}
|
|
347
|
+
I.scan(panel);
|
|
348
|
+
so.open();
|
|
349
|
+
if (draft) offerDraft();
|
|
350
|
+
} catch (err) {
|
|
351
|
+
// Up, but failing: close it (onceClosed tidies and releases) - any stored draft is left alone.
|
|
352
|
+
if (up && !closed) { closed = true; try { close(); } catch { /* gone */ } }
|
|
353
|
+
throw err;
|
|
354
|
+
}
|
|
355
|
+
return {
|
|
356
|
+
so, panel,
|
|
357
|
+
close(force) {
|
|
358
|
+
// A save that was running when the screen went has now succeeded (saves close with force):
|
|
359
|
+
// the draft kept on leaving is saved work, so it goes.
|
|
360
|
+
if (closed && force === true && saving && draft) { dropDraft(draft.key); return undefined; }
|
|
361
|
+
return so.close(force);
|
|
362
|
+
},
|
|
363
|
+
/** The record was saved from this panel and it stays open (the staff note): drafts now start from `was`. */
|
|
364
|
+
rebase(was) { draftWas = was || ''; }
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
function offerDraft() {
|
|
368
|
+
// `asNew`: a deleted record's draft started again as a new one - nothing to compare it with now.
|
|
369
|
+
const restore = (d, asNew = false) => {
|
|
370
|
+
try { draft.apply(d.values, panel); } catch (err) { console.warn('[booking] draft not restored:', err); }
|
|
371
|
+
draftWas = asNew ? (draft.was || '') : (d.was || '');
|
|
372
|
+
// Rows the draft added (hours, closed days) carry icons: drawn once they have rendered.
|
|
373
|
+
M.flushSync?.();
|
|
374
|
+
I.scan(panel);
|
|
375
|
+
requestAnimationFrame(() => { if (panel.isConnected) I.scan(panel); });
|
|
376
|
+
};
|
|
377
|
+
if (draft.initial) {
|
|
378
|
+
restore(draft.initial, true);
|
|
379
|
+
drafting = true;
|
|
380
|
+
keepDraft();
|
|
381
|
+
E.toast(restoreText('auto', {label: draft.initial.label, at: draft.initial.at, kind: draft.kind}), {type: 'info'});
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
const stored = getDraft(draft.key);
|
|
385
|
+
const plan = restorePlan({draft: stored, was: draft.was || '', opening: draft.values(), prefilled: draft.prefilled});
|
|
386
|
+
if (plan === 'none') {
|
|
387
|
+
if (stored) dropDraft(draft.key);
|
|
388
|
+
drafting = true;
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
if (plan === 'auto') {
|
|
392
|
+
restore(stored);
|
|
393
|
+
drafting = true;
|
|
394
|
+
keepDraft();
|
|
395
|
+
E.toast(restoreText('auto', {label: stored.label, at: stored.at, kind: draft.kind}), {type: 'info'});
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
// Discard is the confirm button; Cancel - which Escape and leaving also press - keeps the work.
|
|
399
|
+
ask(esc(restoreText(plan, {label: stored.label, at: stored.at, kind: draft.kind})),
|
|
400
|
+
{title: 'Unsaved changes', confirmText: plan === 'prefilled' ? 'Discard it' : 'Discard them', cancelText: plan === 'prefilled' ? 'Carry on with it' : 'Keep them'}, ctx)
|
|
401
|
+
.then((discard) => {
|
|
402
|
+
// Gone while asked (the screen changed): the draft stays for next time.
|
|
403
|
+
if (closed) return;
|
|
404
|
+
if (discard) dropDraft(draft.key); else restore(stored);
|
|
405
|
+
drafting = true;
|
|
406
|
+
keepDraft();
|
|
407
|
+
});
|
|
408
|
+
}
|
|
190
409
|
}
|
|
191
410
|
|
|
192
411
|
/**
|
|
@@ -207,7 +426,9 @@ export function wireRows(list, {items, main, openMenuAt, scope, match = '.bk-row
|
|
|
207
426
|
const onKey = (e) => {
|
|
208
427
|
const row = e.target.closest(match);
|
|
209
428
|
if (!row) return;
|
|
210
|
-
|
|
429
|
+
// Not the ContextMenu key: Chrome on Windows opens its own menu on that key's key-up, so the menu
|
|
430
|
+
// appeared twice. That key's own contextmenu event reaches E.contextMenu anyway.
|
|
431
|
+
if (e.key === 'F10' && e.shiftKey) { e.preventDefault(); openMenuAt(row); }
|
|
211
432
|
else if ((e.key === 'Enter' || e.key === ' ') && e.target === row) { e.preventDefault(); main(row.dataset.id, row); }
|
|
212
433
|
else if ((e.key === 'ArrowDown' || e.key === 'ArrowUp') && e.target === row) {
|
|
213
434
|
e.preventDefault();
|