@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.
- package/README.md +33 -0
- package/dist/gobj-ui.cjs.js +264 -5
- package/dist/gobj-ui.es.js +264 -5
- package/package.json +1 -1
- package/src/c_yui_treedb_topic_with_form.js +163 -0
- package/src/c_yui_treedb_topics.js +108 -11
- package/src/nodes_answer.js +72 -0
- package/src/nodes_answer.test.js +50 -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
|
|
|
@@ -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
|
-
|
|
1752
|
-
|
|
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
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
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
|
+
});
|