@yuneta/gobj-ui 7.23.70 → 7.23.72

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.
@@ -6403,6 +6403,57 @@ function plan_toolbar(wanted) {
6403
6403
  };
6404
6404
  }
6405
6405
  //#endregion
6406
+ //#region src/form_field_readonly.js
6407
+ /***********************************************************************
6408
+ * form_field_readonly.js
6409
+ *
6410
+ * Is this field of the form read-only?
6411
+ *
6412
+ * Pure, so the rule can be tested without a DOM: the gclass turns
6413
+ * the answer into `readonly`/`disabled` on a control, this decides
6414
+ * the answer. Same split as form_toolbar_plan.js and
6415
+ * treedb_write_plan.js.
6416
+ *
6417
+ * The rule it exists to state ONCE: **`writable` governs the WRITE
6418
+ * of a column's VALUE, and an fkey is not written — it is LINKED.**
6419
+ * An fkey column is normally declared with no `writable` flag at
6420
+ * all (`treedb_authzs`'s `users.roles` is `['fkey']`, and so is
6421
+ * almost every fkey in the tree), so reading "not writable" as "not
6422
+ * editable" takes away the only way to link a record to its parent
6423
+ * — with `users.roles`, the only way to give a person a role.
6424
+ *
6425
+ * The other half of this rule is already written down in
6426
+ * c_yui_treedb_topic_with_form.js, where the record travels back:
6427
+ * the form sends "the writable cols, the fkeys (a link is edited by
6428
+ * linking) and the pkey". The two halves have to agree, and when
6429
+ * they did not, the form disabled the control whose value the save
6430
+ * path was still waiting for.
6431
+ *
6432
+ * A `file` column is an fkey too (flagged `['fkey','file']`) and is
6433
+ * NOT covered: it answers `type: "file"`, its bytes travel beside
6434
+ * the record, and the SDK declares as legal a `file` column that
6435
+ * only a load fills.
6436
+ *
6437
+ * Copyright (c) 2026, ArtGins.
6438
+ * All Rights Reserved.
6439
+ ***********************************************************************/
6440
+ /***************************************************************
6441
+ * field_is_readonly(form_readonly, field_desc) -> boolean
6442
+ *
6443
+ * form_readonly the FORM is read-only: a record opened to be
6444
+ * looked at, or a store nobody may write. It is
6445
+ * a property of the opening, not of the schema,
6446
+ * and it wins over everything.
6447
+ * field_desc as treedb_get_field_desc() answers it
6448
+ * (`type`, `is_writable`).
6449
+ ***************************************************************/
6450
+ function field_is_readonly(form_readonly, field_desc) {
6451
+ if (form_readonly) return true;
6452
+ let desc = field_desc || {};
6453
+ if (desc.type === "fkey") return false;
6454
+ return !desc.is_writable;
6455
+ }
6456
+ //#endregion
6406
6457
  //#region src/yui_theme.js
6407
6458
  /***********************************************************************
6408
6459
  * yui_theme.js
@@ -7215,7 +7266,7 @@ function build_form_field_conf(gobj, field_desc) {
7215
7266
  placeholder: "",
7216
7267
  options: [],
7217
7268
  extras: {},
7218
- readonly: (0, _yuneta_gobj_js.gobj_read_bool_attr)(gobj, "readonly") || !field_desc.is_writable,
7269
+ readonly: field_is_readonly((0, _yuneta_gobj_js.gobj_read_bool_attr)(gobj, "readonly"), field_desc),
7219
7270
  required: field_desc.is_required,
7220
7271
  hidden: field_desc.is_hidden,
7221
7272
  default_value: field_desc.default_value
@@ -25919,6 +25970,7 @@ function ac_paste_rows(gobj, event, kw, src) {
25919
25970
  * fkeys, times and numbers). Route by the form's mode.
25920
25971
  ************************************************************/
