domma-cms 1.1.0 → 1.2.0
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/admin.css +1 -1
- package/admin/js/app.js +1 -1
- package/admin/js/lib/dashboard-board-arrange.js +1 -0
- package/admin/js/lib/sidebar-frequent.js +1 -0
- package/admin/js/lib/sidebar-renderer.js +4 -4
- package/admin/js/lib/widget-render.js +1 -0
- package/admin/js/templates/my-dashboard.html +3 -0
- package/admin/js/views/dashboard-board.js +30 -0
- package/admin/js/views/dashboard.js +2 -2
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/my-dashboard.js +1 -0
- package/package.json +1 -1
- package/server/routes/api/dashboard.js +85 -0
- package/server/routes/api/widgets.js +106 -0
- package/server/server.js +2 -0
- package/server/services/plugins.js +15 -0
- package/server/services/siteGitignore.js +2 -0
- package/server/services/widgets.js +269 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard widgets - the parts a Tool offers for the Dashboard, and the Pro
|
|
3
|
+
* switch for the board that arranges them.
|
|
4
|
+
*
|
|
5
|
+
* A widget is a small, self-describing card. It never ships code: it names a
|
|
6
|
+
* `kind` the admin knows how to draw and a `dataUrl` that answers in that
|
|
7
|
+
* kind's shape, so a Tool adds a widget with a few lines of manifest and
|
|
8
|
+
* every widget looks like part of the same admin.
|
|
9
|
+
*
|
|
10
|
+
* kind dataUrl answers drawn as
|
|
11
|
+
* ------ ----------------------------------------------- -------------------------
|
|
12
|
+
* count {count, label, tone, details} (a sidebar badge) big number + its details list
|
|
13
|
+
* stat {value, label, sub, tone} big value + a line under it
|
|
14
|
+
* list {items: [{text, meta, href, value, tone}], empty} rows that open on click
|
|
15
|
+
* chart {labels: [], values: [], label} a line chart
|
|
16
|
+
*
|
|
17
|
+
* Three sources, merged by getWidgets():
|
|
18
|
+
*
|
|
19
|
+
* 1. Core's own (CORE_WIDGETS) - traffic, needs attention, recent activity,
|
|
20
|
+
* health, visitors now - read from /api/dashboard/widget/<name>.
|
|
21
|
+
* 2. Every sidebar entry with a `countUrl` - a Tool that has a badge has a
|
|
22
|
+
* widget, for free, in the badge's own shape (`count`). This is how
|
|
23
|
+
* Email, Booking, Support and the rest arrive on day one.
|
|
24
|
+
* 3. A plugin's `admin.widgets[]` in plugin.json (bridged in plugins.js),
|
|
25
|
+
* or registerWidget() from code - for anything richer than a badge.
|
|
26
|
+
*
|
|
27
|
+
* PRO ONLY: the board (add, arrange, resize, a layout per user) exists only
|
|
28
|
+
* while a paid, closed-source Tool has called `hooks.enableDashboardPro()` -
|
|
29
|
+
* the same contract as instant calls (services/instantCalls.js). Without it
|
|
30
|
+
* the widget and layout routes answer 404 and `#/` is the fixed Dashboard.
|
|
31
|
+
*
|
|
32
|
+
* @module services/widgets
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import {getRegisteredSidebarItems, registerSidebarItem} from './hooks.js';
|
|
36
|
+
|
|
37
|
+
/** Widths on the board's four-column grid. */
|
|
38
|
+
export const WIDGET_SIZES = ['s', 'm', 'l', 'xl'];
|
|
39
|
+
export const WIDGET_KINDS = ['count', 'stat', 'list', 'chart'];
|
|
40
|
+
|
|
41
|
+
const ID_RE = /^[a-z0-9][a-z0-9:._/-]{0,119}$/;
|
|
42
|
+
const DATA_URL_RE = /^\/api\/[A-Za-z0-9/_.:?=&%-]+$/;
|
|
43
|
+
|
|
44
|
+
/** Core's own widgets - the fixed Dashboard's parts, one card each. */
|
|
45
|
+
export const CORE_WIDGETS = [
|
|
46
|
+
{id: 'core:traffic', title: 'Traffic', description: 'Page views over the last 30 days.', icon: 'activity',
|
|
47
|
+
kind: 'chart', size: 'xl', dataUrl: '/api/dashboard/widget/traffic', href: '#/analytics', admin: true, refresh: 300},
|
|
48
|
+
{id: 'core:visitors', title: 'Visitors now', description: 'People on the site in the last five minutes, and page views today.',
|
|
49
|
+
icon: 'users', kind: 'stat', size: 's', dataUrl: '/api/dashboard/widget/visitors', href: '#/analytics', admin: true, refresh: 30},
|
|
50
|
+
{id: 'core:attention', title: 'Needs attention', description: 'Anything late, failing or waiting on you, across every Tool you can open.',
|
|
51
|
+
icon: 'alert-circle', kind: 'list', size: 'm', dataUrl: '/api/dashboard/widget/attention', refresh: 60},
|
|
52
|
+
{id: 'core:activity', title: 'Recent activity', description: 'What changed on the site lately, and who changed it.',
|
|
53
|
+
icon: 'clock', kind: 'list', size: 'm', dataUrl: '/api/dashboard/widget/activity', admin: true, refresh: 120},
|
|
54
|
+
{id: 'core:health', title: 'Site health', description: 'The health checks - storage, email, backups and the rest.',
|
|
55
|
+
icon: 'heart', kind: 'list', size: 'm', dataUrl: '/api/dashboard/widget/health', admin: true, refresh: 300},
|
|
56
|
+
{id: 'core:top-pages', title: 'Top pages', description: 'The most visited pages today.',
|
|
57
|
+
icon: 'trending-up', kind: 'list', size: 'm', dataUrl: '/api/dashboard/widget/top-pages', href: '#/analytics', admin: true, refresh: 300}
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
/** What a new board starts with when the site has set no default. */
|
|
61
|
+
export const DEFAULT_LAYOUT = [
|
|
62
|
+
{id: 'core:attention', size: 'm'},
|
|
63
|
+
{id: 'core:visitors', size: 's'},
|
|
64
|
+
{id: 'core:traffic', size: 'xl'},
|
|
65
|
+
{id: 'core:activity', size: 'm'},
|
|
66
|
+
{id: 'core:top-pages', size: 'm'}
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
const _registered = [];
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Normalise a widget definition; null when it cannot be drawn.
|
|
73
|
+
*
|
|
74
|
+
* @param {object} def
|
|
75
|
+
* @param {string} [source] - who offers it ('Built in', a plugin's name)
|
|
76
|
+
* @returns {object|null}
|
|
77
|
+
*/
|
|
78
|
+
export function normaliseWidget(def, source = '') {
|
|
79
|
+
if (!def || typeof def !== 'object') return null;
|
|
80
|
+
const id = String(def.id || '').trim();
|
|
81
|
+
if (!ID_RE.test(id)) return null;
|
|
82
|
+
if (!WIDGET_KINDS.includes(def.kind)) return null;
|
|
83
|
+
if (typeof def.dataUrl !== 'string' || !DATA_URL_RE.test(def.dataUrl)) return null;
|
|
84
|
+
const href = typeof def.href === 'string' && /^#\//.test(def.href) ? def.href : '';
|
|
85
|
+
const refresh = Number.isFinite(Number(def.refresh)) ? Math.min(3600, Math.max(15, Number(def.refresh))) : 60;
|
|
86
|
+
return {
|
|
87
|
+
id,
|
|
88
|
+
title: String(def.title || id).slice(0, 60),
|
|
89
|
+
description: String(def.description || '').slice(0, 200),
|
|
90
|
+
icon: typeof def.icon === 'string' && /^[a-z0-9-]{1,40}$/.test(def.icon) ? def.icon : 'grid',
|
|
91
|
+
kind: def.kind,
|
|
92
|
+
size: WIDGET_SIZES.includes(def.size) ? def.size : 'm',
|
|
93
|
+
dataUrl: def.dataUrl,
|
|
94
|
+
refresh,
|
|
95
|
+
...(href && {href}),
|
|
96
|
+
...(typeof def.permission === 'string' && def.permission && {permission: def.permission}),
|
|
97
|
+
...(def.admin === true && {admin: true}),
|
|
98
|
+
// Which plugin offers it, so disabling that plugin takes it away (unregisterWidgetsFrom).
|
|
99
|
+
...(typeof def.owner === 'string' && def.owner && {owner: def.owner}),
|
|
100
|
+
source: String(def.source || source || '').slice(0, 60)
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Offer a widget (core, or a plugin from code - a manifest's `admin.widgets`
|
|
106
|
+
* goes through here too). A second widget with the same id replaces the first.
|
|
107
|
+
*
|
|
108
|
+
* @param {object} def
|
|
109
|
+
* @param {string} [source]
|
|
110
|
+
* @returns {boolean} whether it was accepted
|
|
111
|
+
*/
|
|
112
|
+
export function registerWidget(def, source = '') {
|
|
113
|
+
const w = normaliseWidget(def, source);
|
|
114
|
+
if (!w) return false;
|
|
115
|
+
const at = _registered.findIndex(x => x.id === w.id);
|
|
116
|
+
if (at >= 0) _registered[at] = w; else _registered.push(w);
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Take a plugin's widgets away (it was disabled). */
|
|
121
|
+
export function unregisterWidgetsFrom(owner) {
|
|
122
|
+
for (let i = _registered.length - 1; i >= 0; i--) {
|
|
123
|
+
if (_registered[i].owner === owner) _registered.splice(i, 1);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A sidebar entry with a badge, as a widget in the badge's own shape. */
|
|
128
|
+
export function widgetFromSidebarItem(entry) {
|
|
129
|
+
const item = entry?.item || entry;
|
|
130
|
+
if (!item || typeof item.countUrl !== 'string' || !item.url) return null;
|
|
131
|
+
return normaliseWidget({
|
|
132
|
+
id: `count:${item.url.replace(/^#\//, '') || 'home'}`,
|
|
133
|
+
title: item.text,
|
|
134
|
+
description: item.inventory ? `How many there are in ${item.text}.` : `What ${item.text} is waiting on, as its sidebar badge says.`,
|
|
135
|
+
icon: item.icon,
|
|
136
|
+
kind: 'count',
|
|
137
|
+
size: 's',
|
|
138
|
+
dataUrl: item.countUrl,
|
|
139
|
+
href: item.url,
|
|
140
|
+
permission: item.permission,
|
|
141
|
+
refresh: 60
|
|
142
|
+
}, item.source || '');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Every widget there is: core's, one per sidebar badge, then the registered
|
|
147
|
+
* ones (which win on a clash of id).
|
|
148
|
+
*
|
|
149
|
+
* @param {{sidebarItems?: object[]}} [opts] - injectable for tests
|
|
150
|
+
* @returns {object[]}
|
|
151
|
+
*/
|
|
152
|
+
export function getWidgets({sidebarItems = getRegisteredSidebarItems()} = {}) {
|
|
153
|
+
const byId = new Map();
|
|
154
|
+
for (const w of CORE_WIDGETS) byId.set(w.id, normaliseWidget(w, 'Built in'));
|
|
155
|
+
for (const entry of sidebarItems) {
|
|
156
|
+
const w = widgetFromSidebarItem(entry);
|
|
157
|
+
if (w) byId.set(w.id, w);
|
|
158
|
+
}
|
|
159
|
+
for (const w of _registered) byId.set(w.id, w);
|
|
160
|
+
return [...byId.values()].filter(Boolean);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** canAny() from the admin, server side: the resource or any of its actions. */
|
|
164
|
+
export function canAny(permissions, resource) {
|
|
165
|
+
if (!resource) return true;
|
|
166
|
+
if (!Array.isArray(permissions) || !permissions.length) return false;
|
|
167
|
+
if (permissions.includes(resource)) return true;
|
|
168
|
+
return ['read', 'create', 'update', 'delete'].some(a => permissions.includes(`${resource}.${a}`));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The widgets this user may put on their board: each one's permission, and
|
|
173
|
+
* `admin` widgets for admins only (they read the admin-only summary).
|
|
174
|
+
*
|
|
175
|
+
* @param {object[]} widgets
|
|
176
|
+
* @param {{permissions: string[], isAdmin: boolean}} who
|
|
177
|
+
*/
|
|
178
|
+
export function widgetsFor(widgets, {permissions = [], isAdmin = false} = {}) {
|
|
179
|
+
return widgets.filter(w => (!w.admin || isAdmin) && canAny(permissions, w.permission));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
// Layouts
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
export const MAX_WIDGETS = 40;
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* A stored or submitted layout, cleaned: known sizes, no duplicates, capped.
|
|
190
|
+
* Ids are kept even when no such widget exists right now - a Tool switched off
|
|
191
|
+
* for a moment should not cost someone their arrangement - and dropped at draw.
|
|
192
|
+
*
|
|
193
|
+
* @param {*} input - {widgets: [{id, size}]} or the array itself
|
|
194
|
+
* @returns {{version: number, widgets: Array<{id: string, size: string}>}}
|
|
195
|
+
*/
|
|
196
|
+
export function normaliseLayout(input) {
|
|
197
|
+
const list = Array.isArray(input) ? input : (Array.isArray(input?.widgets) ? input.widgets : []);
|
|
198
|
+
const seen = new Set();
|
|
199
|
+
const widgets = [];
|
|
200
|
+
for (const w of list) {
|
|
201
|
+
const id = String(w?.id || '').trim();
|
|
202
|
+
if (!ID_RE.test(id) || seen.has(id)) continue;
|
|
203
|
+
seen.add(id);
|
|
204
|
+
// `collapsed`: folded down to its title bar on this person's board.
|
|
205
|
+
widgets.push({id, size: WIDGET_SIZES.includes(w.size) ? w.size : 'm', ...(w.collapsed === true && {collapsed: true})});
|
|
206
|
+
if (widgets.length >= MAX_WIDGETS) break;
|
|
207
|
+
}
|
|
208
|
+
return {version: 1, widgets};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** What is wrong with a submitted layout, as messages ([] when it is fine). */
|
|
212
|
+
export function layoutProblems(input) {
|
|
213
|
+
const list = Array.isArray(input) ? input : input?.widgets;
|
|
214
|
+
if (!Array.isArray(list)) return ['widgets must be an array'];
|
|
215
|
+
if (list.length > MAX_WIDGETS) return [`at most ${MAX_WIDGETS} widgets`];
|
|
216
|
+
const out = [];
|
|
217
|
+
list.forEach((w, i) => {
|
|
218
|
+
if (!ID_RE.test(String(w?.id || ''))) out.push(`widgets[${i}].id is not a widget id`);
|
|
219
|
+
if (w?.size != null && !WIDGET_SIZES.includes(w.size)) out.push(`widgets[${i}].size must be one of ${WIDGET_SIZES.join(', ')}`);
|
|
220
|
+
});
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// Switched on by a Pro Tool
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
const owners = new Set();
|
|
229
|
+
let sidebarRegistered = false;
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* A Pro Tool switches the board on (plugins.js hands it to each plugin as
|
|
233
|
+
* `hooks.enableDashboardPro`, bound to the plugin's name, tier and closedSource).
|
|
234
|
+
* Only a paid, closed-source plugin - one that loads only under a licence - may.
|
|
235
|
+
*
|
|
236
|
+
* @param {{owner: string, tier: string, closedSource: boolean}} who
|
|
237
|
+
* @returns {boolean}
|
|
238
|
+
*/
|
|
239
|
+
export function enableDashboardPro({owner, tier, closedSource} = {}) {
|
|
240
|
+
if (typeof owner !== 'string' || !owner.trim()) return false;
|
|
241
|
+
if (tier !== 'paid' || closedSource !== true) return false;
|
|
242
|
+
// The board's own address in the sidebar (Overview), once: `#/` can belong to
|
|
243
|
+
// another Tool (the fleet manager's Dashboard), and My Dashboard still reaches it.
|
|
244
|
+
if (!sidebarRegistered) {
|
|
245
|
+
sidebarRegistered = true;
|
|
246
|
+
try {
|
|
247
|
+
registerSidebarItem({folder: 'overview', item: {source: 'Dashboard Pro', text: 'My Dashboard', url: '#/my-dashboard', icon: 'layout'}});
|
|
248
|
+
} catch { /* a sidebar without folders still has the route */ }
|
|
249
|
+
}
|
|
250
|
+
owners.add(owner.trim());
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** The Tool that kept it on was disabled; the last one gone switches the board off. */
|
|
255
|
+
export function disableDashboardPro(owner) {
|
|
256
|
+
owners.delete(String(owner || '').trim());
|
|
257
|
+
return owners.size > 0;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Whether this site has Dashboard Pro right now. */
|
|
261
|
+
export function isDashboardPro() {
|
|
262
|
+
return owners.size > 0;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Tests only: forget every owner and registration. */
|
|
266
|
+
export function _resetWidgetsForTests() {
|
|
267
|
+
owners.clear();
|
|
268
|
+
_registered.length = 0;
|
|
269
|
+
}
|