@yuneta/gobj-ui 7.23.47 → 7.23.49

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.
@@ -22558,6 +22558,172 @@ function yui_selection_bar(t, opts) {
22558
22558
  };
22559
22559
  }
22560
22560
  //#endregion
22561
+ //#region src/yui_table_filter_clear.js
22562
+ /***********************************************************************
22563
+ * yui_table_filter_clear.js
22564
+ *
22565
+ * LA ✕ DE LOS FILTROS DE CABECERA DE UNA TABULATOR.
22566
+ *
22567
+ * Un filtro de columna se pone escribiendo y se quita BORRANDO lo
22568
+ * escrito, letra a letra, y no hay nada en la cabecera que diga
22569
+ * que se puede quitar. Con varias columnas filtradas, volver a
22570
+ * ver la tabla entera es un ejercicio de memoria: cuáles toqué.
22571
+ * La ✕ lo dice y lo hace.
22572
+ *
22573
+ * TRES DECISIONES QUE NO SON OBVIAS:
22574
+ *
22575
+ * - **Se ve mientras el filtro tenga contenido, con foco o sin
22576
+ * él.** Es al revés que la ✕ de un formulario
22577
+ * (`yui_inputs.js`), que sólo aparece en el campo que se está
22578
+ * editando para no encender un aspa en cada campo relleno. Aquí
22579
+ * el caso de uso es justo el contrario: el filtro ya está puesto,
22580
+ * el foco está en otra parte, y lo que se quiere es verlo y
22581
+ * quitarlo. Escondida tras el foco harían falta dos clics.
22582
+ *
22583
+ * - **Se borra con la API, no simulando teclas.**
22584
+ * `setHeaderFilterValue(field, "")` es lo que Tabulator ofrece, y
22585
+ * deja el valor y el filtro consistentes. Sintetizar un `input`
22586
+ * sobre el `<input>` depende de a qué escuche el editor de ese
22587
+ * filtro, que cambia con el tipo de columna.
22588
+ *
22589
+ * - **Se recorre la cabecera, no se envuelve el editor.** Las
22590
+ * columnas se construyen del esquema del topic, así que no hay un
22591
+ * sitio único donde envolver el editor; y la cabecera se rehace
22592
+ * sola cuando cambian las columnas. El recorrido es idempotente
22593
+ * -- marca lo que ya tiene aspa -- y se dispara con un
22594
+ * `querySelector` de una sola pasada, así que repetirlo en cada
22595
+ * render no cuesta nada.
22596
+ *
22597
+ * Copyright (c) 2026, ArtGins.
22598
+ * All Rights Reserved.
22599
+ ***********************************************************************/
22600
+ var MARK = "data-yui-filter-clear";
22601
+ /***************************************************************
22602
+ * Pon (o repón) la ✕ de cada filtro de cabecera de `table`.
22603
+ *
22604
+ * Idempotente: los que ya la tienen se saltan.
22605
+ ***************************************************************/
22606
+ function decorate(table) {
22607
+ if (!table || !table.element) return;
22608
+ if (!table.element.querySelector(`.tabulator-header-filter:not([${MARK}])`)) return;
22609
+ let list = table.element.querySelectorAll(`.tabulator-header-filter:not([${MARK}])`);
22610
+ for (const $filter of list) {
22611
+ $filter.setAttribute(MARK, "1");
22612
+ let $input = $filter.querySelector("input");
22613
+ if (!$input) continue;
22614
+ let $col = $filter.closest(".tabulator-col");
22615
+ let field = $col ? $col.getAttribute("tabulator-field") : "";
22616
+ if (!field) continue;
22617
+ $filter.classList.add("yui-filter-has-clear");
22618
+ let $btn = (0, _yuneta_gobj_js.createElement2)(["button", {
22619
+ type: "button",
22620
+ class: "delete is-small yui-filter-clear",
22621
+ tabindex: "-1",
22622
+ title: i18next.default.t("clear"),
22623
+ "aria-label": i18next.default.t("clear"),
22624
+ "data-i18n-title": "clear",
22625
+ "data-i18n-aria-label": "clear"
22626
+ }]);
22627
+ const sync = () => {
22628
+ $btn.classList.toggle("is-visible", !!$input.value);
22629
+ };
22630
+ $input.addEventListener("input", sync);
22631
+ $input.addEventListener("change", sync);
22632
+ $btn.addEventListener("click", (event) => {
22633
+ event.stopPropagation();
22634
+ table.setHeaderFilterValue(field, "");
22635
+ sync();
22636
+ });
22637
+ $filter.appendChild($btn);
22638
+ sync();
22639
+ }
22640
+ }
22641
+ /***************************************************************
22642
+ * Engancha la ✕ a una tabla ya creada.
22643
+ *
22644
+ * Se llama una vez, con la tabla construida. Los enganches
22645
+ * cubren los dos momentos en que la cabecera aparece o se
22646
+ * rehace: cuando se construye y cuando cambian las columnas.
22647
+ ***************************************************************/
22648
+ function yui_table_filter_clear(table) {
22649
+ if (!table) return;
22650
+ decorate(table);
22651
+ table.on("renderComplete", () => {
22652
+ decorate(table);
22653
+ });
22654
+ table.on("columnVisibilityChanged", () => {
22655
+ decorate(table);
22656
+ });
22657
+ }
22658
+ //#endregion
22659
+ //#region src/yui_row_search.js
22660
+ /***********************************************************************
22661
+ * yui_row_search.js
22662
+ *
22663
+ * ¿ESTÁ ESTE TÉRMINO EN ESTA FILA?
22664
+ *
22665
+ * La caja de búsqueda de una tabla de treedb miraba los valores de
22666
+ * la fila con `String(val)`, y una fila de treedb no es plana: un
22667
+ * fkey llega como una LISTA DE OBJETOS
22668
+ * `[{id, topic_name, hook_name}]`, y `String()` de eso es
22669
+ * `"[object Object]"`. O sea que buscar el taller de un equipo --
22670
+ * que es donde vive el dato que un operador tiene en la cabeza --
22671
+ * no encontraba nunca nada, y la caja no daba ninguna pista de que
22672
+ * había mirado en otro sitio.
22673
+ *
22674
+ * DOS REGLAS, Y LA SEGUNDA ES LA QUE HACE QUE SIRVA:
22675
+ *
22676
+ * - Se baja por listas y objetos hasta una profundidad corta: un
22677
+ * fkey está a dos niveles y nada de lo que se busca está más
22678
+ * hondo.
22679
+ * - **De un fkey se mira sólo el `id`.** `topic_name` y
22680
+ * `hook_name` son las MISMAS dos palabras en todas las filas, así
22681
+ * que mirarlas convierte el término en un comodín: buscar
22682
+ * "devices" traería el topic entero. El `id` es lo único que
22683
+ * nombra la cosa enlazada.
22684
+ *
22685
+ * Las claves que empiezan por `_` no se miran en ningún nivel: son
22686
+ * del armazón (`_check_box_state_`, `_operation`) o metadatos
22687
+ * (`__md_treedb__`), y nadie busca por ellas.
22688
+ *
22689
+ * Copyright (c) 2026, ArtGins.
22690
+ * All Rights Reserved.
22691
+ ***********************************************************************/
22692
+ var MAX_DEPTH = 4;
22693
+ /***************************************************************
22694
+ * Un fkey, tal y como `nodes` lo contesta con `list_dict`.
22695
+ ***************************************************************/
22696
+ function is_fkey_ref(value) {
22697
+ return !!value && typeof value === "object" && !Array.isArray(value) && "id" in value && "topic_name" in value && "hook_name" in value;
22698
+ }
22699
+ /***************************************************************
22700
+ * ¿Contiene `value` el término? El término viene YA en
22701
+ * minúsculas: quien busca lo baja una vez y no una por celda.
22702
+ ***************************************************************/
22703
+ function value_matches(value, term, depth) {
22704
+ if (value === null || value === void 0) return false;
22705
+ if (depth > MAX_DEPTH) return false;
22706
+ if (Array.isArray(value)) return value.some((v) => value_matches(v, term, depth + 1));
22707
+ if (typeof value === "object") {
22708
+ if (is_fkey_ref(value)) return value_matches(value.id, term, depth + 1);
22709
+ return Object.entries(value).some(([key, v]) => {
22710
+ if (key.startsWith("_")) return false;
22711
+ return value_matches(v, term, depth + 1);
22712
+ });
22713
+ }
22714
+ return String(value).toLowerCase().includes(term);
22715
+ }
22716
+ /***************************************************************
22717
+ * ¿Está el término en alguna parte de esta fila?
22718
+ ***************************************************************/
22719
+ function row_matches(row, term) {
22720
+ if (!row || typeof row !== "object" || !term) return false;
22721
+ return Object.entries(row).some(([key, value]) => {
22722
+ if (key.startsWith("_")) return false;
22723
+ return value_matches(value, term, 0);
22724
+ });
22725
+ }
22726
+ //#endregion
22561
22727
  //#region src/treedb_write_plan.js
