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