@yuneta/gobj-ui 7.9.2 → 7.10.1

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
@@ -491,6 +491,41 @@ The buttons of that toolbar never shrink. When the row runs out of room, the
491
491
  url is what gives way, cut with an ellipsis, and the whole value stays in the
492
492
  `title` and the `aria-label`.
493
493
 
494
+ ### Reading a topic a page at a time
495
+
496
+ `C_YUI_TREEDB_TOPICS` takes **`with_remote_paging`** (off by default) and
497
+ forwards it to every topic table: the table pulls the page it is showing
498
+ instead of the host pushing the whole topic down. It needs the SDK's `nodes`
499
+ with `from` / `limit` (see `YUNO_TREEDB.md` §5.3).
500
+
501
+ **The page size is generous on purpose** (`page_size`, 200). A treedb that
502
+ fits in one page behaves exactly as it did — paginator hidden, every filter
503
+ seeing every row — so nothing that exists today changes. Only a topic that
504
+ does NOT fit pays for paging, and for that one loading it whole was never an
505
+ option.
506
+
507
+ **Safe against a backend that cannot page:** it answers the whole list, which
508
+ `nodes_answer()` reads as one page. That is the truth, and it is why the table
509
+ can ask without knowing what it is talking to.
510
+
511
+ `filterMode: "local"` says the plain truth: the header filters and the search
512
+ box work on the page that is loaded. Same as the tranger browser's Rows card,
513
+ and for the same reason — the alternative is pushing every filter to the
514
+ backend and changing what "search" means.
515
+
516
+ Who does what: the transport belongs to the HOST, so the table asks with
517
+ `EV_REQUEST_PAGE` and the answer comes back as `EV_PAGE_LOADED`, correlated by
518
+ an id echoed in `__md_command__`. **Read that id flat off the command stack**
519
+ (`kw_command.req_id`): `C_IEVENT_CLI` EXTRACTS `__md_command__` and pushes it
520
+ AS the stack's `kw`, so one level deeper is a level too far — and the symptom
521
+ is every request timing out with its answer sitting right there.
522
+
523
+ The promise Tabulator wants is parked in the table (`ajaxRequestFunc` must
524
+ RETURN a promise — it is a data source, not an event), with a watchdog,
525
+ because the link can stay up and an answer still never land. A refresh
526
+ re-pulls the page the reader is on rather than throwing them back to the
527
+ first.
528
+
494
529
  ### Editing a topic table in place
495
530
 
496
531
  A writable scalar is editable in the table, in edition mode
@@ -16381,6 +16381,71 @@ function plan_treedb_writes(flags) {
16381
16381
  };
16382
16382
  }
16383
16383
  //#endregion
16384
+ //#region src/delete_impact.js
16385
+ /***********************************************************************
16386
+ * delete_impact.js
16387
+ *
16388
+ * What a delete takes with it, read off the record itself.
16389
+ *
16390
+ * A treedb delete is not one thing. The backend refuses a node
16391
+ * that has links — UNLESS the caller sends `force`, and the topic
16392
+ * views do. With `force`:
16393
+ *
16394
+ * children are UNLINKED, not deleted. They survive, loose.
16395
+ * parents lose the node from their hook.
16396
+ *
16397
+ * So "delete this row" can mean "detach eleven records from their
16398
+ * only parent", and the question that used to be asked — `are you
16399
+ * sure` — said none of it. This counts what is at stake so the
16400
+ * question can name it.
16401
+ *
16402
+ * Read from the record the table ALREADY has (`list_dict` fills
16403
+ * the hook and fkey columns), so asking costs no round trip.
16404
+ *
16405
+ * Pure and tested, because the shapes are the fiddly part: a hook
16406
+ * or fkey value arrives as a list of refs, a dict keyed by id, or
16407
+ * a single ref string, depending on the column's declared type.
16408
+ *
16409
+ * Copyright (c) 2026, ArtGins.
16410
+ * All Rights Reserved.
16411
+ ***********************************************************************/
16412
+ /***************************************************************
16413
+ * How many things a hook/fkey value holds.
16414
+ ***************************************************************/
16415
+ function ref_count(value) {
16416
+ if (Array.isArray(value)) return value.length;
16417
+ if (value && typeof value === "object") return Object.keys(value).length;
16418
+ if (typeof value === "string" && value.length > 0) return 1;
16419
+ return 0;
16420
+ }
16421
+ /***************************************************************
16422
+ * delete_impact(desc, records) -> {records, children, parents}
16423
+ *
16424
+ * `records` is one record or a list of them, so the same answer
16425
+ * serves the one-row delete and the selection.
16426
+ ***************************************************************/
16427
+ function delete_impact(desc, records) {
16428
+ let list = Array.isArray(records) ? records : records ? [records] : [];
16429
+ let out = {
16430
+ records: list.length,
16431
+ children: 0,
16432
+ parents: 0
16433
+ };
16434
+ let cols = desc && Array.isArray(desc.cols) ? desc.cols : [];
16435
+ if (!cols.length) return out;
16436
+ for (let record of list) {
16437
+ if (!record) continue;
16438
+ for (let col of cols) {
16439
+ if (!col || !col.id || !Array.isArray(col.flag)) continue;
16440
+ let n = ref_count(record[col.id]);
16441
+ if (n === 0) continue;
16442
+ if (col.flag.indexOf("hook") >= 0) out.children += n;
16443
+ if (col.flag.indexOf("fkey") >= 0) out.parents += n;
16444
+ }
16445
+ }
16446
+ return out;
16447
+ }
16448
+ //#endregion
16384
16449
  //#region src/c_yui_treedb_topic_with_form.js
