@arcforgelabs/flow 2026.10.52 → 2026.10.53
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/captures.js +34 -4
- package/server/ui/captures.js +7 -1
- package/server/ui/custom-fields.js +3 -9
- package/server/ui/flow.css +2 -1
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.53",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@arcforgelabs/flow",
|
|
9
|
-
"version": "2026.10.
|
|
9
|
+
"version": "2026.10.53",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@clerk/backend": "3.25.0",
|
package/package.json
CHANGED
package/server/core/captures.js
CHANGED
|
@@ -3,7 +3,8 @@ import { and, desc, eq, gt, isNotNull, isNull, lt, or, sql } from 'drizzle-orm';
|
|
|
3
3
|
import { CAPTURE_DECISIONS, captures } from '../db/schema.js';
|
|
4
4
|
import { FlowError, notFound } from './errors.js';
|
|
5
5
|
import { matchJobs, proposeChange, readDate } from './capture-match.js';
|
|
6
|
-
import { listFields, setJobFieldValue } from './custom-fields.js';
|
|
6
|
+
import { listFields, listJobFieldValues, setJobFieldValue } from './custom-fields.js';
|
|
7
|
+
import { JOB_FIELDS } from '../ui/capture-fields.js';
|
|
7
8
|
|
|
8
9
|
// Shown when the module is off or the keys are missing. Type it still works.
|
|
9
10
|
const READER_OFF = 'reader turned off';
|
|
@@ -253,7 +254,7 @@ export async function matchCapture(db, storage, organisationId, id, { jobs, fiel
|
|
|
253
254
|
state: 'ready',
|
|
254
255
|
problem: null,
|
|
255
256
|
match,
|
|
256
|
-
...(replace ? { proposal: proposeChange(read, match, jobs) } : {}),
|
|
257
|
+
...(replace ? { proposal: await toCustomFields(db, organisationId, proposeChange(read, match, jobs)) } : {}),
|
|
257
258
|
updatedAt: sql`now()`,
|
|
258
259
|
}).where(and(
|
|
259
260
|
eq(captures.organisationId, organisationId),
|
|
@@ -305,14 +306,43 @@ function fieldValue(field, text) {
|
|
|
305
306
|
}
|
|
306
307
|
}
|
|
307
308
|
|
|
309
|
+
const jobOf = (jobId) => {
|
|
310
|
+
const at = jobId.indexOf(':');
|
|
311
|
+
return { sourceSystem: jobId.slice(0, at), sourceJobRef: jobId.slice(at + 1) };
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
// A custom field's stored value as the sheet shows it ("from").
|
|
315
|
+
function shownValue(field, value) {
|
|
316
|
+
if (value == null) return '';
|
|
317
|
+
if (field.type === 'dropdown') return field.options?.find((option) => option.id === value)?.name ?? '';
|
|
318
|
+
if (field.type === 'checkbox') return value ? 'Yes' : 'No';
|
|
319
|
+
return String(value);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// A read field goes to the lab's custom field of the same name ("Shade")
|
|
323
|
+
// when there is one: Flow owns it, so Apply writes it. Any other field stays
|
|
324
|
+
// the work system's; the sheet marks it to change there (maintainer, #357).
|
|
325
|
+
async function toCustomFields(db, organisationId, proposal) {
|
|
326
|
+
if (proposal.action !== 'update') return proposal;
|
|
327
|
+
const byName = new Map((await listFields(db, organisationId)).map((field) => [field.name.trim().toLowerCase(), field]));
|
|
328
|
+
const labels = new Map(JOB_FIELDS.map((field) => [field.key, field.label.toLowerCase()]));
|
|
329
|
+
const values = proposal.jobId
|
|
330
|
+
? new Map((await listJobFieldValues(db, organisationId, { jobs: [jobOf(proposal.jobId)] })).map((row) => [row.fieldId, row.value]))
|
|
331
|
+
: new Map();
|
|
332
|
+
const changes = proposal.changes.map((change) => {
|
|
333
|
+
const field = byName.get(labels.get(change.field));
|
|
334
|
+
return field ? { ...change, field: `custom_${field.id}`, from: shownValue(field, values.get(field.id)) } : change;
|
|
335
|
+
}).filter((change) => change.from !== change.to);
|
|
336
|
+
return { ...proposal, changes };
|
|
337
|
+
}
|
|
338
|
+
|
|
308
339
|
// Apply writes what Flow owns: the job's custom fields, each with its own
|
|
309
340
|
// event (who, when, from and to). A field the work system owns (due date,
|
|
310
341
|
// shade) stays there; the result sheet links to the job in that system.
|
|
311
342
|
// Every value is checked before the first write, so a bad one writes none.
|
|
312
343
|
async function applyToJob(db, organisationId, proposal, personId) {
|
|
313
344
|
if (proposal?.action !== 'update' || !proposal.jobId) return;
|
|
314
|
-
const
|
|
315
|
-
const job = { sourceSystem: proposal.jobId.slice(0, at), sourceJobRef: proposal.jobId.slice(at + 1) };
|
|
345
|
+
const job = jobOf(proposal.jobId);
|
|
316
346
|
const fields = new Map((await listFields(db, organisationId)).map((field) => [`custom_${field.id}`, field]));
|
|
317
347
|
const writes = proposal.changes
|
|
318
348
|
.filter((change) => fields.has(change.field))
|
package/server/ui/captures.js
CHANGED
|
@@ -180,8 +180,14 @@ export async function renderCaptures(main, ui) {
|
|
|
180
180
|
? `${job.caseRef ? `Job ${job.caseRef}` : job.jobId} · ${job.clientName ?? ''}`
|
|
181
181
|
: proposal.action === 'create' ? 'New job' : 'Pick a job';
|
|
182
182
|
|
|
183
|
+
// A field the work system owns: Apply leaves it; the person changes it
|
|
184
|
+
// there (until a two-way sync can send it, #357).
|
|
185
|
+
const system = proposal.action === 'update' && proposal.jobId ? proposal.jobId.split(':')[0] : null;
|
|
186
|
+
const systemName = system ? system[0].toUpperCase() + system.slice(1) : null;
|
|
187
|
+
const elsewhere = (change) => systemName && !change.field.startsWith('custom_');
|
|
183
188
|
const changeRow = (change) => h('div', { class: 'fx-capture__change', 'data-unsure': change.sure != null && change.sure < SURE_LINE ? 'true' : 'false' },
|
|
184
|
-
h('span', { class: 'fx-capture__field' }, labels.get(change.field) ?? change.field
|
|
189
|
+
h('span', { class: 'fx-capture__field' }, labels.get(change.field) ?? change.field,
|
|
190
|
+
elsewhere(change) ? h('span', { class: 'fx-capture__where', title: `Change this in ${systemName}` }, systemName) : null),
|
|
185
191
|
h('span', { class: 'fx-capture__from' }, change.from || '–'),
|
|
186
192
|
h('span', { class: 'fx-capture__arrow', 'aria-hidden': 'true' }, '→'),
|
|
187
193
|
h('span', { class: 'fx-capture__to' }, change.to));
|
|
@@ -111,7 +111,7 @@ export async function renderCustomFields(main, ui) {
|
|
|
111
111
|
const { h, flow, toast, notice, dialog, pageHeader, me } = ui;
|
|
112
112
|
const disabled = !['owner', 'admin'].includes(me?.person.role);
|
|
113
113
|
const host = h('div', { class: 'fx-card fx-cfm__card' }, h('div', { class: 'fx-skeleton' }));
|
|
114
|
-
const addButton = h('button', { class: 'fx-btn fx-btn--primary fx-btn--sm', type: 'button', disabled
|
|
114
|
+
const addButton = h('button', { class: 'fx-btn fx-btn--primary fx-btn--sm', type: 'button', disabled, title: 'New custom field' }, lucide(h, 'plus'), 'New');
|
|
115
115
|
main.append(pageHeader('Custom fields', { help: { href: DOCS, label: 'How custom fields work' } }), host);
|
|
116
116
|
|
|
117
117
|
let data = null;
|
|
@@ -309,7 +309,7 @@ export async function renderCustomFields(main, ui) {
|
|
|
309
309
|
]);
|
|
310
310
|
const filterButton = h('button', { class: 'fx-chip fx-cfm__tool fx-cfm__tool--dashed', type: 'button' });
|
|
311
311
|
const filterMenu = popMenu(filterButton, (close) => [
|
|
312
|
-
item(view.archived ? 'check' : null, '
|
|
312
|
+
item(view.archived ? 'check' : null, 'Archived', () => { view.archived = !view.archived; close(); draw(); }, { role: 'menuitemcheckbox', checked: view.archived }),
|
|
313
313
|
]);
|
|
314
314
|
const toolbar = h('div', { class: 'fx-cfm__bar' }, search, typeButton, typeMenu, filterButton, filterMenu, h('span', { class: 'fx-cfm__gap' }), addButton);
|
|
315
315
|
const table = h('div', { class: 'fx-cfm', role: 'table', 'aria-label': 'Custom fields' });
|
|
@@ -505,15 +505,9 @@ export async function renderCustomFields(main, ui) {
|
|
|
505
505
|
parts.push(...group('archived', { text: 'Archived', icon: 'archive', color: 'gray' }, fields));
|
|
506
506
|
}
|
|
507
507
|
if (!count && (view.q || view.types.size)) parts.push(h('p', { class: 'fx-cfm__empty' }, 'No custom field matches.'));
|
|
508
|
-
if (!
|
|
509
|
-
parts.push(h('p', { class: 'fx-cfm__empty' }, 'No custom fields yet. Create one, for example Priority or Technician, to group the Jobs board by it.'));
|
|
510
|
-
}
|
|
508
|
+
else if (!count) parts.push(h('p', { class: 'fx-cfm__empty' }, 'No custom fields'));
|
|
511
509
|
table.replaceChildren(...parts);
|
|
512
|
-
// The count is what the table shows, the work systems' fields too.
|
|
513
|
-
foot.replaceChildren(h('span', {}, `${count} ${count === 1 ? 'custom field' : 'custom fields'}`));
|
|
514
510
|
}
|
|
515
|
-
const foot = h('div', { class: 'fx-cfm__foot' });
|
|
516
|
-
host.append(foot);
|
|
517
511
|
|
|
518
512
|
async function load() {
|
|
519
513
|
try {
|
package/server/ui/flow.css
CHANGED
|
@@ -1093,6 +1093,7 @@ th:hover > .fx-th-grip::after { background: var(--t-brd-hi); }
|
|
|
1093
1093
|
min-height: 44px; padding: 4px 8px; border-bottom: 1px solid var(--fx-line-soft);
|
|
1094
1094
|
}
|
|
1095
1095
|
.fx-capture__field { color: var(--t-dim); }
|
|
1096
|
+
.fx-capture__where { display: block; font-size: 12px; color: var(--t-dim); }
|
|
1096
1097
|
.fx-capture__from { color: var(--t-dim); text-decoration: line-through; overflow-wrap: anywhere; }
|
|
1097
1098
|
.fx-capture__arrow { color: var(--t-dim); }
|
|
1098
1099
|
.fx-capture__to { color: var(--t-strong); font-weight: 600; overflow-wrap: anywhere; }
|
|
@@ -1682,7 +1683,6 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
1682
1683
|
.fx-cfm__create .fx-lucide { width: 14px; height: 14px; }
|
|
1683
1684
|
.fx-cfm__empty { margin: 0; padding: 20px 16px; color: var(--t-dim); }
|
|
1684
1685
|
.fx-cfm > .fx-notice { margin: 12px 16px 0; }
|
|
1685
|
-
.fx-cfm__foot { display: flex; justify-content: space-between; gap: 12px; padding: 12px 16px; border-top: 1px solid var(--fx-line-soft); color: var(--t-dim); font-size: 13px; }
|
|
1686
1686
|
.fx-pop__item:disabled { opacity: .5; cursor: default; }
|
|
1687
1687
|
.fx-pop__item[data-tone="danger"], .fx-pop__item[data-tone="danger"] .fx-pop__tick { color: var(--t-danger); }
|
|
1688
1688
|
.fx-pop__item .fx-lucide { width: 15px; height: 15px; }
|
|
@@ -1759,6 +1759,7 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
1759
1759
|
.fx-cfm__head, .fx-cfm__row { padding-left: 16px; }
|
|
1760
1760
|
.fx-cfm__create { padding-left: 16px; }
|
|
1761
1761
|
.fx-cfm__search { flex: 1 1 100%; width: auto; }
|
|
1762
|
+
.fx-cfm__bar :is(.fx-cfm__search, .fx-cfm__tool, .fx-btn) { height: auto; min-height: 44px; }
|
|
1762
1763
|
.fx-cfm__edit { display: none !important; }
|
|
1763
1764
|
/* Custom fields uses the same phone slots as a column list: name and the
|
|
1764
1765
|
menu on line 1, up to three facts on line 2, 56px. */
|