@yuneta/gobj-ui 7.25.15 → 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.
@@ -117,6 +117,7 @@ import {
117
117
  kw_get_int,
118
118
  kw_get_dict,
119
119
  is_object,
120
+ json_deep_copy,
120
121
  is_array,
121
122
  empty_string,
122
123
  } from "@yuneta/gobj-js";
@@ -246,6 +247,8 @@ let PRIVATE_DATA = {
246
247
  dropped_round: 0, /* the load a drop ended: its failures are the drop, said once */
247
248
  dropped_write: 0, /* the same for the write a drop ended */
248
249
  reload_after_write: false, /* a write given up turned out DONE while another was in flight */
250
+ refresh_after_write: false, /* EV_REFRESH heard while writes were in flight: run at their end */
251
+ reopen_form: null, /* the form whose Save ran the owed reload: opened again once it lands */
249
252
 
250
253
  /* A position that arrived while a load or a write was in flight */
251
254
  pending_seg: null,
@@ -522,12 +525,37 @@ function owes_reload(gobj)
522
525
  state !== "ST_LOADING" && state !== "ST_SAVING";
523
526
  }
524
527
 
525
- function reload_first(gobj, event)
528
+ function reload_first(gobj, event, reopen)
526
529
  {
530
+ let priv = gobj.priv;
531
+
527
532
  if(!owes_reload(gobj)) {
528
533
  return false;
529
534
  }
530
- gobj.priv.reload_on_open = false;
535
+ priv.reload_on_open = false;
536
+
537
+ /* The Save of a FORM: what the operator typed is not thrown away
538
+ * with the model it was typed on. The load that lands closes the
539
+ * form (end_load()) and opens it again on the schemas it read,
540
+ * with the fields the operator CHANGED put back on top -- only
541
+ * those: a field left as it was shows what the store holds now,
542
+ * so the next Save does not write the old value over a newer
543
+ * one. Closed alone, the form took what was typed with it. */
544
+ if(reopen && priv.dialog) {
545
+ priv.reopen_form = Object.assign(json_deep_copy(reopen), {
546
+ dialog: priv.dialog,
547
+ treedb_id: priv.treedb_id
548
+ });
549
+ log_warning(`${gobj_short_name(gobj)}: ${event} refused, the schemas shown ` +
550
+ `may be out of date: they are read again first, and the form opens again ` +
551
+ `on them with its changes`);
552
+ yui_shell_show_error(yui_shell_of(gobj),
553
+ "the schemas shown may be out of date: they are read again, and the form opens again on them with your changes",
554
+ {t: t});
555
+ request_model(gobj, true);
556
+ return true;
557
+ }
558
+
531
559
  log_warning(`${gobj_short_name(gobj)}: ${event} refused, the schemas shown ` +
532
560
  `may be out of date: they are read again first`);
533
561
  yui_shell_show_error(yui_shell_of(gobj),
@@ -537,6 +565,53 @@ function reload_first(gobj, event)
537
565
  return true;
538
566
  }
539
567
 
568
+ /***************************************************************
569
+ * The form reload_first() took down, opened again on the model
570
+ * the load just made, with the operator's changes on top.
571
+ *
572
+ * Only where it can mean the same thing: on the treedb it was
573
+ * opened on, on the screen whose Save it has, and on a record
574
+ * that is still there. Otherwise it is not opened, and that is
575
+ * said -- a form opened on another treedb, or on nothing, would
576
+ * save somewhere the operator never meant.
577
+ ***************************************************************/
578
+ function reopen_form(gobj, r)
579
+ {
580
+ let priv = gobj.priv;
581
+ let state = gobj_current_state(gobj);
582
+ let treedb = current_treedb(gobj);
583
+ let topic = null;
584
+ let col = null;
585
+ let fits = false;
586
+
587
+ if(treedb && r.treedb_id === priv.treedb_id) {
588
+ if(r.form === "column" && state === "ST_COLUMNS") {
589
+ topic = find_topic(priv.model, priv.treedb_id, r.topic);
590
+ col = (topic && !r.creating) ?
591
+ find_col(priv.model, priv.treedb_id, r.topic, r.col) : null;
592
+ fits = !!topic && (r.creating || !!col);
593
+ } else if(r.form === "topic" && state === "ST_TOPICS") {
594
+ topic = r.creating ? null : find_topic(priv.model, priv.treedb_id, r.topic);
595
+ fits = r.creating || !!topic;
596
+ }
597
+ }
598
+ if(!fits) {
599
+ log_warning(`${gobj_short_name(gobj)}: the ${r.form} form was not opened again: ` +
600
+ `what it edited is not on the schemas the reload read ` +
601
+ `(${r.treedb_id} ${r.topic}${r.col ? "." + r.col : ""}, now ${state})`);
602
+ yui_shell_show_error(yui_shell_of(gobj),
603
+ "the schemas were read again and what the form was editing is not there any more",
604
+ {t: t});
605
+ return -1;
606
+ }
607
+ if(r.form === "column") {
608
+ open_column_form(gobj, topic, col, null, r.changed);
609
+ } else {
610
+ open_topic_form(gobj, treedb, topic, r.changed);
611
+ }
612
+ return 0;
613
+ }
614
+
540
615
  function reload_after_move(gobj)
541
616
  {
542
617
  if(!owes_reload(gobj)) {
@@ -725,7 +800,7 @@ function build_model(gobj)
725
800
  * measurement of "changed since" can be measured against.
726
801
  *
727
802
  * `written` is forgotten only by an EV_REFRESH (the view's own
728
- * Refresh button, or a host that sends one). Since M36 a write moves no
803
+ * Refresh button, or a host that sends one). Since 7.23.196 a write moves no
729
804
  * version, so `written` is the draft chip of what this session
730
805
  * wrote and no longer half of a version measurement: a reload of
731
806
  * the reconnect, or of a write that turned out done, forgot the
@@ -1915,6 +1990,75 @@ function read_form($root)
1915
1990
  return out;
1916
1991
  }
1917
1992
 
1993
+ /***************************************************************
1994
+ * A column form as its Save sends it: the fields and the flags.
1995
+ ***************************************************************/
1996
+ function column_form_state($form)
1997
+ {
1998
+ let values = read_form($form);
1999
+ values.flag = read_flags($form);
2000
+ return values;
2001
+ }
2002
+
2003
+ /***************************************************************
2004
+ * The fields the operator CHANGED: those whose value is not the
2005
+ * one the form was opened with.
2006
+ ***************************************************************/
2007
+ function form_changes(initial, values)
2008
+ {
2009
+ let changed = {};
2010
+
2011
+ for(let name of Object.keys(values)) {
2012
+ if(JSON.stringify(values[name]) !== JSON.stringify(initial[name])) {
2013
+ changed[name] = values[name];
2014
+ }
2015
+ }
2016
+ return changed;
2017
+ }
2018
+
2019
+ /***************************************************************
2020
+ * Put changes back into a form just built (reopen_form()). The
2021
+ * flags are drawn again for the type the changes leave, holding
2022
+ * the flags they name, so none of them is hidden by the type.
2023
+ ***************************************************************/
2024
+ function apply_form_changes(gobj, $form, changes)
2025
+ {
2026
+ for(let name of Object.keys(changes)) {
2027
+ if(name === "flag") {
2028
+ continue;
2029
+ }
2030
+ let $control = $form.querySelector(`[data-name="${name}"]`);
2031
+ if(!$control) {
2032
+ log_warning(`${gobj_short_name(gobj)}: a change of '${name}' has no field ` +
2033
+ `in the form opened again: not put back`);
2034
+ continue;
2035
+ }
2036
+ if($control.type === "checkbox") {
2037
+ $control.checked = !!changes[name];
2038
+ } else {
2039
+ $control.value = changes[name];
2040
+ }
2041
+ }
2042
+
2043
+ let $type = $form.querySelector('[data-name="type"]');
2044
+ if($type && $form.querySelector(".SCHEMA_FLAGS")) {
2045
+ let flags = Array.isArray(changes.flag) ? changes.flag : read_flags($form);
2046
+ redraw_flags(gobj, $form, $type.value, flags);
2047
+ }
2048
+ }
2049
+
2050
+ /***************************************************************
2051
+ * Draw the flags of a column form again, for `type`, with
2052
+ * `current` checked.
2053
+ ***************************************************************/
2054
+ function redraw_flags(gobj, $form, type, current)
2055
+ {
2056
+ let $holder = $form.querySelector(".SCHEMA_FLAGS");
2057
+ let $new = createElement2(flags_field(gobj, type, current));
2058
+ $holder.parentNode.replaceChild($new, $holder);
2059
+ refresh_language($new, t);
2060
+ }
2061
+
1918
2062
  /***************************************************************
1919
2063
  * The flags of a column, as checkboxes that say what they do.
1920
2064
  *
@@ -1977,7 +2121,7 @@ function read_flags($root)
1977
2121
  * Offering a field that silently does something else is worse
1978
2122
  * than not offering it.
1979
2123
  ***************************************************************/
1980
- function open_column_form(gobj, topic, col, prefill)
2124
+ function open_column_form(gobj, topic, col, prefill, changes)
1981
2125
  {
1982
2126
  let priv = gobj.priv;
1983
2127
  /* A DUPLICATE is a creation whose fields start filled: the name must
@@ -2051,11 +2195,7 @@ function open_column_form(gobj, topic, col, prefill)
2051
2195
  * new type does not use. */
2052
2196
  let $type = $form.querySelector('[data-name="type"]');
2053
2197
  $type.addEventListener("change", () => {
2054
- let $holder = $form.querySelector(".SCHEMA_FLAGS");
2055
- let current = read_flags($form);
2056
- let $new = createElement2(flags_field(gobj, $type.value, current));
2057
- $holder.parentNode.replaceChild($new, $holder);
2058
- refresh_language($new, t);
2198
+ redraw_flags(gobj, $form, $type.value, read_flags($form));
2059
2199
  });
2060
2200
 
2061
2201
  /* A flag that excludes another is answered here and not on save,
@@ -2075,15 +2215,22 @@ function open_column_form(gobj, topic, col, prefill)
2075
2215
  evt.stopPropagation();
2076
2216
  close_dialog(gobj);
2077
2217
  });
2218
+ /* What the form showed before the operator touched it: what
2219
+ * was CHANGED is measured against it (form_changes()). */
2220
+ let initial = column_form_state($form);
2221
+ if(changes) {
2222
+ apply_form_changes(gobj, $form, changes);
2223
+ }
2224
+
2078
2225
  $form.querySelector(".SCHEMA_COL_FORM_SAVE").addEventListener("click", (evt) => {
2079
2226
  evt.stopPropagation();
2080
- let values = read_form($form);
2081
- values.flag = read_flags($form);
2227
+ let values = column_form_state($form);
2082
2228
  gobj_send_event(gobj, "EV_SAVE_COLUMN", {
2083
2229
  topic: topic.name,
2084
2230
  col: creating ? "" : col.name,
2085
2231
  creating: creating,
2086
2232
  values: values,
2233
+ changed: creating ? values : form_changes(initial, values),
2087
2234
  model_gen: model_gen
2088
2235
  }, gobj);
2089
2236
  });
@@ -2112,7 +2259,7 @@ function stringify_field(value)
2112
2259
  * actually has: a pkey naming no column is a topic that does
2113
2260
  * not open, and it is the first thing the validator looks for.
2114
2261
  ***************************************************************/
2115
- function open_topic_form(gobj, treedb, topic)
2262
+ function open_topic_form(gobj, treedb, topic, changes)
2116
2263
  {
2117
2264
  let record = (topic && topic.record) || {};
2118
2265
  let creating = !topic;
@@ -2171,12 +2318,19 @@ function open_topic_form(gobj, treedb, topic)
2171
2318
  evt.stopPropagation();
2172
2319
  close_dialog(gobj);
2173
2320
  });
2321
+ let initial = read_form($form);
2322
+ if(changes) {
2323
+ apply_form_changes(gobj, $form, changes);
2324
+ }
2325
+
2174
2326
  $form.querySelector(".SCHEMA_TOPIC_FORM_SAVE").addEventListener("click", (evt) => {
2175
2327
  evt.stopPropagation();
2328
+ let values = read_form($form);
2176
2329
  gobj_send_event(gobj, "EV_SAVE_TOPIC", {
2177
2330
  topic: creating ? "" : topic.name,
2178
2331
  creating: creating,
2179
- values: read_form($form),
2332
+ values: values,
2333
+ changed: creating ? values : form_changes(initial, values),
2180
2334
  model_gen: model_gen
2181
2335
  }, gobj);
2182
2336
  });
@@ -2756,27 +2910,46 @@ function end_load(gobj)
2756
2910
  {
2757
2911
  let priv = gobj.priv;
2758
2912
  let seg = priv.pending_seg;
2913
+ let reopen = priv.reopen_form;
2759
2914
 
2760
2915
  priv.pending_seg = null;
2916
+ priv.reopen_form = null;
2761
2917
  if(priv.dialog && priv.dialog_gen !== priv.model_gen) {
2762
- log_warning(`${gobj_short_name(gobj)}: a dialog was open on the schemas ` +
2763
- `the load replaced: closed`);
2918
+ /* The form whose Save asked for this load is opened again,
2919
+ * below, once the screen is there (reload_first()). Any other
2920
+ * dialog is closed, and that is said. */
2921
+ if(!reopen || reopen.dialog !== priv.dialog) {
2922
+ reopen = null;
2923
+ log_warning(`${gobj_short_name(gobj)}: a dialog was open on the schemas ` +
2924
+ `the load replaced: closed`);
2925
+ yui_shell_show_error(yui_shell_of(gobj),
2926
+ "the schemas were read again: open the dialog again", {t: t});
2927
+ }
2764
2928
  close_dialog(gobj);
2765
- yui_shell_show_error(yui_shell_of(gobj),
2766
- "the schemas were read again: open the dialog again", {t: t});
2929
+ } else {
2930
+ /* Closed meanwhile by the operator, or a load that replaced
2931
+ * nothing: the form is where it was, or where it was left. */
2932
+ reopen = null;
2767
2933
  }
2934
+
2935
+ let ret = 0;
2768
2936
  if(priv.model) {
2769
2937
  if(seg !== null) {
2770
- return apply_seg(gobj, seg);
2938
+ ret = apply_seg(gobj, seg);
2939
+ } else {
2940
+ ret = go(gobj, priv.treedb_id, priv.topic_name, priv.diagram, false);
2941
+ }
2942
+ } else {
2943
+ if(seg !== null) {
2944
+ adopt_seg(gobj, seg);
2771
2945
  }
2772
- return go(gobj, priv.treedb_id, priv.topic_name, priv.diagram, false);
2946
+ gobj_change_state(gobj, "ST_IDLE");
2947
+ render(gobj);
2773
2948
  }
2774
- if(seg !== null) {
2775
- adopt_seg(gobj, seg);
2949
+ if(reopen) {
2950
+ reopen_form(gobj, reopen);
2776
2951
  }
2777
- gobj_change_state(gobj, "ST_IDLE");
2778
- render(gobj);
2779
- return 0;
2952
+ return ret;
2780
2953
  }
2781
2954
 
2782
2955
  /***************************************************************
@@ -2819,7 +2992,17 @@ function end_writes(gobj, error)
2819
2992
  render(gobj);
2820
2993
  publish_check(gobj);
2821
2994
  }
2822
- reload_if_owed(gobj);
2995
+ if(priv.refresh_after_write) {
2996
+ /* The EV_REFRESH heard while the writes were in flight
2997
+ * (ac_refresh_after_the_writes()), run now that none is. It
2998
+ * reads the store whole, so it also pays a reload a late
2999
+ * write owed. */
3000
+ priv.refresh_after_write = false;
3001
+ priv.reload_after_write = false;
3002
+ request_model(gobj, false);
3003
+ } else {
3004
+ reload_if_owed(gobj);
3005
+ }
2823
3006
  return error ? -1 : 0;
2824
3007
  }
