@yuneta/gobj-ui 7.8.0 → 7.9.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.
@@ -14411,6 +14411,66 @@ function yui_shell_show_route_map(shell, opts) {
14411
14411
  return modal_ref.modal;
14412
14412
  }
14413
14413
  //#endregion
14414
+ //#region src/nodes_answer.js
14415
+ /***********************************************************************
14416
+ * nodes_answer.js
14417
+ *
14418
+ * What the `nodes` command answered, whatever shape it came in.
14419
+ *
14420
+ * A treedb lives in memory on the backend, so walking it is not
14421
+ * what costs: serializing every node, pushing it through a
14422
+ * websocket and parsing it here is. So `nodes` learned to answer a
14423
+ * PAGE, with the contract `list-keys` of C_TRANGER already used:
14424
+ *
14425
+ * no `limit` -> the plain list it has always answered
14426
+ * a `limit` -> {total_rows, pages, data}
14427
+ *
14428
+ * Both shapes are alive at once and will be for a long time: the
14429
+ * SPA talks to backends the operator configures, and an older one
14430
+ * answers the plain list whatever this app asks for. So the shape
14431
+ * is read, never assumed — a view that indexed `data` as an array
14432
+ * would render an object's keys as rows the day a backend
14433
+ * upgraded, which is the kind of break that looks like a bug in
14434
+ * the app.
14435
+ *
14436
+ * Pure and tested, because this is exactly the sort of thing that
14437
+ * cannot be exercised until the other side moves.
14438
+ *
14439
+ * Copyright (c) 2026, ArtGins.
14440
+ * All Rights Reserved.
14441
+ ***********************************************************************/
14442
+ /***************************************************************
14443
+ * nodes_answer(data) -> {rows, total, pages, paged}
14444
+ *
14445
+ * `rows` is always an array, so a caller never has to ask which
14446
+ * shape it got. `paged` says whether the backend answered a page,
14447
+ * which is the one thing a caller may legitimately care about (it is
14448
+ * what tells a pager it has something to page).
14449
+ ***************************************************************/
14450
+ function nodes_answer(data) {
14451
+ if (Array.isArray(data)) return {
14452
+ rows: data,
14453
+ total: data.length,
14454
+ pages: 1,
14455
+ paged: false
14456
+ };
14457
+ if (data && typeof data === "object" && Array.isArray(data.data)) {
14458
+ let rows = data.data;
14459
+ return {
14460
+ rows,
14461
+ total: typeof data.total_rows === "number" ? data.total_rows : rows.length,
14462
+ pages: typeof data.pages === "number" && data.pages > 0 ? data.pages : 1,
14463
+ paged: true
14464
+ };
14465
+ }
14466
+ return {
14467
+ rows: [],
14468
+ total: 0,
14469
+ pages: 1,
14470
+ paged: false
14471
+ };
14472
+ }
14473
+ //#endregion
14414
14474
  //#region src/c_yui_treedb_topics.js
