@arcforgelabs/flow 2026.10.43 → 2026.10.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,279 @@
1
+ // The shared pieces every Flow page builds with: elements (h), the page
2
+ // header, notes, toasts, dialogs, setting rows, the ⋯ row menu, the button
3
+ // that waits between presses, cards that open a page, and the crumbs trail. app.js hands them to each view module in ui(),
4
+ // and the design system's cards (scripts/design-system/) render these same
5
+ // functions, so a card is the real thing. Reuse one or give it an option;
6
+ // never copy it into a page (AGENTS.md, "UI: reuse first").
7
+ import { lucide } from '/assets/ui/custom-fields.js';
8
+ import { named } from '/assets/ui/tip.js';
9
+
10
+ // h('p', { class: 'x', onclick }, 'text', child) — children are text or nodes.
11
+ export function h(tag, attrs = {}, ...children) {
12
+ const node = document.createElement(tag);
13
+ for (const [key, value] of Object.entries(attrs ?? {})) {
14
+ if (value == null || value === false) continue;
15
+ if (key.startsWith('on')) node.addEventListener(key.slice(2), value);
16
+ else if (key === 'style' && typeof value === 'object') Object.assign(node.style, value);
17
+ else node.setAttribute(key, value === true ? '' : String(value));
18
+ }
19
+ for (const child of children.flat()) {
20
+ if (child == null || child === false) continue;
21
+ node.append(child instanceof Node ? child : document.createTextNode(String(child)));
22
+ }
23
+ return node;
24
+ }
25
+
26
+ // Keeps a button off for `ms`, with the time left on it, then puts it back.
27
+ export function holdButton(button, ms) {
28
+ if (!button || !(ms > 0)) return;
29
+ clearInterval(button.fxHold);
30
+ const label = button.dataset.label ?? button.textContent;
31
+ button.dataset.label = label;
32
+ const until = Date.now() + ms;
33
+ const draw = () => {
34
+ const left = Math.ceil((until - Date.now()) / 1000);
35
+ if (left <= 0) {
36
+ clearInterval(button.fxHold);
37
+ button.textContent = label;
38
+ delete button.dataset.label;
39
+ button.removeAttribute('title');
40
+ button.disabled = false;
41
+ return;
42
+ }
43
+ const time = left < 60 ? `${left}s` : `${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`;
44
+ button.disabled = true;
45
+ button.textContent = `${label} · ${time}`;
46
+ button.title = `Flow waits before it asks again, to protect the connected system's limits. Ready in ${time}.`;
47
+ };
48
+ draw();
49
+ button.fxHold = setInterval(draw, 1000);
50
+ }
51
+
52
+ // The page's name and nothing under it: the page shows what it is for.
53
+ // `count` is a live number beside the name (2 to check); `help` is a docs
54
+ // link, an icon with its name as a tip.
55
+ export function pageHeader(title, { count = null, help = null, actions = [] } = {}) {
56
+ return h('header', { class: 'fx-ph' },
57
+ h('div', { class: 'fx-ph__text' },
58
+ h('h1', { class: 'fx-title' }, title),
59
+ count != null ? h('span', { class: 'fx-ph__count' }, count) : null,
60
+ help ? helpLink(help) : null),
61
+ actions.length ? h('div', { class: 'fx-ph__actions' }, actions) : null);
62
+ }
63
+
64
+ // The top of a list page (Jobs, Invoices, Reconcile), laid out the same on
65
+ // every list and in Board and List alike, so nothing moves when a person
66
+ // switches: the title row, a row for saved view tabs (`views`: the page's
67
+ // savedViews element, else an empty slot that stays hidden), then one toolbar with `start` at the left (tabs, Group,
68
+ // Sort, Filter) and `end` at the right (search, Board/List), then the page's
69
+ // `actions` (New invoice) last, as Claude's own lists do. `line` draws the
70
+ // rule that tabs sit on; `sticky` keeps the toolbar at the top while the rows
71
+ // scroll up under it, with a shadow once they do. Append `nodes` to the page.
72
+ export function listHead(title, { count = null, help = null, actions = [], start = [], end = [], line = false, sticky = false, views: tabs = null } = {}) {
73
+ const views = tabs ?? h('div', { class: 'fx-views', role: 'group', 'aria-label': 'Views', hidden: true });
74
+ const bar = h('div', { class: 'fx-listhead', 'data-sticky': sticky },
75
+ h('div', { class: `fx-toolbar${line ? '' : ' fx-toolbar--plain'}` }, start, h('div', { class: 'fx-toolbar__end' }, end, actions)));
76
+ const edge = sticky ? h('div', { class: 'fx-listhead__edge', 'aria-hidden': 'true' }) : null;
77
+ if (edge && typeof IntersectionObserver === 'function') {
78
+ const top = window.matchMedia?.('(max-width: 820px)').matches ? 61 : 1;
79
+ new IntersectionObserver(([entry]) => bar.toggleAttribute('data-stuck', !entry.isIntersecting), { rootMargin: `-${top}px 0px 0px 0px` }).observe(edge);
80
+ }
81
+ return { nodes: [pageHeader(title, { count, help }), views, edge, bar].filter(Boolean), views, bar };
82
+ }
83
+
84
+ // The window a list or board sits in under listHead, as HubSpot's: on a
85
+ // desktop it fills the page to the bottom and scrolls inside itself both
86
+ // ways, so nothing runs off the page, with a foot bar for the count and its
87
+ // tools. It makes `main` the window's height (fx-main--frame). Rows fade
88
+ // under an edge they scroll past (edgeFade); the sticky table head is the
89
+ // top edge's own break.
90
+ export function listFrame(main, content) {
91
+ main.classList.add('fx-main--frame');
92
+ const count = h('span', { class: 'fx-frame__count', role: 'status' });
93
+ const tools = h('div', { class: 'fx-frame__tools' });
94
+ const body = h('div', { class: 'fx-frame__body' }, content);
95
+ const view = h('div', { class: 'fx-frame__view' }, body);
96
+ const element = h('section', { class: 'fx-frame' }, view, h('footer', { class: 'fx-frame__foot' }, count, tools));
97
+ edgeFade(body, { target: view, edges: ['bottom', 'left', 'right'] });
98
+ return { element, body, view, tools, count: (text) => { count.textContent = text ?? ''; } };
99
+ }
100
+
101
+ // A soft fade at each edge of `target` that `scroller` has more content
102
+ // past, so rows and cards fade under it instead of vanishing. No fade on
103
+ // an edge a scrollbar already marks (the bottom under a sideways bar, the
104
+ // right beside a down bar). `target` holds the fades and must not scroll.
105
+ const fading = typeof ResizeObserver === 'function'
106
+ ? new ResizeObserver((entries) => entries.forEach(({ target }) => (target.isConnected ? target.fxFade?.() : fading.unobserve(target))))
107
+ : null;
108
+ export function edgeFade(scroller, { target = scroller.parentElement, edges = ['top', 'bottom', 'left', 'right'] } = {}) {
109
+ target.classList.add('fx-fades');
110
+ for (const edge of edges) {
111
+ if (!target.querySelector(`:scope > .fx-fade[data-edge="${edge}"]`)) target.append(h('span', { class: 'fx-fade', 'data-edge': edge, 'aria-hidden': 'true' }));
112
+ }
113
+ const update = () => {
114
+ const { scrollTop, scrollLeft, scrollHeight, scrollWidth, clientHeight, clientWidth, offsetHeight, offsetWidth } = scroller;
115
+ const more = {
116
+ top: scrollTop > 1,
117
+ bottom: scrollTop + clientHeight < scrollHeight - 1 && offsetHeight - clientHeight === 0,
118
+ left: scrollLeft > 1,
119
+ right: scrollLeft + clientWidth < scrollWidth - 1 && offsetWidth - clientWidth === 0,
120
+ };
121
+ // Several scrollers can share a target (the board in the frame): it
122
+ // fades each edge any of them has more past.
123
+ target.fxFades ??= new Map();
124
+ target.fxFades.set(scroller, edges.filter((edge) => more[edge]));
125
+ for (const other of target.fxFades.keys()) if (!other.isConnected) target.fxFades.delete(other);
126
+ target.dataset.fade = [...new Set([...target.fxFades.values()].flat())].join(' ');
127
+ };
128
+ scroller.fxFade = update;
129
+ scroller.addEventListener('scroll', update, { passive: true });
130
+ fading?.observe(scroller);
131
+ requestAnimationFrame(update);
132
+ return update;
133
+ }
134
+
135
+ // A docs link as a small "?" icon whose tip names it (How sending works).
136
+ export function helpLink({ href, label }) {
137
+ return named(h('a', { class: 'fx-help', href, target: '_blank', rel: 'noopener' }, lucide(h, 'circle-help')), label);
138
+ }
139
+
140
+ // A settings section's heading, with its docs link beside it.
141
+ export function sectionTitle(text, help = null) {
142
+ return h('h2', { class: 'fx-set__title' }, text, help ? helpLink(help) : null);
143
+ }
144
+
145
+ export function notice(text, tone = 'info', title = null) {
146
+ return h('div', { class: 'fx-notice', 'data-tone': tone, role: tone === 'danger' ? 'alert' : 'status' },
147
+ title ? h('strong', {}, title) : null,
148
+ h('span', {}, text));
149
+ }
150
+
151
+ // A field that fails a check glows red, like a required field, until the
152
+ // person types in it; the next check marks it again if it still fails.
153
+ // `on` false clears it. Answers the field.
154
+ export function fieldError(field, on = true) {
155
+ if (!on) { field.removeAttribute('aria-invalid'); return field; }
156
+ field.setAttribute('aria-invalid', 'true');
157
+ field.addEventListener('input', () => field.removeAttribute('aria-invalid'), { once: true });
158
+ return field;
159
+ }
160
+
161
+ export function toast(text, tone = 'info') {
162
+ const region = document.getElementById('toasts');
163
+ const item = h('div', { class: 'fx-toast', 'data-tone': tone, role: 'status' }, text);
164
+ region.append(item);
165
+ setTimeout(() => item.remove(), 4200);
166
+ }
167
+
168
+ // A modal built on <dialog>: resolves with the button's value, or null.
169
+ export function dialog({ title, body, confirm, tone = 'primary' }) {
170
+ return new Promise((resolve) => {
171
+ const form = h('form', { method: 'dialog', class: 'fx-dialog__form' },
172
+ h('h2', { class: 'fx-dialog__title' }, title),
173
+ body,
174
+ h('div', { class: 'fx-dialog__actions' },
175
+ h('button', { class: 'fx-btn fx-btn--ghost', value: 'cancel', type: 'submit', formnovalidate: true }, 'Cancel'),
176
+ h('button', { class: `fx-btn fx-btn--${tone}`, value: 'ok', type: 'submit' }, confirm)));
177
+ const element = h('dialog', { class: 'fx-dialog' }, form);
178
+ element.addEventListener('close', () => {
179
+ const ok = element.returnValue === 'ok';
180
+ const data = ok ? new FormData(form) : null;
181
+ element.remove();
182
+ resolve(ok ? data : null);
183
+ });
184
+ document.body.append(element);
185
+ element.showModal();
186
+ element.querySelector('input, select, button[value="ok"]')?.focus();
187
+ });
188
+ }
189
+
190
+ // One setting, laid out the same on every settings screen: a short name, one
191
+ // line of smaller text under it, and the control on the right. Longer help
192
+ // lives behind a link, not here. A wide control (a grid of choices) sits on
193
+ // its own line under the name. `tone: 'danger'` marks a row that needs a person.
194
+ export function settingRow({ label, hint, control, wide = false, tone = null }) {
195
+ const target = control?.matches?.('input, select, textarea') ? control : control?.querySelector?.('input:not([type=color]), select, textarea');
196
+ const id = target ? (target.id ||= `fx-set-${++settingRow.next}`) : null;
197
+ return h('div', { class: `fx-setting${wide ? ' fx-setting--wide' : ''}`, 'data-tone': tone },
198
+ h('div', { class: 'fx-setting__text' },
199
+ id ? h('label', { class: 'fx-setting__name', for: id }, label) : h('span', { class: 'fx-setting__name' }, label),
200
+ hint ? h('p', { class: 'fx-setting__hint' }, hint) : null),
201
+ h('div', { class: 'fx-setting__control' }, control));
202
+ }
203
+ settingRow.next = 0;
204
+
205
+ // The steps of a Connect, each ticked, running or crossed, with the time taken.
206
+
207
+ let rowMenus = 0;
208
+
209
+ // A row's ⋯ menu, as on the Custom fields page. The menu lines up with the
210
+ // button's right edge; `align: 'start'` lines it up with its left edge.
211
+ // `trigger` swaps the ⋯ icon for the given contents (the sidebar's More).
212
+ export function rowMenu(label, items, { trigger = null, className = 'fx-cfm__more', align = 'end' } = {}) {
213
+ const id = `fx-row-menu-${++rowMenus}`;
214
+ const button = h('button', { class: className, type: 'button', 'aria-label': label, popovertarget: id, 'aria-haspopup': 'menu', 'aria-expanded': 'false' }, trigger ?? lucide(h, 'ellipsis'));
215
+ const panel = h('div', { class: 'fx-pop', id, popover: 'auto', role: 'menu' });
216
+ panel.addEventListener('toggle', (event) => {
217
+ button.setAttribute('aria-expanded', String(event.newState === 'open'));
218
+ if (event.newState !== 'open') return;
219
+ const close = () => panel.hidePopover();
220
+ panel.replaceChildren(...items.map((entry) => (entry === '-'
221
+ ? h('div', { class: 'fx-pop__sep' })
222
+ : h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitem', 'data-tone': entry.tone, onclick: () => { close(); entry.run(); } },
223
+ h('span', { class: 'fx-pop__tick' }, lucide(h, entry.icon)), h('span', {}, entry.text)))));
224
+ const at = button.getBoundingClientRect();
225
+ panel.style.top = `${Math.round(at.bottom + 6)}px`;
226
+ const left = align === 'start' ? at.left : at.right - panel.offsetWidth;
227
+ panel.style.left = `${Math.round(Math.max(8, Math.min(left, innerWidth - panel.offsetWidth - 8)))}px`;
228
+ });
229
+ return [button, panel];
230
+ }
231
+
232
+ // Where a link in a shared piece opens: app.js passes its own `go`, so a click
233
+ // opens the page in place (a new tab still works). Without it, as in the
234
+ // design system, a link is a plain link.
235
+ let openPath = null;
236
+ export function linksOpenWith(go) { openPath = go; }
237
+ function inPlace(event) {
238
+ if (!openPath || event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return;
239
+ event.preventDefault();
240
+ openPath(event.currentTarget.getAttribute('href'));
241
+ }
242
+
243
+ // A card that opens a page. Large (Integrations): its mark (a logo node, or a
244
+ // Lucide icon name), the name, a status pill, two lines at most (only when
245
+ // they help), and the action. `row` is the one-line card of a tileSection,
246
+ // like Claude's Customize categories: the mark at the left, the name, and a
247
+ // short dim `meta` value or the status pill at the right.
248
+ export function tileCard({ href, mark, title, status, blurb, meta, action = 'Manage', row = false }) {
249
+ const logo = typeof mark === 'string' ? h('span', { class: 'fx-logo fx-logo--icon', 'aria-hidden': 'true' }, lucide(h, mark)) : mark;
250
+ const pill = status ? h('span', { class: 'fx-status', 'data-tone': status.tone ?? 'mute' }, status.label) : null;
251
+ if (row) {
252
+ return h('a', { class: 'fx-tile', href, onclick: inPlace },
253
+ logo,
254
+ h('h3', {}, title),
255
+ pill ?? (meta ? h('span', { class: 'fx-tile__meta' }, meta) : null));
256
+ }
257
+ return h('a', { class: `fx-int-card${blurb ? '' : ' fx-int-card--bare'}`, href, onclick: inPlace },
258
+ h('header', { class: 'fx-int-card__head' }, logo, h('h3', {}, title), pill),
259
+ blurb ? h('p', { class: 'fx-int-card__blurb' }, blurb) : null,
260
+ h('span', { class: 'fx-int-card__btn' }, action));
261
+ }
262
+
263
+ // A titled group of one-line cards, three columns where they fit, with "Show all" at the right
264
+ // when `href` is given (Claude's Customize page).
265
+ export function tileSection(title, cards, { href, more = 'Show all' } = {}) {
266
+ return h('section', { class: 'fx-tiles' },
267
+ h('header', { class: 'fx-tiles__head' },
268
+ h('h2', {}, title),
269
+ href ? h('a', { class: 'fx-tiles__more', href, onclick: inPlace }, more, lucide(h, 'chevron-right')) : null),
270
+ h('div', { class: 'fx-tiles__grid' }, cards));
271
+ }
272
+
273
+ // The trail above a page one level down: crumbs(['/integrations', 'Integrations'], 'Xero').
274
+ export function crumbs(...trail) {
275
+ const here = trail.pop();
276
+ return h('nav', { class: 'fx-crumbs', 'aria-label': 'Breadcrumb' },
277
+ ...trail.flatMap(([href, label]) => [h('a', { href, onclick: inPlace }, label), h('span', { 'aria-hidden': 'true' }, '›')]),
278
+ h('span', {}, here));
279
+ }
@@ -0,0 +1,115 @@
1
+ // A person's saved views of a list page (Jobs, Invoices, Reconcile), as
2
+ // ClickUp's view tabs: each has a name and the page's view (Group, Sort,
3
+ // Filter, Columns), and one can be the person's default, which opens when
4
+ // they go to the page. No DOM: the server checks a saved list with the same
5
+ // rules the page uses (server/auth/people.js).
6
+ import { cleanFilterView, filterJobs, normaliseJobsView } from './job-view.js';
7
+
8
+ // The Invoices filter's fields (invoicing.js). The tabs are the status, so
9
+ // Status is not here. The page shows the chip (view-bar.js); the server keeps
10
+ // the rows (GET /api/invoicing/invoices), so a filter reaches every invoice
11
+ // of a tab.
12
+ export const INVOICE_FILTER_FIELDS = {
13
+ client: { label: 'Client', kind: 'choice', icon: 'building-2' },
14
+ number: { label: 'Number', kind: 'text', icon: 'hash' },
15
+ reference: { label: 'Reference', kind: 'text', icon: 'text' },
16
+ date: { label: 'Date', kind: 'date', icon: 'calendar' },
17
+ total: { label: 'Total', kind: 'number', icon: 'dollar-sign' },
18
+ problem: { label: 'Problem', kind: 'check', icon: 'triangle-alert' },
19
+ };
20
+
21
+ export const invoiceFilterKind = (field) => INVOICE_FILTER_FIELDS[field]?.kind ?? null;
22
+
23
+ // An invoice's value for a filter field or a sort, as they compare it. A
24
+ // draft has no issue date: it uses the day it was made.
25
+ export function invoiceValue(invoice, field) {
26
+ switch (field) {
27
+ case 'client': return invoice.contactName ?? null;
28
+ case 'number': return invoice.number ?? null;
29
+ case 'reference': return invoice.reference ?? null;
30
+ case 'date': return invoice.issueDate ?? (invoice.createdAt ? new Date(invoice.createdAt).toISOString().slice(0, 10) : null);
31
+ case 'total': return invoice.totalCents == null ? null : invoice.totalCents / 100;
32
+ case 'problem': return (invoice.problems?.length ?? 0) > 0;
33
+ default: return null;
34
+ }
35
+ }
36
+
37
+ // What the Invoices list sorts by (a column head). No sort is newest first.
38
+ export const INVOICE_SORTS = Object.freeze(['number', 'client', 'reference', 'date', 'total']);
39
+
40
+ // One page of an invoice list: the Filter view keeps rows, the sort orders
41
+ // them (an empty value last, either way), then `offset` and `limit` cut the
42
+ // page. `total` and `totalCents` are of every kept row. The rows come in
43
+ // newest first; that order breaks ties.
44
+ export function invoicePage(invoices, { view, sort = null, dir = 'asc', offset = 0, limit = 100, today }) {
45
+ const kept = filterJobs(invoices, view, { valueOf: invoiceValue, kindOf: invoiceFilterKind, today });
46
+ const sign = dir === 'desc' ? -1 : 1;
47
+ const ordered = INVOICE_SORTS.includes(sort)
48
+ ? kept.map((invoice, i) => [invoice, invoiceValue(invoice, sort), i]).sort(([, x, i], [, y, j]) => {
49
+ if (x == null || y == null) return x == null && y == null ? i - j : (x == null) - (y == null);
50
+ const order = typeof x === 'number' ? x - y : String(x).localeCompare(String(y), 'en', { numeric: true });
51
+ return order ? order * sign : i - j;
52
+ }).map(([invoice]) => invoice)
53
+ : kept;
54
+ return {
55
+ rows: ordered.slice(offset, offset + limit),
56
+ total: kept.length,
57
+ totalCents: kept.reduce((sum, i) => sum + (i.totalCents ?? 0), 0),
58
+ };
59
+ }
60
+
61
+ // The Reconcile filter's fields (reconcile.js). Reconcile lists jobs and
62
+ // invoices, so it has both lists' fields; a job's own field (Practitioner,
63
+ // a custom field) skips invoice rows, so it never hides them.
64
+ export const RECONCILE_FILTER_FIELDS = {
65
+ type: { label: 'Type', kind: 'choice', icon: 'layers' },
66
+ status: { label: 'Status', kind: 'choice', icon: 'circle-dot' },
67
+ client: { label: 'Client', kind: 'choice', icon: 'building-2' },
68
+ practitioner: { label: 'Practitioner', kind: 'choice', icon: 'stethoscope', job: true },
69
+ date: { label: 'Date', kind: 'date', icon: 'calendar' },
70
+ total: { label: 'Total', kind: 'number', icon: 'dollar-sign' },
71
+ match: { label: 'Match', kind: 'check', icon: 'link' },
72
+ };
73
+
74
+ const CUSTOM_FIELD = /^field:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
75
+ const kindIn = (fields, custom) => (field) => (Object.hasOwn(fields, field) ? fields[field].kind : custom && CUSTOM_FIELD.test(field) ? '*' : null);
76
+
77
+ // The Invoices and Reconcile views: their filter.
78
+ export const normaliseInvoicesView = (value) => cleanFilterView(value, kindIn(INVOICE_FILTER_FIELDS, false));
79
+ export const normaliseReconcileView = (value) => cleanFilterView(value, kindIn(RECONCILE_FILTER_FIELDS, true));
80
+
81
+ // The pages with saved views. Each is named in a switch, never looked up
82
+ // by the name a request sends, so a name such as "constructor" is refused.
83
+ export const SAVED_VIEW_PAGES = Object.freeze(['jobs', 'invoices', 'reconcile']);
84
+ export function pageViewCheck(page) {
85
+ switch (page) {
86
+ case 'jobs': return normaliseJobsView;
87
+ case 'invoices': return normaliseInvoicesView;
88
+ case 'reconcile': return normaliseReconcileView;
89
+ default: return null;
90
+ }
91
+ }
92
+
93
+ export const MAX_SAVED_VIEWS = 20;
94
+ const MAX_NAME = 60;
95
+ const VIEW_ID = /^[a-z0-9-]{1,40}$/i;
96
+
97
+ // A page's saved views as Flow keeps them: { list: [{ id, name, view }],
98
+ // default }. Bad entries are dropped; a default that names no view is none.
99
+ export function normaliseSavedViews(page, input) {
100
+ const check = pageViewCheck(page);
101
+ const value = input && typeof input === 'object' ? input : {};
102
+ const seen = new Set();
103
+ const list = check && Array.isArray(value.list) ? value.list : [];
104
+ const kept = list.filter((v) => v && typeof v === 'object' && typeof v.id === 'string' && VIEW_ID.test(v.id) && !seen.has(v.id) && seen.add(v.id))
105
+ .map((v) => ({ id: v.id, name: String(v.name ?? '').trim().slice(0, MAX_NAME), view: check(v.view) }))
106
+ .filter((v) => v.name.length > 0)
107
+ .slice(0, MAX_SAVED_VIEWS);
108
+ return { list: kept, default: kept.some((v) => v.id === value.default) ? value.default : null };
109
+ }
110
+
111
+ // Do two views of a page show the same thing?
112
+ export function sameView(page, a, b) {
113
+ const check = pageViewCheck(page);
114
+ return Boolean(check) && JSON.stringify(check(a)) === JSON.stringify(check(b));
115
+ }
@@ -17,15 +17,46 @@ function loadScript({ src, integrity }) {
17
17
  });
