@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/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, filterCount, filterJobs, groupJobs, GROUPS, KIND_OF_TYPE, keptStatuses, laneOf,
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 search = h('input', { class: 'fx-input fx-search', type: 'search', placeholder: 'Find a case, client or practitioner', 'aria-label': 'Find a job', value: asked });
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' }, search, toggle)),
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
- // One toolbar control: a chip that opens a small panel under it, and an ×
181
- // that clears what the chip sets (as ClickUp's "Clear all filters"). The
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(GROUP_ICON, (redraw) => {
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(SORT_ICON, (redraw) => {
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
- // A row's value when its operator changes: kept when it still fits.
333
- function valueFor(field, op, old) {
334
- const kind = kindOf(field);
335
- if (['is', 'not'].includes(op) && ['choice', 'person'].includes(kind)) return Array.isArray(old) ? old : [];
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(COLUMNS_ICON, (redraw) => {
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
- const count = filterCount(state.view);
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(TICK_ICON))),
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
- }
@@ -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 empty and
21
- // is optional. A person who types an address there asks to hear back; Flow
22
- // tags the report notify=yes, so the support desk sees it on the task.
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 email field is the opt-in (tagNotify), so it is not filled from
75
- // the signed-in person: a key setUser never sets.
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
- // One form, made on first use and opened again after that.
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
- if (!form) {
112
- form = await feedback.createForm();
113
- form.appendToDom();
114
- }
113
+ form?.removeFromDom();
114
+ form = await feedback.createForm();
115
+ form.appendToDom();
115
116
  form.open();
116
117
  };
117
118
  }