@yuneta/gobj-ui 6.1.0 → 6.1.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 +11 -1
- package/dist/gobj-ui.cjs.js +232 -77
- package/dist/gobj-ui.es.js +232 -77
- package/package.json +1 -1
- package/src/c_yui_schema_editor.js +119 -33
- package/src/schema_descs.js +14 -6
- package/src/schema_descs.test.js +25 -0
- package/src/schema_import.js +17 -6
- package/src/schema_import.test.js +21 -0
- package/src/schema_model.js +26 -0
- package/src/schema_model.test.js +25 -0
- package/src/schema_to_c.js +22 -8
- package/src/schema_to_c.test.js +47 -0
- package/src/schema_write_options.js +60 -0
- package/src/schema_write_options.test.js +65 -0
- package/src/yui_icons.css +14 -0
|
@@ -120,6 +120,7 @@ import {
|
|
|
120
120
|
col_flags,
|
|
121
121
|
col_hook,
|
|
122
122
|
col_enum,
|
|
123
|
+
is_empty_value,
|
|
123
124
|
fkey_ref,
|
|
124
125
|
next_order,
|
|
125
126
|
moved_orders,
|
|
@@ -129,6 +130,7 @@ import {topic_descs} from "./schema_descs.js";
|
|
|
129
130
|
import {schema_to_json, schema_to_c} from "./schema_to_c.js";
|
|
130
131
|
import {plan_import, plan_deletes} from "./schema_import.js";
|
|
131
132
|
import {grouped_flags, toggle_flag} from "./schema_flags.js";
|
|
133
|
+
import {write_command, write_options} from "./schema_write_options.js";
|
|
132
134
|
import {COL_TYPES} from "./schema_validate.js";
|
|
133
135
|
|
|
134
136
|
import {yui_shell_of} from "./c_yui_shell.js";
|
|
@@ -488,10 +490,23 @@ function remote_command(gobj, command, kw)
|
|
|
488
490
|
service: treedb_name,
|
|
489
491
|
treedb_name: treedb_name
|
|
490
492
|
}, kw || {});
|
|
491
|
-
|
|
493
|
+
|
|
494
|
+
/* What has to come BACK with the answer.
|
|
495
|
+
*
|
|
496
|
+
* Its keys repeat the ones the command already carries, on purpose:
|
|
497
|
+
* the transport may be a direct C_IEVENT_CLI, which echoes the whole
|
|
498
|
+
* request as the command_stack frame, or a routing adapter, which
|
|
499
|
+
* echoes `__md_command__` AS that frame (c_agent_treedb_link.js).
|
|
500
|
+
* Same names in both places is what makes the answer readable
|
|
501
|
+
* whichever one is under this view — and `record_id` is here and
|
|
502
|
+
* NOT at the top level because a delete answers with nothing, and a
|
|
503
|
+
* parameter the treedb does not know has no business travelling. */
|
|
504
|
+
full_kw.__md_command__ = {
|
|
492
505
|
purpose: PURPOSE,
|
|
493
|
-
|
|
494
|
-
|
|
506
|
+
topic_name: (kw && kw.topic_name) || "",
|
|
507
|
+
record_id: (kw && kw.record_id) || ""
|
|
508
|
+
};
|
|
509
|
+
delete full_kw.record_id;
|
|
495
510
|
|
|
496
511
|
let ret = gobj_command(remote, command, full_kw, gobj);
|
|
497
512
|
if(ret) {
|
|
@@ -743,12 +758,12 @@ function render_toolbar(gobj)
|
|
|
743
758
|
if(priv.treedb_id && has_model) {
|
|
744
759
|
$right.push(toolbar_button("SCHEMA_DIAGRAM_BTN", "yi-hexagon-nodes",
|
|
745
760
|
priv.diagram ? "topics" : "diagram", "EV_TOGGLE_DIAGRAM", false));
|
|
746
|
-
$right.push(toolbar_button("SCHEMA_VALIDATE_BTN", "yi-
|
|
761
|
+
$right.push(toolbar_button("SCHEMA_VALIDATE_BTN", "yi-square-check",
|
|
747
762
|
"check", "EV_VALIDATE", false));
|
|
748
|
-
$right.push(toolbar_button("SCHEMA_EXPORT_BTN", "yi-
|
|
763
|
+
$right.push(toolbar_button("SCHEMA_EXPORT_BTN", "yi-download",
|
|
749
764
|
"export", "EV_EXPORT", false));
|
|
750
765
|
if(!readonly) {
|
|
751
|
-
$right.push(toolbar_button("SCHEMA_IMPORT_BTN", "yi-
|
|
766
|
+
$right.push(toolbar_button("SCHEMA_IMPORT_BTN", "yi-upload",
|
|
752
767
|
"import", "EV_IMPORT", false));
|
|
753
768
|
}
|
|
754
769
|
}
|
|
@@ -944,17 +959,23 @@ function render_topics(gobj, $body)
|
|
|
944
959
|
}
|
|
945
960
|
|
|
946
961
|
let readonly = is_readonly(gobj);
|
|
962
|
+
let twice = repeated_names(treedb.topics);
|
|
947
963
|
let $rows = [];
|
|
948
964
|
for(let topic of treedb.topics) {
|
|
949
965
|
let pending = priv.written[topic.id] &&
|
|
950
966
|
String(priv.baseline[topic.id]) === String(topic.topic_version);
|
|
967
|
+
/* Two topics of one name: the id is what tells them apart, and
|
|
968
|
+
* it is what the url has to carry — addressing by name would
|
|
969
|
+
* open the first one whichever row was clicked. */
|
|
970
|
+
let ambiguous = !!twice[topic.name];
|
|
971
|
+
let address = ambiguous ? topic.id : topic.name;
|
|
951
972
|
|
|
952
973
|
let $actions = [];
|
|
953
974
|
if(!readonly) {
|
|
954
975
|
$actions.push(row_icon(gobj, "SCHEMA_TOPIC_EDIT", "yi-pen", "edit topic",
|
|
955
|
-
"EV_EDIT_TOPIC", {topic:
|
|
976
|
+
"EV_EDIT_TOPIC", {topic: address}));
|
|
956
977
|
$actions.push(row_icon(gobj, "SCHEMA_TOPIC_DELETE", "yi-trash", "delete topic",
|
|
957
|
-
"EV_DELETE_TOPIC", {topic:
|
|
978
|
+
"EV_DELETE_TOPIC", {topic: address}));
|
|
958
979
|
}
|
|
959
980
|
|
|
960
981
|
$rows.push(["tr", {class: "SCHEMA_TOPIC_ROW", role: "button", tabindex: "0"}, [
|
|
@@ -963,6 +984,10 @@ function render_topics(gobj, $body)
|
|
|
963
984
|
topic.system_topic
|
|
964
985
|
? ["span", {class: "SCHEMA_TOPIC_SYSTEM tag is-light ml-2",
|
|
965
986
|
i18n: "system"}, t("system")]
|
|
987
|
+
: ["span", {}, ""],
|
|
988
|
+
ambiguous
|
|
989
|
+
? ["div", {class: "SCHEMA_TOPIC_ID is-size-7 has-text-grey"},
|
|
990
|
+
[["code", {}, `${topic.id}`]]]
|
|
966
991
|
: ["span", {}, ""]
|
|
967
992
|
]],
|
|
968
993
|
["td", {class: "SCHEMA_TOPIC_PKEY"}, [["code", {}, `${topic.pkey || "id"}`]]],
|
|
@@ -977,14 +1002,14 @@ function render_topics(gobj, $body)
|
|
|
977
1002
|
if(evt.target.closest(".SCHEMA_TOPIC_ACTIONS")) {
|
|
978
1003
|
return; /* a row action, not the row */
|
|
979
1004
|
}
|
|
980
|
-
gobj_send_event(gobj, "EV_SELECT_TOPIC", {topic:
|
|
1005
|
+
gobj_send_event(gobj, "EV_SELECT_TOPIC", {topic: address}, gobj);
|
|
981
1006
|
},
|
|
982
1007
|
keydown: (evt) => {
|
|
983
1008
|
if(evt.key !== "Enter" && evt.key !== " ") {
|
|
984
1009
|
return;
|
|
985
1010
|
}
|
|
986
1011
|
evt.preventDefault();
|
|
987
|
-
gobj_send_event(gobj, "EV_SELECT_TOPIC", {topic:
|
|
1012
|
+
gobj_send_event(gobj, "EV_SELECT_TOPIC", {topic: address}, gobj);
|
|
988
1013
|
}
|
|
989
1014
|
}]);
|
|
990
1015
|
}
|
|
@@ -1029,6 +1054,31 @@ function row_icon(gobj, logical, icon, key, event, kw)
|
|
|
1029
1054
|
}];
|
|
1030
1055
|
}
|
|
1031
1056
|
|
|
1057
|
+
/***************************************************************
|
|
1058
|
+
* Which names appear more than once.
|
|
1059
|
+
*
|
|
1060
|
+
* A store can hold TWO GENERATIONS of the same schema: the
|
|
1061
|
+
* projector never deletes, so a treedb re-keyed by a newer SDK
|
|
1062
|
+
* keeps its old rowid-keyed rows next to the qualified ones, and
|
|
1063
|
+
* both are children of the same treedb with the same name. Drawn
|
|
1064
|
+
* as two identical rows that behave differently, that reads as a
|
|
1065
|
+
* bug in the editor; drawn with what tells them apart, it reads
|
|
1066
|
+
* as what it is.
|
|
1067
|
+
***************************************************************/
|
|
1068
|
+
function repeated_names(list)
|
|
1069
|
+
{
|
|
1070
|
+
let seen = {};
|
|
1071
|
+
let twice = {};
|
|
1072
|
+
|
|
1073
|
+
for(let entry of list) {
|
|
1074
|
+
if(seen[entry.name]) {
|
|
1075
|
+
twice[entry.name] = true;
|
|
1076
|
+
}
|
|
1077
|
+
seen[entry.name] = true;
|
|
1078
|
+
}
|
|
1079
|
+
return twice;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1032
1082
|
/***************************************************************
|
|
1033
1083
|
* ST_COLUMNS — the columns of one topic, in schema order.
|
|
1034
1084
|
*
|
|
@@ -1062,9 +1112,10 @@ function render_columns(gobj, $body)
|
|
|
1062
1112
|
$wrap.appendChild($banner);
|
|
1063
1113
|
}
|
|
1064
1114
|
|
|
1115
|
+
let twice = repeated_names(topic.cols);
|
|
1065
1116
|
let $rows = [];
|
|
1066
1117
|
for(let i = 0; i < topic.cols.length; i++) {
|
|
1067
|
-
$rows.push(column_row(gobj, topic, topic.cols[i], i, readonly));
|
|
1118
|
+
$rows.push(column_row(gobj, topic, topic.cols[i], i, readonly, twice));
|
|
1068
1119
|
}
|
|
1069
1120
|
|
|
1070
1121
|
let $table = createElement2(
|
|
@@ -1133,7 +1184,7 @@ function version_banner(gobj, topic)
|
|
|
1133
1184
|
title: t("raise the version"), "aria-label": t("raise the version"),
|
|
1134
1185
|
"data-i18n-title": "raise the version",
|
|
1135
1186
|
"data-i18n-aria-label": "raise the version"}, [
|
|
1136
|
-
["span", {class: "icon"}, [["i", {class: "yi-
|
|
1187
|
+
["span", {class: "icon"}, [["i", {class: "yi-chevron-up"}]]],
|
|
1137
1188
|
["span", {i18n: "raise"}, t("raise")]
|
|
1138
1189
|
]]
|
|
1139
1190
|
]]
|
|
@@ -1152,9 +1203,13 @@ function version_banner(gobj, topic)
|
|
|
1152
1203
|
* Yuneta event is plain JSON, so a DOM node never travels in
|
|
1153
1204
|
* one.
|
|
1154
1205
|
***************************************************************/
|
|
1155
|
-
function column_row(gobj, topic, col, index, readonly)
|
|
1206
|
+
function column_row(gobj, topic, col, index, readonly, twice)
|
|
1156
1207
|
{
|
|
1157
1208
|
let record = col.record || {};
|
|
1209
|
+
/* Same two generations as the topics above: when a name is not
|
|
1210
|
+
* unique the id is what addresses the row. */
|
|
1211
|
+
let ambiguous = !!(twice && twice[col.name]);
|
|
1212
|
+
let address = ambiguous ? col.id : col.name;
|
|
1158
1213
|
let flags = col_flags(record);
|
|
1159
1214
|
let hook = col_hook(record);
|
|
1160
1215
|
let is_pkey = (col.name === (topic.pkey || "id"));
|
|
@@ -1163,8 +1218,11 @@ function column_row(gobj, topic, col, index, readonly)
|
|
|
1163
1218
|
return ["span", {class: `SCHEMA_COL_FLAG tag is-light ${flag_style(flag)}`}, `${flag}`];
|
|
1164
1219
|
});
|
|
1165
1220
|
|
|
1221
|
+
/* An unset `blob` column comes back as `{}`, which is an object and
|
|
1222
|
+
* is not a hook: read as one, every column in the topic draws an
|
|
1223
|
+
* arrow pointing at nothing. */
|
|
1166
1224
|
let link = "";
|
|
1167
|
-
if(hook) {
|
|
1225
|
+
if(hook && !is_empty_value(hook)) {
|
|
1168
1226
|
link = "→ " + Object.keys(hook).join(", ");
|
|
1169
1227
|
} else if(flags.indexOf("fkey") >= 0) {
|
|
1170
1228
|
link = "↖";
|
|
@@ -1173,11 +1231,11 @@ function column_row(gobj, topic, col, index, readonly)
|
|
|
1173
1231
|
let $actions = [];
|
|
1174
1232
|
if(!readonly) {
|
|
1175
1233
|
$actions.push(row_icon(gobj, "SCHEMA_COL_EDIT", "yi-pen", "edit column",
|
|
1176
|
-
"EV_EDIT_COLUMN", {col:
|
|
1234
|
+
"EV_EDIT_COLUMN", {col: address}));
|
|
1177
1235
|
$actions.push(row_icon(gobj, "SCHEMA_COL_DUPLICATE", "yi-copy", "duplicate column",
|
|
1178
|
-
"EV_DUPLICATE_COLUMN", {col:
|
|
1236
|
+
"EV_DUPLICATE_COLUMN", {col: address}));
|
|
1179
1237
|
$actions.push(row_icon(gobj, "SCHEMA_COL_DELETE", "yi-trash", "delete column",
|
|
1180
|
-
"EV_DELETE_COLUMN", {col:
|
|
1238
|
+
"EV_DELETE_COLUMN", {col: address}));
|
|
1181
1239
|
}
|
|
1182
1240
|
|
|
1183
1241
|
let $row = createElement2(
|
|
@@ -1188,6 +1246,10 @@ function column_row(gobj, topic, col, index, readonly)
|
|
|
1188
1246
|
[["i", {class: "yi-grip-vertical"}]]]]],
|
|
1189
1247
|
["td", {class: "SCHEMA_COL_NAME"}, [
|
|
1190
1248
|
["code", {}, `${col.name}`],
|
|
1249
|
+
ambiguous
|
|
1250
|
+
? ["span", {class: "SCHEMA_COL_ID is-size-7 has-text-grey ml-2"},
|
|
1251
|
+
[["code", {}, `${col.id}`]]]
|
|
1252
|
+
: ["span", {}, ""],
|
|
1191
1253
|
is_pkey
|
|
1192
1254
|
? ["span", {class: "SCHEMA_COL_PKEY tag is-info is-light ml-2",
|
|
1193
1255
|
i18n: "pkey"}, t("pkey")]
|
|
@@ -1210,7 +1272,7 @@ function column_row(gobj, topic, col, index, readonly)
|
|
|
1210
1272
|
if(readonly || evt.target.closest(".SCHEMA_COL_ACTIONS")) {
|
|
1211
1273
|
return;
|
|
1212
1274
|
}
|
|
1213
|
-
gobj_send_event(gobj, "EV_EDIT_COLUMN", {col:
|
|
1275
|
+
gobj_send_event(gobj, "EV_EDIT_COLUMN", {col: address}, gobj);
|
|
1214
1276
|
});
|
|
1215
1277
|
return $row;
|
|
1216
1278
|
}
|
|
@@ -1552,7 +1614,10 @@ function open_column_form(gobj, topic, col, prefill)
|
|
|
1552
1614
|
let record = prefill || (col && col.record) || {};
|
|
1553
1615
|
let creating = !col;
|
|
1554
1616
|
let type = record.type || "string";
|
|
1555
|
-
let hook = col_hook(record)
|
|
1617
|
+
let hook = col_hook(record);
|
|
1618
|
+
if(is_empty_value(hook)) {
|
|
1619
|
+
hook = {};
|
|
1620
|
+
}
|
|
1556
1621
|
let hook_topic = Object.keys(hook)[0] || "";
|
|
1557
1622
|
let hook_col = hook_topic ? hook[hook_topic] : "";
|
|
1558
1623
|
let treedb = current_treedb(gobj);
|
|
@@ -1686,7 +1751,7 @@ function open_topic_form(gobj, treedb, topic)
|
|
|
1686
1751
|
col_names.length > 0 ? col_names : [record.pkey || "id"])),
|
|
1687
1752
|
field("SCHEMA_TOPIC_FORM_PKEY2S", "pkey2s", "secondary keys",
|
|
1688
1753
|
text_input("pkey2s", stringify_field(record.pkey2s), ""),
|
|
1689
|
-
"the column a record is
|
|
1754
|
+
"the column a record is known by when its key is composed"),
|
|
1690
1755
|
field("SCHEMA_TOPIC_FORM_SYSTEM_FLAG", "system_flag", "system flag",
|
|
1691
1756
|
text_input("system_flag", record.system_flag || "sf_string_key", "")),
|
|
1692
1757
|
field("SCHEMA_TOPIC_FORM_TKEY", "tkey", "time key",
|
|
@@ -1888,7 +1953,7 @@ function open_import(gobj, treedb)
|
|
|
1888
1953
|
["span", {i18n: "preview"}, t("preview")]]],
|
|
1889
1954
|
["button", {class: "SCHEMA_IMPORT_RUN button is-small is-warning",
|
|
1890
1955
|
type: "button", disabled: "disabled"},
|
|
1891
|
-
[["span", {class: "icon"}, [["i", {class: "yi-
|
|
1956
|
+
[["span", {class: "icon"}, [["i", {class: "yi-upload"}]]],
|
|
1892
1957
|
["span", {i18n: "import"}, t("import")]]]
|
|
1893
1958
|
]]
|
|
1894
1959
|
]]
|
|
@@ -2112,13 +2177,18 @@ function run_next_write(gobj)
|
|
|
2112
2177
|
return end_writes(gobj, "");
|
|
2113
2178
|
}
|
|
2114
2179
|
let write = priv.save_queue[0];
|
|
2115
|
-
|
|
2116
|
-
|
|
2180
|
+
|
|
2181
|
+
/* Which command and which options: three words that decide whether
|
|
2182
|
+
* a write does what it says, and each one wrong in a way that
|
|
2183
|
+
* succeeds. They live in schema_write_options.js, with the reasons
|
|
2184
|
+
* and the tests. */
|
|
2185
|
+
let command = write_command(write.op);
|
|
2186
|
+
let options = write_options(write.op);
|
|
2117
2187
|
|
|
2118
2188
|
if(remote_command(gobj, command, {
|
|
2119
2189
|
topic_name: write.topic_name,
|
|
2120
2190
|
record: write.record,
|
|
2121
|
-
options:
|
|
2191
|
+
options: options,
|
|
2122
2192
|
record_id: write.record.id || ""
|
|
2123
2193
|
}) < 0) {
|
|
2124
2194
|
return end_writes(gobj, t("cannot reach the treedb"));
|
|
@@ -2198,7 +2268,14 @@ function column_record(gobj, topic, col, values)
|
|
|
2198
2268
|
record.header = values.header || "";
|
|
2199
2269
|
record.type = values.type || "string";
|
|
2200
2270
|
record.flag = flags;
|
|
2201
|
-
|
|
2271
|
+
/* An empty number field is ABSENT, not zero: sending "" stored a
|
|
2272
|
+
* fillspace of 0 and the column's schema default (10) never
|
|
2273
|
+
* applied. */
|
|
2274
|
+
if(String(values.fillspace).trim() !== "") {
|
|
2275
|
+
record.fillspace = Number(values.fillspace);
|
|
2276
|
+
} else if(!creating) {
|
|
2277
|
+
record.fillspace = ""; /* cleared on purpose */
|
|
2278
|
+
}
|
|
2202
2279
|
record.placeholder = values.placeholder || "";
|
|
2203
2280
|
record.description = values.description || "";
|
|
2204
2281
|
record.default = values.default || "";
|
|
@@ -2359,7 +2436,14 @@ function ac_mt_command_answer(gobj, event, kw, src)
|
|
|
2359
2436
|
let __command__ = msg_iev_get_stack(gobj, kw, "command_stack", true);
|
|
2360
2437
|
let command = kw_get_str(gobj, __command__, "command", "", 0);
|
|
2361
2438
|
let kw_command = kw_get_dict(gobj, __command__, "kw", {}, 0);
|
|
2362
|
-
|
|
2439
|
+
|
|
2440
|
+
/* The frame carries either the whole request (a direct transport)
|
|
2441
|
+
* or the `__md_command__` this view sent (a routing adapter). Both
|
|
2442
|
+
* answer the same questions under the same names, so unwrap one
|
|
2443
|
+
* level when it is there and read from whichever it is. */
|
|
2444
|
+
/* Not kw_get_dict with a null default: it answers Object(null), which
|
|
2445
|
+
* is `{}` and truthy, so the fallback would never run. */
|
|
2446
|
+
let md = is_object(kw_command.__md_command__) ? kw_command.__md_command__ : kw_command;
|
|
2363
2447
|
|
|
2364
2448
|
if(kw_get_str(gobj, md, "purpose", "", 0) !== PURPOSE) {
|
|
2365
2449
|
return 0; /* another panel's answer on a shared transport */
|
|
@@ -2388,15 +2472,14 @@ function ac_mt_command_answer(gobj, event, kw, src)
|
|
|
2388
2472
|
return go(gobj, priv.treedb_id, priv.topic_name, priv.diagram, false);
|
|
2389
2473
|
}
|
|
2390
2474
|
|
|
2391
|
-
if(command === "
|
|
2392
|
-
let topic_name = kw_get_str(gobj,
|
|
2475
|
+
if(command === "update-node" || command === "delete-node") {
|
|
2476
|
+
let topic_name = kw_get_str(gobj, md, "topic_name", "", 0);
|
|
2393
2477
|
if(result < 0) {
|
|
2394
2478
|
return end_writes(gobj, comment || t("the treedb refused the write"));
|
|
2395
2479
|
}
|
|
2396
2480
|
priv.save_wrote = true;
|
|
2397
2481
|
if(command === "delete-node") {
|
|
2398
|
-
forget_record(gobj, topic_name,
|
|
2399
|
-
kw_get_str(gobj, kw_command, "record_id", "", 0));
|
|
2482
|
+
forget_record(gobj, topic_name, kw_get_str(gobj, md, "record_id", "", 0));
|
|
2400
2483
|
} else {
|
|
2401
2484
|
let record = is_object(data) ? data
|
|
2402
2485
|
: (Array.isArray(data) && is_object(data[0]) ? data[0] : null);
|
|
@@ -2412,8 +2495,11 @@ function ac_mt_command_answer(gobj, event, kw, src)
|
|
|
2412
2495
|
}
|
|
2413
2496
|
}
|
|
2414
2497
|
if(topic_name === T_TOPICS || topic_name === T_COLS) {
|
|
2415
|
-
|
|
2416
|
-
|
|
2498
|
+
/* Which record it was is in the QUEUE, not in the answer: an
|
|
2499
|
+
* adapter echoes only what it was asked to echo, and the
|
|
2500
|
+
* record is not small enough to be worth echoing. */
|
|
2501
|
+
let write = priv.save_queue[0];
|
|
2502
|
+
mark_written(gobj, topic_name, write ? write.record : null);
|
|
2417
2503
|
}
|
|
2418
2504
|
priv.save_queue.shift();
|
|
2419
2505
|
return run_next_write(gobj);
|
|
@@ -2927,7 +3013,7 @@ function ac_preview_import(gobj, event, kw, src)
|
|
|
2927
3013
|
} catch(e) {
|
|
2928
3014
|
priv.import_plan = null;
|
|
2929
3015
|
render_import_plan(gobj, {writes: [], summary: {},
|
|
2930
|
-
conflicts: [{code: "that is not
|
|
3016
|
+
conflicts: [{code: "that is not json", topic: "", col: ""}]});
|
|
2931
3017
|
return -1;
|
|
2932
3018
|
}
|
|
2933
3019
|
|
package/src/schema_descs.js
CHANGED
|
@@ -34,16 +34,18 @@ import {
|
|
|
34
34
|
col_enum,
|
|
35
35
|
topic_pkey2s,
|
|
36
36
|
as_json,
|
|
37
|
+
is_empty_value,
|
|
37
38
|
} from "./schema_model.js";
|
|
38
39
|
|
|
39
40
|
|
|
40
41
|
/* Storage-only fields of a `cols` record: they describe where the
|
|
41
42
|
* record LIVES in treedb_system_schema, not the column it declares.
|
|
42
43
|
* The C validator drops the same ones (_treedb_create_topic_cols_desc). */
|
|
43
|
-
const COL_STORAGE_FIELDS = ["id", "value", "topics", "order", "__md_treedb__"];
|
|
44
|
+
const COL_STORAGE_FIELDS = ["id", "value", "topics", "order", "_geometry", "__md_treedb__"];
|
|
44
45
|
|
|
45
46
|
/* Same, one level up, for a `topics` record. */
|
|
46
|
-
const TOPIC_STORAGE_FIELDS = ["id", "value", "treedbs", "cols", "order", "
|
|
47
|
+
const TOPIC_STORAGE_FIELDS = ["id", "value", "treedbs", "cols", "order", "_geometry",
|
|
48
|
+
"__md_treedb__"];
|
|
47
49
|
|
|
48
50
|
/* Fields of a column whose stored form is TEXT and whose desc form is
|
|
49
51
|
* the value: they are `blob` columns of the `cols` topic. */
|
|
@@ -67,16 +69,22 @@ function col_desc(col)
|
|
|
67
69
|
if(COL_STORAGE_FIELDS.indexOf(key) >= 0) {
|
|
68
70
|
continue;
|
|
69
71
|
}
|
|
70
|
-
if(value
|
|
72
|
+
if(is_empty_value(value)) {
|
|
71
73
|
continue;
|
|
72
74
|
}
|
|
73
|
-
|
|
75
|
+
let read = COL_JSON_FIELDS.indexOf(key) >= 0 ? as_json(value) : value;
|
|
76
|
+
if(is_empty_value(read)) {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
desc[key] = read;
|
|
74
80
|
}
|
|
75
81
|
desc.id = col.name;
|
|
76
82
|
desc.flag = col_flags(record);
|
|
77
83
|
let hook = col_hook(record);
|
|
78
|
-
if(hook) {
|
|
84
|
+
if(hook && !is_empty_value(hook)) {
|
|
79
85
|
desc.hook = hook;
|
|
86
|
+
} else {
|
|
87
|
+
delete desc.hook;
|
|
80
88
|
}
|
|
81
89
|
let e = col_enum(record);
|
|
82
90
|
if(e.length > 0) {
|
|
@@ -105,7 +113,7 @@ function topic_descs(treedb)
|
|
|
105
113
|
if(TOPIC_STORAGE_FIELDS.indexOf(key) >= 0) {
|
|
106
114
|
continue;
|
|
107
115
|
}
|
|
108
|
-
if(value
|
|
116
|
+
if(is_empty_value(value)) {
|
|
109
117
|
continue;
|
|
110
118
|
}
|
|
111
119
|
desc[key] = value;
|
package/src/schema_descs.test.js
CHANGED
|
@@ -143,3 +143,28 @@ describe("edges", () => {
|
|
|
143
143
|
expect(topic_descs({})).toEqual({});
|
|
144
144
|
});
|
|
145
145
|
});
|
|
146
|
+
|
|
147
|
+
describe("the empty collections the store answers with", () => {
|
|
148
|
+
const model = build_schema_model({
|
|
149
|
+
treedbs: [{id: "db"}],
|
|
150
|
+
topics: [{id: "db.t", value: "t", _geometry: {}, treedbs: ["treedbs^db^topics"]}],
|
|
151
|
+
cols: [{id: "db.t.id", value: "id", type: "string",
|
|
152
|
+
enum: {}, hook: {}, default: {}, _geometry: {},
|
|
153
|
+
topics: ["topics^db.t^cols"]}],
|
|
154
|
+
});
|
|
155
|
+
const descs = topic_descs(model.treedbs[0]);
|
|
156
|
+
|
|
157
|
+
test("an unset blob does not become a value in the desc", () => {
|
|
158
|
+
for(const key of ["enum", "hook", "default", "_geometry"]) {
|
|
159
|
+
expect(key in descs.t.cols[0]).toBe(false);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("an empty hook is NOT a hook — the info panel would draw an arrow to nothing", () => {
|
|
164
|
+
expect(descs.t.cols[0].hook).toBeUndefined();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("the topic record's own _geometry does not travel", () => {
|
|
168
|
+
expect("_geometry" in descs.t).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
});
|
package/src/schema_import.js
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
col_hook,
|
|
30
30
|
col_enum,
|
|
31
31
|
as_json,
|
|
32
|
+
is_empty_value,
|
|
32
33
|
fkey_ref,
|
|
33
34
|
} from "./schema_model.js";
|
|
34
35
|
|
|
@@ -66,7 +67,7 @@ function same_value(field, a, b)
|
|
|
66
67
|
list = list.filter(f => typeof f === "string" && f.length > 0).sort();
|
|
67
68
|
return JSON.stringify(list);
|
|
68
69
|
}
|
|
69
|
-
if(v
|
|
70
|
+
if(is_empty_value(v)) {
|
|
70
71
|
return "";
|
|
71
72
|
}
|
|
72
73
|
if(typeof v === "object") {
|
|
@@ -86,10 +87,14 @@ function incoming_col_fields(col)
|
|
|
86
87
|
|
|
87
88
|
for(let field of COL_FIELDS) {
|
|
88
89
|
let value = col ? col[field] : undefined;
|
|
89
|
-
if(value
|
|
90
|
+
if(is_empty_value(value)) {
|
|
90
91
|
continue;
|
|
91
92
|
}
|
|
92
|
-
|
|
93
|
+
let read = JSON_FIELDS.indexOf(field) >= 0 ? as_json(value) : value;
|
|
94
|
+
if(is_empty_value(read)) {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
out[field] = read;
|
|
93
98
|
}
|
|
94
99
|
if(Array.isArray(out.flag)) {
|
|
95
100
|
out.flag = out.flag.filter(f => typeof f === "string" && f.length > 0);
|
|
@@ -108,10 +113,14 @@ function stored_col_fields(record)
|
|
|
108
113
|
|
|
109
114
|
for(let field of COL_FIELDS) {
|
|
110
115
|
let value = record ? record[field] : undefined;
|
|
111
|
-
if(value
|
|
116
|
+
if(is_empty_value(value)) {
|
|
112
117
|
continue;
|
|
113
118
|
}
|
|
114
|
-
|
|
119
|
+
let read = JSON_FIELDS.indexOf(field) >= 0 ? as_json(value) : value;
|
|
120
|
+
if(is_empty_value(read)) {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
out[field] = read;
|
|
115
124
|
}
|
|
116
125
|
let flags = col_flags(record);
|
|
117
126
|
if(flags.length > 0) {
|
|
@@ -120,8 +129,10 @@ function stored_col_fields(record)
|
|
|
120
129
|
delete out.flag;
|
|
121
130
|
}
|
|
122
131
|
let hook = col_hook(record);
|
|
123
|
-
if(hook) {
|
|
132
|
+
if(hook && !is_empty_value(hook)) {
|
|
124
133
|
out.hook = hook;
|
|
134
|
+
} else {
|
|
135
|
+
delete out.hook;
|
|
125
136
|
}
|
|
126
137
|
let e = col_enum(record);
|
|
127
138
|
if(e.length > 0) {
|
|
@@ -232,3 +232,24 @@ describe("what is not a schema", () => {
|
|
|
232
232
|
expect(plan_import(null, {topics: []}).conflicts[0].code).toBe("schema has no id");
|
|
233
233
|
});
|
|
234
234
|
});
|
|
235
|
+
|
|
236
|
+
describe("the empty collections the store answers with", () => {
|
|
237
|
+
test("a stored `{}` and an absent field are the same, so nothing is planned", () => {
|
|
238
|
+
/* Otherwise every import of an unchanged schema rewrites every
|
|
239
|
+
column to clear fields that were never set. */
|
|
240
|
+
const stored = build_schema_model({
|
|
241
|
+
treedbs: [{id: "db", schema_version: 1}],
|
|
242
|
+
topics: [{id: "db.t", value: "t", order: 1, pkey: "id", topic_version: 1,
|
|
243
|
+
treedbs: ["treedbs^db^topics"]}],
|
|
244
|
+
cols: [{id: "db.t.id", value: "id", order: 1, header: "Id", type: "string",
|
|
245
|
+
enum: {}, hook: {}, default: {}, template: {}, properties: {},
|
|
246
|
+
topics: ["topics^db.t^cols"]}],
|
|
247
|
+
}).treedbs[0];
|
|
248
|
+
const incoming = {
|
|
249
|
+
id: "db", schema_version: "1",
|
|
250
|
+
topics: [{id: "t", pkey: "id", topic_version: "1",
|
|
251
|
+
cols: {id: {header: "Id", type: "string"}}}]
|
|
252
|
+
};
|
|
253
|
+
expect(plan_import(stored, incoming).writes).toEqual([]);
|
|
254
|
+
});
|
|
255
|
+
});
|
package/src/schema_model.js
CHANGED
|
@@ -294,6 +294,31 @@ function build_schema_model(records)
|
|
|
294
294
|
};
|
|
295
295
|
}
|
|
296
296
|
|
|
297
|
+
/***************************************************************
|
|
298
|
+
* Is there a value here at all?
|
|
299
|
+
*
|
|
300
|
+
* The store answers a `blob` column that was never set with an
|
|
301
|
+
* EMPTY COLLECTION, not with nothing: `enum: {}`, `hook: {}`,
|
|
302
|
+
* `default: {}`. Read as "a value", those turn every export into
|
|
303
|
+
* a schema full of empty objects — and `'hook': {}` in a literal
|
|
304
|
+
* is a hook with no mapping, which is a link the treedb builds
|
|
305
|
+
* and nothing writes.
|
|
306
|
+
***************************************************************/
|
|
307
|
+
function is_empty_value(value)
|
|
308
|
+
{
|
|
309
|
+
if(value === null || value === undefined || value === "") {
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
if(Array.isArray(value)) {
|
|
313
|
+
return value.length === 0;
|
|
314
|
+
}
|
|
315
|
+
if(typeof value === "object") {
|
|
316
|
+
return Object.keys(value).length === 0;
|
|
317
|
+
}
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
|
|
297
322
|
/***************************************************************
|
|
298
323
|
* The fields of a column that are DECLARED as one thing and
|
|
299
324
|
* STORED as another.
|
|
@@ -495,6 +520,7 @@ export {
|
|
|
495
520
|
REF_SEP,
|
|
496
521
|
parse_fkey_ref,
|
|
497
522
|
as_json,
|
|
523
|
+
is_empty_value,
|
|
498
524
|
col_flags,
|
|
499
525
|
col_has_flag,
|
|
500
526
|
col_hook,
|
package/src/schema_model.test.js
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
fkey_ref,
|
|
23
23
|
next_order,
|
|
24
24
|
moved_orders,
|
|
25
|
+
is_empty_value,
|
|
25
26
|
} from "./schema_model.js";
|
|
26
27
|
|
|
27
28
|
|
|
@@ -231,3 +232,27 @@ describe("writing helpers", () => {
|
|
|
231
232
|
expect(moved_orders(list)).toEqual([{id: "a", order: 1}, {id: "b", order: 2}]);
|
|
232
233
|
});
|
|
233
234
|
});
|
|
235
|
+
|
|
236
|
+
describe("is_empty_value — what the store answers when a blob was never set", () => {
|
|
237
|
+
test("nothing is nothing", () => {
|
|
238
|
+
expect(is_empty_value(null)).toBe(true);
|
|
239
|
+
expect(is_empty_value(undefined)).toBe(true);
|
|
240
|
+
expect(is_empty_value("")).toBe(true);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("an EMPTY COLLECTION is nothing too — this is the whole point", () => {
|
|
244
|
+
/* A `blob` column that was never set comes back as `{}`, not as
|
|
245
|
+
nothing. Read as a value it puts `'hook': {}` into an exported
|
|
246
|
+
literal, which is a hook with no mapping. */
|
|
247
|
+
expect(is_empty_value({})).toBe(true);
|
|
248
|
+
expect(is_empty_value([])).toBe(true);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("anything with something in it is something", () => {
|
|
252
|
+
expect(is_empty_value({a: 1})).toBe(false);
|
|
253
|
+
expect(is_empty_value(["a"])).toBe(false);
|
|
254
|
+
expect(is_empty_value("x")).toBe(false);
|
|
255
|
+
expect(is_empty_value(0)).toBe(false);
|
|
256
|
+
expect(is_empty_value(false)).toBe(false);
|
|
257
|
+
});
|
|
258
|
+
});
|
package/src/schema_to_c.js
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
col_enum,
|
|
33
33
|
topic_pkey2s,
|
|
34
34
|
as_json,
|
|
35
|
+
is_empty_value,
|
|
35
36
|
} from "./schema_model.js";
|
|
36
37
|
|
|
37
38
|
|
|
@@ -60,9 +61,12 @@ const COL_KEY_ORDER = [
|
|
|
60
61
|
* hand-written schema without looking foreign. */
|
|
61
62
|
const VERSION_KEYS = ["schema_version", "topic_version"];
|
|
62
63
|
|
|
63
|
-
/* Storage-only: they say where the record lives, not what it declares.
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
/* Storage-only: they say where the record lives, not what it declares.
|
|
65
|
+
* `_geometry` is here as a FIELD — where the graph editor put this
|
|
66
|
+
* record's box — and it is not the same thing as a column NAMED
|
|
67
|
+
* `_geometry`, which the .c literals do declare and which stays. */
|
|
68
|
+
const COL_SKIP = ["id", "value", "topics", "order", "_geometry", "__md_treedb__"];
|
|
69
|
+
const TOPIC_SKIP = ["id", "value", "treedbs", "cols", "order", "_geometry", "__md_treedb__"];
|
|
66
70
|
|
|
67
71
|
/* Fields whose stored form may be the JSON text of the value. */
|
|
68
72
|
const JSON_FIELDS = ["enum", "hook", "default", "template", "properties", "pkey2s"];
|
|
@@ -149,10 +153,14 @@ function schema_to_json(treedb)
|
|
|
149
153
|
if(TOPIC_SKIP.indexOf(key) >= 0) {
|
|
150
154
|
continue;
|
|
151
155
|
}
|
|
152
|
-
if(value
|
|
156
|
+
if(is_empty_value(value)) {
|
|
153
157
|
continue;
|
|
154
158
|
}
|
|
155
|
-
|
|
159
|
+
let read = JSON_FIELDS.indexOf(key) >= 0 ? as_json(value) : value;
|
|
160
|
+
if(is_empty_value(read)) {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
jn_topic[key] = read;
|
|
156
164
|
}
|
|
157
165
|
if(jn_topic.topic_version !== undefined) {
|
|
158
166
|
jn_topic.topic_version = version(jn_topic.topic_version);
|
|
@@ -175,10 +183,14 @@ function schema_to_json(treedb)
|
|
|
175
183
|
if(COL_SKIP.indexOf(key) >= 0) {
|
|
176
184
|
continue;
|
|
177
185
|
}
|
|
178
|
-
if(value
|
|
186
|
+
if(is_empty_value(value)) {
|
|
179
187
|
continue;
|
|
180
188
|
}
|
|
181
|
-
|
|
189
|
+
let read = JSON_FIELDS.indexOf(key) >= 0 ? as_json(value) : value;
|
|
190
|
+
if(is_empty_value(read)) {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
jn_col[key] = read;
|
|
182
194
|
}
|
|
183
195
|
let flags = col_flags(col_record);
|
|
184
196
|
if(flags.length > 0) {
|
|
@@ -187,8 +199,10 @@ function schema_to_json(treedb)
|
|
|
187
199
|
delete jn_col.flag;
|
|
188
200
|
}
|
|
189
201
|
let hook = col_hook(col_record);
|
|
190
|
-
if(hook) {
|
|
202
|
+
if(hook && !is_empty_value(hook)) {
|
|
191
203
|
jn_col.hook = hook;
|
|
204
|
+
} else {
|
|
205
|
+
delete jn_col.hook;
|
|
192
206
|
}
|
|
193
207
|
let e = col_enum(col_record);
|
|
194
208
|
if(e.length > 0) {
|