@yuneta/gobj-ui 7.25.4 → 7.25.5

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.
@@ -43,6 +43,8 @@ import {
43
43
  createElement2, empty_string, is_object, is_array, is_string,
44
44
  refresh_language,
45
45
  is_gobj,
46
+ gobj_has_event,
47
+ gobj_is_destroying,
46
48
  } from "@yuneta/gobj-js";
47
49
 
48
50
  import {
@@ -174,6 +176,10 @@ let PRIVATE_DATA = {
174
176
  * backend-connected state. State is host/event-driven (unlike
175
177
  * the avatar provider it is a setter, not a pull callback). */
176
178
  conn_nodes: [],
179
+ /* The last state yui_shell_set_connection_state() was told, null
180
+ * while nobody told it anything: EV_CONNECTION_STATE goes out on an
181
+ * EDGE, not on every call. */
182
+ connected: null,
177
183
  /* Currently open toolbar dropdown panel, if any. Tracked here
178
184
  * so a second click on any trigger (or programmatic close) can
179
185
  * tear down the previous one through the same code path. */
@@ -3033,11 +3039,34 @@ function yui_shell_set_submenu(shell_gobj, parent_item_id, items)
3033
3039
  /*---------------------------------------------*
3034
3040
  * Global methods table
3035
3041
  *---------------------------------------------*/
3042
+ /***************************************************************
3043
+ * EV_CONNECTION_STATE is an OPTIONAL fact: a view that waits for a
3044
+ * backend answer subscribes to it by name. An app that subscribed
3045
+ * to EVERY event of its shell with one attr (`subscriber`) did not
3046
+ * ask for it and does not declare it -- published to it anyway, the
3047
+ * event would be an FSM error in the app on every connection edge,
3048
+ * and adding it to the shell a breaking change for every such app.
3049
+ * So it reaches only the subscribers that declare it. Every other
3050
+ * event of the shell goes out as before.
3051
+ ***************************************************************/
3052
+ function mt_publication_pre_filter(gobj, subs, event, kw)
3053
+ {
3054
+ if(event !== "EV_CONNECTION_STATE") {
3055
+ return 1;
3056
+ }
3057
+ let subscriber = subs && subs.subscriber;
3058
+ if(!subscriber || !gobj_has_event(subscriber, event, 0)) {
3059
+ return 0;
3060
+ }
3061
+ return 1;
3062
+ }
3063
+
3036
3064
  const gmt = {
3037
- mt_create: mt_create,
3038
- mt_start: mt_start,
3039
- mt_stop: mt_stop,
3040
- mt_destroy: mt_destroy
3065
+ mt_create: mt_create,
3066
+ mt_start: mt_start,
3067
+ mt_stop: mt_stop,
3068
+ mt_destroy: mt_destroy,
3069
+ mt_publication_pre_filter: mt_publication_pre_filter
3041
3070
  };
3042
3071
 
3043
3072
  function create_gclass(gclass_name)
@@ -3088,6 +3117,13 @@ function create_gclass(gclass_name)
3088
3117
  * re-renders its own translated parts. */
3089
3118
  ["EV_LANGUAGE_CHANGED", event_flag_t.EVF_OUTPUT_EVENT
3090
3119
  |event_flag_t.EVF_PUBLIC_EVENT
3120
+ |event_flag_t.EVF_NO_WARN_SUBS],
3121
+
3122
+ /* The app's backend connection went up or down
3123
+ * (yui_shell_set_connection_state), {connected}. Delivered only
3124
+ * to the subscribers that declare it: see
3125
+ * mt_publication_pre_filter(). */
3126
+ ["EV_CONNECTION_STATE", event_flag_t.EVF_OUTPUT_EVENT
3091
3127
  |event_flag_t.EVF_NO_WARN_SUBS]
3092
3128
  ];
3093
3129
 
@@ -3552,14 +3588,31 @@ function yui_shell_language_changed(shell_gobj)
3552
3588
  ************************************************************/
3553
3589
  function yui_shell_set_connection_state(shell_gobj, connected)
3554
3590
  {
3591
+ if(!shell_gobj || !is_gobj(shell_gobj) || gobj_is_destroying(shell_gobj)) {
3592
+ return;
3593
+ }
3555
3594
  let priv = shell_gobj.priv;
3556
- if(!priv || !is_array(priv.conn_nodes)) {
3595
+ if(!priv) {
3557
3596
  return;
3558
3597
  }
3559
3598
  let on = !!connected;
3560
- for(let $n of priv.conn_nodes) {
3561
- $n.classList.toggle("is-connected", on);
3562
- $n.classList.toggle("is-disconnected", !on);
3599
+ let changed = (priv.connected !== on);
3600
+ priv.connected = on;
3601
+ if(is_array(priv.conn_nodes)) {
3602
+ for(let $n of priv.conn_nodes) {
3603
+ $n.classList.toggle("is-connected", on);
3604
+ $n.classList.toggle("is-disconnected", !on);
3605
+ }
3606
+ }
3607
+ /* The fact goes out as well, for the views: a write a view sent
3608
+ * before the backend dropped is never answered, and the view that
3609
+ * waits for it (a topic form's Save) learns the drop from HERE --
3610
+ * it holds no subscription of its own to the transport, because
3611
+ * subscribing to a C_IEVENT_CLI forwards the subscription to the
3612
+ * backend. Only an EDGE is published: the app calls this on every
3613
+ * reconnect attempt too. */
3614
+ if(changed) {
3615
+ gobj_publish_event(shell_gobj, "EV_CONNECTION_STATE", {connected: on});
3563
3616
  }
3564
3617
  }
3565
3618
 
@@ -99,7 +99,8 @@ import {t} from "i18next";
99
99
  import {yui_toolbar_icon} from "./yui_toolbar.js";
100
100
 
101
101
  import {
102
- plan_treedb_writes, READONLY_FORM_TOOLBAR, col_goes_back_to_treedb
102
+ plan_treedb_writes, READONLY_FORM_TOOLBAR, col_goes_back_to_treedb,
103
+ instance_keys_of
103
104
  } from "./treedb_write_plan.js";
104
105
  import {
105
106
  yui_file_read, yui_files_manifest, yui_file_id_label, yui_file_size_label
@@ -2395,6 +2396,10 @@ function open_form_dialog(gobj, mode, record)
2395
2396
  style: 'display:flex; flex-direction:column; height:100%;'}, []]
2396
2397
  );
2397
2398
 
2399
+ /* The instance this form writes to, kept as the record HAD it: the
2400
+ * widget is not trusted with an address (instance_keys_of()). */
2401
+ priv.form_instance = (form_mode === "update")? instance_keys_of(record, desc) : null;
2402
+
2398
2403
  let form_plan = write_plan(gobj);
2399
2404
  let form = gobj_create_pure_child(
2400
2405
  "form_" + clean_name(gobj_name(gobj)),
@@ -2508,6 +2513,7 @@ function teardown_form_child(gobj)
2508
2513
  priv.reading_files = 0; // a read in flight lands on nobody
2509
2514
  priv.awaiting_write = 0; // and so does the answer of a write
2510
2515
  priv.form_busy = {busy: false}; // its toolbar goes with it
2516
+ priv.form_instance = null;
2511
2517
  if(priv.form) {
2512
2518
  if(gobj_is_running(priv.form)) {
2513
2519
  gobj_stop(priv.form);
@@ -4313,6 +4319,11 @@ function publish_treedb_write(gobj, mode, record, jn_files, form_write)
4313
4319
  topic_name: gobj_read_str_attr(gobj, "topic_name"),
4314
4320
  record: strip_read_only_cols(gobj, record)
4315
4321
  };
4322
+ if(mode !== "create" && gobj.priv.form_instance) {
4323
+ /* The pkey2 names the instance: the value the record had, not
4324
+ * the one that came back through a datetime-local. */
4325
+ Object.assign(kw_write.record, gobj.priv.form_instance);
4326
+ }
4316
4327
  if(form_write) {
4317
4328
  /* The serial the host echoes back with the answer */
4318
4329
  kw_write.form_write = form_write;
@@ -156,10 +156,11 @@ let __gclass__ = null;
156
156
  ***************************************************************/
157
157
  function mt_create(gobj)
158
158
  {
159
- /* The writes of topic forms sent and not yet answered, by serial
160
- * (form_writes_in_flight): answered refused when the transport
161
- * closes under them. */
159
+ /* The writes of topic forms sent and not yet answered, by topic
160
+ * and serial (form_writes_in_flight): answered refused when the
161
+ * transport closes under them. */
162
162
  gobj.priv.form_writes = {};
163
+ gobj.priv.conn_shell = null; /* the shell whose EV_CONNECTION_STATE we hear */
163
164
 
164
165
  build_ui(gobj);
165
166
 
@@ -178,6 +179,17 @@ function mt_create(gobj)
178
179
  ***************************************************************/
179
180
  function mt_start(gobj)
180
181
  {
182
+ /* The app's backend connection, as the app tells its shell
183
+ * (yui_shell_set_connection_state). A write in flight when it drops
184
+ * is never answered, and its form waited for ever in every host
185
+ * that did not forward EV_TRANSPORT_STATE -- wattyzer and yunovatios
186
+ * among them (M8 of the 2026-09-23 review). The transport itself is
187
+ * not subscribed to: see ac_transport_state(). */
188
+ let shell = yui_shell_of(gobj);
189
+ if(shell) {
190
+ gobj_subscribe_event(shell, "EV_CONNECTION_STATE", {}, gobj);
191
+ gobj.priv.conn_shell = shell;
192
+ }
181
193
  request_treedb_descs(gobj);
182
194
  }
183
195
 
@@ -186,6 +198,14 @@ function mt_start(gobj)
186
198
  ***************************************************************/
187
199
  function mt_stop(gobj)
188
200
  {
201
+ let priv = gobj.priv;
202
+ if(priv.conn_shell) {
203
+ if(!gobj_is_destroying(priv.conn_shell)) {
204
+ gobj_unsubscribe_event(priv.conn_shell, "EV_CONNECTION_STATE", {}, gobj);
205
+ }
206
+ priv.conn_shell = null;
207
+ }
208
+
189
209
  /* Retire our site-map sub-routes so a torn-down view leaves no
190
210
  * stale children in the map (ROUTING.md contributor). */
191
211
  let shell = yui_shell_of(gobj);
@@ -2077,7 +2097,7 @@ function ac_mt_command_answer(gobj, event, kw, src)
2077
2097
  {topic_name: failed_topic}, gobj);
2078
2098
  }
2079
2099
  settle_form_write(gobj.priv.form_writes,
2080
- kw_get_int(gobj, kw_command, "form_write", 0, 0));
2100
+ kw_get_int(gobj, kw_command, "form_write", 0, 0), failed_topic);
2081
2101
  answer_form_write(
2082
2102
  gobj,
2083
2103
  get_gobj_formtable(gobj, failed_topic),
@@ -2177,7 +2197,8 @@ function ac_mt_command_answer(gobj, event, kw, src)
2177
2197
  command: command
2178
2198
  });
2179
2199
  settle_form_write(gobj.priv.form_writes,
2180
- kw_get_int(gobj, kw_command, "form_write", 0, 0));
2200
+ kw_get_int(gobj, kw_command, "form_write", 0, 0),
2201
+ kw_get_str(gobj, kw_command, "topic_name", "", 0));
2181
2202
  answer_form_write(
2182
2203
  gobj,
2183
2204
  get_gobj_formtable(gobj, kw_get_str(gobj, kw_command, "topic_name", "", 0)),
@@ -2413,11 +2434,19 @@ function ac_back_to_topics(gobj, event, kw, src)
2413
2434
  }
2414
2435
 
2415
2436
  /************************************************************
2416
- * The host (C_TREEDB_VIEW) forwards the backend transport edges here so the
2417
- * toolbar can disable the JSON viewers the moment the session drops (and
2418
- * re-enable them on reconnect) — the library view must not subscribe to the
2419
- * C_IEVENT_CLI itself (that forwards the subscription upstream and breaks
2420
- * the session).
2437
+ * A backend transport edge, from either of two places:
2438
+ * - EV_TRANSPORT_STATE, forwarded by a host that keeps one
2439
+ * transport per view (gui_treedb's C_TREEDB_VIEW, gui_agent's
2440
+ * C_AGENT_TREEDB_VIEW);
2441
+ * - EV_CONNECTION_STATE, published by the SHELL when the app tells
2442
+ * it its backend went up or down (yui_shell_set_connection_state),
2443
+ * which is what every single-backend app already does for the
2444
+ * toolbar dot. No host has to remember anything for this one.
2445
+ * Both can arrive for one edge; what is done here is idempotent.
2446
+ * The toolbar disables the JSON viewers the moment the session drops
2447
+ * (and re-enables them on reconnect). The library view must not
2448
+ * subscribe to the C_IEVENT_CLI itself: that forwards the subscription
2449
+ * upstream and breaks the session.
2421
2450
  ************************************************************/
2422
2451
  function ac_transport_state(gobj, event, kw, src)
2423
2452
  {
@@ -3042,6 +3071,7 @@ function create_gclass(gclass_name)
3042
3071
  ["EV_HIDE", ac_hide, null],
3043
3072
  ["EV_BACK_TO_TOPICS", ac_back_to_topics, null],
3044
3073
  ["EV_TRANSPORT_STATE", ac_transport_state, null],
3074
+ ["EV_CONNECTION_STATE", ac_transport_state, null],
3045
3075
  ]]
