@yuneta/gobj-ui 7.18.2 → 7.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -0
- package/dist/gobj-ui.cjs.js +12 -2
- package/dist/gobj-ui.es.js +12 -2
- package/package.json +1 -1
- package/src/c_g6_nodes_tree.js +37 -10
- package/src/yui_tab_routes.js +146 -0
- package/src/yui_tab_routes.test.js +138 -0
package/README.md
CHANGED
|
@@ -719,6 +719,41 @@ so it survived that — two light islands over a dark canvas.
|
|
|
719
719
|
(all tooltips, so a host that has not defined them shows the key on hover and
|
|
720
720
|
nothing else breaks).
|
|
721
721
|
|
|
722
|
+
### Tabs opened at runtime, and the two decisions their url costs
|
|
723
|
+
|
|
724
|
+
`yui_tab_routes.js`. A workspace whose tabs are opened by the operator —
|
|
725
|
+
`/<ws>/<home>/<id>`, with whatever the tab is showing below it — pays for that
|
|
726
|
+
url twice, and both apps in this family learned the same two lessons, one of
|
|
727
|
+
them the hard way.
|
|
728
|
+
|
|
729
|
+
**`yui_tab_split_subpath(subpath)` → `{id, tail}`.** On a cold load the tab's
|
|
730
|
+
route does not exist yet: it is registered when the tab is opened, so a reload
|
|
731
|
+
on `/<ws>/<home>/<id>/<tail>` resolves only as far as the workspace home and the
|
|
732
|
+
shell hands the WHOLE rest over as the subpath — `<id>/<tail>`, not `<id>`.
|
|
733
|
+
Reading all of it as the id matches nothing, and an app that then falls back to
|
|
734
|
+
its first tab **answers a reload with somebody else's default**. It hides well:
|
|
735
|
+
a bare tab route survives, because there the subpath IS the id, so only the deep
|
|
736
|
+
case breaks and only for whoever reloads on one.
|
|
737
|
+
|
|
738
|
+
Only the id segment is decoded. These ids are composite (`<node>`+`0x1F`+
|
|
739
|
+
`<yuno>`, `<conn>`+`0x1F`+`<treedb>`) and reach the url percent-encoded, so
|
|
740
|
+
decoding the whole tail first would turn an encoded slash inside an id into a
|
|
741
|
+
separator and cut it in two.
|
|
742
|
+
|
|
743
|
+
**`yui_tab_position_plan(prev_base, base, subpath, remembered)` →
|
|
744
|
+
`{record, replay}`.** A tab's nav item is a FIXED route —
|
|
745
|
+
`yui_shell_set_submenu()` registers it, and that route is where the view is
|
|
746
|
+
mounted — so the position inside a tab cannot travel in the item and has to be
|
|
747
|
+
replayed when the tab is entered again. "Entered again" is the whole subtlety:
|
|
748
|
+
arriving at the root of the tab you were ALREADY in is the way OUT of whatever
|
|
749
|
+
was open, and replaying the position there would make that button do nothing.
|
|
750
|
+
|
|
751
|
+
**What is NOT here: the wiring.** One host restores on its transport's
|
|
752
|
+
`EV_ON_OPEN`, another normalizes the route as it arrives, and both are right for
|
|
753
|
+
what they know about when their tabs become real. These are the decisions, not
|
|
754
|
+
the plumbing — which is also why they are pure and tested rather than three
|
|
755
|
+
lines inside an action.
|
|
756
|
+
|
|
722
757
|
### Selecting several nodes, and moving them together
|
|
723
758
|
|
|
724
759
|
In **edition** mode the graph has a real selection, not just "the node you
|
package/dist/gobj-ui.cjs.js
CHANGED
|
@@ -53869,6 +53869,7 @@ var PRIVATE_DATA$1 = {
|
|
|
53869
53869
|
_focus_topic: null,
|
|
53870
53870
|
_focus_ids: [],
|
|
53871
53871
|
_on_pointerdown_focus: null,
|
|
53872
|
+
_on_focusout_restore: null,
|
|
53872
53873
|
_selected_paint_ids: [],
|
|
53873
53874
|
_pending_focus_topic: null,
|
|
53874
53875
|
_pending_find: null,
|
|
@@ -53935,9 +53936,12 @@ function mt_destroy$1(gobj) {
|
|
|
53935
53936
|
}
|
|
53936
53937
|
if (priv._on_pointerdown_focus) {
|
|
53937
53938
|
priv.$container.removeEventListener("pointerdown", priv._on_pointerdown_focus, true);
|
|
53938
|
-
priv.$container.removeEventListener("click", priv._on_pointerdown_focus, true);
|
|
53939
53939
|
priv._on_pointerdown_focus = null;
|
|
53940
53940
|
}
|
|
53941
|
+
if (priv._on_focusout_restore) {
|
|
53942
|
+
priv.$container.removeEventListener("focusout", priv._on_focusout_restore);
|
|
53943
|
+
priv._on_focusout_restore = null;
|
|
53944
|
+
}
|
|
53941
53945
|
if (priv.theme_observer) {
|
|
53942
53946
|
priv.theme_observer.disconnect();
|
|
53943
53947
|
priv.theme_observer = null;
|
|
@@ -54113,7 +54117,13 @@ function configure_events(gobj) {
|
|
|
54113
54117
|
if ($canvas && typeof $canvas.focus === "function") $canvas.focus({ preventScroll: true });
|
|
54114
54118
|
};
|
|
54115
54119
|
priv.$container.addEventListener("pointerdown", priv._on_pointerdown_focus, true);
|
|
54116
|
-
priv
|
|
54120
|
+
priv._on_focusout_restore = (ev) => {
|
|
54121
|
+
if (ev.relatedTarget) return;
|
|
54122
|
+
if ((0, _yuneta_gobj_js.gobj_is_destroying)(gobj)) return;
|
|
54123
|
+
let $canvas = main_canvas_of(priv.$container);
|
|
54124
|
+
if ($canvas && $canvas.isConnected && document.activeElement !== $canvas) $canvas.focus({ preventScroll: true });
|
|
54125
|
+
};
|
|
54126
|
+
priv.$container.addEventListener("focusout", priv._on_focusout_restore);
|
|
54117
54127
|
graph.on("keydown", (evt) => {
|
|
54118
54128
|
let ctrl = !!(evt.ctrlKey || evt.metaKey);
|
|
54119
54129
|
if (ctrl && (evt.key === "a" || evt.key === "A")) {
|
package/dist/gobj-ui.es.js
CHANGED
|
@@ -53856,6 +53856,7 @@ var PRIVATE_DATA$1 = {
|
|
|
53856
53856
|
_focus_topic: null,
|
|
53857
53857
|
_focus_ids: [],
|
|
53858
53858
|
_on_pointerdown_focus: null,
|
|
53859
|
+
_on_focusout_restore: null,
|
|
53859
53860
|
_selected_paint_ids: [],
|
|
53860
53861
|
_pending_focus_topic: null,
|
|
53861
53862
|
_pending_find: null,
|
|
@@ -53922,9 +53923,12 @@ function mt_destroy$1(gobj) {
|
|
|
53922
53923
|
}
|
|
53923
53924
|
if (priv._on_pointerdown_focus) {
|
|
53924
53925
|
priv.$container.removeEventListener("pointerdown", priv._on_pointerdown_focus, true);
|
|
53925
|
-
priv.$container.removeEventListener("click", priv._on_pointerdown_focus, true);
|
|
53926
53926
|
priv._on_pointerdown_focus = null;
|
|
53927
53927
|
}
|
|
53928
|
+
if (priv._on_focusout_restore) {
|
|
53929
|
+
priv.$container.removeEventListener("focusout", priv._on_focusout_restore);
|
|
53930
|
+
priv._on_focusout_restore = null;
|
|
53931
|
+
}
|
|
53928
53932
|
if (priv.theme_observer) {
|
|
53929
53933
|
priv.theme_observer.disconnect();
|
|
53930
53934
|
priv.theme_observer = null;
|
|
@@ -54100,7 +54104,13 @@ function configure_events(gobj) {
|
|
|
54100
54104
|
if ($canvas && typeof $canvas.focus === "function") $canvas.focus({ preventScroll: true });
|
|
54101
54105
|
};
|
|
54102
54106
|
priv.$container.addEventListener("pointerdown", priv._on_pointerdown_focus, true);
|
|
54103
|
-
priv
|
|
54107
|
+
priv._on_focusout_restore = (ev) => {
|
|
54108
|
+
if (ev.relatedTarget) return;
|
|
54109
|
+
if (gobj_is_destroying(gobj)) return;
|
|
54110
|
+
let $canvas = main_canvas_of(priv.$container);
|
|
54111
|
+
if ($canvas && $canvas.isConnected && document.activeElement !== $canvas) $canvas.focus({ preventScroll: true });
|
|
54112
|
+
};
|
|
54113
|
+
priv.$container.addEventListener("focusout", priv._on_focusout_restore);
|
|
54104
54114
|
graph.on("keydown", (evt) => {
|
|
54105
54115
|
let ctrl = !!(evt.ctrlKey || evt.metaKey);
|
|
54106
54116
|
if (ctrl && (evt.key === "a" || evt.key === "A")) {
|
package/package.json
CHANGED
package/src/c_g6_nodes_tree.js
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
gobj_parent,
|
|
38
38
|
gobj_name,
|
|
39
39
|
gobj_short_name,
|
|
40
|
+
gobj_is_destroying,
|
|
40
41
|
gobj_subscribe_event,
|
|
41
42
|
gobj_publish_event,
|
|
42
43
|
gobj_send_event,
|
|
@@ -334,6 +335,7 @@ let PRIVATE_DATA = {
|
|
|
334
335
|
_focus_topic: null, // topic currently focused (EV_FOCUS_TOPIC)
|
|
335
336
|
_focus_ids: [], // node ids carrying the focus 'active' state
|
|
336
337
|
_on_pointerdown_focus: null, // listener keeping the keyboard on the canvas
|
|
338
|
+
_on_focusout_restore: null, // ...and putting it back when it goes nowhere
|
|
337
339
|
_selected_paint_ids: [], // node ids whose card is PAINTED selected
|
|
338
340
|
// (G6's 'selected' state is the selection
|
|
339
341
|
// itself; this is what is on screen, and
|
|
@@ -464,10 +466,14 @@ function mt_destroy(gobj)
|
|
|
464
466
|
priv.$container.removeEventListener(
|
|
465
467
|
"pointerdown", priv._on_pointerdown_focus, true
|
|
466
468
|
);
|
|
469
|
+
priv._on_pointerdown_focus = null;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if(priv._on_focusout_restore) {
|
|
467
473
|
priv.$container.removeEventListener(
|
|
468
|
-
"
|
|
474
|
+
"focusout", priv._on_focusout_restore
|
|
469
475
|
);
|
|
470
|
-
priv.
|
|
476
|
+
priv._on_focusout_restore = null;
|
|
471
477
|
}
|
|
472
478
|
|
|
473
479
|
if(priv.theme_observer) {
|
|
@@ -826,17 +832,38 @@ function configure_events(gobj)
|
|
|
826
832
|
$canvas.focus({preventScroll: true});
|
|
827
833
|
}
|
|
828
834
|
};
|
|
829
|
-
/* Capture
|
|
830
|
-
*
|
|
831
|
-
* click too because the browser does its OWN focus handling on
|
|
832
|
-
* mousedown, after ours, and it sends the focus to <body> when
|
|
833
|
-
* what was pressed cannot take it -- which a card cannot. */
|
|
835
|
+
/* Capture: the cards are DOM and may stop the press from
|
|
836
|
+
* bubbling. */
|
|
834
837
|
priv.$container.addEventListener(
|
|
835
838
|
"pointerdown", priv._on_pointerdown_focus, true
|
|
836
839
|
);
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
+
|
|
841
|
+
/* And put it back when it is taken away to NOWHERE.
|
|
842
|
+
*
|
|
843
|
+
* Focusing on pointerdown is not enough on its own, which is
|
|
844
|
+
* measurable: the press does focus the canvas, and then the
|
|
845
|
+
* browser runs its own focus handling for the mousedown -- after
|
|
846
|
+
* ours -- and a card is not focusable, so it moves the focus to
|
|
847
|
+
* <body>. The graph goes deaf right after the click that
|
|
848
|
+
* selected something, which is the worst possible moment.
|
|
849
|
+
*
|
|
850
|
+
* Only when it goes nowhere (`relatedTarget` null). A focus
|
|
851
|
+
* moving to a REAL element -- the find box, a dialog, the next
|
|
852
|
+
* tab stop -- is the user leaving, and is left alone.
|
|
853
|
+
*/
|
|
854
|
+
priv._on_focusout_restore = (ev) => {
|
|
855
|
+
if(ev.relatedTarget) {
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
if(gobj_is_destroying(gobj)) {
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
let $canvas = main_canvas_of(priv.$container);
|
|
862
|
+
if($canvas && $canvas.isConnected && document.activeElement !== $canvas) {
|
|
863
|
+
$canvas.focus({preventScroll: true});
|
|
864
|
+
}
|
|
865
|
+
};
|
|
866
|
+
priv.$container.addEventListener("focusout", priv._on_focusout_restore);
|
|
840
867
|
|
|
841
868
|
/* The canvas carries a `tabIndex` of its own, so a keydown reaches
|
|
842
869
|
* us only while the GRAPH has focus. That is what keeps Ctrl+A in
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/***********************************************************************
|
|
2
|
+
* yui_tab_routes.js
|
|
3
|
+
*
|
|
4
|
+
* A workspace whose tabs are opened at RUNTIME, and the two
|
|
5
|
+
* decisions its url costs.
|
|
6
|
+
*
|
|
7
|
+
* The shape is always the same: a workspace home `/<ws>/<home>`
|
|
8
|
+
* with one tab per thing the operator has opened, at
|
|
9
|
+
* `/<ws>/<home>/<id>`, and whatever the tab is showing below
|
|
10
|
+
* that. The agent console does it per node, the treedb browser
|
|
11
|
+
* per (connection, treedb) — and both learned the same two
|
|
12
|
+
* lessons, one of them the hard way.
|
|
13
|
+
*
|
|
14
|
+
* 1. ON A COLD LOAD THE TAB'S ROUTE DOES NOT EXIST YET.
|
|
15
|
+
*
|
|
16
|
+
* It is registered when the tab is opened, so a reload on
|
|
17
|
+
* `/<ws>/<home>/<id>/<what the tab was showing>` resolves only as
|
|
18
|
+
* far as the workspace home, and the shell hands the WHOLE rest
|
|
19
|
+
* over as the subpath: `<id>/<tail>`, not `<id>`. Reading all of
|
|
20
|
+
* it as the id matches nothing, and an app that then falls back
|
|
21
|
+
* to its first tab answers a reload with somebody else's default
|
|
22
|
+
* — which is the one thing a reload must not do. It also hides
|
|
23
|
+
* itself well: a BARE tab route survives, because there the
|
|
24
|
+
* subpath IS the id.
|
|
25
|
+
*
|
|
26
|
+
* `yui_tab_split_subpath()` is that split. Only the id segment is
|
|
27
|
+
* decoded, and that is not a detail: these ids are composite
|
|
28
|
+
* (`<node>` + 0x1F + `<yuno>`, `<conn>` + 0x1F + `<treedb>`) and
|
|
29
|
+
* reach the url percent-encoded, so decoding the whole tail first
|
|
30
|
+
* would turn an encoded slash inside an id into a separator and
|
|
31
|
+
* cut it in two.
|
|
32
|
+
*
|
|
33
|
+
* 2. A TAB'S NAV ITEM IS A FIXED ROUTE.
|
|
34
|
+
*
|
|
35
|
+
* `yui_shell_set_submenu()` registers it in the shell's item
|
|
36
|
+
* index, and that route is where the tab's view is MOUNTED and
|
|
37
|
+
* what a deep link resolves to. So the position inside a tab
|
|
38
|
+
* cannot travel in the item — moving it would move the mount —
|
|
39
|
+
* and has to be replayed when the tab is entered again.
|
|
40
|
+
*
|
|
41
|
+
* "Entered again" is the whole subtlety, and it is why
|
|
42
|
+
* `yui_tab_position_plan()` is a function with tests rather than
|
|
43
|
+
* three lines inside an action: arriving at the root of the tab
|
|
44
|
+
* you were ALREADY in is the way OUT of whatever was open (the
|
|
45
|
+
* view's own back button), and replaying the position there would
|
|
46
|
+
* make that button do nothing.
|
|
47
|
+
*
|
|
48
|
+
* WHAT IS NOT HERE: the wiring. One host restores on its
|
|
49
|
+
* transport's `EV_ON_OPEN`, another normalizes the route as it
|
|
50
|
+
* arrives, and both are right for what they know about when their
|
|
51
|
+
* tabs become real. These are the decisions, not the plumbing.
|
|
52
|
+
*
|
|
53
|
+
* Copyright (c) 2026, ArtGins.
|
|
54
|
+
* All Rights Reserved.
|
|
55
|
+
***********************************************************************/
|
|
56
|
+
|
|
57
|
+
/***************************************************************
|
|
58
|
+
* Decode one url segment, or hand it back as it came.
|
|
59
|
+
*
|
|
60
|
+
* A malformed percent escape throws, and an id that cannot be
|
|
61
|
+
* decoded is still better read raw than not read at all: it
|
|
62
|
+
* simply will not match an open tab, which is the honest answer.
|
|
63
|
+
***************************************************************/
|
|
64
|
+
function yui_tab_decode_id(segment)
|
|
65
|
+
{
|
|
66
|
+
try {
|
|
67
|
+
return decodeURIComponent(String(segment || ""));
|
|
68
|
+
} catch(e) {
|
|
69
|
+
return String(segment || "");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/***************************************************************
|
|
74
|
+
* yui_tab_split_subpath(subpath) -> {id, tail}
|
|
75
|
+
*
|
|
76
|
+
* subpath what the shell left below the workspace home
|
|
77
|
+
*
|
|
78
|
+
* `id` the tab's id, decoded
|
|
79
|
+
* `tail` what the tab was showing, untouched — it is the
|
|
80
|
+
* tab's own business and travels with it
|
|
81
|
+
***************************************************************/
|
|
82
|
+
function yui_tab_split_subpath(subpath)
|
|
83
|
+
{
|
|
84
|
+
let sub = String(subpath || "");
|
|
85
|
+
|
|
86
|
+
if(!sub) {
|
|
87
|
+
return {id: "", tail: ""};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let slash = sub.indexOf("/");
|
|
91
|
+
let raw_id = (slash < 0) ? sub : sub.slice(0, slash);
|
|
92
|
+
let tail = (slash < 0) ? "" : sub.slice(slash + 1);
|
|
93
|
+
|
|
94
|
+
return {id: yui_tab_decode_id(raw_id), tail: tail};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/***************************************************************
|
|
98
|
+
* yui_tab_position_plan(prev_base, base, subpath, remembered)
|
|
99
|
+
*
|
|
100
|
+
* prev_base the tab base the previous route resolved to
|
|
101
|
+
* base the tab base this route resolves to
|
|
102
|
+
* subpath what is left of the url below `base`
|
|
103
|
+
* remembered the position last recorded for THIS tab
|
|
104
|
+
*
|
|
105
|
+
* -> {record, replay}
|
|
106
|
+
*
|
|
107
|
+
* `record` is the position to remember (null = leave it), and
|
|
108
|
+
* `replay` the route to normalize to (null = stay). Never both:
|
|
109
|
+
* a position is either being made or being restored.
|
|
110
|
+
***************************************************************/
|
|
111
|
+
function yui_tab_position_plan(prev_base, base, subpath, remembered)
|
|
112
|
+
{
|
|
113
|
+
let inner = String(subpath || "");
|
|
114
|
+
let here = String(base || "");
|
|
115
|
+
|
|
116
|
+
if(!here) {
|
|
117
|
+
return {record: null, replay: null};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/* Somewhere inside the tab: that IS the position. */
|
|
121
|
+
if(inner) {
|
|
122
|
+
return {record: `${here}/${inner}`, replay: null};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* At the root of the tab we were already in: the operator walked
|
|
126
|
+
* UP, on purpose, and that is a position too. */
|
|
127
|
+
if(prev_base === here) {
|
|
128
|
+
return {record: here, replay: null};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/* Entering the tab again: go back to where it was left, if that is
|
|
132
|
+
* anywhere other than the root it would land on anyway. */
|
|
133
|
+
let back = String(remembered || "");
|
|
134
|
+
if(back && back !== here) {
|
|
135
|
+
return {record: null, replay: back};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {record: null, replay: null};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
export {
|
|
143
|
+
yui_tab_decode_id,
|
|
144
|
+
yui_tab_split_subpath,
|
|
145
|
+
yui_tab_position_plan,
|
|
146
|
+
};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/***********************************************************************
|
|
2
|
+
* yui_tab_routes.test.js
|
|
3
|
+
*
|
|
4
|
+
* The two decisions a runtime-opened tab costs its url.
|
|
5
|
+
*
|
|
6
|
+
* The split is here because of what it cost to find: a reload on
|
|
7
|
+
* a deep tab route answered with another tab's default for as
|
|
8
|
+
* long as nobody reloaded on one.
|
|
9
|
+
*
|
|
10
|
+
* The position plan is here because the case that makes it
|
|
11
|
+
* non-trivial reads like a duplicate of the one above it: the
|
|
12
|
+
* root of the tab you are already in is not the same event as
|
|
13
|
+
* the root of a tab you are coming back to, and reading them as
|
|
14
|
+
* one breaks the view's own way out of a topic.
|
|
15
|
+
***********************************************************************/
|
|
16
|
+
import { describe, test, expect } from "vitest";
|
|
17
|
+
import {
|
|
18
|
+
yui_tab_decode_id,
|
|
19
|
+
yui_tab_split_subpath,
|
|
20
|
+
yui_tab_position_plan,
|
|
21
|
+
} from "./yui_tab_routes.js";
|
|
22
|
+
|
|
23
|
+
const US = String.fromCharCode(31); /* the composite-id separator */
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
describe("yui_tab_split_subpath", () => {
|
|
27
|
+
test("a bare tab tail is all id", () => {
|
|
28
|
+
expect(yui_tab_split_subpath("wattyzer")).toEqual({id: "wattyzer", tail: ""});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("a deep tail keeps the id and hands the rest back", () => {
|
|
32
|
+
expect(yui_tab_split_subpath("wattyzer/treedb_authzs/__graphs__"))
|
|
33
|
+
.toEqual({id: "wattyzer", tail: "treedb_authzs/__graphs__"});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("a composite id survives the url it travelled in", () => {
|
|
37
|
+
/* 0x1F joins the two halves of these ids and reaches the url
|
|
38
|
+
* percent-encoded. */
|
|
39
|
+
expect(yui_tab_split_subpath("yunovatios-controlador%1F1630/treedb_authzs/__graphs__"))
|
|
40
|
+
.toEqual({id: "yunovatios-controlador" + US + "1630",
|
|
41
|
+
tail: "treedb_authzs/__graphs__"});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("an encoded slash inside the id stays inside the id", () => {
|
|
45
|
+
/* Which is why the WHOLE tail must not be decoded first: that
|
|
46
|
+
* would turn this %2F into a separator and cut the id in two. */
|
|
47
|
+
expect(yui_tab_split_subpath("a%2Fb/treedb_x"))
|
|
48
|
+
.toEqual({id: "a/b", tail: "treedb_x"});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("a malformed escape is taken as it came, not thrown", () => {
|
|
52
|
+
expect(yui_tab_split_subpath("%E0%A4%A/treedb_x"))
|
|
53
|
+
.toEqual({id: "%E0%A4%A", tail: "treedb_x"});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("nothing in, nothing out", () => {
|
|
57
|
+
expect(yui_tab_split_subpath("")).toEqual({id: "", tail: ""});
|
|
58
|
+
expect(yui_tab_split_subpath(null)).toEqual({id: "", tail: ""});
|
|
59
|
+
expect(yui_tab_split_subpath(undefined)).toEqual({id: "", tail: ""});
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("yui_tab_decode_id", () => {
|
|
64
|
+
test("decodes, and survives what cannot be decoded", () => {
|
|
65
|
+
expect(yui_tab_decode_id("a%2Fb")).toBe("a/b");
|
|
66
|
+
expect(yui_tab_decode_id("%E0%A4%A")).toBe("%E0%A4%A");
|
|
67
|
+
expect(yui_tab_decode_id("")).toBe("");
|
|
68
|
+
expect(yui_tab_decode_id(null)).toBe("");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const A = "/topics/db/conn1%1Fdb1";
|
|
73
|
+
const B = "/topics/db/conn1%1Fdb2";
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
describe("inside a tab", () => {
|
|
77
|
+
test("a position is recorded, and nothing is replayed", () => {
|
|
78
|
+
expect(yui_tab_position_plan(A, A, "users", "")).toEqual(
|
|
79
|
+
{record: `${A}/users`, replay: null});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("a deeper position is recorded whole", () => {
|
|
83
|
+
expect(yui_tab_position_plan(A, A, "users/info", `${A}/users`)).toEqual(
|
|
84
|
+
{record: `${A}/users/info`, replay: null});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("arriving deep from ANOTHER tab records too — the url won", () => {
|
|
88
|
+
expect(yui_tab_position_plan(B, A, "users", `${A}/roles`)).toEqual(
|
|
89
|
+
{record: `${A}/users`, replay: null});
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("the root of the tab you were already in", () => {
|
|
94
|
+
test("is the way OUT of a topic, so it records and never replays", () => {
|
|
95
|
+
/* The view's own "back to Topics" button lands here. Replaying
|
|
96
|
+
the position would make that button do nothing at all. */
|
|
97
|
+
expect(yui_tab_position_plan(A, A, "", `${A}/users`)).toEqual(
|
|
98
|
+
{record: A, replay: null});
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("the root of a tab you are coming back to", () => {
|
|
103
|
+
test("replays what was left open", () => {
|
|
104
|
+
expect(yui_tab_position_plan(B, A, "", `${A}/users`)).toEqual(
|
|
105
|
+
{record: null, replay: `${A}/users`});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("...including from OUTSIDE the tabs — the picker, the settings", () => {
|
|
109
|
+
/* The caller says "no tab" with an empty prev_base. It must, or a
|
|
110
|
+
* return from Select reads as the walk UP that records the root
|
|
111
|
+
* (and the tab comes back on its cards, its position gone). */
|
|
112
|
+
expect(yui_tab_position_plan("", A, "", `${A}/users`)).toEqual(
|
|
113
|
+
{record: null, replay: `${A}/users`});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("...and does nothing when the tab was left at its own root", () => {
|
|
117
|
+
expect(yui_tab_position_plan(B, A, "", A)).toEqual({record: null, replay: null});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("...or when it has never been visited", () => {
|
|
121
|
+
expect(yui_tab_position_plan(B, A, "", "")).toEqual({record: null, replay: null});
|
|
122
|
+
expect(yui_tab_position_plan("", A, "", undefined)).toEqual({record: null, replay: null});
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("edges", () => {
|
|
127
|
+
test("no base is no decision", () => {
|
|
128
|
+
expect(yui_tab_position_plan(A, "", "users", A)).toEqual({record: null, replay: null});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("record and replay are never both set", () => {
|
|
132
|
+
for(const args of [[A, A, "users", A], [A, A, "", A], [B, A, "", `${A}/x`],
|
|
133
|
+
[B, A, "", ""], ["", A, "", ""]]) {
|
|
134
|
+
const plan = yui_tab_position_plan(...args);
|
|
135
|
+
expect(!!(plan.record && plan.replay)).toBe(false);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
});
|