@yuneta/gobj-ui 5.14.2 → 5.15.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.
package/README.md CHANGED
@@ -430,6 +430,38 @@ Logical DOM classes: `JSON_VIEWER`, `JSON_TOOLBAR`, `JSON_SEARCH`, `JSON_TREE`,
430
430
  `JSON_ROW`, `JSON_KEY`, `JSON_VALUE`, `JSON_SUMMARY`, `JSON_COLLAPSED`,
431
431
  `JSON_TIME`. The gclass imports its own `c_yui_json.css`.
432
432
 
433
+ ### Read-only treedbs: `readonly`
434
+
435
+ `C_YUI_TREEDB_TOPICS` takes a **`readonly`** attr, propagated to every topic it
436
+ builds. It is not one more button flag: it is the STATE of the treedb and it
437
+ beats each `with_*` flag at once, because a treedb whose tranger the yuno does
438
+ not master answers **every** write with
439
+
440
+ ```
441
+ ERROR -1: <yuno>: treedb '<name>' is READ-ONLY, this yuno is not the master of its tranger
442
+ ```
443
+
444
+ (the yuno refuses since SDK 7.13.0), so offering the buttons anyway turns a
445
+ fact into an error message per click. Ask the yuno which it is with
446
+ `command-yuno id=<yuno> service=<treedb> command=treedb-info`, which answers
447
+ `{treedb_name, master, schema_version, topics}` — and remember the flag is per
448
+ TREEDB and is runtime state: a yuno is routinely the master of its
449
+ `treedb_system_schema` and a replica of a data treedb it shares.
450
+
451
+ What `readonly` takes away: the edition mode, the *new* / *delete* / *paste*
452
+ buttons, the in-row edit icons, and the write half of the record form's toolbar
453
+ (`copy` stays — reading a record includes taking it with you) with the cells not
454
+ editable. The record form still OPENS: looking is the point of a replica.
455
+
456
+ Two implementation notes worth keeping:
457
+
458
+ - the decision lives in **`treedb_write_plan.js`** (pure, tested), not in five
459
+ `!readonly && with_x` expressions — five places to forget the sixth;
460
+ - and the write **events** are refused as well, in both gclasses, with a
461
+ `log_error`. Hiding a button is not the same as refusing a write: an event can
462
+ still arrive from a keyboard path or a form that outlived the flag, and an
463
+ ignored write is exactly the behaviour this whole change exists to stop.
464
+
433
465
  ### C_YUI_TREEDB_SCHEMA — the treedb as a graph of topics (prototype)
434
466
 
435
467
  A landing view that draws a treedb as a **graph of topics** — one node per
