@yuneta/gobj-ui 7.23.139 → 7.23.141

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.
@@ -58,7 +58,12 @@ import {
58
58
  import "./c_yui_treedb_topics.css";
59
59
 
60
60
  import {yui_shell_show_error, yui_shell_show_modal, yui_shell_popup_layer} from "./shell_modals.js";
61
- import {yui_shell_of, yui_shell_set_sub_routes} from "./c_yui_shell.js";
61
+ import {
62
+ yui_shell_of,
63
+ yui_shell_set_sub_routes,
64
+ yui_shell_navigate,
65
+ yui_shell_last_route_under,
66
+ } from "./c_yui_shell.js";
62
67
  import {nodes_answer} from "./nodes_answer.js";
63
68
  import {yui_toolbar_icon} from "./yui_toolbar.js";
64
69
 
@@ -254,26 +259,28 @@ function build_ui(gobj)
254
259
  {
255
260
  let source_url = gobj_read_str_attr(gobj, "source_url") || "";
256
261
 
257
- /* The whole treedb as a graph, with NO topic focused. A card's graph
258
- * icon lands on `<graph route>/<topic>`, and that segment is a
259
- * FOCUS: going through a card was the only way in, and it always
260
- * arrived with that topic highlighted. The route is the card's
261
- * template without its `/{topic}`; a host whose template does not
262
- * end in it, or gives none, gets no button. */
263
- let routes = gobj_read_attr(gobj, "card_action_routes");
264
- let graph_tpl = is_object(routes) && typeof routes.graph === "string"? routes.graph : "";
265
- let graph_href = /\/\{topic\}$/.test(graph_tpl)? graph_tpl.replace(/\/\{topic\}$/, "") : "";
262
+ /* The treedb as a graph, AS THE READER LEFT IT. The route is the
263
+ * card template without its `/{topic}`; a host whose template does
264
+ * not end in it, or gives none, gets no button. Not a fixed link:
265
+ * the graph's focus lives in its url (`<graph>/<topic>`), so the
266
+ * bare route took it away every time -- the click asks the shell
267
+ * where the reader last was under that route (ac_open_graph). */
268
+ let graph_href = graph_route_of(gobj);
266
269
  let graph_items = [];
267
270
  if(graph_href) {
268
271
  graph_items.push(
269
- ['a', {class: 'button TREEDB_GRAPH_BTN',
270
- href: graph_href,
271
- style: 'margin-left:auto;',
272
- title: t('graph'), 'aria-label': t('graph'),
273
- 'data-i18n-title': 'graph', 'data-i18n-aria-label': 'graph'}, [
272
+ ['button', {class: 'button TREEDB_GRAPH_BTN',
273
+ style: 'margin-left:auto;',
274
+ title: t('graph'), 'aria-label': t('graph'),
275
+ 'data-i18n-title': 'graph', 'data-i18n-aria-label': 'graph'}, [
274
276
  ['span', {class: 'icon'}, [yui_toolbar_icon('yi-hexagon-nodes')]],
275
277
  ['span', {i18n: 'graph'}, 'graph']
276
- ]]
278
+ ], {
279
+ click: (evt) => {
280
+ evt.stopPropagation();
281
+ gobj_send_event(gobj, "EV_OPEN_GRAPH", {}, gobj);
282
+ }
283
+ }]
277
284
  );
278
285
  }
279
286
 
@@ -389,6 +396,21 @@ function build_ui(gobj)
389
396
  }
390
397
  }
391
398
 