2825
3008
 
@@ -3279,7 +3462,11 @@ function ac_mt_command_answer(gobj, event, kw, src)
3279
3462
  gobj_publish_event(gobj, "EV_RECORD_WRITTEN", {
3280
3463
  treedb_name: gobj_read_str_attr(gobj, "treedb_name")
3281
3464
  });
3282
- request_model(gobj, true);
3465
+ /* An EV_REFRESH heard during the writes is paid by this
3466
+ * load: it forgets the drafts, as the refresh would. */
3467
+ let refresh = priv.refresh_after_write;
3468
+ priv.refresh_after_write = false;
3469
+ request_model(gobj, !refresh);
3283
3470
  return 0;
3284
3471
  }
3285
3472
  }
@@ -3475,6 +3662,25 @@ function ac_refresh(gobj, event, kw, src)
3475
3662
  return request_model(gobj, false);
3476
3663
  }
3477
3664
 
3665
+ /***************************************************************
3666
+ * EV_REFRESH while writes are in flight: the view's own Refresh
3667
+ * is busy then, so it is a host's (after a Save, say).
3668
+ *
3669
+ * Run when the writes END (end_writes()), not now. Run now, the
3670
+ * load replaced ST_SAVING and what was still queued was never
3671
+ * sent, with no word: nothing but the end of what is in flight
3672
+ * moves this view out of ST_LOADING or ST_SAVING.
3673
+ ***************************************************************/
3674
+ function ac_refresh_after_the_writes(gobj, event, kw, src)
3675
+ {
3676
+ let priv = gobj.priv;
3677
+
3678
+ log_warning(`${gobj_short_name(gobj)}: ${event} while writes are in flight: ` +
3679
+ `the schemas are read again when the writes end`);
3680
+ priv.refresh_after_write = true;
3681
+ return 0;
3682
+ }
3683
+
3478
3684
  /***************************************************************
3479
3685
  * What a DIALOG sends while the model is being read again: the
3480
3686
  * Save of a form left open when the reload started, a
@@ -3717,7 +3923,8 @@ function ac_save_column(gobj, event, kw, src)
3717
3923
  if(refuse_if_readonly(gobj, event)) {
3718
3924
  return -1;
3719
3925
  }
3720
- if(reload_first(gobj, event)) {
3926
+ if(reload_first(gobj, event, {form: "column", topic: kw.topic, col: kw.col,
3927
+ creating: !!kw.creating, changed: kw.changed || {}})) {
3721
3928
  return -1;
3722
3929
  }
3723
3930
  if(built_on_a_replaced_model(gobj, event, kw)) {
@@ -3892,7 +4099,8 @@ function ac_save_topic(gobj, event, kw, src)
3892
4099
  if(refuse_if_readonly(gobj, event)) {
3893
4100
  return -1;
3894
4101
  }
3895
- if(reload_first(gobj, event)) {
4102
+ if(reload_first(gobj, event, {form: "topic", topic: kw.topic, col: "",
4103
+ creating: !!kw.creating, changed: kw.changed || {}})) {
3896
4104
  return -1;
3897
4105
  }
3898
4106
  if(built_on_a_replaced_model(gobj, event, kw)) {
@@ -4306,7 +4514,9 @@ function create_gclass(gclass_name)
4306
4514
  * the host are heard: every other action would be computed
4307
4515
  * against a model the answer still in the air is about to
4308
4516
  * change. */