@@ -14404,6 +14404,7 @@ var attrs_table$5 = [
14404
14404
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_BOOLEAN, "system", 0, false, "Manage system topics (true) or user topics (false)"),
14405
14405
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_STRING, "tabs_style", 0, "is-toggle is-fullwidth", "Bulma tab styling"),
14406
14406
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.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)."),
14407
+ (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.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`)"),
14407
14408
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.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."),
14408
14409
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.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)."),
14409
14410
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.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)."),
@@ -15184,12 +15185,14 @@ function process_treedb_descs$1(gobj) {
15184
15185
  let descs = (0, _yuneta_gobj_js.gobj_read_attr)(gobj, "descs");
15185
15186
  let system = (0, _yuneta_gobj_js.gobj_read_bool_attr)(gobj, "system");
15186
15187
  let treedb_name = (0, _yuneta_gobj_js.gobj_read_str_attr)(gobj, "treedb_name");
15188
+ let readonly = (0, _yuneta_gobj_js.gobj_read_bool_attr)(gobj, "readonly");
15187
15189
  for (const [key, desc] of Object.entries(descs)) {
15188
15190
  if (system) {} else if (key.substring(0, 2) === "__") continue;
15189
15191
  let kw_topic_form = {
15190
15192
  subscriber: gobj,
15191
- with_new_button: true,
15192
- with_delete_button: true,
15193
+ readonly,
15194
+ with_new_button: !readonly,
15195
+ with_delete_button: !readonly,
15193
15196
  treedb_name,
15194
15197
  topic_name: key,
15195
15198
  desc
@@ -15494,6 +15497,19 @@ function request_print_tranger$1(gobj, path) {
15494
15497
  }, gobj);
15495
15498
  if (ret) (0, _yuneta_gobj_js.log_error)(ret);
15496
15499
  }
15500
+ /***************************************************************
15501
+ * The last gate before a write leaves for the yuno. The per-topic
15502
+ * children already hide their write buttons when the treedb is
15503
+ * read-only, but the record events converge HERE, and this is the
15504
+ * gclass that owns the treedb: a write that got this far on a replica
15505
+ * would travel, be refused by the yuno, and come back as a toast --
15506
+ * after the table had already drawn the row.
15507
+ ***************************************************************/
15508
+ function refuse_if_readonly$1(gobj, event) {
15509
+ if (!(0, _yuneta_gobj_js.gobj_read_bool_attr)(gobj, "readonly")) return false;
15510
+ (0, _yuneta_gobj_js.log_error)(`${(0, _yuneta_gobj_js.gobj_short_name)(gobj)}: ${event} refused, treedb '${(0, _yuneta_gobj_js.gobj_read_str_attr)(gobj, "treedb_name")}' is READ-ONLY`);
15511
+ return true;
15512
+ }
15497
15513
  /***************************
15498
15514
  * Actions
15499
15515
  ***************************/
@@ -15766,6 +15782,7 @@ function ac_treedb_node_deleted$1(gobj, event, kw, src) {
15766
15782
  * }
15767
15783
  ********************************************/
15768
15784
  function ac_create_record(gobj, event, kw, src) {
15785
+ if (refuse_if_readonly$1(gobj, event)) return -1;
15769
15786
  let treedb_name = (0, _yuneta_gobj_js.gobj_read_str_attr)(gobj, "treedb_name");
15770
15787
  let topic_name = (0, _yuneta_gobj_js.gobj_read_attr)(src, "topic_name");
15771
15788
  let record = kw.record;
@@ -15783,6 +15800,7 @@ function ac_create_record(gobj, event, kw, src) {
15783
15800
  * }
15784
15801
  ********************************************/
15785
15802
  function ac_update_record(gobj, event, kw, src) {
15803
+ if (refuse_if_readonly$1(gobj, event)) return -1;
15786
15804
  let treedb_name = (0, _yuneta_gobj_js.gobj_read_str_attr)(gobj, "treedb_name");
15787
15805
  let topic_name = (0, _yuneta_gobj_js.gobj_read_attr)(src, "topic_name");
15788
15806
  let record = kw.record;
@@ -15799,6 +15817,7 @@ function ac_update_record(gobj, event, kw, src) {
15799
15817
  * }
15800
15818
  ********************************************/
15801
15819
  function ac_delete_record(gobj, event, kw, src) {
15820
+ if (refuse_if_readonly$1(gobj, event)) return -1;
15802
15821
  let treedb_name = (0, _yuneta_gobj_js.gobj_read_str_attr)(gobj, "treedb_name");
15803
15822
  let topic_name = (0, _yuneta_gobj_js.gobj_read_attr)(src, "topic_name");
15804
15823
  let record = kw.record;
@@ -16086,6 +16105,68 @@ function yui_tabulator_relocalize(table, t) {
16086
16105
  }
16087
16106
  }
16088
16107
  //#endregion
16108
+ //#region src/treedb_write_plan.js
16109
+ /***********************************************************************
16110
+ * treedb_write_plan.js
16111
+ *
16112
+ * Which write affordances a treedb topic offers.
16113
+ *
16114
+ * Pure, so it can be tested without a DOM: the gclass turns the
16115
+ * plan into buttons and columns, this decides the plan. Same split
16116
+ * as form_toolbar_plan.js.
16117
+ *
16118
+ * The rule it exists to state ONCE: `readonly` is not one more
16119
+ * button flag, it is the STATE of the topic and it wins over all of
16120
+ * them. A treedb whose tranger this yuno does not master answers
16121
+ * every write with "READ-ONLY" (the yuno refuses since SDK 7.13.0),
16122
+ * so offering the buttons anyway only turns a fact into an error
16123
+ * message per click. Written as `!readonly && with_x` in five
16124
+ * places it is five places to forget the sixth.
16125
+ *
16126
+ * Copyright (c) 2026, ArtGins.
16127
+ * All Rights Reserved.
16128
+ ***********************************************************************/
16129
+ var READONLY_FORM_TOOLBAR = ["copy"];
16130
+ /***************************************************************
16131
+ * plan_treedb_writes(flags) -> plan
16132
+ *
16133
+ * flags {readonly, with_edition_mode, with_new_button,
16134
+ * with_delete_button, with_paste_button,
16135
+ * with_in_row_edit_icons}
16136
+ * Every with_* defaults to TRUE when absent, which is
16137
+ * what the gclass attrs default to: a caller that sets
16138
+ * nothing gets the toolbar this view always had.
16139
+ *
16140
+ * plan the same names without the `with_` prefix, plus
16141
+ * `form_toolbar`: the list to hand C_YUI_FORM, or null
16142
+ * to leave the form's own default alone.
16143
+ ***************************************************************/
16144
+ function plan_treedb_writes(flags) {
16145
+ let f = flags || {};
16146
+ let readonly = !!f.readonly;
16147
+ let want = (name) => {
16148
+ return f[name] === void 0 ? true : !!f[name];
16149
+ };
16150
+ if (readonly) return {
16151
+ readonly: true,
16152
+ edition_mode: false,
16153
+ new_button: false,
16154
+ delete_button: false,
16155
+ paste_button: false,
16156
+ in_row_icons: false,
16157
+ form_toolbar: READONLY_FORM_TOOLBAR.slice()
16158
+ };
16159
+ return {
16160
+ readonly: false,
16161
+ edition_mode: want("with_edition_mode"),
16162
+ new_button: want("with_new_button"),
16163
+ delete_button: want("with_delete_button"),
16164
+ paste_button: want("with_paste_button"),
16165
+ in_row_icons: want("with_in_row_edit_icons"),
16166
+ form_toolbar: null
16167
+ };
16168
+ }
16169
+ //#endregion
16089
16170
  //#region src/c_yui_treedb_topic_with_form.js
16090
16171
  /***********************************************************************
16091
16172
  * c_yui_treedb_topic_with_form.js
@@ -16107,6 +16188,7 @@ var attrs_table$4 = [
16107
16188
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_STRING, "treedb_name", 0, null, "Remote service treedb name"),
16108
16189
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_STRING, "topic_name", 0, null, "Topic name"),
16109
16190
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_POINTER, "desc", 0, null, "Description of topics"),
16191
+ (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.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"),
16110
16192
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_BOOLEAN, "with_edition_mode", 0, true, "Enable EDITION mode showing EDIT Button toolbar"),
16111
16193
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_BOOLEAN, "with_new_button", 0, true, "Button toolbar NEW"),
16112
16194
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_BOOLEAN, "with_delete_button", 0, true, "Button toolbar DELETE"),
@@ -16269,11 +16351,12 @@ async function readClipboard(gobj) {
16269
16351
  function build_ui$4(gobj) {
16270
16352
  function create_table_toolbar() {
16271
16353
  let $table_toolbar = [];
16272
- let with_edition_mode = (0, _yuneta_gobj_js.gobj_read_bool_attr)(gobj, "with_edition_mode");
16273
- let with_new_button = (0, _yuneta_gobj_js.gobj_read_bool_attr)(gobj, "with_new_button");
16274
- let with_delete_button = (0, _yuneta_gobj_js.gobj_read_bool_attr)(gobj, "with_delete_button");
16354
+ let plan = write_plan(gobj);
16355
+ let with_edition_mode = plan.edition_mode;
16356
+ let with_new_button = plan.new_button;
16357
+ let with_delete_button = plan.delete_button;
16275
16358
  let with_copy_button = (0, _yuneta_gobj_js.gobj_read_bool_attr)(gobj, "with_copy_button");
16276
- let with_paste_button = (0, _yuneta_gobj_js.gobj_read_bool_attr)(gobj, "with_paste_button");
16359
+ let with_paste_button = plan.paste_button;
16277
16360
  let toolbar_id = (0, _yuneta_gobj_js.gobj_read_str_attr)(gobj, "toolbar_id");
16278
16361
  if (with_edition_mode) {
16279
16362
  $table_toolbar = (0, _yuneta_gobj_js.createElement2)(["div", {
@@ -16716,7 +16799,7 @@ function create_tabulator(gobj) {
16716
16799
  "</button>"
16717
16800
  ].join("");
16718
16801
  }
16719
- if ((0, _yuneta_gobj_js.gobj_read_bool_attr)(gobj, "with_in_row_edit_icons")) columns.push({
16802
+ if (write_plan(gobj).in_row_icons) columns.push({
16720
16803
  field: "_operation",
16721
16804
  title: "Op",
16722
16805
  hozAlign: "center",
@@ -16995,15 +17078,17 @@ function open_form_dialog(gobj, mode, record) {
16995
17078
  },
16996
17079
  []
16997
17080
  ]);
17081
+ let form_plan = write_plan(gobj);
16998
17082
  let form = (0, _yuneta_gobj_js.gobj_create_pure_child)("form_" + (0, _yuneta_gobj_js.clean_name)((0, _yuneta_gobj_js.gobj_name)(gobj)), "C_YUI_FORM", {
16999
17083
  template: build_form_template(gobj),
17000
17084
  record,
17001
17085
  fkey_options: build_fkey_options(gobj),
17002
17086
  form_mode: mode,
17087
+ ...form_plan.form_toolbar ? { toolbar: form_plan.form_toolbar } : {},
17003
17088
  render_mode: "edit",
17004
17089
  pkey: desc.pkey || "id",
17005
17090
  topic_name,
17006
- editable: true,
17091
+ editable: !form_plan.readonly,
17007
17092
  $parent: $body
17008
17093
  }, gobj);
17009
17094
  priv.form = form;
@@ -17530,6 +17615,34 @@ function show_dropdown_popup_menu(gobj, x, y, items, callback) {
17530
17615
  let $with_focus = $element.querySelector(".with-focus");
17531
17616
  if ($with_focus) $with_focus.focus();
17532
17617
  }
17618
+ /***************************************************************
17619
+ * A read-only topic refuses the write EVENTS too, not only their
17620
+ * buttons. The buttons are gone, but an event can still arrive: a
17621
+ * keyboard path, a hosted form that outlived the flag, a caller that
17622
+ * sends EV_SAVE_RECORD itself. Refusing here is what makes `readonly`
17623
+ * a state of the gclass rather than a way of drawing it -- and it
17624
+ * fails LOUDLY, because an ignored write is how the backend used to
17625
+ * behave and what this whole change exists to stop.
17626
+ ***************************************************************/
17627
+ function write_plan(gobj) {
17628
+ return plan_treedb_writes({
17629
+ readonly: (0, _yuneta_gobj_js.gobj_read_bool_attr)(gobj, "readonly"),
17630
+ with_edition_mode: (0, _yuneta_gobj_js.gobj_read_bool_attr)(gobj, "with_edition_mode"),
17631
+ with_new_button: (0, _yuneta_gobj_js.gobj_read_bool_attr)(gobj, "with_new_button"),
17632
+ with_delete_button: (0, _yuneta_gobj_js.gobj_read_bool_attr)(gobj, "with_delete_button"),
17633
+ with_paste_button: (0, _yuneta_gobj_js.gobj_read_bool_attr)(gobj, "with_paste_button"),
17634
+ with_in_row_edit_icons: (0, _yuneta_gobj_js.gobj_read_bool_attr)(gobj, "with_in_row_edit_icons")
17635
+ });
17636
+ }
17637
+ /***************************************************************
17638
+ * A read-only topic refuses the write EVENTS too, not only their
17639
+ * buttons.
17640
+ ***************************************************************/
17641
+ function refuse_if_readonly(gobj, event) {
17642
+ if (!(0, _yuneta_gobj_js.gobj_read_bool_attr)(gobj, "readonly")) return false;
17643
+ (0, _yuneta_gobj_js.log_error)(`${(0, _yuneta_gobj_js.gobj_short_name)(gobj)}: ${event} refused, topic '${(0, _yuneta_gobj_js.gobj_read_str_attr)(gobj, "topic_name")}' is READ-ONLY`);
17644
+ return true;
17645
+ }
17533
17646
  /***************************
17534
17647
  * Actions
17535
17648
  ***************************/
@@ -17622,6 +17735,7 @@ function ac_node_deleted$1(gobj, event, kw, src) {
17622
17735
  * and the Op column is visible (with row's edit/delete icons)
17623
17736
  ************************************************************/
17624
17737
  function ac_edition_mode(gobj, event, kw, src) {
17738
+ if (refuse_if_readonly(gobj, event)) return -1;
17625
17739
  let tabulator = (0, _yuneta_gobj_js.gobj_read_attr)(gobj, "tabulator");
17626
17740
  let editable = (0, _yuneta_gobj_js.gobj_read_bool_attr)(gobj, "editable");
17627
17741
  editable = !editable;
@@ -17665,6 +17779,7 @@ function ac_edition_mode(gobj, event, kw, src) {
17665
17779
  * - the backend will broadcast the new record that will be added to table
17666
17780
  ************************************************************/
17667
17781
  function ac_new_row(gobj, event, kw, src) {
17782
+ if (refuse_if_readonly(gobj, event)) return -1;
17668
17783
  let row = {};
17669
17784
  let desc = (0, _yuneta_gobj_js.gobj_read_attr)(gobj, "desc");
17670
17785
  for (let i = 0; i < desc.cols.length; i++) {
@@ -17694,6 +17809,7 @@ function ac_new_row(gobj, event, kw, src) {
17694
17809
  * kw {index:, row:}
17695
17810
  ************************************************************/
17696
17811
  function ac_delete_rows(gobj, event, kw, src) {
17812
+ if (refuse_if_readonly(gobj, event)) return -1;
17697
17813
  let tabulator = (0, _yuneta_gobj_js.gobj_read_attr)(gobj, "tabulator");
17698
17814
  if ((0, _yuneta_gobj_js.json_size)(kw) === 0) {
17699
17815
  let rows = tabulator.getSelectedData();
@@ -17757,6 +17873,7 @@ function ac_copy_rows(gobj, event, kw, src) {
17757
17873
  * From internal
17758
17874
  ************************************************************/
17759
17875
  function ac_paste_rows(gobj, event, kw, src) {
17876
+ if (refuse_if_readonly(gobj, event)) return -1;
17760
17877
  if (kw.topic_name === (0, _yuneta_gobj_js.gobj_read_str_attr)(gobj, "topic_name") && kw.treedb_name === (0, _yuneta_gobj_js.gobj_read_str_attr)(gobj, "treedb_name")) for (let row of kw.rows) (0, _yuneta_gobj_js.gobj_publish_event)(gobj, "EV_CREATE_RECORD", {
17761
17878
  topic_name: (0, _yuneta_gobj_js.gobj_read_str_attr)(gobj, "topic_name"),
17762
17879
  record: row
@@ -17769,6 +17886,7 @@ function ac_paste_rows(gobj, event, kw, src) {
17769
17886
  * fkeys, times and numbers). Route by the form's mode.
17770
17887
  ************************************************************/
17771
17888
  function ac_form_save_record(gobj, event, kw, src) {
17889
+ if (refuse_if_readonly(gobj, event)) return -1;
17772
17890
  let mode = (0, _yuneta_gobj_js.gobj_read_str_attr)(src, "form_mode");
17773
17891
  (0, _yuneta_gobj_js.gobj_publish_event)(gobj, mode === "create" ? "EV_CREATE_RECORD" : "EV_UPDATE_RECORD", {
17774
17892
  topic_name: (0, _yuneta_gobj_js.gobj_read_str_attr)(gobj, "topic_name"),
@@ -14399,6 +14399,7 @@ var attrs_table$5 = [
14399
14399
  SDATA(data_type_t.DTP_BOOLEAN, "system", 0, false, "Manage system topics (true) or user topics (false)"),
14400
14400
  SDATA(data_type_t.DTP_STRING, "tabs_style", 0, "is-toggle is-fullwidth", "Bulma tab styling"),
14401
14401
  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)."),
14402
+ 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`)"),
14402
14403
  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."),
