@yuneta/gobj-ui 5.14.2 → 5.16.0

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.
@@ -76,6 +76,8 @@ import {yui_tabulator_lang, yui_tabulator_relocalize} from "./yui_tabulator_i18n
76
76
 
77
77
  import {t} from "i18next";
78
78
 
79
+ import {plan_treedb_writes} from "./treedb_write_plan.js";
80
+
79
81
  import "./c_yui_treedb_topic_with_form.css";
80
82
  import "./tabulator.css";
81
83
 
@@ -97,6 +99,7 @@ SDATA(data_type_t.DTP_STRING, "topic_name", 0, null, "Topic name"
97
99
  SDATA(data_type_t.DTP_POINTER, "desc", 0, null, "Description of topics"),
98
100
 
99
101
  /*---------------- Edition Mode ----------------*/
102
+ SDATA(data_type_t.DTP_BOOLEAN, "readonly", 0, false, "The topic cannot be written: no edition mode, no new/delete/paste, no in-row icons, and the record form opens without its save toolbar. Set it when the treedb is not the master of its tranger (only the master can write; the yuno refuses otherwise), or when the user lacks the authz"),
100
103
  SDATA(data_type_t.DTP_BOOLEAN, "with_edition_mode", 0, true, "Enable EDITION mode showing EDIT Button toolbar"),
101
104
  SDATA(data_type_t.DTP_BOOLEAN, "with_new_button", 0, true, "Button toolbar NEW"),
102
105
  SDATA(data_type_t.DTP_BOOLEAN, "with_delete_button", 0, true, "Button toolbar DELETE"),
@@ -343,11 +346,16 @@ function build_ui(gobj)
343
346
  // TODO pon autorización, solo si está autorizado a modificar los datos!!!
344
347
  // TODO deja que estos botones se queden en el top cuando se hace scroll (clip ?)
345
348
  let $table_toolbar = [];
346
- let with_edition_mode = gobj_read_bool_attr(gobj, "with_edition_mode");
347
- let with_new_button = gobj_read_bool_attr(gobj, "with_new_button");
348
- let with_delete_button = gobj_read_bool_attr(gobj, "with_delete_button");
349
+ /* One plan decides every write affordance (treedb_write_plan.js):
350
+ * `readonly` is the STATE of the topic and beats each with_* flag,
351
+ * and stating that in five `!readonly &&` expressions is five places
352
+ * to forget the sixth. */
353
+ let plan = write_plan(gobj);
354
+ let with_edition_mode = plan.edition_mode;
355
+ let with_new_button = plan.new_button;
356
+ let with_delete_button = plan.delete_button;
349
357
  let with_copy_button = gobj_read_bool_attr(gobj, "with_copy_button");
350
- let with_paste_button = gobj_read_bool_attr(gobj, "with_paste_button");
358
+ let with_paste_button = plan.paste_button;
351
359
 
352
360
  let toolbar_id = gobj_read_str_attr(gobj, "toolbar_id");
353
361
 
@@ -884,7 +892,7 @@ function create_tabulator(gobj)
884
892
  ].join('');
885
893
  }
886
894
 
887
- let with_in_row_edit_icons = gobj_read_bool_attr(gobj, "with_in_row_edit_icons");
895
+ let with_in_row_edit_icons = write_plan(gobj).in_row_icons;
888
896
  if(with_in_row_edit_icons) {
889
897
  columns.push({
890
898
  field: '_operation',
@@ -1306,6 +1314,7 @@ function open_form_dialog(gobj, mode, record)
1306
1314
  style: 'display:flex; flex-direction:column; height:100%;'}, []]
1307
1315
  );
1308
1316
 
