@arcforgelabs/flow 2026.10.42 → 2026.10.44
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/auth/people.js +40 -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/integrations/sentry/index.js +9 -1
- package/server/release-channel.js +5 -2
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +46 -162
- package/server/ui/automations.js +9 -3
- package/server/ui/columns.js +268 -0
- package/server/ui/custom-fields.js +17 -16
- package/server/ui/flow.css +268 -55
- package/server/ui/invoice-notes.js +7 -5
- package/server/ui/invoice-rules.js +3 -3
- package/server/ui/invoicing-settings.js +92 -35
- package/server/ui/invoicing.js +206 -119
- package/server/ui/job-board.js +20 -8
- package/server/ui/job-change.js +36 -0
- package/server/ui/job-view.js +255 -60
- package/server/ui/jobs.js +197 -145
- package/server/ui/kit.js +278 -0
- package/server/ui/list-views.js +73 -0
- package/server/ui/monitoring.js +1 -0
- package/server/ui/person.js +29 -0
- package/server/ui/price-list.js +2 -3
- package/server/ui/price-reconcile.js +7 -8
- 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/sidecar.js +72 -0
- package/server/ui/tip.js +99 -0
- package/server/ui/view-bar.js +176 -70
- package/server/ui/xero-import.js +7 -10
package/server/ui/app.js
CHANGED
|
@@ -10,36 +10,23 @@ import { getAppearance, onAppearanceChange, setAppearance, loadFonts } from '/as
|
|
|
10
10
|
import { flowClerkAppearance } from '/assets/ui/clerk-appearance.js';
|
|
11
11
|
import { ACCENT_PRESETS, normalizeAccent, THEME_FAMILIES, tokensFor } from '/assets/theme/themes.js';
|
|
12
12
|
import { renderInvoice, renderInvoiceList } from '/assets/ui/invoicing.js';
|
|
13
|
-
import { automationSection } from '/assets/ui/automations.js';
|
|
13
|
+
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 { 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';
|
|
24
27
|
|
|
25
28
|
const $ = (id) => document.getElementById(id);
|
|
26
29
|
|
|
27
|
-
// h('p', { class: 'x', onclick }, 'text', child) — children are text or nodes.
|
|
28
|
-
function h(tag, attrs = {}, ...children) {
|
|
29
|
-
const node = document.createElement(tag);
|
|
30
|
-
for (const [key, value] of Object.entries(attrs ?? {})) {
|
|
31
|
-
if (value == null || value === false) continue;
|
|
32
|
-
if (key.startsWith('on')) node.addEventListener(key.slice(2), value);
|
|
33
|
-
else if (key === 'style' && typeof value === 'object') Object.assign(node.style, value);
|
|
34
|
-
else node.setAttribute(key, value === true ? '' : String(value));
|
|
35
|
-
}
|
|
36
|
-
for (const child of children.flat()) {
|
|
37
|
-
if (child == null || child === false) continue;
|
|
38
|
-
node.append(child instanceof Node ? child : document.createTextNode(String(child)));
|
|
39
|
-
}
|
|
40
|
-
return node;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
30
|
const ROLE_LABEL = { owner: 'Owner', admin: 'Admin', staff: 'Staff' };
|
|
44
31
|
const ROLE_HINT = {
|
|
45
32
|
owner: 'Manages people, connections and every record.',
|
|
@@ -136,83 +123,8 @@ async function flow(path, options = {}) {
|
|
|
136
123
|
return body;
|
|
137
124
|
}
|
|
138
125
|
|
|
139
|
-
// Keeps a button off for `ms`, with the time left on it, then puts it back.
|
|
140
|
-
function holdButton(button, ms) {
|
|
141
|
-
if (!button || !(ms > 0)) return;
|
|
142
|
-
clearInterval(button.fxHold);
|
|
143
|
-
const label = button.dataset.label ?? button.textContent;
|
|
144
|
-
button.dataset.label = label;
|
|
145
|
-
const until = Date.now() + ms;
|
|
146
|
-
const draw = () => {
|
|
147
|
-
const left = Math.ceil((until - Date.now()) / 1000);
|
|
148
|
-
if (left <= 0) {
|
|
149
|
-
clearInterval(button.fxHold);
|
|
150
|
-
button.textContent = label;
|
|
151
|
-
delete button.dataset.label;
|
|
152
|
-
button.removeAttribute('title');
|
|
153
|
-
button.disabled = false;
|
|
154
|
-
return;
|
|
155
|
-
}
|
|
156
|
-
const time = left < 60 ? `${left}s` : `${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`;
|
|
157
|
-
button.disabled = true;
|
|
158
|
-
button.textContent = `${label} · ${time}`;
|
|
159
|
-
button.title = `Flow waits before it asks again, to protect the connected system's limits. Ready in ${time}.`;
|
|
160
|
-
};
|
|
161
|
-
draw();
|
|
162
|
-
button.fxHold = setInterval(draw, 1000);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
126
|
// ── Shared pieces ──────────────────────────────────────────────────────────
|
|
166
127
|
|
|
167
|
-
function pageHeader(title, { subtitle, actions = [] } = {}) {
|
|
168
|
-
return h('header', { class: 'fx-ph' },
|
|
169
|
-
h('div', { class: 'fx-ph__text' },
|
|
170
|
-
h('h1', { class: 'fx-title' }, title),
|
|
171
|
-
subtitle ? h('p', { class: 'fx-ph__sub' }, subtitle) : null),
|
|
172
|
-
actions.length ? h('div', { class: 'fx-ph__actions' }, actions) : null);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
function notice(text, tone = 'info', title = null) {
|
|
176
|
-
return h('div', { class: 'fx-notice', 'data-tone': tone, role: tone === 'danger' ? 'alert' : 'status' },
|
|
177
|
-
title ? h('strong', {}, title) : null,
|
|
178
|
-
h('span', {}, text));
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
function toast(text, tone = 'info') {
|
|
182
|
-
const region = $('toasts');
|
|
183
|
-
const item = h('div', { class: 'fx-toast', 'data-tone': tone, role: 'status' }, text);
|
|
184
|
-
region.append(item);
|
|
185
|
-
setTimeout(() => item.remove(), 4200);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// A modal built on <dialog>: resolves with the button's value, or null.
|
|
189
|
-
function dialog({ title, body, confirm, tone = 'primary' }) {
|
|
190
|
-
return new Promise((resolve) => {
|
|
191
|
-
const form = h('form', { method: 'dialog', class: 'fx-dialog__form' },
|
|
192
|
-
h('h2', { class: 'fx-dialog__title' }, title),
|
|
193
|
-
body,
|
|
194
|
-
h('div', { class: 'fx-dialog__actions' },
|
|
195
|
-
h('button', { class: 'fx-btn fx-btn--ghost', value: 'cancel', type: 'submit', formnovalidate: true }, 'Cancel'),
|
|
196
|
-
h('button', { class: `fx-btn fx-btn--${tone}`, value: 'ok', type: 'submit' }, confirm)));
|
|
197
|
-
const element = h('dialog', { class: 'fx-dialog' }, form);
|
|
198
|
-
element.addEventListener('close', () => {
|
|
199
|
-
const ok = element.returnValue === 'ok';
|
|
200
|
-
const data = ok ? new FormData(form) : null;
|
|
201
|
-
element.remove();
|
|
202
|
-
resolve(ok ? data : null);
|
|
203
|
-
});
|
|
204
|
-
document.body.append(element);
|
|
205
|
-
element.showModal();
|
|
206
|
-
element.querySelector('input, select, button[value="ok"]')?.focus();
|
|
207
|
-
});
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
function initials(person) {
|
|
211
|
-
const source = (person.name || person.email || '?').replace(/@.*/, '');
|
|
212
|
-
const parts = source.split(/[\s._-]+/).filter(Boolean);
|
|
213
|
-
return ((parts[0]?.[0] ?? '?') + (parts[1]?.[0] ?? '')).toUpperCase();
|
|
214
|
-
}
|
|
215
|
-
|
|
216
128
|
function statusPill(person) {
|
|
217
129
|
if (person.status === 'pending') return h('span', { class: 'fx-status', 'data-tone': 'warn' }, 'Waiting for approval');
|
|
218
130
|
if (person.status === 'disabled') return h('span', { class: 'fx-status', 'data-tone': 'mute' }, 'Turned off');
|
|
@@ -235,7 +147,8 @@ const VIEWS = {
|
|
|
235
147
|
jobs: { label: 'Jobs', icon: 'jobs', group: 'Work', render: (main) => renderJobs(main, ui()) },
|
|
236
148
|
invoices: { label: 'Invoices', icon: 'invoices', group: 'Work', render: (main, id) => (id ? renderInvoice(main, ui(), id) : renderInvoiceList(main, ui())) },
|
|
237
149
|
reconcile: { label: 'Reconcile', icon: 'reconcile', group: 'Work', render: (main, id) => renderReconcile(main, ui(), id) },
|
|
238
|
-
'invoicing-settings': { label: '
|
|
150
|
+
'invoicing-settings': { label: 'Rules', icon: 'rules', group: 'Settings', render: (main, id) => renderInvoicingSettings(main, ui(), id) },
|
|
151
|
+
automations: { label: 'Automations', icon: 'bolt', group: 'Settings', render: (main) => renderAutomations(main, ui()) },
|
|
239
152
|
'price-list': { label: 'Price list', icon: 'tag', group: 'Settings', render: (main, id) => (id === 'reconcile' ? renderPriceReconcile(main, ui()) : renderPriceList(main, ui())) },
|
|
240
153
|
'custom-fields': { label: 'Custom fields', icon: 'fields', group: 'Settings', render: (main) => renderCustomFields(main, ui()) },
|
|
241
154
|
people: { label: 'People', icon: 'people', group: 'Settings', ownerOnly: true, render: renderPeople },
|
|
@@ -252,6 +165,8 @@ const VIEWS = {
|
|
|
252
165
|
const ICON_PATHS = {
|
|
253
166
|
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"/>',
|
|
254
167
|
invoices: '<path d="M6 3h9l4 4v14H6z"/><path d="M14 3v5h5M9 13h7M9 17h5"/>',
|
|
168
|
+
bolt: '<path d="M13 3L5 13h6l-1 8 8-10h-6z"/>',
|
|
169
|
+
rules: '<path d="M3 6l2 2 4-4M3 16l2 2 4-4M13 6h8M13 12h8M13 18h8"/>',
|
|
255
170
|
reconcile: '<path d="M4 8h13M13 4l4 4-4 4M20 16H7M11 12l-4 4 4 4"/>',
|
|
256
171
|
people: '<circle cx="9" cy="8" r="3"/><path d="M3 20c0-3.5 2.7-6 6-6s6 2.5 6 6"/><path d="M16 5a3 3 0 010 6M21 20c0-3-1.6-5-4-5.7"/>',
|
|
257
172
|
tag: '<path d="M3 12V4h8l10 10-8 8z"/><circle cx="7.5" cy="8.5" r="1.5"/>',
|
|
@@ -292,11 +207,11 @@ function drawMenu() {
|
|
|
292
207
|
const label = NARROW.matches ? (open ? 'Close the menu' : 'Open the menu') : (folded ? 'Show the sidebar' : 'Hide the sidebar');
|
|
293
208
|
toggle.setAttribute('aria-expanded', String(open));
|
|
294
209
|
toggle.setAttribute('aria-label', label);
|
|
295
|
-
toggle.
|
|
210
|
+
toggle.dataset.tip = label;
|
|
296
211
|
// Folded, a page's name shows when the pointer rests on its icon.
|
|
297
212
|
for (const link of document.querySelectorAll('[data-nav]')) {
|
|
298
|
-
if (folded && !NARROW.matches) link.
|
|
299
|
-
else link.
|
|
213
|
+
if (folded && !NARROW.matches) Object.assign(link.dataset, { tip: link.dataset.label, tipSide: 'right' });
|
|
214
|
+
else { delete link.dataset.tip; delete link.dataset.tipSide; }
|
|
300
215
|
}
|
|
301
216
|
}
|
|
302
217
|
|
|
@@ -343,7 +258,7 @@ function search() {
|
|
|
343
258
|
}
|
|
344
259
|
|
|
345
260
|
document.addEventListener('keydown', (event) => {
|
|
346
|
-
if (
|
|
261
|
+
if (!isSearchKey(event)) return;
|
|
347
262
|
if (document.body.dataset.state !== 'signed-in') return;
|
|
348
263
|
event.preventDefault();
|
|
349
264
|
search();
|
|
@@ -388,6 +303,8 @@ async function route() {
|
|
|
388
303
|
}
|
|
389
304
|
|
|
390
305
|
// Opens a path in place; the address changes, the page stays.
|
|
306
|
+
linksOpenWith((path) => go(path));
|
|
307
|
+
|
|
391
308
|
function go(path, { replace = false } = {}) {
|
|
392
309
|
if (path !== location.pathname + location.search) history[replace ? 'replaceState' : 'pushState'](null, '', path);
|
|
393
310
|
route();
|
|
@@ -403,7 +320,7 @@ function navigate(event) {
|
|
|
403
320
|
// The shared pieces a view module (invoicing.js) builds with. Its `go`
|
|
404
321
|
// takes a path, or a link's click event.
|
|
405
322
|
const ui = () => ({
|
|
406
|
-
h, flow, toast, notice, pageHeader, dialog, me, holdButton,
|
|
323
|
+
h, flow, toast, notice, fieldError, pageHeader, listHead, listFrame, edgeFade, sectionTitle, dialog, me, holdButton, tileCard, tileSection, crumbs,
|
|
407
324
|
go: (target, options) => (typeof target === 'string' ? go(target, options) : navigate(target)),
|
|
408
325
|
});
|
|
409
326
|
|
|
@@ -529,28 +446,6 @@ async function refreshPeople(host) {
|
|
|
529
446
|
].filter(Boolean));
|
|
530
447
|
}
|
|
531
448
|
|
|
532
|
-
let peopleMenus = 0;
|
|
533
|
-
|
|
534
|
-
// A row's ⋯ menu, as on the Custom fields page.
|
|
535
|
-
function rowMenu(label, items) {
|
|
536
|
-
const id = `fx-people-menu-${++peopleMenus}`;
|
|
537
|
-
const button = h('button', { class: 'fx-cfm__more', type: 'button', 'aria-label': label, popovertarget: id, 'aria-haspopup': 'menu', 'aria-expanded': 'false' }, lucide(h, 'ellipsis'));
|
|
538
|
-
const panel = h('div', { class: 'fx-pop', id, popover: 'auto', role: 'menu' });
|
|
539
|
-
panel.addEventListener('toggle', (event) => {
|
|
540
|
-
button.setAttribute('aria-expanded', String(event.newState === 'open'));
|
|
541
|
-
if (event.newState !== 'open') return;
|
|
542
|
-
const close = () => panel.hidePopover();
|
|
543
|
-
panel.replaceChildren(...items.map((entry) => (entry === '-'
|
|
544
|
-
? h('div', { class: 'fx-pop__sep' })
|
|
545
|
-
: h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitem', 'data-tone': entry.tone, onclick: () => { close(); entry.run(); } },
|
|
546
|
-
h('span', { class: 'fx-pop__tick' }, lucide(h, entry.icon)), h('span', {}, entry.text)))));
|
|
547
|
-
const at = button.getBoundingClientRect();
|
|
548
|
-
panel.style.top = `${Math.round(at.bottom + 6)}px`;
|
|
549
|
-
panel.style.left = `${Math.round(Math.max(8, Math.min(at.right - panel.offsetWidth, innerWidth - panel.offsetWidth - 8)))}px`;
|
|
550
|
-
});
|
|
551
|
-
return [button, panel];
|
|
552
|
-
}
|
|
553
|
-
|
|
554
449
|
function personRow(person, host) {
|
|
555
450
|
const self = person.id === me.person.id;
|
|
556
451
|
const act = async (fn, done) => {
|
|
@@ -564,9 +459,9 @@ function personRow(person, host) {
|
|
|
564
459
|
};
|
|
565
460
|
const patch = (body) => flow(`/api/people/${person.id}`, { method: 'PATCH', body: JSON.stringify(body) });
|
|
566
461
|
const who = h('div', { class: 'fx-person' },
|
|
567
|
-
h('span', { class: 'fx-avatar', 'aria-hidden': 'true' },
|
|
462
|
+
h('span', { class: 'fx-avatar', 'aria-hidden': 'true' }, initialsOf(person)),
|
|
568
463
|
h('span', { class: 'fx-person__text' },
|
|
569
|
-
h('span', { class: 'fx-person__name' }, person
|
|
464
|
+
h('span', { class: 'fx-person__name' }, personLabel(person), self ? h('span', { class: 'fx-tag' }, 'You') : null),
|
|
570
465
|
person.name ? h('span', { class: 'fx-person__email' }, person.email) : null));
|
|
571
466
|
const role = self
|
|
572
467
|
? h('span', { title: ROLE_HINT[person.role] }, ROLE_LABEL[person.role])
|
|
@@ -641,9 +536,9 @@ async function renderIntegrations(main) {
|
|
|
641
536
|
const notices = h('div', { class: 'fx-fields' });
|
|
642
537
|
const content = h('div', { class: 'fx-int-sections' }, h('div', { class: 'fx-skeleton' }));
|
|
643
538
|
main.append(
|
|
644
|
-
|
|
539
|
+
pageHeader('Integrations'),
|
|
645
540
|
alertStrip(ui(), 'integrations').element,
|
|
646
|
-
h('div', { class: 'fx-int-bar' }, tabs, search),
|
|
541
|
+
h('div', { class: 'fx-int-bar' }, tabs, h('div', { class: 'fx-row' }, category, search)),
|
|
647
542
|
notices, content);
|
|
648
543
|
let body;
|
|
649
544
|
try {
|
|
@@ -655,13 +550,14 @@ async function renderIntegrations(main) {
|
|
|
655
550
|
const canChange = Boolean(me.ownerActions?.allowed);
|
|
656
551
|
notices.replaceChildren(...[
|
|
657
552
|
canChange ? null : notice('Changing integrations needs a verified owner sign-in: Google Workspace on the owner domain with no password, or a second factor.', 'warn', 'Read only.'),
|
|
658
|
-
body.secretsKey ? null : notice('Flow
|
|
553
|
+
body.secretsKey ? null : notice('Flow cannot store a client secret. Set FLOW_SECRETS_KEY (64 hex characters), then restart Flow.', 'warn', 'No secrets key.'),
|
|
659
554
|
].filter(Boolean));
|
|
660
555
|
const list = [...body.integrations].sort((a, b) => a.name.localeCompare(b.name));
|
|
661
556
|
const connected = (i) => i.enabled;
|
|
662
557
|
|
|
663
558
|
const draw = () => {
|
|
664
|
-
|
|
559
|
+
category.hidden = state.tab !== 'all';
|
|
560
|
+
tabs.replaceChildren(...[['all', 'All'], ['yours', 'Connected']].map(([id, label]) => h('button', {
|
|
665
561
|
type: 'button', role: 'tab', class: 'fx-seg__item', 'aria-selected': String(state.tab === id), 'aria-checked': String(state.tab === id),
|
|
666
562
|
onclick: () => { state.tab = id; draw(); },
|
|
667
563
|
}, label)));
|
|
@@ -672,7 +568,7 @@ async function renderIntegrations(main) {
|
|
|
672
568
|
const yours = list.filter(connected).filter(matches);
|
|
673
569
|
sections.push(h('section', { class: 'fx-int-section-list' },
|
|
674
570
|
yours.length ? h('div', { class: 'fx-int-grid' }, yours.map(integrationTile))
|
|
675
|
-
: h('p', { class: 'fx-empty' }, q ? 'No integration matches.' : 'Nothing is connected yet. Pick one under All
|
|
571
|
+
: h('p', { class: 'fx-empty' }, q ? 'No integration matches.' : 'Nothing is connected yet. Pick one under All.')));
|
|
676
572
|
} else {
|
|
677
573
|
// Recommended: what is not connected yet, once the catalogue is long
|
|
678
574
|
// enough that a short row helps (with a few, it would repeat the grid).
|
|
@@ -684,7 +580,7 @@ async function renderIntegrations(main) {
|
|
|
684
580
|
}
|
|
685
581
|
const all = list.filter(matches).filter((i) => state.category === 'all' || CATALOGUE[i.id]?.category === state.category);
|
|
686
582
|
sections.push(h('section', { class: 'fx-int-section-list' },
|
|
687
|
-
|
|
583
|
+
recommended.length ? h('h2', { class: 'fx-int-heading' }, 'All') : null,
|
|
688
584
|
all.length ? h('div', { class: 'fx-int-grid' }, all.map(integrationTile)) : h('p', { class: 'fx-empty' }, 'No integration matches.')));
|
|
689
585
|
}
|
|
690
586
|
content.replaceChildren(...sections);
|
|
@@ -695,19 +591,20 @@ async function renderIntegrations(main) {
|
|
|
695
591
|
}
|
|
696
592
|
|
|
697
593
|
function integrationTile(integration) {
|
|
698
|
-
const href = `/integrations/${integration.id}`;
|
|
699
594
|
const entry = CATALOGUE[integration.id];
|
|
700
595
|
const on = integration.enabled;
|
|
701
596
|
const attention = ['failed', 'restart_needed'].includes(integration.state);
|
|
702
|
-
return
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
597
|
+
return tileCard({
|
|
598
|
+
href: `/integrations/${integration.id}`,
|
|
599
|
+
mark: integrationLogo(integration),
|
|
600
|
+
title: integration.name,
|
|
601
|
+
status: on ? { label: STATE_LABEL[integration.state] ?? integration.state, tone: STATE_TONE[integration.state] } : null,
|
|
602
|
+
blurb: entry?.blurb,
|
|
603
|
+
action: attention ? 'Fix' : on ? 'Manage' : '+ Connect',
|
|
604
|
+
});
|
|
709
605
|
}
|
|
710
606
|
|
|
607
|
+
|
|
711
608
|
// One integration's page (/integrations/<id>): its logo and maker, Connect
|
|
712
609
|
// with each step shown as it happens, the setup steps, its settings, and the
|
|
713
610
|
// facts beside them.
|
|
@@ -842,8 +739,7 @@ async function renderIntegration(main, id) {
|
|
|
842
739
|
busy ? h('span', { class: 'fx-spinner fx-spinner--sm', 'aria-hidden': 'true' }) : null, busy ? 'Connecting' : 'Connect');
|
|
843
740
|
|
|
844
741
|
host.replaceChildren(
|
|
845
|
-
|
|
846
|
-
h('a', { href: '/integrations', onclick: navigate }, 'Integrations'), h('span', { 'aria-hidden': 'true' }, '›'), h('span', {}, current.name)),
|
|
742
|
+
crumbs(['/integrations', 'Integrations'], current.name),
|
|
847
743
|
h('header', { class: 'fx-int-hero' },
|
|
848
744
|
integrationLogo(current, 'xl'),
|
|
849
745
|
h('div', { class: 'fx-int-hero__text' },
|
|
@@ -858,7 +754,7 @@ async function renderIntegration(main, id) {
|
|
|
858
754
|
h('div', { class: 'fx-int-page__main' }, ...[
|
|
859
755
|
alerts.element,
|
|
860
756
|
!canChange ? notice('Changing integrations needs a verified owner sign-in: Google Workspace on the owner domain with no password, or a second factor.', 'warn', 'Read only.') : null,
|
|
861
|
-
!body.secretsKey ? notice('Flow
|
|
757
|
+
!body.secretsKey ? notice('Flow cannot store a client secret. Set FLOW_SECRETS_KEY (64 hex characters), then restart Flow.', 'warn', 'No secrets key.') : null,
|
|
862
758
|
current.state === 'failed' && current.error ? notice(current.error, 'danger', 'It could not start.') : null,
|
|
863
759
|
current.id === 'xero' && current.state === 'ready' ? h('section', { class: 'fx-int-section' }, xeroConnection(body.organisations, canChange)) : null,
|
|
864
760
|
current.id === 'seazona' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, automationSection(ui(), 'seazona', 'Ready to invoice')) : null,
|
|
@@ -918,22 +814,6 @@ function personSettingsSection(integration) {
|
|
|
918
814
|
return section;
|
|
919
815
|
}
|
|
920
816
|
|
|
921
|
-
// One setting, laid out the same on every settings screen: a short name, one
|
|
922
|
-
// line of smaller text under it, and the control on the right. Longer help
|
|
923
|
-
// lives behind a link, not here. A wide control (a grid of choices) sits on
|
|
924
|
-
// its own line under the name. `tone: 'danger'` marks a row that needs a person.
|
|
925
|
-
function settingRow({ label, hint, control, wide = false, tone = null }) {
|
|
926
|
-
const target = control?.matches?.('input, select, textarea') ? control : control?.querySelector?.('input:not([type=color]), select, textarea');
|
|
927
|
-
const id = target ? (target.id ||= `fx-set-${++settingRow.next}`) : null;
|
|
928
|
-
return h('div', { class: `fx-setting${wide ? ' fx-setting--wide' : ''}`, 'data-tone': tone },
|
|
929
|
-
h('div', { class: 'fx-setting__text' },
|
|
930
|
-
id ? h('label', { class: 'fx-setting__name', for: id }, label) : h('span', { class: 'fx-setting__name' }, label),
|
|
931
|
-
hint ? h('p', { class: 'fx-setting__hint' }, hint) : null),
|
|
932
|
-
h('div', { class: 'fx-setting__control' }, control));
|
|
933
|
-
}
|
|
934
|
-
settingRow.next = 0;
|
|
935
|
-
|
|
936
|
-
// The steps of a Connect, each ticked, running or crossed, with the time taken.
|
|
937
817
|
function connectProgress({ steps, started }) {
|
|
938
818
|
const seconds = Math.max(0, Math.round((Date.now() - started) / 1000));
|
|
939
819
|
return h('div', { class: 'fx-progress', role: 'status' },
|
|
@@ -1103,7 +983,7 @@ function renderAccount(main) {
|
|
|
1103
983
|
// in it at once.
|
|
1104
984
|
function renderAppearance(main) {
|
|
1105
985
|
main.append(
|
|
1106
|
-
pageHeader('Appearance'
|
|
986
|
+
pageHeader('Appearance'),
|
|
1107
987
|
h('div', { class: 'fx-appearance', id: 'appearance' }));
|
|
1108
988
|
drawAppearance();
|
|
1109
989
|
}
|
|
@@ -1169,13 +1049,16 @@ function drawAppearance() {
|
|
|
1169
1049
|
(p) => chooseAppearance({ accent: p.hex ? p.id : null }),
|
|
1170
1050
|
(p) => p.hex
|
|
1171
1051
|
? h('button', { type: 'button', class: 'fx-swatch', title: p.label, 'aria-label': p.label, style: { background: p.hex } })
|
|
1172
|
-
|
|
1173
|
-
|
|
1052
|
+
// The design system's own accent, named "Default" on the swatch: a
|
|
1053
|
+
// click puts it back.
|
|
1054
|
+
: h('button', { type: 'button', class: 'fx-swatch fx-swatch--default', 'aria-label': `Default: ${active.label}'s own accent` },
|
|
1055
|
+
h('span', { class: 'fx-swatch__dot', 'aria-hidden': 'true', style: { background: `linear-gradient(135deg, ${active.swatches[mode].primary} 50%, ${active.swatches[mode].accent} 50%)` } }),
|
|
1056
|
+
'Default'));
|
|
1174
1057
|
accents.classList.add('fx-swatches');
|
|
1175
1058
|
const custom = h('label', { class: 'fx-swatch-custom' },
|
|
1176
1059
|
h('input', { type: 'color', 'aria-label': 'Custom accent colour', value: customHex ?? '#0d4e5f', onchange: (event) => chooseAppearance({ accent: event.target.value }) }),
|
|
1177
1060
|
'Custom');
|
|
1178
|
-
const accentLabel = customHex ? `Custom ${customHex}` : presetIndex > 0 ? ACCENT_PRESETS[presetIndex].label :
|
|
1061
|
+
const accentLabel = customHex ? `Custom ${customHex}` : presetIndex > 0 ? ACCENT_PRESETS[presetIndex].label : `the default, ${active.label}'s own`;
|
|
1179
1062
|
|
|
1180
1063
|
host.replaceChildren(h('div', { class: 'fx-settings' },
|
|
1181
1064
|
settingRow({ label: 'Mode', hint: 'Auto follows this device.', control: modeGroup }),
|
|
@@ -1230,7 +1113,7 @@ function drawModeToggles() {
|
|
|
1230
1113
|
button.innerHTML = dark ? SUN : MOON;
|
|
1231
1114
|
if (!button.classList.contains('fx-btn--icon')) button.append(` ${dark ? 'Light' : 'Dark'}`);
|
|
1232
1115
|
button.setAttribute('aria-label', label);
|
|
1233
|
-
button.
|
|
1116
|
+
button.dataset.tip = label;
|
|
1234
1117
|
}
|
|
1235
1118
|
}
|
|
1236
1119
|
|
|
@@ -1291,4 +1174,5 @@ async function start() {
|
|
|
1291
1174
|
await renderShell();
|
|
1292
1175
|
}
|
|
1293
1176
|
|
|
1177
|
+
startTips();
|
|
1294
1178
|
start();
|
package/server/ui/automations.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Automation rows (server/core/automations.js, /api/invoicing/automations).
|
|
2
2
|
// Each automation shows where its trigger lives: the work system's ready rule
|
|
3
|
-
// on that integration's page, the
|
|
3
|
+
// on that integration's page, the invoicing ones on Settings, Automations. One row
|
|
4
4
|
// for each: its name, one short line, and its switch, plus a row for each of
|
|
5
5
|
// its settings. A change saves at once. Everyone can read them; an owner or
|
|
6
6
|
// admin changes them. The caller passes in app.js's shared pieces as `ui`.
|
|
@@ -86,11 +86,17 @@ export function automationSection(ui, place, title) {
|
|
|
86
86
|
(async () => {
|
|
87
87
|
try {
|
|
88
88
|
const { automations } = await flow(`/api/invoicing/automations?place=${encodeURIComponent(place)}`);
|
|
89
|
-
section.replaceChildren(
|
|
90
|
-
h('p', { class: 'fx-dim', style: { margin: '8px 0 0' } }, h('a', { href: DOCS, target: '_blank', rel: 'noopener' }, 'How automations work')));
|
|
89
|
+
section.replaceChildren(ui.sectionTitle(title, { href: DOCS, label: 'How automations work' }), ...automations.flatMap(rowsOf));
|
|
91
90
|
} catch (error) {
|
|
92
91
|
section.replaceChildren(h('h2', { class: 'fx-set__title' }, title), notice(error.message, 'danger'));
|
|
93
92
|
}
|
|
94
93
|
})();
|
|
95
94
|
return section;
|
|
96
95
|
}
|
|
96
|
+
|
|
97
|
+
// Settings, Automations (/automations): the invoicing automations. A work
|
|
98
|
+
// system's own automations stay on its integration page.
|
|
99
|
+
export function renderAutomations(main, ui) {
|
|
100
|
+
const { h, pageHeader } = ui;
|
|
101
|
+
main.append(pageHeader('Automations'), h('div', { class: 'fx-card', style: { padding: '8px 20px 16px' } }, automationSection(ui, 'invoicing', 'Invoicing')));
|
|
102
|
+
}
|