@yuneta/gobj-ui 7.7.1 → 7.8.1

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
@@ -491,6 +491,39 @@ The buttons of that toolbar never shrink. When the row runs out of room, the
491
491
  url is what gives way, cut with an ellipsis, and the whole value stays in the
492
492
  `title` and the `aria-label`.
493
493
 
494
+ ### Editing a topic table in place
495
+
496
+ A writable scalar is editable in the table, in edition mode
497
+ (`with_inline_edit`, default on). Changing one field used to mean opening the
498
+ record form, changing it, saving and closing.
499
+
500
+ **Which cells, and why not the rest.** The schema decides first: only a column
501
+ flagged `writable`, and never the pkey — renaming what a record is KEYED by is
502
+ not a field edit. Then the type: a hook holds children and an fkey IS a link,
503
+ so both are edited by linking; a dict or a list is a document the form has an
504
+ editor for; a date cell shows a formatted string over an epoch, so typing into
505
+ it would write the string. Those stay with the form, one click away on the
506
+ same row. `boolean` gets a tick, `enum` the list of its own values, numbers a
507
+ number editor.
508
+
509
+ **The write is a partial update with no `autolink`, and that is the whole
510
+ safety of it.** `treedb_update_node()` merges (`json_object_update`), so the
511
+ fields it does not carry are left alone; `autolink` is the option that wipes a
512
+ node's links and rebuilds them from the fkeys the record carries, and on a
513
+ partial record it reads that as "no parents", detaches the node and answers
514
+ **success**. So a cell edit travels as its own event, `EV_UPDATE_FIELD`, and
515
+ not as `EV_UPDATE_RECORD` — that one does send autolink, and may, because the
516
+ form hands it the whole record with its fkeys in it. See
517
+ `schema_write_options.js` for the rule and why each word of it is there.
518
+
519
+ `editable` is a **function** on the column, not a flag: edition mode is
520
+ toggled on a table that is already built, so the answer has to be asked for at
521
+ the moment of the click.
522
+
523
+ A refused write puts the topic back to what the treedb has. Leaving the typed
524
+ value on screen is tolerable for a form, which stays open on the values it
525
+ failed with; a cell edited in place would just look saved.
526
+
494
527
  ### Reading a topic table: filters, columns, CSV
495
528
 
496
529
  `C_YUI_TREEDB_TOPIC_WITH_FORM` had one global search box over the loaded rows.
@@ -14416,6 +14416,66 @@ function yui_shell_show_route_map(shell, opts) {
14416
14416
  return modal_ref.modal;
14417
14417
  }
14418
14418
  //#endregion
14419
+ //#region src/nodes_answer.js
14420
+ /***********************************************************************
14421
+ * nodes_answer.js
14422
+ *
14423
+ * What the `nodes` command answered, whatever shape it came in.
14424
+ *
14425
+ * A treedb lives in memory on the backend, so walking it is not
14426
+ * what costs: serializing every node, pushing it through a
14427
+ * websocket and parsing it here is. So `nodes` learned to answer a
14428
+ * PAGE, with the contract `list-keys` of C_TRANGER already used:
14429
+ *
14430
+ * no `limit` -> the plain list it has always answered
14431
+ * a `limit` -> {total_rows, pages, data}
14432
+ *
14433
+ * Both shapes are alive at once and will be for a long time: the
14434
+ * SPA talks to backends the operator configures, and an older one
14435
+ * answers the plain list whatever this app asks for. So the shape
14436
+ * is read, never assumed — a view that indexed `data` as an array
14437
+ * would render an object's keys as rows the day a backend
14438
+ * upgraded, which is the kind of break that looks like a bug in
14439
+ * the app.
14440
+ *
14441
+ * Pure and tested, because this is exactly the sort of thing that
14442
+ * cannot be exercised until the other side moves.
14443
+ *
14444
+ * Copyright (c) 2026, ArtGins.
14445
+ * All Rights Reserved.
14446
+ ***********************************************************************/
14447
+ /***************************************************************
14448
+ * nodes_answer(data) -> {rows, total, pages, paged}
14449
+ *
14450
+ * `rows` is always an array, so a caller never has to ask which
14451
+ * shape it got. `paged` says whether the backend answered a page,
14452
+ * which is the one thing a caller may legitimately care about (it is
14453
+ * what tells a pager it has something to page).
14454
+ ***************************************************************/
14455
+ function nodes_answer(data) {
14456
+ if (Array.isArray(data)) return {
14457
+ rows: data,
14458
+ total: data.length,
14459
+ pages: 1,
14460
+ paged: false
14461
+ };
14462
+ if (data && typeof data === "object" && Array.isArray(data.data)) {
14463
+ let rows = data.data;
14464
+ return {
14465
+ rows,
14466
+ total: typeof data.total_rows === "number" ? data.total_rows : rows.length,
14467
+ pages: typeof data.pages === "number" && data.pages > 0 ? data.pages : 1,
14468
+ paged: true
14469
+ };
14470
+ }
14471
+ return {
14472
+ rows: [],
14473
+ total: 0,
14474
+ pages: 1,
14475
+ paged: false
14476
+ };
14477
+ }
14478
+ //#endregion
14419
14479
  //#region src/c_yui_treedb_topics.js
