@yuneta/gobj-ui 7.26.27 → 7.26.29

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 (45) hide show
  1. package/README.md +590 -25
  2. package/dist/gobj-ui.cjs.js +1080 -229
  3. package/dist/gobj-ui.es.js +1081 -230
  4. package/package.json +4 -2
  5. package/scripts/validate-locales.mjs +2656 -315
  6. package/src/c_g6_nodes_tree.js +1086 -253
  7. package/src/c_yui_form.js +16 -4
  8. package/src/c_yui_fsm_graph.js +5 -0
  9. package/src/c_yui_gclass.js +11 -0
  10. package/src/c_yui_gobj_tree_js.js +18 -1
  11. package/src/c_yui_json_graph.js +5 -0
  12. package/src/c_yui_nav.js +21 -16
  13. package/src/c_yui_pager.js +1 -1
  14. package/src/c_yui_period.js +10 -3
  15. package/src/c_yui_schema_editor.js +34 -18
  16. package/src/c_yui_shell.js +11 -10
  17. package/src/c_yui_treedb_graph.js +17 -9
  18. package/src/c_yui_treedb_schema.js +5 -0
  19. package/src/c_yui_treedb_topic_with_form.js +9 -9
  20. package/src/c_yui_treedb_topics.js +13 -13
  21. package/src/c_yui_window.js +2 -2
  22. package/src/c_yui_window_manager.js +5 -2
  23. package/src/c_yui_wizard.js +8 -1
  24. package/src/form_json_field.js +7 -0
  25. package/src/g6_canvas_tab_order.js +84 -0
  26. package/src/g6_canvas_tab_order.wiring.test.js +232 -0
  27. package/src/g6_nodes_tree_controls.test.js +547 -0
  28. package/src/g6_nodes_tree_focus.wiring.test.js +1360 -0
  29. package/src/g6_nodes_tree_i18n_keys.test.js +241 -5
  30. package/src/i18n_key_functions.js +235 -0
  31. package/src/i18n_key_functions.test.js +1525 -0
  32. package/src/lib_graph.js +38 -1
  33. package/src/nav_backbar_name.wiring.test.js +82 -0
  34. package/src/schema_flags.js +47 -0
  35. package/src/schema_import.js +7 -0
  36. package/src/shell_modals.js +20 -16
  37. package/src/shell_route_map.js +13 -3
  38. package/src/wizard_helpers.js +8 -0
  39. package/src/yui_dev.js +16 -3
  40. package/src/yui_dev_controls.test.js +174 -0
  41. package/src/yui_gclass_view.js +1 -1
  42. package/src/yui_graph_camera.js +1 -1
  43. package/src/yui_install.js +10 -8
  44. package/src/yui_table_select.js +2 -2
  45. package/src/yui_time.js +17 -2
