@yuneta/gobj-ui 7.25.18 → 7.25.19

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/src/c_yui_node.js CHANGED
@@ -1278,6 +1278,65 @@ function ac_nav_clicked(gobj, event, kw, src)
1278
1278
  return 0;
1279
1279
  }
1280
1280
 
1281
+ /************************************************************
1282
+ * The ✕ of a closable item, from a projection nav. A node's own
1283
+ * items never carry `closable` (child_nav_items() builds them), so
1284
+ * this is an item the node does not own the fate of: whether it
1285
+ * goes away is the app's call, as for the shell's own closable
1286
+ * tabs. Handed to the shell, which re-publishes it to the app
1287
+ * (ac_nav_item_close there).
1288
+ ************************************************************/
1289
+ function ac_nav_item_close(gobj, event, kw, src)
1290
+ {
1291
+ let shell = yui_shell_of(gobj);
1292
+ if(!shell) {
1293
+ log_error(`${GCLASS_NAME} '${node_path_str(gobj)}': no shell to hand ` +
1294
+ `the close of '${(kw && kw.item_id) || ""}' to`);
1295
+ return -1;
1296
+ }
1297
+ gobj_send_event(shell, "EV_NAV_ITEM_CLOSE", {
1298
+ item_id: (kw && kw.item_id) || "",
1299
+ route: (kw && kw.route) || "",
1300
+ menu_id: (kw && kw.menu_id) || "",
1301
+ zone: (kw && kw.zone) || ""
1302
+ }, gobj);
1303
+ return 0;
1304
+ }
1305
+
1306
+ /************************************************************
1307
+ * The backdrop of a drawer projection was clicked. The drawer is
1308
+ * one of MY navs (a projection with `layout: "drawer"`), not one of
1309
+ * the shell's, so the shell's close_drawer() cannot find it: the
1310
+ * node closes it. A drawer opened through the shell carries the
1311
+ * shell's own close handler (escape stack, focus trap) on its
1312
+ * element, and that is what runs then; otherwise it is only hidden.
1313
+ ************************************************************/
1314
+ function ac_drawer_close_requested(gobj, event, kw, src)
1315
+ {
1316
+ let priv = gobj.priv;
1317
+ let menu_id = (kw && kw.menu_id) || "";
1318
+ let navs = priv.navs.concat(Object.values(priv.zone_navs));
1319
+
1320
+ for(let nav of navs) {
1321
+ if(gobj_read_attr(nav, "layout") !== "drawer") {
1322
+ continue;
1323
+ }
1324
+ if(menu_id && gobj_read_attr(nav, "menu_id") !== menu_id) {
1325
+ continue;
1326
+ }
1327
+ let $c = gobj_read_attr(nav, "$container");
1328
+ if(!$c || !$c.classList.contains("is-active")) {
1329
+ continue;
1330
+ }
1331
+ if(typeof $c.__yui_close_handler__ === "function") {
1332
+ $c.__yui_close_handler__();
1333
+ } else {
1334
+ $c.classList.remove("is-active");
1335
+ }
1336
+ }
1337
+ return 0;
1338
+ }
1339
+
1281
1340
  /************************************************************
1282
1341
  * Runtime API: add a child. Same path the declared tree takes.
1283
1342
  ************************************************************/
