@yuneta/gobj-ui 7.8.0 → 7.9.0

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.
@@ -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
 
@@ -74,6 +75,7 @@ SDATA(data_type_t.DTP_POINTER, "subscriber", 0, null, "Subscriber of o
74
75
  SDATA(data_type_t.DTP_POINTER, "gobj_remote_yuno", 0, null, "Remote yuno for data fetching"),
75
76
  SDATA(data_type_t.DTP_STRING, "treedb_name", 0, null, "Remote TreeDB service name"),
76
77
  SDATA(data_type_t.DTP_JSON, "descs", 0, null, "Description of topics"),
78
+ SDATA(data_type_t.DTP_BOOLEAN, "with_remote_paging", 0, false, "Each topic table pulls its rows a PAGE at a time (`nodes` from/limit) instead of loading the topic whole. Safe against a backend that cannot page: it answers the whole list, which the table reads as one page"),
77
79
  SDATA(data_type_t.DTP_BOOLEAN, "system", 0, false, "Manage system topics (true) or user topics (false)"),
78
80
  SDATA(data_type_t.DTP_STRING, "tabs_style", 0, "is-toggle is-fullwidth", "Bulma tab styling"),
79
81
  SDATA(data_type_t.DTP_BOOLEAN, "with_cards_landing",0, false, "Land on a grid of topic cards (list->detail): a card opens its table, with the tabs bar + a back-to-grid button. Off = tabs only (legacy)."),
@@ -1036,7 +1038,8 @@ function process_treedb_descs(gobj)
1036
1038
 
1037
1039
  treedb_name: treedb_name,
1038
1040
  topic_name: key,
1039
- desc: desc
1041
+ desc: desc,
1042
+ with_remote_paging: gobj_read_bool_attr(gobj, "with_remote_paging")
1040
1043
  };
1041
1044
 
1042
1045
  let id = `${gobj_name(gobj)}?${desc.topic_name}`;
@@ -1170,8 +1173,15 @@ function get_nodes(gobj, topic_name)
1170
1173
  subscribe_treedb(gobj, topic_name);
1171
1174
 
1172
1175
  /*
1173
- * Get data
1176
+ * Get data — unless the table pulls its own pages, in which case it has
1177
+ * already asked (or is about to) and a second whole-topic fetch would be
1178
+ * the very transfer paging exists to avoid.
1174
1179
  */
1180
+ let gobj_topic_form = get_gobj_formtable(gobj, topic_name);
1181
+ if(gobj_topic_form && gobj_read_bool_attr(gobj_topic_form, "with_remote_paging")) {
1182
+ return;
1183
+ }
1184
+
1175
1185
  treedb_nodes(
1176
1186
  gobj,
1177
1187
  treedb_name,
@@ -1327,7 +1337,7 @@ function get_gobj_formtable(gobj, topic_name)
1327
1337
  /************************************************************
1328
1338
  * Command to remote service
1329
1339
  ************************************************************/
1330
- function treedb_nodes(gobj, treedb_name, topic_name, options)
1340
+ function treedb_nodes(gobj, treedb_name, topic_name, options, page)
1331
1341
  {
1332
1342
  let command = "nodes";
1333
1343
 
@@ -1343,6 +1353,16 @@ function treedb_nodes(gobj, treedb_name, topic_name, options)
1343
1353
  topic_name: topic_name,
1344
1354
  };
1345
1355
 
1356
+ /* A PAGE, when the table asked for one. `from` is 1-based and a
1357
+ * backend that does not know these two answers the whole list, which
1358
+ * nodes_answer() reads as one page — so an old backend degrades to
1359
+ * exactly what it always did. */
1360
+ if(page) {
1361
+ kw.from = page.from;
1362
+ kw.limit = page.limit;
1363
+ kw.__md_command__.req_id = page.req_id;
1364
+ }
1365
+
1346
1366
  // TODO review msg_iev_write_key(kw, "__topic_name__", topic_name);
1347
1367
 
1348
1368
  let ret = gobj_command(
@@ -1353,7 +1373,9 @@ function treedb_nodes(gobj, treedb_name, topic_name, options)
1353
1373
  );
1354
1374
  if(ret) {
1355
1375
  log_error(ret);
1376
+ return -1;
1356
1377
  }
1378
+ return 0;
1357
1379
  }
1358
1380
 
