@yuneta/gobj-ui 7.23.192 → 7.23.194

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.
@@ -85,6 +85,8 @@ import {
85
85
  yui_selection_settings,
86
86
  yui_selection_bar,
87
87
  yui_selected_rows,
88
+ yui_row_ids,
89
+ yui_rows_by_ids,
88
90
  yui_clear_selection,
89
91
  } from "./yui_table_select.js";
90
92
 
@@ -195,6 +197,7 @@ SDATA(data_type_t.DTP_BOOLEAN, "with_new_button", 0, true, "Button tool
195
197
  SDATA(data_type_t.DTP_BOOLEAN, "with_delete_button", 0, true, "Button toolbar DELETE"),
196
198
  SDATA(data_type_t.DTP_BOOLEAN, "with_copy_button", 0, true, "Button toolbar COPY"),
197
199
  SDATA(data_type_t.DTP_BOOLEAN, "with_paste_button", 0, true, "Button toolbar PASTE"),
200
+ SDATA(data_type_t.DTP_BOOLEAN, "form_waits_for_answer", 0, false, "The form stays open (busy) after a Save until the host answers EV_WRITE_DONE (close) or EV_WRITE_REFUSED (stay open on what was typed). Off: the form closes when the write is published, whatever the backend says. The host must echo the `form_write` serial of EV_CREATE_RECORD / EV_UPDATE_RECORD; C_YUI_TREEDB_TOPICS does"),
198
201
  SDATA(data_type_t.DTP_BOOLEAN, "with_refresh_button", 0, true, "Button toolbar REFRESH"),
199
202
  SDATA(data_type_t.DTP_BOOLEAN, "with_search_button", 0, true, "Button toolbar SEARCH"),
200
203
  SDATA(data_type_t.DTP_BOOLEAN, "with_schema_button", 0, true, "Button toolbar SCHEMA (show the topic's desc)"),
@@ -424,6 +427,10 @@ function mt_create(gobj)
424
427
  * reopened cannot land on the new form. */
425
428
  gobj.priv.reading_files = 0;
426
429
  gobj.priv.read_serial = 0;
430
+ /* 0 = no write of the form waiting for its answer; otherwise the
431
+ * serial the host echoes in EV_WRITE_DONE / EV_WRITE_REFUSED. */
432
+ gobj.priv.awaiting_write = 0;
433
+ gobj.priv.write_serial = 0;
427
434
 
428
435
  let name = clean_name(gobj_name(gobj));
429
436
  gobj_write_attr(gobj, "table_id", "table" + name);
@@ -1013,14 +1020,18 @@ function render_selection_state(gobj)
1013
1020
  let $delete = $container ? $container.querySelector(`.button-delete-record`) : null;
1014
1021
  let $copy = $container ? $container.querySelector(`.button-copy-record`) : null;
1015
1022
 
1016
- if($delete && $copy) {
1017
- let some = yui_selected_rows(gobj_read_attr(gobj, "tabulator")).length > 0;
1018
- if(some && gobj_read_bool_attr(gobj, "editable")) {
1019
- $delete.removeAttribute("disabled");
1020
- $copy.removeAttribute("disabled");
1023
+ /* Each on its own: with_delete_button / with_copy_button = false build
1024
+ * no button, and asking for BOTH left the other one never enabled. */
1025
+ let some = yui_selected_rows(gobj_read_attr(gobj, "tabulator")).length > 0;
1026
+ let enable = some && gobj_read_bool_attr(gobj, "editable");
1027
+ for(let $button of [$delete, $copy]) {
1028
+ if(!$button) {
1029
+ continue;
1030
+ }
1031
+ if(enable) {
1032
+ $button.removeAttribute("disabled");
1021
1033
  } else {
1022
- $delete.setAttribute("disabled", true);
1023
- $copy.setAttribute("disabled", true);
1034
+ $button.setAttribute("disabled", true);
1024
1035
  }
1025
1036
  }
1026
1037
 
@@ -1481,9 +1492,13 @@ function create_tabulator(gobj)
1481
1492
  * came out wider than the viewport and pushed every other column,
1482
1493
  * the id included, off the screen. The cell keeps its whole text;
1483
1494
  * what the ceiling costs is having to widen the column, or open
1484
- * the record, to read the end of it. */
1495
+ * the record, to read the end of it.
1496
+ * `maxInitialWidth`, not `maxWidth`: the latter is a HARD ceiling
1497
+ * that neither the drag handle nor the double click could pass
1498
+ * (M29 of the 2026-09-21 review); this one caps only the width the
1499
+ * layout gives, and leaves the column to the reader. */
1485
1500
  if(max_col_width > 0) {
1486
- colDef.maxWidth = max_col_width;
1501
+ colDef.maxInitialWidth = max_col_width;
1487
1502
  }
1488
1503
  if(with_header_filters) {
1489
1504
  apply_header_filter(colDef, field_desc);
@@ -1557,7 +1572,7 @@ function create_tabulator(gobj)
1557
1572
  gobj_send_event(gobj, "EV_EDIT_RECORD", {index: index}, gobj);
1558
1573
  } else if(e.target.closest('.remove')) {
1559
1574
  e.stopPropagation();
1560
- gobj_send_event(gobj, "EV_DELETE_ROWS", {index: index, row: row}, gobj);
1575
+ gobj_send_event(gobj, "EV_DELETE_ROWS", {row: row}, gobj);
1561
1576
  }
1562
1577
  }
1563
1578
  });