@@ -1666,6 +1725,12 @@ function create_gclass(gclass_name)
1666
1725
  ["ST_OFF", [
1667
1726
  ["EV_ROUTE_CHANGED", ac_route_changed, null],
1668
1727
  ["EV_ACTIVATE", ac_activate, null],
1728
+ /* A ZONE projection outlives the path (it is the app's
1729
+ * standing chrome, stopped only in mt_stop), so its clicks
1730
+ * reach a root that is off the path too. */
1731
+ ["EV_NAV_CLICKED", ac_nav_clicked, null],
1732
+ ["EV_NAV_ITEM_CLOSE", ac_nav_item_close, null],
1733
+ ["EV_DRAWER_CLOSE_REQUESTED", ac_drawer_close_requested, null],
1669
1734
  ["EV_ADD_NODE", ac_add_node, null],
1670
1735
  ["EV_REMOVE_NODE", ac_remove_node, null],
1671
1736
  ["EV_SET_PROJECTION", ac_set_projection, null],
@@ -1679,6 +1744,8 @@ function create_gclass(gclass_name)
1679
1744
  ["EV_ACTIVATE", ac_activate, null],
1680
1745
  ["EV_DEACTIVATE", ac_deactivate, null],
1681
1746
  ["EV_NAV_CLICKED", ac_nav_clicked, null],
1747
+ ["EV_NAV_ITEM_CLOSE", ac_nav_item_close, null],
1748
+ ["EV_DRAWER_CLOSE_REQUESTED", ac_drawer_close_requested, null],
1682
1749
  ["EV_ADD_NODE", ac_add_node, null],
1683
1750
  ["EV_REMOVE_NODE", ac_remove_node, null],
1684
1751
  ["EV_SET_PROJECTION", ac_set_projection, null],
@@ -1692,6 +1759,8 @@ function create_gclass(gclass_name)
1692
1759
  ["EV_ACTIVATE", ac_activate, null],
1693
1760
  ["EV_DEACTIVATE", ac_deactivate, null],
1694
1761
  ["EV_NAV_CLICKED", ac_nav_clicked, null],
1762
+ ["EV_NAV_ITEM_CLOSE", ac_nav_item_close, null],
1763
+ ["EV_DRAWER_CLOSE_REQUESTED", ac_drawer_close_requested, null],
1695
1764
  ["EV_ADD_NODE", ac_add_node, null],
1696
1765
  ["EV_REMOVE_NODE", ac_remove_node, null],
1697
1766
  ["EV_SET_PROJECTION", ac_set_projection, null],
@@ -1710,6 +1779,8 @@ function create_gclass(gclass_name)
1710
1779
  ["EV_ACTIVATE", 0],
1711
1780
  ["EV_DEACTIVATE", 0],
1712
1781
  ["EV_NAV_CLICKED", 0],
1782
+ ["EV_NAV_ITEM_CLOSE", 0],
1783
+ ["EV_DRAWER_CLOSE_REQUESTED", 0],
1713
1784
  ["EV_ADD_NODE", 0],
1714
1785
  ["EV_REMOVE_NODE", 0],
1715
1786
  ["EV_SET_PROJECTION", 0],
@@ -755,8 +755,11 @@ function remote_command(gobj, command, kw, tag)
755
755
  * echoes `__md_command__` AS that frame (c_agent_treedb_link.js).
756
756
  * Same names in both places is what makes the answer readable
757
757
  * whichever one is under this view — and `record_id` is here and
758
- * NOT at the top level because a delete answers with nothing, and a
759
- * parameter the treedb does not know has no business travelling. */
758
+ * NOT at the top level because a parameter the treedb does not know
759
+ * has no business travelling. It is echoed at all because an answer
760
+ * does not always name its record: a delete answers the node deleted,
761
+ * but a REFUSED write answers none, and the view still has to know
762
+ * which record it was. */
760
763
  full_kw.__md_command__ = Object.assign({
761
764
  purpose: PURPOSE,
762
765
  topic_name: (kw && kw.topic_name) || "",
@@ -981,8 +984,12 @@ function current_topic(gobj)
981
984
  * One line for what the whole view has to say: loading, empty,
982
985
  * or the reason nothing loaded. Carries its i18n key, so it
983
986
  * follows a language change like any other text.
987
+ *
988
+ * `detail` is a NAME (a treedb, a topic) shown as it is, unless
989
+ * `detail_is_key` says it is a key: a name is not text to
990
+ * translate, and a topic called `nodes` is not "Nodos".
984
991
  ***************************************************************/
985
- function show_notice(gobj, key, detail)
992
+ function show_notice(gobj, key, detail, detail_is_key)
986
993
  {
987
994
  let $notice = $of(gobj, ".SCHEMA_NOTICE");
988
995
 
@@ -998,14 +1005,17 @@ function show_notice(gobj, key, detail)
998
1005
  $notice.appendChild(createElement2(
999
1006
  ["span", {class: "SCHEMA_NOTICE_TEXT", i18n: key}, t(key)]
1000
1007
  ));
1001
- /* The detail is a KEY too (ours, or the backend's words, which
1002
- * translate to themselves when no locale has them), so a change
1003
- * of language changes it with the rest. One that repeats the
1004
- * title says nothing. */
1008
+ /* A detail that is a KEY (the load error: ours, or the backend's
1009
+ * words, which translate to themselves when no locale has them)
1010
+ * changes language with the rest. One that repeats the title
1011
+ * says nothing. */
1005
1012
  if(detail && detail !== key) {
1013
+ let attrs = {class: "SCHEMA_NOTICE_DETAIL yui-text-quiet ml-2"};
1014
+ if(detail_is_key) {
1015
+ attrs.i18n = `${detail}`;
1016
+ }
1006
1017
  $notice.appendChild(createElement2(
1007
- ["span", {class: "SCHEMA_NOTICE_DETAIL yui-text-quiet ml-2", i18n: `${detail}`},
1008
- t(`${detail}`)]
1018
+ ["span", attrs, detail_is_key ? t(`${detail}`) : `${detail}`]
1009
1019
  ));
1010
1020
  }
1011
1021
  }
@@ -1188,7 +1198,7 @@ function render(gobj)
1188
1198
  }
1189
1199
  if(state === "ST_IDLE") {
1190
1200
  show_notice(gobj, priv.load_error ? "cannot load the schemas" : "not connected",
1191
- priv.load_error);
1201
+ priv.load_error, true);
1192
1202
  destroy_diagram(gobj);
1193
1203
  clear($body);
1194
1204
  return;
@@ -3696,6 +3696,27 @@ function refuse_if_readonly(gobj, event)
3696
3696
  /************************************************************
3697
3697
  * From external, at the beginning, load all topic data
3698
3698
  ************************************************************/
3699
+ /***************************************************************
3700
+ * One of MY json viewers (the schema, a cell, the table's records)
3701
+ * met a `__collapsed__` sentinel and the reader opened it. The
3702
+ * viewer never fetches: it asks its host, and waits on "loading"
3703
+ * until it is answered. This view holds records, not a store it can
3704
+ * read a subtree of -- the lazy drill is `print-tranger`, which the
3705
+ * TOPICS view runs for its own viewer, over a whole tranger -- so the
3706
+ * answer is that it cannot, and the viewer shows that on the stub
3707
+ * instead of loading for ever.
3708
+ ***************************************************************/
3709
+ function ac_expand_path(gobj, event, kw, src)
3710
+ {
3711
+ let path = (kw && kw.path) || "";
3712
+
3713
+ gobj_send_event(src, "EV_SUBTREE_ERROR", {
3714
+ path: path,
3715
+ error: t("this part cannot be loaded here")
3716
+ }, gobj);
3717
+ return 0;
3718
+ }
3719
+
3699
3720
  /***************************************************************
3700
3721
  * The language changed (the shell publishes it after the app switched).
3701
3722
  *
@@ -5343,6 +5364,7 @@ function create_gclass(gclass_name)
5343
5364
  ["EV_CHANGE_LOCALE", ac_change_locale, null],
5344
5365
  ["EV_REFRESH", ac_refresh, null],
5345
5366
  ["EV_SHOW_SCHEMA", ac_show_schema, null],
5367
+ ["EV_EXPAND_PATH", ac_expand_path, null],
5346
5368
  ["EV_CELL_EDITED", ac_cell_edited, null],
5347
5369
  ["EV_REPULL_PAGE", ac_repull_page, null],
5348
5370
  ["EV_PAGE_LOADED", ac_page_loaded, null],
@@ -5400,6 +5422,7 @@ function create_gclass(gclass_name)
5400
5422
  ["EV_CHANGE_LOCALE", 0],
5401
5423
  ["EV_REFRESH", 0],
5402
5424
  ["EV_SHOW_SCHEMA", 0],
5425
+ ["EV_EXPAND_PATH", 0],
5403
5426
  ["EV_CELL_EDITED", 0],
5404
5427
  ["EV_REPULL_PAGE", 0],
5405
5428
  ["EV_PAGE_LOADED", 0],
@@ -1676,7 +1676,7 @@ function treedb_create_node(gobj, treedb_name, topic_name, record, options)
1676
1676
  /************************************************************
1677
1677
  * Command to remote service
1678
1678
  ************************************************************/
1679
- function treedb_update_node(gobj, treedb_name, topic_name, record, options, form_write)
1679
+ function treedb_update_node(gobj, treedb_name, topic_name, record, options, form_write, created)
1680
1680
  {
1681
1681
  let command = "update-node";
1682
1682
 
@@ -1695,6 +1695,11 @@ function treedb_update_node(gobj, treedb_name, topic_name, record, options, form
1695
1695
  if(form_write) {
1696
1696
  kw.__md_command__.form_write = form_write;
1697
1697
  }
1698
+ if(created) {
1699
+ /* A create goes out as an update-node (create_only); the answer
1700
+ * says `created` only if the request says it back. */
1701
+ kw.__md_command__.created = true;
1702
+ }
1698
1703
  // TODO review msg_iev_write_key(kw, "__topic_name__", topic_name);
1699
1704
 
1700
1705
  let ret = gobj_command(
@@ -1740,7 +1745,9 @@ function treedb_delete_node(gobj, treedb_name, topic_name, record, options)
1740
1745
  );
1741
1746
  if(ret) {
1742
1747
  log_error(ret);
1748
+ return -1;
1743
1749
  }
1750
+ return 0;
1744
1751
  }
1745
1752
 
1746
1753
  /************************************************************
@@ -2217,6 +2224,7 @@ function ac_mt_command_answer(gobj, event, kw, src)
2217
2224
 
2218
2225
  case "create-node":
2219
2226
  case "update-node":
2227
+ case "delete-node":
2220
2228
  /*
2221
2229
  * The view refreshes itself from the treedb's own
2222
2230
  * EV_TREEDB_NODE_* events, which arrive for EVERY writer. This
@@ -2230,14 +2238,23 @@ function ac_mt_command_answer(gobj, event, kw, src)
2230
2238
  /* From what THIS view knows and from the answer, never from
2231
2239
  * the request: the frame carries back only `__md_command__`,
2232
2240
  * so `treedb_name` and `record` read there arrived empty.
2233
- * `record` is the node the store answered, as it was written. */
2241
+ * `record` is the node the store answered, as it was written
2242
+ * (for a delete, the node deleted). The table's own +New goes
2243
+ * out as an update-node, so `created` is also what the request
2244
+ * echoes back in `__md_command__`. */
2234
2245
  gobj_publish_event(gobj, "EV_RECORD_WRITTEN", {
2235
2246
  treedb_name: gobj_read_str_attr(gobj, "treedb_name"),
2236
2247
  topic_name: kw_get_str(gobj, kw_command, "topic_name", "", 0),
2237
2248
  record: answered_record(data),
2238
- created: (command === "create-node"),
2249
+ created: (command === "create-node") ||
2250
+ kw_command.created === true,
2239
2251
  command: command
2240
2252
  });
2253
+ if(command === "delete-node") {
2254
+ /* The rows go away on the treedb's own EV_TREEDB_NODE_DELETED;
2255
+ * a delete has no form waiting for its answer. */
2256
+ break;
2257
+ }
2241
2258
  settle_form_write(gobj.priv.form_writes,
2242
2259
  kw_get_int(gobj, kw_command, "form_write", 0, 0),
2243
2260
  kw_get_str(gobj, kw_command, "topic_name", "", 0));
@@ -2249,10 +2266,6 @@ function ac_mt_command_answer(gobj, event, kw, src)
2249
2266
  );
2250
2267
  break;
2251
2268
 
2252
- case "delete-node":
2253
- // Don't process by here, process on subscribed events.
2254
- break;
2255
-
2256
2269
  default:
2257
2270
  log_error(`${gobj_short_name(gobj)} Command unknown: ${command}`);
2258
2271
  }
@@ -2680,7 +2693,7 @@ function ac_create_record(gobj, event, kw, src)
2680
2693
  };
2681
2694
 
2682
2695
  if(treedb_update_node( // HACK use the powerful update_node
2683
- gobj, treedb_name, topic_name, record, options, form_write) < 0) {
2696
+ gobj, treedb_name, topic_name, record, options, form_write, true) < 0) {
2684
2697
  answer_form_write(gobj, src, form_write, false);
2685
2698
  return -1; /* Error already logged */
2686
2699
  }
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Graphics, Charts with uPlot
5
5
  *