14420
14480
  /***********************************************************************
14421
14481
  * c_yui_treedb_topics.js
@@ -15609,7 +15669,13 @@ function ac_mt_command_answer$2(gobj, event, kw, src) {
15609
15669
  }
15610
15670
  if (result < 0) {
15611
15671
  if (command === "descs") show_load_error$1(gobj, (0, i18next.t)(comment));
15612
- else yui_shell_show_error(yui_shell_of(gobj), comment, { t: i18next.t });
15672
+ else {
15673
+ yui_shell_show_error(yui_shell_of(gobj), comment, { t: i18next.t });
15674
+ if (command === "update-node" || command === "create-node") {
15675
+ let failed_topic = (0, _yuneta_gobj_js.kw_get_str)(gobj, kw_command, "topic_name", "", 0);
15676
+ if (failed_topic) treedb_nodes$1(gobj, (0, _yuneta_gobj_js.gobj_read_str_attr)(gobj, "treedb_name"), failed_topic, { list_dict: true });
15677
+ }
15678
+ }
15613
15679
  return 0;
15614
15680
  }
15615
15681
  switch (command) {
@@ -15623,7 +15689,7 @@ function ac_mt_command_answer$2(gobj, event, kw, src) {
15623
15689
  let topic_name = (0, _yuneta_gobj_js.kw_get_str)(gobj, kw_command, "topic_name", "", _yuneta_gobj_js.kw_flag_t.KW_REQUIRED);
15624
15690
  if (result >= 0) {
15625
15691
  let gobj_topic_form = (0, _yuneta_gobj_js.gobj_find_child)(gobj, { __gobj_name__: `${(0, _yuneta_gobj_js.gobj_name)(gobj)}?${topic_name}` });
15626
- (0, _yuneta_gobj_js.gobj_send_event)(gobj_topic_form, "EV_LOAD_NODES", data, gobj);
15692
+ (0, _yuneta_gobj_js.gobj_send_event)(gobj_topic_form, "EV_LOAD_NODES", nodes_answer(data).rows, gobj);
15627
15693
  }
15628
15694
  break;
15629
15695
  case "create-node":
@@ -15849,6 +15915,35 @@ function ac_create_record(gobj, event, kw, src) {
15849
15915
  });
15850
15916
  }
15851
15917
  /********************************************
15918
+ * Event from formtable: ONE field of one record, edited in place.
15919
+ *
15920
+ * Written as a PARTIAL update and, deliberately, with NO `autolink`.
15921
+ * `treedb_update_node()` merges (`json_object_update`), so the fields
15922
+ * it does not carry are left alone; and `autolink` is what wipes a
15923
+ * node's links to rebuild them from the fkeys the record carries — on
15924
+ * a partial record that reads as "no parents" and DETACHES the node,
15925
+ * answering success. A cell edit changes a scalar and must not be able
15926
+ * to touch a link at all.
15927
+ *
15928
+ * This is why it is its own event and not EV_UPDATE_RECORD: that one
15929
+ * DOES send autolink, and may, because the form hands it the whole
15930
+ * record with its fkeys in it.
15931
+ ********************************************/
15932
+ function ac_update_field(gobj, event, kw, src) {
15933
+ if (refuse_if_readonly$3(gobj, event)) return -1;
15934
+ let topic_name = kw.topic_name || (0, _yuneta_gobj_js.gobj_read_attr)(src, "topic_name");
15935
+ let desc = ((0, _yuneta_gobj_js.gobj_read_attr)(gobj, "descs") || {})[topic_name];
15936
+ let pkey = desc && desc.pkey || "id";
15937
+ if (kw.id === void 0 || kw.id === null || !kw.field) {
15938
+ (0, _yuneta_gobj_js.log_error)(`${(0, _yuneta_gobj_js.gobj_short_name)(gobj)}: a field write with no id or no field`);
15939
+ return -1;
15940
+ }
15941
+ let record = {};
15942
+ record[pkey] = kw.id;
15943
+ record[kw.field] = kw.value;
15944
+ return treedb_update_node$1(gobj, (0, _yuneta_gobj_js.gobj_read_str_attr)(gobj, "treedb_name"), topic_name, record, { list_dict: true });
15945
+ }
15946
+ /********************************************
15852
15947
  * Event from formtable
15853
15948
  * kw: {
15854
15949
  * topic_name,
@@ -15970,6 +16065,11 @@ function create_gclass$6(gclass_name) {
15970
16065
  ac_update_record,
15971
16066
  null
15972
16067
  ],
16068
+ [
16069
+ "EV_UPDATE_FIELD",
16070
+ ac_update_field,
16071
+ null
16072
+ ],
15973
16073
  [
15974
16074
  "EV_DELETE_RECORD",
15975
16075
  ac_delete_record,
@@ -16043,6 +16143,7 @@ function create_gclass$6(gclass_name) {
16043
16143
  ["EV_TREEDB_NODE_DELETED", _yuneta_gobj_js.event_flag_t.EVF_PUBLIC_EVENT],
16044
16144
  ["EV_CREATE_RECORD", 0],
16045
16145
  ["EV_UPDATE_RECORD", 0],
16146
+ ["EV_UPDATE_FIELD", 0],
16046
16147
  ["EV_DELETE_RECORD", 0],
16047
16148
  ["EV_RECORD_WRITTEN", _yuneta_gobj_js.event_flag_t.EVF_OUTPUT_EVENT | _yuneta_gobj_js.event_flag_t.EVF_NO_WARN_SUBS],
16048
16149
  ["EV_REFRESH_TOPIC", 0],
@@ -16256,6 +16357,7 @@ var attrs_table$5 = [
16256
16357
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_BOOLEAN, "with_columns_button", 0, true, "Button toolbar COLUMNS (choose which columns the table shows)"),
16257
16358
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_BOOLEAN, "with_export_button", 0, true, "Button toolbar EXPORT (download as CSV what the table holds)"),
16258
16359
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_BOOLEAN, "with_header_filters", 0, true, "Per-column filter box in the table header, on the columns a text/number match means something (not hooks, fkeys or json)"),
16360
+ (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_BOOLEAN, "with_inline_edit", 0, true, "Edit a writable scalar cell in place while in edition mode (the record form keeps the rest)"),
16259
16361
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_BOOLEAN, "with_in_row_edit_icons", 0, true, "Add a last column with internal EDIT/DELETE icon"),
16260
16362
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_BOOLEAN, "editable", 0, false, "Edit state"),
16261
16363
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_BOOLEAN, "with_checkbox", 0, true, "Auxiliary first column to select rows"),
@@ -16765,6 +16867,7 @@ function create_tabulator(gobj) {
16765
16867
  let table_id = (0, _yuneta_gobj_js.gobj_read_str_attr)(gobj, "table_id");
16766
16868
  let desc = (0, _yuneta_gobj_js.gobj_read_attr)(gobj, "desc");
16767
16869
  let with_header_filters = (0, _yuneta_gobj_js.gobj_read_bool_attr)(gobj, "with_header_filters");
16870
+ let with_inline_edit = (0, _yuneta_gobj_js.gobj_read_bool_attr)(gobj, "with_inline_edit");
16768
16871
  let columns = [];
16769
16872
  let with_checkbox = (0, _yuneta_gobj_js.gobj_read_bool_attr)(gobj, "with_checkbox");
16770
16873
  let with_radio = (0, _yuneta_gobj_js.gobj_read_bool_attr)(gobj, "with_radio");
@@ -16808,6 +16911,42 @@ function create_tabulator(gobj) {
16808
16911
  col_id: cell.getField()
16809
16912
  }, gobj);
16810
16913
  }
16914
+ const INLINE_EDITABLE = [
16915
+ "string",
16916
+ "integer",
16917
+ "real",
16918
+ "email",
16919
+ "url",
16920
+ "tel",
16921
+ "id",
16922
+ "hex",
16923
+ "currency",
16924
+ "percent",
16925
+ "uuid"
16926
+ ];
16927
+ function inline_editor_for(colDef, col, field_desc) {
16928
+ if (!field_desc.is_writable) return;
16929
+ if (col.id === (desc.pkey || "id")) return;
16930
+ switch (field_desc.type) {
16931
+ case "boolean":
16932
+ colDef.editor = "tickCross";
16933
+ break;
16934
+ case "enum":
16935
+ colDef.editor = "list";
16936
+ colDef.editorParams = { values: field_desc.enum_list || [] };
16937
+ break;
16938
+ case "integer":
16939
+ case "real":
16940
+ colDef.editor = "number";
16941
+ break;
16942
+ default:
16943
+ if (!INLINE_EDITABLE.includes(field_desc.type)) return;
16944
+ colDef.editor = "input";
16945
+ }
16946
+ colDef.editable = function() {
16947
+ return (0, _yuneta_gobj_js.gobj_read_bool_attr)(gobj, "editable") && !(0, _yuneta_gobj_js.gobj_read_bool_attr)(gobj, "readonly");
16948
+ };
16949
+ }
16811
16950
  const FILTERABLE = [
16812
16951
  "string",
16813
16952
  "integer",
@@ -16930,6 +17069,7 @@ function create_tabulator(gobj) {
16930
17069
  formatter: colFormatter
16931
17070
  };
16932
17071
  if (with_header_filters) apply_header_filter(colDef, field_desc);
17072
+ if (with_inline_edit) inline_editor_for(colDef, col, field_desc);
16933
17073
  if (vertAlign) colDef.vertAlign = vertAlign;
16934
17074
  if (formatterParams) colDef.formatterParams = formatterParams;
16935
17075
  if (cellClick) colDef.cellClick = cellClick;
@@ -17016,6 +17156,21 @@ function create_tabulator(gobj) {
17016
17156
  });
17017
17157
  tabulator.on("dataProcessed", update_rowcount);
17018
17158
  tabulator.on("dataChanged", update_rowcount);
17159
+ tabulator.on("cellEdited", function(cell) {
17160
+ let pkey = ((0, _yuneta_gobj_js.gobj_read_attr)(gobj, "desc") || {}).pkey || "id";
17161
+ let data = cell.getRow().getData();
17162
+ let id = data ? data[pkey] : void 0;
17163
+ if (id === void 0 || id === null) {
17164
+ (0, _yuneta_gobj_js.log_error)(`${(0, _yuneta_gobj_js.gobj_short_name)(gobj)}: edited a row with no '${pkey}'`);
17165
+ cell.restoreOldValue();
17166
+ return;
17167
+ }
17168
+ (0, _yuneta_gobj_js.gobj_send_event)(gobj, "EV_CELL_EDITED", {
17169
+ id,
17170
+ field: cell.getField(),
17171
+ value: cell.getValue()
17172
+ }, gobj);
17173
+ });
17019
17174
  tabulator.on("dataFiltered", function(filters, rows) {
17020
17175
  update_rowcount(Array.isArray(rows) ? rows.length : void 0);
17021
17176
  });
@@ -18136,6 +18291,20 @@ function ac_show_cell_json(gobj, event, kw, src) {
18136
18291
  return 0;
18137
18292
  }
18138
18293
  /************************************************************
18294
+ * One cell of one record changed. Publish it UP as a field write;
18295
+ * the host owns the treedb command and its options.
18296
+ ************************************************************/
18297
+ function ac_cell_edited(gobj, event, kw, src) {
18298
+ if (refuse_if_readonly$2(gobj, event)) return -1;
18299
+ (0, _yuneta_gobj_js.gobj_publish_event)(gobj, "EV_UPDATE_FIELD", {
18300
+ topic_name: (0, _yuneta_gobj_js.gobj_read_str_attr)(gobj, "topic_name"),
18301
+ id: kw.id,
18302
+ field: kw.field,
18303
+ value: kw.value
18304
+ });
18305
+ return 0;
18306
+ }
18307
+ /************************************************************
18139
18308
  * Show the schema (desc) of this topic
18140
18309
  ************************************************************/