3046
3076
  ];
3047
3077
 
@@ -3077,6 +3107,7 @@ function create_gclass(gclass_name)
3077
3107
  ["EV_HIDE", 0],
3078
3108
  ["EV_BACK_TO_TOPICS", 0],
3079
3109
  ["EV_TRANSPORT_STATE", 0],
3110
+ ["EV_CONNECTION_STATE", 0],
3080
3111
  ["EV_TOPIC_SELECTED",
3081
3112
  event_flag_t.EVF_OUTPUT_EVENT | event_flag_t.EVF_NO_WARN_SUBS]
3082
3113
  ];
@@ -6,6 +6,29 @@
6
6
  * them, and they must be each other's inverse: a writable time
7
7
  * column travels back on EVERY save of the record.
8
8
  *
9
+ * KNOWN LIMIT -- the change of hour. A `datetime-local` holds a
10
+ * LOCAL WALL time and no offset, and a wall time is not always
11
+ * one instant:
12
+ *
13
+ * - the night the clocks go back (autumn), one hour happens
14
+ * twice. "02:30" names two instants an hour apart, and
15
+ * `new Date("YYYY-MM-DDT02:30:00")` answers the EARLIER one
16
+ * (ECMAScript's "compatible" disambiguation). An epoch in the
17
+ * SECOND pass of that hour is shown as 02:30 and comes back
18
+ * one hour earlier: saving the record -- any field of it --
19
+ * moves a writable time column that holds it by -3600 s.
20
+ * - the night the clocks go forward (spring), one hour does not
21
+ * exist. No epoch is shown in it, so nothing read comes back
22
+ * wrong; a wall time TYPED in it is moved forward by the
23
+ * browser.
24
+ *
25
+ * Not fixable here without the offset, which the input cannot
26
+ * carry. What is safe: a read-only time column never goes back
27
+ * (treedb_write_plan.js, col_goes_back_to_treedb), and a time
28
+ * pkey2 goes back as the record had it, never through this
29
+ * widget (instance_keys_of). A writable time column edited or
30
+ * merely saved inside the repeated hour is the exposed case.
31
+ *
9
32
  * Copyright (c) 2026, ArtGins.
10
33
  * All Rights Reserved.
11
34
  ****************************************************************************/
@@ -2,18 +2,33 @@
2
2
  * form_writes_in_flight.js
3
3
  *
4
4
  * The writes of topic forms a C_YUI_TREEDB_TOPICS view has sent
5
- * and not yet answered, by their `form_write` serial. A form that
6
- * waits for its answer (form_waits_for_answer) stays open and
7
- * busy until the host answers, so the host has to answer EVERY
8
- * way a write ends -- and the transport closing under a write in
9
- * flight, or a command that never left because there was no
10
- * session, are two of them that nobody answered: the form stayed
11
- * busy for ever (N8 of the 2026-09-22 review).
5
+ * and not yet answered. A form that waits for its answer
6
+ * (form_waits_for_answer) stays open and busy until the host
7
+ * answers, so the host has to answer EVERY way a write ends --
8
+ * and the transport closing under a write in flight, or a
9
+ * command that never left because there was no session, are two
10
+ * of them that nobody answered: the form stayed busy for ever
11
+ * (N8 of the 2026-09-22 review).
12
+ *
13
+ * A write is known by its TOPIC and its `form_write` serial,
14
+ * never by the serial alone: each topic has its own form, and
15
+ * each form counts its serials from 1. Keyed by the serial, two
16
+ * forms with a write in flight at once shared the key "1", the
17
+ * first answer settled both, and the transport closing then
18
+ * answered only one of them.
12
19
  *
13
20
  * Copyright (c) 2026, ArtGins.
14
21
  * All Rights Reserved.
15
22
  ***********************************************************************/
16
23
 
24
+ /************************************************************
25
+ * The key of one write: `<topic>^<serial>`.
26
+ ************************************************************/
27
+ function form_write_key(topic_name, form_write)
28
+ {
29
+ return `${topic_name || ""}^${form_write}`;
30
+ }
31
+
17
32
  /************************************************************
18
33
  * A write left: remember whose it is
19
34
  ************************************************************/
@@ -22,18 +37,21 @@ function track_form_write(in_flight, form_write, topic_name)
22
37
  if(!in_flight || !form_write) {
23
38
  return;
24
39
  }
25
- in_flight[form_write] = topic_name || "";
40
+ in_flight[form_write_key(topic_name, form_write)] = {
41
+ form_write: form_write,
42
+ topic_name: topic_name || ""
43
+ };
26
44
  }