22562
22728
  /***********************************************************************
22563
22729
  * treedb_write_plan.js
@@ -23631,7 +23797,8 @@ function create_tabulator(gobj) {
23631
23797
  50,
23632
23798
  100,
23633
23799
  200,
23634
- 500
23800
+ 500,
23801
+ true
23635
23802
  ],
23636
23803
  ajaxURL: "nodes",
23637
23804
  ajaxRequestFunc: function(url, config, params) {
@@ -23685,6 +23852,7 @@ function create_tabulator(gobj) {
23685
23852
  tabulator.on("tableBuilt", function() {
23686
23853
  tabulator._ready = true;
23687
23854
  update_rowcount();
23855
+ yui_table_filter_clear(tabulator);
23688
23856
  if (tabulator._pendingData !== void 0) {
23689
23857
  tabulator.setData(tabulator._pendingData);
23690
23858
  delete tabulator._pendingData;
@@ -24999,11 +25167,12 @@ function request_page(gobj, page, size) {
24999
25167
  reject,
25000
25168
  timer
25001
25169
  };
25170
+ const all = size === true;
25002
25171
  (0, _yuneta_gobj_js.gobj_publish_event)(gobj, "EV_REQUEST_PAGE", {
25003
25172
  topic_name: (0, _yuneta_gobj_js.gobj_read_str_attr)(gobj, "topic_name"),
25004
25173
  req_id,
25005
- from: (page - 1) * size + 1,
25006
- limit: size
25174
+ from: all ? 1 : (page - 1) * size + 1,
25175
+ limit: all ? 0 : size
25007
25176
  });
25008
25177
  });
25009
25178
  }
@@ -25085,11 +25254,7 @@ function ac_search(gobj, event, kw, src) {
25085
25254
  return 0;
25086
25255
  }
25087
25256
  tabulator.setFilter(function(data) {
25088
- return Object.entries(data).some(([key, val]) => {
25089
- if (key.startsWith("_")) return false;
25090
- if (val === null || val === void 0) return false;
25091
- return String(val).toLowerCase().includes(term);
25092
- });
25257
+ return row_matches(data, term);
25093
25258
  });
25094
25259
  return 0;
25095
25260
  }
@@ -67150,6 +67315,7 @@ function yui_asset_element(answer, opts = {}) {
67150
67315
  }
67151
67316
  el.src = src;
67152
67317
  el.onerror = () => {
67318
+ (0, _yuneta_gobj_js.log_error)(`asset could not be loaded: ${opts.detail || src}`);
67153
67319
  const $marker = yui_asset_missing(opts.detail, opts);
67154
67320
  if (el.parentNode) el.parentNode.replaceChild($marker, el);
67155
67321
  };
@@ -22553,6 +22553,172 @@ function yui_selection_bar(t, opts) {
22553
22553
  };
22554
22554
  }
22555
22555
  //#endregion
22556
+ //#region src/yui_table_filter_clear.js
22557
+ /***********************************************************************
22558
+ * yui_table_filter_clear.js
22559
+ *
22560
+ * LA ✕ DE LOS FILTROS DE CABECERA DE UNA TABULATOR.
22561
+ *
22562
+ * Un filtro de columna se pone escribiendo y se quita BORRANDO lo
22563
+ * escrito, letra a letra, y no hay nada en la cabecera que diga
22564
+ * que se puede quitar. Con varias columnas filtradas, volver a
22565
+ * ver la tabla entera es un ejercicio de memoria: cuáles toqué.
22566
+ * La ✕ lo dice y lo hace.
22567
+ *
22568
+ * TRES DECISIONES QUE NO SON OBVIAS:
22569
+ *
22570
+ * - **Se ve mientras el filtro tenga contenido, con foco o sin
22571
+ * él.** Es al revés que la ✕ de un formulario
22572
+ * (`yui_inputs.js`), que sólo aparece en el campo que se está
22573
+ * editando para no encender un aspa en cada campo relleno. Aquí
22574
+ * el caso de uso es justo el contrario: el filtro ya está puesto,
22575
+ * el foco está en otra parte, y lo que se quiere es verlo y
22576
+ * quitarlo. Escondida tras el foco harían falta dos clics.
22577
+ *
22578
+ * - **Se borra con la API, no simulando teclas.**
22579
+ * `setHeaderFilterValue(field, "")` es lo que Tabulator ofrece, y
22580
+ * deja el valor y el filtro consistentes. Sintetizar un `input`
22581
+ * sobre el `<input>` depende de a qué escuche el editor de ese
22582
+ * filtro, que cambia con el tipo de columna.
22583
+ *
22584
+ * - **Se recorre la cabecera, no se envuelve el editor.** Las
22585
+ * columnas se construyen del esquema del topic, así que no hay un
22586
+ * sitio único donde envolver el editor; y la cabecera se rehace
22587
+ * sola cuando cambian las columnas. El recorrido es idempotente
22588
+ * -- marca lo que ya tiene aspa -- y se dispara con un
22589
+ * `querySelector` de una sola pasada, así que repetirlo en cada
22590
+ * render no cuesta nada.
22591
+ *
22592
+ * Copyright (c) 2026, ArtGins.
22593
+ * All Rights Reserved.
22594
+ ***********************************************************************/
22595
+ var MARK = "data-yui-filter-clear";
22596
+ /***************************************************************
22597
+ * Pon (o repón) la ✕ de cada filtro de cabecera de `table`.
22598
+ *
22599
+ * Idempotente: los que ya la tienen se saltan.
22600
+ ***************************************************************/
22601
+ function decorate(table) {
22602
+ if (!table || !table.element) return;
22603
+ if (!table.element.querySelector(`.tabulator-header-filter:not([${MARK}])`)) return;
22604
+ let list = table.element.querySelectorAll(`.tabulator-header-filter:not([${MARK}])`);
22605
+ for (const $filter of list) {
22606
+ $filter.setAttribute(MARK, "1");
22607
+ let $input = $filter.querySelector("input");
22608
+ if (!$input) continue;
22609
+ let $col = $filter.closest(".tabulator-col");
22610
+ let field = $col ? $col.getAttribute("tabulator-field") : "";
22611
+ if (!field) continue;
22612
+ $filter.classList.add("yui-filter-has-clear");
22613
+ let $btn = createElement2(["button", {
22614
+ type: "button",
22615
+ class: "delete is-small yui-filter-clear",
22616
+ tabindex: "-1",
22617
+ title: i18next.t("clear"),
22618
+ "aria-label": i18next.t("clear"),
22619
+ "data-i18n-title": "clear",
22620
+ "data-i18n-aria-label": "clear"
22621
+ }]);
22622
+ const sync = () => {
22623
+ $btn.classList.toggle("is-visible", !!$input.value);
22624
+ };
22625
+ $input.addEventListener("input", sync);
22626
+ $input.addEventListener("change", sync);
22627
+ $btn.addEventListener("click", (event) => {
22628
+ event.stopPropagation();
22629
+ table.setHeaderFilterValue(field, "");
22630
+ sync();
22631
+ });
22632
+ $filter.appendChild($btn);
22633
+ sync();
22634
+ }
22635
+ }
22636
+ /***************************************************************
22637
+ * Engancha la ✕ a una tabla ya creada.
22638
+ *
22639
+ * Se llama una vez, con la tabla construida. Los enganches
22640
+ * cubren los dos momentos en que la cabecera aparece o se
22641
+ * rehace: cuando se construye y cuando cambian las columnas.
22642
+ ***************************************************************/
22643
+ function yui_table_filter_clear(table) {
22644
+ if (!table) return;
22645
+ decorate(table);
22646
+ table.on("renderComplete", () => {
22647
+ decorate(table);
22648
+ });
22649
+ table.on("columnVisibilityChanged", () => {
22650
+ decorate(table);
22651
+ });
22652
+ }
22653
+ //#endregion
22654
+ //#region src/yui_row_search.js
22655
+ /***********************************************************************
22656
+ * yui_row_search.js
22657
+ *
22658
+ * ¿ESTÁ ESTE TÉRMINO EN ESTA FILA?
22659
+ *
22660
+ * La caja de búsqueda de una tabla de treedb miraba los valores de
22661
+ * la fila con `String(val)`, y una fila de treedb no es plana: un
22662
+ * fkey llega como una LISTA DE OBJETOS
22663
+ * `[{id, topic_name, hook_name}]`, y `String()` de eso es
22664
+ * `"[object Object]"`. O sea que buscar el taller de un equipo --
22665
+ * que es donde vive el dato que un operador tiene en la cabeza --
22666
+ * no encontraba nunca nada, y la caja no daba ninguna pista de que
22667
+ * había mirado en otro sitio.
22668
+ *
22669
+ * DOS REGLAS, Y LA SEGUNDA ES LA QUE HACE QUE SIRVA:
22670
+ *
22671
+ * - Se baja por listas y objetos hasta una profundidad corta: un
22672
+ * fkey está a dos niveles y nada de lo que se busca está más
22673
+ * hondo.
22674
+ * - **De un fkey se mira sólo el `id`.** `topic_name` y
22675
+ * `hook_name` son las MISMAS dos palabras en todas las filas, así
22676
+ * que mirarlas convierte el término en un comodín: buscar
22677
+ * "devices" traería el topic entero. El `id` es lo único que
22678
+ * nombra la cosa enlazada.
22679
+ *
22680
+ * Las claves que empiezan por `_` no se miran en ningún nivel: son
22681
+ * del armazón (`_check_box_state_`, `_operation`) o metadatos
22682
+ * (`__md_treedb__`), y nadie busca por ellas.
22683
+ *
22684
+ * Copyright (c) 2026, ArtGins.
22685
+ * All Rights Reserved.
22686
+ ***********************************************************************/
22687
+ var MAX_DEPTH = 4;
22688
+ /***************************************************************
22689
+ * Un fkey, tal y como `nodes` lo contesta con `list_dict`.
22690
+ ***************************************************************/
22691
+ function is_fkey_ref(value) {
22692
+ return !!value && typeof value === "object" && !Array.isArray(value) && "id" in value && "topic_name" in value && "hook_name" in value;
22693
+ }
22694
+ /***************************************************************
22695
+ * ¿Contiene `value` el término? El término viene YA en
22696
+ * minúsculas: quien busca lo baja una vez y no una por celda.
22697
+ ***************************************************************/
22698
+ function value_matches(value, term, depth) {
22699
+ if (value === null || value === void 0) return false;
22700
+ if (depth > MAX_DEPTH) return false;
22701
+ if (Array.isArray(value)) return value.some((v) => value_matches(v, term, depth + 1));
22702
+ if (typeof value === "object") {
22703
+ if (is_fkey_ref(value)) return value_matches(value.id, term, depth + 1);
22704
+ return Object.entries(value).some(([key, v]) => {
22705
+ if (key.startsWith("_")) return false;
22706
+ return value_matches(v, term, depth + 1);
22707
+ });
22708
+ }
22709
+ return String(value).toLowerCase().includes(term);
22710
+ }
22711
+ /***************************************************************
22712
+ * ¿Está el término en alguna parte de esta fila?
22713
+ ***************************************************************/
22714
+ function row_matches(row, term) {
22715
+ if (!row || typeof row !== "object" || !term) return false;
22716
+ return Object.entries(row).some(([key, value]) => {
22717
+ if (key.startsWith("_")) return false;
22718
+ return value_matches(value, term, 0);
22719
+ });
22720
+ }
22721
+ //#endregion
22556
22722
  //#region src/treedb_write_plan.js
