@yuneta/gobj-ui 7.23.142 → 7.23.144

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.
@@ -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,
@@ -5,7 +5,8 @@
5
5
  * viewer). Kept out of the gclass so it can be unit-tested with
6
6
  * no DOM: collapsed-sentinel detection, path (segments) algebra,
7
7
  * search matching, timestamp recognition/formatting, the JSON
8
- * type discriminator and the text view's capped dump.
8
+ * type discriminator and the text view's capped dump -- and the
9
+ * differences of two documents, for the JSON pad's compare.
9
10
  *
10
11
  * Path convention mirrors the C kernel (kw_collapse / kw_find_path
11
12
  * in kwid.c): segments are joined by the backtick delimiter, arrays
@@ -14,6 +15,7 @@
14
15
  * Copyright (c) 2026, ArtGins.
15
16
  * All Rights Reserved.
16
17
  ***********************************************************************/
18
+ import {json2flat, flat_diff} from "@yuneta/gobj-js";
17
19
 
18
20
  /*
19
21
  * Path delimiter, identical to the kernel's `delimiter` in kwid.c.
@@ -397,3 +399,56 @@ export function count_branches(value)
397
399
  }
398
400
  return n;
399
401
  }
402
+
403
+ /************************************************************
404
+ * What differs between two documents: one row per id of
405
+ * their flat form (json2flat), sorted by id in natural order,
406
+ * so `[2]` comes before `[10]`:
407
+ *
408
+ * [{id, kind: "added"|"removed"|"changed", from?, to?}]
409
+ *
410
+ * `from` is the first document's value and `to` the second's:
411
+ * an added row has no `from`, a removed row no `to`.
412
+ *
413
+ * Two roots that are not containers of the same kind have no
414
+ * ids in common to compare by: they are one value each, and
415
+ * the row that says they differ has the empty id.
416
+ *
417
+ * Throws what json2flat throws (a document too deep).
418
+ ************************************************************/
419
+ export function json_diff_rows(doc_a, doc_b)
420
+ {
421
+ const kind_of = (v) => {
422
+ if(Array.isArray(v)) {
423
+ return "array";
424
+ }
425
+ if(v !== null && typeof v === "object") {
426
+ return "object";
427
+ }
428
+ return "scalar";
429
+ };
430
+
431
+ let kind_a = kind_of(doc_a);
432
+ if(kind_a === "scalar" || kind_a !== kind_of(doc_b)) {
433
+ if(JSON.stringify(doc_a) === JSON.stringify(doc_b)) {
434
+ return [];
435
+ }
436
+ return [{id: "", kind: "changed", from: doc_a, to: doc_b}];
437
+ }
438
+
439
+ let diff = flat_diff(json2flat(doc_a), json2flat(doc_b));
440
+ let rows = [];
441
+ for(const id of Object.keys(diff.added)) {
442
+ rows.push({id: id, kind: "added", to: diff.added[id]});
443
+ }
444
+ for(const id of Object.keys(diff.removed)) {
445
+ rows.push({id: id, kind: "removed", from: diff.removed[id]});
446
+ }
447
+ for(const id of Object.keys(diff.changed)) {
448
+ rows.push({id: id, kind: "changed", from: diff.changed[id].from, to: diff.changed[id].to});
449
+ }
450
+
451
+ const collator = new Intl.Collator("en", {numeric: true});
452
+ rows.sort((x, y) => collator.compare(x.id, y.id));
453
+ return rows;
454
+ }
@@ -21,9 +21,52 @@ import {
21
21
  is_pure_collection,
22
22
  has_branch,
23
23
  count_branches,
24
+ json_diff_rows,
24
25
  } from "./json_view_helpers.js";
25
26
 
26
27
 
