@yuneta/gobj-ui 7.9.1 → 7.10.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
@@ -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
@@ -16447,6 +16512,7 @@ var attrs_table$5 = [
16447
16512
  var PRIVATE_DATA$5 = {
16448
16513
  _pending_pages: null,
16449
16514
  _page_seq: 0,
16515
+ _page_total: null,
16450
16516
  $container: null,
16451
16517
  treedb_name: "",
16452
16518
  topic_name: "",
@@ -17215,11 +17281,19 @@ function create_tabulator(gobj) {
17215
17281
  } catch (e) {
17216
17282
  n = 0;
17217
17283
  }
17218
- $rc.textContent = `${n} ${(0, i18next.t)("rows")}`;
17284
+ let paged = (0, _yuneta_gobj_js.gobj_read_bool_attr)(gobj, "with_remote_paging");
17285
+ let total = gobj.priv._page_total;
17286
+ if (paged && typeof total === "number" && total > n) $rc.textContent = `${n} / ${total} ${(0, i18next.t)("rows")}`;
17287
+ else $rc.textContent = `${n} ${(0, i18next.t)("rows")}`;
17219
17288
  let single_page = true;
17220
17289
  try {
17221
- let size = tabulator.getPageSize();
17222
- single_page = !size || n <= size;
17290
+ if (paged) {
17291
+ let max = tabulator.getPageMax();
17292
+ single_page = max === false || max <= 1;
17293
+ } else {
17294
+ let size = tabulator.getPageSize();
17295
+ single_page = !size || n <= size;
17296
+ }
17223
17297
  } catch (e) {
17224
17298
  single_page = true;
17225
17299
  }
@@ -18210,7 +18284,7 @@ function ac_delete_rows(gobj, event, kw, src) {
18210
18284
  });
18211
18285
  return 0;
18212
18286
  }
18213
- 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), {
18214
18288
  t: i18next.t,
18215
18289
  yes_label: "yes",
18216
18290
  no_label: "no",
@@ -18221,7 +18295,7 @@ function ac_delete_rows(gobj, event, kw, src) {
18221
18295
  record: row
18222
18296
  });
18223
18297
  });