1359
1381
  /************************************************************
@@ -1748,8 +1770,8 @@ function ac_mt_command_answer(gobj, event, kw, src)
1748
1770
  if(command === "update-node" || command === "create-node") {
1749
1771
  let failed_topic = kw_get_str(gobj, kw_command, "topic_name", "", 0);
1750
1772
  if(failed_topic) {
1751
- treedb_nodes(gobj, gobj_read_str_attr(gobj, "treedb_name"),
1752
- failed_topic, {list_dict: true});
1773
+ gobj_send_event(gobj, "EV_REFRESH_TOPIC",
1774
+ {topic_name: failed_topic}, gobj);
1753
1775
  }
1754
1776
  }
1755
1777
  }
@@ -1770,12 +1792,43 @@ function ac_mt_command_answer(gobj, event, kw, src)
1770
1792
  let gobj_topic_form = gobj_find_child(gobj, {
1771
1793
  __gobj_name__: `${gobj_name(gobj)}?${topic_name}`
1772
1794
  });
1773
- gobj_send_event(
1774
- gobj_topic_form,
1775
- "EV_LOAD_NODES",
1776
- data,
1777
- gobj
1778
- );
1795
+ /* `nodes` answers a plain list, or — when asked for a page —
1796
+ * the {total_rows, pages, data} envelope. Both shapes are
1797
+ * alive at once and will be for a long time: this SPA talks
1798
+ * to backends the operator configures, and an older one
1799
+ * answers the plain list whatever is asked of it. Reading
1800
+ * the shape rather than assuming it is what keeps the table
1801
+ * from rendering an object's keys as rows the day a backend
1802
+ * upgrades — and it is what lets a paging table survive a
1803
+ * backend that cannot page: the whole list arrives as one
1804
+ * page, which is the truth. */
1805
+ let answer = nodes_answer(data);
1806
+ /* The correlation id travels in `__md_command__` of the kw
1807
+ * that was SENT — the same place `topic_name` rides — so it
1808
+ * is read back from the command stack and never sent to the
1809
+ * backend as a parameter it does not know. */
1810
+ let md = kw_get_dict(gobj, kw_command, "__md_command__", {}, 0);
1811
+ let req_id = kw_get_str(gobj, md, "req_id", "", 0);
1812
+ if(req_id) {
1813
+ gobj_send_event(
1814
+ gobj_topic_form,
1815
+ "EV_PAGE_LOADED",
1816
+ {
1817
+ req_id: req_id,
1818
+ rows: answer.rows,
1819
+ pages: answer.pages,
1820
+ total: answer.total
1821
+ },
1822
+ gobj
1823
+ );
1824
+ } else {
1825
+ gobj_send_event(
1826
+ gobj_topic_form,
1827
+ "EV_LOAD_NODES",
1828
+ answer.rows,
1829
+ gobj
1830
+ );
1831
+ }
1779
1832
  }
1780
1833
  break;
1781
1834
 
@@ -2138,6 +2191,40 @@ function ac_create_record(gobj, event, kw, src)
2138
2191
  );
2139
2192
  }
2140
2193
 
2194
+ /********************************************
2195
+ * A topic table wants a page.
2196
+ *
2197
+ * The transport is ours, so the table asks and we fetch; the answer
2198
+ * finds its way back by the `req_id` we echo. A backend that cannot
2199
+ * page answers the whole list, which the table takes as one page —
2200
+ * which is the truth, and is why the table can ask without knowing
2201
+ * what it is talking to.
2202
+ ********************************************/
2203
+ function ac_request_page(gobj, event, kw, src)
2204
+ {
2205
+ let topic_name = kw.topic_name || gobj_read_attr(src, "topic_name");
2206
+ if(!topic_name || !kw.req_id) {
2207
+ log_error(`${gobj_short_name(gobj)}: a page request with no topic or no id`);
2208
+ return -1;
2209
+ }
2210
+
2211
+ let ret = treedb_nodes(
2212
+ gobj,
2213
+ gobj_read_str_attr(gobj, "treedb_name"),
2214
+ topic_name,
2215
+ {list_dict: true},
2216
+ {from: kw.from, limit: kw.limit, req_id: kw.req_id}
2217
+ );
2218
+
2219
+ if(ret < 0) {
2220
+ /* The command never went out, so no answer will ever come: tell the
2221
+ * table now instead of leaving it spinning until its watchdog. */
2222
+ gobj_send_event(src, "EV_PAGE_FAILED",
2223
+ {req_id: kw.req_id, error: "cannot reach the backend"}, gobj);
2224
+ }
2225
+ return 0;
2226
+ }
2227
+
2141
2228
  /********************************************
2142
2229
  * Event from formtable: ONE field of one record, edited in place.
2143
2230
  *
@@ -2247,6 +2334,14 @@ function ac_delete_record(gobj, event, kw, src)
2247
2334
  ********************************************/
2248
2335
  function ac_refresh_topic(gobj, event, kw, src)
2249
2336
  {
2337
+ let gobj_topic_form = get_gobj_formtable(gobj, kw.topic_name);
2338
+ if(gobj_topic_form && gobj_read_bool_attr(gobj_topic_form, "with_remote_paging")) {
2339
+ /* A table that pulls refreshes by pulling: pushing a whole topic
2340
+ * into a paged table would replace its page with everything. */
2341
+ gobj_send_event(gobj_topic_form, "EV_REPULL_PAGE", {}, gobj);
2342
+ return 0;
2343
+ }
2344
+
2250
2345
  let options = {
2251
2346
  list_dict: true
2252
2347
  };
@@ -2348,6 +2443,7 @@ function create_gclass(gclass_name)
2348
2443
  ["EV_CREATE_RECORD", ac_create_record, null],
2349
2444
  ["EV_UPDATE_RECORD", ac_update_record, null],
2350
2445
  ["EV_UPDATE_FIELD", ac_update_field, null],
2446
+ ["EV_REQUEST_PAGE", ac_request_page, null],
2351
2447
  ["EV_DELETE_RECORD", ac_delete_record, null],
2352
2448
  ["EV_REFRESH_TOPIC", ac_refresh_topic, null],
2353
2449
  ["EV_OPEN_JSON", ac_open_json, null],
@@ -2375,6 +2471,7 @@ function create_gclass(gclass_name)
2375
2471
  ["EV_CREATE_RECORD", 0],
2376
2472
  ["EV_UPDATE_RECORD", 0],
2377
2473
  ["EV_UPDATE_FIELD", 0],
2474
+ ["EV_REQUEST_PAGE", 0],
2378
2475
  ["EV_DELETE_RECORD", 0],
2379
2476
  ["EV_RECORD_WRITTEN", event_flag_t.EVF_OUTPUT_EVENT|
2380
2477
  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
+ });