@@ -2485,6 +2500,7 @@ function teardown_form_child(gobj)
2485
2500
  {
2486
2501
  let priv = gobj.priv;
2487
2502
  priv.reading_files = 0; // a read in flight lands on nobody
2503
+ priv.awaiting_write = 0; // and so does the answer of a write
2488
2504
  if(priv.form) {
2489
2505
  if(gobj_is_running(priv.form)) {
2490
2506
  gobj_stop(priv.form);
@@ -3882,14 +3898,21 @@ function ac_edition_mode(gobj, event, kw, src)
3882
3898
  /*
3883
3899
  * Set edition mode
3884
3900
  */
3885
- $button_edit_record.classList.add('is-primary');
3886
- $button_new_record.classList.add('is-info');
3887
- $button_delete_record.classList.add('is-danger');
3901
+ /* with_new_button / with_delete_button = false build no button */
3902
+ if($button_edit_record) {
3903
+ $button_edit_record.classList.add('is-primary');
3904
+ }
3905
+ if($button_new_record) {
3906
+ $button_new_record.classList.add('is-info');
3907
+ $button_new_record.removeAttribute("disabled");
3908
+ }
3909
+ if($button_delete_record) {
3910
+ $button_delete_record.classList.add('is-danger');
3911
+ }
3888
3912
 
3889
3913
  tabulator.showColumn('_operation');
3890
3914
  tabulator.showColumn('_check_box_state_');
3891
3915
 
3892
- $button_new_record.removeAttribute("disabled");
3893
3916
  /* with_copy_button / with_paste_button = false build no button */
3894
3917
  if($button_paste_record) {
3895
3918
  $button_paste_record.removeAttribute("disabled");
@@ -3897,7 +3920,9 @@ function ac_edition_mode(gobj, event, kw, src)
3897
3920
 
3898
3921
  let rows = yui_selected_rows(tabulator);
3899
3922
  if (rows.length) {
3900
- $button_delete_record.removeAttribute("disabled");
3923
+ if($button_delete_record) {
3924
+ $button_delete_record.removeAttribute("disabled");
3925
+ }
3901
3926
  if($button_copy_record) {
3902
3927
  $button_copy_record.removeAttribute("disabled");
3903
3928
  }
@@ -3907,15 +3932,21 @@ function ac_edition_mode(gobj, event, kw, src)
3907
3932
  /*
3908
3933
  * Remove edition mode
3909
3934
  */
3910
- $button_edit_record.classList.remove('is-primary');
3911
- $button_new_record.classList.remove('is-info');
3912
- $button_delete_record.classList.remove('is-danger');
3935
+ if($button_edit_record) {
3936
+ $button_edit_record.classList.remove('is-primary');
3937
+ }
3938
+ if($button_new_record) {
3939
+ $button_new_record.classList.remove('is-info');
3940
+ $button_new_record.setAttribute("disabled", true);
3941
+ }
3942
+ if($button_delete_record) {
3943
+ $button_delete_record.classList.remove('is-danger');
3944
+ $button_delete_record.setAttribute("disabled", true);
3945
+ }
3913
3946
 
3914
3947
  tabulator.hideColumn('_operation');
3915
3948
  tabulator.hideColumn('_check_box_state_');
3916
3949
 
3917
- $button_new_record.setAttribute("disabled", true);
3918
- $button_delete_record.setAttribute("disabled", true);
3919
3950
  if($button_copy_record) {
3920
3951
  $button_copy_record.setAttribute("disabled", true);
3921
3952
  }
@@ -3982,7 +4013,12 @@ function ac_new_row(gobj, event, kw, src)
3982
4013
  * kw {} empty
3983
4014
  * - From the column _operation delete icon inside a row
3984
4015
  * It will delete this one row
3985
- * kw {index:, row:}
4016
+ * kw {row:}
4017
+ *
4018
+ * What crosses the confirmation is the rows' IDENTITY, never their
4019
+ * position: the view applies EV_TREEDB_NODE_* of every writer while the
4020
+ * dialog is open, and a position names whatever row sits there when the
4021
+ * person answers -- with force:true (A6 of the 2026-09-21 review).
3986
4022
  ************************************************************/
3987
4023
  function ac_delete_rows(gobj, event, kw, src)
3988
4024
  {
@@ -4005,15 +4041,15 @@ function ac_delete_rows(gobj, event, kw, src)
4005
4041
  }
4006
4042
 
4007
4043
  confirm_then(gobj, build_delete_question(gobj, rows),
4008
- {what: "delete_selection"});
4044
+ {what: "delete_selection", ids: yui_row_ids(tabulator, rows)});
4009
4045
 
4010
4046
  } else {
4011
4047
  /*----------------------------*
4012
4048
  * Delete one row
4013
- * {index: , row: }
4049
+ * {row: }
4014
4050
  *----------------------------*/
4015
4051
  confirm_then(gobj, build_delete_question(gobj, kw.row),
4016
- {what: "delete_row", index: kw.index});
4052
+ {what: "delete_row", ids: yui_row_ids(tabulator, [kw.row])});
4017
4053
  }
4018
4054
 
4019
4055
  return 0;
@@ -4021,8 +4057,8 @@ function ac_delete_rows(gobj, event, kw, src)
4021
4057
 
4022
4058
  /************************************************************
4023
4059
  * {what, ...} -- the person said yes to what confirm_then()
4024
- * asked. The rows are read again HERE and not carried in the
4025
- * kw: the kw is plain json, and a Tabulator row is not.
4060
+ * asked. The rows are found again HERE by the ids the question
4061
+ * named: the kw is plain json, and a Tabulator row is not.
4026
4062
  ************************************************************/
4027
4063
  function ac_confirmed(gobj, event, kw, src)
4028
4064
  {
@@ -4042,13 +4078,19 @@ function ac_confirmed(gobj, event, kw, src)
4042
4078
  let tabulator = gobj_read_attr(gobj, "tabulator");
4043
4079
  let topic_name = gobj_read_str_attr(gobj, "topic_name");
4044
4080
 
4045
- if(what === "delete_selection") {
4046
- let rows = yui_selected_rows(tabulator);
4047
- if(!rows.length) {
4048
- log_error(`${gobj_short_name(gobj)}: ${event} with the selection gone`);
4049
- return -1;
4081
+ if(what === "delete_selection" || what === "delete_row") {
4082
+ let ids = Array.isArray(kw.ids) ? kw.ids : [];
4083
+ let found = yui_rows_by_ids(tabulator, ids);
4084
+ if(found.missing.length) {
4085
+ /* Gone while the question was open (another writer deleted
4086
+ * them): never replaced by the row that took their place. */
4087
+ log_error(`${gobj_short_name(gobj)}: ${event}: rows gone while ` +
4088
+ `confirming the delete, not deleted: ${found.missing.join(", ")}`);
4050
4089
  }
4051
- for(let row of rows) {
4090
+ if(!found.rows.length) {
4091
+ return -1; /* Error already logged, or nothing was named */
4092
+ }
4093
+ for(let row of found.rows) {
4052
4094
  // TODO why don't send once EV_DELETE_RECORD(S)
4053
4095
  gobj_publish_event(
4054
4096
  gobj,
@@ -4059,22 +4101,6 @@ function ac_confirmed(gobj, event, kw, src)
4059
4101
  return 0;
4060
4102
  }
4061
4103
 
4062
- if(what === "delete_row") {
4063
- let index = kw.index;
4064
- let row = (tabulator && typeof index === "number")?
4065
- tabulator.getRowFromPosition(index) : null;
4066
- if(!row) {
4067
- log_error(`${gobj_short_name(gobj)}: ${event} names no row: ${index}`);
4068
- return -1;
4069
- }
4070
- gobj_publish_event(
4071
- gobj,
4072
- "EV_DELETE_RECORD",
4073
- {topic_name: topic_name, record: row.getData()}
4074
- );
4075
- return 0;
4076
- }
4077
-
4078
4104
  log_error(`${gobj_short_name(gobj)}: ${event} of nothing this view asked about: ${what}`);
4079
4105
  return -1;
4080
4106
  }
@@ -4178,6 +4204,10 @@ function ac_form_save_record(gobj, event, kw, src)
4178
4204
  const cols = Object.keys(picked);
4179
4205
 
4180
4206
  if(cols.length === 0) {
4207
+ if(wait_for_write_answer(gobj)) {
4208
+ publish_treedb_write(gobj, mode, kw, null, priv.awaiting_write);
4209
+ return 0;
4210
+ }
4181
4211
  publish_treedb_write(gobj, mode, kw);
4182
4212
 
4183
4213
  /* We are INSIDE the form child's gobj_publish_event stack, and
@@ -4275,7 +4305,7 @@ function read_is_current(gobj, kw)
4275
4305
  /************************************************************
4276
4306
  * The one place a write of this view leaves for the app.
4277
4307
  ************************************************************/
4278
- function publish_treedb_write(gobj, mode, record, jn_files)
4308
+ function publish_treedb_write(gobj, mode, record, jn_files, form_write)
4279
4309
  {
4280
4310
  /* Both writes of the form pass here, with and without files: the
4281
4311
  * read-only columns it shows stay on screen and off the wire. */
@@ -4283,6 +4313,10 @@ function publish_treedb_write(gobj, mode, record, jn_files)
4283
4313
  topic_name: gobj_read_str_attr(gobj, "topic_name"),
4284
4314
  record: strip_read_only_cols(gobj, record)
4285
4315
  };
4316
+ if(form_write) {
4317
+ /* The serial the host echoes back with the answer */
4318
+ kw_write.form_write = form_write;
4319
+ }
4286
4320
  if(jn_files && Object.keys(jn_files).length > 0) {
4287
4321
  /* Not a column: an instruction to the treedb write path,
4288
4322
  * consumed and dropped at the door. */
@@ -4315,11 +4349,67 @@ function ac_files_read(gobj, event, kw, src)
4315
4349
  }
4316
4350
  gobj.priv.reading_files = 0;
4317
4351
  const {record, __files__} = yui_files_manifest(kw.picks, kw.record);
4352
+ if(wait_for_write_answer(gobj)) {
4353
+ publish_treedb_write(gobj, kw.mode, record, __files__, gobj.priv.awaiting_write);
4354
+ return 0;
4355
+ }
4318
4356
  publish_treedb_write(gobj, kw.mode, record, __files__);
4319
4357
  close_form_dialog(gobj);
4320
4358
  return 0;
4321
4359
  }
4322
4360
 
4361
+ /************************************************************
4362
+ * Does the form wait for the answer of its write? Then it stays
4363
+ * open and BUSY, and the serial it waits for is taken here.
4364
+ *
4365
+ * A refused write used to close the form all the same and throw
4366
+ * away what was typed, although the README said it stayed open
4367
+ * (M25 of the 2026-09-21 review): the close was posted with the
4368
+ * publish, before the backend had answered anything.
4369
+ ************************************************************/
4370
+ function wait_for_write_answer(gobj)
4371
+ {
4372
+ let priv = gobj.priv;
4373
+ if(!gobj_read_bool_attr(gobj, "form_waits_for_answer") || !priv.form) {
4374
+ return false;
4375
+ }
4376
+ priv.awaiting_write = ++priv.write_serial;
4377
+ set_form_busy(gobj, true);
4378
+ return true;
4379
+ }
4380
+
4381
+ /************************************************************
4382
+ * {form_write} -- the host says the form's write was done
4383
+ ************************************************************/
4384
+ function ac_write_done(gobj, event, kw, src)
4385
+ {
4386
+ let priv = gobj.priv;
4387
+ if(!priv.awaiting_write || !kw || kw.form_write !== priv.awaiting_write) {
4388
+ /* Not the write this form waits for: its form was closed, or
4389
+ * replaced by another one, meanwhile. Nothing to act on. */
4390
+ return 0;
4391
+ }
4392
+ priv.awaiting_write = 0;
4393
+ set_form_busy(gobj, false);
4394
+ close_form_dialog(gobj);
4395
+ return 0;
4396
+ }
4397
+
4398
+ /************************************************************
4399
+ * {form_write} -- the host says the form's write was refused.
4400
+ * The form stays OPEN on what was typed; the host has shown why.
4401
+ ************************************************************/
4402
+ function ac_write_refused(gobj, event, kw, src)
4403
+ {
4404
+ let priv = gobj.priv;
4405
+ if(!priv.awaiting_write || !kw || kw.form_write !== priv.awaiting_write) {
4406
+ return 0; /* Not the write this form waits for (see ac_write_done) */
4407
+ }
4408
+ priv.awaiting_write = 0;
4409
+ set_form_busy(gobj, false);
4410
+ return 0;
4411
+ }
4412
+
4323
4413
  /************************************************************
4324
4414
  * {reason} -- the browser could not read what was picked
4325
4415
  *
@@ -5218,6 +5308,8 @@ function create_gclass(gclass_name)
5218
5308
  ["EV_EDITION_MODE", ac_edition_mode, null],
5219
5309
  ["EV_SAVE_RECORD", ac_form_save_record, null],
5220
5310
  ["EV_CLOSE_FORM", ac_close_form, null],
5311
+ ["EV_WRITE_DONE", ac_write_done, null],
5312
+ ["EV_WRITE_REFUSED", ac_write_refused, null],
5221
5313
  ["EV_FILES_READ", ac_files_read, null],
5222
5314
  ["EV_FILES_FAILED", ac_files_failed, null],
5223
5315
 
@@ -5272,6 +5364,8 @@ function create_gclass(gclass_name)
5272
5364
  ["EV_EDITION_MODE", 0],
5273
5365
  ["EV_SAVE_RECORD", 0],
5274
5366
  ["EV_CLOSE_FORM", 0],
5367
+ ["EV_WRITE_DONE", 0],
5368
+ ["EV_WRITE_REFUSED", 0],
5275
5369
  ["EV_FILES_READ", 0],
5276
5370
  ["EV_FILES_FAILED", 0],
5277
5371
  ["EV_NEW_ROW", 0],
@@ -26,6 +26,7 @@ import {
26
26
  createElement2,
27
27
  kw_get_dict_value,
28
28
  kw_get_str,
29
+ kw_get_int,
29
30
  gobj_send_event,
30
31
  gobj_publish_event,
31
32
  gobj_write_attr,
@@ -1245,7 +1246,10 @@ function process_treedb_descs(gobj)
1245
1246
  desc: desc,
1246
1247
  with_remote_paging: gobj_read_bool_attr(gobj, "with_remote_paging"),
1247
1248
  page_size: gobj_read_integer_attr(gobj, "page_size"),
1248
- with_selection_bar: gobj_read_bool_attr(gobj, "with_selection_bar")
1249
+ with_selection_bar: gobj_read_bool_attr(gobj, "with_selection_bar"),
1250
+ /* This host answers every write of the form (see
1251
+ * answer_form_write): the form closes on success only. */
1252
+ form_waits_for_answer: true
1249
1253
  };
1250
1254
 
1251
1255
  let id = `${gobj_name(gobj)}?${desc.topic_name}`;
@@ -1620,7 +1624,7 @@ function treedb_create_node(gobj, treedb_name, topic_name, record, options)
1620
1624
  /************************************************************
1621
1625
  * Command to remote service
1622
1626
  ************************************************************/
1623
- function treedb_update_node(gobj, treedb_name, topic_name, record, options)
1627
+ function treedb_update_node(gobj, treedb_name, topic_name, record, options, form_write)
1624
1628
  {
1625
1629
  let command = "update-node";
1626
1630
 
@@ -1636,6 +1640,9 @@ function treedb_update_node(gobj, treedb_name, topic_name, record, options)
1636
1640
  kw.__md_command__ = { // Data to be returned
1637
1641
  topic_name: topic_name,
1638
1642
  };
1643
+ if(form_write) {
1644
+ kw.__md_command__.form_write = form_write;
1645
+ }
1639
1646
  // TODO review msg_iev_write_key(kw, "__topic_name__", topic_name);
1640
1647
 
1641
1648
  let ret = gobj_command(
@@ -1646,7 +1653,9 @@ function treedb_update_node(gobj, treedb_name, topic_name, record, options)
1646
1653
  );
1647
1654
  if(ret) {
1648
1655
  log_error(ret);
1656
+ return -1;
1649
1657
  }
1658
+ return 0;
1650
1659
  }
1651
1660
 
1652
1661
  /************************************************************
@@ -2056,6 +2065,12 @@ function ac_mt_command_answer(gobj, event, kw, src)
2056
2065
  gobj_send_event(gobj, "EV_REFRESH_TOPIC",
2057
2066
  {topic_name: failed_topic}, gobj);
2058
2067
  }
2068
+ answer_form_write(
2069
+ gobj,
2070
+ get_gobj_formtable(gobj, failed_topic),
2071
+ kw_get_int(gobj, kw_command, "form_write", 0, 0),
2072
+ false
2073
+ );
2059
2074
  }
2060
2075
  }
2061
2076
  return 0;
@@ -2148,6 +2163,12 @@ function ac_mt_command_answer(gobj, event, kw, src)
2148
2163
  created: (command === "create-node"),
2149
2164
  command: command
2150
2165
  });
2166
+ answer_form_write(
2167
+ gobj,
2168
+ get_gobj_formtable(gobj, kw_get_str(gobj, kw_command, "topic_name", "", 0)),
2169
+ kw_get_int(gobj, kw_command, "form_write", 0, 0),
2170
+ true
2171
+ );
2151
2172
  break;
2152
2173
 
2153
2174
  case "delete-node":
@@ -2493,26 +2514,33 @@ function ac_treedb_node_deleted(gobj, event, kw, src)
2493
2514
  ********************************************/
2494
2515
  function ac_create_record(gobj, event, kw, src)
2495
2516
  {
2517
+ let form_write = (kw && kw.form_write) || 0;
2496
2518
  if(refuse_if_readonly(gobj, event)) {
2519
+ answer_form_write(gobj, src, form_write, false);
2497
2520
  return -1; /* Error already logged */
2498
2521
  }
2499
2522
  let treedb_name = gobj_read_str_attr(gobj, "treedb_name");
2500
2523
  let topic_name = gobj_read_attr(src, "topic_name");
2501
2524
  let record = kw.record;
2502
2525
 
2526
+ /* `create_only`: +New makes a NEW record. With `create` alone a taken
2527
+ * id was an update -- the record overwritten and, through autolink
2528
+ * with the selects empty, unlinked, answered "Node update!" (M28 of
2529
+ * the 2026-09-21 review). A backend older than the option ignores it
2530
+ * and keeps the old upsert. */
2503
2531
  let options = {
2504
2532
  list_dict: true,
2505
2533
  create: true,
2534
+ create_only: true,
2506
2535
  autolink: true
2507
2536
  };
2508
2537
 
2509
- return treedb_update_node( // HACK use the powerful update_node
2510
- gobj,
2511
- treedb_name,
2512
- topic_name,
2513
- record,
2514
- options
2515
- );
2538
+ if(treedb_update_node( // HACK use the powerful update_node
2539
+ gobj, treedb_name, topic_name, record, options, form_write) < 0) {
2540
+ answer_form_write(gobj, src, form_write, false);
2541
+ return -1; /* Error already logged */
2542
+ }
2543
+ return 0;
2516
2544
  }
2517
2545
 
2518
2546
  /********************************************
@@ -2715,7 +2743,9 @@ function ac_update_field(gobj, event, kw, src)
2715
2743
  ********************************************/
2716
2744
  function ac_update_record(gobj, event, kw, src)
2717
2745
  {
2746
+ let form_write = (kw && kw.form_write) || 0;
2718
2747
  if(refuse_if_readonly(gobj, event)) {
2748
+ answer_form_write(gobj, src, form_write, false);
2719
2749
  return -1; /* Error already logged */
2720
2750
  }
2721
2751
  let treedb_name = gobj_read_str_attr(gobj, "treedb_name");
@@ -2727,12 +2757,35 @@ function ac_update_record(gobj, event, kw, src)
2727
2757
  autolink: true
2728
2758
  };
2729
2759
 
2730
- return treedb_update_node(
2731
- gobj,
2732
- treedb_name,
2733
- topic_name,
2734
- record,
2735
- options
2760
+ if(treedb_update_node(
2761
+ gobj, treedb_name, topic_name, record, options, form_write) < 0) {
2762
+ answer_form_write(gobj, src, form_write, false);
2763
+ return -1; /* Error already logged */
2764
+ }
2765
+ return 0;
2766
+ }
2767
+
2768
+ /************************************************************
2769
+ * Tell a topic form how its write ended: EV_WRITE_DONE closes
2770
+ * it, EV_WRITE_REFUSED leaves it open on what was typed. Every
2771
+ * way a form's write can end passes here -- refused before it
2772
+ * left (read-only, no session) or answered by the backend -- so
2773
+ * a form never waits for an answer that will not come.
2774
+ ************************************************************/
2775
+ function answer_form_write(gobj, gobj_topic_form, form_write, ok)
2776
+ {
2777
+ if(!form_write) {
2778
+ return; /* Not a form's write (a cell edited in place) */
2779
+ }
2780
+ if(!gobj_topic_form) {
2781
+ log_error(`${gobj_short_name(gobj)}: no topic form to answer write ${form_write}`);
2782
+ return;
2783
+ }
2784
+ gobj_send_event(
2785
+ gobj_topic_form,
2786
+ ok ? "EV_WRITE_DONE" : "EV_WRITE_REFUSED",
2787
+ {form_write: form_write},
2788
+ gobj
2736
2789
  );
2737
2790
  }
2738
2791
 
@@ -38,6 +38,7 @@
38
38
  import {
39
39
  is_object,
40
40
  json_is_identical,
41
+ json_deep_copy,
41
42
  } from "@yuneta/gobj-js";
42
43
 
43
44
  /***************************************************************
@@ -101,7 +102,46 @@ function plan_graph_saves(live, saved)
101
102
  return plan;
102
103
  }
103
104
 
105
+ /***************************************************************
106
+ * An ECHO of one __graphs__ record arrived: the node event a save
107
+ * of it sends back, from this browser or from another one.
108
+ *
109
+ * live {topic_name: properties} what the view shows
110
+ * saved {topic_name: properties} what the backend has
111
+ * records every __graphs__ record the view holds, `rec`
112
+ * already in its place among them
113
+ * rec the echoed record
114
+ *
115
+ * Only rec's TOPIC moves: it becomes the record, as shown and as
116
+ * saved. The other topics are left alone -- rebuilding their saved
117
+ * copies here took them from the live objects the view had already
118
+ * rearranged in place, so their unsaved changes counted as saved.
119
+ *
120
+ * `live[topic]` IS the record's own object, as when the view loads:
121
+ * the view arranges it in place. `saved[topic]` is a copy.
122
+ ***************************************************************/
123
+ function apply_graphs_echo(live, saved, records, rec)
124
+ {
125
+ if(!is_object(rec) || !rec.topic) {
126
+ return;
127
+ }
128
+ if(rec.active && is_object(rec.properties)) {
129
+ live[rec.topic] = rec.properties;
130
+ saved[rec.topic] = json_deep_copy(rec.properties);
131
+ return;
132
+ }
133
+ let other_active = (records || []).some((r) => {
134
+ return r && r !== rec && r.topic === rec.topic && r.active &&
135
+ is_object(r.properties);
136
+ });
137
+ if(!other_active) {
138
+ delete live[rec.topic];
139
+ delete saved[rec.topic];
140
+ }
141
+ }
142
+
104
143
  export {
144
+ apply_graphs_echo,
105
145
  plan_graph_saves,
106
146
  topic_arrangement_changed,
107
147
  arrangement_of,
@@ -18,7 +18,7 @@
18
18
  ***********************************************************************/
19
19
  import {describe, test, expect} from "vitest";
20
20
  import {
21
- plan_graph_saves, topic_arrangement_changed, arrangement_of
21
+ plan_graph_saves, topic_arrangement_changed, arrangement_of, apply_graphs_echo
22
22
  } from "./graph_save_plan.js";
23
23
 
24
24
  const ORIGIN = "f3163a9f-1f37-412a-b8c9-87a4341f14de";
@@ -144,3 +144,59 @@ describe("arrangement_of", () => {
144
144
  expect(arrangement_of("nodes")).toEqual({});
145
145
  });
146
146
  });
147
+
148
+ /*
149
+ * An ECHO of one __graphs__ record -- the node event a save of it, or a
150
+ * save by another browser, sends back. It used to rebuild the saved
151
+ * snapshot of EVERY topic, taking the copies from the live objects the
152
+ * view had already rearranged: what was unsaved in OTHER topics counted
153
+ * as saved, and the next Save skipped it (M32 of the 2026-09-21 review).
154
+ */
155
+ describe("an echo of one __graphs__ record", () => {
156
+ function state()
157
+ {
158
+ let live = {
159
+ realms: layout({a: {x: 1, y: 1}}),
160
+ yunos: layout({b: {x: 2, y: 2}}),
161
+ };
162
+ let saved = {
163
+ realms: layout({a: {x: 1, y: 1}}),
164
+ yunos: layout({b: {x: 2, y: 2}}),
165
+ };
166
+ return {live, saved};
167
+ }
168
+
169
+ test("touches only the topic of its record", () => {
170
+ let {live, saved} = state();
171
+ live.yunos.nodes.b.x = 99; /* moved, not saved yet */
172
+ let echo = {id: "1", topic: "realms", active: true,
173
+ properties: layout({a: {x: 5, y: 5}})};
174
+ apply_graphs_echo(live, saved, [echo], echo);
175
+ expect(plan_graph_saves(live, saved)).toEqual(["yunos"]);
176
+ });
177
+
178
+ test("its topic becomes the echoed record, saved and live", () => {
179
+ let {live, saved} = state();
180
+ let echo = {id: "1", topic: "realms", active: true,
181
+ properties: layout({a: {x: 5, y: 5}})};
182
+ apply_graphs_echo(live, saved, [echo], echo);
183
+ expect(live.realms.nodes.a).toEqual({x: 5, y: 5});
184
+ expect(saved.realms.nodes.a).toEqual({x: 5, y: 5});
185
+ live.realms.nodes.a.x = 6; /* the view arranges it in place */
186
+ expect(saved.realms.nodes.a.x).toBe(5);
187
+ });
188
+
189
+ test("an inactive record drops its topic only when no active one is left", () => {
190
+ let {live, saved} = state();
191
+ let old = {id: "2", topic: "realms", active: false, properties: layout({})};
192
+ let current = {id: "3", topic: "realms", active: true,
193
+ properties: layout({a: {x: 1, y: 1}})};
194
+ apply_graphs_echo(live, saved, [old, current], old);
195
+ expect(live.realms).toBeDefined();
196
+
197
+ apply_graphs_echo(live, saved, [old], old);
198
+ expect(live.realms).toBeUndefined();
199
+ expect(saved.realms).toBeUndefined();
200
+ expect(live.yunos).toBeDefined();
201
+ });
202
+ });
@@ -40,8 +40,13 @@ const MAX_DEPTH = 4;
40
40
 
41
41
 
42
42
  /***************************************************************
43
- * An fkey, as `nodes` answers it with `list_dict`.
43
+ * A REFERENCE to a node: an fkey as `nodes` answers it with
44
+ * `list_dict` -- `{id, topic_name, hook_name}` -- or a hook's child
45
+ * as the node events deliver it, `{id, topic_name}`. It carries
46
+ * nothing but those keys; an object with more is data.
44
47
  ***************************************************************/
48
+ const REF_KEYS = new Set(["id", "topic_name", "hook_name"]);
49
+
45
50
  function is_fkey_ref(value)
46
51
  {
47
52
  return !!value
@@ -49,7 +54,7 @@ function is_fkey_ref(value)
49
54
  && !Array.isArray(value)
50
55
  && ("id" in value)
51
56
  && ("topic_name" in value)
52
- && ("hook_name" in value);
57
+ && Object.keys(value).every((k) => REF_KEYS.has(k));
53
58
  }
54
59
 
55
60