22557
22723
  /***********************************************************************
22558
22724
  * treedb_write_plan.js
@@ -23626,7 +23792,8 @@ function create_tabulator(gobj) {
23626
23792
  50,
23627
23793
  100,
23628
23794
  200,
23629
- 500
23795
+ 500,
23796
+ true
23630
23797
  ],
23631
23798
  ajaxURL: "nodes",
23632
23799
  ajaxRequestFunc: function(url, config, params) {
@@ -23680,6 +23847,7 @@ function create_tabulator(gobj) {
23680
23847
  tabulator.on("tableBuilt", function() {
23681
23848
  tabulator._ready = true;
23682
23849
  update_rowcount();
23850
+ yui_table_filter_clear(tabulator);
23683
23851
  if (tabulator._pendingData !== void 0) {
23684
23852
  tabulator.setData(tabulator._pendingData);
23685
23853
  delete tabulator._pendingData;
@@ -24994,11 +25162,12 @@ function request_page(gobj, page, size) {
24994
25162
  reject,
24995
25163
  timer
24996
25164
  };
25165
+ const all = size === true;
24997
25166
  gobj_publish_event(gobj, "EV_REQUEST_PAGE", {
24998
25167
  topic_name: gobj_read_str_attr(gobj, "topic_name"),
24999
25168
  req_id,
25000
- from: (page - 1) * size + 1,
25001
- limit: size
25169
+ from: all ? 1 : (page - 1) * size + 1,
25170
+ limit: all ? 0 : size
25002
25171
  });
25003
25172
  });
25004
25173
  }
@@ -25080,11 +25249,7 @@ function ac_search(gobj, event, kw, src) {
25080
25249
  return 0;
25081
25250
  }
25082
25251
  tabulator.setFilter(function(data) {
25083
- return Object.entries(data).some(([key, val]) => {
25084
- if (key.startsWith("_")) return false;
25085
- if (val === null || val === void 0) return false;
25086
- return String(val).toLowerCase().includes(term);
25087
- });
25252
+ return row_matches(data, term);
25088
25253
  });
25089
25254
  return 0;
25090
25255
  }
@@ -67145,6 +67310,7 @@ function yui_asset_element(answer, opts = {}) {
67145
67310
  }
67146
67311
  el.src = src;
67147
67312
  el.onerror = () => {
67313
+ log_error(`asset could not be loaded: ${opts.detail || src}`);
67148
67314
  const $marker = yui_asset_missing(opts.detail, opts);
67149
67315
  if (el.parentNode) el.parentNode.replaceChild($marker, el);
67150
67316
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuneta/gobj-ui",
3
- "version": "7.23.47",
3
+ "version": "7.23.49",
4
4
  "type": "module",
5
5
  "main": "dist/gobj-ui.cjs.js",
6
6
  "module": "dist/gobj-ui.es.js",
@@ -85,6 +85,9 @@ import {
85
85
  yui_clear_selection,
86
86
  } from "./yui_table_select.js";
87
87
 
88
+ import {yui_table_filter_clear} from "./yui_table_filter_clear.js";
89
+ import {row_matches} from "./yui_row_search.js";
90
+
88
91
  import {t} from "i18next";
89
92
 
90
93
  import {plan_treedb_writes} from "./treedb_write_plan.js";
@@ -1346,7 +1349,13 @@ function create_tabulator(gobj)
1346
1349
  paginationMode: "remote",
1347
1350
  filterMode: "local",
1348
1351
  paginationSize: page_size,
1349
- paginationSizeSelector: [50, 100, 200, 500],
1352
+ /* `true` is Tabulator's "All", and here it is not a courtesy:
1353
+ * `filterMode: "local"` means the header filters and the search
1354
+ * box only see the page that is loaded, so on a topic that does
1355
+ * not fit, searching for something on page 17 finds nothing.
1356
+ * "All" is the way out, and the backend has it -- `nodes` takes
1357
+ * `limit: 0` for "every one", answering the plain list. */
1358
+ paginationSizeSelector: [50, 100, 200, 500, true],
1350
1359
  ajaxURL: "nodes", /* dummy: only fires the func */