16385
16450
  /***********************************************************************
16386
16451
  * c_yui_treedb_topic_with_form.js
@@ -18219,7 +18284,7 @@ function ac_delete_rows(gobj, event, kw, src) {
18219
18284
  });
18220
18285
  return 0;
18221
18286
  }
18222
- yui_shell_confirm_yesnocancel(yui_shell_of(gobj), "are you sure", {
18287
+ yui_shell_confirm_yesnocancel(yui_shell_of(gobj), build_delete_question(gobj, rows), {
18223
18288
  t: i18next.t,
18224
18289
  yes_label: "yes",
18225
18290
  no_label: "no",
@@ -18230,7 +18295,7 @@ function ac_delete_rows(gobj, event, kw, src) {
18230
18295
  record: row
18231
18296
  });
18232
18297
  });
18233
- } else yui_shell_confirm_yesnocancel(yui_shell_of(gobj), "are you sure", {
18298
+ } else yui_shell_confirm_yesnocancel(yui_shell_of(gobj), build_delete_question(gobj, kw.row), {
18234
18299
  t: i18next.t,
18235
18300
  yes_label: "yes",
18236
18301
  no_label: "no",
@@ -18401,6 +18466,122 @@ function ac_show_schema(gobj, event, kw, src) {
18401
18466
  return 0;
18402
18467
  }
18403
18468
  /************************************************************
18469
+ * The question a delete deserves.
18470
+ *
18471
+ * These views delete with `force`, and `force` on a treedb node does
18472
+ * not only remove it: its children are UNLINKED — they survive, loose
18473
+ * — and it is cleaned off its parents. So "delete this row" can mean
18474
+ * "detach eleven records from their only parent", and `are you sure`
18475
+ * said none of it.
18476
+ *
18477
+ * Built as DOM, not as a string, and that is forced rather than
18478
+ * chosen: the dialog renders its message AS AN I18N KEY, so a
18479
+ * composed sentence could never be one. Each translatable half
18480
+ * carries its own key and the numbers sit between them as data —
18481
+ * which is also the only way the question survives a language switch.
18482
+ ************************************************************/
18483
+ function build_delete_question(gobj, records) {
18484
+ let desc = (0, _yuneta_gobj_js.gobj_read_attr)(gobj, "desc");
18485
+ let impact = delete_impact(desc, records);
18486
+ const ROW = "display:flex; align-items:baseline; gap:.35rem; flex-wrap:wrap;";
18487
+ let lines = [];
18488
+ let pkey = desc && desc.pkey || "id";
18489
+ let one = impact.records === 1 && Array.isArray(records) ? records[0] : impact.records === 1 ? records : null;
18490
+ if (one && one[pkey] !== void 0 && one[pkey] !== null) lines.push([
18491
+ "p",
18492
+ {
18493
+ class: "DELETE_ASK_WHAT",
18494
+ style: ROW
18495
+ },
18496
+ [[
18497
+ "span",
18498
+ { i18n: "delete" },
18499
+ (0, i18next.t)("delete")
18500
+ ], [
18501
+ "strong",
18502
+ {},
18503
+ String(one[pkey])
18504
+ ]]
18505
+ ]);
18506
+ else lines.push([
18507
+ "p",
18508
+ {
18509
+ class: "DELETE_ASK_WHAT",
18510
+ style: ROW
18511
+ },
18512
+ [
18513
+ [
18514
+ "span",
18515
+ { i18n: "delete" },
18516
+ (0, i18next.t)("delete")
18517
+ ],
18518
+ [
18519
+ "strong",
18520
+ {},
18521
+ String(impact.records)
18522
+ ],
18523
+ [
18524
+ "span",
18525
+ { i18n: "records" },
18526
+ (0, i18next.t)("records")
18527
+ ]
18528
+ ]
18529
+ ]);
18530
+ if (impact.children > 0) lines.push([
18531
+ "p",
18532
+ {
18533
+ class: "DELETE_ASK_CHILDREN has-text-weight-semibold",
18534
+ style: ROW
18535
+ },
18536
+ [[
18537
+ "strong",
18538
+ {},
18539
+ String(impact.children)
18540
+ ], [
18541
+ "span",
18542
+ { i18n: "children will be unlinked, not deleted" },
18543
+ (0, i18next.t)("children will be unlinked, not deleted")
18544
+ ]]
18545
+ ]);
18546
+ if (impact.parents > 0) lines.push([
18547
+ "p",
18548
+ {
18549
+ class: "DELETE_ASK_PARENTS",
18550
+ style: ROW
18551
+ },
18552
+ [
18553
+ [
18554
+ "span",
18555
+ { i18n: "it will be detached from" },
18556
+ (0, i18next.t)("it will be detached from")
18557
+ ],
18558
+ [
18559
+ "strong",
18560
+ {},
18561
+ String(impact.parents)
18562
+ ],
18563
+ [
18564
+ "span",
18565
+ { i18n: "parents" },
18566
+ (0, i18next.t)("parents")
18567
+ ]
18568
+ ]
18569
+ ]);
18570
+ lines.push([
18571
+ "p",
18572
+ {
18573
+ class: "DELETE_ASK_SURE",
18574
+ i18n: "are you sure"
18575
+ },
18576
+ (0, i18next.t)("are you sure")
18577
+ ]);
18578
+ return (0, _yuneta_gobj_js.createElement2)([
18579
+ "div",
18580
+ { class: "DELETE_ASK" },
18581
+ lines
18582
+ ]);
18583
+ }
18584
+ /************************************************************
18404
18585
  * Ask the host for one page and park the promise Tabulator wants.
18405
18586
  *
18406
18587
  * The transport belongs to the HOST, so the request goes up as an event
@@ -54889,7 +55070,7 @@ function show_confirm_popover(gobj, target_el, message, confirm_text, confirm_co
54889
55070
  let containerRect = priv.$container.getBoundingClientRect();
54890
55071
  const popover = create_popover_base(iconRect.right - containerRect.left + 6, iconRect.top - containerRect.top - 4, "g6-confirm-popover", "#ff4d4f", 160);
54891
55072
  let msg = document.createElement("div");
54892
- msg.style.cssText = "margin-bottom:10px;font-weight:500;";
55073
+ msg.style.cssText = "margin-bottom:10px;font-weight:500;white-space:pre-line;";
54893
55074
  msg.textContent = message;
54894
55075
  popover.appendChild(msg);
54895
55076
  create_form_button_row(popover, [{
@@ -56463,7 +56644,11 @@ function show_delete_confirm(gobj, nodeData) {
56463
56644
  let priv = gobj.priv;
56464
56645
  let record = nodeData.data.record || {};
56465
56646
  let topic_name = nodeData.data.desc.topic_name;
56466
- show_confirm_popover(gobj, priv._node_delete_el, (0, i18next.t)("delete") + " " + topic_name + ": " + record.id + "?", "delete", "#ff4d4f", () => execute_delete_node(gobj, nodeData), "_delete_confirm_el");
56647
+ let impact = delete_impact(nodeData.data.desc, record);
56648
+ let question = (0, i18next.t)("delete") + " " + topic_name + ": " + record.id + "?";
56649
+ if (impact.children > 0) question += "\n" + impact.children + " " + (0, i18next.t)("children will be unlinked, not deleted");
56650
+ if (impact.parents > 0) question += "\n" + (0, i18next.t)("it will be detached from") + " " + impact.parents + " " + (0, i18next.t)("parents");
56651
+ show_confirm_popover(gobj, priv._node_delete_el, question, "delete", "#ff4d4f", () => execute_delete_node(gobj, nodeData), "_delete_confirm_el");
56467
56652
  }
56468
56653
  function hide_delete_confirm(gobj) {
56469
56654
  hide_overlay(gobj, "_delete_confirm_el");
@@ -56621,7 +56806,7 @@ function execute_unlink_edge(gobj, edgeData) {
56621
56806
  function show_unlink_confirm(gobj, edgeData) {
56622
56807
  let priv = gobj.priv;
56623
56808
  const d = edgeData.data;
56624
- show_confirm_popover(gobj, priv._edge_delete_el, (0, i18next.t)("unlink") + " " + d.child_id + " → " + d.parent_id + "?", "unlink", "#ff4d4f", () => execute_unlink_edge(gobj, edgeData), "_unlink_confirm_el");
56809
+ show_confirm_popover(gobj, priv._edge_delete_el, (0, i18next.t)("unlink") + " " + d.child_id + " → " + d.parent_id + "?\n" + (0, i18next.t)("neither record is deleted"), "unlink", "#ff4d4f", () => execute_unlink_edge(gobj, edgeData), "_unlink_confirm_el");
56625
56810
  }
56626
56811
  function hide_unlink_confirm(gobj) {
56627
56812
  hide_overlay(gobj, "_unlink_confirm_el");
@@ -16376,6 +16376,71 @@ function plan_treedb_writes(flags) {
16376
16376
  };
16377
16377
  }
16378
16378
  //#endregion
16379
+ //#region src/delete_impact.js
16380
+ /***********************************************************************
16381
+ * delete_impact.js
16382
+ *
16383
+ * What a delete takes with it, read off the record itself.
16384
+ *
16385
+ * A treedb delete is not one thing. The backend refuses a node
16386
+ * that has links — UNLESS the caller sends `force`, and the topic
16387
+ * views do. With `force`:
16388
+ *
16389
+ * children are UNLINKED, not deleted. They survive, loose.
16390
+ * parents lose the node from their hook.
16391
+ *
16392
+ * So "delete this row" can mean "detach eleven records from their
16393
+ * only parent", and the question that used to be asked — `are you
16394
+ * sure` — said none of it. This counts what is at stake so the
16395
+ * question can name it.
16396
+ *
16397
+ * Read from the record the table ALREADY has (`list_dict` fills
16398
+ * the hook and fkey columns), so asking costs no round trip.
16399
+ *
16400
+ * Pure and tested, because the shapes are the fiddly part: a hook
16401
+ * or fkey value arrives as a list of refs, a dict keyed by id, or
16402
+ * a single ref string, depending on the column's declared type.
16403
+ *
16404
+ * Copyright (c) 2026, ArtGins.
16405
+ * All Rights Reserved.
16406
+ ***********************************************************************/
16407
+ /***************************************************************
16408
+ * How many things a hook/fkey value holds.
16409
+ ***************************************************************/
16410
+ function ref_count(value) {
16411
+ if (Array.isArray(value)) return value.length;
16412
+ if (value && typeof value === "object") return Object.keys(value).length;
16413
+ if (typeof value === "string" && value.length > 0) return 1;
16414
+ return 0;
16415
+ }
16416
+ /***************************************************************
16417
+ * delete_impact(desc, records) -> {records, children, parents}
16418
+ *
16419
+ * `records` is one record or a list of them, so the same answer
16420
+ * serves the one-row delete and the selection.
16421
+ ***************************************************************/
16422
+ function delete_impact(desc, records) {
16423
+ let list = Array.isArray(records) ? records : records ? [records] : [];
16424
+ let out = {
16425
+ records: list.length,
16426
+ children: 0,
16427
+ parents: 0
16428
+ };
16429
+ let cols = desc && Array.isArray(desc.cols) ? desc.cols : [];
16430
+ if (!cols.length) return out;
16431
+ for (let record of list) {
16432
+ if (!record) continue;
16433
+ for (let col of cols) {
16434
+ if (!col || !col.id || !Array.isArray(col.flag)) continue;
16435
+ let n = ref_count(record[col.id]);
16436
+ if (n === 0) continue;
16437
+ if (col.flag.indexOf("hook") >= 0) out.children += n;
16438
+ if (col.flag.indexOf("fkey") >= 0) out.parents += n;
16439
+ }
16440
+ }
16441
+ return out;
16442
+ }
16443
+ //#endregion
16379
16444
  //#region src/c_yui_treedb_topic_with_form.js