14415
14475
  /***********************************************************************
14416
14476
  * c_yui_treedb_topics.js
@@ -14432,6 +14492,7 @@ var attrs_table$6 = [
14432
14492
  SDATA(data_type_t.DTP_POINTER, "gobj_remote_yuno", 0, null, "Remote yuno for data fetching"),
14433
14493
  SDATA(data_type_t.DTP_STRING, "treedb_name", 0, null, "Remote TreeDB service name"),
14434
14494
  SDATA(data_type_t.DTP_JSON, "descs", 0, null, "Description of topics"),
14495
+ SDATA(data_type_t.DTP_BOOLEAN, "with_remote_paging", 0, false, "Each topic table pulls its rows a PAGE at a time (`nodes` from/limit) instead of loading the topic whole. Safe against a backend that cannot page: it answers the whole list, which the table reads as one page"),
14435
14496
  SDATA(data_type_t.DTP_BOOLEAN, "system", 0, false, "Manage system topics (true) or user topics (false)"),
14436
14497
  SDATA(data_type_t.DTP_STRING, "tabs_style", 0, "is-toggle is-fullwidth", "Bulma tab styling"),
14437
14498
  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)."),
@@ -15245,7 +15306,8 @@ function process_treedb_descs$1(gobj) {
15245
15306
  with_delete_button: !readonly,
15246
15307
  treedb_name,
15247
15308
  topic_name: key,
15248
- desc
15309
+ desc,
15310
+ with_remote_paging: gobj_read_bool_attr(gobj, "with_remote_paging")
15249
15311
  };
15250
15312
  let id = `${gobj_name(gobj)}?${desc.topic_name}`;
15251
15313
  let gobj_topic_form = gobj_create_service(id, "C_YUI_TREEDB_TOPIC_WITH_FORM", kw_topic_form, gobj);
@@ -15316,6 +15378,8 @@ function register_sub_routes$1(gobj) {
15316
15378
  function get_nodes$1(gobj, topic_name) {
15317
15379
  const treedb_name = gobj.priv.treedb_name;
15318
15380
  subscribe_treedb$1(gobj, topic_name);
15381
+ let gobj_topic_form = get_gobj_formtable(gobj, topic_name);
15382
+ if (gobj_topic_form && gobj_read_bool_attr(gobj_topic_form, "with_remote_paging")) return;
15319
15383
  treedb_nodes$1(gobj, treedb_name, topic_name, { list_dict: true });
15320
15384
  }
15321
15385
  /************************************************************
@@ -15363,7 +15427,7 @@ function get_gobj_formtable(gobj, topic_name) {
15363
15427
  /************************************************************
15364
15428
  * Command to remote service
15365
15429
  ************************************************************/