18224
- } 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), {
18225
18299
  t: i18next.t,
18226
18300
  yes_label: "yes",
18227
18301
  no_label: "no",
@@ -18392,6 +18466,145 @@ function ac_show_schema(gobj, event, kw, src) {
18392
18466
  return 0;
18393
18467
  }
18394
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
+ let lines = [];
18487
+ let pkey = desc && desc.pkey || "id";
18488
+ let one = impact.records === 1 && Array.isArray(records) ? records[0] : impact.records === 1 ? records : null;
18489
+ if (one && one[pkey] !== void 0 && one[pkey] !== null) lines.push([
18490
+ "p",
18491
+ { class: "DELETE_ASK_WHAT" },
18492
+ [
18493
+ [
18494
+ "span",
18495
+ { i18n: "delete" },
18496
+ (0, i18next.t)("delete")
18497
+ ],
18498
+ [
18499
+ "span",
18500
+ {},
18501
+ " "
18502
+ ],
18503
+ [
18504
+ "strong",
18505
+ {},
18506
+ String(one[pkey])
18507
+ ]
18508
+ ]
18509
+ ]);
18510
+ else lines.push([
18511
+ "p",
18512
+ { class: "DELETE_ASK_WHAT" },
18513
+ [
18514
+ [
18515
+ "span",
18516
+ { i18n: "delete" },
18517
+ (0, i18next.t)("delete")
18518
+ ],
18519
+ [
18520
+ "span",
18521
+ {},
18522
+ " "
18523
+ ],
18524
+ [
18525
+ "strong",
18526
+ {},
18527
+ String(impact.records)
18528
+ ],
18529
+ [
18530
+ "span",
18531
+ {},
18532
+ " "
18533
+ ],
18534
+ [
18535
+ "span",
18536
+ { i18n: "records" },
18537
+ (0, i18next.t)("records")
18538
+ ]
18539
+ ]
18540
+ ]);
18541
+ if (impact.children > 0) lines.push([
18542
+ "p",
18543
+ { class: "DELETE_ASK_CHILDREN has-text-weight-semibold" },
18544
+ [
18545
+ [
18546
+ "strong",
18547
+ {},
18548
+ String(impact.children)
18549
+ ],
18550
+ [
18551
+ "span",
18552
+ {},
18553
+ " "
18554
+ ],
18555
+ [
18556
+ "span",
18557
+ { i18n: "children will be unlinked, not deleted" },
18558
+ (0, i18next.t)("children will be unlinked, not deleted")
18559
+ ]
18560
+ ]
18561
+ ]);
18562
+ if (impact.parents > 0) lines.push([
18563
+ "p",
18564
+ { class: "DELETE_ASK_PARENTS" },
18565
+ [
18566
+ [
18567
+ "span",
18568
+ { i18n: "it will be detached from" },
18569
+ (0, i18next.t)("it will be detached from")
18570
+ ],
18571
+ [
18572
+ "span",
18573
+ {},
18574
+ " "
18575
+ ],
18576
+ [
18577
+ "strong",
18578
+ {},
18579
+ String(impact.parents)
18580
+ ],
18581
+ [
18582
+ "span",
18583
+ {},
18584
+ " "
18585
+ ],
18586
+ [
18587
+ "span",
18588
+ { i18n: "parents" },
18589
+ (0, i18next.t)("parents")
18590
+ ]
18591
+ ]
18592
+ ]);
18593
+ lines.push([
18594
+ "p",
18595
+ {
18596
+ class: "DELETE_ASK_SURE",
18597
+ i18n: "are you sure"
18598
+ },
18599
+ (0, i18next.t)("are you sure")
18600
+ ]);
18601
+ return (0, _yuneta_gobj_js.createElement2)([
18602
+ "div",
18603
+ { class: "DELETE_ASK" },
18604
+ lines
18605
+ ]);
18606
+ }
18607
+ /************************************************************
18395
18608
  * Ask the host for one page and park the promise Tabulator wants.
18396
18609
  *
18397
18610
  * The transport belongs to the HOST, so the request goes up as an event
@@ -18460,6 +18673,7 @@ function ac_repull_page(gobj, event, kw, src) {
18460
18673
  * The host answered a page.
18461
18674
  ************************************************************/