1351
1360
  ajaxRequestFunc: function(url, config, params) {
1352
1361
  /* Widget plumbing, not an action: Tabulator wants a PROMISE
@@ -1467,6 +1476,10 @@ function create_tabulator(gobj)
1467
1476
  tabulator.on("tableBuilt", function() {
1468
1477
  tabulator._ready = true;
1469
1478
  update_rowcount();
1479
+ /* La ✕ de cada filtro de cabecera. Un filtro se quita borrando lo
1480
+ * escrito, y con varias columnas filtradas volver a la tabla entera
1481
+ * era acordarse de cuáles se tocaron. */
1482
+ yui_table_filter_clear(tabulator);
1470
1483
  if(tabulator._pendingData !== undefined) {
1471
1484
  tabulator.setData(tabulator._pendingData);
1472
1485
  delete tabulator._pendingData;
@@ -3370,14 +3383,21 @@ function request_page(gobj, page, size)
3370
3383
 
3371
3384
  priv._pending_pages[req_id] = {resolve: resolve, reject: reject, timer: timer};
3372
3385
 
3386
+ /* "All" (Tabulator sends the size as `true`): `nodes` says every
3387
+ * one with `limit: 0`, and then it answers the plain list -- which
3388
+ * nodes_answer() reads as a single page, so the paginator hides and
3389
+ * every filter sees every row. Arithmetic on `true` would send
3390
+ * `limit: true` to a backend that wants an integer. */
3391
+ const all = (size === true);
3392
+
3373
3393
  gobj_publish_event(
3374
3394
  gobj,
3375
3395
  "EV_REQUEST_PAGE",
3376
3396
  {
3377
3397
  topic_name: gobj_read_str_attr(gobj, "topic_name"),
3378
3398
  req_id: req_id,
3379
- from: (page - 1) * size + 1, /* `nodes` counts from 1 */
3380
- limit: size
3399
+ from: all? 1: (page - 1) * size + 1, /* `nodes` counts from 1 */
3400
+ limit: all? 0: size
3381
3401
  }
3382
3402
  );
3383
3403
  });