@@ -0,0 +1,1360 @@
1
+ /***********************************************************************
2
+ * g6_nodes_tree_focus.wiring.test.js
3
+ *
4
+ * The real C_G6_NODES_TREE, on a G6 Graph double that behaves as
5
+ * G6 does where it matters here: the first render() leaves FOUR
6
+ * canvases in the container, each with `tabIndex = 1` (G6's
7
+ * configCanvasDom), and says so with `aftercanvasinit`; and a
8
+ * keydown inside the container is forwarded to the graph's
9
+ * `keydown` listeners (G6's forwardContainerEvents).
10
+ *
11
+ * What it holds (REV-CLOUD V20.5, second pass):
12
+ *
13
+ * - **The keyboard is not lost when a focused button goes.** A
14
+ * floating button reached with Tab, or the ✕ of the detail
15
+ * popover, is removed by what the reader does next (Escape
16
+ * deselects, the ✕ closes). Firefox fires no focusout for a
17
+ * removed element: the focus fell to <body> and the graph heard
18
+ * no other key. The view gives the focus to the main canvas
19
+ * BEFORE it removes the element.
20
+ * - **Tab goes from the graph to the graph's own buttons.** The
21
+ * main canvas takes tabindex 0, the other layers -1: a positive
22
+ * tabindex put them before every control of the page.
23
+ * - **A button's click is an EVENT.** The ✕ and the floating
24
+ * buttons send an input event with the identity of what they
25
+ * belong to, and the ACTION does the work.
26
+ * - **The detail popover's ✕ is the named, keyed button** of
27
+ * create_detail_close, and "no fields" carries its key.
28
+ *
29
+ * And from the third pass (V20.5 #4 and #5):
30
+ *
31
+ * - **Every other button of the view is an event too**: the
32
+ * edit toolbar's create, the cancel / apply / confirm rows of
33
+ * the popovers, and the press on the link icon that may be a
34
+ * drag -- with its release. Each carries the identity of what
35
+ * it is about (and what the form held), and an identity that
36
+ * is not the one open is refused, loudly.
37
+ * - **A toggle of the toolbars says its state** in
38
+ * `aria-pressed`, and a disabled item is born out of the Tab
39
+ * order.
40
+ *
41
+ * Copyright (c) 2026, ArtGins.
42
+ * All Rights Reserved.
43
+ ***********************************************************************/
44
+ import {describe, test, expect, beforeAll, beforeEach, afterEach, vi} from "vitest";
45
+ import {install_dom_double, FakeElement} from "../test/dom_double.js";
46
+
47
+ install_dom_double();
48
+
49
+ /* What G6 leaves in the container, and what it forwards. */
50
+ const graphs = vi.hoisted(() => []);
51
+ vi.mock("@antv/g6", async (importOriginal) => {
52
+ const real = await importOriginal();
53
+ class FakeGraph {
54
+ constructor(options) {
55
+ this.options = options;
56
+ this.handlers = {};
57
+ this.plugins = [];
58
+ this.layers = null;
59
+ this.nodes = new Map();
60
+ this.edges = new Map();
61
+ this.rendered = false;
62
+ const $c = options.container;
63
+ $c.addEventListener("keydown", (ev) => {
64
+ this.emit("keydown", ev);
65
+ });
66
+ graphs.push(this);
67
+ return new Proxy(this, {
68
+ get: (t, k) => (k in t) ? t[k] : (() => undefined),
69
+ });
70
+ }
71
+ render() {
72
+ /* The canvas is built by the FIRST render, and G6 says so
73
+ * with `aftercanvasinit` once it is (initCanvas). */
74
+ if(!this.layers) {
75
+ const $c = this.options.container;
76
+ this.layers = {};
77
+ for(const name of ["background", "main", "label", "transient"]) {
78
+ const el = document.createElement("canvas");
79
+ el.setAttribute("data-layer", name);
80
+ el.tabIndex = 1;
81
+ el.isConnected = true;
82
+ if(name !== "main") {
83
+ el.style.pointerEvents = "none";
84
+ }
85
+ $c.appendChild(el);
86
+ this.layers[name] = {getContextService: () => ({getDomElement: () => el})};
87
+ }
88
+ this.emit(real.GraphEvent.AFTER_CANVAS_INIT, {canvas: this.getCanvas()});
89
+ }
90
+ this.rendered = true;
91
+ return Promise.resolve();
92
+ }
93
+ getCanvas() {
94
+ return {
95
+ getLayers: () => this.layers,
96
+ getContainer: () => this.options.container,
97
+ getContextService: () => this.layers.main.getContextService(),
98
+ };
99
+ }
100
+ on(name, fn) {
101
+ (this.handlers[name] = this.handlers[name] || []).push(fn);
102
+ }
103
+ emit(name, ev) {
104
+ for(const fn of (this.handlers[name] || [])) {
105
+ fn(ev);
106
+ }
107
+ }
108
+ getOptions() {
109
+ return {behaviors: []};
110
+ }
111
+ getPlugins() {
112
+ return this.plugins;
113
+ }
114
+ setPlugins(fn) {
115
+ this.plugins = (typeof fn === "function") ? fn(this.plugins) : fn;
116
+ }
117
+ getPluginInstance() {
118
+ /* A plugin that answers anything with "no": nothing to
119
+ * undo, nothing to redo. */
120
+ return new Proxy({undoStack: [], redoStack: []}, {
121
+ get: (t, k) => (k in t) ? t[k] : (() => false),
122
+ });
123
+ }
124
+ getBehaviors() {
125
+ return [];
126
+ }
127
+ getData() {
128
+ return {nodes: [...this.nodes.values()], edges: [], combos: []};
129
+ }
130
+ getNodeData(id) {
131
+ if(id === undefined) {
132
+ return [...this.nodes.values()];
133
+ }
134
+ const nd = this.nodes.get(id);
135
+ if(!nd) {
136
+ throw new Error(`node ${id} not found`);
137
+ }
138
+ return nd;
139
+ }
140
+ getEdgeData(id) {
141
+ if(id === undefined) {
142
+ return [...this.edges.values()];
143
+ }
144
+ const ed = this.edges.get(id);
145
+ if(!ed) {
146
+ throw new Error(`edge ${id} not found`);
147
+ }
148
+ return ed;
149
+ }
150
+ getElementDataByState() {
151
+ return [];
152
+ }
153
+ getElementPosition() {
154
+ return [100, 100];
155
+ }
156
+ getViewportByCanvas(p) {
157
+ return p;
158
+ }
159
+ getCanvasByViewport(p) {
160
+ return p;
161
+ }
162
+ getZoom() {
163
+ return 1;
164
+ }
165
+ draw() {
166
+ return Promise.resolve();
167
+ }
168
+ }
169
+ return {...real, Graph: FakeGraph};
170
+ });
171
+
172
+ const {
173
+ SDATA, SDATA_END, data_type_t,
174
+ gclass_create, gclass_find_by_name, gclass_check_fsm, gclass_event_type,
175
+ gobj_start_up, gobj_create_yuno, gobj_create_pure_child,
176
+ gobj_send_event, register_c_timer, set_log_callback,
177
+ } = await import("@yuneta/gobj-js");
178
+ const {register_c_g6_nodes_tree, toolbar_item_html} = await import("./c_g6_nodes_tree.js");
179
+
180
+ const logged = [];
181
+
182
+ /* A DOM event as the browser dispatches it: at the target, then up
183
+ * its ancestors, until a listener stops it. */
184
+ function dispatch(target, type, init)
185
+ {
186
+ const ev = Object.assign({
187
+ type: type, target: target,
188
+ prevented: false, stopped: false,
189
+ preventDefault() {
190
+ this.prevented = true;
191
+ },
192
+ stopPropagation() {
193
+ this.stopped = true;
194
+ },
195
+ }, init || {});
196
+ for(let n = target; n && !ev.stopped; n = n.parentNode) {
197
+ ev.currentTarget = n;
198
+ for(const fn of [...((n.listeners && n.listeners[type]) || [])]) {
199
+ fn.call(n, ev);
200
+ }
201
+ }
202
+ return ev;
203
+ }
204
+
205
+ FakeElement.prototype.click = function() {
206
+ dispatch(this, "click");
207
+ };
208
+
209
+ /* focus() moves document.activeElement, as in a browser. A removed
210
+ * element KEEPS it here, which is what Firefox does in effect -- no
211
+ * focusout, nothing gives it back -- so a test reads exactly the
212
+ * focus the view left behind. */
213
+ FakeElement.prototype.focus = function() {
214
+ document.activeElement = this;
215
+ };
216
+
217
+ /* main_canvas_of() finds the main layer by its `pointer-events`. */
218
+ window.getComputedStyle = (el) => el.style;
219
+
220
+ /* Which actions ran, by event: each action of the machine, wrapped. */
221
+ const actions_run = [];
222
+
223
+ /* What the view published to its host. */
224
+ const published = [];
225
+
226
+ let yuno = null;
227
+
228
+ beforeAll(async () => {
229
+ const i18next = (await import("i18next")).default;
230
+ await i18next.init({lng: "en", resources: {en: {translation: {
231
+ "close": "Close", "no fields": "No fields", "node properties": "Node properties",
232
+ }}}});
233
+ gobj_start_up(null, null, null, null, null, null, null);
234
+ set_log_callback((level, msg) => {
235
+ logged.push({level: String(level), msg: String(msg)});
236
+ });
237
+ register_c_timer();
238
+ register_c_g6_nodes_tree();
239
+
240
+ const gclass = gclass_find_by_name("C_G6_NODES_TREE", false);
241
+ for(const st of gclass.dl_states) {
242
+ for(const a of st.dl_actions) {
243
+ const action = a.action;
244
+ a.action = (gobj, event, kw, src) => {
245
+ actions_run.push({event: event, kw: JSON.parse(JSON.stringify(kw || {}))});
246
+ return action(gobj, event, kw, src);
247
+ };
248
+ }
249
+ }
250
+
251
+ const host_events = ["EV_VERTEX_CLICKED", "EV_EDGE_CLICKED", "EV_UPDATE_NODE",
252
+ "EV_DELETE_NODE", "EV_LAYOUT_AUTOSET", "EV_LEGEND_STATE", "EV_FIND_RESULT",
253
+ "EV_CREATE_NODE", "EV_LINK_NODES", "EV_UNLINK_NODES"];
254
+ gclass_create(
255
+ "C_TEST_FOCUS_HOST",
256
+ host_events.map((e) => [e, 0]),
257
+ [["ST_IDLE", host_events.map((e) => [e, (gobj, ev, kw) => {
258
+ published.push({event: ev, kw: JSON.parse(JSON.stringify(kw || {}))});
259
+ return 0;
260
+ }, null])]],
261
+ {}, 0,
262
+ [SDATA(data_type_t.DTP_STRING, "node_uuid", 0, "test-node-uuid", ""), SDATA_END()],
263
+ {}, 0, 0, 0, 0
264
+ );
265
+ yuno = gobj_create_yuno("focus_yuno", "C_TEST_FOCUS_HOST", {});
266
+ });
267
+
268
+ /* Nothing but what a test asks for is logged as an error: a test
269
+ * that expects a refusal says so in its name. */
270
+ afterEach((ctx) => {
271
+ if(!ctx.task.name.includes("refused")) {
272
+ expect(errors()).toEqual([]);
273
+ }
274
+ });
275
+
276
+ beforeEach(() => {
277
+ logged.length = 0;
278
+ actions_run.length = 0;
279
+ published.length = 0;
280
+ document.activeElement = document.body;
281
+ });
282
+
283
+ let seq = 0;
284
+
285
+ const DESC = {topic_name: "users", cols: [
286
+ {id: "id", type: "string", flag: ["persistent"]},
287
+ {id: "manager", type: "array", flag: ["fkey"], fkey: {users: "managers"}},
288
+ ]};
289
+
290
+ /* A tree, rendered, in `mode`, holding one node of `record`. */
291
+ async function build(mode, record, attrs)
292
+ {
293
+ const $container = document.createElement("div");
294
+ const tree = gobj_create_pure_child(`focus_tree_${++seq}`, "C_G6_NODES_TREE", Object.assign({
295
+ $container: $container, operation_mode: mode,
296
+ with_toolbar: false, confirm_delete_node: true,
297
+ }, attrs || {}), yuno);
298
+ /* render() resolves on its own time, as G6's does. */
299
+ await new Promise((resolve) => setTimeout(resolve, 0));
300
+ const graph = graphs[graphs.length - 1];
301
+ /* n1 has one port, `manager`, on its right edge: (160, 100) on
302
+ * the canvas, which the double maps 1:1 to the viewport. */
303
+ graph.nodes.set("n1", {
304
+ id: "n1",
305
+ data: {desc: DESC, record: record || {}},
306
+ style: {size: [120, 40], ports: [{key: "manager", placement: [1, 0.5], r: 14}]},
307
+ });
308
+ graph.edges.set("e1", {
309
+ id: "e1", source: "n1", target: "n1",
310
+ data: {parent_topic: "users", parent_id: "u1", hook_name: "managers",
311
+ child_topic: "users", child_id: "u1", fkey_name: "manager"},
312
+ });
313
+ gobj_send_event(tree, "EV_SET_OPERATION_MODE", {operation_mode: mode}, tree);
314
+ const $main = $container.querySelector('canvas[data-layer="main"]');
315
+ return {tree, $container, graph, $main};
316
+ }
317
+
318
+ function errors()
319
+ {
320
+ return logged.filter((l) => l.level === "error").map((l) => l.msg);
321
+ }
322
+
323
+ function ran(event)
324
+ {
325
+ return actions_run.filter((a) => a.event === event);
326
+ }
327
+
328
+ describe("the machine hears the buttons' events", () => {
329
+ test("each one is declared, with an action", () => {
330
+ const gclass = gclass_find_by_name("C_G6_NODES_TREE", false);
331
+ const idle = gclass.dl_states.find((st) => st.state_name === "ST_IDLE");
332
+ for(const ev of ["EV_TOGGLE_NODE_POPOVER", "EV_TOGGLE_EDGE_POPOVER",
333
+ "EV_TOGGLE_PORT_POPOVER", "EV_ENTER_LINKING_MODE",
334
+ "EV_CLOSE_NODE_DETAIL", "EV_REQUEST_DELETE_NODE",
335
+ "EV_REQUEST_UNLINK_EDGE"]) {
336
+ expect(gclass_event_type(gclass, ev), ev).toBeTruthy();
337
+ expect(idle.dl_actions.some((a) => a.event_name === ev &&
338
+ typeof a.action === "function"), ev).toBe(true);
339
+ }
340
+ expect(gclass_check_fsm(gclass)).toBe(0);
341
+ });
342
+ });
343
+
344
+ describe("Tab order: the main canvas in its place, the other layers out", () => {
345
+ test("after render, main is 0 and the other three are -1", async () => {
346
+ const {$container} = await build("reading");
347
+ const order = {};
348
+ for(const c of $container.querySelectorAll("canvas")) {
349
+ order[c.getAttribute("data-layer")] = c.tabIndex;
350
+ }
351
+ expect(order).toEqual({background: -1, main: 0, label: -1, transient: -1});
352
+ });
353
+
354
+ test("the canvas the focus handlers use is the one in the Tab order", async () => {
355
+ const {$container, $main} = await build("reading");
356
+ const card = document.createElement("div");
357
+ $container.appendChild(card);
358
+ dispatch(card, "pointerdown");
359
+ expect(document.activeElement === $main, "the focus is on $main").toBe(true);
360
+ expect($main.tabIndex).toBe(0);
361
+ });
362
+ });
363
+
364
+ describe("the detail popover (reading)", () => {
365
+ test("its ✕ is the keyed button, and 'no fields' carries its key", async () => {
366
+ const {tree, $container} = await build("reading", {__md_treedb__: {}});
367
+ gobj_send_event(tree, "EV_NODE_CLICK", {id: "n1"}, tree);
368
+ const popover = $container.querySelector(".g6-node-detail");
369
+ expect(popover).toBeTruthy();
370
+ const close = popover.querySelector(".g6-detail-head .g6-detail-close");
371
+ expect(close).toBeTruthy();
372
+ expect(close.getAttribute("role")).toBe("button");
373
+ expect(close.getAttribute("tabindex")).toBe("0");
374
+ expect(close.getAttribute("data-i18n-title")).toBe("close");
375
+ expect(close.getAttribute("data-i18n-aria-label")).toBe("close");
376
+ expect(close.getAttribute("title")).toBe("Close");
377
+ expect(close.getAttribute("aria-label")).toBe("Close");
378
+ const empty = popover.querySelector(".g6-detail-empty");
379
+ expect(empty).toBeTruthy();
380
+ expect(empty.getAttribute("data-i18n")).toBe("no fields");
381
+ expect(empty.textContent).toBe("No fields");
382
+ expect(popover.querySelector(".g6-detail-id").textContent).toBe("n1");
383
+ expect(popover.querySelector(".g6-detail-topic").textContent).toBe("users");
384
+ });
385
+
386
+ test("a record's fields are rows of TEXT, and then there is no 'no fields'", async () => {
387
+ const {tree, $container} = await build("reading",
388
+ {id: "u1", name: "<b>Ann</b>", tags: ["a"], __md_treedb__: {}});
389
+ gobj_send_event(tree, "EV_NODE_CLICK", {id: "n1"}, tree);
390
+ const popover = $container.querySelector(".g6-node-detail");
391
+ const rows = popover.querySelectorAll(".g6-detail-row");
392
+ expect(rows.map((r) => [
393
+ r.querySelector(".g6-detail-key").textContent,
394
+ r.querySelector(".g6-detail-val").textContent,
395
+ ])).toEqual([["id", "u1"], ["name", "<b>Ann</b>"], ["tags", '["a"]']]);
396
+ expect(popover.querySelector(".g6-detail-empty")).toBe(null);
397
+ });
398
+
399
+ test("the ✕ click is EV_CLOSE_NODE_DETAIL, and the action closes it", async () => {
400
+ const {tree, $container} = await build("reading");
401
+ gobj_send_event(tree, "EV_NODE_CLICK", {id: "n1"}, tree);
402
+ const close = $container.querySelector(".g6-detail-close");
403
+ close.click();
404
+ expect(ran("EV_CLOSE_NODE_DETAIL")).toEqual([
405
+ {event: "EV_CLOSE_NODE_DETAIL", kw: {node_id: "n1"}}
406
+ ]);
407
+ expect($container.querySelector(".g6-node-detail")).toBe(null);
408
+ expect(tree.priv._detail_node_id).toBe(null);
409
+ expect(errors()).toEqual([]);
410
+ });
411
+
412
+ test("Enter on the focused ✕ closes it, and the focus goes back to the canvas", async () => {
413
+ const {tree, $container, $main} = await build("reading");
414
+ gobj_send_event(tree, "EV_NODE_CLICK", {id: "n1"}, tree);
415
+ const close = $container.querySelector(".g6-detail-close");
416
+ close.focus(); /* reached with Tab */
417
+ const ev = dispatch(close, "keydown", {key: "Enter"});
418
+ expect(ev.stopped).toBe(true);
419
+ expect($container.querySelector(".g6-node-detail")).toBe(null);
420
+ expect(document.activeElement === $main, "the focus is on $main").toBe(true);
421
+ expect(ran("EV_KEY_DOWN")).toEqual([]); /* the key was the button's */
422
+ });
423
+
424
+ test("a pointer press on the ✕ does not take the focus from the canvas", async () => {
425
+ const {tree, $container, $main} = await build("reading");
426
+ gobj_send_event(tree, "EV_NODE_CLICK", {id: "n1"}, tree);
427
+ const close = $container.querySelector(".g6-detail-close");
428
+ $main.focus();
429
+ const ev = dispatch(close, "pointerdown");
430
+ expect(ev.prevented).toBe(true);
431
+ expect(document.activeElement === $main, "the focus is on $main").toBe(true);
432
+ });
433
+
434
+ test("a ✕ of another popover is refused, loudly", async () => {
435
+ const {tree, $container} = await build("reading");
436
+ gobj_send_event(tree, "EV_NODE_CLICK", {id: "n1"}, tree);
437
+ gobj_send_event(tree, "EV_CLOSE_NODE_DETAIL", {node_id: "n9"}, tree);
438
+ expect($container.querySelector(".g6-node-detail")).toBeTruthy();
439
+ expect(errors().some((m) => m.includes("EV_CLOSE_NODE_DETAIL"))).toBe(true);
440
+ });
441
+ });
442
+
443
+ /* The button of `$container` named by `key` (the double's selectors
444
+ * split at a space, so an attribute value with one is matched here). */
445
+ function by_key($container, key)
446
+ {
447
+ return $container.querySelectorAll("[data-i18n-title]")
448
+ .find((el) => el.getAttribute("data-i18n-title") === key) || null;
449
+ }
450
+
451
+ /* A tree in edition with node n1 selected: its gear and its trash. */
452
+ async function selected()
453
+ {
454
+ const b = await build("edition");
455
+ gobj_send_event(b.tree, "EV_NODE_CLICK", {id: "n1", client_x: 0, client_y: 0}, b.tree);
456
+ b.gear = by_key(b.$container, "node properties");
457
+ b.trash = by_key(b.$container, "delete node");
458
+ return b;
459
+ }
460
+
461
+ describe("the floating buttons beside a selected node (edition)", () => {
462
+ test("are there once the node is selected", async () => {
463
+ const {tree, gear, trash} = await selected();
464
+ expect(tree.priv._selected_node_id).toBe("n1");
465
+ expect(gear).toBeTruthy();
466
+ expect(trash).toBeTruthy();
467
+ });
468
+
469
+ test("Escape with the gear focused: the gear goes, the focus goes to the canvas", async () => {
470
+ const {tree, $container, $main, gear} = await selected();
471
+ gear.focus(); /* reached with Tab */
472
+ /* Not the button's key: it reaches the container, which G6
473
+ * forwards to the graph -- EV_KEY_DOWN. */
474
+ const ev = dispatch(gear, "keydown", {key: "Escape"});
475
+ expect(ev.stopped).toBe(false);
476
+ expect(ran("EV_KEY_DOWN").map((a) => a.kw.key)).toEqual(["Escape"]);
477
+ expect(tree.priv._selected_node_id).toBe(null);
478
+ expect(by_key($container, "node properties")).toBe(null);
479
+ /* The graph still has the keyboard: the next key reaches it. */
480
+ expect(document.activeElement === $main, "the focus is on $main").toBe(true);
481
+ dispatch(document.activeElement, "keydown", {key: "Delete"});
482
+ expect(ran("EV_KEY_DOWN").map((a) => a.kw.key)).toEqual(["Escape", "Delete"]);
483
+ });
484
+
485
+ test("a pointer press on the gear leaves the focus on the canvas", async () => {
486
+ const {$main, gear} = await selected();
487
+ /* The container's listener puts it on the canvas, and the
488
+ * gear's own cancels the mousedown that would move it on to
489
+ * the gear. */
490
+ const ev = dispatch(gear, "pointerdown");
491
+ expect(ev.prevented).toBe(true);
492
+ expect(document.activeElement === $main, "the focus is on $main").toBe(true);
493
+ });
494
+
495
+ test("the gear's click is EV_TOGGLE_NODE_POPOVER with the node's identity", async () => {
496
+ const {$container, gear} = await selected();
497
+ gear.click();
498
+ expect(ran("EV_TOGGLE_NODE_POPOVER")).toEqual([
499
+ {event: "EV_TOGGLE_NODE_POPOVER", kw: {node_id: "n1"}}
500
+ ]);
501
+ expect($container.querySelector(".g6-node-popover")).toBeTruthy();
502
+ gear.click();
503
+ expect($container.querySelector(".g6-node-popover")).toBe(null);
504
+ });
505
+
506
+ test("Enter on the trash is EV_REQUEST_DELETE_NODE, and the graph hears no key", async () => {
507
+ const {trash} = await selected();
508
+ trash.focus();
509
+ dispatch(trash, "keydown", {key: "Enter"});
510
+ expect(ran("EV_REQUEST_DELETE_NODE")).toEqual([
511
+ {event: "EV_REQUEST_DELETE_NODE", kw: {node_id: "n1"}}
512
+ ]);
513
+ expect(ran("EV_KEY_DOWN")).toEqual([]);
514
+ });
515
+
516
+ test("an identity that is not the selection is refused, loudly", async () => {
517
+ const {tree, $container} = await selected();
518
+ gobj_send_event(tree, "EV_TOGGLE_NODE_POPOVER", {node_id: "n9"}, tree);
519
+ gobj_send_event(tree, "EV_REQUEST_DELETE_NODE", {}, tree);
520
+ expect($container.querySelector(".g6-node-popover")).toBe(null);
521
+ expect(errors().filter((m) => m.includes("but the selection is")).length).toBe(2);
522
+ });
523
+ });
524
+
525
+ describe("the floating buttons beside a selected edge (edition)", () => {
526
+ async function edge_selected()
527
+ {
528
+ const b = await build("edition");
529
+ gobj_send_event(b.tree, "EV_EDGE_CLICK", {id: "e1"}, b.tree);
530
+ b.gear = by_key(b.$container, "edge properties");
531
+ b.trash = by_key(b.$container, "unlink");
532
+ return b;
533
+ }
534
+
535
+ test("the gear's click is EV_TOGGLE_EDGE_POPOVER, Enter on the trash EV_REQUEST_UNLINK_EDGE", async () => {
536
+ const {tree, gear, trash} = await edge_selected();
537
+ expect(tree.priv._selected_edge_id).toBe("e1");
538
+ gear.click();
539
+ expect(ran("EV_TOGGLE_EDGE_POPOVER")).toEqual([
540
+ {event: "EV_TOGGLE_EDGE_POPOVER", kw: {edge_id: "e1"}}
541
+ ]);
542
+ dispatch(trash, "keydown", {key: "Enter"});
543
+ expect(ran("EV_REQUEST_UNLINK_EDGE")).toEqual([
544
+ {event: "EV_REQUEST_UNLINK_EDGE", kw: {edge_id: "e1"}}
545
+ ]);
546
+ expect(ran("EV_KEY_DOWN")).toEqual([]);
547
+ });
548
+
549
+ test("Escape with the trash focused: the focus goes to the canvas", async () => {
550
+ const {$container, $main, trash} = await edge_selected();
551
+ trash.focus();
552
+ dispatch(trash, "keydown", {key: "Escape"});
553
+ expect(by_key($container, "unlink")).toBe(null);
554
+ expect(document.activeElement === $main, "the focus is on $main").toBe(true);
555
+ });
556
+ });
557
+
558
+ describe("the floating buttons beside a selected fkey port (edition)", () => {
559
+ async function port_selected()
560
+ {
561
+ const b = await build("edition");
562
+ gobj_send_event(b.tree, "EV_NODE_CLICK", {id: "n1", client_x: 160, client_y: 100}, b.tree);
563
+ b.link = by_key(b.$container, "link to hook");
564
+ b.gear = by_key(b.$container, "port properties");
565
+ return b;
566
+ }
567
+
568
+ test("the link and the gear send their events with the port's identity", async () => {
569
+ const {tree, link, gear} = await port_selected();
570
+ expect(tree.priv._selected_port_key).toBe("manager");
571
+ expect(link).toBeTruthy();
572
+ expect(gear).toBeTruthy();
573
+ gear.click();
574
+ expect(ran("EV_TOGGLE_PORT_POPOVER")).toEqual([
575
+ {event: "EV_TOGGLE_PORT_POPOVER", kw: {node_id: "n1", port_key: "manager"}}
576
+ ]);
577
+ link.click();
578
+ expect(ran("EV_ENTER_LINKING_MODE")).toEqual([
579
+ {event: "EV_ENTER_LINKING_MODE", kw: {node_id: "n1", port_key: "manager"}}
580
+ ]);
581
+ });
582
+
583
+ test("Escape with the port gear focused: the focus goes to the canvas", async () => {
584
+ const {$container, $main, gear} = await port_selected();
585
+ gear.focus();
586
+ dispatch(gear, "keydown", {key: "Escape"});
587
+ expect(by_key($container, "port properties")).toBe(null);
588
+ expect(document.activeElement === $main, "the focus is on $main").toBe(true);
589
+ });
590
+ });
591
+
592
+ /* What the view published to its host, of `events` only: a click
593
+ * on a node also tells the host (EV_VERTEX_CLICKED). */
594
+ function published_of(...events)
595
+ {
596
+ return published.filter((p) => events.includes(p.event));
597
+ }
598
+
599
+ /* A browser's <select> holds its first option until somebody picks
600
+ * another; the double's holds "". Picked here, as the person would. */
601
+ function pick_first_options($popover)
602
+ {
603
+ for(const $sel of $popover.querySelectorAll("select")) {
604
+ const first = $sel.querySelectorAll("option")[0];
605
+ $sel.value = first? first.value : "";
606
+ }
607
+ }
608
+
609
+ /* The button of `$popover` whose text carries `key`. */
610
+ function button_of($popover, key)
611
+ {
612
+ return $popover.querySelectorAll("button")
613
+ .find((b) => b.getAttribute("data-i18n") === key) || null;
614
+ }
615
+
616
+ /* The plugin a view handed G6, by key: its getItems and onClick. */
617
+ function plugin_def(graph, key)
618
+ {
619
+ return graph.plugins.find((p) => p && p.key === key) || null;
620
+ }
621
+
622
+ /* What the view writes on the live style, recorded. */
623
+ function record_updates(graph)
624
+ {
625
+ const calls = [];
626
+ graph.updateNodeData = (arr) => {
627
+ calls.push(JSON.parse(JSON.stringify(arr)));
628
+ };
629
+ graph.updateEdgeData = (arr) => {
630
+ calls.push(JSON.parse(JSON.stringify(arr)));
631
+ };
632
+ return calls;
633
+ }
634
+
635
+ describe("the edit toolbar's create button is an EVENT (third pass)", () => {
636
+ async function edition_with_toolbar()
637
+ {
638
+ const b = await build("edition", null, {with_toolbar: true});
639
+ gobj_send_event(b.tree, "EV_DESCS", {users: JSON.parse(JSON.stringify(DESC))}, b.tree);
640
+ b.edit = plugin_def(b.graph, "toolbar-edit");
641
+ return b;
642
+ }
643
+
644
+ test("create-node is EV_TOGGLE_CREATE_POPOVER, and the action opens and closes it", async () => {
645
+ const {tree, $container, edit} = await edition_with_toolbar();
646
+ expect(edit).toBeTruthy();
647
+ edit.onClick("create-node");
648
+ expect(ran("EV_TOGGLE_CREATE_POPOVER")).toEqual([
649
+ {event: "EV_TOGGLE_CREATE_POPOVER", kw: {}}
650
+ ]);
651
+ expect($container.querySelector(".g6-create-popover")).toBeTruthy();
652
+ edit.onClick("create-node");
653
+ expect($container.querySelector(".g6-create-popover")).toBe(null);
654
+ expect(tree.priv._create_popover_el).toBe(null);
655
+ });
656
+
657
+ test("its cancel and Escape are EV_CLOSE_CREATE_POPOVER", async () => {
658
+ const {$container, edit} = await edition_with_toolbar();
659
+ edit.onClick("create-node");
660
+ let $pop = $container.querySelector(".g6-create-popover");
661
+ button_of($pop, "cancel").click();
662
+ expect(ran("EV_CLOSE_CREATE_POPOVER")).toEqual([
663
+ {event: "EV_CLOSE_CREATE_POPOVER", kw: {}}
664
+ ]);
665
+ expect($container.querySelector(".g6-create-popover")).toBe(null);
666
+
667
+ edit.onClick("create-node");
668
+ $pop = $container.querySelector(".g6-create-popover");
669
+ dispatch($pop.querySelector(".G6_CREATE_ID"), "keydown", {key: "Escape"});
670
+ expect(ran("EV_CLOSE_CREATE_POPOVER").length).toBe(2);
671
+ expect($container.querySelector(".g6-create-popover")).toBe(null);
672
+ });
673
+
674
+ test("create sends what the form holds; an empty id is said in the popover", async () => {
675
+ const {$container, edit} = await edition_with_toolbar();
676
+ edit.onClick("create-node");
677
+ const $pop = $container.querySelector(".g6-create-popover");
678
+ pick_first_options($pop);
679
+ const $id = $pop.querySelector(".G6_CREATE_ID");
680
+ const $create = button_of($pop, "create");
681
+
682
+ $create.click();
683
+ expect(ran("EV_SUBMIT_NEW_NODE")).toEqual([
684
+ {event: "EV_SUBMIT_NEW_NODE", kw: {topic_name: "users", id: ""}}
685
+ ]);
686
+ const $error = $pop.querySelector(".G6_CREATE_ERROR");
687
+ expect($error.getAttribute("data-i18n")).toBe("node id required");
688
+ expect($error.style.display).toBe("block");
689
+ expect(document.activeElement === $id, "the focus is on the id").toBe(true);
690
+ expect(published_of("EV_CREATE_NODE")).toEqual([]);
691
+
692
+ $id.value = " u7 ";
693
+ dispatch($id, "keydown", {key: "Enter"});
694
+ expect(ran("EV_SUBMIT_NEW_NODE")[1]).toEqual(
695
+ {event: "EV_SUBMIT_NEW_NODE", kw: {topic_name: "users", id: " u7 "}}
696
+ );
697
+ expect(published_of("EV_CREATE_NODE").map((p) => [p.kw.topic_name, p.kw.record])).toEqual([
698
+ ["users", {id: "u7"}]
699
+ ]);
700
+ expect($container.querySelector(".g6-create-popover")).toBe(null);
701
+ });
702
+
703
+ test("a close with no create popover open is refused, loudly", async () => {
704
+ const {tree} = await edition_with_toolbar();
705
+ gobj_send_event(tree, "EV_CLOSE_CREATE_POPOVER", {}, tree);
706
+ expect(errors().some((m) => m.includes("no create popover open"))).toBe(true);
707
+ });
708
+
709
+ test("a create toggle outside edition is refused, loudly", async () => {
710
+ const {tree, $container} = await build("reading");
711
+ gobj_send_event(tree, "EV_TOGGLE_CREATE_POPOVER", {}, tree);
712
+ expect($container.querySelector(".g6-create-popover")).toBe(null);
713
+ expect(errors().some((m) => m.includes("EV_TOGGLE_CREATE_POPOVER outside edition"))).toBe(true);
714
+ });
715
+ });
716
+
717
+ describe("the popover button rows are EVENTS (third pass)", () => {
718
+ test("node popover: apply sends the identity and the form, the action applies it", async () => {
719
+ const {tree, $container, graph, gear} = await selected();
720
+ gear.click();
721
+ const $pop = $container.querySelector(".g6-node-popover");
722
+ pick_first_options($pop);
723
+ const inputs = $pop.querySelectorAll("input");
724
+ inputs[0].value = "#ff0000"; /* fill */
725
+ const calls = record_updates(graph);
726
+ button_of($pop, "apply").click();
727
+ const sent = ran("EV_APPLY_NODE_POPOVER");
728
+ expect(sent.length).toBe(1);
729
+ expect(sent[0].kw).toMatchObject({node_id: "n1", fill: "#ff0000", scope: "this"});
730
+ expect(Object.keys(sent[0].kw).sort()).toEqual(
731
+ ["fill", "line_width", "node_id", "scope", "shape", "shape_before", "stroke"]);
732
+ expect(calls[0][0]).toMatchObject({id: "n1", style: {fill: "#ff0000"}});
733
+ expect($container.querySelector(".g6-node-popover")).toBe(null);
734
+ expect(tree.priv._node_preview_undo).toBe(null);
735
+ });
736
+
737
+ test("node popover: cancel is EV_CANCEL_NODE_POPOVER, and the preview is undone", async () => {
738
+ const {tree, $container, graph, gear} = await selected();
739
+ gear.click();
740
+ const $pop = $container.querySelector(".g6-node-popover");
741
+ const calls = record_updates(graph);
742
+ button_of($pop, "cancel").click();
743
+ expect(ran("EV_CANCEL_NODE_POPOVER")).toEqual([
744
+ {event: "EV_CANCEL_NODE_POPOVER", kw: {node_id: "n1"}}
745
+ ]);
746
+ expect($container.querySelector(".g6-node-popover")).toBe(null);
747
+ /* The undo of the preview repaints the node as it opened. */
748
+ expect(calls.length).toBe(1);
749
+ expect(tree.priv._node_preview_undo).toBe(null);
750
+ });
751
+
752
+ test("node popover: another node's apply, or none open, is refused, loudly", async () => {
753
+ const {tree, $container, gear} = await selected();
754
+ gobj_send_event(tree, "EV_APPLY_NODE_POPOVER", {node_id: "n1", fill: "#000"}, tree);
755
+ expect(errors().some((m) => m.includes("its popover is not open"))).toBe(true);
756
+ gear.click();
757
+ gobj_send_event(tree, "EV_CANCEL_NODE_POPOVER", {node_id: "n9"}, tree);
758
+ expect($container.querySelector(".g6-node-popover")).toBeTruthy();
759
+ expect(errors().some((m) => m.includes("but the selection is"))).toBe(true);
760
+ });
761
+
762
+ test("edge popover: apply and cancel are events, with the edge's identity", async () => {
763
+ const b = await build("edition");
764
+ gobj_send_event(b.tree, "EV_EDGE_CLICK", {id: "e1"}, b.tree);
765
+ by_key(b.$container, "edge properties").click();
766
+ let $pop = b.$container.querySelector(".g6-edge-popover");
767
+ pick_first_options($pop);
768
+ const calls = record_updates(b.graph);
769
+ /* The double's getData() lists no edge; G6's lists them all. */
770
+ b.graph.getData = () => ({nodes: [...b.graph.nodes.values()],
771
+ edges: [...b.graph.edges.values()], combos: []});
772
+ $pop.querySelectorAll("input")[0].value = "7"; /* line width */
773
+ button_of($pop, "apply").click();
774
+ expect(ran("EV_APPLY_EDGE_POPOVER").map((a) => a.kw)).toEqual([
775
+ {edge_id: "e1", line_width: "7", color: $pop.querySelectorAll("input")[1].value, scope: "this"}
776
+ ]);
777
+ expect(calls[0][0]).toMatchObject({id: "e1", style: {lineWidth: 7}});
778
+ expect(b.tree.priv._selected_edge_id).toBe(null);
779
+
780
+ gobj_send_event(b.tree, "EV_EDGE_CLICK", {id: "e1"}, b.tree);
781
+ by_key(b.$container, "edge properties").click();
782
+ $pop = b.$container.querySelector(".g6-edge-popover");
783
+ button_of($pop, "cancel").click();
784
+ expect(ran("EV_CANCEL_EDGE_POPOVER")).toEqual([
785
+ {event: "EV_CANCEL_EDGE_POPOVER", kw: {edge_id: "e1"}}
786
+ ]);
787
+ expect(b.$container.querySelector(".g6-edge-popover")).toBe(null);
788
+ expect(b.tree.priv._selected_edge_id).toBe(null);
789
+ });
790
+
791
+ test("port popover: apply and cancel are events, with the port's identity", async () => {
792
+ const b = await build("edition");
793
+ gobj_send_event(b.tree, "EV_NODE_CLICK", {id: "n1", client_x: 160, client_y: 100}, b.tree);
794
+ by_key(b.$container, "port properties").click();
795
+ let $pop = b.$container.querySelector(".g6-port-popover");
796
+ pick_first_options($pop);
797
+ $pop.querySelectorAll("input")[0].value = "20"; /* radius */
798
+ button_of($pop, "apply").click();
799
+ const sent = ran("EV_APPLY_PORT_POPOVER");
800
+ expect(sent.length).toBe(1);
801
+ expect(sent[0].kw).toMatchObject({node_id: "n1", port_key: "manager", r: "20", scope: "this"});
802
+ expect(b.$container.querySelector(".g6-port-popover")).toBe(null);
803
+
804
+ by_key(b.$container, "port properties").click();
805
+ $pop = b.$container.querySelector(".g6-port-popover");
806
+ button_of($pop, "cancel").click();
807
+ expect(ran("EV_CANCEL_PORT_POPOVER")).toEqual([
808
+ {event: "EV_CANCEL_PORT_POPOVER", kw: {node_id: "n1", port_key: "manager"}}
809
+ ]);
810
+ expect(b.$container.querySelector(".g6-port-popover")).toBe(null);
811
+ });
812
+
813
+ test("confirm popover: cancel is EV_CANCEL_CONFIRM, confirm EV_CONFIRMED, same question", async () => {
814
+ const {tree, $container, trash} = await selected();
815
+ trash.click();
816
+ let $pop = $container.querySelector(".g6-confirm-popover");
817
+ expect($pop).toBeTruthy();
818
+ button_of($pop, "cancel").click();
819
+ expect(ran("EV_CANCEL_CONFIRM")).toEqual([
820
+ {event: "EV_CANCEL_CONFIRM", kw: {what: "delete_node", id: "n1"}}
821
+ ]);
822
+ expect($container.querySelector(".g6-confirm-popover")).toBe(null);
823
+ expect(published_of("EV_DELETE_NODE")).toEqual([]);
824
+
825
+ trash.click();
826
+ $pop = $container.querySelector(".g6-confirm-popover");
827
+ button_of($pop, "delete").click();
828
+ expect(ran("EV_CONFIRMED")).toEqual([
829
+ {event: "EV_CONFIRMED", kw: {what: "delete_node", id: "n1"}}
830
+ ]);
831
+ expect(published_of("EV_DELETE_NODE").length).toBe(1);
832
+ expect(tree.priv._delete_confirm_el).toBe(null);
833
+ });
834
+
835
+ test("a cancel of a question that is not the one open is refused, loudly", async () => {
836
+ const {tree, $container, trash} = await selected();
837
+ trash.click();
838
+ gobj_send_event(tree, "EV_CANCEL_CONFIRM", {what: "delete_node", id: "n9"}, tree);
839
+ gobj_send_event(tree, "EV_CANCEL_CONFIRM", {what: "unlink_edge", id: "n1"}, tree);
840
+ expect($container.querySelector(".g6-confirm-popover")).toBeTruthy();
841
+ expect(errors().filter((m) => m.includes("but the question open is")).length).toBe(2);
842
+ });
843
+ });
844
+
845
+ /* Each of the six actions of the popover rows asks the same two
846
+ * questions before it does anything (popover_is_current): is its
847
+ * popover open, and is the identity in the kw the selection? A row
848
+ * only checked through its own buttons never asks either -- the
849
+ * button exists only while its popover does -- so each is asked here
850
+ * with the event sent by hand (V20, a fourth pass: dropping the check
851
+ * from the edge apply or the port cancel left every test green). */
852
+ describe("a popover's apply and cancel act only on the popover open (fourth pass)", () => {
853
+ /* The selection each popover belongs to, made with the popover
854
+ * CLOSED; `open` opens it. */
855
+ async function node_selected()
856
+ {
857
+ const b = await selected();
858
+ b.open = () => {
859
+ b.gear.click();
860
+ };
861
+ return b;
862
+ }
863
+
864
+ async function edge_selected()
865
+ {
866
+ const b = await build("edition");
867
+ gobj_send_event(b.tree, "EV_EDGE_CLICK", {id: "e1"}, b.tree);
868
+ b.open = () => {
869
+ by_key(b.$container, "edge properties").click();
870
+ };
871
+ return b;
872
+ }
873
+
874
+ async function port_selected()
875
+ {
876
+ const b = await build("edition");
877
+ gobj_send_event(b.tree, "EV_NODE_CLICK", {id: "n1", client_x: 160, client_y: 100}, b.tree);
878
+ b.open = () => {
879
+ by_key(b.$container, "port properties").click();
880
+ };
881
+ return b;
882
+ }
883
+
884
+ const CASES = [
885
+ {event: "EV_APPLY_NODE_POPOVER", popover: ".g6-node-popover", select: node_selected,
886
+ kw: {node_id: "n1", shape_before: "", fill: "#ff0000", stroke: "#000000",
887
+ line_width: "3", scope: "this", shape: ""},
888
+ wrong: {node_id: "n9"}},
889
+ {event: "EV_CANCEL_NODE_POPOVER", popover: ".g6-node-popover", select: node_selected,
890
+ kw: {node_id: "n1"},
891
+ wrong: {node_id: "n9"}},
892
+ {event: "EV_APPLY_EDGE_POPOVER", popover: ".g6-edge-popover", select: edge_selected,
893
+ kw: {edge_id: "e1", line_width: "7", color: "#ff0000", scope: "this"},
894
+ wrong: {edge_id: "e9"}},
895
+ {event: "EV_CANCEL_EDGE_POPOVER", popover: ".g6-edge-popover", select: edge_selected,
896
+ kw: {edge_id: "e1"},
897
+ wrong: {edge_id: "e9"}},
898
+ {event: "EV_APPLY_PORT_POPOVER", popover: ".g6-port-popover", select: port_selected,
899
+ kw: {node_id: "n1", port_key: "manager", r_before: 14, shape: "circle", r: "20", scope: "this"},
900
+ wrong: {port_key: "other"}},
901
+ {event: "EV_CANCEL_PORT_POPOVER", popover: ".g6-port-popover", select: port_selected,
902
+ kw: {node_id: "n1", port_key: "manager"},
903
+ wrong: {port_key: "other"}},
904
+ ];
905
+
906
+ function selection_of(tree)
907
+ {
908
+ return {
909
+ node: tree.priv._selected_node_id,
910
+ port: tree.priv._selected_port_key,
911
+ edge: tree.priv._selected_edge_id,
912
+ };
913
+ }
914
+
915
+ for(const c of CASES) {
916
+ test(`${c.event}: with its popover closed, or for another selection, it is refused, loudly`, async () => {
917
+ const b = await c.select();
918
+ /* The double's getData() lists no edge; G6's lists them all,
919
+ * and an apply that ran would write e1. */
920
+ b.graph.getData = () => ({nodes: [...b.graph.nodes.values()],
921
+ edges: [...b.graph.edges.values()], combos: []});
922
+ const calls = record_updates(b.graph);
923
+ const before = selection_of(b.tree);
924
+
925
+ gobj_send_event(b.tree, c.event, JSON.parse(JSON.stringify(c.kw)), b.tree);
926
+ expect(errors().length).toBe(1);
927
+ expect(errors()[0]).toContain(`${c.event} for `);
928
+ expect(errors()[0]).toContain("but its popover is not open");
929
+ expect(calls).toEqual([]);
930
+ expect(selection_of(b.tree)).toEqual(before);
931
+
932
+ b.open();
933
+ const $pop = b.$container.querySelector(c.popover);
934
+ expect($pop).toBeTruthy();
935
+ logged.length = 0;
936
+ const written = calls.length;
937
+
938
+ gobj_send_event(b.tree, c.event, Object.assign(JSON.parse(JSON.stringify(c.kw)), c.wrong), b.tree);
939
+ expect(errors().length).toBe(1);
940
+ expect(errors()[0]).toContain("but the selection is");
941
+ expect(b.$container.querySelector(c.popover) === $pop, "the popover is still open").toBe(true);
942
+ expect(calls.length).toBe(written);
943
+ expect(selection_of(b.tree)).toEqual(before);
944
+ });
945
+ }
946
+ });
947
+
948
+ describe("the link icon's drag is an EVENT, and so is its release (third pass)", () => {
949
+ const DESC_LINK = {topic_name: "users", cols: [
950
+ {id: "id", type: "string", flag: ["persistent"]},
951
+ {id: "manager", type: "array", flag: ["fkey"], fkey: {users: "managers"}},
952
+ {id: "managers", type: "object", flag: ["hook"], hook: {users: "manager"}},
953
+ ]};
954
+
955
+ /* n1's fkey port selected, and n2 -- another user -- whose
956
+ * `managers` hook is at (40, 100) on the canvas. */
957
+ async function linkable()
958
+ {
959
+ const b = await build("edition");
960
+ gobj_send_event(b.tree, "EV_DESCS", {users: JSON.parse(JSON.stringify(DESC_LINK))}, b.tree);
961
+ b.graph.nodes.get("n1").data.desc = DESC_LINK;
962
+ b.graph.nodes.set("n2", {
963
+ id: "n2",
964
+ data: {desc: DESC_LINK, record: {id: "u2"}},
965
+ style: {size: [120, 40], ports: [{key: "managers", placement: [0, 0.5], r: 14}]},
966
+ });
967
+ b.graph.nodes.get("n1").data.record = {id: "u1"};
968
+ gobj_send_event(b.tree, "EV_NODE_CLICK", {id: "n1", client_x: 160, client_y: 100}, b.tree);
969
+ b.link = by_key(b.$container, "link to hook");
970
+ return b;
971
+ }
972
+
973
+ function on_document(type, init)
974
+ {
975
+ document.dispatchEvent(Object.assign({type: type}, init || {}));
976
+ }
977
+
978
+ test("the press is EV_START_LINK_DRAG with the port's identity and where it went down", async () => {
979
+ const {tree, $container, link} = await linkable();
980
+ expect(link).toBeTruthy();
981
+ const ev = dispatch(link, "pointerdown", {clientX: 170, clientY: 105});
982
+ expect(ev.prevented).toBe(true);
983
+ expect(ran("EV_START_LINK_DRAG")).toEqual([
984
+ {event: "EV_START_LINK_DRAG",
985
+ kw: {node_id: "n1", port_key: "manager", client_x: 170, client_y: 105}}
986
+ ]);
987
+ expect(tree.priv._linking_mode).toBe(true);
988
+ expect(tree.priv._link_drag_svg).toBeTruthy();
989
+
990
+ /* No move: a click. The release takes the drag down, and the
991
+ * click that follows is the icon's own event. */
992
+ on_document("pointerup", {clientX: 170, clientY: 105});
993
+ expect(ran("EV_LINK_DRAG_END")).toEqual([
994
+ {event: "EV_LINK_DRAG_END", kw: {client_x: 170, client_y: 105, dragged: false}}
995
+ ]);
996
+ expect(tree.priv._link_drag).toBe(null);
997
+ expect(tree.priv._link_drag_svg).toBe(null);
998
+ expect(tree.priv._linking_mode).toBe(true);
999
+ expect(published_of("EV_LINK_NODES")).toEqual([]);
1000
+ expect($container.style.cursor).toBe("");
1001
+ });
1002
+
1003
+ test("a drag dropped on a hook links, through EV_LINK_DRAG_END", async () => {
1004
+ const {tree, link} = await linkable();
1005
+ dispatch(link, "pointerdown", {clientX: 170, clientY: 105});
1006
+ on_document("pointermove", {clientX: 100, clientY: 100});
1007
+ on_document("pointerup", {clientX: 40, clientY: 100});
1008
+ expect(ran("EV_LINK_DRAG_END").map((a) => a.kw)).toEqual([
1009
+ {client_x: 40, client_y: 100, dragged: true}
1010
+ ]);
1011
+ expect(published_of("EV_LINK_NODES").map((p) => [p.kw.parent_ref, p.kw.child_ref])).toEqual([
1012
+ ["users^u2^managers", "users^u1"]
1013
+ ]);
1014
+ expect(tree.priv._linking_mode).toBe(false);
1015
+ /* The listeners are off: another release is nobody's. */
1016
+ on_document("pointerup", {clientX: 40, clientY: 100});
1017
+ expect(ran("EV_LINK_DRAG_END").length).toBe(1);
1018
+ });
1019
+
1020
+ test("a drag dropped on nothing leaves the linking mode", async () => {
1021
+ const {tree, link} = await linkable();
1022
+ dispatch(link, "pointerdown", {clientX: 170, clientY: 105});
1023
+ on_document("pointermove", {clientX: 300, clientY: 300});
1024
+ on_document("pointerup", {clientX: 300, clientY: 300});
1025
+ expect(published_of("EV_LINK_NODES")).toEqual([]);
1026
+ expect(tree.priv._linking_mode).toBe(false);
1027
+ });
1028
+
1029
+ test("a cancelled pointer is EV_LINK_DRAG_CANCEL: no drop, no linking mode", async () => {
1030
+ const {tree, link} = await linkable();
1031
+ dispatch(link, "pointerdown", {clientX: 170, clientY: 105});
1032
+ on_document("pointercancel", {});
1033
+ expect(ran("EV_LINK_DRAG_CANCEL")).toEqual([
1034
+ {event: "EV_LINK_DRAG_CANCEL", kw: {}}
1035
+ ]);
1036
+ expect(tree.priv._linking_mode).toBe(false);
1037
+ expect(tree.priv._link_drag_svg).toBe(null);
1038
+ expect(published_of("EV_LINK_NODES")).toEqual([]);
1039
+ });
1040
+
1041
+ /* What a view leaves on `document`, of the pointer listeners a
1042
+ * link drag puts there: the kind of each one still on. */
1043
+ function track_document_pointer_listeners()
1044
+ {
1045
+ const add = document.addEventListener;
1046
+ const remove = document.removeEventListener;
1047
+ const live = [];
1048
+ document.addEventListener = (type, fn) => {
1049
+ live.push({type, fn});
1050
+ return add(type, fn);
1051
+ };
1052
+ document.removeEventListener = (type, fn) => {
1053
+ const i = live.findIndex((l) => l.type === type && l.fn === fn);
1054
+ if(i >= 0) {
1055
+ live.splice(i, 1);
1056
+ }
1057
+ return remove(type, fn);
1058
+ };
1059
+ return {
1060
+ live: () => live.map((l) => l.type).filter((t) => t.startsWith("pointer")).sort(),
1061
+ restore() {
1062
+ document.addEventListener = add;
1063
+ document.removeEventListener = remove;
1064
+ },
1065
+ };
1066
+ }
1067
+
1068
+ test("a press while a drag is still in flight takes that drag down first: its listeners, its line", async () => {
1069
+ const {tree, $container} = await linkable();
1070
+ const doc = track_document_pointer_listeners();
1071
+ try {
1072
+ gobj_send_event(tree, "EV_START_LINK_DRAG",
1073
+ {node_id: "n1", port_key: "manager", client_x: 170, client_y: 105}, tree);
1074
+ expect(doc.live()).toEqual(["pointercancel", "pointermove", "pointerup"]);
1075
+ const first_line = tree.priv._link_drag_svg;
1076
+ expect(first_line).toBeTruthy();
1077
+
1078
+ /* Its release never came (the window lost the pointer, a
1079
+ * press went down on another device): this press starts
1080
+ * over, and the last one is not left behind on document. */
1081
+ gobj_send_event(tree, "EV_START_LINK_DRAG",
1082
+ {node_id: "n1", port_key: "manager", client_x: 171, client_y: 106}, tree);
1083
+ expect(logged.filter((l) => l.level === "warning" &&
1084
+ l.msg.includes("EV_START_LINK_DRAG with a link drag still in flight")).length).toBe(1);
1085
+ expect(doc.live()).toEqual(["pointercancel", "pointermove", "pointerup"]);
1086
+ expect($container.querySelectorAll("svg").filter((s) => s === first_line)).toEqual([]);
1087
+ expect(tree.priv._link_drag_svg).toBeTruthy();
1088
+
1089
+ /* One release, one end -- not one per press. */
1090
+ on_document("pointerup", {clientX: 171, clientY: 106});
1091
+ expect(ran("EV_LINK_DRAG_END").length).toBe(1);
1092
+ expect(doc.live()).toEqual([]);
1093
+ expect(tree.priv._link_drag).toBe(null);
1094
+ expect(tree.priv._link_drag_svg).toBe(null);
1095
+ expect(tree.priv._linking_mode).toBe(true);
1096
+ } finally {
1097
+ doc.restore();
1098
+ }
1099
+ });
1100
+
1101
+ test("a drag of another port, or a release of no drag, is refused, loudly", async () => {
1102
+ const {tree} = await linkable();
1103
+ gobj_send_event(tree, "EV_START_LINK_DRAG",
1104
+ {node_id: "n1", port_key: "other", client_x: 0, client_y: 0}, tree);
1105
+ expect(tree.priv._link_drag).toBe(null);
1106
+ gobj_send_event(tree, "EV_LINK_DRAG_END", {client_x: 0, client_y: 0, dragged: true}, tree);
1107
+ gobj_send_event(tree, "EV_LINK_DRAG_CANCEL", {}, tree);
1108
+ expect(errors().filter((m) => m.includes("but the selection is")).length).toBe(1);
1109
+ expect(errors().filter((m) => m.includes("with no link drag in flight")).length).toBe(2);
1110
+ });
1111
+ });
1112
+
1113
+ describe("the toggles of the toolbars say their state (third pass)", () => {
1114
+ test("the anchor item: aria-pressed only when it is ON", async () => {
1115
+ const {tree, graph} = await build("reading", null, {with_toolbar: true});
1116
+ const bar = plugin_def(graph, "toolbar");
1117
+ const anchor = () => bar.getItems().find((it) => it.value === "anchor");
1118
+ expect(anchor().pressed).toBe(false);
1119
+ gobj_send_event(tree, "EV_TOGGLE_ANCHOR", {}, tree); /* arming */
1120
+ expect(anchor().pressed).toBe(false);
1121
+ gobj_send_event(tree, "EV_NODE_CLICK", {id: "n1"}, tree); /* on */
1122
+ expect(tree.priv.anchor_state).toBe("on");
1123
+ expect(anchor().pressed).toBe(true);
1124
+ });
1125
+
1126
+ test("selection mode: aria-pressed baked into the item, and set on the element", async () => {
1127
+ const {tree, $container, graph} = await build("edition", null, {with_toolbar: true});
1128
+ const edit = plugin_def(graph, "toolbar-edit");
1129
+ const mode = () => edit.getItems().find((it) => it.value === "selection-mode");
1130
+ expect(mode().pressed).toBe(false);
1131
+ /* The item G6 would draw from that list. */
1132
+ const $item = document.createElement("div");
1133
+ $item.className = "g6-toolbar-item EV_TOGGLE_SELECTION_MODE";
1134
+ $item.setAttribute("aria-pressed", "false");
1135
+ $container.appendChild($item);
1136
+ gobj_send_event(tree, "EV_TOGGLE_SELECTION_MODE", {}, tree);
1137
+ expect(mode().pressed).toBe(true);
1138
+ expect($item.getAttribute("aria-pressed")).toBe("true");
1139
+ gobj_send_event(tree, "EV_TOGGLE_SELECTION_MODE", {}, tree);
1140
+ expect($item.getAttribute("aria-pressed")).toBe("false");
1141
+ });
1142
+
1143
+ test("save, undo and redo are born disabled; full screen is one control", async () => {
1144
+ const {graph} = await build("edition", null, {with_toolbar: true});
1145
+ const edit = plugin_def(graph, "toolbar-edit");
1146
+ for(const v of ["save", "undo", "redo"]) {
1147
+ expect(edit.getItems().find((it) => it.value === v).disabled, v).toBe(true);
1148
+ }
1149
+ const bar = plugin_def(graph, "toolbar");
1150
+ const fs = bar.getItems().find((it) => it.value === "request-fullscreen");
1151
+ expect(fs.control).toBe("fullscreen");
1152
+ });
1153
+ });
1154
+
1155
+ /* G6 as it rebuilds a toolbar. Every setPlugins() -- an
1156
+ * updatePlugin(), a plugin added or taken away -- updates EVERY
1157
+ * plugin it keeps, and a toolbar's update reads its getItems() right
1158
+ * there; the innerHTML drawn from them lands once that promise
1159
+ * resolves, over whatever the old element was told meanwhile.
1160
+ * `drawn[key]` is the list each toolbar shows after the last one. */
1161
+ function rebuild_like_g6(graph)
1162
+ {
1163
+ const drawn = {};
1164
+ const set_plugins = graph.setPlugins.bind(graph);
1165
+ const read_items = () => {
1166
+ for(const key of Object.keys(drawn)) {
1167
+ delete drawn[key];
1168
+ }
1169
+ for(const p of graph.plugins) {
1170
+ if(p && p.type === "yui-toolbar") {
1171
+ drawn[p.key] = p.getItems();
1172
+ }
1173
+ }
1174
+ };
1175
+ graph.setPlugins = (fn) => {
1176
+ set_plugins(fn);
1177
+ read_items();
1178
+ };
1179
+ graph.updatePlugin = (def) => {
1180
+ graph.setPlugins((plugins) => plugins.map((p) => {
1181
+ return (p && p.key === def.key) ? Object.assign({}, p, def) : p;
1182
+ }));
1183
+ };
1184
+ read_items();
1185
+ return drawn;
1186
+ }
1187
+
1188
+ /* The history plugin of a tree in edition, with `undo` / `redo`
1189
+ * commands on its stacks; `record()` changes them and says so, as
1190
+ * G6 does after every draw (HistoryEvent.CHANGE). Out of edition and
1191
+ * back, so the view takes THIS one as the history of its edition and
1192
+ * wires its listener on it. */
1193
+ function history_double(tree, graph)
1194
+ {
1195
+ const listeners = [];
1196
+ const history = {
1197
+ undo: 0,
1198
+ redo: 0,
1199
+ canUndo() {
1200
+ return this.undo > 0;
1201
+ },
1202
+ canRedo() {
1203
+ return this.redo > 0;
1204
+ },
1205
+ on(ev, fn) {
1206
+ listeners.push(fn);
1207
+ },
1208
+ off() {
1209
+ },
1210
+ clear() {
1211
+ this.undo = 0;
1212
+ this.redo = 0;
1213
+ },
1214
+ record(undo, redo) {
1215
+ this.undo = undo;
1216
+ this.redo = redo;
1217
+ for(const fn of listeners) {
1218
+ fn();
1219
+ }
1220
+ },
1221
+ };
1222
+ const instance_of = graph.getPluginInstance.bind(graph);
1223
+ graph.getPluginInstance = (key) => (key === "history") ? history : instance_of(key);
1224
+ gobj_send_event(tree, "EV_SET_OPERATION_MODE", {operation_mode: "reading"}, tree);
1225
+ gobj_send_event(tree, "EV_SET_OPERATION_MODE", {operation_mode: "edition"}, tree);
1226
+ expect(listeners.length).toBe(1);
1227
+ return history;
1228
+ }
1229
+
1230
+ /* What an item of a drawn toolbar shows: disabled, and lit (the
1231
+ * colour of its kind of action). */
1232
+ function shown(drawn, key, value)
1233
+ {
1234
+ const it = drawn[key].find((i) => i.value === value);
1235
+ return {
1236
+ disabled: !!it.disabled,
1237
+ lit: /\bcolor_(submit|active)_state\b/.test(it.className),
1238
+ };
1239
+ }
1240
+
1241
+ const DARK = {disabled: true, lit: false};
1242
+ const LIT = {disabled: false, lit: true};
1243
+
1244
+ describe("a rebuilt toolbar shows what its items were told (fourth pass)", () => {
1245
+ test("zoom to selection: a selection made before a rebuild is enabled after it", async () => {
1246
+ const {tree, graph} = await build("edition", null, {with_toolbar: true});
1247
+ const drawn = rebuild_like_g6(graph);
1248
+ const fit = () => drawn.toolbar.find((it) => it.value === "fit-selection");
1249
+ expect(fit().disabled).toBe(true);
1250
+
1251
+ gobj_send_event(tree, "EV_NODE_CLICK", {id: "n1", client_x: 0, client_y: 0}, tree);
1252
+ expect(tree.priv._selected_paint_ids).toEqual(["n1"]);
1253
+ gobj_send_event(tree, "EV_LANGUAGE_CHANGED", {}, tree);
1254
+ expect(fit().disabled).toBe(false);
1255
+ /* What the reader got instead: `tabindex="-1"
1256
+ * aria-disabled="true" disabled` over a selection. */
1257
+ const html = toolbar_item_html(fit());
1258
+ expect(html).toContain(' tabindex="0"');
1259
+ expect(html).not.toContain("aria-disabled");
1260
+
1261
+ /* The anchor rebuilds the same toolbar (the verifier's repro). */
1262
+ gobj_send_event(tree, "EV_TOGGLE_ANCHOR", {}, tree);
1263
+ expect(tree.priv.anchor_state).toBe("arming");
1264
+ expect(fit().disabled).toBe(false);
1265
+ gobj_send_event(tree, "EV_TOGGLE_ANCHOR", {}, tree);
1266
+
1267
+ gobj_send_event(tree, "EV_KEY_DOWN", {key: "Escape"}, tree);
1268
+ expect(tree.priv._selected_paint_ids).toEqual([]);
1269
+ gobj_send_event(tree, "EV_LANGUAGE_CHANGED", {}, tree);
1270
+ expect(fit().disabled).toBe(true);
1271
+ });
1272
+
1273
+ test("save, undo and redo: history pending before a rebuild is shown after it", async () => {
1274
+ const {tree, graph} = await build("edition", null, {with_toolbar: true});
1275
+ const history = history_double(tree, graph);
1276
+ const drawn = rebuild_like_g6(graph);
1277
+ for(const v of ["save", "undo", "redo"]) {
1278
+ expect(shown(drawn, "toolbar-edit", v), v).toEqual(DARK);
1279
+ }
1280
+
1281
+ history.record(1, 0); /* a move G6 recorded */
1282
+ gobj_send_event(tree, "EV_LANGUAGE_CHANGED", {}, tree);
1283
+ expect(shown(drawn, "toolbar-edit", "save")).toEqual(LIT);
1284
+ expect(shown(drawn, "toolbar-edit", "undo")).toEqual(LIT);
1285
+ expect(shown(drawn, "toolbar-edit", "redo")).toEqual(DARK);
1286
+
1287
+ history.record(1, 1); /* ...one undone, one left */
1288
+ gobj_send_event(tree, "EV_TOGGLE_ANCHOR", {}, tree);
1289
+ expect(shown(drawn, "toolbar-edit", "redo")).toEqual(LIT);
1290
+ const html = toolbar_item_html(drawn["toolbar-edit"].find((it) => it.value === "undo"));
1291
+ expect(html).toContain(' tabindex="0"');
1292
+ expect(html).not.toContain("aria-disabled");
1293
+ });
1294
+
1295
+ test("a Save lit for a change the history does not record survives a rebuild and a fold", async () => {
1296
+ const {tree, graph, $container} = await build("edition", null, {with_toolbar: true});
1297
+ history_double(tree, graph);
1298
+ const drawn = rebuild_like_g6(graph);
1299
+
1300
+ gobj_send_event(tree, "EV_NODE_DRAG_END", {}, tree); /* mark_graph_dirty */
1301
+ gobj_send_event(tree, "EV_LANGUAGE_CHANGED", {}, tree);
1302
+ expect(shown(drawn, "toolbar-edit", "save")).toEqual(LIT);
1303
+ expect(shown(drawn, "toolbar-edit", "undo")).toEqual(DARK);
1304
+
1305
+ /* Narrow: the toolbars fold, and the edit toolbar is taken
1306
+ * away and made again. */
1307
+ $container.getBoundingClientRect = () => ({left: 0, top: 0, right: 300, bottom: 400,
1308
+ width: 300, height: 400, x: 0, y: 0});
1309
+ gobj_send_event(tree, "EV_TOGGLE_TOOLBARS", {}, tree); /* unfolded */
1310
+ gobj_send_event(tree, "EV_TOGGLE_TOOLBARS", {}, tree); /* folded */
1311
+ expect(drawn["toolbar-edit"]).toBe(undefined);
1312
+ gobj_send_event(tree, "EV_TOGGLE_TOOLBARS", {}, tree); /* unfolded: made again */
1313
+ expect(shown(drawn, "toolbar-edit", "save")).toEqual(LIT);
1314
+ });
1315
+
1316
+ test("a clear (a refresh) puts the Save out: the next rebuild draws it dark", async () => {
1317
+ const {tree, graph} = await build("edition", null, {with_toolbar: true});
1318
+ history_double(tree, graph);
1319
+ const drawn = rebuild_like_g6(graph);
1320
+
1321
+ gobj_send_event(tree, "EV_NODE_DRAG_END", {}, tree); /* mark_graph_dirty */
1322
+ gobj_send_event(tree, "EV_LANGUAGE_CHANGED", {}, tree);
1323
+ expect(shown(drawn, "toolbar-edit", "save")).toEqual(LIT);
1324
+
1325
+ /* What a refresh does first: the data goes, and the history
1326
+ * with it. The Save stayed lit in `_save_lit`, so every rebuild
1327
+ * drew it lit over a graph with nothing to save. */
1328
+ gobj_send_event(tree, "EV_CLEAR_DATA", {}, tree);
1329
+ expect(tree.priv._save_lit).toBe(false);
1330
+ gobj_send_event(tree, "EV_LANGUAGE_CHANGED", {}, tree);
1331
+ expect(shown(drawn, "toolbar-edit", "save")).toEqual(DARK);
1332
+ });
1333
+
1334
+ test("leaving edition and coming back: the Save says what the new history says", async () => {
1335
+ const {tree, graph} = await build("edition", null, {with_toolbar: true});
1336
+ const history = history_double(tree, graph);
1337
+ const drawn = rebuild_like_g6(graph);
1338
+
1339
+ history.record(2, 0);
1340
+ gobj_send_event(tree, "EV_NODE_DRAG_END", {}, tree);
1341
+ gobj_send_event(tree, "EV_SET_OPERATION_MODE", {operation_mode: "reading"}, tree);
1342
+ expect(drawn["toolbar-edit"]).toBe(undefined);
1343
+
1344
+ /* The history goes with the edition: the one it comes back
1345
+ * with has nothing to undo, and nothing is owed. */
1346
+ history.clear();
1347
+ gobj_send_event(tree, "EV_SET_OPERATION_MODE", {operation_mode: "edition"}, tree);
1348
+ for(const v of ["save", "undo", "redo"]) {
1349
+ expect(shown(drawn, "toolbar-edit", v), v).toEqual(DARK);
1350
+ }
1351
+
1352
+ /* A write refused while reading is owed: shown as the edit
1353
+ * toolbar is made, not at the next draw. */
1354
+ gobj_send_event(tree, "EV_SET_OPERATION_MODE", {operation_mode: "reading"}, tree);
1355
+ gobj_send_event(tree, "EV_GRAPHS_WRITE_REFUSED", {topic: "users"}, tree);
1356
+ gobj_send_event(tree, "EV_SET_OPERATION_MODE", {operation_mode: "edition"}, tree);
1357
+ expect(shown(drawn, "toolbar-edit", "save")).toEqual(LIT);
1358
+ expect(shown(drawn, "toolbar-edit", "undo")).toEqual(DARK);
1359
+ });
1360
+ });