@yuneta/gobj-ui 7.23.48 → 7.23.50

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
@@ -1780,6 +1780,51 @@ this facility: it is `formatter: "rowSelection"` with no `titleFormatter` and
1780
1780
  `selectableRows: 1`, and the header checkbox, the count and the bar all mean
1781
1781
  nothing there.
1782
1782
 
1783
+ ### Clearing a header filter — `yui_table_filter_clear.js`
1784
+
1785
+ A column filter is set by typing and undone by deleting what you typed, letter
1786
+ by letter, and nothing in the header says it can be dropped at all. With
1787
+ several columns filtered, getting back to the whole table is an exercise in
1788
+ remembering which ones you touched. One call, on a table that is already built:
1789
+
1790
+ ```js
1791
+ import {yui_table_filter_clear} from "@yuneta/gobj-ui/src/yui_table_filter_clear.js";
1792
+
1793
+ table.on("tableBuilt", () => {
1794
+ yui_table_filter_clear(table);
1795
+ });
1796
+ ```
1797
+
1798
+ The app must define the i18n key **`"clear"`**. Three decisions are baked in:
1799
+
1800
+ - **The ✕ is visible whenever the filter has content, focus or not** — the
1801
+ opposite of the ✕ in a form (`yui_inputs.js`), which shows only on the field
1802
+ being edited so a form full of filled fields does not light up an ✕ on every
1803
+ one. Here the filter is already set, the focus is elsewhere, and the whole
1804
+ point is to see it and drop it; hidden behind focus it would take two clicks.
1805
+ - **It clears through `setHeaderFilterValue()`**, not by synthesising a
1806
+ keystroke on the input — what a header filter listens to depends on that
1807
+ column's filter editor.
1808
+ - **It decorates the header by walking it**, because a table's columns are
1809
+ often built from data (a treedb schema) and the header rebuilds itself when
1810
+ they change. The walk is idempotent and gated by a single `querySelector`, so
1811
+ repeating it on every render costs nothing.
1812
+
1813
+ ### Searching a row that is not flat — `yui_row_search.js`
1814
+
1815
+ `row_matches(row, term)` is what the treedb topic table's search box uses, and
1816
+ it exists because **a treedb row is not flat**: with `list_dict` an fkey
1817
+ arrives as a list of objects — `[{id, topic_name, hook_name}]` — and
1818
+ `String(val)` of that is `"[object Object]"`. A search written the obvious way
1819
+ therefore never finds the place a node is linked to, while the cell plainly
1820
+ renders that id: what you see and what is searched are not the same thing.
1821
+
1822
+ It walks into lists and objects, and reads only the **`id`** of an fkey:
1823
+ `topic_name` and `hook_name` are the same two words on every row, so matching
1824
+ them turns any such term into a wildcard over the whole topic. Keys starting
1825
+ with `_` are never searched, at any level — scaffolding (`_operation`) and
1826
+ metadata (`__md_treedb__`).
1827
+
1783
1828
  ## Conventions
1784
1829
 
1785
1830
  ### i18n: a string must be able to CHANGE language, not just be translated once
@@ -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) {
@@ -23674,17 +23841,32 @@ function create_tabulator(gobj) {
23674
23841
  single_page = max === false || max <= 1;
23675
23842
  } else {
23676
23843
  let size = tabulator.getPageSize();
23677
- single_page = !size || n <= size;
23844
+ single_page = size === true || !size || n <= size;
23678
23845
  }
23679
23846
  } catch (e) {
23680
23847
  single_page = true;
23681
23848
  }
23849
+ let total_rows;
23850
+ if (paged) total_rows = total;
23851
+ else try {
23852
+ total_rows = tabulator.getDataCount();
23853
+ } catch (e) {
23854
+ total_rows = n;
23855
+ }
23856
+ let min_size = 0;
23857
+ let sizes = tabulator.options ? tabulator.options.paginationSizeSelector : null;
23858
+ if (Array.isArray(sizes)) sizes.forEach(function(size) {
23859
+ if (typeof size === "number" && (!min_size || size < min_size)) min_size = size;
23860
+ });
23861
+ let no_paginator = single_page && (!min_size || typeof total_rows !== "number" || total_rows <= min_size);
23682
23862
  tabulator.element.classList.toggle("yui-single-page", single_page);
23863
+ tabulator.element.classList.toggle("yui-no-paginator", no_paginator);
23683
23864
  }
23684
23865
  tabulator._ready = false;
23685
23866
  tabulator.on("tableBuilt", function() {
23686
23867
  tabulator._ready = true;
23687
23868
  update_rowcount();
23869
+ yui_table_filter_clear(tabulator);
23688
23870
  if (tabulator._pendingData !== void 0) {
23689
23871
  tabulator.setData(tabulator._pendingData);
23690
23872
  delete tabulator._pendingData;
@@ -24999,11 +25181,12 @@ function request_page(gobj, page, size) {
24999
25181
  reject,
25000
25182
  timer
25001
25183
  };
25184
+ const all = size === true;
25002
25185
  (0, _yuneta_gobj_js.gobj_publish_event)(gobj, "EV_REQUEST_PAGE", {
25003
25186
  topic_name: (0, _yuneta_gobj_js.gobj_read_str_attr)(gobj, "topic_name"),
25004
25187
  req_id,
25005
- from: (page - 1) * size + 1,
25006
- limit: size
25188
+ from: all ? 1 : (page - 1) * size + 1,
25189
+ limit: all ? 0 : size
25007
25190
  });
25008
25191
  });
25009
25192
  }