399
+ /************************************************************
400
+ * The graph's route with no topic: the host's card template
401
+ * `card_action_routes.graph` without its trailing `/{topic}`,
402
+ * as a ROUTE (no `#`). "" when the host gives no such template.
403
+ ************************************************************/
404
+ function graph_route_of(gobj)
405
+ {
406
+ let routes = gobj_read_attr(gobj, "card_action_routes");
407
+ let tpl = (is_object(routes) && typeof routes.graph === "string")? routes.graph : "";
408
+ if(!/\/\{topic\}$/.test(tpl)) {
409
+ return "";
410
+ }
411
+ return tpl.replace(/\/\{topic\}$/, "").replace(/^#/, "");
412
+ }
413
+
392
414
  /************************************************************
393
415
  * Is the backend session live? The Raw JSON viewer issues a remote
394
416
  * print-tranger, so it only makes sense with a session — the remote
@@ -2476,6 +2498,28 @@ function ac_open_json(gobj, event, kw, src)
2476
2498
  return 0;
2477
2499
  }
2478
2500
 
2501
+ /********************************************
2502
+ * Open the graph where the reader left it: its last visited
2503
+ * route under the graph's base -- `<graph>/<topic>` when a topic
2504
+ * was focused there -- or the bare base when the graph was never
2505
+ * opened in this page. A user move: pushed, so Back returns.
2506
+ ********************************************/
2507
+ function ac_open_graph(gobj, event, kw, src)
2508
+ {
2509
+ let base = graph_route_of(gobj);
2510
+ if(!base) {
2511
+ log_error(`${gobj_short_name(gobj)}: open graph with no graph route`);
2512
+ return -1;
2513
+ }
2514
+ let shell = yui_shell_of(gobj);
2515
+ if(!shell) {
2516
+ log_error(`${gobj_short_name(gobj)}: open graph with no shell`);
2517
+ return -1;
2518
+ }
2519
+ yui_shell_navigate(shell, yui_shell_last_route_under(shell, base), {push: true});
2520
+ return 0;
2521
+ }
2522
+
2479
2523
  /********************************************
2480
2524
  * The viewer asked to load a collapsed subtree (tranger mode only):
2481
2525
  * re-issue print-tranger for that path.
@@ -2556,6 +2600,7 @@ function create_gclass(gclass_name)
2556
2600
  ["EV_DELETE_RECORD", ac_delete_record, null],
2557
2601
  ["EV_REFRESH_TOPIC", ac_refresh_topic, null],
2558
2602
  ["EV_OPEN_JSON", ac_open_json, null],
2603
+ ["EV_OPEN_GRAPH", ac_open_graph, null],
2559
2604
  ["EV_EXPAND_PATH", ac_json_expand_path, null],
2560
2605
  ["EV_JSON_CLOSED", ac_json_closed, null],
2561
2606
  ["EV_SHOW", ac_show, null],
@@ -2586,6 +2631,7 @@ function create_gclass(gclass_name)
2586
2631
  event_flag_t.EVF_NO_WARN_SUBS],
2587
2632
  ["EV_REFRESH_TOPIC", 0],
2588
2633
  ["EV_OPEN_JSON", 0],
2634
+ ["EV_OPEN_GRAPH", 0],
2589
2635
  ["EV_EXPAND_PATH", 0],
2590
2636
  ["EV_JSON_CLOSED", 0],
2591
2637
  ["EV_SHOW", 0],
@@ -281,7 +281,7 @@ export function fold_build_model(descs, records, opts)
281
281
  }
282
282
  }
283
283
 
284
- return {
284
+ let model = {
285
285
  nodes: nodes,
286
286
  groups: groups,
287
287
  topics: topics,
@@ -291,7 +291,67 @@ export function fold_build_model(descs, records, opts)
291
291
  linked: linked, /* Set of topics the schema hangs from main */
292
292
  loose: loose, /* {topic: [key, ...]} records that should hang and do not */
293
293
  loose_shown: loose_shown,
294
+ level_of: null, /* Map<node_key, level> of the MAIN tree, see below */
295
+ levels: 0, /* its deepest level (0 = no tree to step through) */
294
296
  };
297
+ count_levels(model);
298
+ return model;
299
+ }
300
+
301
+ /************************************************************
302
+ * The LEVELS of the main tree: the roots of the main topic --
303
+ * and of the topics hung from it, whose roots are the loose
304
+ * records the reader asked for -- are level 1, their children
305
+ * level 2, and so on down every hook of every topic that is not
306
+ * hidden. A node reached by two paths takes the shallowest.
307
+ *
308
+ * With no main topic every topic is a tree of its own and all
309
+ * of them count. A topic the schema does not tie to the main
310
+ * one is a separate tree, and has no level: the stepper of the
311
+ * toolbar speaks for the main tree and nothing else, those
312
+ * trees are opened by their own pills.
313
+ ************************************************************/
314
+ function count_levels(model)
315
+ {
316
+ let level_of = new Map();
317
+ let queue = [];
318
+ let deepest = 0;
319
+
320
+ for(let topic_name of model.topics) {
321
+ if(model.main_topic && !model.linked.has(topic_name)) {
322
+ continue;
323
+ }
324
+ let roots = model.groups.get(fold_root_group_key(topic_name)) || [];
325
+ for(let key of roots) {
326
+ if(!level_of.has(key)) {
327
+ level_of.set(key, 1);
328
+ queue.push(key);
329
+ }
330
+ }
331
+ }
332
+ let head = 0;
333
+ while(head < queue.length) {
334
+ let key = queue[head++];
335
+ let lv = level_of.get(key);
336
+ if(lv > deepest) {
337
+ deepest = lv;
338
+ }
339
+ let node = model.nodes.get(key);
340
+ if(!node) {
341
+ continue;
342
+ }
343
+ for(let hook of Object.keys(node.hooks)) {
344
+ for(let child of node.hooks[hook]) {
345
+ if(!level_of.has(child)) {
346
+ level_of.set(child, lv + 1);
347
+ queue.push(child);
348
+ }
349
+ }
350
+ }
351
+ }
352
+
353
+ model.level_of = level_of;
354
+ model.levels = deepest;
295
355
  }
