@arcforgelabs/flow 2026.10.46 → 2026.10.47
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/VISION.md +10 -10
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/api/invoicing.js +56 -16
- package/server/auth/people.js +3 -1
- package/server/auth/routes.js +4 -1
- package/server/core/invoice-rules.js +34 -4
- package/server/core/invoices.js +40 -1
- package/server/core/invoicing-rules.js +20 -3
- package/server/core/job-board.js +1 -1
- package/server/core/sending.js +76 -22
- package/server/integrations/sentry/index.js +14 -7
- package/server/ui/alerts.js +2 -1
- package/server/ui/app.js +105 -25
- package/server/ui/bulk-bar.js +18 -5
- package/server/ui/columns.js +5 -2
- package/server/ui/custom-fields.js +22 -7
- package/server/ui/flow.css +235 -24
- package/server/ui/invoicing.js +254 -55
- package/server/ui/job-board.js +10 -0
- package/server/ui/job-change.js +4 -3
- package/server/ui/job-view.js +3 -1
- package/server/ui/jobs.js +150 -19
- package/server/ui/kit.js +198 -43
- package/server/ui/list-views.js +50 -10
- package/server/ui/mobile.js +726 -0
- package/server/ui/monitoring.js +51 -0
- package/server/ui/nav-pins.js +41 -0
- package/server/ui/pager.js +57 -10
- package/server/ui/price-list.js +1 -1
- package/server/ui/price-reconcile.js +12 -12
- package/server/ui/reconcile.js +18 -17
- package/server/ui/routes.js +1 -1
- package/server/ui/saved-views.js +2 -1
- package/server/ui/sidecar.js +106 -6
- package/server/ui/view-bar.js +22 -11
- package/server/ui/xero-import.js +48 -19
|
@@ -9,13 +9,17 @@ import { scrubSpan } from '../../ui/monitoring.js';
|
|
|
9
9
|
// the host setting SENTRY_DSN. It has no tables.
|
|
10
10
|
|
|
11
11
|
// The browser SDK, loaded from Sentry's CDN only while this adapter is on
|
|
12
|
-
// (server/ui/monitoring.js). Pinned, with its integrity hash. With
|
|
13
|
-
// the bundle
|
|
14
|
-
// it
|
|
12
|
+
// (server/ui/monitoring.js). Pinned, with its integrity hash. With replay on,
|
|
13
|
+
// the bundle with session replay; with traces on, the bundle that also traces
|
|
14
|
+
// (Sentry ships it only with replay in it).
|
|
15
15
|
export const SENTRY_BROWSER = Object.freeze({
|
|
16
16
|
src: 'https://browser.sentry-cdn.com/11.4.0/bundle.feedback.min.js',
|
|
17
17
|
integrity: 'sha384-H9RmZ/VaqOPZrUezgCSqHp4t3LoQMd/5+0Y4oEGUB2bddZzSnwZiOYoWneK4diWt',
|
|
18
18
|
});
|
|
19
|
+
export const SENTRY_BROWSER_REPLAY = Object.freeze({
|
|
20
|
+
src: 'https://browser.sentry-cdn.com/11.4.0/bundle.replay.feedback.min.js',
|
|
21
|
+
integrity: 'sha384-9Iyc17wHvpK0bqZe+cuAedQDHoWwenMrxXR+HgLLkQ3rHM4zQD+amLzBnmrQgowj',
|
|
22
|
+
});
|
|
19
23
|
export const SENTRY_BROWSER_TRACING = Object.freeze({
|
|
20
24
|
src: 'https://browser.sentry-cdn.com/11.4.0/bundle.tracing.replay.feedback.min.js',
|
|
21
25
|
integrity: 'sha384-LjwHQKAojcITl6Q7innDSAFjziFnqV9ovZBNXnXnQwub1VXIdBMJnYrQNihtxSa6',
|
|
@@ -44,6 +48,7 @@ export const SENTRY_SETUP = {
|
|
|
44
48
|
{ name: 'SENTRY_ENVIRONMENT', label: 'Environment', help: 'How Sentry labels this installation, for example production. Empty: production, or workspace in a Replit workspace.' },
|
|
45
49
|
{ 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.' },
|
|
46
50
|
{ 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.' },
|
|
51
|
+
{ name: 'SENTRY_REPLAY', label: 'Replay with reports', kind: 'onoff', help: 'On: each report and each browser error keeps up to the last minute of the page, as a replay with every word hidden.' },
|
|
47
52
|
{ 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.' },
|
|
48
53
|
],
|
|
49
54
|
};
|
|
@@ -56,6 +61,7 @@ export function readSentrySettings(env) {
|
|
|
56
61
|
// labelled apart so they never read as the live app's.
|
|
57
62
|
const environment = String(env.SENTRY_ENVIRONMENT ?? '').trim() || (isReplitWorkspace(env) ? 'workspace' : 'production');
|
|
58
63
|
const feedback = String(env.SENTRY_FEEDBACK ?? 'on').trim() !== 'off';
|
|
64
|
+
const replay = String(env.SENTRY_REPLAY ?? 'on').trim() !== 'off';
|
|
59
65
|
const rate = String(env.SENTRY_TRACES_SAMPLE_RATE ?? '').trim();
|
|
60
66
|
const tracesSampleRate = rate === '' ? DEFAULT_TRACES_SAMPLE_RATE : Number(rate);
|
|
61
67
|
if (!/^\d*\.?\d+$/.test(rate || '0') || !(tracesSampleRate >= 0 && tracesSampleRate <= 1)) {
|
|
@@ -66,7 +72,7 @@ export function readSentrySettings(env) {
|
|
|
66
72
|
// Every event says which product and, when set, which client it is from,
|
|
67
73
|
// so one support desk can serve many installations.
|
|
68
74
|
const tags = client ? { product: 'flow', client } : { product: 'flow' };
|
|
69
|
-
return { dsn, environment, feedback, tracesSampleRate, tags };
|
|
75
|
+
return { dsn, environment, feedback, replay, tracesSampleRate, tags };
|
|
70
76
|
}
|
|
71
77
|
|
|
72
78
|
// No trace for the host's health check: it is not a person waiting.
|
|
@@ -108,7 +114,7 @@ export function createSentryAdapter({ sdk = SentryNode, env: hostEnv = process.e
|
|
|
108
114
|
),
|
|
109
115
|
beforeSendSpan: scrubSpan,
|
|
110
116
|
});
|
|
111
|
-
log.info?.(`[sentry] on: environment ${settings.environment}; report button ${settings.feedback ? 'on' : 'off'}; traces ${settings.tracesSampleRate > 0 ? settings.tracesSampleRate : 'off'}`);
|
|
117
|
+
log.info?.(`[sentry] on: environment ${settings.environment}; report button ${settings.feedback ? 'on' : 'off'}; replay ${settings.replay ? 'on' : 'off'}; traces ${settings.tracesSampleRate > 0 ? settings.tracesSampleRate : 'off'}`);
|
|
112
118
|
},
|
|
113
119
|
async stop() {
|
|
114
120
|
if (settings) await sdk.close(2000);
|
|
@@ -133,15 +139,16 @@ export function createSentryAdapter({ sdk = SentryNode, env: hostEnv = process.e
|
|
|
133
139
|
// meant for browsers.
|
|
134
140
|
browserMonitoring() {
|
|
135
141
|
if (!settings) return null;
|
|
136
|
-
const { tracesSampleRate } = settings;
|
|
142
|
+
const { tracesSampleRate, replay } = settings;
|
|
137
143
|
return {
|
|
138
144
|
dsn: settings.dsn,
|
|
139
145
|
environment: settings.environment,
|
|
140
146
|
release,
|
|
141
147
|
feedback: settings.feedback,
|
|
148
|
+
replay,
|
|
142
149
|
tags: settings.tags,
|
|
143
150
|
tracesSampleRate,
|
|
144
|
-
script: tracesSampleRate > 0 ? SENTRY_BROWSER_TRACING : SENTRY_BROWSER,
|
|
151
|
+
script: tracesSampleRate > 0 ? SENTRY_BROWSER_TRACING : replay ? SENTRY_BROWSER_REPLAY : SENTRY_BROWSER,
|
|
145
152
|
};
|
|
146
153
|
},
|
|
147
154
|
// Runs `work` in a span of the current trace (server/core/tracing.js).
|
package/server/ui/alerts.js
CHANGED
|
@@ -33,8 +33,9 @@ export function alertStrip(ui, page, { onTab = () => {}, scope = null, onLoad =
|
|
|
33
33
|
let action = null;
|
|
34
34
|
if (first.action?.href && first.action.href !== location.pathname) action = h('a', { class: 'fx-alerts__action', href: first.action.href, onclick: go }, first.action.label);
|
|
35
35
|
else if (first.action?.tab) action = h('button', { class: 'fx-alerts__action', type: 'button', onclick: () => onTab(first.action.tab) }, first.action.label);
|
|
36
|
+
const line = first.moneyAtRiskCents > 0 ? `${first.title} ${money.format(first.moneyAtRiskCents / 100)} at risk.` : first.title;
|
|
36
37
|
element.replaceChildren(...[
|
|
37
|
-
h('p', { class: 'fx-alerts__text' },
|
|
38
|
+
h('p', { class: 'fx-alerts__text', title: line },
|
|
38
39
|
first.title,
|
|
39
40
|
first.moneyAtRiskCents > 0 ? h('span', { class: 'fx-alerts__money' }, ` ${money.format(first.moneyAtRiskCents / 100)} at risk.`) : null),
|
|
40
41
|
action,
|
package/server/ui/app.js
CHANGED
|
@@ -25,6 +25,7 @@ import { alertStrip } from '/assets/ui/alerts.js';
|
|
|
25
25
|
import { initialsOf, personLabel } from '/assets/ui/person.js';
|
|
26
26
|
import { startTips } from '/assets/ui/tip.js';
|
|
27
27
|
import { crumbs, dialog, edgeFade, fieldError, h, holdButton, linksOpenWith, listFrame, listHead, notice, pageHeader, rowMenu, sectionTitle, settingRow, tileCard, tileSection, toast } from '/assets/ui/kit.js';
|
|
28
|
+
import { NAV_DEFAULT, NAV_LOCKED } from '/assets/ui/nav-pins.js';
|
|
28
29
|
|
|
29
30
|
const $ = (id) => document.getElementById(id);
|
|
30
31
|
|
|
@@ -143,19 +144,20 @@ function roleSelect(value, attrs = {}) {
|
|
|
143
144
|
|
|
144
145
|
let me = null;
|
|
145
146
|
|
|
146
|
-
// Where each view sits
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
// by link
|
|
147
|
+
// Where each view sits, in sidebar order, daily work first. No `place`: a
|
|
148
|
+
// sidebar page, pinned until the person unpins it. `place: 'more'`: a sidebar
|
|
149
|
+
// page that starts in More until they pin it. `place: 'account'`: the account
|
|
150
|
+
// menu. `sidebar: false`: opened by a link or Search only. Jobs and Invoices
|
|
151
|
+
// cannot be unpinned (nav-pins.js).
|
|
150
152
|
const VIEWS = {
|
|
151
153
|
jobs: { label: 'Jobs', icon: 'jobs', render: (main) => renderJobs(main, ui()) },
|
|
152
154
|
invoices: { label: 'Invoices', icon: 'invoices', render: (main, id) => (id ? renderInvoice(main, ui(), id) : renderInvoiceList(main, ui())) },
|
|
153
155
|
reconcile: { label: 'Reconcile', icon: 'reconcile', render: (main, id) => renderReconcile(main, ui(), id) },
|
|
154
156
|
'price-list': { label: 'Price list', icon: 'tag', render: (main, id) => (id === 'reconcile' ? renderPriceReconcile(main, ui()) : renderPriceList(main, ui())) },
|
|
155
157
|
'invoicing-settings': { label: 'Rules', icon: 'rules', render: (main, id) => renderInvoicingSettings(main, ui(), id) },
|
|
156
|
-
automations: { label: 'Automations', icon: 'bolt', place: 'more',
|
|
157
|
-
'custom-fields': { label: 'Custom fields', icon: 'fields', place: 'more',
|
|
158
|
-
integrations: { label: 'Integrations', icon: 'plug', place: 'more',
|
|
158
|
+
automations: { label: 'Automations', icon: 'bolt', place: 'more', render: (main) => renderAutomations(main, ui()) },
|
|
159
|
+
'custom-fields': { label: 'Custom fields', icon: 'fields', place: 'more', render: (main) => renderCustomFields(main, ui()) },
|
|
160
|
+
integrations: { label: 'Integrations', icon: 'plug', place: 'more', ownerOnly: true, render: (main, id) => (id ? renderIntegration(main, id) : renderIntegrations(main)) },
|
|
159
161
|
people: { label: 'People', icon: 'people', place: 'account', ownerOnly: true, render: renderPeople },
|
|
160
162
|
// Not in the sidebar: "Manage account" in the account menu opens the same
|
|
161
163
|
// Clerk profile. The page stays for links and Search.
|
|
@@ -301,11 +303,7 @@ async function route() {
|
|
|
301
303
|
const [view, id] = currentView();
|
|
302
304
|
const path = id ? `/${view}/${id}` : `/${view}`;
|
|
303
305
|
if (location.pathname !== path) history.replaceState(null, '', path);
|
|
304
|
-
|
|
305
|
-
link.setAttribute('aria-current', link.dataset.nav === view ? 'page' : 'false');
|
|
306
|
-
}
|
|
307
|
-
// More shows as current while one of its pages is open.
|
|
308
|
-
document.querySelector('[data-nav="more"]')?.setAttribute('aria-current', VIEWS[view].place === 'more' ? 'page' : 'false');
|
|
306
|
+
markNavCurrent(view);
|
|
309
307
|
main.replaceChildren();
|
|
310
308
|
main.className = 'fx-main';
|
|
311
309
|
document.title = `${VIEWS[view].label} · Flow`;
|
|
@@ -359,26 +357,108 @@ function renderSignedOut() {
|
|
|
359
357
|
});
|
|
360
358
|
}
|
|
361
359
|
|
|
360
|
+
// Sidebar pages this person may open. Account pages stay in the account menu.
|
|
361
|
+
function sidebarViews() {
|
|
362
|
+
return Object.entries(VIEWS).filter(([, view]) => view.sidebar !== false && view.place !== 'account' && (!view.ownerOnly || me?.person.role === 'owner'));
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Null until the shell reads the account. Jobs and Invoices are always in it.
|
|
366
|
+
let pinned = null;
|
|
367
|
+
let pinEpoch = 0;
|
|
368
|
+
let pinWrite = Promise.resolve();
|
|
369
|
+
let morePanel = null;
|
|
370
|
+
|
|
371
|
+
function pinSet() {
|
|
372
|
+
if (!pinned) {
|
|
373
|
+
pinned = new Set(me?.views?.nav?.pinned ?? NAV_DEFAULT);
|
|
374
|
+
for (const name of NAV_LOCKED) pinned.add(name);
|
|
375
|
+
}
|
|
376
|
+
return pinned;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function navLink(name, view) {
|
|
380
|
+
return h('a', { href: `/${name}`, 'data-nav': name, 'data-label': view.label, onclick: navigate }, icon(view.icon), h('span', { class: 'fx-nav__label' }, view.label));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// More is the current page while the open page is a sidebar page that is
|
|
384
|
+
// not pinned, so it has no row of its own.
|
|
385
|
+
function markNavCurrent(view = currentView()[0]) {
|
|
386
|
+
for (const link of document.querySelectorAll('a[data-nav]')) {
|
|
387
|
+
link.setAttribute('aria-current', link.dataset.nav === view ? 'page' : 'false');
|
|
388
|
+
}
|
|
389
|
+
const shown = [...document.querySelectorAll('a[data-nav]')].some((link) => link.dataset.nav === view);
|
|
390
|
+
const inSidebar = sidebarViews().some(([name]) => name === view);
|
|
391
|
+
document.querySelector('[data-nav="more"]')?.setAttribute('aria-current', inSidebar && !shown ? 'page' : 'false');
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function syncSidebar() {
|
|
395
|
+
const nav = $('nav');
|
|
396
|
+
const moreButton = nav?.querySelector('[data-nav="more"]');
|
|
397
|
+
if (!moreButton) return;
|
|
398
|
+
for (const link of [...nav.querySelectorAll('a[data-nav]')]) link.remove();
|
|
399
|
+
const links = sidebarViews().filter(([name]) => pinSet().has(name)).map(([name, view]) => navLink(name, view));
|
|
400
|
+
if (links.length) moreButton.before(...links);
|
|
401
|
+
morePanel?.refresh?.();
|
|
402
|
+
drawMenu();
|
|
403
|
+
markNavCurrent();
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function moreItems() {
|
|
407
|
+
const pins = pinSet();
|
|
408
|
+
return sidebarViews().map(([name, view]) => {
|
|
409
|
+
const on = pins.has(name);
|
|
410
|
+
const locked = NAV_LOCKED.includes(name);
|
|
411
|
+
return {
|
|
412
|
+
icon: icon(view.icon),
|
|
413
|
+
text: view.label,
|
|
414
|
+
run: () => { closeMenu(); go(`/${name}`); },
|
|
415
|
+
trailing: locked
|
|
416
|
+
? { icon: 'pin', label: 'Pinned', pressed: true, locked: true }
|
|
417
|
+
: { icon: 'pin', label: on ? 'Unpin' : 'Pin', pressed: on, run: () => togglePin(name) },
|
|
418
|
+
};
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function togglePin(name) {
|
|
423
|
+
if (NAV_LOCKED.includes(name)) return;
|
|
424
|
+
const next = new Set(pinSet());
|
|
425
|
+
if (next.has(name)) next.delete(name);
|
|
426
|
+
else next.add(name);
|
|
427
|
+
for (const locked of NAV_LOCKED) next.add(locked);
|
|
428
|
+
const epoch = ++pinEpoch;
|
|
429
|
+
pinned = next;
|
|
430
|
+
syncSidebar();
|
|
431
|
+
const body = { pinned: [...next] };
|
|
432
|
+
pinWrite = pinWrite.then(() => savePins(body, epoch), () => savePins(body, epoch));
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function savePins(body, epoch) {
|
|
436
|
+
try {
|
|
437
|
+
const saved = await flow('/api/me/views/nav', { method: 'PUT', body: JSON.stringify(body) });
|
|
438
|
+
if (epoch !== pinEpoch) return;
|
|
439
|
+
pinned = new Set(saved.view.pinned);
|
|
440
|
+
me.views = { ...(me.views ?? {}), nav: saved.view };
|
|
441
|
+
syncSidebar();
|
|
442
|
+
} catch (error) {
|
|
443
|
+
if (epoch !== pinEpoch) return;
|
|
444
|
+
pinned = new Set(me?.views?.nav?.pinned ?? NAV_DEFAULT);
|
|
445
|
+
for (const locked of NAV_LOCKED) pinned.add(locked);
|
|
446
|
+
syncSidebar();
|
|
447
|
+
toast(error.message, 'danger');
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
362
451
|
function renderShell() {
|
|
363
452
|
document.body.dataset.state = 'signed-in';
|
|
364
453
|
const nav = $('nav');
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
for (const [name, view] of Object.entries(VIEWS)) {
|
|
368
|
-
if (view.ownerOnly && me?.person.role !== 'owner') continue;
|
|
369
|
-
if (view.sidebar === false || view.place === 'account') continue;
|
|
370
|
-
if (view.place === 'more') more.push({ icon: view.more, text: view.label, run: () => { closeMenu(); go(`/${name}`); } });
|
|
371
|
-
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)));
|
|
372
|
-
}
|
|
373
|
-
// The rest of the setup pages, behind More (the shared ⋯ menu).
|
|
374
|
-
const [moreButton, morePanel] = rowMenu('More', more, { trigger: [icon('more'), h('span', { class: 'fx-nav__label' }, 'More')], className: 'fx-nav__more', align: 'start' });
|
|
454
|
+
const [moreButton, panel] = rowMenu('More', moreItems, { trigger: [icon('more'), h('span', { class: 'fx-nav__label' }, 'More')], className: 'fx-nav__more', align: 'start', place: 'side' });
|
|
455
|
+
morePanel = panel;
|
|
375
456
|
Object.assign(moreButton.dataset, { nav: 'more', label: 'More' });
|
|
376
|
-
items.push(moreButton, morePanel);
|
|
377
457
|
// "Report a problem" sits at the foot of the menu, above the account chip,
|
|
378
458
|
// while Sentry and its report form are on (monitoring.js).
|
|
379
459
|
const report = h('button', { type: 'button', class: 'fx-nav__report', 'data-nav': 'report', 'data-label': 'Report a problem', hidden: true },
|
|
380
460
|
icon('flag'), h('span', { class: 'fx-nav__label' }, 'Report a problem'));
|
|
381
|
-
nav.replaceChildren(
|
|
461
|
+
nav.replaceChildren(moreButton, panel, report);
|
|
382
462
|
// Folded or narrow, the logo is the sidebar button: it shows the mark and
|
|
383
463
|
// turns into the sidebar icon under the pointer or keyboard focus.
|
|
384
464
|
const mark = h('span', { class: 'fx-toggle__mark', 'aria-hidden': 'true' });
|
|
@@ -387,7 +467,7 @@ function renderShell() {
|
|
|
387
467
|
sidebar.classList.add('fx-toggle__icon');
|
|
388
468
|
$('menu-toggle').replaceChildren(mark, sidebar);
|
|
389
469
|
$('menu-toggle').onclick = toggleMenu;
|
|
390
|
-
|
|
470
|
+
syncSidebar();
|
|
391
471
|
// The sidebar foot shows who is signed in: photo or initial, then the name
|
|
392
472
|
// (the email when the account has no name). It opens the account menu.
|
|
393
473
|
// People (owners) and Appearance live in the account menu.
|
package/server/ui/bulk-bar.js
CHANGED
|
@@ -9,16 +9,19 @@
|
|
|
9
9
|
// bar for a screen reader ("Ticked invoices", "Ticked jobs"). state = { count, onClear,
|
|
10
10
|
// groups: [[action]], more: [{ head, actions: [action] }] }; an action is
|
|
11
11
|
// { icon, label, title?, run, primary?, iconOnly?, tone? }. title is the
|
|
12
|
-
// long name, for the hover tip and the More menu.
|
|
13
|
-
//
|
|
12
|
+
// long name, for the hover tip and the More menu. run(working) may call
|
|
13
|
+
// working('Sending') once the step really starts (after its dialog): the
|
|
14
|
+
// pressed button then shows a spinner and that word until run ends. A count
|
|
15
|
+
// of 0 hides the bar; Esc clears the ticks while it shows.
|
|
14
16
|
import { lucide } from '/assets/ui/custom-fields.js';
|
|
17
|
+
import { applyPhoneRule } from './mobile.js';
|
|
15
18
|
|
|
16
19
|
let barCount = 0;
|
|
17
20
|
|
|
18
21
|
export function bulkBar(ui, { label = 'Ticked invoices' } = {}) {
|
|
19
22
|
const { h } = ui;
|
|
20
23
|
const id = `fx-bulk-more-${++barCount}`;
|
|
21
|
-
const element = h('div', { class: 'fx-bulk', role: 'toolbar', 'aria-label': label, hidden: true });
|
|
24
|
+
const element = applyPhoneRule(h('div', { class: 'fx-bulk', role: 'toolbar', 'aria-label': label, hidden: true }), 'bulkBar');
|
|
22
25
|
const panel = h('div', { class: 'fx-pop fx-bulk__more', id, popover: 'auto', role: 'menu' });
|
|
23
26
|
let current = null;
|
|
24
27
|
let busy = false;
|
|
@@ -27,14 +30,24 @@ export function bulkBar(ui, { label = 'Ticked invoices' } = {}) {
|
|
|
27
30
|
if (busy) return;
|
|
28
31
|
busy = true;
|
|
29
32
|
element.setAttribute('aria-busy', 'true');
|
|
30
|
-
|
|
33
|
+
// The step is on its way: its button (on the bar, or More's) spins.
|
|
34
|
+
const working = (word) => {
|
|
35
|
+
const pressed = [...element.querySelectorAll('.fx-bulk__act')].find((b) => b.dataset.action === action.label);
|
|
36
|
+
if (!pressed) return;
|
|
37
|
+
pressed.dataset.busy = '';
|
|
38
|
+
pressed.firstChild?.replaceWith(h('span', { class: 'fx-spinner fx-spinner--sm', 'aria-hidden': 'true' }));
|
|
39
|
+
const text = pressed.querySelector('.fx-bulk__word');
|
|
40
|
+
if (text && word) text.textContent = `${word}…`;
|
|
41
|
+
};
|
|
42
|
+
try { await action.run(working); } finally {
|
|
31
43
|
busy = false;
|
|
32
44
|
element.removeAttribute('aria-busy');
|
|
45
|
+
if (current) draw(current);
|
|
33
46
|
}
|
|
34
47
|
};
|
|
35
48
|
|
|
36
49
|
const button = (action) => h('button', {
|
|
37
|
-
type: 'button',
|
|
50
|
+
type: 'button', 'data-action': action.label,
|
|
38
51
|
class: `fx-bulk__act${action.primary ? ' fx-bulk__act--primary' : ''}${action.iconOnly ? ' fx-bulk__act--icon' : ''}`,
|
|
39
52
|
...(action.tone ? { 'data-tone': action.tone } : {}),
|
|
40
53
|
...(action.iconOnly ? { 'aria-label': action.title ?? action.label, 'data-tip': action.title ?? action.label } : action.title ? { title: action.title } : {}),
|
package/server/ui/columns.js
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
// Jobs and on Invoices, where it is the invoice's job's value).
|
|
32
32
|
import { lucide } from '/assets/ui/custom-fields.js';
|
|
33
33
|
import { endWidth, LEAD_WIDTH, MAX_WIDTH, minWidth, moveColumn, TAIL_WIDTH, tableWidth, widthOf } from '/assets/ui/job-view.js';
|
|
34
|
+
import { applyPhoneRule, assignPhoneSlots, phoneSlot } from './mobile.js';
|
|
34
35
|
import { initialsOf } from '/assets/ui/person.js';
|
|
35
36
|
|
|
36
37
|
const fullDate = (iso) => new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' });
|
|
@@ -81,10 +82,12 @@ export function fieldColumn(h, field, { id, valueOf, people }) {
|
|
|
81
82
|
// A row's cells for the shown columns: a class per kind, the label for
|
|
82
83
|
// the phone layout, the full text as the cell's name on hover.
|
|
83
84
|
export function rowCells(h, columns, row) {
|
|
84
|
-
return columns.map((c) => {
|
|
85
|
+
return assignPhoneSlots(columns).map((c) => {
|
|
86
|
+
const slot = phoneSlot(c);
|
|
85
87
|
const td = h('td', {
|
|
86
88
|
class: [`fx-c-${c.kind}`, c.num ? 'fx-num' : null, c.className].filter(Boolean).join(' '),
|
|
87
89
|
'data-label': c.label,
|
|
90
|
+
...(slot ? { 'data-phone-slot': slot } : {}),
|
|
88
91
|
}, c.cell(row));
|
|
89
92
|
// A cell stays on one line and ends in "…" when the column is narrow
|
|
90
93
|
// (flow.css, .fx-table--sized); the whole text shows on hover.
|
|
@@ -173,7 +176,7 @@ export function columnTable(h, config) {
|
|
|
173
176
|
h('colgroup', {}, leadCols, cols, endCol),
|
|
174
177
|
h('thead', {}, h('tr', {}, lead, heads, tail)),
|
|
175
178
|
rows);
|
|
176
|
-
const box = h('div', { class: 'fx-table-scroll' }, node);
|
|
179
|
+
const box = applyPhoneRule(h('div', { class: 'fx-table-scroll' }, node), 'columnTable');
|
|
177
180
|
// The width the columns need; past the window the table scrolls
|
|
178
181
|
// sideways. A browser shares spare width among fixed columns, so the
|
|
179
182
|
// end column is sized here, and again when the window changes size.
|
|
@@ -15,6 +15,21 @@
|
|
|
15
15
|
|
|
16
16
|
import { orderStatuses, statusRank } from '/assets/ui/job-board.js';
|
|
17
17
|
import { sidecar } from '/assets/ui/sidecar.js';
|
|
18
|
+
import { assignPhoneSlots, phoneSlot } from './mobile.js';
|
|
19
|
+
|
|
20
|
+
// The manager row uses the same phone slots as a column list.
|
|
21
|
+
const FIELD_SLOTS = assignPhoneSlots([
|
|
22
|
+
{ id: 'name', kind: 'text' },
|
|
23
|
+
{ id: 'type', kind: 'text' },
|
|
24
|
+
{ id: 'by', kind: 'person' },
|
|
25
|
+
{ id: 'date', kind: 'date' },
|
|
26
|
+
{ id: 'where', kind: 'text' },
|
|
27
|
+
{ id: 'menu', kind: 'text', phone: 'end' },
|
|
28
|
+
]);
|
|
29
|
+
const fieldSlot = (id) => {
|
|
30
|
+
const slot = phoneSlot(FIELD_SLOTS.find((column) => column.id === id));
|
|
31
|
+
return slot ? { 'data-phone-slot': slot } : {};
|
|
32
|
+
};
|
|
18
33
|
|
|
19
34
|
const DOCS = 'https://github.com/arcforgelabs/flow/blob/main/docs/CUSTOM_FIELDS.md';
|
|
20
35
|
const TYPE_LABELS = {
|
|
@@ -395,12 +410,12 @@ export async function renderCustomFields(main, ui) {
|
|
|
395
410
|
});
|
|
396
411
|
input.addEventListener('blur', () => { if (view.renaming === field.id) rename(field, input.value); });
|
|
397
412
|
queueMicrotask(() => { input.focus(); input.select(); });
|
|
398
|
-
return h('div', { class: 'fx-cfm__name', role: 'cell' }, input);
|
|
413
|
+
return h('div', { class: 'fx-cfm__name', role: 'cell', ...fieldSlot('name') }, input);
|
|
399
414
|
}
|
|
400
415
|
const editButton = field.archived || disabled || field.source ? null
|
|
401
416
|
: h('button', { class: 'fx-cfm__edit', type: 'button', onclick: () => edit(field) }, 'Edit', lucide(h, 'panel-right'));
|
|
402
417
|
const viewButton = field.source ? h('button', { class: 'fx-cfm__edit', type: 'button', onclick: () => edit(field) }, 'View', lucide(h, 'panel-right')) : null;
|
|
403
|
-
return h('div', { class: 'fx-cfm__name', role: 'cell' },
|
|
418
|
+
return h('div', { class: 'fx-cfm__name', role: 'cell', ...fieldSlot('name') },
|
|
404
419
|
h('div', { class: 'fx-cfm__title' },
|
|
405
420
|
field.source ? lucide(h, 'lock', 'Read only') : null,
|
|
406
421
|
h('span', { class: 'fx-cfm__label', title: field.name }, field.name), editButton, viewButton));
|
|
@@ -418,11 +433,11 @@ export async function renderCustomFields(main, ui) {
|
|
|
418
433
|
});
|
|
419
434
|
return h('div', { class: 'fx-cfm__row', role: 'row', 'data-id': field.id, 'data-open': sideFor === field.id ? 'true' : null, 'data-archived': field.archived ? 'true' : null },
|
|
420
435
|
nameCell(field),
|
|
421
|
-
h('div', { class: 'fx-cfm__type', role: 'cell' }, picker ? [picker.button, picker.panel] : h('span', { class: 'fx-cfm__icon', 'data-color': color }, icon)),
|
|
422
|
-
h('div', { class: 'fx-cfm__by', role: 'cell' }, by ? h('span', { class: 'fx-cfm__avatar', title: by }, field.source ? lucide(h, 'plug') : initials(by)) : h('span', { class: 'fx-dim' }, '—')),
|
|
423
|
-
h('div', { class: 'fx-cfm__date', role: 'cell', title: field.source ? `${field.sourceLabel} connected` : null }, field.createdAt ? createdDay(field.createdAt) : h('span', { class: 'fx-dim' }, '—')),
|
|
424
|
-
h('div', { class: 'fx-cfm__where', role: 'cell' }, field.source ? `${field.sourceLabel} jobs` : 'Jobs'),
|
|
425
|
-
h('div', { class: 'fx-cfm__end', role: 'cell' }, field.source ? null : rowMenu(field, picker)));
|
|
436
|
+
h('div', { class: 'fx-cfm__type', role: 'cell', ...fieldSlot('type') }, picker ? [picker.button, picker.panel] : h('span', { class: 'fx-cfm__icon', 'data-color': color }, icon)),
|
|
437
|
+
h('div', { class: 'fx-cfm__by', role: 'cell', ...fieldSlot('by') }, by ? h('span', { class: 'fx-cfm__avatar', title: by }, field.source ? lucide(h, 'plug') : initials(by)) : h('span', { class: 'fx-dim' }, '—')),
|
|
438
|
+
h('div', { class: 'fx-cfm__date', role: 'cell', ...fieldSlot('date'), title: field.source ? `${field.sourceLabel} connected` : null }, field.createdAt ? createdDay(field.createdAt) : h('span', { class: 'fx-dim' }, '—')),
|
|
439
|
+
h('div', { class: 'fx-cfm__where', role: 'cell', ...fieldSlot('where') }, field.source ? `${field.sourceLabel} jobs` : 'Jobs'),
|
|
440
|
+
h('div', { class: 'fx-cfm__end', role: 'cell', ...fieldSlot('menu') }, field.source ? null : rowMenu(field, picker)));
|
|
426
441
|
}
|
|
427
442
|
|
|
428
443
|
// One group: a header that folds it, its rows, and "Create … field". A
|