@arcforgelabs/flow 2026.10.42 → 2026.10.43
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/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/integrations/sentry/index.js +9 -1
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +74 -29
- package/server/ui/automations.js +9 -3
- package/server/ui/custom-fields.js +17 -16
- package/server/ui/flow.css +77 -37
- package/server/ui/invoice-notes.js +7 -5
- package/server/ui/invoicing-settings.js +69 -35
- package/server/ui/invoicing.js +16 -50
- package/server/ui/job-board.js +20 -8
- package/server/ui/job-change.js +36 -0
- package/server/ui/jobs.js +88 -32
- package/server/ui/monitoring.js +1 -0
- package/server/ui/price-list.js +2 -3
- package/server/ui/price-reconcile.js +7 -8
- package/server/ui/routes.js +1 -1
- package/server/ui/sidecar.js +72 -0
- package/server/ui/tip.js +99 -0
- package/server/ui/xero-import.js +7 -10
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.43",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@arcforgelabs/flow",
|
|
9
|
-
"version": "2026.10.
|
|
9
|
+
"version": "2026.10.43",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@clerk/backend": "3.25.0",
|
package/package.json
CHANGED
|
@@ -42,6 +42,7 @@ export const SENTRY_SETUP = {
|
|
|
42
42
|
fields: [
|
|
43
43
|
{ name: 'SENTRY_DSN', label: 'DSN', kind: 'url', help: 'From the Sentry project\'s Client Keys. Browsers see it, so it is not a secret.' },
|
|
44
44
|
{ name: 'SENTRY_ENVIRONMENT', label: 'Environment', help: 'How Sentry labels this installation, for example production. Empty: production, or workspace in a Replit workspace.' },
|
|
45
|
+
{ name: 'SENTRY_CLIENT', label: 'Client', help: 'The name your support desk files this installation\'s reports under. Sent as the client tag. Empty: no client tag.' },
|
|
45
46
|
{ name: 'SENTRY_FEEDBACK', label: 'Report a problem button', kind: 'onoff', help: 'On: everyone signed in can send a report, with a screenshot, to this Sentry project.' },
|
|
46
47
|
{ name: 'SENTRY_TRACES_SAMPLE_RATE', label: 'Traces', help: 'The share of clicks, page loads and requests timed, from 0 (off) to 1 (all). Empty: 0.2.' },
|
|
47
48
|
],
|
|
@@ -60,7 +61,12 @@ export function readSentrySettings(env) {
|
|
|
60
61
|
if (!/^\d*\.?\d+$/.test(rate || '0') || !(tracesSampleRate >= 0 && tracesSampleRate <= 1)) {
|
|
61
62
|
throw new Error('SENTRY_TRACES_SAMPLE_RATE must be a number from 0 to 1, such as 0.2.');
|
|
62
63
|
}
|
|
63
|
-
|
|
64
|
+
const client = String(env.SENTRY_CLIENT ?? '').trim();
|
|
65
|
+
if (client.length > 60 || /[\r\n]/.test(client)) throw new Error('SENTRY_CLIENT must be one short line.');
|
|
66
|
+
// Every event says which product and, when set, which client it is from,
|
|
67
|
+
// so one support desk can serve many installations.
|
|
68
|
+
const tags = client ? { product: 'flow', client } : { product: 'flow' };
|
|
69
|
+
return { dsn, environment, feedback, tracesSampleRate, tags };
|
|
64
70
|
}
|
|
65
71
|
|
|
66
72
|
// No trace for the host's health check: it is not a person waiting.
|
|
@@ -92,6 +98,7 @@ export function createSentryAdapter({ sdk = SentryNode, env: hostEnv = process.e
|
|
|
92
98
|
dsn: settings.dsn,
|
|
93
99
|
environment: settings.environment,
|
|
94
100
|
release: release ?? undefined,
|
|
101
|
+
initialScope: { tags: settings.tags },
|
|
95
102
|
// Flow sends what it logs; no request bodies, cookies or IP addresses.
|
|
96
103
|
sendDefaultPii: false,
|
|
97
104
|
// A request the browser traced keeps the browser's choice, so a
|
|
@@ -132,6 +139,7 @@ export function createSentryAdapter({ sdk = SentryNode, env: hostEnv = process.e
|
|
|
132
139
|
environment: settings.environment,
|
|
133
140
|
release,
|
|
134
141
|
feedback: settings.feedback,
|
|
142
|
+
tags: settings.tags,
|
|
135
143
|
tracesSampleRate,
|
|
136
144
|
script: tracesSampleRate > 0 ? SENTRY_BROWSER_TRACING : SENTRY_BROWSER,
|
|
137
145
|
};
|
package/server/ui/app-page.js
CHANGED
|
@@ -51,7 +51,7 @@ const BODY = `<div class="fx-app">
|
|
|
51
51
|
<div id="toasts" class="fx-toasts" aria-live="polite"></div>
|
|
52
52
|
</div>`;
|
|
53
53
|
|
|
54
|
-
export const APP_PATHS = ['/sign-in', '/jobs', '/invoices', '/invoices/:id', '/reconcile', '/reconcile/:id', '/xero-import', '/people', '/integrations', '/integrations/:id', '/account', '/appearance', '/invoicing-settings', '/price-list', '/custom-fields'];
|
|
54
|
+
export const APP_PATHS = ['/sign-in', '/jobs', '/invoices', '/invoices/:id', '/reconcile', '/reconcile/:id', '/xero-import', '/people', '/integrations', '/integrations/:id', '/account', '/appearance', '/invoicing-settings', '/invoicing-settings/:id', '/automations', '/price-list', '/custom-fields'];
|
|
55
55
|
|
|
56
56
|
export function appPage({ defaultFamily }) {
|
|
57
57
|
return renderPage({
|
package/server/ui/app.js
CHANGED
|
@@ -10,7 +10,7 @@ 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';
|
|
@@ -21,6 +21,7 @@ import { renderXeroImport } from '/assets/ui/xero-import.js';
|
|
|
21
21
|
import { startMonitoring } from '/assets/ui/monitoring.js';
|
|
22
22
|
import { openSearch } from '/assets/ui/search.js';
|
|
23
23
|
import { alertStrip } from '/assets/ui/alerts.js';
|
|
24
|
+
import { named, startTips } from '/assets/ui/tip.js';
|
|
24
25
|
|
|
25
26
|
const $ = (id) => document.getElementById(id);
|
|
26
27
|
|
|
@@ -164,14 +165,28 @@ function holdButton(button, ms) {
|
|
|
164
165
|
|
|
165
166
|
// ── Shared pieces ──────────────────────────────────────────────────────────
|
|
166
167
|
|
|
167
|
-
|
|
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 = [] } = {}) {
|
|
168
172
|
return h('header', { class: 'fx-ph' },
|
|
169
173
|
h('div', { class: 'fx-ph__text' },
|
|
170
174
|
h('h1', { class: 'fx-title' }, title),
|
|
171
|
-
|
|
175
|
+
count != null ? h('span', { class: 'fx-ph__count' }, count) : null,
|
|
176
|
+
help ? helpLink(help) : null),
|
|
172
177
|
actions.length ? h('div', { class: 'fx-ph__actions' }, actions) : null);
|
|
173
178
|
}
|
|
174
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
|
+
|
|
175
190
|
function notice(text, tone = 'info', title = null) {
|
|
176
191
|
return h('div', { class: 'fx-notice', 'data-tone': tone, role: tone === 'danger' ? 'alert' : 'status' },
|
|
177
192
|
title ? h('strong', {}, title) : null,
|
|
@@ -235,7 +250,8 @@ const VIEWS = {
|
|
|
235
250
|
jobs: { label: 'Jobs', icon: 'jobs', group: 'Work', render: (main) => renderJobs(main, ui()) },
|
|
236
251
|
invoices: { label: 'Invoices', icon: 'invoices', group: 'Work', render: (main, id) => (id ? renderInvoice(main, ui(), id) : renderInvoiceList(main, ui())) },
|
|
237
252
|
reconcile: { label: 'Reconcile', icon: 'reconcile', group: 'Work', render: (main, id) => renderReconcile(main, ui(), id) },
|
|
238
|
-
'invoicing-settings': { label: '
|
|
253
|
+
'invoicing-settings': { label: 'Rules', icon: 'rules', group: 'Settings', render: (main, id) => renderInvoicingSettings(main, ui(), id) },
|
|
254
|
+
automations: { label: 'Automations', icon: 'bolt', group: 'Settings', render: (main) => renderAutomations(main, ui()) },
|
|
239
255
|
'price-list': { label: 'Price list', icon: 'tag', group: 'Settings', render: (main, id) => (id === 'reconcile' ? renderPriceReconcile(main, ui()) : renderPriceList(main, ui())) },
|
|
240
256
|
'custom-fields': { label: 'Custom fields', icon: 'fields', group: 'Settings', render: (main) => renderCustomFields(main, ui()) },
|
|
241
257
|
people: { label: 'People', icon: 'people', group: 'Settings', ownerOnly: true, render: renderPeople },
|
|
@@ -252,6 +268,9 @@ const VIEWS = {
|
|
|
252
268
|
const ICON_PATHS = {
|
|
253
269
|
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
270
|
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
|
+
bolt: '<path d="M13 3L5 13h6l-1 8 8-10h-6z"/>',
|
|
273
|
+
rules: '<path d="M3 6l2 2 4-4M3 16l2 2 4-4M13 6h8M13 12h8M13 18h8"/>',
|
|
255
274
|
reconcile: '<path d="M4 8h13M13 4l4 4-4 4M20 16H7M11 12l-4 4 4 4"/>',
|
|
256
275
|
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
276
|
tag: '<path d="M3 12V4h8l10 10-8 8z"/><circle cx="7.5" cy="8.5" r="1.5"/>',
|
|
@@ -292,11 +311,11 @@ function drawMenu() {
|
|
|
292
311
|
const label = NARROW.matches ? (open ? 'Close the menu' : 'Open the menu') : (folded ? 'Show the sidebar' : 'Hide the sidebar');
|
|
293
312
|
toggle.setAttribute('aria-expanded', String(open));
|
|
294
313
|
toggle.setAttribute('aria-label', label);
|
|
295
|
-
toggle.
|
|
314
|
+
toggle.dataset.tip = label;
|
|
296
315
|
// Folded, a page's name shows when the pointer rests on its icon.
|
|
297
316
|
for (const link of document.querySelectorAll('[data-nav]')) {
|
|
298
|
-
if (folded && !NARROW.matches) link.
|
|
299
|
-
else link.
|
|
317
|
+
if (folded && !NARROW.matches) Object.assign(link.dataset, { tip: link.dataset.label, tipSide: 'right' });
|
|
318
|
+
else { delete link.dataset.tip; delete link.dataset.tipSide; }
|
|
300
319
|
}
|
|
301
320
|
}
|
|
302
321
|
|
|
@@ -403,7 +422,7 @@ function navigate(event) {
|
|
|
403
422
|
// The shared pieces a view module (invoicing.js) builds with. Its `go`
|
|
404
423
|
// takes a path, or a link's click event.
|
|
405
424
|
const ui = () => ({
|
|
406
|
-
h, flow, toast, notice, pageHeader, dialog, me, holdButton,
|
|
425
|
+
h, flow, toast, notice, pageHeader, sectionTitle, dialog, me, holdButton, tileCard, crumbs,
|
|
407
426
|
go: (target, options) => (typeof target === 'string' ? go(target, options) : navigate(target)),
|
|
408
427
|
});
|
|
409
428
|
|
|
@@ -641,9 +660,9 @@ async function renderIntegrations(main) {
|
|
|
641
660
|
const notices = h('div', { class: 'fx-fields' });
|
|
642
661
|
const content = h('div', { class: 'fx-int-sections' }, h('div', { class: 'fx-skeleton' }));
|
|
643
662
|
main.append(
|
|
644
|
-
|
|
663
|
+
pageHeader('Integrations'),
|
|
645
664
|
alertStrip(ui(), 'integrations').element,
|
|
646
|
-
h('div', { class: 'fx-int-bar' }, tabs, search),
|
|
665
|
+
h('div', { class: 'fx-int-bar' }, tabs, h('div', { class: 'fx-row' }, category, search)),
|
|
647
666
|
notices, content);
|
|
648
667
|
let body;
|
|
649
668
|
try {
|
|
@@ -655,13 +674,14 @@ async function renderIntegrations(main) {
|
|
|
655
674
|
const canChange = Boolean(me.ownerActions?.allowed);
|
|
656
675
|
notices.replaceChildren(...[
|
|
657
676
|
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
|
|
677
|
+
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
678
|
].filter(Boolean));
|
|
660
679
|
const list = [...body.integrations].sort((a, b) => a.name.localeCompare(b.name));
|
|
661
680
|
const connected = (i) => i.enabled;
|
|
662
681
|
|
|
663
682
|
const draw = () => {
|
|
664
|
-
|
|
683
|
+
category.hidden = state.tab !== 'all';
|
|
684
|
+
tabs.replaceChildren(...[['all', 'All'], ['yours', 'Connected']].map(([id, label]) => h('button', {
|
|
665
685
|
type: 'button', role: 'tab', class: 'fx-seg__item', 'aria-selected': String(state.tab === id), 'aria-checked': String(state.tab === id),
|
|
666
686
|
onclick: () => { state.tab = id; draw(); },
|
|
667
687
|
}, label)));
|
|
@@ -672,7 +692,7 @@ async function renderIntegrations(main) {
|
|
|
672
692
|
const yours = list.filter(connected).filter(matches);
|
|
673
693
|
sections.push(h('section', { class: 'fx-int-section-list' },
|
|
674
694
|
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
|
|
695
|
+
: h('p', { class: 'fx-empty' }, q ? 'No integration matches.' : 'Nothing is connected yet. Pick one under All.')));
|
|
676
696
|
} else {
|
|
677
697
|
// Recommended: what is not connected yet, once the catalogue is long
|
|
678
698
|
// enough that a short row helps (with a few, it would repeat the grid).
|
|
@@ -684,7 +704,7 @@ async function renderIntegrations(main) {
|
|
|
684
704
|
}
|
|
685
705
|
const all = list.filter(matches).filter((i) => state.category === 'all' || CATALOGUE[i.id]?.category === state.category);
|
|
686
706
|
sections.push(h('section', { class: 'fx-int-section-list' },
|
|
687
|
-
|
|
707
|
+
recommended.length ? h('h2', { class: 'fx-int-heading' }, 'All') : null,
|
|
688
708
|
all.length ? h('div', { class: 'fx-int-grid' }, all.map(integrationTile)) : h('p', { class: 'fx-empty' }, 'No integration matches.')));
|
|
689
709
|
}
|
|
690
710
|
content.replaceChildren(...sections);
|
|
@@ -695,17 +715,39 @@ async function renderIntegrations(main) {
|
|
|
695
715
|
}
|
|
696
716
|
|
|
697
717
|
function integrationTile(integration) {
|
|
698
|
-
const href = `/integrations/${integration.id}`;
|
|
699
718
|
const entry = CATALOGUE[integration.id];
|
|
700
719
|
const on = integration.enabled;
|
|
701
720
|
const attention = ['failed', 'restart_needed'].includes(integration.state);
|
|
702
|
-
return
|
|
721
|
+
return tileCard({
|
|
722
|
+
href: `/integrations/${integration.id}`,
|
|
723
|
+
mark: integrationLogo(integration),
|
|
724
|
+
title: integration.name,
|
|
725
|
+
status: on ? { label: STATE_LABEL[integration.state] ?? integration.state, tone: STATE_TONE[integration.state] } : null,
|
|
726
|
+
blurb: entry?.blurb,
|
|
727
|
+
action: attention ? 'Fix' : on ? 'Manage' : '+ Connect',
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
|
|
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 },
|
|
703
737
|
h('header', { class: 'fx-int-card__head' },
|
|
704
|
-
|
|
705
|
-
h('h3', {},
|
|
706
|
-
|
|
707
|
-
h('p', { class: 'fx-int-card__blurb' },
|
|
708
|
-
h('span', { class: 'fx-int-card__btn' },
|
|
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));
|
|
709
751
|
}
|
|
710
752
|
|
|
711
753
|
// One integration's page (/integrations/<id>): its logo and maker, Connect
|
|
@@ -842,8 +884,7 @@ async function renderIntegration(main, id) {
|
|
|
842
884
|
busy ? h('span', { class: 'fx-spinner fx-spinner--sm', 'aria-hidden': 'true' }) : null, busy ? 'Connecting' : 'Connect');
|
|
843
885
|
|
|
844
886
|
host.replaceChildren(
|
|
845
|
-
|
|
846
|
-
h('a', { href: '/integrations', onclick: navigate }, 'Integrations'), h('span', { 'aria-hidden': 'true' }, '›'), h('span', {}, current.name)),
|
|
887
|
+
crumbs(['/integrations', 'Integrations'], current.name),
|
|
847
888
|
h('header', { class: 'fx-int-hero' },
|
|
848
889
|
integrationLogo(current, 'xl'),
|
|
849
890
|
h('div', { class: 'fx-int-hero__text' },
|
|
@@ -858,7 +899,7 @@ async function renderIntegration(main, id) {
|
|
|
858
899
|
h('div', { class: 'fx-int-page__main' }, ...[
|
|
859
900
|
alerts.element,
|
|
860
901
|
!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
|
|
902
|
+
!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
903
|
current.state === 'failed' && current.error ? notice(current.error, 'danger', 'It could not start.') : null,
|
|
863
904
|
current.id === 'xero' && current.state === 'ready' ? h('section', { class: 'fx-int-section' }, xeroConnection(body.organisations, canChange)) : null,
|
|
864
905
|
current.id === 'seazona' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, automationSection(ui(), 'seazona', 'Ready to invoice')) : null,
|
|
@@ -1103,7 +1144,7 @@ function renderAccount(main) {
|
|
|
1103
1144
|
// in it at once.
|
|
1104
1145
|
function renderAppearance(main) {
|
|
1105
1146
|
main.append(
|
|
1106
|
-
pageHeader('Appearance'
|
|
1147
|
+
pageHeader('Appearance'),
|
|
1107
1148
|
h('div', { class: 'fx-appearance', id: 'appearance' }));
|
|
1108
1149
|
drawAppearance();
|
|
1109
1150
|
}
|
|
@@ -1169,13 +1210,16 @@ function drawAppearance() {
|
|
|
1169
1210
|
(p) => chooseAppearance({ accent: p.hex ? p.id : null }),
|
|
1170
1211
|
(p) => p.hex
|
|
1171
1212
|
? h('button', { type: 'button', class: 'fx-swatch', title: p.label, 'aria-label': p.label, style: { background: p.hex } })
|
|
1172
|
-
|
|
1173
|
-
|
|
1213
|
+
// The design system's own accent, named "Default" on the swatch: a
|
|
1214
|
+
// click puts it back.
|
|
1215
|
+
: h('button', { type: 'button', class: 'fx-swatch fx-swatch--default', 'aria-label': `Default: ${active.label}'s own accent` },
|
|
1216
|
+
h('span', { class: 'fx-swatch__dot', 'aria-hidden': 'true', style: { background: `linear-gradient(135deg, ${active.swatches[mode].primary} 50%, ${active.swatches[mode].accent} 50%)` } }),
|
|
1217
|
+
'Default'));
|
|
1174
1218
|
accents.classList.add('fx-swatches');
|
|
1175
1219
|
const custom = h('label', { class: 'fx-swatch-custom' },
|
|
1176
1220
|
h('input', { type: 'color', 'aria-label': 'Custom accent colour', value: customHex ?? '#0d4e5f', onchange: (event) => chooseAppearance({ accent: event.target.value }) }),
|
|
1177
1221
|
'Custom');
|
|
1178
|
-
const accentLabel = customHex ? `Custom ${customHex}` : presetIndex > 0 ? ACCENT_PRESETS[presetIndex].label :
|
|
1222
|
+
const accentLabel = customHex ? `Custom ${customHex}` : presetIndex > 0 ? ACCENT_PRESETS[presetIndex].label : `the default, ${active.label}'s own`;
|
|
1179
1223
|
|
|
1180
1224
|
host.replaceChildren(h('div', { class: 'fx-settings' },
|
|
1181
1225
|
settingRow({ label: 'Mode', hint: 'Auto follows this device.', control: modeGroup }),
|
|
@@ -1230,7 +1274,7 @@ function drawModeToggles() {
|
|
|
1230
1274
|
button.innerHTML = dark ? SUN : MOON;
|
|
1231
1275
|
if (!button.classList.contains('fx-btn--icon')) button.append(` ${dark ? 'Light' : 'Dark'}`);
|
|
1232
1276
|
button.setAttribute('aria-label', label);
|
|
1233
|
-
button.
|
|
1277
|
+
button.dataset.tip = label;
|
|
1234
1278
|
}
|
|
1235
1279
|
}
|
|
1236
1280
|
|
|
@@ -1291,4 +1335,5 @@ async function start() {
|
|
|
1291
1335
|
await renderShell();
|
|
1292
1336
|
}
|
|
1293
1337
|
|
|
1338
|
+
startTips();
|
|
1294
1339
|
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
|
+
}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// passes in app.js's shared pieces as `ui`.
|
|
15
15
|
|
|
16
16
|
import { orderStatuses, statusRank } from '/assets/ui/job-board.js';
|
|
17
|
+
import { sidecar } from '/assets/ui/sidecar.js';
|
|
17
18
|
|
|
18
19
|
const DOCS = 'https://github.com/arcforgelabs/flow/blob/main/docs/CUSTOM_FIELDS.md';
|
|
19
20
|
const TYPE_LABELS = {
|
|
@@ -96,7 +97,7 @@ export async function renderCustomFields(main, ui) {
|
|
|
96
97
|
const disabled = !['owner', 'admin'].includes(me?.person.role);
|
|
97
98
|
const host = h('div', { class: 'fx-card fx-cfm__card' }, h('div', { class: 'fx-skeleton' }));
|
|
98
99
|
const addButton = h('button', { class: 'fx-btn fx-btn--primary fx-btn--sm', type: 'button', disabled }, 'Create custom field');
|
|
99
|
-
main.append(pageHeader('Custom fields', {
|
|
100
|
+
main.append(pageHeader('Custom fields', { help: { href: DOCS, label: 'How custom fields work' } }), host);
|
|
100
101
|
|
|
101
102
|
let data = null;
|
|
102
103
|
const groupColor = (type) => data?.groupColors?.[type] ?? 'gray';
|
|
@@ -157,17 +158,19 @@ export async function renderCustomFields(main, ui) {
|
|
|
157
158
|
// the manager. Edit on a row (or Create) opens it; another row's Edit
|
|
158
159
|
// switches it at once, so a person moves between fields quickly. The
|
|
159
160
|
// table stays usable behind it. Save closes it; so do Cancel, × and Esc.
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
document.addEventListener('keydown', (event) => {
|
|
169
|
-
if (event.key === 'Escape' && side.isConnected && !side.hidden && !document.querySelector('dialog[open], .fx-pop:popover-open')) closeSide();
|
|
161
|
+
// The shared sidecar (sidecar.js), over the table.
|
|
162
|
+
const panel = sidecar(h, {
|
|
163
|
+
label: 'Custom field', overlay: true, escape: true,
|
|
164
|
+
onChange: (tab) => {
|
|
165
|
+
if (tab !== null) return;
|
|
166
|
+
sideFor = null;
|
|
167
|
+
table.querySelector('.fx-cfm__row[data-open]')?.removeAttribute('data-open');
|
|
168
|
+
},
|
|
170
169
|
});
|
|
170
|
+
const side = panel.element;
|
|
171
|
+
side.classList.add('fx-cfs');
|
|
172
|
+
let sideFor = null;
|
|
173
|
+
const closeSide = () => panel.close();
|
|
171
174
|
|
|
172
175
|
// Open the sidecar for a field (null: a new one; `draft` fills it after a
|
|
173
176
|
// failed save). A work system's field opens read only.
|
|
@@ -188,7 +191,7 @@ export async function renderCustomFields(main, ui) {
|
|
|
188
191
|
// lies over the label, so the label shows and the browser's list opens.
|
|
189
192
|
const typeText = h('span', {}, TYPE_LABELS[type.value]);
|
|
190
193
|
const headType = field ? typeLabel() : h('label', { class: 'fx-cfs__typepick' }, lucide(h, TYPE_ICONS[type.value]), typeText, lucide(h, 'chevron-down'), type);
|
|
191
|
-
const close =
|
|
194
|
+
const close = panel.closeButton();
|
|
192
195
|
|
|
193
196
|
const name = h('input', { class: 'fx-cfs__name', required: true, maxlength: '60', value: draft?.name ?? '', 'aria-label': 'Custom field name', placeholder: 'For example Priority', readonly: readOnly });
|
|
194
197
|
name.addEventListener('input', changed);
|
|
@@ -245,10 +248,9 @@ export async function renderCustomFields(main, ui) {
|
|
|
245
248
|
await load();
|
|
246
249
|
});
|
|
247
250
|
fit();
|
|
248
|
-
|
|
251
|
+
panel.show(form);
|
|
249
252
|
// Below the toolbar, so search and Create stay in reach.
|
|
250
253
|
side.style.top = `${toolbar.offsetHeight}px`;
|
|
251
|
-
side.hidden = false;
|
|
252
254
|
table.querySelector('.fx-cfm__row[data-open]')?.removeAttribute('data-open');
|
|
253
255
|
if (field) table.querySelector(`.fx-cfm__row[data-id="${CSS.escape(field.id)}"]`)?.setAttribute('data-open', 'true');
|
|
254
256
|
(readOnly ? close : name).focus({ preventScroll: true });
|
|
@@ -493,8 +495,7 @@ export async function renderCustomFields(main, ui) {
|
|
|
493
495
|
}
|
|
494
496
|
table.replaceChildren(...parts);
|
|
495
497
|
// The count is what the table shows, the work systems' fields too.
|
|
496
|
-
foot.replaceChildren(h('span', {}, `${count} ${count === 1 ? 'custom field' : 'custom fields'}`)
|
|
497
|
-
h('a', { href: DOCS, target: '_blank', rel: 'noopener' }, 'How custom fields work'));
|
|
498
|
+
foot.replaceChildren(h('span', {}, `${count} ${count === 1 ? 'custom field' : 'custom fields'}`));
|
|
498
499
|
}
|
|
499
500
|
const foot = h('div', { class: 'fx-cfm__foot' });
|
|
500
501
|
host.append(foot);
|