@yuneta/gobj-ui 7.25.14 → 7.25.16

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.
Files changed (35) hide show
  1. package/README.md +100 -4
  2. package/dist/gobj-ui.cjs.js +406 -94
  3. package/dist/gobj-ui.es.js +406 -94
  4. package/package.json +2 -2
  5. package/src/c_g6_nodes_tree.js +79 -15
  6. package/src/c_yui_schema_editor.js +343 -66
  7. package/src/c_yui_treedb_graph.js +53 -6
  8. package/src/c_yui_treedb_topic_with_form.js +10 -10
  9. package/src/c_yui_treedb_topics.js +7 -7
  10. package/src/form_busy.js +2 -3
  11. package/src/form_busy.test.js +1 -1
  12. package/src/form_time_value.js +1 -1
  13. package/src/form_time_value.test.js +1 -1
  14. package/src/form_writes_in_flight.js +1 -2
  15. package/src/form_writes_in_flight.test.js +1 -1
  16. package/src/g6_graphs_write_owed.wiring.test.js +200 -0
  17. package/src/g6_nodes_tree_fsm.test.js +33 -0
  18. package/src/graph_save_plan.js +28 -0
  19. package/src/graph_save_plan.test.js +36 -2
  20. package/src/host_drafts.js +2 -3
  21. package/src/host_drafts.test.js +2 -2
  22. package/src/schema_editor_drafts.wiring.test.js +2 -2
  23. package/src/schema_editor_reload.wiring.test.js +329 -49
  24. package/src/schema_editor_transport.wiring.test.js +2 -2
  25. package/src/schema_version_bump.test.js +1 -1
  26. package/src/shell_modals.js +31 -11
  27. package/src/shell_toast_once.test.js +73 -10
  28. package/src/table_cell_text.js +1 -1
  29. package/src/table_cell_text.test.js +1 -1
  30. package/src/treedb_graph_writes.wiring.test.js +208 -0
  31. package/src/treedb_topics_transport.wiring.test.js +2 -2
  32. package/src/yui_install.js +14 -3
  33. package/src/yui_install.test.js +23 -0
  34. package/src/yui_row_search.test.js +1 -1
  35. package/src/yui_table_select.test.js +1 -1
@@ -5,17 +5,17 @@
5
5
  * stacked under it: one close of the transport settles every
6
6
  * request it cut, each view that asked shows its failure, and
7
7
  * the operator got a column of identical "the connection
8
- * dropped" (low of the independent review of the 2nd round).
8
+ * dropped".
9
9
  *
10
- * And a repeat is a caller of its own (third independent
11
- * review): its handle and its time are its own, and the toast
12
- * stays while any caller still holds it.
10
+ * And a repeat is a caller of its own: its handle and its time
11
+ * are its own, and the toast stays while any caller still holds
12
+ * it.
13
13
  *
14
14
  * And every ✕ this module draws is a control with a NAME: a
15
- * `title` and an `aria-label`, both translatable (fourth
16
- * independent review: TOAST_CLOSE had the label only). So is
17
- * every other button of a dialog (fifth: the dialog's back arrow
18
- * had no title, and the Yes/No of every confirmation had neither).
15
+ * `title` and an `aria-label`, both translatable (TOAST_CLOSE had
16
+ * the label only). So is every other button of a dialog (the
17
+ * dialog's back arrow had no title, and the Yes/No of every
18
+ * confirmation had neither).
19
19
  *
20
20
  * Copyright (c) 2026, ArtGins.
21
21
  * All Rights Reserved.
@@ -30,6 +30,9 @@ const {
30
30
  yui_shell_show_info,
31
31
  yui_shell_show_modal,
32
32
  yui_shell_confirm_danger,
33
+ yui_shell_confirm_ok,
34
+ yui_shell_confirm_yesno,
35
+ yui_shell_confirm_yesnocancel,
33
36
  } = await import("./shell_modals.js");
34
37
 
35
38
  let shell = null;