27
45
 
28
46
  /************************************************************
29
47
  * A write was answered, one way or the other
30
48
  ************************************************************/
31
- function settle_form_write(in_flight, form_write)
49
+ function settle_form_write(in_flight, form_write, topic_name)
32
50
  {
33
51
  if(!in_flight || !form_write) {
34
52
  return;
35
53
  }
36
- delete in_flight[form_write];
54
+ delete in_flight[form_write_key(topic_name, form_write)];
37
55
  }
38
56
 
39
57
  /************************************************************
@@ -46,9 +64,10 @@ function abandon_form_writes(in_flight)
46
64
  if(!in_flight) {
47
65
  return abandoned;
48
66
  }
49
- Object.keys(in_flight).forEach((serial) => {
50
- abandoned.push({form_write: Number(serial), topic_name: in_flight[serial]});
51
- delete in_flight[serial];
67
+ Object.keys(in_flight).forEach((key) => {
68
+ const w = in_flight[key];
69
+ abandoned.push({form_write: Number(w.form_write), topic_name: w.topic_name});
70
+ delete in_flight[key];
52
71
  });
53
72
  return abandoned;
54
73
  }
@@ -11,8 +11,23 @@ describe("form writes in flight", () => {
11
11
  const in_flight = {};
12
12
  track_form_write(in_flight, 1, "users");
13
13
  track_form_write(in_flight, 2, "roles");
14
- settle_form_write(in_flight, 1);
15
- expect(Object.keys(in_flight)).toEqual(["2"]);
14
+ settle_form_write(in_flight, 1, "users");
15
+ expect(abandon_form_writes(in_flight)).toEqual([
16
+ {form_write: 2, topic_name: "roles"},
17
+ ]);
18
+ });
19
+
20
+ it("two forms, each at its serial 1: two writes, not one", () => {
21
+ /* Each topic's form counts its serials from 1. Keyed by the serial
22
+ * alone, the answer of one form settled the other's write, and a
23
+ * transport closing then left that one busy for ever. */
24
+ const in_flight = {};
25
+ track_form_write(in_flight, 1, "users");
26
+ track_form_write(in_flight, 1, "roles");
27
+ settle_form_write(in_flight, 1, "users");
28
+ expect(abandon_form_writes(in_flight)).toEqual([
29
+ {form_write: 1, topic_name: "roles"},
30
+ ]);
16
31
  });
