@yuneta/gobj-ui 7.10.4 → 7.11.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/README.md CHANGED
@@ -970,6 +970,67 @@ the middle, and **a toolbar left with a single group is centred**. An unknown
970
970
  name is reported, not silently dropped: a typo would otherwise remove the save
971
971
  button with no trace of why.
972
972
 
973
+ ### Selecting rows in any table — `yui_table_select.js`
974
+
975
+ Deleting twenty rows one confirmation at a time is not a workflow. Any view
976
+ whose table can remove (or export, or act on) a row eventually needs to do it
977
+ to several at once, so the checkbox column, the settings behind it and the bar
978
+ that appears while something is ticked live here once:
979
+
980
+ ```js
981
+ import {
982
+ yui_selection_column,
983
+ yui_selection_settings,
984
+ yui_selection_bar,
985
+ yui_wire_selection,
986
+ yui_selected_rows,
987
+ yui_clear_selection,
988
+ } from "@yuneta/gobj-ui/src/yui_table_select.js";
989
+
990
+ /* 1. the column, FIRST in the list */
991
+ let columns = [yui_selection_column(), ...my_columns];
992
+
993
+ /* 2. the settings it needs */
994
+ let table = new Tabulator($div, {...yui_selection_settings(), columns: columns, ...});
995
+
996
+ /* 3. the bar. Every button's job is to SEND AN EVENT */
997
+ priv.bar = yui_selection_bar(t, {
998
+ name: "CONNECTIONS",
999
+ actions: [{
1000
+ label: "remove selected", /* an i18n KEY */
1001
+ icon: "yi-trash",
1002
+ class: "is-danger",
1003
+ on_click: () => gobj_send_event(gobj, "EV_REMOVE_SELECTED", {}, gobj)
1004
+ }],
1005
+ on_clear: () => gobj_send_event(gobj, "EV_CLEAR_SELECTION", {}, gobj)
1006
+ });
1007
+ $container.appendChild(priv.bar.$el);
1008
+
1009
+ /* 4. the table tells the bar how many are ticked */
1010
+ yui_wire_selection(table, (n) => gobj_send_event(gobj, "EV_SELECTION_CHANGED",
1011
+ {count: n}, gobj));
1012
+ ```
1013
+
1014
+ Two decisions are baked in, both learned in the treedb topic table:
1015
+
1016
+ - **Selection is driven only by the checkbox** (`selectableRows: "highlight"`),
1017
+ never by clicking the row. A row is full of things to click — an editor, an
1018
+ icon, a nested table — and click-to-select ticks a row every time you reach
1019
+ for one of them.
1020
+ - **The header checkbox covers the ACTIVE rows**, the ones the filters leave on
1021
+ screen (`titleFormatterParams: {rowRange: "active"}`). "Select all" over rows
1022
+ nobody can see is how a filtered delete takes the whole topic with it.
1023
+
1024
+ The bar takes its words from the HOST's `t` (this library translates through
1025
+ the app's i18next): the app must define **`"{{n}} selected"`** and
1026
+ **`"clear selection"`**, and each action's own key. The count is composed at
1027
+ render time, so `refresh_language()` cannot reach it — call `bar.refresh()`
1028
+ from the view's `EV_LANGUAGE_CHANGED` action.
1029
+
1030
+ `yui_selected_rows(table)` and `yui_clear_selection(table)` answer safely on a
1031
+ table that is not built yet or is already gone. Clear the selection after
1032
+ acting on it: the rows it names are no longer there.
1033
+
973
1034
  ## Conventions
974
1035
 
975
1036
  ### i18n: a string must be able to CHANGE language, not just be translated once