1317
+ let form_plan = write_plan(gobj);
1309
1318
  let form = gobj_create_pure_child(
1310
1319
  "form_" + clean_name(gobj_name(gobj)),
1311
1320
  "C_YUI_FORM",
@@ -1314,13 +1323,18 @@ function open_form_dialog(gobj, mode, record)
1314
1323
  record: record,
1315
1324
  fkey_options: build_fkey_options(gobj),
1316
1325
  form_mode: mode,
1326
+ /* A read-only topic still OPENS its form -- looking at a record
1327
+ * is the point of a replica -- with the cells not editable and
1328
+ * the write half of the toolbar gone. A null plan leaves the
1329
+ * form's own default alone. */
1330
+ ...(form_plan.form_toolbar ? {toolbar: form_plan.form_toolbar} : {}),
1317
1331
  /* Editing the raw topic record: structured cols
1318
1332
  * (template / table / coordinates) are raw JSON editors,
1319
1333
  * not interpreted into sub-widgets (the pre-merge behaviour). */
1320
1334
  render_mode: "edit",
1321
1335
  pkey: desc.pkey || "id",
1322
1336
  topic_name: topic_name,
1323
- editable: true,
1337
+ editable: !form_plan.readonly,
1324
1338
  $parent: $body
1325
1339
  },
1326
1340
  gobj
@@ -2066,6 +2080,42 @@ function show_dropdown_popup_menu(gobj, x, y, items, callback)
2066
2080
 
2067
2081
 
2068
2082
 
2083
+ /***************************************************************
2084
+ * A read-only topic refuses the write EVENTS too, not only their
2085
+ * buttons. The buttons are gone, but an event can still arrive: a
2086
+ * keyboard path, a hosted form that outlived the flag, a caller that
2087
+ * sends EV_SAVE_RECORD itself. Refusing here is what makes `readonly`
2088
+ * a state of the gclass rather than a way of drawing it -- and it
2089
+ * fails LOUDLY, because an ignored write is how the backend used to
2090
+ * behave and what this whole change exists to stop.
2091
+ ***************************************************************/
2092
+ function write_plan(gobj)
2093
+ {
2094
+ return plan_treedb_writes({
2095
+ readonly: gobj_read_bool_attr(gobj, "readonly"),
2096
+ with_edition_mode: gobj_read_bool_attr(gobj, "with_edition_mode"),
2097
+ with_new_button: gobj_read_bool_attr(gobj, "with_new_button"),
2098
+ with_delete_button: gobj_read_bool_attr(gobj, "with_delete_button"),
2099
+ with_paste_button: gobj_read_bool_attr(gobj, "with_paste_button"),
2100
+ with_in_row_edit_icons: gobj_read_bool_attr(gobj, "with_in_row_edit_icons")
2101
+ });
2102
+ }
2103
+
2104
+ /***************************************************************
2105
+ * A read-only topic refuses the write EVENTS too, not only their
2106
+ * buttons.
2107
+ ***************************************************************/
2108
+ function refuse_if_readonly(gobj, event)
2109
+ {
2110
+ if(!gobj_read_bool_attr(gobj, "readonly")) {
2111
+ return false;
2112
+ }
2113
+ log_error(`${gobj_short_name(gobj)}: ${event} refused, topic ` +
2114
+ `'${gobj_read_str_attr(gobj, "topic_name")}' is READ-ONLY`);
2115
+ return true;
2116
+ }
2117
+
2118
+
2069
2119
  /***************************
2070
2120
  * Actions
2071
2121
  ***************************/
@@ -2192,7 +2242,22 @@ function ac_load_node_updated(gobj, event, kw, src)
2192
2242
  let tabulator = gobj_read_attr(gobj, "tabulator");
2193
2243
  if(tabulator) {
2194
2244
  for(let record of data) {
2195
- tabulator.updateData([record]);
2245
+ /*
2246
+ * updateData() REJECTS the promise on a row it cannot find and
2247
+ * nobody awaits it, so the rejection surfaces as an unhandled
2248
+ * "Update Error - Unable to find row" that names neither the
2249
+ * gclass nor the topic. Ask first, like ac_node_deleted does.
2250
+ *
2251
+ * A table nobody has opened holds NO rows, and a node event for
2252
+ * it is not news: its rows are read when the topic is shown. A
2253
+ * row missing from a table that IS loaded is.
2254
+ */
2255
+ if(tabulator.getRow(record.id)) {
2256
+ tabulator.updateData([record]);
2257
+ } else if(tabulator.getDataCount() > 0) {
2258
+ log_error(`${gobj_short_name(gobj)}: updated node is not in ` +
2259
+ `the loaded table: ${record.id}`);
2260
+ }
2196
2261
  }
2197
2262
  }