16380
16445
  /***********************************************************************
16381
16446
  * c_yui_treedb_topic_with_form.js
@@ -18214,7 +18279,7 @@ function ac_delete_rows(gobj, event, kw, src) {
18214
18279
  });
18215
18280
  return 0;
18216
18281
  }
18217
- yui_shell_confirm_yesnocancel(yui_shell_of(gobj), "are you sure", {
18282
+ yui_shell_confirm_yesnocancel(yui_shell_of(gobj), build_delete_question(gobj, rows), {
18218
18283
  t,
18219
18284
  yes_label: "yes",
18220
18285
  no_label: "no",
@@ -18225,7 +18290,7 @@ function ac_delete_rows(gobj, event, kw, src) {
18225
18290
  record: row
18226
18291
  });
18227
18292
  });
18228
- } else yui_shell_confirm_yesnocancel(yui_shell_of(gobj), "are you sure", {
18293
+ } else yui_shell_confirm_yesnocancel(yui_shell_of(gobj), build_delete_question(gobj, kw.row), {
18229
18294
  t,
18230
18295
  yes_label: "yes",
18231
18296
  no_label: "no",
@@ -18396,6 +18461,122 @@ function ac_show_schema(gobj, event, kw, src) {
18396
18461
  return 0;
18397
18462
  }
18398
18463
  /************************************************************
18464
+ * The question a delete deserves.
18465
+ *
18466
+ * These views delete with `force`, and `force` on a treedb node does
18467
+ * not only remove it: its children are UNLINKED — they survive, loose
18468
+ * — and it is cleaned off its parents. So "delete this row" can mean
18469
+ * "detach eleven records from their only parent", and `are you sure`
18470
+ * said none of it.
18471
+ *
18472
+ * Built as DOM, not as a string, and that is forced rather than
18473
+ * chosen: the dialog renders its message AS AN I18N KEY, so a
18474
+ * composed sentence could never be one. Each translatable half
18475
+ * carries its own key and the numbers sit between them as data —
18476
+ * which is also the only way the question survives a language switch.
18477
+ ************************************************************/
18478
+ function build_delete_question(gobj, records) {
18479
+ let desc = gobj_read_attr(gobj, "desc");
18480
+ let impact = delete_impact(desc, records);
18481
+ const ROW = "display:flex; align-items:baseline; gap:.35rem; flex-wrap:wrap;";
18482
+ let lines = [];
18483
+ let pkey = desc && desc.pkey || "id";
18484
+ let one = impact.records === 1 && Array.isArray(records) ? records[0] : impact.records === 1 ? records : null;
18485
+ if (one && one[pkey] !== void 0 && one[pkey] !== null) lines.push([
18486
+ "p",
18487
+ {
18488
+ class: "DELETE_ASK_WHAT",
18489
+ style: ROW
18490
+ },
18491
+ [[
18492
+ "span",
18493
+ { i18n: "delete" },
18494
+ t("delete")
18495
+ ], [
18496
+ "strong",
18497
+ {},
18498
+ String(one[pkey])
18499
+ ]]
18500
+ ]);
18501
+ else lines.push([
18502
+ "p",
18503
+ {
18504
+ class: "DELETE_ASK_WHAT",
18505
+ style: ROW
18506
+ },
18507
+ [
18508
+ [
18509
+ "span",
18510
+ { i18n: "delete" },
18511
+ t("delete")
18512
+ ],
18513
+ [
18514
+ "strong",
18515
+ {},
18516
+ String(impact.records)
18517
+ ],
18518
+ [
18519
+ "span",
18520
+ { i18n: "records" },
18521
+ t("records")
18522
+ ]
18523
+ ]
18524
+ ]);
18525
+ if (impact.children > 0) lines.push([
18526
+ "p",
18527
+ {
18528
+ class: "DELETE_ASK_CHILDREN has-text-weight-semibold",
18529
+ style: ROW
18530
+ },
18531
+ [[
18532
+ "strong",
18533
+ {},
18534
+ String(impact.children)
18535
+ ], [
18536
+ "span",
18537
+ { i18n: "children will be unlinked, not deleted" },
18538
+ t("children will be unlinked, not deleted")
18539
+ ]]
18540
+ ]);
18541
+ if (impact.parents > 0) lines.push([
18542
+ "p",
18543
+ {
18544
+ class: "DELETE_ASK_PARENTS",
18545
+ style: ROW
18546
+ },
18547
+ [
18548
+ [
18549
+ "span",
18550
+ { i18n: "it will be detached from" },
18551
+ t("it will be detached from")
18552
+ ],
18553
+ [
18554
+ "strong",
18555
+ {},
18556
+ String(impact.parents)
18557
+ ],
18558
+ [
18559
+ "span",
18560
+ { i18n: "parents" },
18561
+ t("parents")
18562
+ ]
18563
+ ]
18564
+ ]);
18565
+ lines.push([
18566
+ "p",
18567
+ {
18568
+ class: "DELETE_ASK_SURE",
18569
+ i18n: "are you sure"
18570
+ },
18571
+ t("are you sure")
18572
+ ]);
18573
+ return createElement2([
18574
+ "div",
18575
+ { class: "DELETE_ASK" },
18576
+ lines
18577
+ ]);
18578
+ }
18579
+ /************************************************************
18399
18580
  * Ask the host for one page and park the promise Tabulator wants.
18400
18581
  *
18401
18582
  * The transport belongs to the HOST, so the request goes up as an event
@@ -54876,7 +55057,7 @@ function show_confirm_popover(gobj, target_el, message, confirm_text, confirm_co
54876
55057
  let containerRect = priv.$container.getBoundingClientRect();
54877
55058
  const popover = create_popover_base(iconRect.right - containerRect.left + 6, iconRect.top - containerRect.top - 4, "g6-confirm-popover", "#ff4d4f", 160);
54878
55059
  let msg = document.createElement("div");
54879
- msg.style.cssText = "margin-bottom:10px;font-weight:500;";
55060
+ msg.style.cssText = "margin-bottom:10px;font-weight:500;white-space:pre-line;";
54880
55061
  msg.textContent = message;
54881
55062
  popover.appendChild(msg);
54882
55063
  create_form_button_row(popover, [{
@@ -56450,7 +56631,11 @@ function show_delete_confirm(gobj, nodeData) {
56450
56631
  let priv = gobj.priv;
56451
56632
  let record = nodeData.data.record || {};
56452
56633
  let topic_name = nodeData.data.desc.topic_name;
56453
- show_confirm_popover(gobj, priv._node_delete_el, t("delete") + " " + topic_name + ": " + record.id + "?", "delete", "#ff4d4f", () => execute_delete_node(gobj, nodeData), "_delete_confirm_el");
56634
+ let impact = delete_impact(nodeData.data.desc, record);
56635
+ let question = t("delete") + " " + topic_name + ": " + record.id + "?";
56636
+ if (impact.children > 0) question += "\n" + impact.children + " " + t("children will be unlinked, not deleted");
56637
+ if (impact.parents > 0) question += "\n" + t("it will be detached from") + " " + impact.parents + " " + t("parents");
56638
+ show_confirm_popover(gobj, priv._node_delete_el, question, "delete", "#ff4d4f", () => execute_delete_node(gobj, nodeData), "_delete_confirm_el");
56454
56639
  }
56455
56640
  function hide_delete_confirm(gobj) {
56456
56641
  hide_overlay(gobj, "_delete_confirm_el");
@@ -56608,7 +56793,7 @@ function execute_unlink_edge(gobj, edgeData) {
56608
56793
  function show_unlink_confirm(gobj, edgeData) {
56609
56794
  let priv = gobj.priv;
56610
56795
  const d = edgeData.data;
56611
- show_confirm_popover(gobj, priv._edge_delete_el, t("unlink") + " " + d.child_id + " → " + d.parent_id + "?", "unlink", "#ff4d4f", () => execute_unlink_edge(gobj, edgeData), "_unlink_confirm_el");
56796
+ show_confirm_popover(gobj, priv._edge_delete_el, t("unlink") + " " + d.child_id + " → " + d.parent_id + "?\n" + t("neither record is deleted"), "unlink", "#ff4d4f", () => execute_unlink_edge(gobj, edgeData), "_unlink_confirm_el");
56612
56797
  }
56613
56798
  function hide_unlink_confirm(gobj) {
56614
56799
  hide_overlay(gobj, "_unlink_confirm_el");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuneta/gobj-ui",
3
- "version": "7.9.2",
3
+ "version": "7.10.1",
4
4
  "type": "module",
5
5
  "main": "dist/gobj-ui.cjs.js",
6
6
  "module": "dist/gobj-ui.es.js",
@@ -81,6 +81,7 @@ import {
81
81
  } from "./lib_graph.js";
82
82
 
83
83
  import {node_label} from "./treedb_node_label.js";
84
+ import {delete_impact} from "./delete_impact.js";
84
85
 
85
86
  import {
86
87
  BaseLayout,
@@ -2782,7 +2783,9 @@ function show_confirm_popover(gobj, target_el, message, confirm_text, confirm_co
2782
2783
 
2783
2784
  // Message
2784
2785
  let msg = document.createElement('div');
2785
- msg.style.cssText = 'margin-bottom:10px;font-weight:500;';
2786
+ /* pre-line: a confirmation that has to say what it takes with it needs
2787
+ * a second line, and textContent alone would run it together. */
2788
+ msg.style.cssText = 'margin-bottom:10px;font-weight:500;white-space:pre-line;';
2786
2789
  msg.textContent = message;
