@yuneta/gobj-ui 2.6.0 → 4.0.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 +331 -11
- package/dist/gobj-ui.cjs.js +21386 -17919
- package/dist/gobj-ui.es.js +26059 -22613
- package/index.js +45 -9
- package/package.json +4 -4
- package/src/c_g6_nodes_tree.js +128 -40
- package/src/c_yui_form.css +9 -0
- package/src/c_yui_form.js +113 -53
- package/src/c_yui_gobj_tree_js.js +58 -36
- package/src/c_yui_json.css +139 -0
- package/src/c_yui_json.js +928 -0
- package/src/c_yui_json_graph.js +110 -20
- package/src/c_yui_map.js +18 -36
- package/src/c_yui_nav.js +6 -9
- package/src/c_yui_period.css +184 -0
- package/src/c_yui_period.js +1441 -0
- package/src/c_yui_shell.css +120 -0
- package/src/c_yui_shell.js +672 -111
- package/src/c_yui_treedb_graph.js +639 -37
- package/src/c_yui_treedb_schema.js +478 -0
- package/src/c_yui_treedb_topic_with_form.css +18 -0
- package/src/c_yui_treedb_topic_with_form.js +153 -103
- package/src/c_yui_treedb_topics.css +73 -0
- package/src/c_yui_treedb_topics.js +1070 -29
- package/src/c_yui_window.css +22 -0
- package/src/c_yui_window.js +182 -97
- package/src/c_yui_window_manager.js +38 -4
- package/src/json_view_helpers.js +214 -0
- package/src/json_view_helpers.test.js +135 -0
- package/src/route_map_model.js +317 -0
- package/src/route_map_model.test.js +209 -0
- package/src/route_resolver.js +24 -1
- package/src/route_resolver.test.js +40 -1
- package/src/shell_modals.js +162 -52
- package/src/shell_route_map.css +225 -0
- package/src/shell_route_map.js +363 -0
- package/src/tabulator.css +245 -0
- package/src/yui_dev.js +130 -8
- package/src/yui_frontend_view.js +106 -0
- package/src/yui_icons.css +62 -0
- package/src/yui_inputs.css +8 -3
- package/src/yui_inputs.js +51 -8
- package/src/yui_tabulator_i18n.js +128 -0
- package/src/yui_theme.js +147 -0
- package/src/yui_time.js +666 -0
- package/src/yui_time.test.js +330 -0
- package/src/yui_toolbar.css +28 -0
- package/src/yui_toolbar.js +86 -43
- package/src/c_yui_main.css +0 -501
- package/src/c_yui_main.js +0 -1623
- package/src/c_yui_routing.css +0 -18
- package/src/c_yui_routing.js +0 -837
- package/src/c_yui_tabs.js +0 -487
- package/src/themes.js +0 -215
- package/src/ytable.css +0 -63
- package/src/ytable.js +0 -505
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/***********************************************************************
|
|
2
|
+
* route_map_model.test.js
|
|
3
|
+
*
|
|
4
|
+
* Unit tests for the pure site-map (nav map) builder.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) 2026, ArtGins.
|
|
7
|
+
* All Rights Reserved.
|
|
8
|
+
***********************************************************************/
|
|
9
|
+
import { describe, test, expect } from "vitest";
|
|
10
|
+
import { build_nav_map } from "./route_map_model.js";
|
|
11
|
+
|
|
12
|
+
const view = (gclass) => ({ stage: "main", gclass: gclass });
|
|
13
|
+
|
|
14
|
+
/* A config + index shaped like a small wattyzer-style app:
|
|
15
|
+
* toolbar (brand + account dropdown), a primary menu with a submenu,
|
|
16
|
+
* a second menu, and a route table with URL-only routes. */
|
|
17
|
+
function make_input(extra)
|
|
18
|
+
{
|
|
19
|
+
const config = {
|
|
20
|
+
toolbar: { items: [
|
|
21
|
+
{ type: "brand", id: "brand", wordmark: "demo",
|
|
22
|
+
action: { type: "navigate", route: "/" } },
|
|
23
|
+
{ id: "theme", name: "theme",
|
|
24
|
+
action: { type: "event", event: "EV_TOGGLE_THEME" } },
|
|
25
|
+
{ type: "avatar", id: "user", name: "account",
|
|
26
|
+
action: { type: "dropdown", items: [
|
|
27
|
+
{ id: "sitemap", name: "site map",
|
|
28
|
+
action: { type: "navigate", route: "/sitemap" } },
|
|
29
|
+
{ type: "divider" },
|
|
30
|
+
{ id: "logout", name: "logout",
|
|
31
|
+
action: { type: "event", event: "EV_LOGOUT" } }
|
|
32
|
+
] } }
|
|
33
|
+
] },
|
|
34
|
+
menu: {
|
|
35
|
+
primary: { items: [
|
|
36
|
+
{ id: "reports", name: "reports", route: "/reports",
|
|
37
|
+
submenu: { items: [
|
|
38
|
+
{ id: "a", name: "a", route: "/reports/a" }
|
|
39
|
+
] } },
|
|
40
|
+
{ id: "form", name: "form", route: "/form" }
|
|
41
|
+
] },
|
|
42
|
+
footer: { items: [
|
|
43
|
+
{ id: "legal", name: "legal", route: "/legal" }
|
|
44
|
+
] }
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
const item_index = {
|
|
48
|
+
"/": { item: null, target: view("C_HOME") },
|
|
49
|
+
"/reports": { item: config.menu.primary.items[0], target: null },
|
|
50
|
+
"/reports/a": { item: null, target: view("C_REPORT_A") },
|
|
51
|
+
"/form": { item: null, target: view("C_FORM") },
|
|
52
|
+
"/legal": { item: null, target: view("C_LEGAL") },
|
|
53
|
+
"/sitemap": { item: null,
|
|
54
|
+
target: { kind: "action", event: "EV_OPEN_SITEMAP",
|
|
55
|
+
redirect: "back" } },
|
|
56
|
+
"/hidden": { item: null, target: view("C_HIDDEN") }
|
|
57
|
+
};
|
|
58
|
+
return Object.assign({
|
|
59
|
+
config: config,
|
|
60
|
+
item_index: item_index,
|
|
61
|
+
sub_routes: {},
|
|
62
|
+
event_handlers: {},
|
|
63
|
+
current_route: ""
|
|
64
|
+
}, extra || {});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe("build_nav_map", () => {
|
|
68
|
+
test("brand + toolbar in declaration order, dividers skipped", () => {
|
|
69
|
+
const m = build_nav_map(make_input());
|
|
70
|
+
expect(m.brand).toEqual({ label: "demo", route: "/" });
|
|
71
|
+
expect(m.toolbar.map(n => n.id)).toEqual(["theme", "user"]);
|
|
72
|
+
expect(m.toolbar[1].children.map(n => n.id))
|
|
73
|
+
.toEqual(["sitemap", "logout"]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("action-route item shows the event it fires", () => {
|
|
77
|
+
const m = build_nav_map(make_input());
|
|
78
|
+
const sitemap = m.toolbar[1].children[0];
|
|
79
|
+
expect(sitemap.route).toBe("/sitemap");
|
|
80
|
+
expect(sitemap.event).toBe("EV_OPEN_SITEMAP");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("primary menu flat; other menus as labelled groups", () => {
|
|
84
|
+
const m = build_nav_map(make_input());
|
|
85
|
+
expect(m.nav.map(n => n.id)).toEqual(["reports", "form", "footer"]);
|
|
86
|
+
const footer = m.nav[2];
|
|
87
|
+
expect(footer.kind).toBe("group");
|
|
88
|
+
expect(footer.children.map(n => n.route)).toEqual(["/legal"]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("other: uncovered route-table routes only, in index order", () => {
|
|
92
|
+
const m = build_nav_map(make_input());
|
|
93
|
+
/* "/" is brand-covered; "/sitemap" is dropdown-covered;
|
|
94
|
+
* "/legal" is footer-covered → only "/hidden" is left. */
|
|
95
|
+
expect(m.other.map(n => n.route)).toEqual(["/hidden"]);
|
|
96
|
+
expect(m.other[0].gclass).toBe("C_HIDDEN");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("dynamic submenu children merged by parent id", () => {
|
|
100
|
+
const input = make_input();
|
|
101
|
+
input.item_index["/reports/rt1"] = {
|
|
102
|
+
item: { id: "rt1", name: "runtime tab" },
|
|
103
|
+
parent_item: input.config.menu.primary.items[0],
|
|
104
|
+
target: view("C_RT")
|
|
105
|
+
};
|
|
106
|
+
const m = build_nav_map(input);
|
|
107
|
+
const reports = m.nav[0];
|
|
108
|
+
expect(reports.children.map(n => n.route))
|
|
109
|
+
.toEqual(["/reports/a", "/reports/rt1"]);
|
|
110
|
+
/* ...and a dynamic child is covered, not duplicated in other. */
|
|
111
|
+
expect(m.other.map(n => n.route)).toEqual(["/hidden"]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("sub_routes enrich their base node (and count as covered)", () => {
|
|
115
|
+
const input = make_input({
|
|
116
|
+
sub_routes: { "/reports/a": [
|
|
117
|
+
{ route: "/reports/a/x", label: "x" }
|
|
118
|
+
] }
|
|
119
|
+
});
|
|
120
|
+
const m = build_nav_map(input);
|
|
121
|
+
const a = m.nav[0].children[0];
|
|
122
|
+
expect(a.children.map(n => n.route)).toEqual(["/reports/a/x"]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("event handlers stamp the implementing gclass", () => {
|
|
126
|
+
const input = make_input({
|
|
127
|
+
event_handlers: { "EV_LOGOUT": ["C_APP"] }
|
|
128
|
+
});
|
|
129
|
+
const m = build_nav_map(input);
|
|
130
|
+
const logout = m.toolbar[1].children[1];
|
|
131
|
+
expect(logout.gclass).toBe("C_APP");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("current: exact route marked", () => {
|
|
135
|
+
const m = build_nav_map(make_input({ current_route: "/form" }));
|
|
136
|
+
const form = m.nav[1];
|
|
137
|
+
expect(form.current).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("current: deep subpath marks the longest base node", () => {
|
|
141
|
+
const m = build_nav_map(
|
|
142
|
+
make_input({ current_route: "/reports/a/topic/42" }));
|
|
143
|
+
expect(m.nav[0].children[0].current).toBe(true); /* /reports/a */
|
|
144
|
+
expect(m.nav[0].current).toBeUndefined();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("current: the brand's own route is markable (it renders as root)", () => {
|
|
148
|
+
const m = build_nav_map(make_input({ current_route: "/" }));
|
|
149
|
+
expect(m.brand.current).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("current: a menu item beats the brand on the same route", () => {
|
|
153
|
+
const input = make_input({ current_route: "/" });
|
|
154
|
+
input.config.menu.primary.items.push(
|
|
155
|
+
{ id: "home", name: "home", route: "/" });
|
|
156
|
+
const m = build_nav_map(input);
|
|
157
|
+
expect(m.nav.find(n => n.id === "home").current).toBe(true);
|
|
158
|
+
expect(m.brand.current).toBeUndefined();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
/* yui_shell_set_sub_routes stores the view's array BY REFERENCE, so a
|
|
162
|
+
* builder that splices those objects in and stamps `current` on one
|
|
163
|
+
* leaves the mark on a view-owned object forever: the next build shows
|
|
164
|
+
* a second "you are here" and the viewer scrolls to the stale one. */
|
|
165
|
+
test("sub-route contributions are cloned, never marked in place", () => {
|
|
166
|
+
const sub_routes = { "/reports": [
|
|
167
|
+
{ id: "", label: "a", icon: "", route: "/reports/topic",
|
|
168
|
+
event: "", gclass: "C_TOPIC", kind: "route", children: [] }
|
|
169
|
+
] };
|
|
170
|
+
const marked = (m) => {
|
|
171
|
+
const out = [];
|
|
172
|
+
const visit = (n) => {
|
|
173
|
+
if(n.current) {
|
|
174
|
+
out.push(n.route);
|
|
175
|
+
}
|
|
176
|
+
(n.children || []).forEach(visit);
|
|
177
|
+
};
|
|
178
|
+
m.toolbar.forEach(visit);
|
|
179
|
+
m.nav.forEach(visit);
|
|
180
|
+
m.other.forEach(visit);
|
|
181
|
+
return out;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const first = build_nav_map(
|
|
185
|
+
make_input({ sub_routes: sub_routes, current_route: "/reports/topic" }));
|
|
186
|
+
expect(marked(first)).toEqual(["/reports/topic"]);
|
|
187
|
+
/* the caller's own object was never touched */
|
|
188
|
+
expect(sub_routes["/reports"][0].current).toBeUndefined();
|
|
189
|
+
|
|
190
|
+
const second = build_nav_map(
|
|
191
|
+
make_input({ sub_routes: sub_routes, current_route: "/form" }));
|
|
192
|
+
expect(marked(second)).toEqual(["/form"]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("current: at most one node, none when nothing matches", () => {
|
|
196
|
+
const m = build_nav_map(make_input({ current_route: "/nope" }));
|
|
197
|
+
const marked = [];
|
|
198
|
+
const visit = (n) => {
|
|
199
|
+
if(n.current) {
|
|
200
|
+
marked.push(n.route);
|
|
201
|
+
}
|
|
202
|
+
(n.children || []).forEach(visit);
|
|
203
|
+
};
|
|
204
|
+
m.toolbar.forEach(visit);
|
|
205
|
+
m.nav.forEach(visit);
|
|
206
|
+
m.other.forEach(visit);
|
|
207
|
+
expect(marked).toEqual([]);
|
|
208
|
+
});
|
|
209
|
+
});
|
package/src/route_resolver.js
CHANGED
|
@@ -24,6 +24,29 @@
|
|
|
24
24
|
* - nothing matched → entry as found (may be
|
|
25
25
|
* null or a targetless submenu parent), subpath "".
|
|
26
26
|
************************************************************/
|
|
27
|
+
/************************************************************
|
|
28
|
+
* normalize_route — canonical form of a route: leading "/",
|
|
29
|
+
* duplicate slashes collapsed, trailing slashes stripped
|
|
30
|
+
* (root "/" stays "/"). Routes arrive from the outside
|
|
31
|
+
* world (typed URLs, shared links, old bookmarks): "#/a/b/"
|
|
32
|
+
* must resolve like "#/a/b" instead of missing the index
|
|
33
|
+
* and falling into the unknown-route redirect.
|
|
34
|
+
************************************************************/
|
|
35
|
+
function normalize_route(route)
|
|
36
|
+
{
|
|
37
|
+
if(!route) {
|
|
38
|
+
return "";
|
|
39
|
+
}
|
|
40
|
+
let s = String(route).replace(/\/{2,}/g, "/");
|
|
41
|
+
if(s.charAt(0) !== "/") {
|
|
42
|
+
s = "/" + s;
|
|
43
|
+
}
|
|
44
|
+
if(s.length > 1) {
|
|
45
|
+
s = s.replace(/\/+$/, "");
|
|
46
|
+
}
|
|
47
|
+
return s;
|
|
48
|
+
}
|
|
49
|
+
|
|
27
50
|
function resolve_route(item_index, route)
|
|
28
51
|
{
|
|
29
52
|
let entry = item_index[route];
|
|
@@ -50,4 +73,4 @@ function resolve_route(item_index, route)
|
|
|
50
73
|
return { entry: entry, matched_route: matched_route, subpath: subpath };
|
|
51
74
|
}
|
|
52
75
|
|
|
53
|
-
export { resolve_route };
|
|
76
|
+
export { resolve_route, normalize_route };
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* All Rights Reserved.
|
|
8
8
|
***********************************************************************/
|
|
9
9
|
import { describe, test, expect } from "vitest";
|
|
10
|
-
import { resolve_route } from "./route_resolver.js";
|
|
10
|
+
import { resolve_route, normalize_route } from "./route_resolver.js";
|
|
11
11
|
|
|
12
12
|
/* Minimal route table shaped like C_YUI_SHELL.priv.item_index. */
|
|
13
13
|
function make_index() {
|
|
@@ -80,3 +80,42 @@ describe("resolve_route", () => {
|
|
|
80
80
|
expect(r.entry.item.submenu).toBeTruthy();
|
|
81
81
|
});
|
|
82
82
|
});
|
|
83
|
+
|
|
84
|
+
describe("normalize_route", () => {
|
|
85
|
+
test("already canonical → unchanged", () => {
|
|
86
|
+
expect(normalize_route("/a/b")).toBe("/a/b");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("trailing slash stripped", () => {
|
|
90
|
+
expect(normalize_route("/a/b/")).toBe("/a/b");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("multiple trailing slashes stripped", () => {
|
|
94
|
+
expect(normalize_route("/a/b///")).toBe("/a/b");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("duplicate inner slashes collapsed", () => {
|
|
98
|
+
expect(normalize_route("//a///b")).toBe("/a/b");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("missing leading slash added", () => {
|
|
102
|
+
expect(normalize_route("a/b")).toBe("/a/b");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("root stays root", () => {
|
|
106
|
+
expect(normalize_route("/")).toBe("/");
|
|
107
|
+
expect(normalize_route("///")).toBe("/");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("empty stays empty (caller decides the error)", () => {
|
|
111
|
+
expect(normalize_route("")).toBe("");
|
|
112
|
+
expect(normalize_route(null)).toBe("");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("normalized trailing-slash route resolves like the clean one", () => {
|
|
116
|
+
const idx = make_index();
|
|
117
|
+
const r = resolve_route(idx, normalize_route("/monitoring/realtime/"));
|
|
118
|
+
expect(r.matched_route).toBe("/monitoring/realtime");
|
|
119
|
+
expect(r.subpath).toBe("");
|
|
120
|
+
});
|
|
121
|
+
});
|