18
18
  }
19
19
 
20
- // "Notify me": the form's email field (Sentry's own words) starts with the
21
- // signed-in person's email. A report sent with an address asks to hear back;
22
- // Flow tags it notify=yes, so the support desk sees it on the task. A person
23
- // who clears the field sends notify=no.
24
- // Sentry's form has no tick box of its own; this keeps to its stock options.
25
- export function tagNotify(event) {
20
+ // "Request a response": a tick box under the form's email field, off by
21
+ // default. The email field (Sentry's own words) starts with the signed-in
22
+ // person's email, but an address alone does not ask for a reply. A report
23
+ // sent with the box ticked and an address is tagged notify=yes, so the
24
+ // support desk replies (fixed, planned or won't fix). Every other report
25
+ // sends notify=no. Sentry's form has no tick box of its own (SDK 11.4.0),
26
+ // so Flow adds one to it (addResponseBox).
27
+ export function tagNotify(event, wantsResponse = false) {
26
28
  if (event?.type !== 'feedback') return;
27
29
  const email = String(event.contexts?.feedback?.contact_email ?? '').trim();
28
- event.tags = { ...event.tags, notify: email ? 'yes' : 'no' };
30
+ event.tags = { ...event.tags, notify: wantsResponse && email ? 'yes' : 'no' };
31
+ }
32
+
33
+ export const RESPONSE_LABEL = 'Request a response';
34
+
35
+ // The tick box goes under the email field inside the form's shadow root, so
36
+ // flow.css cannot reach it: its look comes from the form's own variables,
37
+ // which flow.css sets from the --t-* tokens (#sentry-feedback). Ticked, the
38
+ // email is required. No email field (or no form yet): no box.
39
+ const RESPONSE_STYLE = `
40
+ .form__response { display: flex; flex-direction: row; align-items: center; gap: 8px; margin: 0; cursor: pointer; }
41
+ .form__response input { margin: 0; width: 16px; height: 16px; accent-color: var(--accent-background); cursor: pointer; }`;
42
+ function addResponseBox(root, onChange) {
43
+ const email = root?.querySelector('input[name="email"]:not([type="hidden"])');
44
+ if (!email || root.querySelector('.form__response')) return Boolean(email);
45
+ const style = document.createElement('style');
46
+ style.textContent = RESPONSE_STYLE;
47
+ const box = document.createElement('input');
48
+ box.type = 'checkbox';
49
+ box.name = 'response';
50
+ box.addEventListener('change', () => {
51
+ email.required = box.checked;
52
+ onChange(box.checked);
53
+ });
54
+ const label = document.createElement('label');
55
+ label.className = 'form__response';
56
+ label.append(box, RESPONSE_LABEL);
57
+ (email.closest('label') ?? email).after(label);
58
+ root.append(style);
59
+ return true;
29
60
  }
30
61
 
31
62
  // A path as a pattern: each id in it becomes ":id", so every invoice's page
@@ -101,7 +132,9 @@ export async function startMonitoring({ flow, person }) {
101
132
  beforeSendSpan: scrubSpan,
102
133
  initialScope: { tags: config.tags ?? {} },
103
134
  });