2787
2790
  popover.appendChild(msg);
2788
2791
 
@@ -5001,8 +5004,23 @@ function show_delete_confirm(gobj, nodeData)
5001
5004
  let record = nodeData.data.record || {};
5002
5005
  let topic_name = nodeData.data.desc.topic_name;
5003
5006
 
5007
+ /* What the delete takes with it. These views delete with `force`, and
5008
+ * `force` UNLINKS a node's children — they survive, loose — and cleans
5009
+ * it off its parents. A question that names only the node lets an
5010
+ * operator detach eleven records believing they removed one. */
5011
+ let impact = delete_impact(nodeData.data.desc, record);
5012
+ let question = t('delete') + ' ' + topic_name + ': ' + record.id + '?';
5013
+ if(impact.children > 0) {
5014
+ question += '\n' + impact.children + ' ' +
5015
+ t('children will be unlinked, not deleted');
5016
+ }
5017
+ if(impact.parents > 0) {
5018
+ question += '\n' + t('it will be detached from') + ' ' +
5019
+ impact.parents + ' ' + t('parents');
5020
+ }
5021
+
5004
5022
  show_confirm_popover(gobj, priv._node_delete_el,
5005
- t('delete') + ' ' + topic_name + ': ' + record.id + '?',
5023
+ question,
5006
5024
  'delete', '#ff4d4f',
5007
5025
  () => execute_delete_node(gobj, nodeData),
5008
5026
  '_delete_confirm_el'
@@ -5254,8 +5272,12 @@ function show_unlink_confirm(gobj, edgeData)
5254
5272
  let priv = gobj.priv;
5255
5273
  const d = edgeData.data;
5256
5274
 
5275
+ /* The reassurance is the point: an unlink removes the LINK and nothing
5276
+ * else, and next to a delete button painted the same red, that is not
5277
+ * obvious. */
5257
5278
  show_confirm_popover(gobj, priv._edge_delete_el,
5258
- t('unlink') + ' ' + d.child_id + ' → ' + d.parent_id + '?',
5279
+ t('unlink') + ' ' + d.child_id + ' → ' + d.parent_id + '?' +
5280
+ '\n' + t('neither record is deleted'),
5259
5281
  'unlink', '#ff4d4f',
5260
5282
  () => execute_unlink_edge(gobj, edgeData),
5261
5283
  '_unlink_confirm_el'
@@ -78,6 +78,7 @@ import {yui_tabulator_lang, yui_tabulator_relocalize} from "./yui_tabulator_i18n
78
78
  import {t} from "i18next";
79
79
 
80
80
  import {plan_treedb_writes} from "./treedb_write_plan.js";
81
+ import {delete_impact} from "./delete_impact.js";
81
82
 
82
83
  import "./c_yui_treedb_topic_with_form.css";
83
84
  import "./tabulator.css";
@@ -2724,7 +2725,7 @@ function ac_delete_rows(gobj, event, kw, src)
2724
2725
  }
2725
2726
 
2726
2727
  yui_shell_confirm_yesnocancel(
2727
- yui_shell_of(gobj), 'are you sure',
2728
+ yui_shell_of(gobj), build_delete_question(gobj, rows),
2728
2729
  {t: t, yes_label: "yes", no_label: "no", cancel_label: "cancel"}
2729
2730
  ).then(function(answer) {
2730
2731
  if(answer === "yes") {
@@ -2748,7 +2749,7 @@ function ac_delete_rows(gobj, event, kw, src)
2748
2749
  * {index: , row: }
2749
2750
  *----------------------------*/
2750
2751
  yui_shell_confirm_yesnocancel(
2751
- yui_shell_of(gobj), 'are you sure',
2752
+ yui_shell_of(gobj), build_delete_question(gobj, kw.row),
2752
2753
  {t: t, yes_label: "yes", no_label: "no", cancel_label: "cancel"}
2753
2754
  ).then(function(answer) {
2754
2755
  if(answer === "yes") {
@@ -3019,6 +3020,73 @@ function ac_show_schema(gobj, event, kw, src)
3019
3020
  return 0;
3020
3021
  }
3021
3022
 
3023
+ /************************************************************
3024
+ * The question a delete deserves.
3025
+ *
3026
+ * These views delete with `force`, and `force` on a treedb node does
3027
+ * not only remove it: its children are UNLINKED — they survive, loose
3028
+ * — and it is cleaned off its parents. So "delete this row" can mean
3029
+ * "detach eleven records from their only parent", and `are you sure`
3030
+ * said none of it.
3031
+ *
3032
+ * Built as DOM, not as a string, and that is forced rather than
3033
+ * chosen: the dialog renders its message AS AN I18N KEY, so a
3034
+ * composed sentence could never be one. Each translatable half
3035
+ * carries its own key and the numbers sit between them as data —
3036
+ * which is also the only way the question survives a language switch.
3037
+ ************************************************************/
3038
+ function build_delete_question(gobj, records)
3039
+ {
3040
+ let desc = gobj_read_attr(gobj, "desc");
3041
+ let impact = delete_impact(desc, records);
3042
+
3043
+ /* Every line is a flex row with a GAP, and the words carry no spaces of
3044
+ * their own: `createElement2` TRIMS text nodes, so a `["span", {}, " "]`
3045
+ * separator vanishes and the question reads "BorrarDeveloper". Spacing
3046
+ * is CSS here, as it has to be anywhere text is composed from keys. */
3047
+ const ROW = "display:flex; align-items:baseline; gap:.35rem; flex-wrap:wrap;";
3048
+ let lines = [];
3049
+
3050
+ /* What is being deleted: one named record, or how many. */
3051
+ let pkey = (desc && desc.pkey) || "id";
3052
+ let one = (impact.records === 1 && Array.isArray(records))? records[0] :
3053
+ (impact.records === 1? records : null);
3054
+ if(one && one[pkey] !== undefined && one[pkey] !== null) {
3055
+ lines.push(["p", {class: "DELETE_ASK_WHAT", style: ROW}, [
3056
+ ["span", {i18n: "delete"}, t("delete")],
3057
+ ["strong", {}, String(one[pkey])]
3058
+ ]]);
3059
+ } else {
3060
+ lines.push(["p", {class: "DELETE_ASK_WHAT", style: ROW}, [
3061
+ ["span", {i18n: "delete"}, t("delete")],
3062
+ ["strong", {}, String(impact.records)],
3063
+ ["span", {i18n: "records"}, t("records")]
3064
+ ]]);
3065
+ }
3066
+
3067
+ /* What goes with it. Said only when there IS something: a loose
3068
+ * record must not be dressed up as a dangerous one. */
3069
+ if(impact.children > 0) {
3070
+ lines.push(["p", {class: "DELETE_ASK_CHILDREN has-text-weight-semibold",
3071
+ style: ROW}, [
3072
+ ["strong", {}, String(impact.children)],
3073
+ ["span", {i18n: "children will be unlinked, not deleted"},
3074
+ t("children will be unlinked, not deleted")]
3075
+ ]]);
3076
+ }
3077
+ if(impact.parents > 0) {
3078
+ lines.push(["p", {class: "DELETE_ASK_PARENTS", style: ROW}, [
3079
+ ["span", {i18n: "it will be detached from"}, t("it will be detached from")],
3080
+ ["strong", {}, String(impact.parents)],
3081
+ ["span", {i18n: "parents"}, t("parents")]
3082
+ ]]);
3083
+ }
3084
+
3085
+ lines.push(["p", {class: "DELETE_ASK_SURE", i18n: "are you sure"}, t("are you sure")]);
3086
+
3087
+ return createElement2(["div", {class: "DELETE_ASK"}, lines]);
3088
+ }
3089
+
3022
3090
  /************************************************************
3023
3091
  * Ask the host for one page and park the promise Tabulator wants.
3024
3092
  *
@@ -0,0 +1,90 @@
1
+ /***********************************************************************
2
+ * delete_impact.js
3
+ *
4
+ * What a delete takes with it, read off the record itself.
5
+ *
6
+ * A treedb delete is not one thing. The backend refuses a node
7
+ * that has links — UNLESS the caller sends `force`, and the topic
8
+ * views do. With `force`:
9
+ *
10
+ * children are UNLINKED, not deleted. They survive, loose.
11
+ * parents lose the node from their hook.
12
+ *
13
+ * So "delete this row" can mean "detach eleven records from their
14
+ * only parent", and the question that used to be asked — `are you
15
+ * sure` — said none of it. This counts what is at stake so the
16
+ * question can name it.
17
+ *
18
+ * Read from the record the table ALREADY has (`list_dict` fills
19
+ * the hook and fkey columns), so asking costs no round trip.
20
+ *
21
+ * Pure and tested, because the shapes are the fiddly part: a hook
22
+ * or fkey value arrives as a list of refs, a dict keyed by id, or
23
+ * a single ref string, depending on the column's declared type.
24
+ *
25
+ * Copyright (c) 2026, ArtGins.
26
+ * All Rights Reserved.
27
+ ***********************************************************************/
28
+
29
+ /***************************************************************
30
+ * How many things a hook/fkey value holds.
31
+ ***************************************************************/
32
+ function ref_count(value)
33
+ {
34
+ if(Array.isArray(value)) {
35
+ return value.length;
36
+ }
37
+ if(value && typeof value === "object") {
38
+ return Object.keys(value).length;
39
+ }
40
+ if(typeof value === "string" && value.length > 0) {
41
+ return 1;
42
+ }
43
+ return 0;
44
+ }
45
+
46
+ /***************************************************************
47
+ * delete_impact(desc, records) -> {records, children, parents}
48
+ *
49
+ * `records` is one record or a list of them, so the same answer
50
+ * serves the one-row delete and the selection.
51
+ ***************************************************************/
52
+ function delete_impact(desc, records)
53
+ {
54
+ let list = Array.isArray(records)? records : (records? [records] : []);
55
+ let out = {records: list.length, children: 0, parents: 0};
56
+
57
+ let cols = (desc && Array.isArray(desc.cols))? desc.cols : [];
58
+ if(!cols.length) {
59
+ return out;
60
+ }
61
+
62
+ for(let record of list) {
63
+ if(!record) {
64
+ continue;
65
+ }
66
+ for(let col of cols) {
67
+ if(!col || !col.id || !Array.isArray(col.flag)) {
68
+ continue;
69
+ }
70
+ let n = ref_count(record[col.id]);
71
+ if(n === 0) {
72
+ continue;
73
+ }
74
+ /* A column can be BOTH (a department's `users` is a hook to its
75
+ * users and a fkey to the department that manages it). Counting
76
+ * it on both sides is right: the delete does both things. */
77
+ if(col.flag.indexOf("hook") >= 0) {
78
+ out.children += n;
79
+ }
80
+ if(col.flag.indexOf("fkey") >= 0) {
81
+ out.parents += n;
82
+ }
83
+ }
84
+ }
85
+
86
+ return out;
87
+ }
88
+
89
+
90
+ export {delete_impact, ref_count};
@@ -0,0 +1,61 @@
1
+ import {describe, it, expect} from "vitest";
2
+ import {delete_impact, ref_count} from "./delete_impact.js";
3
+
4
+ const DESC = {
5
+ cols: [
6
+ {id: "id", flag: ["persistent", "required"]},
7
+ {id: "name", flag: ["persistent", "writable"]},
8
+ {id: "users", flag: ["hook"]},
9
+ {id: "parent", flag: ["fkey"]},
10
+ {id: "managers", flag: ["hook", "fkey"]}
11
+ ]
12
+ };
13
+
14
+ describe("ref_count", () => {
15
+ it("counts a list, a dict, a ref string and nothing", () => {
16
+ expect(ref_count([1, 2, 3])).toBe(3);
17
+ expect(ref_count({a: 1, b: 2})).toBe(2);
18
+ expect(ref_count("topic^id^hook")).toBe(1);
19
+ expect(ref_count("")).toBe(0);
20
+ expect(ref_count(null)).toBe(0);
21
+ expect(ref_count(undefined)).toBe(0);
22
+ expect(ref_count(7)).toBe(0);
23
+ });
24
+ });
25
+
26
+ describe("delete_impact", () => {
27
+ it("a loose record takes nothing with it", () => {
28
+ let i = delete_impact(DESC, {id: "a", name: "A"});
29
+ expect(i).toEqual({records: 1, children: 0, parents: 0});
30
+ });
31
+
32
+ it("counts children off a hook and parents off a fkey", () => {
33
+ let i = delete_impact(DESC, {
34
+ id: "a", users: [{id: "u1"}, {id: "u2"}], parent: "roles^root^users"
35
+ });
36
+ expect(i.children).toBe(2);
37
+ expect(i.parents).toBe(1);
38
+ });
39
+
40
+ it("a column that is BOTH counts on both sides: the delete does both", () => {
41
+ let i = delete_impact(DESC, {id: "a", managers: [{id: "m1"}, {id: "m2"}]});
42
+ expect(i.children).toBe(2);
43
+ expect(i.parents).toBe(2);
44
+ });
45
+
46
+ it("adds up over a selection", () => {
47
+ let i = delete_impact(DESC, [
48
+ {id: "a", users: [{id: "u1"}]},
49
+ {id: "b", users: {u2: {}, u3: {}}, parent: "x^y^z"}
50
+ ]);
51
+ expect(i.records).toBe(2);
52
+ expect(i.children).toBe(3);
53
+ expect(i.parents).toBe(1);
54
+ });
55
+
56
+ it("says nothing it cannot know", () => {
57
+ expect(delete_impact(null, {id: "a"})).toEqual({records: 1, children: 0, parents: 0});
58
+ expect(delete_impact(DESC, [])).toEqual({records: 0, children: 0, parents: 0});
59
+ expect(delete_impact(DESC, null)).toEqual({records: 0, children: 0, parents: 0});
60
+ });
61
+ });