4309
- ["ST_SAVING", COMMON.slice()]
4517
+ ["ST_SAVING", COMMON.filter((a) => a[0] !== "EV_REFRESH").concat([
4518
+ ["EV_REFRESH", ac_refresh_after_the_writes, null]
4519
+ ])]
4310
4520
  ];
4311
4521
 
4312
4522
  /*---------------------------------------------*
@@ -53,7 +53,7 @@ describe("set_toolbar_busy", () => {
53
53
  expect(tb.save.classList.contains("is-loading")).toBe(false);
54
54
  });
55
55
 
56
- it("busy twice then free: Save and Cancel come back (N9)", () => {
56
+ it("busy twice then free: Save and Cancel come back", () => {
57
57
  /* A record with a picked file went busy for the read and busy
58
58
  * again for the write; the second pass recorded the buttons the
59
59
  * first had disabled as disabled on their own, and the free of a
@@ -30,7 +30,7 @@ describe("form writes in flight", () => {
30
30
  ]);
31
31
  });
32
32
 
33
- it("the transport closes: every write in flight is abandoned, once (N8)", () => {
33
+ it("the transport closes: every write in flight is abandoned, once", () => {
34
34
  /* The form of each one is answered EV_WRITE_REFUSED by the host
35
35
  * and stays open on what was typed; before, it stayed busy for
36
36
  * ever, because nobody answered a write whose answer was lost. */