@@ -3482,16 +3502,13 @@ function ac_search(gobj, event, kw, src)
3482
3502
  return 0;
3483
3503
  }
3484
3504
 
3505
+ /* A treedb row is not flat: an fkey arrives as a list of objects, and
3506
+ * `String()` of that is "[object Object]" -- so searching for the
3507
+ * workshop of a meter, which is where the value a person has in mind
3508
+ * lives, never found anything. `row_matches()` walks into them, and
3509
+ * reads only the `id` of an fkey. See yui_row_search.js. */
3485
3510
  tabulator.setFilter(function(data) {
3486
- return Object.entries(data).some(([key, val]) => {
3487
- if(key.startsWith('_')) {
3488
- return false;
3489
- }
3490
- if(val === null || val === undefined) {
3491
- return false;
3492
- }
3493
- return String(val).toLowerCase().includes(term);
3494
- });
3511
+ return row_matches(data, term);
3495
3512
  });
3496
3513
 
3497
3514
  return 0;
package/src/yui_asset.js CHANGED
@@ -16,7 +16,7 @@
16
16
  */
17
17
 
18
18
  import {
19
- createElement2
19
+ createElement2, log_error
20
20
  } from "@yuneta/gobj-js";
21
21
 
22
22
  import "./yui_asset.css";