25921
25972
  function ac_form_save_record(gobj, event, kw, src) {
25973
+ let priv = gobj.priv;
25922
25974
  if (refuse_if_readonly$2(gobj, event)) return -1;
25923
25975
  let mode = (0, _yuneta_gobj_js.gobj_read_str_attr)(src, "form_mode");
25924
25976
  if (priv.reading_files) {
@@ -6398,6 +6398,57 @@ function plan_toolbar(wanted) {
6398
6398
  };
6399
6399
  }
6400
6400
  //#endregion
6401
+ //#region src/form_field_readonly.js
6402
+ /***********************************************************************
6403
+ * form_field_readonly.js
6404
+ *
6405
+ * Is this field of the form read-only?
6406
+ *
6407
+ * Pure, so the rule can be tested without a DOM: the gclass turns
6408
+ * the answer into `readonly`/`disabled` on a control, this decides
6409
+ * the answer. Same split as form_toolbar_plan.js and
6410
+ * treedb_write_plan.js.
6411
+ *
6412
+ * The rule it exists to state ONCE: **`writable` governs the WRITE
6413
+ * of a column's VALUE, and an fkey is not written — it is LINKED.**
6414
+ * An fkey column is normally declared with no `writable` flag at
6415
+ * all (`treedb_authzs`'s `users.roles` is `['fkey']`, and so is
6416
+ * almost every fkey in the tree), so reading "not writable" as "not
6417
+ * editable" takes away the only way to link a record to its parent
6418
+ * — with `users.roles`, the only way to give a person a role.
6419
+ *
6420
+ * The other half of this rule is already written down in
6421
+ * c_yui_treedb_topic_with_form.js, where the record travels back:
6422
+ * the form sends "the writable cols, the fkeys (a link is edited by
6423
+ * linking) and the pkey". The two halves have to agree, and when
6424
+ * they did not, the form disabled the control whose value the save
6425
+ * path was still waiting for.
6426
+ *
6427
+ * A `file` column is an fkey too (flagged `['fkey','file']`) and is
6428
+ * NOT covered: it answers `type: "file"`, its bytes travel beside
6429
+ * the record, and the SDK declares as legal a `file` column that
6430
+ * only a load fills.
6431
+ *
6432
+ * Copyright (c) 2026, ArtGins.
6433
+ * All Rights Reserved.
6434
+ ***********************************************************************/
6435
+ /***************************************************************
6436
+ * field_is_readonly(form_readonly, field_desc) -> boolean
6437
+ *
6438
+ * form_readonly the FORM is read-only: a record opened to be
6439
+ * looked at, or a store nobody may write. It is
6440
+ * a property of the opening, not of the schema,
6441
+ * and it wins over everything.
6442
+ * field_desc as treedb_get_field_desc() answers it
6443
+ * (`type`, `is_writable`).
6444
+ ***************************************************************/
6445
+ function field_is_readonly(form_readonly, field_desc) {
6446
+ if (form_readonly) return true;
6447
+ let desc = field_desc || {};
6448
+ if (desc.type === "fkey") return false;
6449
+ return !desc.is_writable;
6450
+ }
6451
+ //#endregion
6401
6452
  //#region src/yui_theme.js
6402
6453
  /***********************************************************************
6403
6454
  * yui_theme.js
@@ -7210,7 +7261,7 @@ function build_form_field_conf(gobj, field_desc) {
7210
7261
  placeholder: "",
7211
7262
  options: [],
7212
7263
  extras: {},
7213
- readonly: gobj_read_bool_attr(gobj, "readonly") || !field_desc.is_writable,
7264
+ readonly: field_is_readonly(gobj_read_bool_attr(gobj, "readonly"), field_desc),
7214
7265
  required: field_desc.is_required,
7215
7266
  hidden: field_desc.is_hidden,
7216
7267
  default_value: field_desc.default_value
@@ -25914,6 +25965,7 @@ function ac_paste_rows(gobj, event, kw, src) {
25914
25965
  * fkeys, times and numbers). Route by the form's mode.
25915
25966
  ************************************************************/
25916
25967
  function ac_form_save_record(gobj, event, kw, src) {
25968
+ let priv = gobj.priv;
25917
25969
  if (refuse_if_readonly$2(gobj, event)) return -1;
25918
25970
  let mode = gobj_read_str_attr(src, "form_mode");
25919
25971
  if (priv.reading_files) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuneta/gobj-ui",
3
- "version": "7.23.70",
3
+ "version": "7.23.72",
4
4
  "type": "module",
5
5
  "main": "dist/gobj-ui.cjs.js",
6
6
  "module": "dist/gobj-ui.es.js",
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,
@@ -3479,6 +3479,8 @@ function ac_paste_rows(gobj, event, kw, src)
3479
3479
  ************************************************************/
3480
3480
  function ac_form_save_record(gobj, event, kw, src)
3481
3481
  {
3482
+ let priv = gobj.priv;
3483
+
3482
3484
  if(refuse_if_readonly(gobj, event)) {
3483
3485
  return -1; /* Error already logged */
3484
3486
  }
@@ -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,212 @@
1
+ /***********************************************************************
2
+ * priv_declared.test.js
3
+ *
4
+ * Every function that reads `priv` must have declared it.
5
+ *
6
+ * WHY THIS IS A TEST AND NOT A REVIEW HABIT: a missing
7
+ * `let priv = gobj.priv;` is a ReferenceError that throws only
8
+ * when its LINE runs, and the line that reads `priv` is usually
9
+ * the first line of a path nobody walks every day. One shipped in
10
+ * `ac_form_save_record()` (7.23.64, the "form is busy while it
11
+ * reads the picked files" guard) and it meant that for three
12
+ * releases NO record could be saved from a treedb form dialog, in
13
+ * any consumer — the record travelled, the action threw before
14
+ * writing it, and the dialog just stayed open.
15
+ *
16
+ * The rule is the one every gclass of this repo already follows:
17
+ * the function that uses `priv` opens with `let priv = gobj.priv;`,
18
+ * or takes it as an argument, or is nested inside one that did.
19
+ *
20
+ * Copyright (c) 2026, ArtGins.
21
+ * All Rights Reserved.
22
+ ***********************************************************************/
23
+ import {describe, it, expect} from "vitest";
24
+ import {readdirSync, readFileSync} from "node:fs";
25
+ import {dirname, join} from "node:path";
26
+ import {fileURLToPath} from "node:url";
27
+
28
+ const SRC = dirname(fileURLToPath(import.meta.url));
29
+
30
+
31
+ /***************************************************************
32
+ * The source with its comments blanked out, line count kept.
33
+ *
34
+ * A doc block that SHOWS the idiom (`priv.theme_observer = ...`)
35
+ * is not a use of it, and every early version of this check
36
+ * reported four of those before the one real bug.
37
+ ***************************************************************/
38
+ function without_comments(src)
39
+ {
40
+ let out = "";
41
+ let i = 0;
42
+ let state = "code"; /* code | line | block | s | d | t */
43
+
44
+ while(i < src.length) {
45
+ const c = src[i];
46
+ const d = src[i + 1];
47
+
48
+ if(state === "code") {
49
+ if(c === "/" && d === "/") {
50
+ state = "line";
51
+ i += 2;
52
+ continue;
53
+ }
54
+ if(c === "/" && d === "*") {
55
+ state = "block";
56
+ out += " ";
57
+ i += 2;
58
+ continue;
59
+ }
60
+ if(c === "'") {
61
+ state = "s";
62
+ } else if(c === '"') {
63
+ state = "d";
64
+ } else if(c === "`") {
65
+ state = "t";
66
+ }
67
+ out += c;
68
+ i++;
69
+ continue;
70
+ }
71
+
72
+ if(state === "line") {
73
+ if(c === "\n") {
74
+ state = "code";
75
+ out += c;
76
+ }
77
+ i++;
78
+ continue;
79
+ }
80
+
81
+ if(state === "block") {
82
+ if(c === "*" && d === "/") {
83
+ state = "code";
84
+ out += " ";
85
+ i += 2;
86
+ continue;
87
+ }
88
+ out += (c === "\n") ? "\n" : " ";
89
+ i++;
90
+ continue;
91
+ }
92
+
93
+ /* Inside a string: it ends at its own quote, and a backslash
94
+ * escapes whatever follows. */
95
+ if(c === "\\") {
96
+ out += " ";
97
+ i += 2;
98
+ continue;
99
+ }
100
+ if((state === "s" && c === "'") || (state === "d" && c === '"')
101
+ || (state === "t" && c === "`")) {
102
+ state = "code";
103
+ }
104
+ out += c;
105
+ i++;
106
+ }
107
+
108
+ return out;
109
+ }
110
+
111
+
112
+ /***************************************************************
113
+ * Every top-level function of a file, as {name, line, params,
114
+ * body}. A nested function belongs to the body of the one that
115
+ * holds it, which is what lets it use the parent's `priv`.
116
+ ***************************************************************/
117
+ function top_level_functions(src)
118
+ {
119
+ const lines = src.split("\n");
120
+ const starts = [];
121
+ for(let i = 0; i < lines.length; i++) {
122
+ if(/^(?:async )?function\s+\w+/.test(lines[i])) {
123
+ starts.push(i);
124
+ }
125
+ }
126
+ starts.push(lines.length);
127
+
128
+ const out = [];
129
+ for(let k = 0; k < starts.length - 1; k++) {
130
+ const a = starts[k];
131
+ const m = /^(?:async )?function\s+(\w+)\s*\(([^)]*)/.exec(lines[a]);
132
+ if(!m) {
133
+ continue;
134
+ }
135
+ out.push({
136
+ name: m[1],
137
+ line: a + 1,
138
+ params: m[2],
139
+ body: lines.slice(a, starts[k + 1]).join("\n"),
140
+ });
141
+ }
142
+ return out;
143
+ }
144
+
145
+
146
+ describe("every function that reads `priv` declares it", () => {
147
+
148
+ it("no bare `priv.` without `priv =` in the same function", () => {
149
+ const files = readdirSync(SRC)
150
+ .filter((f) => f.endsWith(".js") && !f.endsWith(".test.js"));
151
+ expect(files.length).toBeGreaterThan(10);
152
+
153
+ const offenders = [];
154
+ for(const f of files) {
155
+ const src = without_comments(readFileSync(join(SRC, f), "utf8"));
156
+ for(const fn of top_level_functions(src)) {
157
+ if(/(?<![.\w])priv(?![\w])/.test(fn.params)) {
158
+ continue; /* it takes priv as an argument */
159
+ }
160
+ const uses = /(?<![.\w])priv\s*\./.test(fn.body);
161
+ const declared = /(?<![.\w])priv\s*=[^=]/.test(fn.body);
162
+ if(uses && !declared) {
163
+ offenders.push(`${f}:${fn.line} ${fn.name}()`);
164
+ }
165
+ }
166
+ }
167
+
168
+ expect(offenders).toEqual([]);
169
+ });
170
+
171
+ it("the checker sees a missing declaration", () => {
172
+ /* The guard needs its own guard: a check that cannot fail is
173
+ * a check nobody can trust. */
174
+ const src = [
175
+ "function ac_broken(gobj, event, kw, src)",
176
+ "{",
177
+ " if(priv.reading_files) {",
178
+ " return -1;",
179
+ " }",
180
+ " return 0;",
181
+ "}",
182
+ ].join("\n");
183
+ const fns = top_level_functions(without_comments(src));
184
+ expect(fns.length).toBe(1);
185
+ expect(/(?<![.\w])priv\s*\./.test(fns[0].body)).toBe(true);
186
+ expect(/(?<![.\w])priv\s*=[^=]/.test(fns[0].body)).toBe(false);
187
+ });
188
+
189
+ it("a doc block that shows the idiom is not a use of it", () => {
190
+ const src = [
191
+ "/* priv.theme_observer = yui_watch_theme(gobj); */",
192
+ "function yui_is_dark()",
193
+ "{",
194
+ " return yui_theme_now() === 'dark';",
195
+ "}",
196
+ ].join("\n");
197
+ const fns = top_level_functions(without_comments(src));
198
+ expect(fns.length).toBe(1);
199
+ expect(/(?<![.\w])priv\s*\./.test(fns[0].body)).toBe(false);
200
+ });
201
+
202
+ it("`gobj.priv.x` is not a bare `priv`", () => {
203
+ const src = [
204
+ "function reads_through_gobj(gobj)",
205
+ "{",
206
+ " return gobj.priv.something;",
207
+ "}",
208
+ ].join("\n");
209
+ const fns = top_level_functions(without_comments(src));
210
+ expect(/(?<![.\w])priv\s*\./.test(fns[0].body)).toBe(false);
211
+ });
212
+ });