flexdesk 0.12.1 → 0.14.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.
@@ -601,11 +601,12 @@ export class TileGrid {
601
601
  if (this.data) {
602
602
  state.tile.fullData = this.fullData;
603
603
  state.tile.update(this.data);
604
- } else if (this.dataSource) {
604
+ } else {
605
605
  // Re-rendering from a dataset already in hand is free; asking
606
606
  // the server again because a tile got 40 px wider is not. A
607
- // self-fetching tile is told the geometry settled and decides
608
- // what that costs it — for a chart, one `Plotly.Plots.resize`.
607
+ // self-fetching tile — or one the embedder feeds through
608
+ // `setData` — is told the geometry settled and decides what
609
+ // that costs it — for a chart, one `Plotly.Plots.resize`.
609
610
  state.tile.onResize?.();
610
611
  }
611
612
  }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * ══ THE BOTTOM PANEL'S TAB HOST ══════════════════════════════════════════
3
+ *
4
+ * The `.twm-bp*` CSS shipped for a long time with no JS behind it, and two
5
+ * embedders wrote the same strip on top of it: bugdesk (Alerts and Changes,
6
+ * with a count on each tab) and Tables (SQL, Database, History, Activity, each
7
+ * pane with storage of its own). This is the two merged, so the third embedder
8
+ * writes none of it (FlexDesk STORY-0018).
9
+ *
10
+ * const tabs = createBottomPanelTabs(host, {
11
+ * panes: [{ id: 'alerts', label: 'Alerts', eager: true, mount(el, store, api) { … } }],
12
+ * remember: { read: () => saved, write: (id) => … },
13
+ * eventBus,
14
+ * });
15
+ * tabs.setBadge('alerts', 3, 'warning');
16
+ * eventBus.emit('bottompanel:show', { tab: 'alerts' });
17
+ *
18
+ * A PANE is `{ id, label, mount(el, store, api), eager? }`. `mount` answers an
19
+ * instance, or nothing; the host calls the instance's `onShow()` each time its
20
+ * tab comes to the front, `persist()` before the panel goes, `destroy()` when
21
+ * it is gone for good. A pane is mounted the first time it is shown — or at
22
+ * once, with `eager: true`, when its tab carries a count that must be right
23
+ * before anyone opens it (bugdesk's Alerts). Those two mount INSIDE this call,
24
+ * before it has answered: a pane reaches the host through its `api` argument
25
+ * (`api.setBadge(…)`), never through the variable the call is being assigned to.
26
+ *
27
+ * `store` is the pane's OWN persistence, from the embedder's `store(pane)`, or
28
+ * null. COLLAPSING A PANEL DESTROYS ITS CONTENT (the tile leaf goes, and its
29
+ * GC calls `destroy`), so a pane holding unsaved state — half-written SQL —
30
+ * saves it itself; the host cannot know what that is.
31
+ *
32
+ * `remember` keeps the tab last in front, in whatever store the embedder has
33
+ * (bugdesk: localStorage; Tables: its server blob, beside the panel's height).
34
+ * Its `read` may answer a promise. A remembered tab no longer offered falls
35
+ * back to the first; and A TAB A CALLER ALREADY CHOSE WINS — the read is a
36
+ * round trip, and a panel opened BY a button for one tab used to be put back
37
+ * on the remembered one when the read answered (Tables, 2026-10-12).
38
+ *
39
+ * `bottompanel:show` on the embedder's bus, with `{ tab }`, brings a tab to the
40
+ * front (a toast's "+N more" asks for Alerts). The host only selects; whether
41
+ * the panel itself is open is the embedder's (a tile it toggles), so the bus
42
+ * event is the embedder's to hear too.
43
+ *
44
+ * The strip is never rebuilt: a select toggles classes on the same buttons, so
45
+ * a click that lands while a tab is being selected keeps its target.
46
+ */
47
+
48
+ /** Badge levels, worst last. Any other value draws the neutral badge. */
49
+ export const BADGE_LEVELS = Object.freeze(['info', 'warning', 'alert']);
50
+
51
+ /**
52
+ * @param {HTMLElement} container
53
+ * @param {object} opts
54
+ * @param {Array} opts.panes `{ id, label, mount(el, store, api), eager? }`
55
+ * @param {object} [opts.remember] `{ read(): id|Promise<id>, write(id) }`
56
+ * @param {Function}[opts.store] `(pane) => { read, write } | null` — a pane's own storage
57
+ * @param {object} [opts.eventBus] hears `bottompanel:show`
58
+ * @param {Function}[opts.onSelect] `(id, pane)` — e.g. the tile's title
59
+ * @param {string} [opts.initial] the tab in front before `remember` answers
60
+ */
61
+ export function createBottomPanelTabs(container, {
62
+ panes = [], remember = null, store = null, eventBus = null, onSelect = null, initial = null,
63
+ } = {}) {
64
+ const doc = container.ownerDocument;
65
+ const root = doc.createElement('div');
66
+ root.className = 'twm-bp';
67
+ const strip = doc.createElement('div');
68
+ strip.className = 'twm-bp__tabs';
69
+ strip.setAttribute('role', 'tablist');
70
+ const body = doc.createElement('div');
71
+ body.className = 'twm-bp__body';
72
+ root.append(strip, body);
73
+ container.appendChild(root);
74
+
75
+ const byId = new Map(panes.map((pane) => [pane.id, pane]));
76
+ const tabs = new Map();
77
+ const mounted = new Map();
78
+ let active = null;
79
+ let chosen = false;
80
+ let destroyed = false;
81
+
82
+ for (const pane of panes) {
83
+ const tab = doc.createElement('button');
84
+ tab.type = 'button';
85
+ tab.className = 'twm-bp__tab';
86
+ tab.dataset.pane = pane.id;
87
+ tab.setAttribute('role', 'tab');
88
+ tab.setAttribute('aria-selected', 'false');
89
+ const label = doc.createElement('span');
90
+ label.className = 'twm-bp__label';
91
+ label.textContent = pane.label;
92
+ const badge = doc.createElement('span');
93
+ badge.className = 'twm-bp__badge';
94
+ badge.hidden = true;
95
+ tab.append(label, badge);
96
+ tab.addEventListener('click', () => api.select(pane.id));
97
+ strip.appendChild(tab);
98
+ tabs.set(pane.id, tab);
99
+ }
100
+
101
+ function mount(id) {
102
+ if (mounted.has(id)) return mounted.get(id);
103
+ const pane = byId.get(id);
104
+ const el = doc.createElement('div');
105
+ el.className = 'twm-bp-pane';
106
+ el.dataset.pane = id;
107
+ el.setAttribute('role', 'tabpanel');
108
+ el.hidden = id !== active;
109
+ body.appendChild(el);
110
+ const entry = { el, instance: null };
111
+ mounted.set(id, entry);
112
+ // ONE PANE THAT THROWS IS ONE EMPTY TAB, not a dead panel: the strip
113
+ // and every other pane go on working.
114
+ try {
115
+ entry.instance = pane.mount?.(el, store ? store(pane) : null, api) ?? null;
116
+ } catch (err) {
117
+ console.error(`[bottom-panel] pane "${id}" failed to mount`, err);
118
+ }
119
+ return entry;
120
+ }
121
+
122
+ function show(id) {
123
+ if (destroyed || !byId.has(id)) return false;
124
+ active = id;
125
+ for (const [key, tab] of tabs) {
126
+ const on = key === id;
127
+ tab.classList.toggle('twm-bp__tab--on', on);
128
+ tab.setAttribute('aria-selected', String(on));
129
+ }
130
+ for (const [key, entry] of mounted) entry.el.hidden = key !== id;
131
+ mount(id).instance?.onShow?.();
132
+ onSelect?.(id, byId.get(id));
133
+ return true;
134
+ }
135
+
136
+ const api = {
137
+ root,
138
+ /** Bring a tab to the front, and remember it. */
139
+ select(id) {
140
+ if (!show(id)) return;
141
+ chosen = true;
142
+ try { remember?.write?.(id); } catch { /* storage refused: not ours to fix */ }
143
+ },
144
+ /** The tab in front, or null. */
145
+ active: () => active,
146
+ /** `count` on a tab, coloured by `level`; nothing at zero. */
147
+ setBadge(id, count, level = null) {
148
+ const badge = tabs.get(id)?.querySelector('.twm-bp__badge');
149
+ if (!badge) return;
150
+ const n = Number(count) || 0;
151
+ badge.hidden = n <= 0;
152
+ badge.textContent = n > 99 ? '99+' : String(Math.max(n, 0));
153
+ badge.className = 'twm-bp__badge'
154
+ + (BADGE_LEVELS.includes(level) ? ` twm-bp__badge--${level}` : '');
155
+ },
156
+ /** A mounted pane's live instance, or null — how one tab hands work to
157
+ * another. `select` it first, which mounts it. */
158
+ instance: (id) => mounted.get(id)?.instance ?? null,
159
+ /** The panel is on screen again (its tile re-mounted): tell the pane in front. */
160
+ reshow() {
161
+ if (active) mounted.get(active)?.instance?.onShow?.();
162
+ },
163
+ /** Before the panel is torn down: every mounted pane saves. */
164
+ async persist() {
165
+ for (const entry of mounted.values()) await entry.instance?.persist?.();
166
+ },
167
+ /** Gone for good: panes save, then are destroyed; the bus is let go. */
168
+ async destroy() {
169
+ if (destroyed) return;
170
+ await api.persist();
171
+ destroyed = true;
172
+ try { subscription?.dispose?.(); } catch { /* bus gone */ }
173
+ for (const entry of mounted.values()) {
174
+ try { entry.instance?.destroy?.(); } catch (err) {
175
+ console.warn('[bottom-panel] pane destroy failed', err);
176
+ }
177
+ }
178
+ mounted.clear();
179
+ root.remove();
180
+ },
181
+ };
182
+
183
+ for (const pane of panes) if (pane.eager) mount(pane.id);
184
+
185
+ const subscription = eventBus?.on?.('bottompanel:show',
186
+ (payload) => api.select(payload?.tab ?? panes[0]?.id)) ?? null;
187
+
188
+ show(byId.has(initial) ? initial : panes[0]?.id);
189
+
190
+ if (remember?.read) {
191
+ Promise.resolve()
192
+ .then(() => remember.read())
193
+ .then((saved) => {
194
+ if (chosen || destroyed) return;
195
+ if (byId.has(saved)) show(saved);
196
+ })
197
+ .catch(() => { /* nothing remembered */ });
198
+ }
199
+
200
+ return api;
201
+ }
@@ -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
+ }