@fieldia/grid 0.11.0 → 0.13.0
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/package.json +3 -3
- package/src/lib/grid.js +904 -78
- package/src/lib/grid.js.map +1 -1
- package/src/lib/grid.mjs +906 -80
- package/src/lib/grid.mjs.map +1 -1
- package/src/lib/styles.d.mts +1 -1
- package/src/lib/styles.d.ts +1 -1
- package/src/lib/styles.js +77 -2
- package/src/lib/styles.js.map +1 -1
- package/src/lib/styles.mjs +77 -2
- package/src/lib/styles.mjs.map +1 -1
package/src/lib/grid.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { _stopPropagationForAgGrid, AllCommunityModule, createGrid, themeQuartz, } from 'ag-grid-community';
|
|
2
|
-
import { fill, lineKind } from '@fieldia/core';
|
|
2
|
+
import { fill, isRightToLeft, lineKind } from '@fieldia/core';
|
|
3
3
|
import { installGridStyles } from './styles.mjs';
|
|
4
|
-
import { createWidget,
|
|
4
|
+
import { cellText, createWidget, distributionIds, distributionModel, distributionWords, DRAWN_IN_CELLS, drawIcon, openLineDialog, kindTextField, lineForm, WIDGET_LABELS, } from '@fieldia/widgets';
|
|
5
5
|
/**
|
|
6
6
|
* A one2many as a spreadsheet, on AG Grid. Each cell is edited with the same
|
|
7
7
|
* Fieldia widget the plain table uses, wired to its line, so every keystroke
|
|
@@ -17,6 +17,9 @@ const apis = new WeakMap();
|
|
|
17
17
|
export function gridApiOf(element) {
|
|
18
18
|
return apis.get(element);
|
|
19
19
|
}
|
|
20
|
+
/** Everything a cell needs, handed to AG Grid's editors and renderers. */
|
|
21
|
+
/** The tones a line or a cell can take. */
|
|
22
|
+
const TONES = ['info', 'success', 'warning', 'danger', 'muted'];
|
|
20
23
|
/** The grid's own columns (the drag handle, the delete button) start with two underscores. */
|
|
21
24
|
const isTool = (id) => id.startsWith('__');
|
|
22
25
|
/** The column a section or note spans from: the first field shown. */
|
|
@@ -69,9 +72,143 @@ class OpenRenderer {
|
|
|
69
72
|
return true;
|
|
70
73
|
}
|
|
71
74
|
}
|
|
72
|
-
/**
|
|
75
|
+
/** A line's own buttons, each shown by its condition on the line; a press runs with the line. */
|
|
76
|
+
class RowButtonsRenderer {
|
|
77
|
+
box;
|
|
78
|
+
params;
|
|
79
|
+
init(params) {
|
|
80
|
+
this.params = params;
|
|
81
|
+
this.box = document.createElement('span');
|
|
82
|
+
this.box.className = 'fd-grid-row-buttons';
|
|
83
|
+
for (const own of params.buttons) {
|
|
84
|
+
const button = document.createElement('button');
|
|
85
|
+
button.type = 'button';
|
|
86
|
+
const icon = drawIcon(document, own.icon);
|
|
87
|
+
button.className = `fd-button fd-button-link fd-line-button${icon ? ' fd-line-button-icon' : ''}`;
|
|
88
|
+
button.dataset['rowButton'] = own.id;
|
|
89
|
+
button.setAttribute('aria-label', own.label);
|
|
90
|
+
button.title = own.label;
|
|
91
|
+
button.append(icon ?? own.label);
|
|
92
|
+
button.addEventListener('click', async (event) => {
|
|
93
|
+
event.stopPropagation();
|
|
94
|
+
const key = this.params.data?.key;
|
|
95
|
+
if (!key || button.hasAttribute('aria-busy'))
|
|
96
|
+
return;
|
|
97
|
+
button.setAttribute('aria-busy', 'true');
|
|
98
|
+
try {
|
|
99
|
+
await params.press(own.id, key);
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
button.removeAttribute('aria-busy');
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
this.box.append(button);
|
|
106
|
+
}
|
|
107
|
+
this.show();
|
|
108
|
+
}
|
|
109
|
+
show() {
|
|
110
|
+
const state = this.params.data ? this.params.stateOf(this.params.data.key) : undefined;
|
|
111
|
+
for (const button of this.box.querySelectorAll('button'))
|
|
112
|
+
button.hidden = !state?.buttons[button.dataset['rowButton']];
|
|
113
|
+
}
|
|
114
|
+
getGui() {
|
|
115
|
+
return this.box;
|
|
116
|
+
}
|
|
117
|
+
refresh(params) {
|
|
118
|
+
this.params = params;
|
|
119
|
+
this.show();
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/** A choice drawn as a pill in the tone its rules give it: Flectra's widget="badge". */
|
|
124
|
+
class BadgeRenderer {
|
|
125
|
+
pill;
|
|
126
|
+
init(params) {
|
|
127
|
+
this.pill = document.createElement('span');
|
|
128
|
+
this.refresh(params);
|
|
129
|
+
}
|
|
130
|
+
getGui() {
|
|
131
|
+
return this.pill;
|
|
132
|
+
}
|
|
133
|
+
refresh(params) {
|
|
134
|
+
const text = params.valueFormatted ?? String(params.value ?? '');
|
|
135
|
+
this.pill.className = text ? 'fd-grid-badge' : '';
|
|
136
|
+
this.pill.textContent = text;
|
|
137
|
+
const tone = params.toneOf(params.data);
|
|
138
|
+
if (tone)
|
|
139
|
+
this.pill.dataset['tone'] = tone;
|
|
140
|
+
else
|
|
141
|
+
delete this.pill.dataset['tone'];
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/** An analytic distribution's cell: each account by its name and its share, "Sales 60%, Marketing 40%"; its editor opens its lines. */
|
|
146
|
+
class DistributionRenderer {
|
|
147
|
+
text;
|
|
148
|
+
init(params) {
|
|
149
|
+
this.text = document.createElement('span');
|
|
150
|
+
this.text.className = 'fd-grid-distribution';
|
|
151
|
+
this.refresh(params);
|
|
152
|
+
}
|
|
153
|
+
getGui() {
|
|
154
|
+
return this.text;
|
|
155
|
+
}
|
|
156
|
+
refresh(params) {
|
|
157
|
+
const value = params.value;
|
|
158
|
+
const unknown = distributionIds(value).map(String).filter((id) => params.nameOf(id) === undefined);
|
|
159
|
+
if (unknown.length && params.data)
|
|
160
|
+
params.find(params.data, unknown);
|
|
161
|
+
this.text.textContent = distributionWords(value, params.locale, params.nameOf);
|
|
162
|
+
// All of it on pointing at it, when the column is narrower than its words.
|
|
163
|
+
this.text.title = this.text.textContent;
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* A cell drawn by a widget of its own — a progress bar, priority stars, a
|
|
169
|
+
* state's dot — used in the cell itself with a click, as the plain table's
|
|
170
|
+
* are: the same Fieldia widget, wired to its line.
|
|
171
|
+
*/
|
|
172
|
+
class DrawnRenderer {
|
|
173
|
+
box;
|
|
174
|
+
widget = null;
|
|
175
|
+
init(params) {
|
|
176
|
+
this.box = document.createElement('span');
|
|
177
|
+
this.box.className = 'fd-grid-drawn';
|
|
178
|
+
const { cell, subfield, look, data } = params;
|
|
179
|
+
if (data) {
|
|
180
|
+
const node = { type: 'field', id: `${cell.fieldId}.${data.key}.${subfield}`, field: subfield, widget: look.widget, ...(look.options ? { options: look.options } : {}) };
|
|
181
|
+
this.widget = createWidget({ form: lineForm(cell.form, cell.field, data.key), name: subfield, field: cell.defs[subfield], node, id: `${cell.fieldId}-${data.key}-${subfield}-cell`, document, labels: cell.labels, locale: cell.locale, dialogs: cell.dialogs }, cell.registry);
|
|
182
|
+
// Named by its column, as the plain table's cell is by its hidden label.
|
|
183
|
+
const named = this.widget.element.matches('[role]') ? this.widget.element : this.widget.element.querySelector('[role]');
|
|
184
|
+
named?.setAttribute('aria-label', cell.defs[subfield].label);
|
|
185
|
+
this.box.append(this.widget.element);
|
|
186
|
+
}
|
|
187
|
+
this.refresh(params);
|
|
188
|
+
}
|
|
189
|
+
getGui() {
|
|
190
|
+
return this.box;
|
|
191
|
+
}
|
|
192
|
+
refresh(params) {
|
|
193
|
+
const { cell, subfield, data } = params;
|
|
194
|
+
this.widget?.update({
|
|
195
|
+
value: data?.values[subfield],
|
|
196
|
+
values: data?.values ?? {},
|
|
197
|
+
parent: cell.form.getState().values,
|
|
198
|
+
readonly: params.locked(data),
|
|
199
|
+
required: false,
|
|
200
|
+
invalid: false,
|
|
201
|
+
});
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
destroy() {
|
|
205
|
+
this.widget?.destroy?.();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/** The delete button at the end of each line: hidden on a line the table keeps (`lineDelete`). */
|
|
73
209
|
class DeleteRenderer {
|
|
74
210
|
button;
|
|
211
|
+
params;
|
|
75
212
|
init(params) {
|
|
76
213
|
this.button = document.createElement('button');
|
|
77
214
|
this.button.type = 'button';
|
|
@@ -79,17 +216,91 @@ class DeleteRenderer {
|
|
|
79
216
|
this.button.textContent = '×';
|
|
80
217
|
this.button.setAttribute('aria-label', params.cell.labels.deleteLine);
|
|
81
218
|
this.button.addEventListener('click', () => {
|
|
82
|
-
|
|
83
|
-
|
|
219
|
+
const key = this.params.data?.key;
|
|
220
|
+
if (key && this.params.deletable(key))
|
|
221
|
+
this.params.cell.form.removeLine(this.params.cell.field, key);
|
|
84
222
|
});
|
|
223
|
+
this.refresh(params);
|
|
85
224
|
}
|
|
86
225
|
getGui() {
|
|
87
226
|
return this.button;
|
|
88
227
|
}
|
|
228
|
+
refresh(params) {
|
|
229
|
+
this.params = params;
|
|
230
|
+
this.button.hidden = !params.data || !params.deletable(params.data.key);
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/** A line's tick, choosing it for the table's buttons for chosen lines. */
|
|
235
|
+
class PickRenderer {
|
|
236
|
+
box;
|
|
237
|
+
params;
|
|
238
|
+
init(params) {
|
|
239
|
+
this.box = document.createElement('input');
|
|
240
|
+
this.box.type = 'checkbox';
|
|
241
|
+
this.box.className = 'fd-checkbox fd-line-pick';
|
|
242
|
+
this.box.addEventListener('change', () => {
|
|
243
|
+
if (this.params.data)
|
|
244
|
+
this.params.picking.choose(this.params.data.key, this.box.checked);
|
|
245
|
+
});
|
|
246
|
+
this.refresh(params);
|
|
247
|
+
}
|
|
248
|
+
getGui() {
|
|
249
|
+
return this.box;
|
|
250
|
+
}
|
|
251
|
+
refresh(params) {
|
|
252
|
+
this.params = params;
|
|
253
|
+
this.box.checked = !!params.data && params.picking.isChosen(params.data.key);
|
|
254
|
+
this.box.disabled = params.picking.readonly();
|
|
255
|
+
this.box.setAttribute('aria-label', params.picking.label((params.node.rowIndex ?? 0) + 1));
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/** The head's tick: every line chosen, or none. */
|
|
260
|
+
class PickAllHeader {
|
|
261
|
+
box;
|
|
262
|
+
init(params) {
|
|
263
|
+
this.box = document.createElement('input');
|
|
264
|
+
this.box.type = 'checkbox';
|
|
265
|
+
this.box.className = 'fd-checkbox fd-line-pick fd-line-pick-all';
|
|
266
|
+
this.box.setAttribute('aria-label', params.picking.allLabel);
|
|
267
|
+
this.box.addEventListener('change', () => params.picking.chooseAll(this.box.checked));
|
|
268
|
+
params.picking.head(this.box);
|
|
269
|
+
}
|
|
270
|
+
getGui() {
|
|
271
|
+
return this.box;
|
|
272
|
+
}
|
|
89
273
|
refresh() {
|
|
90
274
|
return true;
|
|
91
275
|
}
|
|
92
276
|
}
|
|
277
|
+
/** The button that puts a copy of a line right after it. */
|
|
278
|
+
class CopyRenderer {
|
|
279
|
+
button;
|
|
280
|
+
params;
|
|
281
|
+
init(params) {
|
|
282
|
+
this.button = document.createElement('button');
|
|
283
|
+
this.button.type = 'button';
|
|
284
|
+
this.button.className = 'fd-line-copy';
|
|
285
|
+
this.button.textContent = '⧉';
|
|
286
|
+
this.button.addEventListener('click', (event) => {
|
|
287
|
+
event.stopPropagation();
|
|
288
|
+
if (this.params.data)
|
|
289
|
+
this.params.copy(this.params.data.key);
|
|
290
|
+
});
|
|
291
|
+
this.refresh(params);
|
|
292
|
+
}
|
|
293
|
+
getGui() {
|
|
294
|
+
return this.button;
|
|
295
|
+
}
|
|
296
|
+
refresh(params) {
|
|
297
|
+
this.params = params;
|
|
298
|
+
const label = params.label((params.node.rowIndex ?? 0) + 1);
|
|
299
|
+
this.button.setAttribute('aria-label', label);
|
|
300
|
+
this.button.title = label;
|
|
301
|
+
return true;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
93
304
|
/** The header of the last column: a button that opens the column chooser. */
|
|
94
305
|
class ChooserHeader {
|
|
95
306
|
button;
|
|
@@ -118,6 +329,18 @@ class ChooserHeader {
|
|
|
118
329
|
return true;
|
|
119
330
|
}
|
|
120
331
|
}
|
|
332
|
+
/** What an empty grid says in its body: the page's words (`emptyLabel`), or a short line of its own. */
|
|
333
|
+
class EmptyOverlay {
|
|
334
|
+
line;
|
|
335
|
+
init(params) {
|
|
336
|
+
this.line = document.createElement('span');
|
|
337
|
+
this.line.className = 'fd-help fd-grid-empty';
|
|
338
|
+
this.line.textContent = params.words;
|
|
339
|
+
}
|
|
340
|
+
getGui() {
|
|
341
|
+
return this.line;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
121
344
|
/** A cell being edited: the field's own Fieldia widget, writing straight into its line. */
|
|
122
345
|
class FieldiaCellEditor {
|
|
123
346
|
box;
|
|
@@ -129,17 +352,22 @@ class FieldiaCellEditor {
|
|
|
129
352
|
/** The line field this editor writes: the column's, or a section's or note's text. */
|
|
130
353
|
column = '';
|
|
131
354
|
def;
|
|
355
|
+
/** A section or note being edited, not an item. */
|
|
356
|
+
kind = null;
|
|
132
357
|
note = null;
|
|
133
|
-
|
|
358
|
+
/** A tags box in a whole line open at once, which makes its line as tall as it needs. */
|
|
359
|
+
grows = false;
|
|
134
360
|
init(params) {
|
|
135
361
|
this.params = params;
|
|
136
362
|
// Not `column`: AG Grid's own params carry the column object under that name.
|
|
137
363
|
const { cell } = params;
|
|
138
364
|
const key = params.data.key;
|
|
139
|
-
const kind = cell.kindOf(params.data);
|
|
365
|
+
const kind = (this.kind = cell.kindOf(params.data));
|
|
140
366
|
const column = (this.column = kind && cell.kinds ? cell.kinds.text : params.subfield);
|
|
141
367
|
const def = (this.def = kind ? kindTextField(cell.defs[column], kind) : cell.defs[column]);
|
|
142
|
-
|
|
368
|
+
// A cell typed in its own way (hours as HH:MM, a per cent) is typed so in its editor too.
|
|
369
|
+
const look = kind ? undefined : cell.looks?.[column];
|
|
370
|
+
const node = { type: 'field', id: `${cell.fieldId}.${key}.${column}`, field: column, ...(look?.widget ? { widget: look.widget } : {}), ...(look?.options ? { options: look.options } : {}) };
|
|
143
371
|
const context = {
|
|
144
372
|
form: lineForm(cell.form, cell.field, key),
|
|
145
373
|
name: column,
|
|
@@ -159,14 +387,36 @@ class FieldiaCellEditor {
|
|
|
159
387
|
this.box.dataset['kind'] = kind;
|
|
160
388
|
this.box.setAttribute('aria-label', def.label);
|
|
161
389
|
this.box.append(this.widget.element);
|
|
390
|
+
// A whole line open at once keeps its editors in the line (AG Grid has no popups then): a tags box makes the line as tall as it needs.
|
|
391
|
+
if (cell.rowMode && def.type === 'many2many') {
|
|
392
|
+
this.grows = true;
|
|
393
|
+
this.box.classList.add('fd-grid-editor-grows');
|
|
394
|
+
if (typeof ResizeObserver !== 'undefined')
|
|
395
|
+
new ResizeObserver(() => this.fitLine()).observe(this.box);
|
|
396
|
+
}
|
|
162
397
|
if (kind === 'note')
|
|
163
398
|
this.growing(this.box.querySelector('textarea'));
|
|
164
|
-
if (
|
|
165
|
-
this.
|
|
399
|
+
else if (def.type === 'text')
|
|
400
|
+
this.paragraph(this.box.querySelector('textarea'));
|
|
166
401
|
if (this.isPopup()) {
|
|
167
|
-
// AG Grid places a popup over its cell but leaves its size to the editor.
|
|
402
|
+
// AG Grid places a popup over its cell but leaves its size to the editor; a distribution's lines take the room they need.
|
|
168
403
|
const { width, height } = params.eGridCell.getBoundingClientRect();
|
|
169
|
-
|
|
404
|
+
if (def.type === 'json')
|
|
405
|
+
Object.assign(this.box.style, { minWidth: `${Math.max(width, 360)}px` });
|
|
406
|
+
// Tags wrap in a box of their own, as wide as a few need and as tall as they take, never spilling over the lines around.
|
|
407
|
+
else if (def.type === 'many2many')
|
|
408
|
+
Object.assign(this.box.style, { minWidth: `${Math.max(width, 280)}px`, minHeight: `${height}px` });
|
|
409
|
+
// A paragraph is typed in a box of several lines, wide enough to read them, never spilling over the rows around.
|
|
410
|
+
else if (def.type === 'text')
|
|
411
|
+
Object.assign(this.box.style, { minWidth: `${Math.max(width, 320)}px`, minHeight: `${height}px` });
|
|
412
|
+
// A date is as wide as its day and time need (the stylesheet's max-content), its cell's width at the least.
|
|
413
|
+
else if (def.type === 'date' || def.type === 'datetime')
|
|
414
|
+
Object.assign(this.box.style, { minWidth: `${width}px`, height: `${height}px` });
|
|
415
|
+
// A link is searched by its name: wide enough to read one over a narrow column, on its row's line.
|
|
416
|
+
else if (def.type === 'many2one' || def.type === 'reference')
|
|
417
|
+
Object.assign(this.box.style, { minWidth: `${Math.max(width, 240)}px`, height: `${height}px` });
|
|
418
|
+
else
|
|
419
|
+
Object.assign(this.box.style, { width: `${width}px`, height: `${height}px` });
|
|
170
420
|
}
|
|
171
421
|
// The form never changes values in place, so holding them is enough.
|
|
172
422
|
this.before = this.line()?.values ?? {};
|
|
@@ -177,6 +427,21 @@ class FieldiaCellEditor {
|
|
|
177
427
|
else if (event.key === 'Escape')
|
|
178
428
|
params.cell.cancel(key, this.before);
|
|
179
429
|
});
|
|
430
|
+
// A day picked from a date's calendar is the whole answer: the cell keeps it and closes, as a
|
|
431
|
+
// spreadsheet's does. The browser says change while a date is typed too (once the year makes
|
|
432
|
+
// one), so a change right after a key is typing, which Enter or Tab commits. A date with a time
|
|
433
|
+
// stays open for its time, and a whole line open at once stays open.
|
|
434
|
+
if (def.type === 'date' && !cell.rowMode) {
|
|
435
|
+
let typedAt = 0;
|
|
436
|
+
this.box.addEventListener('keydown', () => (typedAt = Date.now()), true);
|
|
437
|
+
this.box.addEventListener('change', (event) => {
|
|
438
|
+
if (event.target.type !== 'date' || Date.now() - typedAt < 500)
|
|
439
|
+
return;
|
|
440
|
+
params.stopEditing();
|
|
441
|
+
});
|
|
442
|
+
// Fieldia's own calendar (options.weekNumbers) sets the day itself, and says it was picked.
|
|
443
|
+
this.box.addEventListener('fd-picked', () => params.stopEditing());
|
|
444
|
+
}
|
|
180
445
|
// Like a spreadsheet, a cell reached from the keyboard has its text selected,
|
|
181
446
|
// so typing replaces it; a click still puts the caret where it lands.
|
|
182
447
|
let pointing = false;
|
|
@@ -184,15 +449,17 @@ class FieldiaCellEditor {
|
|
|
184
449
|
this.box.addEventListener('focusin', (event) => {
|
|
185
450
|
const input = event.target;
|
|
186
451
|
if (!pointing && input instanceof HTMLInputElement && input.type !== 'checkbox' && input.type !== 'radio')
|
|
187
|
-
input
|
|
452
|
+
selectFromStart(input);
|
|
188
453
|
pointing = false;
|
|
189
454
|
});
|
|
455
|
+
// A value longer than its cell has room for takes the cell's padding too.
|
|
456
|
+
this.box.addEventListener('input', () => this.fit());
|
|
190
457
|
const show = () => {
|
|
191
458
|
const line = this.line();
|
|
192
459
|
// A cell the form found wrong stays marked while it is fixed.
|
|
193
460
|
const invalid = !!cell.form.getState().errors[`${cell.field}.${key}.${column}`];
|
|
194
461
|
this.box.classList.toggle('fd-grid-editor-invalid', invalid);
|
|
195
|
-
this.widget.update({ value: line?.values[column], values: line?.values ?? {}, readonly: false, required: def.required === true, invalid });
|
|
462
|
+
this.widget.update({ value: line?.values[column], values: line?.values ?? {}, parent: cell.form.getState().values, readonly: false, required: def.required === true, invalid });
|
|
196
463
|
};
|
|
197
464
|
show();
|
|
198
465
|
this.leave = cell.form.subscribe(show);
|
|
@@ -212,39 +479,65 @@ class FieldiaCellEditor {
|
|
|
212
479
|
});
|
|
213
480
|
// Enter makes a new line; Ctrl/Cmd+Enter finishes the note.
|
|
214
481
|
area.addEventListener('keydown', (event) => {
|
|
215
|
-
if (event.key !== 'Enter' ||
|
|
482
|
+
if (event.key !== 'Enter' || event.shiftKey || event.altKey)
|
|
216
483
|
return;
|
|
217
|
-
|
|
484
|
+
// The popup's own handler hears the key as well as the cell's (whose suppressKeyboardEvent leaves Enter alone).
|
|
218
485
|
_stopPropagationForAgGrid(event);
|
|
486
|
+
if (!(event.ctrlKey || event.metaKey))
|
|
487
|
+
return;
|
|
488
|
+
event.preventDefault();
|
|
219
489
|
this.params.api.stopEditing();
|
|
220
490
|
});
|
|
221
491
|
}
|
|
222
492
|
/**
|
|
223
|
-
*
|
|
224
|
-
*
|
|
493
|
+
* A paragraph (a text field) is typed in a box of several lines over its
|
|
494
|
+
* cell: Enter makes a new line there (the grid leaves it alone), and
|
|
495
|
+
* Ctrl/Cmd+Enter finishes it, as a note's does.
|
|
225
496
|
*/
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
if (!list)
|
|
497
|
+
paragraph(area) {
|
|
498
|
+
if (!area)
|
|
229
499
|
return;
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
layer.append(list);
|
|
233
|
-
const place = () => {
|
|
234
|
-
if (list.hidden || !this.box.isConnected)
|
|
500
|
+
area.addEventListener('keydown', (event) => {
|
|
501
|
+
if (event.key !== 'Enter' || event.shiftKey || event.altKey)
|
|
235
502
|
return;
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
};
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
503
|
+
// The popup's own handler hears the key as well as the cell's (whose suppressKeyboardEvent leaves Enter alone).
|
|
504
|
+
_stopPropagationForAgGrid(event);
|
|
505
|
+
if (!(event.ctrlKey || event.metaKey))
|
|
506
|
+
return;
|
|
507
|
+
event.preventDefault();
|
|
508
|
+
this.params.api.stopEditing();
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
/** A value wider than the room inside the cell's padding takes the padding too, so more of it shows. */
|
|
512
|
+
fit() {
|
|
513
|
+
const input = this.box.querySelector('input:not([type="checkbox"]):not([type="radio"])');
|
|
514
|
+
if (!input)
|
|
515
|
+
return;
|
|
516
|
+
if (this.isPopup()) {
|
|
517
|
+
// A link's name longer than its box widens the box to show it whole, as far as the window allows.
|
|
518
|
+
const short = input.scrollWidth - input.clientWidth;
|
|
519
|
+
if ((this.def.type === 'many2one' || this.def.type === 'reference') && short > 0) {
|
|
520
|
+
const { left, width } = this.box.getBoundingClientRect();
|
|
521
|
+
const room = this.box.ownerDocument.defaultView.innerWidth - left - 16;
|
|
522
|
+
this.box.style.minWidth = `${Math.round(Math.max(width, Math.min(width + short + 8, LINK_EDITOR_MOST, room)))}px`;
|
|
523
|
+
}
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
if (input.scrollWidth > input.clientWidth)
|
|
527
|
+
this.box.classList.add('fd-grid-editor-tight');
|
|
528
|
+
}
|
|
529
|
+
/** A line as tall as its tags box needs, never shorter than a line. */
|
|
530
|
+
fitLine() {
|
|
531
|
+
const { node, api } = this.params;
|
|
532
|
+
if (!this.box.isConnected || api.isDestroyed())
|
|
533
|
+
return;
|
|
534
|
+
const height = Math.max(ROW_HEIGHT, this.box.scrollHeight + 2);
|
|
535
|
+
if (height <= (node.rowHeight ?? 0))
|
|
536
|
+
return;
|
|
537
|
+
node.setRowHeight(height);
|
|
538
|
+
api.onRowHeightChanged();
|
|
247
539
|
}
|
|
540
|
+
/** A note's box as tall as its text, and its row with it. */
|
|
248
541
|
resize() {
|
|
249
542
|
const area = this.note;
|
|
250
543
|
if (!area)
|
|
@@ -276,6 +569,9 @@ class FieldiaCellEditor {
|
|
|
276
569
|
return this.box;
|
|
277
570
|
}
|
|
278
571
|
afterGuiAttached() {
|
|
572
|
+
this.fit();
|
|
573
|
+
if (this.grows)
|
|
574
|
+
this.fitLine();
|
|
279
575
|
// With a whole line open, every editor is attached: only the one clicked takes the focus.
|
|
280
576
|
if (this.params.cellStartedEdit === false)
|
|
281
577
|
return;
|
|
@@ -287,28 +583,34 @@ class FieldiaCellEditor {
|
|
|
287
583
|
}
|
|
288
584
|
const input = this.box.querySelector('input');
|
|
289
585
|
if (input && input.type !== 'checkbox' && document.activeElement === input)
|
|
290
|
-
input
|
|
586
|
+
selectFromStart(input);
|
|
291
587
|
}
|
|
292
588
|
getValue() {
|
|
293
589
|
return this.line()?.values[this.column];
|
|
294
590
|
}
|
|
295
|
-
/**
|
|
591
|
+
/**
|
|
592
|
+
* Lists that open below their input need room the cell does not have (a whole open line floats them instead); so do a
|
|
593
|
+
* distribution's lines, a date wider than its cell, and a paragraph's several lines (a note's grow in its own row).
|
|
594
|
+
*/
|
|
296
595
|
isPopup() {
|
|
297
596
|
if (this.params.cell.rowMode)
|
|
298
597
|
return false;
|
|
299
598
|
const type = this.def.type;
|
|
300
|
-
return type === 'many2one' || type === 'many2many' || type === 'reference';
|
|
599
|
+
return type === 'many2one' || type === 'many2many' || type === 'reference' || type === 'json' || type === 'date' || type === 'datetime' || (type === 'text' && !this.kind);
|
|
301
600
|
}
|
|
302
601
|
getPopupPosition() {
|
|
303
602
|
return 'over';
|
|
304
603
|
}
|
|
305
604
|
destroy() {
|
|
306
605
|
this.leave();
|
|
307
|
-
this.floating?.watch.disconnect();
|
|
308
|
-
this.floating?.list.remove();
|
|
309
606
|
this.widget.destroy?.();
|
|
310
607
|
const { node, api, cell, data } = this.params;
|
|
311
608
|
cell.finish(data.key);
|
|
609
|
+
// The line closed: it gives back the height its tags box took.
|
|
610
|
+
if (this.grows && !api.isDestroyed()) {
|
|
611
|
+
node.setRowHeight(null);
|
|
612
|
+
api.onRowHeightChanged();
|
|
613
|
+
}
|
|
312
614
|
// A note's row grew by hand while it was typed; drawing it afresh measures
|
|
313
615
|
// it again from its text (resetting the height would make it one line tall).
|
|
314
616
|
if (this.note) {
|
|
@@ -319,19 +621,61 @@ class FieldiaCellEditor {
|
|
|
319
621
|
}
|
|
320
622
|
}
|
|
321
623
|
}
|
|
624
|
+
/**
|
|
625
|
+
* A box's text selected so typing replaces it, shown from its start: a
|
|
626
|
+
* browser scrolls a long value to its end as it is selected, and its start is
|
|
627
|
+
* what reads. (A date's own input has no text selection to set.)
|
|
628
|
+
*/
|
|
629
|
+
function selectFromStart(input) {
|
|
630
|
+
try {
|
|
631
|
+
input.setSelectionRange(0, input.value.length, 'backward');
|
|
632
|
+
}
|
|
633
|
+
catch {
|
|
634
|
+
input.select();
|
|
635
|
+
}
|
|
636
|
+
input.scrollLeft = 0;
|
|
637
|
+
}
|
|
322
638
|
// ---- the widget -------------------------------------------------------------------
|
|
323
639
|
/** Past this many lines a table stops growing and scrolls inside, drawing only the rows in view. */
|
|
324
640
|
const LONG_TABLE = 15;
|
|
325
641
|
const ROW_HEIGHT = 40;
|
|
326
642
|
const HEADER_HEIGHT = 38;
|
|
327
643
|
const EDITABLE = new Set(['char', 'text', 'html', 'integer', 'float', 'monetary', 'date', 'datetime', 'selection', 'many2one', 'many2many', 'reference']);
|
|
644
|
+
/** A badge's pill round its words, both sides: room its column keeps besides theirs. */
|
|
645
|
+
const BADGE_PADDING = 20;
|
|
646
|
+
/** The kinds of field that hold words: a line is named by the first of them. */
|
|
647
|
+
const WORDS = new Set(['char', 'text', 'html', 'many2one', 'reference']);
|
|
648
|
+
/** The kinds of field whose column is as wide as its values need, in characters, as Flectra's lists size them. */
|
|
649
|
+
/** The widest a link's editor grows to show a long name, in pixels. */
|
|
650
|
+
const LINK_EDITOR_MOST = 480;
|
|
651
|
+
const FIXED_CHARS = { boolean: 4, integer: 4, float: 6, monetary: 14, date: 11, datetime: 17 };
|
|
328
652
|
export const gridWidget = ({ form, name, field, node, id, document, labels = WIDGET_LABELS.en, preferences, locale, dialogs }) => {
|
|
329
653
|
const def = field;
|
|
330
654
|
const kinds = def.lineKinds;
|
|
331
655
|
const sequence = def.sequenceField;
|
|
332
656
|
// The fields that say what a line is and keep the lines' order never show as columns.
|
|
333
|
-
|
|
657
|
+
// Lines a line holds are edited in its form, never drawn in its row.
|
|
658
|
+
const columns = (node.columns ?? Object.keys(def.fields)).filter((column) => def.fields[column] && def.fields[column].type !== 'one2many' && column !== kinds?.field && column !== sequence);
|
|
334
659
|
const kindOf = (line) => (line ? lineKind(def, line.values) : null);
|
|
660
|
+
// Its columns run the way the page reads; a column held at the start or the end of the line is held at that side.
|
|
661
|
+
const rtl = !!locale && isRightToLeft(locale);
|
|
662
|
+
const lineStart = rtl ? 'right' : 'left';
|
|
663
|
+
const lineEnd = rtl ? 'left' : 'right';
|
|
664
|
+
// The table's own rules, read by the form for each line as the form changes; a table with none asks nothing.
|
|
665
|
+
const ruled = !!(node.cells || node.rowTones || node.rowBold !== undefined || node.rowButtons || node.lineDelete !== undefined);
|
|
666
|
+
let lineStates = new Map();
|
|
667
|
+
/** Accounts' names an analytic distribution's cells show, by model and id, as they are found. */
|
|
668
|
+
const accountNames = new Map();
|
|
669
|
+
/** The analytic distributions among the columns, by column: their names found and said. */
|
|
670
|
+
const distributions = new Map();
|
|
671
|
+
/** Whether a line may be deleted now: always, unless the table's lineDelete keeps it. */
|
|
672
|
+
const deletable = (key) => node.lineDelete === undefined || (lineStates.get(key) ?? form.lineState(node.id, key)).deletable;
|
|
673
|
+
const ruleOf = (line, column) => (line ? lineStates.get(line.key)?.cells[column] : undefined);
|
|
674
|
+
/** Columns hidden by the record now. */
|
|
675
|
+
let ruleHidden = new Set();
|
|
676
|
+
/** The record's fields its money takes its currency from (`parent.currency_id`), and their values when last drawn. */
|
|
677
|
+
const parentCurrencies = [...new Set(Object.values(def.fields).flatMap((sub) => (sub.type === 'monetary' && sub.currencyField?.startsWith('parent.') ? [sub.currencyField.slice('parent.'.length)] : [])))];
|
|
678
|
+
let currencyShown = '';
|
|
335
679
|
// Columns a person may hide or show, and where their choices (and widths, and order) are kept.
|
|
336
680
|
const optional = node.optionalColumns ?? {};
|
|
337
681
|
const optionalIds = columns.filter((column) => column in optional);
|
|
@@ -358,6 +702,8 @@ export const gridWidget = ({ form, name, field, node, id, document, labels = WID
|
|
|
358
702
|
layer: element,
|
|
359
703
|
locale,
|
|
360
704
|
dialogs,
|
|
705
|
+
// Columns typed in a widget's own way, or with settings of their own (a calendar's week numbers): drawn ones are used in their cells instead.
|
|
706
|
+
looks: Object.fromEntries(Object.entries(node.cells ?? {}).flatMap(([column, rules]) => rules.widget ? (DRAWN_IN_CELLS.has(rules.widget) ? [] : [[column, { widget: rules.widget, ...(rules.options ? { options: rules.options } : {}) }]]) : rules.options ? [[column, { options: rules.options }]] : [])),
|
|
361
707
|
};
|
|
362
708
|
installGridStyles(document);
|
|
363
709
|
element.className = 'fd-grid-lines';
|
|
@@ -365,6 +711,15 @@ export const gridWidget = ({ form, name, field, node, id, document, labels = WID
|
|
|
365
711
|
element.setAttribute('role', 'group');
|
|
366
712
|
const host = document.createElement('div');
|
|
367
713
|
host.className = 'fd-grid-host';
|
|
714
|
+
// A press on a tick in a line — chosen, or a yes or no — is the tick's alone: the grid focusing its cell would
|
|
715
|
+
// scroll a cell cut by the screen's edge into view, moving the tick from under the pointer, and the click would
|
|
716
|
+
// land on the cell instead. (A button in a cell the grid leaves alone.) The tick still takes the focus and the click.
|
|
717
|
+
for (const type of ['pointerdown', 'mousedown']) {
|
|
718
|
+
host.addEventListener(type, (event) => {
|
|
719
|
+
if (event.target.closest?.('.ag-cell input[type="checkbox"]'))
|
|
720
|
+
event.stopPropagation();
|
|
721
|
+
}, true);
|
|
722
|
+
}
|
|
368
723
|
// What is wrong on which line, once a save has been refused.
|
|
369
724
|
const problems = document.createElement('div');
|
|
370
725
|
problems.className = 'fd-error fd-grid-problems';
|
|
@@ -372,7 +727,52 @@ export const gridWidget = ({ form, name, field, node, id, document, labels = WID
|
|
|
372
727
|
problems.hidden = true;
|
|
373
728
|
const adds = document.createElement('div');
|
|
374
729
|
adds.className = 'fd-lines-adds';
|
|
375
|
-
|
|
730
|
+
// Lines chosen for the table's buttons for them: a bar of those buttons over the grid while any is.
|
|
731
|
+
const choosing = !!node.selectedButtons?.length;
|
|
732
|
+
const chosen = new Set();
|
|
733
|
+
let pickAll = null;
|
|
734
|
+
const chosenCount = document.createElement('span');
|
|
735
|
+
chosenCount.className = 'fd-lines-chosen-count';
|
|
736
|
+
chosenCount.setAttribute('role', 'status');
|
|
737
|
+
/** A press that waits for its run, and is not pressed again meanwhile. */
|
|
738
|
+
const pressed = async (button, run) => {
|
|
739
|
+
if (button.hasAttribute('aria-busy'))
|
|
740
|
+
return;
|
|
741
|
+
button.setAttribute('aria-busy', 'true');
|
|
742
|
+
try {
|
|
743
|
+
await run();
|
|
744
|
+
}
|
|
745
|
+
finally {
|
|
746
|
+
button.removeAttribute('aria-busy');
|
|
747
|
+
}
|
|
748
|
+
};
|
|
749
|
+
const ownButton = (own, className) => {
|
|
750
|
+
const button = document.createElement('button');
|
|
751
|
+
button.type = 'button';
|
|
752
|
+
button.className = className;
|
|
753
|
+
button.dataset['node'] = own.id;
|
|
754
|
+
const icon = drawIcon(document, own.icon);
|
|
755
|
+
if (icon)
|
|
756
|
+
button.append(icon);
|
|
757
|
+
button.append(own.label);
|
|
758
|
+
return button;
|
|
759
|
+
};
|
|
760
|
+
const chosenButtons = (node.selectedButtons ?? []).map((own) => {
|
|
761
|
+
const button = ownButton(own, `fd-button fd-button-${own.style ?? 'secondary'} fd-lines-chosen-button`);
|
|
762
|
+
// In the table's order, whatever order they were ticked in.
|
|
763
|
+
button.addEventListener('click', () => void pressed(button, () => form.runLinesAction(node.id, own.id, lines().filter((line) => chosen.has(line.key)).map((line) => line.key))));
|
|
764
|
+
return { id: own.id, button };
|
|
765
|
+
});
|
|
766
|
+
const bar = document.createElement('div');
|
|
767
|
+
bar.className = 'fd-lines-chosen';
|
|
768
|
+
bar.hidden = true;
|
|
769
|
+
bar.append(chosenCount, ...chosenButtons.map((b) => b.button));
|
|
770
|
+
// Lines as cards too (cards: narrow or always): the stylesheet shows them in the grid's place on a narrow form.
|
|
771
|
+
const cardList = document.createElement('div');
|
|
772
|
+
cardList.className = 'fd-line-cards';
|
|
773
|
+
if (node.cards)
|
|
774
|
+
element.dataset['cards'] = node.cards;
|
|
775
|
+
element.append(...(choosing ? [bar] : []), ...(node.cards ? [cardList] : []), host, problems, adds);
|
|
376
776
|
let readonly = false;
|
|
377
777
|
const lines = () => form.getState().values[name] ?? [];
|
|
378
778
|
/** A line field no one types in: read-only, or worked out from the line's others. */
|
|
@@ -410,8 +810,9 @@ export const gridWidget = ({ form, name, field, node, id, document, labels = WID
|
|
|
410
810
|
if (!before)
|
|
411
811
|
return void fresh.delete(key);
|
|
412
812
|
cancelling.delete(key);
|
|
813
|
+
// A line added a moment ago is taken back, unless the table keeps it: then it stays, as it was begun.
|
|
413
814
|
if (fresh.delete(key))
|
|
414
|
-
return form.removeLine(name, key);
|
|
815
|
+
return deletable(key) ? form.removeLine(name, key) : undefined;
|
|
415
816
|
const current = lines();
|
|
416
817
|
if (current.some((l) => l.key === key))
|
|
417
818
|
form.setValue(name, current.map((l) => (l.key === key ? { ...l, values: before } : l)));
|
|
@@ -428,6 +829,8 @@ export const gridWidget = ({ form, name, field, node, id, document, labels = WID
|
|
|
428
829
|
api.setFocusedCell(index, column);
|
|
429
830
|
api.startEditingCell({ rowIndex: index, colKey: column });
|
|
430
831
|
};
|
|
832
|
+
/** A paragraph (a text field) of an item, edited in its box of several lines over the cell. */
|
|
833
|
+
const paragraphCell = (line, column) => !cell.rowMode && !kindOf(line) && def.fields[column]?.type === 'text';
|
|
431
834
|
/**
|
|
432
835
|
* Keys the grid must leave alone, and the two that run off the end of the
|
|
433
836
|
* table while editing: Enter on the last line and Tab on its last editable
|
|
@@ -439,8 +842,8 @@ export const gridWidget = ({ form, name, field, node, id, document, labels = WID
|
|
|
439
842
|
// Alt+Up/Down moves the line (below), it does not move the focus.
|
|
440
843
|
if (!editing && event.altKey && (event.key === 'ArrowUp' || event.key === 'ArrowDown'))
|
|
441
844
|
return true;
|
|
442
|
-
// In a note, Enter is a new line
|
|
443
|
-
if (editing && event.key === 'Enter' && !event.ctrlKey && !event.metaKey && kindOf(node.data) === 'note')
|
|
845
|
+
// In a note, or a paragraph opened over its cell, Enter is a new line (Ctrl/Cmd+Enter finishes it).
|
|
846
|
+
if (editing && event.key === 'Enter' && !event.ctrlKey && !event.metaKey && (kindOf(node.data) === 'note' || paragraphCell(node.data, column.getColId())))
|
|
444
847
|
return true;
|
|
445
848
|
if (!editing || event.type !== 'keydown' || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey)
|
|
446
849
|
return false;
|
|
@@ -456,16 +859,51 @@ export const gridWidget = ({ form, name, field, node, id, document, labels = WID
|
|
|
456
859
|
}
|
|
457
860
|
return false;
|
|
458
861
|
};
|
|
862
|
+
// Each column's share of the width by what it holds, as Flectra's lists size theirs: yes/no, numbers and dates by
|
|
863
|
+
// their kind, words and links by the longest the lines have — a long name gets the room a short code does not need.
|
|
864
|
+
const linesOf = (value) => (value ?? []).filter((line) => !kindOf(line));
|
|
865
|
+
const drawnFirst = linesOf(form.getState().values[name]);
|
|
866
|
+
/** A column whose values are drawn as pills (widget="badge"). */
|
|
867
|
+
const badged = (column) => !!node.cells?.[column]?.badge || node.cells?.[column]?.widget === 'badge';
|
|
868
|
+
/** Whether a column is as wide as its values, not a share of the width: yes/no, numbers, money, dates and badges, unless the grid shrinks to fit. */
|
|
869
|
+
const fixedWidth = (column) => (!!FIXED_CHARS[def.fields[column].type] || badged(column)) && node.fit !== 'shrink';
|
|
870
|
+
/** The room a column's values need, in pixels: its longest value, its kind's least, a short label — and the cell's padding. */
|
|
871
|
+
const roomOf = (column, lines = drawnFirst) => {
|
|
872
|
+
const sub = def.fields[column];
|
|
873
|
+
const least = FIXED_CHARS[sub.type] ?? 6;
|
|
874
|
+
let longest = sub.type === 'boolean' ? 0 : lines.reduce((most, line) => Math.max(most, cellText(sub, line.values[column], line.values, locale, form.getState().values, node.cells?.[column]).length), 0);
|
|
875
|
+
// A badge may turn to any of its choices: room for the longest, though no line holds it yet.
|
|
876
|
+
if (badged(column) && sub.type === 'selection')
|
|
877
|
+
longest = sub.options.reduce((most, option) => Math.max(most, option.label.length), longest);
|
|
878
|
+
// A long label over short numbers wraps onto a second line rather than widen its column; only its longest word sets a floor.
|
|
879
|
+
return Math.round(Math.min(Math.max(longest, least, labelWord(column)), 40) * 7.6 + 40) + (badged(column) ? BADGE_PADDING : 0);
|
|
880
|
+
};
|
|
881
|
+
/** Its label's longest word, in characters: a wrapping header breaks between words, never inside one. */
|
|
882
|
+
const labelWord = (column) => Math.min(Math.max(0, ...def.fields[column].label.split(/\s+/).map((word) => word.length)), 12);
|
|
883
|
+
/** The column a line is named by: its first of words (a name, a description, a link), else its first. */
|
|
884
|
+
const titleColumn = (shown) => shown.find((column) => WORDS.has(def.fields[column].type)) ?? shown[0];
|
|
885
|
+
/** A line's name by its place, "Line 3", when it has none to show. */
|
|
886
|
+
const placeName = (n) => {
|
|
887
|
+
const text = fill(labels.lineN, { n });
|
|
888
|
+
return text.charAt(0).toLocaleUpperCase(locale) + text.slice(1);
|
|
889
|
+
};
|
|
459
890
|
const columnDefs = columns.map((column) => {
|
|
460
891
|
const sub = def.fields[column];
|
|
892
|
+
const room = roomOf(column);
|
|
461
893
|
const numeric = sub.type === 'integer' || sub.type === 'float' || sub.type === 'monetary';
|
|
462
894
|
// Words get the room; numbers and yes/no take what they need.
|
|
463
895
|
const wide = ['char', 'text', 'html', 'many2one', 'many2many', 'reference', 'selection'].includes(sub.type);
|
|
464
896
|
/** Whether this column is where a section's or note's text starts. */
|
|
465
897
|
const spans = (api) => spanStart(api)?.getColId() === column;
|
|
898
|
+
/** An analytic distribution (a json line field shown as `distribution`): its cell says its accounts, its editor opens its lines. */
|
|
899
|
+
const distributed = sub.type === 'json' && node.cells?.[column]?.widget === 'distribution';
|
|
466
900
|
const base = {
|
|
467
901
|
colId: column,
|
|
468
902
|
headerName: sub.label,
|
|
903
|
+
// A long label wraps onto a second line rather than be cut, so two columns never read the same; all of it on pointing at it.
|
|
904
|
+
headerTooltip: sub.label,
|
|
905
|
+
wrapHeaderText: true,
|
|
906
|
+
autoHeaderHeight: true,
|
|
469
907
|
valueGetter: (p) => {
|
|
470
908
|
if (p.node?.rowPinned) {
|
|
471
909
|
if (totals.includes(column))
|
|
@@ -476,7 +914,12 @@ export const gridWidget = ({ form, name, field, node, id, document, labels = WID
|
|
|
476
914
|
return p.data?.values[column];
|
|
477
915
|
return kinds && spans(p.api) ? p.data?.values[kinds.text] : null;
|
|
478
916
|
},
|
|
479
|
-
valueFormatter: (p) => kindOf(p.data) || (p.node?.rowPinned && !totals.includes(column))
|
|
917
|
+
valueFormatter: (p) => kindOf(p.data) || (p.node?.rowPinned && !totals.includes(column))
|
|
918
|
+
? String(p.value ?? '')
|
|
919
|
+
: // Hidden by its line, the cell stays, blank, so the column lines up.
|
|
920
|
+
ruleOf(p.data, column)?.invisible
|
|
921
|
+
? ''
|
|
922
|
+
: cellText(sub, p.value, p.data?.values ?? {}, locale, form.getState().values, node.cells?.[column]),
|
|
480
923
|
type: numeric ? 'rightAligned' : undefined,
|
|
481
924
|
// A section or note runs across every column but the delete button.
|
|
482
925
|
colSpan: (p) => (kindOf(p.data) && spans(p.api) ? spanWidth(p.api) : 1),
|
|
@@ -485,23 +928,97 @@ export const gridWidget = ({ form, name, field, node, id, document, labels = WID
|
|
|
485
928
|
cellClassRules: {
|
|
486
929
|
'fd-grid-kind-text': (p) => !!kindOf(p.data),
|
|
487
930
|
'fd-grid-invalid': (p) => !p.node.rowPinned && !!problemAt(p.data, column, p.api),
|
|
931
|
+
'fd-cell-bold': (p) => !!ruleOf(p.data, column)?.bold,
|
|
932
|
+
...Object.fromEntries(TONES.map((tone) => [`fd-tone-${tone}`, (p) => ruleOf(p.data, column)?.tone === tone])),
|
|
488
933
|
},
|
|
489
934
|
tooltipValueGetter: (p) => (p.node?.rowPinned ? undefined : problemAt(p.data, column, p.api)),
|
|
490
935
|
suppressKeyboardEvent: keys,
|
|
491
|
-
//
|
|
492
|
-
minWidth:
|
|
493
|
-
|
|
936
|
+
// Numbers, money and dates as wide as their values, never cut; words share the rest by the room they need, giving way when it is tight.
|
|
937
|
+
minWidth: fixedWidth(column) ? 48 : Math.max(96, Math.round(labelWord(column) * 7.6 + 40)),
|
|
938
|
+
...(fixedWidth(column) ? { width: room } : { flex: room }),
|
|
494
939
|
hide: optional[column] === 'hide',
|
|
940
|
+
...(node.cells?.[column]?.width ? { width: node.cells[column].width * 8 + 32, flex: 0 } : {}),
|
|
941
|
+
// Columns as wide as what they hold (fit: content), sized once the lines are drawn.
|
|
942
|
+
...(node.fit === 'content' && !node.cells?.[column]?.width ? { flex: undefined, minWidth: 64 } : {}),
|
|
943
|
+
// Columns that shrink to fit (fit: shrink), as Flectra's list beside a chatter: narrower before the table scrolls, headers wrapping.
|
|
944
|
+
...(node.fit === 'shrink' ? { minWidth: wide ? 72 : sub.type === 'monetary' ? 80 : 48 } : {}),
|
|
495
945
|
editable: (p) => {
|
|
496
946
|
if (readonly || p.node.rowPinned)
|
|
497
947
|
return false;
|
|
498
948
|
if (kindOf(p.data))
|
|
499
949
|
return spans(p.api);
|
|
500
|
-
|
|
950
|
+
const rules = ruleOf(p.data, column);
|
|
951
|
+
return sub.type !== 'boolean' && (EDITABLE.has(sub.type) || distributed) && !locked(sub) && !rules?.readonly && !rules?.invisible;
|
|
501
952
|
},
|
|
502
953
|
cellEditor: FieldiaCellEditor,
|
|
503
954
|
cellEditorParams: { cell, subfield: column },
|
|
504
955
|
};
|
|
956
|
+
if (distributed) {
|
|
957
|
+
const model = distributionModel(node.cells?.[column]?.options);
|
|
958
|
+
const asked = new Set();
|
|
959
|
+
const params = {
|
|
960
|
+
model,
|
|
961
|
+
locale,
|
|
962
|
+
nameOf: (id) => accountNames.get(`${model}:${id}`),
|
|
963
|
+
find(line, ids) {
|
|
964
|
+
const fresh = ids.filter((id) => !asked.has(`${model}:${id}`));
|
|
965
|
+
if (!fresh.length)
|
|
966
|
+
return;
|
|
967
|
+
for (const id of fresh)
|
|
968
|
+
asked.add(`${model}:${id}`);
|
|
969
|
+
form.searchLine(name, line.key, column, '', fresh.length, { model, ids: fresh.map((id) => (/^\d+$/.test(id) ? Number(id) : id)) }).then((found) => {
|
|
970
|
+
for (const record of found)
|
|
971
|
+
accountNames.set(`${model}:${record.id}`, record.label);
|
|
972
|
+
if (api.isDestroyed())
|
|
973
|
+
return;
|
|
974
|
+
api.refreshCells({ columns: [column], force: true, suppressFlash: true });
|
|
975
|
+
// The cards say the accounts by their names too.
|
|
976
|
+
cardsDrawn = '';
|
|
977
|
+
drawCards();
|
|
978
|
+
}, () => undefined);
|
|
979
|
+
},
|
|
980
|
+
};
|
|
981
|
+
distributions.set(column, params);
|
|
982
|
+
return {
|
|
983
|
+
...base,
|
|
984
|
+
// Its accounts' names take room, as words do.
|
|
985
|
+
minWidth: node.fit === 'shrink' ? 96 : 180,
|
|
986
|
+
flex: 2,
|
|
987
|
+
cellRendererSelector: (p) => (kindOf(p.data) || p.node.rowPinned ? undefined : { component: DistributionRenderer, params }),
|
|
988
|
+
// Tab goes round the lines of its open editor; from its last box, on to the next cell.
|
|
989
|
+
suppressKeyboardEvent: (p) => {
|
|
990
|
+
if (p.editing && p.event.key === 'Tab') {
|
|
991
|
+
const editor = p.event.target?.closest?.('.fd-grid-editor');
|
|
992
|
+
const boxes = editor ? [...editor.querySelectorAll('input, button, select')].filter((b) => !b.closest('[hidden]') && !b.disabled) : [];
|
|
993
|
+
const at = boxes.indexOf(p.event.target);
|
|
994
|
+
const to = at + (p.event.shiftKey ? -1 : 1);
|
|
995
|
+
if (at >= 0 && to >= 0 && to < boxes.length)
|
|
996
|
+
return true;
|
|
997
|
+
}
|
|
998
|
+
return keys(p);
|
|
999
|
+
},
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
const drawn = node.cells?.[column]?.widget;
|
|
1003
|
+
if (drawn && DRAWN_IN_CELLS.has(drawn) && drawn !== 'badge') {
|
|
1004
|
+
const look = { widget: drawn, ...(node.cells?.[column]?.options ? { options: node.cells[column].options } : {}) };
|
|
1005
|
+
return {
|
|
1006
|
+
...base,
|
|
1007
|
+
// Used in the cell itself: no editor opens over it.
|
|
1008
|
+
editable: false,
|
|
1009
|
+
cellRendererSelector: (p) => kindOf(p.data) || p.node.rowPinned
|
|
1010
|
+
? undefined
|
|
1011
|
+
: { component: DrawnRenderer, params: { cell, subfield: column, look, locked: (line) => readonly || locked(sub) || !!ruleOf(line, column)?.readonly } },
|
|
1012
|
+
// Keys inside the widget are its own: its stars' arrows, its menu's.
|
|
1013
|
+
suppressKeyboardEvent: (p) => p.event.target?.closest?.('.fd-grid-drawn') ? true : keys(p),
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
if (node.cells?.[column]?.badge || drawn === 'badge') {
|
|
1017
|
+
return {
|
|
1018
|
+
...base,
|
|
1019
|
+
cellRendererSelector: (p) => (kindOf(p.data) || p.node.rowPinned ? undefined : { component: BadgeRenderer, params: { toneOf: (line) => ruleOf(line, column)?.tone ?? null } }),
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
505
1022
|
if (sub.type === 'boolean') {
|
|
506
1023
|
return {
|
|
507
1024
|
...base,
|
|
@@ -526,23 +1043,114 @@ export const gridWidget = ({ form, name, field, node, id, document, labels = WID
|
|
|
526
1043
|
resizable: false,
|
|
527
1044
|
sortable: false,
|
|
528
1045
|
suppressMovable: true,
|
|
529
|
-
lockPosition:
|
|
1046
|
+
lockPosition: lineStart,
|
|
1047
|
+
// Held in sight at the line's start, however far the lines scroll sideways.
|
|
1048
|
+
pinned: lineStart,
|
|
530
1049
|
suppressKeyboardEvent: keys,
|
|
531
1050
|
});
|
|
532
1051
|
}
|
|
533
|
-
//
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
1052
|
+
// A tick at the start of each item, choosing it for the table's buttons for chosen lines; the head's chooses every one.
|
|
1053
|
+
const picking = {
|
|
1054
|
+
label: (n) => fill(labels.chooseLine, { name: fill(labels.lineN, { n }) }),
|
|
1055
|
+
allLabel: labels.chooseAllLines,
|
|
1056
|
+
isChosen: (key) => chosen.has(key),
|
|
1057
|
+
choose(key, on) {
|
|
1058
|
+
if (on)
|
|
1059
|
+
chosen.add(key);
|
|
1060
|
+
else
|
|
1061
|
+
chosen.delete(key);
|
|
1062
|
+
showChosen(true);
|
|
1063
|
+
},
|
|
1064
|
+
chooseAll(on) {
|
|
1065
|
+
chosen.clear();
|
|
1066
|
+
if (on)
|
|
1067
|
+
for (const line of lines())
|
|
1068
|
+
if (!kindOf(line))
|
|
1069
|
+
chosen.add(line.key);
|
|
1070
|
+
showChosen(true);
|
|
1071
|
+
},
|
|
1072
|
+
head: (box) => {
|
|
1073
|
+
pickAll = box;
|
|
1074
|
+
showChosen(false);
|
|
1075
|
+
},
|
|
1076
|
+
readonly: () => readonly,
|
|
1077
|
+
};
|
|
1078
|
+
if (choosing) {
|
|
1079
|
+
columnDefs.unshift({
|
|
1080
|
+
colId: '__pick',
|
|
1081
|
+
headerName: '',
|
|
1082
|
+
headerComponent: PickAllHeader,
|
|
1083
|
+
headerComponentParams: { picking },
|
|
1084
|
+
width: 36,
|
|
1085
|
+
minWidth: 36,
|
|
1086
|
+
maxWidth: 36,
|
|
1087
|
+
cellClass: 'fd-grid-tools fd-grid-pick',
|
|
1088
|
+
resizable: false,
|
|
1089
|
+
sortable: false,
|
|
1090
|
+
suppressMovable: true,
|
|
1091
|
+
lockPosition: lineStart,
|
|
1092
|
+
// Held in sight at the line's start, however far the lines scroll sideways.
|
|
1093
|
+
pinned: lineStart,
|
|
1094
|
+
cellRendererSelector: (p) => (p.node.rowPinned || kindOf(p.data) ? undefined : { component: PickRenderer, params: { picking } }),
|
|
1095
|
+
suppressKeyboardEvent: keys,
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
// A copy of a line, its values too, right after it: never its saved id or its place in the order.
|
|
1099
|
+
const copyLine = (key) => {
|
|
1100
|
+
const all = lines();
|
|
1101
|
+
const at = all.findIndex((l) => l.key === key);
|
|
1102
|
+
if (readonly || at < 0)
|
|
542
1103
|
return;
|
|
543
|
-
|
|
544
|
-
|
|
1104
|
+
const values = { ...all[at].values };
|
|
1105
|
+
if (sequence)
|
|
1106
|
+
delete values[sequence];
|
|
1107
|
+
const made = form.addLine(name, values);
|
|
1108
|
+
if (sequence)
|
|
1109
|
+
form.moveLine(name, made, at + 1);
|
|
1110
|
+
else {
|
|
1111
|
+
const now = [...lines()];
|
|
1112
|
+
now.splice(at + 1, 0, ...now.splice(now.findIndex((l) => l.key === made), 1));
|
|
1113
|
+
form.setValue(name, now);
|
|
1114
|
+
}
|
|
545
1115
|
};
|
|
1116
|
+
if (node.options?.['copy'] === true) {
|
|
1117
|
+
columnDefs.push({
|
|
1118
|
+
colId: '__copy',
|
|
1119
|
+
headerName: '',
|
|
1120
|
+
width: 40,
|
|
1121
|
+
minWidth: 40,
|
|
1122
|
+
cellClass: 'fd-grid-tools',
|
|
1123
|
+
resizable: false,
|
|
1124
|
+
sortable: false,
|
|
1125
|
+
suppressMovable: true,
|
|
1126
|
+
lockPosition: lineEnd,
|
|
1127
|
+
pinned: lineEnd,
|
|
1128
|
+
cellRendererSelector: (p) => (p.node.rowPinned || kindOf(p.data) ? undefined : { component: CopyRenderer, params: { copy: copyLine, label: (n) => fill(labels.copy, { name: fill(labels.lineN, { n }) }) } }),
|
|
1129
|
+
suppressKeyboardEvent: keys,
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
// AG Grid copies column definitions deeply, so the button calls through, never a copy.
|
|
1133
|
+
// With dialogs, a line can be opened to see and edit every one of its fields at once.
|
|
1134
|
+
// Its own record's page, by the table's model (lineOpens: "record"), or every one of its fields.
|
|
1135
|
+
const openLine = (line) => (dialogs ? openLineDialog(form, name, node, line, dialogs, readonly) : undefined);
|
|
1136
|
+
if (node.rowButtons?.length) {
|
|
1137
|
+
columnDefs.push({
|
|
1138
|
+
colId: '__row_buttons',
|
|
1139
|
+
headerName: '',
|
|
1140
|
+
// An icon takes a small square; words, what they need.
|
|
1141
|
+
width: 16 + node.rowButtons.reduce((total, b) => total + (b.icon ? 28 : 16 + b.label.length * 7), 0),
|
|
1142
|
+
cellClass: 'fd-grid-tools',
|
|
1143
|
+
resizable: false,
|
|
1144
|
+
sortable: false,
|
|
1145
|
+
suppressMovable: true,
|
|
1146
|
+
lockPosition: lineEnd,
|
|
1147
|
+
pinned: lineEnd,
|
|
1148
|
+
cellRendererSelector: (p) => p.node.rowPinned || kindOf(p.data)
|
|
1149
|
+
? undefined
|
|
1150
|
+
: { component: RowButtonsRenderer, params: { buttons: node.rowButtons, stateOf: (key) => lineStates.get(key), press: (id, key) => form.runRowAction(node.id, id, key) } },
|
|
1151
|
+
suppressKeyboardEvent: keys,
|
|
1152
|
+
});
|
|
1153
|
+
}
|
|
546
1154
|
if (dialogs) {
|
|
547
1155
|
columnDefs.push({
|
|
548
1156
|
colId: '__open',
|
|
@@ -554,7 +1162,8 @@ export const gridWidget = ({ form, name, field, node, id, document, labels = WID
|
|
|
554
1162
|
resizable: false,
|
|
555
1163
|
sortable: false,
|
|
556
1164
|
suppressMovable: true,
|
|
557
|
-
lockPosition:
|
|
1165
|
+
lockPosition: lineEnd,
|
|
1166
|
+
pinned: lineEnd,
|
|
558
1167
|
cellRendererSelector: (p) => (p.node.rowPinned || kindOf(p.data) ? undefined : { component: OpenRenderer, params: { open: openLine, label: labels.openLine } }),
|
|
559
1168
|
suppressKeyboardEvent: keys,
|
|
560
1169
|
});
|
|
@@ -563,7 +1172,9 @@ export const gridWidget = ({ form, name, field, node, id, document, labels = WID
|
|
|
563
1172
|
columnDefs.push({
|
|
564
1173
|
colId: '__delete',
|
|
565
1174
|
headerName: '',
|
|
566
|
-
lockPosition:
|
|
1175
|
+
lockPosition: lineEnd,
|
|
1176
|
+
// A line's buttons held in sight at its end, however far the lines scroll sideways.
|
|
1177
|
+
pinned: lineEnd,
|
|
567
1178
|
...(optionalIds.length ? { headerComponent: ChooserHeader, headerComponentParams: { chooser } } : {}),
|
|
568
1179
|
width: 44,
|
|
569
1180
|
minWidth: 44,
|
|
@@ -572,9 +1183,14 @@ export const gridWidget = ({ form, name, field, node, id, document, labels = WID
|
|
|
572
1183
|
resizable: false,
|
|
573
1184
|
sortable: false,
|
|
574
1185
|
suppressMovable: true,
|
|
575
|
-
cellRendererSelector: (p) => (p.node.rowPinned ? undefined : { component: DeleteRenderer, params: { cell } }),
|
|
1186
|
+
cellRendererSelector: (p) => (p.node.rowPinned ? undefined : { component: DeleteRenderer, params: { cell, deletable } }),
|
|
576
1187
|
suppressKeyboardEvent: keys,
|
|
577
1188
|
});
|
|
1189
|
+
// The buttons' words and the empty table's, as the plain table takes them from the node's options.
|
|
1190
|
+
const words = (key) => {
|
|
1191
|
+
const said = node.options?.[key];
|
|
1192
|
+
return typeof said === 'string' && said.trim() ? said : null;
|
|
1193
|
+
};
|
|
578
1194
|
const api = createGrid(host, {
|
|
579
1195
|
theme: themeQuartz.withParams({
|
|
580
1196
|
fontFamily: 'var(--fd-font)',
|
|
@@ -590,6 +1206,7 @@ export const gridWidget = ({ form, name, field, node, id, document, labels = WID
|
|
|
590
1206
|
headerHeight: HEADER_HEIGHT,
|
|
591
1207
|
}),
|
|
592
1208
|
columnDefs,
|
|
1209
|
+
enableRtl: rtl,
|
|
593
1210
|
rowData: lines(),
|
|
594
1211
|
pinnedBottomRowData: totals.length ? [totalsRow(lines())] : undefined,
|
|
595
1212
|
getRowId: (p) => p.data.key,
|
|
@@ -599,12 +1216,20 @@ export const gridWidget = ({ form, name, field, node, id, document, labels = WID
|
|
|
599
1216
|
const kind = kindOf(p.data);
|
|
600
1217
|
return kind ? `fd-grid-${kind}` : undefined;
|
|
601
1218
|
},
|
|
1219
|
+
// A line's tone and bold, read again whenever its rules may have changed.
|
|
1220
|
+
rowClassRules: ruled
|
|
1221
|
+
? {
|
|
1222
|
+
'fd-line-bold': (p) => !!(p.data && lineStates.get(p.data.key)?.bold),
|
|
1223
|
+
...Object.fromEntries(TONES.map((tone) => [`fd-tone-${tone}`, (p) => !!p.data && lineStates.get(p.data.key)?.tone === tone])),
|
|
1224
|
+
}
|
|
1225
|
+
: undefined,
|
|
602
1226
|
domLayout: 'autoHeight',
|
|
603
1227
|
// Lines have few columns: all of them are drawn.
|
|
604
1228
|
suppressColumnVirtualisation: true,
|
|
605
1229
|
readOnlyEdit: true,
|
|
606
|
-
// An empty table
|
|
607
|
-
|
|
1230
|
+
// An empty table says so in a short line where its rows would be, never a blank row.
|
|
1231
|
+
noRowsOverlayComponent: EmptyOverlay,
|
|
1232
|
+
noRowsOverlayComponentParams: { words: words('emptyLabel') ?? labels.noLines },
|
|
608
1233
|
singleClickEdit: true,
|
|
609
1234
|
// A page may ask for a whole line to open at once.
|
|
610
1235
|
editType: node.editMode === 'row' ? 'fullRow' : undefined,
|
|
@@ -614,10 +1239,13 @@ export const gridWidget = ({ form, name, field, node, id, document, labels = WID
|
|
|
614
1239
|
// A line dragged by its handle moves among the others as it goes; where
|
|
615
1240
|
// it is let go, the form moves it too and numbers the lines again.
|
|
616
1241
|
rowDragManaged: !!sequence,
|
|
1242
|
+
// A line dragged is named by its words (a yes or no names nothing), else by its place.
|
|
617
1243
|
rowDragText: (p) => {
|
|
618
1244
|
const line = p.rowNode?.data;
|
|
619
|
-
const
|
|
620
|
-
|
|
1245
|
+
const column = titleColumn(columns.filter((c) => api.getColumn(c)?.isVisible() !== false));
|
|
1246
|
+
const value = line && (kindOf(line) && kinds ? line.values[kinds.text] : column ? line.values[column] : null);
|
|
1247
|
+
const text = value && typeof value === 'object' ? String(value.label ?? '') : String(value ?? '');
|
|
1248
|
+
return text.trim() || placeName((p.rowNode?.rowIndex ?? 0) + 1);
|
|
621
1249
|
},
|
|
622
1250
|
onRowDragEnd: (event) => {
|
|
623
1251
|
const key = event.node.data?.key;
|
|
@@ -629,11 +1257,27 @@ export const gridWidget = ({ form, name, field, node, id, document, labels = WID
|
|
|
629
1257
|
defaultColDef: { sortable: false, resizable: true },
|
|
630
1258
|
}, { modules: [AllCommunityModule] });
|
|
631
1259
|
apis.set(element, api);
|
|
1260
|
+
// Out of sight under display: none — a tab not shown, a field hidden by its condition — AG Grid measures
|
|
1261
|
+
// itself again every frame, for as long as it stays hidden. Taken off the page meanwhile, it rests;
|
|
1262
|
+
// put back the moment it has a box again, it draws itself to fit.
|
|
1263
|
+
const away = document.createComment('fd-grid-away');
|
|
1264
|
+
const View = document.defaultView;
|
|
1265
|
+
const sizes = View && 'ResizeObserver' in View
|
|
1266
|
+
? new View.ResizeObserver(([entry]) => {
|
|
1267
|
+
const hidden = entry.contentRect.width === 0 && entry.contentRect.height === 0;
|
|
1268
|
+
if (hidden && host.isConnected)
|
|
1269
|
+
host.replaceWith(away);
|
|
1270
|
+
else if (!hidden && away.isConnected)
|
|
1271
|
+
away.replaceWith(host);
|
|
1272
|
+
})
|
|
1273
|
+
: null;
|
|
1274
|
+
sizes?.observe(element);
|
|
632
1275
|
// ---- columns a person arranged: kept, and brought back ----
|
|
633
1276
|
// A person's widths, order and choices are kept for the next visit. AG Grid
|
|
634
1277
|
// tells of each change a moment after it; bringing a layout back is told too,
|
|
635
1278
|
// and keeping it again changes nothing.
|
|
636
|
-
|
|
1279
|
+
// A person widens a column by dragging its edge; widths the grid sets itself (by its lines) are not theirs.
|
|
1280
|
+
api.addEventListener('columnResized', (event) => event.finished && event.source !== 'api' && remember(event.source));
|
|
637
1281
|
api.addEventListener('columnMoved', (event) => event.finished && remember(event.source));
|
|
638
1282
|
api.addEventListener('columnVisible', (event) => remember(event.source));
|
|
639
1283
|
/** Sizing the grid does by itself is not a choice to keep. */
|
|
@@ -642,14 +1286,35 @@ export const gridWidget = ({ form, name, field, node, id, document, labels = WID
|
|
|
642
1286
|
// A grid taking itself apart also sends column events: they are not choices.
|
|
643
1287
|
if (automatic.has(source) || !preferences || api.isDestroyed())
|
|
644
1288
|
return;
|
|
645
|
-
|
|
1289
|
+
// A column the record hides is kept as the person last had it: the rule's hiding is not their choice.
|
|
1290
|
+
const state = (api.getColumnState() ?? []).map(({ colId, width, flex, hide }) => ({ colId, width: width ?? null, flex: flex ?? null, hide: ruleHidden.has(colId) ? personHidden.has(colId) : !!hide }));
|
|
646
1291
|
preferences.set(columnsKey, state);
|
|
647
1292
|
}
|
|
1293
|
+
/** The columns the person hid with the chooser, or the page starts hidden. */
|
|
1294
|
+
const personHidden = new Set(optionalIds.filter((column) => optional[column] === 'hide'));
|
|
1295
|
+
const hiddenByPerson = (column) => personHidden.has(column);
|
|
648
1296
|
const saved = preferences?.get(columnsKey);
|
|
1297
|
+
/** The words' columns are sized by the lines once: when the first come, unless the person arranged the columns. */
|
|
1298
|
+
let sizedByLines = drawnFirst.length > 0 || Array.isArray(saved) || node.fit === 'content';
|
|
1299
|
+
function sizeByLines(lines) {
|
|
1300
|
+
if (sizedByLines || !lines.length)
|
|
1301
|
+
return;
|
|
1302
|
+
sizedByLines = true;
|
|
1303
|
+
const state = columns.filter((column) => !node.cells?.[column]?.width).map((colId) => (fixedWidth(colId) ? { colId, width: roomOf(colId, lines), flex: null } : { colId, flex: roomOf(colId, lines) }));
|
|
1304
|
+
api.applyColumnState({ state });
|
|
1305
|
+
}
|
|
649
1306
|
if (Array.isArray(saved)) {
|
|
650
1307
|
const known = new Set(api.getColumns()?.map((c) => c.getColId()));
|
|
651
1308
|
const state = saved.filter((s) => typeof s?.colId === 'string' && known.has(s.colId));
|
|
652
1309
|
api.applyColumnState({ state, applyOrder: true });
|
|
1310
|
+
for (const s of state) {
|
|
1311
|
+
if (!optionalIds.includes(s.colId))
|
|
1312
|
+
continue;
|
|
1313
|
+
if (s.hide)
|
|
1314
|
+
personHidden.add(s.colId);
|
|
1315
|
+
else
|
|
1316
|
+
personHidden.delete(s.colId);
|
|
1317
|
+
}
|
|
653
1318
|
}
|
|
654
1319
|
// ---- the column chooser: a short list of the columns a person may hide ----
|
|
655
1320
|
const chooserBox = document.createElement('div');
|
|
@@ -671,13 +1336,21 @@ export const gridWidget = ({ form, name, field, node, id, document, labels = WID
|
|
|
671
1336
|
if (!chooserBox.hidden)
|
|
672
1337
|
return closeChooser(fromKeyboard);
|
|
673
1338
|
opener = button;
|
|
674
|
-
chooserBox.replaceChildren(
|
|
1339
|
+
chooserBox.replaceChildren(
|
|
1340
|
+
// A column the record hides now is not the person's to show.
|
|
1341
|
+
...optionalIds.filter((column) => !ruleHidden.has(column)).map((column) => {
|
|
675
1342
|
const label = document.createElement('label');
|
|
676
1343
|
const box = document.createElement('input');
|
|
677
1344
|
box.type = 'checkbox';
|
|
678
1345
|
box.className = 'fd-checkbox';
|
|
679
1346
|
box.checked = api.getColumn(column)?.isVisible() ?? false;
|
|
680
|
-
box.addEventListener('change', () =>
|
|
1347
|
+
box.addEventListener('change', () => {
|
|
1348
|
+
if (box.checked)
|
|
1349
|
+
personHidden.delete(column);
|
|
1350
|
+
else
|
|
1351
|
+
personHidden.add(column);
|
|
1352
|
+
api.setColumnsVisible([column], box.checked);
|
|
1353
|
+
});
|
|
681
1354
|
label.append(box, document.createTextNode(def.fields[column].label));
|
|
682
1355
|
return label;
|
|
683
1356
|
}));
|
|
@@ -727,10 +1400,127 @@ export const gridWidget = ({ form, name, field, node, id, document, labels = WID
|
|
|
727
1400
|
button.addEventListener('click', () => addAndEdit(values));
|
|
728
1401
|
adds.append(button);
|
|
729
1402
|
};
|
|
730
|
-
addButton(labels.addLine, {}, 'line');
|
|
1403
|
+
addButton(words('addLabel') ?? labels.addLine, {}, 'line');
|
|
731
1404
|
if (kinds) {
|
|
732
|
-
addButton(labels.addSection, { [kinds.field]: kinds.section ?? 'section' }, 'section');
|
|
733
|
-
addButton(labels.addNote, { [kinds.field]: kinds.note ?? 'note' }, 'note');
|
|
1405
|
+
addButton(words('addSectionLabel') ?? labels.addSection, { [kinds.field]: kinds.section ?? 'section' }, 'section');
|
|
1406
|
+
addButton(words('addNoteLabel') ?? labels.addNote, { [kinds.field]: kinds.note ?? 'note' }, 'note');
|
|
1407
|
+
}
|
|
1408
|
+
// Buttons in the control row, beside the add buttons: the record's, shown by a condition on it (Flectra's <control>).
|
|
1409
|
+
const controls = (node.controlButtons ?? []).map((own) => {
|
|
1410
|
+
const button = ownButton(own, 'fd-button fd-button-link fd-lines-add fd-lines-control');
|
|
1411
|
+
button.addEventListener('click', () => void pressed(button, () => form.runAction(own.id)));
|
|
1412
|
+
adds.append(button);
|
|
1413
|
+
return { id: own.id, button };
|
|
1414
|
+
});
|
|
1415
|
+
/** What the cards were last drawn from: drawn again only when it changes. */
|
|
1416
|
+
let cardsDrawn = '';
|
|
1417
|
+
/** Each line as a card: its first column as its title, the rest by their labels, its tone, its buttons, ↗ and ×. */
|
|
1418
|
+
function drawCards() {
|
|
1419
|
+
if (!node.cards)
|
|
1420
|
+
return;
|
|
1421
|
+
const current = lines();
|
|
1422
|
+
const hidden = new Set(columns.filter((column) => ruleHidden.has(column) || api.getColumn(column)?.isVisible() === false));
|
|
1423
|
+
const shownColumns = columns.filter((column) => !hidden.has(column));
|
|
1424
|
+
const signature = JSON.stringify([current, [...lineStates], shownColumns, readonly, form.getState().values]);
|
|
1425
|
+
if (signature === cardsDrawn)
|
|
1426
|
+
return;
|
|
1427
|
+
cardsDrawn = signature;
|
|
1428
|
+
const parent = form.getState().values;
|
|
1429
|
+
const cards = current.map((line) => {
|
|
1430
|
+
const card = document.createElement('div');
|
|
1431
|
+
card.className = 'fd-line-card';
|
|
1432
|
+
card.dataset['line'] = line.key;
|
|
1433
|
+
const kind = kindOf(line);
|
|
1434
|
+
if (kind && kinds) {
|
|
1435
|
+
card.classList.add(`fd-line-card-${kind}`);
|
|
1436
|
+
card.textContent = String(line.values[kinds.text] ?? '');
|
|
1437
|
+
return card;
|
|
1438
|
+
}
|
|
1439
|
+
const state = lineStates.get(line.key);
|
|
1440
|
+
if (state?.tone)
|
|
1441
|
+
card.dataset['tone'] = state.tone;
|
|
1442
|
+
card.classList.toggle('fd-line-bold', !!state?.bold);
|
|
1443
|
+
const shownHere = shownColumns.filter((column) => !state?.cells[column]?.invisible);
|
|
1444
|
+
const first = titleColumn(shownHere);
|
|
1445
|
+
const rest = shownHere.filter((column) => column !== first);
|
|
1446
|
+
const text = (column) => {
|
|
1447
|
+
// A distribution says its accounts by their names, as its cell does, once they are found.
|
|
1448
|
+
const shares = distributions.get(column);
|
|
1449
|
+
if (!shares)
|
|
1450
|
+
return cellText(def.fields[column], line.values[column], line.values, locale, parent, node.cells?.[column]);
|
|
1451
|
+
const unknown = distributionIds(line.values[column]).map(String).filter((id) => shares.nameOf(id) === undefined);
|
|
1452
|
+
if (unknown.length)
|
|
1453
|
+
shares.find(line, unknown);
|
|
1454
|
+
return distributionWords(line.values[column], locale, shares.nameOf);
|
|
1455
|
+
};
|
|
1456
|
+
const title = document.createElement(dialogs ? 'button' : 'div');
|
|
1457
|
+
title.className = 'fd-line-card-title';
|
|
1458
|
+
title.textContent = first ? text(first) || placeName(current.indexOf(line) + 1) : '';
|
|
1459
|
+
if (dialogs && title instanceof HTMLButtonElement) {
|
|
1460
|
+
title.type = 'button';
|
|
1461
|
+
title.addEventListener('click', () => void openLine(line));
|
|
1462
|
+
}
|
|
1463
|
+
const list = document.createElement('dl');
|
|
1464
|
+
for (const column of rest) {
|
|
1465
|
+
const dt = document.createElement('dt');
|
|
1466
|
+
dt.textContent = def.fields[column].label;
|
|
1467
|
+
const dd = document.createElement('dd');
|
|
1468
|
+
dd.textContent = text(column);
|
|
1469
|
+
const tone = state?.cells[column]?.tone;
|
|
1470
|
+
if (tone)
|
|
1471
|
+
dd.dataset['tone'] = tone;
|
|
1472
|
+
list.append(dt, dd);
|
|
1473
|
+
}
|
|
1474
|
+
const tools = document.createElement('div');
|
|
1475
|
+
tools.className = 'fd-line-card-tools';
|
|
1476
|
+
for (const own of node.rowButtons ?? []) {
|
|
1477
|
+
if (!state?.buttons[own.id])
|
|
1478
|
+
continue;
|
|
1479
|
+
const button = ownButton(own, 'fd-button fd-button-link fd-line-button');
|
|
1480
|
+
button.dataset['rowButton'] = own.id;
|
|
1481
|
+
delete button.dataset['node'];
|
|
1482
|
+
button.addEventListener('click', () => void pressed(button, () => form.runRowAction(node.id, own.id, line.key)));
|
|
1483
|
+
tools.append(button);
|
|
1484
|
+
}
|
|
1485
|
+
if (!readonly && deletable(line.key)) {
|
|
1486
|
+
const remove = document.createElement('button');
|
|
1487
|
+
remove.type = 'button';
|
|
1488
|
+
remove.className = 'fd-line-delete';
|
|
1489
|
+
remove.textContent = '×';
|
|
1490
|
+
remove.setAttribute('aria-label', labels.deleteLine);
|
|
1491
|
+
remove.addEventListener('click', () => form.removeLine(name, line.key));
|
|
1492
|
+
tools.append(remove);
|
|
1493
|
+
}
|
|
1494
|
+
card.append(title, list, tools);
|
|
1495
|
+
return card;
|
|
1496
|
+
});
|
|
1497
|
+
const items = current.filter((line) => !kindOf(line));
|
|
1498
|
+
const sums = totals.filter((column) => !hidden.has(column)).map((column) => {
|
|
1499
|
+
const sum = items.reduce((total, line) => total + Number(line.values[column] ?? 0), 0);
|
|
1500
|
+
return `${def.fields[column].label}: ${cellText(def.fields[column], sum, items[0]?.values ?? {}, locale, parent, node.cells?.[column])}`;
|
|
1501
|
+
});
|
|
1502
|
+
const total = sums.length ? Object.assign(document.createElement('div'), { className: 'fd-line-cards-total', textContent: sums.join(' · ') }) : null;
|
|
1503
|
+
cardList.replaceChildren(...cards, ...(total ? [total] : []));
|
|
1504
|
+
}
|
|
1505
|
+
/** The bar and the ticks, as the lines chosen are now; `redraw` draws the lines' ticks again. */
|
|
1506
|
+
function showChosen(redraw) {
|
|
1507
|
+
if (!choosing)
|
|
1508
|
+
return;
|
|
1509
|
+
const items = lines().filter((line) => !kindOf(line));
|
|
1510
|
+
for (const key of [...chosen])
|
|
1511
|
+
if (!items.some((line) => line.key === key))
|
|
1512
|
+
chosen.delete(key);
|
|
1513
|
+
bar.hidden = readonly || chosen.size === 0;
|
|
1514
|
+
chosenCount.textContent = fill(labels.linesChosen, { n: chosen.size });
|
|
1515
|
+
for (const { id, button } of chosenButtons)
|
|
1516
|
+
button.hidden = form.node(id).invisible;
|
|
1517
|
+
if (pickAll) {
|
|
1518
|
+
pickAll.checked = items.length > 0 && chosen.size === items.length;
|
|
1519
|
+
pickAll.indeterminate = chosen.size > 0 && chosen.size < items.length;
|
|
1520
|
+
pickAll.disabled = readonly || !items.length;
|
|
1521
|
+
}
|
|
1522
|
+
if (redraw)
|
|
1523
|
+
api.refreshCells({ columns: ['__pick'], force: true, suppressFlash: true });
|
|
734
1524
|
}
|
|
735
1525
|
// A focused cell that has no editor still answers keys: Space ticks yes/no,
|
|
736
1526
|
// and Enter or Space presses the delete button. (AG Grid's onCellKeyDown
|
|
@@ -768,7 +1558,8 @@ export const gridWidget = ({ form, name, field, node, id, document, labels = WID
|
|
|
768
1558
|
}
|
|
769
1559
|
else if (column === '__delete') {
|
|
770
1560
|
event.preventDefault();
|
|
771
|
-
|
|
1561
|
+
if (deletable(line.key))
|
|
1562
|
+
form.removeLine(name, line.key);
|
|
772
1563
|
}
|
|
773
1564
|
});
|
|
774
1565
|
// A table grows with its lines until it is long; then it keeps a height of
|
|
@@ -795,9 +1586,11 @@ export const gridWidget = ({ form, name, field, node, id, document, labels = WID
|
|
|
795
1586
|
update(state) {
|
|
796
1587
|
if (state.readonly !== readonly) {
|
|
797
1588
|
readonly = state.readonly;
|
|
798
|
-
api.setGridOption('columnDefs', columnDefs.filter((c) => !(readonly &&
|
|
1589
|
+
api.setGridOption('columnDefs', columnDefs.filter((c) => !(readonly && ['__delete', '__handle', '__copy', '__pick'].includes(c.colId))));
|
|
799
1590
|
}
|
|
800
1591
|
adds.hidden = readonly;
|
|
1592
|
+
for (const { id, button } of controls)
|
|
1593
|
+
button.hidden = form.node(id).invisible;
|
|
801
1594
|
const current = state.value ?? [];
|
|
802
1595
|
if (current !== shown) {
|
|
803
1596
|
shown = current;
|
|
@@ -805,6 +1598,36 @@ export const gridWidget = ({ form, name, field, node, id, document, labels = WID
|
|
|
805
1598
|
fitHeight(current.length);
|
|
806
1599
|
if (totals.length)
|
|
807
1600
|
api.setGridOption('pinnedBottomRowData', [totalsRow(current)]);
|
|
1601
|
+
// Columns as wide as what they hold, once more as the lines change.
|
|
1602
|
+
if (node.fit === 'content')
|
|
1603
|
+
api.autoSizeAllColumns();
|
|
1604
|
+
sizeByLines(linesOf(current));
|
|
1605
|
+
// The ticks and copies name a line by where it is: it may have moved.
|
|
1606
|
+
if (choosing || node.options?.['copy'] === true)
|
|
1607
|
+
api.refreshCells({ columns: ['__pick', '__copy'], force: true, suppressFlash: true });
|
|
1608
|
+
}
|
|
1609
|
+
// Money in the record's currency: its cells written again once that currency changes.
|
|
1610
|
+
if (parentCurrencies.length) {
|
|
1611
|
+
const now = JSON.stringify(parentCurrencies.map((field) => state.values[field] ?? null));
|
|
1612
|
+
if (now !== currencyShown) {
|
|
1613
|
+
currencyShown = now;
|
|
1614
|
+
api.refreshCells({ force: true, suppressFlash: true });
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
// The table's own rules as the form has them now: redrawn only when they changed.
|
|
1618
|
+
if (ruled) {
|
|
1619
|
+
const states = new Map(current.filter((line) => !kindOf(line)).map((line) => [line.key, form.lineState(node.id, line.key)]));
|
|
1620
|
+
const hidden = new Set(columns.filter((column) => form.columnHidden(node.id, column)));
|
|
1621
|
+
const before = JSON.stringify([[...lineStates], [...ruleHidden]]);
|
|
1622
|
+
lineStates = states;
|
|
1623
|
+
if (JSON.stringify([[...states], [...hidden]]) !== before) {
|
|
1624
|
+
const turned = columns.filter((column) => hidden.has(column) !== ruleHidden.has(column));
|
|
1625
|
+
ruleHidden = hidden;
|
|
1626
|
+
for (const column of turned)
|
|
1627
|
+
api.setColumnsVisible([column], !hidden.has(column) && !hiddenByPerson(column));
|
|
1628
|
+
api.refreshCells({ force: true, suppressFlash: true });
|
|
1629
|
+
api.redrawRows();
|
|
1630
|
+
}
|
|
808
1631
|
}
|
|
809
1632
|
const found = readonly ? [] : lineProblems();
|
|
810
1633
|
const signature = found.map((p) => `${p.index}.${p.column}:${p.message}`).join('|');
|
|
@@ -817,10 +1640,13 @@ export const gridWidget = ({ form, name, field, node, id, document, labels = WID
|
|
|
817
1640
|
problems.textContent = listed.join(' · ');
|
|
818
1641
|
problems.hidden = !found.length;
|
|
819
1642
|
}
|
|
1643
|
+
showChosen(false);
|
|
1644
|
+
drawCards();
|
|
820
1645
|
element.setAttribute('aria-invalid', String(state.invalid || found.length > 0));
|
|
821
1646
|
},
|
|
822
1647
|
destroy() {
|
|
823
1648
|
document.removeEventListener('mousedown', outside);
|
|
1649
|
+
sizes?.disconnect();
|
|
824
1650
|
api.destroy();
|
|
825
1651
|
apis.delete(element);
|
|
826
1652
|
},
|