@@ -78,7 +81,7 @@ describe("one toast per message on screen", () => {
78
81
  });
79
82
 
80
83
  /*
81
- * The third independent review: a repeat was handed the FIRST toast's
84
+ * A repeat was handed the FIRST toast's
82
85
  * handle and timer. A repeat asking `timeout: 0` was dismissed on the
83
86
  * first one's timer, and closing one caller's handle closed the toast
84
87
  * another caller was still showing.
@@ -168,7 +171,7 @@ describe("every close control is named", () => {
168
171
  });
169
172
  });
170
173
 
171
- describe("every other button of a dialog is named (fifth review)", () => {
174
+ describe("every other button of a dialog is named", () => {
172
175
 
173
176
  function named($b)
174
177
  {
@@ -200,3 +203,63 @@ describe("every other button of a dialog is named (fifth review)", () => {
200
203
  ]);
201
204
  });
202
205
  });
206
+
207
+ /*
208
+ * A confirmation that names no label shows the DEFAULT ones, and those
209
+ * are i18n keys like every other label: lower-case, translated by the
210
+ * app's t(), and carried as the key of the text, the title and the
211
+ * aria-label. They were "OK", "Yes", "No", "Delete" and "Cancel" --
212
+ * keys no locale has, so the button read in English in every language.
213
+ */
214
+ describe("the default labels of a confirmation are i18n keys", () => {
215
+
216
+ function keys_of($layer)
217
+ {
218
+ return [...$layer.querySelectorAll(".CONFIRM_BTN")].map(($b) => [
219
+ $b.getAttribute("data-i18n"),
220
+ $b.getAttribute("data-i18n-title"),
221
+ $b.getAttribute("data-i18n-aria-label"),
222
+ ]);
223
+ }
224
+
225
+ /* What the reader hears: the aria-label, translated by t() when
226
+ * the button is built. */
227
+ function texts_of($layer)
228
+ {
229
+ return [...$layer.querySelectorAll(".CONFIRM_BTN")].map(($b) => $b.getAttribute("aria-label"));
230
+ }
231
+
232
+ const k = (key) => [key, key, key];
233
+
234
+ /* A translator that tells a key it knows from one it does not. */
235
+ const ES = {ok: "Aceptar", yes: "Sí", no: "No", delete: "Borrar", cancel: "Cancelar"};
236
+ const t = (key) => ES[key] || `?${key}?`;
237
+
238
+ test("ok", () => {
239
+ shell.priv.layers.modal = document.createElement("div");
240
+ yui_shell_confirm_ok(shell, "saved", {t: t});
241
+ expect(keys_of(shell.priv.layers.modal)).toEqual([k("ok")]);
242
+ expect(texts_of(shell.priv.layers.modal)).toEqual(["Aceptar"]);
243
+ });
244
+
245
+ test("yes / no", () => {
246
+ shell.priv.layers.modal = document.createElement("div");
247
+ yui_shell_confirm_yesno(shell, "go on?", {t: t});
248
+ expect(keys_of(shell.priv.layers.modal)).toEqual([k("yes"), k("no")]);
249
+ expect(texts_of(shell.priv.layers.modal)).toEqual(["Sí", "No"]);
250
+ });
251
+
252
+ test("yes / no / cancel", () => {
253
+ shell.priv.layers.modal = document.createElement("div");
254
+ yui_shell_confirm_yesnocancel(shell, "keep it?", {t: t});
255
+ expect(keys_of(shell.priv.layers.modal)).toEqual([k("yes"), k("no"), k("cancel")]);
256
+ expect(texts_of(shell.priv.layers.modal)).toEqual(["Sí", "No", "Cancelar"]);
257
+ });
258
+
259
+ test("delete / cancel", () => {
260
+ shell.priv.layers.modal = document.createElement("div");
261
+ yui_shell_confirm_danger(shell, "delete this column?", {t: t});
262
+ expect(keys_of(shell.priv.layers.modal)).toEqual([k("delete"), k("cancel")]);
263
+ expect(texts_of(shell.priv.layers.modal)).toEqual(["Borrar", "Cancelar"]);
264
+ });
265
+ });
@@ -7,7 +7,7 @@
7
7
  * (only its own `plaintext` formatter escapes), so a record's text
