@fieldia/grid 0.0.0-stage → 0.4.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/LICENSE +21 -0
- package/README.md +42 -2
- package/package.json +39 -3
- package/src/index.d.mts +2 -0
- package/src/index.d.ts +2 -0
- package/src/index.js +6 -0
- package/src/index.js.map +1 -0
- package/src/index.mjs +3 -0
- package/src/index.mjs.map +1 -0
- package/src/lib/grid.d.mts +8 -0
- package/src/lib/grid.d.ts +8 -0
- package/src/lib/grid.js +786 -0
- package/src/lib/grid.js.map +1 -0
- package/src/lib/grid.mjs +781 -0
- package/src/lib/grid.mjs.map +1 -0
- package/src/lib/styles.d.mts +3 -0
- package/src/lib/styles.d.ts +3 -0
- package/src/lib/styles.js +73 -0
- package/src/lib/styles.js.map +1 -0
- package/src/lib/styles.mjs +69 -0
- package/src/lib/styles.mjs.map +1 -0
package/src/lib/grid.js
ADDED
|
@@ -0,0 +1,786 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.gridWidgets = exports.gridWidget = void 0;
|
|
4
|
+
exports.gridApiOf = gridApiOf;
|
|
5
|
+
const ag_grid_community_1 = require("ag-grid-community");
|
|
6
|
+
const core_1 = require("@fieldia/core");
|
|
7
|
+
const styles_1 = require("./styles");
|
|
8
|
+
const widgets_1 = require("@fieldia/widgets");
|
|
9
|
+
/**
|
|
10
|
+
* A one2many as a spreadsheet, on AG Grid. Each cell is edited with the same
|
|
11
|
+
* Fieldia widget the plain table uses, wired to its line, so every keystroke
|
|
12
|
+
* reaches the form at once. AG Grid never changes the data itself
|
|
13
|
+
* (`readOnlyEdit`); its rows follow the form.
|
|
14
|
+
*
|
|
15
|
+
* A page asks for it with `"widget": "grid"` on a one2many node, and an app
|
|
16
|
+
* turns it on by passing `gridWidgets` to the viewer. Without it the page shows
|
|
17
|
+
* the plain table, so simple forms never load AG Grid.
|
|
18
|
+
*/
|
|
19
|
+
const apis = new WeakMap();
|
|
20
|
+
/** The AG Grid API behind a grid's element, for an app that needs more than the page describes. */
|
|
21
|
+
function gridApiOf(element) {
|
|
22
|
+
return apis.get(element);
|
|
23
|
+
}
|
|
24
|
+
/** The grid's own columns (the drag handle, the delete button) start with two underscores. */
|
|
25
|
+
const isTool = (id) => id.startsWith('__');
|
|
26
|
+
/** The column a section or note spans from: the first field shown. */
|
|
27
|
+
const spanStart = (api) => api.getAllDisplayedColumns().find((c) => !isTool(c.getColId()));
|
|
28
|
+
/** How many columns a section or note covers: every field shown. */
|
|
29
|
+
const spanWidth = (api) => api.getAllDisplayedColumns().filter((c) => !isTool(c.getColId())).length;
|
|
30
|
+
// ---- cells ------------------------------------------------------------------------
|
|
31
|
+
/** Yes or no, ticked in place: no editor to open for one click. */
|
|
32
|
+
class CheckboxRenderer {
|
|
33
|
+
box;
|
|
34
|
+
params;
|
|
35
|
+
init(params) {
|
|
36
|
+
this.params = params;
|
|
37
|
+
this.box = document.createElement('input');
|
|
38
|
+
this.box.type = 'checkbox';
|
|
39
|
+
this.box.className = 'fd-checkbox';
|
|
40
|
+
this.box.setAttribute('aria-label', params.cell.defs[params.subfield].label);
|
|
41
|
+
this.box.addEventListener('change', () => {
|
|
42
|
+
const { cell, subfield, data } = this.params;
|
|
43
|
+
if (data)
|
|
44
|
+
cell.form.updateLine(cell.field, data.key, subfield, this.box.checked);
|
|
45
|
+
});
|
|
46
|
+
this.refresh(params);
|
|
47
|
+
}
|
|
48
|
+
getGui() {
|
|
49
|
+
return this.box;
|
|
50
|
+
}
|
|
51
|
+
refresh(params) {
|
|
52
|
+
this.params = params;
|
|
53
|
+
this.box.checked = params.data?.values[params.subfield] === true;
|
|
54
|
+
this.box.disabled = params.cell.form.fieldReadonly(params.cell.field);
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** The delete button at the end of each line. */
|
|
59
|
+
class DeleteRenderer {
|
|
60
|
+
button;
|
|
61
|
+
init(params) {
|
|
62
|
+
this.button = document.createElement('button');
|
|
63
|
+
this.button.type = 'button';
|
|
64
|
+
this.button.className = 'fd-line-delete';
|
|
65
|
+
this.button.textContent = '×';
|
|
66
|
+
this.button.setAttribute('aria-label', params.cell.labels.deleteLine);
|
|
67
|
+
this.button.addEventListener('click', () => {
|
|
68
|
+
if (params.data)
|
|
69
|
+
params.cell.form.removeLine(params.cell.field, params.data.key);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
getGui() {
|
|
73
|
+
return this.button;
|
|
74
|
+
}
|
|
75
|
+
refresh() {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** The header of the last column: a button that opens the column chooser. */
|
|
80
|
+
class ChooserHeader {
|
|
81
|
+
button;
|
|
82
|
+
init(params) {
|
|
83
|
+
const button = (this.button = document.createElement('button'));
|
|
84
|
+
button.type = 'button';
|
|
85
|
+
button.className = 'fd-grid-chooser-button';
|
|
86
|
+
button.textContent = '⋮';
|
|
87
|
+
button.tabIndex = -1; // AG Grid moves the focus between header cells itself
|
|
88
|
+
button.setAttribute('aria-label', params.chooser.label);
|
|
89
|
+
button.setAttribute('aria-haspopup', 'true');
|
|
90
|
+
button.setAttribute('aria-expanded', 'false');
|
|
91
|
+
button.addEventListener('click', () => params.chooser.toggle(button, false));
|
|
92
|
+
// From the keyboard the header cell has the focus, not the button.
|
|
93
|
+
params.eGridHeader.addEventListener('keydown', (event) => {
|
|
94
|
+
if (event.key !== 'Enter' && event.key !== ' ')
|
|
95
|
+
return;
|
|
96
|
+
event.preventDefault();
|
|
97
|
+
params.chooser.toggle(button, true);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
getGui() {
|
|
101
|
+
return this.button;
|
|
102
|
+
}
|
|
103
|
+
refresh() {
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** A cell being edited: the field's own Fieldia widget, writing straight into its line. */
|
|
108
|
+
class FieldiaCellEditor {
|
|
109
|
+
box;
|
|
110
|
+
widget;
|
|
111
|
+
leave = () => undefined;
|
|
112
|
+
params;
|
|
113
|
+
/** The line as it was when the edit began, for Escape to put back. */
|
|
114
|
+
before = {};
|
|
115
|
+
/** The line field this editor writes: the column's, or a section's or note's text. */
|
|
116
|
+
column = '';
|
|
117
|
+
def;
|
|
118
|
+
note = null;
|
|
119
|
+
floating = null;
|
|
120
|
+
init(params) {
|
|
121
|
+
this.params = params;
|
|
122
|
+
// Not `column`: AG Grid's own params carry the column object under that name.
|
|
123
|
+
const { cell } = params;
|
|
124
|
+
const key = params.data.key;
|
|
125
|
+
const kind = cell.kindOf(params.data);
|
|
126
|
+
const column = (this.column = kind && cell.kinds ? cell.kinds.text : params.subfield);
|
|
127
|
+
const def = (this.def = kind ? (0, widgets_1.kindTextField)(cell.defs[column], kind) : cell.defs[column]);
|
|
128
|
+
const node = { type: 'field', id: `${cell.fieldId}.${key}.${column}`, field: column };
|
|
129
|
+
const context = {
|
|
130
|
+
form: (0, widgets_1.lineForm)(cell.form, cell.field, key),
|
|
131
|
+
name: column,
|
|
132
|
+
field: def,
|
|
133
|
+
node,
|
|
134
|
+
id: `${cell.fieldId}-${key}-${column}-editor`,
|
|
135
|
+
document,
|
|
136
|
+
labels: cell.labels,
|
|
137
|
+
locale: cell.locale,
|
|
138
|
+
};
|
|
139
|
+
this.widget = (0, widgets_1.createWidget)(context, cell.registry);
|
|
140
|
+
this.box = document.createElement('div');
|
|
141
|
+
this.box.className = 'fd-grid-editor';
|
|
142
|
+
this.box.dataset['type'] = def.type;
|
|
143
|
+
if (kind)
|
|
144
|
+
this.box.dataset['kind'] = kind;
|
|
145
|
+
this.box.setAttribute('aria-label', def.label);
|
|
146
|
+
this.box.append(this.widget.element);
|
|
147
|
+
if (kind === 'note')
|
|
148
|
+
this.growing(this.box.querySelector('textarea'));
|
|
149
|
+
if (cell.rowMode)
|
|
150
|
+
this.floatList();
|
|
151
|
+
if (this.isPopup()) {
|
|
152
|
+
// AG Grid places a popup over its cell but leaves its size to the editor.
|
|
153
|
+
const { width, height } = params.eGridCell.getBoundingClientRect();
|
|
154
|
+
Object.assign(this.box.style, { width: `${width}px`, height: `${height}px` });
|
|
155
|
+
}
|
|
156
|
+
// The form never changes values in place, so holding them is enough.
|
|
157
|
+
this.before = this.line()?.values ?? {};
|
|
158
|
+
// A key the widget used itself (Enter picking from its list, Escape closing it) is not the grid's.
|
|
159
|
+
this.box.addEventListener('keydown', (event) => {
|
|
160
|
+
if (event.defaultPrevented)
|
|
161
|
+
(0, ag_grid_community_1._stopPropagationForAgGrid)(event);
|
|
162
|
+
else if (event.key === 'Escape')
|
|
163
|
+
params.cell.cancel(key, this.before);
|
|
164
|
+
});
|
|
165
|
+
// Like a spreadsheet, a cell reached from the keyboard has its text selected,
|
|
166
|
+
// so typing replaces it; a click still puts the caret where it lands.
|
|
167
|
+
let pointing = false;
|
|
168
|
+
this.box.addEventListener('pointerdown', () => (pointing = true));
|
|
169
|
+
this.box.addEventListener('focusin', (event) => {
|
|
170
|
+
const input = event.target;
|
|
171
|
+
if (!pointing && input instanceof HTMLInputElement && input.type !== 'checkbox' && input.type !== 'radio')
|
|
172
|
+
input.select();
|
|
173
|
+
pointing = false;
|
|
174
|
+
});
|
|
175
|
+
const show = () => {
|
|
176
|
+
const line = this.line();
|
|
177
|
+
// A cell the form found wrong stays marked while it is fixed.
|
|
178
|
+
const invalid = !!cell.form.getState().errors[`${cell.field}.${key}.${column}`];
|
|
179
|
+
this.box.classList.toggle('fd-grid-editor-invalid', invalid);
|
|
180
|
+
this.widget.update({ value: line?.values[column], values: line?.values ?? {}, readonly: false, required: def.required === true, invalid });
|
|
181
|
+
};
|
|
182
|
+
show();
|
|
183
|
+
this.leave = cell.form.subscribe(show);
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* A note is typed in a box that starts one line tall and grows with its text,
|
|
187
|
+
* taking its row (and the rows below) with it: the lessons of the bench.
|
|
188
|
+
*/
|
|
189
|
+
growing(area) {
|
|
190
|
+
if (!area)
|
|
191
|
+
return;
|
|
192
|
+
this.note = area;
|
|
193
|
+
area.rows = 1; // a textarea is two lines tall by default, which made a one-line note jump
|
|
194
|
+
area.addEventListener('input', () => {
|
|
195
|
+
this.resize();
|
|
196
|
+
requestAnimationFrame(() => this.keepBottomInView());
|
|
197
|
+
});
|
|
198
|
+
// Enter makes a new line; Ctrl/Cmd+Enter finishes the note.
|
|
199
|
+
area.addEventListener('keydown', (event) => {
|
|
200
|
+
if (event.key !== 'Enter' || !(event.ctrlKey || event.metaKey))
|
|
201
|
+
return;
|
|
202
|
+
event.preventDefault();
|
|
203
|
+
(0, ag_grid_community_1._stopPropagationForAgGrid)(event);
|
|
204
|
+
this.params.api.stopEditing();
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* In a cell, a link's list would be cut off by the rows around it, so it
|
|
209
|
+
* floats in the grid's own box, under its input, while the line is open.
|
|
210
|
+
*/
|
|
211
|
+
floatList() {
|
|
212
|
+
const list = this.box.querySelector('.fd-listbox');
|
|
213
|
+
if (!list)
|
|
214
|
+
return;
|
|
215
|
+
const { layer, document: doc } = { layer: this.params.cell.layer, document: this.box.ownerDocument };
|
|
216
|
+
list.classList.add('fd-grid-floating-list');
|
|
217
|
+
layer.append(list);
|
|
218
|
+
const place = () => {
|
|
219
|
+
if (list.hidden || !this.box.isConnected)
|
|
220
|
+
return;
|
|
221
|
+
const at = (this.box.querySelector('input') ?? this.box).getBoundingClientRect();
|
|
222
|
+
const frame = layer.getBoundingClientRect();
|
|
223
|
+
const rtl = doc.defaultView?.getComputedStyle(layer).direction === 'rtl';
|
|
224
|
+
list.style.insetBlockStart = `${at.bottom - frame.top + 2}px`;
|
|
225
|
+
list.style.insetInlineStart = `${rtl ? frame.right - at.right : at.left - frame.left}px`;
|
|
226
|
+
list.style.minWidth = `${this.params.eGridCell.getBoundingClientRect().width}px`;
|
|
227
|
+
};
|
|
228
|
+
const watch = new MutationObserver(place);
|
|
229
|
+
watch.observe(list, { attributes: true, attributeFilter: ['hidden'], childList: true });
|
|
230
|
+
this.floating = { list, watch };
|
|
231
|
+
place();
|
|
232
|
+
}
|
|
233
|
+
resize() {
|
|
234
|
+
const area = this.note;
|
|
235
|
+
if (!area)
|
|
236
|
+
return;
|
|
237
|
+
const { node, api } = this.params;
|
|
238
|
+
area.style.height = 'auto';
|
|
239
|
+
const content = area.scrollHeight;
|
|
240
|
+
area.style.height = `${content}px`;
|
|
241
|
+
const height = Math.max(40, content + 2);
|
|
242
|
+
if (height === node.rowHeight)
|
|
243
|
+
return;
|
|
244
|
+
node.setRowHeight(height);
|
|
245
|
+
api.onRowHeightChanged();
|
|
246
|
+
}
|
|
247
|
+
/** While typing on a note's last line, keep its bottom edge in view, not just the caret. */
|
|
248
|
+
keepBottomInView() {
|
|
249
|
+
const area = this.note;
|
|
250
|
+
if (!area?.isConnected || area.value.indexOf('\n', area.selectionEnd) !== -1)
|
|
251
|
+
return;
|
|
252
|
+
const hidden = area.getBoundingClientRect().bottom + 4 - window.innerHeight;
|
|
253
|
+
if (hidden > 0)
|
|
254
|
+
window.scrollBy(0, hidden);
|
|
255
|
+
}
|
|
256
|
+
line() {
|
|
257
|
+
const lines = this.params.cell.form.getState().values[this.params.cell.field] ?? [];
|
|
258
|
+
return lines.find((l) => l.key === this.params.data.key);
|
|
259
|
+
}
|
|
260
|
+
getGui() {
|
|
261
|
+
return this.box;
|
|
262
|
+
}
|
|
263
|
+
afterGuiAttached() {
|
|
264
|
+
// With a whole line open, every editor is attached: only the one clicked takes the focus.
|
|
265
|
+
if (this.params.cellStartedEdit === false)
|
|
266
|
+
return;
|
|
267
|
+
this.widget.focus();
|
|
268
|
+
if (this.note) {
|
|
269
|
+
this.note.setSelectionRange(this.note.value.length, this.note.value.length);
|
|
270
|
+
this.resize();
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const input = this.box.querySelector('input');
|
|
274
|
+
if (input && input.type !== 'checkbox' && document.activeElement === input)
|
|
275
|
+
input.select();
|
|
276
|
+
}
|
|
277
|
+
getValue() {
|
|
278
|
+
return this.line()?.values[this.column];
|
|
279
|
+
}
|
|
280
|
+
/** Lists that open below their input need room the cell does not have (a whole open line floats them instead). */
|
|
281
|
+
isPopup() {
|
|
282
|
+
if (this.params.cell.rowMode)
|
|
283
|
+
return false;
|
|
284
|
+
const type = this.def.type;
|
|
285
|
+
return type === 'many2one' || type === 'many2many' || type === 'reference';
|
|
286
|
+
}
|
|
287
|
+
getPopupPosition() {
|
|
288
|
+
return 'over';
|
|
289
|
+
}
|
|
290
|
+
destroy() {
|
|
291
|
+
this.leave();
|
|
292
|
+
this.floating?.watch.disconnect();
|
|
293
|
+
this.floating?.list.remove();
|
|
294
|
+
this.widget.destroy?.();
|
|
295
|
+
const { node, api, cell, data } = this.params;
|
|
296
|
+
cell.finish(data.key);
|
|
297
|
+
// A note's row grew by hand while it was typed; drawing it afresh measures
|
|
298
|
+
// it again from its text (resetting the height would make it one line tall).
|
|
299
|
+
if (this.note) {
|
|
300
|
+
setTimeout(() => {
|
|
301
|
+
if (!api.isDestroyed() && node.rowIndex !== null)
|
|
302
|
+
api.redrawRows({ rowNodes: [node] });
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
// ---- the widget -------------------------------------------------------------------
|
|
308
|
+
/** Past this many lines a table stops growing and scrolls inside, drawing only the rows in view. */
|
|
309
|
+
const LONG_TABLE = 15;
|
|
310
|
+
const ROW_HEIGHT = 40;
|
|
311
|
+
const HEADER_HEIGHT = 38;
|
|
312
|
+
const EDITABLE = new Set(['char', 'text', 'html', 'integer', 'float', 'monetary', 'date', 'datetime', 'selection', 'many2one', 'many2many', 'reference']);
|
|
313
|
+
const gridWidget = ({ form, name, field, node, id, document, labels = widgets_1.WIDGET_LABELS.en, preferences, locale }) => {
|
|
314
|
+
const def = field;
|
|
315
|
+
const kinds = def.lineKinds;
|
|
316
|
+
const sequence = def.sequenceField;
|
|
317
|
+
// The fields that say what a line is and keep the lines' order never show as columns.
|
|
318
|
+
const columns = (node.columns ?? Object.keys(def.fields)).filter((column) => def.fields[column] && column !== kinds?.field && column !== sequence);
|
|
319
|
+
const kindOf = (line) => (line ? (0, core_1.lineKind)(def, line.values) : null);
|
|
320
|
+
// Columns a person may hide or show, and where their choices (and widths, and order) are kept.
|
|
321
|
+
const optional = node.optionalColumns ?? {};
|
|
322
|
+
const optionalIds = columns.filter((column) => column in optional);
|
|
323
|
+
const columnsKey = `${form.page.id}.${node.id}.columns`;
|
|
324
|
+
// Number columns the page asks to add up, in a row pinned under the lines.
|
|
325
|
+
const totals = (node.totals ?? []).filter((column) => columns.includes(column));
|
|
326
|
+
const totalsRow = (current) => {
|
|
327
|
+
const items = current.filter((line) => !kindOf(line));
|
|
328
|
+
const values = Object.fromEntries(totals.map((c) => [c, items.reduce((sum, line) => sum + Number(line.values[c] ?? 0), 0)]));
|
|
329
|
+
return { key: '__totals', values: { ...(items[0]?.values ?? {}), ...values } };
|
|
330
|
+
};
|
|
331
|
+
const element = document.createElement('div');
|
|
332
|
+
const cell = {
|
|
333
|
+
form,
|
|
334
|
+
field: name,
|
|
335
|
+
fieldId: node.id,
|
|
336
|
+
defs: def.fields,
|
|
337
|
+
labels,
|
|
338
|
+
cancel: () => undefined,
|
|
339
|
+
finish: () => undefined,
|
|
340
|
+
kinds,
|
|
341
|
+
kindOf,
|
|
342
|
+
rowMode: node.editMode === 'row',
|
|
343
|
+
layer: element,
|
|
344
|
+
locale,
|
|
345
|
+
};
|
|
346
|
+
(0, styles_1.installGridStyles)(document);
|
|
347
|
+
element.className = 'fd-grid-lines';
|
|
348
|
+
element.id = id;
|
|
349
|
+
element.setAttribute('role', 'group');
|
|
350
|
+
const host = document.createElement('div');
|
|
351
|
+
host.className = 'fd-grid-host';
|
|
352
|
+
// What is wrong on which line, once a save has been refused.
|
|
353
|
+
const problems = document.createElement('div');
|
|
354
|
+
problems.className = 'fd-error fd-grid-problems';
|
|
355
|
+
problems.setAttribute('role', 'alert');
|
|
356
|
+
problems.hidden = true;
|
|
357
|
+
const adds = document.createElement('div');
|
|
358
|
+
adds.className = 'fd-lines-adds';
|
|
359
|
+
element.append(host, problems, adds);
|
|
360
|
+
let readonly = false;
|
|
361
|
+
const lines = () => form.getState().values[name] ?? [];
|
|
362
|
+
const firstEditable = () => columns.find((c) => EDITABLE.has(def.fields[c].type) && !def.fields[c].readonly);
|
|
363
|
+
/** The line field a cell edits: its column's, or a section's or note's text. */
|
|
364
|
+
const editedField = (line, column) => (kindOf(line) && kinds ? kinds.text : column);
|
|
365
|
+
/** The message for a cell, if the form found its value wrong. */
|
|
366
|
+
const problemAt = (line, column, api) => {
|
|
367
|
+
if (!line || (kindOf(line) && spanStart(api)?.getColId() !== column))
|
|
368
|
+
return undefined;
|
|
369
|
+
return form.getState().errors[`${name}.${line.key}.${editedField(line, column)}`];
|
|
370
|
+
};
|
|
371
|
+
/** Every line problem, in the order the lines and columns are shown. */
|
|
372
|
+
const lineProblems = () => {
|
|
373
|
+
const shown = api.getAllDisplayedColumns().map((c) => c.getColId()).filter((id) => !isTool(id));
|
|
374
|
+
const found = [];
|
|
375
|
+
lines().forEach((line, index) => {
|
|
376
|
+
for (const column of shown) {
|
|
377
|
+
const message = problemAt(line, column, api);
|
|
378
|
+
if (message)
|
|
379
|
+
found.push({ index, column, message });
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
return found;
|
|
383
|
+
};
|
|
384
|
+
/** Lines the grid added that nobody has finished an edit in yet: Escape takes them away again. */
|
|
385
|
+
const fresh = new Set();
|
|
386
|
+
/** Lines Escape was pressed in, with how they were: the first of their editors to close acts on it. */
|
|
387
|
+
const cancelling = new Map();
|
|
388
|
+
// Every editor of an open line took the same picture of it, so any one will do.
|
|
389
|
+
cell.cancel = (key, before) => void cancelling.set(key, before);
|
|
390
|
+
cell.finish = (key) => {
|
|
391
|
+
const before = cancelling.get(key);
|
|
392
|
+
if (!before)
|
|
393
|
+
return void fresh.delete(key);
|
|
394
|
+
cancelling.delete(key);
|
|
395
|
+
if (fresh.delete(key))
|
|
396
|
+
return form.removeLine(name, key);
|
|
397
|
+
const current = lines();
|
|
398
|
+
if (current.some((l) => l.key === key))
|
|
399
|
+
form.setValue(name, current.map((l) => (l.key === key ? { ...l, values: before } : l)));
|
|
400
|
+
};
|
|
401
|
+
/** Add a line (or a section or note) at the end and start editing its first cell. */
|
|
402
|
+
const addAndEdit = (values = {}) => {
|
|
403
|
+
const key = form.addLine(name, values);
|
|
404
|
+
fresh.add(key);
|
|
405
|
+
const index = lines().findIndex((l) => l.key === key);
|
|
406
|
+
const column = kindOf(lines()[index]) ? spanStart(api)?.getColId() : firstEditable();
|
|
407
|
+
if (index < 0 || !column)
|
|
408
|
+
return;
|
|
409
|
+
api.ensureIndexVisible(index);
|
|
410
|
+
api.setFocusedCell(index, column);
|
|
411
|
+
api.startEditingCell({ rowIndex: index, colKey: column });
|
|
412
|
+
};
|
|
413
|
+
/**
|
|
414
|
+
* Keys the grid must leave alone, and the two that run off the end of the
|
|
415
|
+
* table while editing: Enter on the last line and Tab on its last editable
|
|
416
|
+
* cell both start a new line.
|
|
417
|
+
*/
|
|
418
|
+
const keys = ({ event, editing, node, column }) => {
|
|
419
|
+
if (event.defaultPrevented)
|
|
420
|
+
return true;
|
|
421
|
+
// Alt+Up/Down moves the line (below), it does not move the focus.
|
|
422
|
+
if (!editing && event.altKey && (event.key === 'ArrowUp' || event.key === 'ArrowDown'))
|
|
423
|
+
return true;
|
|
424
|
+
// In a note, Enter is a new line of the note (Ctrl/Cmd+Enter finishes it).
|
|
425
|
+
if (editing && event.key === 'Enter' && !event.ctrlKey && !event.metaKey && kindOf(node.data) === 'note')
|
|
426
|
+
return true;
|
|
427
|
+
if (!editing || event.type !== 'keydown' || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey)
|
|
428
|
+
return false;
|
|
429
|
+
if (node.rowIndex !== lines().length - 1)
|
|
430
|
+
return false;
|
|
431
|
+
const shown = api.getAllDisplayedColumns();
|
|
432
|
+
const lastCell = !shown.slice(shown.indexOf(column) + 1).some((c) => c.isCellEditable(node));
|
|
433
|
+
if (event.key === 'Enter' || (event.key === 'Tab' && lastCell)) {
|
|
434
|
+
event.preventDefault();
|
|
435
|
+
api.stopEditing();
|
|
436
|
+
addAndEdit();
|
|
437
|
+
return true;
|
|
438
|
+
}
|
|
439
|
+
return false;
|
|
440
|
+
};
|
|
441
|
+
const columnDefs = columns.map((column) => {
|
|
442
|
+
const sub = def.fields[column];
|
|
443
|
+
const numeric = sub.type === 'integer' || sub.type === 'float' || sub.type === 'monetary';
|
|
444
|
+
// Words get the room; numbers and yes/no take what they need.
|
|
445
|
+
const wide = ['char', 'text', 'html', 'many2one', 'many2many', 'reference', 'selection'].includes(sub.type);
|
|
446
|
+
/** Whether this column is where a section's or note's text starts. */
|
|
447
|
+
const spans = (api) => spanStart(api)?.getColId() === column;
|
|
448
|
+
const base = {
|
|
449
|
+
colId: column,
|
|
450
|
+
headerName: sub.label,
|
|
451
|
+
valueGetter: (p) => {
|
|
452
|
+
if (p.node?.rowPinned) {
|
|
453
|
+
if (totals.includes(column))
|
|
454
|
+
return p.data?.values[column];
|
|
455
|
+
return spans(p.api) ? labels.total : null;
|
|
456
|
+
}
|
|
457
|
+
if (!kindOf(p.data))
|
|
458
|
+
return p.data?.values[column];
|
|
459
|
+
return kinds && spans(p.api) ? p.data?.values[kinds.text] : null;
|
|
460
|
+
},
|
|
461
|
+
valueFormatter: (p) => kindOf(p.data) || (p.node?.rowPinned && !totals.includes(column)) ? String(p.value ?? '') : (0, widgets_1.displayValue)(sub, p.value, p.data?.values ?? {}, locale),
|
|
462
|
+
type: numeric ? 'rightAligned' : undefined,
|
|
463
|
+
// A section or note runs across every column but the delete button.
|
|
464
|
+
colSpan: (p) => (kindOf(p.data) && spans(p.api) ? spanWidth(p.api) : 1),
|
|
465
|
+
// Rows are measured, so a note of several lines shows all of them.
|
|
466
|
+
autoHeight: !!kinds,
|
|
467
|
+
cellClassRules: {
|
|
468
|
+
'fd-grid-kind-text': (p) => !!kindOf(p.data),
|
|
469
|
+
'fd-grid-invalid': (p) => !p.node.rowPinned && !!problemAt(p.data, column, p.api),
|
|
470
|
+
},
|
|
471
|
+
tooltipValueGetter: (p) => (p.node?.rowPinned ? undefined : problemAt(p.data, column, p.api)),
|
|
472
|
+
suppressKeyboardEvent: keys,
|
|
473
|
+
// Money keeps room for a bold total in the millions (EGP 1,234,567.00).
|
|
474
|
+
minWidth: wide ? 140 : sub.type === 'monetary' ? 150 : 96,
|
|
475
|
+
flex: wide ? 2 : sub.type === 'monetary' ? 1.3 : 1,
|
|
476
|
+
hide: optional[column] === 'hide',
|
|
477
|
+
editable: (p) => {
|
|
478
|
+
if (readonly || p.node.rowPinned)
|
|
479
|
+
return false;
|
|
480
|
+
if (kindOf(p.data))
|
|
481
|
+
return spans(p.api);
|
|
482
|
+
return sub.type !== 'boolean' && EDITABLE.has(sub.type) && !sub.readonly;
|
|
483
|
+
},
|
|
484
|
+
cellEditor: FieldiaCellEditor,
|
|
485
|
+
cellEditorParams: { cell, subfield: column },
|
|
486
|
+
};
|
|
487
|
+
if (sub.type === 'boolean') {
|
|
488
|
+
return {
|
|
489
|
+
...base,
|
|
490
|
+
cellRendererSelector: (p) => (kindOf(p.data) || p.node.rowPinned ? undefined : { component: CheckboxRenderer, params: { cell, subfield: column } }),
|
|
491
|
+
flex: 0,
|
|
492
|
+
width: 100,
|
|
493
|
+
minWidth: 80,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
return base;
|
|
497
|
+
});
|
|
498
|
+
if (sequence) {
|
|
499
|
+
// Lines that keep an order are moved by a handle at their start.
|
|
500
|
+
columnDefs.unshift({
|
|
501
|
+
colId: '__handle',
|
|
502
|
+
headerName: '',
|
|
503
|
+
rowDrag: (p) => !p.node.rowPinned,
|
|
504
|
+
width: 32,
|
|
505
|
+
minWidth: 32,
|
|
506
|
+
maxWidth: 32,
|
|
507
|
+
cellClass: 'fd-grid-tools fd-grid-handle',
|
|
508
|
+
resizable: false,
|
|
509
|
+
sortable: false,
|
|
510
|
+
suppressMovable: true,
|
|
511
|
+
lockPosition: 'left',
|
|
512
|
+
suppressKeyboardEvent: keys,
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
// AG Grid copies column definitions deeply, so the button calls through, never a copy.
|
|
516
|
+
const chooser = { label: labels.chooseColumns, toggle: (button, fromKeyboard) => toggleChooser(button, fromKeyboard) };
|
|
517
|
+
columnDefs.push({
|
|
518
|
+
colId: '__delete',
|
|
519
|
+
headerName: '',
|
|
520
|
+
lockPosition: 'right',
|
|
521
|
+
...(optionalIds.length ? { headerComponent: ChooserHeader, headerComponentParams: { chooser } } : {}),
|
|
522
|
+
width: 44,
|
|
523
|
+
minWidth: 44,
|
|
524
|
+
maxWidth: 44,
|
|
525
|
+
cellClass: 'fd-grid-tools',
|
|
526
|
+
resizable: false,
|
|
527
|
+
sortable: false,
|
|
528
|
+
suppressMovable: true,
|
|
529
|
+
cellRendererSelector: (p) => (p.node.rowPinned ? undefined : { component: DeleteRenderer, params: { cell } }),
|
|
530
|
+
suppressKeyboardEvent: keys,
|
|
531
|
+
});
|
|
532
|
+
const api = (0, ag_grid_community_1.createGrid)(host, {
|
|
533
|
+
theme: ag_grid_community_1.themeQuartz.withParams({
|
|
534
|
+
fontFamily: 'var(--fd-font)',
|
|
535
|
+
fontSize: 13.5,
|
|
536
|
+
foregroundColor: 'var(--fd-text)',
|
|
537
|
+
backgroundColor: 'var(--fd-surface)',
|
|
538
|
+
headerTextColor: 'var(--fd-muted)',
|
|
539
|
+
headerBackgroundColor: 'var(--fd-surface)',
|
|
540
|
+
borderColor: 'var(--fd-border)',
|
|
541
|
+
accentColor: 'var(--fd-accent)',
|
|
542
|
+
wrapperBorderRadius: 'var(--fd-radius)',
|
|
543
|
+
rowHeight: ROW_HEIGHT,
|
|
544
|
+
headerHeight: HEADER_HEIGHT,
|
|
545
|
+
}),
|
|
546
|
+
columnDefs,
|
|
547
|
+
rowData: lines(),
|
|
548
|
+
pinnedBottomRowData: totals.length ? [totalsRow(lines())] : undefined,
|
|
549
|
+
getRowId: (p) => p.data.key,
|
|
550
|
+
getRowClass: (p) => {
|
|
551
|
+
if (p.node.rowPinned)
|
|
552
|
+
return 'fd-grid-totals';
|
|
553
|
+
const kind = kindOf(p.data);
|
|
554
|
+
return kind ? `fd-grid-${kind}` : undefined;
|
|
555
|
+
},
|
|
556
|
+
domLayout: 'autoHeight',
|
|
557
|
+
// Lines have few columns: all of them are drawn.
|
|
558
|
+
suppressColumnVirtualisation: true,
|
|
559
|
+
readOnlyEdit: true,
|
|
560
|
+
// An empty table shows its Add a line button below, not a message inside.
|
|
561
|
+
suppressNoRowsOverlay: true,
|
|
562
|
+
singleClickEdit: true,
|
|
563
|
+
// A page may ask for a whole line to open at once.
|
|
564
|
+
editType: node.editMode === 'row' ? 'fullRow' : undefined,
|
|
565
|
+
stopEditingWhenCellsLoseFocus: true,
|
|
566
|
+
// Like a spreadsheet: Enter keeps what was typed and moves down.
|
|
567
|
+
enterNavigatesVerticallyAfterEdit: true,
|
|
568
|
+
// A line dragged by its handle moves among the others as it goes; where
|
|
569
|
+
// it is let go, the form moves it too and numbers the lines again.
|
|
570
|
+
rowDragManaged: !!sequence,
|
|
571
|
+
rowDragText: (p) => {
|
|
572
|
+
const line = p.rowNode?.data;
|
|
573
|
+
const text = line && (kindOf(line) && kinds ? line.values[kinds.text] : line.values[columns[0]]);
|
|
574
|
+
return text && typeof text === 'object' ? String(text.label ?? '') : String(text ?? '');
|
|
575
|
+
},
|
|
576
|
+
onRowDragEnd: (event) => {
|
|
577
|
+
const key = event.node.data?.key;
|
|
578
|
+
if (key && event.node.rowIndex !== null)
|
|
579
|
+
form.moveLine(name, key, event.node.rowIndex);
|
|
580
|
+
},
|
|
581
|
+
animateRows: false,
|
|
582
|
+
suppressMovableColumns: false,
|
|
583
|
+
defaultColDef: { sortable: false, resizable: true },
|
|
584
|
+
}, { modules: [ag_grid_community_1.AllCommunityModule] });
|
|
585
|
+
apis.set(element, api);
|
|
586
|
+
// ---- columns a person arranged: kept, and brought back ----
|
|
587
|
+
// A person's widths, order and choices are kept for the next visit. AG Grid
|
|
588
|
+
// tells of each change a moment after it; bringing a layout back is told too,
|
|
589
|
+
// and keeping it again changes nothing.
|
|
590
|
+
api.addEventListener('columnResized', (event) => event.finished && remember(event.source));
|
|
591
|
+
api.addEventListener('columnMoved', (event) => event.finished && remember(event.source));
|
|
592
|
+
api.addEventListener('columnVisible', (event) => remember(event.source));
|
|
593
|
+
/** Sizing the grid does by itself is not a choice to keep. */
|
|
594
|
+
const automatic = new Set(['gridInitializing', 'flex', 'gridOptionsChanged', 'sizeColumnsToFit', 'autosizeColumns']);
|
|
595
|
+
function remember(source) {
|
|
596
|
+
// A grid taking itself apart also sends column events: they are not choices.
|
|
597
|
+
if (automatic.has(source) || !preferences || api.isDestroyed())
|
|
598
|
+
return;
|
|
599
|
+
const state = (api.getColumnState() ?? []).map(({ colId, width, flex, hide }) => ({ colId, width: width ?? null, flex: flex ?? null, hide: !!hide }));
|
|
600
|
+
preferences.set(columnsKey, state);
|
|
601
|
+
}
|
|
602
|
+
const saved = preferences?.get(columnsKey);
|
|
603
|
+
if (Array.isArray(saved)) {
|
|
604
|
+
const known = new Set(api.getColumns()?.map((c) => c.getColId()));
|
|
605
|
+
const state = saved.filter((s) => typeof s?.colId === 'string' && known.has(s.colId));
|
|
606
|
+
api.applyColumnState({ state, applyOrder: true });
|
|
607
|
+
}
|
|
608
|
+
// ---- the column chooser: a short list of the columns a person may hide ----
|
|
609
|
+
const chooserBox = document.createElement('div');
|
|
610
|
+
chooserBox.className = 'fd-grid-chooser';
|
|
611
|
+
chooserBox.setAttribute('role', 'group');
|
|
612
|
+
chooserBox.setAttribute('aria-label', labels.chooseColumns);
|
|
613
|
+
chooserBox.hidden = true;
|
|
614
|
+
element.append(chooserBox);
|
|
615
|
+
let opener = null;
|
|
616
|
+
const closeChooser = (refocus) => {
|
|
617
|
+
if (chooserBox.hidden)
|
|
618
|
+
return;
|
|
619
|
+
chooserBox.hidden = true;
|
|
620
|
+
opener?.setAttribute('aria-expanded', 'false');
|
|
621
|
+
if (refocus)
|
|
622
|
+
api.setFocusedHeader('__delete');
|
|
623
|
+
};
|
|
624
|
+
function toggleChooser(button, fromKeyboard) {
|
|
625
|
+
if (!chooserBox.hidden)
|
|
626
|
+
return closeChooser(fromKeyboard);
|
|
627
|
+
opener = button;
|
|
628
|
+
chooserBox.replaceChildren(...optionalIds.map((column) => {
|
|
629
|
+
const label = document.createElement('label');
|
|
630
|
+
const box = document.createElement('input');
|
|
631
|
+
box.type = 'checkbox';
|
|
632
|
+
box.className = 'fd-checkbox';
|
|
633
|
+
box.checked = api.getColumn(column)?.isVisible() ?? false;
|
|
634
|
+
box.addEventListener('change', () => api.setColumnsVisible([column], box.checked));
|
|
635
|
+
label.append(box, document.createTextNode(def.fields[column].label));
|
|
636
|
+
return label;
|
|
637
|
+
}));
|
|
638
|
+
// Under the header's end, inside the grid's own box.
|
|
639
|
+
const at = button.getBoundingClientRect();
|
|
640
|
+
const frame = element.getBoundingClientRect();
|
|
641
|
+
chooserBox.style.insetBlockStart = `${at.bottom - frame.top + 4}px`;
|
|
642
|
+
chooserBox.style.insetInlineEnd = `${frame.right - at.right}px`;
|
|
643
|
+
chooserBox.hidden = false;
|
|
644
|
+
button.setAttribute('aria-expanded', 'true');
|
|
645
|
+
if (fromKeyboard)
|
|
646
|
+
chooserBox.querySelector('input')?.focus();
|
|
647
|
+
}
|
|
648
|
+
chooserBox.addEventListener('keydown', (event) => {
|
|
649
|
+
if (event.key === 'Escape') {
|
|
650
|
+
event.preventDefault();
|
|
651
|
+
closeChooser(true);
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
const outside = (event) => {
|
|
655
|
+
const target = event.target;
|
|
656
|
+
if (!chooserBox.contains(target) && target !== opener)
|
|
657
|
+
closeChooser(false);
|
|
658
|
+
};
|
|
659
|
+
document.addEventListener('mousedown', outside);
|
|
660
|
+
chooserBox.addEventListener('focusout', (event) => {
|
|
661
|
+
const next = event.relatedTarget;
|
|
662
|
+
if (next && !chooserBox.contains(next) && next !== opener)
|
|
663
|
+
closeChooser(false);
|
|
664
|
+
});
|
|
665
|
+
// Asked for the focus after a refused save: open the first wrong cell for editing.
|
|
666
|
+
element.addEventListener('fd-focus-problem', (event) => {
|
|
667
|
+
const first = lineProblems()[0];
|
|
668
|
+
if (!first)
|
|
669
|
+
return;
|
|
670
|
+
event.preventDefault();
|
|
671
|
+
api.ensureIndexVisible(first.index);
|
|
672
|
+
api.setFocusedCell(first.index, first.column);
|
|
673
|
+
api.startEditingCell({ rowIndex: first.index, colKey: first.column });
|
|
674
|
+
});
|
|
675
|
+
const addButton = (text, values, kind) => {
|
|
676
|
+
const button = document.createElement('button');
|
|
677
|
+
button.type = 'button';
|
|
678
|
+
button.className = 'fd-button fd-button-link fd-lines-add';
|
|
679
|
+
button.dataset['add'] = kind;
|
|
680
|
+
button.textContent = `+ ${text}`;
|
|
681
|
+
button.addEventListener('click', () => addAndEdit(values));
|
|
682
|
+
adds.append(button);
|
|
683
|
+
};
|
|
684
|
+
addButton(labels.addLine, {}, 'line');
|
|
685
|
+
if (kinds) {
|
|
686
|
+
addButton(labels.addSection, { [kinds.field]: kinds.section ?? 'section' }, 'section');
|
|
687
|
+
addButton(labels.addNote, { [kinds.field]: kinds.note ?? 'note' }, 'note');
|
|
688
|
+
}
|
|
689
|
+
// A focused cell that has no editor still answers keys: Space ticks yes/no,
|
|
690
|
+
// and Enter or Space presses the delete button. (AG Grid's onCellKeyDown
|
|
691
|
+
// arrives too late to stop Space scrolling the page.)
|
|
692
|
+
host.addEventListener('keydown', (event) => {
|
|
693
|
+
// Alt+Up/Down moves the focused line, for those who do not drag.
|
|
694
|
+
// The totals row is not a line: its index counts among pinned rows, not lines.
|
|
695
|
+
if (api.getFocusedCell()?.rowPinned)
|
|
696
|
+
return;
|
|
697
|
+
if (sequence && !readonly && event.altKey && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
|
|
698
|
+
const focused = api.getFocusedCell();
|
|
699
|
+
const line = focused ? api.getDisplayedRowAtIndex(focused.rowIndex)?.data : undefined;
|
|
700
|
+
if (!focused || !line || api.getEditingCells().length)
|
|
701
|
+
return;
|
|
702
|
+
event.preventDefault();
|
|
703
|
+
const to = focused.rowIndex + (event.key === 'ArrowUp' ? -1 : 1);
|
|
704
|
+
if (to < 0 || to >= lines().length)
|
|
705
|
+
return;
|
|
706
|
+
form.moveLine(name, line.key, to);
|
|
707
|
+
api.setFocusedCell(to, focused.column);
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
if (readonly || (event.key !== ' ' && event.key !== 'Enter'))
|
|
711
|
+
return;
|
|
712
|
+
if (!event.target.classList.contains('ag-cell'))
|
|
713
|
+
return; // a checkbox or an editor answers for itself
|
|
714
|
+
const focused = api.getFocusedCell();
|
|
715
|
+
const line = focused ? api.getDisplayedRowAtIndex(focused.rowIndex)?.data : undefined;
|
|
716
|
+
if (!focused || !line)
|
|
717
|
+
return;
|
|
718
|
+
const column = focused.column.getColId();
|
|
719
|
+
if (event.key === ' ' && def.fields[column]?.type === 'boolean' && !kindOf(line)) {
|
|
720
|
+
event.preventDefault();
|
|
721
|
+
form.updateLine(name, line.key, column, line.values[column] !== true);
|
|
722
|
+
}
|
|
723
|
+
else if (column === '__delete') {
|
|
724
|
+
event.preventDefault();
|
|
725
|
+
form.removeLine(name, line.key);
|
|
726
|
+
}
|
|
727
|
+
});
|
|
728
|
+
// A table grows with its lines until it is long; then it keeps a height of
|
|
729
|
+
// LONG_TABLE rows and scrolls inside, so hundreds of lines draw no more than fit.
|
|
730
|
+
let long = false;
|
|
731
|
+
const fitHeight = (count) => {
|
|
732
|
+
if (count > LONG_TABLE === long)
|
|
733
|
+
return;
|
|
734
|
+
long = count > LONG_TABLE;
|
|
735
|
+
host.style.height = long ? `${HEADER_HEIGHT + (LONG_TABLE + (totals.length ? 1 : 0)) * ROW_HEIGHT + 2}px` : '';
|
|
736
|
+
api.setGridOption('domLayout', long ? 'normal' : 'autoHeight');
|
|
737
|
+
};
|
|
738
|
+
let shown = null;
|
|
739
|
+
let problemsShown = '';
|
|
740
|
+
return {
|
|
741
|
+
element,
|
|
742
|
+
focus: () => {
|
|
743
|
+
const column = firstEditable();
|
|
744
|
+
if (lines().length && column)
|
|
745
|
+
api.setFocusedCell(0, column);
|
|
746
|
+
else
|
|
747
|
+
adds.querySelector('button')?.focus();
|
|
748
|
+
},
|
|
749
|
+
update(state) {
|
|
750
|
+
if (state.readonly !== readonly) {
|
|
751
|
+
readonly = state.readonly;
|
|
752
|
+
api.setGridOption('columnDefs', columnDefs.filter((c) => !(readonly && (c.colId === '__delete' || c.colId === '__handle'))));
|
|
753
|
+
}
|
|
754
|
+
adds.hidden = readonly;
|
|
755
|
+
const current = state.value ?? [];
|
|
756
|
+
if (current !== shown) {
|
|
757
|
+
shown = current;
|
|
758
|
+
api.setGridOption('rowData', current);
|
|
759
|
+
fitHeight(current.length);
|
|
760
|
+
if (totals.length)
|
|
761
|
+
api.setGridOption('pinnedBottomRowData', [totalsRow(current)]);
|
|
762
|
+
}
|
|
763
|
+
const found = readonly ? [] : lineProblems();
|
|
764
|
+
const signature = found.map((p) => `${p.index}.${p.column}:${p.message}`).join('|');
|
|
765
|
+
if (signature !== problemsShown) {
|
|
766
|
+
problemsShown = signature;
|
|
767
|
+
api.refreshCells({ force: true, suppressFlash: true });
|
|
768
|
+
const listed = found.slice(0, 3).map((p) => (0, core_1.fill)(labels.lineProblem, { n: p.index + 1, message: p.message }));
|
|
769
|
+
if (found.length > 3)
|
|
770
|
+
listed.push((0, core_1.fill)(labels.moreProblems, { n: found.length - 3 }));
|
|
771
|
+
problems.textContent = listed.join(' · ');
|
|
772
|
+
problems.hidden = !found.length;
|
|
773
|
+
}
|
|
774
|
+
element.setAttribute('aria-invalid', String(state.invalid || found.length > 0));
|
|
775
|
+
},
|
|
776
|
+
destroy() {
|
|
777
|
+
document.removeEventListener('mousedown', outside);
|
|
778
|
+
api.destroy();
|
|
779
|
+
apis.delete(element);
|
|
780
|
+
},
|
|
781
|
+
};
|
|
782
|
+
};
|
|
783
|
+
exports.gridWidget = gridWidget;
|
|
784
|
+
/** Pass these to the viewer (`widgets`) to show one2many nodes marked `"widget": "grid"` in the grid. */
|
|
785
|
+
exports.gridWidgets = { 'one2many.grid': exports.gridWidget };
|
|
786
|
+
//# sourceMappingURL=grid.js.map
|