@@ -0,0 +1,200 @@
1
+ /***********************************************************************
2
+ * g6_graphs_write_owed.wiring.test.js
3
+ *
4
+ * A `__graphs__` write that did not land is OWED, and the Save
5
+ * button says so until a Save writes it again.
6
+ *
7
+ * EV_GRAPHS_WRITE_REFUSED lit the Save (in edition only), and
8
+ * the next repaint of the history buttons put it out again:
9
+ * update_history_buttons() decides the Save from
10
+ * `history.canUndo()` alone, and it runs on every history
11
+ * change, every change of mode and every redraw of the theme.
12
+ * A refusal heard in reading was never shown at all.
13
+ *
14
+ * The real C_G6_NODES_TREE, on a fake G6 graph: only the history
15
+ * plugin and the Save button are what is looked at.
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
+ const {
26
+ SDATA, SDATA_END, data_type_t,
27
+ gclass_create, gobj_start_up, gobj_create_yuno, gobj_create_pure_child,
28
+ gobj_send_event, register_c_timer, set_log_callback,
29
+ } = await import("@yuneta/gobj-js");
30
+ const {register_c_g6_nodes_tree} = await import("./c_g6_nodes_tree.js");
31
+
32
+ const logged = [];
33
+ const published = [];
34
+
35
+ /* G6, as far as these paths ask it: plugins by key, and nothing on
36
+ * screen. Any other method answers undefined. */
37
+ function fake_graph(history)
38
+ {
39
+ let plugins = [];
40
+ const known = {
41
+ rendered: true,
42
+ getPlugins: () => plugins,
43
+ setPlugins: (fn) => {
44
+ plugins = (typeof fn === "function") ? fn(plugins) : fn;
45
+ },
46
+ getPluginInstance: (key) => (key === "history") ? history : {on() {}, off() {}},
47
+ updatePlugin: () => {},
48
+ getBehaviors: () => [],
49
+ setBehaviors: () => {},
50
+ getData: () => ({nodes: [], edges: [], combos: []}),
51
+ getZoom: () => 1,
52
+ };
53
+ return new Proxy(known, {
54
+ get: (t, k) => (k in t) ? t[k] : (() => undefined)
55
+ });
56
+ }
57
+
58
+ let yuno = null;
59
+
60
+ beforeAll(() => {
61
+ gobj_start_up(null, null, null, null, null, null, null);
62
+ set_log_callback((level, msg) => {
63
+ logged.push({level: String(level), msg: String(msg)});
64
+ });
65
+ register_c_timer();
66
+ register_c_g6_nodes_tree();
67
+ /* The host: what the tree publishes lands here. */
68
+ const host_events = ["EV_UPDATE_NODE"];
69
+ gclass_create(
70
+ "C_TEST_GRAPH_HOST",
71
+ host_events.map((e) => [e, 0]),
72
+ [["ST_IDLE", host_events.map((e) => [e, (gobj, ev, kw) => {
73
+ published.push({event: ev, kw: kw});
74
+ return 0;
75
+ }, null])]],
76
+ {}, 0,
77
+ [SDATA(data_type_t.DTP_STRING, "node_uuid", 0, "test-node-uuid", ""), SDATA_END()],
78
+ {}, 0, 0, 0, 0
79
+ );
80
+ yuno = gobj_create_yuno("owed_yuno", "C_TEST_GRAPH_HOST", {});
81
+ });
82
+
83
+ beforeEach(() => {
84
+ logged.length = 0;
85
+ published.length = 0;
86
+ });
87
+
88
+ let seq = 0;
89
+
90
+ /* A tree whose `users` arrangement the backend holds, in `mode`,
91
+ * with a Save button in its container and a history that has
92
+ * nothing to undo. */
93
+ function build(mode)
94
+ {
95
+ const $container = document.createElement("div");
96
+ const tree = gobj_create_pure_child(`tree_${++seq}`, "C_G6_NODES_TREE",
97
+ {$container: $container}, yuno);
98
+ const listeners = [];
99
+ const history = {
100
+ undo: false,
101
+ canUndo() {
102
+ return this.undo;
103
+ },
104
+ canRedo: () => false,
105
+ on: (ev, fn) => {
106
+ listeners.push(fn);
107
+ },
108
+ clear() {
109
+ this.undo = false;
110
+ }
111
+ };
112
+ tree.priv.graph = fake_graph(history);
113
+ tree.priv.graph_rendered = true;
114
+ tree.priv.treedb_name = "treedb_x";
115
+ tree.priv._graph_properties = {users: {nodes: {u1: {x: 10, y: 20}}}};
116
+ tree.priv._saved_graph_properties = {users: {nodes: {u1: {x: 10, y: 20}}}};
117
+
118
+ const $save = document.createElement("div");
119
+ $save.className = "EV_SAVE_GRAPH";
120
+ $save.setAttribute("disabled", "");
121
+ $container.appendChild($save);
122
+
123
+ gobj_send_event(tree, "EV_SET_OPERATION_MODE", {operation_mode: mode}, tree);
124
+ return {
125
+ tree,
126
+ lit: () => !$save.hasAttribute("disabled"),
127
+ history_changed: () => {
128
+ for(const fn of listeners) {
129
+ fn();
130
+ }
131
+ }
132
+ };
133
+ }
134
+
135
+ function errors()
136
+ {
137
+ return logged.filter((l) => l.level === "error").map((l) => l.msg);
138
+ }
139
+
140
+ describe("a refused __graphs__ write keeps the Save lit", () => {
141
+
142
+ test("in edition: a history change does not put it out", () => {
143
+ const g = build("edition");
144
+ expect(g.lit()).toBe(false);
145
+
146
+ gobj_send_event(g.tree, "EV_GRAPHS_WRITE_REFUSED", {topic: "users"}, g.tree);
147
+ expect(g.lit()).toBe(true);
148
+
149
+ g.history_changed(); /* nothing to undo */
150
+ expect(g.lit()).toBe(true);
151
+
152
+ gobj_send_event(g.tree, "EV_SET_OPERATION_MODE", {operation_mode: "edition"}, g.tree);
153
+ expect(g.lit()).toBe(true);
154
+ expect(errors()).toEqual([]);
155
+ });
156
+
157
+ test("heard in reading, it is shown on entering edition", () => {
158
+ const g = build("reading");
159
+ gobj_send_event(g.tree, "EV_GRAPHS_WRITE_REFUSED", {topic: "users"}, g.tree);
160
+
161
+ gobj_send_event(g.tree, "EV_SET_OPERATION_MODE", {operation_mode: "edition"}, g.tree);
162
+ expect(g.lit()).toBe(true);
163
+ expect(errors()).toEqual([]);
164
+ });
165
+
166
+ test("the Save writes it again, and then the Save goes dark", () => {
167
+ const g = build("edition");
168
+ gobj_send_event(g.tree, "EV_GRAPHS_WRITE_REFUSED", {topic: "users"}, g.tree);
169
+
170
+ gobj_send_event(g.tree, "EV_SAVE_GRAPH", {}, g.tree);
171
+ const writes = published.filter((p) => p.event === "EV_UPDATE_NODE");
172
+ expect(writes.map((w) => w.kw.record.id)).toEqual(["users"]);
173
+ expect(g.lit()).toBe(false);
174
+
175
+ g.history_changed();
176
+ expect(g.lit()).toBe(false);
177
+ expect(errors()).toEqual([]);
178
+ });
179
+
180
+ test("refused again after that Save: lit again", () => {
181
+ const g = build("edition");
182
+ gobj_send_event(g.tree, "EV_GRAPHS_WRITE_REFUSED", {topic: "users"}, g.tree);
183
+ gobj_send_event(g.tree, "EV_SAVE_GRAPH", {}, g.tree);
184
+ gobj_send_event(g.tree, "EV_GRAPHS_WRITE_REFUSED", {topic: "users"}, g.tree);
185
+
186
+ g.history_changed();
187
+ expect(g.lit()).toBe(true);
188
+ expect(errors()).toEqual([]);
189
+ });
190
+
191
+ test("a reload forgets it: the view holds what the backend holds", () => {
192
+ const g = build("edition");
193
+ gobj_send_event(g.tree, "EV_GRAPHS_WRITE_REFUSED", {topic: "users"}, g.tree);
194
+ gobj_send_event(g.tree, "EV_CLEAR_DATA", {}, g.tree);
195
+ /* What the load does once the data is in again. */
196
+ gobj_send_event(g.tree, "EV_SET_OPERATION_MODE", {operation_mode: "edition"}, g.tree);
197
+ expect(g.lit()).toBe(false);
198
+ expect(errors()).toEqual([]);
199
+ });
200
+ });
@@ -15,7 +15,7 @@ const model = {
15
15
 
16
16
  describe("mark_host_drafts", () => {
17
17
 
18
- it("marks the topics the host names, by treedb (N13)", () => {
18
+ it("marks the topics the host names, by treedb", () => {
19
19
  /* The mark lived in the memory of one session: after a reload the
20
20
  * editor showed no draft while __system__ still differed from the
21
21
  * file in use. Rebuilt from the host's data. */
@@ -68,7 +68,7 @@ describe("host_draft_ids", () => {
68
68
  .toEqual({"treedb_a^users": true, "treedb_b^users": true});
69
69
  });
70
70
 
71
- it("is a fresh map every time: what the host stopped naming is gone (M1)", () => {
71
+ it("is a fresh map every time: what the host stopped naming is gone", () => {
72
72
  /* mark_host_drafts() only ADDS, and the editor fed it the same map
73
73
  * every time: a topic the host named before the Save stayed a
74
74
  * draft after it. */
@@ -138,7 +138,7 @@ describe("EV_DRAFTS: the host is the truth", () => {
138
138
  expect(drafts_on_screen(editor)).toBe(1);
139
139
  });
140
140
 
141
- test("the host names none any more (a Save): the mark goes (M1)", () => {
141
+ test("the host names none any more (a Save): the mark goes", () => {
142
142
  /* It only ever ADDED: after the Save the editor still said
143
143
  * "unsaved schema changes". */
144
144
  const {editor, host} = build("d2");