14403
14404
  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)."),
14404
14405
  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)."),
@@ -15179,12 +15180,14 @@ function process_treedb_descs$1(gobj) {
15179
15180
  let descs = gobj_read_attr(gobj, "descs");
15180
15181
  let system = gobj_read_bool_attr(gobj, "system");
15181
15182
  let treedb_name = gobj_read_str_attr(gobj, "treedb_name");
15183
+ let readonly = gobj_read_bool_attr(gobj, "readonly");
15182
15184
  for (const [key, desc] of Object.entries(descs)) {
15183
15185
  if (system) {} else if (key.substring(0, 2) === "__") continue;
15184
15186
  let kw_topic_form = {
15185
15187
  subscriber: gobj,
15186
- with_new_button: true,
15187
- with_delete_button: true,
15188
+ readonly,
15189
+ with_new_button: !readonly,
15190
+ with_delete_button: !readonly,
15188
15191
  treedb_name,
15189
15192
  topic_name: key,
15190
15193
  desc
@@ -15489,6 +15492,19 @@ function request_print_tranger$1(gobj, path) {
15489
15492
  }, gobj);
15490
15493
  if (ret) log_error(ret);
15491
15494
  }
15495
+ /***************************************************************
15496
+ * The last gate before a write leaves for the yuno. The per-topic
15497
+ * children already hide their write buttons when the treedb is
15498
+ * read-only, but the record events converge HERE, and this is the
15499
+ * gclass that owns the treedb: a write that got this far on a replica
15500
+ * would travel, be refused by the yuno, and come back as a toast --
15501
+ * after the table had already drawn the row.
15502
+ ***************************************************************/
15503
+ function refuse_if_readonly$1(gobj, event) {
15504
+ if (!gobj_read_bool_attr(gobj, "readonly")) return false;
15505
+ log_error(`${gobj_short_name(gobj)}: ${event} refused, treedb '${gobj_read_str_attr(gobj, "treedb_name")}' is READ-ONLY`);
15506
+ return true;
15507
+ }
15492
15508
  /***************************
15493
15509
  * Actions
15494
15510
  ***************************/
@@ -15761,6 +15777,7 @@ function ac_treedb_node_deleted$1(gobj, event, kw, src) {
15761
15777
  * }
15762
15778
  ********************************************/
15763
15779
  function ac_create_record(gobj, event, kw, src) {
15780
+ if (refuse_if_readonly$1(gobj, event)) return -1;
15764
15781
  let treedb_name = gobj_read_str_attr(gobj, "treedb_name");
15765
15782
  let topic_name = gobj_read_attr(src, "topic_name");
15766
15783
  let record = kw.record;
@@ -15778,6 +15795,7 @@ function ac_create_record(gobj, event, kw, src) {
15778
15795
  * }
15779
15796
  ********************************************/
15780
15797
  function ac_update_record(gobj, event, kw, src) {
15798
+ if (refuse_if_readonly$1(gobj, event)) return -1;
15781
15799
  let treedb_name = gobj_read_str_attr(gobj, "treedb_name");
15782
15800
  let topic_name = gobj_read_attr(src, "topic_name");
15783
15801
  let record = kw.record;
@@ -15794,6 +15812,7 @@ function ac_update_record(gobj, event, kw, src) {
15794
15812
  * }
15795
15813
  ********************************************/
15796
15814
  function ac_delete_record(gobj, event, kw, src) {
15815
+ if (refuse_if_readonly$1(gobj, event)) return -1;
15797
15816
  let treedb_name = gobj_read_str_attr(gobj, "treedb_name");
15798
15817
  let topic_name = gobj_read_attr(src, "topic_name");
15799
15818
  let record = kw.record;
@@ -16081,6 +16100,68 @@ function yui_tabulator_relocalize(table, t) {
16081
16100
  }
16082
16101
  }
16083
16102
  //#endregion
16103
+ //#region src/treedb_write_plan.js
16104
+ /***********************************************************************
16105
+ * treedb_write_plan.js
16106
+ *
16107
+ * Which write affordances a treedb topic offers.
16108
+ *
16109
+ * Pure, so it can be tested without a DOM: the gclass turns the
16110
+ * plan into buttons and columns, this decides the plan. Same split
16111
+ * as form_toolbar_plan.js.
16112
+ *
16113
+ * The rule it exists to state ONCE: `readonly` is not one more
16114
+ * button flag, it is the STATE of the topic and it wins over all of
16115
+ * them. A treedb whose tranger this yuno does not master answers
16116
+ * every write with "READ-ONLY" (the yuno refuses since SDK 7.13.0),
16117
+ * so offering the buttons anyway only turns a fact into an error
16118
+ * message per click. Written as `!readonly && with_x` in five
16119
+ * places it is five places to forget the sixth.
16120
+ *
16121
+ * Copyright (c) 2026, ArtGins.
16122
+ * All Rights Reserved.
16123
+ ***********************************************************************/
16124
+ var READONLY_FORM_TOOLBAR = ["copy"];
16125
+ /***************************************************************
16126
+ * plan_treedb_writes(flags) -> plan
16127
+ *
16128
+ * flags {readonly, with_edition_mode, with_new_button,
16129
+ * with_delete_button, with_paste_button,
16130
+ * with_in_row_edit_icons}
16131
+ * Every with_* defaults to TRUE when absent, which is
16132
+ * what the gclass attrs default to: a caller that sets
16133
+ * nothing gets the toolbar this view always had.
16134
+ *
16135
+ * plan the same names without the `with_` prefix, plus
16136
+ * `form_toolbar`: the list to hand C_YUI_FORM, or null
16137
+ * to leave the form's own default alone.
16138
+ ***************************************************************/
16139
+ function plan_treedb_writes(flags) {
16140
+ let f = flags || {};
16141
+ let readonly = !!f.readonly;
16142
+ let want = (name) => {
16143
+ return f[name] === void 0 ? true : !!f[name];
16144
+ };
16145
+ if (readonly) return {
16146
+ readonly: true,
16147
+ edition_mode: false,
16148
+ new_button: false,
16149
+ delete_button: false,
16150
+ paste_button: false,
16151
+ in_row_icons: false,
16152
+ form_toolbar: READONLY_FORM_TOOLBAR.slice()
16153
+ };
16154
+ return {
16155
+ readonly: false,
16156
+ edition_mode: want("with_edition_mode"),
16157
+ new_button: want("with_new_button"),
16158
+ delete_button: want("with_delete_button"),
16159
+ paste_button: want("with_paste_button"),
16160
+ in_row_icons: want("with_in_row_edit_icons"),
16161
+ form_toolbar: null
16162
+ };
16163
+ }
16164
+ //#endregion
16084
16165
  //#region src/c_yui_treedb_topic_with_form.js
16085
16166
  /***********************************************************************
16086
16167
  * c_yui_treedb_topic_with_form.js
@@ -16102,6 +16183,7 @@ var attrs_table$4 = [
16102
16183
  SDATA(data_type_t.DTP_STRING, "treedb_name", 0, null, "Remote service treedb name"),
16103
16184
  SDATA(data_type_t.DTP_STRING, "topic_name", 0, null, "Topic name"),
16104
16185
  SDATA(data_type_t.DTP_POINTER, "desc", 0, null, "Description of topics"),
16186
+ 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"),
16105
16187
  SDATA(data_type_t.DTP_BOOLEAN, "with_edition_mode", 0, true, "Enable EDITION mode showing EDIT Button toolbar"),
16106
16188
  SDATA(data_type_t.DTP_BOOLEAN, "with_new_button", 0, true, "Button toolbar NEW"),
16107
16189
  SDATA(data_type_t.DTP_BOOLEAN, "with_delete_button", 0, true, "Button toolbar DELETE"),
@@ -16264,11 +16346,12 @@ async function readClipboard(gobj) {
16264
16346
  function build_ui$4(gobj) {
16265
16347
  function create_table_toolbar() {
16266
16348
  let $table_toolbar = [];
16267
- let with_edition_mode = gobj_read_bool_attr(gobj, "with_edition_mode");
16268
- let with_new_button = gobj_read_bool_attr(gobj, "with_new_button");
16269
- let with_delete_button = gobj_read_bool_attr(gobj, "with_delete_button");
16349
+ let plan = write_plan(gobj);
16350
+ let with_edition_mode = plan.edition_mode;
16351
+ let with_new_button = plan.new_button;
16352
+ let with_delete_button = plan.delete_button;
16270
16353
  let with_copy_button = gobj_read_bool_attr(gobj, "with_copy_button");
16271
- let with_paste_button = gobj_read_bool_attr(gobj, "with_paste_button");
16354
+ let with_paste_button = plan.paste_button;
16272
16355
  let toolbar_id = gobj_read_str_attr(gobj, "toolbar_id");
16273
16356
  if (with_edition_mode) {
16274
16357
  $table_toolbar = createElement2(["div", {
@@ -16711,7 +16794,7 @@ function create_tabulator(gobj) {
16711
16794
  "</button>"
16712
16795
  ].join("");
16713
16796
  }
16714
- if (gobj_read_bool_attr(gobj, "with_in_row_edit_icons")) columns.push({
16797
+ if (write_plan(gobj).in_row_icons) columns.push({
16715
16798
  field: "_operation",
16716
16799
  title: "Op",
16717
16800
  hozAlign: "center",
@@ -16990,15 +17073,17 @@ function open_form_dialog(gobj, mode, record) {
16990
17073
  },
16991
17074
  []
16992
17075
  ]);
17076
+ let form_plan = write_plan(gobj);
16993
17077
  let form = gobj_create_pure_child("form_" + clean_name(gobj_name(gobj)), "C_YUI_FORM", {
16994
17078
  template: build_form_template(gobj),
16995
17079
  record,
16996
17080
  fkey_options: build_fkey_options(gobj),
16997
17081
  form_mode: mode,
17082
+ ...form_plan.form_toolbar ? { toolbar: form_plan.form_toolbar } : {},
16998
17083
  render_mode: "edit",
16999
17084
  pkey: desc.pkey || "id",
17000
17085
  topic_name,
17001
- editable: true,
17086
+ editable: !form_plan.readonly,
17002
17087
  $parent: $body
17003
17088
  }, gobj);
17004
17089
  priv.form = form;
@@ -17525,6 +17610,34 @@ function show_dropdown_popup_menu(gobj, x, y, items, callback) {
17525
17610
  let $with_focus = $element.querySelector(".with-focus");
17526
17611
  if ($with_focus) $with_focus.focus();
17527
17612
  }
17613
+ /***************************************************************
17614
+ * A read-only topic refuses the write EVENTS too, not only their
17615
+ * buttons. The buttons are gone, but an event can still arrive: a
17616
+ * keyboard path, a hosted form that outlived the flag, a caller that
17617
+ * sends EV_SAVE_RECORD itself. Refusing here is what makes `readonly`
17618
+ * a state of the gclass rather than a way of drawing it -- and it
17619
+ * fails LOUDLY, because an ignored write is how the backend used to
17620
+ * behave and what this whole change exists to stop.
17621
+ ***************************************************************/
17622
+ function write_plan(gobj) {
17623
+ return plan_treedb_writes({
17624
+ readonly: gobj_read_bool_attr(gobj, "readonly"),
17625
+ with_edition_mode: gobj_read_bool_attr(gobj, "with_edition_mode"),
17626
+ with_new_button: gobj_read_bool_attr(gobj, "with_new_button"),
17627
+ with_delete_button: gobj_read_bool_attr(gobj, "with_delete_button"),
17628
+ with_paste_button: gobj_read_bool_attr(gobj, "with_paste_button"),
17629
+ with_in_row_edit_icons: gobj_read_bool_attr(gobj, "with_in_row_edit_icons")
17630
+ });
17631
+ }
17632
+ /***************************************************************
17633
+ * A read-only topic refuses the write EVENTS too, not only their
17634
+ * buttons.
17635
+ ***************************************************************/
17636
+ function refuse_if_readonly(gobj, event) {
17637
+ if (!gobj_read_bool_attr(gobj, "readonly")) return false;
17638
+ log_error(`${gobj_short_name(gobj)}: ${event} refused, topic '${gobj_read_str_attr(gobj, "topic_name")}' is READ-ONLY`);
17639
+ return true;
17640
+ }
17528
17641
  /***************************
17529
17642
  * Actions
17530
17643
  ***************************/
@@ -17617,6 +17730,7 @@ function ac_node_deleted$1(gobj, event, kw, src) {
17617
17730
  * and the Op column is visible (with row's edit/delete icons)
17618
17731
  ************************************************************/
17619
17732
  function ac_edition_mode(gobj, event, kw, src) {
17733
+ if (refuse_if_readonly(gobj, event)) return -1;
17620
17734
  let tabulator = gobj_read_attr(gobj, "tabulator");
17621
17735
  let editable = gobj_read_bool_attr(gobj, "editable");
17622
17736
  editable = !editable;
@@ -17660,6 +17774,7 @@ function ac_edition_mode(gobj, event, kw, src) {
17660
17774
  * - the backend will broadcast the new record that will be added to table
17661
17775
  ************************************************************/
17662
17776
  function ac_new_row(gobj, event, kw, src) {
17777
+ if (refuse_if_readonly(gobj, event)) return -1;
17663
17778
  let row = {};
17664
17779
  let desc = gobj_read_attr(gobj, "desc");
17665
17780
  for (let i = 0; i < desc.cols.length; i++) {
@@ -17689,6 +17804,7 @@ function ac_new_row(gobj, event, kw, src) {
17689
17804
  * kw {index:, row:}
17690
17805
  ************************************************************/
17691
17806
  function ac_delete_rows(gobj, event, kw, src) {
17807
+ if (refuse_if_readonly(gobj, event)) return -1;
17692
17808
  let tabulator = gobj_read_attr(gobj, "tabulator");
17693
17809
  if (json_size(kw) === 0) {
17694
17810
  let rows = tabulator.getSelectedData();
@@ -17752,6 +17868,7 @@ function ac_copy_rows(gobj, event, kw, src) {
17752
17868
  * From internal
17753
17869
  ************************************************************/
17754
17870
  function ac_paste_rows(gobj, event, kw, src) {
17871
+ if (refuse_if_readonly(gobj, event)) return -1;
17755
17872
  if (kw.topic_name === gobj_read_str_attr(gobj, "topic_name") && kw.treedb_name === gobj_read_str_attr(gobj, "treedb_name")) for (let row of kw.rows) gobj_publish_event(gobj, "EV_CREATE_RECORD", {
17756
17873
  topic_name: gobj_read_str_attr(gobj, "topic_name"),
17757
17874
  record: row
@@ -17764,6 +17881,7 @@ function ac_paste_rows(gobj, event, kw, src) {
17764
17881
  * fkeys, times and numbers). Route by the form's mode.
17765
17882
  ************************************************************/
17766
17883
  function ac_form_save_record(gobj, event, kw, src) {
17884
+ if (refuse_if_readonly(gobj, event)) return -1;
17767
17885
  let mode = gobj_read_str_attr(src, "form_mode");
17768
17886
  gobj_publish_event(gobj, mode === "create" ? "EV_CREATE_RECORD" : "EV_UPDATE_RECORD", {
17769
17887
  topic_name: gobj_read_str_attr(gobj, "topic_name"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuneta/gobj-ui",
3
- "version": "5.14.2",
3
+ "version": "5.15.0",
4
4
  "type": "module",
5
5
  "main": "dist/gobj-ui.cjs.js",
6
6
  "module": "dist/gobj-ui.es.js",
@@ -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
  ***************************/
@@ -2242,6 +2292,9 @@ function ac_node_deleted(gobj, event, kw, src)
2242
2292
  ************************************************************/
2243
2293
  function ac_edition_mode(gobj, event, kw, src)
2244
2294
  {
2295
+ if(refuse_if_readonly(gobj, event)) {
2296
+ return -1; /* Error already logged */
2297
+ }
2245
2298
  let tabulator = gobj_read_attr(gobj, "tabulator");
2246
2299
 
2247
2300
  /*
@@ -2307,6 +2360,9 @@ function ac_edition_mode(gobj, event, kw, src)
2307
2360
  ************************************************************/
2308
2361
  function ac_new_row(gobj, event, kw, src)
2309
2362
  {
2363
+ if(refuse_if_readonly(gobj, event)) {
2364
+ return -1; /* Error already logged */
2365
+ }
2310
2366
  /*
2311
2367
  * Build default values. TODO no debería estar en desc configuration?
2312
2368
  */
@@ -2352,6 +2408,9 @@ function ac_new_row(gobj, event, kw, src)
2352
2408
  ************************************************************/
2353
2409
  function ac_delete_rows(gobj, event, kw, src)
2354
2410
  {
2411
+ if(refuse_if_readonly(gobj, event)) {
2412
+ return -1; /* Error already logged */
2413
+ }
2355
2414
  let tabulator = gobj_read_attr(gobj, "tabulator");
2356
2415
 
2357
2416
  if(json_size(kw) === 0) {
@@ -2451,6 +2510,9 @@ function ac_copy_rows(gobj, event, kw, src)
2451
2510
  ************************************************************/
2452
2511
  function ac_paste_rows(gobj, event, kw, src)
2453
2512
  {
2513
+ if(refuse_if_readonly(gobj, event)) {
2514
+ return -1; /* Error already logged */
2515
+ }
2454
2516
  /*----------------------------*
2455
2517
  * Paste rows
2456
2518
  *----------------------------*/
@@ -2478,6 +2540,9 @@ function ac_paste_rows(gobj, event, kw, src)
2478
2540
  ************************************************************/
2479
2541
  function ac_form_save_record(gobj, event, kw, src)
2480
2542
  {
2543
+ if(refuse_if_readonly(gobj, event)) {
2544
+ return -1; /* Error already logged */
2545
+ }
2481
2546
  let mode = gobj_read_str_attr(src, "form_mode");
2482
2547
  gobj_publish_event(
2483
2548
  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
  ***************************/
@@ -2051,6 +2075,9 @@ function ac_treedb_node_deleted(gobj, event, kw, src)
2051
2075
  ********************************************/
2052
2076
  function ac_create_record(gobj, event, kw, src)
2053
2077
  {
2078
+ if(refuse_if_readonly(gobj, event)) {
2079
+ return -1; /* Error already logged */
2080
+ }
2054
2081
  let treedb_name = gobj_read_str_attr(gobj, "treedb_name");
2055
2082
  let topic_name = gobj_read_attr(src, "topic_name");
2056
2083
  let record = kw.record;
@@ -2079,6 +2106,9 @@ function ac_create_record(gobj, event, kw, src)
2079
2106
  ********************************************/
2080
2107
  function ac_update_record(gobj, event, kw, src)
2081
2108
  {
2109
+ if(refuse_if_readonly(gobj, event)) {
2110
+ return -1; /* Error already logged */
2111
+ }
2082
2112
  let treedb_name = gobj_read_str_attr(gobj, "treedb_name");
2083
2113
  let topic_name = gobj_read_attr(src, "topic_name");
2084
2114
  let record = kw.record;
@@ -2106,6 +2136,9 @@ function ac_update_record(gobj, event, kw, src)
2106
2136
  ********************************************/
2107
2137
  function ac_delete_record(gobj, event, kw, src)
2108
2138
  {
2139
+ if(refuse_if_readonly(gobj, event)) {
2140
+ return -1; /* Error already logged */
2141
+ }
2109
2142
  let treedb_name = gobj_read_str_attr(gobj, "treedb_name");
2110
2143
  let topic_name = gobj_read_attr(src, "topic_name");
2111
2144
  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
+ });