18141
18310
  function ac_show_schema(gobj, event, kw, src) {
@@ -18385,6 +18554,11 @@ function create_gclass$5(gclass_name) {
18385
18554
  ac_show_schema,
18386
18555
  null
18387
18556
  ],
18557
+ [
18558
+ "EV_CELL_EDITED",
18559
+ ac_cell_edited,
18560
+ null
18561
+ ],
18388
18562
  [
18389
18563
  "EV_SEARCH",
18390
18564
  ac_search,
@@ -18435,6 +18609,8 @@ function create_gclass$5(gclass_name) {
18435
18609
  ["EV_CHANGE_LOCALE", 0],
18436
18610
  ["EV_REFRESH", 0],
18437
18611
  ["EV_SHOW_SCHEMA", 0],
18612
+ ["EV_CELL_EDITED", 0],
18613
+ ["EV_UPDATE_FIELD", _yuneta_gobj_js.event_flag_t.EVF_OUTPUT_EVENT],
18438
18614
  ["EV_SEARCH", 0],
18439
18615
  ["EV_OPEN_COLUMNS", 0],
18440
18616
  ["EV_TOGGLE_COLUMN", 0],
@@ -14411,6 +14411,66 @@ function yui_shell_show_route_map(shell, opts) {
14411
14411
  return modal_ref.modal;
14412
14412
  }
14413
14413
  //#endregion
14414
+ //#region src/nodes_answer.js
14415
+ /***********************************************************************
14416
+ * nodes_answer.js
14417
+ *
14418
+ * What the `nodes` command answered, whatever shape it came in.
14419
+ *
14420
+ * A treedb lives in memory on the backend, so walking it is not
14421
+ * what costs: serializing every node, pushing it through a
14422
+ * websocket and parsing it here is. So `nodes` learned to answer a
14423
+ * PAGE, with the contract `list-keys` of C_TRANGER already used:
14424
+ *
14425
+ * no `limit` -> the plain list it has always answered
14426
+ * a `limit` -> {total_rows, pages, data}
14427
+ *
14428
+ * Both shapes are alive at once and will be for a long time: the
14429
+ * SPA talks to backends the operator configures, and an older one
14430
+ * answers the plain list whatever this app asks for. So the shape
14431
+ * is read, never assumed — a view that indexed `data` as an array
14432
+ * would render an object's keys as rows the day a backend
14433
+ * upgraded, which is the kind of break that looks like a bug in
14434
+ * the app.
14435
+ *
14436
+ * Pure and tested, because this is exactly the sort of thing that
14437
+ * cannot be exercised until the other side moves.
14438
+ *
14439
+ * Copyright (c) 2026, ArtGins.
14440
+ * All Rights Reserved.
14441
+ ***********************************************************************/
14442
+ /***************************************************************
14443
+ * nodes_answer(data) -> {rows, total, pages, paged}
14444
+ *
14445
+ * `rows` is always an array, so a caller never has to ask which
14446
+ * shape it got. `paged` says whether the backend answered a page,
14447
+ * which is the one thing a caller may legitimately care about (it is
14448
+ * what tells a pager it has something to page).
14449
+ ***************************************************************/
14450
+ function nodes_answer(data) {
14451
+ if (Array.isArray(data)) return {
14452
+ rows: data,
14453
+ total: data.length,
14454
+ pages: 1,
14455
+ paged: false
14456
+ };
14457
+ if (data && typeof data === "object" && Array.isArray(data.data)) {
14458
+ let rows = data.data;
14459
+ return {
14460
+ rows,
14461
+ total: typeof data.total_rows === "number" ? data.total_rows : rows.length,
14462
+ pages: typeof data.pages === "number" && data.pages > 0 ? data.pages : 1,
14463
+ paged: true
14464
+ };
14465
+ }
14466
+ return {
14467
+ rows: [],
14468
+ total: 0,
14469
+ pages: 1,
14470
+ paged: false
14471
+ };
14472
+ }
14473
+ //#endregion
14414
14474
  //#region src/c_yui_treedb_topics.js
14415
14475
  /***********************************************************************
14416
14476
  * c_yui_treedb_topics.js
@@ -15604,7 +15664,13 @@ function ac_mt_command_answer$2(gobj, event, kw, src) {
15604
15664
  }
15605
15665
  if (result < 0) {
15606
15666
  if (command === "descs") show_load_error$1(gobj, t(comment));
15607
- else yui_shell_show_error(yui_shell_of(gobj), comment, { t });
15667
+ else {
15668
+ yui_shell_show_error(yui_shell_of(gobj), comment, { t });
15669
+ if (command === "update-node" || command === "create-node") {
15670
+ let failed_topic = kw_get_str(gobj, kw_command, "topic_name", "", 0);
15671
+ if (failed_topic) treedb_nodes$1(gobj, gobj_read_str_attr(gobj, "treedb_name"), failed_topic, { list_dict: true });
15672
+ }
15673
+ }
15608
15674
  return 0;
15609
15675
  }
15610
15676
  switch (command) {
@@ -15618,7 +15684,7 @@ function ac_mt_command_answer$2(gobj, event, kw, src) {
15618
15684
  let topic_name = kw_get_str(gobj, kw_command, "topic_name", "", kw_flag_t.KW_REQUIRED);
15619
15685
  if (result >= 0) {
15620
15686
  let gobj_topic_form = gobj_find_child(gobj, { __gobj_name__: `${gobj_name(gobj)}?${topic_name}` });
15621
- gobj_send_event(gobj_topic_form, "EV_LOAD_NODES", data, gobj);
15687
+ gobj_send_event(gobj_topic_form, "EV_LOAD_NODES", nodes_answer(data).rows, gobj);
15622
15688
  }
15623
15689
  break;
15624
15690
  case "create-node":
@@ -15844,6 +15910,35 @@ function ac_create_record(gobj, event, kw, src) {
15844
15910
  });
15845
15911
  }
15846
15912
  /********************************************
15913
+ * Event from formtable: ONE field of one record, edited in place.
15914
+ *
15915
+ * Written as a PARTIAL update and, deliberately, with NO `autolink`.
15916
+ * `treedb_update_node()` merges (`json_object_update`), so the fields
15917
+ * it does not carry are left alone; and `autolink` is what wipes a
15918
+ * node's links to rebuild them from the fkeys the record carries — on
15919
+ * a partial record that reads as "no parents" and DETACHES the node,
15920
+ * answering success. A cell edit changes a scalar and must not be able
15921
+ * to touch a link at all.
15922
+ *
15923
+ * This is why it is its own event and not EV_UPDATE_RECORD: that one
15924
+ * DOES send autolink, and may, because the form hands it the whole
15925
+ * record with its fkeys in it.
15926
+ ********************************************/
15927
+ function ac_update_field(gobj, event, kw, src) {
15928
+ if (refuse_if_readonly$3(gobj, event)) return -1;
15929
+ let topic_name = kw.topic_name || gobj_read_attr(src, "topic_name");
15930
+ let desc = (gobj_read_attr(gobj, "descs") || {})[topic_name];
15931
+ let pkey = desc && desc.pkey || "id";
15932
+ if (kw.id === void 0 || kw.id === null || !kw.field) {
15933
+ log_error(`${gobj_short_name(gobj)}: a field write with no id or no field`);
15934
+ return -1;
15935
+ }
15936
+ let record = {};
15937
+ record[pkey] = kw.id;
15938
+ record[kw.field] = kw.value;
15939
+ return treedb_update_node$1(gobj, gobj_read_str_attr(gobj, "treedb_name"), topic_name, record, { list_dict: true });
15940
+ }
15941
+ /********************************************
15847
15942
  * Event from formtable
15848
15943
  * kw: {
15849
15944
  * topic_name,
@@ -15965,6 +16060,11 @@ function create_gclass$6(gclass_name) {
15965
16060
  ac_update_record,
15966
16061
  null
15967
16062
  ],
16063
+ [
16064
+ "EV_UPDATE_FIELD",
16065
+ ac_update_field,
16066
+ null
16067
+ ],
15968
16068
  [
15969
16069
  "EV_DELETE_RECORD",
15970
16070
  ac_delete_record,
@@ -16038,6 +16138,7 @@ function create_gclass$6(gclass_name) {
16038
16138
  ["EV_TREEDB_NODE_DELETED", event_flag_t.EVF_PUBLIC_EVENT],
16039
16139
  ["EV_CREATE_RECORD", 0],
16040
16140
  ["EV_UPDATE_RECORD", 0],
16141
+ ["EV_UPDATE_FIELD", 0],
16041
16142
  ["EV_DELETE_RECORD", 0],
16042
16143
  ["EV_RECORD_WRITTEN", event_flag_t.EVF_OUTPUT_EVENT | event_flag_t.EVF_NO_WARN_SUBS],
16043
16144
  ["EV_REFRESH_TOPIC", 0],
@@ -16251,6 +16352,7 @@ var attrs_table$5 = [
16251
16352
  SDATA(data_type_t.DTP_BOOLEAN, "with_columns_button", 0, true, "Button toolbar COLUMNS (choose which columns the table shows)"),
16252
16353
  SDATA(data_type_t.DTP_BOOLEAN, "with_export_button", 0, true, "Button toolbar EXPORT (download as CSV what the table holds)"),
16253
16354
  SDATA(data_type_t.DTP_BOOLEAN, "with_header_filters", 0, true, "Per-column filter box in the table header, on the columns a text/number match means something (not hooks, fkeys or json)"),
16355
+ SDATA(data_type_t.DTP_BOOLEAN, "with_inline_edit", 0, true, "Edit a writable scalar cell in place while in edition mode (the record form keeps the rest)"),
16254
16356
  SDATA(data_type_t.DTP_BOOLEAN, "with_in_row_edit_icons", 0, true, "Add a last column with internal EDIT/DELETE icon"),
16255
16357
  SDATA(data_type_t.DTP_BOOLEAN, "editable", 0, false, "Edit state"),
16256
16358
  SDATA(data_type_t.DTP_BOOLEAN, "with_checkbox", 0, true, "Auxiliary first column to select rows"),
@@ -16760,6 +16862,7 @@ function create_tabulator(gobj) {
16760
16862
  let table_id = gobj_read_str_attr(gobj, "table_id");
16761
16863
  let desc = gobj_read_attr(gobj, "desc");
16762
16864
  let with_header_filters = gobj_read_bool_attr(gobj, "with_header_filters");
16865
+ let with_inline_edit = gobj_read_bool_attr(gobj, "with_inline_edit");
16763
16866
  let columns = [];
16764
16867
  let with_checkbox = gobj_read_bool_attr(gobj, "with_checkbox");
16765
16868
  let with_radio = gobj_read_bool_attr(gobj, "with_radio");
@@ -16803,6 +16906,42 @@ function create_tabulator(gobj) {
16803
16906
  col_id: cell.getField()
16804
16907
  }, gobj);
16805
16908
  }
16909
+ const INLINE_EDITABLE = [
16910
+ "string",
16911
+ "integer",
16912
+ "real",
16913
+ "email",
16914
+ "url",
16915
+ "tel",
16916
+ "id",
16917
+ "hex",
16918
+ "currency",
16919
+ "percent",
16920
+ "uuid"
16921
+ ];
16922
+ function inline_editor_for(colDef, col, field_desc) {
16923
+ if (!field_desc.is_writable) return;
16924
+ if (col.id === (desc.pkey || "id")) return;
16925
+ switch (field_desc.type) {
16926
+ case "boolean":
16927
+ colDef.editor = "tickCross";
16928
+ break;
16929
+ case "enum":
16930
+ colDef.editor = "list";
16931
+ colDef.editorParams = { values: field_desc.enum_list || [] };
16932
+ break;
16933
+ case "integer":
16934
+ case "real":
16935
+ colDef.editor = "number";
16936
+ break;
16937
+ default:
16938
+ if (!INLINE_EDITABLE.includes(field_desc.type)) return;
16939
+ colDef.editor = "input";
16940
+ }
16941
+ colDef.editable = function() {
16942
+ return gobj_read_bool_attr(gobj, "editable") && !gobj_read_bool_attr(gobj, "readonly");
16943
+ };
16944
+ }
16806
16945
  const FILTERABLE = [
16807
16946
  "string",
16808
16947
  "integer",
@@ -16925,6 +17064,7 @@ function create_tabulator(gobj) {
16925
17064
  formatter: colFormatter
16926
17065
  };
16927
17066
  if (with_header_filters) apply_header_filter(colDef, field_desc);
17067
+ if (with_inline_edit) inline_editor_for(colDef, col, field_desc);
16928
17068
  if (vertAlign) colDef.vertAlign = vertAlign;
16929
17069
  if (formatterParams) colDef.formatterParams = formatterParams;
16930
17070
  if (cellClick) colDef.cellClick = cellClick;
@@ -17011,6 +17151,21 @@ function create_tabulator(gobj) {
17011
17151
  });
17012
17152
  tabulator.on("dataProcessed", update_rowcount);
17013
17153
  tabulator.on("dataChanged", update_rowcount);
17154
+ tabulator.on("cellEdited", function(cell) {
17155
+ let pkey = (gobj_read_attr(gobj, "desc") || {}).pkey || "id";
17156
+ let data = cell.getRow().getData();
17157
+ let id = data ? data[pkey] : void 0;
17158
+ if (id === void 0 || id === null) {
17159
+ log_error(`${gobj_short_name(gobj)}: edited a row with no '${pkey}'`);
17160
+ cell.restoreOldValue();
17161
+ return;
17162
+ }
17163
+ gobj_send_event(gobj, "EV_CELL_EDITED", {
17164
+ id,
17165
+ field: cell.getField(),
17166
+ value: cell.getValue()
17167
+ }, gobj);
17168
+ });
17014
17169
  tabulator.on("dataFiltered", function(filters, rows) {
17015
17170
  update_rowcount(Array.isArray(rows) ? rows.length : void 0);
17016
17171
  });
@@ -18131,6 +18286,20 @@ function ac_show_cell_json(gobj, event, kw, src) {
18131
18286
  return 0;
18132
18287
  }
18133
18288
  /************************************************************
18289
+ * One cell of one record changed. Publish it UP as a field write;
18290
+ * the host owns the treedb command and its options.
18291
+ ************************************************************/
18292
+ function ac_cell_edited(gobj, event, kw, src) {
18293
+ if (refuse_if_readonly$2(gobj, event)) return -1;
18294
+ gobj_publish_event(gobj, "EV_UPDATE_FIELD", {
18295
+ topic_name: gobj_read_str_attr(gobj, "topic_name"),
18296
+ id: kw.id,
18297
+ field: kw.field,
18298
+ value: kw.value
18299
+ });
18300
+ return 0;
18301
+ }
18302
+ /************************************************************
18134
18303
  * Show the schema (desc) of this topic
18135
18304
  ************************************************************/
18136
18305
  function ac_show_schema(gobj, event, kw, src) {
@@ -18380,6 +18549,11 @@ function create_gclass$5(gclass_name) {
18380
18549
  ac_show_schema,
18381
18550
  null
18382
18551
  ],
18552
+ [
18553
+ "EV_CELL_EDITED",
18554
+ ac_cell_edited,
18555
+ null
18556
+ ],
18383
18557
  [
18384
18558
  "EV_SEARCH",
18385
18559
  ac_search,
@@ -18430,6 +18604,8 @@ function create_gclass$5(gclass_name) {
18430
18604
  ["EV_CHANGE_LOCALE", 0],
18431
18605
  ["EV_REFRESH", 0],
18432
18606
  ["EV_SHOW_SCHEMA", 0],
18607
+ ["EV_CELL_EDITED", 0],
18608
+ ["EV_UPDATE_FIELD", event_flag_t.EVF_OUTPUT_EVENT],
18433
18609
  ["EV_SEARCH", 0],
18434
18610
  ["EV_OPEN_COLUMNS", 0],
18435
18611
  ["EV_TOGGLE_COLUMN", 0],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuneta/gobj-ui",
3
- "version": "7.7.1",
3
+ "version": "7.8.1",
4
4
  "type": "module",
5
5
  "main": "dist/gobj-ui.cjs.js",
6
6
  "module": "dist/gobj-ui.es.js",
@@ -111,6 +111,7 @@ SDATA(data_type_t.DTP_BOOLEAN, "with_schema_button", 0, true, "Button
111
111
  SDATA(data_type_t.DTP_BOOLEAN, "with_columns_button", 0, true, "Button toolbar COLUMNS (choose which columns the table shows)"),
112
112
  SDATA(data_type_t.DTP_BOOLEAN, "with_export_button", 0, true, "Button toolbar EXPORT (download as CSV what the table holds)"),
113
113
  SDATA(data_type_t.DTP_BOOLEAN, "with_header_filters", 0, true, "Per-column filter box in the table header, on the columns a text/number match means something (not hooks, fkeys or json)"),
114
+ SDATA(data_type_t.DTP_BOOLEAN, "with_inline_edit", 0, true, "Edit a writable scalar cell in place while in edition mode (the record form keeps the rest)"),
114
115
  SDATA(data_type_t.DTP_BOOLEAN, "with_in_row_edit_icons", 0, true, "Add a last column with internal EDIT/DELETE icon"),
115
116
 
116
117
  SDATA(data_type_t.DTP_BOOLEAN, "editable", 0, false, "Edit state"),
@@ -730,6 +731,7 @@ function create_tabulator(gobj)
730
731
  let table_id = gobj_read_str_attr(gobj, "table_id");
731
732
  let desc = gobj_read_attr(gobj, "desc");
732
733
  let with_header_filters = gobj_read_bool_attr(gobj, "with_header_filters");
734
+ let with_inline_edit = gobj_read_bool_attr(gobj, "with_inline_edit");
733
735
 
734
736
  let columns = [];
735
737
 
@@ -819,6 +821,62 @@ function create_tabulator(gobj)
819
821
  * value is stringified first because a fkey can arrive as a ref string,
820
822
  * a list of them or a dict.
821
823
  */
824
+ /* A cell you can type into, and the ones you cannot.
825
+ *
826
+ * Editing in place is for a SCALAR the table can round-trip. A hook
827
+ * holds children and an fkey IS a link — both are edited by linking,
828
+ * not by typing — a dict or a list is a document (the form has an
829
+ * editor for it), and a date cell shows a formatted string over an
830
+ * epoch, so typing into it would write the string. Those stay with
831
+ * the form, which is one click away on the same row.
832
+ *
833
+ * The schema decides the rest: only a column flagged `writable` is
834
+ * offered, and never the pkey — renaming what a record is KEYED by
835
+ * is not a field edit.
836
+ */
837
+ const INLINE_EDITABLE = [
838
+ "string", "integer", "real",
839
+ "email", "url", "tel", "id", "hex", "currency", "percent", "uuid"
840
+ ];
841
+
842
+ function inline_editor_for(colDef, col, field_desc)
843
+ {
844
+ if(!field_desc.is_writable) {
845
+ return;
846
+ }
847
+ if(col.id === (desc.pkey || "id")) {
848
+ return;
849
+ }
850
+
851
+ switch(field_desc.type) {
852
+ case "boolean":
853
+ colDef.editor = "tickCross";
854
+ break;
855
+ case "enum":
856
+ colDef.editor = "list";
857
+ colDef.editorParams = {values: field_desc.enum_list || []};
858
+ break;
859
+ case "integer":
860
+ case "real":
861
+ colDef.editor = "number";
862
+ break;
863
+ default:
864
+ if(!INLINE_EDITABLE.includes(field_desc.type)) {
865
+ return;
866
+ }
867
+ colDef.editor = "input";
868
+ break;
869
+ }
870
+
871
+ /* A FUNCTION, not a flag: edition mode is toggled on a table that
872
+ * is already built (ac_edition_mode only shows and hides columns),
873
+ * so the answer has to be asked for at the moment of the click. */
874
+ colDef.editable = function() {
875
+ return gobj_read_bool_attr(gobj, "editable") &&
876
+ !gobj_read_bool_attr(gobj, "readonly");
877
+ };
878
+ }
879
+
822
880
  const FILTERABLE = [
823
881
  "string", "integer", "real", "rowid", "uuid", "qualified",
824
882
  "email", "url", "tel", "id", "hex", "currency", "percent"
@@ -974,6 +1032,9 @@ function create_tabulator(gobj)
974
1032
  if(with_header_filters) {
975
1033
  apply_header_filter(colDef, field_desc);
976
1034
  }
1035
+ if(with_inline_edit) {
1036
+ inline_editor_for(colDef, col, field_desc);
1037
+ }
977
1038
  if(vertAlign) {
978
1039
  colDef.vertAlign = vertAlign;
979
1040
  }
@@ -1139,6 +1200,33 @@ function create_tabulator(gobj)
1139
1200
  * box; a filter per column made it a claim you read on every keystroke. */
1140
1201
  tabulator.on("dataProcessed", update_rowcount);
1141
1202
  tabulator.on("dataChanged", update_rowcount);
1203
+ /* A cell edited in place. Announced as ONE FIELD of one record — not
1204
+ * as the row — and that is the whole safety of it: the host writes it
1205
+ * with a partial update and no `autolink`, so nothing but that field
1206
+ * moves. `autolink` wipes a node's links and rebuilds them from the
1207
+ * fkeys the record carries, so sending a row that the table shaped for
1208
+ * DISPLAY would unlink the node and answer success. */
1209
+ tabulator.on("cellEdited", function(cell) {
1210
+ let pkey = (gobj_read_attr(gobj, "desc") || {}).pkey || "id";
1211
+ let data = cell.getRow().getData();
1212
+ let id = data ? data[pkey] : undefined;
1213
+ if(id === undefined || id === null) {
1214
+ log_error(`${gobj_short_name(gobj)}: edited a row with no '${pkey}'`);
1215
+ cell.restoreOldValue();
1216
+ return;
1217
+ }
1218
+ gobj_send_event(
1219
+ gobj,
1220
+ "EV_CELL_EDITED",
1221
+ {
1222
+ id: id,
1223
+ field: cell.getField(),
1224
+ value: cell.getValue()
1225
+ },
1226
+ gobj
1227
+ );
1228
+ });
1229
+
1142
1230
  tabulator.on("dataFiltered", function(filters, rows) {
1143
1231
  update_rowcount(Array.isArray(rows)? rows.length : undefined);
1144
1232
  });
@@ -2845,6 +2933,28 @@ function ac_show_cell_json(gobj, event, kw, src)
2845
2933
  return 0;
2846
2934
  }
2847
2935
 
2936
+ /************************************************************
2937
+ * One cell of one record changed. Publish it UP as a field write;
2938
+ * the host owns the treedb command and its options.
2939
+ ************************************************************/
2940
+ function ac_cell_edited(gobj, event, kw, src)
2941
+ {
2942
+ if(refuse_if_readonly(gobj, event)) {
2943
+ return -1; /* Error already logged */
2944
+ }
2945
+ gobj_publish_event(
2946
+ gobj,
2947
+ "EV_UPDATE_FIELD",
2948
+ {
2949
+ topic_name: gobj_read_str_attr(gobj, "topic_name"),
2950
+ id: kw.id,
2951
+ field: kw.field,
2952
+ value: kw.value
2953
+ }
2954
+ );
2955
+ return 0;
2956
+ }
2957
+
2848
2958
  /************************************************************
2849
2959
  * Show the schema (desc) of this topic
2850
2960
  ************************************************************/
@@ -3076,6 +3186,7 @@ function create_gclass(gclass_name)
3076
3186
  ["EV_CHANGE_LOCALE", ac_change_locale, null],
3077
3187
  ["EV_REFRESH", ac_refresh, null],
3078
3188
  ["EV_SHOW_SCHEMA", ac_show_schema, null],
3189
+ ["EV_CELL_EDITED", ac_cell_edited, null],
3079
3190
  ["EV_SEARCH", ac_search, null],
3080
3191
  ["EV_OPEN_COLUMNS", ac_open_columns, null],
3081
3192
  ["EV_TOGGLE_COLUMN", ac_toggle_column, null],
@@ -3108,6 +3219,8 @@ function create_gclass(gclass_name)
3108
3219
  ["EV_CHANGE_LOCALE", 0],
3109
3220
  ["EV_REFRESH", 0],
3110
3221
  ["EV_SHOW_SCHEMA", 0],
3222
+ ["EV_CELL_EDITED", 0],
3223
+ ["EV_UPDATE_FIELD", event_flag_t.EVF_OUTPUT_EVENT],
3111
3224
  ["EV_SEARCH", 0],
3112
3225
  ["EV_OPEN_COLUMNS", 0],
3113
3226
  ["EV_TOGGLE_COLUMN", 0],
@@ -58,6 +58,7 @@ import "./c_yui_treedb_topics.css";
58
58
 
59
59
  import {yui_shell_show_error, yui_shell_show_modal, yui_shell_popup_layer} from "./shell_modals.js";
60
60
  import {yui_shell_of, yui_shell_set_sub_routes} from "./c_yui_shell.js";
61
+ import {nodes_answer} from "./nodes_answer.js";
61
62
 
62
63
  import {t} from "i18next";
63
64
 
@@ -1740,6 +1741,18 @@ function ac_mt_command_answer(gobj, event, kw, src)
1740
1741
  show_load_error(gobj, t(comment));
1741
1742
  } else {
1742
1743
  yui_shell_show_error(yui_shell_of(gobj), comment, {t: t});
1744
+ /* A refused write leaves the TABLE showing what the operator
1745
+ * typed and the store holding what it had. That is tolerable
1746
+ * for a form (it stays open on the values it failed with) and
1747
+ * is not for a cell edited in place, which looks saved. Put the
1748
+ * topic back to what the treedb actually has. */
1749
+ if(command === "update-node" || command === "create-node") {
1750
+ let failed_topic = kw_get_str(gobj, kw_command, "topic_name", "", 0);
1751
+ if(failed_topic) {
1752
+ treedb_nodes(gobj, gobj_read_str_attr(gobj, "treedb_name"),
1753
+ failed_topic, {list_dict: true});
1754
+ }
1755
+ }
1743
1756
  }
1744
1757
  return 0;
1745
1758
  }
@@ -1758,10 +1771,18 @@ function ac_mt_command_answer(gobj, event, kw, src)
1758
1771
  let gobj_topic_form = gobj_find_child(gobj, {
1759
1772
  __gobj_name__: `${gobj_name(gobj)}?${topic_name}`
1760
1773
  });
1774
+ /* `nodes` answers a plain list, or — when asked for a page —
1775
+ * the {total_rows, pages, data} envelope. Both shapes are
1776
+ * alive at once and will be for a long time: this SPA talks
1777
+ * to backends the operator configures, and an older one
1778
+ * answers the plain list whatever is asked of it. Reading
1779
+ * the shape rather than assuming it is what keeps the table
1780
+ * from rendering an object's keys as rows the day a backend
1781
+ * upgrades. */
1761
1782
  gobj_send_event(
1762
1783
  gobj_topic_form,
1763
1784
  "EV_LOAD_NODES",
1764
- data,
1785
+ nodes_answer(data).rows,
1765
1786
  gobj
1766
1787
  );
1767
1788
  }
@@ -2126,6 +2147,49 @@ function ac_create_record(gobj, event, kw, src)
2126
2147
  );
2127
2148
  }
2128
2149
 
2150
+ /********************************************
2151
+ * Event from formtable: ONE field of one record, edited in place.
2152
+ *
2153
+ * Written as a PARTIAL update and, deliberately, with NO `autolink`.
2154
+ * `treedb_update_node()` merges (`json_object_update`), so the fields
2155
+ * it does not carry are left alone; and `autolink` is what wipes a
2156
+ * node's links to rebuild them from the fkeys the record carries — on
2157
+ * a partial record that reads as "no parents" and DETACHES the node,
2158
+ * answering success. A cell edit changes a scalar and must not be able
2159
+ * to touch a link at all.
2160
+ *
2161
+ * This is why it is its own event and not EV_UPDATE_RECORD: that one
2162
+ * DOES send autolink, and may, because the form hands it the whole
2163
+ * record with its fkeys in it.
2164
+ ********************************************/
2165
+ function ac_update_field(gobj, event, kw, src)
2166
+ {
2167
+ if(refuse_if_readonly(gobj, event)) {
2168
+ return -1; /* Error already logged */
2169
+ }
2170
+ let topic_name = kw.topic_name || gobj_read_attr(src, "topic_name");
2171
+ let descs = gobj_read_attr(gobj, "descs") || {};
2172
+ let desc = descs[topic_name];
2173
+ let pkey = (desc && desc.pkey) || "id";
2174
+
2175
+ if(kw.id === undefined || kw.id === null || !kw.field) {
2176
+ log_error(`${gobj_short_name(gobj)}: a field write with no id or no field`);
2177
+ return -1;
2178
+ }
2179
+
2180
+ let record = {};
2181
+ record[pkey] = kw.id;
2182
+ record[kw.field] = kw.value;
2183
+
2184
+ return treedb_update_node(
2185
+ gobj,
2186
+ gobj_read_str_attr(gobj, "treedb_name"),
2187
+ topic_name,
2188
+ record,
2189
+ {list_dict: true}
2190
+ );
2191
+ }
2192
+
2129
2193
  /********************************************
2130
2194
  * Event from formtable
2131
2195
  * kw: {
@@ -2292,6 +2356,7 @@ function create_gclass(gclass_name)
2292
2356
  ["EV_TREEDB_NODE_DELETED", ac_treedb_node_deleted, null],
2293
2357
  ["EV_CREATE_RECORD", ac_create_record, null],
2294
2358
  ["EV_UPDATE_RECORD", ac_update_record, null],
2359
+ ["EV_UPDATE_FIELD", ac_update_field, null],
2295
2360
  ["EV_DELETE_RECORD", ac_delete_record, null],
2296
2361
  ["EV_REFRESH_TOPIC", ac_refresh_topic, null],
2297
2362
  ["EV_OPEN_JSON", ac_open_json, null],
@@ -2318,6 +2383,7 @@ function create_gclass(gclass_name)
2318
2383
  ["EV_TREEDB_NODE_DELETED", event_flag_t.EVF_PUBLIC_EVENT],
2319
2384
  ["EV_CREATE_RECORD", 0],
2320
2385
  ["EV_UPDATE_RECORD", 0],
2386
+ ["EV_UPDATE_FIELD", 0],
2321
2387
  ["EV_DELETE_RECORD", 0],
2322
2388
  ["EV_RECORD_WRITTEN", event_flag_t.EVF_OUTPUT_EVENT|
2323
2389
  event_flag_t.EVF_NO_WARN_SUBS],
@@ -0,0 +1,72 @@
1
+ /***********************************************************************
2
+ * nodes_answer.js
3
+ *
4
+ * What the `nodes` command answered, whatever shape it came in.
5
+ *
6
+ * A treedb lives in memory on the backend, so walking it is not
7
+ * what costs: serializing every node, pushing it through a
8
+ * websocket and parsing it here is. So `nodes` learned to answer a
9
+ * PAGE, with the contract `list-keys` of C_TRANGER already used:
10
+ *
11
+ * no `limit` -> the plain list it has always answered
12
+ * a `limit` -> {total_rows, pages, data}
13
+ *
14
+ * Both shapes are alive at once and will be for a long time: the
15
+ * SPA talks to backends the operator configures, and an older one
16
+ * answers the plain list whatever this app asks for. So the shape
17
+ * is read, never assumed — a view that indexed `data` as an array
18
+ * would render an object's keys as rows the day a backend
19
+ * upgraded, which is the kind of break that looks like a bug in
20
+ * the app.
21
+ *
22
+ * Pure and tested, because this is exactly the sort of thing that
23
+ * cannot be exercised until the other side moves.
24
+ *
25
+ * Copyright (c) 2026, ArtGins.
26
+ * All Rights Reserved.
27
+ ***********************************************************************/
28
+
29
+ /***************************************************************
30
+ * nodes_answer(data) -> {rows, total, pages, paged}
31
+ *
32
+ * `rows` is always an array, so a caller never has to ask which
33
+ * shape it got. `paged` says whether the backend answered a page,
34
+ * which is the one thing a caller may legitimately care about (it is
35
+ * what tells a pager it has something to page).
36
+ ***************************************************************/
37
+ function nodes_answer(data)
38
+ {
39
+ if(Array.isArray(data)) {
40
+ return {
41
+ rows: data,
42
+ total: data.length,
43
+ pages: 1,
44
+ paged: false
45
+ };
46
+ }
47
+
48
+ if(data && typeof data === "object" && Array.isArray(data.data)) {
49
+ let rows = data.data;
50
+ let total = (typeof data.total_rows === "number")? data.total_rows : rows.length;
51
+ let pages = (typeof data.pages === "number" && data.pages > 0)? data.pages : 1;
52
+ return {
53
+ rows: rows,
54
+ total: total,
55
+ pages: pages,
56
+ paged: true
57
+ };
58
+ }
59
+
60
+ /* Anything else — null, an error body, a shape nobody has seen — is
61
+ * no rows. The caller renders an empty table, which is honest, and
62
+ * the command answer's own `result` is what says something failed. */
63
+ return {
64
+ rows: [],
65
+ total: 0,
66
+ pages: 1,
67
+ paged: false
68
+ };
69
+ }
70
+
71
+
72
+ export {nodes_answer};
@@ -0,0 +1,50 @@
1
+ import {describe, it, expect} from "vitest";
2
+ import {nodes_answer} from "./nodes_answer.js";
3
+
4
+ describe("nodes_answer", () => {
5
+ it("reads the plain list an unpaged backend answers", () => {
6
+ let a = nodes_answer([{id: "a"}, {id: "b"}]);
7
+ expect(a.rows.length).toBe(2);
8
+ expect(a.total).toBe(2);
9
+ expect(a.pages).toBe(1);
10
+ expect(a.paged).toBe(false);
11
+ });
12
+
13
+ it("reads the envelope a paged backend answers", () => {
14
+ let a = nodes_answer({total_rows: 120, pages: 3, data: [{id: "a"}]});
15
+ expect(a.rows.length).toBe(1);
16
+ expect(a.total).toBe(120);
17
+ expect(a.pages).toBe(3);
18
+ expect(a.paged).toBe(true);
19
+ });
20
+
21
+ it("an empty page still reports the true total", () => {
22
+ let a = nodes_answer({total_rows: 120, pages: 3, data: []});
23
+ expect(a.rows).toEqual([]);
24
+ expect(a.total).toBe(120);
25
+ expect(a.paged).toBe(true);
26
+ });
27
+
28
+ it("an empty list is not a page", () => {
29
+ let a = nodes_answer([]);
30
+ expect(a.rows).toEqual([]);
31
+ expect(a.total).toBe(0);
32
+ expect(a.paged).toBe(false);
33
+ });
34
+
35
+ it("falls back to the row count when the envelope omits its totals", () => {
36
+ let a = nodes_answer({data: [{id: "a"}, {id: "b"}]});
37
+ expect(a.total).toBe(2);
38
+ expect(a.pages).toBe(1);
39
+ expect(a.paged).toBe(true);
40
+ });
41
+
42
+ it("gives no rows for anything it does not recognise", () => {
43
+ for(let bad of [null, undefined, 0, "x", {}, {data: "nope"}]) {
44
+ let a = nodes_answer(bad);
45
+ expect(a.rows).toEqual([]);
46
+ expect(a.total).toBe(0);
47
+ expect(a.paged).toBe(false);
48
+ }
49
+ });
50
+ });