@@ -25085,11 +25268,7 @@ function ac_search(gobj, event, kw, src) {
25085
25268
  return 0;
25086
25269
  }
25087
25270
  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
- });
25271
+ return row_matches(data, term);
25093
25272
  });
25094
25273
  return 0;
25095
25274
  }
@@ -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) {
@@ -23669,17 +23836,32 @@ function create_tabulator(gobj) {
23669
23836
  single_page = max === false || max <= 1;
23670
23837
  } else {
23671
23838
  let size = tabulator.getPageSize();
23672
- single_page = !size || n <= size;
23839
+ single_page = size === true || !size || n <= size;
23673
23840
  }
23674
23841
  } catch (e) {
23675
23842
  single_page = true;
23676
23843
  }
23844
+ let total_rows;
23845
+ if (paged) total_rows = total;
23846
+ else try {
23847
+ total_rows = tabulator.getDataCount();
23848
+ } catch (e) {
23849
+ total_rows = n;
23850
+ }
23851
+ let min_size = 0;
23852
+ let sizes = tabulator.options ? tabulator.options.paginationSizeSelector : null;
23853
+ if (Array.isArray(sizes)) sizes.forEach(function(size) {
23854
+ if (typeof size === "number" && (!min_size || size < min_size)) min_size = size;
23855
+ });
23856
+ let no_paginator = single_page && (!min_size || typeof total_rows !== "number" || total_rows <= min_size);
23677
23857
  tabulator.element.classList.toggle("yui-single-page", single_page);
23858
+ tabulator.element.classList.toggle("yui-no-paginator", no_paginator);
23678
23859
  }
23679
23860
  tabulator._ready = false;
23680
23861
  tabulator.on("tableBuilt", function() {
23681
23862
  tabulator._ready = true;
23682
23863
  update_rowcount();
23864
+ yui_table_filter_clear(tabulator);
23683
23865
  if (tabulator._pendingData !== void 0) {
23684
23866
  tabulator.setData(tabulator._pendingData);
23685
23867
  delete tabulator._pendingData;
@@ -24994,11 +25176,12 @@ function request_page(gobj, page, size) {
24994
25176
  reject,
24995
25177
  timer
24996
25178
  };
25179
+ const all = size === true;
24997
25180
  gobj_publish_event(gobj, "EV_REQUEST_PAGE", {
24998
25181
  topic_name: gobj_read_str_attr(gobj, "topic_name"),
24999
25182
  req_id,
25000
- from: (page - 1) * size + 1,
25001
- limit: size
25183
+ from: all ? 1 : (page - 1) * size + 1,
25184
+ limit: all ? 0 : size
25002
25185
  });
25003
25186
  });
25004
25187
  }
@@ -25080,11 +25263,7 @@ function ac_search(gobj, event, kw, src) {
25080
25263
  return 0;
25081
25264
  }
25082
25265
  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
- });
25266
+ return row_matches(data, term);
25088
25267
  });
25089
25268
  return 0;
25090
25269
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuneta/gobj-ui",
3
- "version": "7.23.48",
3
+ "version": "7.23.50",
4
4
  "type": "module",
5
5
  "main": "dist/gobj-ui.cjs.js",
6
6
  "module": "dist/gobj-ui.es.js",
@@ -23,10 +23,20 @@
23
23
  line-height: 1.5;
24
24
  }
25
25
 
26
- /* All rows fit in one page: the page-size selector and the
27
- * First/Prev/1/Next/Last strip are pure noise (worst on mobile) —
28
- * update_rowcount() toggles the class on every data change. */
29
- .tabulator.yui-single-page .tabulator-footer .tabulator-paginator {
26
+ /* All rows fit in one page: the First/Prev/1/Next/Last strip is pure
27
+ * noise (worst on mobile) — update_rowcount() toggles the class on every
28
+ * data change. The page-size SELECTOR stays: it is what made the table
29
+ * one page in the first place ("All" on a topic of 300 rows), so hiding
30
+ * it together with the buttons left the reader no way back to 50. */
31
+ .tabulator.yui-single-page .tabulator-footer .tabulator-paginator .tabulator-page,
32
+ .tabulator.yui-single-page .tabulator-footer .tabulator-paginator .tabulator-pages {
33
+ display: none;
34
+ }
35
+
36
+ /* No choice in the selector would change anything either — the topic is
37
+ * smaller than the smallest page size it offers — so the whole strip,
38
+ * label and select included, is noise. */
39
+ .tabulator.yui-no-paginator .tabulator-footer .tabulator-paginator {
30
40
  display: none;
31
41
  }
32
42
 
@@ -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
@@ -1453,20 +1462,60 @@ function create_tabulator(gobj)
1453
1462
  single_page = (max === false) || max <= 1;
1454
1463
  } else {
1455
1464
  /* Derived from the count and the page size rather than read
1456
- * from getPageMax(), which is stale in the filter path. */
1465
+ * from getPageMax(), which is stale in the filter path.
1466
+ * `true` is Tabulator's "All" and it is the SIZE itself, so
1467
+ * it has to be read as a word: compared as a number it is 1,
1468
+ * and a table showing every one of its 300 rows in a single
1469
+ * page answered that it needed 300 pages. */
1457
1470
  let size = tabulator.getPageSize();
1458
- single_page = !size || n <= size;
1471
+ single_page = (size === true) || !size || n <= size;
1459
1472
  }
