@arcforgelabs/flow 2026.10.42 → 2026.10.44
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/README.md +1 -0
- package/npm-shrinkwrap.json +4 -3
- package/package.json +5 -3
- package/server/auth/people.js +40 -2
- package/server/auth/routes.js +11 -0
- package/server/auth/session.js +22 -3
- package/server/core/custom-fields.js +4 -3
- package/server/core/invoice-rules.js +8 -3
- package/server/core/job-match.js +6 -2
- package/server/core/sending.js +4 -3
- package/server/integrations/sentry/index.js +9 -1
- package/server/release-channel.js +5 -2
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +46 -162
- package/server/ui/automations.js +9 -3
- package/server/ui/columns.js +268 -0
- package/server/ui/custom-fields.js +17 -16
- package/server/ui/flow.css +268 -55
- package/server/ui/invoice-notes.js +7 -5
- package/server/ui/invoice-rules.js +3 -3
- package/server/ui/invoicing-settings.js +92 -35
- package/server/ui/invoicing.js +206 -119
- package/server/ui/job-board.js +20 -8
- package/server/ui/job-change.js +36 -0
- package/server/ui/job-view.js +255 -60
- package/server/ui/jobs.js +197 -145
- package/server/ui/kit.js +278 -0
- package/server/ui/list-views.js +73 -0
- package/server/ui/monitoring.js +1 -0
- package/server/ui/person.js +29 -0
- package/server/ui/price-list.js +2 -3
- package/server/ui/price-reconcile.js +7 -8
- package/server/ui/reconcile.js +114 -61
- package/server/ui/routes.js +1 -1
- package/server/ui/saved-views.js +141 -0
- package/server/ui/search.js +8 -0
- package/server/ui/sidecar.js +72 -0
- package/server/ui/tip.js +99 -0
- package/server/ui/view-bar.js +176 -70
- package/server/ui/xero-import.js +7 -10
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// A list's columns, as ClickUp's: drag a head's right edge to make the
|
|
2
|
+
// column wider or narrower (never under its kind's minimum), drag a head
|
|
3
|
+
// to move the column, and click or right-click a head for its menu (Move to
|
|
4
|
+
// start, Move to end, Hide; + shows a hidden column again). Jobs, Invoices
|
|
5
|
+
// and Reconcile build their tables here, so every list works the same.
|
|
6
|
+
//
|
|
7
|
+
// The view a person keeps for a list (with their account, PUT
|
|
8
|
+
// /api/me/views/:page) holds two keys from here:
|
|
9
|
+
// columns the shown columns' ids, in order
|
|
10
|
+
// widths { [id]: px } for the columns the person made wider or narrower
|
|
11
|
+
// The rules (no DOM) are in job-view.js ("Columns"), so the server checks a
|
|
12
|
+
// saved view with them (server/auth/people.js) and tests read them.
|
|
13
|
+
//
|
|
14
|
+
// columnTable(h, config), once per page, answers { table, menu, openMenu }:
|
|
15
|
+
// order() the shown columns' ids, as the view keeps them
|
|
16
|
+
// widths() the saved widths
|
|
17
|
+
// change(patch) saves { columns } or { widths }
|
|
18
|
+
// hidden() [{ id, label }] the + menu lists (no hidden: no +)
|
|
19
|
+
// extra(column, item) menu nodes before Move (Sort, Edit custom field)
|
|
20
|
+
// table({ columns, rows, lead, className }), per draw, answers the table:
|
|
21
|
+
// columns the shown columns, in order: { id, label, kind, cell(row),
|
|
22
|
+
// num?, className?, sort?(): node, icon?, fixed? }. An icon
|
|
23
|
+
// shows in place of the label (the label is its name). A fixed
|
|
24
|
+
// column keeps its place: no drag, no Move, no Hide.
|
|
25
|
+
// rows the <tbody> nodes (rowCells builds a row's cells)
|
|
26
|
+
// lead <th> nodes before the columns (the tick column)
|
|
27
|
+
import { lucide } from '/assets/ui/custom-fields.js';
|
|
28
|
+
import { MAX_WIDTH, minWidth, moveColumn, widthOf } from '/assets/ui/job-view.js';
|
|
29
|
+
|
|
30
|
+
// The tick column at the start and the + column at the end.
|
|
31
|
+
const LEAD_WIDTH = 44;
|
|
32
|
+
const TAIL_WIDTH = 40;
|
|
33
|
+
|
|
34
|
+
// A row's cells for the shown columns: a class per kind, the label for
|
|
35
|
+
// the phone layout, the full text as the cell's name on hover.
|
|
36
|
+
export function rowCells(h, columns, row) {
|
|
37
|
+
return columns.map((c) => {
|
|
38
|
+
const td = h('td', {
|
|
39
|
+
class: [`fx-c-${c.kind}`, c.num ? 'fx-num' : null, c.className].filter(Boolean).join(' '),
|
|
40
|
+
'data-label': c.label,
|
|
41
|
+
}, c.cell(row));
|
|
42
|
+
// A cell stays on one line and ends in "…" when the column is narrow
|
|
43
|
+
// (flow.css, .fx-table--sized); the whole text shows on hover.
|
|
44
|
+
const text = td.textContent?.trim();
|
|
45
|
+
if (text && text !== '—') td.title = text;
|
|
46
|
+
return td;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── The table ──────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
// One menu for every column head, as a popover: made at the first open and
|
|
53
|
+
// kept for every page after.
|
|
54
|
+
function columnPopover(h) {
|
|
55
|
+
let pop = null;
|
|
56
|
+
const close = () => pop?.hidePopover?.();
|
|
57
|
+
const item = (icon, text, run) => h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitem', onclick: () => { close(); run(); } },
|
|
58
|
+
h('span', { class: 'fx-pop__tick' }, lucide(h, icon)), h('span', {}, text));
|
|
59
|
+
const sep = () => h('div', { class: 'fx-pop__sep' });
|
|
60
|
+
// Open with these nodes, at the pointer or under the node clicked.
|
|
61
|
+
function show(parts, event) {
|
|
62
|
+
pop = document.getElementById('fx-colmenu');
|
|
63
|
+
if (!pop) {
|
|
64
|
+
pop = h('div', { class: 'fx-pop', id: 'fx-colmenu', popover: 'auto', role: 'menu' });
|
|
65
|
+
document.body.append(pop);
|
|
66
|
+
}
|
|
67
|
+
pop.replaceChildren(...parts);
|
|
68
|
+
pop.showPopover?.();
|
|
69
|
+
const box = event.currentTarget?.getBoundingClientRect?.();
|
|
70
|
+
const x = event.clientX || box?.left || 0;
|
|
71
|
+
const y = event.clientY || box?.bottom || 0;
|
|
72
|
+
pop.style.top = `${Math.round(Math.min(y + 4, innerHeight - pop.offsetHeight - 8))}px`;
|
|
73
|
+
pop.style.left = `${Math.round(Math.max(8, Math.min(x, innerWidth - pop.offsetWidth - 8)))}px`;
|
|
74
|
+
}
|
|
75
|
+
return { close, item, sep, show };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// A popover opened while the right button is still down closes again on
|
|
79
|
+
// its release (light dismiss), so open it after the release.
|
|
80
|
+
export function onRightButton(event, open) {
|
|
81
|
+
if (event.buttons & 2) addEventListener('pointerup', () => setTimeout(open), { once: true });
|
|
82
|
+
else open();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function columnTable(h, config) {
|
|
86
|
+
const menu = columnPopover(h);
|
|
87
|
+
const ids = () => config.order?.() ?? [];
|
|
88
|
+
const widths = () => config.widths?.() ?? {};
|
|
89
|
+
|
|
90
|
+
function openMenu(column, event) {
|
|
91
|
+
const parts = [];
|
|
92
|
+
if (column) {
|
|
93
|
+
parts.push(...(config.extra?.(column, menu.item) ?? []));
|
|
94
|
+
if (!column.fixed) {
|
|
95
|
+
const order = ids();
|
|
96
|
+
parts.push(menu.item('arrow-left-to-line', 'Move to start', () => config.change({ columns: moveColumn(order, column.id, 0) })),
|
|
97
|
+
menu.item('arrow-right-to-line', 'Move to end', () => config.change({ columns: moveColumn(order, column.id, order.length) })));
|
|
98
|
+
if (config.hidden) parts.push(menu.sep(), menu.item('eye-off', 'Hide column', () => config.change({ columns: order.filter((id) => id !== column.id) })));
|
|
99
|
+
}
|
|
100
|
+
if (widths()[column.id] != null) {
|
|
101
|
+
parts.push(menu.item('move-horizontal', 'Reset width', () => config.change({ widths: withoutKey(widths(), column.id) })));
|
|
102
|
+
}
|
|
103
|
+
} else {
|
|
104
|
+
const hidden = config.hidden?.() ?? [];
|
|
105
|
+
parts.push(...hidden.map((c) => h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitem', onclick: () => { menu.close(); config.change({ columns: [...ids(), c.id] }); } }, c.label)));
|
|
106
|
+
if (!hidden.length) parts.push(h('p', { class: 'fx-pop__head' }, 'Every column shows'));
|
|
107
|
+
}
|
|
108
|
+
if (parts.length) menu.show(parts, event);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function table({ columns, rows, lead = [], className = '' }) {
|
|
112
|
+
const saved = widths();
|
|
113
|
+
// Each column keeps its own width: the table is as wide as its columns,
|
|
114
|
+
// never stretched to the page. Space left over stays empty at the right.
|
|
115
|
+
// A resize changes the table only; the tools stay put.
|
|
116
|
+
const cols = columns.map((c) => h('col', { style: { width: `${widthOf(c, saved)}px` } }));
|
|
117
|
+
const leadCols = lead.map(() => h('col', { style: { width: `${LEAD_WIDTH}px` } }));
|
|
118
|
+
const tail = config.hidden ? h('th', { class: 'fx-th-add' }, h('button', {
|
|
119
|
+
type: 'button', class: 'fx-th fx-th--add', 'aria-label': 'Show a column', title: 'Show a column', onclick: (event) => openMenu(null, event),
|
|
120
|
+
}, '+')) : h('th', { class: 'fx-th-end', 'aria-hidden': 'true' });
|
|
121
|
+
const heads = columns.map((c, n) => head(c, n));
|
|
122
|
+
const node = h('table', { class: `fx-table fx-table--sized ${className}`.trim() },
|
|
123
|
+
h('colgroup', {}, leadCols, cols, h('col', { style: { width: `${TAIL_WIDTH}px` } })),
|
|
124
|
+
h('thead', {}, h('tr', {}, lead, heads, tail)),
|
|
125
|
+
rows);
|
|
126
|
+
// The table is as wide as its columns; past the page it scrolls sideways.
|
|
127
|
+
const fit = () => { node.style.width = `${columns.reduce((sum, c) => sum + widthOf(c, widths()), 0) + lead.length * LEAD_WIDTH + TAIL_WIDTH}px`; };
|
|
128
|
+
fit();
|
|
129
|
+
return h('div', { class: 'fx-table-scroll' }, node);
|
|
130
|
+
|
|
131
|
+
function head(c, n) {
|
|
132
|
+
const button = h('button', {
|
|
133
|
+
type: 'button', class: c.fixed ? 'fx-th fx-th--fixed' : 'fx-th', 'aria-haspopup': 'menu',
|
|
134
|
+
title: c.fixed ? c.label : `${c.label}. Drag to move, click for the menu`,
|
|
135
|
+
onclick: (event) => { if (!dragged) openMenu(c, event); },
|
|
136
|
+
}, c.icon ? lucide(h, c.icon, c.label) : c.label);
|
|
137
|
+
const grip = h('span', {
|
|
138
|
+
class: 'fx-th-grip', role: 'separator', tabindex: '0', 'aria-orientation': 'vertical',
|
|
139
|
+
'aria-label': `${c.label} width`, title: 'Drag to resize. Double-click to reset',
|
|
140
|
+
'aria-valuemin': String(minWidth(c.kind)), 'aria-valuemax': String(MAX_WIDTH), 'aria-valuenow': String(widthOf(c, widths())),
|
|
141
|
+
});
|
|
142
|
+
const th = h('th', {
|
|
143
|
+
class: [c.num ? 'fx-num' : null, c.fixed ? null : 'fx-th--drag'].filter(Boolean).join(' ') || null,
|
|
144
|
+
'data-col': c.id,
|
|
145
|
+
oncontextmenu: (event) => { event.preventDefault(); onRightButton(event, () => openMenu(c, event)); },
|
|
146
|
+
}, h('span', { class: 'fx-th-cell' }, button, c.sort?.() ?? null), grip);
|
|
147
|
+
resizer(grip, c, n);
|
|
148
|
+
if (!c.fixed) dragger(button, th, c);
|
|
149
|
+
return th;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Drag the edge: the column follows the pointer, kept to its minimum;
|
|
153
|
+
// the width saves on release. Arrow keys step it; a double-click resets.
|
|
154
|
+
function resizer(grip, c, n) {
|
|
155
|
+
const col = cols[n];
|
|
156
|
+
const set = (px) => {
|
|
157
|
+
const w = Math.round(Math.min(MAX_WIDTH, Math.max(minWidth(c.kind), px)));
|
|
158
|
+
col.style.width = `${w}px`;
|
|
159
|
+
grip.setAttribute('aria-valuenow', String(w));
|
|
160
|
+
return w;
|
|
161
|
+
};
|
|
162
|
+
const save = (w) => config.change({ widths: { ...widths(), [c.id]: w } });
|
|
163
|
+
grip.addEventListener('pointerdown', (event) => {
|
|
164
|
+
if (event.button !== 0) return;
|
|
165
|
+
event.preventDefault();
|
|
166
|
+
event.stopPropagation();
|
|
167
|
+
grip.setPointerCapture(event.pointerId);
|
|
168
|
+
const from = event.clientX;
|
|
169
|
+
const start = col.getBoundingClientRect().width || widthOf(c, widths());
|
|
170
|
+
const base = node.getBoundingClientRect().width;
|
|
171
|
+
let w = start;
|
|
172
|
+
node.dataset.resizing = '';
|
|
173
|
+
const move = (e) => {
|
|
174
|
+
w = set(start + e.clientX - from);
|
|
175
|
+
node.style.width = `${base + w - start}px`;
|
|
176
|
+
};
|
|
177
|
+
const up = () => {
|
|
178
|
+
grip.removeEventListener('pointermove', move);
|
|
179
|
+
delete node.dataset.resizing;
|
|
180
|
+
if (Math.round(w) !== Math.round(start)) save(w);
|
|
181
|
+
};
|
|
182
|
+
grip.addEventListener('pointermove', move);
|
|
183
|
+
grip.addEventListener('pointerup', up, { once: true });
|
|
184
|
+
grip.addEventListener('pointercancel', up, { once: true });
|
|
185
|
+
});
|
|
186
|
+
grip.addEventListener('click', (event) => event.stopPropagation());
|
|
187
|
+
grip.addEventListener('dblclick', (event) => {
|
|
188
|
+
event.stopPropagation();
|
|
189
|
+
config.change({ widths: withoutKey(widths(), c.id) });
|
|
190
|
+
});
|
|
191
|
+
grip.addEventListener('keydown', (event) => {
|
|
192
|
+
const step = event.key === 'ArrowRight' ? 16 : event.key === 'ArrowLeft' ? -16 : 0;
|
|
193
|
+
if (!step) return;
|
|
194
|
+
event.preventDefault();
|
|
195
|
+
save(set(widthOf(c, widths()) + step));
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Drag a head: past a few pixels it lifts, a line shows where it lands,
|
|
201
|
+
// and the release moves it there. A click without a drag opens the menu.
|
|
202
|
+
let dragged = false;
|
|
203
|
+
function dragger(button, th, c) {
|
|
204
|
+
button.addEventListener('pointerdown', (event) => {
|
|
205
|
+
if (event.button !== 0) return;
|
|
206
|
+
const row = th.parentElement;
|
|
207
|
+
const from = event.clientX;
|
|
208
|
+
let target = null;
|
|
209
|
+
dragged = false;
|
|
210
|
+
const heads = () => [...row.querySelectorAll('th.fx-th--drag')];
|
|
211
|
+
const clear = () => { for (const other of row.children) other.removeAttribute('data-drop'); };
|
|
212
|
+
const move = (e) => {
|
|
213
|
+
if (!dragged && Math.abs(e.clientX - from) < 6) return;
|
|
214
|
+
if (!dragged) {
|
|
215
|
+
dragged = true;
|
|
216
|
+
button.setPointerCapture(event.pointerId);
|
|
217
|
+
th.dataset.dragging = '';
|
|
218
|
+
}
|
|
219
|
+
clear();
|
|
220
|
+
target = null;
|
|
221
|
+
for (const other of heads()) {
|
|
222
|
+
const box = other.getBoundingClientRect();
|
|
223
|
+
if (e.clientX >= box.left && e.clientX < box.right && other !== th) {
|
|
224
|
+
const side = e.clientX < box.left + box.width / 2 ? 'before' : 'after';
|
|
225
|
+
other.dataset.drop = side;
|
|
226
|
+
target = { id: other.dataset.col, side };
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
const up = () => {
|
|
231
|
+
removeEventListener('pointermove', move);
|
|
232
|
+
delete th.dataset.dragging;
|
|
233
|
+
clear();
|
|
234
|
+
if (dragged && target) {
|
|
235
|
+
const order = ids().filter((id) => id !== c.id);
|
|
236
|
+
const at = order.indexOf(target.id) + (target.side === 'after' ? 1 : 0);
|
|
237
|
+
config.change({ columns: moveColumn(ids(), c.id, at) });
|
|
238
|
+
}
|
|
239
|
+
// The click that follows a drag does not open the menu.
|
|
240
|
+
setTimeout(() => { dragged = false; });
|
|
241
|
+
};
|
|
242
|
+
addEventListener('pointermove', move);
|
|
243
|
+
addEventListener('pointerup', up, { once: true });
|
|
244
|
+
addEventListener('pointercancel', up, { once: true });
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return { table, menu, openMenu };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function withoutKey(object, key) {
|
|
252
|
+
const { [key]: _gone, ...rest } = object;
|
|
253
|
+
return rest;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Saves a page's view with the person's account (PUT /api/me/views/:page)
|
|
257
|
+
// shortly after a change, so a few quick changes make one request.
|
|
258
|
+
export function viewSaver(ui, page) {
|
|
259
|
+
let timer = null;
|
|
260
|
+
return (view) => {
|
|
261
|
+
if (ui.me) ui.me.views = { ...ui.me.views, [page]: view };
|
|
262
|
+
clearTimeout(timer);
|
|
263
|
+
timer = setTimeout(() => {
|
|
264
|
+
ui.flow(`/api/me/views/${page}`, { method: 'PUT', body: JSON.stringify(view) })
|
|
265
|
+
.catch(() => ui.toast?.('Flow could not save your view. It applies on this page only.', 'danger'));
|
|
266
|
+
}, 500);
|
|
267
|
+
};
|
|
268
|
+
}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// passes in app.js's shared pieces as `ui`.
|
|
15
15
|
|
|
16
16
|
import { orderStatuses, statusRank } from '/assets/ui/job-board.js';
|
|
17
|
+
import { sidecar } from '/assets/ui/sidecar.js';
|
|
17
18
|
|
|
18
19
|
const DOCS = 'https://github.com/arcforgelabs/flow/blob/main/docs/CUSTOM_FIELDS.md';
|
|
19
20
|
const TYPE_LABELS = {
|
|
@@ -96,7 +97,7 @@ export async function renderCustomFields(main, ui) {
|
|
|
96
97
|
const disabled = !['owner', 'admin'].includes(me?.person.role);
|
|
97
98
|
const host = h('div', { class: 'fx-card fx-cfm__card' }, h('div', { class: 'fx-skeleton' }));
|
|
98
99
|
const addButton = h('button', { class: 'fx-btn fx-btn--primary fx-btn--sm', type: 'button', disabled }, 'Create custom field');
|
|
99
|
-
main.append(pageHeader('Custom fields', {
|
|
100
|
+
main.append(pageHeader('Custom fields', { help: { href: DOCS, label: 'How custom fields work' } }), host);
|
|
100
101
|
|
|
101
102
|
let data = null;
|
|
102
103
|
const groupColor = (type) => data?.groupColors?.[type] ?? 'gray';
|
|
@@ -157,17 +158,19 @@ export async function renderCustomFields(main, ui) {
|
|
|
157
158
|
// the manager. Edit on a row (or Create) opens it; another row's Edit
|
|
158
159
|
// switches it at once, so a person moves between fields quickly. The
|
|
159
160
|
// table stays usable behind it. Save closes it; so do Cancel, × and Esc.
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
document.addEventListener('keydown', (event) => {
|
|
169
|
-
if (event.key === 'Escape' && side.isConnected && !side.hidden && !document.querySelector('dialog[open], .fx-pop:popover-open')) closeSide();
|
|
161
|
+
// The shared sidecar (sidecar.js), over the table.
|
|
162
|
+
const panel = sidecar(h, {
|
|
163
|
+
label: 'Custom field', overlay: true, escape: true,
|
|
164
|
+
onChange: (tab) => {
|
|
165
|
+
if (tab !== null) return;
|
|
166
|
+
sideFor = null;
|
|
167
|
+
table.querySelector('.fx-cfm__row[data-open]')?.removeAttribute('data-open');
|
|
168
|
+
},
|
|
170
169
|
});
|
|
170
|
+
const side = panel.element;
|
|
171
|
+
side.classList.add('fx-cfs');
|
|
172
|
+
let sideFor = null;
|
|
173
|
+
const closeSide = () => panel.close();
|
|
171
174
|
|
|
172
175
|
// Open the sidecar for a field (null: a new one; `draft` fills it after a
|
|
173
176
|
// failed save). A work system's field opens read only.
|
|
@@ -188,7 +191,7 @@ export async function renderCustomFields(main, ui) {
|
|
|
188
191
|
// lies over the label, so the label shows and the browser's list opens.
|
|
189
192
|
const typeText = h('span', {}, TYPE_LABELS[type.value]);
|
|
190
193
|
const headType = field ? typeLabel() : h('label', { class: 'fx-cfs__typepick' }, lucide(h, TYPE_ICONS[type.value]), typeText, lucide(h, 'chevron-down'), type);
|
|
191
|
-
const close =
|
|
194
|
+
const close = panel.closeButton();
|
|
192
195
|
|
|
193
196
|
const name = h('input', { class: 'fx-cfs__name', required: true, maxlength: '60', value: draft?.name ?? '', 'aria-label': 'Custom field name', placeholder: 'For example Priority', readonly: readOnly });
|
|
194
197
|
name.addEventListener('input', changed);
|
|
@@ -245,10 +248,9 @@ export async function renderCustomFields(main, ui) {
|
|
|
245
248
|
await load();
|
|
246
249
|
});
|
|
247
250
|
fit();
|
|
248
|
-
|
|
251
|
+
panel.show(form);
|
|
249
252
|
// Below the toolbar, so search and Create stay in reach.
|
|
250
253
|
side.style.top = `${toolbar.offsetHeight}px`;
|
|
251
|
-
side.hidden = false;
|
|
252
254
|
table.querySelector('.fx-cfm__row[data-open]')?.removeAttribute('data-open');
|
|
253
255
|
if (field) table.querySelector(`.fx-cfm__row[data-id="${CSS.escape(field.id)}"]`)?.setAttribute('data-open', 'true');
|
|
254
256
|
(readOnly ? close : name).focus({ preventScroll: true });
|
|
@@ -493,8 +495,7 @@ export async function renderCustomFields(main, ui) {
|
|
|
493
495
|
}
|
|
494
496
|
table.replaceChildren(...parts);
|
|
495
497
|
// The count is what the table shows, the work systems' fields too.
|
|
496
|
-
foot.replaceChildren(h('span', {}, `${count} ${count === 1 ? 'custom field' : 'custom fields'}`)
|
|
497
|
-
h('a', { href: DOCS, target: '_blank', rel: 'noopener' }, 'How custom fields work'));
|
|
498
|
+
foot.replaceChildren(h('span', {}, `${count} ${count === 1 ? 'custom field' : 'custom fields'}`));
|
|
498
499
|
}
|
|
499
500
|
const foot = h('div', { class: 'fx-cfm__foot' });
|
|
500
501
|
host.append(foot);
|