@@ -177,6 +177,16 @@ function yui_asset_element(answer, opts = {})
177
177
  * silent gap this module was written to stop.
178
178
  */
179
179
  el.onerror = () => {
180
+ /*
181
+ * And it is REPORTED, not only drawn. The marker tells the person
182
+ * looking at the screen; log_error is what reaches the yuno, which
183
+ * is the only place anybody can count how often it happens. An
184
+ * asset the backend served and the browser could not load is a
185
+ * fact of the system -- an expired signature, a blob gone from the
186
+ * store, a codec nobody has -- and a failure only a user can see
187
+ * is a failure nobody measures.
188
+ */
189
+ log_error(`asset could not be loaded: ${opts.detail || src}`);
180
190
  const $marker = yui_asset_missing(opts.detail, opts);
181
191
  if(el.parentNode) {
182
192
  el.parentNode.replaceChild($marker, el);
@@ -0,0 +1,96 @@
1
+ /***********************************************************************
2
+ * yui_row_search.js
3
+ *
4
+ * ¿ESTÁ ESTE TÉRMINO EN ESTA FILA?
5
+ *
6
+ * La caja de búsqueda de una tabla de treedb miraba los valores de
7
+ * la fila con `String(val)`, y una fila de treedb no es plana: un
8
+ * fkey llega como una LISTA DE OBJETOS
9
+ * `[{id, topic_name, hook_name}]`, y `String()` de eso es
10
+ * `"[object Object]"`. O sea que buscar el taller de un equipo --
11
+ * que es donde vive el dato que un operador tiene en la cabeza --
12
+ * no encontraba nunca nada, y la caja no daba ninguna pista de que
13
+ * había mirado en otro sitio.
14
+ *
15
+ * DOS REGLAS, Y LA SEGUNDA ES LA QUE HACE QUE SIRVA:
16
+ *
17
+ * - Se baja por listas y objetos hasta una profundidad corta: un
18
+ * fkey está a dos niveles y nada de lo que se busca está más
19
+ * hondo.
20
+ * - **De un fkey se mira sólo el `id`.** `topic_name` y
21
+ * `hook_name` son las MISMAS dos palabras en todas las filas, así
22
+ * que mirarlas convierte el término en un comodín: buscar
23
+ * "devices" traería el topic entero. El `id` es lo único que
24
+ * nombra la cosa enlazada.
25
+ *
26
+ * Las claves que empiezan por `_` no se miran en ningún nivel: son
27
+ * del armazón (`_check_box_state_`, `_operation`) o metadatos
28
+ * (`__md_treedb__`), y nadie busca por ellas.
29
+ *
30
+ * Copyright (c) 2026, ArtGins.
31
+ * All Rights Reserved.
32
+ ***********************************************************************/
33
+
34
+ const MAX_DEPTH = 4;
35
+
36
+
37
+ /***************************************************************
38
+ * Un fkey, tal y como `nodes` lo contesta con `list_dict`.
39
+ ***************************************************************/
40
+ function is_fkey_ref(value)
41
+ {
42
+ return !!value
43
+ && typeof value === "object"
44
+ && !Array.isArray(value)
45
+ && ("id" in value)
46
+ && ("topic_name" in value)
47
+ && ("hook_name" in value);
48
+ }
49
+
50
+ /***************************************************************
51
+ * ¿Contiene `value` el término? El término viene YA en
52
+ * minúsculas: quien busca lo baja una vez y no una por celda.
53
+ ***************************************************************/
54
+ function value_matches(value, term, depth)
55
+ {
56
+ if(value === null || value === undefined) {
57
+ return false;
58
+ }
59
+ if(depth > MAX_DEPTH) {
60
+ return false;
61
+ }
62
+ if(Array.isArray(value)) {
63
+ return value.some((v) => value_matches(v, term, depth + 1));
64
+ }
65
+ if(typeof value === "object") {
66
+ if(is_fkey_ref(value)) {
67
+ return value_matches(value.id, term, depth + 1);
68
+ }
69
+ return Object.entries(value).some(([key, v]) => {
70
+ if(key.startsWith("_")) {
71
+ return false;
72
+ }
73
+ return value_matches(v, term, depth + 1);
74
+ });
75
+ }
76
+ return String(value).toLowerCase().includes(term);
77
+ }
78
+
79
+ /***************************************************************
80
+ * ¿Está el término en alguna parte de esta fila?
81
+ ***************************************************************/
82
+ function row_matches(row, term)
83
+ {
84
+ if(!row || typeof row !== "object" || !term) {
85
+ return false;
86
+ }
87
+ return Object.entries(row).some(([key, value]) => {
88
+ if(key.startsWith("_")) {
89
+ return false;
90
+ }
91
+ return value_matches(value, term, 0);
92
+ });
93
+ }
94
+
95
+
96
+ export {row_matches, value_matches, is_fkey_ref};
@@ -0,0 +1,62 @@
1
+ import {describe, it, expect} from "vitest";
2
+
3
+ import {row_matches, is_fkey_ref} from "./yui_row_search.js";
4
+
5
+ describe("row_matches", () => {
6
+ const row = {
7
+ id: "E22003089",
8
+ name: "",
9
+ place: [
10
+ {
11
+ id: "es.madrid.bm-madrid-fuencarral-m-av.local-13-torno",
12
+ topic_name: "places",
13
+ hook_name: "devices"
14
+ }
15
+ ],
16
+ tension: 400,
17
+ enabled: true,
18
+ _operation: "hidden",
19
+ __md_treedb__: {topic_name: "devices"}
20
+ };
21
+
22
+ it("finds a plain field", () => {
23
+ expect(row_matches(row, "e22003089")).toBe(true);
24
+ });
25
+
26
+ it("finds a number", () => {
27
+ expect(row_matches(row, "400")).toBe(true);
28
+ });
29
+
30
+ it("finds a value INSIDE an fkey, which is the whole point", () => {
31
+ expect(row_matches(row, "local-13-torno")).toBe(true);
32
+ });
33
+
34
+ it("does NOT match the structural halves of an fkey", () => {
35
+ /* `topic_name`/`hook_name` are the same two words on every row:
36
+ * matching them turns the term into a wildcard. */
37
+ expect(row_matches(row, "places")).toBe(false);
38
+ expect(row_matches(row, "hook")).toBe(false);
39
+ });
40
+
41
+ it("ignores keys of the scaffolding", () => {
42
+ expect(row_matches(row, "hidden")).toBe(false);
43
+ });
44
+
45
+ it("says no when nothing matches", () => {
46
+ expect(row_matches(row, "zzz")).toBe(false);
47
+ });
48
+
49
+ it("says no for an empty term or a missing row", () => {
50
+ expect(row_matches(row, "")).toBe(false);
51
+ expect(row_matches(null, "a")).toBe(false);
52
+ });
53
+ });
54
+
55
+ describe("is_fkey_ref", () => {
56
+ it("wants the three keys", () => {
57
+ expect(is_fkey_ref({id: "a", topic_name: "t", hook_name: "h"})).toBe(true);
58
+ expect(is_fkey_ref({id: "a"})).toBe(false);
59
+ expect(is_fkey_ref(["a"])).toBe(false);
60
+ expect(is_fkey_ref(null)).toBe(false);
61
+ });
62
+ });
@@ -0,0 +1,37 @@
1
+ /***********************************************************************
2
+ * yui_table_filter_clear.css
3
+ *
4
+ * La ✕ de un filtro de cabecera (yui_table_filter_clear.js).
5
+ *
6
+ * Al reves que la ✕ de un formulario (yui_inputs.css), esta se ve
7
+ * SIN foco: el filtro ya esta puesto, el foco esta en otra parte,
8
+ * y lo que se quiere es verlo y quitarlo.
9
+ *
10
+ * Copyright (c) 2026, ArtGins.
11
+ * All Rights Reserved.
12
+ ***********************************************************************/
13
+ .tabulator-header-filter.yui-filter-has-clear {
14
+ position: relative;
15
+ }
16
+
17
+ /* Sitio para el aspa dentro del propio input, para que no se coma
18
+ * ancho de columna. */
19
+ .tabulator-header-filter.yui-filter-has-clear input {
20
+ padding-right: 1.6em;
21
+ }
22
+
23
+ /* Dos clases sobre el elemento para ganarle a las reglas propias de
24
+ * Bulma para `.delete` (position:relative; display:inline-block) sin
25
+ * depender de que hoja carga la ultima. */
26
+ .tabulator-header-filter .yui-filter-clear {
27
+ position: absolute;
28
+ top: 50%;
29
+ right: 0.25em;
30
+ transform: translateY(-50%);
31
+ display: none;
32
+ z-index: 4;
33
+ }
34
+
35
+ .tabulator-header-filter .yui-filter-clear.is-visible {
36
+ display: inline-block;
37
+ }
@@ -0,0 +1,151 @@
1
+ /***********************************************************************
2
+ * yui_table_filter_clear.js
3
+ *
4
+ * LA ✕ DE LOS FILTROS DE CABECERA DE UNA TABULATOR.
5
+ *
6
+ * Un filtro de columna se pone escribiendo y se quita BORRANDO lo
7
+ * escrito, letra a letra, y no hay nada en la cabecera que diga
8
+ * que se puede quitar. Con varias columnas filtradas, volver a
9
+ * ver la tabla entera es un ejercicio de memoria: cuáles toqué.
10
+ * La ✕ lo dice y lo hace.
11
+ *
12
+ * TRES DECISIONES QUE NO SON OBVIAS:
13
+ *
14
+ * - **Se ve mientras el filtro tenga contenido, con foco o sin
15
+ * él.** Es al revés que la ✕ de un formulario
16
+ * (`yui_inputs.js`), que sólo aparece en el campo que se está
17
+ * editando para no encender un aspa en cada campo relleno. Aquí
18
+ * el caso de uso es justo el contrario: el filtro ya está puesto,
19
+ * el foco está en otra parte, y lo que se quiere es verlo y
20
+ * quitarlo. Escondida tras el foco harían falta dos clics.
21
+ *
22
+ * - **Se borra con la API, no simulando teclas.**
23
+ * `setHeaderFilterValue(field, "")` es lo que Tabulator ofrece, y
24
+ * deja el valor y el filtro consistentes. Sintetizar un `input`
25
+ * sobre el `<input>` depende de a qué escuche el editor de ese
26
+ * filtro, que cambia con el tipo de columna.
27
+ *
28
+ * - **Se recorre la cabecera, no se envuelve el editor.** Las
29
+ * columnas se construyen del esquema del topic, así que no hay un
30
+ * sitio único donde envolver el editor; y la cabecera se rehace
31
+ * sola cuando cambian las columnas. El recorrido es idempotente
32
+ * -- marca lo que ya tiene aspa -- y se dispara con un
33
+ * `querySelector` de una sola pasada, así que repetirlo en cada
34
+ * render no cuesta nada.
35
+ *
36
+ * Copyright (c) 2026, ArtGins.
37
+ * All Rights Reserved.
38
+ ***********************************************************************/
39
+ import i18next from "i18next";
40
+
41
+ import {createElement2} from "@yuneta/gobj-js";
42
+
43
+ import "./yui_table_filter_clear.css";
44
+
45
+
46
+ const MARK = "data-yui-filter-clear";
47
+
48
+
49
+ /***************************************************************
50
+ * Pon (o repón) la ✕ de cada filtro de cabecera de `table`.
51
+ *
52
+ * Idempotente: los que ya la tienen se saltan.
53
+ ***************************************************************/
54
+ function decorate(table)
55
+ {
56
+ if(!table || !table.element) {
57
+ return;
58
+ }
59
+ let $pending = table.element.querySelector(`.tabulator-header-filter:not([${MARK}])`);
60
+ if(!$pending) {
61
+ return; /* nada nuevo en la cabecera */
62
+ }
63
+
64
+ let list = table.element.querySelectorAll(`.tabulator-header-filter:not([${MARK}])`);
65
+ for(const $filter of list) {
66
+ $filter.setAttribute(MARK, "1");
67
+
68
+ let $input = $filter.querySelector("input");
69
+ if(!$input) {
70
+ continue; /* un filtro que no se escribe: un select, un rango */
71
+ }
72
+
73
+ /* El campo se lee del `.tabulator-col` que lo contiene, que es
74
+ * quien lo lleva; el `<input>` no sabe de qué columna es. */
75
+ let $col = $filter.closest(".tabulator-col");
76
+ let field = $col? $col.getAttribute("tabulator-field"): "";
77
+ if(!field) {
78
+ continue; /* sin campo no hay a quién decirle que se limpie */
79
+ }
80
+
81
+ $filter.classList.add("yui-filter-has-clear");
82
+
83
+ /* La clave viaja con el botón: un `title` puesto con t() al
84
+ * construir es invisible para refresh_language(), y se queda en
85
+ * el idioma de aquel momento para siempre. */
86
+ let $btn = createElement2(["button", {
87
+ type: "button",
88
+ class: "delete is-small yui-filter-clear",
89
+ tabindex: "-1",
90
+ title: i18next.t("clear"),
91
+ "aria-label": i18next.t("clear"),
92
+ "data-i18n-title": "clear",
93
+ "data-i18n-aria-label": "clear"
94
+ }]);
95
+
96
+ const sync = () => {
97
+ $btn.classList.toggle("is-visible", !!$input.value);
98
+ };
99
+
100
+ $input.addEventListener("input", sync);
101
+ $input.addEventListener("change", sync);
102
+ $btn.addEventListener("click", (event) => {
103
+ event.stopPropagation(); /* no ordenar la columna al pulsar */
104
+ table.setHeaderFilterValue(field, "");
105
+ sync();
106
+ });
107
+
108
+ $filter.appendChild($btn);
109
+ sync();
110
+ }
111
+ }
112
+
113
+ /***************************************************************
114
+ * Engancha la ✕ a una tabla ya creada.
115
+ *
116
+ * Se llama una vez, con la tabla construida. Los enganches
117
+ * cubren los dos momentos en que la cabecera aparece o se
118
+ * rehace: cuando se construye y cuando cambian las columnas.
119
+ ***************************************************************/
120
+ export function yui_table_filter_clear(table)
121
+ {
122
+ if(!table) {
123
+ return;
124
+ }
125
+ decorate(table);
126
+ table.on("renderComplete", () => {
127
+ decorate(table);
128
+ });
129
+ table.on("columnVisibilityChanged", () => {
130
+ decorate(table);
131
+ });
132
+ }
133
+
134
+ /***************************************************************
135
+ * Repasa el estado de las ✕ tras un cambio que no vino del
136
+ * teclado -- limpiar todos los filtros, cargar una vista
137
+ * guardada -- porque eso no dispara `input`.
138
+ ***************************************************************/
139
+ export function yui_table_filter_clear_refresh(table)
140
+ {
141
+ if(!table || !table.element) {
142
+ return;
143
+ }
144
+ for(const $filter of table.element.querySelectorAll(".yui-filter-has-clear")) {
145
+ let $input = $filter.querySelector("input");
146
+ let $btn = $filter.querySelector(".yui-filter-clear");
147
+ if($input && $btn) {
148
+ $btn.classList.toggle("is-visible", !!$input.value);
149
+ }
150
+ }
151
+ }