@arcforgelabs/flow 2026.10.39 → 2026.10.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/core/job-match.js +2 -0
- package/server/integrations/call-guard.js +57 -15
- package/server/integrations/xero/settings.js +2 -1
- package/server/ui/app.js +7 -3
- package/server/ui/flow.css +24 -38
- package/server/ui/invoicing.js +15 -53
- package/server/ui/jobs.js +15 -172
- package/server/ui/monitoring.js +13 -12
- package/server/ui/reconcile.js +279 -95
- package/server/ui/routes.js +1 -1
- package/server/ui/view-bar.js +236 -0
package/server/ui/jobs.js
CHANGED
|
@@ -19,9 +19,10 @@ import {
|
|
|
19
19
|
caseNumber, FINAL_RANK, fixes, INVOICE_STAGES, invoiceStage, matches, NO_STATUS, orderStatuses, problemSummary, statusRank,
|
|
20
20
|
} from '/assets/ui/job-board.js';
|
|
21
21
|
import {
|
|
22
|
-
COLUMNS, DEFAULT_VIEW, fieldGroup, fieldIdOf, fieldLanes, FILTER_FIELDS,
|
|
22
|
+
COLUMNS, DEFAULT_VIEW, fieldGroup, fieldIdOf, fieldLanes, FILTER_FIELDS, filterJobs, groupJobs, GROUPS, KIND_OF_TYPE, keptStatuses, laneOf,
|
|
23
23
|
normaliseJobsView, NOT_SET, OPERATORS, sortJobs, SORTS,
|
|
24
24
|
} from '/assets/ui/job-view.js';
|
|
25
|
+
import { findBox, ICONS, svg, today, viewBar } from '/assets/ui/view-bar.js';
|
|
25
26
|
|
|
26
27
|
const LAYOUT_KEY = 'flow_jobs_layout';
|
|
27
28
|
const FOLDED_KEY = 'flow_jobs_folded';
|
|
@@ -84,19 +85,9 @@ function saveFolded(folded) {
|
|
|
84
85
|
try { localStorage.setItem(FOLDED_KEY, JSON.stringify(folded)); } catch { /* a convenience only */ }
|
|
85
86
|
}
|
|
86
87
|
|
|
87
|
-
// The local calendar day, as yyyy-mm-dd, to mark late jobs.
|
|
88
|
-
const today = () => {
|
|
89
|
-
const now = new Date();
|
|
90
|
-
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
|
91
|
-
};
|
|
92
88
|
|
|
93
89
|
const WARN_ICON = '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.5 15 14H1z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/><path d="M8 6v3.6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><circle cx="8" cy="11.6" r=".9" fill="currentColor"/></svg>';
|
|
94
90
|
const PERSON_ICON = '<svg width="13" height="13" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="5.5" r="3" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M2.5 14c.6-2.8 2.8-4.3 5.5-4.3s4.9 1.5 5.5 4.3" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>';
|
|
95
|
-
const GROUP_ICON = '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.8 14.5 5 8 8.2 1.5 5z" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/><path d="m1.5 8.2 6.5 3.2 6.5-3.2M1.5 11.2 8 14.4l6.5-3.2" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/></svg>';
|
|
96
|
-
const SORT_ICON = '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path d="m4.5 6 3.5-3.5L11.5 6M4.5 10 8 13.5l3.5-3.5" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
97
|
-
const COLUMNS_ICON = '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><rect x="2" y="2.5" width="12" height="11" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M6 2.5v11M10 2.5v11" stroke="currentColor" stroke-width="1.3"/></svg>';
|
|
98
|
-
const FILTER_ICON = '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path d="M2 3.5h12M4.5 8h7M7 12.5h2" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>';
|
|
99
|
-
const TICK_ICON = '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path d="m3 8.5 3.2 3L13 4.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
100
91
|
const CHEVRON_ICON = '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path d="m6 3.5 4.5 4.5L6 12.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
101
92
|
const CALENDAR_ICON = '<svg width="13" height="13" viewBox="0 0 16 16" aria-hidden="true"><rect x="2" y="3" width="12" height="11" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M2 6.5h12M5.5 1.5v3M10.5 1.5v3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>';
|
|
102
93
|
|
|
@@ -120,7 +111,7 @@ export async function renderJobs(main, ui) {
|
|
|
120
111
|
ticked: new Set(), folded: readFolded(),
|
|
121
112
|
};
|
|
122
113
|
const toggle = h('div', { class: 'fx-seg', role: 'radiogroup', 'aria-label': 'Layout' });
|
|
123
|
-
const
|
|
114
|
+
const find = findBox(h, { label: 'Find a case, client or practitioner', value: asked, oninput: (words) => { state.q = words.trim(); draw(); } });
|
|
124
115
|
const host = h('div', { class: 'fx-jobs' }, h('div', { class: 'fx-skeleton' }));
|
|
125
116
|
const controls = h('div', { class: 'fx-viewbar', role: 'group', 'aria-label': 'Group, sort and filter' });
|
|
126
117
|
// In the list, ticked rows get the same floating bar as Invoices.
|
|
@@ -128,7 +119,7 @@ export async function renderJobs(main, ui) {
|
|
|
128
119
|
main.classList.add('fx-main--wide');
|
|
129
120
|
main.append(
|
|
130
121
|
pageHeader('Jobs', { subtitle: 'Every job in the lab system, by its status there.' }),
|
|
131
|
-
h('div', { class: 'fx-toolbar fx-toolbar--plain' }, controls, h('div', { class: 'fx-toolbar__end' },
|
|
122
|
+
h('div', { class: 'fx-toolbar fx-toolbar--plain' }, controls, h('div', { class: 'fx-toolbar__end' }, find.element, toggle)),
|
|
132
123
|
host,
|
|
133
124
|
bulk.element);
|
|
134
125
|
|
|
@@ -137,7 +128,6 @@ export async function renderJobs(main, ui) {
|
|
|
137
128
|
onclick: () => { saveLayout(id); change({ layout: id }); drawToggle(); },
|
|
138
129
|
}, label)));
|
|
139
130
|
drawToggle();
|
|
140
|
-
search.addEventListener('input', () => { state.q = search.value.trim(); draw(); });
|
|
141
131
|
|
|
142
132
|
const problemsOf = (job) => fixes(job.obligation);
|
|
143
133
|
const jobKey = (job) => `${job.sourceSystem}\u0000${job.sourceJobRef}`;
|
|
@@ -177,45 +167,8 @@ export async function renderJobs(main, ui) {
|
|
|
177
167
|
}, 500);
|
|
178
168
|
}
|
|
179
169
|
|
|
180
|
-
//
|
|
181
|
-
|
|
182
|
-
// panel is a popover, so a click outside or Escape closes it.
|
|
183
|
-
let menuCount = 0;
|
|
184
|
-
function menu(icon, build, { rows = false, clearLabel = 'Clear' } = {}) {
|
|
185
|
-
const id = `fx-jobs-menu-${++menuCount}`;
|
|
186
|
-
const panel = h('div', { class: `fx-pop${rows ? ' fx-pop--rows' : ''}`, id, popover: 'auto', role: rows ? 'dialog' : 'menu' });
|
|
187
|
-
const button = h('button', { type: 'button', class: 'fx-chip', popovertarget: id, 'aria-haspopup': rows ? 'dialog' : 'menu', 'aria-expanded': 'false' });
|
|
188
|
-
const clear = h('button', { type: 'button', class: 'fx-chip__clear', 'aria-label': clearLabel, title: clearLabel, hidden: true }, lucide(h, 'x'));
|
|
189
|
-
const wrap = h('span', { class: 'fx-chipset' }, button, clear);
|
|
190
|
-
let onClear = null;
|
|
191
|
-
clear.addEventListener('click', () => onClear?.());
|
|
192
|
-
const fill = () => panel.replaceChildren(...build(fill, () => panel.hidePopover()).filter(Boolean));
|
|
193
|
-
panel.addEventListener('toggle', (event) => {
|
|
194
|
-
button.setAttribute('aria-expanded', String(event.newState === 'open'));
|
|
195
|
-
if (event.newState !== 'open') return;
|
|
196
|
-
fill();
|
|
197
|
-
const at = button.getBoundingClientRect();
|
|
198
|
-
panel.style.top = `${Math.round(at.bottom + 6)}px`;
|
|
199
|
-
panel.style.left = `${Math.round(Math.max(8, Math.min(at.left, innerWidth - panel.offsetWidth - 8)))}px`;
|
|
200
|
-
});
|
|
201
|
-
return {
|
|
202
|
-
node: [wrap, panel],
|
|
203
|
-
wrap,
|
|
204
|
-
fill: () => { if (panel.matches(':popover-open')) fill(); },
|
|
205
|
-
set(label, on, clearIt = null) {
|
|
206
|
-
button.replaceChildren(svg(icon), h('span', {}, label));
|
|
207
|
-
wrap.classList.toggle('fx-chipset--on', on);
|
|
208
|
-
button.classList.toggle('fx-chip--on', on);
|
|
209
|
-
onClear = clearIt;
|
|
210
|
-
clear.hidden = !clearIt;
|
|
211
|
-
},
|
|
212
|
-
};
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// A choice in a menu: a row with a tick when it is chosen.
|
|
216
|
-
const option = (text, checked, onclick, role = 'menuitemradio') => h('button', {
|
|
217
|
-
type: 'button', class: 'fx-pop__item', role, 'aria-checked': String(checked), onclick,
|
|
218
|
-
}, h('span', { class: 'fx-pop__tick' }, checked ? svg(TICK_ICON) : null), h('span', {}, text));
|
|
170
|
+
// The chips (view-bar.js), shared with every list page.
|
|
171
|
+
const { menu, option, title, choose, bin, addButton, rowsFoot, clearAll, filterMenu: buildFilterMenu } = viewBar(h);
|
|
219
172
|
const DATE_WORDS = ['Oldest first', 'Newest first'];
|
|
220
173
|
const NUMBER_WORDS = ['Low to high', 'High to low'];
|
|
221
174
|
const DIR_WORDS = { total: NUMBER_WORDS, due: DATE_WORDS, ordered: DATE_WORDS, shipped: DATE_WORDS };
|
|
@@ -223,19 +176,6 @@ export async function renderJobs(main, ui) {
|
|
|
223
176
|
const dirWords = (by) => DIR_WORDS[by] ?? TYPE_WORDS[fieldById(fieldIdOf(by))?.type] ?? ['A to Z', 'Z to A'];
|
|
224
177
|
const fieldById = (id) => state.fields.find((f) => f.id === id) ?? null;
|
|
225
178
|
|
|
226
|
-
// The parts of a row panel (Group by, Sort, Filters): a title, rows of
|
|
227
|
-
// selects with a bin at the end, and a foot with "Add" and "Clear all".
|
|
228
|
-
const title = (text) => h('p', { class: 'fx-rows__title' }, text);
|
|
229
|
-
const choose = (label, value, options, onchange, extra = {}) => h('select', {
|
|
230
|
-
class: 'fx-select fx-select--sm', 'aria-label': label, onchange: (event) => onchange(event.target.value), ...extra,
|
|
231
|
-
}, options.map((o) => (o.group
|
|
232
|
-
? (o.options.length ? h('optgroup', { label: o.group }, o.options.map((x) => h('option', { value: x.value, selected: x.value === value }, x.label))) : null)
|
|
233
|
-
: h('option', { value: o.value, selected: o.value === value, disabled: o.disabled }, o.label))));
|
|
234
|
-
const bin = (label, onclick) => h('button', { type: 'button', class: 'fx-rows__bin', 'aria-label': label, title: label, onclick }, lucide(h, 'trash-2'));
|
|
235
|
-
const addButton = (text, onclick) => h('button', { type: 'button', class: 'fx-btn fx-btn--ghost fx-btn--sm fx-rows__add', onclick }, lucide(h, 'plus'), text);
|
|
236
|
-
const rowsFoot = (...parts) => h('div', { class: 'fx-rows__foot' }, ...parts);
|
|
237
|
-
const clearAll = (onclick) => h('button', { type: 'button', class: 'fx-btn fx-btn--ghost fx-btn--sm', onclick }, 'Clear all');
|
|
238
|
-
|
|
239
179
|
// ── Group by ──
|
|
240
180
|
// One row: what to group by, the group order, and a bin that takes the
|
|
241
181
|
// groups away (the list only: the board always has lanes).
|
|
@@ -243,7 +183,7 @@ export async function renderJobs(main, ui) {
|
|
|
243
183
|
...Object.entries(GROUPS).filter(([id]) => id !== 'none').map(([value, label]) => ({ value, label })),
|
|
244
184
|
{ group: 'Custom fields', options: groupable().map((f) => ({ value: fieldGroup(f.id), label: f.name })) },
|
|
245
185
|
];
|
|
246
|
-
const groupMenu = menu(
|
|
186
|
+
const groupMenu = menu(ICONS.group, (redraw) => {
|
|
247
187
|
const list = state.view.layout === 'list';
|
|
248
188
|
const group = state.view.group === 'none' && !list ? 'status' : state.view.group;
|
|
249
189
|
const none = group === 'none';
|
|
@@ -264,7 +204,7 @@ export async function renderJobs(main, ui) {
|
|
|
264
204
|
{ group: 'Custom fields', options: state.fields.map((f) => ({ value: fieldGroup(f.id), label: f.name })) },
|
|
265
205
|
];
|
|
266
206
|
const setSorts = (sorts) => change({ sorts });
|
|
267
|
-
const sortMenu = menu(
|
|
207
|
+
const sortMenu = menu(ICONS.sort, (redraw) => {
|
|
268
208
|
const { sorts } = state.view;
|
|
269
209
|
const unused = sortOptions().flatMap((o) => o.options ?? [o]).filter((o) => !sorts.some((s) => s.by === o.value));
|
|
270
210
|
return [
|
|
@@ -329,105 +269,15 @@ export async function renderJobs(main, ui) {
|
|
|
329
269
|
default: return valueOf(job, fieldIdOf(field))?.value ?? null;
|
|
330
270
|
}
|
|
331
271
|
}
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
if (['before', 'after', 'on'].includes(op)) return typeof old === 'string' && /^\d{4}-/.test(old) ? old : today();
|
|
337
|
-
if (['eq', 'gt', 'lt'].includes(op)) return typeof old === 'number' ? old : null;
|
|
338
|
-
if (['has', 'is'].includes(op)) return typeof old === 'string' ? old : '';
|
|
339
|
-
return null;
|
|
340
|
-
}
|
|
341
|
-
const setFilters = (filters) => change({ filters });
|
|
342
|
-
const setRow = (i, patch) => setFilters(state.view.filters.map((f, n) => (n === i ? { ...f, ...patch } : f)));
|
|
343
|
-
// A choice row's value: a button that opens a list of choices to tick,
|
|
344
|
-
// with a search when the list is long.
|
|
345
|
-
function choiceValue(i, filter) {
|
|
346
|
-
const choices = choicesOf(filter.field);
|
|
347
|
-
const named = () => {
|
|
348
|
-
const on = choices.filter((c) => filter.value.includes(c.value)).map((c) => c.label);
|
|
349
|
-
return on.length ? on.join(', ') : 'Choose…';
|
|
350
|
-
};
|
|
351
|
-
const id = `fx-jobs-choice-${i}`;
|
|
352
|
-
const button = h('button', { type: 'button', class: 'fx-select fx-select--sm fx-rows__value', popovertarget: id, title: named() }, named());
|
|
353
|
-
const pop = h('div', { class: 'fx-pop fx-pop--choices', id, popover: 'auto', role: 'menu' });
|
|
354
|
-
const find = choices.length > 8 ? h('input', { class: 'fx-input fx-input--sm', type: 'search', placeholder: 'Find', 'aria-label': 'Find a choice' }) : null;
|
|
355
|
-
const items = h('div', {});
|
|
356
|
-
const drawItems = () => {
|
|
357
|
-
const q = find?.value.trim().toLowerCase() ?? '';
|
|
358
|
-
items.replaceChildren(...choices.filter((c) => !q || c.label.toLowerCase().includes(q)).map((c) => option(c.label, filter.value.includes(c.value), () => {
|
|
359
|
-
filter = { ...filter, value: filter.value.includes(c.value) ? filter.value.filter((v) => v !== c.value) : [...filter.value, c.value] };
|
|
360
|
-
setRow(i, { value: filter.value });
|
|
361
|
-
button.textContent = named();
|
|
362
|
-
button.title = named();
|
|
363
|
-
drawItems();
|
|
364
|
-
}, 'menuitemcheckbox')));
|
|
365
|
-
};
|
|
366
|
-
find?.addEventListener('input', drawItems);
|
|
367
|
-
pop.append(...[find, items].filter(Boolean));
|
|
368
|
-
pop.addEventListener('toggle', (event) => {
|
|
369
|
-
if (event.newState !== 'open') return;
|
|
370
|
-
drawItems();
|
|
371
|
-
const at = button.getBoundingClientRect();
|
|
372
|
-
pop.style.top = `${Math.round(at.bottom + 4)}px`;
|
|
373
|
-
pop.style.left = `${Math.round(Math.max(8, Math.min(at.left, innerWidth - pop.offsetWidth - 8)))}px`;
|
|
374
|
-
find?.focus();
|
|
375
|
-
});
|
|
376
|
-
return [button, pop];
|
|
377
|
-
}
|
|
378
|
-
function valueControl(i, filter) {
|
|
379
|
-
const kind = kindOf(filter.field);
|
|
380
|
-
const { op } = filter;
|
|
381
|
-
if (['is', 'not'].includes(op) && ['choice', 'person'].includes(kind)) return choiceValue(i, filter);
|
|
382
|
-
if (['before', 'after', 'on'].includes(op)) {
|
|
383
|
-
return h('input', { class: 'fx-input fx-input--sm fx-rows__value', type: 'date', 'aria-label': 'Date', value: filter.value ?? '', onchange: (event) => setRow(i, { value: event.target.value || null }) });
|
|
384
|
-
}
|
|
385
|
-
if (['eq', 'gt', 'lt'].includes(op)) {
|
|
386
|
-
return h('input', {
|
|
387
|
-
class: 'fx-input fx-input--sm fx-rows__value', type: 'number', step: 'any', inputmode: 'decimal', 'aria-label': 'Number', value: filter.value ?? '',
|
|
388
|
-
oninput: (event) => setRow(i, { value: event.target.value === '' ? null : Number(event.target.value) }),
|
|
389
|
-
});
|
|
390
|
-
}
|
|
391
|
-
if (['has', 'is'].includes(op)) {
|
|
392
|
-
return h('input', { class: 'fx-input fx-input--sm fx-rows__value', type: 'text', 'aria-label': 'Text', value: filter.value ?? '', oninput: (event) => setRow(i, { value: event.target.value }) });
|
|
393
|
-
}
|
|
394
|
-
return h('span', { class: 'fx-rows__value fx-rows__value--none' });
|
|
395
|
-
}
|
|
396
|
-
const filterMenu = menu(FILTER_ICON, (redraw) => {
|
|
397
|
-
const { filters, match } = state.view;
|
|
398
|
-
const fields = filterFields();
|
|
399
|
-
return [
|
|
400
|
-
title('Filters'),
|
|
401
|
-
filters.length ? h('div', { class: 'fx-rows' }, filters.map((filter, i) => {
|
|
402
|
-
const known = kindOf(filter.field);
|
|
403
|
-
const lead = i === 0 ? h('span', { class: 'fx-rows__lead' }, 'Where')
|
|
404
|
-
: i === 1 ? choose('Match', match, [{ value: 'all', label: 'And' }, { value: 'any', label: 'Or' }], (value) => { change({ match: value }); redraw(); })
|
|
405
|
-
: h('span', { class: 'fx-rows__lead' }, match === 'any' ? 'Or' : 'And');
|
|
406
|
-
if (!known) {
|
|
407
|
-
return h('div', { class: 'fx-rows__row' }, lead, h('span', { class: 'fx-rows__gone' }, 'A custom field that was deleted'), bin('Remove this filter', () => { setFilters(filters.filter((_, n) => n !== i)); redraw(); }));
|
|
408
|
-
}
|
|
409
|
-
const ops = opsOf(filter.field);
|
|
410
|
-
return h('div', { class: 'fx-rows__row' },
|
|
411
|
-
lead,
|
|
412
|
-
choose('Field', filter.field, fields, (field) => {
|
|
413
|
-
const op = Object.keys(opsOf(field))[0];
|
|
414
|
-
setRow(i, { field, op, value: valueFor(field, op, null) });
|
|
415
|
-
redraw();
|
|
416
|
-
}),
|
|
417
|
-
choose('Operator', filter.op, Object.entries(ops).map(([value, label]) => ({ value, label })), (op) => { setRow(i, { op, value: valueFor(filter.field, op, filter.value) }); redraw(); }),
|
|
418
|
-
valueControl(i, filter),
|
|
419
|
-
bin('Remove this filter', () => { setFilters(filters.filter((_, n) => n !== i)); redraw(); }));
|
|
420
|
-
})) : h('p', { class: 'fx-rows__empty' }, 'No filters. Every job shows.'),
|
|
421
|
-
rowsFoot(
|
|
422
|
-
filters.length < 20 ? addButton('Add filter', () => { setFilters([...filters, { field: 'status', op: 'is', value: [] }]); redraw(); }) : h('span'),
|
|
423
|
-
filters.length ? clearAll(() => { setFilters([]); redraw(); }) : null),
|
|
424
|
-
];
|
|
425
|
-
}, { rows: true, clearLabel: 'Clear all filters' });
|
|
272
|
+
const filterMenu = buildFilterMenu({
|
|
273
|
+
fields: filterFields, kindOf, opsOf, choicesOf, first: 'status',
|
|
274
|
+
view: () => state.view, change, empty: 'No filters. Every job shows.',
|
|
275
|
+
});
|
|
426
276
|
|
|
427
277
|
// Columns (the list only): Client always shows; each other column can be
|
|
428
278
|
// shown or hidden. Custom fields are columns too.
|
|
429
279
|
const heading = (text) => h('p', { class: 'fx-pop__head' }, text);
|
|
430
|
-
const columnsMenu = menu(
|
|
280
|
+
const columnsMenu = menu(ICONS.columns, (redraw) => {
|
|
431
281
|
const on = new Set(state.view.columns);
|
|
432
282
|
const flip = (id) => {
|
|
433
283
|
const next = on.has(id) ? state.view.columns.filter((c) => c !== id) : [...state.view.columns, id];
|
|
@@ -463,8 +313,7 @@ export async function renderJobs(main, ui) {
|
|
|
463
313
|
sortMenu.set(sorts.length > 1 ? `Sort · ${sorts.length}` : first ? `Sort: ${sortName(first.by)} ${first.dir === 'desc' ? '↓' : '↑'}` : 'Sort', sorts.length > 0,
|
|
464
314
|
sorts.length ? () => change({ sorts: [] }) : null);
|
|
465
315
|
sortMenu.wrap.hidden = list && !sorts.length;
|
|
466
|
-
|
|
467
|
-
filterMenu.set(count ? `${count} ${count === 1 ? 'Filter' : 'Filters'}` : 'Filter', count > 0, state.view.filters.length ? () => change({ filters: [], match: 'all' }) : null);
|
|
316
|
+
filterMenu.draw();
|
|
468
317
|
columnsMenu.set('Columns', false);
|
|
469
318
|
columnsMenu.wrap.hidden = !list;
|
|
470
319
|
}
|
|
@@ -898,7 +747,7 @@ export async function renderJobs(main, ui) {
|
|
|
898
747
|
const flip = () => change({ sorts: sorts.map((s, n) => (n === at ? { ...s, dir: s.dir === 'asc' ? 'desc' : 'asc' } : s)) });
|
|
899
748
|
columnPop.replaceChildren(
|
|
900
749
|
h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitemcheckbox', 'aria-checked': 'true', title: 'Change the direction', onclick: () => { close(); flip(); } },
|
|
901
|
-
h('span', { class: 'fx-pop__tick' }, lucide(h, on.dir === 'asc' ? 'arrow-up' : 'arrow-down')), h('span', {}, `Sort by ${label}`), h('span', { class: 'fx-pop__end' }, svg(
|
|
750
|
+
h('span', { class: 'fx-pop__tick' }, lucide(h, on.dir === 'asc' ? 'arrow-up' : 'arrow-down')), h('span', {}, `Sort by ${label}`), h('span', { class: 'fx-pop__end' }, svg(ICONS.tick))),
|
|
902
751
|
h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitem', onclick: () => { close(); change({ sorts: sorts.filter((_, n) => n !== at) }); } },
|
|
903
752
|
h('span', { class: 'fx-pop__tick' }, lucide(h, 'ban')), h('span', {}, 'Clear sort')));
|
|
904
753
|
placeColumnPop(event);
|
|
@@ -1426,9 +1275,3 @@ export async function renderJobs(main, ui) {
|
|
|
1426
1275
|
|
|
1427
1276
|
await load();
|
|
1428
1277
|
}
|
|
1429
|
-
|
|
1430
|
-
function svg(markup) {
|
|
1431
|
-
const span = document.createElement('span');
|
|
1432
|
-
span.innerHTML = markup;
|
|
1433
|
-
return span.firstChild;
|
|
1434
|
-
}
|
package/server/ui/monitoring.js
CHANGED
|
@@ -17,9 +17,10 @@ function loadScript({ src, integrity }) {
|
|
|
17
17
|
});
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
// "Notify me": the form's email field (Sentry's own words) starts
|
|
21
|
-
//
|
|
22
|
-
// tags
|
|
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.
|
|
23
24
|
// Sentry's form has no tick box of its own; this keeps to its stock options.
|
|
24
25
|
export function tagNotify(event) {
|
|
25
26
|
if (event?.type !== 'feedback') return;
|
|
@@ -71,9 +72,8 @@ export async function startMonitoring({ flow, person }) {
|
|
|
71
72
|
showBranding: false,
|
|
72
73
|
// Flow already knows who is signed in (setUser below).
|
|
73
74
|
showName: false,
|
|
74
|
-
// The
|
|
75
|
-
|
|
76
|
-
useSentryUser: { name: 'username', email: 'notify_email' },
|
|
75
|
+
// The form fills in the signed-in person's name and email (setUser).
|
|
76
|
+
useSentryUser: { name: 'username', email: 'email' },
|
|
77
77
|
enableScreenshot: true,
|
|
78
78
|
triggerLabel: 'Report a problem',
|
|
79
79
|
formTitle: 'Report a problem',
|
|
@@ -102,16 +102,17 @@ export async function startMonitoring({ flow, person }) {
|
|
|
102
102
|
});
|
|
103
103
|
Sentry.getClient()?.on('beforeSendFeedback', tagNotify);
|
|
104
104
|
// Who reported or met the error, so the lab can follow up.
|
|
105
|
-
Sentry.setUser({ id: person.id, email: person.email });
|
|
105
|
+
Sentry.setUser({ id: person.id, email: person.email, username: person.name || undefined });
|
|
106
106
|
const feedback = config.feedback ? Sentry.getFeedback() : null;
|
|
107
107
|
if (!feedback) return null;
|
|
108
|
-
//
|
|
108
|
+
// A new form each time. Sentry's form keeps a refused screenshot's error
|
|
109
|
+
// ("Permission denied by user") until the next click, so a reused form
|
|
110
|
+
// would show it before the person tries again.
|
|
109
111
|
let form = null;
|
|
110
112
|
return async () => {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
113
|
+
form?.removeFromDom();
|
|
114
|
+
form = await feedback.createForm();
|
|
115
|
+
form.appendToDom();
|
|
115
116
|
form.open();
|
|
116
117
|
};
|
|
117
118
|
}
|