@yuneta/gobj-ui 7.8.0 → 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
@@ -15629,7 +15689,7 @@ function ac_mt_command_answer$2(gobj, event, kw, src) {
15629
15689
  let topic_name = (0, _yuneta_gobj_js.kw_get_str)(gobj, kw_command, "topic_name", "", _yuneta_gobj_js.kw_flag_t.KW_REQUIRED);
15630
15690
  if (result >= 0) {
15631
15691
  let gobj_topic_form = (0, _yuneta_gobj_js.gobj_find_child)(gobj, { __gobj_name__: `${(0, _yuneta_gobj_js.gobj_name)(gobj)}?${topic_name}` });
15632
- (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);
15633
15693
  }
15634
15694
  break;
15635
15695
  case "create-node":
@@ -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
@@ -15624,7 +15684,7 @@ function ac_mt_command_answer$2(gobj, event, kw, src) {
15624
15684
  let topic_name = kw_get_str(gobj, kw_command, "topic_name", "", kw_flag_t.KW_REQUIRED);
15625
15685
  if (result >= 0) {
15626
15686
  let gobj_topic_form = gobj_find_child(gobj, { __gobj_name__: `${gobj_name(gobj)}?${topic_name}` });
15627
- 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);
15628
15688
  }
15629
15689
  break;
15630
15690
  case "create-node":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuneta/gobj-ui",
3
- "version": "7.8.0",
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",
@@ -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
 
@@ -1770,10 +1771,18 @@ function ac_mt_command_answer(gobj, event, kw, src)
1770
1771
  let gobj_topic_form = gobj_find_child(gobj, {
1771
1772
  __gobj_name__: `${gobj_name(gobj)}?${topic_name}`
1772
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. */
1773
1782
  gobj_send_event(
1774
1783
  gobj_topic_form,
1775
1784
  "EV_LOAD_NODES",
1776
- data,
1785
+ nodes_answer(data).rows,
1777
1786
  gobj
1778
1787
  );
1779
1788
  }
@@ -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
+ });