1460
1473
  } catch(e) {
1461
1474
  single_page = true;
1462
1475
  }
1476
+ /* The page-size selector lives INSIDE the paginator, so hiding the
1477
+ * whole strip when everything fits took away the very control that
1478
+ * got the reader there: pick "All" on a topic of 300 rows and the
1479
+ * table becomes one page, the paginator goes, and 50 is
1480
+ * unreachable. The selector is noise only when no choice in it
1481
+ * would change anything -- when the topic is smaller than the
1482
+ * smallest size it offers. Measure that on the WHOLE topic, never
1483
+ * on the filtered count, or typing a filter that leaves 3 rows
1484
+ * takes the selector away too. */
1485
+ let total_rows;
1486
+ if(paged) {
1487
+ total_rows = total;
1488
+ } else {
1489
+ try {
1490
+ total_rows = tabulator.getDataCount();
1491
+ } catch(e) {
1492
+ total_rows = n;
1493
+ }
1494
+ }
1495
+ let min_size = 0;
1496
+ let sizes = tabulator.options? tabulator.options.paginationSizeSelector : null;
1497
+ if(Array.isArray(sizes)) {
1498
+ sizes.forEach(function(size) {
1499
+ if(typeof size === "number" && (!min_size || size < min_size)) {
1500
+ min_size = size;
1501
+ }
1502
+ });
1503
+ }
1504
+ let no_paginator = single_page &&
1505
+ (!min_size || typeof total_rows !== "number" || total_rows <= min_size);
1506
+
1463
1507
  tabulator.element.classList.toggle("yui-single-page", single_page);
1508
+ tabulator.element.classList.toggle("yui-no-paginator", no_paginator);
1464
1509
  }
1465
1510
 
1466
1511
  tabulator._ready = false;
1467
1512
  tabulator.on("tableBuilt", function() {
1468
1513
  tabulator._ready = true;
1469
1514
  update_rowcount();
1515
+ /* La ✕ de cada filtro de cabecera. Un filtro se quita borrando lo
1516
+ * escrito, y con varias columnas filtradas volver a la tabla entera
1517
+ * era acordarse de cuáles se tocaron. */
1518
+ yui_table_filter_clear(tabulator);
1470
1519
  if(tabulator._pendingData !== undefined) {
1471
1520
  tabulator.setData(tabulator._pendingData);
1472
1521
  delete tabulator._pendingData;
@@ -3370,14 +3419,21 @@ function request_page(gobj, page, size)
3370
3419
 
3371
3420
  priv._pending_pages[req_id] = {resolve: resolve, reject: reject, timer: timer};
3372
3421
 
3422
+ /* "All" (Tabulator sends the size as `true`): `nodes` says every
3423
+ * one with `limit: 0`, and then it answers the plain list -- which
3424
+ * nodes_answer() reads as a single page, so the paginator hides and
3425
+ * every filter sees every row. Arithmetic on `true` would send
3426
+ * `limit: true` to a backend that wants an integer. */
3427
+ const all = (size === true);
3428
+
3373
3429
  gobj_publish_event(
3374
3430
  gobj,
3375
3431
  "EV_REQUEST_PAGE",
3376
3432
  {
3377
3433
  topic_name: gobj_read_str_attr(gobj, "topic_name"),
3378
3434
  req_id: req_id,
3379
- from: (page - 1) * size + 1, /* `nodes` counts from 1 */
3380
- limit: size
3435
+ from: all? 1: (page - 1) * size + 1, /* `nodes` counts from 1 */
3436
+ limit: all? 0: size
3381
3437
  }
3382
3438
  );
3383
3439
  });
@@ -3482,16 +3538,13 @@ function ac_search(gobj, event, kw, src)
3482
3538
  return 0;
3483
3539
  }
3484
3540
 
3541
+ /* A treedb row is not flat: an fkey arrives as a list of objects, and
3542
+ * `String()` of that is "[object Object]" -- so searching for the
3543
+ * workshop of a meter, which is where the value a person has in mind
3544
+ * lives, never found anything. `row_matches()` walks into them, and
3545
+ * reads only the `id` of an fkey. See yui_row_search.js. */
3485
3546
  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
- });
3547
+ return row_matches(data, term);
3495
3548
  });
3496
3549
 
3497
3550
  return 0;
@@ -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
+ }