@@ -8536,7 +8536,7 @@ function decod_coordinates(gobj, coordinates) {
8536
8536
  *
8537
8537
  ************************************************************/
8538
8538
  function get_coordinates(gobj, device) {
8539
- let coordinates = decod_coordinates(gobj, device.settings.coordinates);
8539
+ let coordinates = decod_coordinates(gobj, device.settings?.coordinates);
8540
8540
  if (coordinates.lng === 0 && coordinates.lat === 0) coordinates = decod_coordinates(gobj, device.coordinates);
8541
8541
  if (coordinates.lng === 0 && coordinates.lat === 0) {
8542
8542
  coordinates.lng = (0, _yuneta_gobj_js.gobj_read_attr)(gobj, "default_longitude");
@@ -8585,7 +8585,7 @@ function devices2geojson(gobj, devices) {
8585
8585
  id: device.id,
8586
8586
  name: device.name,
8587
8587
  image: device.image,
8588
- connected: device.connected,
8588
+ connected: !!device.connected,
8589
8589
  gobj_service_name: device.gobj_service_name
8590
8590
  }
8591
8591
  };
@@ -8531,7 +8531,7 @@ function decod_coordinates(gobj, coordinates) {
8531
8531
  *
8532
8532
  ************************************************************/
8533
8533
  function get_coordinates(gobj, device) {
8534
- let coordinates = decod_coordinates(gobj, device.settings.coordinates);
8534
+ let coordinates = decod_coordinates(gobj, device.settings?.coordinates);
8535
8535
  if (coordinates.lng === 0 && coordinates.lat === 0) coordinates = decod_coordinates(gobj, device.coordinates);
8536
8536
  if (coordinates.lng === 0 && coordinates.lat === 0) {
8537
8537
  coordinates.lng = gobj_read_attr(gobj, "default_longitude");
@@ -8580,7 +8580,7 @@ function devices2geojson(gobj, devices) {
8580
8580
  id: device.id,
8581
8581
  name: device.name,
8582
8582
  image: device.image,
8583
- connected: device.connected,
8583
+ connected: !!device.connected,
8584
8584
  gobj_service_name: device.gobj_service_name
8585
8585
  }
8586
8586
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuneta/gobj-ui",
3
- "version": "7.10.4",
3
+ "version": "7.11.0",
4
4
  "type": "module",
5
5
  "main": "dist/gobj-ui.cjs.js",
6
6
  "module": "dist/gobj-ui.es.js",
package/src/c_yui_map.js CHANGED
@@ -293,7 +293,7 @@ function get_coordinates(gobj, device)
293
293
  * Coordinates can be set in device.settings or in device
294
294
  * If devices.settings are null then get it from device
295
295
  */
296
- let coordinates = decod_coordinates(gobj, device.settings.coordinates);
296
+ let coordinates = decod_coordinates(gobj, device.settings?.coordinates);
297
297
  if(coordinates.lng === 0 && coordinates.lat === 0) {
298
298
  coordinates = decod_coordinates(gobj, device.coordinates);
299
299
  }
@@ -369,7 +369,18 @@ function devices2geojson(gobj, devices)
369
369
  id: device.id,
370
370
  name: device.name,
371
371
  image: device.image,
372
- connected: device.connected,
372
+ /*
373
+ * A REAL boolean. Four style expressions test this with
374
+ * ['case', ...], and maplibre asserts a STRICT boolean
375
+ * there, so null, absent, or the 1/0 a backend may send all
376
+ * fail the assertion. The consequences are not cosmetic:
377
+ * the point paints with the property default instead of
378
+ * green/red, and the cluster accumulator turns NULL, which
379
+ * the cluster colour compares against point_count -- never
380
+ * equal, so the whole cluster stays red as if something were
381
+ * disconnected.
382
+ */
383
+ connected: !!device.connected,
373
384
  gobj_service_name: device.gobj_service_name,
374
385
  }
375
386
  };
@@ -0,0 +1,231 @@
1
+ /***********************************************************************
2
+ * yui_table_select.js
3
+ *
4
+ * SELECT ROWS IN A TABLE, and act on the lot.
5
+ *
6
+ * Deleting twenty rows one confirmation at a time is not a
7
+ * workflow, it is a punishment, and every table that lets you
8
+ * remove a row will eventually be asked to remove twenty. This
9
+ * is that facility, once: the checkbox column, the settings
10
+ * that make selection behave, and the bar that appears while
11
+ * something is selected and carries what can be done to it.
12
+ *
13
+ * Two decisions are baked in, both learned in the treedb
14
+ * topic table:
15
+ *
16
+ * - Selection is driven ONLY by the checkbox, never by
17
+ * clicking the row (`selectableRows: "highlight"`). A table
18
+ * row is full of things to click -- an editor, an icon, a
19
+ * nested table -- and click-to-select ticks a row every time
20
+ * you reach for one of them.
21
+ *
22
+ * - The header checkbox covers the ACTIVE rows, the ones the
23
+ * filters leave on screen. "Select all" over rows nobody can
24
+ * see is how a filtered delete takes the whole topic with
25
+ * it.
26
+ *
27
+ * The bar takes its words from the HOST's `t`: this library
28
+ * translates through the app's i18next, and the app owns the
29
+ * keys. It needs "{{n}} selected" and "clear selection"; each
30
+ * action brings its own key.
31
+ *
32
+ * Copyright (c) 2026, ArtGins.
33
+ * All Rights Reserved.
34
+ ***********************************************************************/
35
+ import {createElement2, log_error} from "@yuneta/gobj-js";
36
+
37
+ /***************************************************************
38
+ * The checkbox column. Put it FIRST in the column list.
39
+ *
40
+ * opts:
41
+ * field the (virtual) field name, default "_select"
42
+ * width default 44
43
+ ***************************************************************/
44
+ function yui_selection_column(opts)
45
+ {
46
+ let o = opts || {};
47
+ return {
48
+ title: "",
49
+ field: o.field || "_select",
50
+ width: o.width || 44,
51
+ minWidth: o.width || 44,
52
+ hozAlign: "center",
53
+ headerHozAlign: "center",
54
+ headerSort: false,
55
+ resizable: false,
56
+ formatter: "rowSelection",
57
+ titleFormatter: "rowSelection",
58
+ /* The header ticks what is ON SCREEN, not what the filters hide. */
59
+ titleFormatterParams: {rowRange: "active"},
60
+ cssClass: "TABLE_SELECT_CELL"
61
+ };
62
+ }
63
+
64
+ /***************************************************************
65
+ * What the Tabulator settings need for the column above.
66
+ * Spread it into the settings object.
67
+ ***************************************************************/
68
+ function yui_selection_settings()
69
+ {
70
+ return {selectableRows: "highlight"};
71
+ }
72
+
73
+ /***************************************************************
74
+ * The rows selected right now (never null).
75
+ ***************************************************************/
76
+ function yui_selected_rows(tabulator)
77
+ {
78
+ if(!tabulator || typeof tabulator.getSelectedData !== "function") {
79
+ return [];
80
+ }
81
+ try {
82
+ return tabulator.getSelectedData() || [];
83
+ } catch(e) {
84
+ return []; /* asked before the table was built */
85
+ }
86
+ }
87
+
88
+ /***************************************************************
89
+ * Drop the selection (after acting on it, or after a
90
+ * reload that leaves the ticked rows behind).
91
+ ***************************************************************/
92
+ function yui_clear_selection(tabulator)
93
+ {
94
+ if(!tabulator || typeof tabulator.deselectRow !== "function") {
95
+ return;
96
+ }
97
+ try {
98
+ tabulator.deselectRow();
99
+ } catch(e) {
100
+ /* the table is gone: there is no selection to drop */
101
+ }
102
+ }
103
+
104
+ /***************************************************************
105
+ * Tell me when the selection changes.
106
+ *
107
+ * `on_change(count)` is a DOM/widget notification and nothing
108
+ * else: in a gclass its whole job is to SEND AN EVENT.
109
+ ***************************************************************/
110
+ function yui_wire_selection(tabulator, on_change)
111
+ {
112
+ if(!tabulator || typeof tabulator.on !== "function") {
113
+ log_error("yui_wire_selection(): no tabulator");
114
+ return;
115
+ }
116
+ if(typeof on_change !== "function") {
117
+ log_error("yui_wire_selection(): on_change is not a function");
118
+ return;
119
+ }
120
+ tabulator.on("rowSelectionChanged", function(data) {
121
+ on_change((data && data.length) || 0);
122
+ });
123
+ }
124
+
125
+ /***************************************************************
126
+ * The bar that appears while rows are selected.
127
+ *
128
+ * t the HOST's translator
129
+ * opts:
130
+ * actions [{label, icon, class, on_click}] -- `label` is an
131
+ * i18n KEY, `class` extra Bulma classes for the
132
+ * button (e.g. "is-danger"), `on_click` the DOM
133
+ * handler, whose job is to send an event
134
+ * on_clear called by the "clear selection" button
135
+ * name logical-class prefix, default "TABLE"
136
+ *
137
+ * -> {$el, set_count(n), refresh()}
138
+ *
139
+ * `refresh()` redraws it in the current language: the count is
140
+ * composed at render time, so it cannot re-translate itself
141
+ * through `refresh_language()`. Call it on EV_LANGUAGE_CHANGED.
142
+ ***************************************************************/
143
+ function yui_selection_bar(t, opts)
144
+ {
145
+ let o = opts || {};
146
+ let actions = Array.isArray(o.actions) ? o.actions : [];
147
+ let name = o.name || "TABLE";
148
+ let count = 0;
149
+
150
+ let $count = createElement2(
151
+ ["span", {class: `${name}_SELECTION_COUNT has-text-weight-semibold`}, ""]);
152
+
153
+ let $buttons = [];
154
+ for(let action of actions) {
155
+ let $b = createElement2(
156
+ ["button", {class: `${name}_SELECTION_ACTION button ${action.class || ""}`,
157
+ type: "button"}, [
158
+ ["span", {class: "icon"}, [["i", {class: action.icon || "yi-check"}]]],
159
+ ["span", {}, ""]
160
+ ], {
161
+ click: action.on_click
162
+ }]
163
+ );
164
+ $b._yui_label = action.label || "";
165
+ $buttons.push($b);
166
+ }
167
+
168
+ let $clear = createElement2(
169
+ ["button", {class: `${name}_SELECTION_CLEAR button is-ghost`, type: "button"}, [
170
+ ["span", {class: "icon"}, [["i", {class: "yi-xmark"}]]],
171
+ ["span", {}, ""]
172
+ ], {
173
+ click: function(e) {
174
+ if(typeof o.on_clear === "function") {
175
+ o.on_clear(e);
176
+ }
177
+ }
178
+ }]
179
+ );
180
+ $clear._yui_label = "clear selection";
181
+
182
+ let $el = createElement2(
183
+ ["div", {class: `${name}_SELECTION_BAR is-flex is-align-items-center `
184
+ + "is-flex-wrap-wrap mb-2 is-hidden",
185
+ style: "gap:0.5rem;"},
186
+ [$count].concat($buttons, [$clear])]
187
+ );
188
+
189
+ function label_of($b)
190
+ {
191
+ /* The second span: the icon carries the first one. */
192
+ return $b.querySelector("span.icon ~ span");
193
+ }
194
+
195
+ function refresh()
196
+ {
197
+ $count.textContent = t("{{n}} selected", {n: count});
198
+ for(let $b of $buttons.concat([$clear])) {
199
+ let $label = label_of($b);
200
+ let key = $b._yui_label;
201
+ if($label && key) {
202
+ $label.textContent = t(key);
203
+ }
204
+ $b.title = key ? t(key) : "";
205
+ $b.setAttribute("aria-label", $b.title);
206
+ }
207
+ /* Bulma's `is-hidden` carries `!important`: the class is what
208
+ * toggles, an inline display would lose to it. */
209
+ $el.classList.toggle("is-hidden", count <= 0);
210
+ }
211
+
212
+ function set_count(n)
213
+ {
214
+ count = n > 0 ? n : 0;
215
+ refresh();
216
+ }
217
+
218
+ refresh();
219
+
220
+ return {$el: $el, set_count: set_count, refresh: refresh};
221
+ }
222
+
223
+
224
+ export {
225
+ yui_selection_column,
226
+ yui_selection_settings,
227
+ yui_selected_rows,
228
+ yui_clear_selection,
229
+ yui_wire_selection,
230
+ yui_selection_bar,
231
+ };
@@ -0,0 +1,54 @@
1
+ /***********************************************************************
2
+ * yui_table_select.test.js
3
+ *
4
+ * The part of the facility that is not DOM: what the checkbox
5
+ * column is, and reading a selection out of a table that may not
6
+ * be there any more. The bar itself is DOM, like every other view
7
+ * in this library, and this suite has no DOM.
8
+ ***********************************************************************/
9
+ import { test, expect, describe } from "vitest";
10
+ import {
11
+ yui_selection_column,
12
+ yui_selection_settings,
13
+ yui_selected_rows,
14
+ yui_clear_selection,
15
+ } from "./yui_table_select.js";
16
+
17
+ describe("the column", () => {
18
+ test("is a rowSelection checkbox that does not sort", () => {
19
+ let col = yui_selection_column();
20
+ expect(col.formatter).toBe("rowSelection");
21
+ expect(col.titleFormatter).toBe("rowSelection");
22
+ expect(col.headerSort).toBe(false);
23
+ });
24
+
25
+ test("the header ticks the rows the filters leave on screen", () => {
26
+ expect(yui_selection_column().titleFormatterParams).toEqual({rowRange: "active"});
27
+ });
28
+
29
+ test("the row itself is not clickable into selection", () => {
30
+ expect(yui_selection_settings()).toEqual({selectableRows: "highlight"});
31
+ });
32
+ });
33
+
34
+ describe("reading and dropping the selection", () => {
35
+ test("a table that is not built yet has nothing selected", () => {
36
+ expect(yui_selected_rows(null)).toEqual([]);
37
+ expect(yui_selected_rows({})).toEqual([]);
38
+ });
39
+
40
+ test("a table that throws has nothing selected either", () => {
41
+ expect(yui_selected_rows({getSelectedData: () => { throw new Error("gone"); }}))
42
+ .toEqual([]);
43
+ });
44
+
45
+ test("the rows come back as the table gives them", () => {
46
+ expect(yui_selected_rows({getSelectedData: () => [{id: "a"}]})).toEqual([{id: "a"}]);
47
+ });
48
+
49
+ test("clearing a table that is gone is not a crash", () => {
50
+ expect(() => yui_clear_selection(null)).not.toThrow();
51
+ expect(() => yui_clear_selection({deselectRow: () => { throw new Error("gone"); }}))
52
+ .not.toThrow();
53
+ });
54
+ });