@yuneta/gobj-ui 7.26.40 → 7.26.41

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
@@ -3422,6 +3422,15 @@ true. A record that lacks a defaulted field opens changed and closes without
3422
3422
  a question; until 7.26.39 it asked on every close, about nothing the reader
3423
3423
  would lose.
3424
3424
 
3425
+ What the reader does to a TABLE of the form -- edits a cell, adds a row,
3426
+ deletes one, in a nested table too -- is `edited` like anything typed, whatever
3427
+ the record the form opened with. Each table hears it from the moment it is
3428
+ built (a nested one is built by its row, whenever the row is drawn); the rows
3429
+ the form loads itself say nothing. In 7.26.39 and 7.26.40 a form that opened
3430
+ changed did not hear its tables at all, so closing it lost a table edit without
3431
+ a question. And the titles of those tables change language with the shell's
3432
+ `EV_LANGUAGE_CHANGED` (7.26.41+), like the rest of the form.
3433
+
3425
3434
  ```js
3426
3435
  const kw = {abort_close: false};
3427
3436
  gobj_send_event(form, "EV_WINDOW_TO_CLOSE", kw, gobj);
@@ -3917,14 +3926,24 @@ loading/error notices) and it never went through i18n. Use:
3917
3926
  ```js
3918
3927
  new Tabulator($el, {...settings, ...yui_tabulator_lang(t)}); // at build
3919
3928
  yui_tabulator_relocalize(table, t); // on the event