28
+ /*============================================================
29
+ * json_diff_rows
30
+ *============================================================*/
31
+ test("json_diff_rows: two equal documents have no rows, whatever their key order", () => {
32
+ expect(json_diff_rows({a: 1, b: [1, {c: null}]}, {b: [1, {c: null}], a: 1})).toEqual([]);
33
+ });
34
+
35
+ test("json_diff_rows: one row per differing id, sorted by id", () => {
36
+ let a = {a: 1, b: {c: [1, 2]}, e: "x"};
37
+ let b = {a: 2, b: {c: [1]}, d: {}, e: "x"};
38
+ expect(json_diff_rows(a, b)).toEqual([
39
+ {id: "a", kind: "changed", from: 1, to: 2},
40
+ {id: "b`c`[1]", kind: "removed", from: 2},
41
+ {id: "d", kind: "added", to: {}},
42
+ ]);
43
+ });
44
+
45
+ test("json_diff_rows: array indexes sort as numbers, [2] before [10]", () => {
46
+ let a = [];
47
+ let b = [];
48
+ for(let i = 0; i < 12; i++) {
49
+ a.push(i);
50
+ b.push((i === 2 || i === 10)? -1 : i);
51
+ }
52
+ expect(json_diff_rows(a, b).map((r) => r.id)).toEqual(["[2]", "[10]"]);
53
+ });
54
+
55
+ test("json_diff_rows: a leaf that becomes a container is one id gone and one new", () => {
56
+ expect(json_diff_rows({a: 1}, {a: {b: 2}})).toEqual([
57
+ {id: "a", kind: "removed", from: 1},
58
+ {id: "a`b", kind: "added", to: 2},
59
+ ]);
60
+ });
61
+
62
+ test("json_diff_rows: roots that are not containers of one kind are one value each", () => {
63
+ expect(json_diff_rows(1, 1)).toEqual([]);
64
+ expect(json_diff_rows("x", "y")).toEqual([{id: "", kind: "changed", from: "x", to: "y"}]);
65
+ expect(json_diff_rows({}, [])).toEqual([{id: "", kind: "changed", from: {}, to: []}]);
66
+ expect(json_diff_rows(null, {a: 1})).toEqual([{id: "", kind: "changed", from: null, to: {a: 1}}]);
67
+ });
68
+
69
+
27
70
  /*============================================================
28
71
  * json_type
29
72
  *============================================================*/
package/src/yui_icons.css CHANGED
@@ -392,3 +392,15 @@
392
392
  -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 448 512'%3E%3Cpath fill-rule='evenodd' d='M190 32h68l150 448h-72l-40-120H152l-40 120H40L190 32zM172 300h104l-52-158-52 158z'/%3E%3C/svg%3E");
393
393
  mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 448 512'%3E%3Cpath fill-rule='evenodd' d='M190 32h68l150 448h-72l-40-120H152l-40 120H40L190 32zM172 300h104l-52-158-52 158z'/%3E%3C/svg%3E");
394
394
  }
395
+
396
+ /* Two columns, for "a second pane beside the first" (the JSON pad). */
397
+ .yi-columns::before {
398
+ -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Cpath fill-rule='evenodd' d='M0 96C0 60.7 28.7 32 64 32h384c35.3 0 64 28.7 64 64v320c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V96zm64 32v288h160V128H64zm224 0v288h160V128H288z'/%3E%3C/svg%3E");
399
+ mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Cpath fill-rule='evenodd' d='M0 96C0 60.7 28.7 32 64 32h384c35.3 0 64 28.7 64 64v320c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V96zm64 32v288h160V128H64zm224 0v288h160V128H288z'/%3E%3C/svg%3E");
400
+ }
401
+
402
+ /* Two opposite arrows, for "compare" (the JSON pad's differences). */
403
+ .yi-right-left::before {
404
+ -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Cpath d='M352 64L480 160 352 256V192H64V128H352ZM160 256L32 352 160 448V384H448V320H160Z'/%3E%3C/svg%3E");
405
+ mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Cpath d='M352 64L480 160 352 256V192H64V128H352ZM160 256L32 352 160 448V384H448V320H160Z'/%3E%3C/svg%3E");
406
+ }
@@ -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};