15366
- function treedb_nodes$1(gobj, treedb_name, topic_name, options) {
15430
+ function treedb_nodes$1(gobj, treedb_name, topic_name, options, page) {
15367
15431
  let command = "nodes";
15368
15432
  let gobj_remote_yuno = gobj_read_pointer_attr(gobj, "gobj_remote_yuno");
15369
15433
  let kw = {
@@ -15373,8 +15437,17 @@ function treedb_nodes$1(gobj, treedb_name, topic_name, options) {
15373
15437
  options: options || {}
15374
15438
  };
15375
15439
  kw.__md_command__ = { topic_name };
15440
+ if (page) {
15441
+ kw.from = page.from;
15442
+ kw.limit = page.limit;
15443
+ kw.__md_command__.req_id = page.req_id;
15444
+ }
15376
15445
  let ret = gobj_command(gobj_remote_yuno, command, kw, gobj);
15377
- if (ret) log_error(ret);
15446
+ if (ret) {
15447
+ log_error(ret);
15448
+ return -1;
15449
+ }
15450
+ return 0;
15378
15451
  }
15379
15452
  /************************************************************
15380
15453
  * Command to remote service
@@ -15608,7 +15681,7 @@ function ac_mt_command_answer$2(gobj, event, kw, src) {
15608
15681
  yui_shell_show_error(yui_shell_of(gobj), comment, { t });
15609
15682
  if (command === "update-node" || command === "create-node") {
15610
15683
  let failed_topic = kw_get_str(gobj, kw_command, "topic_name", "", 0);
15611
- if (failed_topic) treedb_nodes$1(gobj, gobj_read_str_attr(gobj, "treedb_name"), failed_topic, { list_dict: true });
15684
+ if (failed_topic) gobj_send_event(gobj, "EV_REFRESH_TOPIC", { topic_name: failed_topic }, gobj);
15612
15685
  }
15613
15686
  }
15614
15687
  return 0;
@@ -15624,7 +15697,16 @@ function ac_mt_command_answer$2(gobj, event, kw, src) {
15624
15697
  let topic_name = kw_get_str(gobj, kw_command, "topic_name", "", kw_flag_t.KW_REQUIRED);
15625
15698
  if (result >= 0) {
15626
15699
  let gobj_topic_form = gobj_find_child(gobj, { __gobj_name__: `${gobj_name(gobj)}?${topic_name}` });
15627
- gobj_send_event(gobj_topic_form, "EV_LOAD_NODES", data, gobj);
15700
+ let answer = nodes_answer(data);
15701
+ let md = kw_get_dict(gobj, kw_command, "__md_command__", {}, 0);
15702
+ let req_id = kw_get_str(gobj, md, "req_id", "", 0);
15703
+ if (req_id) gobj_send_event(gobj_topic_form, "EV_PAGE_LOADED", {
15704
+ req_id,
15705
+ rows: answer.rows,
15706
+ pages: answer.pages,
15707
+ total: answer.total
15708
+ }, gobj);
15709
+ else gobj_send_event(gobj_topic_form, "EV_LOAD_NODES", answer.rows, gobj);
15628
15710
  }
15629
15711
  break;
15630
15712
  case "create-node":
@@ -15850,6 +15932,31 @@ function ac_create_record(gobj, event, kw, src) {
15850
15932
  });
15851
15933
  }
15852
15934
  /********************************************
15935
+ * A topic table wants a page.
15936
+ *
15937
+ * The transport is ours, so the table asks and we fetch; the answer
15938
+ * finds its way back by the `req_id` we echo. A backend that cannot
15939
+ * page answers the whole list, which the table takes as one page —
15940
+ * which is the truth, and is why the table can ask without knowing
15941
+ * what it is talking to.
15942
+ ********************************************/
15943
+ function ac_request_page(gobj, event, kw, src) {
15944
+ let topic_name = kw.topic_name || gobj_read_attr(src, "topic_name");
15945
+ if (!topic_name || !kw.req_id) {
15946
+ log_error(`${gobj_short_name(gobj)}: a page request with no topic or no id`);
15947
+ return -1;
15948
+ }
15949
+ if (treedb_nodes$1(gobj, gobj_read_str_attr(gobj, "treedb_name"), topic_name, { list_dict: true }, {
15950
+ from: kw.from,
15951
+ limit: kw.limit,
15952
+ req_id: kw.req_id
15953
+ }) < 0) gobj_send_event(src, "EV_PAGE_FAILED", {
15954
+ req_id: kw.req_id,
15955
+ error: "cannot reach the backend"
15956
+ }, gobj);
15957
+ return 0;
15958
+ }
15959
+ /********************************************
15853
15960
  * Event from formtable: ONE field of one record, edited in place.
15854
15961
  *
15855
15962
  * Written as a PARTIAL update and, deliberately, with NO `autolink`.
@@ -15916,6 +16023,11 @@ function ac_delete_record(gobj, event, kw, src) {
15916
16023
  * }
15917
16024
  ********************************************/
15918
16025
  function ac_refresh_topic(gobj, event, kw, src) {
16026
+ let gobj_topic_form = get_gobj_formtable(gobj, kw.topic_name);
16027
+ if (gobj_topic_form && gobj_read_bool_attr(gobj_topic_form, "with_remote_paging")) {
16028
+ gobj_send_event(gobj_topic_form, "EV_REPULL_PAGE", {}, gobj);
16029
+ return 0;
16030
+ }
15919
16031
  treedb_nodes$1(gobj, gobj_read_str_attr(gobj, "treedb_name"), kw.topic_name, { list_dict: true });
15920
16032
  return 0;
15921
16033
  }
@@ -16005,6 +16117,11 @@ function create_gclass$6(gclass_name) {
16005
16117
  ac_update_field,
16006
16118
  null
16007
16119
  ],
16120
+ [
16121
+ "EV_REQUEST_PAGE",
16122
+ ac_request_page,
16123
+ null
16124
+ ],
16008
16125
  [
16009
16126
  "EV_DELETE_RECORD",
16010
16127
  ac_delete_record,
@@ -16079,6 +16196,7 @@ function create_gclass$6(gclass_name) {
16079
16196
  ["EV_CREATE_RECORD", 0],
16080
16197
  ["EV_UPDATE_RECORD", 0],
16081
16198
  ["EV_UPDATE_FIELD", 0],
16199
+ ["EV_REQUEST_PAGE", 0],
16082
16200
  ["EV_DELETE_RECORD", 0],
16083
16201
  ["EV_RECORD_WRITTEN", event_flag_t.EVF_OUTPUT_EVENT | event_flag_t.EVF_NO_WARN_SUBS],
16084
16202
  ["EV_REFRESH_TOPIC", 0],
@@ -16293,6 +16411,8 @@ var attrs_table$5 = [
16293
16411
  SDATA(data_type_t.DTP_BOOLEAN, "with_export_button", 0, true, "Button toolbar EXPORT (download as CSV what the table holds)"),
16294
16412
  SDATA(data_type_t.DTP_BOOLEAN, "with_header_filters", 0, true, "Per-column filter box in the table header, on the columns a text/number match means something (not hooks, fkeys or json)"),
16295
16413
  SDATA(data_type_t.DTP_BOOLEAN, "with_inline_edit", 0, true, "Edit a writable scalar cell in place while in edition mode (the record form keeps the rest)"),
16414
+ SDATA(data_type_t.DTP_BOOLEAN, "with_remote_paging", 0, false, "Pull the topic a PAGE at a time from the backend (`nodes` from/limit) instead of loading it whole. Needs a backend that pages"),
16415
+ SDATA(data_type_t.DTP_INTEGER, "page_size", 0, 200, "Rows per page when with_remote_paging. Generous on purpose: a treedb that fits in one page behaves exactly as it did, paginator hidden and every filter seeing every row"),
16296
16416
  SDATA(data_type_t.DTP_BOOLEAN, "with_in_row_edit_icons", 0, true, "Add a last column with internal EDIT/DELETE icon"),
16297
16417
  SDATA(data_type_t.DTP_BOOLEAN, "editable", 0, false, "Edit state"),
16298
16418
  SDATA(data_type_t.DTP_BOOLEAN, "with_checkbox", 0, true, "Auxiliary first column to select rows"),
@@ -16321,6 +16441,8 @@ var attrs_table$5 = [
16321
16441
  SDATA_END()
16322
16442
  ];
16323
16443
  var PRIVATE_DATA$5 = {
16444
+ _pending_pages: null,
16445
+ _page_seq: 0,
16324
16446
  $container: null,
16325
16447
  treedb_name: "",
16326
16448
  topic_name: "",
@@ -17046,6 +17168,25 @@ function create_tabulator(gobj) {
17046
17168
  let pkey = desc.pkey || "id";
17047
17169
  let selectable = with_checkbox ? "highlight" : with_radio ? 1 : false;
17048
17170
  let tabulator_settings = json_deep_copy(gobj_read_attr(gobj, "tabulator_settings"));
17171
+ if (gobj_read_bool_attr(gobj, "with_remote_paging")) {
17172
+ let page_size = gobj_read_integer_attr(gobj, "page_size") || 200;
17173
+ Object.assign(tabulator_settings, {
17174
+ pagination: true,
17175
+ paginationMode: "remote",
17176
+ filterMode: "local",
17177
+ paginationSize: page_size,
17178
+ paginationSizeSelector: [
17179
+ 50,
17180
+ 100,
17181
+ 200,
17182
+ 500
17183
+ ],
17184
+ ajaxURL: "nodes",
17185
+ ajaxRequestFunc: function(url, config, params) {
17186
+ return request_page(gobj, params.page || 1, params.size || page_size);
17187
+ }
17188
+ });
17189
+ }
17049
17190
  Object.assign(tabulator_settings, {
17050
17191
  index: pkey,
17051
17192
  columns,
@@ -18247,6 +18388,99 @@ function ac_show_schema(gobj, event, kw, src) {
18247
18388
  return 0;
18248
18389
  }
18249
18390
  /************************************************************
18391
+ * Ask the host for one page and park the promise Tabulator wants.
18392
+ *
18393
+ * The transport belongs to the HOST, so the request goes up as an event
18394
+ * and the answer comes back down as one; what lives here is only the
18395
+ * promise, keyed by a correlation id the host echoes.
18396
+ *
18397
+ * Rejected right away when there is nobody to ask: a request that can
18398
+ * never be answered would leave the table spinning for ever and leak
18399
+ * one entry per attempt. Same for the watchdog — the link can stay up
18400
+ * and the answer still never land.
18401
+ ************************************************************/
18402
+ var PAGE_TIMEOUT_MS = 2e4;
18403
+ function request_page(gobj, page, size) {
18404
+ let priv = gobj.priv;
18405
+ return new Promise(function(resolve, reject) {
18406
+ if (!priv._pending_pages) priv._pending_pages = {};
18407
+ let req_id = `p${++priv._page_seq}`;
18408
+ let timer = window.setTimeout(function() {
18409
+ gobj_send_event(gobj, "EV_PAGE_TIMEOUT", { req_id }, gobj);
18410
+ }, PAGE_TIMEOUT_MS);
18411
+ priv._pending_pages[req_id] = {
18412
+ resolve,
18413
+ reject,
18414
+ timer
18415
+ };
18416
+ gobj_publish_event(gobj, "EV_REQUEST_PAGE", {
18417
+ topic_name: gobj_read_str_attr(gobj, "topic_name"),
18418
+ req_id,
18419
+ from: (page - 1) * size + 1,
18420
+ limit: size
18421
+ });
18422
+ });
18423
+ }
18424
+ /************************************************************
18425
+ * Settle a parked page request, however it ended.
18426
+ ************************************************************/
18427
+ function settle_page(gobj, req_id, value, error) {
18428
+ let priv = gobj.priv;
18429
+ let pend = priv._pending_pages ? priv._pending_pages[req_id] : null;
18430
+ if (!pend) return;
18431
+ delete priv._pending_pages[req_id];
18432
+ if (pend.timer) window.clearTimeout(pend.timer);
18433
+ if (error) {
18434
+ pend.reject(new Error(error));
18435
+ return;
18436
+ }
18437
+ pend.resolve(value);
18438
+ }
18439
+ /************************************************************
18440
+ * Pull the current page again. `replaceData()` re-runs
18441
+ * ajaxRequestFunc for the page the reader is on, so a refresh keeps
18442
+ * their position instead of throwing them back to the first page.
18443
+ ************************************************************/
18444
+ function ac_repull_page(gobj, event, kw, src) {
18445
+ let tabulator = gobj_read_attr(gobj, "tabulator");
18446
+ if (!tabulator) return 0;
18447
+ try {
18448
+ tabulator.replaceData();
18449
+ } catch (e) {
18450
+ log_error(`${gobj_short_name(gobj)}: cannot re-pull the page: ${e}`);
18451
+ return -1;
18452
+ }
18453
+ return 0;
18454
+ }
18455
+ /************************************************************
18456
+ * The host answered a page.
18457
+ ************************************************************/
18458
+ function ac_page_loaded(gobj, event, kw, src) {
18459
+ settle_page(gobj, kw.req_id, {
18460
+ data: is_array(kw.rows) ? kw.rows : [],
18461
+ last_page: kw.pages || 1,
18462
+ last_row: typeof kw.total === "number" ? kw.total : 0
18463
+ }, null);
18464
+ return 0;
18465
+ }
18466
+ /************************************************************
18467
+ * The host could not answer it.
18468
+ ************************************************************/
18469
+ function ac_page_failed(gobj, event, kw, src) {
18470
+ let why = kw && kw.error || "page request failed";
18471
+ log_error(`${gobj_short_name(gobj)}: ${why}`);
18472
+ settle_page(gobj, kw.req_id, null, why);
18473
+ return 0;
18474
+ }
18475
+ /************************************************************
18476
+ * Nobody answered in time.
18477
+ ************************************************************/
18478
+ function ac_page_timeout(gobj, event, kw, src) {
18479
+ log_error(`${gobj_short_name(gobj)}: no answer for page request '${kw.req_id}' in ${PAGE_TIMEOUT_MS}ms`);
18480
+ settle_page(gobj, kw.req_id, null, "page request timed out");
18481
+ return 0;
18482
+ }
18483
+ /************************************************************
18250
18484
  * Filter the loaded rows by a term matched against every non-internal
18251
18485
  * field. `clearFilter()` with no argument drops only THIS filter: the
18252
18486
  * per-column header filters are a separate layer and survive, so a
@@ -18494,6 +18728,26 @@ function create_gclass$5(gclass_name) {
18494
18728
  ac_cell_edited,
18495
18729
  null
18496
18730
  ],
18731
+ [
18732
+ "EV_REPULL_PAGE",
18733
+ ac_repull_page,
18734
+ null
18735
+ ],
18736
+ [
18737
+ "EV_PAGE_LOADED",
18738
+ ac_page_loaded,
18739
+ null
18740
+ ],
18741
+ [
18742
+ "EV_PAGE_FAILED",
18743
+ ac_page_failed,
18744
+ null
18745
+ ],
18746
+ [
18747
+ "EV_PAGE_TIMEOUT",
18748
+ ac_page_timeout,
18749
+ null
18750
+ ],
18497
18751
  [
18498
18752
  "EV_SEARCH",
18499
18753
  ac_search,
@@ -18545,7 +18799,12 @@ function create_gclass$5(gclass_name) {
18545
18799
  ["EV_REFRESH", 0],
18546
18800
  ["EV_SHOW_SCHEMA", 0],
18547
18801
  ["EV_CELL_EDITED", 0],
18802
+ ["EV_REPULL_PAGE", 0],
18803
+ ["EV_PAGE_LOADED", 0],
18804
+ ["EV_PAGE_FAILED", 0],
18805
+ ["EV_PAGE_TIMEOUT", 0],
18548
18806
  ["EV_UPDATE_FIELD", event_flag_t.EVF_OUTPUT_EVENT],
18807
+ ["EV_REQUEST_PAGE", event_flag_t.EVF_OUTPUT_EVENT],
18549
18808
  ["EV_SEARCH", 0],
18550
18809
  ["EV_OPEN_COLUMNS", 0],
18551
18810
  ["EV_TOGGLE_COLUMN", 0],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuneta/gobj-ui",
3
- "version": "7.8.0",
3
+ "version": "7.9.0",
4
4
  "type": "module",
5
5
  "main": "dist/gobj-ui.cjs.js",
6
6
  "module": "dist/gobj-ui.es.js",
@@ -48,6 +48,7 @@ import {
48
48
  gclass_find_by_name,
49
49
  gobj_write_attr,
50
50
  gobj_read_bool_attr,
51
+ gobj_read_integer_attr,
51
52
  gobj_read_str_attr,
52
53
  gobj_write_bool_attr,
53
54
  gobj_publish_event,
@@ -112,6 +113,8 @@ SDATA(data_type_t.DTP_BOOLEAN, "with_columns_button", 0, true, "Button
112
113
  SDATA(data_type_t.DTP_BOOLEAN, "with_export_button", 0, true, "Button toolbar EXPORT (download as CSV what the table holds)"),
113
114
  SDATA(data_type_t.DTP_BOOLEAN, "with_header_filters", 0, true, "Per-column filter box in the table header, on the columns a text/number match means something (not hooks, fkeys or json)"),
114
115
  SDATA(data_type_t.DTP_BOOLEAN, "with_inline_edit", 0, true, "Edit a writable scalar cell in place while in edition mode (the record form keeps the rest)"),
116
+ SDATA(data_type_t.DTP_BOOLEAN, "with_remote_paging", 0, false, "Pull the topic a PAGE at a time from the backend (`nodes` from/limit) instead of loading it whole. Needs a backend that pages"),
117
+ SDATA(data_type_t.DTP_INTEGER, "page_size", 0, 200, "Rows per page when with_remote_paging. Generous on purpose: a treedb that fits in one page behaves exactly as it did, paginator hidden and every filter seeing every row"),
115
118
  SDATA(data_type_t.DTP_BOOLEAN, "with_in_row_edit_icons", 0, true, "Add a last column with internal EDIT/DELETE icon"),
116
119
 
117
120
  SDATA(data_type_t.DTP_BOOLEAN, "editable", 0, false, "Edit state"),
@@ -146,6 +149,8 @@ SDATA_END()
146
149
  ];
147
150
 
148
151
  let PRIVATE_DATA = {
152
+ _pending_pages: null, // req_id -> {resolve, reject, timer}
153
+ _page_seq: 0, // correlation id of a page request
149
154
  $container: null,
150
155
  treedb_name: "",
151
156
  topic_name: "",
@@ -1095,6 +1100,36 @@ function create_tabulator(gobj)
1095
1100
 
1096
1101
  let tabulator_settings = json_deep_copy(gobj_read_attr(gobj, "tabulator_settings"));
1097
1102
 
1103
+ /* Remote paging: the TABLE pulls, instead of the host pushing the whole
1104
+ * topic down. The page size is generous on purpose — a treedb that fits
1105
+ * in one page behaves exactly as it did before, with the paginator
1106
+ * hidden and every filter seeing every row. Only a topic that does NOT
1107
+ * fit pays for paging, and for that one loading it whole was never an
1108
+ * option anyway.
1109
+ *
1110
+ * `filterMode: "local"` says the plain truth: the header filters and the
1111
+ * search box work on the page that is loaded. Same as the tranger
1112
+ * browser's Rows card, and for the same reason — the alternative is
1113
+ * pushing every filter to the backend and changing what "search" means.
1114
+ */
1115
+ if(gobj_read_bool_attr(gobj, "with_remote_paging")) {
1116
+ let page_size = gobj_read_integer_attr(gobj, "page_size") || 200;
1117
+ Object.assign(tabulator_settings, {
1118
+ pagination: true,
1119
+ paginationMode: "remote",
1120
+ filterMode: "local",
1121
+ paginationSize: page_size,
1122
+ paginationSizeSelector: [50, 100, 200, 500],
1123
+ ajaxURL: "nodes", /* dummy: only fires the func */
1124
+ ajaxRequestFunc: function(url, config, params) {
1125
+ /* Widget plumbing, not an action: Tabulator wants a PROMISE
1126
+ * back — it is a data source. The action is the event this
1127
+ * parks on. */
1128
+ return request_page(gobj, params.page || 1, params.size || page_size);
1129
+ }
1130
+ });
1131
+ }
1132
+
1098
1133
  Object.assign(tabulator_settings, {
1099
1134
  index: pkey,
1100
1135
  columns: columns,
@@ -2965,6 +3000,125 @@ function ac_show_schema(gobj, event, kw, src)
2965
3000
  return 0;
2966
3001
  }
2967
3002
 
3003
+ /************************************************************
3004
+ * Ask the host for one page and park the promise Tabulator wants.
3005
+ *
3006
+ * The transport belongs to the HOST, so the request goes up as an event
3007
+ * and the answer comes back down as one; what lives here is only the
3008
+ * promise, keyed by a correlation id the host echoes.
3009
+ *
3010
+ * Rejected right away when there is nobody to ask: a request that can
3011
+ * never be answered would leave the table spinning for ever and leak
3012
+ * one entry per attempt. Same for the watchdog — the link can stay up
3013
+ * and the answer still never land.
3014
+ ************************************************************/
3015
+ const PAGE_TIMEOUT_MS = 20000;
3016
+
3017
+ function request_page(gobj, page, size)
3018
+ {
3019
+ let priv = gobj.priv;
3020
+
3021
+ return new Promise(function(resolve, reject) {
3022
+ if(!priv._pending_pages) {
3023
+ priv._pending_pages = {};
3024
+ }
3025
+ let req_id = `p${++priv._page_seq}`;
3026
+
3027
+ let timer = window.setTimeout(function() {
3028
+ gobj_send_event(gobj, "EV_PAGE_TIMEOUT", {req_id: req_id}, gobj);
3029
+ }, PAGE_TIMEOUT_MS);
3030
+
3031
+ priv._pending_pages[req_id] = {resolve: resolve, reject: reject, timer: timer};
3032
+
3033
+ gobj_publish_event(
3034
+ gobj,
3035
+ "EV_REQUEST_PAGE",
3036
+ {
3037
+ topic_name: gobj_read_str_attr(gobj, "topic_name"),
3038
+ req_id: req_id,
3039
+ from: (page - 1) * size + 1, /* `nodes` counts from 1 */
3040
+ limit: size
3041
+ }
3042
+ );
3043
+ });
3044
+ }
3045
+
3046
+ /************************************************************
3047
+ * Settle a parked page request, however it ended.
3048
+ ************************************************************/
3049
+ function settle_page(gobj, req_id, value, error)
3050
+ {
3051
+ let priv = gobj.priv;
3052
+ let pend = priv._pending_pages? priv._pending_pages[req_id] : null;
3053
+ if(!pend) {
3054
+ return; /* already settled: a late answer after a timeout */
3055
+ }
3056
+ delete priv._pending_pages[req_id];
3057
+ if(pend.timer) {
3058
+ window.clearTimeout(pend.timer);
3059
+ }
3060
+ if(error) {
3061
+ pend.reject(new Error(error));
3062
+ return;
3063
+ }
3064
+ pend.resolve(value);
3065
+ }
3066
+
3067
+ /************************************************************
3068
+ * Pull the current page again. `replaceData()` re-runs
3069
+ * ajaxRequestFunc for the page the reader is on, so a refresh keeps
3070
+ * their position instead of throwing them back to the first page.
3071
+ ************************************************************/
3072
+ function ac_repull_page(gobj, event, kw, src)
3073
+ {
3074
+ let tabulator = gobj_read_attr(gobj, "tabulator");
3075
+ if(!tabulator) {
3076
+ return 0;
3077
+ }
3078
+ try {
3079
+ tabulator.replaceData();
3080
+ } catch(e) {
3081
+ log_error(`${gobj_short_name(gobj)}: cannot re-pull the page: ${e}`);
3082
+ return -1;
3083
+ }
3084
+ return 0;
3085
+ }
3086
+
3087
+ /************************************************************
3088
+ * The host answered a page.
3089
+ ************************************************************/
3090
+ function ac_page_loaded(gobj, event, kw, src)
3091
+ {
3092
+ settle_page(gobj, kw.req_id, {
3093
+ data: is_array(kw.rows)? kw.rows : [],
3094
+ last_page: kw.pages || 1,
3095
+ last_row: (typeof kw.total === "number")? kw.total : 0
3096
+ }, null);
3097
+ return 0;
3098
+ }
3099
+
3100
+ /************************************************************
3101
+ * The host could not answer it.
3102
+ ************************************************************/
3103
+ function ac_page_failed(gobj, event, kw, src)
3104
+ {
3105
+ let why = (kw && kw.error) || "page request failed";
3106
+ log_error(`${gobj_short_name(gobj)}: ${why}`);
3107
+ settle_page(gobj, kw.req_id, null, why);
3108
+ return 0;
3109
+ }
3110
+
3111
+ /************************************************************
3112
+ * Nobody answered in time.
3113
+ ************************************************************/
3114
+ function ac_page_timeout(gobj, event, kw, src)
3115
+ {
3116
+ log_error(`${gobj_short_name(gobj)}: no answer for page request ` +
3117
+ `'${kw.req_id}' in ${PAGE_TIMEOUT_MS}ms`);
3118
+ settle_page(gobj, kw.req_id, null, "page request timed out");
3119
+ return 0;
3120
+ }
3121
+
2968
3122
  /************************************************************
2969
3123
  * Filter the loaded rows by a term matched against every non-internal
2970
3124
  * field. `clearFilter()` with no argument drops only THIS filter: the
@@ -3187,6 +3341,10 @@ function create_gclass(gclass_name)
3187
3341
  ["EV_REFRESH", ac_refresh, null],
3188
3342
  ["EV_SHOW_SCHEMA", ac_show_schema, null],
3189
3343
  ["EV_CELL_EDITED", ac_cell_edited, null],
3344
+ ["EV_REPULL_PAGE", ac_repull_page, null],
3345
+ ["EV_PAGE_LOADED", ac_page_loaded, null],
3346
+ ["EV_PAGE_FAILED", ac_page_failed, null],
3347
+ ["EV_PAGE_TIMEOUT", ac_page_timeout, null],
3190
3348
  ["EV_SEARCH", ac_search, null],
3191
3349
  ["EV_OPEN_COLUMNS", ac_open_columns, null],
3192
3350
  ["EV_TOGGLE_COLUMN", ac_toggle_column, null],
@@ -3220,7 +3378,12 @@ function create_gclass(gclass_name)
3220
3378
  ["EV_REFRESH", 0],
3221
3379
  ["EV_SHOW_SCHEMA", 0],
3222
3380
  ["EV_CELL_EDITED", 0],
3381
+ ["EV_REPULL_PAGE", 0],
3382
+ ["EV_PAGE_LOADED", 0],
3383
+ ["EV_PAGE_FAILED", 0],
3384
+ ["EV_PAGE_TIMEOUT", 0],
3223
3385
  ["EV_UPDATE_FIELD", event_flag_t.EVF_OUTPUT_EVENT],
3386
+ ["EV_REQUEST_PAGE", event_flag_t.EVF_OUTPUT_EVENT],
3224
3387
  ["EV_SEARCH", 0],
3225
3388
  ["EV_OPEN_COLUMNS", 0],
3226
3389
  ["EV_TOGGLE_COLUMN", 0],