@yuneta/gobj-ui 7.23.69 → 7.23.71

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/src/c_yui_form.js CHANGED
@@ -65,6 +65,7 @@ import {
65
65
  import "./c_yui_form.css";
66
66
  import {attach_clear, refresh_clear} from "./yui_inputs.js";
67
67
  import {plan_toolbar, DEFAULT_TOOLBAR} from "./form_toolbar_plan.js";
68
+ import {field_is_readonly} from "./form_field_readonly.js";
68
69
  /* Read at field-build time only — deliberately NOT watched: the hosting
69
70
  * dialog rebuilds the form on every open, and re-rendering a form under
70
71
  * the user mid-edit would throw away what they typed. */
@@ -601,8 +602,13 @@ function build_form_field_conf(gobj, field_desc)
601
602
  extras: {}, // extra html input/textarea attributes
602
603
  /* A form opened to LOOK at a record has no editable field, and
603
604
  * that is a property of the opening, not of the schema: the same
604
- * col is writable when the same form is opened to edit. */
605
- readonly: gobj_read_bool_attr(gobj, "readonly") || !field_desc.is_writable,
605
+ * col is writable when the same form is opened to edit. And an
606
+ * fkey is editable WITHOUT `writable`, because a link is not
607
+ * written, it is linked -- the rule and its why live in
608
+ * form_field_readonly.js. */
609
+ readonly: field_is_readonly(
610
+ gobj_read_bool_attr(gobj, "readonly"), field_desc
611
+ ),
606
612
  required: field_desc.is_required,
607
613
  hidden: field_desc.is_hidden,
608
614
  default_value: field_desc.default_value,
@@ -506,6 +506,12 @@ function option_label(name)
506
506
  /* Layouts (`layout_names` of the G6 child). */
507
507
  case "manual":
508
508
  return t("manual");
509
+ case "treedb-tree":
510
+ return t("treedb-tree");
511
+ case "treedb-outline":
512
+ return t("treedb-outline");
513
+ case "radial":
514
+ return t("radial");
509
515
  case "dagre":
510
516
  return t("dagre");
511
517
  case "antv-dagre":
@@ -0,0 +1,61 @@
1
+ /***********************************************************************
2
+ * form_field_readonly.js
3
+ *
4
+ * Is this field of the form read-only?
5
+ *
6
+ * Pure, so the rule can be tested without a DOM: the gclass turns
7
+ * the answer into `readonly`/`disabled` on a control, this decides
8
+ * the answer. Same split as form_toolbar_plan.js and
9
+ * treedb_write_plan.js.
10
+ *
11
+ * The rule it exists to state ONCE: **`writable` governs the WRITE
12
+ * of a column's VALUE, and an fkey is not written — it is LINKED.**
13
+ * An fkey column is normally declared with no `writable` flag at
14
+ * all (`treedb_authzs`'s `users.roles` is `['fkey']`, and so is
15
+ * almost every fkey in the tree), so reading "not writable" as "not
16
+ * editable" takes away the only way to link a record to its parent
17
+ * — with `users.roles`, the only way to give a person a role.
18
+ *
19
+ * The other half of this rule is already written down in
20
+ * c_yui_treedb_topic_with_form.js, where the record travels back:
21
+ * the form sends "the writable cols, the fkeys (a link is edited by
22
+ * linking) and the pkey". The two halves have to agree, and when
23
+ * they did not, the form disabled the control whose value the save
24
+ * path was still waiting for.
25
+ *
26
+ * A `file` column is an fkey too (flagged `['fkey','file']`) and is
27
+ * NOT covered: it answers `type: "file"`, its bytes travel beside
28
+ * the record, and the SDK declares as legal a `file` column that
29
+ * only a load fills.
30
+ *
31
+ * Copyright (c) 2026, ArtGins.
32
+ * All Rights Reserved.
33
+ ***********************************************************************/
34
+
35
+
36
+ /***************************************************************
37
+ * field_is_readonly(form_readonly, field_desc) -> boolean
38
+ *
39
+ * form_readonly the FORM is read-only: a record opened to be
40
+ * looked at, or a store nobody may write. It is
41
+ * a property of the opening, not of the schema,
42
+ * and it wins over everything.
43
+ * field_desc as treedb_get_field_desc() answers it
44
+ * (`type`, `is_writable`).
45
+ ***************************************************************/
46
+ function field_is_readonly(form_readonly, field_desc)
47
+ {
48
+ if(form_readonly) {
49
+ return true;
50
+ }
51
+
52
+ let desc = field_desc || {};
53
+ if(desc.type === "fkey") {
54
+ return false;
55
+ }
56
+
57
+ return !desc.is_writable;
58
+ }
59
+
60
+
61
+ export {field_is_readonly};
@@ -0,0 +1,50 @@
1
+ import {describe, it, expect} from "vitest";
2
+ import {field_is_readonly} from "./form_field_readonly.js";
3
+
4
+ describe("field_is_readonly", () => {
5
+
6
+ it("a form opened to LOOK has no editable field, fkey included", () => {
7
+ /* A record opened to be read: the schema does not enter into it. */
8
+ expect(field_is_readonly(true, {type: "fkey", is_writable: false}))
9
+ .toBe(true);
10
+ expect(field_is_readonly(true, {type: "fkey", is_writable: true}))
11
+ .toBe(true);
12
+ expect(field_is_readonly(true, {type: "string", is_writable: true}))
13
+ .toBe(true);
14
+ });
15
+
16
+ it("an fkey is EDITABLE without `writable`: a link is edited by linking", () => {
17
+ /* `treedb_authzs`'s `users.roles` is declared `['fkey']` and
18
+ * nothing else -- as almost every fkey in the tree is. Reading
19
+ * that as "not editable" left a user with no way to be given a
20
+ * role, while the save path was still sending the column. */
21
+ expect(field_is_readonly(false, {type: "fkey", is_writable: false}))
22
+ .toBe(false);
23
+ });
24
+
25
+ it("a plain column still needs `writable`", () => {
26
+ expect(field_is_readonly(false, {type: "string", is_writable: false}))
27
+ .toBe(true);
28
+ expect(field_is_readonly(false, {type: "string", is_writable: true}))
29
+ .toBe(false);
30
+ expect(field_is_readonly(false, {type: "enum", is_writable: false}))
31
+ .toBe(true);
32
+ });
33
+
34
+ it("a `file` column is NOT covered by the fkey exemption", () => {
35
+ /* It is an fkey (`['fkey','file']`) but answers `type: "file"`,
36
+ * and the SDK declares as legal a `file` column that only a load
37
+ * fills. */
38
+ expect(field_is_readonly(false, {type: "file", is_writable: false, is_file: true}))
39
+ .toBe(true);
40
+ expect(field_is_readonly(false, {type: "file", is_writable: true, is_file: true}))
41
+ .toBe(false);
42
+ });
43
+
44
+ it("no descriptor is read-only, not editable", () => {
45
+ /* Nothing known about a field is not a reason to let it be
46
+ * written. */
47
+ expect(field_is_readonly(false, undefined)).toBe(true);
48
+ expect(field_is_readonly(false, {})).toBe(true);
49
+ });
50
+ });
@@ -0,0 +1,380 @@
1
+ /***********************************************************************
2
+ * treedb_layout.js
3
+ *
4
+ * Two layouts made for a treedb, and nothing else.
5
+ *
6
+ * What a treedb graph IS, once it is folded (treedb_fold_model.js):
7
+ * a forest read left to right. A main topic is a tree (places:
8
+ * country, region, site, hall), the other topics hang from its
9
+ * nodes through hooks (devices, users, controllers), a record may
10
+ * hang from two parents (a device from its place AND its
11
+ * controller), and every visible node was reached from a root.
12
+ * Tens to a few hundred cards on screen, never the whole store.
13
+ *
14
+ * G6's own layouts either do not know that or pay for not knowing:
15
+ *
16
+ * - `dagre` gets the reading direction right and then ranks by
17
+ * longest path and minimises crossings with a heuristic that
18
+ * REORDERS the siblings on every run -- open one hook and the
19
+ * column next to it shuffles. It is O(n·m) per sweep and it is
20
+ * the one thing that takes time on a big expansion.
21
+ * - the tree layouts (`compact-box`, `indented`, `dendrogram`,
22
+ * `mindmap`) draw exactly the shape wanted, but G6 runs them on
23
+ * a TREE it builds from the edges: a node with two parents is
24
+ * placed by whichever parent the builder met first, unstably.
25
+ * - the force family scatters a tree, which is the pile with
26
+ * physics.
27
+ *
28
+ * So: a tidy tree (`tree`) and an outline (`outline`), both fed by
29
+ * the SAME spanning tree, chosen deterministically:
30
+ *
31
+ * - roots are the nodes with no incoming edge, in node order;
32
+ * - a node belongs to the FIRST parent that reaches it in a
33
+ * breadth-first walk from the roots -- the place, not the
34
+ * controller, because the place's column came first -- and the
35
+ * other links are drawn as edges across the tree;
36
+ * - the children of a node are ordered by the HOOK they hang from
37
+ * (the parent's port order, which is the schema's column order)
38
+ * and then by node order (record order, the pages' order);
39
+ * - a node nothing reaches (a cycle) is a root too.
40
+ *
41
+ * `tree`: the classic tidy tree, left to right. Each depth is a
42
+ * column as wide as its widest card; a node is centred on the
43
+ * block of its children; siblings stack with `nodesep` between
44
+ * them. O(n), and opening a hook moves NOTHING that is not below
45
+ * or beside it -- the cards keep their order, the eye keeps its
46
+ * place.
47
+ *
48
+ * `outline`: one node per row, indented by depth -- what a JSON
49
+ * viewer draws, and what a treedb is when read as one. Tall, but
50
+ * nothing is ever beside anything: the reader scrolls a list.
51
+ *
52
+ * Pure: takes plain nodes and edges, returns positions. The G6
53
+ * classes in c_g6_nodes_tree.js are two thin adapters over it.
54
+ *
55
+ * Copyright (c) 2026, ArtGins.
56
+ * All Rights Reserved.
57
+ ***********************************************************************/
58
+
59
+ const DEFAULTS = {
60
+ nodesep: 18, /* gap between siblings (tree) / rows (outline) */
61
+ ranksep: 110, /* gap between a column and the next (tree) */
62
+ indent: 48, /* what a depth adds to the x of a row (outline) */
63
+ };
64
+
65
+ /************************************************************
66
+ * The spanning tree of a graph, deterministic.
67
+ *
68
+ * nodes [{id, w, h, order}] order: creation index
69
+ * edges [{source, target, rank}] rank: the hook's position in
70
+ * the parent's ports (0 = first)
71
+ *
72
+ * Returns {roots: [id], children: Map<id, [id]>, depth: Map<id, n>,
73
+ * by_id: Map<id, node>}
74
+ ************************************************************/
75
+ export function spanning_tree(nodes, edges)
76
+ {
77
+ let by_id = new Map();
78
+ for(let i = 0; i < nodes.length; i++) {
79
+ let n = nodes[i];
80
+ by_id.set(n.id, Object.assign({order: i}, n));
81
+ }
82
+
83
+ /* Outgoing links per node, with the rank of the hook they leave by. */
84
+ let out = new Map();
85
+ let has_parent = new Set();
86
+ for(let e of edges) {
87
+ if(!by_id.has(e.source) || !by_id.has(e.target) || e.source === e.target) {
88
+ continue;
89
+ }
90
+ if(!out.has(e.source)) {
91
+ out.set(e.source, []);
92
+ }
93
+ out.get(e.source).push({id: e.target, rank: (e.rank > 0)? e.rank : 0});
94
+ has_parent.add(e.target);
95
+ }
96
+ for(let list of out.values()) {
97
+ list.sort((a, b) => {
98
+ if(a.rank !== b.rank) {
99
+ return a.rank - b.rank;
100
+ }
101
+ return by_id.get(a.id).order - by_id.get(b.id).order;
102
+ });
103
+ }
104
+
105
+ let roots = [];
106
+ for(let n of by_id.values()) {
107
+ if(!has_parent.has(n.id)) {
108
+ roots.push(n.id);
109
+ }
110
+ }
111
+
112
+ let children = new Map();
113
+ let depth = new Map();
114
+ let assign = (start) => {
115
+ let queue = [start];
116
+ depth.set(start, 0);
117
+ while(queue.length) {
118
+ let id = queue.shift();
119
+ let kids = [];
120
+ for(let link of (out.get(id) || [])) {
121
+ if(depth.has(link.id)) {
122
+ continue; /* reached before: that parent keeps it */
123
+ }
124
+ depth.set(link.id, depth.get(id) + 1);
125
+ kids.push(link.id);
126
+ queue.push(link.id);
127
+ }
128
+ children.set(id, kids);
129
+ }
130
+ };
131
+ for(let root of roots) {
132
+ assign(root);
133
+ }
134
+ /* A cycle with no way in: its first node, in node order, is a root. */
135
+ for(let n of by_id.values()) {
136
+ if(!depth.has(n.id)) {
137
+ roots.push(n.id);
138
+ assign(n.id);
139
+ }
140
+ }
141
+
142
+ return {roots: roots, children: children, depth: depth, by_id: by_id};
143
+ }
144
+
145
+ /************************************************************
146
+ * The tidy tree, left to right.
147
+ *
148
+ * Returns Map<id, {x, y}> with the CENTRE of each node, the way G6
149
+ * positions a node.
150
+ ************************************************************/
151
+ export function layout_tree(nodes, edges, opts)
152
+ {
153
+ let o = Object.assign({}, DEFAULTS, opts || {});
154
+ let t = spanning_tree(nodes, edges);
155
+ let pos = new Map();
156
+
157
+ /* Column widths: the widest card of each depth. */
158
+ let col_w = [];
159
+ for(let [id, d] of t.depth) {
160
+ let w = t.by_id.get(id).w;
161
+ col_w[d] = Math.max(col_w[d] || 0, w);
162
+ }
163
+ let col_x = [];
164
+ let x = 0;
165
+ for(let d = 0; d < col_w.length; d++) {
166
+ col_x[d] = x;
167
+ x += (col_w[d] || 0) + o.ranksep;
168
+ }
169
+
170
+ /* Subtree heights, post-order. */
171
+ let height = new Map();
172
+ let measure = (id) => {
173
+ let kids = t.children.get(id) || [];
174
+ let own = t.by_id.get(id).h;
175
+ if(!kids.length) {
176
+ height.set(id, own);
177
+ return own;
178
+ }
179
+ let sum = 0;
180
+ for(let i = 0; i < kids.length; i++) {
181
+ sum += measure(kids[i]);
182
+ if(i > 0) {
183
+ sum += o.nodesep;
184
+ }
185
+ }
186
+ let h = Math.max(own, sum);
187
+ height.set(id, h);
188
+ return h;
189
+ };
190
+ for(let root of t.roots) {
191
+ measure(root);
192
+ }
193
+
194
+ /* Placement, pre-order: a node is centred on the block of its
195
+ * children, which starts at the top of its own block. */
196
+ let place = (id, top) => {
197
+ let n = t.by_id.get(id);
198
+ let d = t.depth.get(id);
199
+ let kids = t.children.get(id) || [];
200
+ let block = height.get(id);
201
+ let cx = col_x[d] + (col_w[d] - n.w) / 2 + n.w / 2;
202
+ if(!kids.length) {
203
+ pos.set(id, {x: cx, y: top + block / 2});
204
+ return;
205
+ }
206
+ let kids_h = 0;
207
+ for(let i = 0; i < kids.length; i++) {
208
+ kids_h += height.get(kids[i]);
209
+ if(i > 0) {
210
+ kids_h += o.nodesep;
211
+ }
212
+ }
213
+ let cursor = top + (block - kids_h) / 2;
214
+ for(let kid of kids) {
215
+ place(kid, cursor);
216
+ cursor += height.get(kid) + o.nodesep;
217
+ }
218
+ pos.set(id, {x: cx, y: top + block / 2});
219
+ };
220
+ let top = 0;
221
+ for(let root of t.roots) {
222
+ place(root, top);
223
+ top += height.get(root) + o.nodesep * 2;
224
+ }
225
+
226
+ return pos;
227
+ }
228
+
229
+ /************************************************************
230
+ * The radial tree: the root in the middle, a ring per depth.
231
+ *
232
+ * G6's `radial` places by MDS on the graph distances and pushes
233
+ * overlaps apart afterwards, with a ring radius it is TOLD; twenty
234
+ * cards of 172px on a ring of radius 200 have 52px each and pile
235
+ * up. This one is the radial form of the tidy tree above: every
236
+ * subtree gets an angular SECTOR proportional to its leaves, a node
237
+ * sits in the middle of its sector, and the radius of each ring is
238
+ * the larger of "one step further out than the ring before" and
239
+ * "long enough for its nodes to sit side by side" -- so a hall
240
+ * with a hundred devices is a wide, sparse fan, and nothing
241
+ * overlaps by construction. Several roots share the circle around
242
+ * a virtual centre.
243
+ *
244
+ * ranksep the step between rings
245
+ * nodesep the gap between neighbours on a ring
246
+ ************************************************************/
247
+ export function layout_radial(nodes, edges, opts)
248
+ {
249
+ let o = Object.assign({}, DEFAULTS, opts || {});
250
+ let t = spanning_tree(nodes, edges);
251
+ let pos = new Map();
252
+ if(!t.roots.length) {
253
+ return pos;
254
+ }
255
+
256
+ /* Leaves under each node: the sector it deserves. */
257
+ let leaves = new Map();
258
+ let count = (id) => {
259
+ let kids = t.children.get(id) || [];
260
+ if(!kids.length) {
261
+ leaves.set(id, 1);
262
+ return 1;
263
+ }
264
+ let n = 0;
265
+ for(let kid of kids) {
266
+ n += count(kid);
267
+ }
268
+ leaves.set(id, n);
269
+ return n;
270
+ };
271
+ let total = 0;
272
+ for(let root of t.roots) {
273
+ total += count(root);
274
+ }
275
+
276
+ /* One root sits at the centre; several sit on the first ring
277
+ * around an empty centre, so every depth shifts out by one. */
278
+ let single = (t.roots.length === 1);
279
+ let ring_of = (id) => t.depth.get(id) + (single? 0 : 1);
280
+
281
+ /* Pass 1, the ANGLES: a node's children split its sector by
282
+ * their leaves, and a node sits at the middle of its sector.
283
+ * Angles do not depend on the radii, so they come first. */
284
+ let angle = new Map();
285
+ let rings = []; /* ring -> [{id, angle, diag}] */
286
+ let sector = (id, from, span) => {
287
+ let a = from + span / 2;
288
+ angle.set(id, a);
289
+ let ring = ring_of(id);
290
+ if(!rings[ring]) {
291
+ rings[ring] = [];
292
+ }
293
+ let n = t.by_id.get(id);
294
+ rings[ring].push({id: id, angle: a, diag: Math.hypot(n.w, n.h)});
295
+ let cursor = from;
296
+ for(let kid of (t.children.get(id) || [])) {
297
+ let part = span * leaves.get(kid) / leaves.get(id);
298
+ sector(kid, cursor, part);
299
+ cursor += part;
300
+ }
301
+ };
302
+ if(single) {
303
+ let root = t.roots[0];
304
+ angle.set(root, 0);
305
+ let cursor = -Math.PI / 2; /* the first child at the top */
306
+ for(let kid of (t.children.get(root) || [])) {
307
+ let part = 2 * Math.PI * leaves.get(kid) / leaves.get(root);
308
+ sector(kid, cursor, part);
309
+ cursor += part;
310
+ }
311
+ } else {
312
+ let cursor = -Math.PI / 2;
313
+ for(let root of t.roots) {
314
+ let part = 2 * Math.PI * leaves.get(root) / total;
315
+ sector(root, cursor, part);
316
+ cursor += part;
317
+ }
318
+ }
319
+
320
+ /* Pass 2, the RADII: a ring is one step further out than the
321
+ * ring before, and as far out as its two closest neighbours need
322
+ * to sit side by side -- the arc between two adjacent centres is
323
+ * r·Δangle, and it has to hold half of each card plus the gap.
324
+ * Measured on NEIGHBOURS, not on the ring's total length: a
325
+ * leafless root squeezed between two big subtrees gets a sliver
326
+ * of angle, and it is the sliver that sets the radius. */
327
+ let radius = [0];
328
+ for(let ring = 1; ring < rings.length; ring++) {
329
+ let r = radius[ring - 1] + o.ranksep;
330
+ let list = (rings[ring] || []).slice().sort((a, b) => a.angle - b.angle);
331
+ for(let i = 0; i < list.length && list.length > 1; i++) {
332
+ let a = list[i];
333
+ let b = list[(i + 1) % list.length];
334
+ let gap = (i + 1 < list.length)
335
+ ? b.angle - a.angle
336
+ : b.angle + 2 * Math.PI - a.angle;
337
+ if(gap > 1e-9) {
338
+ r = Math.max(r, (a.diag / 2 + b.diag / 2 + o.nodesep) / gap);
339
+ }
340
+ }
341
+ radius[ring] = r;
342
+ }
343
+
344
+ /* Pass 3, the positions. */
345
+ for(let [id, a] of angle) {
346
+ let r = radius[ring_of(id)] || 0;
347
+ pos.set(id, {x: r * Math.cos(a), y: r * Math.sin(a)});
348
+ }
349
+
350
+ return pos;
351
+ }
352
+
353
+ /************************************************************
354
+ * The outline: one node per row, indented by depth, in the
355
+ * pre-order of the spanning tree. Rows are left-aligned on
356
+ * their indent, so a column of chips reads as a list.
357
+ ************************************************************/
358
+ export function layout_outline(nodes, edges, opts)
359
+ {
360
+ let o = Object.assign({}, DEFAULTS, opts || {});
361
+ let t = spanning_tree(nodes, edges);
362
+ let pos = new Map();
363
+
364
+ let y = 0;
365
+ let visit = (id) => {
366
+ let n = t.by_id.get(id);
367
+ let d = t.depth.get(id);
368
+ pos.set(id, {x: d * o.indent + n.w / 2, y: y + n.h / 2});
369
+ y += n.h + o.nodesep;
370
+ for(let kid of (t.children.get(id) || [])) {
371
+ visit(kid);
372
+ }
373
+ };
374
+ for(let root of t.roots) {
375
+ visit(root);
376
+ y += o.nodesep; /* a breath between two trees */
377
+ }
378
+
379
+ return pos;
380
+ }