@yuneta/gobj-ui 7.7.1 → 7.8.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 +33 -0
- package/dist/gobj-ui.cjs.js +178 -2
- package/dist/gobj-ui.es.js +178 -2
- package/package.json +1 -1
- package/src/c_yui_treedb_topic_with_form.js +113 -0
- package/src/c_yui_treedb_topics.js +67 -1
- package/src/nodes_answer.js +72 -0
- package/src/nodes_answer.test.js +50 -0
package/README.md
CHANGED
|
@@ -491,6 +491,39 @@ 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
|
+
### Editing a topic table in place
|
|
495
|
+
|
|
496
|
+
A writable scalar is editable in the table, in edition mode
|
|
497
|
+
(`with_inline_edit`, default on). Changing one field used to mean opening the
|
|
498
|
+
record form, changing it, saving and closing.
|
|
499
|
+
|
|
500
|
+
**Which cells, and why not the rest.** The schema decides first: only a column
|
|
501
|
+
flagged `writable`, and never the pkey — renaming what a record is KEYED by is
|
|
502
|
+
not a field edit. Then the type: a hook holds children and an fkey IS a link,
|
|
503
|
+
so both are edited by linking; a dict or a list is a document the form has an
|
|
504
|
+
editor for; a date cell shows a formatted string over an epoch, so typing into
|
|
505
|
+
it would write the string. Those stay with the form, one click away on the
|
|
506
|
+
same row. `boolean` gets a tick, `enum` the list of its own values, numbers a
|
|
507
|
+
number editor.
|
|
508
|
+
|
|
509
|
+
**The write is a partial update with no `autolink`, and that is the whole
|
|
510
|
+
safety of it.** `treedb_update_node()` merges (`json_object_update`), so the
|
|
511
|
+
fields it does not carry are left alone; `autolink` is the option that wipes a
|
|
512
|
+
node's links and rebuilds them from the fkeys the record carries, and on a
|
|
513
|
+
partial record it reads that as "no parents", detaches the node and answers
|
|
514
|
+
**success**. So a cell edit travels as its own event, `EV_UPDATE_FIELD`, and
|
|
515
|
+
not as `EV_UPDATE_RECORD` — that one does send autolink, and may, because the
|
|
516
|
+
form hands it the whole record with its fkeys in it. See
|
|
517
|
+
`schema_write_options.js` for the rule and why each word of it is there.
|
|
518
|
+
|
|
519
|
+
`editable` is a **function** on the column, not a flag: edition mode is
|
|
520
|
+
toggled on a table that is already built, so the answer has to be asked for at
|
|
521
|
+
the moment of the click.
|
|
522
|
+
|
|
523
|
+
A refused write puts the topic back to what the treedb has. Leaving the typed
|
|
524
|
+
value on screen is tolerable for a form, which stays open on the values it
|
|
525
|
+
failed with; a cell edited in place would just look saved.
|
|
526
|
+
|
|
494
527
|
### Reading a topic table: filters, columns, CSV
|
|
495
528
|
|
|
496
529
|
`C_YUI_TREEDB_TOPIC_WITH_FORM` had one global search box over the loaded rows.
|
package/dist/gobj-ui.cjs.js
CHANGED
|
@@ -14416,6 +14416,66 @@ function yui_shell_show_route_map(shell, opts) {
|
|
|
14416
14416
|
return modal_ref.modal;
|
|
14417
14417
|
}
|
|
14418
14418
|
//#endregion
|
|
14419
|
+
//#region src/nodes_answer.js
|
|
14420
|
+
/***********************************************************************
|
|
14421
|
+
* nodes_answer.js
|
|
14422
|
+
*
|
|
14423
|
+
* What the `nodes` command answered, whatever shape it came in.
|
|
14424
|
+
*
|
|
14425
|
+
* A treedb lives in memory on the backend, so walking it is not
|
|
14426
|
+
* what costs: serializing every node, pushing it through a
|
|
14427
|
+
* websocket and parsing it here is. So `nodes` learned to answer a
|
|
14428
|
+
* PAGE, with the contract `list-keys` of C_TRANGER already used:
|
|
14429
|
+
*
|
|
14430
|
+
* no `limit` -> the plain list it has always answered
|
|
14431
|
+
* a `limit` -> {total_rows, pages, data}
|
|
14432
|
+
*
|
|
14433
|
+
* Both shapes are alive at once and will be for a long time: the
|
|
14434
|
+
* SPA talks to backends the operator configures, and an older one
|
|
14435
|
+
* answers the plain list whatever this app asks for. So the shape
|
|
14436
|
+
* is read, never assumed — a view that indexed `data` as an array
|
|
14437
|
+
* would render an object's keys as rows the day a backend
|
|
14438
|
+
* upgraded, which is the kind of break that looks like a bug in
|
|
14439
|
+
* the app.
|
|
14440
|
+
*
|
|
14441
|
+
* Pure and tested, because this is exactly the sort of thing that
|
|
14442
|
+
* cannot be exercised until the other side moves.
|
|
14443
|
+
*
|
|
14444
|
+
* Copyright (c) 2026, ArtGins.
|
|
14445
|
+
* All Rights Reserved.
|
|
14446
|
+
***********************************************************************/
|
|
14447
|
+
/***************************************************************
|
|
14448
|
+
* nodes_answer(data) -> {rows, total, pages, paged}
|
|
14449
|
+
*
|
|
14450
|
+
* `rows` is always an array, so a caller never has to ask which
|
|
14451
|
+
* shape it got. `paged` says whether the backend answered a page,
|
|
14452
|
+
* which is the one thing a caller may legitimately care about (it is
|
|
14453
|
+
* what tells a pager it has something to page).
|
|
14454
|
+
***************************************************************/
|
|
14455
|
+
function nodes_answer(data) {
|
|
14456
|
+
if (Array.isArray(data)) return {
|
|
14457
|
+
rows: data,
|
|
14458
|
+
total: data.length,
|
|
14459
|
+
pages: 1,
|
|
14460
|
+
paged: false
|
|
14461
|
+
};
|
|
14462
|
+
if (data && typeof data === "object" && Array.isArray(data.data)) {
|
|
14463
|
+
let rows = data.data;
|
|
14464
|
+
return {
|
|
14465
|
+
rows,
|
|
14466
|
+
total: typeof data.total_rows === "number" ? data.total_rows : rows.length,
|
|
14467
|
+
pages: typeof data.pages === "number" && data.pages > 0 ? data.pages : 1,
|
|
14468
|
+
paged: true
|
|
14469
|
+
};
|
|
14470
|
+
}
|
|
14471
|
+
return {
|
|
14472
|
+
rows: [],
|
|
14473
|
+
total: 0,
|
|
14474
|
+
pages: 1,
|
|
14475
|
+
paged: false
|
|
14476
|
+
};
|
|
14477
|
+
}
|
|
14478
|
+
//#endregion
|
|
14419
14479
|
//#region src/c_yui_treedb_topics.js
|
|
14420
14480
|
/***********************************************************************
|
|
14421
14481
|
* c_yui_treedb_topics.js
|
|
@@ -15609,7 +15669,13 @@ function ac_mt_command_answer$2(gobj, event, kw, src) {
|
|
|
15609
15669
|
}
|
|
15610
15670
|
if (result < 0) {
|
|
15611
15671
|
if (command === "descs") show_load_error$1(gobj, (0, i18next.t)(comment));
|
|
15612
|
-
else
|
|
15672
|
+
else {
|
|
15673
|
+
yui_shell_show_error(yui_shell_of(gobj), comment, { t: i18next.t });
|
|
15674
|
+
if (command === "update-node" || command === "create-node") {
|
|
15675
|
+
let failed_topic = (0, _yuneta_gobj_js.kw_get_str)(gobj, kw_command, "topic_name", "", 0);
|
|
15676
|
+
if (failed_topic) treedb_nodes$1(gobj, (0, _yuneta_gobj_js.gobj_read_str_attr)(gobj, "treedb_name"), failed_topic, { list_dict: true });
|
|
15677
|
+
}
|
|
15678
|
+
}
|
|
15613
15679
|
return 0;
|
|
15614
15680
|
}
|
|
15615
15681
|
switch (command) {
|
|
@@ -15623,7 +15689,7 @@ function ac_mt_command_answer$2(gobj, event, kw, src) {
|
|
|
15623
15689
|
let topic_name = (0, _yuneta_gobj_js.kw_get_str)(gobj, kw_command, "topic_name", "", _yuneta_gobj_js.kw_flag_t.KW_REQUIRED);
|
|
15624
15690
|
if (result >= 0) {
|
|
15625
15691
|
let gobj_topic_form = (0, _yuneta_gobj_js.gobj_find_child)(gobj, { __gobj_name__: `${(0, _yuneta_gobj_js.gobj_name)(gobj)}?${topic_name}` });
|
|
15626
|
-
(0, _yuneta_gobj_js.gobj_send_event)(gobj_topic_form, "EV_LOAD_NODES", data, gobj);
|
|
15692
|
+
(0, _yuneta_gobj_js.gobj_send_event)(gobj_topic_form, "EV_LOAD_NODES", nodes_answer(data).rows, gobj);
|
|
15627
15693
|
}
|
|
15628
15694
|
break;
|
|
15629
15695
|
case "create-node":
|
|
@@ -15849,6 +15915,35 @@ function ac_create_record(gobj, event, kw, src) {
|
|
|
15849
15915
|
});
|
|
15850
15916
|
}
|
|
15851
15917
|
/********************************************
|
|
15918
|
+
* Event from formtable: ONE field of one record, edited in place.
|
|
15919
|
+
*
|
|
15920
|
+
* Written as a PARTIAL update and, deliberately, with NO `autolink`.
|
|
15921
|
+
* `treedb_update_node()` merges (`json_object_update`), so the fields
|
|
15922
|
+
* it does not carry are left alone; and `autolink` is what wipes a
|
|
15923
|
+
* node's links to rebuild them from the fkeys the record carries — on
|
|
15924
|
+
* a partial record that reads as "no parents" and DETACHES the node,
|
|
15925
|
+
* answering success. A cell edit changes a scalar and must not be able
|
|
15926
|
+
* to touch a link at all.
|
|
15927
|
+
*
|
|
15928
|
+
* This is why it is its own event and not EV_UPDATE_RECORD: that one
|
|
15929
|
+
* DOES send autolink, and may, because the form hands it the whole
|
|
15930
|
+
* record with its fkeys in it.
|
|
15931
|
+
********************************************/
|
|
15932
|
+
function ac_update_field(gobj, event, kw, src) {
|
|
15933
|
+
if (refuse_if_readonly$3(gobj, event)) return -1;
|
|
15934
|
+
let topic_name = kw.topic_name || (0, _yuneta_gobj_js.gobj_read_attr)(src, "topic_name");
|
|
15935
|
+
let desc = ((0, _yuneta_gobj_js.gobj_read_attr)(gobj, "descs") || {})[topic_name];
|
|
15936
|
+
let pkey = desc && desc.pkey || "id";
|
|
15937
|
+
if (kw.id === void 0 || kw.id === null || !kw.field) {
|
|
15938
|
+
(0, _yuneta_gobj_js.log_error)(`${(0, _yuneta_gobj_js.gobj_short_name)(gobj)}: a field write with no id or no field`);
|
|
15939
|
+
return -1;
|
|
15940
|
+
}
|
|
15941
|
+
let record = {};
|
|
15942
|
+
record[pkey] = kw.id;
|
|
15943
|
+
record[kw.field] = kw.value;
|
|
15944
|
+
return treedb_update_node$1(gobj, (0, _yuneta_gobj_js.gobj_read_str_attr)(gobj, "treedb_name"), topic_name, record, { list_dict: true });
|
|
15945
|
+
}
|
|
15946
|
+
/********************************************
|
|
15852
15947
|
* Event from formtable
|
|
15853
15948
|
* kw: {
|
|
15854
15949
|
* topic_name,
|
|
@@ -15970,6 +16065,11 @@ function create_gclass$6(gclass_name) {
|
|
|
15970
16065
|
ac_update_record,
|
|
15971
16066
|
null
|
|
15972
16067
|
],
|
|
16068
|
+
[
|
|
16069
|
+
"EV_UPDATE_FIELD",
|
|
16070
|
+
ac_update_field,
|
|
16071
|
+
null
|
|
16072
|
+
],
|
|
15973
16073
|
[
|
|
15974
16074
|
"EV_DELETE_RECORD",
|
|
15975
16075
|
ac_delete_record,
|
|
@@ -16043,6 +16143,7 @@ function create_gclass$6(gclass_name) {
|
|
|
16043
16143
|
["EV_TREEDB_NODE_DELETED", _yuneta_gobj_js.event_flag_t.EVF_PUBLIC_EVENT],
|
|
16044
16144
|
["EV_CREATE_RECORD", 0],
|
|
16045
16145
|
["EV_UPDATE_RECORD", 0],
|
|
16146
|
+
["EV_UPDATE_FIELD", 0],
|
|
16046
16147
|
["EV_DELETE_RECORD", 0],
|
|
16047
16148
|
["EV_RECORD_WRITTEN", _yuneta_gobj_js.event_flag_t.EVF_OUTPUT_EVENT | _yuneta_gobj_js.event_flag_t.EVF_NO_WARN_SUBS],
|
|
16048
16149
|
["EV_REFRESH_TOPIC", 0],
|
|
@@ -16256,6 +16357,7 @@ var attrs_table$5 = [
|
|
|
16256
16357
|
(0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_BOOLEAN, "with_columns_button", 0, true, "Button toolbar COLUMNS (choose which columns the table shows)"),
|
|
16257
16358
|
(0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_BOOLEAN, "with_export_button", 0, true, "Button toolbar EXPORT (download as CSV what the table holds)"),
|
|
16258
16359
|
(0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.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)"),
|
|
16360
|
+
(0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.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)"),
|
|
16259
16361
|
(0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_BOOLEAN, "with_in_row_edit_icons", 0, true, "Add a last column with internal EDIT/DELETE icon"),
|
|
16260
16362
|
(0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_BOOLEAN, "editable", 0, false, "Edit state"),
|
|
16261
16363
|
(0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_BOOLEAN, "with_checkbox", 0, true, "Auxiliary first column to select rows"),
|
|
@@ -16765,6 +16867,7 @@ function create_tabulator(gobj) {
|
|
|
16765
16867
|
let table_id = (0, _yuneta_gobj_js.gobj_read_str_attr)(gobj, "table_id");
|
|
16766
16868
|
let desc = (0, _yuneta_gobj_js.gobj_read_attr)(gobj, "desc");
|
|
16767
16869
|
let with_header_filters = (0, _yuneta_gobj_js.gobj_read_bool_attr)(gobj, "with_header_filters");
|
|
16870
|
+
let with_inline_edit = (0, _yuneta_gobj_js.gobj_read_bool_attr)(gobj, "with_inline_edit");
|
|
16768
16871
|
let columns = [];
|
|
16769
16872
|
let with_checkbox = (0, _yuneta_gobj_js.gobj_read_bool_attr)(gobj, "with_checkbox");
|
|
16770
16873
|
let with_radio = (0, _yuneta_gobj_js.gobj_read_bool_attr)(gobj, "with_radio");
|
|
@@ -16808,6 +16911,42 @@ function create_tabulator(gobj) {
|
|
|
16808
16911
|
col_id: cell.getField()
|
|
16809
16912
|
}, gobj);
|
|
16810
16913
|
}
|
|
16914
|
+
const INLINE_EDITABLE = [
|
|
16915
|
+
"string",
|
|
16916
|
+
"integer",
|
|
16917
|
+
"real",
|
|
16918
|
+
"email",
|
|
16919
|
+
"url",
|
|
16920
|
+
"tel",
|
|
16921
|
+
"id",
|
|
16922
|
+
"hex",
|
|
16923
|
+
"currency",
|
|
16924
|
+
"percent",
|
|
16925
|
+
"uuid"
|
|
16926
|
+
];
|
|
16927
|
+
function inline_editor_for(colDef, col, field_desc) {
|
|
16928
|
+
if (!field_desc.is_writable) return;
|
|
16929
|
+
if (col.id === (desc.pkey || "id")) return;
|
|
16930
|
+
switch (field_desc.type) {
|
|
16931
|
+
case "boolean":
|
|
16932
|
+
colDef.editor = "tickCross";
|
|
16933
|
+
break;
|
|
16934
|
+
case "enum":
|
|
16935
|
+
colDef.editor = "list";
|
|
16936
|
+
colDef.editorParams = { values: field_desc.enum_list || [] };
|
|
16937
|
+
break;
|
|
16938
|
+
case "integer":
|
|
16939
|
+
case "real":
|
|
16940
|
+
colDef.editor = "number";
|
|
16941
|
+
break;
|
|
16942
|
+
default:
|
|
16943
|
+
if (!INLINE_EDITABLE.includes(field_desc.type)) return;
|
|
16944
|
+
colDef.editor = "input";
|
|
16945
|
+
}
|
|
16946
|
+
colDef.editable = function() {
|
|
16947
|
+
return (0, _yuneta_gobj_js.gobj_read_bool_attr)(gobj, "editable") && !(0, _yuneta_gobj_js.gobj_read_bool_attr)(gobj, "readonly");
|
|
16948
|
+
};
|
|
16949
|
+
}
|
|
16811
16950
|
const FILTERABLE = [
|
|
16812
16951
|
"string",
|
|
16813
16952
|
"integer",
|
|
@@ -16930,6 +17069,7 @@ function create_tabulator(gobj) {
|
|
|
16930
17069
|
formatter: colFormatter
|
|
16931
17070
|
};
|
|
16932
17071
|
if (with_header_filters) apply_header_filter(colDef, field_desc);
|
|
17072
|
+
if (with_inline_edit) inline_editor_for(colDef, col, field_desc);
|
|
16933
17073
|
if (vertAlign) colDef.vertAlign = vertAlign;
|
|
16934
17074
|
if (formatterParams) colDef.formatterParams = formatterParams;
|
|
16935
17075
|
if (cellClick) colDef.cellClick = cellClick;
|
|
@@ -17016,6 +17156,21 @@ function create_tabulator(gobj) {
|
|
|
17016
17156
|
});
|
|
17017
17157
|
tabulator.on("dataProcessed", update_rowcount);
|
|
17018
17158
|
tabulator.on("dataChanged", update_rowcount);
|
|
17159
|
+
tabulator.on("cellEdited", function(cell) {
|
|
17160
|
+
let pkey = ((0, _yuneta_gobj_js.gobj_read_attr)(gobj, "desc") || {}).pkey || "id";
|
|
17161
|
+
let data = cell.getRow().getData();
|
|
17162
|
+
let id = data ? data[pkey] : void 0;
|
|
17163
|
+
if (id === void 0 || id === null) {
|
|
17164
|
+
(0, _yuneta_gobj_js.log_error)(`${(0, _yuneta_gobj_js.gobj_short_name)(gobj)}: edited a row with no '${pkey}'`);
|
|
17165
|
+
cell.restoreOldValue();
|
|
17166
|
+
return;
|
|
17167
|
+
}
|
|
17168
|
+
(0, _yuneta_gobj_js.gobj_send_event)(gobj, "EV_CELL_EDITED", {
|
|
17169
|
+
id,
|
|
17170
|
+
field: cell.getField(),
|
|
17171
|
+
value: cell.getValue()
|
|
17172
|
+
}, gobj);
|
|
17173
|
+
});
|
|
17019
17174
|
tabulator.on("dataFiltered", function(filters, rows) {
|
|
17020
17175
|
update_rowcount(Array.isArray(rows) ? rows.length : void 0);
|
|
17021
17176
|
});
|
|
@@ -18136,6 +18291,20 @@ function ac_show_cell_json(gobj, event, kw, src) {
|
|
|
18136
18291
|
return 0;
|
|
18137
18292
|
}
|
|
18138
18293
|
/************************************************************
|
|
18294
|
+
* One cell of one record changed. Publish it UP as a field write;
|
|
18295
|
+
* the host owns the treedb command and its options.
|
|
18296
|
+
************************************************************/
|
|
18297
|
+
function ac_cell_edited(gobj, event, kw, src) {
|
|
18298
|
+
if (refuse_if_readonly$2(gobj, event)) return -1;
|
|
18299
|
+
(0, _yuneta_gobj_js.gobj_publish_event)(gobj, "EV_UPDATE_FIELD", {
|
|
18300
|
+
topic_name: (0, _yuneta_gobj_js.gobj_read_str_attr)(gobj, "topic_name"),
|
|
18301
|
+
id: kw.id,
|
|
18302
|
+
field: kw.field,
|
|
18303
|
+
value: kw.value
|
|
18304
|
+
});
|
|
18305
|
+
return 0;
|
|
18306
|
+
}
|
|
18307
|
+
/************************************************************
|
|
18139
18308
|
* Show the schema (desc) of this topic
|
|
18140
18309
|
************************************************************/
|
|
18141
18310
|
function ac_show_schema(gobj, event, kw, src) {
|
|
@@ -18385,6 +18554,11 @@ function create_gclass$5(gclass_name) {
|
|
|
18385
18554
|
ac_show_schema,
|
|
18386
18555
|
null
|
|
18387
18556
|
],
|
|
18557
|
+
[
|
|
18558
|
+
"EV_CELL_EDITED",
|
|
18559
|
+
ac_cell_edited,
|
|
18560
|
+
null
|
|
18561
|
+
],
|
|
18388
18562
|
[
|
|
18389
18563
|
"EV_SEARCH",
|
|
18390
18564
|
ac_search,
|
|
@@ -18435,6 +18609,8 @@ function create_gclass$5(gclass_name) {
|
|
|
18435
18609
|
["EV_CHANGE_LOCALE", 0],
|
|
18436
18610
|
["EV_REFRESH", 0],
|
|
18437
18611
|
["EV_SHOW_SCHEMA", 0],
|
|
18612
|
+
["EV_CELL_EDITED", 0],
|
|
18613
|
+
["EV_UPDATE_FIELD", _yuneta_gobj_js.event_flag_t.EVF_OUTPUT_EVENT],
|
|
18438
18614
|
["EV_SEARCH", 0],
|
|
18439
18615
|
["EV_OPEN_COLUMNS", 0],
|
|
18440
18616
|
["EV_TOGGLE_COLUMN", 0],
|
package/dist/gobj-ui.es.js
CHANGED
|
@@ -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
|
|
@@ -15604,7 +15664,13 @@ function ac_mt_command_answer$2(gobj, event, kw, src) {
|
|
|
15604
15664
|
}
|
|
15605
15665
|
if (result < 0) {
|
|
15606
15666
|
if (command === "descs") show_load_error$1(gobj, t(comment));
|
|
15607
|
-
else
|
|
15667
|
+
else {
|
|
15668
|
+
yui_shell_show_error(yui_shell_of(gobj), comment, { t });
|
|
15669
|
+
if (command === "update-node" || command === "create-node") {
|
|
15670
|
+
let failed_topic = kw_get_str(gobj, kw_command, "topic_name", "", 0);
|
|
15671
|
+
if (failed_topic) treedb_nodes$1(gobj, gobj_read_str_attr(gobj, "treedb_name"), failed_topic, { list_dict: true });
|
|
15672
|
+
}
|
|
15673
|
+
}
|
|
15608
15674
|
return 0;
|
|
15609
15675
|
}
|
|
15610
15676
|
switch (command) {
|
|
@@ -15618,7 +15684,7 @@ function ac_mt_command_answer$2(gobj, event, kw, src) {
|
|
|
15618
15684
|
let topic_name = kw_get_str(gobj, kw_command, "topic_name", "", kw_flag_t.KW_REQUIRED);
|
|
15619
15685
|
if (result >= 0) {
|
|
15620
15686
|
let gobj_topic_form = gobj_find_child(gobj, { __gobj_name__: `${gobj_name(gobj)}?${topic_name}` });
|
|
15621
|
-
gobj_send_event(gobj_topic_form, "EV_LOAD_NODES", data, gobj);
|
|
15687
|
+
gobj_send_event(gobj_topic_form, "EV_LOAD_NODES", nodes_answer(data).rows, gobj);
|
|
15622
15688
|
}
|
|
15623
15689
|
break;
|
|
15624
15690
|
case "create-node":
|
|
@@ -15844,6 +15910,35 @@ function ac_create_record(gobj, event, kw, src) {
|
|
|
15844
15910
|
});
|
|
15845
15911
|
}
|
|
15846
15912
|
/********************************************
|
|
15913
|
+
* Event from formtable: ONE field of one record, edited in place.
|
|
15914
|
+
*
|
|
15915
|
+
* Written as a PARTIAL update and, deliberately, with NO `autolink`.
|
|
15916
|
+
* `treedb_update_node()` merges (`json_object_update`), so the fields
|
|
15917
|
+
* it does not carry are left alone; and `autolink` is what wipes a
|
|
15918
|
+
* node's links to rebuild them from the fkeys the record carries — on
|
|
15919
|
+
* a partial record that reads as "no parents" and DETACHES the node,
|
|
15920
|
+
* answering success. A cell edit changes a scalar and must not be able
|
|
15921
|
+
* to touch a link at all.
|
|
15922
|
+
*
|
|
15923
|
+
* This is why it is its own event and not EV_UPDATE_RECORD: that one
|
|
15924
|
+
* DOES send autolink, and may, because the form hands it the whole
|
|
15925
|
+
* record with its fkeys in it.
|
|
15926
|
+
********************************************/
|
|
15927
|
+
function ac_update_field(gobj, event, kw, src) {
|
|
15928
|
+
if (refuse_if_readonly$3(gobj, event)) return -1;
|
|
15929
|
+
let topic_name = kw.topic_name || gobj_read_attr(src, "topic_name");
|
|
15930
|
+
let desc = (gobj_read_attr(gobj, "descs") || {})[topic_name];
|
|
15931
|
+
let pkey = desc && desc.pkey || "id";
|
|
15932
|
+
if (kw.id === void 0 || kw.id === null || !kw.field) {
|
|
15933
|
+
log_error(`${gobj_short_name(gobj)}: a field write with no id or no field`);
|
|
15934
|
+
return -1;
|
|
15935
|
+
}
|
|
15936
|
+
let record = {};
|
|
15937
|
+
record[pkey] = kw.id;
|
|
15938
|
+
record[kw.field] = kw.value;
|
|
15939
|
+
return treedb_update_node$1(gobj, gobj_read_str_attr(gobj, "treedb_name"), topic_name, record, { list_dict: true });
|
|
15940
|
+
}
|
|
15941
|
+
/********************************************
|
|
15847
15942
|
* Event from formtable
|
|
15848
15943
|
* kw: {
|
|
15849
15944
|
* topic_name,
|
|
@@ -15965,6 +16060,11 @@ function create_gclass$6(gclass_name) {
|
|
|
15965
16060
|
ac_update_record,
|
|
15966
16061
|
null
|
|
15967
16062
|
],
|
|
16063
|
+
[
|
|
16064
|
+
"EV_UPDATE_FIELD",
|
|
16065
|
+
ac_update_field,
|
|
16066
|
+
null
|
|
16067
|
+
],
|
|
15968
16068
|
[
|
|
15969
16069
|
"EV_DELETE_RECORD",
|
|
15970
16070
|
ac_delete_record,
|
|
@@ -16038,6 +16138,7 @@ function create_gclass$6(gclass_name) {
|
|
|
16038
16138
|
["EV_TREEDB_NODE_DELETED", event_flag_t.EVF_PUBLIC_EVENT],
|
|
16039
16139
|
["EV_CREATE_RECORD", 0],
|
|
16040
16140
|
["EV_UPDATE_RECORD", 0],
|
|
16141
|
+
["EV_UPDATE_FIELD", 0],
|
|
16041
16142
|
["EV_DELETE_RECORD", 0],
|
|
16042
16143
|
["EV_RECORD_WRITTEN", event_flag_t.EVF_OUTPUT_EVENT | event_flag_t.EVF_NO_WARN_SUBS],
|
|
16043
16144
|
["EV_REFRESH_TOPIC", 0],
|
|
@@ -16251,6 +16352,7 @@ var attrs_table$5 = [
|
|
|
16251
16352
|
SDATA(data_type_t.DTP_BOOLEAN, "with_columns_button", 0, true, "Button toolbar COLUMNS (choose which columns the table shows)"),
|
|
16252
16353
|
SDATA(data_type_t.DTP_BOOLEAN, "with_export_button", 0, true, "Button toolbar EXPORT (download as CSV what the table holds)"),
|
|
16253
16354
|
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)"),
|
|
16355
|
+
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)"),
|
|
16254
16356
|
SDATA(data_type_t.DTP_BOOLEAN, "with_in_row_edit_icons", 0, true, "Add a last column with internal EDIT/DELETE icon"),
|
|
16255
16357
|
SDATA(data_type_t.DTP_BOOLEAN, "editable", 0, false, "Edit state"),
|
|
16256
16358
|
SDATA(data_type_t.DTP_BOOLEAN, "with_checkbox", 0, true, "Auxiliary first column to select rows"),
|
|
@@ -16760,6 +16862,7 @@ function create_tabulator(gobj) {
|
|
|
16760
16862
|
let table_id = gobj_read_str_attr(gobj, "table_id");
|
|
16761
16863
|
let desc = gobj_read_attr(gobj, "desc");
|
|
16762
16864
|
let with_header_filters = gobj_read_bool_attr(gobj, "with_header_filters");
|
|
16865
|
+
let with_inline_edit = gobj_read_bool_attr(gobj, "with_inline_edit");
|
|
16763
16866
|
let columns = [];
|
|
16764
16867
|
let with_checkbox = gobj_read_bool_attr(gobj, "with_checkbox");
|
|
16765
16868
|
let with_radio = gobj_read_bool_attr(gobj, "with_radio");
|
|
@@ -16803,6 +16906,42 @@ function create_tabulator(gobj) {
|
|
|
16803
16906
|
col_id: cell.getField()
|
|
16804
16907
|
}, gobj);
|
|
16805
16908
|
}
|
|
16909
|
+
const INLINE_EDITABLE = [
|
|
16910
|
+
"string",
|
|
16911
|
+
"integer",
|
|
16912
|
+
"real",
|
|
16913
|
+
"email",
|
|
16914
|
+
"url",
|
|
16915
|
+
"tel",
|
|
16916
|
+
"id",
|
|
16917
|
+
"hex",
|
|
16918
|
+
"currency",
|
|
16919
|
+
"percent",
|
|
16920
|
+
"uuid"
|
|
16921
|
+
];
|
|
16922
|
+
function inline_editor_for(colDef, col, field_desc) {
|
|
16923
|
+
if (!field_desc.is_writable) return;
|
|
16924
|
+
if (col.id === (desc.pkey || "id")) return;
|
|
16925
|
+
switch (field_desc.type) {
|
|
16926
|
+
case "boolean":
|
|
16927
|
+
colDef.editor = "tickCross";
|
|
16928
|
+
break;
|
|
16929
|
+
case "enum":
|
|
16930
|
+
colDef.editor = "list";
|
|
16931
|
+
colDef.editorParams = { values: field_desc.enum_list || [] };
|
|
16932
|
+
break;
|
|
16933
|
+
case "integer":
|
|
16934
|
+
case "real":
|
|
16935
|
+
colDef.editor = "number";
|
|
16936
|
+
break;
|
|
16937
|
+
default:
|
|
16938
|
+
if (!INLINE_EDITABLE.includes(field_desc.type)) return;
|
|
16939
|
+
colDef.editor = "input";
|
|
16940
|
+
}
|
|
16941
|
+
colDef.editable = function() {
|
|
16942
|
+
return gobj_read_bool_attr(gobj, "editable") && !gobj_read_bool_attr(gobj, "readonly");
|
|
16943
|
+
};
|
|
16944
|
+
}
|
|
16806
16945
|
const FILTERABLE = [
|
|
16807
16946
|
"string",
|
|
16808
16947
|
"integer",
|
|
@@ -16925,6 +17064,7 @@ function create_tabulator(gobj) {
|
|
|
16925
17064
|
formatter: colFormatter
|
|
16926
17065
|
};
|
|
16927
17066
|
if (with_header_filters) apply_header_filter(colDef, field_desc);
|
|
17067
|
+
if (with_inline_edit) inline_editor_for(colDef, col, field_desc);
|
|
16928
17068
|
if (vertAlign) colDef.vertAlign = vertAlign;
|
|
16929
17069
|
if (formatterParams) colDef.formatterParams = formatterParams;
|
|
16930
17070
|
if (cellClick) colDef.cellClick = cellClick;
|
|
@@ -17011,6 +17151,21 @@ function create_tabulator(gobj) {
|
|
|
17011
17151
|
});
|
|
17012
17152
|
tabulator.on("dataProcessed", update_rowcount);
|
|
17013
17153
|
tabulator.on("dataChanged", update_rowcount);
|
|
17154
|
+
tabulator.on("cellEdited", function(cell) {
|
|
17155
|
+
let pkey = (gobj_read_attr(gobj, "desc") || {}).pkey || "id";
|
|
17156
|
+
let data = cell.getRow().getData();
|
|
17157
|
+
let id = data ? data[pkey] : void 0;
|
|
17158
|
+
if (id === void 0 || id === null) {
|
|
17159
|
+
log_error(`${gobj_short_name(gobj)}: edited a row with no '${pkey}'`);
|
|
17160
|
+
cell.restoreOldValue();
|
|
17161
|
+
return;
|
|
17162
|
+
}
|
|
17163
|
+
gobj_send_event(gobj, "EV_CELL_EDITED", {
|
|
17164
|
+
id,
|
|
17165
|
+
field: cell.getField(),
|
|
17166
|
+
value: cell.getValue()
|
|
17167
|
+
}, gobj);
|
|
17168
|
+
});
|
|
17014
17169
|
tabulator.on("dataFiltered", function(filters, rows) {
|
|
17015
17170
|
update_rowcount(Array.isArray(rows) ? rows.length : void 0);
|
|
17016
17171
|
});
|
|
@@ -18131,6 +18286,20 @@ function ac_show_cell_json(gobj, event, kw, src) {
|
|
|
18131
18286
|
return 0;
|
|
18132
18287
|
}
|
|
18133
18288
|
/************************************************************
|
|
18289
|
+
* One cell of one record changed. Publish it UP as a field write;
|
|
18290
|
+
* the host owns the treedb command and its options.
|
|
18291
|
+
************************************************************/
|
|
18292
|
+
function ac_cell_edited(gobj, event, kw, src) {
|
|
18293
|
+
if (refuse_if_readonly$2(gobj, event)) return -1;
|
|
18294
|
+
gobj_publish_event(gobj, "EV_UPDATE_FIELD", {
|
|
18295
|
+
topic_name: gobj_read_str_attr(gobj, "topic_name"),
|
|
18296
|
+
id: kw.id,
|
|
18297
|
+
field: kw.field,
|
|
18298
|
+
value: kw.value
|
|
18299
|
+
});
|
|
18300
|
+
return 0;
|
|
18301
|
+
}
|
|
18302
|
+
/************************************************************
|
|
18134
18303
|
* Show the schema (desc) of this topic
|
|
18135
18304
|
************************************************************/
|
|
18136
18305
|
function ac_show_schema(gobj, event, kw, src) {
|
|
@@ -18380,6 +18549,11 @@ function create_gclass$5(gclass_name) {
|
|
|
18380
18549
|
ac_show_schema,
|
|
18381
18550
|
null
|
|
18382
18551
|
],
|
|
18552
|
+
[
|
|
18553
|
+
"EV_CELL_EDITED",
|
|
18554
|
+
ac_cell_edited,
|
|
18555
|
+
null
|
|
18556
|
+
],
|
|
18383
18557
|
[
|
|
18384
18558
|
"EV_SEARCH",
|
|
18385
18559
|
ac_search,
|
|
@@ -18430,6 +18604,8 @@ function create_gclass$5(gclass_name) {
|
|
|
18430
18604
|
["EV_CHANGE_LOCALE", 0],
|
|
18431
18605
|
["EV_REFRESH", 0],
|
|
18432
18606
|
["EV_SHOW_SCHEMA", 0],
|
|
18607
|
+
["EV_CELL_EDITED", 0],
|
|
18608
|
+
["EV_UPDATE_FIELD", event_flag_t.EVF_OUTPUT_EVENT],
|
|
18433
18609
|
["EV_SEARCH", 0],
|
|
18434
18610
|
["EV_OPEN_COLUMNS", 0],
|
|
18435
18611
|
["EV_TOGGLE_COLUMN", 0],
|
package/package.json
CHANGED
|
@@ -111,6 +111,7 @@ SDATA(data_type_t.DTP_BOOLEAN, "with_schema_button", 0, true, "Button
|
|
|
111
111
|
SDATA(data_type_t.DTP_BOOLEAN, "with_columns_button", 0, true, "Button toolbar COLUMNS (choose which columns the table shows)"),
|
|
112
112
|
SDATA(data_type_t.DTP_BOOLEAN, "with_export_button", 0, true, "Button toolbar EXPORT (download as CSV what the table holds)"),
|
|
113
113
|
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
|
+
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)"),
|
|
114
115
|
SDATA(data_type_t.DTP_BOOLEAN, "with_in_row_edit_icons", 0, true, "Add a last column with internal EDIT/DELETE icon"),
|
|
115
116
|
|
|
116
117
|
SDATA(data_type_t.DTP_BOOLEAN, "editable", 0, false, "Edit state"),
|
|
@@ -730,6 +731,7 @@ function create_tabulator(gobj)
|
|
|
730
731
|
let table_id = gobj_read_str_attr(gobj, "table_id");
|
|
731
732
|
let desc = gobj_read_attr(gobj, "desc");
|
|
732
733
|
let with_header_filters = gobj_read_bool_attr(gobj, "with_header_filters");
|
|
734
|
+
let with_inline_edit = gobj_read_bool_attr(gobj, "with_inline_edit");
|
|
733
735
|
|
|
734
736
|
let columns = [];
|
|
735
737
|
|
|
@@ -819,6 +821,62 @@ function create_tabulator(gobj)
|
|
|
819
821
|
* value is stringified first because a fkey can arrive as a ref string,
|
|
820
822
|
* a list of them or a dict.
|
|
821
823
|
*/
|
|
824
|
+
/* A cell you can type into, and the ones you cannot.
|
|
825
|
+
*
|
|
826
|
+
* Editing in place is for a SCALAR the table can round-trip. A hook
|
|
827
|
+
* holds children and an fkey IS a link — both are edited by linking,
|
|
828
|
+
* not by typing — a dict or a list is a document (the form has an
|
|
829
|
+
* editor for it), and a date cell shows a formatted string over an
|
|
830
|
+
* epoch, so typing into it would write the string. Those stay with
|
|
831
|
+
* the form, which is one click away on the same row.
|
|
832
|
+
*
|
|
833
|
+
* The schema decides the rest: only a column flagged `writable` is
|
|
834
|
+
* offered, and never the pkey — renaming what a record is KEYED by
|
|
835
|
+
* is not a field edit.
|
|
836
|
+
*/
|
|
837
|
+
const INLINE_EDITABLE = [
|
|
838
|
+
"string", "integer", "real",
|
|
839
|
+
"email", "url", "tel", "id", "hex", "currency", "percent", "uuid"
|
|
840
|
+
];
|
|
841
|
+
|
|
842
|
+
function inline_editor_for(colDef, col, field_desc)
|
|
843
|
+
{
|
|
844
|
+
if(!field_desc.is_writable) {
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
if(col.id === (desc.pkey || "id")) {
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
switch(field_desc.type) {
|
|
852
|
+
case "boolean":
|
|
853
|
+
colDef.editor = "tickCross";
|
|
854
|
+
break;
|
|
855
|
+
case "enum":
|
|
856
|
+
colDef.editor = "list";
|
|
857
|
+
colDef.editorParams = {values: field_desc.enum_list || []};
|
|
858
|
+
break;
|
|
859
|
+
case "integer":
|
|
860
|
+
case "real":
|
|
861
|
+
colDef.editor = "number";
|
|
862
|
+
break;
|
|
863
|
+
default:
|
|
864
|
+
if(!INLINE_EDITABLE.includes(field_desc.type)) {
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
colDef.editor = "input";
|
|
868
|
+
break;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/* A FUNCTION, not a flag: edition mode is toggled on a table that
|
|
872
|
+
* is already built (ac_edition_mode only shows and hides columns),
|
|
873
|
+
* so the answer has to be asked for at the moment of the click. */
|
|
874
|
+
colDef.editable = function() {
|
|
875
|
+
return gobj_read_bool_attr(gobj, "editable") &&
|
|
876
|
+
!gobj_read_bool_attr(gobj, "readonly");
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
|
|
822
880
|
const FILTERABLE = [
|
|
823
881
|
"string", "integer", "real", "rowid", "uuid", "qualified",
|
|
824
882
|
"email", "url", "tel", "id", "hex", "currency", "percent"
|
|
@@ -974,6 +1032,9 @@ function create_tabulator(gobj)
|
|
|
974
1032
|
if(with_header_filters) {
|
|
975
1033
|
apply_header_filter(colDef, field_desc);
|
|
976
1034
|
}
|
|
1035
|
+
if(with_inline_edit) {
|
|
1036
|
+
inline_editor_for(colDef, col, field_desc);
|
|
1037
|
+
}
|
|
977
1038
|
if(vertAlign) {
|
|
978
1039
|
colDef.vertAlign = vertAlign;
|
|
979
1040
|
}
|
|
@@ -1139,6 +1200,33 @@ function create_tabulator(gobj)
|
|
|
1139
1200
|
* box; a filter per column made it a claim you read on every keystroke. */
|
|
1140
1201
|
tabulator.on("dataProcessed", update_rowcount);
|
|
1141
1202
|
tabulator.on("dataChanged", update_rowcount);
|
|
1203
|
+
/* A cell edited in place. Announced as ONE FIELD of one record — not
|
|
1204
|
+
* as the row — and that is the whole safety of it: the host writes it
|
|
1205
|
+
* with a partial update and no `autolink`, so nothing but that field
|
|
1206
|
+
* moves. `autolink` wipes a node's links and rebuilds them from the
|
|
1207
|
+
* fkeys the record carries, so sending a row that the table shaped for
|
|
1208
|
+
* DISPLAY would unlink the node and answer success. */
|
|
1209
|
+
tabulator.on("cellEdited", function(cell) {
|
|
1210
|
+
let pkey = (gobj_read_attr(gobj, "desc") || {}).pkey || "id";
|
|
1211
|
+
let data = cell.getRow().getData();
|
|
1212
|
+
let id = data ? data[pkey] : undefined;
|
|
1213
|
+
if(id === undefined || id === null) {
|
|
1214
|
+
log_error(`${gobj_short_name(gobj)}: edited a row with no '${pkey}'`);
|
|
1215
|
+
cell.restoreOldValue();
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1218
|
+
gobj_send_event(
|
|
1219
|
+
gobj,
|
|
1220
|
+
"EV_CELL_EDITED",
|
|
1221
|
+
{
|
|
1222
|
+
id: id,
|
|
1223
|
+
field: cell.getField(),
|
|
1224
|
+
value: cell.getValue()
|
|
1225
|
+
},
|
|
1226
|
+
gobj
|
|
1227
|
+
);
|
|
1228
|
+
});
|
|
1229
|
+
|
|
1142
1230
|
tabulator.on("dataFiltered", function(filters, rows) {
|
|
1143
1231
|
update_rowcount(Array.isArray(rows)? rows.length : undefined);
|
|
1144
1232
|
});
|
|
@@ -2845,6 +2933,28 @@ function ac_show_cell_json(gobj, event, kw, src)
|
|
|
2845
2933
|
return 0;
|
|
2846
2934
|
}
|
|
2847
2935
|
|
|
2936
|
+
/************************************************************
|
|
2937
|
+
* One cell of one record changed. Publish it UP as a field write;
|
|
2938
|
+
* the host owns the treedb command and its options.
|
|
2939
|
+
************************************************************/
|
|
2940
|
+
function ac_cell_edited(gobj, event, kw, src)
|
|
2941
|
+
{
|
|
2942
|
+
if(refuse_if_readonly(gobj, event)) {
|
|
2943
|
+
return -1; /* Error already logged */
|
|
2944
|
+
}
|
|
2945
|
+
gobj_publish_event(
|
|
2946
|
+
gobj,
|
|
2947
|
+
"EV_UPDATE_FIELD",
|
|
2948
|
+
{
|
|
2949
|
+
topic_name: gobj_read_str_attr(gobj, "topic_name"),
|
|
2950
|
+
id: kw.id,
|
|
2951
|
+
field: kw.field,
|
|
2952
|
+
value: kw.value
|
|
2953
|
+
}
|
|
2954
|
+
);
|
|
2955
|
+
return 0;
|
|
2956
|
+
}
|
|
2957
|
+
|
|
2848
2958
|
/************************************************************
|
|
2849
2959
|
* Show the schema (desc) of this topic
|
|
2850
2960
|
************************************************************/
|
|
@@ -3076,6 +3186,7 @@ function create_gclass(gclass_name)
|
|
|
3076
3186
|
["EV_CHANGE_LOCALE", ac_change_locale, null],
|
|
3077
3187
|
["EV_REFRESH", ac_refresh, null],
|
|
3078
3188
|
["EV_SHOW_SCHEMA", ac_show_schema, null],
|
|
3189
|
+
["EV_CELL_EDITED", ac_cell_edited, null],
|
|
3079
3190
|
["EV_SEARCH", ac_search, null],
|
|
3080
3191
|
["EV_OPEN_COLUMNS", ac_open_columns, null],
|
|
3081
3192
|
["EV_TOGGLE_COLUMN", ac_toggle_column, null],
|
|
@@ -3108,6 +3219,8 @@ function create_gclass(gclass_name)
|
|
|
3108
3219
|
["EV_CHANGE_LOCALE", 0],
|
|
3109
3220
|
["EV_REFRESH", 0],
|
|
3110
3221
|
["EV_SHOW_SCHEMA", 0],
|
|
3222
|
+
["EV_CELL_EDITED", 0],
|
|
3223
|
+
["EV_UPDATE_FIELD", event_flag_t.EVF_OUTPUT_EVENT],
|
|
3111
3224
|
["EV_SEARCH", 0],
|
|
3112
3225
|
["EV_OPEN_COLUMNS", 0],
|
|
3113
3226
|
["EV_TOGGLE_COLUMN", 0],
|
|
@@ -58,6 +58,7 @@ import "./c_yui_treedb_topics.css";
|
|
|
58
58
|
|
|
59
59
|
import {yui_shell_show_error, yui_shell_show_modal, yui_shell_popup_layer} from "./shell_modals.js";
|
|
60
60
|
import {yui_shell_of, yui_shell_set_sub_routes} from "./c_yui_shell.js";
|
|
61
|
+
import {nodes_answer} from "./nodes_answer.js";
|
|
61
62
|
|
|
62
63
|
import {t} from "i18next";
|
|
63
64
|
|
|
@@ -1740,6 +1741,18 @@ function ac_mt_command_answer(gobj, event, kw, src)
|
|
|
1740
1741
|
show_load_error(gobj, t(comment));
|
|
1741
1742
|
} else {
|
|
1742
1743
|
yui_shell_show_error(yui_shell_of(gobj), comment, {t: t});
|
|
1744
|
+
/* A refused write leaves the TABLE showing what the operator
|
|
1745
|
+
* typed and the store holding what it had. That is tolerable
|
|
1746
|
+
* for a form (it stays open on the values it failed with) and
|
|
1747
|
+
* is not for a cell edited in place, which looks saved. Put the
|
|
1748
|
+
* topic back to what the treedb actually has. */
|
|
1749
|
+
if(command === "update-node" || command === "create-node") {
|
|
1750
|
+
let failed_topic = kw_get_str(gobj, kw_command, "topic_name", "", 0);
|
|
1751
|
+
if(failed_topic) {
|
|
1752
|
+
treedb_nodes(gobj, gobj_read_str_attr(gobj, "treedb_name"),
|
|
1753
|
+
failed_topic, {list_dict: true});
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1743
1756
|
}
|
|
1744
1757
|
return 0;
|
|
1745
1758
|
}
|
|
@@ -1758,10 +1771,18 @@ function ac_mt_command_answer(gobj, event, kw, src)
|
|
|
1758
1771
|
let gobj_topic_form = gobj_find_child(gobj, {
|
|
1759
1772
|
__gobj_name__: `${gobj_name(gobj)}?${topic_name}`
|
|
1760
1773
|
});
|
|
1774
|
+
/* `nodes` answers a plain list, or — when asked for a page —
|
|
1775
|
+
* the {total_rows, pages, data} envelope. Both shapes are
|
|
1776
|
+
* alive at once and will be for a long time: this SPA talks
|
|
1777
|
+
* to backends the operator configures, and an older one
|
|
1778
|
+
* answers the plain list whatever is asked of it. Reading
|
|
1779
|
+
* the shape rather than assuming it is what keeps the table
|
|
1780
|
+
* from rendering an object's keys as rows the day a backend
|
|
1781
|
+
* upgrades. */
|
|
1761
1782
|
gobj_send_event(
|
|
1762
1783
|
gobj_topic_form,
|
|
1763
1784
|
"EV_LOAD_NODES",
|
|
1764
|
-
data,
|
|
1785
|
+
nodes_answer(data).rows,
|
|
1765
1786
|
gobj
|
|
1766
1787
|
);
|
|
1767
1788
|
}
|
|
@@ -2126,6 +2147,49 @@ function ac_create_record(gobj, event, kw, src)
|
|
|
2126
2147
|
);
|
|
2127
2148
|
}
|
|
2128
2149
|
|
|
2150
|
+
/********************************************
|
|
2151
|
+
* Event from formtable: ONE field of one record, edited in place.
|
|
2152
|
+
*
|
|
2153
|
+
* Written as a PARTIAL update and, deliberately, with NO `autolink`.
|
|
2154
|
+
* `treedb_update_node()` merges (`json_object_update`), so the fields
|
|
2155
|
+
* it does not carry are left alone; and `autolink` is what wipes a
|
|
2156
|
+
* node's links to rebuild them from the fkeys the record carries — on
|
|
2157
|
+
* a partial record that reads as "no parents" and DETACHES the node,
|
|
2158
|
+
* answering success. A cell edit changes a scalar and must not be able
|
|
2159
|
+
* to touch a link at all.
|
|
2160
|
+
*
|
|
2161
|
+
* This is why it is its own event and not EV_UPDATE_RECORD: that one
|
|
2162
|
+
* DOES send autolink, and may, because the form hands it the whole
|
|
2163
|
+
* record with its fkeys in it.
|
|
2164
|
+
********************************************/
|
|
2165
|
+
function ac_update_field(gobj, event, kw, src)
|
|
2166
|
+
{
|
|
2167
|
+
if(refuse_if_readonly(gobj, event)) {
|
|
2168
|
+
return -1; /* Error already logged */
|
|
2169
|
+
}
|
|
2170
|
+
let topic_name = kw.topic_name || gobj_read_attr(src, "topic_name");
|
|
2171
|
+
let descs = gobj_read_attr(gobj, "descs") || {};
|
|
2172
|
+
let desc = descs[topic_name];
|
|
2173
|
+
let pkey = (desc && desc.pkey) || "id";
|
|
2174
|
+
|
|
2175
|
+
if(kw.id === undefined || kw.id === null || !kw.field) {
|
|
2176
|
+
log_error(`${gobj_short_name(gobj)}: a field write with no id or no field`);
|
|
2177
|
+
return -1;
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
let record = {};
|
|
2181
|
+
record[pkey] = kw.id;
|
|
2182
|
+
record[kw.field] = kw.value;
|
|
2183
|
+
|
|
2184
|
+
return treedb_update_node(
|
|
2185
|
+
gobj,
|
|
2186
|
+
gobj_read_str_attr(gobj, "treedb_name"),
|
|
2187
|
+
topic_name,
|
|
2188
|
+
record,
|
|
2189
|
+
{list_dict: true}
|
|
2190
|
+
);
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2129
2193
|
/********************************************
|
|
2130
2194
|
* Event from formtable
|
|
2131
2195
|
* kw: {
|
|
@@ -2292,6 +2356,7 @@ function create_gclass(gclass_name)
|
|
|
2292
2356
|
["EV_TREEDB_NODE_DELETED", ac_treedb_node_deleted, null],
|
|
2293
2357
|
["EV_CREATE_RECORD", ac_create_record, null],
|
|
2294
2358
|
["EV_UPDATE_RECORD", ac_update_record, null],
|
|
2359
|
+
["EV_UPDATE_FIELD", ac_update_field, null],
|
|
2295
2360
|
["EV_DELETE_RECORD", ac_delete_record, null],
|
|
2296
2361
|
["EV_REFRESH_TOPIC", ac_refresh_topic, null],
|
|
2297
2362
|
["EV_OPEN_JSON", ac_open_json, null],
|
|
@@ -2318,6 +2383,7 @@ function create_gclass(gclass_name)
|
|
|
2318
2383
|
["EV_TREEDB_NODE_DELETED", event_flag_t.EVF_PUBLIC_EVENT],
|
|
2319
2384
|
["EV_CREATE_RECORD", 0],
|
|
2320
2385
|
["EV_UPDATE_RECORD", 0],
|
|
2386
|
+
["EV_UPDATE_FIELD", 0],
|
|
2321
2387
|
["EV_DELETE_RECORD", 0],
|
|
2322
2388
|
["EV_RECORD_WRITTEN", event_flag_t.EVF_OUTPUT_EVENT|
|
|
2323
2389
|
event_flag_t.EVF_NO_WARN_SUBS],
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/***********************************************************************
|
|
2
|
+
* nodes_answer.js
|
|
3
|
+
*
|
|
4
|
+
* What the `nodes` command answered, whatever shape it came in.
|
|
5
|
+
*
|
|
6
|
+
* A treedb lives in memory on the backend, so walking it is not
|
|
7
|
+
* what costs: serializing every node, pushing it through a
|
|
8
|
+
* websocket and parsing it here is. So `nodes` learned to answer a
|
|
9
|
+
* PAGE, with the contract `list-keys` of C_TRANGER already used:
|
|
10
|
+
*
|
|
11
|
+
* no `limit` -> the plain list it has always answered
|
|
12
|
+
* a `limit` -> {total_rows, pages, data}
|
|
13
|
+
*
|
|
14
|
+
* Both shapes are alive at once and will be for a long time: the
|
|
15
|
+
* SPA talks to backends the operator configures, and an older one
|
|
16
|
+
* answers the plain list whatever this app asks for. So the shape
|
|
17
|
+
* is read, never assumed — a view that indexed `data` as an array
|
|
18
|
+
* would render an object's keys as rows the day a backend
|
|
19
|
+
* upgraded, which is the kind of break that looks like a bug in
|
|
20
|
+
* the app.
|
|
21
|
+
*
|
|
22
|
+
* Pure and tested, because this is exactly the sort of thing that
|
|
23
|
+
* cannot be exercised until the other side moves.
|
|
24
|
+
*
|
|
25
|
+
* Copyright (c) 2026, ArtGins.
|
|
26
|
+
* All Rights Reserved.
|
|
27
|
+
***********************************************************************/
|
|
28
|
+
|
|
29
|
+
/***************************************************************
|
|
30
|
+
* nodes_answer(data) -> {rows, total, pages, paged}
|
|
31
|
+
*
|
|
32
|
+
* `rows` is always an array, so a caller never has to ask which
|
|
33
|
+
* shape it got. `paged` says whether the backend answered a page,
|
|
34
|
+
* which is the one thing a caller may legitimately care about (it is
|
|
35
|
+
* what tells a pager it has something to page).
|
|
36
|
+
***************************************************************/
|
|
37
|
+
function nodes_answer(data)
|
|
38
|
+
{
|
|
39
|
+
if(Array.isArray(data)) {
|
|
40
|
+
return {
|
|
41
|
+
rows: data,
|
|
42
|
+
total: data.length,
|
|
43
|
+
pages: 1,
|
|
44
|
+
paged: false
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if(data && typeof data === "object" && Array.isArray(data.data)) {
|
|
49
|
+
let rows = data.data;
|
|
50
|
+
let total = (typeof data.total_rows === "number")? data.total_rows : rows.length;
|
|
51
|
+
let pages = (typeof data.pages === "number" && data.pages > 0)? data.pages : 1;
|
|
52
|
+
return {
|
|
53
|
+
rows: rows,
|
|
54
|
+
total: total,
|
|
55
|
+
pages: pages,
|
|
56
|
+
paged: true
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/* Anything else — null, an error body, a shape nobody has seen — is
|
|
61
|
+
* no rows. The caller renders an empty table, which is honest, and
|
|
62
|
+
* the command answer's own `result` is what says something failed. */
|
|
63
|
+
return {
|
|
64
|
+
rows: [],
|
|
65
|
+
total: 0,
|
|
66
|
+
pages: 1,
|
|
67
|
+
paged: false
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
export {nodes_answer};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import {describe, it, expect} from "vitest";
|
|
2
|
+
import {nodes_answer} from "./nodes_answer.js";
|
|
3
|
+
|
|
4
|
+
describe("nodes_answer", () => {
|
|
5
|
+
it("reads the plain list an unpaged backend answers", () => {
|
|
6
|
+
let a = nodes_answer([{id: "a"}, {id: "b"}]);
|
|
7
|
+
expect(a.rows.length).toBe(2);
|
|
8
|
+
expect(a.total).toBe(2);
|
|
9
|
+
expect(a.pages).toBe(1);
|
|
10
|
+
expect(a.paged).toBe(false);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("reads the envelope a paged backend answers", () => {
|
|
14
|
+
let a = nodes_answer({total_rows: 120, pages: 3, data: [{id: "a"}]});
|
|
15
|
+
expect(a.rows.length).toBe(1);
|
|
16
|
+
expect(a.total).toBe(120);
|
|
17
|
+
expect(a.pages).toBe(3);
|
|
18
|
+
expect(a.paged).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("an empty page still reports the true total", () => {
|
|
22
|
+
let a = nodes_answer({total_rows: 120, pages: 3, data: []});
|
|
23
|
+
expect(a.rows).toEqual([]);
|
|
24
|
+
expect(a.total).toBe(120);
|
|
25
|
+
expect(a.paged).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("an empty list is not a page", () => {
|
|
29
|
+
let a = nodes_answer([]);
|
|
30
|
+
expect(a.rows).toEqual([]);
|
|
31
|
+
expect(a.total).toBe(0);
|
|
32
|
+
expect(a.paged).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("falls back to the row count when the envelope omits its totals", () => {
|
|
36
|
+
let a = nodes_answer({data: [{id: "a"}, {id: "b"}]});
|
|
37
|
+
expect(a.total).toBe(2);
|
|
38
|
+
expect(a.pages).toBe(1);
|
|
39
|
+
expect(a.paged).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("gives no rows for anything it does not recognise", () => {
|
|
43
|
+
for(let bad of [null, undefined, 0, "x", {}, {data: "nope"}]) {
|
|
44
|
+
let a = nodes_answer(bad);
|
|
45
|
+
expect(a.rows).toEqual([]);
|
|
46
|
+
expect(a.total).toBe(0);
|
|
47
|
+
expect(a.paged).toBe(false);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
});
|