@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.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.42",
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.42",
9
+ "version": "2026.10.43",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@clerk/backend": "3.25.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.42",
3
+ "version": "2026.10.43",
4
4
  "description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
- return { dsn, environment, feedback, tracesSampleRate };
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
  };
@@ -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
- function pageHeader(title, { subtitle, actions = [] } = {}) {
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
- subtitle ? h('p', { class: 'fx-ph__sub' }, subtitle) : null),
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: 'Invoice rules', icon: 'invoices', group: 'Settings', render: (main) => renderInvoicingSettings(main, ui()) },
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.title = label;
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.title = link.dataset.label;
299
- else link.removeAttribute('title');
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
- h('header', { class: 'fx-ph' }, h('div', { class: 'fx-ph__text' }, h('h1', { class: 'fx-title' }, 'Integrations'))),
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 has no secrets key, so it cannot store a client secret. Ask whoever hosts Flow to set FLOW_SECRETS_KEY (64 hex characters), then restart Flow.', 'warn', 'No secrets key.'),
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
- tabs.replaceChildren(...[['all', 'All integrations'], ['yours', 'Your integrations']].map(([id, label]) => h('button', {
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 integrations.')));
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
- h('div', { class: 'fx-int-heading-row' }, h('h2', { class: 'fx-int-heading' }, 'All integrations'), category),
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 h('a', { class: 'fx-int-card', href, onclick: navigate },
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
- integrationLogo(integration),
705
- h('h3', {}, integration.name),
706
- on ? h('span', { class: 'fx-status', 'data-tone': STATE_TONE[integration.state] ?? 'mute' }, STATE_LABEL[integration.state] ?? integration.state) : null),
707
- h('p', { class: 'fx-int-card__blurb' }, entry?.blurb ?? ''),
708
- h('span', { class: 'fx-int-card__btn' }, attention ? 'Fix' : on ? 'Manage' : '+ Connect'));
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
- h('nav', { class: 'fx-crumbs', 'aria-label': 'Breadcrumb' },
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 has no secrets key, so it cannot store a client secret. Ask whoever hosts Flow to set FLOW_SECRETS_KEY (64 hex characters), then restart Flow.', 'warn', 'No secrets key.') : null,
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', { subtitle: 'Saved with your account, on every device.' }),
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
- : h('button', { type: 'button', class: 'fx-swatch fx-swatch--default', title: `${active.label}'s own`, 'aria-label': `${active.label}'s own accent` },
1173
- h('span', { 'aria-hidden': 'true', style: { background: `linear-gradient(135deg, ${active.swatches[mode].primary} 50%, ${active.swatches[mode].accent} 50%)` } })));
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 : `${active.label}'s own`;
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.title = label;
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();
@@ -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 draft rule on Settings, Invoicing. One row
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(h('h2', { class: 'fx-set__title' }, title), ...automations.flatMap(rowsOf),
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', { subtitle: 'Fields for your jobs. They stay in Flow and never change Seazona or Xero.' }), host);
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
- const side = h('aside', { class: 'fx-cfs', 'aria-label': 'Custom field', hidden: true });
161
- let sideFor = null;
162
- function closeSide() {
163
- side.hidden = true;
164
- side.replaceChildren();
165
- sideFor = null;
166
- table.querySelector('.fx-cfm__row[data-open]')?.removeAttribute('data-open');
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 = h('button', { class: 'fx-cfs__close', type: 'button', 'aria-label': 'Close', onclick: closeSide }, lucide(h, 'x'));
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
- side.replaceChildren(form);
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);