flexdesk 0.13.0 → 0.14.1
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/README.md +72 -0
- package/css/flexdesk.css +99 -0
- package/dist/charts.js +2 -2
- package/dist/{chunk-M2L4XW2U.js → chunk-FBGP5KN5.js} +33 -21
- package/dist/chunk-FBGP5KN5.js.map +7 -0
- package/dist/{chunk-IYMNDPWH.js → chunk-RQM5Z2MU.js} +2 -2
- package/dist/flexdesk.css +99 -0
- package/dist/flow.js +4 -1
- package/dist/flow.js.map +2 -2
- package/dist/host.js +138 -0
- package/dist/host.js.map +3 -3
- package/dist/tiles.js +11 -3
- package/dist/tiles.js.map +2 -2
- package/dist/widgets.js +564 -6
- package/dist/widgets.js.map +3 -3
- package/dist/wm.js +146 -0
- package/dist/wm.js.map +3 -3
- package/package.json +1 -1
- package/src/flow/kit/dom.js +6 -0
- package/src/host/photino_host.js +176 -0
- package/src/host/photino_host.md +98 -0
- package/src/tiles/chart_tiles.js +14 -1
- package/src/tiling/bottom_panel_tabs.js +201 -0
- package/src/ui/components/alert_log.js +274 -0
- package/src/ui/components/alerts_pane.js +294 -0
- package/src/ui/components/data_table.js +32 -14
- package/src/ui/components/notification_history.js +54 -4
- package/src/ui/utils/auto_scrollbars.js +30 -0
- package/dist/chunk-M2L4XW2U.js.map +0 -7
- /package/dist/{chunk-IYMNDPWH.js.map → chunk-RQM5Z2MU.js.map} +0 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ══ THE ALERT LOG ═════════════════════════════════════════════════════════
|
|
3
|
+
*
|
|
4
|
+
* What an application told its user, kept: every entry, newest first, read or
|
|
5
|
+
* not, until it is dismissed or falls off the end (FlexDesk STORY-0019, from
|
|
6
|
+
* bugdesk's `alerts.js`). NotificationCenter's toasts are the moment; this is
|
|
7
|
+
* the history the Alerts pane (`alerts_pane.js`) and the bell
|
|
8
|
+
* (NotificationHistory's `alertLog` option) read.
|
|
9
|
+
*
|
|
10
|
+
* An ENTRY is `{ id, at, level, title, body?, ref?, read, ruleId?, rule? }`.
|
|
11
|
+
* `at` is a Date; `level` is one of `info`, `warning`, `alert` (`normalizeLevel`
|
|
12
|
+
* maps the toast's `warn`/`error` and the server's `danger` onto them).
|
|
13
|
+
*
|
|
14
|
+
* WHERE ENTRIES LIVE IS THE EMBEDDER'S — a pluggable SOURCE:
|
|
15
|
+
*
|
|
16
|
+
* load({ offset, limit }) → { entries, more?, unread? } a page, newest first
|
|
17
|
+
* markRead(ids | null) → null is "all"
|
|
18
|
+
* dismiss(ids | null) → optional; without it nothing can be dismissed
|
|
19
|
+
*
|
|
20
|
+
* bugdesk keeps the whole log in its workspace state (`localSource`: one
|
|
21
|
+
* blob, read once, written on every change); Tables pages its server's
|
|
22
|
+
* notifications and has no dismiss. A source's `unread` is the count beyond
|
|
23
|
+
* the loaded page — the server knows it, the page does not.
|
|
24
|
+
*
|
|
25
|
+
* CHANGES ARE APPLIED HERE FIRST and the source told after, so a click marks
|
|
26
|
+
* read at once; a source that refuses is logged and the log re-read, rather
|
|
27
|
+
* than the click being made to wait on a round trip.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** The three levels, worst last; `icon` is a Material Symbols name. */
|
|
31
|
+
export const ALERT_LEVELS = Object.freeze({
|
|
32
|
+
info: Object.freeze({ label: 'Info', icon: 'info' }),
|
|
33
|
+
warning: Object.freeze({ label: 'Warning', icon: 'warning' }),
|
|
34
|
+
alert: Object.freeze({ label: 'Alert', icon: 'notifications_active' }),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const LEVEL_ALIASES = { warn: 'warning', error: 'alert', danger: 'alert', critical: 'alert', success: 'info' };
|
|
38
|
+
|
|
39
|
+
/** One of `info`, `warning`, `alert`, whatever the caller spelt. */
|
|
40
|
+
export function normalizeLevel(level) {
|
|
41
|
+
const l = String(level ?? '').toLowerCase();
|
|
42
|
+
if (ALERT_LEVELS[l]) return l;
|
|
43
|
+
return LEVEL_ALIASES[l] ?? 'info';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The worst level among these entries — a badge's colour — or null for none. */
|
|
47
|
+
export function worstLevel(entries) {
|
|
48
|
+
let worst = null;
|
|
49
|
+
for (const e of entries || []) {
|
|
50
|
+
const l = normalizeLevel(e?.level);
|
|
51
|
+
if (l === 'alert') return 'alert';
|
|
52
|
+
if (l === 'warning') worst = 'warning';
|
|
53
|
+
else if (!worst) worst = 'info';
|
|
54
|
+
}
|
|
55
|
+
return worst;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** "just now", "5 min ago", "3 h ago"; older than a day, the clock time (the day header has the date). */
|
|
59
|
+
export function ago(at, now = new Date()) {
|
|
60
|
+
const s = Math.max(0, (now - at) / 1000);
|
|
61
|
+
if (s < 60) return 'just now';
|
|
62
|
+
if (s < 3600) return `${Math.floor(s / 60)} min ago`;
|
|
63
|
+
if (s < 86400) return `${Math.floor(s / 3600)} h ago`;
|
|
64
|
+
return at.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** TODAY, YESTERDAY, or the date — the Alerts pane's day header. */
|
|
68
|
+
export function dayLabel(at, now = new Date()) {
|
|
69
|
+
const d = new Date(at.getFullYear(), at.getMonth(), at.getDate());
|
|
70
|
+
const t = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
71
|
+
const days = Math.round((t - d) / 86400000);
|
|
72
|
+
if (days === 0) return 'TODAY';
|
|
73
|
+
if (days === 1) return 'YESTERDAY';
|
|
74
|
+
return at.toLocaleDateString([], { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' })
|
|
75
|
+
.toUpperCase();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let _seq = 0;
|
|
79
|
+
|
|
80
|
+
/** An entry in the shape the log keeps, from whatever a source or a caller handed in. */
|
|
81
|
+
function normalizeEntry(raw) {
|
|
82
|
+
const at = raw.at instanceof Date ? raw.at : new Date(raw.at ?? Date.now());
|
|
83
|
+
return {
|
|
84
|
+
...raw,
|
|
85
|
+
id: String(raw.id ?? `${Date.now().toString(36)}-${(_seq++).toString(36)}`),
|
|
86
|
+
at: Number.isNaN(at.getTime()) ? new Date() : at,
|
|
87
|
+
level: normalizeLevel(raw.level),
|
|
88
|
+
title: String(raw.title ?? ''),
|
|
89
|
+
read: !!raw.read,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A source that keeps the whole log in one stored value — bugdesk's workspace
|
|
95
|
+
* state, or localStorage. `read()` answers the saved array (or a promise of
|
|
96
|
+
* it); `write(array)` stores one. Dates are written as ISO strings.
|
|
97
|
+
*/
|
|
98
|
+
export function localSource({ read, write, limit = 300 } = {}) {
|
|
99
|
+
let all = null;
|
|
100
|
+
const save = () => {
|
|
101
|
+
try {
|
|
102
|
+
write?.(all.map((e) => ({ ...e, at: e.at.toISOString() })));
|
|
103
|
+
} catch (err) { console.warn('[alert-log] the log could not be saved', err); }
|
|
104
|
+
};
|
|
105
|
+
const ensure = async () => {
|
|
106
|
+
if (all) return all;
|
|
107
|
+
let saved = [];
|
|
108
|
+
try { saved = (await read?.()) ?? []; } catch (err) {
|
|
109
|
+
console.warn('[alert-log] the log could not be read', err);
|
|
110
|
+
}
|
|
111
|
+
all = (Array.isArray(saved) ? saved : []).filter((e) => e && typeof e === 'object')
|
|
112
|
+
.map(normalizeEntry).sort((a, b) => b.at - a.at).slice(0, limit);
|
|
113
|
+
return all;
|
|
114
|
+
};
|
|
115
|
+
return {
|
|
116
|
+
local: true,
|
|
117
|
+
async load({ offset = 0, limit: n = limit } = {}) {
|
|
118
|
+
const list = await ensure();
|
|
119
|
+
return { entries: list.slice(offset, offset + n), more: offset + n < list.length };
|
|
120
|
+
},
|
|
121
|
+
async add(entry) {
|
|
122
|
+
const list = await ensure();
|
|
123
|
+
list.unshift(entry);
|
|
124
|
+
if (list.length > limit) list.length = limit;
|
|
125
|
+
save();
|
|
126
|
+
},
|
|
127
|
+
async markRead(ids) {
|
|
128
|
+
const want = ids ? new Set(ids) : null;
|
|
129
|
+
for (const e of await ensure()) if (!want || want.has(e.id)) e.read = true;
|
|
130
|
+
save();
|
|
131
|
+
},
|
|
132
|
+
async dismiss(ids) {
|
|
133
|
+
const want = ids ? new Set(ids) : null;
|
|
134
|
+
all = (await ensure()).filter((e) => want && !want.has(e.id));
|
|
135
|
+
save();
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* @param {object} [opts]
|
|
142
|
+
* @param {object} [opts.source] where entries live; none keeps them in memory only
|
|
143
|
+
* @param {number} [opts.limit] most entries held, and a page's size
|
|
144
|
+
* @returns the log
|
|
145
|
+
*/
|
|
146
|
+
export function createAlertLog({ source = null, limit = 300 } = {}) {
|
|
147
|
+
let entries = [];
|
|
148
|
+
let more = false;
|
|
149
|
+
let remoteUnread = null;
|
|
150
|
+
let loaded = false;
|
|
151
|
+
let loading = null;
|
|
152
|
+
const listeners = new Set();
|
|
153
|
+
|
|
154
|
+
const changed = () => {
|
|
155
|
+
for (const fn of [...listeners]) {
|
|
156
|
+
try { fn(log); } catch (err) { console.warn('[alert-log] listener failed', err); }
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
const tell = (verb, ...args) => {
|
|
160
|
+
const fn = source?.[verb];
|
|
161
|
+
if (typeof fn !== 'function') return;
|
|
162
|
+
Promise.resolve().then(() => fn.apply(source, args)).catch((err) => {
|
|
163
|
+
console.warn(`[alert-log] the source refused ${verb}`, err);
|
|
164
|
+
log.refresh();
|
|
165
|
+
});
|
|
166
|
+
};
|
|
167
|
+
const page = async (offset) => {
|
|
168
|
+
const res = (await source?.load?.({ offset, limit })) ?? {};
|
|
169
|
+
const got = (res.entries ?? []).map(normalizeEntry);
|
|
170
|
+
return { got, more: !!res.more, unread: Number.isFinite(res.unread) ? res.unread : null };
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const log = {
|
|
174
|
+
/** Loaded entries, newest first. */
|
|
175
|
+
entries: () => entries,
|
|
176
|
+
loaded: () => loaded,
|
|
177
|
+
/** Whether the source has older entries than those loaded. */
|
|
178
|
+
hasMore: () => more,
|
|
179
|
+
/** Whether entries can be dismissed: a log with no source, or a source that can. */
|
|
180
|
+
canDismiss: () => !source || typeof source.dismiss === 'function',
|
|
181
|
+
/** Unread entries: the source's count when it knows one, else the loaded ones. */
|
|
182
|
+
unreadCount() {
|
|
183
|
+
const here = entries.filter((e) => !e.read).length;
|
|
184
|
+
return remoteUnread === null ? here : Math.max(remoteUnread, here);
|
|
185
|
+
},
|
|
186
|
+
/** The worst level among unread entries, or null. */
|
|
187
|
+
worstUnread: () => worstLevel(entries.filter((e) => !e.read)),
|
|
188
|
+
get: (id) => entries.find((e) => e.id === String(id)) ?? null,
|
|
189
|
+
|
|
190
|
+
/** `fn(log)` after every change; answers the unsubscribe. */
|
|
191
|
+
subscribe(fn) {
|
|
192
|
+
listeners.add(fn);
|
|
193
|
+
return () => listeners.delete(fn);
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
/** The first page, again. */
|
|
197
|
+
refresh() {
|
|
198
|
+
if (!source?.load) { loaded = true; changed(); return Promise.resolve(log); }
|
|
199
|
+
loading = page(0).then(({ got, more: m, unread }) => {
|
|
200
|
+
entries = got;
|
|
201
|
+
more = m;
|
|
202
|
+
remoteUnread = unread;
|
|
203
|
+
loaded = true;
|
|
204
|
+
changed();
|
|
205
|
+
return log;
|
|
206
|
+
}).catch((err) => {
|
|
207
|
+
console.warn('[alert-log] the log could not be loaded', err);
|
|
208
|
+
loaded = true;
|
|
209
|
+
changed();
|
|
210
|
+
return log;
|
|
211
|
+
}).finally(() => { loading = null; });
|
|
212
|
+
return loading;
|
|
213
|
+
},
|
|
214
|
+
/** Load once; later calls answer the same promise or nothing. */
|
|
215
|
+
load() { return loading ?? (loaded ? Promise.resolve(log) : log.refresh()); },
|
|
216
|
+
/** The next page, after the loaded ones. */
|
|
217
|
+
async loadMore() {
|
|
218
|
+
if (!more || !source?.load) return log;
|
|
219
|
+
const { got, more: m, unread } = await page(entries.length);
|
|
220
|
+
const seen = new Set(entries.map((e) => e.id));
|
|
221
|
+
entries = [...entries, ...got.filter((e) => !seen.has(e.id))];
|
|
222
|
+
more = m;
|
|
223
|
+
if (unread !== null) remoteUnread = unread;
|
|
224
|
+
changed();
|
|
225
|
+
return log;
|
|
226
|
+
},
|
|
227
|
+
|
|
228
|
+
/** A new entry raised HERE (bugdesk's rules): kept, and handed to the source to save. */
|
|
229
|
+
add(raw) {
|
|
230
|
+
const entry = normalizeEntry(raw);
|
|
231
|
+
entries = [entry, ...entries.filter((e) => e.id !== entry.id)];
|
|
232
|
+
if (entries.length > limit) entries.length = limit;
|
|
233
|
+
if (remoteUnread !== null && !entry.read) remoteUnread += 1;
|
|
234
|
+
tell('add', entry);
|
|
235
|
+
changed();
|
|
236
|
+
return entry;
|
|
237
|
+
},
|
|
238
|
+
/** Entries that arrived from the source's side (a push): shown, not saved back. */
|
|
239
|
+
receive(raws) {
|
|
240
|
+
const got = (Array.isArray(raws) ? raws : [raws]).map(normalizeEntry);
|
|
241
|
+
const ids = new Set(got.map((e) => e.id));
|
|
242
|
+
const fresh = got.filter((e) => !entries.some((x) => x.id === e.id));
|
|
243
|
+
entries = [...got, ...entries.filter((e) => !ids.has(e.id))]
|
|
244
|
+
.sort((a, b) => b.at - a.at).slice(0, limit);
|
|
245
|
+
if (remoteUnread !== null) remoteUnread += fresh.filter((e) => !e.read).length;
|
|
246
|
+
changed();
|
|
247
|
+
},
|
|
248
|
+
/** Mark these read — all of them when `ids` is omitted. */
|
|
249
|
+
markRead(ids = null) {
|
|
250
|
+
const want = ids ? new Set(ids.map(String)) : null;
|
|
251
|
+
let n = 0;
|
|
252
|
+
for (const e of entries) if (!e.read && (!want || want.has(e.id))) { e.read = true; n++; }
|
|
253
|
+
if (remoteUnread !== null) remoteUnread = want ? Math.max(0, remoteUnread - n) : 0;
|
|
254
|
+
if (!n && want) return;
|
|
255
|
+
tell('markRead', want ? [...want] : null);
|
|
256
|
+
changed();
|
|
257
|
+
},
|
|
258
|
+
/** Remove these — all of them when `ids` is omitted. Nothing, when the source cannot. */
|
|
259
|
+
dismiss(ids = null) {
|
|
260
|
+
if (!log.canDismiss()) return;
|
|
261
|
+
const want = ids ? new Set(ids.map(String)) : null;
|
|
262
|
+
const gone = entries.filter((e) => !want || want.has(e.id));
|
|
263
|
+
if (!gone.length) return;
|
|
264
|
+
entries = want ? entries.filter((e) => !want.has(e.id)) : [];
|
|
265
|
+
if (remoteUnread !== null) {
|
|
266
|
+
remoteUnread = want ? Math.max(0, remoteUnread - gone.filter((e) => !e.read).length) : 0;
|
|
267
|
+
}
|
|
268
|
+
tell('dismiss', want ? [...want] : null);
|
|
269
|
+
changed();
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
if (!source?.load) loaded = true;
|
|
273
|
+
return log;
|
|
274
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ══ THE ALERTS PANE ═══════════════════════════════════════════════════════
|
|
3
|
+
*
|
|
4
|
+
* An alert log (`alert_log.js`) drawn as the bottom panel's Alerts tab: level
|
|
5
|
+
* chips with counts, a text filter, entries grouped by day with relative
|
|
6
|
+
* times, an unread mark, Open and Dismiss on each row, Mark all read and Clear
|
|
7
|
+
* for the lot (FlexDesk STORY-0019, from bugdesk's `alerts.js`). The same
|
|
8
|
+
* layout whether the log is local or pages a server: *Load older* appears
|
|
9
|
+
* when the source has more.
|
|
10
|
+
*
|
|
11
|
+
* mountAlertsPane(el, { log, onOpen: (entry) => …, onBadge: ({ unread, level }) => … })
|
|
12
|
+
*
|
|
13
|
+
* OPTIONAL, for an embedder with rules of its own (bugdesk): `rules` is
|
|
14
|
+
* `{ list() → [{ id, name, level, meta?, off? }], onEdit?, foot? }` — a column
|
|
15
|
+
* of them on the left that narrows the log to one rule (entries carry
|
|
16
|
+
* `ruleId`) — and `stillMatches(entry)` → true / false / null, drawn as "still
|
|
17
|
+
* matches" or "no longer matches" on each row.
|
|
18
|
+
*
|
|
19
|
+
* NEVER REBUILT UNDER THE POINTER. A log changes on its own — a push from the
|
|
20
|
+
* server, a rule firing, the minute tick — and a row repainted between a
|
|
21
|
+
* mousedown and its click loses the click (Tables' recorded trap). While a
|
|
22
|
+
* button is down inside the pane a repaint waits, and runs once it is up.
|
|
23
|
+
* The bar (chips, the filter, the buttons) is built once and updated in
|
|
24
|
+
* place, so typing in the filter never loses the caret either.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { ALERT_LEVELS, ago, dayLabel, normalizeLevel } from './alert_log.js';
|
|
28
|
+
|
|
29
|
+
const CHIPS = [['all', 'All'], ['alert', 'Alert'], ['warning', 'Warning'], ['info', 'Info']];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {HTMLElement} host
|
|
33
|
+
* @param {object} opts
|
|
34
|
+
* @param {object} opts.log `createAlertLog(…)`
|
|
35
|
+
* @param {Function}[opts.onOpen] `(entry)` — Open; the entry is marked read first
|
|
36
|
+
* @param {Function}[opts.onBadge] `({ unread, level })` after every change — a tab's badge
|
|
37
|
+
* @param {object} [opts.rules] `{ list, onEdit?, foot? }`
|
|
38
|
+
* @param {Function}[opts.stillMatches] `(entry) → boolean | null`
|
|
39
|
+
* @param {Function}[opts.describe] `(entry) → string` — a small chip on the row (bugdesk: the store)
|
|
40
|
+
* @param {string} [opts.status] the footer line
|
|
41
|
+
* @param {object} [opts.empty] `{ none, filtered }` — the two empty texts
|
|
42
|
+
* @returns {{ refresh, destroy, root }}
|
|
43
|
+
*/
|
|
44
|
+
export function mountAlertsPane(host, {
|
|
45
|
+
log, onOpen = null, onBadge = null, rules = null, stillMatches = null, describe = null,
|
|
46
|
+
status = '', empty = {},
|
|
47
|
+
} = {}) {
|
|
48
|
+
const doc = host.ownerDocument;
|
|
49
|
+
const el = (tag, cls, text) => {
|
|
50
|
+
const n = doc.createElement(tag);
|
|
51
|
+
if (cls) n.className = cls;
|
|
52
|
+
if (text !== undefined) n.textContent = text;
|
|
53
|
+
return n;
|
|
54
|
+
};
|
|
55
|
+
const icon = (name) => el('span', 'material-symbols-outlined', name);
|
|
56
|
+
|
|
57
|
+
let level = 'all';
|
|
58
|
+
let rule = null;
|
|
59
|
+
let q = '';
|
|
60
|
+
let pointerDown = false;
|
|
61
|
+
let pending = false;
|
|
62
|
+
let destroyed = false;
|
|
63
|
+
|
|
64
|
+
// ── the frame, built once ────────────────────────────────────────────
|
|
65
|
+
const root = el('div', 'twm-alerts');
|
|
66
|
+
let ruleList = null;
|
|
67
|
+
if (rules) {
|
|
68
|
+
const side = el('div', 'twm-alerts__rules');
|
|
69
|
+
const head = el('div', 'twm-alerts__head');
|
|
70
|
+
head.append(el('span', 'twm-alerts__caps', 'RULES'), el('span', 'twm-alerts__spacer'));
|
|
71
|
+
if (rules.onEdit) {
|
|
72
|
+
const edit = el('button', 'twm-alerts__link', 'Edit rules');
|
|
73
|
+
edit.type = 'button';
|
|
74
|
+
edit.dataset.act = 'edit-rules';
|
|
75
|
+
head.append(edit);
|
|
76
|
+
}
|
|
77
|
+
ruleList = el('div', 'twm-alerts__rulelist');
|
|
78
|
+
side.append(head, ruleList);
|
|
79
|
+
if (rules.foot) side.append(el('div', 'twm-alerts__foot', rules.foot));
|
|
80
|
+
root.append(side);
|
|
81
|
+
}
|
|
82
|
+
const main = el('div', 'twm-alerts__main');
|
|
83
|
+
const bar = el('div', 'twm-alerts__bar');
|
|
84
|
+
const chips = el('div', 'twm-alerts__chips');
|
|
85
|
+
chips.setAttribute('role', 'group');
|
|
86
|
+
chips.setAttribute('aria-label', 'Level');
|
|
87
|
+
const chipEls = new Map();
|
|
88
|
+
for (const [key, label] of CHIPS) {
|
|
89
|
+
const chip = el('button', 'twm-alerts__chip');
|
|
90
|
+
chip.type = 'button';
|
|
91
|
+
chip.dataset.level = key;
|
|
92
|
+
chip.append(doc.createTextNode(`${label} `), el('span', 'twm-alerts__count', '0'));
|
|
93
|
+
chips.append(chip);
|
|
94
|
+
chipEls.set(key, chip);
|
|
95
|
+
}
|
|
96
|
+
const input = el('input', 'twm-alerts__q');
|
|
97
|
+
input.type = 'text';
|
|
98
|
+
input.autocomplete = 'off';
|
|
99
|
+
input.spellcheck = false;
|
|
100
|
+
input.placeholder = 'Filter';
|
|
101
|
+
input.setAttribute('aria-label', 'Filter alerts');
|
|
102
|
+
const readAll = el('button', 'twm-btn twm-alerts__btn', 'Mark all read');
|
|
103
|
+
readAll.type = 'button';
|
|
104
|
+
readAll.dataset.act = 'read-all';
|
|
105
|
+
const clear = el('button', 'twm-btn twm-alerts__btn', 'Clear');
|
|
106
|
+
clear.type = 'button';
|
|
107
|
+
clear.dataset.act = 'clear';
|
|
108
|
+
bar.append(chips, input, el('span', 'twm-alerts__spacer'), readAll, clear);
|
|
109
|
+
const list = el('div', 'twm-alerts__log');
|
|
110
|
+
const foot = el('div', 'twm-alerts__status', status);
|
|
111
|
+
foot.hidden = !status;
|
|
112
|
+
main.append(bar, list, foot);
|
|
113
|
+
root.append(main);
|
|
114
|
+
host.append(root);
|
|
115
|
+
|
|
116
|
+
// ── what is shown ───────────────────────────────────────────────────
|
|
117
|
+
const byRule = () => (rule ? log.entries().filter((e) => String(e.ruleId) === rule) : log.entries());
|
|
118
|
+
const shown = () => {
|
|
119
|
+
const needle = q.trim().toLowerCase();
|
|
120
|
+
return byRule()
|
|
121
|
+
.filter((e) => level === 'all' || e.level === level)
|
|
122
|
+
.filter((e) => !needle || `${e.ref ?? ''} ${e.title} ${e.body ?? ''} ${e.rule ?? ''}`
|
|
123
|
+
.toLowerCase().includes(needle));
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
function row(entry, now) {
|
|
127
|
+
const lv = ALERT_LEVELS[normalizeLevel(entry.level)];
|
|
128
|
+
const r = el('div', `twm-alerts__row twm-alerts__row--${entry.level}${entry.read ? '' : ' twm-alerts__row--unread'}`);
|
|
129
|
+
r.dataset.id = entry.id;
|
|
130
|
+
const mark = el('span', 'twm-alerts__unread');
|
|
131
|
+
mark.setAttribute('aria-label', entry.read ? 'Read' : 'Unread');
|
|
132
|
+
const ic = icon(lv.icon);
|
|
133
|
+
ic.classList.add('twm-alerts__icon');
|
|
134
|
+
ic.title = lv.label;
|
|
135
|
+
const time = el('span', 'twm-alerts__time', ago(entry.at, now));
|
|
136
|
+
time.title = entry.at.toLocaleString();
|
|
137
|
+
const text = el('span', 'twm-alerts__text');
|
|
138
|
+
text.append(el('span', 'twm-alerts__title', entry.title));
|
|
139
|
+
if (entry.body) text.append(el('span', 'twm-alerts__body', entry.body));
|
|
140
|
+
const cells = [mark, ic, time];
|
|
141
|
+
if (rules) cells.push(el('span', 'twm-alerts__rule', entry.rule ?? ''));
|
|
142
|
+
const tag = describe?.(entry);
|
|
143
|
+
cells.push(el('span', tag ? 'twm-alerts__tag' : 'twm-alerts__tag twm-alerts__tag--none', tag ?? ''));
|
|
144
|
+
const ref = el(entry.ref && onOpen ? 'button' : 'span', 'twm-alerts__ref', entry.ref ?? '');
|
|
145
|
+
if (ref.tagName === 'BUTTON') { ref.type = 'button'; ref.dataset.open = entry.id; }
|
|
146
|
+
cells.push(ref, text);
|
|
147
|
+
if (stillMatches) {
|
|
148
|
+
const still = stillMatches(entry);
|
|
149
|
+
cells.push(el('span', `twm-alerts__state${still === false ? ' twm-alerts__state--gone' : ''}`,
|
|
150
|
+
still === true ? 'still matches' : still === false ? 'no longer matches' : ''));
|
|
151
|
+
}
|
|
152
|
+
const acts = el('span', 'twm-alerts__acts');
|
|
153
|
+
if (onOpen) {
|
|
154
|
+
const open = el('button', 'twm-alerts__icon-btn');
|
|
155
|
+
open.type = 'button';
|
|
156
|
+
open.dataset.open = entry.id;
|
|
157
|
+
open.title = 'Open';
|
|
158
|
+
open.setAttribute('aria-label', 'Open');
|
|
159
|
+
open.append(icon('open_in_new'));
|
|
160
|
+
acts.append(open);
|
|
161
|
+
}
|
|
162
|
+
if (log.canDismiss()) {
|
|
163
|
+
const dis = el('button', 'twm-alerts__icon-btn');
|
|
164
|
+
dis.type = 'button';
|
|
165
|
+
dis.dataset.dismiss = entry.id;
|
|
166
|
+
dis.title = 'Dismiss';
|
|
167
|
+
dis.setAttribute('aria-label', 'Dismiss');
|
|
168
|
+
dis.append(icon('close'));
|
|
169
|
+
acts.append(dis);
|
|
170
|
+
}
|
|
171
|
+
cells.push(acts);
|
|
172
|
+
r.append(...cells);
|
|
173
|
+
return r;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function paint() {
|
|
177
|
+
if (destroyed) return;
|
|
178
|
+
if (pointerDown) { pending = true; return; }
|
|
179
|
+
pending = false;
|
|
180
|
+
const now = new Date();
|
|
181
|
+
const all = log.entries();
|
|
182
|
+
const scoped = byRule();
|
|
183
|
+
|
|
184
|
+
for (const [key, chip] of chipEls) {
|
|
185
|
+
const on = level === key;
|
|
186
|
+
chip.classList.toggle('twm-alerts__chip--on', on);
|
|
187
|
+
chip.setAttribute('aria-pressed', String(on));
|
|
188
|
+
chip.querySelector('.twm-alerts__count').textContent =
|
|
189
|
+
String(scoped.filter((e) => key === 'all' || e.level === key).length);
|
|
190
|
+
}
|
|
191
|
+
readAll.disabled = !all.some((e) => !e.read) && log.unreadCount() === 0;
|
|
192
|
+
clear.hidden = !log.canDismiss();
|
|
193
|
+
clear.disabled = !all.length;
|
|
194
|
+
|
|
195
|
+
if (ruleList) {
|
|
196
|
+
const items = [{ id: null, name: 'All alerts', meta: String(all.length) }, ...(rules.list?.() ?? [])];
|
|
197
|
+
ruleList.replaceChildren(...items.map((r) => {
|
|
198
|
+
const key = r.id === null ? null : String(r.id);
|
|
199
|
+
const b = el('button', `twm-alerts__rulebtn${rule === key ? ' twm-alerts__rulebtn--on' : ''}${r.off ? ' twm-alerts__rulebtn--off' : ''}`);
|
|
200
|
+
b.type = 'button';
|
|
201
|
+
b.dataset.rule = key ?? '';
|
|
202
|
+
b.setAttribute('aria-pressed', String(rule === key));
|
|
203
|
+
b.append(el('span', `twm-alerts__dot${r.level ? ` twm-alerts__dot--${normalizeLevel(r.level)}` : ''}`),
|
|
204
|
+
el('span', 'twm-alerts__rulename', r.name || 'Unnamed rule'),
|
|
205
|
+
el('span', 'twm-alerts__meta', r.meta ?? ''));
|
|
206
|
+
return b;
|
|
207
|
+
}));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const rows = [];
|
|
211
|
+
let lastDay = '';
|
|
212
|
+
for (const entry of shown()) {
|
|
213
|
+
const day = dayLabel(entry.at, now);
|
|
214
|
+
if (day !== lastDay) rows.push(el('div', 'twm-alerts__day', day));
|
|
215
|
+
lastDay = day;
|
|
216
|
+
rows.push(row(entry, now));
|
|
217
|
+
}
|
|
218
|
+
if (!rows.length) {
|
|
219
|
+
rows.push(el('div', 'twm-alerts__none', !log.loaded() ? 'Loading…'
|
|
220
|
+
: all.length ? (empty.filtered ?? 'No alerts match.') : (empty.none ?? 'Nothing yet.')));
|
|
221
|
+
}
|
|
222
|
+
if (log.hasMore()) {
|
|
223
|
+
const older = el('button', 'twm-btn twm-alerts__more', 'Load older');
|
|
224
|
+
older.type = 'button';
|
|
225
|
+
older.dataset.act = 'more';
|
|
226
|
+
rows.push(older);
|
|
227
|
+
}
|
|
228
|
+
const scroll = list.scrollTop;
|
|
229
|
+
list.replaceChildren(...rows);
|
|
230
|
+
list.scrollTop = scroll;
|
|
231
|
+
onBadge?.({ unread: log.unreadCount(), level: log.worstUnread() });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── input ───────────────────────────────────────────────────────────
|
|
235
|
+
const onDown = () => { pointerDown = true; };
|
|
236
|
+
const onUp = () => {
|
|
237
|
+
if (!pointerDown) return;
|
|
238
|
+
// After the click this mouseup belongs to has been dispatched.
|
|
239
|
+
setTimeout(() => {
|
|
240
|
+
pointerDown = false;
|
|
241
|
+
if (pending) paint();
|
|
242
|
+
}, 0);
|
|
243
|
+
};
|
|
244
|
+
root.addEventListener('mousedown', onDown, true);
|
|
245
|
+
root.addEventListener('pointerdown', onDown, true);
|
|
246
|
+
doc.addEventListener('mouseup', onUp, true);
|
|
247
|
+
doc.addEventListener('pointerup', onUp, true);
|
|
248
|
+
doc.addEventListener('pointercancel', onUp, true);
|
|
249
|
+
|
|
250
|
+
root.addEventListener('click', (event) => {
|
|
251
|
+
const t = event.target;
|
|
252
|
+
const chip = t.closest('[data-level]');
|
|
253
|
+
if (chip) { level = chip.dataset.level; paint(); return; }
|
|
254
|
+
const r = t.closest('[data-rule]');
|
|
255
|
+
if (r) { rule = r.dataset.rule || null; paint(); return; }
|
|
256
|
+
const open = t.closest('[data-open]');
|
|
257
|
+
if (open) {
|
|
258
|
+
const entry = log.get(open.dataset.open);
|
|
259
|
+
if (!entry) return;
|
|
260
|
+
log.markRead([entry.id]);
|
|
261
|
+
onOpen?.(entry);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const dis = t.closest('[data-dismiss]');
|
|
265
|
+
if (dis) { log.dismiss([dis.dataset.dismiss]); return; }
|
|
266
|
+
const act = t.closest('[data-act]')?.dataset.act;
|
|
267
|
+
if (act === 'read-all') log.markRead();
|
|
268
|
+
else if (act === 'clear') log.dismiss();
|
|
269
|
+
else if (act === 'more') log.loadMore();
|
|
270
|
+
else if (act === 'edit-rules') rules?.onEdit?.();
|
|
271
|
+
});
|
|
272
|
+
input.addEventListener('input', () => { q = input.value; paint(); });
|
|
273
|
+
|
|
274
|
+
const unsubscribe = log.subscribe(() => paint());
|
|
275
|
+
// "5 min ago" moves on its own.
|
|
276
|
+
const timer = setInterval(paint, 60000);
|
|
277
|
+
paint();
|
|
278
|
+
log.load?.();
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
root,
|
|
282
|
+
refresh: paint,
|
|
283
|
+
destroy() {
|
|
284
|
+
if (destroyed) return;
|
|
285
|
+
destroyed = true;
|
|
286
|
+
clearInterval(timer);
|
|
287
|
+
unsubscribe();
|
|
288
|
+
doc.removeEventListener('mouseup', onUp, true);
|
|
289
|
+
doc.removeEventListener('pointerup', onUp, true);
|
|
290
|
+
doc.removeEventListener('pointercancel', onUp, true);
|
|
291
|
+
root.remove();
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
}
|
|
@@ -327,7 +327,8 @@ const AUTOSIZE_MAX_PX = 520;
|
|
|
327
327
|
* @property {Function} [onRowClick] - Row click handler: (rowIdx, row, ev) => void. Receives the original (unfiltered) row index.
|
|
328
328
|
* @property {Function} [onRowContextMenu] - Row right-click handler: (rowIdx, row, ev) => void. Fires before the default context menu; call ev.preventDefault() to suppress the default.
|
|
329
329
|
* @property {Function} [onCellContextMenu] - Cell right-click handler: (colIdx, rowIdx, value, td, ev) => void. Fires before onRowContextMenu; same suppression semantics.
|
|
330
|
-
* @property {boolean} [showExportButton=false] - Adds a "CSV" download button to the pagination strip that invokes `downloadCSV()`.
|
|
330
|
+
* @property {boolean} [showExportButton=false] - Adds a "CSV" download button to the pagination strip that invokes `downloadCSV()`. The strip is drawn for it even when the table fits on one page (0.14.1).
|
|
331
|
+
* @property {string|function(DataTable): string} [exportFilename='data.csv'] - The file the export button saves (0.14.1); in a desktop host, the name the Save dialog suggests.
|
|
331
332
|
* @property {Object} [host] - A Host (see ui/js/host/host.js). Its `dialogs` capability backs `downloadCSV()`'s native save dialog; without it CSV export falls back to a Blob download.
|
|
332
333
|
* @property {Object} [stateStore] - `{ ready(), get(key), set(key, state) }` — REQUIRED when `persistKey` is set. Built by `createTableStateStore()`.
|
|
333
334
|
* @property {'container'|'content'} [columnFit='container'] - How the automatic fit pass sizes a column the user has NOT dragged. 'container' squeezes every column into the wrap: a 360px cap, a more generous one for the first column, and water-fill-shrink toward the 40px floor when they do not fit. 'content' sizes each column to its own content through the same `_autoWidth` a grip double-click uses (520px cap) and lets the body scroll sideways rather than squeezing. A pinned column is honoured verbatim under both.
|
|
@@ -466,6 +467,7 @@ export class DataTable {
|
|
|
466
467
|
onRowContextMenu: null,
|
|
467
468
|
onCellContextMenu: null,
|
|
468
469
|
showExportButton: false,
|
|
470
|
+
exportFilename: null,
|
|
469
471
|
// The host port. TOP LEVEL, deliberately — NOT inside `services`,
|
|
470
472
|
// which most call sites never pass. Supplies the native save
|
|
471
473
|
// dialog for downloadCSV(); absent => Blob fallback.
|
|
@@ -2623,7 +2625,7 @@ export class DataTable {
|
|
|
2623
2625
|
// Don't render the strip when there's only one page — the row
|
|
2624
2626
|
// count is uninformative (the table itself shows every row) and
|
|
2625
2627
|
// the nav buttons would all be disabled.
|
|
2626
|
-
if (totalPages <= 1 && !isFiltered) return
|
|
2628
|
+
if (totalPages <= 1 && !isFiltered) return this._createExportOnlyStrip();
|
|
2627
2629
|
const currentPage = Math.floor(offset / pageSize) + 1;
|
|
2628
2630
|
const hasPrev = offset > 0;
|
|
2629
2631
|
const hasNext = offset + pageSize < effectiveTotal;
|
|
@@ -2719,16 +2721,35 @@ export class DataTable {
|
|
|
2719
2721
|
// CSV export button (opt-in). Re-uses the existing downloadCSV
|
|
2720
2722
|
// method which already handles the host save dialog + browser
|
|
2721
2723
|
// fallback.
|
|
2722
|
-
if (this.config.showExportButton)
|
|
2723
|
-
const exportBtn = this._createPaginationBtn(
|
|
2724
|
-
'download', 'Download visible rows as CSV', false,
|
|
2725
|
-
() => this.downloadCSV('data.csv'));
|
|
2726
|
-
el.appendChild(exportBtn);
|
|
2727
|
-
}
|
|
2724
|
+
if (this.config.showExportButton) el.appendChild(this._createExportBtn());
|
|
2728
2725
|
|
|
2729
2726
|
return el;
|
|
2730
2727
|
}
|
|
2731
2728
|
|
|
2729
|
+
/** The export button: downloadCSV() under the configured file name. */
|
|
2730
|
+
_createExportBtn() {
|
|
2731
|
+
const name = this.config.exportFilename;
|
|
2732
|
+
const filename = (typeof name === 'function' ? name(this) : name) || 'data.csv';
|
|
2733
|
+
return this._createPaginationBtn('download', 'Download visible rows as CSV', false,
|
|
2734
|
+
() => this.downloadCSV(filename));
|
|
2735
|
+
}
|
|
2736
|
+
|
|
2737
|
+
/**
|
|
2738
|
+
* The strip on a table with one page: nothing, as always, unless the export
|
|
2739
|
+
* button needs somewhere to be. Then the button alone, with no page
|
|
2740
|
+
* buttons (0.14.1).
|
|
2741
|
+
*/
|
|
2742
|
+
_createExportOnlyStrip() {
|
|
2743
|
+
if (!this.config.showExportButton) return null;
|
|
2744
|
+
const el = document.createElement('div');
|
|
2745
|
+
el.className = 'twm-pagination-controls twm-pagination-controls--export-only';
|
|
2746
|
+
el.style.cssText = 'display:flex; align-items:center; gap:6px; padding:2px 8px; border-bottom:1px solid #2a2a2a; font-size:11px;';
|
|
2747
|
+
const spacer = document.createElement('div');
|
|
2748
|
+
spacer.style.flex = '1';
|
|
2749
|
+
el.append(spacer, this._createExportBtn());
|
|
2750
|
+
return el;
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2732
2753
|
_createPaginationBtn(icon, tooltip, disabled, onClick, textLabel = null) {
|
|
2733
2754
|
const btn = document.createElement('button');
|
|
2734
2755
|
btn.type = 'button';
|
|
@@ -4738,8 +4759,8 @@ export class DataTable {
|
|
|
4738
4759
|
const pn = this._pageNav({ offset, held: drawn, total, hasMore: hasMoreKnown, pageSize });
|
|
4739
4760
|
const { hasPrev, hasNext, page } = pn;
|
|
4740
4761
|
const totalPages = pn.pages;
|
|
4741
|
-
if (known && totalPages <= 1 && !isFiltered && !hasPrev) return
|
|
4742
|
-
if (!known && !hasPrev && !hasNext) return
|
|
4762
|
+
if (known && totalPages <= 1 && !isFiltered && !hasPrev) return this._createExportOnlyStrip();
|
|
4763
|
+
if (!known && !hasPrev && !hasNext) return this._createExportOnlyStrip();
|
|
4743
4764
|
const maxJump = Number.isFinite(c.maxJumpOffset) ? c.maxJumpOffset : null;
|
|
4744
4765
|
|
|
4745
4766
|
const go = (newOffset) => {
|
|
@@ -4817,10 +4838,7 @@ export class DataTable {
|
|
|
4817
4838
|
jumpBtn.dataset.pager = 'go';
|
|
4818
4839
|
|
|
4819
4840
|
el.append(info, spacer, firstBtn, prevBtn, pageInfo, nextBtn, lastBtn, jumpInput, jumpBtn);
|
|
4820
|
-
if (c.showExportButton)
|
|
4821
|
-
el.appendChild(this._createPaginationBtn('download', 'Download visible rows as CSV', false,
|
|
4822
|
-
() => this.downloadCSV('data.csv')));
|
|
4823
|
-
}
|
|
4841
|
+
if (c.showExportButton) el.appendChild(this._createExportBtn());
|
|
4824
4842
|
return el;
|
|
4825
4843
|
}
|
|
4826
4844
|
|