18462
18675
  function ac_page_loaded(gobj, event, kw, src) {
18676
+ if (typeof kw.total === "number") gobj.priv._page_total = kw.total;
18463
18677
  settle_page(gobj, kw.req_id, {
18464
18678
  data: (0, _yuneta_gobj_js.is_array)(kw.rows) ? kw.rows : [],
18465
18679
  last_page: kw.pages || 1,
@@ -54879,7 +55093,7 @@ function show_confirm_popover(gobj, target_el, message, confirm_text, confirm_co
54879
55093
  let containerRect = priv.$container.getBoundingClientRect();
54880
55094
  const popover = create_popover_base(iconRect.right - containerRect.left + 6, iconRect.top - containerRect.top - 4, "g6-confirm-popover", "#ff4d4f", 160);
54881
55095
  let msg = document.createElement("div");
54882
- msg.style.cssText = "margin-bottom:10px;font-weight:500;";
55096
+ msg.style.cssText = "margin-bottom:10px;font-weight:500;white-space:pre-line;";
54883
55097
  msg.textContent = message;
54884
55098
  popover.appendChild(msg);
54885
55099
  create_form_button_row(popover, [{
@@ -56453,7 +56667,11 @@ function show_delete_confirm(gobj, nodeData) {
56453
56667
  let priv = gobj.priv;
56454
56668
  let record = nodeData.data.record || {};
56455
56669
  let topic_name = nodeData.data.desc.topic_name;
56456
- 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");
56670
+ let impact = delete_impact(nodeData.data.desc, record);
56671
+ let question = (0, i18next.t)("delete") + " " + topic_name + ": " + record.id + "?";
56672
+ if (impact.children > 0) question += "\n" + impact.children + " " + (0, i18next.t)("children will be unlinked, not deleted");
56673
+ if (impact.parents > 0) question += "\n" + (0, i18next.t)("it will be detached from") + " " + impact.parents + " " + (0, i18next.t)("parents");
56674
+ show_confirm_popover(gobj, priv._node_delete_el, question, "delete", "#ff4d4f", () => execute_delete_node(gobj, nodeData), "_delete_confirm_el");
56457
56675
  }
56458
56676
  function hide_delete_confirm(gobj) {
56459
56677
  hide_overlay(gobj, "_delete_confirm_el");
@@ -56611,7 +56829,7 @@ function execute_unlink_edge(gobj, edgeData) {
56611
56829
  function show_unlink_confirm(gobj, edgeData) {
56612
56830
  let priv = gobj.priv;
56613
56831
  const d = edgeData.data;
56614
- 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");
56832
+ 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");
56615
56833
  }
56616
56834
  function hide_unlink_confirm(gobj) {
56617
56835
  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
@@ -16442,6 +16507,7 @@ var attrs_table$5 = [
16442
16507
  var PRIVATE_DATA$5 = {
16443
16508
  _pending_pages: null,
16444
16509
  _page_seq: 0,
16510
+ _page_total: null,
16445
16511
  $container: null,
16446
16512
  treedb_name: "",
16447
16513
  topic_name: "",
@@ -17210,11 +17276,19 @@ function create_tabulator(gobj) {
17210
17276
  } catch (e) {
17211
17277
  n = 0;
17212
17278
  }
17213
- $rc.textContent = `${n} ${t("rows")}`;
17279
+ let paged = gobj_read_bool_attr(gobj, "with_remote_paging");
17280
+ let total = gobj.priv._page_total;
17281
+ if (paged && typeof total === "number" && total > n) $rc.textContent = `${n} / ${total} ${t("rows")}`;
17282
+ else $rc.textContent = `${n} ${t("rows")}`;
17214
17283
  let single_page = true;
17215
17284
  try {
17216
- let size = tabulator.getPageSize();
17217
- single_page = !size || n <= size;
17285
+ if (paged) {
17286
+ let max = tabulator.getPageMax();
17287
+ single_page = max === false || max <= 1;
17288
+ } else {
17289
+ let size = tabulator.getPageSize();
17290
+ single_page = !size || n <= size;
17291
+ }
17218
17292
  } catch (e) {
17219
17293
  single_page = true;
17220
17294
  }
@@ -18205,7 +18279,7 @@ function ac_delete_rows(gobj, event, kw, src) {
18205
18279
  });
18206
18280
  return 0;
18207
18281
  }
18208
- 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), {
18209
18283
  t,
18210
18284
  yes_label: "yes",
18211
18285
  no_label: "no",
@@ -18216,7 +18290,7 @@ function ac_delete_rows(gobj, event, kw, src) {
18216
18290
  record: row
18217
18291
  });
18218
18292
  });
18219
- } 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), {
18220
18294
  t,
18221
18295
  yes_label: "yes",
18222
18296
  no_label: "no",
@@ -18387,6 +18461,145 @@ function ac_show_schema(gobj, event, kw, src) {
18387
18461
  return 0;
18388
18462
  }
18389
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
+ let lines = [];
18482
+ let pkey = desc && desc.pkey || "id";
18483
+ let one = impact.records === 1 && Array.isArray(records) ? records[0] : impact.records === 1 ? records : null;
18484
+ if (one && one[pkey] !== void 0 && one[pkey] !== null) lines.push([
18485
+ "p",
18486
+ { class: "DELETE_ASK_WHAT" },
18487
+ [
18488
+ [
18489
+ "span",
18490
+ { i18n: "delete" },
18491
+ t("delete")
18492
+ ],
18493
+ [
18494
+ "span",
18495
+ {},
18496
+ " "
18497
+ ],
18498
+ [
18499
+ "strong",
18500
+ {},
18501
+ String(one[pkey])
18502
+ ]
18503
+ ]
18504
+ ]);
18505
+ else lines.push([
18506
+ "p",
18507
+ { class: "DELETE_ASK_WHAT" },
18508
+ [
18509
+ [
18510
+ "span",
18511
+ { i18n: "delete" },
18512
+ t("delete")
18513
+ ],
18514
+ [
18515
+ "span",
18516
+ {},
18517
+ " "
18518
+ ],
18519
+ [
18520
+ "strong",
18521
+ {},
18522
+ String(impact.records)
18523
+ ],
18524
+ [
18525
+ "span",
18526
+ {},
18527
+ " "
18528
+ ],
18529
+ [
18530
+ "span",
18531
+ { i18n: "records" },
18532
+ t("records")
18533
+ ]
18534
+ ]
18535
+ ]);
18536
+ if (impact.children > 0) lines.push([
18537
+ "p",
18538
+ { class: "DELETE_ASK_CHILDREN has-text-weight-semibold" },
18539
+ [
18540
+ [
18541
+ "strong",
18542
+ {},
18543
+ String(impact.children)
18544
+ ],
18545
+ [
18546
+ "span",
18547
+ {},
18548
+ " "
18549
+ ],
18550
+ [
18551
+ "span",
18552
+ { i18n: "children will be unlinked, not deleted" },
18553
+ t("children will be unlinked, not deleted")
18554
+ ]
18555
+ ]
18556
+ ]);
18557
+ if (impact.parents > 0) lines.push([
18558
+ "p",
18559
+ { class: "DELETE_ASK_PARENTS" },
18560
+ [
18561
+ [
18562
+ "span",
18563
+ { i18n: "it will be detached from" },
18564
+ t("it will be detached from")
18565
+ ],
18566
+ [
18567
+ "span",
18568
+ {},
18569
+ " "
18570
+ ],
18571
+ [
18572
+ "strong",
18573
+ {},
18574
+ String(impact.parents)
18575
+ ],
18576
+ [
18577
+ "span",
18578
+ {},
18579
+ " "
18580
+ ],
18581
+ [
18582
+ "span",
18583
+ { i18n: "parents" },
18584
+ t("parents")
18585
+ ]
18586
+ ]
18587
+ ]);
18588
+ lines.push([
18589
+ "p",
18590
+ {
18591
+ class: "DELETE_ASK_SURE",
18592
+ i18n: "are you sure"
18593
+ },
18594
+ t("are you sure")
18595
+ ]);
18596
+ return createElement2([
18597
+ "div",
18598
+ { class: "DELETE_ASK" },
18599
+ lines
18600
+ ]);
18601
+ }
18602
+ /************************************************************
18390
18603
  * Ask the host for one page and park the promise Tabulator wants.
18391
18604
  *
18392
18605
  * The transport belongs to the HOST, so the request goes up as an event
@@ -18455,6 +18668,7 @@ function ac_repull_page(gobj, event, kw, src) {
18455
18668
  * The host answered a page.
18456
18669
  ************************************************************/
18457
18670
  function ac_page_loaded(gobj, event, kw, src) {
18671
+ if (typeof kw.total === "number") gobj.priv._page_total = kw.total;
18458
18672
  settle_page(gobj, kw.req_id, {
18459
18673
  data: is_array(kw.rows) ? kw.rows : [],
18460
18674
  last_page: kw.pages || 1,
@@ -54866,7 +55080,7 @@ function show_confirm_popover(gobj, target_el, message, confirm_text, confirm_co
54866
55080
  let containerRect = priv.$container.getBoundingClientRect();
54867
55081
  const popover = create_popover_base(iconRect.right - containerRect.left + 6, iconRect.top - containerRect.top - 4, "g6-confirm-popover", "#ff4d4f", 160);
54868
55082
  let msg = document.createElement("div");
54869
- msg.style.cssText = "margin-bottom:10px;font-weight:500;";
55083
+ msg.style.cssText = "margin-bottom:10px;font-weight:500;white-space:pre-line;";
54870
55084
  msg.textContent = message;
54871
55085
  popover.appendChild(msg);
54872
55086
  create_form_button_row(popover, [{
@@ -56440,7 +56654,11 @@ function show_delete_confirm(gobj, nodeData) {
56440
56654
  let priv = gobj.priv;
56441
56655
  let record = nodeData.data.record || {};
56442
56656
  let topic_name = nodeData.data.desc.topic_name;
56443
- show_confirm_popover(gobj, priv._node_delete_el, t("delete") + " " + topic_name + ": " + record.id + "?", "delete", "#ff4d4f", () => execute_delete_node(gobj, nodeData), "_delete_confirm_el");
56657
+ let impact = delete_impact(nodeData.data.desc, record);
56658
+ let question = t("delete") + " " + topic_name + ": " + record.id + "?";
56659
+ if (impact.children > 0) question += "\n" + impact.children + " " + t("children will be unlinked, not deleted");
56660
+ if (impact.parents > 0) question += "\n" + t("it will be detached from") + " " + impact.parents + " " + t("parents");
56661
+ show_confirm_popover(gobj, priv._node_delete_el, question, "delete", "#ff4d4f", () => execute_delete_node(gobj, nodeData), "_delete_confirm_el");
56444
56662
  }
56445
56663
  function hide_delete_confirm(gobj) {
56446
56664
  hide_overlay(gobj, "_delete_confirm_el");
@@ -56598,7 +56816,7 @@ function execute_unlink_edge(gobj, edgeData) {
56598
56816
  function show_unlink_confirm(gobj, edgeData) {
56599
56817
  let priv = gobj.priv;
56600
56818
  const d = edgeData.data;
56601
- 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");
56819
+ 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");
56602
56820
  }
56603
56821
  function hide_unlink_confirm(gobj) {
56604
56822
  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.1",
3
+ "version": "7.10.0",
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";
@@ -151,6 +152,7 @@ SDATA_END()
151
152
  let PRIVATE_DATA = {
152
153
  _pending_pages: null, // req_id -> {resolve, reject, timer}
153
154
  _page_seq: 0, // correlation id of a page request
155
+ _page_total: null, // rows the topic HAS, from the last page answer
154
156
  $container: null,
155
157
  treedb_name: "",
156
158
  topic_name: "",
@@ -1203,15 +1205,33 @@ function create_tabulator(gobj)
1203
1205
  n = 0;
1204
1206
  }
1205
1207
  }
1206
- $rc.textContent = `${n} ${t("rows")}`;
1208
+ /* Remote paging: `n` is the PAGE, so saying "50 rows" of a topic
1209
+ * with 114 would be a lie by omission. Say both. */
1210
+ let paged = gobj_read_bool_attr(gobj, "with_remote_paging");
1211
+ let total = gobj.priv._page_total;
1212
+ if(paged && typeof total === "number" && total > n) {
1213
+ $rc.textContent = `${n} / ${total} ${t("rows")}`;
1214
+ } else {
1215
+ $rc.textContent = `${n} ${t("rows")}`;
1216
+ }
1207
1217
 
1208
- /* Derived from the count and the page size rather than read from
1209
- * getPageMax(), which is stale in the filter path for the same
1210
- * reason. getPageSize() throws with pagination off -> one page. */
1211
1218
  let single_page = true;
1212
1219
  try {
1213
- let size = tabulator.getPageSize();
1214
- single_page = !size || n <= size;
1220
+ if(paged) {
1221
+ /* Tabulator knows the last page from the answer's
1222
+ * `last_page`, and that is the only thing that says whether
1223
+ * there is more. Deriving it from the row count instead —
1224
+ * which is what the local path does — hid the paginator on
1225
+ * every FULL page: 50 rows of a 50-row page reads as "it all
1226
+ * fits" and it does not. */
1227
+ let max = tabulator.getPageMax();
1228
+ single_page = (max === false) || max <= 1;
1229
+ } else {
1230
+ /* Derived from the count and the page size rather than read
1231
+ * from getPageMax(), which is stale in the filter path. */
1232
+ let size = tabulator.getPageSize();
1233
+ single_page = !size || n <= size;
1234
+ }
1215
1235
  } catch(e) {
1216
1236
  single_page = true;
1217
1237
  }
@@ -2705,7 +2725,7 @@ function ac_delete_rows(gobj, event, kw, src)
2705
2725
  }
2706
2726
 
2707
2727
  yui_shell_confirm_yesnocancel(
2708
- yui_shell_of(gobj), 'are you sure',
2728
+ yui_shell_of(gobj), build_delete_question(gobj, rows),
2709
2729
  {t: t, yes_label: "yes", no_label: "no", cancel_label: "cancel"}
2710
2730
  ).then(function(answer) {
2711
2731
  if(answer === "yes") {
@@ -2729,7 +2749,7 @@ function ac_delete_rows(gobj, event, kw, src)
2729
2749
  * {index: , row: }
2730
2750
  *----------------------------*/
2731
2751
  yui_shell_confirm_yesnocancel(
2732
- yui_shell_of(gobj), 'are you sure',
2752
+ yui_shell_of(gobj), build_delete_question(gobj, kw.row),
2733
2753
  {t: t, yes_label: "yes", no_label: "no", cancel_label: "cancel"}
2734
2754
  ).then(function(answer) {
2735
2755
  if(answer === "yes") {
@@ -3000,6 +3020,73 @@ function ac_show_schema(gobj, event, kw, src)
3000
3020
  return 0;
3001
3021
  }
3002
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
+ let lines = [];
3044
+
3045
+ /* What is being deleted: one named record, or how many. */
3046
+ let pkey = (desc && desc.pkey) || "id";
3047
+ let one = (impact.records === 1 && Array.isArray(records))? records[0] :
3048
+ (impact.records === 1? records : null);
3049
+ if(one && one[pkey] !== undefined && one[pkey] !== null) {
3050
+ lines.push(["p", {class: "DELETE_ASK_WHAT"}, [
3051
+ ["span", {i18n: "delete"}, t("delete")],
3052
+ ["span", {}, " "],
3053
+ ["strong", {}, String(one[pkey])]
3054
+ ]]);
3055
+ } else {
3056
+ lines.push(["p", {class: "DELETE_ASK_WHAT"}, [
3057
+ ["span", {i18n: "delete"}, t("delete")],
3058
+ ["span", {}, " "],
3059
+ ["strong", {}, String(impact.records)],
3060
+ ["span", {}, " "],
3061
+ ["span", {i18n: "records"}, t("records")]
3062
+ ]]);
3063
+ }
3064
+
3065
+ /* What goes with it. Said only when there IS something: a loose
3066
+ * record must not be dressed up as a dangerous one. */
3067
+ if(impact.children > 0) {
3068
+ lines.push(["p", {class: "DELETE_ASK_CHILDREN has-text-weight-semibold"}, [
3069
+ ["strong", {}, String(impact.children)],
3070
+ ["span", {}, " "],
3071
+ ["span", {i18n: "children will be unlinked, not deleted"},
3072
+ t("children will be unlinked, not deleted")]
3073
+ ]]);
3074
+ }
3075
+ if(impact.parents > 0) {
3076
+ lines.push(["p", {class: "DELETE_ASK_PARENTS"}, [
3077
+ ["span", {i18n: "it will be detached from"}, t("it will be detached from")],
3078
+ ["span", {}, " "],
3079
+ ["strong", {}, String(impact.parents)],
3080
+ ["span", {}, " "],
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
+
3003
3090
  /************************************************************
3004
3091
  * Ask the host for one page and park the promise Tabulator wants.
3005
3092
  *
@@ -3089,6 +3176,9 @@ function ac_repull_page(gobj, event, kw, src)
3089
3176
  ************************************************************/
3090
3177
  function ac_page_loaded(gobj, event, kw, src)
3091
3178
  {
3179
+ if(typeof kw.total === "number") {
3180
+ gobj.priv._page_total = kw.total;
3181
+ }
3092
3182
  settle_page(gobj, kw.req_id, {
3093
3183
  data: is_array(kw.rows)? kw.rows : [],
3094
3184
  last_page: kw.pages || 1,
@@ -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
+ });