@yuneta/gobj-ui 7.23.140 → 7.23.142

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.
@@ -183,7 +183,7 @@ function sort_by_order(list)
183
183
  * schema_version, c_schema_version, system_schema_version,
184
184
  * topics: [{
185
185
  * id, name, order, record,
186
- * pkey, topic_version, system_topic,
186
+ * pkey, topic_version, system_topic, main_topic,
187
187
  * cols: [{id, name, order, record}]
188
188
  * }]
189
189
  * }],
@@ -239,6 +239,7 @@ function build_schema_model(records)
239
239
  tkey: record.tkey || "",
240
240
  topic_version: record.topic_version,
241
241
  system_topic: !!record.system_topic,
242
+ main_topic: !!record.main_topic,
242
243
  cols: []
243
244
  };
244
245
  by_topic_id[entry.id] = entry;
@@ -47,7 +47,7 @@ const DEFAULT_PAD = 68;
47
47
  * and every export diffs against the last one. */
48
48
  const TOPIC_KEY_ORDER = [
49
49
  "id", "pkey", "pkey2s", "system_flag", "tkey",
50
- "topic_version", "system_topic"
50
+ "topic_version", "system_topic", "main_topic"
51
51
  ];
52
52
 
53
53
  /* Same for a column, in the order the .c literals write it. */
@@ -189,6 +189,26 @@ function validate_schema(treedb, options)
189
189
  }
190
190
  }
191
191
 
