@arcforgelabs/flow 2026.10.43 → 2026.10.45
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 +1 -0
- package/npm-shrinkwrap.json +4 -3
- package/package.json +5 -3
- package/server/api/invoicing.js +53 -3
- package/server/auth/people.js +41 -2
- package/server/auth/routes.js +11 -0
- package/server/auth/session.js +22 -3
- package/server/core/custom-fields.js +4 -3
- package/server/core/invoice-rules.js +8 -3
- package/server/core/job-match.js +6 -2
- package/server/core/sending.js +4 -3
- package/server/release-channel.js +5 -2
- package/server/ui/app.js +53 -197
- package/server/ui/columns.js +286 -0
- package/server/ui/flow.css +230 -43
- package/server/ui/invoice-rules.js +3 -3
- package/server/ui/invoicing-settings.js +52 -29
- package/server/ui/invoicing.js +219 -72
- package/server/ui/job-view.js +276 -60
- package/server/ui/jobs.js +133 -137
- package/server/ui/kit.js +279 -0
- package/server/ui/list-views.js +115 -0
- package/server/ui/monitoring.js +51 -8
- package/server/ui/pager.js +67 -0
- package/server/ui/person.js +29 -0
- package/server/ui/price-list.js +27 -7
- package/server/ui/reconcile.js +114 -61
- package/server/ui/routes.js +1 -1
- package/server/ui/saved-views.js +141 -0
- package/server/ui/search.js +8 -0
- package/server/ui/view-bar.js +176 -70
package/server/ui/app.js
CHANGED
|
@@ -14,33 +14,19 @@ import { automationSection, renderAutomations } from '/assets/ui/automations.js'
|
|
|
14
14
|
import { renderInvoicingSettings, sendModeSection } from '/assets/ui/invoicing-settings.js';
|
|
15
15
|
import { renderPriceList } from '/assets/ui/price-list.js';
|
|
16
16
|
import { renderPriceReconcile } from '/assets/ui/price-reconcile.js';
|
|
17
|
-
import {
|
|
17
|
+
import { renderCustomFields } from '/assets/ui/custom-fields.js';
|
|
18
18
|
import { renderJobs } from '/assets/ui/jobs.js';
|
|
19
19
|
import { renderReconcile } from '/assets/ui/reconcile.js';
|
|
20
20
|
import { renderXeroImport } from '/assets/ui/xero-import.js';
|
|
21
21
|
import { startMonitoring } from '/assets/ui/monitoring.js';
|
|
22
|
-
import { openSearch } from '/assets/ui/search.js';
|
|
22
|
+
import { isSearchKey, openSearch } from '/assets/ui/search.js';
|
|
23
23
|
import { alertStrip } from '/assets/ui/alerts.js';
|
|
24
|
-
import {
|
|
24
|
+
import { initialsOf, personLabel } from '/assets/ui/person.js';
|
|
25
|
+
import { startTips } from '/assets/ui/tip.js';
|
|
26
|
+
import { crumbs, dialog, edgeFade, fieldError, h, holdButton, linksOpenWith, listFrame, listHead, notice, pageHeader, rowMenu, sectionTitle, settingRow, tileCard, tileSection, toast } from '/assets/ui/kit.js';
|
|
25
27
|
|
|
26
28
|
const $ = (id) => document.getElementById(id);
|
|
27
29
|
|
|
28
|
-
// h('p', { class: 'x', onclick }, 'text', child) — children are text or nodes.
|
|
29
|
-
function h(tag, attrs = {}, ...children) {
|
|
30
|
-
const node = document.createElement(tag);
|
|
31
|
-
for (const [key, value] of Object.entries(attrs ?? {})) {
|
|
32
|
-
if (value == null || value === false) continue;
|
|
33
|
-
if (key.startsWith('on')) node.addEventListener(key.slice(2), value);
|
|
34
|
-
else if (key === 'style' && typeof value === 'object') Object.assign(node.style, value);
|
|
35
|
-
else node.setAttribute(key, value === true ? '' : String(value));
|
|
36
|
-
}
|
|
37
|
-
for (const child of children.flat()) {
|
|
38
|
-
if (child == null || child === false) continue;
|
|
39
|
-
node.append(child instanceof Node ? child : document.createTextNode(String(child)));
|
|
40
|
-
}
|
|
41
|
-
return node;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
30
|
const ROLE_LABEL = { owner: 'Owner', admin: 'Admin', staff: 'Staff' };
|
|
45
31
|
const ROLE_HINT = {
|
|
46
32
|
owner: 'Manages people, connections and every record.',
|
|
@@ -137,97 +123,8 @@ async function flow(path, options = {}) {
|
|
|
137
123
|
return body;
|
|
138
124
|
}
|
|
139
125
|
|
|
140
|
-
// Keeps a button off for `ms`, with the time left on it, then puts it back.
|
|
141
|
-
function holdButton(button, ms) {
|
|
142
|
-
if (!button || !(ms > 0)) return;
|
|
143
|
-
clearInterval(button.fxHold);
|
|
144
|
-
const label = button.dataset.label ?? button.textContent;
|
|
145
|
-
button.dataset.label = label;
|
|
146
|
-
const until = Date.now() + ms;
|
|
147
|
-
const draw = () => {
|
|
148
|
-
const left = Math.ceil((until - Date.now()) / 1000);
|
|
149
|
-
if (left <= 0) {
|
|
150
|
-
clearInterval(button.fxHold);
|
|
151
|
-
button.textContent = label;
|
|
152
|
-
delete button.dataset.label;
|
|
153
|
-
button.removeAttribute('title');
|
|
154
|
-
button.disabled = false;
|
|
155
|
-
return;
|
|
156
|
-
}
|
|
157
|
-
const time = left < 60 ? `${left}s` : `${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`;
|
|
158
|
-
button.disabled = true;
|
|
159
|
-
button.textContent = `${label} · ${time}`;
|
|
160
|
-
button.title = `Flow waits before it asks again, to protect the connected system's limits. Ready in ${time}.`;
|
|
161
|
-
};
|
|
162
|
-
draw();
|
|
163
|
-
button.fxHold = setInterval(draw, 1000);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
126
|
// ── Shared pieces ──────────────────────────────────────────────────────────
|
|
167
127
|
|
|
168
|
-
// The page's name and nothing under it: the page shows what it is for.
|
|
169
|
-
// `count` is a live number beside the name (2 to check); `help` is a docs
|
|
170
|
-
// link, an icon with its name as a tip.
|
|
171
|
-
function pageHeader(title, { count = null, help = null, actions = [] } = {}) {
|
|
172
|
-
return h('header', { class: 'fx-ph' },
|
|
173
|
-
h('div', { class: 'fx-ph__text' },
|
|
174
|
-
h('h1', { class: 'fx-title' }, title),
|
|
175
|
-
count != null ? h('span', { class: 'fx-ph__count' }, count) : null,
|
|
176
|
-
help ? helpLink(help) : null),
|
|
177
|
-
actions.length ? h('div', { class: 'fx-ph__actions' }, actions) : null);
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// A docs link as a small "?" icon whose tip names it (How sending works).
|
|
181
|
-
function helpLink({ href, label }) {
|
|
182
|
-
return named(h('a', { class: 'fx-help', href, target: '_blank', rel: 'noopener' }, lucide(h, 'circle-help')), label);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// A settings section's heading, with its docs link beside it.
|
|
186
|
-
function sectionTitle(text, help = null) {
|
|
187
|
-
return h('h2', { class: 'fx-set__title' }, text, help ? helpLink(help) : null);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function notice(text, tone = 'info', title = null) {
|
|
191
|
-
return h('div', { class: 'fx-notice', 'data-tone': tone, role: tone === 'danger' ? 'alert' : 'status' },
|
|
192
|
-
title ? h('strong', {}, title) : null,
|
|
193
|
-
h('span', {}, text));
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
function toast(text, tone = 'info') {
|
|
197
|
-
const region = $('toasts');
|
|
198
|
-
const item = h('div', { class: 'fx-toast', 'data-tone': tone, role: 'status' }, text);
|
|
199
|
-
region.append(item);
|
|
200
|
-
setTimeout(() => item.remove(), 4200);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// A modal built on <dialog>: resolves with the button's value, or null.
|
|
204
|
-
function dialog({ title, body, confirm, tone = 'primary' }) {
|
|
205
|
-
return new Promise((resolve) => {
|
|
206
|
-
const form = h('form', { method: 'dialog', class: 'fx-dialog__form' },
|
|
207
|
-
h('h2', { class: 'fx-dialog__title' }, title),
|
|
208
|
-
body,
|
|
209
|
-
h('div', { class: 'fx-dialog__actions' },
|
|
210
|
-
h('button', { class: 'fx-btn fx-btn--ghost', value: 'cancel', type: 'submit', formnovalidate: true }, 'Cancel'),
|
|
211
|
-
h('button', { class: `fx-btn fx-btn--${tone}`, value: 'ok', type: 'submit' }, confirm)));
|
|
212
|
-
const element = h('dialog', { class: 'fx-dialog' }, form);
|
|
213
|
-
element.addEventListener('close', () => {
|
|
214
|
-
const ok = element.returnValue === 'ok';
|
|
215
|
-
const data = ok ? new FormData(form) : null;
|
|
216
|
-
element.remove();
|
|
217
|
-
resolve(ok ? data : null);
|
|
218
|
-
});
|
|
219
|
-
document.body.append(element);
|
|
220
|
-
element.showModal();
|
|
221
|
-
element.querySelector('input, select, button[value="ok"]')?.focus();
|
|
222
|
-
});
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
function initials(person) {
|
|
226
|
-
const source = (person.name || person.email || '?').replace(/@.*/, '');
|
|
227
|
-
const parts = source.split(/[\s._-]+/).filter(Boolean);
|
|
228
|
-
return ((parts[0]?.[0] ?? '?') + (parts[1]?.[0] ?? '')).toUpperCase();
|
|
229
|
-
}
|
|
230
|
-
|
|
231
128
|
function statusPill(person) {
|
|
232
129
|
if (person.status === 'pending') return h('span', { class: 'fx-status', 'data-tone': 'warn' }, 'Waiting for approval');
|
|
233
130
|
if (person.status === 'disabled') return h('span', { class: 'fx-status', 'data-tone': 'mute' }, 'Turned off');
|
|
@@ -245,30 +142,32 @@ function roleSelect(value, attrs = {}) {
|
|
|
245
142
|
|
|
246
143
|
let me = null;
|
|
247
144
|
|
|
248
|
-
//
|
|
145
|
+
// Where each view sits: the sidebar, in this order, daily work first (no
|
|
146
|
+
// `place`); the sidebar's More menu (`place: 'more'`, `more` names its Lucide
|
|
147
|
+
// icon); the account menu (`place: 'account'`). `sidebar: false` views open
|
|
148
|
+
// by link and Search only.
|
|
249
149
|
const VIEWS = {
|
|
250
|
-
jobs: { label: 'Jobs', icon: 'jobs',
|
|
251
|
-
invoices: { label: 'Invoices', icon: 'invoices',
|
|
252
|
-
reconcile: { label: 'Reconcile', icon: 'reconcile',
|
|
253
|
-
'
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
'custom-fields': { label: 'Custom fields', icon: 'fields',
|
|
257
|
-
|
|
258
|
-
|
|
150
|
+
jobs: { label: 'Jobs', icon: 'jobs', render: (main) => renderJobs(main, ui()) },
|
|
151
|
+
invoices: { label: 'Invoices', icon: 'invoices', render: (main, id) => (id ? renderInvoice(main, ui(), id) : renderInvoiceList(main, ui())) },
|
|
152
|
+
reconcile: { label: 'Reconcile', icon: 'reconcile', render: (main, id) => renderReconcile(main, ui(), id) },
|
|
153
|
+
'price-list': { label: 'Price list', icon: 'tag', render: (main, id) => (id === 'reconcile' ? renderPriceReconcile(main, ui()) : renderPriceList(main, ui())) },
|
|
154
|
+
'invoicing-settings': { label: 'Rules', icon: 'rules', render: (main, id) => renderInvoicingSettings(main, ui(), id) },
|
|
155
|
+
automations: { label: 'Automations', icon: 'bolt', place: 'more', more: 'zap', render: (main) => renderAutomations(main, ui()) },
|
|
156
|
+
'custom-fields': { label: 'Custom fields', icon: 'fields', place: 'more', more: 'form-input', render: (main) => renderCustomFields(main, ui()) },
|
|
157
|
+
integrations: { label: 'Integrations', icon: 'plug', place: 'more', more: 'plug', ownerOnly: true, render: (main, id) => (id ? renderIntegration(main, id) : renderIntegrations(main)) },
|
|
158
|
+
people: { label: 'People', icon: 'people', place: 'account', ownerOnly: true, render: renderPeople },
|
|
259
159
|
// Not in the sidebar: "Manage account" in the account menu opens the same
|
|
260
160
|
// Clerk profile. The page stays for links and Search.
|
|
261
161
|
// Opened from the Xero integration page: Xero invoices against Flow's own.
|
|
262
|
-
'xero-import': { label: 'Xero import', icon: 'plug',
|
|
263
|
-
account: { label: 'Account', icon: 'person',
|
|
264
|
-
appearance: { label: 'Appearance', icon: 'palette',
|
|
162
|
+
'xero-import': { label: 'Xero import', icon: 'plug', sidebar: false, render: (main) => renderXeroImport(main, ui()) },
|
|
163
|
+
account: { label: 'Account', icon: 'person', sidebar: false, render: renderAccount },
|
|
164
|
+
appearance: { label: 'Appearance', icon: 'palette', place: 'account', render: renderAppearance },
|
|
265
165
|
};
|
|
266
166
|
|
|
267
167
|
// Line icons for the sidebar, drawn on a 24-unit grid in the text colour.
|
|
268
168
|
const ICON_PATHS = {
|
|
269
169
|
jobs: '<rect x="3" y="4" width="5" height="16" rx="1"/><rect x="10" y="4" width="5" height="11" rx="1"/><rect x="17" y="4" width="4" height="14" rx="1"/>',
|
|
270
170
|
invoices: '<path d="M6 3h9l4 4v14H6z"/><path d="M14 3v5h5M9 13h7M9 17h5"/>',
|
|
271
|
-
draft: '<path d="M14 3H6v18h5M14 3l4 4v3M14 3v4h4"/><path d="M13 21v-2.5l6-6 2.5 2.5-6 6z"/>',
|
|
272
171
|
bolt: '<path d="M13 3L5 13h6l-1 8 8-10h-6z"/>',
|
|
273
172
|
rules: '<path d="M3 6l2 2 4-4M3 16l2 2 4-4M13 6h8M13 12h8M13 18h8"/>',
|
|
274
173
|
reconcile: '<path d="M4 8h13M13 4l4 4-4 4M20 16H7M11 12l-4 4 4 4"/>',
|
|
@@ -280,6 +179,7 @@ const ICON_PATHS = {
|
|
|
280
179
|
flag: '<path d="M5 21V4M5 4h11l-2 4 2 4H5"/>',
|
|
281
180
|
search: '<circle cx="11" cy="11" r="6.5"/><path d="M16 16l4.5 4.5"/>',
|
|
282
181
|
sidebar: '<rect x="3" y="4" width="18" height="16" rx="2.5"/><path d="M9.5 4v16"/>',
|
|
182
|
+
more: '<circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/>',
|
|
283
183
|
palette: '<path d="M12 3a9 9 0 100 18c1.1 0 1.5-.8 1.5-1.6 0-1-.8-1.4-.8-2.4 0-.9.7-1.5 1.6-1.5H17a4 4 0 004-4c0-4.7-4-8.5-9-8.5z"/><circle cx="7.5" cy="11" r="1"/><circle cx="10" cy="7" r="1"/><circle cx="15" cy="7" r="1"/>',
|
|
284
184
|
};
|
|
285
185
|
|
|
@@ -342,7 +242,7 @@ document.addEventListener('keydown', (event) => {
|
|
|
342
242
|
}
|
|
343
243
|
});
|
|
344
244
|
document.addEventListener('click', (event) => {
|
|
345
|
-
if (!event.target.closest?.('.fx-side') || event.target.closest?.('[data-nav]')) closeMenu();
|
|
245
|
+
if (!event.target.closest?.('.fx-side') || event.target.closest?.('[data-nav]:not([data-nav="more"])')) closeMenu();
|
|
346
246
|
});
|
|
347
247
|
// Folded, a click on the sidebar's empty space opens it out again, as in
|
|
348
248
|
// Claude. A click on a page, the account photo or Search still does its job.
|
|
@@ -362,7 +262,7 @@ function search() {
|
|
|
362
262
|
}
|
|
363
263
|
|
|
364
264
|
document.addEventListener('keydown', (event) => {
|
|
365
|
-
if (
|
|
265
|
+
if (!isSearchKey(event)) return;
|
|
366
266
|
if (document.body.dataset.state !== 'signed-in') return;
|
|
367
267
|
event.preventDefault();
|
|
368
268
|
search();
|
|
@@ -370,15 +270,19 @@ document.addEventListener('keydown', (event) => {
|
|
|
370
270
|
|
|
371
271
|
// "Report a problem" in Clerk's account menu, between "Manage account" and
|
|
372
272
|
// "Sign out". Clerk mounts and unmounts the icon itself.
|
|
373
|
-
function
|
|
273
|
+
function accountMenuItem(label, iconName, onClick) {
|
|
374
274
|
return {
|
|
375
|
-
label
|
|
376
|
-
onClick
|
|
377
|
-
mountIcon: (element) => element.replaceChildren(icon(
|
|
275
|
+
label,
|
|
276
|
+
onClick,
|
|
277
|
+
mountIcon: (element) => element.replaceChildren(icon(iconName)),
|
|
378
278
|
unmountIcon: (element) => element.replaceChildren(),
|
|
379
279
|
};
|
|
380
280
|
}
|
|
381
281
|
|
|
282
|
+
function reportMenuItem(openReport) {
|
|
283
|
+
return accountMenuItem('Report a problem', 'flag', () => { openReport().catch((error) => toast(error.message, 'danger')); });
|
|
284
|
+
}
|
|
285
|
+
|
|
382
286
|
// The page a person lands on after sign-in, and for / or an unknown path.
|
|
383
287
|
// Invoices opens on its Ready tab. A home page can take this over later.
|
|
384
288
|
const START_VIEW = 'invoices';
|
|
@@ -399,6 +303,8 @@ async function route() {
|
|
|
399
303
|
for (const link of document.querySelectorAll('a[data-nav]')) {
|
|
400
304
|
link.setAttribute('aria-current', link.dataset.nav === view ? 'page' : 'false');
|
|
401
305
|
}
|
|
306
|
+
// More shows as current while one of its pages is open.
|
|
307
|
+
document.querySelector('[data-nav="more"]')?.setAttribute('aria-current', VIEWS[view].place === 'more' ? 'page' : 'false');
|
|
402
308
|
main.replaceChildren();
|
|
403
309
|
main.className = 'fx-main';
|
|
404
310
|
document.title = `${VIEWS[view].label} · Flow`;
|
|
@@ -407,6 +313,8 @@ async function route() {
|
|
|
407
313
|
}
|
|
408
314
|
|
|
409
315
|
// Opens a path in place; the address changes, the page stays.
|
|
316
|
+
linksOpenWith((path) => go(path));
|
|
317
|
+
|
|
410
318
|
function go(path, { replace = false } = {}) {
|
|
411
319
|
if (path !== location.pathname + location.search) history[replace ? 'replaceState' : 'pushState'](null, '', path);
|
|
412
320
|
route();
|
|
@@ -422,7 +330,7 @@ function navigate(event) {
|
|
|
422
330
|
// The shared pieces a view module (invoicing.js) builds with. Its `go`
|
|
423
331
|
// takes a path, or a link's click event.
|
|
424
332
|
const ui = () => ({
|
|
425
|
-
h, flow, toast, notice, pageHeader, sectionTitle, dialog, me, holdButton, tileCard, crumbs,
|
|
333
|
+
h, flow, toast, notice, fieldError, pageHeader, listHead, listFrame, edgeFade, sectionTitle, dialog, me, holdButton, tileCard, tileSection, crumbs,
|
|
426
334
|
go: (target, options) => (typeof target === 'string' ? go(target, options) : navigate(target)),
|
|
427
335
|
});
|
|
428
336
|
|
|
@@ -454,14 +362,17 @@ function renderShell() {
|
|
|
454
362
|
document.body.dataset.state = 'signed-in';
|
|
455
363
|
const nav = $('nav');
|
|
456
364
|
const items = [];
|
|
457
|
-
|
|
365
|
+
const more = [];
|
|
458
366
|
for (const [name, view] of Object.entries(VIEWS)) {
|
|
459
367
|
if (view.ownerOnly && me?.person.role !== 'owner') continue;
|
|
460
|
-
if (view.sidebar === false) continue;
|
|
461
|
-
if (view.
|
|
462
|
-
|
|
463
|
-
items.push(h('a', { href: `/${name}`, 'data-nav': name, 'data-label': view.label, onclick: navigate }, icon(view.icon), h('span', { class: 'fx-nav__label' }, view.label)));
|
|
368
|
+
if (view.sidebar === false || view.place === 'account') continue;
|
|
369
|
+
if (view.place === 'more') more.push({ icon: view.more, text: view.label, run: () => { closeMenu(); go(`/${name}`); } });
|
|
370
|
+
else items.push(h('a', { href: `/${name}`, 'data-nav': name, 'data-label': view.label, onclick: navigate }, icon(view.icon), h('span', { class: 'fx-nav__label' }, view.label)));
|
|
464
371
|
}
|
|
372
|
+
// The rest of the setup pages, behind More (the shared ⋯ menu).
|
|
373
|
+
const [moreButton, morePanel] = rowMenu('More', more, { trigger: [icon('more'), h('span', { class: 'fx-nav__label' }, 'More')], className: 'fx-nav__more', align: 'start' });
|
|
374
|
+
Object.assign(moreButton.dataset, { nav: 'more', label: 'More' });
|
|
375
|
+
items.push(moreButton, morePanel);
|
|
465
376
|
// "Report a problem" sits at the foot of the menu, above the account chip,
|
|
466
377
|
// while Sentry and its report form are on (monitoring.js).
|
|
467
378
|
const report = h('button', { type: 'button', class: 'fx-nav__report', 'data-nav': 'report', 'data-label': 'Report a problem', hidden: true },
|
|
@@ -478,7 +389,11 @@ function renderShell() {
|
|
|
478
389
|
drawMenu();
|
|
479
390
|
// The sidebar foot shows who is signed in: photo or initial, then the name
|
|
480
391
|
// (the email when the account has no name). It opens the account menu.
|
|
481
|
-
|
|
392
|
+
// People (owners) and Appearance live in the account menu.
|
|
393
|
+
const accountItems = Object.entries(VIEWS)
|
|
394
|
+
.filter(([, view]) => view.place === 'account' && (!view.ownerOnly || me?.person.role === 'owner'))
|
|
395
|
+
.map(([name, view]) => accountMenuItem(view.label, view.icon, () => go(`/${name}`)));
|
|
396
|
+
const userButton = { afterSignOutUrl: '/sign-in', showName: true, customMenuItems: accountItems };
|
|
482
397
|
$('search').replaceChildren(icon('search'));
|
|
483
398
|
$('search').onclick = search;
|
|
484
399
|
mountClerk('userButton', $('user-button'), userButton);
|
|
@@ -491,7 +406,7 @@ function renderShell() {
|
|
|
491
406
|
report.hidden = false;
|
|
492
407
|
report.onclick = () => { openReport().catch((error) => toast(error.message, 'danger')); };
|
|
493
408
|
unmountClerk($('user-button'));
|
|
494
|
-
mountClerk('userButton', $('user-button'), { ...userButton, customMenuItems: [reportMenuItem(openReport)] });
|
|
409
|
+
mountClerk('userButton', $('user-button'), { ...userButton, customMenuItems: [...accountItems, reportMenuItem(openReport)] });
|
|
495
410
|
})
|
|
496
411
|
.catch((error) => console.warn(`[monitoring] ${error.message}`));
|
|
497
412
|
window.addEventListener('popstate', route);
|
|
@@ -548,28 +463,6 @@ async function refreshPeople(host) {
|
|
|
548
463
|
].filter(Boolean));
|
|
549
464
|
}
|
|
550
465
|
|
|
551
|
-
let peopleMenus = 0;
|
|
552
|
-
|
|
553
|
-
// A row's ⋯ menu, as on the Custom fields page.
|
|
554
|
-
function rowMenu(label, items) {
|
|
555
|
-
const id = `fx-people-menu-${++peopleMenus}`;
|
|
556
|
-
const button = h('button', { class: 'fx-cfm__more', type: 'button', 'aria-label': label, popovertarget: id, 'aria-haspopup': 'menu', 'aria-expanded': 'false' }, lucide(h, 'ellipsis'));
|
|
557
|
-
const panel = h('div', { class: 'fx-pop', id, popover: 'auto', role: 'menu' });
|
|
558
|
-
panel.addEventListener('toggle', (event) => {
|
|
559
|
-
button.setAttribute('aria-expanded', String(event.newState === 'open'));
|
|
560
|
-
if (event.newState !== 'open') return;
|
|
561
|
-
const close = () => panel.hidePopover();
|
|
562
|
-
panel.replaceChildren(...items.map((entry) => (entry === '-'
|
|
563
|
-
? h('div', { class: 'fx-pop__sep' })
|
|
564
|
-
: h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitem', 'data-tone': entry.tone, onclick: () => { close(); entry.run(); } },
|
|
565
|
-
h('span', { class: 'fx-pop__tick' }, lucide(h, entry.icon)), h('span', {}, entry.text)))));
|
|
566
|
-
const at = button.getBoundingClientRect();
|
|
567
|
-
panel.style.top = `${Math.round(at.bottom + 6)}px`;
|
|
568
|
-
panel.style.left = `${Math.round(Math.max(8, Math.min(at.right - panel.offsetWidth, innerWidth - panel.offsetWidth - 8)))}px`;
|
|
569
|
-
});
|
|
570
|
-
return [button, panel];
|
|
571
|
-
}
|
|
572
|
-
|
|
573
466
|
function personRow(person, host) {
|
|
574
467
|
const self = person.id === me.person.id;
|
|
575
468
|
const act = async (fn, done) => {
|
|
@@ -583,9 +476,9 @@ function personRow(person, host) {
|
|
|
583
476
|
};
|
|
584
477
|
const patch = (body) => flow(`/api/people/${person.id}`, { method: 'PATCH', body: JSON.stringify(body) });
|
|
585
478
|
const who = h('div', { class: 'fx-person' },
|
|
586
|
-
h('span', { class: 'fx-avatar', 'aria-hidden': 'true' },
|
|
479
|
+
h('span', { class: 'fx-avatar', 'aria-hidden': 'true' }, initialsOf(person)),
|
|
587
480
|
h('span', { class: 'fx-person__text' },
|
|
588
|
-
h('span', { class: 'fx-person__name' }, person
|
|
481
|
+
h('span', { class: 'fx-person__name' }, personLabel(person), self ? h('span', { class: 'fx-tag' }, 'You') : null),
|
|
589
482
|
person.name ? h('span', { class: 'fx-person__email' }, person.email) : null));
|
|
590
483
|
const role = self
|
|
591
484
|
? h('span', { title: ROLE_HINT[person.role] }, ROLE_LABEL[person.role])
|
|
@@ -728,27 +621,6 @@ function integrationTile(integration) {
|
|
|
728
621
|
});
|
|
729
622
|
}
|
|
730
623
|
|
|
731
|
-
// One card in a grid of choices (Integrations, Rules): its mark (a node, or
|
|
732
|
-
// an ICON_PATHS name), the name, a status pill, two lines at most (only when
|
|
733
|
-
// they help), and the action. The whole card is the link.
|
|
734
|
-
function tileCard({ href, mark, title, status, blurb, action = 'Manage' }) {
|
|
735
|
-
const logo = typeof mark === 'string' ? h('span', { class: 'fx-logo fx-logo--icon', 'aria-hidden': 'true' }, icon(mark)) : mark;
|
|
736
|
-
return h('a', { class: `fx-int-card${blurb ? '' : ' fx-int-card--bare'}`, href, onclick: navigate },
|
|
737
|
-
h('header', { class: 'fx-int-card__head' },
|
|
738
|
-
logo,
|
|
739
|
-
h('h3', {}, title),
|
|
740
|
-
status ? h('span', { class: 'fx-status', 'data-tone': status.tone ?? 'mute' }, status.label) : null),
|
|
741
|
-
blurb ? h('p', { class: 'fx-int-card__blurb' }, blurb) : null,
|
|
742
|
-
h('span', { class: 'fx-int-card__btn' }, action));
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
// The trail above a page one level down: crumbs(['/integrations', 'Integrations'], 'Xero').
|
|
746
|
-
function crumbs(...trail) {
|
|
747
|
-
const here = trail.pop();
|
|
748
|
-
return h('nav', { class: 'fx-crumbs', 'aria-label': 'Breadcrumb' },
|
|
749
|
-
...trail.flatMap(([href, label]) => [h('a', { href, onclick: navigate }, label), h('span', { 'aria-hidden': 'true' }, '›')]),
|
|
750
|
-
h('span', {}, here));
|
|
751
|
-
}
|
|
752
624
|
|
|
753
625
|
// One integration's page (/integrations/<id>): its logo and maker, Connect
|
|
754
626
|
// with each step shown as it happens, the setup steps, its settings, and the
|
|
@@ -959,22 +831,6 @@ function personSettingsSection(integration) {
|
|
|
959
831
|
return section;
|
|
960
832
|
}
|
|
961
833
|
|
|
962
|
-
// One setting, laid out the same on every settings screen: a short name, one
|
|
963
|
-
// line of smaller text under it, and the control on the right. Longer help
|
|
964
|
-
// lives behind a link, not here. A wide control (a grid of choices) sits on
|
|
965
|
-
// its own line under the name. `tone: 'danger'` marks a row that needs a person.
|
|
966
|
-
function settingRow({ label, hint, control, wide = false, tone = null }) {
|
|
967
|
-
const target = control?.matches?.('input, select, textarea') ? control : control?.querySelector?.('input:not([type=color]), select, textarea');
|
|
968
|
-
const id = target ? (target.id ||= `fx-set-${++settingRow.next}`) : null;
|
|
969
|
-
return h('div', { class: `fx-setting${wide ? ' fx-setting--wide' : ''}`, 'data-tone': tone },
|
|
970
|
-
h('div', { class: 'fx-setting__text' },
|
|
971
|
-
id ? h('label', { class: 'fx-setting__name', for: id }, label) : h('span', { class: 'fx-setting__name' }, label),
|
|
972
|
-
hint ? h('p', { class: 'fx-setting__hint' }, hint) : null),
|
|
973
|
-
h('div', { class: 'fx-setting__control' }, control));
|
|
974
|
-
}
|
|
975
|
-
settingRow.next = 0;
|
|
976
|
-
|
|
977
|
-
// The steps of a Connect, each ticked, running or crossed, with the time taken.
|
|
978
834
|
function connectProgress({ steps, started }) {
|
|
979
835
|
const seconds = Math.max(0, Math.round((Date.now() - started) / 1000));
|
|
980
836
|
return h('div', { class: 'fx-progress', role: 'status' },
|