@qnc/qnc_data_tables 1.0.2 → 1.0.4

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.
@@ -0,0 +1,1136 @@
1
+ /*
2
+ Usage:
3
+ 1. (optionally) call register_renderer(...)
4
+ 2. call define('some-tag-name')
5
+ 3. In html: <some-tag-name table-config="..."></some-tag-name>
6
+
7
+ table-config should be JSON-encoded options. See source for details.
8
+
9
+ TODO:
10
+ - stop using JSON.parse directly; work with unknown instead of any
11
+ */
12
+ import m from "mithril";
13
+ import * as drag_sorter from "@qnc/drag_sorter";
14
+ import css from './qnc_data_tables_inline_css.js';
15
+ /**
16
+ * Create a <style> element that users should inject into their document somewhere.
17
+ */
18
+ export function make_style() {
19
+ const style = document.createElement('style');
20
+ style.innerText = css;
21
+ return style;
22
+ }
23
+ function expected_but_got(type_name, value) {
24
+ throw new Error(`Expected ${type_name} but got ${typeof value} ("${value}")`);
25
+ }
26
+ function is_json_map(value) {
27
+ return typeof value == "object" && value != null;
28
+ }
29
+ /** typescript uses Array.isArray() to narrow to any[] ; here, we narrow to unknown[] */
30
+ function is_array(value) {
31
+ return Array.isArray(value);
32
+ }
33
+ function is_2_tuple(value) {
34
+ return Array.isArray(value) && value.length == 2;
35
+ }
36
+ function is_3_tuple(value) {
37
+ return Array.isArray(value) && value.length == 3;
38
+ }
39
+ function is_number(value) {
40
+ return typeof value == "number" && !isNaN(value);
41
+ }
42
+ function require_json_map(value) {
43
+ if (is_json_map(value))
44
+ return value;
45
+ expected_but_got('simple object', value);
46
+ }
47
+ function require_number(value) {
48
+ if (!is_number(value))
49
+ expected_but_got('number', value);
50
+ return value;
51
+ }
52
+ export function require_array(value) {
53
+ if (is_array(value))
54
+ return value;
55
+ expected_but_got('array', value);
56
+ }
57
+ export function require_array_of(value, element_validator) {
58
+ return require_array(value).map(element_validator);
59
+ }
60
+ /**
61
+ Like nullish coalescing operator, except that the fallback is ONLY used if value is undefined
62
+ Use in situations where null is a valid option and distinct from the fallback
63
+ */
64
+ function defined_or_default(value, fallback) {
65
+ if (typeof value == "undefined")
66
+ return fallback;
67
+ return value;
68
+ }
69
+ function is_loading(xhr) {
70
+ return xhr.readyState != 4;
71
+ }
72
+ function log_error(xhr) {
73
+ /*
74
+ Always log the error but don't alert when status == 0, statusText == "" and responseText == "" because Safari
75
+ returns those conditions when a request is interrupted by the user making another request and we don't want to show
76
+ an empty alert. These conditions are also met when there is a network failure, however there is no info to
77
+ show in an alert so logging without alerting still makes sense.
78
+ */
79
+ console.warn("qnc_data_tables xhr error: ", xhr);
80
+ if (xhr.status != 0 || xhr.statusText != "" || xhr.responseText != "") {
81
+ alert("Data table error: " + xhr.responseText || xhr.statusText);
82
+ }
83
+ }
84
+ const DEFAULT_KEY_PREFIX = "table-manager";
85
+ function storage_key(path, table_id, key) {
86
+ return `${path}#${table_id}-${key}`;
87
+ }
88
+ function optional_storage_wrapper(storage, path, table_id) {
89
+ if (!storage)
90
+ return {
91
+ get(key) {
92
+ return null;
93
+ },
94
+ set(key, value) { },
95
+ };
96
+ return {
97
+ get(key) {
98
+ return storage.getItem(storage_key(path, table_id, key));
99
+ },
100
+ set(key, value) {
101
+ storage.setItem(storage_key(path, table_id, key), value);
102
+ },
103
+ };
104
+ }
105
+ function render_help_text(text) {
106
+ return m("span", {
107
+ tabindex: 0,
108
+ title: text,
109
+ onclick: function (e) {
110
+ alert(text);
111
+ // This allows you to render help text inside other interactive elements
112
+ e.preventDefault();
113
+ e.stopPropagation();
114
+ },
115
+ onkeydown: function (e) {
116
+ if (e.key == "Enter" || e.key == " ") {
117
+ this.click();
118
+ // in case you pressed space, don't scroll page
119
+ e.preventDefault();
120
+ }
121
+ },
122
+ className: "qnc-data-table-help-text",
123
+ });
124
+ }
125
+ function render_header({ text, help_text, sortable, sorted, sorted_reverse, sort, sort_reverse, }) {
126
+ // entire header text is a (clickable) button (similar to many file browser and other UIs) :
127
+ if (sortable)
128
+ return m("button", {
129
+ onclick: sorted ? sort_reverse : sort,
130
+ className: "qnc-data-table-header-button",
131
+ },
132
+ // The wrapping span is so that you can see (in your browsers dev tools) exactly how wide the button content is, in case you want to fix column width at that width
133
+ // (note - button _might_ also suffice for this, but users might style button as full-width, for increased click target size)
134
+ m("span", m("span", { className: "qnc-data-table-header-button-text" }, text, " ", help_text), sorted &&
135
+ m("span", { className: "qnc-data-table-sort-indicator" }, " ▲"), sorted_reverse &&
136
+ m("span", { className: "qnc-data-table-sort-indicator" }, " ▼")));
137
+ // The wrapping span is so that you can see (in your browsers dev tools) exactly how wide the text content is, in case you want to fix column width at that width
138
+ return m("span", text, " ", help_text);
139
+ // OR, show text (always) with button (only when sortable) :
140
+ // text,
141
+ // ' ',
142
+ // sortable && m(
143
+ // 'button',
144
+ // {onclick: sorted ? sort_reverse : sort, style: 'padding: 0'},
145
+ // m('span', {style: !sorted && 'opacity: 0.2'}, '▼'),
146
+ // m('span', {style: !sorted_reverse && 'opacity: 0.2'}, '▲'),
147
+ // ),
148
+ }
149
+ function render_form_content({ result_count_loading, result_count, results_limited, results_limited_to, table_data_loading, table, current_page, total_pages, set_page, page_size, set_page_size, editable_columns_list, selected_row_count, all_results_selected, entire_page_selected, selection_buttons, select_page, clear_page, select_table, clear_table, clear_selection, }) {
150
+ function with_loader(loading, content) {
151
+ return m("div", { className: loading ? "loading" : "" }, content);
152
+ }
153
+ const selection_operators = [
154
+ entire_page_selected
155
+ ? m("button", { onclick: clear_page }, "Deselect Current Page")
156
+ : m("button", { onclick: select_page }, "Select Current Page"),
157
+ all_results_selected
158
+ ? m("button", { onclick: clear_table }, "Deselect All Results")
159
+ : m("button", { onclick: select_table }, "Select All Results"),
160
+ m("button", { onclick: clear_selection, disabled: selected_row_count == 0 }, "Clear Selection"),
161
+ ];
162
+ function space_horizontally(gap, items) {
163
+ return m("div", { style: { overflow: "hidden" } }, m("div", { style: { display: "flex", marginRight: "-" + gap } }, items.map((item) => m("div", { style: { marginRight: gap } }, item, " "))));
164
+ }
165
+ return [
166
+ space_horizontally("2em", [
167
+ // Paginator
168
+ [
169
+ m("button", {
170
+ type: "button",
171
+ disabled: current_page <= 1,
172
+ onclick: (e) => set_page(current_page - 1),
173
+ }, "<"),
174
+ " Page ",
175
+ m("input", {
176
+ type: "number",
177
+ value: current_page,
178
+ max: total_pages,
179
+ style: { width: "5em" },
180
+ onchange: function (e) {
181
+ set_page(parseInt(e.target.value));
182
+ },
183
+ }),
184
+ " of ",
185
+ total_pages,
186
+ " ",
187
+ m("button", {
188
+ type: "button",
189
+ disabled: current_page >= total_pages,
190
+ onclick: (e) => set_page(current_page + 1),
191
+ }, ">"),
192
+ ],
193
+ // Results per page
194
+ m("label", m("input", {
195
+ value: page_size,
196
+ type: "number",
197
+ style: { width: "5em" },
198
+ onchange: function (e) {
199
+ set_page_size(parseInt(e.target.value));
200
+ },
201
+ }), " results per page"),
202
+ // Result count
203
+ with_loader(result_count_loading, m("span", m("b", result_count), " result(s) total.", results_limited &&
204
+ m("sup", { style: { color: "red" } }, "*"))),
205
+ ]),
206
+ results_limited &&
207
+ m("div", { style: { color: "red", fontWeight: "bold" } }, m("sup", "*"), "The result table is limited to the first ", results_limited_to, " results."),
208
+ // Columns list
209
+ m("details", { open: false }, m("summary", "Columns"), editable_columns_list),
210
+ // Result table
211
+ with_loader(table_data_loading, table),
212
+ // Selection options
213
+ selection_buttons.length > 0 && [
214
+ m("div", selection_operators.map((b) => [b, " "])),
215
+ // Selection buttons
216
+ m("fieldset", { disabled: selected_row_count == 0 }, m("legend", "With ", selected_row_count, " selected rows:"), m("div", selection_buttons.map((a) => [a, " "]))),
217
+ ],
218
+ ];
219
+ }
220
+ const table_renderers = new Map();
221
+ function make_renderer(renderer) {
222
+ var _a, _b, _c;
223
+ return {
224
+ help_text: (_a = renderer.help_text) !== null && _a !== void 0 ? _a : render_help_text,
225
+ header: (_b = renderer.header) !== null && _b !== void 0 ? _b : render_header,
226
+ form_content: (_c = renderer.form_content) !== null && _c !== void 0 ? _c : render_form_content,
227
+ };
228
+ }
229
+ const default_renderer = make_renderer({});
230
+ export function register_renderer(name, renderer) {
231
+ table_renderers.set(name, make_renderer(renderer));
232
+ }
233
+ register_renderer("default", default_renderer);
234
+ function column_width_property_name(key) {
235
+ return `--qdt-column-${key}-width`;
236
+ }
237
+ function column_width_style(key) {
238
+ return `var(${column_width_property_name(key)})`;
239
+ }
240
+ function complete_table_manager_factory_options(options) {
241
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
242
+ return {
243
+ buttons: (_a = options.buttons) !== null && _a !== void 0 ? _a : [],
244
+ api_url: (_b = options.api_url) !== null && _b !== void 0 ? _b : ".",
245
+ filter_form: (_c = options.filter_form) !== null && _c !== void 0 ? _c : null,
246
+ key_prefix: (_d = options.key_prefix) !== null && _d !== void 0 ? _d : DEFAULT_KEY_PREFIX,
247
+ auto_submit: (_e = options.auto_submit) !== null && _e !== void 0 ? _e : true,
248
+ page_size: (_f = options.page_size) !== null && _f !== void 0 ? _f : 25,
249
+ fill_last_page: (_g = options.fill_last_page) !== null && _g !== void 0 ? _g : true,
250
+ renderer: (_h = options.renderer) !== null && _h !== void 0 ? _h : default_renderer,
251
+ default_sort_ascending: (_j = options.default_sort_ascending) !== null && _j !== void 0 ? _j : true,
252
+ default_sort_column_key: (_k = options.default_sort_column_key) !== null && _k !== void 0 ? _k : null,
253
+ layout_storage: defined_or_default(options.layout_storage, localStorage),
254
+ navigation_storage: defined_or_default(options.navigation_storage, sessionStorage),
255
+ selection_storage: defined_or_default(options.selection_storage, sessionStorage),
256
+ sort_storage: defined_or_default(options.sort_storage, "navigation_storage"),
257
+ extra_sort_options: (_l = options.extra_sort_options) !== null && _l !== void 0 ? _l : [],
258
+ };
259
+ }
260
+ function safe_json_parse(data) {
261
+ try {
262
+ return JSON.parse(data);
263
+ }
264
+ catch (e) { }
265
+ }
266
+ /**
267
+ If html represents exactly one node, and that node is an HTMLElement, return that element.
268
+ Otherwise, wrap in a div.
269
+ */
270
+ function conditionally_wrapped_element(html) {
271
+ const t = document.createElement("template");
272
+ t.innerHTML = html;
273
+ if (t.content.childNodes.length == 1 &&
274
+ t.content.children[0] instanceof HTMLElement)
275
+ return t.content.children[0];
276
+ const d = document.createElement("div");
277
+ d.innerHTML = html;
278
+ return d;
279
+ }
280
+ function mount_data_table(container_element, columns, partial_options) {
281
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
282
+ const options = complete_table_manager_factory_options(partial_options);
283
+ for (const c of columns) {
284
+ // Keys are used in custom css property names
285
+ // This might be more restrictive than strictly necessary
286
+ if (!c.key.match(/^[-a-zA-Z_0-9]+$/))
287
+ throw new Error("Invalid column key.");
288
+ }
289
+ // TODO - check if id is unique?
290
+ // Initial state
291
+ ///////////////////////////////////////////////////////////////////////////
292
+ let selected_pks = new Set();
293
+ let shift_key_pressed = false;
294
+ let prev_selected_pk = null;
295
+ let prev_unselected_pk = null;
296
+ let result_count = 0;
297
+ let limit = 1;
298
+ let filtered_ids = [];
299
+ let page = 1;
300
+ let page_size = options.page_size;
301
+ let sort_column_key = (_a = options.default_sort_column_key) !== null && _a !== void 0 ? _a : (columns.filter((c) => c.enabled && c.sortable)[0] ||
302
+ columns.filter((c) => c.sortable)[0] || { key: "" }).key;
303
+ let sort_reverse = !((_b = options.default_sort_ascending) !== null && _b !== void 0 ? _b : true);
304
+ let table_data = [];
305
+ const filter_form = (_c = options.filter_form) !== null && _c !== void 0 ? _c : document.createElement("form");
306
+ const buttons = ((_d = options.buttons) !== null && _d !== void 0 ? _d : []).map((html) => m.trust(html));
307
+ let download_form = null;
308
+ let download_form_input = null;
309
+ // element references (to be set in oncreate hooks)
310
+ ///////////////////////////////////////////////////////////////////////////
311
+ let table_element = null;
312
+ let header_row = null;
313
+ let sticky_column_style_element = null;
314
+ let row_width_style_element = null;
315
+ // interpretation of options
316
+ ///////////////////////////////////////////////////////////////////////////
317
+ let fixed_columns = columns.filter((c) => c.fixed);
318
+ let sortable_columns = columns.filter((c) => !c.fixed);
319
+ // Note: henceforth we should only use fixed_columns and sortable_columns, NOT columns
320
+ // TODO: how to enforce? Rethink this?
321
+ const has_selection_actions = buttons.length > 0;
322
+ function all_columns() {
323
+ return fixed_columns.concat(sortable_columns);
324
+ }
325
+ function column_by_key(key) {
326
+ for (const column of fixed_columns)
327
+ if (column.key == key)
328
+ return column;
329
+ for (const column of sortable_columns)
330
+ if (column.key == key)
331
+ return column;
332
+ }
333
+ const key_prefix = (_e = options.key_prefix) !== null && _e !== void 0 ? _e : DEFAULT_KEY_PREFIX;
334
+ // Read configuration from storage
335
+ // Different categories of settings may be stored in different storages
336
+ ///////////////////////////////////////////////////////////////////////////
337
+ const layout_storage = optional_storage_wrapper(options.layout_storage, location.pathname, key_prefix);
338
+ const navigation_storage = optional_storage_wrapper(options.navigation_storage, location.pathname, key_prefix);
339
+ const selection_storage = optional_storage_wrapper(options.selection_storage, location.pathname, key_prefix);
340
+ const sort_storage = options.sort_storage == "navigation_storage"
341
+ ? navigation_storage
342
+ : optional_storage_wrapper(options.sort_storage, location.pathname, key_prefix);
343
+ {
344
+ const page_size = layout_storage.get("page_size");
345
+ if (page_size)
346
+ set_page_size(page_size);
347
+ }
348
+ {
349
+ const column_config_string = (_f = layout_storage.get("column_config")) !== null && _f !== void 0 ? _f : "{}";
350
+ const column_config = safe_json_parse(column_config_string);
351
+ if (is_json_map(column_config)) {
352
+ sortable_columns.forEach((c) => {
353
+ const config = column_config[c.key];
354
+ if (is_json_map(config)) {
355
+ c.enabled = Boolean(config.enabled);
356
+ }
357
+ });
358
+ for (const c of all_columns()) {
359
+ const config = column_config[c.key];
360
+ if (is_json_map(config)) {
361
+ if (is_number(config.width))
362
+ c.width = config.width;
363
+ }
364
+ }
365
+ const column_order = (key) => {
366
+ const config = column_config[key];
367
+ if (is_json_map(config) && is_number(config.order))
368
+ return config.order;
369
+ // column is newly added, was not in config
370
+ // add at end of list
371
+ return sortable_columns.length;
372
+ };
373
+ sortable_columns.sort(function (a, b) {
374
+ return column_order(a.key) - column_order(b.key);
375
+ });
376
+ }
377
+ }
378
+ {
379
+ const stored_page = parseInt((_g = navigation_storage.get("page")) !== null && _g !== void 0 ? _g : "nan");
380
+ if (!isNaN(stored_page))
381
+ page = stored_page;
382
+ }
383
+ {
384
+ const stored_sort_column_key = (_h = sort_storage.get("sort_column_key")) !== null && _h !== void 0 ? _h : "";
385
+ const column = column_by_key(stored_sort_column_key);
386
+ if (column && column.sortable) {
387
+ sort_column_key = stored_sort_column_key;
388
+ }
389
+ const stored_sort_reverse = safe_json_parse((_j = sort_storage.get("sort_reverse")) !== null && _j !== void 0 ? _j : "");
390
+ if (typeof stored_sort_reverse == "boolean") {
391
+ sort_reverse = stored_sort_reverse;
392
+ }
393
+ }
394
+ {
395
+ const stored_selected_pks = safe_json_parse((_k = selection_storage.get("selected_pks")) !== null && _k !== void 0 ? _k : "");
396
+ if (is_array(stored_selected_pks)) {
397
+ selected_pks = new Set(stored_selected_pks);
398
+ }
399
+ }
400
+ function set_page_size(any_val) {
401
+ page_size = parseInt(any_val);
402
+ if (isNaN(page_size))
403
+ page_size = 25;
404
+ page_size = Math.max(1, page_size);
405
+ }
406
+ function save_session_if_enabled() {
407
+ const column_config = {};
408
+ for (const c of all_columns()) {
409
+ column_config[c.key] = {
410
+ width: c.width,
411
+ };
412
+ }
413
+ sortable_columns.forEach(function (column, index) {
414
+ Object.assign(column_config[column.key], {
415
+ enabled: column.enabled,
416
+ order: index,
417
+ });
418
+ });
419
+ layout_storage.set("page_size", page_size.toString());
420
+ layout_storage.set("column_config", JSON.stringify(column_config));
421
+ navigation_storage.set("page", page.toString());
422
+ sort_storage.set("sort_column_key", sort_column_key);
423
+ sort_storage.set("sort_reverse", JSON.stringify(sort_reverse));
424
+ selection_storage.set("selected_pks", JSON.stringify(Array.from(selected_pks)));
425
+ }
426
+ ///////////////////////////////////////////////////////////////////////////
427
+ const result_count_xhr = new XMLHttpRequest();
428
+ const id_list_xhr = new XMLHttpRequest();
429
+ const page_xhr = new XMLHttpRequest();
430
+ if (options.auto_submit) {
431
+ filter_form.addEventListener("change", function (e) {
432
+ page = 1;
433
+ fetch_result_count();
434
+ fetch_id_list();
435
+ });
436
+ }
437
+ filter_form.addEventListener("submit", function (e) {
438
+ e.preventDefault();
439
+ page = 1;
440
+ fetch_result_count();
441
+ fetch_id_list();
442
+ });
443
+ function fetch_result_count() {
444
+ const data = new FormData(filter_form);
445
+ data.append(`${key_prefix}_action`, "get_result_count");
446
+ result_count_xhr.open("POST", options.api_url);
447
+ result_count_xhr.send(data);
448
+ }
449
+ result_count_xhr.onload = function () {
450
+ if (this.status != 200) {
451
+ log_error(this);
452
+ m.redraw();
453
+ return;
454
+ }
455
+ const data = JSON.parse(this.responseText);
456
+ result_count = data.count;
457
+ limit = data.limit;
458
+ m.redraw();
459
+ };
460
+ result_count_xhr.onerror = function () {
461
+ log_error(this);
462
+ m.redraw();
463
+ };
464
+ function fetch_id_list() {
465
+ const data = new FormData(filter_form);
466
+ data.append(`${key_prefix}_action`, 'get_ids');
467
+ data.append(`${key_prefix}_sort_key`, sort_column_key);
468
+ id_list_xhr.open("POST", options.api_url);
469
+ id_list_xhr.send(data);
470
+ }
471
+ id_list_xhr.onload = function () {
472
+ if (this.status != 200) {
473
+ log_error(this);
474
+ m.redraw();
475
+ return;
476
+ }
477
+ const data = require_json_map(safe_json_parse(this.responseText));
478
+ const ids = require_array(data.ids);
479
+ filtered_ids = ids;
480
+ container_element.dispatchEvent(new CustomEvent("qnc-data-table-ids-loaded", {
481
+ detail: filtered_ids,
482
+ bubbles: true,
483
+ }));
484
+ m.redraw();
485
+ fetch_page();
486
+ };
487
+ id_list_xhr.onerror = function () {
488
+ log_error(this);
489
+ m.redraw();
490
+ };
491
+ function current_page_ids() {
492
+ const start = (page - 1) * page_size;
493
+ return filtered_ids.slice(start, start + page_size);
494
+ }
495
+ function fetch_page() {
496
+ // Note - most pages won't need data from filter_form to return results
497
+ // However, some pages MAY include controls in the filter_form which impact how the columns/data are generated
498
+ // It's part of our documented contract that we always submit filter_form data with each post request
499
+ const data = new FormData(filter_form);
500
+ data.append(`${key_prefix}_action`, "get_table_data");
501
+ data.append(`${key_prefix}_columns`, JSON.stringify(all_columns()
502
+ .filter((c) => c.enabled)
503
+ .map((c) => c.key)));
504
+ data.append(`${key_prefix}_ids`, JSON.stringify(current_page_ids()));
505
+ page_xhr.open("POST", options.api_url);
506
+ page_xhr.send(data);
507
+ }
508
+ page_xhr.onload = function () {
509
+ if (this.status != 200) {
510
+ log_error(this);
511
+ m.redraw();
512
+ return;
513
+ }
514
+ const data = require_json_map(safe_json_parse(this.responseText));
515
+ table_data = require_array(data.rows).map(function (row) {
516
+ if (!is_2_tuple(row))
517
+ throw new Error("row data not an array");
518
+ const pk = row[0];
519
+ const cell_data = row[1];
520
+ if (!is_json_map(cell_data))
521
+ throw new Error("row data [1] is not a map");
522
+ const cells = {};
523
+ for (const key in cell_data) {
524
+ if (!cell_data.hasOwnProperty(key))
525
+ continue;
526
+ const column = column_by_key(key);
527
+ if (!column) {
528
+ throw new Error(`Received data for unknown column "${key}"`);
529
+ }
530
+ const datum = cell_data[key];
531
+ if (typeof datum != "string")
532
+ throw new Error(`Received non-string cell data "${datum}" for key "${key}"`);
533
+ cells[key] = make_cell(column, datum);
534
+ }
535
+ return { pk: pk, cells };
536
+ });
537
+ m.redraw();
538
+ };
539
+ page_xhr.onerror = function () {
540
+ log_error(this);
541
+ m.redraw();
542
+ };
543
+ function add_style_props(s, column) {
544
+ s.width = column_width_style(column.key);
545
+ }
546
+ function make_cell(column, html) {
547
+ const cell = conditionally_wrapped_element(html);
548
+ cell.setAttribute("role", "cell");
549
+ add_style_props(cell.style, column);
550
+ return m.fragment({ key: column.key }, [m.trust(cell.outerHTML)]);
551
+ }
552
+ function make_empty_cell(column) {
553
+ const style = {};
554
+ add_style_props(style, column);
555
+ return m("div", { role: "cell", style });
556
+ }
557
+ function set_page(value) {
558
+ if (typeof value == "string")
559
+ value = parseInt(value);
560
+ if (isNaN(value))
561
+ value = 1;
562
+ value = Math.max(1, value);
563
+ value = Math.min(result_count, value);
564
+ page = value;
565
+ fetch_page();
566
+ }
567
+ function on_column_sort(element) {
568
+ const indeces = new Map();
569
+ for (const [index, child] of Array.from(element.children).entries()) {
570
+ indeces.set(child.getAttribute("data-key"), index);
571
+ }
572
+ sortable_columns.sort(function (a, b) {
573
+ return indeces.get(a.key) - indeces.get(b.key);
574
+ });
575
+ m.redraw();
576
+ fetch_page();
577
+ }
578
+ function columns_list() {
579
+ return m("ol", {
580
+ oncreate: (vnode) => drag_sorter.enable(vnode.dom, {
581
+ onchange: on_column_sort,
582
+ stationary_list_item_markers: true,
583
+ }),
584
+ }, sortable_columns.map((c, index) => m("li", {
585
+ "data-key": c.key,
586
+ key: c.key,
587
+ className: "qnc-data-table-column-item",
588
+ }, m("label", m("input", {
589
+ type: "checkbox",
590
+ checked: c.enabled,
591
+ onclick: (e) => {
592
+ c.enabled = !c.enabled;
593
+ // AFTER redraw, recompute row widths (based on enabled columns)
594
+ setTimeout(update_row_width_style);
595
+ fetch_page();
596
+ },
597
+ }), " ", c.display, " ", c.help_text && options.renderer.help_text(c.help_text),
598
+ // The whole li is a drag target, this just makes it more obvious
599
+ " ", m("span", "↕︎")))));
600
+ }
601
+ function checkbox_cell(checked, set_checked) {
602
+ return m("label", {
603
+ role: "cell",
604
+ className: "qnc-data-table-row-checkbox-label",
605
+ }, m("input", {
606
+ type: "checkbox",
607
+ checked: checked,
608
+ onchange: (e) => set_checked(e.target.checked),
609
+ }));
610
+ }
611
+ function empty_checkbox_cell() {
612
+ return m("div", {
613
+ role: "columnheader",
614
+ className: "qnc-data-table-row-checkbox-label",
615
+ },
616
+ // Render an invisible checkbox, so this header cell takes up the same width as the rest of the checkbox cells
617
+ m("input", {
618
+ type: "checkbox",
619
+ style: {
620
+ visibility: "hidden",
621
+ pointerEvents: "none",
622
+ },
623
+ tabIndex: -1,
624
+ }));
625
+ }
626
+ function empty_expanding_cell(role = "cell") {
627
+ return m("div", {
628
+ style: { flexGrow: "1", padding: "0", flexBasis: "0px" },
629
+ // Just in case users are using this in their selector for default cell background/border
630
+ role: role,
631
+ });
632
+ }
633
+ function total_pages() {
634
+ return Math.ceil(result_count / page_size);
635
+ }
636
+ fetch_result_count();
637
+ fetch_id_list();
638
+ function onformcreate(vnode) {
639
+ // attach this custom attribute to the form element
640
+ // this is a bit of hack, so that "selection buttons" can easily access these helpers
641
+ vnode.dom.data_table_controller = {
642
+ table_id: key_prefix,
643
+ get_selected_ids: function () {
644
+ return Array.from(selected_pks);
645
+ },
646
+ get_enabled_column_keys: function () {
647
+ return all_columns()
648
+ .filter((c) => c.enabled)
649
+ .map((c) => c.key);
650
+ },
651
+ clear_selection: function () {
652
+ selected_pks.clear();
653
+ m.redraw();
654
+ },
655
+ reload: function () {
656
+ fetch_result_count();
657
+ fetch_id_list();
658
+ m.redraw();
659
+ },
660
+ post_ids_to_new_window: function (url) {
661
+ /*
662
+ Intended to help you implement server-side bulk actions (ie. spreadsheet export).
663
+ Posts a form which submits to given url and opens a new window.
664
+ The form content will be "ids=json_encoded_list_of_ids".
665
+
666
+ If the returned response has "Content-Disposition: attachment", it will trigger file download, and then the new window will automatically close.
667
+ */
668
+ this.post_ids_to_window_named("_blank", url);
669
+ },
670
+ post_ids_to_window_named: function (name, url) {
671
+ /*
672
+ Similar to post_ids_to_new_window, but more generic.
673
+ Can post to an existing iframe, a new window that you opened (and need a reference to), etc.
674
+ */
675
+ if (!download_form)
676
+ throw new Error("download_form has not yet been created");
677
+ if (!download_form_input)
678
+ throw new Error("download_form_input has not yet been created");
679
+ download_form.target = name;
680
+ download_form.action = url;
681
+ download_form_input.value = JSON.stringify(Array.from(selected_pks));
682
+ download_form.submit();
683
+ },
684
+ };
685
+ }
686
+ function select_ids(ids) {
687
+ ids.forEach(selected_pks.add.bind(selected_pks));
688
+ m.redraw();
689
+ }
690
+ function deselect_ids(ids) {
691
+ ids.forEach(selected_pks.delete.bind(selected_pks));
692
+ m.redraw();
693
+ }
694
+ function header_cell(c) {
695
+ return m("div", {
696
+ role: "columnheader",
697
+ style: {
698
+ width: column_width_style(c.key),
699
+ },
700
+ class: "qdt-resizable-header",
701
+ }, m("div", // the only reason this div is here is so that it has "overflow: hidden;", while the parent element has "overflow: visible" (to allow resize handle to overflow)
702
+ options.renderer.header({
703
+ text: c.display,
704
+ help_text: c.help_text && options.renderer.help_text(c.help_text),
705
+ sortable: c.sortable,
706
+ sorted: c.key == sort_column_key && !sort_reverse,
707
+ sorted_reverse: c.key == sort_column_key && sort_reverse,
708
+ sort: () => {
709
+ sort_column_key = c.key;
710
+ sort_reverse = false;
711
+ fetch_id_list();
712
+ },
713
+ sort_reverse: () => {
714
+ sort_column_key = c.key;
715
+ sort_reverse = true;
716
+ fetch_id_list();
717
+ },
718
+ })), m("span", {
719
+ onmousedown: begin_header_drag.bind(null, c),
720
+ class: "qdt-column-resizer",
721
+ }));
722
+ }
723
+ function render() {
724
+ // This is a bit of a hack
725
+ // Ideally, we'd call this from any function that changes any of our state, but this approach guarantees it gets called as needed
726
+ save_session_if_enabled();
727
+ const enabled_columns = all_columns().filter((c) => c.enabled);
728
+ const page_ids = table_data.map((r) => r.pk);
729
+ const thead_rows = m("div", {
730
+ role: "row",
731
+ className: `qnc-data-table-row qnc-data-table-header-row qnc-data-table-row-${key_prefix}`,
732
+ oncreate: (vnode) => (header_row = vnode.dom),
733
+ },
734
+ // "overflow-left detector"
735
+ m("div", {
736
+ role: "columnheader",
737
+ style: "padding: 0; width: 0; border: none;",
738
+ }, m("div", {
739
+ style: "width: 1px; height: 1px; background-color: transparent; pointerEvents: none;",
740
+ oncreate: (vnode) => setup_left_overflow_detector(vnode.dom),
741
+ })), has_selection_actions && empty_checkbox_cell(), fixed_columns.filter((c) => c.enabled).map(header_cell), m("div", {
742
+ role: "columnheader",
743
+ class: `qdt-overflow-indicator-cell qdt-overflow-indicator-cell-left`,
744
+ }), sortable_columns.filter((c) => c.enabled).map(header_cell), empty_expanding_cell("columnheader"), m("div", {
745
+ role: "columnheader",
746
+ class: `qdt-overflow-indicator-cell qdt-overflow-indicator-cell-right`,
747
+ }),
748
+ // "overflow-right detector"
749
+ m("div", {
750
+ role: "columnheader",
751
+ style: "padding: 0; width: 0; border: none; position: relative;",
752
+ }, m("div", {
753
+ style: "position: absolute; right: 0; width: 1px; height: 1px; background-color: transparent; pointerEvents: none;",
754
+ oncreate: (vnode) => setup_right_overflow_detector(vnode.dom),
755
+ })));
756
+ function table_row(checkbox_cell, fixed_cells, sortable_cells) {
757
+ return m("div", {
758
+ role: "row",
759
+ className: `qnc-data-table-row qnc-data-table-body-row qnc-data-table-row-${key_prefix}`,
760
+ },
761
+ // Empty "scroll detector" column
762
+ m("div", {
763
+ role: "cell",
764
+ style: "width: 0px; padding: 0; border: none;",
765
+ }), checkbox_cell,
766
+ // has_selection_actions && checkbox_cell(
767
+ // selected_pks.has(row.pk),
768
+ // checked => {
769
+ // if (checked) selected_pks.add(row.pk)
770
+ // else selected_pks.delete(row.pk)
771
+ // m.redraw()
772
+ // },
773
+ // ),
774
+ fixed_cells, m("div", {
775
+ role: "cell",
776
+ class: `qdt-overflow-indicator-cell qdt-overflow-indicator-cell-left`,
777
+ }), sortable_cells, empty_expanding_cell(), m("div", {
778
+ role: "cell",
779
+ class: `qdt-overflow-indicator-cell qdt-overflow-indicator-cell-right`,
780
+ }),
781
+ // Empty "overflow detector" column
782
+ m("div", {
783
+ role: "cell",
784
+ style: "width: 0px; padding: 0; border: none;",
785
+ }));
786
+ }
787
+ const tbody_rows = [
788
+ table_data.map((row) => table_row(has_selection_actions &&
789
+ checkbox_cell(selected_pks.has(row.pk), (checked) => {
790
+ if (checked) {
791
+ if (shift_key_pressed &&
792
+ prev_selected_pk !== null) {
793
+ const start_index = filtered_ids.indexOf(prev_selected_pk);
794
+ const end_index = filtered_ids.indexOf(row.pk);
795
+ if (start_index < 0 ||
796
+ end_index < 0 ||
797
+ start_index == end_index) {
798
+ selected_pks.add(row.pk);
799
+ }
800
+ else if (start_index < end_index) {
801
+ for (let i = start_index; i <= end_index; i++) {
802
+ selected_pks.add(filtered_ids[i]);
803
+ }
804
+ }
805
+ else if (start_index > end_index) {
806
+ for (let i = end_index; i <= start_index; i++) {
807
+ selected_pks.add(filtered_ids[i]);
808
+ }
809
+ }
810
+ }
811
+ else {
812
+ selected_pks.add(row.pk);
813
+ }
814
+ prev_selected_pk = row.pk;
815
+ prev_unselected_pk = null;
816
+ }
817
+ else {
818
+ if (shift_key_pressed &&
819
+ prev_unselected_pk !== null) {
820
+ const start_index = filtered_ids.indexOf(prev_unselected_pk);
821
+ const end_index = filtered_ids.indexOf(row.pk);
822
+ if (start_index < 0 ||
823
+ end_index < 0 ||
824
+ start_index == end_index) {
825
+ selected_pks.delete(row.pk);
826
+ }
827
+ else if (start_index < end_index) {
828
+ for (let i = start_index; i <= end_index; i++) {
829
+ selected_pks.delete(filtered_ids[i]);
830
+ }
831
+ }
832
+ else if (start_index > end_index) {
833
+ for (let i = end_index; i <= start_index; i++) {
834
+ selected_pks.delete(filtered_ids[i]);
835
+ }
836
+ }
837
+ }
838
+ else {
839
+ selected_pks.delete(row.pk);
840
+ }
841
+ prev_unselected_pk = row.pk;
842
+ prev_selected_pk = null;
843
+ }
844
+ m.redraw();
845
+ }), fixed_columns
846
+ .filter((c) => c.enabled)
847
+ .map((c) => row.cells[c.key]), sortable_columns
848
+ .filter((c) => c.enabled)
849
+ .map((c) => row.cells[c.key]))),
850
+ options.fill_last_page &&
851
+ Array.from(Array(Math.max(0, page_size - table_data.length))).map((x) => table_row(empty_checkbox_cell(), fixed_columns
852
+ .filter((c) => c.enabled)
853
+ .map(make_empty_cell), sortable_columns
854
+ .filter((c) => c.enabled)
855
+ .map(make_empty_cell))),
856
+ ];
857
+ const form_content = options.renderer.form_content({
858
+ result_count_loading: is_loading(result_count_xhr),
859
+ result_count: result_count,
860
+ results_limited: result_count > limit,
861
+ results_limited_to: limit,
862
+ table_data_loading: is_loading(id_list_xhr) || is_loading(page_xhr),
863
+ table: m("div", {
864
+ oncreate: (vnode) => (table_element = vnode.dom),
865
+ class: "qnc-data-table",
866
+ role: "table",
867
+ style: all_columns()
868
+ .map((c) => `${column_width_property_name(c.key)}: ${c.width}px`)
869
+ .join(";"),
870
+ },
871
+ // "overflow-top detector"
872
+ m("div", {
873
+ id: "qdt-overflow-top-detector",
874
+ oncreate: (vnode) => setup_top_overflow_detector(vnode.dom),
875
+ }), m("div", {
876
+ class: "qnc-data-table-head",
877
+ role: "rowgroup",
878
+ }, thead_rows, m("div", { id: "qdt-overflow-top-indicator" })), m("div", {
879
+ class: "qnc-data-table-body",
880
+ role: "rowgroup",
881
+ }, tbody_rows),
882
+ // "overflow-bottom detector"
883
+ m("div", {
884
+ id: "qdt-overflow-bottom-detector",
885
+ oncreate: (vnode) => setup_bottom_overflow_detector(vnode.dom),
886
+ }), m("div", { id: "qdt-overflow-bottom-indicator" })),
887
+ current_page: page,
888
+ total_pages: Math.ceil(filtered_ids.length / page_size),
889
+ set_page: set_page,
890
+ page_size: page_size,
891
+ set_page_size: (v) => {
892
+ set_page_size(v);
893
+ m.redraw();
894
+ },
895
+ editable_columns_list: columns_list(),
896
+ selected_row_count: selected_pks.size,
897
+ entire_page_selected: current_page_ids().every((id) => selected_pks.has(id)),
898
+ all_results_selected: filtered_ids.every((id) => selected_pks.has(id)),
899
+ selection_buttons: buttons,
900
+ select_page: () => select_ids(page_ids),
901
+ clear_page: () => deselect_ids(page_ids),
902
+ select_table: () => select_ids(filtered_ids),
903
+ clear_table: () => deselect_ids(filtered_ids),
904
+ clear_selection: () => {
905
+ selected_pks.clear();
906
+ m.redraw();
907
+ },
908
+ });
909
+ return [
910
+ m("form", {
911
+ onsubmit: (e) => e.preventDefault(),
912
+ oncreate: onformcreate,
913
+ }, m("style", {
914
+ oncreate: (vnode) => {
915
+ row_width_style_element = vnode.dom;
916
+ update_row_width_style();
917
+ },
918
+ }), m("style", {
919
+ oncreate: (vnode) => {
920
+ sticky_column_style_element = vnode.dom;
921
+ update_sticky_column_style();
922
+ },
923
+ }), form_content),
924
+ // This form is used by the "post_ids_to_new_window" function
925
+ m("form", {
926
+ oncreate: (vnode) => (download_form = vnode.dom),
927
+ target: "_blank",
928
+ method: "POST",
929
+ style: "display: none;",
930
+ }, m("input", {
931
+ name: "ids",
932
+ oncreate: (vnode) => (download_form_input = vnode.dom),
933
+ })),
934
+ ];
935
+ }
936
+ function setup_top_overflow_detector(detector_cell) {
937
+ new IntersectionObserver((entries, observer) => {
938
+ if (table_element && entries[0]) {
939
+ table_element.classList.toggle("qdt-overflows-top", !entries[0].isIntersecting);
940
+ }
941
+ }, {
942
+ root: table_element,
943
+ }).observe(detector_cell);
944
+ }
945
+ function setup_bottom_overflow_detector(detector_cell) {
946
+ new IntersectionObserver(function (entries, observer) {
947
+ if (table_element && entries[0]) {
948
+ table_element.classList.toggle("qdt-overflows-bottom", !entries[0].isIntersecting);
949
+ }
950
+ }, {
951
+ root: table_element,
952
+ }).observe(detector_cell);
953
+ }
954
+ function setup_left_overflow_detector(detector_cell) {
955
+ new IntersectionObserver(function (entries, observer) {
956
+ if (table_element && entries[0]) {
957
+ table_element.classList.toggle("qdt-overflows-left", !entries[0].isIntersecting);
958
+ }
959
+ }, {
960
+ root: table_element,
961
+ }).observe(detector_cell);
962
+ }
963
+ function setup_right_overflow_detector(detector_cell) {
964
+ new IntersectionObserver(function (entries, observer) {
965
+ if (table_element && entries[0]) {
966
+ table_element.classList.toggle("qdt-overflows-right", !entries[0].isIntersecting);
967
+ }
968
+ }, {
969
+ root: table_element,
970
+ }).observe(detector_cell);
971
+ }
972
+ function update_row_width_style() {
973
+ /*
974
+ Rows need explicit width.
975
+ Otherwise, they are only as wide as table, and cell overflow.
976
+ This causes problems if:
977
+ 1. there are any background colours/borders on rows
978
+ 2. there are sticky columns, and row content is more than two table-widths wide (eventually, the sticky columns get pushed out view)
979
+
980
+ #1. can be solved by putting all colours/borders on cells, rather than rows, but issue #2 still requires fixed row widths.
981
+
982
+ Note - this needs to run whenever any columns are toggled or resized
983
+ It might be better to split this off from the "sticky position" calculations (different style element, different function, called at different times)
984
+ */
985
+ const s = row_width_style_element;
986
+ if (!s)
987
+ return;
988
+ if (!header_row)
989
+ return;
990
+ s.innerHTML = "";
991
+ let row_width = 0;
992
+ let cell = header_row.firstElementChild;
993
+ // Notice - skip the lastElementChild, since it's the "empty_expanding_cell"
994
+ // (it's width _should_ be 0 right now)
995
+ while (cell && cell != header_row.lastElementChild) {
996
+ row_width += cell.clientWidth;
997
+ cell = cell.nextElementSibling;
998
+ }
999
+ s.innerHTML = `
1000
+ .qnc-data-table-row-${key_prefix}{width: ${row_width}px;}
1001
+ #qdt-overflow-top-indicator{width: ${row_width}px;}
1002
+ #qdt-overflow-top-detector{width: ${row_width}px;}
1003
+ #qdt-overflow-bottom-indicator{width: ${row_width}px;}
1004
+ #qdt-overflow-bottom-detector{width: ${row_width}px;}
1005
+ `;
1006
+ }
1007
+ function update_sticky_column_style() {
1008
+ if (!sticky_column_style_element)
1009
+ return;
1010
+ if (!header_row)
1011
+ return;
1012
+ sticky_column_style_element.innerHTML = "";
1013
+ const fixed_column_count = Number(has_selection_actions) + // 1, iff selection column exists
1014
+ fixed_columns.length +
1015
+ 1; // the "sticky divider" column, where box-shadow is rendered
1016
+ let previous_width = 0;
1017
+ let i = 1; // skip over the "scroll detector" column
1018
+ while (i <= fixed_column_count) {
1019
+ const cell = header_row.children[i];
1020
+ if (!cell)
1021
+ break;
1022
+ sticky_column_style_element.innerHTML += `.qnc-data-table-row-${key_prefix} > :nth-child(${i + 1}) {
1023
+ position: sticky;
1024
+ left: ${previous_width}px;
1025
+ z-index: 2;
1026
+ }`;
1027
+ previous_width += cell.clientWidth;
1028
+ i++;
1029
+ }
1030
+ }
1031
+ window.addEventListener("load", update_sticky_column_style);
1032
+ window.addEventListener("load", update_row_width_style);
1033
+ window.addEventListener("resize", update_row_width_style);
1034
+ let column_resizing = null;
1035
+ function begin_header_drag(column, event) {
1036
+ const style = document.createElement("style");
1037
+ style.innerHTML = "* {cursor: col-resize !important}";
1038
+ document.body.appendChild(style);
1039
+ column_resizing = {
1040
+ column,
1041
+ width: column.width,
1042
+ x: event.clientX,
1043
+ style,
1044
+ };
1045
+ }
1046
+ window.addEventListener("mousemove", function (event) {
1047
+ if (!column_resizing)
1048
+ return;
1049
+ event.preventDefault(); // prevent text selection
1050
+ const { column, width, x } = column_resizing;
1051
+ column.width = Math.max(0, width + event.clientX - x);
1052
+ update_sticky_column_style();
1053
+ m.redraw();
1054
+ });
1055
+ window.addEventListener("mouseup", function () {
1056
+ if (!column_resizing)
1057
+ return;
1058
+ column_resizing.style.remove();
1059
+ column_resizing = null;
1060
+ update_row_width_style();
1061
+ });
1062
+ window.addEventListener("keydown", function (e) {
1063
+ shift_key_pressed = e.shiftKey;
1064
+ });
1065
+ window.addEventListener("keyup", function (e) {
1066
+ shift_key_pressed = e.shiftKey;
1067
+ });
1068
+ m.mount(container_element, { view: render });
1069
+ }
1070
+ function parse_storage(name) {
1071
+ if (name == "sessionStorage")
1072
+ return sessionStorage;
1073
+ if (name == "localStorage")
1074
+ return localStorage;
1075
+ if (name)
1076
+ throw new Error("Unknown storage: " + name);
1077
+ return null;
1078
+ }
1079
+ class QncDataTable extends HTMLElement {
1080
+ static get observedAttributes() {
1081
+ return ["table-config"];
1082
+ }
1083
+ attributeChangedCallback() {
1084
+ this.read_and_update_config();
1085
+ }
1086
+ constructor() {
1087
+ super();
1088
+ this.read_and_update_config();
1089
+ }
1090
+ read_and_update_config() {
1091
+ const config = JSON.parse(this.getAttribute("table-config"));
1092
+ let storage = config.storage;
1093
+ if (storage == "sessionStorage")
1094
+ storage = sessionStorage;
1095
+ else if (storage == "localStorage")
1096
+ storage = localStorage;
1097
+ else if (storage)
1098
+ throw new Error("Unknown storage: " + storage);
1099
+ function get_filter_form() {
1100
+ if (!config.filter_form_id)
1101
+ return null;
1102
+ const form = document.getElementById(config.filter_form_id);
1103
+ if (!(form instanceof HTMLFormElement))
1104
+ throw new Error(`"#${config.filter_form_id}" does not identify an HTMLFormElement`);
1105
+ return form;
1106
+ }
1107
+ const renderer = table_renderers.get(config.renderer);
1108
+ if (!renderer)
1109
+ throw new Error(`No table renderer registered for "${config.renderer}"`);
1110
+ const navigation_storage = parse_storage(config.navigation_storage);
1111
+ const sort_storage_key = config.sort_storage;
1112
+ // Note sure whether or not this is necessary
1113
+ m.mount(this, null);
1114
+ mount_data_table(this, config.columns, {
1115
+ buttons: config.buttons,
1116
+ api_url: config.api_url,
1117
+ filter_form: get_filter_form(),
1118
+ key_prefix: config.key_prefix,
1119
+ auto_submit: config.auto_submit,
1120
+ page_size: config.page_size,
1121
+ renderer: renderer,
1122
+ default_sort_column_key: config.default_sort_column_key,
1123
+ default_sort_ascending: config.default_sort_ascending,
1124
+ fill_last_page: config.fill_last_page,
1125
+ layout_storage: parse_storage(config.layout_storage),
1126
+ navigation_storage: parse_storage(config.navigation_storage),
1127
+ selection_storage: parse_storage(config.selection_storage),
1128
+ sort_storage: sort_storage_key == "navigation_storage"
1129
+ ? navigation_storage
1130
+ : parse_storage(sort_storage_key),
1131
+ });
1132
+ }
1133
+ }
1134
+ export function define(tag_name) {
1135
+ customElements.define(tag_name, QncDataTable);
1136
+ }