8
8
  * returned as a string is parsed as markup: `a<b and c>d` showed
9
9
  * as `ad`, and a field holding `<img src=x onerror=...>` ran it in
10
- * the operator's browser (M31 of the 2026-09-21 review). Record
10
+ * the operator's browser. Record
11
11
  * data goes in as a TEXT NODE, and a cell built around it as DOM,
12
12
  * never as an HTML string with the data interpolated.
13
13
  *
@@ -9,7 +9,7 @@ const doc = {
9
9
  describe("table_cell_text", () => {
10
10
 
11
11
  it("record text is a text node, markup included", () => {
12
- /* M31: Tabulator puts a string through innerHTML */
12
+ /* Tabulator puts a string through innerHTML */
13
13
  const node = cell_text("limit a<b and c>d", doc);
14
14
  expect(node.nodeType).toBe(3);
15
15
  expect(node.textContent).toBe("limit a<b and c>d");
@@ -0,0 +1,208 @@
1
+ /***********************************************************************
2
+ * treedb_graph_writes.wiring.test.js
3
+ *
4
+ * C_YUI_TREEDB_GRAPH and the writes of `__graphs__`, the topic
5
+ * where the graph keeps its arrangement. The engine
6
+ * (C_G6_NODES_TREE) takes such a write for granted before it
7
+ * leaves: nothing answers it when it lands. So the host must say
8
+ * when one did NOT land -- refused by the backend, refused on the
9
+ * way out, or never sent for want of a session. Without that, the
10
+ * engine believed the backend held the arrangement, and the next
11
+ * Save had nothing to write.
12
+ *
13
+ * Driven through the real host on a document double, with a fake
14
+ * engine that records what it is told and a fake transport whose
15
+ * state is the one the host reads.
16
+ *
17
+ * Copyright (c) 2026, ArtGins.
18
+ * All Rights Reserved.
19
+ ***********************************************************************/
20
+ import {describe, test, expect, beforeAll, beforeEach} from "vitest";
21
+ import {install_dom_double} from "../test/dom_double.js";
22
+
23
+ install_dom_double();
24
+
25
+ /* The host watches its container's size; the document double has no
26
+ * layout to watch. */
27
+ if(!globalThis.ResizeObserver) {
28
+ globalThis.ResizeObserver = class {
29
+ observe() {}
30
+ unobserve() {}
31
+ disconnect() {}
32
+ };
33
+ }
34
+
35
+ const {
36
+ SDATA, SDATA_END, data_type_t,
37
+ gclass_create,
38
+ gobj_start_up, gobj_create_yuno, gobj_create, gobj_create_service,
39
+ gobj_start, gobj_send_event, gobj_publish_event,
40
+ gobj_read_pointer_attr, gobj_subscribe_event,
41
+ gobj_change_state, gobj_current_state,
42
+ set_log_callback,
43
+ } = await import("@yuneta/gobj-js");
44
+
45
+ const logged = [];
46
+ const commands = []; /* what the fake transport was asked */
47
+ const told = []; /* what the fake engine was told */
48
+ let refuse_on_the_way = false;
49
+
50
+ function remote_command_parser(gobj, command, kw, src)
51
+ {
52
+ if(refuse_on_the_way) {
53
+ return `cannot route '${command}'`;
54
+ }
55
+ commands.push({command, kw, src});
56
+ return null;
57
+ }
58
+
59
+ let yuno = null;
60
+
61
+ beforeAll(async () => {
62
+ gobj_start_up(null, null, null, null, null, null, null);
63
+ set_log_callback((level, msg) => {
64
+ logged.push({level: String(level), msg: String(msg)});
65
+ });
66
+
67
+ gclass_create("C_TEST_HOST", [], [["ST_IDLE", []]], {}, 0, [SDATA_END()], {}, 0, 0, 0, 0);
68
+ gclass_create(
69
+ "C_TEST_REMOTE",
70
+ [],
71
+ [["ST_SESSION", []], ["ST_DISCONNECTED", []]],
72
+ {mt_command_parser: remote_command_parser},
73
+ 0, [SDATA_END()], {}, 0, 0, 0, 0
74
+ );
75
+
76
+ /* The engine, registered BEFORE the host so the host uses it. It
77
+ * publishes what the real one publishes on a Save, and records every
78
+ * event it is sent. */
79
+ const record = (gobj, event, kw) => {
80
+ told.push({event, kw});
81
+ return 0;
82
+ };
83
+ const ENGINE_EVENTS = [
84
+ "EV_DESCS", "EV_CLEAR_DATA", "EV_LOAD_DATA", "EV_NODE_CREATED",
85
+ "EV_NODE_UPDATED", "EV_NODE_DELETED", "EV_GRAPHS_WRITE_REFUSED",
86
+ "EV_SHOW", "EV_HIDE", "EV_RESIZE", "EV_THEME", "EV_LANGUAGE_CHANGED",
87
+ "EV_SET_OPERATION_MODE", "EV_SET_LAYOUT"
88
+ ];
89
+ gclass_create(
90
+ "C_G6_NODES_TREE",
91
+ ENGINE_EVENTS.map((e) => [e, 0]).concat([["EV_UPDATE_NODE", 2]]),
92
+ [["ST_IDLE", ENGINE_EVENTS.map((e) => [e, record, null])]],
93
+ {
94
+ mt_create: (gobj) => {
95
+ const subscriber = gobj_read_pointer_attr(gobj, "subscriber");
96
+ if(subscriber) {
97
+ gobj_subscribe_event(gobj, null, {}, subscriber);
98
+ }
99
+ }
100
+ },
101
+ 0,
102
+ [
103
+ SDATA(data_type_t.DTP_POINTER, "subscriber", 0, null, ""),
104
+ SDATA(data_type_t.DTP_POINTER, "$container", 0, null, ""),
105
+ SDATA(data_type_t.DTP_POINTER, "gobj_remote_yuno", 0, null, ""),
106
+ SDATA(data_type_t.DTP_STRING, "treedb_name", 0, "", ""),
107
+ SDATA(data_type_t.DTP_JSON, "layout_names", 0, "[]", ""),
108
+ SDATA(data_type_t.DTP_STRING, "layout", 0, "", ""),
109
+ SDATA_END()
110
+ ],
111
+ {}, 0, 0, 0, 0
112
+ );
113
+
114
+ const {register_c_yui_treedb_graph} = await import("./c_yui_treedb_graph.js");
115
+ register_c_yui_treedb_graph();
116
+
117
+ yuno = gobj_create_yuno("graph_writes_yuno", "C_TEST_HOST", {});
118
+ gobj_start(yuno);
119
+ });
120
+
121
+ beforeEach(() => {
122
+ logged.length = 0;
123
+ commands.length = 0;
124
+ told.length = 0;
125
+ refuse_on_the_way = false;
126
+ });
127
+
128
+ function build(name)
129
+ {
130
+ const remote = gobj_create_service(`${name}_remote`, "C_TEST_REMOTE", {}, yuno);
131
+ gobj_change_state(remote, "ST_SESSION");
132
+ const host = gobj_create_service(`${name}_graph`, "C_YUI_TREEDB_GRAPH", {
133
+ gobj_remote_yuno: remote,
134
+ treedb_name: "treedb_test"
135
+ }, yuno);
136
+ const engine = host.priv.gobj_nodes_tree;
137
+ expect(engine).toBeTruthy();
138
+ return {remote, host, engine};
139
+ }
140
+
141
+ /* What the engine publishes on a Save of one topic's arrangement. */
142
+ function save_arrangement(engine, topic)
143
+ {
144
+ gobj_publish_event(engine, "EV_UPDATE_NODE", {
145
+ treedb_name: "treedb_test",
146
+ topic_name: "__graphs__",
147
+ record: {id: topic, topic: topic, active: true, properties: {nodes: {}}},
148
+ options: {list_dict: true, autolink: false, create: true}
149
+ });
150
+ }
151
+
152
+ function answer(host, remote, request, result, comment)
153
+ {
154
+ gobj_send_event(host, "EV_MT_COMMAND_ANSWER", {
155
+ result: result,
156
+ comment: comment || "",
157
+ data: null,
158
+ __md_iev__: {command_stack: [{command: request.command, kw: request.kw}]}
159
+ }, remote);
160
+ }
161
+
162
+ function refused()
163
+ {
164
+ return told.filter((t) => t.event === "EV_GRAPHS_WRITE_REFUSED").map((t) => t.kw.topic);
165
+ }
166
+
167
+ describe("a __graphs__ write that does not land is said to the engine", () => {
168
+
169
+ test("refused by the backend", () => {
170
+ const {remote, host, engine} = build("g1");
171
+ save_arrangement(engine, "yunos");
172
+ expect(commands.length).toBe(1);
173
+ answer(host, remote, commands[0], -1, "not authorized");
174
+ expect(refused()).toEqual(["yunos"]);
175
+ });
176
+
177
+ test("landed: nothing is said", () => {
178
+ const {remote, host, engine} = build("g2");
179
+ save_arrangement(engine, "yunos");
180
+ answer(host, remote, commands[0], 0);
181
+ expect(refused()).toEqual([]);
182
+ });
183
+
184
+ test("with no session: not sent, and said", () => {
185
+ const {remote, host, engine} = build("g3");
186
+ gobj_change_state(remote, "ST_DISCONNECTED");
187
+ save_arrangement(engine, "realms");
188
+ expect(commands).toEqual([]);
189
+ expect(refused()).toEqual(["realms"]);
190
+ expect(logged.some((l) => /not sent/.test(l.msg))).toBe(true);
191
+ });
192
+
193
+ test("refused on the way out by the transport", () => {
194
+ const {remote, host, engine} = build("g4");
195
+ refuse_on_the_way = true;
196
+ save_arrangement(engine, "binaries");
197
+ expect(refused()).toEqual(["binaries"]);
198
+ });
199
+
200
+ test("a refused write of another topic says nothing to the engine", () => {
201
+ const {remote, host, engine} = build("g5");
202
+ gobj_send_event(host, "EV_UPDATE_NODE", {
203
+ topic_name: "yunos", record: {id: "1", x: 1}
204
+ }, engine);
205
+ answer(host, remote, commands[0], -1, "refused");
206
+ expect(refused()).toEqual([]);
207
+ });
208
+ });
@@ -5,7 +5,7 @@
5
5
  * through the real gclasses (C_YUI_SHELL, C_YUI_TREEDB_TOPICS) on a
6
6
  * document double. The helper (form_writes_in_flight.js) was tested
7
7
  * and right; what failed was that two hosts never called the action
8
- * that uses it (M8 of the 2026-09-23 review), which no helper test
8
+ * that uses it, which no helper test
9
9
  * can see.
10
10
  *
11
11
  * Copyright (c) 2026, ArtGins.
@@ -169,7 +169,7 @@ function errors()
169
169
  return logged.filter((l) => l.level === "error").map((l) => l.msg);
170
170
  }
171
171
 
172
- describe("a Save in flight when the backend drops (M8)", () => {
172
+ describe("a Save in flight when the backend drops", () => {
173
173
 
174
174
  test("the shell's connection edge answers every form, with no host forwarding", () => {
175
175
  const {shell, topics, forms} = build("t1");
@@ -194,6 +194,17 @@ function yui_install_start_watch()
194
194
  * app can call yui_install_prompt() from a button of its
195
195
  * own whenever it likes.
196
196
  ***************************************************************/
197
+ /* The question and its answers when the app names none. They are i18n
198
+ * KEYS, lower-case, and the ones every consumer already defines; the
199
+ * question was "Install this app?" until 7.25.16, a key no locale
200
+ * carries. Written as `i18n:` entries so the validate-locales script of
201
+ * a consumer finds them when it scans this module. */
202
+ const DEFAULT_ASK = Object.freeze({
203
+ message: Object.freeze({i18n: "install this app"}),
204
+ yes: Object.freeze({i18n: "install"}),
205
+ no: Object.freeze({i18n: "not now"})
206
+ });
207
+
197
208
  function yui_install_ask_once(shell, opts)
198
209
  {
199
210
  opts = opts || {};
@@ -213,10 +224,10 @@ function yui_install_ask_once(shell, opts)
213
224
  S.asking = true;
214
225
  kw_set_local_storage_value(ASKED_KEY, true);
215
226
 
216
- yui_shell_confirm_yesno(shell, opts.message || "Install this app?", {
227
+ yui_shell_confirm_yesno(shell, opts.message || DEFAULT_ASK.message.i18n, {
217
228
  t: opts.t,
218
- yes_label: opts.yes_label || "install",
219
- no_label: opts.no_label || "not now"
229
+ yes_label: opts.yes_label || DEFAULT_ASK.yes.i18n,
230
+ no_label: opts.no_label || DEFAULT_ASK.no.i18n
220
231
  }).then(function(yes) {
221
232
  S.asking = false;
222
233
  if(yes) {
@@ -140,3 +140,26 @@ test("subscribe(): a throwing listener does not break the others", async () => {
140
140
  off1();
141
141
  off2();
142
142
  });
143
+
144
+ /* The question asked when the app names none is an i18n KEY, the one
145
+ * every consumer already defines ("install this app"). It was
146
+ * "Install this app?", a key no locale carries, so an app that took
147
+ * the default asked in English in every language. */
148
+ test("ask_once(): the default question and answers are i18n keys", async () => {
149
+ const asked = [];
150
+ vi.resetModules();
151
+ vi.doMock("./shell_modals.js", () => ({
152
+ yui_shell_confirm_yesno: (shell, message, opts) => {
153
+ asked.push({message, yes: opts.yes_label, no: opts.no_label});
154
+ return new Promise(() => {});
155
+ }
156
+ }));
157
+ try {
158
+ const {yui_install_ask_once} = await import("./yui_install.js");
159
+ window[STASH] = fake_event("accepted");
160
+ yui_install_ask_once({fake_shell: true}, {});
161
+ expect(asked).toEqual([{message: "install this app", yes: "install", no: "not now"}]);
162
+ } finally {
163
+ vi.doUnmock("./shell_modals.js");
164
+ }
165
+ });
@@ -115,7 +115,7 @@ describe("is_hook_size", () => {
115
115
  * The children of a hook as the NODE EVENTS deliver them: `{id,
116
116
  * topic_name}`, with no hook_name. Not taken for a reference, their
117
117
  * topic_name -- the same word on every row -- was a wildcard for every
118
- * row that had received an UPDATED (M30 of the 2026-09-21 review).
118
+ * row that had received an UPDATED.
119
119
  */
120
120
  describe("a hook's children as the node events deliver them", () => {
121
121
  const row = {id: "w1", devices: [{id: "d7", topic_name: "devices"}]};
@@ -59,7 +59,7 @@ describe("reading and dropping the selection", () => {
59
59
  * A table as the delete of the treedb topic table meets it AFTER a
60
60
  * confirmation: rows came and went while the dialog was open. A position
61
61
  * names whatever row sits there now; an id names the row the question named
62
- * (the regression of d60ec78, A6 of the 2026-09-21 review).
62
+ * (the regression of d60ec78).
63
63
  */
64
64
  function fake_table(rows, index)
65
65
  {