104
- Sentry.getClient()?.on('beforeSendFeedback', tagNotify);
135
+ // Read when the report is sent: the tick box of the form open now.
136
+ let wantsResponse = false;
137
+ Sentry.getClient()?.on('beforeSendFeedback', (event) => tagNotify(event, wantsResponse));
105
138
  // Who reported or met the error, so the lab can follow up.
106
139
  Sentry.setUser({ id: person.id, email: person.email, username: person.name || undefined });
107
140
  const feedback = config.feedback ? Sentry.getFeedback() : null;
@@ -112,8 +145,18 @@ export async function startMonitoring({ flow, person }) {
112
145
  let form = null;
113
146
  return async () => {
114
147
  form?.removeFromDom();
148
+ wantsResponse = false;
115
149
  form = await feedback.createForm();
116
150
  form.appendToDom();
117
151
  form.open();
152
+ // The form draws itself a moment after open(); add the tick box once its
153
+ // email field is there.
154
+ const root = document.getElementById('sentry-feedback')?.shadowRoot;
155
+ const onChange = (checked) => { wantsResponse = checked; };
156
+ if (!addResponseBox(root, onChange) && root) {
157
+ const watch = new MutationObserver(() => { if (addResponseBox(root, onChange)) watch.disconnect(); });
158
+ watch.observe(root, { childList: true, subtree: true });
159
+ setTimeout(() => watch.disconnect(), 5000);
160
+ }
118
161
  };
119
162
  }
