@yuneta/gobj-ui 7.25.1 → 7.25.2

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
@@ -783,6 +783,12 @@ answer EVERY way the write can end: refused before it left (read-only, no
783
783
  session) as well as answered by the backend, or the form waits forever. A host
784
784
  that does not answer leaves the attribute off, and the form closes when the
785
785
  write is published, as before 7.23.193 — whatever the backend says.
786
+ `C_YUI_TREEDB_TOPICS` answers them all since 7.25.2: a write sent with no
787
+ session is refused before it is sent (the command would never leave, and
788
+ `C_IEVENT_CLI` answers `null` for that as for a command that went), and the
789
+ writes in flight when the transport closes are answered refused on the
790
+ transport edge (`form_writes_in_flight.js`), so the form comes back on what
791
+ was typed instead of staying busy for ever.
786
792
 
787
793
  ```js
788
794
  // A host of C_YUI_TREEDB_TOPIC_WITH_FORM that answers the form's writes
@@ -23021,6 +23021,53 @@ function yui_shell_show_route_map(shell, opts) {
23021
23021
  return modal_ref.modal;
23022
23022
  }
23023
23023
  //#endregion
23024
+ //#region src/form_writes_in_flight.js
23025
+ /***********************************************************************
23026
+ * form_writes_in_flight.js
23027
+ *
23028
+ * The writes of topic forms a C_YUI_TREEDB_TOPICS view has sent
23029
+ * and not yet answered, by their `form_write` serial. A form that
23030
+ * waits for its answer (form_waits_for_answer) stays open and
23031
+ * busy until the host answers, so the host has to answer EVERY
23032
+ * way a write ends -- and the transport closing under a write in
23033
+ * flight, or a command that never left because there was no
23034
+ * session, are two of them that nobody answered: the form stayed
23035
+ * busy for ever (N8 of the 2026-09-22 review).
23036
+ *
23037
+ * Copyright (c) 2026, ArtGins.
23038
+ * All Rights Reserved.
23039
+ ***********************************************************************/
23040
+ /************************************************************
23041
+ * A write left: remember whose it is
23042
+ ************************************************************/
23043
+ function track_form_write(in_flight, form_write, topic_name) {
23044
+ if (!in_flight || !form_write) return;
23045
+ in_flight[form_write] = topic_name || "";
23046
+ }
23047
+ /************************************************************
23048
+ * A write was answered, one way or the other
23049
+ ************************************************************/
23050
+ function settle_form_write(in_flight, form_write) {
23051
+ if (!in_flight || !form_write) return;
23052
+ delete in_flight[form_write];
23053
+ }
23054
+ /************************************************************
23055
+ * Nothing in flight can be answered any more: take them all,
23056
+ * as [{form_write, topic_name}], and leave the map empty.
23057
+ ************************************************************/
23058
+ function abandon_form_writes(in_flight) {
23059
+ const abandoned = [];
23060
+ if (!in_flight) return abandoned;
23061
+ Object.keys(in_flight).forEach((serial) => {
23062
+ abandoned.push({
23063
+ form_write: Number(serial),
23064
+ topic_name: in_flight[serial]
23065
+ });
23066
+ delete in_flight[serial];
23067
+ });
23068
+ return abandoned;
23069
+ }
23070
+ //#endregion
23024
23071
  //#region src/nodes_answer.js
23025
23072
  /***********************************************************************
23026
23073
  * nodes_answer.js
@@ -23587,6 +23634,7 @@ var __gclass__$6 = null;
23587
23634
  * Framework Method: Create
23588
23635
  ***************************************************************/
23589
23636
  function mt_create$6(gobj) {
23637
+ gobj.priv.form_writes = {};
23590
23638
  build_ui$6(gobj);
23591
23639
  let subscriber = (0, _yuneta_gobj_js.gobj_read_pointer_attr)(gobj, "subscriber");
23592
23640
  if (!subscriber) subscriber = (0, _yuneta_gobj_js.gobj_parent)(gobj);
@@ -24743,6 +24791,7 @@ function treedb_update_node$1(gobj, treedb_name, topic_name, record, options, fo
24743
24791
  (0, _yuneta_gobj_js.log_error)(ret);
24744
24792
  return -1;
24745
24793
  }
24794
+ track_form_write(gobj.priv.form_writes, form_write, topic_name);
24746
24795
  return 0;
24747
24796
  }
24748
24797
  /************************************************************
@@ -25006,6 +25055,7 @@ function ac_mt_command_answer$2(gobj, event, kw, src) {
25006
25055
  if (command === "update-node" || command === "create-node") {
25007
25056
  let failed_topic = (0, _yuneta_gobj_js.kw_get_str)(gobj, kw_command, "topic_name", "", 0);
25008
25057
  if (failed_topic) (0, _yuneta_gobj_js.gobj_send_event)(gobj, "EV_REFRESH_TOPIC", { topic_name: failed_topic }, gobj);
25058
+ settle_form_write(gobj.priv.form_writes, (0, _yuneta_gobj_js.kw_get_int)(gobj, kw_command, "form_write", 0, 0));
25009
25059
  answer_form_write(gobj, get_gobj_formtable(gobj, failed_topic), (0, _yuneta_gobj_js.kw_get_int)(gobj, kw_command, "form_write", 0, 0), false);
25010
25060
  }
25011
25061
  }
@@ -25043,6 +25093,7 @@ function ac_mt_command_answer$2(gobj, event, kw, src) {
25043
25093
  created: command === "create-node",
25044
25094
  command
25045
25095
  });
25096
+ settle_form_write(gobj.priv.form_writes, (0, _yuneta_gobj_js.kw_get_int)(gobj, kw_command, "form_write", 0, 0));
25046
25097
  answer_form_write(gobj, get_gobj_formtable(gobj, (0, _yuneta_gobj_js.kw_get_str)(gobj, kw_command, "topic_name", "", 0)), (0, _yuneta_gobj_js.kw_get_int)(gobj, kw_command, "form_write", 0, 0), true);
25047
25098
  break;
25048
25099
  case "delete-node": break;
@@ -25193,7 +25244,12 @@ function ac_back_to_topics(gobj, event, kw, src) {
25193
25244
  * the session).
25194
25245
  ************************************************************/
25195
25246
  function ac_transport_state$2(gobj, event, kw, src) {
25196
- refresh_toolbar_buttons(gobj, !!(kw && kw.connected));
25247
+ let connected = !!(kw && kw.connected);
25248
+ refresh_toolbar_buttons(gobj, connected);
25249
+ if (!connected) abandon_form_writes(gobj.priv.form_writes).forEach((w) => {
25250
+ (0, _yuneta_gobj_js.log_warning)(`${(0, _yuneta_gobj_js.gobj_short_name)(gobj)}: transport closed, the write ${w.form_write} of '${w.topic_name}' is lost`);
25251
+ answer_form_write(gobj, get_gobj_formtable(gobj, w.topic_name), w.form_write, false);
25252
+ });
25197
25253
  return 0;
25198
25254
  }
25199
25255
  /************************************************************
@@ -25267,6 +25323,10 @@ function ac_create_record(gobj, event, kw, src) {
25267
25323
  let treedb_name = (0, _yuneta_gobj_js.gobj_read_str_attr)(gobj, "treedb_name");
25268
25324
  let topic_name = (0, _yuneta_gobj_js.gobj_read_attr)(src, "topic_name");
25269
25325
  let record = kw.record;
25326
+ if (refuse_without_session(gobj, topic_name)) {
25327
+ answer_form_write(gobj, src, form_write, false);
25328
+ return -1;
25329
+ }
25270
25330
  if (treedb_update_node$1(gobj, treedb_name, topic_name, record, {
25271
25331
  list_dict: true,
25272
25332
  create: true,
@@ -25434,6 +25494,10 @@ function ac_update_record(gobj, event, kw, src) {
25434
25494
  let treedb_name = (0, _yuneta_gobj_js.gobj_read_str_attr)(gobj, "treedb_name");
25435
25495
  let topic_name = (0, _yuneta_gobj_js.gobj_read_attr)(src, "topic_name");
25436
25496
  let record = kw.record;
25497
+ if (refuse_without_session(gobj, topic_name)) {
25498
+ answer_form_write(gobj, src, form_write, false);
25499
+ return -1;
25500
+ }
25437
25501
  if (treedb_update_node$1(gobj, treedb_name, topic_name, record, {
25438
25502
  list_dict: true,
25439
25503
  autolink: true
@@ -25444,6 +25508,18 @@ function ac_update_record(gobj, event, kw, src) {
25444
25508
  return 0;
25445
25509
  }
25446
25510
  /************************************************************
25511
+ * A write sent with no session never leaves: C_IEVENT_CLI logs
25512
+ * "Not in session" and answers null, as it does when the command
25513
+ * went -- so a form waiting for the answer waited for ever (N8 of
25514
+ * the 2026-09-22 review). Refused before it is sent, aloud.
25515
+ ************************************************************/
25516
+ function refuse_without_session(gobj, topic_name) {
25517
+ if (is_connected$1(gobj)) return false;
25518
+ (0, _yuneta_gobj_js.log_error)(`${(0, _yuneta_gobj_js.gobj_short_name)(gobj)}: no session, the write of '${topic_name}' was not sent`);
25519
+ yui_shell_show_error(yui_shell_of(gobj), "no session", { t: i18next.t });
25520
+ return true;
25521
+ }
25522
+ /************************************************************
25447
25523
  * Tell a topic form how its write ended: EV_WRITE_DONE closes
25448
25524
  * it, EV_WRITE_REFUSED leaves it open on what was typed. Every
25449
25525
  * way a form's write can end passes here -- refused before it
@@ -26511,6 +26587,49 @@ function delete_impact(desc, records) {
26511
26587
  return out;
26512
26588
  }
26513
26589
  //#endregion
26590
+ //#region src/form_busy.js
26591
+ /***********************************************************************
26592
+ * form_busy.js
26593
+ *
26594
+ * The toolbar of a topic form while a write is in flight: every
26595
+ * button disabled, the save button spinning. Bulma's `is-loading`
26596
+ * is the spinner; `disabled` is what stops the second click.
26597
+ *
26598
+ * A transition, not a setting: each button remembers whether it
26599
+ * was disabled BEFORE the toolbar went busy, and busy(false) puts
26600
+ * that back. So a second busy(true) while already busy must not
26601
+ * run at all -- it read the buttons it had just disabled as
26602
+ * "disabled on their own", and the busy(false) of a refused write
26603
+ * left Save and Cancel dead (N9 of the 2026-09-22 review: a record
26604
+ * with a picked file went busy for the read, then busy again for
26605
+ * the write). The caller owns `state` ({busy}) for the form's life.
26606
+ *
26607
+ * Copyright (c) 2026, ArtGins.
26608
+ * All Rights Reserved.
26609
+ ***********************************************************************/
26610
+ /************************************************************
26611
+ * Move the toolbar to `busy`. Returns true when it moved, false when
26612
+ * it was already there (nothing touched).
26613
+ ************************************************************/
26614
+ function set_toolbar_busy($container, state, busy) {
26615
+ busy = !!busy;
26616
+ if (!$container || !state) return false;
26617
+ if (!!state.busy === busy) return false;
26618
+ state.busy = busy;
26619
+ $container.querySelectorAll(".yui-toolbar-form button").forEach(($b) => {
26620
+ if (busy) {
26621
+ $b.dataset.was_disabled = $b.disabled ? "1" : "0";
26622
+ $b.disabled = true;
26623
+ } else if ($b.dataset.was_disabled !== void 0) {
26624
+ $b.disabled = $b.dataset.was_disabled === "1";
26625
+ delete $b.dataset.was_disabled;
26626
+ }
26627
+ });
26628
+ const $save = $container.querySelector(".yui-toolbar-form .button-save");
26629
+ if ($save) $save.classList.toggle("is-loading", busy);
26630
+ return true;
26631
+ }
26632
+ //#endregion
26514
26633
  //#region src/table_cell_text.js
26515
26634
  /****************************************************************************
26516
26635
  * table_cell_text.js
@@ -26795,6 +26914,7 @@ function mt_create$5(gobj) {
26795
26914
  gobj.priv.read_serial = 0;
26796
26915
  gobj.priv.awaiting_write = 0;
26797
26916
  gobj.priv.write_serial = 0;
26917
+ gobj.priv.form_busy = { busy: false };
26798
26918
  let name = (0, _yuneta_gobj_js.clean_name)((0, _yuneta_gobj_js.gobj_name)(gobj));
26799
26919
  (0, _yuneta_gobj_js.gobj_write_attr)(gobj, "table_id", "table" + name);
26800
26920
  (0, _yuneta_gobj_js.gobj_write_attr)(gobj, "toolbar_id", "toolbar" + name);
@@ -28198,6 +28318,7 @@ function teardown_form_child(gobj) {
28198
28318
  let priv = gobj.priv;
28199
28319
  priv.reading_files = 0;
28200
28320
  priv.awaiting_write = 0;
28321
+ priv.form_busy = { busy: false };
28201
28322
  if (priv.form) {
28202
28323
  if ((0, _yuneta_gobj_js.gobj_is_running)(priv.form)) (0, _yuneta_gobj_js.gobj_stop)(priv.form);
28203
28324
  (0, _yuneta_gobj_js.gobj_destroy)(priv.form);
@@ -29422,26 +29543,16 @@ function ac_form_save_record(gobj, event, kw, src) {
29422
29543
  return 0;
29423
29544
  }
29424
29545
  /************************************************************
29425
- * The form's toolbar while a read is in flight: every button
29426
- * disabled, the save button spinning. Bulma's `is-loading` is the
29427
- * spinner; `disabled` is what stops the second click.
29546
+ * The form's toolbar while a read or a write is in flight: every
29547
+ * button disabled, the save button spinning (set_toolbar_busy). A
29548
+ * transition: busy(true) while busy touches nothing, or a record
29549
+ * with a picked file -- busy for the read, busy again for the
29550
+ * write -- came back from a refusal with Save and Cancel dead.
29428
29551
  ************************************************************/
29429
29552
  function set_form_busy(gobj, busy) {
29430
29553
  let priv = gobj.priv;
29431
29554
  if (!priv.form) return;
29432
- const $container = (0, _yuneta_gobj_js.gobj_read_attr)(priv.form, "$container");
29433
- if (!$container) return;
29434
- $container.querySelectorAll(".yui-toolbar-form button").forEach(($b) => {
29435
- if (busy) {
29436
- $b.dataset.was_disabled = $b.disabled ? "1" : "0";
29437
- $b.disabled = true;
29438
- } else if ($b.dataset.was_disabled !== void 0) {
29439
- $b.disabled = $b.dataset.was_disabled === "1";
29440
- delete $b.dataset.was_disabled;
29441
- }
29442
- });
29443
- const $save = $container.querySelector(".yui-toolbar-form .button-save");
29444
- if ($save) $save.classList.toggle("is-loading", !!busy);
29555
+ set_toolbar_busy((0, _yuneta_gobj_js.gobj_read_attr)(priv.form, "$container"), priv.form_busy, busy);
29445
29556
  }
29446
29557
  /************************************************************
29447
29558
  * Is this the read the view is waiting for? A read whose form was
@@ -23016,6 +23016,53 @@ function yui_shell_show_route_map(shell, opts) {
23016
23016
  return modal_ref.modal;
23017
23017
  }
23018
23018
  //#endregion
23019
+ //#region src/form_writes_in_flight.js
23020
+ /***********************************************************************
23021
+ * form_writes_in_flight.js
23022
+ *
23023
+ * The writes of topic forms a C_YUI_TREEDB_TOPICS view has sent
23024
+ * and not yet answered, by their `form_write` serial. A form that
23025
+ * waits for its answer (form_waits_for_answer) stays open and
23026
+ * busy until the host answers, so the host has to answer EVERY
23027
+ * way a write ends -- and the transport closing under a write in
23028
+ * flight, or a command that never left because there was no
23029
+ * session, are two of them that nobody answered: the form stayed
23030
+ * busy for ever (N8 of the 2026-09-22 review).
23031
+ *
23032
+ * Copyright (c) 2026, ArtGins.
23033
+ * All Rights Reserved.
23034
+ ***********************************************************************/
23035
+ /************************************************************
23036
+ * A write left: remember whose it is
23037
+ ************************************************************/
23038
+ function track_form_write(in_flight, form_write, topic_name) {
23039
+ if (!in_flight || !form_write) return;
23040
+ in_flight[form_write] = topic_name || "";
23041
+ }
23042
+ /************************************************************
23043
+ * A write was answered, one way or the other
23044
+ ************************************************************/
23045
+ function settle_form_write(in_flight, form_write) {
23046
+ if (!in_flight || !form_write) return;
23047
+ delete in_flight[form_write];
23048
+ }
23049
+ /************************************************************
23050
+ * Nothing in flight can be answered any more: take them all,
23051
+ * as [{form_write, topic_name}], and leave the map empty.
23052
+ ************************************************************/
23053
+ function abandon_form_writes(in_flight) {
23054
+ const abandoned = [];
23055
+ if (!in_flight) return abandoned;
23056
+ Object.keys(in_flight).forEach((serial) => {
23057
+ abandoned.push({
23058
+ form_write: Number(serial),
23059
+ topic_name: in_flight[serial]
23060
+ });
23061
+ delete in_flight[serial];
23062
+ });
23063
+ return abandoned;
23064
+ }
23065
+ //#endregion
23019
23066
  //#region src/nodes_answer.js
23020
23067
  /***********************************************************************
23021
23068
  * nodes_answer.js
@@ -23582,6 +23629,7 @@ var __gclass__$6 = null;
23582
23629
  * Framework Method: Create
23583
23630
  ***************************************************************/
23584
23631
  function mt_create$6(gobj) {
23632
+ gobj.priv.form_writes = {};
23585
23633
  build_ui$6(gobj);
23586
23634
  let subscriber = gobj_read_pointer_attr(gobj, "subscriber");
23587
23635
  if (!subscriber) subscriber = gobj_parent(gobj);
@@ -24738,6 +24786,7 @@ function treedb_update_node$1(gobj, treedb_name, topic_name, record, options, fo
24738
24786
  log_error(ret);
24739
24787
  return -1;
24740
24788
  }
24789
+ track_form_write(gobj.priv.form_writes, form_write, topic_name);
24741
24790
  return 0;
24742
24791
  }
24743
24792
  /************************************************************
@@ -25001,6 +25050,7 @@ function ac_mt_command_answer$2(gobj, event, kw, src) {
25001
25050
  if (command === "update-node" || command === "create-node") {
25002
25051
  let failed_topic = kw_get_str(gobj, kw_command, "topic_name", "", 0);
25003
25052
  if (failed_topic) gobj_send_event(gobj, "EV_REFRESH_TOPIC", { topic_name: failed_topic }, gobj);
25053
+ settle_form_write(gobj.priv.form_writes, kw_get_int(gobj, kw_command, "form_write", 0, 0));
25004
25054
  answer_form_write(gobj, get_gobj_formtable(gobj, failed_topic), kw_get_int(gobj, kw_command, "form_write", 0, 0), false);
25005
25055
  }
25006
25056
  }
@@ -25038,6 +25088,7 @@ function ac_mt_command_answer$2(gobj, event, kw, src) {
25038
25088
  created: command === "create-node",
25039
25089
  command
25040
25090
  });
25091
+ settle_form_write(gobj.priv.form_writes, kw_get_int(gobj, kw_command, "form_write", 0, 0));
25041
25092
  answer_form_write(gobj, get_gobj_formtable(gobj, kw_get_str(gobj, kw_command, "topic_name", "", 0)), kw_get_int(gobj, kw_command, "form_write", 0, 0), true);
25042
25093
  break;
25043
25094
  case "delete-node": break;
@@ -25188,7 +25239,12 @@ function ac_back_to_topics(gobj, event, kw, src) {
25188
25239
  * the session).
25189
25240
  ************************************************************/
25190
25241
  function ac_transport_state$2(gobj, event, kw, src) {
25191
- refresh_toolbar_buttons(gobj, !!(kw && kw.connected));
25242
+ let connected = !!(kw && kw.connected);
25243
+ refresh_toolbar_buttons(gobj, connected);
25244
+ if (!connected) abandon_form_writes(gobj.priv.form_writes).forEach((w) => {
25245
+ log_warning(`${gobj_short_name(gobj)}: transport closed, the write ${w.form_write} of '${w.topic_name}' is lost`);
25246
+ answer_form_write(gobj, get_gobj_formtable(gobj, w.topic_name), w.form_write, false);
25247
+ });
25192
25248
  return 0;
25193
25249
  }
25194
25250
  /************************************************************
@@ -25262,6 +25318,10 @@ function ac_create_record(gobj, event, kw, src) {
25262
25318
  let treedb_name = gobj_read_str_attr(gobj, "treedb_name");
25263
25319
  let topic_name = gobj_read_attr(src, "topic_name");
25264
25320
  let record = kw.record;
25321
+ if (refuse_without_session(gobj, topic_name)) {
25322
+ answer_form_write(gobj, src, form_write, false);
25323
+ return -1;
25324
+ }
25265
25325
  if (treedb_update_node$1(gobj, treedb_name, topic_name, record, {
25266
25326
  list_dict: true,
25267
25327
  create: true,
@@ -25429,6 +25489,10 @@ function ac_update_record(gobj, event, kw, src) {
25429
25489
  let treedb_name = gobj_read_str_attr(gobj, "treedb_name");
25430
25490
  let topic_name = gobj_read_attr(src, "topic_name");
25431
25491
  let record = kw.record;
25492
+ if (refuse_without_session(gobj, topic_name)) {
25493
+ answer_form_write(gobj, src, form_write, false);
25494
+ return -1;
25495
+ }
25432
25496
  if (treedb_update_node$1(gobj, treedb_name, topic_name, record, {
25433
25497
  list_dict: true,
25434
25498
  autolink: true
@@ -25439,6 +25503,18 @@ function ac_update_record(gobj, event, kw, src) {
25439
25503
  return 0;
25440
25504
  }
25441
25505
  /************************************************************
25506
+ * A write sent with no session never leaves: C_IEVENT_CLI logs
25507
+ * "Not in session" and answers null, as it does when the command
25508
+ * went -- so a form waiting for the answer waited for ever (N8 of
25509
+ * the 2026-09-22 review). Refused before it is sent, aloud.
25510
+ ************************************************************/
25511
+ function refuse_without_session(gobj, topic_name) {
25512
+ if (is_connected$1(gobj)) return false;
25513
+ log_error(`${gobj_short_name(gobj)}: no session, the write of '${topic_name}' was not sent`);
25514
+ yui_shell_show_error(yui_shell_of(gobj), "no session", { t });
25515
+ return true;
25516
+ }
25517
+ /************************************************************
25442
25518
  * Tell a topic form how its write ended: EV_WRITE_DONE closes
25443
25519
  * it, EV_WRITE_REFUSED leaves it open on what was typed. Every
25444
25520
  * way a form's write can end passes here -- refused before it
@@ -26506,6 +26582,49 @@ function delete_impact(desc, records) {
26506
26582
  return out;
26507
26583
  }
26508
26584
  //#endregion
26585
+ //#region src/form_busy.js
26586
+ /***********************************************************************
26587
+ * form_busy.js
26588
+ *
26589
+ * The toolbar of a topic form while a write is in flight: every
26590
+ * button disabled, the save button spinning. Bulma's `is-loading`
26591
+ * is the spinner; `disabled` is what stops the second click.
26592
+ *
26593
+ * A transition, not a setting: each button remembers whether it
26594
+ * was disabled BEFORE the toolbar went busy, and busy(false) puts
26595
+ * that back. So a second busy(true) while already busy must not
26596
+ * run at all -- it read the buttons it had just disabled as
26597
+ * "disabled on their own", and the busy(false) of a refused write
26598
+ * left Save and Cancel dead (N9 of the 2026-09-22 review: a record
26599
+ * with a picked file went busy for the read, then busy again for
26600
+ * the write). The caller owns `state` ({busy}) for the form's life.
26601
+ *
26602
+ * Copyright (c) 2026, ArtGins.
26603
+ * All Rights Reserved.
26604
+ ***********************************************************************/
26605
+ /************************************************************
26606
+ * Move the toolbar to `busy`. Returns true when it moved, false when
26607
+ * it was already there (nothing touched).
26608
+ ************************************************************/
26609
+ function set_toolbar_busy($container, state, busy) {
26610
+ busy = !!busy;
26611
+ if (!$container || !state) return false;
26612
+ if (!!state.busy === busy) return false;
26613
+ state.busy = busy;
26614
+ $container.querySelectorAll(".yui-toolbar-form button").forEach(($b) => {
26615
+ if (busy) {
26616
+ $b.dataset.was_disabled = $b.disabled ? "1" : "0";
26617
+ $b.disabled = true;
26618
+ } else if ($b.dataset.was_disabled !== void 0) {
26619
+ $b.disabled = $b.dataset.was_disabled === "1";
26620
+ delete $b.dataset.was_disabled;
26621
+ }
26622
+ });
26623
+ const $save = $container.querySelector(".yui-toolbar-form .button-save");
26624
+ if ($save) $save.classList.toggle("is-loading", busy);
26625
+ return true;
26626
+ }
26627
+ //#endregion
26509
26628
  //#region src/table_cell_text.js
26510
26629
  /****************************************************************************
26511
26630
  * table_cell_text.js
@@ -26790,6 +26909,7 @@ function mt_create$5(gobj) {
26790
26909
  gobj.priv.read_serial = 0;
26791
26910
  gobj.priv.awaiting_write = 0;
26792
26911
  gobj.priv.write_serial = 0;
26912
+ gobj.priv.form_busy = { busy: false };
26793
26913
  let name = clean_name(gobj_name(gobj));
26794
26914
  gobj_write_attr(gobj, "table_id", "table" + name);
26795
26915
  gobj_write_attr(gobj, "toolbar_id", "toolbar" + name);
@@ -28193,6 +28313,7 @@ function teardown_form_child(gobj) {
28193
28313
  let priv = gobj.priv;
28194
28314
  priv.reading_files = 0;
28195
28315
  priv.awaiting_write = 0;
28316
+ priv.form_busy = { busy: false };
28196
28317
  if (priv.form) {
28197
28318
  if (gobj_is_running(priv.form)) gobj_stop(priv.form);
28198
28319
  gobj_destroy(priv.form);
@@ -29417,26 +29538,16 @@ function ac_form_save_record(gobj, event, kw, src) {
29417
29538
  return 0;
29418
29539
  }
29419
29540
  /************************************************************
29420
- * The form's toolbar while a read is in flight: every button
29421
- * disabled, the save button spinning. Bulma's `is-loading` is the
29422
- * spinner; `disabled` is what stops the second click.
29541
+ * The form's toolbar while a read or a write is in flight: every
29542
+ * button disabled, the save button spinning (set_toolbar_busy). A
29543
+ * transition: busy(true) while busy touches nothing, or a record
29544
+ * with a picked file -- busy for the read, busy again for the
29545
+ * write -- came back from a refusal with Save and Cancel dead.
29423
29546
  ************************************************************/
29424
29547
  function set_form_busy(gobj, busy) {
29425
29548
  let priv = gobj.priv;
29426
29549
  if (!priv.form) return;
29427
- const $container = gobj_read_attr(priv.form, "$container");
29428
- if (!$container) return;
29429
- $container.querySelectorAll(".yui-toolbar-form button").forEach(($b) => {
29430
- if (busy) {
29431
- $b.dataset.was_disabled = $b.disabled ? "1" : "0";
29432
- $b.disabled = true;
29433
- } else if ($b.dataset.was_disabled !== void 0) {
29434
- $b.disabled = $b.dataset.was_disabled === "1";
29435
- delete $b.dataset.was_disabled;
29436
- }
29437
- });
29438
- const $save = $container.querySelector(".yui-toolbar-form .button-save");
29439
- if ($save) $save.classList.toggle("is-loading", !!busy);
29550
+ set_toolbar_busy(gobj_read_attr(priv.form, "$container"), priv.form_busy, busy);
29440
29551
  }
29441
29552
  /************************************************************
29442
29553
  * Is this the read the view is waiting for? A read whose form was
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuneta/gobj-ui",
3
- "version": "7.25.1",
3
+ "version": "7.25.2",
4
4
  "type": "module",
5
5
  "main": "dist/gobj-ui.cjs.js",
6
6
  "module": "dist/gobj-ui.es.js",
@@ -110,6 +110,7 @@ import {
110
110
  import {delete_impact} from "./delete_impact.js";
111
111
 
112
112
  import "./c_yui_treedb_topic_with_form.css";
113
+ import {set_toolbar_busy} from "./form_busy.js";
113
114
  import "./tabulator.css";
114
115
  import {cell_text, hook_cell_spec} from "./table_cell_text.js";
115
116
 
@@ -432,6 +433,8 @@ function mt_create(gobj)
432
433
  * serial the host echoes in EV_WRITE_DONE / EV_WRITE_REFUSED. */
433
434
  gobj.priv.awaiting_write = 0;
434
435
  gobj.priv.write_serial = 0;
436
+ /* The busy transition of the form's toolbar (set_toolbar_busy) */
437
+ gobj.priv.form_busy = {busy: false};
435
438
 
436
439
  let name = clean_name(gobj_name(gobj));
437
440
  gobj_write_attr(gobj, "table_id", "table" + name);
@@ -2503,6 +2506,7 @@ function teardown_form_child(gobj)
2503
2506
  let priv = gobj.priv;
2504
2507
  priv.reading_files = 0; // a read in flight lands on nobody
2505
2508
  priv.awaiting_write = 0; // and so does the answer of a write
2509
+ priv.form_busy = {busy: false}; // its toolbar goes with it
2506
2510
  if(priv.form) {
2507
2511
  if(gobj_is_running(priv.form)) {
2508
2512
  gobj_stop(priv.form);
@@ -4261,9 +4265,11 @@ function ac_form_save_record(gobj, event, kw, src)
4261
4265
  }
4262
4266
 
4263
4267
  /************************************************************
4264
- * The form's toolbar while a read is in flight: every button
4265
- * disabled, the save button spinning. Bulma's `is-loading` is the
4266
- * spinner; `disabled` is what stops the second click.
4268
+ * The form's toolbar while a read or a write is in flight: every
4269
+ * button disabled, the save button spinning (set_toolbar_busy). A
4270
+ * transition: busy(true) while busy touches nothing, or a record
4271
+ * with a picked file -- busy for the read, busy again for the
4272
+ * write -- came back from a refusal with Save and Cancel dead.
4267
4273
  ************************************************************/
4268
4274
  function set_form_busy(gobj, busy)
4269
4275
  {
@@ -4271,23 +4277,7 @@ function set_form_busy(gobj, busy)
4271
4277
  if(!priv.form) {
4272
4278
  return;
4273
4279
  }
4274
- const $container = gobj_read_attr(priv.form, "$container");
4275
- if(!$container) {
4276
- return;
4277
- }
4278
- $container.querySelectorAll('.yui-toolbar-form button').forEach(($b) => {
4279
- if(busy) {
4280
- $b.dataset.was_disabled = $b.disabled? "1": "0";
4281
- $b.disabled = true;
4282
- } else if($b.dataset.was_disabled !== undefined) {
4283
- $b.disabled = ($b.dataset.was_disabled === "1");
4284
- delete $b.dataset.was_disabled;
4285
- }
4286
- });
4287
- const $save = $container.querySelector('.yui-toolbar-form .button-save');
4288
- if($save) {
4289
- $save.classList.toggle('is-loading', !!busy);
4290
- }
4280
+ set_toolbar_busy(gobj_read_attr(priv.form, "$container"), priv.form_busy, busy);
4291
4281
  }
4292
4282
 
4293
4283
  /************************************************************
@@ -57,6 +57,11 @@ import {
57
57
  } from "@yuneta/gobj-js";
58
58
 
59
59
  import "./c_yui_treedb_topics.css";
60
+ import {
61
+ track_form_write,
62
+ settle_form_write,
63
+ abandon_form_writes,
64
+ } from "./form_writes_in_flight.js";
60
65
 
61
66
  import {yui_shell_show_error, yui_shell_show_modal, yui_shell_popup_layer} from "./shell_modals.js";
62
67
  import {
@@ -151,6 +156,11 @@ let __gclass__ = null;
151
156
  ***************************************************************/
152
157
  function mt_create(gobj)
153
158
  {
159
+ /* The writes of topic forms sent and not yet answered, by serial
160
+ * (form_writes_in_flight): answered refused when the transport
161
+ * closes under them. */
162
+ gobj.priv.form_writes = {};
163
+
154
164
  build_ui(gobj);
155
165
 
156
166
  /*
@@ -1655,6 +1665,7 @@ function treedb_update_node(gobj, treedb_name, topic_name, record, options, form
1655
1665
  log_error(ret);
1656
1666
  return -1;
1657
1667
  }
1668
+ track_form_write(gobj.priv.form_writes, form_write, topic_name);
1658
1669
  return 0;
1659
1670
  }
1660
1671
 
@@ -2065,6 +2076,8 @@ function ac_mt_command_answer(gobj, event, kw, src)
2065
2076
  gobj_send_event(gobj, "EV_REFRESH_TOPIC",
2066
2077
  {topic_name: failed_topic}, gobj);
2067
2078
  }
2079
+ settle_form_write(gobj.priv.form_writes,
2080
+ kw_get_int(gobj, kw_command, "form_write", 0, 0));
2068
2081
  answer_form_write(
2069
2082
  gobj,
2070
2083
  get_gobj_formtable(gobj, failed_topic),
@@ -2163,6 +2176,8 @@ function ac_mt_command_answer(gobj, event, kw, src)
2163
2176
  created: (command === "create-node"),
2164
2177
  command: command
2165
2178
  });
2179
+ settle_form_write(gobj.priv.form_writes,
2180
+ kw_get_int(gobj, kw_command, "form_write", 0, 0));
2166
2181
  answer_form_write(
2167
2182
  gobj,
2168
2183
  get_gobj_formtable(gobj, kw_get_str(gobj, kw_command, "topic_name", "", 0)),
@@ -2406,7 +2421,26 @@ function ac_back_to_topics(gobj, event, kw, src)
2406
2421
  ************************************************************/
2407
2422
  function ac_transport_state(gobj, event, kw, src)
2408
2423
  {
2409
- refresh_toolbar_buttons(gobj, !!(kw && kw.connected));
2424
+ let connected = !!(kw && kw.connected);
2425
+ refresh_toolbar_buttons(gobj, connected);
2426
+
2427
+ /*
2428
+ * A write in flight when the transport closes is never answered by
2429
+ * the backend: its form stayed open and busy for ever (N8 of the
2430
+ * 2026-09-22 review). Answered refused: the form stays open on what
2431
+ * was typed, and the person saves again when the session is back.
2432
+ */
2433
+ if(!connected) {
2434
+ abandon_form_writes(gobj.priv.form_writes).forEach((w) => {
2435
+ log_warning(`${gobj_short_name(gobj)}: transport closed, the write ${w.form_write} of '${w.topic_name}' is lost`);
2436
+ answer_form_write(
2437
+ gobj,
2438
+ get_gobj_formtable(gobj, w.topic_name),
2439
+ w.form_write,
2440
+ false
2441
+ );
2442
+ });
2443
+ }
2410
2444
  return 0;
2411
2445
  }
2412
2446
 
@@ -2522,6 +2556,10 @@ function ac_create_record(gobj, event, kw, src)
2522
2556
  let treedb_name = gobj_read_str_attr(gobj, "treedb_name");
2523
2557
  let topic_name = gobj_read_attr(src, "topic_name");
2524
2558
  let record = kw.record;
2559
+ if(refuse_without_session(gobj, topic_name)) {
2560
+ answer_form_write(gobj, src, form_write, false);
2561
+ return -1; /* Error already logged */
2562
+ }
2525
2563
 
2526
2564
  /* `create_only`: +New makes a NEW record. With `create` alone a taken
2527
2565
  * id was an update -- the record overwritten and, through autolink
@@ -2751,6 +2789,10 @@ function ac_update_record(gobj, event, kw, src)
2751
2789
  let treedb_name = gobj_read_str_attr(gobj, "treedb_name");
2752
2790
  let topic_name = gobj_read_attr(src, "topic_name");
2753
2791
  let record = kw.record;
2792
+ if(refuse_without_session(gobj, topic_name)) {
2793
+ answer_form_write(gobj, src, form_write, false);
2794
+ return -1; /* Error already logged */
2795
+ }
2754
2796
 
2755
2797
  let options = {
2756
2798
  list_dict: true,
@@ -2765,6 +2807,22 @@ function ac_update_record(gobj, event, kw, src)
2765
2807
  return 0;
2766
2808
  }
2767
2809
 
2810
+ /************************************************************
2811
+ * A write sent with no session never leaves: C_IEVENT_CLI logs
2812
+ * "Not in session" and answers null, as it does when the command
2813
+ * went -- so a form waiting for the answer waited for ever (N8 of
2814
+ * the 2026-09-22 review). Refused before it is sent, aloud.
2815
+ ************************************************************/
2816
+ function refuse_without_session(gobj, topic_name)
2817
+ {
2818
+ if(is_connected(gobj)) {
2819
+ return false;
2820
+ }
2821
+ log_error(`${gobj_short_name(gobj)}: no session, the write of '${topic_name}' was not sent`);
2822
+ yui_shell_show_error(yui_shell_of(gobj), "no session", {t: t});
2823
+ return true;
2824
+ }
2825
+
2768
2826
  /************************************************************
2769
2827
  * Tell a topic form how its write ended: EV_WRITE_DONE closes
2770
2828
  * it, EV_WRITE_REFUSED leaves it open on what was typed. Every
@@ -0,0 +1,54 @@
1
+ /***********************************************************************
2
+ * form_busy.js
3
+ *
4
+ * The toolbar of a topic form while a write is in flight: every
5
+ * button disabled, the save button spinning. Bulma's `is-loading`
6
+ * is the spinner; `disabled` is what stops the second click.
7
+ *
8
+ * A transition, not a setting: each button remembers whether it
9
+ * was disabled BEFORE the toolbar went busy, and busy(false) puts
10
+ * that back. So a second busy(true) while already busy must not
11
+ * run at all -- it read the buttons it had just disabled as
12
+ * "disabled on their own", and the busy(false) of a refused write
13
+ * left Save and Cancel dead (N9 of the 2026-09-22 review: a record
14
+ * with a picked file went busy for the read, then busy again for
15
+ * the write). The caller owns `state` ({busy}) for the form's life.
16
+ *
17
+ * Copyright (c) 2026, ArtGins.
18
+ * All Rights Reserved.
19
+ ***********************************************************************/
20
+
21
+ /************************************************************
22
+ * Move the toolbar to `busy`. Returns true when it moved, false when
23
+ * it was already there (nothing touched).
24
+ ************************************************************/
25
+ function set_toolbar_busy($container, state, busy)
26
+ {
27
+ busy = !!busy;
28
+ if(!$container || !state) {
29
+ return false;
30
+ }
31
+ if(!!state.busy === busy) {
32
+ return false;
33
+ }
34
+ state.busy = busy;
35
+
36
+ $container.querySelectorAll('.yui-toolbar-form button').forEach(($b) => {
37
+ if(busy) {
38
+ $b.dataset.was_disabled = $b.disabled? "1": "0";
39
+ $b.disabled = true;
40
+ } else if($b.dataset.was_disabled !== undefined) {
41
+ $b.disabled = ($b.dataset.was_disabled === "1");
42
+ delete $b.dataset.was_disabled;
43
+ }
44
+ });
45
+ const $save = $container.querySelector('.yui-toolbar-form .button-save');
46
+ if($save) {
47
+ $save.classList.toggle('is-loading', busy);
48
+ }
49
+ return true;
50
+ }
51
+
52
+ export {
53
+ set_toolbar_busy,
54
+ };
@@ -0,0 +1,84 @@
1
+ import {describe, it, expect} from "vitest";
2
+ import {set_toolbar_busy} from "./form_busy.js";
3
+
4
+ /* A toolbar double: the two things the helper touches on a button are
5
+ * `disabled` and `dataset`, and on the save button its class list. */
6
+ function button(cls, disabled)
7
+ {
8
+ const classes = new Set(cls.split(" "));
9
+ return {
10
+ disabled: disabled,
11
+ dataset: {},
12
+ classList: {
13
+ toggle: (c, on) => {
14
+ if(on) {
15
+ classes.add(c);
16
+ } else {
17
+ classes.delete(c);
18
+ }
19
+ },
20
+ contains: (c) => classes.has(c),
21
+ },
22
+ };
23
+ }
24
+
25
+ function toolbar()
26
+ {
27
+ const save = button("button-save", false);
28
+ const cancel = button("button-cancel", false);
29
+ const undo = button("button-undo", true); /* disabled on its own */
30
+ const buttons = [save, cancel, undo];
31
+ return {
32
+ save, cancel, undo,
33
+ $container: {
34
+ querySelectorAll: () => buttons,
35
+ querySelector: () => save,
36
+ },
37
+ };
38
+ }
39
+
40
+ describe("set_toolbar_busy", () => {
41
+
42
+ it("busy then free: every button is as it was", () => {
43
+ const tb = toolbar();
44
+ const state = {busy: false};
45
+ expect(set_toolbar_busy(tb.$container, state, true)).toBe(true);
46
+ expect(tb.save.disabled).toBe(true);
47
+ expect(tb.cancel.disabled).toBe(true);
48
+ expect(tb.save.classList.contains("is-loading")).toBe(true);
49
+ expect(set_toolbar_busy(tb.$container, state, false)).toBe(true);
50
+ expect(tb.save.disabled).toBe(false);
51
+ expect(tb.cancel.disabled).toBe(false);
52
+ expect(tb.undo.disabled).toBe(true);
53
+ expect(tb.save.classList.contains("is-loading")).toBe(false);
54
+ });
55
+
56
+ it("busy twice then free: Save and Cancel come back (N9)", () => {
57
+ /* A record with a picked file went busy for the read and busy
58
+ * again for the write; the second pass recorded the buttons the
59
+ * first had disabled as disabled on their own, and the free of a
60
+ * refused write left Save and Cancel dead. */
61
+ const tb = toolbar();
62
+ const state = {busy: false};
63
+ set_toolbar_busy(tb.$container, state, true);
64
+ expect(set_toolbar_busy(tb.$container, state, true)).toBe(false);
65
+ set_toolbar_busy(tb.$container, state, false);
66
+ expect(tb.save.disabled).toBe(false);
67
+ expect(tb.cancel.disabled).toBe(false);
68
+ expect(tb.undo.disabled).toBe(true);
69
+ expect(state.busy).toBe(false);
70
+ });
71
+
72
+ it("free while free touches nothing", () => {
73
+ const tb = toolbar();
74
+ const state = {busy: false};
75
+ expect(set_toolbar_busy(tb.$container, state, false)).toBe(false);
76
+ expect(tb.save.disabled).toBe(false);
77
+ expect(tb.undo.disabled).toBe(true);
78
+ });
79
+
80
+ it("no container, no state: nothing, and says so", () => {
81
+ expect(set_toolbar_busy(null, {busy: false}, true)).toBe(false);
82
+ expect(set_toolbar_busy(toolbar().$container, null, true)).toBe(false);
83
+ });
84
+ });
@@ -0,0 +1,60 @@
1
+ /***********************************************************************
2
+ * form_writes_in_flight.js
3
+ *
4
+ * The writes of topic forms a C_YUI_TREEDB_TOPICS view has sent
5
+ * and not yet answered, by their `form_write` serial. A form that
6
+ * waits for its answer (form_waits_for_answer) stays open and
7
+ * busy until the host answers, so the host has to answer EVERY
8
+ * way a write ends -- and the transport closing under a write in
9
+ * flight, or a command that never left because there was no
10
+ * session, are two of them that nobody answered: the form stayed
11
+ * busy for ever (N8 of the 2026-09-22 review).
12
+ *
13
+ * Copyright (c) 2026, ArtGins.
14
+ * All Rights Reserved.
15
+ ***********************************************************************/
16
+
17
+ /************************************************************
18
+ * A write left: remember whose it is
19
+ ************************************************************/
20
+ function track_form_write(in_flight, form_write, topic_name)
21
+ {
22
+ if(!in_flight || !form_write) {
23
+ return;
24
+ }
25
+ in_flight[form_write] = topic_name || "";
26
+ }
27
+
28
+ /************************************************************
29
+ * A write was answered, one way or the other
30
+ ************************************************************/
31
+ function settle_form_write(in_flight, form_write)
32
+ {
33
+ if(!in_flight || !form_write) {
34
+ return;
35
+ }
36
+ delete in_flight[form_write];
37
+ }
38
+
39
+ /************************************************************
40
+ * Nothing in flight can be answered any more: take them all,
41
+ * as [{form_write, topic_name}], and leave the map empty.
42
+ ************************************************************/
43
+ function abandon_form_writes(in_flight)
44
+ {
45
+ const abandoned = [];
46
+ if(!in_flight) {
47
+ return abandoned;
48
+ }
49
+ Object.keys(in_flight).forEach((serial) => {
50
+ abandoned.push({form_write: Number(serial), topic_name: in_flight[serial]});
51
+ delete in_flight[serial];
52
+ });
53
+ return abandoned;
54
+ }
55
+
56
+ export {
57
+ track_form_write,
58
+ settle_form_write,
59
+ abandon_form_writes,
60
+ };
@@ -0,0 +1,45 @@
1
+ import {describe, it, expect} from "vitest";
2
+ import {
3
+ track_form_write,
4
+ settle_form_write,
5
+ abandon_form_writes,
6
+ } from "./form_writes_in_flight.js";
7
+
8
+ describe("form writes in flight", () => {
9
+
10
+ it("a write answered is settled; the others stay", () => {
11
+ const in_flight = {};
12
+ track_form_write(in_flight, 1, "users");
13
+ track_form_write(in_flight, 2, "roles");
14
+ settle_form_write(in_flight, 1);
15
+ expect(Object.keys(in_flight)).toEqual(["2"]);
16
+ });
17
+
18
+ it("the transport closes: every write in flight is abandoned, once (N8)", () => {
19
+ /* The form of each one is answered EV_WRITE_REFUSED by the host
20
+ * and stays open on what was typed; before, it stayed busy for
21
+ * ever, because nobody answered a write whose answer was lost. */
22
+ const in_flight = {};
23
+ track_form_write(in_flight, 3, "users");
24
+ track_form_write(in_flight, 4, "roles");
25
+ const abandoned = abandon_form_writes(in_flight);
26
+ expect(abandoned).toEqual([
27
+ {form_write: 3, topic_name: "users"},
28
+ {form_write: 4, topic_name: "roles"},
29
+ ]);
30
+ expect(abandon_form_writes(in_flight)).toEqual([]);
31
+ });
32
+
33
+ it("a cell edited in place (no serial) is not a form write", () => {
34
+ const in_flight = {};
35
+ track_form_write(in_flight, 0, "users");
36
+ track_form_write(in_flight, undefined, "users");
37
+ expect(abandon_form_writes(in_flight)).toEqual([]);
38
+ });
39
+
40
+ it("no map: nothing to do", () => {
41
+ expect(() => track_form_write(null, 1, "x")).not.toThrow();
42
+ expect(() => settle_form_write(null, 1)).not.toThrow();
43
+ expect(abandon_form_writes(null)).toEqual([]);
44
+ });
45
+ });