6
- * Copyright (c) 2025, ArtGins.
6
+ * Copyright (c) 2025-2026, ArtGins.
7
7
  * All Rights Reserved.
8
8
  ***********************************************************************/
9
9
 
@@ -12,14 +12,12 @@ import {
12
12
  SDATA_END,
13
13
  data_type_t,
14
14
  event_flag_t,
15
- kw_flag_t,
16
15
  gclass_create,
17
16
  createElement2,
18
17
  log_error,
19
18
  gobj_read_pointer_attr,
20
19
  gobj_parent,
21
20
  gobj_subscribe_event,
22
- kw_get_str,
23
21
  gobj_read_attr,
24
22
  gobj_read_str_attr,
25
23
  gobj_write_attr,
@@ -457,8 +455,15 @@ function ac_add_serie(gobj, event, kw, src)
457
455
  * colour: the old fallback was `Orange`, 1.97:1 on a white page,
458
456
  * and it was what every series past the fourth got. */
459
457
  let color = colors[Math.max(0, idx - 1) % colors.length];
460
- kw_get_str(gobj, kw, "stroke", color.stroke, kw_flag_t.KW_CREATE);
461
- kw_get_str(gobj, kw, "fill", color.fill, kw_flag_t.KW_CREATE);
458
+ /* Only when the host gave none: uPlot takes a stroke or a fill
459
+ * that is a FUNCTION (or a gradient) too, and kw_get_str() logs
460
+ * a value that is not a string (gobj-js 7.25.5, as C does). */
461
+ if(kw.stroke === undefined) {
462
+ kw.stroke = color.stroke;
463
+ }
464
+ if(kw.fill === undefined) {
465
+ kw.fill = color.fill;
466
+ }
462
467
  //kw["paths"] = beziercurve_path; //rectangle_path;
463
468
  uplot.addSeries(kw);
464
469
  return 0;
@@ -0,0 +1,121 @@
1
+ /***********************************************************************
2
+ * form_json_expand.wiring.test.js
3
+ *
4
+ * C_YUI_TREEDB_TOPIC_WITH_FORM hosts C_YUI_JSON as a pure CHILD
5
+ * (the schema, a cell, the table's records), so it hears every
6
+ * event the viewer publishes -- and it did not declare
7
+ * EV_EXPAND_PATH, which the viewer publishes when the reader opens
8
+ * a `__collapsed__` sentinel. The click answered "Event NOT DEFINED
9
+ * in state" and the stub stayed on "loading" for good. The form
10
+ * cannot read a subtree (that is print-tranger, which the TOPICS
11
+ * view runs for its own viewer), so it says so, and the stub shows
12
+ * it.
13
+ *
14
+ * Driven through the real gclasses (C_YUI_SHELL,
15
+ * C_YUI_TREEDB_TOPIC_WITH_FORM, C_YUI_JSON) on a document double:
16
+ * the event is published by the real viewer the form created.
17
+ *
18
+ * Copyright (c) 2026, ArtGins.
19
+ * All Rights Reserved.
20
+ ***********************************************************************/
21
+ import {describe, test, expect, beforeAll, beforeEach} from "vitest";
22
+ import {install_dom_double} from "../test/dom_double.js";
23
+
24
+ install_dom_double();
25
+
26
+ /* A phone: the viewer opens in the shell's sheet, not in a
27
+ * C_YUI_WINDOW, whose markup the document double cannot parse. */
28
+ globalThis.innerWidth = 400;
29
+ if(globalThis.window) {
30
+ globalThis.window.innerWidth = 400;
31
+ }
32
+
33
+ /* Tabulator watches its table; the document double has no observer. */
34
+ if(typeof globalThis.ResizeObserver === "undefined") {
35
+ globalThis.ResizeObserver = class {
36
+ observe() {}
37
+ unobserve() {}
38
+ disconnect() {}
39
+ };
40
+ }
41
+
42
+ const {
43
+ SDATA_END, gclass_create,
44
+ gobj_start_up, gobj_create_yuno, gobj_create, gobj_create_service,
45
+ gobj_start, gobj_send_event, gobj_current_state,
46
+ set_log_callback,
47
+ } = await import("@yuneta/gobj-js");
48
+ const {register_c_yui_shell} = await import("./c_yui_shell.js");
49
+ const {register_c_yui_json} = await import("./c_yui_json.js");
50
+ const {register_c_yui_treedb_topic_with_form} = await import("./c_yui_treedb_topic_with_form.js");
51
+
52
+ const logged = [];
53
+ let yuno = null;
54
+
55
+ beforeAll(async () => {
56
+ /* The app's i18next, with no locale: a key answers itself. */
57
+ const i18next = (await import("i18next")).default;
58
+ await i18next.init({lng: "en", resources: {}});
59
+ gobj_start_up(null, null, null, null, null, null, null);
60
+ set_log_callback((level, msg) => {
61
+ logged.push({level: String(level), msg: String(msg)});
62
+ });
63
+ gclass_create("C_TEST_HOST", [], [["ST_IDLE", []]], {}, 0, [SDATA_END()], {}, 0, 0, 0, 0);
64
+ register_c_yui_shell();
65
+ register_c_yui_json();
66
+ register_c_yui_treedb_topic_with_form();
67
+ yuno = gobj_create_yuno("form_json_yuno", "C_TEST_HOST", {});
68
+ gobj_start(yuno);
69
+ });
70
+
71
+ beforeEach(() => {
72
+ logged.length = 0;
73
+ });
74
+
75
+ const DESC = {
76
+ id: "users",
77
+ pkey: "id",
78
+ cols: {
79
+ id: {id: "id", header: "Id", type: "string", flag: ["persistent", "required"]},
80
+ name: {id: "name", header: "Name", type: "string", flag: ["persistent", "writable"]}
81
+ }
82
+ };
83
+
84
+ function errors()
85
+ {
86
+ return logged.filter((l) => l.level === "error").map((l) => l.msg);
87
+ }
88
+
89
+ describe("a viewer of the form asks to load a truncated part", () => {
90
+
91
+ test("the form answers that it cannot, and the stub stops loading", () => {
92
+ const app = gobj_create("f1_app", "C_TEST_HOST", {}, yuno);
93
+ const shell = gobj_create("f1_shell", "C_YUI_SHELL",
94
+ {config: {shell: {}}, default_route: "/home", subscriber: app}, app);
95
+ gobj_start(shell);
96
+ const form = gobj_create_service("f1_form", "C_YUI_TREEDB_TOPIC_WITH_FORM", {
97
+ treedb_name: "treedb_test",
98
+ topic_name: "users",
99
+ desc: DESC
100
+ }, shell);
101
+ gobj_start(form);
102
+
103
+ logged.length = 0;
104
+ gobj_send_event(form, "EV_SHOW_SCHEMA", {}, form);
105
+ const viewer = form.priv.schema_gobj;
106
+ expect(viewer).toBeTruthy();
107
+ expect(gobj_current_state(viewer)).toBe("ST_READY");
108
+ logged.length = 0;
109
+
110
+ /* What the stub's click sends: the viewer then PUBLISHES
111
+ * EV_EXPAND_PATH to its host. */
112
+ gobj_send_event(viewer, "EV_EXPAND_COLLAPSED", {path: "cols`name", size: 40}, viewer);
113
+
114
+ expect(viewer.priv.pending.has("cols`name")).toBe(false);
115
+ expect(viewer.priv.errors.get("cols`name")).toBe("this part cannot be loaded here");
116
+ /* The viewer logs what it could not load; nothing else is. */
117
+ expect(errors()).toEqual([
118
+ "C_YUI_JSON: subtree load failed at 'cols`name': this part cannot be loaded here"
119
+ ]);
120
+ });
121
+ });
@@ -188,6 +188,43 @@ describe("a refused __graphs__ write keeps the Save lit", () => {
188
188
  expect(errors()).toEqual([]);
189
189
  });
190
190
 
191
+ /* Before gobj-ui 7.25.19 only a Save that planned the topic paid
192
+ * what was owed. Save #1 and Save #2 both write `users`, #1 is
193
+ * refused and its answer lands after #2's echo: the echo made the
194
+ * topic saved, so no Save planned it again, and the button stayed
195
+ * lit over nothing to write until a reload. */
196
+ test("an echo of the topic pays what was owed: the Save goes dark", () => {
197
+ const g = build("edition");
198
+ g.tree.priv.descs = {};
199
+ g.tree.priv.__graphs__ = [];
200
+ gobj_send_event(g.tree, "EV_GRAPHS_WRITE_REFUSED", {topic: "users"}, g.tree);
201
+ expect(g.lit()).toBe(true);
202
+
203
+ gobj_send_event(g.tree, "EV_NODE_UPDATED", {
204
+ topic_name: "__graphs__",
205
+ node: {id: "users", topic: "users", active: true,
206
+ properties: {nodes: {u1: {x: 30, y: 40}}}}
207
+ }, g.tree);
208
+ expect(g.lit()).toBe(false);
209
+
210
+ gobj_send_event(g.tree, "EV_SAVE_GRAPH", {}, g.tree);
211
+ expect(published.filter((p) => p.event === "EV_UPDATE_NODE")).toEqual([]);
212
+ expect(errors()).toEqual([]);
213
+ });
214
+
215
+ test("an echo of ANOTHER topic leaves it owed", () => {
216
+ const g = build("edition");
217
+ g.tree.priv.descs = {};
218
+ g.tree.priv.__graphs__ = [];
219
+ gobj_send_event(g.tree, "EV_GRAPHS_WRITE_REFUSED", {topic: "users"}, g.tree);
220
+ gobj_send_event(g.tree, "EV_NODE_UPDATED", {
221
+ topic_name: "__graphs__",
222
+ node: {id: "roles", topic: "roles", active: true, properties: {nodes: {}}}
223
+ }, g.tree);
224
+ expect(g.lit()).toBe(true);
225
+ expect(errors()).toEqual([]);
226
+ });
227
+
191
228
  test("a reload forgets it: the view holds what the backend holds", () => {
192
229
  const g = build("edition");
193
230
  gobj_send_event(g.tree, "EV_GRAPHS_WRITE_REFUSED", {topic: "users"}, g.tree);
@@ -0,0 +1,150 @@
1
+ /***********************************************************************
2
+ * node_nav_events.wiring.test.js
3
+ *
4
+ * C_YUI_NODE hosts C_YUI_NAV as a pure CHILD, so it hears every
5
+ * event the nav publishes -- and it declared only EV_NAV_CLICKED.
6
+ * EV_NAV_ITEM_CLOSE (the ✕ of a closable item) and
7
+ * EV_DRAWER_CLOSE_REQUESTED (the backdrop of a drawer projection,
8
+ * a layout a node may declare) answered "Event NOT DEFINED in
9
+ * state" -- and the drawer stayed open, because the shell that
10
+ * closes drawers knows only its own navs. A zone projection also
11
+ * outlives the path (it is stopped only in mt_stop), so the clicks
12
+ * of a root that is off the path landed on ST_OFF, which declared
13
+ * none of the three.
14
+ *
15
+ * Driven through the real gclasses (C_YUI_SHELL, C_YUI_NODE,
16
+ * C_YUI_NAV) on a document double: each event is published by the
17
+ * real nav the node created.
18
+ *
19
+ * Copyright (c) 2026, ArtGins.
20
+ * All Rights Reserved.
21
+ ***********************************************************************/
22
+ import {describe, test, expect, beforeAll, beforeEach} from "vitest";
23
+ import {install_dom_double} from "../test/dom_double.js";
24
+
25
+ install_dom_double();
26
+
27
+ const {
28
+ SDATA_END, gclass_create,
29
+ gobj_start_up, gobj_create_yuno, gobj_create, gobj_create_pure_child,
30
+ gobj_start, gobj_send_event, gobj_publish_event, gobj_read_attr,
31
+ gobj_current_state, gobj_change_state,
32
+ set_log_callback,
33
+ } = await import("@yuneta/gobj-js");
34
+ const {register_c_yui_shell} = await import("./c_yui_shell.js");
35
+ const {register_c_yui_nav} = await import("./c_yui_nav.js");
36
+ const {register_c_yui_node} = await import("./c_yui_node.js");
37
+
38
+ const logged = [];
39
+ const closed = []; /* the EV_NAV_ITEM_CLOSE the app heard */
40
+ let yuno = null;
41
+
42
+ beforeAll(() => {
43
+ gobj_start_up(null, null, null, null, null, null, null);
44
+ set_log_callback((level, msg) => {
45
+ logged.push({level: String(level), msg: String(msg)});
46
+ });
47
+ gclass_create("C_TEST_HOST", [], [["ST_IDLE", []]], {}, 0, [SDATA_END()], {}, 0, 0, 0, 0);
48
+ /* An app that uses closable items: it declares what the shell
49
+ * re-publishes. */
50
+ gclass_create(
51
+ "C_TEST_APP",
52
+ [["EV_NAV_ITEM_CLOSE", 0]],
53
+ [["ST_IDLE", [
54
+ ["EV_NAV_ITEM_CLOSE", (gobj, event, kw) => {
55
+ closed.push(kw);
56
+ return 0;
57
+ }, null]
58
+ ]]],
59
+ {}, 0, [SDATA_END()], {}, 0, 0, 0, 0
60
+ );
61
+ register_c_yui_shell();
62
+ register_c_yui_nav();
63
+ register_c_yui_node();
64
+ yuno = gobj_create_yuno("node_nav_yuno", "C_TEST_HOST", {});
65
+ gobj_start(yuno);
66
+ });
67
+
68
+ beforeEach(() => {
69
+ logged.length = 0;
70
+ closed.length = 0;
71
+ });
72
+
73
+ let seq = 0;
74
+
75
+ /* A tree under a shell, on the path, projecting its children with
76
+ * `layout`. Returns the tree and the nav it created. */
77
+ function build(layout)
78
+ {
79
+ const name = `n${++seq}`;
80
+ const app = gobj_create(`${name}_app`, "C_TEST_APP", {}, yuno);
81
+ gobj_start(app);
82
+ const shell = gobj_create(`${name}_shell`, "C_YUI_SHELL",
83
+ {config: {shell: {}}, default_route: "/home", subscriber: app}, app);
84
+ gobj_start(shell);
85
+ const tree = gobj_create_pure_child(`${name}_tree`, "C_YUI_NODE", {
86
+ node_id: "root",
87
+ base_route: "/tree",
88
+ projection: {index: {layout: layout}},
89
+ children: [{id: "a", label: "A"}, {id: "b", label: "B"}]
90
+ }, shell);
91
+ gobj_start(tree);
92
+ gobj_send_event(tree, "EV_ROUTE_CHANGED", {base: "/tree", subpath: ""}, shell);
93
+ expect(gobj_current_state(tree)).toBe("ST_SELF");
94
+ const nav = tree.priv.navs[0];
95
+ expect(nav).toBeTruthy();
96
+ expect(gobj_read_attr(nav, "layout")).toBe(layout);
97
+ logged.length = 0;
98
+ return {app, shell, tree, nav};
99
+ }
100
+
101
+ function errors()
102
+ {
103
+ return logged.filter((l) => l.level === "error").map((l) => l.msg);
104
+ }
105
+
106
+ describe("the events of a C_YUI_NAV reach its C_YUI_NODE", () => {
107
+
108
+ test("EV_NAV_ITEM_CLOSE is handed to the shell, and the app hears it", () => {
109
+ const {nav} = build("tabs");
110
+ gobj_publish_event(nav, "EV_NAV_ITEM_CLOSE", {
111
+ item_id: "a", route: "/tree/a", menu_id: "node./tree", zone: ""
112
+ });
113
+ expect(errors()).toEqual([]);
114
+ expect(closed).toEqual([{
115
+ item_id: "a", route: "/tree/a", menu_id: "node./tree", zone: ""
116
+ }]);
117
+ });
118
+
119
+ test("EV_DRAWER_CLOSE_REQUESTED closes the node's own drawer", () => {
120
+ const {nav} = build("drawer");
121
+ const $drawer = gobj_read_attr(nav, "$container");
122
+ $drawer.classList.add("is-active");
123
+
124
+ gobj_publish_event(nav, "EV_DRAWER_CLOSE_REQUESTED", {
125
+ menu_id: gobj_read_attr(nav, "menu_id")
126
+ });
127
+ expect(errors()).toEqual([]);
128
+ expect($drawer.classList.contains("is-active")).toBe(false);
129
+ });
130
+
131
+ test("a drawer of another menu is left open", () => {
132
+ const {nav} = build("drawer");
133
+ const $drawer = gobj_read_attr(nav, "$container");
134
+ $drawer.classList.add("is-active");
135
+ gobj_publish_event(nav, "EV_DRAWER_CLOSE_REQUESTED", {menu_id: "other"});
136
+ expect(errors()).toEqual([]);
137
+ expect($drawer.classList.contains("is-active")).toBe(true);
138
+ });
139
+
140
+ test("off the path, a nav that is still there is heard too", () => {
141
+ const {tree, nav} = build("tabs");
142
+ /* What a zone nav is: alive while its root is off the path. */
143
+ gobj_change_state(tree, "ST_OFF");
144
+ gobj_publish_event(nav, "EV_NAV_ITEM_CLOSE", {item_id: "b"});
145
+ gobj_publish_event(nav, "EV_DRAWER_CLOSE_REQUESTED", {menu_id: "x"});
146
+ gobj_publish_event(nav, "EV_NAV_CLICKED", {route: "/tree/a", item_id: "a"});
147
+ expect(errors()).toEqual([]);
148
+ expect(closed.map((c) => c.item_id)).toEqual(["b"]);
149
+ });
150
+ });