192
+ /* The topic the tree hangs from (`main_topic`): ONE per treedb, and
193
+ * only a topic hooked to itself. treedb_open_db logs either mistake
194
+ * and ignores the mark, so the restart succeeds and the graph goes
195
+ * on deducing -- better said here, before it. */
196
+ let marked = treedb.topics.filter((topic) => topic.main_topic);
197
+ if(marked.length > 1) {
198
+ add("error", "more than one main topic", marked[1].name, "",
199
+ marked.map((topic) => topic.name).join(", "));
200
+ }
201
+ for(let topic of marked) {
202
+ let hooks_itself = topic.cols.some((col) => {
203
+ let hook = col_hook(col.record);
204
+ return col_flags(col.record).indexOf("hook") >= 0 &&
205
+ !!hook && Object.prototype.hasOwnProperty.call(hook, topic.name);
206
+ });
207
+ if(!hooks_itself) {
208
+ add("error", "main topic not hooked to itself", topic.name, "", "");
209
+ }
210
+ }
211
+
192
212
  /* How many hooks name each fkey column. Both answers are a
193
213
  * finding: NONE and the parent side of the link was renamed or
194
214
  * deleted, so the references are written by nobody; TWO and the
@@ -94,6 +94,50 @@ describe("the topic will not open", () => {
94
94
  });
95
95
  });
96
96
 
97
+ describe("the main topic mark", () => {
98
+ /* departments hooked to itself: the one topic the mark can go on */
99
+ const self_hook = {
100
+ id: "db.departments.departments", value: "departments", order: 3,
101
+ type: "dict", flag: ["hook"], hook: {departments: "departments"},
102
+ topics: ["topics^db.departments^cols"]
103
+ };
104
+
105
+ test("on a topic hooked to itself, says nothing", () => {
106
+ const t = schema({extra_col: self_hook});
107
+ t.topics[0].main_topic = true;
108
+ const c = codes(validate_schema(t));
109
+ expect(c).not.toContain("main topic not hooked to itself");
110
+ expect(c).not.toContain("more than one main topic");
111
+ });
112
+
113
+ test("on a topic that is not hooked to itself", () => {
114
+ const t = schema();
115
+ t.topics[1].main_topic = true;
116
+ const f = validate_schema(t);
117
+ expect(codes(f)).toContain("main topic not hooked to itself");
118
+ expect(has_errors(f)).toBe(true);
119
+ });
120
+
121
+ test("on two topics", () => {
122
+ const t = schema({extra_col: self_hook});
123
+ t.topics[0].main_topic = true;
124
+ t.topics[1].main_topic = true;
125
+ const f = validate_schema(t);
126
+ expect(codes(f)).toContain("more than one main topic");
127
+ expect(f.find((x) => x.code === "more than one main topic").detail)
128
+ .toBe("departments, users");
129
+ });
130
+
131
+ test("the model reads the mark from the record", () => {
132
+ const model = build_schema_model({
133
+ treedbs: [{id: "db"}],
134
+ topics: [{id: "db.a", value: "a", main_topic: true, treedbs: ["treedbs^db^topics"]}],
135
+ cols: [],
136
+ });
137
+ expect(model.treedbs[0].topics[0].main_topic).toBe(true);
138
+ });
139
+ });
140
+
97
141
  describe("the link does nothing, and the write succeeded", () => {
98
142
  test("a hook with no mapping", () => {
99
143
  expect(codes(validate_schema(schema({hook: null})))).toContain("hook has no mapping");
@@ -152,7 +152,13 @@ export function fold_build_model(descs, records, opts)
152
152
 
153
153
  let hidden = new Set(is_array(opts.hidden_topics)? opts.hidden_topics : []);
154
154
  let loose_shown = new Set(is_array(opts.loose_topics)? opts.loose_topics : []);
155
- let main_topic = opts.main_topic || fold_main_topic(descs);
155
+ let main_topic = opts.main_topic || "";
156
+ if(main_topic && !fold_is_hierarchical(descs, main_topic)) {
157
+ main_topic = ""; /* a pick the rule does not allow (see fold_main_topic): deduce */
158
+ }
159
+ if(!main_topic) {
160
+ main_topic = fold_main_topic(descs);
161
+ }
156
162
  if(main_topic && (!descs[main_topic] || hidden.has(main_topic))) {
157
163
  main_topic = ""; /* a main topic that is not drawn governs nothing */
158
164
  }
@@ -281,7 +287,7 @@ export function fold_build_model(descs, records, opts)
281
287
  }
282
288
  }
283
289
 
284
- return {
290
+ let model = {
285
291
  nodes: nodes,
286
292
  groups: groups,
287
293
  topics: topics,
@@ -291,38 +297,124 @@ export function fold_build_model(descs, records, opts)
291
297
  linked: linked, /* Set of topics the schema hangs from main */
292
298
  loose: loose, /* {topic: [key, ...]} records that should hang and do not */
293
299
  loose_shown: loose_shown,
300
+ level_of: null, /* Map<node_key, level> of the MAIN tree, see below */
301
+ levels: 0, /* its deepest level (0 = no tree to step through) */
294
302
  };
303
+ count_levels(model);
304
+ return model;
295
305
  }
296
306
 
297
307
  /************************************************************
298
- * The topic the schema hangs the others from: the one whose hooks
299
- * reach the most OTHER topics, a self-referent hook breaking a tie
300
- * (a tree of places over a flat list of groups), schema order
301
- * breaking the rest. "" when no topic reaches another -- a treedb of
302
- * unrelated lists has no trunk, and every topic is a tree of its own.
308
+ * The LEVELS of the main tree: the roots of the main topic --
309
+ * and of the topics hung from it, whose roots are the loose
310
+ * records the reader asked for -- are level 1, their children
311
+ * level 2, and so on down every hook of every topic that is not
312
+ * hidden. A node reached by two paths takes the shallowest.
313
+ *
314
+ * With no main topic every topic is a tree of its own and all
315
+ * of them count. A topic the schema does not tie to the main
316
+ * one is a separate tree, and has no level: the stepper of the
317
+ * toolbar speaks for the main tree and nothing else, those
318
+ * trees are opened by their own pills.
319
+ ************************************************************/
320
+ function count_levels(model)
321
+ {
322
+ let level_of = new Map();
323
+ let queue = [];
324
+ let deepest = 0;
325
+
326
+ for(let topic_name of model.topics) {
327
+ if(model.main_topic && !model.linked.has(topic_name)) {
328
+ continue;
329
+ }
330
+ let roots = model.groups.get(fold_root_group_key(topic_name)) || [];
331
+ for(let key of roots) {
332
+ if(!level_of.has(key)) {
333
+ level_of.set(key, 1);
334
+ queue.push(key);
335
+ }
336
+ }
337
+ }
338
+ let head = 0;
339
+ while(head < queue.length) {
340
+ let key = queue[head++];
341
+ let lv = level_of.get(key);
342
+ if(lv > deepest) {
343
+ deepest = lv;
344
+ }
345
+ let node = model.nodes.get(key);
346
+ if(!node) {
347
+ continue;
348
+ }
349
+ for(let hook of Object.keys(node.hooks)) {
350
+ for(let child of node.hooks[hook]) {
351
+ if(!level_of.has(child)) {
352
+ level_of.set(child, lv + 1);
353
+ queue.push(child);
354
+ }
355
+ }
356
+ }
357
+ }
358
+
359
+ model.level_of = level_of;
360
+ model.levels = deepest;
361
+ }
362
+
363
+ /************************************************************
364
+ * A HIERARCHICAL topic: one hooked to ITSELF -- places inside
365
+ * places, groups inside groups, roles inside roles. It is the
366
+ * only kind of topic a tree can hang from: a topic that only
367
+ * reaches OTHER topics is a list with children, and making it
368
+ * the trunk drew the agent's treedb from `yunos` instead of
369
+ * `realms`.
370
+ *
371
+ * (Not the engine's `entity` tier, which is any topic with
372
+ * hooks AND fkeys: that one sizes a card and says nothing about
373
+ * where a tree starts.)
374
+ ************************************************************/
375
+ export function fold_is_hierarchical(descs, topic_name)
376
+ {
377
+ if(!is_object(descs) || !descs[topic_name]) {
378
+ return false;
379
+ }
380
+ return hook_targets(descs, topic_name).has(topic_name);
381
+ }
382
+
383
+ /************************************************************
384
+ * The topic the tree hangs from. Only a HIERARCHICAL topic can
385
+ * be it, and in this order:
386
+ *
387
+ * the one the schema MARKS (`main_topic: true` on the topic,
388
+ * sent by the backend in its desc since SDK 7.19; the first
389
+ * one in schema order if a schema marks two);
390
+ * else the one whose hooks reach the most OTHER topics,
391
+ * schema order breaking a tie.
392
+ *
393
+ * "" when no topic is hooked to itself: there is no trunk, and
394
+ * every topic is a tree of its own. (A pick by the reader comes
395
+ * first of all, and is fold_build_model's business.)
303
396
  ************************************************************/
304
397
  export function fold_main_topic(descs)
305
398
  {
306
399
  descs = is_object(descs)? descs : {};
307
- let best = "";
308
- let best_score = 0;
309
- let best_self = false;
400
+ let candidates = Object.keys(descs).filter((topic_name) => {
401
+ return topic_name.substring(0, 2) !== "__" && fold_is_hierarchical(descs, topic_name);
402
+ });
310
403
 
311
- for(let topic_name of Object.keys(descs)) {
312
- if(topic_name.substring(0, 2) === "__") {
313
- continue;
404
+ for(let topic_name of candidates) {
405
+ if(descs[topic_name].main_topic === true) {
406
+ return topic_name;
314
407
  }
408
+ }
409
+
410
+ let best = "";
411
+ let best_score = -1;
412
+ for(let topic_name of candidates) {
315
413
  let reach = hook_targets(descs, topic_name);
316
- let self = reach.has(topic_name);
317
414
  reach.delete(topic_name);
318
- let score = reach.size;
319
- if(score === 0) {
320
- continue;
321
- }
322
- if(score > best_score || (score === best_score && self && !best_self)) {
415
+ if(reach.size > best_score) {
323
416
  best = topic_name;
324
- best_score = score;
325
- best_self = self;
417
+ best_score = reach.size;
326
418
  }
327
419
  }
328
420
  return best;
@@ -565,6 +657,78 @@ export function fold_expand_to_depth(model, state, depth)
565
657
  }
566
658
  }
567
659
 
660
+ /************************************************************
661
+ * The toolbar's STEPPER over the levels of the main tree (see
662
+ * count_levels): level N shows the roots and N-1 levels under
663
+ * them. A level is a floor of what is open, not a picture that
664
+ * replaces it:
665
+ *
666
+ * opening to N opens, on its first page, every hook of
667
+ * every node above level N that is folded;
668
+ * what the reader opened, and every page
669
+ * already shown, stays
670
+ * closing to N folds every hook of every node at level N
671
+ * or below, and touches nothing above it
672
+ *
673
+ * A separate tree (a topic not tied to the main one) has no
674
+ * level, so neither ever touches it.
675
+ ************************************************************/
676
+ export function fold_level_clamp(model, level)
677
+ {
678
+ let top = (model.levels > 0)? model.levels : 1;
679
+ level = Math.floor(Number(level)) || 1;
680
+ if(level < 1) {
681
+ return 1;
682
+ }
683
+ if(level > top) {
684
+ return top;
685
+ }
686
+ return level;
687
+ }
688
+
689
+ export function fold_open_levels(model, state, level)
690
+ {
691
+ for(let [key, lv] of model.level_of) {
692
+ if(lv >= level) {
693
+ continue;
694
+ }
695
+ let node = model.nodes.get(key);
696
+ if(!node) {
697
+ continue;
698
+ }
699
+ for(let hook of Object.keys(node.hooks)) {
700
+ let group_key = fold_group_key(key, hook);
701
+ if(node.hooks[hook].length > 0 && !fold_is_open(state, group_key)) {
702
+ state.shown.set(group_key, state.page_size);
703
+ }
704
+ }
705
+ }
706
+ }
707
+
708
+ export function fold_close_levels(model, state, level)
709
+ {
710
+ for(let [key, lv] of model.level_of) {
711
+ if(lv < level) {
712
+ continue;
713
+ }
714
+ let node = model.nodes.get(key);
715
+ if(!node) {
716
+ continue;
717
+ }
718
+ for(let hook of Object.keys(node.hooks)) {
719
+ state.shown.delete(fold_group_key(key, hook));
720
+ }
721
+ }
722
+ }
723
+
724
+ /* From scratch: the roots of every tree on their first page, and
725
+ * the main tree open down to `level`. What a load opens. */
726
+ export function fold_expand_to_level(model, state, level)
727
+ {
728
+ fold_collapse_all(model, state);
729
+ fold_open_levels(model, state, fold_level_clamp(model, level));
730
+ }
731
+
568
732
  /************************************************************
569
733
  * The set of node keys on screen, from the roots down through
570
734
  * every open group -- and only through open groups whose parent
@@ -26,11 +26,17 @@ 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,
34
+ fold_is_hierarchical,
29
35
  } from "./treedb_fold_model.js";
30
36
 
31
37
  const places_desc = {
32
38
  topic_name: "places",
33
- node_treedb_type: "hierarchical",
39
+ node_treedb_type: "entity",
34
40
  cols: [
35
41
  {id: "id", flag: []},
36
42
  {id: "place", flag: ["fkey"], fkey: {places: "places"}},
@@ -51,13 +57,20 @@ const devices_desc = {
51
57
  };
52
58
  const controllers_desc = {
53
59
  topic_name: "controllers",
54
- node_treedb_type: "hierarchical",
60
+ node_treedb_type: "entity",
55
61
  cols: [
56
62
  {id: "id", flag: []},
57
63
  {id: "place", flag: ["fkey"], fkey: {places: "controllers"}},
58
64
  {id: "devices", flag: ["hook"], hook: {devices: "controller"}},
59
65
  ],
60
66
  };
67
+ /* controllers inside controllers: a second HIERARCHICAL topic */
68
+ const controllers_self = Object.assign({}, controllers_desc, {
69
+ cols: controllers_desc.cols.concat([
70
+ {id: "controllers", flag: ["hook"], hook: {controllers: "parent"}},
71
+ {id: "parent", flag: ["fkey"], fkey: {controllers: "controllers"}},
72
+ ]),
73
+ });
61
74
  const groups_desc = {
62
75
  topic_name: "device_groups",
63
76
  node_treedb_type: "extended",
@@ -184,10 +197,33 @@ describe("main topic, hidden topics, loose records", () => {
184
197
  expect(m.linked.has("device_groups")).toBe(false);
185
198
  });
186
199
 
200
+ test("only a topic hooked to ITSELF can be the main one", () => {
201
+ expect(fold_is_hierarchical(descs, "places")).toBe(true);
202
+ expect(fold_is_hierarchical(descs, "controllers")).toBe(false);
203
+ expect(fold_is_hierarchical(descs, "device_groups")).toBe(false);
204
+ /* c reaches x and nothing reaches itself: no trunk at all */
205
+ let c = {topic_name: "c", cols: [{id: "id", flag: []},
206
+ {id: "xs", flag: ["hook"], hook: {x: "c"}}]};
207
+ let x = {topic_name: "x", cols: [{id: "id", flag: []}, {id: "c", flag: ["fkey"]}]};
208
+ expect(fold_main_topic({c: c, x: x})).toBe("");
209
+ /* a tree of places alone, reaching no other topic, is still a trunk */
210
+ expect(fold_main_topic({places: places_desc})).toBe("places");
211
+ });
212
+
213
+ test("the schema's mark wins over the deduction, among hierarchical topics", () => {
214
+ let d = Object.assign({}, descs, {controllers: controllers_self});
215
+ expect(fold_main_topic(d)).toBe("places"); /* places reaches more */
216
+ let marked = Object.assign({}, d, {controllers: Object.assign({}, controllers_self, {main_topic: true})});
217
+ expect(fold_main_topic(marked)).toBe("controllers");
218
+ /* a mark on a topic that is not hierarchical is not a trunk */
219
+ let wrong = Object.assign({}, descs, {devices: Object.assign({}, devices_desc, {main_topic: true})});
220
+ expect(fold_main_topic(wrong)).toBe("places");
221
+ });
222
+
187
223
  test("a self-referent hook breaks a tie", () => {
188
224
  let a = {topic_name: "a", node_treedb_type: "extended",
189
225
  cols: [{id: "id", flag: []}, {id: "xs", flag: ["hook"], hook: {x: "a"}}]};
190
- let b = {topic_name: "b", node_treedb_type: "hierarchical",
226
+ let b = {topic_name: "b", node_treedb_type: "entity",
191
227
  cols: [{id: "id", flag: []}, {id: "xs", flag: ["hook"], hook: {x: "b"}},
192
228
  {id: "bs", flag: ["hook"], hook: {b: "parent"}},
193
229
  {id: "parent", flag: ["fkey"], fkey: {b: "bs"}}]};
@@ -196,12 +232,16 @@ describe("main topic, hidden topics, loose records", () => {
196
232
  expect(fold_main_topic({x: x})).toBe("");
197
233
  });
198
234
 
199
- test("an explicit main topic wins over the deduced one", () => {
200
- let m = fold_build_model(descs, make_records(3), {main_topic: "controllers"});
235
+ test("the reader's pick wins -- if it is a hierarchical topic", () => {
236
+ let d = Object.assign({}, descs, {controllers: controllers_self});
237
+ let m = fold_build_model(d, make_records(3), {main_topic: "controllers"});
201
238
  expect(m.main_topic).toBe("controllers");
202
239
  expect([...m.linked].sort()).toEqual(["controllers", "devices"]);
203
240
  /* places is not linked to controllers, so its roots are roots */
204
241
  expect(m.groups.get(fold_root_group_key("places"))).toEqual([K("places", "es")]);
242
+ /* a pick the rule refuses is ignored, and the trunk is deduced */
243
+ let m2 = fold_build_model(descs, make_records(3), {main_topic: "controllers"});
244
+ expect(m2.main_topic).toBe("places");
205
245
  });
206
246
 
207
247
  test("a device with no place is LOOSE: counted, not a root, unless asked for", () => {
@@ -323,6 +363,81 @@ describe("depth", () => {
323
363
  });
324
364
  });
325
365
 
366
+ describe("levels of the main tree (the toolbar's stepper)", () => {
367
+ test("levels count from the main topic's roots, down every hook", () => {
368
+ let m = fold_build_model(descs, make_records(30));
369
+ /* es 1, norte/sur 2, nave1 3, its devices and c1 4 */
370
+ expect(m.levels).toBe(4);
371
+ expect(m.level_of.get(K("places", "es"))).toBe(1);
372
+ expect(m.level_of.get(K("places", "nave1"))).toBe(3);
373
+ /* d0 hangs from nave1 (3) and c1 (4): the shallowest wins */
374
+ expect(m.level_of.get(K("devices", "d0"))).toBe(4);
375
+ /* device_groups is not tied to places: a tree of its own, no level */
376
+ expect(m.level_of.has(K("device_groups", "g1"))).toBe(false);
377
+ });
378
+
379
+ test("a hidden topic takes its levels with it", () => {
380
+ let m = fold_build_model(descs, make_records(30), {hidden_topics: ["devices", "controllers"]});
381
+ expect(m.levels).toBe(3);
382
+ let m0 = fold_build_model({}, {});
383
+ expect(m0.levels).toBe(0);
384
+ expect(fold_level_clamp(m0, 5)).toBe(1);
385
+ });
386
+
387
+ test("clamp keeps a level between 1 and the deepest one", () => {
388
+ let m = fold_build_model(descs, make_records(3));
389
+ expect(fold_level_clamp(m, 0)).toBe(1);
390
+ expect(fold_level_clamp(m, 3)).toBe(3);
391
+ expect(fold_level_clamp(m, 9)).toBe(4);
392
+ expect(fold_level_clamp(m, "2")).toBe(2);
393
+ });
394
+
395
+ test("level 1 is the roots of every tree, and each step opens one more", () => {
396
+ let m = fold_build_model(descs, make_records(30));
397
+ let st = fold_new_state(24);
398
+ fold_expand_to_level(m, st, 1);
399
+ expect([...fold_visible_set(m, st)].sort())
400
+ .toEqual([K("device_groups", "g1"), K("places", "es")]);
401
+ fold_open_levels(m, st, 2);
402
+ let v = fold_visible_set(m, st);
403
+ expect(v.has(K("places", "norte"))).toBe(true);
404
+ expect(v.has(K("places", "nave1"))).toBe(false);
405
+ fold_open_levels(m, st, 3);
406
+ expect(fold_visible_set(m, st).has(K("places", "nave1"))).toBe(true);
407
+ expect(fold_visible_set(m, st).has(K("devices", "d0"))).toBe(false);
408
+ fold_open_levels(m, st, 4);
409
+ v = fold_visible_set(m, st);
410
+ expect(v.has(K("devices", "d23"))).toBe(true);
411
+ expect(v.has(K("devices", "d24"))).toBe(false); /* one page */
412
+ expect(v.has(K("controllers", "c1"))).toBe(true);
413
+ });
414
+
415
+ test("opening keeps the pages already shown; closing folds only from its level down", () => {
416
+ let m = fold_build_model(descs, make_records(30));
417
+ let st = fold_new_state(24);
418
+ fold_expand_to_level(m, st, 4);
419
+ let g = fold_group_key(K("places", "nave1"), "devices");
420
+ fold_show_more(m, st, g); /* 30 */
421
+ fold_open_levels(m, st, 4);
422
+ expect(fold_group_shown(m, st, g)).toBe(30);
423
+ fold_close_levels(m, st, 3);
424
+ let v = fold_visible_set(m, st);
425
+ expect(v.has(K("places", "nave1"))).toBe(true); /* level 3 stays */
426
+ expect(v.has(K("devices", "d0"))).toBe(false); /* its hooks folded */
427
+ expect(v.has(K("controllers", "c1"))).toBe(false);
428
+ expect(fold_is_open(st, fold_group_key(K("places", "norte"), "places"))).toBe(true);
429
+ });
430
+
431
+ test("closing does not reopen what the reader folded above the level", () => {
432
+ let m = fold_build_model(descs, make_records(3));
433
+ let st = fold_new_state(24);
434
+ fold_expand_to_level(m, st, 4);
435
+ fold_toggle(m, st, fold_group_key(K("places", "es"), "places")); /* fold es */
436
+ fold_close_levels(m, st, 3);
437
+ expect(fold_is_open(st, fold_group_key(K("places", "es"), "places"))).toBe(false);
438
+ });
439
+ });
440
+
326
441
  describe("toggle and pages", () => {
327
442
  test("a folded group opens on its first page and closes again", () => {
328
443
  let m = fold_build_model(descs, make_records(30));