@yuneta/gobj-ui 7.23.141 → 7.23.143

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.
@@ -111,7 +111,7 @@ SDATA(data_type_t.DTP_LIST, "operation_modes", 0,
111
111
  '["reading", "operation", "writing", "edition"]',
112
112
  "Available **permission** or behaviour modes. These operation modes are required to be accomplish by the graph handler (G6 child). TODO permissions must match treedb permissions."),
113
113
  SDATA(data_type_t.DTP_BOOLEAN, "with_treedb_tables",0, false, "Include treedb tables"),
114
- SDATA(data_type_t.DTP_INTEGER, "expand_depth", 0, 2, "Levels of the treedb open when it loads: 1 = the roots alone, 2 = the roots and their children. Forwarded to the G6 child"),
114
+ SDATA(data_type_t.DTP_INTEGER, "expand_depth", 0, 1, "Levels of the treedb open when it loads, until the reader steps (`fold_level`): 1 = the roots alone (the default), 2 = the roots and their children. Forwarded to the G6 child"),
115
115
  SDATA(data_type_t.DTP_INTEGER, "fold_page_size", 0, 24, "Children of one hook shown per page in the graph; a `+N` chip opens the next page. Forwarded to the G6 child"),
116
116
 
117
117
  /*---------------- Forwarded to the G6 child (its own defaults) ----------------*/
@@ -2699,6 +2699,11 @@ function refresh_legend(gobj)
2699
2699
  return String(a.topic).localeCompare(String(b.topic));
2700
2700
  });
2701
2701
 
2702
+ /* Only a topic hooked to ITSELF can be the main one, so only those
2703
+ * offer the star -- and only when there is more than one to pick
2704
+ * from: with a single hierarchical topic the choice is made. */
2705
+ let hierarchical_count = topics.filter((e) => e.hierarchical && !e.hidden).length;
2706
+
2702
2707
  for(let entry of topics) {
2703
2708
  let topic_name = entry.topic;
2704
2709
  let is_main = (topic_name === state.main_topic);
@@ -2813,7 +2818,7 @@ function refresh_legend(gobj)
2813
2818
  style: 'padding:0 .5rem; cursor:default;',
2814
2819
  title: t('main topic'), 'data-i18n-title': 'main topic'},
2815
2820
  [['span', {style: LEGEND_STAR_MAIN_STYLE}, '★']]]);