2198
2263
 
@@ -2242,6 +2307,9 @@ function ac_node_deleted(gobj, event, kw, src)
2242
2307
  ************************************************************/
2243
2308
  function ac_edition_mode(gobj, event, kw, src)
2244
2309
  {
2310
+ if(refuse_if_readonly(gobj, event)) {
2311
+ return -1; /* Error already logged */
2312
+ }
2245
2313
  let tabulator = gobj_read_attr(gobj, "tabulator");
2246
2314
 
2247
2315
  /*
@@ -2307,6 +2375,9 @@ function ac_edition_mode(gobj, event, kw, src)
2307
2375
  ************************************************************/
2308
2376
  function ac_new_row(gobj, event, kw, src)
2309
2377
  {
2378
+ if(refuse_if_readonly(gobj, event)) {
2379
+ return -1; /* Error already logged */
2380
+ }
2310
2381
  /*
2311
2382
  * Build default values. TODO no debería estar en desc configuration?
2312
2383
  */
@@ -2352,6 +2423,9 @@ function ac_new_row(gobj, event, kw, src)
2352
2423
  ************************************************************/
2353
2424
  function ac_delete_rows(gobj, event, kw, src)
2354
2425
  {
2426
+ if(refuse_if_readonly(gobj, event)) {
2427
+ return -1; /* Error already logged */
2428
+ }
2355
2429
  let tabulator = gobj_read_attr(gobj, "tabulator");
2356
2430
 
2357
2431
  if(json_size(kw) === 0) {
@@ -2451,6 +2525,9 @@ function ac_copy_rows(gobj, event, kw, src)
2451
2525
  ************************************************************/
2452
2526
  function ac_paste_rows(gobj, event, kw, src)
2453
2527
  {
2528
+ if(refuse_if_readonly(gobj, event)) {
2529
+ return -1; /* Error already logged */
2530
+ }
2454
2531
  /*----------------------------*
2455
2532
  * Paste rows
2456
2533
  *----------------------------*/
@@ -2478,6 +2555,9 @@ function ac_paste_rows(gobj, event, kw, src)
2478
2555
  ************************************************************/
2479
2556
  function ac_form_save_record(gobj, event, kw, src)
2480
2557
  {
2558
+ if(refuse_if_readonly(gobj, event)) {
2559
+ return -1; /* Error already logged */
2560
+ }
2481
2561
  let mode = gobj_read_str_attr(src, "form_mode");
2482
2562
  gobj_publish_event(
2483
2563
  gobj,
@@ -77,6 +77,7 @@ SDATA(data_type_t.DTP_JSON, "descs", 0, null, "Description of
77
77
  SDATA(data_type_t.DTP_BOOLEAN, "system", 0, false, "Manage system topics (true) or user topics (false)"),
78
78
  SDATA(data_type_t.DTP_STRING, "tabs_style", 0, "is-toggle is-fullwidth", "Bulma tab styling"),
79
79
  SDATA(data_type_t.DTP_BOOLEAN, "with_cards_landing",0, false, "Land on a grid of topic cards (list->detail): a card opens its table, with the tabs bar + a back-to-grid button. Off = tabs only (legacy)."),
80
+ SDATA(data_type_t.DTP_BOOLEAN, "readonly", 0, false, "The whole treedb is read-only: every topic opens without its write affordances and the write events are refused. Set it when this yuno is not the MASTER of the treedb's tranger -- only the master can write, and the yuno answers 'READ-ONLY' to a write since SDK 7.13.0 (ask `treedb-info`)"),
80
81
  SDATA(data_type_t.DTP_JSON, "card_action_routes",0, null, "Per-card hash-route templates {info, table, graph} with a {topic} placeholder (host-supplied, route-agnostic). Present ⇒ cards show 3 icon actions; absent ⇒ a single card that opens the table."),
81
82
  SDATA(data_type_t.DTP_JSON, "landing_routes", 0, null, "Host-supplied hashes for the two landing sub-views {cards, schema}; the toggle navigates to them so the landing is URL-addressable (ROUTING.md). Absent ⇒ toggle flips in-view only (legacy)."),
82
83
  SDATA(data_type_t.DTP_STRING, "base_route", 0, "", "This view's base route (host-supplied); used to declare its sub-routes (topics / info / schema) to the site map (ROUTING.md contributor)."),
@@ -997,6 +998,7 @@ function process_treedb_descs(gobj)
997
998
  let descs = gobj_read_attr(gobj, "descs");
998
999
  let system = gobj_read_bool_attr(gobj, "system");
999
1000
  let treedb_name = gobj_read_str_attr(gobj, "treedb_name");
1001
+ let readonly = gobj_read_bool_attr(gobj, "readonly");
1000
1002
  for(const [key, desc] of Object.entries(descs)) {
1001
1003
  if(system) {
1002
1004
  } else {
@@ -1008,10 +1010,13 @@ function process_treedb_descs(gobj)
1008
1010
  let kw_topic_form = {
1009
1011
  subscriber: gobj, // HACK get all output events
1010
1012
 
1011
- // TODO set according the authz
1012
- //with_edit_button: true,
1013
- with_new_button: true,
1014
- with_delete_button: true,
1013
+ /* One flag, not three: a read-only treedb has no writable
1014
+ * topic. (The old TODO here asked for the authz to decide the
1015
+ * buttons; the authz is a per-user question the backend answers
1016
+ * per command, while this is a property of the STORE.) */
1017
+ readonly: readonly,
1018
+ with_new_button: !readonly,
1019
+ with_delete_button: !readonly,
1015
1020
 
1016
1021
  treedb_name: treedb_name,
1017
1022
  topic_name: key,
@@ -1619,6 +1624,25 @@ function request_print_tranger(gobj, path)
1619
1624
 
1620
1625
 
1621
1626
 
1627
+ /***************************************************************
1628
+ * The last gate before a write leaves for the yuno. The per-topic
1629
+ * children already hide their write buttons when the treedb is
1630
+ * read-only, but the record events converge HERE, and this is the
1631
+ * gclass that owns the treedb: a write that got this far on a replica
1632
+ * would travel, be refused by the yuno, and come back as a toast --
1633
+ * after the table had already drawn the row.
1634
+ ***************************************************************/
1635
+ function refuse_if_readonly(gobj, event)
1636
+ {
1637
+ if(!gobj_read_bool_attr(gobj, "readonly")) {
1638
+ return false;
1639
+ }
1640
+ log_error(`${gobj_short_name(gobj)}: ${event} refused, treedb ` +
1641
+ `'${gobj_read_str_attr(gobj, "treedb_name")}' is READ-ONLY`);
1642
+ return true;
1643
+ }
1644
+
1645
+
1622
1646
  /***************************
1623
1647
  * Actions
1624
1648
  ***************************/
@@ -1731,7 +1755,8 @@ function ac_mt_command_answer(gobj, event, kw, src)
1731
1755
  treedb_name: kw_get_str(gobj, kw_command, "treedb_name", "", 0),
1732
1756
  topic_name: kw_get_str(gobj, kw_command, "topic_name", "", 0),
1733
1757
  record: kw_get_dict(gobj, kw_command, "record", {}, 0),
1734
- created: (command === "create-node")
1758
+ created: (command === "create-node"),
1759
+ command: command
1735
1760
  });
1736
1761
  break;
1737
1762
 
@@ -2051,6 +2076,9 @@ function ac_treedb_node_deleted(gobj, event, kw, src)
2051
2076
  ********************************************/
2052
2077
  function ac_create_record(gobj, event, kw, src)
2053
2078
  {
2079
+ if(refuse_if_readonly(gobj, event)) {
2080
+ return -1; /* Error already logged */
2081
+ }
2054
2082
  let treedb_name = gobj_read_str_attr(gobj, "treedb_name");
2055
2083
  let topic_name = gobj_read_attr(src, "topic_name");
2056
2084
  let record = kw.record;
@@ -2079,6 +2107,9 @@ function ac_create_record(gobj, event, kw, src)
2079
2107
  ********************************************/
2080
2108
  function ac_update_record(gobj, event, kw, src)
2081
2109
  {
2110
+ if(refuse_if_readonly(gobj, event)) {
2111
+ return -1; /* Error already logged */
2112
+ }
2082
2113
  let treedb_name = gobj_read_str_attr(gobj, "treedb_name");
2083
2114
  let topic_name = gobj_read_attr(src, "topic_name");
2084
2115
  let record = kw.record;
@@ -2106,6 +2137,9 @@ function ac_update_record(gobj, event, kw, src)
2106
2137
  ********************************************/
2107
2138
  function ac_delete_record(gobj, event, kw, src)
2108
2139
  {
2140
+ if(refuse_if_readonly(gobj, event)) {
2141
+ return -1; /* Error already logged */
2142
+ }
2109
2143
  let treedb_name = gobj_read_str_attr(gobj, "treedb_name");
2110
2144
  let topic_name = gobj_read_attr(src, "topic_name");
2111
2145
  let record = kw.record;
@@ -0,0 +1,73 @@
1
+ /***********************************************************************
2
+ * treedb_write_plan.js
3
+ *
4
+ * Which write affordances a treedb topic offers.
5
+ *
6
+ * Pure, so it can be tested without a DOM: the gclass turns the
7
+ * plan into buttons and columns, this decides the plan. Same split
8
+ * as form_toolbar_plan.js.
9
+ *
10
+ * The rule it exists to state ONCE: `readonly` is not one more
11
+ * button flag, it is the STATE of the topic and it wins over all of
12
+ * them. A treedb whose tranger this yuno does not master answers
13
+ * every write with "READ-ONLY" (the yuno refuses since SDK 7.13.0),
14
+ * so offering the buttons anyway only turns a fact into an error
15
+ * message per click. Written as `!readonly && with_x` in five
16
+ * places it is five places to forget the sixth.
17
+ *
18
+ * Copyright (c) 2026, ArtGins.
19
+ * All Rights Reserved.
20
+ ***********************************************************************/
21
+
22
+ /* What a read-only form keeps: reading a record includes taking it with
23
+ * you, so `copy` stays and the write half goes. */
24
+ const READONLY_FORM_TOOLBAR = ["copy"];
25
+
26
+
27
+ /***************************************************************
28
+ * plan_treedb_writes(flags) -> plan
29
+ *
30
+ * flags {readonly, with_edition_mode, with_new_button,
31
+ * with_delete_button, with_paste_button,
32
+ * with_in_row_edit_icons}
33
+ * Every with_* defaults to TRUE when absent, which is
34
+ * what the gclass attrs default to: a caller that sets
35
+ * nothing gets the toolbar this view always had.
36
+ *
37
+ * plan the same names without the `with_` prefix, plus
38
+ * `form_toolbar`: the list to hand C_YUI_FORM, or null
39
+ * to leave the form's own default alone.
40
+ ***************************************************************/
41
+ function plan_treedb_writes(flags)
42
+ {
43
+ let f = flags || {};
44
+ let readonly = !!f.readonly;
45
+ let want = (name) => {
46
+ return (f[name] === undefined) ? true : !!f[name];
47
+ };
48
+
49
+ if(readonly) {
50
+ return {
51
+ readonly: true,
52
+ edition_mode: false,
53
+ new_button: false,
54
+ delete_button: false,
55
+ paste_button: false,
56
+ in_row_icons: false,
57
+ form_toolbar: READONLY_FORM_TOOLBAR.slice()
58
+ };
59
+ }
60
+
61
+ return {
62
+ readonly: false,
63
+ edition_mode: want("with_edition_mode"),
64
+ new_button: want("with_new_button"),
65
+ delete_button: want("with_delete_button"),
66
+ paste_button: want("with_paste_button"),
67
+ in_row_icons: want("with_in_row_edit_icons"),
68
+ form_toolbar: null
69
+ };
70
+ }
71
+
72
+
73
+ export {plan_treedb_writes, READONLY_FORM_TOOLBAR};
@@ -0,0 +1,83 @@
1
+ /***********************************************************************
2
+ * treedb_write_plan.test.js
3
+ *
4
+ * What a read-only treedb topic must NOT offer, pinned.
5
+ *
6
+ * The point of the file under test is that one flag beats five, so
7
+ * these tests are mostly about the ways someone could get a write
8
+ * affordance back by accident.
9
+ ***********************************************************************/
10
+ import { describe, test, expect } from "vitest";
11
+ import { plan_treedb_writes, READONLY_FORM_TOOLBAR } from "./treedb_write_plan.js";
12
+
13
+ const WRITES = ["edition_mode", "new_button", "delete_button", "paste_button", "in_row_icons"];
14
+
15
+
16
+ describe("writable (the default)", () => {
17
+ test("nothing set is everything on — the toolbar this view always had", () => {
18
+ const plan = plan_treedb_writes({});
19
+ for(const k of WRITES) {
20
+ expect(plan[k]).toBe(true);
21
+ }
22
+ expect(plan.readonly).toBe(false);
23
+ });
24
+
25
+ test("an absent flags object behaves the same", () => {
26
+ expect(plan_treedb_writes(undefined).new_button).toBe(true);
27
+ expect(plan_treedb_writes(null).delete_button).toBe(true);
28
+ });
29
+
30
+ test("the form keeps its OWN default toolbar (null, not a copy of it)", () => {
31
+ /* Repeating the five names here is how the default drifts from
32
+ * C_YUI_FORM's; null says "do not touch it". */
33
+ expect(plan_treedb_writes({}).form_toolbar).toBe(null);
34
+ });
35
+
36
+ test("a single with_* off turns off only that one", () => {
37
+ const plan = plan_treedb_writes({with_delete_button: false});
38
+ expect(plan.delete_button).toBe(false);
39
+ expect(plan.new_button).toBe(true);
40
+ expect(plan.edition_mode).toBe(true);
41
+ });
42
+ });
43
+
44
+
45
+ describe("readonly beats every with_*", () => {
46
+ test("all write affordances off, even when each is explicitly asked for", () => {
47
+ const plan = plan_treedb_writes({
48
+ readonly: true,
49
+ with_edition_mode: true,
50
+ with_new_button: true,
51
+ with_delete_button: true,
52
+ with_paste_button: true,
53
+ with_in_row_edit_icons: true
54
+ });
55
+ for(const k of WRITES) {
56
+ expect(plan[k]).toBe(false);
57
+ }
58
+ });
59
+
60
+ test("the form opens with copy only — reading includes taking the record", () => {
61
+ expect(plan_treedb_writes({readonly: true}).form_toolbar).toEqual(["copy"]);
62
+ });
63
+
64
+ test("the plan hands out a COPY of the toolbar, so a caller cannot mutate the constant", () => {
65
+ const plan = plan_treedb_writes({readonly: true});
66
+ plan.form_toolbar.push("save");
67
+ expect(READONLY_FORM_TOOLBAR).toEqual(["copy"]);
68
+ expect(plan_treedb_writes({readonly: true}).form_toolbar).toEqual(["copy"]);
69
+ });
70
+
71
+ test("readonly is reported, so the gclass can refuse the EVENTS too", () => {
72
+ /* Hiding the buttons is not the same as refusing the write: an
73
+ * event can still arrive from a keyboard path or a stale form. */
74
+ expect(plan_treedb_writes({readonly: true}).readonly).toBe(true);
75
+ });
76
+
77
+ test("only a truthy readonly counts — a missing flag is writable", () => {
78
+ expect(plan_treedb_writes({readonly: false}).new_button).toBe(true);
79
+ expect(plan_treedb_writes({readonly: 0}).new_button).toBe(true);
80
+ expect(plan_treedb_writes({readonly: undefined}).new_button).toBe(true);
81
+ expect(plan_treedb_writes({readonly: "yes"}).new_button).toBe(false);
82
+ });
83
+ });