296
356
 
297
357
  /************************************************************
@@ -565,6 +625,78 @@ export function fold_expand_to_depth(model, state, depth)
565
625
  }
566
626
  }
567
627
 
628
+ /************************************************************
629
+ * The toolbar's STEPPER over the levels of the main tree (see
630
+ * count_levels): level N shows the roots and N-1 levels under
631
+ * them. A level is a floor of what is open, not a picture that
632
+ * replaces it:
633
+ *
634
+ * opening to N opens, on its first page, every hook of
635
+ * every node above level N that is folded;
636
+ * what the reader opened, and every page
637
+ * already shown, stays
638
+ * closing to N folds every hook of every node at level N
639
+ * or below, and touches nothing above it
640
+ *
641
+ * A separate tree (a topic not tied to the main one) has no
642
+ * level, so neither ever touches it.
643
+ ************************************************************/
644
+ export function fold_level_clamp(model, level)
645
+ {
646
+ let top = (model.levels > 0)? model.levels : 1;
647
+ level = Math.floor(Number(level)) || 1;
648
+ if(level < 1) {
649
+ return 1;
650
+ }
651
+ if(level > top) {
652
+ return top;
653
+ }
654
+ return level;
655
+ }
656
+
657
+ export function fold_open_levels(model, state, level)
658
+ {
659
+ for(let [key, lv] of model.level_of) {
660
+ if(lv >= level) {
661
+ continue;
662
+ }
663
+ let node = model.nodes.get(key);
664
+ if(!node) {
665
+ continue;
666
+ }
667
+ for(let hook of Object.keys(node.hooks)) {
668
+ let group_key = fold_group_key(key, hook);
669
+ if(node.hooks[hook].length > 0 && !fold_is_open(state, group_key)) {
670
+ state.shown.set(group_key, state.page_size);
671
+ }
672
+ }
673
+ }
674
+ }
675
+
676
+ export function fold_close_levels(model, state, level)
677
+ {
678
+ for(let [key, lv] of model.level_of) {
679
+ if(lv < level) {
680
+ continue;
681
+ }
682
+ let node = model.nodes.get(key);
683
+ if(!node) {
684
+ continue;
685
+ }
686
+ for(let hook of Object.keys(node.hooks)) {
687
+ state.shown.delete(fold_group_key(key, hook));
688
+ }
689
+ }
690
+ }
691
+
692
+ /* From scratch: the roots of every tree on their first page, and
693
+ * the main tree open down to `level`. What a load opens. */
694
+ export function fold_expand_to_level(model, state, level)
695
+ {
696
+ fold_collapse_all(model, state);
697
+ fold_open_levels(model, state, fold_level_clamp(model, level));
698
+ }
699
+
568
700
  /************************************************************
569
701
  * The set of node keys on screen, from the roots down through
570
702
  * every open group -- and only through open groups whose parent
@@ -26,6 +26,11 @@ import {
26
26
  fold_split_group_key,
27
27
  fold_main_topic,
28
28
  fold_group_shown,
29
+ fold_is_open,
30
+ fold_level_clamp,
31
+ fold_open_levels,
32
+ fold_close_levels,
33
+ fold_expand_to_level,
29
34
  } from "./treedb_fold_model.js";
30
35
 
31
36
  const places_desc = {
@@ -323,6 +328,81 @@ describe("depth", () => {
323
328
  });
324
329
  });
325
330
 
331
+ describe("levels of the main tree (the toolbar's stepper)", () => {
332
+ test("levels count from the main topic's roots, down every hook", () => {
333
+ let m = fold_build_model(descs, make_records(30));
334
+ /* es 1, norte/sur 2, nave1 3, its devices and c1 4 */
335
+ expect(m.levels).toBe(4);
336
+ expect(m.level_of.get(K("places", "es"))).toBe(1);
337
+ expect(m.level_of.get(K("places", "nave1"))).toBe(3);
338
+ /* d0 hangs from nave1 (3) and c1 (4): the shallowest wins */
339
+ expect(m.level_of.get(K("devices", "d0"))).toBe(4);
340
+ /* device_groups is not tied to places: a tree of its own, no level */
341
+ expect(m.level_of.has(K("device_groups", "g1"))).toBe(false);
342
+ });
343
+
344
+ test("a hidden topic takes its levels with it", () => {
345
+ let m = fold_build_model(descs, make_records(30), {hidden_topics: ["devices", "controllers"]});
346
+ expect(m.levels).toBe(3);
347
+ let m0 = fold_build_model({}, {});
348
+ expect(m0.levels).toBe(0);
349
+ expect(fold_level_clamp(m0, 5)).toBe(1);
350
+ });
351
+
352
+ test("clamp keeps a level between 1 and the deepest one", () => {
353
+ let m = fold_build_model(descs, make_records(3));
354
+ expect(fold_level_clamp(m, 0)).toBe(1);
355
+ expect(fold_level_clamp(m, 3)).toBe(3);
356
+ expect(fold_level_clamp(m, 9)).toBe(4);
357
+ expect(fold_level_clamp(m, "2")).toBe(2);
358
+ });
359
+
360
+ test("level 1 is the roots of every tree, and each step opens one more", () => {
361
+ let m = fold_build_model(descs, make_records(30));
362
+ let st = fold_new_state(24);
363
+ fold_expand_to_level(m, st, 1);
364
+ expect([...fold_visible_set(m, st)].sort())
365
+ .toEqual([K("device_groups", "g1"), K("places", "es")]);
366
+ fold_open_levels(m, st, 2);
367
+ let v = fold_visible_set(m, st);
368
+ expect(v.has(K("places", "norte"))).toBe(true);
369
+ expect(v.has(K("places", "nave1"))).toBe(false);
370
+ fold_open_levels(m, st, 3);
371
+ expect(fold_visible_set(m, st).has(K("places", "nave1"))).toBe(true);
372
+ expect(fold_visible_set(m, st).has(K("devices", "d0"))).toBe(false);
373
+ fold_open_levels(m, st, 4);
374
+ v = fold_visible_set(m, st);
375
+ expect(v.has(K("devices", "d23"))).toBe(true);
376
+ expect(v.has(K("devices", "d24"))).toBe(false); /* one page */
377
+ expect(v.has(K("controllers", "c1"))).toBe(true);
378
+ });
379
+
380
+ test("opening keeps the pages already shown; closing folds only from its level down", () => {
381
+ let m = fold_build_model(descs, make_records(30));
382
+ let st = fold_new_state(24);
383
+ fold_expand_to_level(m, st, 4);
384
+ let g = fold_group_key(K("places", "nave1"), "devices");
385
+ fold_show_more(m, st, g); /* 30 */
386
+ fold_open_levels(m, st, 4);
387
+ expect(fold_group_shown(m, st, g)).toBe(30);
388
+ fold_close_levels(m, st, 3);
389
+ let v = fold_visible_set(m, st);
390
+ expect(v.has(K("places", "nave1"))).toBe(true); /* level 3 stays */
391
+ expect(v.has(K("devices", "d0"))).toBe(false); /* its hooks folded */
392
+ expect(v.has(K("controllers", "c1"))).toBe(false);
393
+ expect(fold_is_open(st, fold_group_key(K("places", "norte"), "places"))).toBe(true);
394
+ });
395
+
396
+ test("closing does not reopen what the reader folded above the level", () => {
397
+ let m = fold_build_model(descs, make_records(3));
398
+ let st = fold_new_state(24);
399
+ fold_expand_to_level(m, st, 4);
400
+ fold_toggle(m, st, fold_group_key(K("places", "es"), "places")); /* fold es */
401
+ fold_close_levels(m, st, 3);
402
+ expect(fold_is_open(st, fold_group_key(K("places", "es"), "places"))).toBe(false);
403
+ });
404
+ });
405
+
326
406
  describe("toggle and pages", () => {
327
407
  test("a folded group opens on its first page and closes again", () => {
328
408
  let m = fold_build_model(descs, make_records(30));