2816
- } else if(!hidden) {
2821
+ } else if(!hidden && entry.hierarchical && hierarchical_count > 1) {
2817
2822
  extras.push(['button', {class: 'GRAPH_LEGEND_STAR button',
2818
2823
  type: 'button', style: 'padding:0 .5rem;',
2819
2824
  title: t('main topic'), 'data-i18n-title': 'main topic',
@@ -1522,6 +1522,20 @@ function create_tabulator(gobj)
1522
1522
  * had once opened the table. Only a real change is written. */
1523
1523
  gobj.priv._page_size = tabulator_settings.paginationSize;
1524
1524
 
1525
+ /* Normalized records are read by their KEY: a topic table opens
1526
+ * sorted by `id` (the pkey), ascending and natural (`d2` before
1527
+ * `d10`) -- unless the app set its own order, or the table pages
1528
+ * from the backend, where a sort in the browser would reorder one
1529
+ * page and say nothing about the others. */
1530
+ let pkey_col = columns.find((c) => c && c.field === pkey);
1531
+ if(pkey_col && !tabulator_settings.initialSort &&
1532
+ !gobj_read_bool_attr(gobj, "with_remote_paging")) {
1533
+ if(!pkey_col.sorter) {
1534
+ pkey_col.sorter = "alphanum";
1535
+ }
1536
+ tabulator_settings.initialSort = [{column: pkey, dir: "asc"}];
1537
+ }
1538
+
1525
1539
  Object.assign(tabulator_settings, {
1526
1540
  index: pkey,
1527
1541
  columns: columns,
@@ -38,7 +38,8 @@ import {
38
38
  * the fkey, `order`) is composed, and `_geometry` belongs to whoever
39
39
  * laid the graph out — an import must not move their boxes. */
40
40
  const TOPIC_FIELDS = [
41
- "pkey", "pkey2s", "system_flag", "tkey", "topic_version", "system_topic"
41
+ "pkey", "pkey2s", "system_flag", "tkey", "topic_version", "system_topic",
42
+ "main_topic"
42
43
  ];
43
44
 
44
45
  /* Same for a `cols` record. */
@@ -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
  }
@@ -355,34 +361,60 @@ function count_levels(model)
355
361
  }
356
362
 
357
363
  /************************************************************
358
- * The topic the schema hangs the others from: the one whose hooks
359
- * reach the most OTHER topics, a self-referent hook breaking a tie
360
- * (a tree of places over a flat list of groups), schema order
361
- * breaking the rest. "" when no topic reaches another -- a treedb of
362
- * unrelated lists has no trunk, and every topic is a tree of its own.
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.)
363
396
  ************************************************************/
364
397
  export function fold_main_topic(descs)
365
398
  {
366
399
  descs = is_object(descs)? descs : {};
367
- let best = "";
368
- let best_score = 0;
369
- 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
+ });
370
403
 
371
- for(let topic_name of Object.keys(descs)) {
372
- if(topic_name.substring(0, 2) === "__") {
373
- continue;
404
+ for(let topic_name of candidates) {
405
+ if(descs[topic_name].main_topic === true) {
406
+ return topic_name;
374
407
  }
408
+ }
409
+
410
+ let best = "";
411
+ let best_score = -1;
412
+ for(let topic_name of candidates) {
375
413
  let reach = hook_targets(descs, topic_name);
376
- let self = reach.has(topic_name);
377
414
  reach.delete(topic_name);
378
- let score = reach.size;
379
- if(score === 0) {
380
- continue;
381
- }
382
- if(score > best_score || (score === best_score && self && !best_self)) {
415
+ if(reach.size > best_score) {
383
416
  best = topic_name;
384
- best_score = score;
385
- best_self = self;
417
+ best_score = reach.size;
386
418
  }
387
419
  }
388
420
  return best;
@@ -31,11 +31,12 @@ import {
31
31
  fold_open_levels,
32
32
  fold_close_levels,
33
33
  fold_expand_to_level,
34
+ fold_is_hierarchical,
34
35
  } from "./treedb_fold_model.js";
35
36
 
36
37
  const places_desc = {
37
38
  topic_name: "places",
38
- node_treedb_type: "hierarchical",
39
+ node_treedb_type: "entity",
39
40
  cols: [
40
41
  {id: "id", flag: []},
41
42
  {id: "place", flag: ["fkey"], fkey: {places: "places"}},
@@ -56,13 +57,20 @@ const devices_desc = {
56
57
  };
57
58
  const controllers_desc = {
58
59
  topic_name: "controllers",
59
- node_treedb_type: "hierarchical",
60
+ node_treedb_type: "entity",
60
61
  cols: [
61
62
  {id: "id", flag: []},
62
63
  {id: "place", flag: ["fkey"], fkey: {places: "controllers"}},
63
64
  {id: "devices", flag: ["hook"], hook: {devices: "controller"}},
64
65
  ],
65
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
+ });
66
74
  const groups_desc = {
67
75
  topic_name: "device_groups",
68
76
  node_treedb_type: "extended",
@@ -189,10 +197,33 @@ describe("main topic, hidden topics, loose records", () => {
189
197
  expect(m.linked.has("device_groups")).toBe(false);
190
198
  });
191
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
+
192
223
  test("a self-referent hook breaks a tie", () => {
193
224
  let a = {topic_name: "a", node_treedb_type: "extended",
194
225
  cols: [{id: "id", flag: []}, {id: "xs", flag: ["hook"], hook: {x: "a"}}]};
195
- let b = {topic_name: "b", node_treedb_type: "hierarchical",
226
+ let b = {topic_name: "b", node_treedb_type: "entity",
196
227
  cols: [{id: "id", flag: []}, {id: "xs", flag: ["hook"], hook: {x: "b"}},
197
228
  {id: "bs", flag: ["hook"], hook: {b: "parent"}},
198
229
  {id: "parent", flag: ["fkey"], fkey: {b: "bs"}}]};
@@ -201,12 +232,16 @@ describe("main topic, hidden topics, loose records", () => {
201
232
  expect(fold_main_topic({x: x})).toBe("");
202
233
  });
203
234
 
204
- test("an explicit main topic wins over the deduced one", () => {
205
- 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"});
206
238
  expect(m.main_topic).toBe("controllers");
207
239
  expect([...m.linked].sort()).toEqual(["controllers", "devices"]);
208
240
  /* places is not linked to controllers, so its roots are roots */
209
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");
210
245
  });
211
246
 
212
247
  test("a device with no place is LOOSE: counted, not a root, unless asked for", () => {
@@ -0,0 +1,109 @@
1
+ /***********************************************************************
2
+ * yui_json_pad.js
3
+ *
4
+ * JSON viewer -- a blank C_YUI_JSON_PAD in a floating window,
5
+ * to paste JSON from outside and read it with the library's own
6
+ * viewer. Peer of the frontend view (yui_frontend_view.js), and
7
+ * opened the same way: an entry of the account menu.
8
+ *
9
+ * Copyright (c) 2026, ArtGins.
10
+ * All Rights Reserved.
11
+ ***********************************************************************/
12
+ import {
13
+ createElement2,
14
+ gclass_find_by_name,
15
+ gobj_create_service,
16
+ gobj_create_pure_child,
17
+ gobj_find_service,
18
+ gobj_read_attr,
19
+ gobj_start_tree,
20
+ log_error,
21
+ } from "@yuneta/gobj-js";
22
+
23
+ import {register_c_yui_json_pad} from "./c_yui_json_pad.js";
24
+
25
+ /* Service name of the window. The host toggles the entry by looking
26
+ * it up, exactly as it does with "Frontend-View-Window". */
27
+ const WIN_NAME = "Json-Viewer-Window";
28
+
29
+
30
+ /******************************
31
+ * Public API
32
+ ******************************/
33
+
34
+
35
+ /************************************************************
36
+ * Open a blank JSON pad (C_YUI_JSON_PAD) inside a non-modal
37
+ * C_YUI_WINDOW, the same shape as the frontend view.
38
+ *
39
+ * Self-contained: it registers C_YUI_JSON_PAD (and the
40
+ * C_YUI_JSON it hosts) if the app did not, so an app gets the
41
+ * viewer with one menu entry and one action.
42
+ *
43
+ * Returns the window gobj, or null.
44
+ ************************************************************/
45
+ function setup_json_pad(self)
46
+ {
47
+ if(gobj_find_service(WIN_NAME, false)) {
48
+ return null;
49
+ }
50
+ if(!gclass_find_by_name("C_YUI_JSON_PAD", false)) {
51
+ register_c_yui_json_pad();
52
+ }
53
+
54
+ /* The pad is created AFTER the window, as a pure child of it, so
55
+ * every teardown path (the ✕, or the host destroying the window to
56
+ * toggle the entry off) takes the pad down with it -- which is why
57
+ * the body is a placeholder: C_YUI_WINDOW builds its UI in
58
+ * mt_create, before the pad exists. */
59
+ let $body = createElement2(
60
+ ["div", {class: "YJSONPAD_BODY",
61
+ style: "height:100%; display:flex; flex-direction:column;"}, []]
62
+ );
63
+
64
+ let win = gobj_create_service(
65
+ WIN_NAME,
66
+ "C_YUI_WINDOW",
67
+ {
68
+ $parent: (typeof document !== "undefined" &&
69
+ document.getElementById("top-layer")) || null,
70
+ subscriber: null,
71
+ modal: false,
72
+ showMax: true,
73
+ showFooter: false,
74
+ resizable: true,
75
+ center: false,
76
+ auto_save_size_and_position: true,
77
+ width: 900,
78
+ height: 640,
79
+ logical_class: "JSON_VIEWER_WINDOW",
80
+ title: "json viewer",
81
+ icon: "yi-code",
82
+ body: $body,
83
+ /* `|| null`: an undefined attr value logs "attr undefined:
84
+ * manager" in an app without a window manager. */
85
+ manager: gobj_find_service("__window_manager__", false) || null,
86
+ },
87
+ self
88
+ );
89
+ if(!win) {
90
+ log_error("yui_json_pad: cannot create the json-viewer window");
91
+ return null;
92
+ }
93
+
94
+ /* The window is mounted by its mt_create, so $body is live DOM here:
95
+ * the viewer's graph measures its canvas when it is first shown. */
96
+ let pad = gobj_create_pure_child("json_pad", "C_YUI_JSON_PAD", {}, win);
97
+ let $pad = pad? gobj_read_attr(pad, "$container") : null;
98
+ if(!$pad) {
99
+ log_error("yui_json_pad: C_YUI_JSON_PAD without $container");
100
+ return win;
101
+ }
102
+ $body.appendChild($pad);
103
+
104
+ gobj_start_tree(win);
105
+
106
+ return win;
107
+ }
108
+
109
+ export {setup_json_pad};