17
32
 
18
33
  it("the transport closes: every write in flight is abandoned, once (N8)", () => {
@@ -39,7 +54,7 @@ describe("form writes in flight", () => {
39
54
 
40
55
  it("no map: nothing to do", () => {
41
56
  expect(() => track_form_write(null, 1, "x")).not.toThrow();
42
- expect(() => settle_form_write(null, 1)).not.toThrow();
57
+ expect(() => settle_form_write(null, 1, "x")).not.toThrow();
43
58
  expect(abandon_form_writes(null)).toEqual([]);
44
59
  });
45
60
  });
@@ -10,17 +10,42 @@
10
10
  * __system__ still differed from the file in use (N13 of the
11
11
  * 2026-09-22 review). The host reads that difference from
12
12
  * C_TREEDB's `saved-schema` (`draft_changed`, by treedb) and
13
- * hands it here; the editor applies it to its model.
13
+ * hands it here; the editor applies it to its model, in place
14
+ * of what the host said before.
15
+ *
16
+ * What `draft_changed` measures depends on the node: the draft
17
+ * against the SAVED schema on a node newer than yunetas 7.25.3,
18
+ * and against the file IN USE on a 7.25.3 node -- where a topic saved and
19
+ * not yet applied is still named, and the editor, which cannot
20
+ * tell the two apart, shows it as a draft until the Apply.
14
21
  *
15
22
  * Copyright (c) 2026, ArtGins.
16
23
  * All Rights Reserved.
17
24
  ***********************************************************************/
18
25
 
26
+ /************************************************************
27
+ * The topics of `model` the host names as drafts, as
28
+ * {topic id: true}: `drafts` is {treedb_name: [topic names]}. A
29
+ * treedb or a topic the model does not hold is ignored.
30
+ *
31
+ * What the editor keeps is THIS, apart from what its own session
32
+ * wrote, and replaced whole on every EV_DRAFTS: the host is the
33
+ * truth after a reload. Folded into the session's marks, a topic the
34
+ * host once named stayed a draft after the save that published it.
35
+ ************************************************************/
36
+ function host_draft_ids(model, drafts)
37
+ {
38
+ const ids = {};
39
+ mark_host_drafts(ids, model, drafts);
40
+ return ids;
41
+ }
42
+
19
43
  /************************************************************
20
44
  * Mark in `written` the topics of `model` the host names as
21
45
  * drafts: `drafts` is {treedb_name: [topic names]}. A treedb or a
22
46
  * topic the model does not hold is ignored. Returns how many were
23
- * marked.
47
+ * marked. It only ADDS: a caller that must forget what the host
48
+ * no longer names starts from an empty map (host_draft_ids()).
24
49
  ************************************************************/
25
50
  function mark_host_drafts(written, model, drafts)
26
51
  {
@@ -63,6 +88,7 @@ function drafts_of_saved_answer(rows, into)
63
88
  }
64
89
 
65
90
  export {
91
+ host_draft_ids,
66
92
  mark_host_drafts,
67
93
  drafts_of_saved_answer,
68
94
  };
@@ -1,5 +1,5 @@
1
1
  import {describe, it, expect} from "vitest";
2
- import {mark_host_drafts, drafts_of_saved_answer} from "./host_drafts.js";
2
+ import {host_draft_ids, mark_host_drafts, drafts_of_saved_answer} from "./host_drafts.js";
3
3
 
4
4
  const model = {
5
5
  treedbs: [
@@ -60,3 +60,26 @@ describe("drafts_of_saved_answer", () => {
60
60
  expect(drafts_of_saved_answer(null)).toEqual({});
61
61
  });
62
62
  });
63
+
64
+ describe("host_draft_ids", () => {
65
+
66
+ it("resolves the host's names on the model, as topic ids", () => {
67
+ expect(host_draft_ids(model, {treedb_a: ["users"], treedb_b: ["users"]}))
68
+ .toEqual({"treedb_a^users": true, "treedb_b^users": true});
69
+ });
70
+
71
+ it("is a fresh map every time: what the host stopped naming is gone (M1)", () => {
72
+ /* mark_host_drafts() only ADDS, and the editor fed it the same map
73
+ * every time: a topic the host named before the Save stayed a
74
+ * draft after it. */
75
+ const before = host_draft_ids(model, {treedb_a: ["users", "roles"]});
76
+ const after = host_draft_ids(model, {treedb_a: []});
77
+ expect(before).toEqual({"treedb_a^users": true, "treedb_a^roles": true});
78
+ expect(after).toEqual({});
79
+ });
80
+
81
+ it("no model, no drafts: empty", () => {
82
+ expect(host_draft_ids(null, {treedb_a: ["users"]})).toEqual({});
83
+ expect(host_draft_ids(model, null)).toEqual({});
84
+ });
85
+ });
@@ -0,0 +1,176 @@
1
+ /***********************************************************************
2
+ * schema_editor_drafts.wiring.test.js
3
+ *
4
+ * EV_DRAFTS driven through a real C_YUI_SCHEMA_EDITOR on a document
5
+ * double. The helper was right and tested; the ACTION folded the
6
+ * host's marks into the session's, so a topic the host named before
7
+ * a Save stayed a draft after it (M1 of the 2026-09-23 review).
8
+ *
9
+ * Copyright (c) 2026, ArtGins.
10
+ * All Rights Reserved.
11
+ ***********************************************************************/
12
+ import {describe, test, expect, beforeAll, beforeEach} from "vitest";
13
+ import {install_dom_double} from "../test/dom_double.js";
14
+
15
+ install_dom_double();
16
+
17
+ const {
18
+ SDATA_END,
19
+ gclass_create,
20
+ gobj_start_up, gobj_create_yuno, gobj_create, gobj_create_service,
21
+ gobj_start, gobj_send_event, gobj_read_attr,
22
+ gobj_change_state,
23
+ set_log_callback,
24
+ } = await import("@yuneta/gobj-js");
25
+ const {register_c_yui_schema_editor, topic_is_draft} = await import("./c_yui_schema_editor.js");
26
+
27
+ const logged = [];
28
+ const commands = [];
29
+
30
+ /* The yuno's system schema: one treedb, two topics. */
31
+ const RECORDS = {
32
+ treedbs: [{id: "db", schema_version: 4}],
33
+ topics: [
34
+ {id: "db.users", value: "users", order: 1, pkey: "id", topic_version: 2,
35
+ treedbs: ["treedbs^db^topics"]},
36
+ {id: "db.roles", value: "roles", order: 2, pkey: "id", topic_version: 1,
37
+ treedbs: ["treedbs^db^topics"]},
38
+ ],
39
+ cols: [
40
+ {id: "db.users.id", value: "id", order: 1, type: "string",
41
+ flag: ["persistent", "required"], topics: ["topics^db.users^cols"]},
42
+ {id: "db.roles.id", value: "id", order: 1, type: "string",
43
+ flag: ["persistent", "required"], topics: ["topics^db.roles^cols"]},
44
+ ],
45
+ };
46
+
47
+ function remote_command_parser(gobj, command, kw, src)
48
+ {
49
+ commands.push({command, kw, src});
50
+ return null;
51
+ }
52
+
53
+ let yuno = null;
54
+
55
+ beforeAll(() => {
56
+ gobj_start_up(null, null, null, null, null, null, null);
57
+ set_log_callback((level, msg) => {
58
+ logged.push({level: String(level), msg: String(msg)});
59
+ });
60
+ gclass_create("C_TEST_HOST", [], [["ST_IDLE", []]], {}, 0, [SDATA_END()], {}, 0, 0, 0, 0);
61
+ gclass_create(
62
+ "C_TEST_EDITOR_HOST",
63
+ [["EV_POSITION_CHANGED", 0], ["EV_RECORD_WRITTEN", 0], ["EV_SCHEMA_CHECKED", 0]],
64
+ [["ST_IDLE", [
65
+ ["EV_POSITION_CHANGED", () => 0, null],
66
+ ["EV_RECORD_WRITTEN", () => 0, null],
67
+ ["EV_SCHEMA_CHECKED", () => 0, null]
68
+ ]]],
69
+ {}, 0, [SDATA_END()], {}, 0, 0, 0, 0
70
+ );
71
+ gclass_create(
72
+ "C_TEST_REMOTE",
73
+ [],
74
+ [["ST_SESSION", []]],
75
+ {mt_command_parser: remote_command_parser},
76
+ 0, [SDATA_END()], {}, 0, 0, 0, 0
77
+ );
78
+ register_c_yui_schema_editor();
79
+ yuno = gobj_create_yuno("drafts_yuno", "C_TEST_HOST", {});
80
+ gobj_start(yuno);
81
+ });
82
+
83
+ beforeEach(() => {
84
+ logged.length = 0;
85
+ commands.length = 0;
86
+ });
87
+
88
+ /* Answer every `nodes` the editor asked, the way a transport does: the
89
+ * request's frame on top of the command_stack. */
90
+ function answer_the_load(editor, remote)
91
+ {
92
+ const asked = commands.filter((c) => c.command === "nodes");
93
+ commands.length = 0;
94
+ for(const c of asked) {
95
+ gobj_send_event(editor, "EV_MT_COMMAND_ANSWER", {
96
+ result: 0,
97
+ data: RECORDS[c.kw.topic_name],
98
+ __md_iev__: {command_stack: [{command: "nodes", kw: c.kw}]}
99
+ }, remote);
100
+ }
101
+ return asked.length;
102
+ }
103
+
104
+ function build(name)
105
+ {
106
+ const remote = gobj_create_service(`${name}_remote`, "C_TEST_REMOTE", {}, yuno);
107
+ gobj_change_state(remote, "ST_SESSION");
108
+ const host = gobj_create(`${name}_host`, "C_TEST_EDITOR_HOST", {}, yuno);
109
+ const editor = gobj_create(`${name}_editor`, "C_YUI_SCHEMA_EDITOR", {
110
+ gobj_remote_yuno: remote,
111
+ treedb_name: "treedb_system_schema",
112
+ base_route: "/schemas"
113
+ }, host);
114
+ gobj_start(editor);
115
+ expect(answer_the_load(editor, remote)).toBe(3);
116
+ gobj_send_event(editor, "EV_SHOW", {subpath: "db"}, host);
117
+ return {editor, remote, host};
118
+ }
119
+
120
+ function drafts_on_screen(editor)
121
+ {
122
+ const $c = gobj_read_attr(editor, "$container");
123
+ return $c.querySelectorAll(".SCHEMA_TOPIC_VERSION .is-warning").length;
124
+ }
125
+
126
+ function is_draft(editor, topic_id)
127
+ {
128
+ return topic_is_draft(editor.priv, {id: topic_id});
129
+ }
130
+
131
+ describe("EV_DRAFTS: the host is the truth", () => {
132
+
133
+ test("the host names a topic: it is a draft, on screen too", () => {
134
+ const {editor, host} = build("d1");
135
+ gobj_send_event(editor, "EV_DRAFTS", {drafts: {db: ["users"]}}, host);
136
+ expect(is_draft(editor, "db.users")).toBe(true);
137
+ expect(is_draft(editor, "db.roles")).toBe(false);
138
+ expect(drafts_on_screen(editor)).toBe(1);
139
+ });
140
+
141
+ test("the host names none any more (a Save): the mark goes (M1)", () => {
142
+ /* It only ever ADDED: after the Save the editor still said
143
+ * "unsaved schema changes". */
144
+ const {editor, host} = build("d2");
145
+ gobj_send_event(editor, "EV_DRAFTS", {drafts: {db: ["users", "roles"]}}, host);
146
+ expect(drafts_on_screen(editor)).toBe(2);
147
+
148
+ gobj_send_event(editor, "EV_DRAFTS", {drafts: {db: []}}, host);
149
+ expect(is_draft(editor, "db.users")).toBe(false);
150
+ expect(is_draft(editor, "db.roles")).toBe(false);
151
+ expect(drafts_on_screen(editor)).toBe(0);
152
+ });
153
+
154
+ test("a reload keeps what the host said last, and only that", () => {
155
+ const {editor, remote, host} = build("d3");
156
+ gobj_send_event(editor, "EV_DRAFTS", {drafts: {db: ["roles"]}}, host);
157
+ gobj_send_event(editor, "EV_REFRESH", {}, host);
158
+ expect(answer_the_load(editor, remote)).toBe(3);
159
+ expect(is_draft(editor, "db.roles")).toBe(true);
160
+ expect(is_draft(editor, "db.users")).toBe(false);
161
+ });
162
+
163
+ test("what this session wrote is not cleared by the host's answer", () => {
164
+ const {editor, host} = build("d4");
165
+ editor.priv.written["db.users"] = true; /* a write of this session */
166
+ gobj_send_event(editor, "EV_DRAFTS", {drafts: {}}, host);
167
+ expect(is_draft(editor, "db.users")).toBe(true);
168
+ });
169
+
170
+ test("no error logged on the way", () => {
171
+ const {editor, host} = build("d5");
172
+ gobj_send_event(editor, "EV_DRAFTS", {drafts: {db: ["users"]}}, host);
173
+ gobj_send_event(editor, "EV_DRAFTS", {drafts: {}}, host);
174
+ expect(logged.filter((l) => l.level === "error").map((l) => l.msg)).toEqual([]);
175
+ });
176
+ });