3929
+ yui_tabulator_relocalize(table, t, {device_id: t("device id")}); // and the titles
3920
3930
  ```
3921
3931
 
3922
3932
  Every key falls back to the English string Tabulator used to render
3923
3933
  (`defaultValue`), so an app that defines none of them sees no change. Two traps
3924
3934
  the implementation already handles: `setLocale()` with the locale name already
3925
3935
  in force is a **no-op** (hence a fresh name per switch), and re-applying a locale
3926
- makes Tabulator re-run a title formatter on the EXISTING header cell, which
3927
- **appends** to it — rebuild the columns from their definitions.
3936
+ makes Tabulator run every title formatter again on the EXISTING header cell, and
3937
+ **append** what one returns when it is a Node. `text_title_formatter` empties
3938
+ the cell first (7.26.41; until then each change of language added one more copy
3939
+ of every title, *"Id dispositivoId dispositivo"*); a title formatter of your own
3940
+ that returns a Node must do the same (`cell.getElement().textContent = ""`).
3941
+
3942
+ The column TITLES change language through the locale too: hand them, already
3943
+ translated, by field (the third argument). Tabulator binds each title to its
3944
+ locale, so they are drawn again and no column is rebuilt -- `updateDefinition()`
3945
+ re-renders every row, and a row formatter that builds something in its row (a
3946
+ nested table) builds it again, empty.
3928
3947
 
3929
3948
  **A missing key is invisible:** i18next answers an unknown key **with the key
3930
3949
  itself**, so it renders (lower-case English) and simply never changes language.
@@ -505,8 +505,16 @@ function text_node(value) {
505
505
  * Tabulator puts a STRING title in with innerHTML, so a title taken
506
506
  * from a schema (`header`, which the schema editor writes) is markup
507
507
  * unless the column carries a formatter that returns a Node.
508
+ *
509
+ * And it APPENDS that Node to the title's holder, which it never
510
+ * empties -- while it runs the formatter again on every setLocale()
511
+ * (yui_tabulator_relocalize()). Until 7.26.40 each change of language
512
+ * added one more copy of every title ("Id dispositivoId dispositivo").
513
+ * The holder (the mock cell's element) is emptied first.
508
514
  ************************************************************/
509
515
  function text_title_formatter(cell) {
516
+ const $holder = typeof cell.getElement === "function" ? cell.getElement() : null;
517
+ if ($holder) $holder.textContent = "";
510
518
  return text_node(cell.getValue());
511
519
  }
512
520
  /************************************************************
@@ -7106,6 +7114,12 @@ function register_c_yui_wizard() {
7106
7114
  * new Tabulator($el, {..., ...yui_tabulator_lang(t)});
7107
7115
  * yui_tabulator_relocalize(table, t); // on a language change
7108
7116
  *
7117
+ * A view whose COLUMN TITLES change language hands them too, in the
7118
+ * new language, by field -- Tabulator binds each column's title to its
7119
+ * locale, so they change with it and no column is rebuilt:
7120
+ *
7121
+ * yui_tabulator_relocalize(table, t, {device_id: t("device id")});
7122
+ *
7109
7123
  * Every key carries an English `defaultValue`, so an app that does not
7110
7124
  * define it renders exactly what Tabulator rendered before — the
7111
7125
  * keys are an OPPORTUNITY to translate, never a requirement.
@@ -7264,11 +7278,23 @@ function yui_tabulator_name_editor(column_def, name_of) {
7264
7278
  return Object.assign({}, params, { elementAttributes: attrs });
7265
7279
  };
7266
7280
  }
7267
- function yui_tabulator_relocalize(table, t) {
7281
+ /***************************************************************
7282
+ * yui_tabulator_relocalize() -- see "Put an EXISTING table in the
7283
+ * current language" above.
7284
+ *
7285
+ * `titles`, optional: {field: title}, the titles of the columns in
7286
+ * the new language, already translated. They go in the locale
7287
+ * (`columns`), where Tabulator binds each column's title: setLocale()
7288
+ * draws them again without rebuilding a column -- updateDefinition()
7289
+ * re-renders every row, and a row whose formatter builds something (a
7290
+ * nested table) builds it anew. A field left out keeps its title.
7291
+ ***************************************************************/
7292
+ function yui_tabulator_relocalize(table, t, titles) {
7268
7293
  if (!table) return;
7269
7294
  try {
7270
7295
  let name = next_lang_name();
7271
7296
  let strings = tabulator_strings(t);
7297
+ if (titles && typeof titles === "object") strings.columns = Object.assign({}, titles);
7272
7298
  table.options.langs = table.options.langs || {};
7273
7299
  table.options.langs[name] = strings;
7274
7300
  let localize = table.modules && table.modules.localize;
@@ -9487,7 +9513,7 @@ var attrs_table$17 = [
9487
9513
  (0, _yuneta_gobj_js.SDATA_END)()
9488
9514
  ];
9489
9515
  var PRIVATE_DATA$17 = {
9490
- rearm_timer: null,
9516
+ loading_rows: 0,
9491
9517
  tables: null,
9492
9518
  table_seq: 0,
9493
9519
  json_names_posted: false,
@@ -9543,18 +9569,12 @@ function mt_stop$17(gobj) {
9543
9569
  * Local Methods
9544
9570
  ***************************/
9545
9571
  /************************************************************
9546
- *
9547
- ************************************************************/
9548
- function isEventSet(table, event) {
9549
- let eventListeners = table.externalEvents.events;
9550
- return eventListeners && eventListeners[event] && eventListeners[event].length > 0;
9551
- }
9552
- /************************************************************
9553
- *
9572
+ * `changes` and the save/undo buttons. What the tables say is
9573
+ * not switched here: they always say it (create_tabulator).
9554
9574
  ************************************************************/
9555
9575
  function set_changed_stated(gobj, changed) {
9556
9576
  let $container = (0, _yuneta_gobj_js.gobj_read_attr)(gobj, "$container");
9557
- let $form = $container.querySelector("form");
9577
+ $container.querySelector("form");
9558
9578
  const enable = ($btn, on, color) => {
9559
9579
  if (!$btn) return;
9560
9580
  if (on) $btn.removeAttribute("disabled");
@@ -9569,35 +9589,11 @@ function set_changed_stated(gobj, changed) {
9569
9589
  (0, _yuneta_gobj_js.gobj_write_bool_attr)(gobj, "changes", true);
9570
9590
  enable($button_save, true, "MediumSeaGreen");
9571
9591
  enable($button_undo, true, "Magenta");
9572
- $form.querySelectorAll(".yui-tabulator-table").forEach(($table) => {
9573
- if ($table.tabulator && isEventSet($table.tabulator, "dataChanged")) $table.tabulator.off("dataChanged");
9574
- $table.querySelectorAll(".yui-tabulator-table-nested").forEach(($table_nested) => {
9575
- if ($table_nested.tabulator && isEventSet($table_nested.tabulator, "dataChanged")) $table_nested.tabulator.off("dataChanged");
9576
- });
9577
- });
9578
9592
  } else {
9579
9593
  (0, _yuneta_gobj_js.gobj_write_bool_attr)(gobj, "changes", false);
9580
9594
  (0, _yuneta_gobj_js.gobj_write_bool_attr)(gobj, "edited", false);
9581
9595
  enable($button_save, false, OFF_COLOR);
9582
9596
  enable($button_undo, false, OFF_COLOR);
9583
- if (gobj.priv.rearm_timer) clearTimeout(gobj.priv.rearm_timer);
9584
- gobj.priv.rearm_timer = setTimeout(function() {
9585
- gobj.priv.rearm_timer = null;
9586
- if ((0, _yuneta_gobj_js.gobj_is_destroying)(gobj)) return;
9587
- const rearm = (tabulator) => {
9588
- if (!tabulator) return;
9589
- if (isEventSet(tabulator, "dataChanged")) tabulator.off("dataChanged");
9590
- tabulator.on("dataChanged", function(data) {
9591
- (0, _yuneta_gobj_js.gobj_send_event)(gobj, "EV_RECORD_CHANGED", {}, gobj);
9592
- });
9593
- };
9594
- $form.querySelectorAll(".yui-tabulator-table").forEach(($table) => {
9595
- rearm($table.tabulator);
9596
- $table.querySelectorAll(".yui-tabulator-table-nested").forEach(($table_nested) => {
9597
- rearm($table_nested.tabulator);
9598
- });
9599
- });
9600
- }, 200);
9601
9597
  }
9602
9598
  }
9603
9599
  /************************************************************
@@ -9834,10 +9830,6 @@ function build_ui$15(gobj) {
9834
9830
  * Destroy UI
9835
9831
  ************************************************************/
9836
9832
  function destroy_ui$10(gobj) {
9837
- if (gobj.priv && gobj.priv.rearm_timer) {
9838
- clearTimeout(gobj.priv.rearm_timer);
9839
- gobj.priv.rearm_timer = null;
9840
- }
9841
9833
  let $container = (0, _yuneta_gobj_js.gobj_read_attr)(gobj, "$container");
9842
9834
  if ($container) {
9843
9835
  destroy_widgets(gobj, $container);
@@ -11231,6 +11223,10 @@ function create_tabulator$1(gobj, $extend, name, template) {
11231
11223
  hozAlign: "center"
11232
11224
  };
11233
11225
  let tabulator = new tabulator_tables.TabulatorFull($extend, tabulator_settings);
11226
+ tabulator.on("dataChanged", function() {
11227
+ if (gobj.priv.loading_rows > 0) return;
11228
+ (0, _yuneta_gobj_js.gobj_send_event)(gobj, "EV_RECORD_CHANGED", {}, gobj);
11229
+ });
11234
11230
  tabulator.on("renderComplete", function() {
11235
11231
  yui_tabulator_name_row_selects(tabulator, i18next.t);
11236
11232
  });
@@ -11248,6 +11244,24 @@ function create_tabulator$1(gobj, $extend, name, template) {
11248
11244
  return tabulator;
11249
11245
  }
11250
11246
  /******************************************************************
11247
+ * The title of a table's column: the schema's header, or the
11248
+ * field's name, translated.
11249
+ ******************************************************************/
11250
+ function column_title(field_desc) {
11251
+ return (0, i18next.t)(field_desc.header || field_desc.name);
11252
+ }
11253
+ /******************************************************************
11254
+ * {field: title} of a table's template, in the current language.
11255
+ ******************************************************************/
11256
+ function table_column_titles(template) {
11257
+ let titles = {};
11258
+ Object.entries(template || {}).forEach(([key, value]) => {
11259
+ const field_desc = (0, _yuneta_gobj_js.template_get_field_desc)(key, value);
11260
+ titles[field_desc.name] = column_title(field_desc);
11261
+ });
11262
+ return titles;
11263
+ }
11264
+ /******************************************************************
11251
11265
  * Convert a template's items in tabulator columns
11252
11266
  ******************************************************************/
11253
11267
  function template2columns(gobj, columns, template, sub_elements) {
@@ -11260,7 +11274,7 @@ function template2columns(gobj, columns, template, sub_elements) {
11260
11274
  let validator = [];
11261
11275
  let column = {
11262
11276
  field: field_desc.name,
11263
- title: (0, i18next.t)(field_desc.header || field_desc.name),
11277
+ title: column_title(field_desc),
11264
11278
  titleFormatter: text_title_formatter,
11265
11279
  editable: get_editable_state,
11266
11280
  editor: false,
@@ -12115,7 +12129,14 @@ function ac_add_table_row(gobj, event, kw, src) {
12115
12129
  }
12116
12130
  let record = kw.record;
12117
12131
  let loading = kw.loading;
12118
- tabulator.addRow(record).then(function(row) {
12132
+ let added;
12133
+ if (loading) gobj.priv.loading_rows++;
12134
+ try {
12135
+ added = tabulator.addRow(record);
12136
+ } finally {
12137
+ if (loading) gobj.priv.loading_rows--;
12138
+ }
12139
+ added.then(function(row) {
12119
12140
  if (loading) {
12120
12141
  let template = $table.template;
12121
12142
  Object.entries(template).forEach(([key, template_value]) => {
@@ -12222,6 +12243,10 @@ function ac_language_changed$10(gobj, event, kw, src) {
12222
12243
  let editor = $editor.jsoneditor;
12223
12244
  if (editor && typeof editor.updateProps === "function") editor.updateProps({ onRenderMenu: json_editor_render_menu(gobj) });
12224
12245
  }
12246
+ for (let $table of $container.querySelectorAll(".yui-tabulator-table, .yui-tabulator-table-nested")) {
12247
+ let tabulator = $table.tabulator;
12248
+ if (tabulator && tabulator.initialized && $table.template) yui_tabulator_relocalize(tabulator, i18next.t, table_column_titles($table.template));
12249
+ }
12225
12250
  return 0;
12226
12251
  }
12227
12252
  /***************************
@@ -500,8 +500,16 @@ function text_node(value) {
500
500
  * Tabulator puts a STRING title in with innerHTML, so a title taken
501
501
  * from a schema (`header`, which the schema editor writes) is markup
502
502
  * unless the column carries a formatter that returns a Node.
503
+ *
504
+ * And it APPENDS that Node to the title's holder, which it never
505
+ * empties -- while it runs the formatter again on every setLocale()
506
+ * (yui_tabulator_relocalize()). Until 7.26.40 each change of language
507
+ * added one more copy of every title ("Id dispositivoId dispositivo").
508
+ * The holder (the mock cell's element) is emptied first.
503
509
  ************************************************************/
504
510
  function text_title_formatter(cell) {
511
+ const $holder = typeof cell.getElement === "function" ? cell.getElement() : null;
512
+ if ($holder) $holder.textContent = "";
505
513
  return text_node(cell.getValue());
506
514
  }
507
515
  /************************************************************
@@ -7101,6 +7109,12 @@ function register_c_yui_wizard() {
7101
7109
  * new Tabulator($el, {..., ...yui_tabulator_lang(t)});
7102
7110
  * yui_tabulator_relocalize(table, t); // on a language change
7103
7111
  *
7112
+ * A view whose COLUMN TITLES change language hands them too, in the
7113
+ * new language, by field -- Tabulator binds each column's title to its
7114
+ * locale, so they change with it and no column is rebuilt:
7115
+ *
7116
+ * yui_tabulator_relocalize(table, t, {device_id: t("device id")});
7117
+ *
7104
7118
  * Every key carries an English `defaultValue`, so an app that does not
7105
7119
  * define it renders exactly what Tabulator rendered before — the
7106
7120
  * keys are an OPPORTUNITY to translate, never a requirement.
@@ -7259,11 +7273,23 @@ function yui_tabulator_name_editor(column_def, name_of) {
7259
7273
  return Object.assign({}, params, { elementAttributes: attrs });
7260
7274
  };
7261
7275
  }
7262
- function yui_tabulator_relocalize(table, t) {
7276
+ /***************************************************************
7277
+ * yui_tabulator_relocalize() -- see "Put an EXISTING table in the
7278
+ * current language" above.
7279
+ *
7280
+ * `titles`, optional: {field: title}, the titles of the columns in
7281
+ * the new language, already translated. They go in the locale
7282
+ * (`columns`), where Tabulator binds each column's title: setLocale()
7283
+ * draws them again without rebuilding a column -- updateDefinition()
7284
+ * re-renders every row, and a row whose formatter builds something (a
7285
+ * nested table) builds it anew. A field left out keeps its title.
7286
+ ***************************************************************/
7287
+ function yui_tabulator_relocalize(table, t, titles) {
7263
7288
  if (!table) return;
7264
7289
  try {
7265
7290
  let name = next_lang_name();
7266
7291
  let strings = tabulator_strings(t);
7292
+ if (titles && typeof titles === "object") strings.columns = Object.assign({}, titles);
7267
7293
  table.options.langs = table.options.langs || {};
7268
7294
  table.options.langs[name] = strings;
7269
7295
  let localize = table.modules && table.modules.localize;
@@ -9482,7 +9508,7 @@ var attrs_table$17 = [
9482
9508
  SDATA_END()
9483
9509
  ];
9484
9510
  var PRIVATE_DATA$17 = {
9485
- rearm_timer: null,
9511
+ loading_rows: 0,
9486
9512
  tables: null,
9487
9513
  table_seq: 0,
9488
9514
  json_names_posted: false,
@@ -9538,18 +9564,12 @@ function mt_stop$17(gobj) {
9538
9564
  * Local Methods
9539
9565
  ***************************/
9540
9566
  /************************************************************
9541
- *
9542
- ************************************************************/
9543
- function isEventSet(table, event) {
9544
- let eventListeners = table.externalEvents.events;
9545
- return eventListeners && eventListeners[event] && eventListeners[event].length > 0;
9546
- }
9547
- /************************************************************
9548
- *
9567
+ * `changes` and the save/undo buttons. What the tables say is
9568
+ * not switched here: they always say it (create_tabulator).
9549
9569
  ************************************************************/
9550
9570
  function set_changed_stated(gobj, changed) {
9551
9571
  let $container = gobj_read_attr(gobj, "$container");
9552
- let $form = $container.querySelector("form");
9572
+ $container.querySelector("form");
9553
9573
  const enable = ($btn, on, color) => {
9554
9574
  if (!$btn) return;
9555
9575
  if (on) $btn.removeAttribute("disabled");
@@ -9564,35 +9584,11 @@ function set_changed_stated(gobj, changed) {
9564
9584
  gobj_write_bool_attr(gobj, "changes", true);
9565
9585
  enable($button_save, true, "MediumSeaGreen");
9566
9586
  enable($button_undo, true, "Magenta");
9567
- $form.querySelectorAll(".yui-tabulator-table").forEach(($table) => {
9568
- if ($table.tabulator && isEventSet($table.tabulator, "dataChanged")) $table.tabulator.off("dataChanged");
9569
- $table.querySelectorAll(".yui-tabulator-table-nested").forEach(($table_nested) => {
9570
- if ($table_nested.tabulator && isEventSet($table_nested.tabulator, "dataChanged")) $table_nested.tabulator.off("dataChanged");
9571
- });
9572
- });
9573
9587
  } else {
9574
9588
  gobj_write_bool_attr(gobj, "changes", false);
9575
9589
  gobj_write_bool_attr(gobj, "edited", false);
9576
9590
  enable($button_save, false, OFF_COLOR);
9577
9591
  enable($button_undo, false, OFF_COLOR);
9578
- if (gobj.priv.rearm_timer) clearTimeout(gobj.priv.rearm_timer);
9579
- gobj.priv.rearm_timer = setTimeout(function() {
9580
- gobj.priv.rearm_timer = null;
9581
- if (gobj_is_destroying(gobj)) return;
9582
- const rearm = (tabulator) => {
9583
- if (!tabulator) return;
9584
- if (isEventSet(tabulator, "dataChanged")) tabulator.off("dataChanged");
9585
- tabulator.on("dataChanged", function(data) {
9586
- gobj_send_event(gobj, "EV_RECORD_CHANGED", {}, gobj);
9587
- });
9588
- };
9589
- $form.querySelectorAll(".yui-tabulator-table").forEach(($table) => {
9590
- rearm($table.tabulator);
9591
- $table.querySelectorAll(".yui-tabulator-table-nested").forEach(($table_nested) => {
9592
- rearm($table_nested.tabulator);
9593
- });
9594
- });
9595
- }, 200);
9596
9592
  }
9597
9593
  }
9598
9594
  /************************************************************
@@ -9829,10 +9825,6 @@ function build_ui$15(gobj) {
9829
9825
  * Destroy UI
9830
9826
  ************************************************************/
9831
9827
  function destroy_ui$10(gobj) {
9832
- if (gobj.priv && gobj.priv.rearm_timer) {
9833
- clearTimeout(gobj.priv.rearm_timer);
9834
- gobj.priv.rearm_timer = null;
9835
- }
9836
9828
  let $container = gobj_read_attr(gobj, "$container");
9837
9829
  if ($container) {
9838
9830
  destroy_widgets(gobj, $container);
@@ -11226,6 +11218,10 @@ function create_tabulator$1(gobj, $extend, name, template) {
11226
11218
  hozAlign: "center"
11227
11219
  };
11228
11220
  let tabulator = new TabulatorFull($extend, tabulator_settings);
11221
+ tabulator.on("dataChanged", function() {
11222
+ if (gobj.priv.loading_rows > 0) return;
11223
+ gobj_send_event(gobj, "EV_RECORD_CHANGED", {}, gobj);
11224
+ });
11229
11225
  tabulator.on("renderComplete", function() {
11230
11226
  yui_tabulator_name_row_selects(tabulator, t);
11231
11227
  });
@@ -11243,6 +11239,24 @@ function create_tabulator$1(gobj, $extend, name, template) {
11243
11239
  return tabulator;
11244
11240
  }
11245
11241
  /******************************************************************
11242
+ * The title of a table's column: the schema's header, or the
11243
+ * field's name, translated.
11244
+ ******************************************************************/
11245
+ function column_title(field_desc) {
11246
+ return t(field_desc.header || field_desc.name);
11247
+ }
11248
+ /******************************************************************
11249
+ * {field: title} of a table's template, in the current language.
11250
+ ******************************************************************/
11251
+ function table_column_titles(template) {
11252
+ let titles = {};
11253
+ Object.entries(template || {}).forEach(([key, value]) => {
11254
+ const field_desc = template_get_field_desc(key, value);
11255
+ titles[field_desc.name] = column_title(field_desc);
11256
+ });
11257
+ return titles;
11258
+ }
11259
+ /******************************************************************
11246
11260
  * Convert a template's items in tabulator columns
11247
11261
  ******************************************************************/
11248
11262
  function template2columns(gobj, columns, template, sub_elements) {
@@ -11255,7 +11269,7 @@ function template2columns(gobj, columns, template, sub_elements) {
11255
11269
  let validator = [];
11256
11270
  let column = {
11257
11271
  field: field_desc.name,
11258
- title: t(field_desc.header || field_desc.name),
11272
+ title: column_title(field_desc),
11259
11273
  titleFormatter: text_title_formatter,
11260
11274
  editable: get_editable_state,
11261
11275
  editor: false,
@@ -12110,7 +12124,14 @@ function ac_add_table_row(gobj, event, kw, src) {
12110
12124
  }
12111
12125
  let record = kw.record;
12112
12126
  let loading = kw.loading;
12113
- tabulator.addRow(record).then(function(row) {
12127
+ let added;
12128
+ if (loading) gobj.priv.loading_rows++;
12129
+ try {
12130
+ added = tabulator.addRow(record);
12131
+ } finally {
12132
+ if (loading) gobj.priv.loading_rows--;
12133
+ }
12134
+ added.then(function(row) {
12114
12135
  if (loading) {
12115
12136
  let template = $table.template;
12116
12137
  Object.entries(template).forEach(([key, template_value]) => {
@@ -12217,6 +12238,10 @@ function ac_language_changed$10(gobj, event, kw, src) {
12217
12238
  let editor = $editor.jsoneditor;
12218
12239
  if (editor && typeof editor.updateProps === "function") editor.updateProps({ onRenderMenu: json_editor_render_menu(gobj) });
12219
12240
  }
12241
+ for (let $table of $container.querySelectorAll(".yui-tabulator-table, .yui-tabulator-table-nested")) {
12242
+ let tabulator = $table.tabulator;
12243
+ if (tabulator && tabulator.initialized && $table.template) yui_tabulator_relocalize(tabulator, t, table_column_titles($table.template));
12244
+ }
12220
12245
  return 0;
12221
12246
  }
12222
12247
  /***************************
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuneta/gobj-ui",
3
- "version": "7.26.40",
3
+ "version": "7.26.41",
4
4
  "type": "module",
5
5
  "main": "dist/gobj-ui.cjs.js",
6
6
  "module": "dist/gobj-ui.es.js",
package/src/c_yui_form.js CHANGED
@@ -55,7 +55,11 @@ import {
55
55
 
56
56
  import {t} from "i18next";
57
57
 
58
- import {yui_tabulator_name_row_selects, yui_tabulator_name_editor} from "./yui_tabulator_i18n.js";
58
+ import {
59
+ yui_tabulator_name_row_selects,
60
+ yui_tabulator_name_editor,
61
+ yui_tabulator_relocalize
62
+ } from "./yui_tabulator_i18n.js";
59
63
  import "tom-select/dist/css/tom-select.css"; // Import Tom-Select CSS
60
64
  import TomSelect from "tom-select"; // Import Tom-Select JS
61
65
 
@@ -172,9 +176,10 @@ SDATA_END()
172
176
  ];
173
177
 
174
178
  let PRIVATE_DATA = {
175
- /* The deferred re-wiring of the tables' dataChanged after a
176
- * save/undo (see set_changed_stated): cancelled in destroy_ui. */
177
- rearm_timer: null,
179
+ /* > 0 while the FORM adds rows (a record loaded, a nested table
180
+ * filled): what a table says then is no change of the reader's
181
+ * (see create_tabulator's dataChanged). */
182
+ loading_rows: 0,
178
183
  /* table_id -> WeakRef of its element: EV_ADD_TABLE_ROW names a
179
184
  * table by id (a kw is plain json), see table_identity(). */
180
185
  tables: null,
@@ -281,17 +286,8 @@ function mt_stop(gobj)
281
286
 
282
287
 
283
288
  /************************************************************
284
- *
285
- ************************************************************/
286
- function isEventSet(table, event)
287
- {
288
- // Access the event listeners directly from the Tabulator instance
289
- let eventListeners = table.externalEvents.events;
290
- return eventListeners && eventListeners[event] && eventListeners[event].length > 0;
291
- }
292
-
293
- /************************************************************
294
- *
289
+ * `changes` and the save/undo buttons. What the tables say is
290
+ * not switched here: they always say it (create_tabulator).
295
291
  ************************************************************/
296
292
  function set_changed_stated(gobj, changed)
297
293
  {
@@ -333,18 +329,6 @@ function set_changed_stated(gobj, changed)
333
329
  enable($button_save, true, "MediumSeaGreen");
334
330
  enable($button_undo, true, "Magenta");
335
331
 
336
- $form.querySelectorAll('.yui-tabulator-table').forEach($table => {
337
- if($table.tabulator && isEventSet($table.tabulator, "dataChanged")) {
338
- $table.tabulator.off("dataChanged");
339
- }
340
-
341
- $table.querySelectorAll('.yui-tabulator-table-nested').forEach($table_nested => {
342
- if($table_nested.tabulator && isEventSet($table_nested.tabulator, "dataChanged")) {
343
- $table_nested.tabulator.off("dataChanged");
344
- }
345
- });
346
- });
347
-
348
332
  } else {
349
333
  /*
350
334
  * Data not changed, disable buttons to save/undo
@@ -354,41 +338,6 @@ function set_changed_stated(gobj, changed)
354
338
 
355
339
  enable($button_save, false, OFF_COLOR);
356
340
  enable($button_undo, false, OFF_COLOR);
357
-
358
- /*
359
- * TODO improve. The tables are re-wired a moment later, once the
360
- * data just loaded has settled. The timer is kept so that
361
- * destroy_ui() cancels it: a form closed right after a save (what
362
- * a dialog does) destroyed its widgets first, and the timer then
363
- * called .on() on a null tabulator -- an uncaught TypeError.
364
- */
365
- if(gobj.priv.rearm_timer) {
366
- clearTimeout(gobj.priv.rearm_timer);
367
- }
368
- gobj.priv.rearm_timer = setTimeout(function() {
369
- gobj.priv.rearm_timer = null;
370
- if(gobj_is_destroying(gobj)) {
371
- return; // destroy_ui() cancels it; this is the belt
372
- }
373
- const rearm = (tabulator) => {
374
- if(!tabulator) {
375
- return; // destroyed with its form
376
- }
377
- if(isEventSet(tabulator, "dataChanged")) {
378
- tabulator.off("dataChanged");
379
- }
380
- tabulator.on("dataChanged", function(data) {
381
- //data - the updated table data
382
- gobj_send_event(gobj, "EV_RECORD_CHANGED", {}, gobj);
383
- });
384
- };
385
- $form.querySelectorAll('.yui-tabulator-table').forEach($table => {
386
- rearm($table.tabulator);
387
- $table.querySelectorAll('.yui-tabulator-table-nested').forEach($table_nested => {
388
- rearm($table_nested.tabulator);
389
- });
390
- });
391
- }, 200);
392
341
  }
393
342
  }
394
343
 
@@ -562,10 +511,6 @@ function build_ui(gobj)
562
511
  ************************************************************/
563
512
  function destroy_ui(gobj)
564
513
  {
565
- if(gobj.priv && gobj.priv.rearm_timer) {
566
- clearTimeout(gobj.priv.rearm_timer);
567
- gobj.priv.rearm_timer = null;
568
- }
569
514
  let $container = gobj_read_attr(gobj, "$container");
570
515
  if($container) {
571
516
  destroy_widgets(gobj, $container);
@@ -2332,6 +2277,22 @@ function create_tabulator(gobj, $extend, name, template)
2332
2277
  $extend,
2333
2278
  tabulator_settings
2334
2279
  );
2280
+ /* What the READER does to a table -- edits a cell, adds or deletes a
2281
+ * row -- reaches the form only through `dataChanged`. Wired here, on
2282
+ * every table, nested ones included (a row's formatter builds them
2283
+ * whenever the row is drawn), and never switched off: until 7.26.40
2284
+ * it was unwired whenever the form was CHANGED and re-wired only by a
2285
+ * save, an undo or a load of what is stored, so a form that opened
2286
+ * changed (a field its record lacks, filled with its default) never
2287
+ * heard its tables -- an edit there was not `edited`, and closing lost
2288
+ * it without a question. The rows the form adds itself say nothing
2289
+ * (`loading_rows`, ac_add_table_row). */
2290
+ tabulator.on("dataChanged", function() {
2291
+ if(gobj.priv.loading_rows > 0) {
2292
+ return;
2293
+ }
2294
+ gobj_send_event(gobj, "EV_RECORD_CHANGED", {}, gobj);
2295
+ });
2335
2296
  /* Tabulator hard-codes `aria-label="Select Row"` on the box its
2336
2297
  * rowSelection formatter draws, and it draws a fresh one on EVERY
2337
2298
  * render -- so this hangs off `renderComplete`, not `tableBuilt`:
@@ -2369,6 +2330,28 @@ function create_tabulator(gobj, $extend, name, template)
2369
2330
  return tabulator;
2370
2331
  }
2371
2332
 
2333
+ /******************************************************************
2334
+ * The title of a table's column: the schema's header, or the
2335
+ * field's name, translated.
2336
+ ******************************************************************/
2337
+ function column_title(field_desc)
2338
+ {
2339
+ return t(/* i18n: data */ field_desc.header || field_desc.name); // the host's schema
2340
+ }
2341
+
2342
+ /******************************************************************
2343
+ * {field: title} of a table's template, in the current language.
2344
+ ******************************************************************/
2345
+ function table_column_titles(template)
2346
+ {
2347
+ let titles = {};
2348
+ Object.entries(template || {}).forEach(([key, value]) => {
2349
+ const field_desc = template_get_field_desc(key, value);
2350
+ titles[field_desc.name] = column_title(field_desc);
2351
+ });
2352
+ return titles;
2353
+ }
2354
+
2372
2355
  /******************************************************************
2373
2356
  * Convert a template's items in tabulator columns
2374
2357
  ******************************************************************/
@@ -2388,7 +2371,7 @@ function template2columns(gobj, columns, template, sub_elements)
2388
2371
  let validator = [];
2389
2372
  let column = {
2390
2373
  field: field_desc.name,
2391
- title: t(/* i18n: data */ field_desc.header || field_desc.name), // the host's schema
2374
+ title: column_title(field_desc),
2392
2375
  titleFormatter: text_title_formatter,
2393
2376
  editable: get_editable_state,
2394
2377
  editor: false,
@@ -3793,7 +3776,23 @@ function ac_add_table_row(gobj, event, kw, src)
3793
3776
  let record = kw.record;
3794
3777
  let loading = kw.loading;
3795
3778
 
3796
- tabulator.addRow(record)
3779
+ /* Tabulator says `dataChanged` INSIDE addRow(), synchronously: a row
3780
+ * loaded from the record is muted there, whenever it lands -- also
3781
+ * from a `tableBuilt` that comes after the load returned, which is
3782
+ * why no timer can tell the two apart. */
3783
+ let added;
3784
+ if(loading) {
3785
+ gobj.priv.loading_rows++;
3786
+ }
3787
+ try {
3788
+ added = tabulator.addRow(record);
3789
+ } finally {
3790
+ if(loading) {
3791
+ gobj.priv.loading_rows--;
3792
+ }
3793
+ }
3794
+
3795
+ added
3797
3796
  .then(function(row) {
3798
3797
  if(loading) {
3799
3798
  /*
@@ -3955,6 +3954,19 @@ function ac_language_changed(gobj, event, kw, src)
3955
3954
  editor.updateProps({onRenderMenu: json_editor_render_menu(gobj)});
3956
3955
  }
3957
3956
  }
3957
+
3958
+ /* The titles of the columns of every table, nested ones included:
3959
+ * Tabulator draws them at build, so they stayed in the language the
3960
+ * form was built in. Through the table's LOCALE, never by
3961
+ * updateDefinition(): rebuilding a column re-renders every row, and
3962
+ * a row's formatter builds its nested tables anew -- empty. A table
3963
+ * not built yet is drawn in the current language when it is. */
3964
+ for(let $table of $container.querySelectorAll(".yui-tabulator-table, .yui-tabulator-table-nested")) {
3965
+ let tabulator = $table.tabulator;
3966
+ if(tabulator && tabulator.initialized && $table.template) {
3967
+ yui_tabulator_relocalize(tabulator, t, table_column_titles($table.template));
3968
+ }
3969
+ }
3958
3970
  return 0;
3959
3971
  }
3960
3972
 
@@ -0,0 +1,398 @@
1
+ /***********************************************************************
2
+ * form_table_edits_ask.wiring.test.js
3
+ *
4
+ * What the reader does to the TABLES of a C_YUI_FORM is `edited`,
5
+ * whatever the record the form opened with -- so closing asks.
6
+ *
7
+ * A form opens CHANGED when its record lacks a field the template
8
+ * fills with its default. Until 7.26.40 that also unwired the
9
+ * tables' `dataChanged` (and only a save, an undo or a load of what
10
+ * is stored wired it again), so adding a row, editing a cell,
11
+ * deleting a row or editing a nested table marked nothing: since
12
+ * 7.26.39 the close asks only for `edited`, and it closed without a
13
+ * question -- the edit lost. In estadodelaire that is the settings of
14
+ * every device without its alarm colours, whose alarms are tables.
15
+ *
16
+ * And the column titles of those tables change language: they were
17
+ * drawn at build and never again.
18
+ *
19
+ * Driven through the real gclass on a document double, with a
20
+ * Tabulator double that, like Tabulator, draws a row (its formatter
21
+ * runs) and says `dataChanged` INSIDE addRow(), synchronously.
22
+ *
23
+ * Copyright (c) 2026, ArtGins.
24
+ * All Rights Reserved.
25
+ ***********************************************************************/
26
+ import {describe, test, expect, beforeAll, beforeEach, vi} from "vitest";
27
+ import {install_dom_double} from "../test/dom_double.js";
28
+
29
+ install_dom_double();
30
+
31
+ if(typeof globalThis.ResizeObserver === "undefined") {
32
+ globalThis.ResizeObserver = class {
33
+ observe() {}
34
+ unobserve() {}
35
+ disconnect() {}
36
+ };
37
+ }
38
+
39
+ const tables = vi.hoisted(() => []);
40
+ vi.mock("tabulator-tables", () => {
41
+ class FakeTabulator {
42
+ constructor($el, settings) {
43
+ this.element = $el;
44
+ this.settings = settings;
45
+ this.options = {};
46
+ this.handlers = {};
47
+ this.initialized = true;
48
+ this.rows = [];
49
+ this.installed = {};
50
+ this.locale = null;
51
+ this.modules = {
52
+ localize: {
53
+ installLang: (name, strings) => {
54
+ this.installed[name] = strings;
55
+ }
56
+ }
57
+ };
58
+ tables.push(this);
59
+ return new Proxy(this, {
60
+ get(target, prop) {
61
+ if(prop in target) {
62
+ return target[prop];
63
+ }
64
+ return () => [];
65
+ }
66
+ });
67
+ }
68
+ on(ev, fn) {
69
+ (this.handlers[ev] = this.handlers[ev] || []).push(fn);
70
+ }
71
+ off(ev) {
72
+ delete this.handlers[ev];
73
+ }
74
+ emit(ev) {
75
+ for(const fn of (this.handlers[ev] || [])) {
76
+ fn([]);
77
+ }
78
+ }
79
+ addRow(record) {
80
+ /* The row's element lives in the table's, as in Tabulator:
81
+ * a nested table drawn in it is inside the form. */
82
+ const $row = document.createElement("div");
83
+ this.element.appendChild($row);
84
+ const row = {getElement: () => $row, getData: () => record};
85
+ if(this.settings && typeof this.settings.rowFormatter === "function") {
86
+ this.settings.rowFormatter(row);
87
+ }
88
+ this.rows.push(row);
89
+ this.emit("dataChanged");
90
+ return Promise.resolve(row);
91
+ }
92
+ /* What the trash of a row ends in (row.delete()). */
93
+ delete_row() {
94
+ const row = this.rows.pop();
95
+ row.getElement().remove();
96
+ this.emit("dataChanged");
97
+ }
98
+ /* What an edited cell says. */
99
+ edit_cell() {
100
+ this.emit("dataChanged");
101
+ }
102
+ validate() {
103
+ return true;
104
+ }
105
+ clearData() {
106
+ for(const row of this.rows) {
107
+ row.getElement().remove();
108
+ }
109
+ this.rows = [];
110
+ }
111
+ setLocale(name) {
112
+ this.locale = name;
113
+ }
114
+ destroy() {
115
+ }
116
+ }
117
+ return {TabulatorFull: FakeTabulator, Tabulator: FakeTabulator};
118
+ });
119
+
120
+ const {
121
+ SDATA_END, gclass_create,
122
+ gobj_start_up, gobj_create_yuno, gobj_create_pure_child,
123
+ gobj_start, gobj_send_event, gobj_read_attr,
124
+ set_log_callback,
125
+ } = await import("@yuneta/gobj-js");
126
+ const {register_c_yui_form} = await import("./c_yui_form.js");
127
+ const i18next = (await import("i18next")).default;
128
+
129
+ const logged = [];
130
+ let yuno = null;
131
+
132
+ /* A device's settings, cut down: a defaulted field, and the alarms as
133
+ * a table whose rows carry a nested table of triggers. */
134
+ const TEMPLATE = {
135
+ template_version: {
136
+ type: "integer",
137
+ default: 7,
138
+ flag: ["hidden", "persistent"]
139
+ },
140
+ level: {
141
+ type: "integer",
142
+ default: 3,
143
+ flag: ["writable", "persistent"]
144
+ },
145
+ alarms: {
146
+ type: "array",
147
+ flag: ["writable", "persistent", "table"],
148
+ table: {
149
+ name: {
150
+ type: "string",
151
+ header: "alarm name",
152
+ flag: ["writable", "persistent"]
153
+ },
154
+ triggers: {
155
+ type: "array",
156
+ flag: ["writable", "persistent", "table"],
157
+ table: {
158
+ measure: {
159
+ type: "string",
160
+ header: "measure",
161
+ flag: ["writable", "persistent"]
162
+ }
163
+ }
164
+ }
165
+ }
166
+ }
167
+ };
168
+
169
+ /* `level` missing: the form opens changed. */
170
+ const LACKING = {
171
+ template_version: 7,
172
+ alarms: [{name: "hot", triggers: [{measure: "temperature"}]}]
173
+ };
174
+
175
+ const COMPLETE = {
176
+ template_version: 7,
177
+ level: 3,
178
+ alarms: [{name: "hot", triggers: [{measure: "temperature"}]}]
179
+ };
180
+
181
+ beforeAll(async () => {
182
+ await i18next.init({
183
+ lng: "en",
184
+ resources: {
185
+ en: {translation: {"alarm name": "Alarm name", "measure": "Measure"}},
186
+ es: {translation: {"alarm name": "Nombre de la alarma", "measure": "Medida"}}
187
+ }
188
+ });
189
+ gobj_start_up(null, null, null, null, null, null, null);
190
+ set_log_callback((level, msg) => {
191
+ logged.push({level: String(level), msg: String(msg)});
192
+ });
193
+ gclass_create("C_TEST_TE_HOST", [], [["ST_IDLE", []]], {}, 0, [SDATA_END()], {}, 0, 0, 0, 0);
194
+ gclass_create(
195
+ "C_TEST_TE_APP",
196
+ [["EV_SAVE_RECORD", 0]],
197
+ [["ST_IDLE", [
198
+ ["EV_SAVE_RECORD", () => 0, null],
199
+ ]]],
200
+ {}, 0, [SDATA_END()], {}, 0, 0, 0, 0
201
+ );
202
+ register_c_yui_form();
203
+ yuno = gobj_create_yuno("form_table_edits_yuno", "C_TEST_TE_HOST", {});
204
+ gobj_start(yuno);
205
+ });
206
+
207
+ beforeEach(async () => {
208
+ logged.length = 0;
209
+ await i18next.changeLanguage("en");
210
+ });
211
+
212
+ let seq = 0;
213
+
214
+ function build(record)
215
+ {
216
+ const first = tables.length;
217
+ const app = gobj_create_pure_child(`teapp${++seq}`, "C_TEST_TE_APP", {}, yuno);
218
+ gobj_start(app);
219
+ const form = gobj_create_pure_child(`teform${seq}`, "C_YUI_FORM", {
220
+ template: TEMPLATE,
221
+ record: record,
222
+ editable: true,
223
+ }, app);
224
+ gobj_start(form);
225
+ return {form, first};
226
+ }
227
+
228
+ /* The tables the form built since `first`: the alarms table first,
229
+ * then a triggers table per alarm row drawn. */
230
+ function built(first)
231
+ {
232
+ return tables.slice(first);
233
+ }
234
+
235
+ /* The microtasks of addRow().then(): the nested rows load there. */
236
+ async function settle()
237
+ {
238
+ await new Promise(resolve => setTimeout(resolve, 0));
239
+ }
240
+
241
+ function close_asks(form)
242
+ {
243
+ const kw = {abort_close: false};
244
+ gobj_send_event(form, "EV_WINDOW_TO_CLOSE", kw, form);
245
+ return kw.abort_close ? (kw.warning || "(no warning)") : "";
246
+ }
247
+
248
+ function errors()
249
+ {
250
+ return logged.filter(l => /error/i.test(l.level) || /ERROR/.test(l.msg));
251
+ }
252
+
253
+ describe("a form that opens changed hears what the reader does to its tables", () => {
254
+ test("it opens changed, not edited, and closes without asking", async () => {
255
+ const {form, first} = build(LACKING);
256
+ await settle();
257
+ expect(gobj_read_attr(form, "changes")).toBe(true);
258
+ expect(gobj_read_attr(form, "edited")).toBe(false);
259
+ expect(built(first).length).toBe(2); // the alarms, and the triggers of its row
260
+ expect(close_asks(form)).toBe("");
261
+ expect(errors()).toEqual([]);
262
+ });
263
+
264
+ test("an edited cell makes it ask", async () => {
265
+ const {form, first} = build(LACKING);
266
+ await settle();
267
+ built(first)[0].edit_cell();
268
+ expect(gobj_read_attr(form, "edited")).toBe(true);
269
+ expect(close_asks(form)).toBe("all changes will be lost");
270
+ expect(errors()).toEqual([]);
271
+ });
272
+
273
+ test("a deleted row makes it ask", async () => {
274
+ const {form, first} = build(LACKING);
275
+ await settle();
276
+ built(first)[0].delete_row();
277
+ expect(close_asks(form)).toBe("all changes will be lost");
278
+ });
279
+
280
+ test("a row the reader adds makes it ask; the rows the form loads do not", async () => {
281
+ const {form, first} = build(LACKING);
282
+ await settle();
283
+ const alarms = built(first)[0];
284
+ expect(alarms.rows.length).toBe(1); // loaded: no question
285
+ expect(close_asks(form)).toBe("");
286
+
287
+ /* What the table's add button sends: the reader's row. */
288
+ const tables_before = tables.length;
289
+ gobj_send_event(form, "EV_ADD_TABLE_ROW", {
290
+ table_id: alarms.element.yui_table_id,
291
+ record: {},
292
+ loading: false
293
+ }, form);
294
+ expect(alarms.rows.length).toBe(2);
295
+ expect(tables.length).toBe(tables_before + 1); // the new row's triggers table
296
+ expect(gobj_read_attr(form, "edited")).toBe(true);
297
+ expect(close_asks(form)).toBe("all changes will be lost");
298
+ expect(errors()).toEqual([]);
299
+ });
300
+
301
+ test("an edit of a NESTED table makes it ask, also one drawn after the load", async () => {
302
+ const loaded = build(LACKING);
303
+ await settle();
304
+ const triggers = built(loaded.first)[1];
305
+ expect(triggers.rows.length).toBe(1); // loaded in its row: no question
306
+ expect(close_asks(loaded.form)).toBe("");
307
+ triggers.edit_cell();
308
+ expect(close_asks(loaded.form)).toBe("all changes will be lost");
309
+
310
+ /* An undo draws the rows again, and each row builds its triggers
311
+ * table then: a table that did not exist when the form loaded. */
312
+ const before_undo = tables.length;
313
+ gobj_send_event(loaded.form, "EV_UNDO_RECORD", {}, loaded.form);
314
+ await settle();
315
+ expect(close_asks(loaded.form)).toBe("");
316
+ const redrawn = tables.slice(before_undo);
317
+ expect(redrawn.length).toBe(1);
318
+ expect(redrawn[0].rows.length).toBe(1);
319
+ redrawn[0].edit_cell();
320
+ expect(close_asks(loaded.form)).toBe("all changes will be lost");
321
+ expect(errors()).toEqual([]);
322
+ });
323
+
324
+ test("a record loaded LATER that lacks a field (a reload, a refused save) still hears them", async () => {
325
+ const {form, first} = build(COMPLETE);
326
+ await settle();
327
+ expect(gobj_read_attr(form, "changes")).toBe(false);
328
+ gobj_send_event(form, "EV_LOAD_RECORD", LACKING, form);
329
+ await settle();
330
+ expect(gobj_read_attr(form, "changes")).toBe(true);
331
+ expect(gobj_read_attr(form, "edited")).toBe(false);
332
+ expect(close_asks(form)).toBe("");
333
+ built(first)[0].edit_cell();
334
+ expect(close_asks(form)).toBe("all changes will be lost");
335
+ expect(errors()).toEqual([]);
336
+ });
337
+
338
+ test("a table built after the load (tableBuilt) takes its rows without a question", async () => {
339
+ const {form, first} = build(COMPLETE);
340
+ await settle();
341
+ const alarms = built(first)[0];
342
+ /* A table not built yet: no rows, and the ones it is given wait
343
+ * for its `tableBuilt`. */
344
+ alarms.clearData();
345
+ alarms.initialized = false;
346
+ gobj_send_event(form, "EV_LOAD_RECORD", LACKING, form);
347
+ await settle();
348
+ expect(alarms.rows.length).toBe(0);
349
+ /* Tabulator builds the table now, long after the load returned. */
350
+ alarms.initialized = true;
351
+ alarms.emit("tableBuilt");
352
+ await settle();
353
+ expect(alarms.rows.length).toBe(1);
354
+ expect(gobj_read_attr(form, "edited")).toBe(false);
355
+ expect(close_asks(form)).toBe("");
356
+ expect(errors()).toEqual([]);
357
+ });
358
+
359
+ test("with a complete record it asks as before", async () => {
360
+ /* The waits are the 200 ms after which 7.26.40 wired the tables
361
+ * again: with them this case passes on the code before too. */
362
+ const wait = () => new Promise(resolve => setTimeout(resolve, 260));
363
+ const {form, first} = build(COMPLETE);
364
+ await wait();
365
+ expect(gobj_read_attr(form, "changes")).toBe(false);
366
+ built(first)[1].edit_cell();
367
+ expect(close_asks(form)).toBe("all changes will be lost");
368
+ gobj_send_event(form, "EV_SAVE_RECORD", {}, form);
369
+ expect(close_asks(form)).toBe("");
370
+ await wait();
371
+ built(first)[0].edit_cell();
372
+ expect(close_asks(form)).toBe("all changes will be lost");
373
+ expect(errors()).toEqual([]);
374
+ });
375
+ });
376
+
377
+ describe("the column titles of the tables change language", () => {
378
+ test("each table, nested ones too, is given its titles in the new language", async () => {
379
+ const {form, first} = build(COMPLETE);
380
+ await settle();
381
+ const [alarms, triggers] = built(first);
382
+
383
+ await i18next.changeLanguage("es");
384
+ gobj_send_event(form, "EV_LANGUAGE_CHANGED", {}, form);
385
+
386
+ expect(alarms.locale).toBeTruthy();
387
+ expect(alarms.installed[alarms.locale].columns).toEqual({
388
+ name: "Nombre de la alarma",
389
+ triggers: "triggers"
390
+ });
391
+ expect(triggers.installed[triggers.locale].columns).toEqual({measure: "Medida"});
392
+ /* No column was rebuilt to get there: the rows (and the nested
393
+ * table) are the ones loaded. */
394
+ expect(alarms.rows.length).toBe(1);
395
+ expect(triggers.rows.length).toBe(1);
396
+ expect(errors()).toEqual([]);
397
+ });
398
+ });
@@ -5,11 +5,13 @@
5
5
  * a save or an undo -- which is what a dialog does: it closes the
6
6
  * moment the record is saved.
7
7
  *
8
- * set_changed_stated(false) re-wires the tables' `dataChanged` from
9
- * a 200 ms timer. Since 7.26.7 destroy_ui() destroys the widgets and
10
- * nulls `$table.tabulator`, and the timer, left running, called
11
- * `.on()` on that null: an uncaught TypeError. The timer is kept and
12
- * cancelled now, and it skips a table that is gone.
8
+ * set_changed_stated(false) used to re-wire the tables' `dataChanged`
9
+ * from a 200 ms timer. Since 7.26.7 destroy_ui() destroys the widgets
10
+ * and nulls `$table.tabulator`, and the timer, left running, called
11
+ * `.on()` on that null: an uncaught TypeError. Since 7.26.41 there is
12
+ * no timer: each table is wired once, when it is built, and stays
13
+ * wired (form_table_edits_ask.wiring.test.js says why). These cases
14
+ * keep guarding the destroy right after a save or an undo.
13
15
  *
14
16
  * Copyright (c) 2026, ArtGins.
15
17
  * All Rights Reserved.
@@ -117,7 +119,7 @@ function build(rows)
117
119
  }
118
120
 
119
121
  describe("a form with a table, destroyed right after", () => {
120
- test("an undo: the deferred re-wiring does not throw", async () => {
122
+ test("an undo, then the destroy: nothing left behind throws", async () => {
121
123
  const {form} = build();
122
124
  expect(tables.length).toBeGreaterThan(0);
123
125
  const errors = [];
@@ -135,7 +137,7 @@ describe("a form with a table, destroyed right after", () => {
135
137
  expect(errors).toEqual([]);
136
138
  });
137
139
 
138
- test("a live form is still re-wired", async () => {
140
+ test("a live form's table stays wired, once", async () => {
139
141
  const {form} = build();
140
142
  const table = tables[tables.length - 1];
141
143
  gobj_send_event(form, "EV_UNDO_RECORD", {}, form);
@@ -14,6 +14,12 @@
14
14
  * new Tabulator($el, {..., ...yui_tabulator_lang(t)});
15
15
  * yui_tabulator_relocalize(table, t); // on a language change
16
16
  *
17
+ * A view whose COLUMN TITLES change language hands them too, in the
18
+ * new language, by field -- Tabulator binds each column's title to its
19
+ * locale, so they change with it and no column is rebuilt:
20
+ *
21
+ * yui_tabulator_relocalize(table, t, {device_id: t("device id")});
22
+ *
17
23
  * Every key carries an English `defaultValue`, so an app that does not
18
24
  * define it renders exactly what Tabulator rendered before — the
19
25
  * keys are an OPPORTUNITY to translate, never a requirement.
@@ -236,7 +242,18 @@ function yui_tabulator_name_editor(column_def, name_of)
236
242
  };
237
243
  }
238
244
 
239
- function yui_tabulator_relocalize(table, t)
245
+ /***************************************************************
246
+ * yui_tabulator_relocalize() -- see "Put an EXISTING table in the
247
+ * current language" above.
248
+ *
249
+ * `titles`, optional: {field: title}, the titles of the columns in
250
+ * the new language, already translated. They go in the locale
251
+ * (`columns`), where Tabulator binds each column's title: setLocale()
252
+ * draws them again without rebuilding a column -- updateDefinition()
253
+ * re-renders every row, and a row whose formatter builds something (a
254
+ * nested table) builds it anew. A field left out keeps its title.
255
+ ***************************************************************/
256
+ function yui_tabulator_relocalize(table, t, titles)
240
257
  {
241
258
  if(!table) {
242
259
  return;
@@ -244,6 +261,9 @@ function yui_tabulator_relocalize(table, t)
244
261
  try {
245
262
  let name = next_lang_name();
246
263
  let strings = tabulator_strings(t);
264
+ if(titles && typeof titles === "object") {
265
+ strings.columns = Object.assign({}, titles);
266
+ }
247
267
 
248
268
  /* Tabulator DEEP-CLONES options.langs into its localize module when
249
269
  * the table is built, and never looks at the option again: writing a
@@ -15,6 +15,7 @@ import {
15
15
  yui_tabulator_name_filters,
16
16
  yui_tabulator_name_row_selects,
17
17
  yui_tabulator_name_editor,
18
+ yui_tabulator_relocalize,
18
19
  } from "./yui_tabulator_i18n.js";
19
20
 
20
21
  /* An element double: attributes, and the one child a selector finds. */
@@ -125,3 +126,41 @@ describe("yui_tabulator_name_editor", () => {
125
126
  expect(def.editorParams({})).toEqual({a: 1});
126
127
  });
127
128
  });
129
+
130
+ describe("yui_tabulator_relocalize", () => {
131
+ /* A table double: what the localize module is given, and the
132
+ * locale it is set to. */
133
+ function table()
134
+ {
135
+ const installed = {};
136
+ const tb = {
137
+ options: {},
138
+ installed,
139
+ locale: null,
140
+ modules: {localize: {installLang: (name, strings) => {
141
+ installed[name] = strings;
142
+ }}},
143
+ setLocale: (name) => {
144
+ tb.locale = name;
145
+ },
146
+ getColumns: () => [],
147
+ element: element(null),
148
+ };
149
+ return tb;
150
+ }
151
+
152
+ it("hands the column titles to the locale, where Tabulator binds them", () => {
153
+ const tb = table();
154
+ yui_tabulator_relocalize(tb, t, {device_id: "Id dispositivo", value: "Valor"});
155
+ expect(tb.locale).toBeTruthy();
156
+ expect(tb.installed[tb.locale].columns).toEqual(
157
+ {device_id: "Id dispositivo", value: "Valor"});
158
+ expect(tb.installed[tb.locale].pagination).toBeTruthy();
159
+ });
160
+
161
+ it("without titles, the locale carries none, as before", () => {
162
+ const tb = table();
163
+ yui_tabulator_relocalize(tb, t);
164
+ expect(tb.installed[tb.locale].columns).toBe(undefined);
165
+ });
166
+ });
package/src/yui_text.js CHANGED
@@ -36,9 +36,19 @@ export function text_node(value)
36
36
  * Tabulator puts a STRING title in with innerHTML, so a title taken
37
37
  * from a schema (`header`, which the schema editor writes) is markup
38
38
  * unless the column carries a formatter that returns a Node.
39
+ *
40
+ * And it APPENDS that Node to the title's holder, which it never
41
+ * empties -- while it runs the formatter again on every setLocale()
42
+ * (yui_tabulator_relocalize()). Until 7.26.40 each change of language
43
+ * added one more copy of every title ("Id dispositivoId dispositivo").
44
+ * The holder (the mock cell's element) is emptied first.
39
45
  ************************************************************/
40
46
  export function text_title_formatter(cell)
41
47
  {
48
+ const $holder = (typeof cell.getElement === "function")? cell.getElement() : null;
49
+ if($holder) {
50
+ $holder.textContent = "";
51
+ }
42
52
  return text_node(cell.getValue());
43
53
  }
44
54
 
@@ -48,6 +48,19 @@ describe("text_node()", () => {
48
48
  expect($title.textContent).toBe(EVIL);
49
49
  });
50
50
 
51
+ test("a title formatted again (every setLocale) is not appended to the old one", () => {
52
+ /* What Tabulator does: it appends the Node into the title's
53
+ * holder, and on a change of locale runs the formatter again
54
+ * on the same holder. */
55
+ const $holder = document.createElement("div");
56
+ const cell = (title) => ({getValue: () => title, getElement: () => $holder});
57
+ $holder.appendChild(text_title_formatter(cell("Id dispositivo")));
58
+ $holder.appendChild(text_title_formatter(cell("Device id")));
59
+ $holder.appendChild(text_title_formatter(cell("Id dispositivo")));
60
+ expect($holder.textContent).toBe("Id dispositivo");
61
+ expect($holder.childNodes.length).toBe(1);
62
+ });
63
+
51
64
  test("the accordion nav shows a section named by a backend as text", async () => {
52
65
  const i18next = (await import("i18next")).default;
53
66
  await i18next.init({lng: "en", resources: {}});