@@ -0,0 +1,67 @@
1
+ // The pages of a long list, in the list window's foot bar (listFrame), as
2
+ // HubSpot's: rows per page, "1–100 of 1,204", and numbered pages between
3
+ // previous and next. The server cuts the page (GET /api/invoicing/invoices
4
+ // takes offset and limit), so a list works with any number of rows.
5
+ //
6
+ // listPager(h, { label, sizes, change }) answers { element, draw(page) }.
7
+ // page = { offset, limit, total }. change({ offset, limit }) asks for a
8
+ // page; a new size starts again at the first page. One page or none hides
9
+ // the page numbers; the size stays, so a person can show more per page.
10
+ // Issue #220 brings the pager over one record (.fx-pager) into this file.
11
+
12
+ export const PAGE_SIZES = Object.freeze([25, 50, 100, 200]);
13
+
14
+ const chevron = (d) => `<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="${d}" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
15
+ const PREV = chevron('M14.5 6l-6 6 6 6');
16
+ const NEXT = chevron('M9.5 6l6 6-6 6');
17
+ const count = new Intl.NumberFormat('en-AU');
18
+
19
+ // The page numbers to show: the first, the last, and two each side of the
20
+ // one shown, with '…' for a gap. Pages count from 1.
21
+ export function pageNumbers(at, pages) {
22
+ if (pages <= 7) return Array.from({ length: pages }, (_, n) => n + 1);
23
+ const near = new Set([1, pages, at - 1, at, at + 1]);
24
+ if (at <= 3) [2, 3, 4].forEach((n) => near.add(n));
25
+ if (at >= pages - 2) [pages - 3, pages - 2, pages - 1].forEach((n) => near.add(n));
26
+ const list = [...near].filter((n) => n >= 1 && n <= pages).sort((a, b) => a - b);
27
+ return list.flatMap((n, i) => (i && n - list[i - 1] > 1 ? ['…', n] : [n]));
28
+ }
29
+
30
+ // "1–100 of 1,204", or "0 of 0".
31
+ export function pageRange({ offset, limit, total }) {
32
+ if (!total) return '0 of 0';
33
+ return `${count.format(offset + 1)}–${count.format(Math.min(offset + limit, total))} of ${count.format(total)}`;
34
+ }
35
+
36
+ export function listPager(h, { label = 'Pages', sizes = PAGE_SIZES, change }) {
37
+ const element = h('nav', { class: 'fx-pages', 'aria-label': label });
38
+ const icon = (markup) => { const span = h('span', { 'aria-hidden': 'true' }); span.innerHTML = markup; return span; };
39
+
40
+ function draw({ offset = 0, limit = sizes[0], total = 0 }) {
41
+ const pages = Math.max(1, Math.ceil(total / limit));
42
+ const at = Math.min(pages, Math.floor(offset / limit) + 1);
43
+ const go = (n) => change({ offset: (n - 1) * limit, limit });
44
+ const size = h('select', {
45
+ class: 'fx-select fx-pages__size', 'aria-label': 'Rows per page', title: 'Rows per page',
46
+ onchange: (event) => change({ offset: 0, limit: Number(event.target.value) }),
47
+ }, sizes.map((n) => h('option', { value: String(n), ...(n === limit ? { selected: true } : {}) }, String(n))));
48
+ const step = (n, markup, name) => h('button', {
49
+ type: 'button', class: 'fx-pages__step', 'aria-label': name, title: name,
50
+ ...(n < 1 || n > pages ? { disabled: true } : {}), onclick: () => go(n),
51
+ }, icon(markup));
52
+ element.replaceChildren(
53
+ h('label', { class: 'fx-pages__rows' }, 'Rows', size),
54
+ h('span', { class: 'fx-pages__range' }, pageRange({ offset, limit, total })),
55
+ pages > 1 ? h('span', { class: 'fx-pages__steps' },
56
+ step(at - 1, PREV, 'Previous page'),
57
+ pageNumbers(at, pages).map((n) => (n === '…'
58
+ ? h('span', { class: 'fx-pages__gap', 'aria-hidden': 'true' }, '…')
59
+ : h('button', {
60
+ type: 'button', class: 'fx-pages__num', 'aria-label': `Page ${n}`,
61
+ ...(n === at ? { 'aria-current': 'page' } : {}), onclick: () => { if (n !== at) go(n); },
62
+ }, String(n)))),
63
+ step(at + 1, NEXT, 'Next page')) : '');
64
+ }
65
+
66
+ return { element, draw };
67
+ }
@@ -0,0 +1,29 @@
1
+ // How Flow names a person, in one place: a person field's choices, its
2
+ // filter values and lanes, the Assignee badge, and the People page. The
3
+ // label is the person's full name; with no name, their whole email. No DOM,
4
+ // so the server builds the same label (server/core/custom-fields.js
5
+ // choosablePeople) and tests can read it (test/person.test.js).
6
+
7
+ // The longest label a native menu shows. A longer one ends in "…" and the
8
+ // full label is the option's hover name. Pages with their own lists clip
9
+ // with CSS (`text-overflow: ellipsis`) instead.
10
+ export const LABEL_MAX = 32;
11
+
12
+ // { name, email } → "Jane Citizen", or "jane@example.test" with no name.
13
+ export function personLabel(person) {
14
+ return String(person?.name ?? '').trim() || String(person?.email ?? '').trim();
15
+ }
16
+
17
+ // A badge's letters: the first letters of the first two words of the label
18
+ // ("JC"), or of the email's part before @ ("jane.citizen@…" → "JC").
19
+ export function initialsOf(person) {
20
+ const source = (typeof person === 'string' ? person : personLabel(person)).replace(/@.*/, '');
21
+ const parts = source.split(/[\s._-]+/).filter(Boolean);
22
+ return ((parts[0]?.[0] ?? '?') + (parts[1]?.[0] ?? '')).toUpperCase();
23
+ }
24
+
25
+ // A label cut to `max` characters with "…" at the end, on one line.
26
+ export function clipLabel(text, max = LABEL_MAX) {
27
+ const value = String(text ?? '');
28
+ return value.length > max ? `${value.slice(0, max - 1).trimEnd()}…` : value;
29
+ }
@@ -7,6 +7,8 @@
7
7
  // records need a person and opens the work. Everyone can read it; an owner or
8
8
  // admin changes it. The caller passes in app.js's shared pieces as `ui`.
9
9
  import { alertStrip } from '/assets/ui/alerts.js';
10
+ import { columnTable, rowCells, viewSaver } from '/assets/ui/columns.js';
11
+ import { normaliseListView, PRICE_COLUMNS } from '/assets/ui/job-view.js';
10
12
  import { reconcileRows } from '/assets/ui/price-match.js';
11
13
 
12
14
  const DOCS = 'https://github.com/arcforgelabs/flow/blob/main/docs/PRICE_LIST.md';
@@ -16,6 +18,15 @@ const LINE_PRICES = [
16
18
  { value: 'list', label: 'Price list' },
17
19
  ];
18
20
 
21
+ // The list's columns (columns.js): each keeps its own width, saved with the
22
+ // person's account; + shows a hidden one again.
23
+ const COLUMN_DEFS = {
24
+ code: { kind: 'number', className: 'fx-price__code', cell: (item) => item.code },
25
+ name: { kind: 'client', cell: (item) => item.name },
26
+ price: { kind: 'total', num: true, cell: (item) => money(item.unitAmountCents) },
27
+ tax: { kind: 'case', className: 'fx-dim', cell: (item) => TAX_LABELS[item.taxCode] ?? item.taxCode },
28
+ };
29
+
19
30
  const money = (cents) => (cents === null || cents === undefined ? '' : `$${(cents / 100).toFixed(2)}`);
20
31
  const toCents = (text) => {
21
32
  const value = Number(String(text ?? '').replace(/[$,\s]/g, ''));
@@ -26,6 +37,18 @@ export async function renderPriceList(main, ui) {
26
37
  const { h, flow, toast, notice, dialog, pageHeader, me } = ui;
27
38
  const disabled = !['owner', 'admin'].includes(me?.person.role);
28
39
  const host = h('div', { class: 'fx-price' }, h('div', { class: 'fx-skeleton' }));
40
+ let view = normaliseListView(me?.views?.['price-list'], PRICE_COLUMNS);
41
+ const saveView = viewSaver(ui, 'price-list');
42
+ const heads = columnTable(h, {
43
+ order: () => view.columns,
44
+ widths: () => view.widths,
45
+ change: (patch) => {
46
+ view = normaliseListView({ ...view, ...patch }, PRICE_COLUMNS);
47
+ saveView(view);
48
+ draw();
49
+ },
50
+ hidden: () => Object.keys(PRICE_COLUMNS).filter((id) => !view.columns.includes(id)).map((id) => ({ id, label: PRICE_COLUMNS[id] })),
51
+ });
29
52
  const addButton = h('button', { class: 'fx-btn fx-btn--primary', type: 'button', disabled }, 'Add code');
30
53
  const reconcileButton = h('button', { class: 'fx-btn fx-btn--ghost', type: 'button', hidden: true, onclick: () => ui.go('/price-list/reconcile') }, 'Reconcile');
31
54
  const alerts = alertStrip(ui, 'price-list');
@@ -160,15 +183,12 @@ export async function renderPriceList(main, ui) {
160
183
  const tabs = h('div', { class: 'fx-tabs', role: 'tablist' }, [['active', 'Active', live.length], ['archived', 'Archived', archived.length]].map(([id, label, count]) => h('button', {
161
184
  type: 'button', role: 'tab', 'aria-selected': String(tab === id), onclick: () => { tab = id; draw(); },
162
185
  }, label, h('span', { class: 'fx-tabs__count' }, String(count)))));
186
+ const columns = view.columns.map((id) => ({ id, label: PRICE_COLUMNS[id], ...COLUMN_DEFS[id] }));
163
187
  const itemRow = (item) => h('tr', { onclick: () => edit(item), tabindex: '0', onkeydown: (event) => { if (event.key === 'Enter') edit(item); } },
164
- h('td', { class: 'fx-price__code' }, item.code),
165
- h('td', {}, item.name),
166
- h('td', { class: 'fx-num' }, money(item.unitAmountCents)),
167
- h('td', { class: 'fx-dim' }, TAX_LABELS[item.taxCode] ?? item.taxCode));
188
+ rowCells(h, columns, item),
189
+ h('td', { class: 'fx-c-end' }));
168
190
  const table = shown.length
169
- ? h('div', { class: 'fx-price__scroll' }, h('table', { class: 'fx-table fx-table--rows fx-price__table' },
170
- h('thead', {}, h('tr', {}, h('th', {}, 'Code'), h('th', {}, 'Name'), h('th', { class: 'fx-num' }, 'Price'), h('th', {}, 'Tax'))),
171
- h('tbody', {}, shown.map(itemRow))))
191
+ ? h('div', { class: 'fx-panel' }, heads.table({ className: 'fx-table--rows fx-price__table', columns, rows: h('tbody', {}, shown.map(itemRow)) }))
172
192
  : h('p', { class: 'fx-empty' }, words ? 'No code matches.' : tab === 'archived' ? 'No archived codes.' : 'No codes yet. Add the first one.');
173
193
  const sections = [
174
194
  disabled ? notice('Only an owner or admin can change these.', 'info') : null,