@yuneta/gobj-ui 6.0.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.
@@ -0,0 +1,3303 @@
1
+ /***********************************************************************
2
+ * c_yui_schema_editor.js
3
+ *
4
+ * C_YUI_SCHEMA_EDITOR — a treedb's schema edited AS A SCHEMA.
5
+ *
6
+ * WHAT IT IS FOR. Every schema a yuno holds lives in its
7
+ * `treedb_system_schema`, stored as data in three flat topics
8
+ * linked by fkeys: `treedbs` -> `topics` -> `cols`. Opened with
9
+ * the ordinary topic editor — which is how it was edited until
10
+ * now — adding one column to one topic means finding it in a
11
+ * table holding every column of every topic of every treedb the
12
+ * yuno has, composing the parent fkey by hand, and remembering to
13
+ * raise a `topic_version` that nothing asks about. The storage is
14
+ * right; it is not a screen.
15
+ *
16
+ * So this view puts the schema back together and edits THAT:
17
+ * treedb -> topics -> columns, each in its declared `order`,
18
+ * with the storage composed underneath — the qualified id, the
19
+ * fkey, the order of a new sibling, the version that publishes
20
+ * the change.
21
+ *
22
+ * THE VERSION IS THE POINT, not a detail. A topic whose columns
23
+ * changed and whose `topic_version` did not is a topic whose
24
+ * persisted `topic_cols.json` masks the whole edit: the restart
25
+ * succeeds and nothing moved. Nothing in the store says so, and
26
+ * the log that would is on the node. So every write here raises
27
+ * the version of the topic it touched, and the toolbar says which
28
+ * topics are waiting for a restart.
29
+ *
30
+ * WHAT ELSE IS HERE, and why each is here and not somewhere else:
31
+ *
32
+ * - the flags of a column as checkboxes with what they do
33
+ * (schema_flags.js): `flag` is the field that decides the most
34
+ * and explains the least.
35
+ * - the schema DRAWN (schema_descs.js + C_YUI_TREEDB_SCHEMA).
36
+ * The diagram of a treedb opened here is otherwise the META
37
+ * schema — treedbs/topics/cols, the same three cards on every
38
+ * yuno — never the schema the operator came to look at.
39
+ * - what the treedb would refuse, before the restart that finds
40
+ * out (schema_validate.js). Applying a schema is restarting the
41
+ * yuno that owns it.
42
+ * - the schema as its C literal (schema_to_c.js), because an
43
+ * edit made here works and lives nowhere the next build knows
44
+ * about; and the way back in, as a reviewed plan
45
+ * (schema_import.js).
46
+ *
47
+ * STATES, because each is a different screen and a different set
48
+ * of legal actions — an action arriving in the wrong one fails
49
+ * loudly and names its sender, which is the whole point of
50
+ * routing a click through the machine:
51
+ * ST_IDLE no transport yet, or hidden
52
+ * ST_LOADING the three `nodes` requests in flight
53
+ * ST_EMPTY the yuno's system schema holds no treedb
54
+ * ST_TREEDBS the treedbs of this yuno
55
+ * ST_TOPICS the topics of one treedb
56
+ * ST_DIAGRAM that treedb drawn
57
+ * ST_COLUMNS the columns of one topic
58
+ * ST_SAVING a write, or a plan of them, in flight
59
+ *
60
+ * THE URL CARRIES THE POSITION (ROUTING.md): the tail this view
61
+ * owns is `<treedb>[/<topic>]` or `<treedb>/diagram`. It is
62
+ * applied from the host's EV_SHOW and mirrored back with
63
+ * EV_POSITION_CHANGED; this gclass navigates nothing itself.
64
+ *
65
+ * TRANSPORT: the `gobj_remote_yuno` every backend-talking view of
66
+ * this library declares. It answers `nodes` / `create-node` /
67
+ * `update-node` / `delete-node` on the treedb service, and it may
68
+ * be a routing adapter two hops away (the agent console's
69
+ * C_AGENT_TREEDB_LINK) — this view cannot tell, and must not.
70
+ *
71
+ * Copyright (c) 2026, ArtGins.
72
+ * All Rights Reserved.
73
+ ***********************************************************************/
74
+ import {
75
+ SDATA,
76
+ SDATA_END,
77
+ data_type_t,
78
+ event_flag_t,
79
+ gclass_create,
80
+ gclass_find_by_name,
81
+ log_error,
82
+ log_warning,
83
+ gobj_read_attr,
84
+ gobj_read_str_attr,
85
+ gobj_read_bool_attr,
86
+ gobj_read_pointer_attr,
87
+ gobj_write_attr,
88
+ gobj_name,
89
+ gobj_short_name,
90
+ gobj_parent,
91
+ gobj_command,
92
+ gobj_create_pure_child,
93
+ gobj_start,
94
+ gobj_stop,
95
+ gobj_destroy,
96
+ gobj_is_destroying,
97
+ gobj_send_event,
98
+ gobj_publish_event,
99
+ gobj_subscribe_event,
100
+ gobj_unsubscribe_event,
101
+ gobj_current_state,
102
+ gobj_change_state,
103
+ createElement2,
104
+ refresh_language,
105
+ clean_name,
106
+ msg_iev_get_stack,
107
+ kw_get_str,
108
+ kw_get_dict,
109
+ is_object,
110
+ empty_string,
111
+ } from "@yuneta/gobj-js";
112
+
113
+ import "./c_yui_schema_editor.css";
114
+
115
+ import {
116
+ build_schema_model,
117
+ find_treedb,
118
+ find_topic,
119
+ find_col,
120
+ col_flags,
121
+ col_hook,
122
+ col_enum,
123
+ is_empty_value,
124
+ fkey_ref,
125
+ next_order,
126
+ moved_orders,
127
+ } from "./schema_model.js";
128
+ import {validate_schema, validate_model, has_errors} from "./schema_validate.js";
129
+ import {topic_descs} from "./schema_descs.js";
130
+ import {schema_to_json, schema_to_c} from "./schema_to_c.js";
131
+ import {plan_import, plan_deletes} from "./schema_import.js";
132
+ import {grouped_flags, toggle_flag} from "./schema_flags.js";
133
+ import {write_command, write_options} from "./schema_write_options.js";
134
+ import {COL_TYPES} from "./schema_validate.js";
135
+
136
+ import {yui_shell_of} from "./c_yui_shell.js";
137
+ import {
138
+ yui_shell_show_modal,
139
+ yui_shell_show_error,
140
+ yui_shell_show_info,
141
+ yui_shell_confirm_danger,
142
+ } from "./shell_modals.js";
143
+
144
+ import {t} from "i18next";
145
+
146
+
147
+ /***************************************************************
148
+ * Constants
149
+ ***************************************************************/
150
+ const GCLASS_NAME = "C_YUI_SCHEMA_EDITOR";
151
+
152
+ /* The three topics a schema is stored in. Named once: everything
153
+ * below addresses them by these constants and nothing composes the
154
+ * strings again. */
155
+ const T_TREEDBS = "treedbs";
156
+ const T_TOPICS = "topics";
157
+ const T_COLS = "cols";
158
+
159
+ /* The hook each level is reached through, which is what a new child's
160
+ * fkey has to name. Getting it wrong links the record to nothing
161
+ * while the write still succeeds. */
162
+ const HOOK_TOPICS = "topics";
163
+ const HOOK_COLS = "cols";
164
+
165
+ /* The tail segment that opens the drawing instead of a topic. Reserved:
166
+ * a topic with this name is not reachable by its own name, the same
167
+ * bargain the treedb views already make with `schema` and `graph`. */
168
+ const DIAGRAM_SEG = "diagram";
169
+
170
+ /* Marker of OUR requests: the transport may be shared with other
171
+ * panels (the agent console publishes every answer to every one of
172
+ * them), and each filters on its own purpose. */
173
+ const PURPOSE = "schema_editor";
174
+
175
+
176
+ /***************************************************************
177
+ * Attrs
178
+ ***************************************************************/
179
+ const attrs_table = [
180
+ SDATA(data_type_t.DTP_POINTER, "subscriber", 0, null, "Subscriber of output events"),
181
+ SDATA(data_type_t.DTP_POINTER, "gobj_remote_yuno", 0, null, "Transport: the treedb's service, or an adapter that reaches it"),
182
+ SDATA(data_type_t.DTP_STRING, "treedb_name", 0, "", "Service name of the system-schema treedb (the one holding the schemas as data)"),
183
+ SDATA(data_type_t.DTP_BOOLEAN, "readonly", 0, false, "This yuno is not the MASTER of the treedb's tranger, so it refuses every write: mount without the write affordances instead of turning each click into an error"),
184
+ SDATA(data_type_t.DTP_STRING, "base_route", 0, "", "This view's route: the position under it is <treedb>[/<topic>] (host-supplied; ROUTING.md)"),
185
+ SDATA(data_type_t.DTP_POINTER, "$container", 0, null, "Root HTMLElement"),
186
+ SDATA_END()
187
+ ];
188
+
189
+ let PRIVATE_DATA = {
190
+ /* What the store answered, and what it means */
191
+ records: null, /* {treedbs, topics, cols} as they arrived */
192
+ model: null, /* build_schema_model() of the above */
193
+ pending: 0, /* `nodes` requests still in flight */
194
+ load_error: "",
195
+
196
+ /* Where the operator is. The url carries it; these mirror it. */
197
+ treedb_id: "",
198
+ topic_name: "",
199
+ diagram: false,
200
+
201
+ /* What this session changed, which is what decides whether a
202
+ * version still has to move. */
203
+ baseline: null, /* {topic id: topic_version} at load time */
204
+ written: null, /* {topic id: true} */
205
+
206
+ /* Children and overlays */
207
+ diagram_gobj: null,
208
+ dialog: null,
209
+
210
+ /* A write, or a plan of them, in flight */
211
+ save_queue: null,
212
+ save_return: "",
213
+ save_wrote: false,
214
+ connected: false,
215
+
216
+ /* A position that arrived while a write was in flight */
217
+ pending_seg: null,
218
+
219
+ /* The import dialog's plan and the two nodes it draws into */
220
+ import_plan: null,
221
+ import_pane: null,
222
+ import_run: null,
223
+ };
224
+
225
+ let __gclass__ = null;
226
+
227
+
228
+
229
+
230
+ /******************************
231
+ * Framework Methods
232
+ ******************************/
233
+
234
+
235
+
236
+
237
+ /***************************************************************
238
+ * Framework Method: Create
239
+ ***************************************************************/
240
+ function mt_create(gobj)
241
+ {
242
+ let priv = gobj.priv;
243
+
244
+ priv.baseline = {};
245
+ priv.written = {};
246
+ priv.save_queue = [];
247
+
248
+ build_ui(gobj);
249
+
250
+ /*
251
+ * CHILD subscription model
252
+ */
253
+ let subscriber = gobj_read_pointer_attr(gobj, "subscriber");
254
+ if(!subscriber) {
255
+ subscriber = gobj_parent(gobj);
256
+ }
257
+ gobj_subscribe_event(gobj, null, {}, subscriber);
258
+ }
259
+
260
+ /***************************************************************
261
+ * Framework Method: Start
262
+ ***************************************************************/
263
+ function mt_start(gobj)
264
+ {
265
+ let shell = yui_shell_of(gobj);
266
+
267
+ /* What a WIDGET draws is reachable by no i18n attribute, and this
268
+ * view draws all of its rows itself: it re-renders on the shell's
269
+ * language event rather than listening to i18next directly. */
270
+ if(shell) {
271
+ gobj_subscribe_event(shell, "EV_LANGUAGE_CHANGED", {}, gobj);
272
+ }
273
+ request_model(gobj);
274
+ }
275
+
276
+ /***************************************************************
277
+ * Framework Method: Stop
278
+ ***************************************************************/
279
+ function mt_stop(gobj)
280
+ {
281
+ let shell = yui_shell_of(gobj);
282
+
283
+ if(shell) {
284
+ gobj_unsubscribe_event(shell, "EV_LANGUAGE_CHANGED", {}, gobj);
285
+ }
286
+ close_dialog(gobj);
287
+ destroy_diagram(gobj);
288
+ }
289
+
290
+ /***************************************************************
291
+ * Framework Method: Destroy
292
+ ***************************************************************/
293
+ function mt_destroy(gobj)
294
+ {
295
+ destroy_ui(gobj);
296
+ }
297
+
298
+
299
+
300
+
301
+ /***************************
302
+ * Local Methods
303
+ ***************************/
304
+
305
+
306
+
307
+
308
+ /***************************************************************
309
+ * The frame: a toolbar that never changes place, a notice line
310
+ * for what the whole view has to say, and one body the screens
311
+ * take turns in. The screens are re-rendered, the frame is not.
312
+ ***************************************************************/
313
+ function build_ui(gobj)
314
+ {
315
+ let $container = createElement2(
316
+ ["div", {class: `${GCLASS_NAME} SCHEMA_CARD view-card`,
317
+ style: "height:100%; display:flex; flex-direction:column; min-height:0;"}, [
318
+ ["div", {class: "SCHEMA_TOOLBAR is-flex is-align-items-center",
319
+ style: "gap:.25rem; padding:.25rem;"}, []],
320
+ ["div", {class: "SCHEMA_NOTICE p-4 has-text-grey is-hidden"}, []],
321
+ ["div", {class: "SCHEMA_BODY", style: "flex:1 1 auto; min-height:0; overflow:auto;"}, []]
322
+ ]]
323
+ );
324
+
325
+ gobj_write_attr(gobj, "$container", $container);
326
+ refresh_language($container, t);
327
+ }
328
+
329
+ /***************************************************************
330
+ * Destroy UI
331
+ ***************************************************************/
332
+ function destroy_ui(gobj)
333
+ {
334
+ let $container = gobj_read_attr(gobj, "$container");
335
+
336
+ if($container) {
337
+ if($container.parentNode) {
338
+ $container.parentNode.removeChild($container);
339
+ }
340
+ gobj_write_attr(gobj, "$container", null);
341
+ }
342
+ }
343
+
344
+ /***************************************************************
345
+ * Attributes, minus the ones that are not there.
346
+ *
347
+ * createElement2() sets every key it is given with
348
+ * setAttribute(), so a key whose value is `undefined` renders
349
+ * the STRING "undefined" — and `disabled="undefined"` is a
350
+ * disabled button, `checked="undefined"` a checked box. Every
351
+ * conditional attribute in this file goes through here.
352
+ ***************************************************************/
353
+ function el_attrs(attrs)
354
+ {
355
+ for(let key of Object.keys(attrs)) {
356
+ if(attrs[key] === undefined) {
357
+ delete attrs[key];
358
+ }
359
+ }
360
+ return attrs;
361
+ }
362
+
363
+ /***************************************************************
364
+ * The body stops answering while a write is in flight.
365
+ ***************************************************************/
366
+ function set_busy(gobj, busy)
367
+ {
368
+ let $body = $of(gobj, ".SCHEMA_BODY");
369
+
370
+ if($body) {
371
+ $body.classList.toggle("SCHEMA_BUSY", !!busy);
372
+ }
373
+ }
374
+
375
+ /***************************************************************
376
+ * Shorthands for the three panes of the frame.
377
+ ***************************************************************/
378
+ function $of(gobj, selector)
379
+ {
380
+ let $container = gobj_read_attr(gobj, "$container");
381
+
382
+ return $container ? $container.querySelector(selector) : null;
383
+ }
384
+
385
+ function clear($el)
386
+ {
387
+ if(!$el) {
388
+ return;
389
+ }
390
+ while($el.firstChild) {
391
+ $el.removeChild($el.firstChild);
392
+ }
393
+ }
394
+
395
+ /***************************************************************
396
+ * Is the store writable from here? A read-only mount is a
397
+ * property of the STORE (this yuno does not master the tranger),
398
+ * and it wins over every button.
399
+ ***************************************************************/
400
+ function is_readonly(gobj)
401
+ {
402
+ return gobj_read_bool_attr(gobj, "readonly");
403
+ }
404
+
405
+ /***************************************************************
406
+ * A write this view refuses to attempt. Called from the actions
407
+ * themselves and not from the code that draws the buttons: a
408
+ * hidden button is not a refused action, and an event arriving
409
+ * anyway — from a keyboard, from a stale screen — must be
410
+ * answered.
411
+ ***************************************************************/
412
+ function refuse_if_readonly(gobj, event)
413
+ {
414
+ if(!is_readonly(gobj)) {
415
+ return false;
416
+ }
417
+ log_warning(`${gobj_short_name(gobj)}: ${event} refused, the treedb is read-only`);
418
+ yui_shell_show_error(yui_shell_of(gobj), "the treedb is read-only", {t: t});
419
+ return true;
420
+ }
421
+
422
+
423
+
424
+
425
+ /***************************
426
+ * The model
427
+ ***************************/
428
+
429
+
430
+
431
+
432
+ /***************************************************************
433
+ * One request per meta-topic. Three round trips and the whole
434
+ * schema set of the yuno is here: everything below — the
435
+ * drawing, the validation, the export — is computed from these
436
+ * and asks the backend nothing more.
437
+ ***************************************************************/
438
+ function request_model(gobj)
439
+ {
440
+ let priv = gobj.priv;
441
+ let remote = gobj_read_pointer_attr(gobj, "gobj_remote_yuno");
442
+
443
+ if(!remote) {
444
+ return -1; /* not mounted on a transport yet: mt_start retries via EV_TRANSPORT_STATE */
445
+ }
446
+
447
+ priv.records = {treedbs: [], topics: [], cols: []};
448
+ priv.load_error = "";
449
+ priv.pending = 0;
450
+ gobj_change_state(gobj, "ST_LOADING");
451
+ show_notice(gobj, "loading the schemas");
452
+
453
+ for(let topic_name of [T_TREEDBS, T_TOPICS, T_COLS]) {
454
+ if(remote_command(gobj, "nodes", {
455
+ topic_name: topic_name,
456
+ options: {list_dict: true}
457
+ }) === 0) {
458
+ priv.pending++;
459
+ }
460
+ }
461
+ if(priv.pending === 0) {
462
+ priv.load_error = t("cannot reach the treedb");
463
+ gobj_change_state(gobj, "ST_IDLE");
464
+ show_notice(gobj, "cannot reach the treedb");
465
+ return -1;
466
+ }
467
+ return 0;
468
+ }
469
+
470
+ /***************************************************************
471
+ * Every request this view makes goes through here, so the
472
+ * service name, the purpose marker and the echo of what was
473
+ * asked are written ONCE.
474
+ *
475
+ * `__md_command__` travels out and comes back: it is how an
476
+ * answer says which topic it is about, since the command is
477
+ * `nodes` for all three.
478
+ ***************************************************************/
479
+ function remote_command(gobj, command, kw)
480
+ {
481
+ let remote = gobj_read_pointer_attr(gobj, "gobj_remote_yuno");
482
+ let treedb_name = gobj_read_str_attr(gobj, "treedb_name");
483
+
484
+ if(!remote) {
485
+ log_error(`${gobj_short_name(gobj)}: no transport for '${command}'`);
486
+ return -1;
487
+ }
488
+
489
+ let full_kw = Object.assign({
490
+ service: treedb_name,
491
+ treedb_name: treedb_name
492
+ }, kw || {});
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__ = {
505
+ purpose: PURPOSE,
506
+ topic_name: (kw && kw.topic_name) || "",
507
+ record_id: (kw && kw.record_id) || ""
508
+ };
509
+ delete full_kw.record_id;
510
+
511
+ let ret = gobj_command(remote, command, full_kw, gobj);
512
+ if(ret) {
513
+ log_error(ret);
514
+ return -1;
515
+ }
516
+ return 0;
517
+ }
518
+
519
+ /***************************************************************
520
+ * The three answers are in: build the schema and remember the
521
+ * versions it arrived with. The baseline is what "this topic
522
+ * still needs its version raised" is measured against, so it is
523
+ * taken here and never touched again until the next load.
524
+ ***************************************************************/
525
+ function build_model(gobj)
526
+ {
527
+ let priv = gobj.priv;
528
+
529
+ priv.model = build_schema_model(priv.records);
530
+ priv.baseline = {};
531
+ for(let treedb of priv.model.treedbs) {
532
+ for(let topic of treedb.topics) {
533
+ priv.baseline[topic.id] = topic.topic_version;
534
+ }
535
+ }
536
+ }
537
+
538
+ /***************************************************************
539
+ * Say what the whole store would be refused for.
540
+ *
541
+ * Not for this view — it has a button for that — but for
542
+ * WHOEVER APPLIES. Applying a schema is restarting the yuno that
543
+ * owns it, and the restart is driven from a tab above this one
544
+ * that has no way to know what was edited. So the count travels
545
+ * up on every settle, and the confirmation up there can say what
546
+ * it is about to restart onto.
547
+ ***************************************************************/
548
+ function publish_check(gobj)
549
+ {
550
+ let priv = gobj.priv;
551
+
552
+ if(!priv.model) {
553
+ return;
554
+ }
555
+ let findings = validate_model(priv.model, {
556
+ written_topics: Object.keys(priv.written),
557
+ baseline: priv.baseline
558
+ });
559
+ gobj_publish_event(gobj, "EV_SCHEMA_CHECKED", {
560
+ treedb_name: gobj_read_str_attr(gobj, "treedb_name"),
561
+ errors: findings.filter(f => f.severity === "error").length,
562
+ warnings: findings.filter(f => f.severity === "warning").length,
563
+ /* Plain JSON, and capped: the kw of an event is dumped by the
564
+ * machine trace, and a schema with two hundred findings would
565
+ * put all of them in it. */
566
+ first: findings.slice(0, 5).map((f) => {
567
+ return {severity: f.severity, code: f.code, topic: f.topic,
568
+ col: f.col, detail: f.detail};
569
+ })
570
+ });
571
+ }
572
+
573
+ /***************************************************************
574
+ * What the store has to be asked again for. A write answers
575
+ * with the record it wrote, so the model is patched in place
576
+ * rather than reloaded: reloading three topics after every
577
+ * keystroke of an edit is three round trips per column.
578
+ ***************************************************************/
579
+ function patch_record(gobj, topic_name, record)
580
+ {
581
+ let priv = gobj.priv;
582
+
583
+ if(!priv.records || !record || typeof record.id !== "string") {
584
+ return;
585
+ }
586
+ let list = priv.records[topic_name];
587
+ if(!Array.isArray(list)) {
588
+ return;
589
+ }
590
+ let at = -1;
591
+ for(let i = 0; i < list.length; i++) {
592
+ if(list[i] && list[i].id === record.id) {
593
+ at = i;
594
+ break;
595
+ }
596
+ }
597
+ if(at >= 0) {
598
+ list[at] = Object.assign({}, list[at], record);
599
+ } else {
600
+ list.push(record);
601
+ }
602
+ build_model(gobj);
603
+ }
604
+
605
+ function forget_record(gobj, topic_name, id)
606
+ {
607
+ let priv = gobj.priv;
608
+
609
+ if(!priv.records || !Array.isArray(priv.records[topic_name])) {
610
+ return;
611
+ }
612
+ priv.records[topic_name] = priv.records[topic_name].filter((r) => {
613
+ return !r || r.id !== id;
614
+ });
615
+ build_model(gobj);
616
+ }
617
+
618
+ /***************************************************************
619
+ * Where the operator is, resolved against the model that is
620
+ * actually loaded. A deep link to a treedb or a topic that is
621
+ * no longer there lands one level up instead of on an empty
622
+ * screen.
623
+ ***************************************************************/
624
+ function current_treedb(gobj)
625
+ {
626
+ let priv = gobj.priv;
627
+
628
+ return priv.model ? find_treedb(priv.model, priv.treedb_id) : null;
629
+ }
630
+
631
+ function current_topic(gobj)
632
+ {
633
+ let priv = gobj.priv;
634
+
635
+ return priv.model ? find_topic(priv.model, priv.treedb_id, priv.topic_name) : null;
636
+ }
637
+
638
+
639
+
640
+
641
+ /***************************
642
+ * The screens
643
+ ***************************/
644
+
645
+
646
+
647
+
648
+ /***************************************************************
649
+ * One line for what the whole view has to say: loading, empty,
650
+ * or the reason nothing loaded. Carries its i18n key, so it
651
+ * follows a language change like any other text.
652
+ ***************************************************************/
653
+ function show_notice(gobj, key, detail)
654
+ {
655
+ let $notice = $of(gobj, ".SCHEMA_NOTICE");
656
+
657
+ if(!$notice) {
658
+ return;
659
+ }
660
+ clear($notice);
661
+ if(!key) {
662
+ $notice.classList.add("is-hidden");
663
+ return;
664
+ }
665
+ $notice.classList.remove("is-hidden");
666
+ $notice.appendChild(createElement2(
667
+ ["span", {class: "SCHEMA_NOTICE_TEXT", i18n: key}, t(key)]
668
+ ));
669
+ if(detail) {
670
+ $notice.appendChild(createElement2(
671
+ ["span", {class: "SCHEMA_NOTICE_DETAIL has-text-grey ml-2"}, `${detail}`]
672
+ ));
673
+ }
674
+ }
675
+
676
+ /***************************************************************
677
+ * A button of the toolbar. Every one carries an icon and a
678
+ * label; the label hides on a phone because this toolbar holds
679
+ * more than two buttons at its widest.
680
+ ***************************************************************/
681
+ function toolbar_button(logical, icon, key, event, disabled)
682
+ {
683
+ return ["button", el_attrs({class: `${logical} button is-small`,
684
+ type: "button",
685
+ disabled: disabled ? "disabled" : undefined,
686
+ title: t(key), "aria-label": t(key),
687
+ "data-i18n-title": key, "data-i18n-aria-label": key}), [
688
+ ["span", {class: "icon"}, [["i", {class: icon}]]],
689
+ ["span", {class: "is-hidden-mobile", i18n: key}, t(key)]
690
+ ], {
691
+ click: (evt) => {
692
+ evt.stopPropagation();
693
+ gobj_send_event(evt.currentTarget.__gobj__, event, {}, evt.currentTarget.__gobj__);
694
+ }
695
+ }];
696
+ }
697
+
698
+ /***************************************************************
699
+ * The toolbar: where you are on the left, what you can do on
700
+ * the right. Rebuilt with the screen, because both halves
701
+ * depend on it.
702
+ ***************************************************************/
703
+ function render_toolbar(gobj)
704
+ {
705
+ let priv = gobj.priv;
706
+ let $toolbar = $of(gobj, ".SCHEMA_TOOLBAR");
707
+
708
+ if(!$toolbar) {
709
+ return;
710
+ }
711
+ clear($toolbar);
712
+
713
+ let state = gobj_current_state(gobj);
714
+ let readonly = is_readonly(gobj);
715
+ let has_model = !!priv.model;
716
+
717
+ /*----------------------------------------------*
718
+ * Left: back, then the position as a trail.
719
+ *----------------------------------------------*/
720
+ let $left = [];
721
+ if(priv.treedb_id) {
722
+ $left.push(["button", {class: "SCHEMA_BACK button is-small",
723
+ type: "button",
724
+ title: t("back"), "aria-label": t("back"),
725
+ "data-i18n-title": "back", "data-i18n-aria-label": "back"}, [
726
+ ["span", {class: "icon"}, [["i", {class: "yi-arrow-left"}]]]
727
+ ], {
728
+ click: (evt) => {
729
+ evt.stopPropagation();
730
+ gobj_send_event(gobj, "EV_BACK", {}, gobj);
731
+ }
732
+ }]);
733
+ }
734
+
735
+ let $trail = [
736
+ ["span", {class: "SCHEMA_CRUMB_ROOT", i18n: "schemas"}, t("schemas")]
737
+ ];
738
+ if(priv.treedb_id) {
739
+ $trail.push(["span", {class: "SCHEMA_CRUMB_SEP"}, "/"]);
740
+ $trail.push(["span", {class: "SCHEMA_CRUMB_TREEDB"}, priv.treedb_id]);
741
+ }
742
+ if(priv.topic_name) {
743
+ $trail.push(["span", {class: "SCHEMA_CRUMB_SEP"}, "/"]);
744
+ $trail.push(["span", {class: "SCHEMA_CRUMB_TOPIC"}, priv.topic_name]);
745
+ }
746
+ if(priv.diagram) {
747
+ $trail.push(["span", {class: "SCHEMA_CRUMB_SEP"}, "/"]);
748
+ $trail.push(["span", {class: "SCHEMA_CRUMB_DIAGRAM", i18n: "diagram"}, t("diagram")]);
749
+ }
750
+ $left.push(["nav", {class: "SCHEMA_CRUMBS is-size-7 has-text-grey ml-1"}, $trail]);
751
+
752
+ /*----------------------------------------------*
753
+ * Right: what is legal HERE. A button that is not
754
+ * legal is left out; a button that is legal and
755
+ * refused says why when it is pressed.
756
+ *----------------------------------------------*/
757
+ let $right = [];
758
+ if(priv.treedb_id && has_model) {
759
+ $right.push(toolbar_button("SCHEMA_DIAGRAM_BTN", "yi-hexagon-nodes",
760
+ priv.diagram ? "topics" : "diagram", "EV_TOGGLE_DIAGRAM", false));
761
+ $right.push(toolbar_button("SCHEMA_VALIDATE_BTN", "yi-square-check",
762
+ "check", "EV_VALIDATE", false));
763
+ $right.push(toolbar_button("SCHEMA_EXPORT_BTN", "yi-download",
764
+ "export", "EV_EXPORT", false));
765
+ if(!readonly) {
766
+ $right.push(toolbar_button("SCHEMA_IMPORT_BTN", "yi-upload",
767
+ "import", "EV_IMPORT", false));
768
+ }
769
+ }
770
+ if(priv.topic_name && !readonly && !priv.diagram) {
771
+ $right.push(toolbar_button("SCHEMA_ADD_COL_BTN", "yi-plus",
772
+ "new column", "EV_ADD_COLUMN", false));
773
+ }
774
+ if(priv.treedb_id && !priv.topic_name && !readonly && !priv.diagram) {
775
+ $right.push(toolbar_button("SCHEMA_ADD_TOPIC_BTN", "yi-plus",
776
+ "new topic", "EV_ADD_TOPIC", false));
777
+ }
778
+ $right.push(toolbar_button("SCHEMA_REFRESH_BTN", "yi-arrows-rotate",
779
+ "refresh", "EV_REFRESH", state === "ST_LOADING"));
780
+
781
+ let $bar = createElement2(
782
+ ["div", {class: "SCHEMA_TOOLBAR_INNER is-flex is-align-items-center",
783
+ style: "gap:.25rem; width:100%;"}, [
784
+ ["div", {class: "SCHEMA_TOOLBAR_START is-flex is-align-items-center",
785
+ style: "gap:.25rem; min-width:0;"}, $left],
786
+ ["div", {class: "SCHEMA_TOOLBAR_END is-flex is-align-items-center",
787
+ style: "gap:.25rem; margin-left:auto;"}, $right]
788
+ ]]
789
+ );
790
+
791
+ /* The click handlers above reach the gobj through the button they
792
+ * are on: a kw may not carry one (the machine trace dumps it), and
793
+ * a closure per button would keep this gclass alive after it is
794
+ * destroyed. */
795
+ for(let $button of $bar.querySelectorAll("button")) {
796
+ $button.__gobj__ = gobj;
797
+ if(state === "ST_SAVING") {
798
+ $button.disabled = true;
799
+ }
800
+ }
801
+ $toolbar.appendChild($bar);
802
+ refresh_language($toolbar, t);
803
+ }
804
+
805
+ /***************************************************************
806
+ * Draw whatever the current state says. One place decides which
807
+ * screen is on, so a state change and a re-render cannot
808
+ * disagree.
809
+ ***************************************************************/
810
+ function render(gobj)
811
+ {
812
+ let priv = gobj.priv;
813
+ let state = gobj_current_state(gobj);
814
+ let $body = $of(gobj, ".SCHEMA_BODY");
815
+
816
+ if(!$body) {
817
+ return;
818
+ }
819
+ render_toolbar(gobj);
820
+
821
+ if(state === "ST_LOADING") {
822
+ show_notice(gobj, "loading the schemas");
823
+ clear($body);
824
+ return;
825
+ }
826
+ if(state === "ST_IDLE") {
827
+ show_notice(gobj, priv.load_error ? "cannot load the schemas" : "not connected",
828
+ priv.load_error);
829
+ clear($body);
830
+ return;
831
+ }
832
+ if(state === "ST_EMPTY") {
833
+ show_notice(gobj, "this yuno stores no schema");
834
+ clear($body);
835
+ return;
836
+ }
837
+ if(state === "ST_SAVING") {
838
+ return; /* the screen under it stays; end_writes redraws it */
839
+ }
840
+
841
+ show_notice(gobj, "");
842
+ if(state === "ST_DIAGRAM") {
843
+ render_diagram(gobj);
844
+ return;
845
+ }
846
+ destroy_diagram(gobj);
847
+ clear($body);
848
+ if(state === "ST_COLUMNS") {
849
+ render_columns(gobj, $body);
850
+ return;
851
+ }
852
+ if(state === "ST_TOPICS") {
853
+ render_topics(gobj, $body);
854
+ return;
855
+ }
856
+ render_treedbs(gobj, $body);
857
+ }
858
+
859
+ /***************************************************************
860
+ * A card that opens something, with its own logical name. The
861
+ * cards grid is the shell's, so a schema card and a nav card
862
+ * look alike on purpose.
863
+ ***************************************************************/
864
+ function open_card(gobj, logical, title, icon, tags, event, kw)
865
+ {
866
+ let $tags = tags.map((tag) => {
867
+ return ["span", {class: `${logical}_TAG tag ${tag.style || "is-light"}`},
868
+ tag.i18n
869
+ ? [["span", {i18n: tag.i18n}, t(tag.i18n)],
870
+ ["span", {class: "ml-1"}, `${tag.text}`]]
871
+ : `${tag.text}`];
872
+ });
873
+
874
+ return ["div", {class: `${logical} yui-nav-card card`, role: "button", tabindex: "0"}, [
875
+ ["div", {class: `${logical}_BODY card-content p-3`}, [
876
+ ["p", {class: `${logical}_TITLE is-flex is-align-items-center`,
877
+ style: "gap:.4rem;"}, [
878
+ ["span", {class: "icon"}, [["i", {class: icon}]]],
879
+ ["span", {class: `${logical}_NAME has-text-weight-semibold`}, `${title}`]
880
+ ]],
881
+ ["div", {class: `${logical}_TAGS tags mt-2`, style: "gap:.25rem;"}, $tags]
882
+ ]]
883
+ ], {
884
+ click: (evt) => {
885
+ evt.stopPropagation();
886
+ gobj_send_event(gobj, event, kw, gobj);
887
+ },
888
+ keydown: (evt) => {
889
+ if(evt.key !== "Enter" && evt.key !== " ") {
890
+ return;
891
+ }
892
+ evt.preventDefault();
893
+ gobj_send_event(gobj, event, kw, gobj);
894
+ }
895
+ }];
896
+ }
897
+
898
+ /***************************************************************
899
+ * ST_TREEDBS — the treedbs this yuno keeps a schema for.
900
+ *
901
+ * The badge that matters is the version pair: `schema_version`
902
+ * above `c_schema_version` is the shape of an operator edit AND
903
+ * the shape of a plain re-projection, so it is shown and not
904
+ * interpreted — `diff-schema`, in the tab above this view, is
905
+ * what tells the two apart.
906
+ ***************************************************************/
907
+ function render_treedbs(gobj, $body)
908
+ {
909
+ let priv = gobj.priv;
910
+ let $cards = [];
911
+
912
+ for(let treedb of priv.model.treedbs) {
913
+ let tags = [
914
+ {i18n: "topics", text: treedb.topics.length},
915
+ {i18n: "version", text: treedb.schema_version === undefined
916
+ ? "?" : treedb.schema_version}
917
+ ];
918
+ if(treedb.c_schema_version !== undefined &&
919
+ String(treedb.c_schema_version) !== String(treedb.schema_version)
920
+ ) {
921
+ tags.push({i18n: "from c", text: treedb.c_schema_version, style: "is-warning is-light"});
922
+ }
923
+ $cards.push(open_card(gobj, "SCHEMA_TREEDB_CARD", treedb.id, "yi-database",
924
+ tags, "EV_SELECT_TREEDB", {treedb: treedb.id}));
925
+ }
926
+
927
+ if(priv.model.orphan_topics.length > 0 || priv.model.orphan_cols.length > 0) {
928
+ /* A leftover of a deletion belongs to no treedb and would be
929
+ * invisible in a screen that only draws the tree. Saying it
930
+ * here is the only place it can be said. */
931
+ $cards.push(open_card(gobj, "SCHEMA_ORPHAN_CARD", t("orphans"), "yi-triangle-exclamation",
932
+ [{i18n: "topics", text: priv.model.orphan_topics.length},
933
+ {i18n: "columns", text: priv.model.orphan_cols.length}],
934
+ "EV_SHOW_ORPHANS", {}));
935
+ }
936
+
937
+ let $grid = createElement2(
938
+ ["div", {class: "SCHEMA_TREEDBS yui-nav-cards p-3"}, $cards]
939
+ );
940
+ $body.appendChild($grid);
941
+ refresh_language($grid, t);
942
+ }
943
+
944
+ /***************************************************************
945
+ * ST_TOPICS — the topics of one treedb, in schema order.
946
+ *
947
+ * A row per topic rather than a card: the operator is comparing
948
+ * versions and column counts down a column, which a grid of
949
+ * cards makes them read across.
950
+ ***************************************************************/
951
+ function render_topics(gobj, $body)
952
+ {
953
+ let priv = gobj.priv;
954
+ let treedb = current_treedb(gobj);
955
+
956
+ if(!treedb) {
957
+ show_notice(gobj, "that treedb is not here any more", priv.treedb_id);
958
+ return;
959
+ }
960
+
961
+ let readonly = is_readonly(gobj);
962
+ let twice = repeated_names(treedb.topics);
963
+ let $rows = [];
964
+ for(let topic of treedb.topics) {
965
+ let pending = priv.written[topic.id] &&
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;
972
+
973
+ let $actions = [];
974
+ if(!readonly) {
975
+ $actions.push(row_icon(gobj, "SCHEMA_TOPIC_EDIT", "yi-pen", "edit topic",
976
+ "EV_EDIT_TOPIC", {topic: address}));
977
+ $actions.push(row_icon(gobj, "SCHEMA_TOPIC_DELETE", "yi-trash", "delete topic",
978
+ "EV_DELETE_TOPIC", {topic: address}));
979
+ }
980
+
981
+ $rows.push(["tr", {class: "SCHEMA_TOPIC_ROW", role: "button", tabindex: "0"}, [
982
+ ["td", {class: "SCHEMA_TOPIC_NAME"}, [
983
+ ["span", {class: "has-text-weight-semibold"}, `${topic.name}`],
984
+ topic.system_topic
985
+ ? ["span", {class: "SCHEMA_TOPIC_SYSTEM tag is-light ml-2",
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}`]]]
991
+ : ["span", {}, ""]
992
+ ]],
993
+ ["td", {class: "SCHEMA_TOPIC_PKEY"}, [["code", {}, `${topic.pkey || "id"}`]]],
994
+ ["td", {class: "SCHEMA_TOPIC_COLS has-text-right"}, `${topic.cols.length}`],
995
+ ["td", {class: "SCHEMA_TOPIC_VERSION"}, [
996
+ ["span", {class: `tag ${pending ? "is-warning" : "is-info"} is-light`},
997
+ `${topic.topic_version === undefined ? "?" : topic.topic_version}`]
998
+ ]],
999
+ ["td", {class: "SCHEMA_TOPIC_ACTIONS has-text-right"}, $actions]
1000
+ ], {
1001
+ click: (evt) => {
1002
+ if(evt.target.closest(".SCHEMA_TOPIC_ACTIONS")) {
1003
+ return; /* a row action, not the row */
1004
+ }
1005
+ gobj_send_event(gobj, "EV_SELECT_TOPIC", {topic: address}, gobj);
1006
+ },
1007
+ keydown: (evt) => {
1008
+ if(evt.key !== "Enter" && evt.key !== " ") {
1009
+ return;
1010
+ }
1011
+ evt.preventDefault();
1012
+ gobj_send_event(gobj, "EV_SELECT_TOPIC", {topic: address}, gobj);
1013
+ }
1014
+ }]);
1015
+ }
1016
+
1017
+ let $table = createElement2(
1018
+ ["div", {class: "SCHEMA_TOPICS p-3", style: "overflow-x:auto;"}, [
1019
+ ["table", {class: "SCHEMA_TOPICS_TABLE table is-fullwidth is-narrow is-hoverable"}, [
1020
+ ["thead", {}, [
1021
+ ["tr", {}, [
1022
+ ["th", {i18n: "topic"}, t("topic")],
1023
+ ["th", {i18n: "pkey"}, t("pkey")],
1024
+ ["th", {class: "has-text-right", i18n: "columns"}, t("columns")],
1025
+ ["th", {i18n: "version"}, t("version")],
1026
+ ["th", {}, ""]
1027
+ ]]
1028
+ ]],
1029
+ ["tbody", {}, $rows]
1030
+ ]]
1031
+ ]]
1032
+ );
1033
+ $body.appendChild($table);
1034
+ refresh_language($table, t);
1035
+ }
1036
+
1037
+ /***************************************************************
1038
+ * A small icon button inside a row. Icon only, with the label
1039
+ * in `title`/`aria-label`: a row is the one place where the
1040
+ * labels do not fit at 360px.
1041
+ ***************************************************************/
1042
+ function row_icon(gobj, logical, icon, key, event, kw)
1043
+ {
1044
+ return ["button", {class: `${logical} button is-small is-ghost`,
1045
+ type: "button",
1046
+ title: t(key), "aria-label": t(key),
1047
+ "data-i18n-title": key, "data-i18n-aria-label": key}, [
1048
+ ["span", {class: "icon is-small"}, [["i", {class: icon}]]]
1049
+ ], {
1050
+ click: (evt) => {
1051
+ evt.stopPropagation();
1052
+ gobj_send_event(gobj, event, kw, gobj);
1053
+ }
1054
+ }];
1055
+ }
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
+
1082
+ /***************************************************************
1083
+ * ST_COLUMNS — the columns of one topic, in schema order.
1084
+ *
1085
+ * The order is a FIELD (`order`), so the rows are draggable and
1086
+ * a drop writes the rows whose place actually changed — a drag
1087
+ * in a forty-column topic is two or three writes, not forty.
1088
+ *
1089
+ * What each row shows is what a schema is read for: the name,
1090
+ * what it holds, and what it links to. The rest of a column
1091
+ * definition is one click away, in the form.
1092
+ ***************************************************************/
1093
+ function render_columns(gobj, $body)
1094
+ {
1095
+ let priv = gobj.priv;
1096
+ let topic = current_topic(gobj);
1097
+
1098
+ if(!topic) {
1099
+ show_notice(gobj, "that topic is not here any more", priv.topic_name);
1100
+ return;
1101
+ }
1102
+
1103
+ let readonly = is_readonly(gobj);
1104
+ let $wrap = createElement2(["div", {class: "SCHEMA_COLUMNS p-3"}, []]);
1105
+
1106
+ /*----------------------------------------------*
1107
+ * The version banner: the one thing that makes
1108
+ * an edit succeed and change nothing.
1109
+ *----------------------------------------------*/
1110
+ let $banner = version_banner(gobj, topic);
1111
+ if($banner) {
1112
+ $wrap.appendChild($banner);
1113
+ }
1114
+
1115
+ let twice = repeated_names(topic.cols);
1116
+ let $rows = [];
1117
+ for(let i = 0; i < topic.cols.length; i++) {
1118
+ $rows.push(column_row(gobj, topic, topic.cols[i], i, readonly, twice));
1119
+ }
1120
+
1121
+ let $table = createElement2(
1122
+ ["div", {class: "SCHEMA_COLUMNS_WRAP", style: "overflow-x:auto;"}, [
1123
+ ["table", {class: "SCHEMA_COLUMNS_TABLE table is-fullwidth is-narrow is-hoverable"}, [
1124
+ ["thead", {}, [
1125
+ ["tr", {}, [
1126
+ ["th", {class: "SCHEMA_COL_HANDLE_TH"}, ""],
1127
+ ["th", {i18n: "column"}, t("column")],
1128
+ ["th", {i18n: "header"}, t("header")],
1129
+ ["th", {i18n: "type"}, t("type")],
1130
+ ["th", {i18n: "flags"}, t("flags")],
1131
+ ["th", {i18n: "link"}, t("link")],
1132
+ ["th", {}, ""]
1133
+ ]]
1134
+ ]],
1135
+ ["tbody", {class: "SCHEMA_COLUMNS_BODY"}, $rows]
1136
+ ]]
1137
+ ]]
1138
+ );
1139
+ $wrap.appendChild($table);
1140
+
1141
+ if(topic.cols.length === 0) {
1142
+ $wrap.appendChild(createElement2(
1143
+ ["p", {class: "SCHEMA_COLUMNS_EMPTY has-text-grey p-3",
1144
+ i18n: "this topic has no column"}, t("this topic has no column")]
1145
+ ));
1146
+ }
1147
+
1148
+ $body.appendChild($wrap);
1149
+ refresh_language($wrap, t);
1150
+ }
1151
+
1152
+ /***************************************************************
1153
+ * "This topic changed and its version did not."
1154
+ *
1155
+ * Only shown when it is TRUE — the topic was written in this
1156
+ * session and its `topic_version` still reads what it read when
1157
+ * the model loaded. Every write this view makes raises it, so
1158
+ * the banner is for the case where something else did not: an
1159
+ * edit made from the raw tables, or a bump undone by hand.
1160
+ ***************************************************************/
1161
+ function version_banner(gobj, topic)
1162
+ {
1163
+ let priv = gobj.priv;
1164
+
1165
+ if(!priv.written[topic.id]) {
1166
+ return null;
1167
+ }
1168
+ if(String(priv.baseline[topic.id]) !== String(topic.topic_version)) {
1169
+ return null;
1170
+ }
1171
+ if(is_readonly(gobj)) {
1172
+ return null;
1173
+ }
1174
+
1175
+ let $banner = createElement2(
1176
+ ["div", {class: "SCHEMA_VERSION_BANNER notification is-warning is-light p-3 mb-3 " +
1177
+ "is-flex is-align-items-center", style: "gap:.5rem;"}, [
1178
+ ["span", {class: "icon"}, [["i", {class: "yi-triangle-exclamation"}]]],
1179
+ ["span", {class: "SCHEMA_VERSION_TEXT is-size-7", i18n: "version not raised"},
1180
+ t("version not raised")],
1181
+ ["button", {class: "SCHEMA_VERSION_BUMP button is-small is-warning",
1182
+ type: "button",
1183
+ style: "margin-left:auto;",
1184
+ title: t("raise the version"), "aria-label": t("raise the version"),
1185
+ "data-i18n-title": "raise the version",
1186
+ "data-i18n-aria-label": "raise the version"}, [
1187
+ ["span", {class: "icon"}, [["i", {class: "yi-chevron-up"}]]],
1188
+ ["span", {i18n: "raise"}, t("raise")]
1189
+ ]]
1190
+ ]]
1191
+ );
1192
+ let $bump = $banner.querySelector(".SCHEMA_VERSION_BUMP");
1193
+ $bump.addEventListener("click", (evt) => {
1194
+ evt.stopPropagation();
1195
+ gobj_send_event(gobj, "EV_BUMP_VERSION", {topic: topic.name}, gobj);
1196
+ });
1197
+ return $banner;
1198
+ }
1199
+
1200
+ /***************************************************************
1201
+ * One column, as a row. The drag events carry INDEXES in the
1202
+ * dataset and the drop turns them into an event: the kw of a
1203
+ * Yuneta event is plain JSON, so a DOM node never travels in
1204
+ * one.
1205
+ ***************************************************************/
1206
+ function column_row(gobj, topic, col, index, readonly, twice)
1207
+ {
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;
1213
+ let flags = col_flags(record);
1214
+ let hook = col_hook(record);
1215
+ let is_pkey = (col.name === (topic.pkey || "id"));
1216
+
1217
+ let $flags = flags.map((flag) => {
1218
+ return ["span", {class: `SCHEMA_COL_FLAG tag is-light ${flag_style(flag)}`}, `${flag}`];
1219
+ });
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. */
1224
+ let link = "";
1225
+ if(hook && !is_empty_value(hook)) {
1226
+ link = "→ " + Object.keys(hook).join(", ");
1227
+ } else if(flags.indexOf("fkey") >= 0) {
1228
+ link = "↖";
1229
+ }
1230
+
1231
+ let $actions = [];
1232
+ if(!readonly) {
1233
+ $actions.push(row_icon(gobj, "SCHEMA_COL_EDIT", "yi-pen", "edit column",
1234
+ "EV_EDIT_COLUMN", {col: address}));
1235
+ $actions.push(row_icon(gobj, "SCHEMA_COL_DUPLICATE", "yi-copy", "duplicate column",
1236
+ "EV_DUPLICATE_COLUMN", {col: address}));
1237
+ $actions.push(row_icon(gobj, "SCHEMA_COL_DELETE", "yi-trash", "delete column",
1238
+ "EV_DELETE_COLUMN", {col: address}));
1239
+ }
1240
+
1241
+ let $row = createElement2(
1242
+ ["tr", {class: "SCHEMA_COL_ROW", "data-index": `${index}`,
1243
+ draggable: readonly ? "false" : "true"}, [
1244
+ ["td", {class: "SCHEMA_COL_HANDLE"},
1245
+ readonly ? [] : [["span", {class: "icon is-small has-text-grey"},
1246
+ [["i", {class: "yi-grip-vertical"}]]]]],
1247
+ ["td", {class: "SCHEMA_COL_NAME"}, [
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", {}, ""],
1253
+ is_pkey
1254
+ ? ["span", {class: "SCHEMA_COL_PKEY tag is-info is-light ml-2",
1255
+ i18n: "pkey"}, t("pkey")]
1256
+ : ["span", {}, ""]
1257
+ ]],
1258
+ ["td", {class: "SCHEMA_COL_HEADER"}, `${record.header || ""}`],
1259
+ ["td", {class: "SCHEMA_COL_TYPE"}, [["code", {}, `${record.type || ""}`]]],
1260
+ ["td", {class: "SCHEMA_COL_FLAGS"}, [
1261
+ ["div", {class: "tags", style: "gap:.2rem;"}, $flags]
1262
+ ]],
1263
+ ["td", {class: "SCHEMA_COL_LINK"}, `${link}`],
1264
+ ["td", {class: "SCHEMA_COL_ACTIONS has-text-right"}, $actions]
1265
+ ]]
1266
+ );
1267
+
1268
+ if(!readonly) {
1269
+ wire_row_drag(gobj, $row);
1270
+ }
1271
+ $row.addEventListener("dblclick", (evt) => {
1272
+ if(readonly || evt.target.closest(".SCHEMA_COL_ACTIONS")) {
1273
+ return;
1274
+ }
1275
+ gobj_send_event(gobj, "EV_EDIT_COLUMN", {col: address}, gobj);
1276
+ });
1277
+ return $row;
1278
+ }
1279
+
1280
+ /***************************************************************
1281
+ * Which flags are worth a colour: the ones that change what
1282
+ * the record IS, rather than how it is drawn.
1283
+ ***************************************************************/
1284
+ function flag_style(flag)
1285
+ {
1286
+ if(flag === "hook" || flag === "fkey") {
1287
+ return "is-link";
1288
+ }
1289
+ if(flag === "required" || flag === "notnull") {
1290
+ return "is-danger";
1291
+ }
1292
+ if(flag === "persistent") {
1293
+ return "is-success";
1294
+ }
1295
+ return "";
1296
+ }
1297
+
1298
+ /***************************************************************
1299
+ * Dragging a row to a new place. The DOM events are the
1300
+ * browser's — an operating system as far as this app is
1301
+ * concerned — so their only job is to turn a drop into
1302
+ * EV_MOVE_COLUMN, which is where the work happens.
1303
+ ***************************************************************/
1304
+ function wire_row_drag(gobj, $row)
1305
+ {
1306
+ $row.addEventListener("dragstart", (evt) => {
1307
+ evt.dataTransfer.effectAllowed = "move";
1308
+ evt.dataTransfer.setData("text/plain", $row.dataset.index);
1309
+ $row.classList.add("SCHEMA_COL_DRAGGING");
1310
+ });
1311
+ $row.addEventListener("dragend", () => {
1312
+ $row.classList.remove("SCHEMA_COL_DRAGGING");
1313
+ let $body = $row.parentNode;
1314
+ if($body) {
1315
+ for(let $other of $body.querySelectorAll(".SCHEMA_COL_DROP_BEFORE, .SCHEMA_COL_DROP_AFTER")) {
1316
+ $other.classList.remove("SCHEMA_COL_DROP_BEFORE", "SCHEMA_COL_DROP_AFTER");
1317
+ }
1318
+ }
1319
+ });
1320
+ $row.addEventListener("dragover", (evt) => {
1321
+ evt.preventDefault();
1322
+ evt.dataTransfer.dropEffect = "move";
1323
+ let rect = $row.getBoundingClientRect();
1324
+ let before = (evt.clientY - rect.top) < (rect.height / 2);
1325
+ $row.classList.toggle("SCHEMA_COL_DROP_BEFORE", before);
1326
+ $row.classList.toggle("SCHEMA_COL_DROP_AFTER", !before);
1327
+ });
1328
+ $row.addEventListener("dragleave", () => {
1329
+ $row.classList.remove("SCHEMA_COL_DROP_BEFORE", "SCHEMA_COL_DROP_AFTER");
1330
+ });
1331
+ $row.addEventListener("drop", (evt) => {
1332
+ evt.preventDefault();
1333
+ evt.stopPropagation();
1334
+ let from = parseInt(evt.dataTransfer.getData("text/plain"), 10);
1335
+ let to = parseInt($row.dataset.index, 10);
1336
+ let rect = $row.getBoundingClientRect();
1337
+ if((evt.clientY - rect.top) >= (rect.height / 2)) {
1338
+ to = to + 1;
1339
+ }
1340
+ $row.classList.remove("SCHEMA_COL_DROP_BEFORE", "SCHEMA_COL_DROP_AFTER");
1341
+ if(!isFinite(from) || !isFinite(to)) {
1342
+ log_error(`${gobj_short_name(gobj)}: drop with no row index`);
1343
+ return;
1344
+ }
1345
+ gobj_send_event(gobj, "EV_MOVE_COLUMN", {from: from, to: to}, gobj);
1346
+ });
1347
+ }
1348
+
1349
+ /***************************************************************
1350
+ * ST_DIAGRAM — the treedb drawn from the records being edited.
1351
+ *
1352
+ * Built lazily and shown, never created hidden: G6 measures its
1353
+ * canvas the first time it is painted and would size it to a
1354
+ * zero rect.
1355
+ ***************************************************************/
1356
+ function render_diagram(gobj)
1357
+ {
1358
+ let priv = gobj.priv;
1359
+ let $body = $of(gobj, ".SCHEMA_BODY");
1360
+ let treedb = current_treedb(gobj);
1361
+
1362
+ if(!$body || !treedb) {
1363
+ show_notice(gobj, "that treedb is not here any more", priv.treedb_id);
1364
+ return;
1365
+ }
1366
+ if(!gclass_find_by_name("C_YUI_TREEDB_SCHEMA")) {
1367
+ log_error(`${gobj_short_name(gobj)}: C_YUI_TREEDB_SCHEMA not registered by the app`);
1368
+ show_notice(gobj, "the schema drawing is not available");
1369
+ return;
1370
+ }
1371
+
1372
+ let descs = topic_descs(treedb);
1373
+ if(priv.diagram_gobj) {
1374
+ gobj_write_attr(priv.diagram_gobj, "descs", descs);
1375
+ gobj_send_event(priv.diagram_gobj, "EV_SHOW", {}, gobj);
1376
+ return;
1377
+ }
1378
+
1379
+ clear($body);
1380
+ let $pane = createElement2(
1381
+ ["div", {class: "SCHEMA_DIAGRAM", style: "height:100%; min-height:0; overflow:hidden;"}, []]
1382
+ );
1383
+ $body.appendChild($pane);
1384
+
1385
+ let diagram = gobj_create_pure_child(
1386
+ "diagram_" + clean_name(gobj_name(gobj)),
1387
+ "C_YUI_TREEDB_SCHEMA",
1388
+ {
1389
+ subscriber: gobj,
1390
+ descs: descs,
1391
+ /* No `node_route`: this diagram lives inside the editor and
1392
+ * a node click is answered here, not by a hash the shell
1393
+ * would have to own. */
1394
+ node_route: "",
1395
+ system: true
1396
+ },
1397
+ gobj
1398
+ );
1399
+ if(!diagram) {
1400
+ log_error(`${gobj_short_name(gobj)}: cannot create the schema drawing`);
1401
+ return;
1402
+ }
1403
+ priv.diagram_gobj = diagram;
1404
+ let $d = gobj_read_attr(diagram, "$container");
1405
+ if($d) {
1406
+ $pane.appendChild($d);
1407
+ }
1408
+ gobj_start(diagram);
1409
+ gobj_send_event(diagram, "EV_SHOW", {}, gobj);
1410
+ }
1411
+
1412
+ function destroy_diagram(gobj)
1413
+ {
1414
+ let priv = gobj.priv;
1415
+
1416
+ if(!priv.diagram_gobj) {
1417
+ return;
1418
+ }
1419
+ gobj_stop(priv.diagram_gobj);
1420
+ gobj_destroy(priv.diagram_gobj);
1421
+ priv.diagram_gobj = null;
1422
+ }
1423
+
1424
+
1425
+
1426
+
1427
+ /***************************
1428
+ * The forms
1429
+ ***************************/
1430
+
1431
+
1432
+
1433
+
1434
+ /***************************************************************
1435
+ * One overlay at a time, closed from one place: a dialog left
1436
+ * behind by a re-render is a screen the operator can still type
1437
+ * into and nothing will read.
1438
+ ***************************************************************/
1439
+ function close_dialog(gobj)
1440
+ {
1441
+ let priv = gobj.priv;
1442
+
1443
+ if(!priv.dialog) {
1444
+ return;
1445
+ }
1446
+ let dialog = priv.dialog;
1447
+ priv.dialog = null;
1448
+ try {
1449
+ dialog.close();
1450
+ } catch(e) {
1451
+ log_warning(`${gobj_short_name(gobj)}: closing a dialog threw: ${e}`);
1452
+ }
1453
+ }
1454
+
1455
+ function open_dialog(gobj, $content, title, logical, title_prefix)
1456
+ {
1457
+ let priv = gobj.priv;
1458
+
1459
+ close_dialog(gobj);
1460
+ priv.dialog = yui_shell_show_modal(yui_shell_of(gobj), $content, {
1461
+ dialog: true,
1462
+ title: title,
1463
+ title_prefix: title_prefix || "",
1464
+ logical_class: logical,
1465
+ t: t,
1466
+ on_close: () => {
1467
+ priv.dialog = null;
1468
+ }
1469
+ });
1470
+ refresh_language($content, t);
1471
+ }
1472
+
1473
+ /***************************************************************
1474
+ * A labelled field. The label carries its key and the input
1475
+ * carries its name, so reading the form back is one query and
1476
+ * a language change does not have to know the form exists.
1477
+ ***************************************************************/
1478
+ function field(logical, name, label, $control, help)
1479
+ {
1480
+ return ["div", {class: `${logical} field mb-3`}, [
1481
+ ["label", {class: "label is-small", i18n: label}, t(label)],
1482
+ ["div", {class: "control"}, [$control]],
1483
+ help
1484
+ ? ["p", {class: "help", i18n: help}, t(help)]
1485
+ : ["span", {}, ""]
1486
+ ]];
1487
+ }
1488
+
1489
+ function text_input(name, value, placeholder, disabled)
1490
+ {
1491
+ return ["input", el_attrs({class: "input is-small", type: "text",
1492
+ "data-name": name,
1493
+ value: value === undefined || value === null ? "" : `${value}`,
1494
+ placeholder: placeholder || "",
1495
+ disabled: disabled ? "disabled" : undefined})];
1496
+ }
1497
+
1498
+ function number_input(name, value)
1499
+ {
1500
+ return ["input", {class: "input is-small", type: "number",
1501
+ "data-name": name,
1502
+ value: (value === undefined || value === null || value === "")
1503
+ ? "" : `${value}`}];
1504
+ }
1505
+
1506
+ function select_input(name, value, options)
1507
+ {
1508
+ let $options = options.map((option) => {
1509
+ return ["option", el_attrs({value: `${option}`,
1510
+ selected: `${option}` === `${value}` ? "selected" : undefined}),
1511
+ `${option}`];
1512
+ });
1513
+
1514
+ return ["div", {class: "select is-small is-fullwidth"}, [
1515
+ ["select", {"data-name": name}, $options]
1516
+ ]];
1517
+ }
1518
+
1519
+ function textarea_input(name, value, rows, placeholder)
1520
+ {
1521
+ return ["textarea", {class: "textarea is-small", "data-name": name,
1522
+ rows: `${rows || 3}`,
1523
+ placeholder: placeholder || ""},
1524
+ value === undefined || value === null ? "" : `${value}`];
1525
+ }
1526
+
1527
+ /***************************************************************
1528
+ * Read a form back: every control carries its own name, so
1529
+ * this walks the dialog once and never knows which form it is.
1530
+ ***************************************************************/
1531
+ function read_form($root)
1532
+ {
1533
+ let out = {};
1534
+
1535
+ for(let $control of $root.querySelectorAll("[data-name]")) {
1536
+ let name = $control.dataset.name;
1537
+ if($control.type === "checkbox") {
1538
+ out[name] = $control.checked;
1539
+ continue;
1540
+ }
1541
+ out[name] = $control.value;
1542
+ }
1543
+ return out;
1544
+ }
1545
+
1546
+ /***************************************************************
1547
+ * The flags of a column, as checkboxes that say what they do.
1548
+ *
1549
+ * Grouped the way they act, greyed when they are meaningless on
1550
+ * the chosen type, and never filtered: a flag already set on a
1551
+ * column of another type has to stay visible or the next save
1552
+ * drops it silently. Turning one on may turn another off —
1553
+ * `hook` and `fkey` are the two ends of one link — and
1554
+ * toggle_flag() is what knows that.
1555
+ ***************************************************************/
1556
+ function flags_field(gobj, type, current)
1557
+ {
1558
+ let $groups = [];
1559
+
1560
+ for(let group of grouped_flags(type, current)) {
1561
+ let $boxes = group.flags.map((flag) => {
1562
+ return ["label", el_attrs({class: "SCHEMA_FLAG checkbox is-size-7 " +
1563
+ (flag.meaningful ? "" : "SCHEMA_FLAG_DIM has-text-grey-light"),
1564
+ title: flag.desc ? t(flag.desc) : "",
1565
+ "data-i18n-title": flag.desc || undefined}), [
1566
+ ["input", el_attrs({type: "checkbox", class: "SCHEMA_FLAG_BOX",
1567
+ "data-flag": flag.name,
1568
+ checked: flag.on ? "checked" : undefined})],
1569
+ ["span", {class: "SCHEMA_FLAG_NAME ml-1"}, `${flag.name}`]
1570
+ ]];
1571
+ });
1572
+
1573
+ $groups.push(["div", {class: "SCHEMA_FLAG_GROUP mb-2"}, [
1574
+ ["p", {class: "SCHEMA_FLAG_GROUP_NAME is-size-7 has-text-weight-semibold has-text-grey",
1575
+ i18n: group.group}, t(group.group)],
1576
+ ["div", {class: "SCHEMA_FLAG_LIST"}, $boxes]
1577
+ ]]);
1578
+ }
1579
+
1580
+ return ["div", {class: "SCHEMA_FLAGS"}, $groups];
1581
+ }
1582
+
1583
+ /***************************************************************
1584
+ * The flags a dialog currently holds, in the order they were
1585
+ * drawn.
1586
+ ***************************************************************/
1587
+ function read_flags($root)
1588
+ {
1589
+ let flags = [];
1590
+
1591
+ for(let $box of $root.querySelectorAll(".SCHEMA_FLAG_BOX")) {
1592
+ if($box.checked) {
1593
+ flags.push($box.dataset.flag);
1594
+ }
1595
+ }
1596
+ return flags;
1597
+ }
1598
+
1599
+ /***************************************************************
1600
+ * The column form, for a new column and for an existing one.
1601
+ *
1602
+ * A NAME IS NOT EDITABLE HERE, and the form says why: the store
1603
+ * keys a column by its qualified id — treedb, topic and name —
1604
+ * so renaming one is creating another and deleting this one.
1605
+ * Offering a field that silently does something else is worse
1606
+ * than not offering it.
1607
+ ***************************************************************/
1608
+ function open_column_form(gobj, topic, col, prefill)
1609
+ {
1610
+ let priv = gobj.priv;
1611
+ /* A DUPLICATE is a creation whose fields start filled: the name must
1612
+ * be typed (the store keys a column by its whole path) and everything
1613
+ * else is the column it was copied from. */
1614
+ let record = prefill || (col && col.record) || {};
1615
+ let creating = !col;
1616
+ let type = record.type || "string";
1617
+ let hook = col_hook(record);
1618
+ if(is_empty_value(hook)) {
1619
+ hook = {};
1620
+ }
1621
+ let hook_topic = Object.keys(hook)[0] || "";
1622
+ let hook_col = hook_topic ? hook[hook_topic] : "";
1623
+ let treedb = current_treedb(gobj);
1624
+ let sibling_topics = treedb ? treedb.topics.map(x => x.name) : [];
1625
+
1626
+ let $form = createElement2(
1627
+ ["div", {class: "SCHEMA_COL_FORM box"}, [
1628
+ field("SCHEMA_COL_FORM_NAME", "value", "column",
1629
+ text_input("value", creating ? (prefill ? prefill.__name__ || "" : "") : col.name,
1630
+ t("column name"), !creating),
1631
+ creating ? "" : "a column is keyed by its whole path: renaming it is creating another one"),
1632
+ field("SCHEMA_COL_FORM_HEADER", "header", "header",
1633
+ text_input("header", record.header, "")),
1634
+ field("SCHEMA_COL_FORM_TYPE", "type", "type",
1635
+ select_input("type", type, COL_TYPES)),
1636
+ field("SCHEMA_COL_FORM_FILLSPACE", "fillspace", "fillspace",
1637
+ number_input("fillspace", record.fillspace)),
1638
+ ["div", {class: "SCHEMA_COL_FORM_FLAGS field mb-3"}, [
1639
+ ["label", {class: "label is-small", i18n: "flags"}, t("flags")],
1640
+ flags_field(gobj, type, col_flags(record))
1641
+ ]],
1642
+ ["div", {class: "SCHEMA_COL_FORM_HOOK field mb-3"}, [
1643
+ ["label", {class: "label is-small", i18n: "hook"}, t("hook")],
1644
+ ["div", {class: "is-flex", style: "gap:.4rem;"}, [
1645
+ select_input("hook_topic", hook_topic, [""].concat(sibling_topics)),
1646
+ text_input("hook_col", hook_col, t("the fkey column of the child"))
1647
+ ]],
1648
+ ["p", {class: "help", i18n: "a hook names the child topic and the column of the child that points back"},
1649
+ t("a hook names the child topic and the column of the child that points back")]
1650
+ ]],
1651
+ field("SCHEMA_COL_FORM_ENUM", "enum", "enum",
1652
+ textarea_input("enum", col_enum(record).join("\n"), 3, t("one value per line")),
1653
+ "only read when the column is flagged enum"),
1654
+ field("SCHEMA_COL_FORM_DEFAULT", "default", "default",
1655
+ text_input("default", stringify_field(record.default), "")),
1656
+ field("SCHEMA_COL_FORM_PLACEHOLDER", "placeholder", "placeholder",
1657
+ text_input("placeholder", record.placeholder, "")),
1658
+ field("SCHEMA_COL_FORM_DESCRIPTION", "description", "description",
1659
+ text_input("description", record.description, "")),
1660
+ ["div", {class: "SCHEMA_COL_FORM_ACTIONS is-flex mt-4",
1661
+ style: "gap:.5rem; justify-content:flex-end;"}, [
1662
+ ["button", {class: "SCHEMA_COL_FORM_CANCEL button is-small", type: "button"},
1663
+ [["span", {i18n: "cancel"}, t("cancel")]]],
1664
+ ["button", {class: "SCHEMA_COL_FORM_SAVE button is-small is-primary", type: "button"},
1665
+ [["span", {class: "icon"}, [["i", {class: "yi-floppy-disk"}]]],
1666
+ ["span", {i18n: "save"}, t("save")]]]
1667
+ ]]
1668
+ ]]
1669
+ );
1670
+
1671
+ /* The type decides which flags are meaningful, so changing it
1672
+ * redraws them — keeping whatever is checked, including a flag the
1673
+ * new type does not use. */
1674
+ let $type = $form.querySelector('[data-name="type"]');
1675
+ $type.addEventListener("change", () => {
1676
+ let $holder = $form.querySelector(".SCHEMA_FLAGS");
1677
+ let current = read_flags($form);
1678
+ let $new = createElement2(flags_field(gobj, $type.value, current));
1679
+ $holder.parentNode.replaceChild($new, $holder);
1680
+ refresh_language($new, t);
1681
+ });
1682
+
1683
+ /* A flag that excludes another is answered here and not on save,
1684
+ * so the form never shows a state the store would refuse. */
1685
+ $form.addEventListener("change", (evt) => {
1686
+ let $box = evt.target.closest(".SCHEMA_FLAG_BOX");
1687
+ if(!$box) {
1688
+ return;
1689
+ }
1690
+ let wanted = toggle_flag(read_flags($form), $box.dataset.flag, $box.checked);
1691
+ for(let $other of $form.querySelectorAll(".SCHEMA_FLAG_BOX")) {
1692
+ $other.checked = wanted.indexOf($other.dataset.flag) >= 0;
1693
+ }
1694
+ });
1695
+
1696
+ $form.querySelector(".SCHEMA_COL_FORM_CANCEL").addEventListener("click", (evt) => {
1697
+ evt.stopPropagation();
1698
+ close_dialog(gobj);
1699
+ });
1700
+ $form.querySelector(".SCHEMA_COL_FORM_SAVE").addEventListener("click", (evt) => {
1701
+ evt.stopPropagation();
1702
+ let values = read_form($form);
1703
+ values.flag = read_flags($form);
1704
+ gobj_send_event(gobj, "EV_SAVE_COLUMN", {
1705
+ topic: topic.name,
1706
+ col: creating ? "" : col.name,
1707
+ creating: creating,
1708
+ values: values
1709
+ }, gobj);
1710
+ });
1711
+
1712
+ open_dialog(gobj, $form, creating ? "new column" : "edit column",
1713
+ "SCHEMA_COL_FORM_DIALOG", creating ? topic.name : `${topic.name}.${col.name}`);
1714
+ }
1715
+
1716
+ /***************************************************************
1717
+ * A field whose stored form may be the JSON text of a value,
1718
+ * shown as text either way.
1719
+ ***************************************************************/
1720
+ function stringify_field(value)
1721
+ {
1722
+ if(value === undefined || value === null) {
1723
+ return "";
1724
+ }
1725
+ if(typeof value === "object") {
1726
+ return JSON.stringify(value);
1727
+ }
1728
+ return `${value}`;
1729
+ }
1730
+
1731
+ /***************************************************************
1732
+ * The topic form. `pkey` is a SELECT of the columns the topic
1733
+ * actually has: a pkey naming no column is a topic that does
1734
+ * not open, and it is the first thing the validator looks for.
1735
+ ***************************************************************/
1736
+ function open_topic_form(gobj, treedb, topic)
1737
+ {
1738
+ let record = (topic && topic.record) || {};
1739
+ let creating = !topic;
1740
+ let col_names = topic ? topic.cols.map(c => c.name) : [];
1741
+
1742
+ let $form = createElement2(
1743
+ ["div", {class: "SCHEMA_TOPIC_FORM box"}, [
1744
+ field("SCHEMA_TOPIC_FORM_NAME", "value", "topic",
1745
+ text_input("value", creating ? "" : topic.name, t("topic name"), !creating),
1746
+ creating ? "" : "a topic is keyed by its whole path: renaming it is creating another one"),
1747
+ field("SCHEMA_TOPIC_FORM_PKEY", "pkey", "pkey",
1748
+ creating
1749
+ ? text_input("pkey", "id", "id")
1750
+ : select_input("pkey", record.pkey || "id",
1751
+ col_names.length > 0 ? col_names : [record.pkey || "id"])),
1752
+ field("SCHEMA_TOPIC_FORM_PKEY2S", "pkey2s", "secondary keys",
1753
+ text_input("pkey2s", stringify_field(record.pkey2s), ""),
1754
+ "the column a record is known by when its key is composed"),
1755
+ field("SCHEMA_TOPIC_FORM_SYSTEM_FLAG", "system_flag", "system flag",
1756
+ text_input("system_flag", record.system_flag || "sf_string_key", "")),
1757
+ field("SCHEMA_TOPIC_FORM_TKEY", "tkey", "time key",
1758
+ text_input("tkey", record.tkey, "")),
1759
+ field("SCHEMA_TOPIC_FORM_VERSION", "topic_version", "version",
1760
+ number_input("topic_version",
1761
+ record.topic_version === undefined ? 1 : record.topic_version),
1762
+ "raising it is what publishes a change of the columns"),
1763
+ ["div", {class: "SCHEMA_TOPIC_FORM_SYSTEM field mb-3"}, [
1764
+ ["label", {class: "checkbox is-size-7"}, [
1765
+ ["input", el_attrs({type: "checkbox", "data-name": "system_topic",
1766
+ checked: record.system_topic ? "checked" : undefined})],
1767
+ ["span", {class: "ml-1", i18n: "system topic"}, t("system topic")]
1768
+ ]]
1769
+ ]],
1770
+ ["div", {class: "SCHEMA_TOPIC_FORM_ACTIONS is-flex mt-4",
1771
+ style: "gap:.5rem; justify-content:flex-end;"}, [
1772
+ ["button", {class: "SCHEMA_TOPIC_FORM_CANCEL button is-small", type: "button"},
1773
+ [["span", {i18n: "cancel"}, t("cancel")]]],
1774
+ ["button", {class: "SCHEMA_TOPIC_FORM_SAVE button is-small is-primary",
1775
+ type: "button"},
1776
+ [["span", {class: "icon"}, [["i", {class: "yi-floppy-disk"}]]],
1777
+ ["span", {i18n: "save"}, t("save")]]]
1778
+ ]]
1779
+ ]]
1780
+ );
1781
+
1782
+ $form.querySelector(".SCHEMA_TOPIC_FORM_CANCEL").addEventListener("click", (evt) => {
1783
+ evt.stopPropagation();
1784
+ close_dialog(gobj);
1785
+ });
1786
+ $form.querySelector(".SCHEMA_TOPIC_FORM_SAVE").addEventListener("click", (evt) => {
1787
+ evt.stopPropagation();
1788
+ gobj_send_event(gobj, "EV_SAVE_TOPIC", {
1789
+ topic: creating ? "" : topic.name,
1790
+ creating: creating,
1791
+ values: read_form($form)
1792
+ }, gobj);
1793
+ });
1794
+
1795
+ open_dialog(gobj, $form, creating ? "new topic" : "edit topic",
1796
+ "SCHEMA_TOPIC_FORM_DIALOG", creating ? treedb.id : `${treedb.id}.${topic.name}`);
1797
+ }
1798
+
1799
+ /***************************************************************
1800
+ * What the treedb would refuse, as a table.
1801
+ *
1802
+ * Every `code` is an i18n key and carries it, so the report
1803
+ * follows a language change; the detail beside it is DATA (a
1804
+ * column name, a type) and never goes through t().
1805
+ ***************************************************************/
1806
+ function open_report(gobj, findings, treedb_id)
1807
+ {
1808
+ let $rows = findings.map((finding) => {
1809
+ return ["tr", {class: `SCHEMA_REPORT_ROW SCHEMA_REPORT_${finding.severity.toUpperCase()}`}, [
1810
+ ["td", {class: "SCHEMA_REPORT_SEVERITY"}, [
1811
+ ["span", {class: `tag ${finding.severity === "error" ? "is-danger" : "is-warning"}` +
1812
+ " is-light", i18n: finding.severity}, t(finding.severity)]
1813
+ ]],
1814
+ ["td", {class: "SCHEMA_REPORT_WHAT", i18n: finding.code}, t(finding.code)],
1815
+ ["td", {class: "SCHEMA_REPORT_TOPIC"}, `${finding.topic || ""}`],
1816
+ ["td", {class: "SCHEMA_REPORT_COL"}, `${finding.col || ""}`],
1817
+ ["td", {class: "SCHEMA_REPORT_DETAIL"}, [["code", {}, `${finding.detail || ""}`]]]
1818
+ ]];
1819
+ });
1820
+
1821
+ let $content = createElement2(
1822
+ ["div", {class: "SCHEMA_REPORT box"}, [
1823
+ findings.length === 0
1824
+ ? ["p", {class: "SCHEMA_REPORT_CLEAN", i18n: "nothing to report"},
1825
+ t("nothing to report")]
1826
+ : ["div", {class: "SCHEMA_REPORT_TABLE_WRAP", style: "overflow-x:auto;"}, [
1827
+ ["table", {class: "SCHEMA_REPORT_TABLE table is-narrow is-fullwidth is-size-7"}, [
1828
+ ["thead", {}, [
1829
+ ["tr", {}, [
1830
+ ["th", {i18n: "severity"}, t("severity")],
1831
+ ["th", {i18n: "finding"}, t("finding")],
1832
+ ["th", {i18n: "topic"}, t("topic")],
1833
+ ["th", {i18n: "column"}, t("column")],
1834
+ ["th", {i18n: "detail"}, t("detail")]
1835
+ ]]
1836
+ ]],
1837
+ ["tbody", {}, $rows]
1838
+ ]]
1839
+ ]]
1840
+ ]]
1841
+ );
1842
+
1843
+ open_dialog(gobj, $content, "check", "SCHEMA_REPORT_DIALOG", treedb_id);
1844
+ }
1845
+
1846
+ /***************************************************************
1847
+ * The schema as its C literal, and as JSON.
1848
+ *
1849
+ * This is the half `diff-schema` cannot give: it says the two
1850
+ * halves drifted, and this is the edit in the form the SOURCE
1851
+ * can hold. The text is selectable and copyable — never offered
1852
+ * as a download, which a published page cannot start anyway.
1853
+ ***************************************************************/
1854
+ function open_export(gobj, treedb)
1855
+ {
1856
+ let c_text = schema_to_c(treedb);
1857
+ let json_text = JSON.stringify(schema_to_json(treedb), null, 4);
1858
+
1859
+ let $content = createElement2(
1860
+ ["div", {class: "SCHEMA_EXPORT box"}, [
1861
+ ["div", {class: "SCHEMA_EXPORT_TABS tabs is-small is-toggle mb-2"}, [
1862
+ ["ul", {}, [
1863
+ ["li", {class: "SCHEMA_EXPORT_TAB is-active", "data-pane": "c"},
1864
+ [["a", {}, [["span", {}, "C"]]]]],
1865
+ ["li", {class: "SCHEMA_EXPORT_TAB", "data-pane": "json"},
1866
+ [["a", {}, [["span", {}, "JSON"]]]]]
1867
+ ]]
1868
+ ]],
1869
+ ["p", {class: "SCHEMA_EXPORT_HELP help mb-2",
1870
+ i18n: "an edit made here works and lives in no source: this is what to paste back"},
1871
+ t("an edit made here works and lives in no source: this is what to paste back")],
1872
+ ["textarea", {class: "SCHEMA_EXPORT_TEXT textarea is-small is-family-monospace",
1873
+ rows: "18", readonly: "readonly", spellcheck: "false"}, c_text],
1874
+ ["div", {class: "SCHEMA_EXPORT_ACTIONS is-flex mt-3",
1875
+ style: "gap:.5rem; justify-content:flex-end;"}, [
1876
+ ["button", {class: "SCHEMA_EXPORT_COPY button is-small", type: "button"}, [
1877
+ ["span", {class: "icon"}, [["i", {class: "yi-copy"}]]],
1878
+ ["span", {i18n: "copy"}, t("copy")]
1879
+ ]]
1880
+ ]]
1881
+ ]]
1882
+ );
1883
+
1884
+ let $text = $content.querySelector(".SCHEMA_EXPORT_TEXT");
1885
+ for(let $tab of $content.querySelectorAll(".SCHEMA_EXPORT_TAB")) {
1886
+ $tab.addEventListener("click", (evt) => {
1887
+ evt.stopPropagation();
1888
+ for(let $other of $content.querySelectorAll(".SCHEMA_EXPORT_TAB")) {
1889
+ $other.classList.toggle("is-active", $other === $tab);
1890
+ }
1891
+ $text.value = ($tab.dataset.pane === "c") ? c_text : json_text;
1892
+ });
1893
+ }
1894
+ $content.querySelector(".SCHEMA_EXPORT_COPY").addEventListener("click", (evt) => {
1895
+ evt.stopPropagation();
1896
+ copy_text(gobj, $text);
1897
+ });
1898
+
1899
+ open_dialog(gobj, $content, "export", "SCHEMA_EXPORT_DIALOG", treedb.id);
1900
+ }
1901
+
1902
+ /***************************************************************
1903
+ * Copy through the clipboard when the page is allowed one, and
1904
+ * through a selection when it is not: an artifact or an http
1905
+ * origin has no navigator.clipboard, and a copy button that
1906
+ * does nothing is worse than one that selects the text.
1907
+ ***************************************************************/
1908
+ function copy_text(gobj, $text)
1909
+ {
1910
+ let ok = () => {
1911
+ yui_shell_show_info(yui_shell_of(gobj), "copied", {t: t});
1912
+ };
1913
+
1914
+ $text.select();
1915
+ if(navigator.clipboard && navigator.clipboard.writeText) {
1916
+ navigator.clipboard.writeText($text.value).then(ok, (e) => {
1917
+ log_warning(`${gobj_short_name(gobj)}: clipboard refused: ${e}`);
1918
+ });
1919
+ return;
1920
+ }
1921
+ log_warning(`${gobj_short_name(gobj)}: no clipboard here, the text is selected instead`);
1922
+ }
1923
+
1924
+ /***************************************************************
1925
+ * Import: paste a schema, see the plan, then run it.
1926
+ *
1927
+ * Two steps and not one, because this is the only operation in
1928
+ * the editor that can DELETE a column. What is shown is the
1929
+ * plan itself, so what is confirmed is what runs.
1930
+ ***************************************************************/
1931
+ function open_import(gobj, treedb)
1932
+ {
1933
+ let $content = createElement2(
1934
+ ["div", {class: "SCHEMA_IMPORT box"}, [
1935
+ ["p", {class: "SCHEMA_IMPORT_HELP help mb-2",
1936
+ i18n: "paste a schema as json: the plan is shown before anything is written"},
1937
+ t("paste a schema as json: the plan is shown before anything is written")],
1938
+ ["textarea", {class: "SCHEMA_IMPORT_TEXT textarea is-small is-family-monospace",
1939
+ rows: "12", spellcheck: "false",
1940
+ placeholder: '{"id": "...", "schema_version": "1", "topics": []}'}, ""],
1941
+ ["label", {class: "SCHEMA_IMPORT_PRUNE checkbox is-size-7 mt-2"}, [
1942
+ ["input", {type: "checkbox", class: "SCHEMA_IMPORT_PRUNE_BOX", checked: "checked"}],
1943
+ ["span", {class: "ml-1", i18n: "delete what the pasted schema does not declare"},
1944
+ t("delete what the pasted schema does not declare")]
1945
+ ]],
1946
+ ["div", {class: "SCHEMA_IMPORT_PLAN mt-3"}, []],
1947
+ ["div", {class: "SCHEMA_IMPORT_ACTIONS is-flex mt-3",
1948
+ style: "gap:.5rem; justify-content:flex-end;"}, [
1949
+ ["button", {class: "SCHEMA_IMPORT_CANCEL button is-small", type: "button"},
1950
+ [["span", {i18n: "cancel"}, t("cancel")]]],
1951
+ ["button", {class: "SCHEMA_IMPORT_PREVIEW button is-small", type: "button"},
1952
+ [["span", {class: "icon"}, [["i", {class: "yi-eye"}]]],
1953
+ ["span", {i18n: "preview"}, t("preview")]]],
1954
+ ["button", {class: "SCHEMA_IMPORT_RUN button is-small is-warning",
1955
+ type: "button", disabled: "disabled"},
1956
+ [["span", {class: "icon"}, [["i", {class: "yi-upload"}]]],
1957
+ ["span", {i18n: "import"}, t("import")]]]
1958
+ ]]
1959
+ ]]
1960
+ );
1961
+
1962
+ let $text = $content.querySelector(".SCHEMA_IMPORT_TEXT");
1963
+ let $prune = $content.querySelector(".SCHEMA_IMPORT_PRUNE_BOX");
1964
+ let $plan_pane = $content.querySelector(".SCHEMA_IMPORT_PLAN");
1965
+ let $run = $content.querySelector(".SCHEMA_IMPORT_RUN");
1966
+
1967
+ $content.querySelector(".SCHEMA_IMPORT_CANCEL").addEventListener("click", (evt) => {
1968
+ evt.stopPropagation();
1969
+ close_dialog(gobj);
1970
+ });
1971
+ $content.querySelector(".SCHEMA_IMPORT_PREVIEW").addEventListener("click", (evt) => {
1972
+ evt.stopPropagation();
1973
+ gobj_send_event(gobj, "EV_PREVIEW_IMPORT", {
1974
+ text: $text.value,
1975
+ prune: $prune.checked
1976
+ }, gobj);
1977
+ });
1978
+ $run.addEventListener("click", (evt) => {
1979
+ evt.stopPropagation();
1980
+ gobj_send_event(gobj, "EV_APPLY_IMPORT", {}, gobj);
1981
+ });
1982
+
1983
+ gobj.priv.import_pane = $plan_pane;
1984
+ gobj.priv.import_run = $run;
1985
+ open_dialog(gobj, $content, "import", "SCHEMA_IMPORT_DIALOG", treedb.id);
1986
+ }
1987
+
1988
+ /***************************************************************
1989
+ * The plan, drawn where the paste is: one line per write, the
1990
+ * deletions apart and counted, because they are what the
1991
+ * operator is being asked about.
1992
+ ***************************************************************/
1993
+ function render_import_plan(gobj, plan)
1994
+ {
1995
+ let priv = gobj.priv;
1996
+ let $pane = priv.import_pane;
1997
+
1998
+ if(!$pane) {
1999
+ return;
2000
+ }
2001
+ clear($pane);
2002
+
2003
+ if(plan.conflicts.length > 0) {
2004
+ $pane.appendChild(createElement2(
2005
+ ["div", {class: "SCHEMA_IMPORT_CONFLICTS notification is-danger is-light p-3"},
2006
+ plan.conflicts.map((conflict) => {
2007
+ return ["p", {class: "SCHEMA_IMPORT_CONFLICT is-size-7", i18n: conflict.code},
2008
+ t(conflict.code)];
2009
+ })]
2010
+ ));
2011
+ if(priv.import_run) {
2012
+ priv.import_run.disabled = true;
2013
+ }
2014
+ refresh_language($pane, t);
2015
+ return;
2016
+ }
2017
+
2018
+ let deletes = plan_deletes(plan);
2019
+ let $lines = plan.writes.map((write) => {
2020
+ let what = write.what.col
2021
+ ? `${write.what.topic}.${write.what.col}`
2022
+ : (write.what.topic || write.topic_name);
2023
+ return ["li", {class: `SCHEMA_IMPORT_LINE SCHEMA_IMPORT_${write.op.toUpperCase()}`}, [
2024
+ ["span", {class: `tag is-light ${write.op === "delete" ? "is-danger" : "is-info"}`,
2025
+ i18n: write.op}, t(write.op)],
2026
+ ["code", {class: "ml-2"}, `${what}`]
2027
+ ]];
2028
+ });
2029
+
2030
+ $pane.appendChild(createElement2(
2031
+ ["div", {class: "SCHEMA_IMPORT_SUMMARY"}, [
2032
+ ["p", {class: "SCHEMA_IMPORT_COUNT is-size-7 has-text-grey mb-2"}, [
2033
+ ["span", {i18n: "writes"}, t("writes")],
2034
+ ["span", {class: "ml-1"}, `${plan.writes.length}`],
2035
+ ["span", {class: "ml-3", i18n: "deletions"}, t("deletions")],
2036
+ ["span", {class: "ml-1 has-text-weight-bold"}, `${deletes.length}`]
2037
+ ]],
2038
+ ["ul", {class: "SCHEMA_IMPORT_LINES is-size-7",
2039
+ style: "max-height:14rem; overflow:auto;"}, $lines]
2040
+ ]]
2041
+ ));
2042
+ if(priv.import_run) {
2043
+ priv.import_run.disabled = (plan.writes.length === 0);
2044
+ }
2045
+ refresh_language($pane, t);
2046
+ }
2047
+
2048
+ /***************************************************************
2049
+ * What belongs to nothing: a topic whose treedb is gone, a
2050
+ * column whose topic is gone. Leftovers of a deletion, and the
2051
+ * only screen they can be seen on.
2052
+ ***************************************************************/
2053
+ function open_orphans(gobj)
2054
+ {
2055
+ let priv = gobj.priv;
2056
+ let readonly = is_readonly(gobj);
2057
+
2058
+ let row = (kind, entry) => {
2059
+ return ["tr", {class: "SCHEMA_ORPHAN_ROW"}, [
2060
+ ["td", {class: "SCHEMA_ORPHAN_KIND", i18n: kind}, t(kind)],
2061
+ ["td", {class: "SCHEMA_ORPHAN_ID"}, [["code", {}, `${entry.id}`]]],
2062
+ ["td", {class: "SCHEMA_ORPHAN_ACTIONS has-text-right"},
2063
+ readonly ? [] : [row_icon(gobj, "SCHEMA_ORPHAN_DELETE", "yi-trash", "delete",
2064
+ "EV_DELETE_ORPHAN", {kind: kind, id: entry.id})]]
2065
+ ]];
2066
+ };
2067
+
2068
+ let $rows = priv.model.orphan_topics.map(x => row("topic", x))
2069
+ .concat(priv.model.orphan_cols.map(x => row("column", x)));
2070
+
2071
+ let $content = createElement2(
2072
+ ["div", {class: "SCHEMA_ORPHANS box"}, [
2073
+ ["p", {class: "SCHEMA_ORPHANS_HELP help mb-2",
2074
+ i18n: "these belong to no treedb or to no topic: leftovers of a deletion"},
2075
+ t("these belong to no treedb or to no topic: leftovers of a deletion")],
2076
+ ["table", {class: "SCHEMA_ORPHANS_TABLE table is-narrow is-fullwidth is-size-7"}, [
2077
+ ["tbody", {}, $rows]
2078
+ ]]
2079
+ ]]
2080
+ );
2081
+
2082
+ open_dialog(gobj, $content, "orphans", "SCHEMA_ORPHANS_DIALOG");
2083
+ }
2084
+
2085
+
2086
+
2087
+
2088
+ /***************************
2089
+ * Writing
2090
+ ***************************/
2091
+
2092
+
2093
+
2094
+
2095
+ /***************************************************************
2096
+ * THE TWO VERSIONS A SCHEMA EDIT HAS TO RAISE, and why both.
2097
+ *
2098
+ * `topic_version` is what publishes a change of a topic's
2099
+ * columns: leave it and the persisted `topic_cols.json` masks
2100
+ * the edit, the restart succeeds and nothing moved.
2101
+ *
2102
+ * `schema_version` is what publishes the schema as a whole —
2103
+ * "the stored one wins on ties, and the incoming one has to be
2104
+ * strictly newer to take over" (c_treedb.c). It is safe to
2105
+ * raise: re-projection from C compares `c_schema_version`, the
2106
+ * version of the LITERAL, precisely so that an edit made here
2107
+ * survives every start until a newer literal arrives.
2108
+ *
2109
+ * So every write that changes a schema carries both, and the
2110
+ * operator is never asked to remember either.
2111
+ ***************************************************************/
2112
+ function version_writes(gobj, topic)
2113
+ {
2114
+ let treedb = current_treedb(gobj);
2115
+ let writes = [];
2116
+
2117
+ if(topic) {
2118
+ let version = Number(topic.topic_version);
2119
+ if(!isFinite(version)) {
2120
+ version = 0;
2121
+ }
2122
+ writes.push({
2123
+ op: "update", topic_name: T_TOPICS,
2124
+ record: {id: topic.id, topic_version: version + 1}
2125
+ });
2126
+ }
2127
+ if(treedb) {
2128
+ let version = Number(treedb.schema_version);
2129
+ if(!isFinite(version)) {
2130
+ version = 0;
2131
+ }
2132
+ writes.push({
2133
+ op: "update", topic_name: T_TREEDBS,
2134
+ record: {id: treedb.id, schema_version: version + 1}
2135
+ });
2136
+ }
2137
+ return writes;
2138
+ }
2139
+
2140
+ /***************************************************************
2141
+ * Run a list of writes, one at a time, and say so while it
2142
+ * runs. One at a time because each answer patches the model the
2143
+ * next one is computed against, and because a treedb that
2144
+ * refuses the third of five must not be left having done two
2145
+ * and been asked for two more.
2146
+ ***************************************************************/
2147
+ function queue_writes(gobj, writes)
2148
+ {
2149
+ let priv = gobj.priv;
2150
+
2151
+ if(!Array.isArray(writes) || writes.length === 0) {
2152
+ return 0;
2153
+ }
2154
+ if(gobj_current_state(gobj) === "ST_SAVING") {
2155
+ log_warning(`${gobj_short_name(gobj)}: a write is already in flight`);
2156
+ return -1;
2157
+ }
2158
+ priv.save_queue = writes.slice();
2159
+ priv.save_return = gobj_current_state(gobj);
2160
+ priv.save_wrote = false;
2161
+ gobj_change_state(gobj, "ST_SAVING");
2162
+ show_notice(gobj, "saving");
2163
+ /* The screen stays up and stops answering. Not to be tidy: every
2164
+ * action of a screen is computed against the model, and the answer
2165
+ * still in the air is about to change it — so a click during a save
2166
+ * would be an action decided on a schema that no longer exists. */
2167
+ set_busy(gobj, true);
2168
+ render_toolbar(gobj);
2169
+ return run_next_write(gobj);
2170
+ }
2171
+
2172
+ function run_next_write(gobj)
2173
+ {
2174
+ let priv = gobj.priv;
2175
+
2176
+ if(priv.save_queue.length === 0) {
2177
+ return end_writes(gobj, "");
2178
+ }
2179
+ let write = priv.save_queue[0];
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);
2187
+
2188
+ if(remote_command(gobj, command, {
2189
+ topic_name: write.topic_name,
2190
+ record: write.record,
2191
+ options: options,
2192
+ record_id: write.record.id || ""
2193
+ }) < 0) {
2194
+ return end_writes(gobj, t("cannot reach the treedb"));
2195
+ }
2196
+ return 0;
2197
+ }
2198
+
2199
+ /***************************************************************
2200
+ * The queue is done, or gave up. Either way the model is what
2201
+ * the answers made it, so the screen is redrawn from it and the
2202
+ * host is told once — the tab above owns the pending flag and
2203
+ * the Apply button, and a write per event would blink it.
2204
+ ***************************************************************/
2205
+ function end_writes(gobj, error)
2206
+ {
2207
+ let priv = gobj.priv;
2208
+
2209
+ priv.save_queue = [];
2210
+ gobj_change_state(gobj, priv.save_return || "ST_TREEDBS");
2211
+ priv.save_return = "";
2212
+ show_notice(gobj, "");
2213
+ set_busy(gobj, false);
2214
+
2215
+ /* A position that arrived while the write was in flight: applied
2216
+ * now, so a Back pressed mid-save is not simply lost. */
2217
+ if(priv.pending_seg !== null) {
2218
+ let seg = priv.pending_seg;
2219
+ priv.pending_seg = null;
2220
+ if(!error) {
2221
+ apply_seg(gobj, seg);
2222
+ if(priv.save_wrote) {
2223
+ gobj_publish_event(gobj, "EV_RECORD_WRITTEN", {
2224
+ treedb_name: gobj_read_str_attr(gobj, "treedb_name")
2225
+ });
2226
+ }
2227
+ priv.save_wrote = false;
2228
+ return 0;
2229
+ }
2230
+ }
2231
+
2232
+ if(error) {
2233
+ yui_shell_show_error(yui_shell_of(gobj), error, {t: t});
2234
+ }
2235
+ if(priv.save_wrote) {
2236
+ gobj_publish_event(gobj, "EV_RECORD_WRITTEN", {
2237
+ treedb_name: gobj_read_str_attr(gobj, "treedb_name")
2238
+ });
2239
+ }
2240
+ priv.save_wrote = false;
2241
+ render(gobj);
2242
+ publish_check(gobj);
2243
+ return error ? -1 : 0;
2244
+ }
2245
+
2246
+ /***************************************************************
2247
+ * The record a column form produces.
2248
+ *
2249
+ * Everything the store needs and the operator did not type is
2250
+ * composed here: the fkey to the parent topic, the place among
2251
+ * its siblings, and the fields that are only meaningful when a
2252
+ * flag says so.
2253
+ ***************************************************************/
2254
+ function column_record(gobj, topic, col, values)
2255
+ {
2256
+ let creating = !col;
2257
+ let flags = Array.isArray(values.flag) ? values.flag : [];
2258
+ let record = {};
2259
+
2260
+ if(creating) {
2261
+ record.value = String(values.value || "").trim();
2262
+ record.topics = [fkey_ref(T_TOPICS, topic.id, HOOK_COLS)];
2263
+ record.order = next_order(topic.cols);
2264
+ } else {
2265
+ record.id = col.id;
2266
+ }
2267
+
2268
+ record.header = values.header || "";
2269
+ record.type = values.type || "string";
2270
+ record.flag = flags;
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
+ }
2279
+ record.placeholder = values.placeholder || "";
2280
+ record.description = values.description || "";
2281
+ record.default = values.default || "";
2282
+
2283
+ /* A hook is a mapping and the form asks for its two halves; an
2284
+ * incomplete pair is no mapping, not half of one. */
2285
+ if(values.hook_topic && values.hook_col) {
2286
+ record.hook = {[values.hook_topic]: values.hook_col};
2287
+ } else {
2288
+ record.hook = "";
2289
+ }
2290
+
2291
+ let enum_lines = String(values.enum || "")
2292
+ .split("\n").map(x => x.trim()).filter(x => x.length > 0);
2293
+ record.enum = enum_lines.length > 0 ? enum_lines : "";
2294
+
2295
+ return record;
2296
+ }
2297
+
2298
+ /***************************************************************
2299
+ * The record a topic form produces.
2300
+ ***************************************************************/
2301
+ function topic_record(gobj, treedb, topic, values)
2302
+ {
2303
+ let creating = !topic;
2304
+ let record = {};
2305
+
2306
+ if(creating) {
2307
+ record.value = String(values.value || "").trim();
2308
+ record.treedbs = [fkey_ref(T_TREEDBS, treedb.id, HOOK_TOPICS)];
2309
+ record.order = next_order(treedb.topics);
2310
+ } else {
2311
+ record.id = topic.id;
2312
+ }
2313
+
2314
+ record.pkey = values.pkey || "id";
2315
+ record.pkey2s = values.pkey2s || "";
2316
+ record.system_flag = values.system_flag || "sf_string_key";
2317
+ record.tkey = values.tkey || "";
2318
+ record.topic_version = values.topic_version === "" ? 1 : Number(values.topic_version);
2319
+ record.system_topic = !!values.system_topic;
2320
+
2321
+ return record;
2322
+ }
2323
+
2324
+
2325
+
2326
+
2327
+ /***************************
2328
+ * Positions
2329
+ ***************************/
2330
+
2331
+
2332
+
2333
+
2334
+ /***************************************************************
2335
+ * Put the position in the model's terms and in the url's.
2336
+ *
2337
+ * The state IS the screen, so moving is a state change and
2338
+ * nothing else decides what is drawn. The url is mirrored
2339
+ * outwards — this view navigates nothing itself, the host owns
2340
+ * the route it hangs from (ROUTING.md).
2341
+ ***************************************************************/
2342
+ function go(gobj, treedb_id, topic_name, diagram, mirror)
2343
+ {
2344
+ let priv = gobj.priv;
2345
+
2346
+ priv.treedb_id = treedb_id || "";
2347
+ priv.topic_name = topic_name || "";
2348
+ priv.diagram = !!diagram;
2349
+
2350
+ let state = "ST_TREEDBS";
2351
+ if(!priv.model) {
2352
+ state = "ST_LOADING";
2353
+ } else if(priv.model.treedbs.length === 0) {
2354
+ state = "ST_EMPTY";
2355
+ } else if(priv.treedb_id && priv.diagram) {
2356
+ state = "ST_DIAGRAM";
2357
+ } else if(priv.treedb_id && priv.topic_name) {
2358
+ state = "ST_COLUMNS";
2359
+ } else if(priv.treedb_id) {
2360
+ state = "ST_TOPICS";
2361
+ }
2362
+
2363
+ gobj_change_state(gobj, state);
2364
+ render(gobj);
2365
+ publish_check(gobj);
2366
+
2367
+ if(mirror !== false) {
2368
+ gobj_publish_event(gobj, "EV_POSITION_CHANGED", {subpath: position_seg(gobj)});
2369
+ }
2370
+ return 0;
2371
+ }
2372
+
2373
+ /***************************************************************
2374
+ * The tail of the url this view owns.
2375
+ ***************************************************************/
2376
+ function position_seg(gobj)
2377
+ {
2378
+ let priv = gobj.priv;
2379
+
2380
+ if(!priv.treedb_id) {
2381
+ return "";
2382
+ }
2383
+ if(priv.diagram) {
2384
+ return `${priv.treedb_id}/${DIAGRAM_SEG}`;
2385
+ }
2386
+ if(priv.topic_name) {
2387
+ return `${priv.treedb_id}/${priv.topic_name}`;
2388
+ }
2389
+ return priv.treedb_id;
2390
+ }
2391
+
2392
+ /***************************************************************
2393
+ * ...and the same in reverse. A tail naming a treedb or a topic
2394
+ * the store does not have lands one level up rather than on an
2395
+ * empty screen: a shared link outlives the schema it points at.
2396
+ ***************************************************************/
2397
+ function apply_seg(gobj, seg)
2398
+ {
2399
+ let parts = String(seg || "").split("/").filter(x => x.length > 0);
2400
+ let treedb_id = parts[0] || "";
2401
+ let tail = parts[1] || "";
2402
+
2403
+ if(!treedb_id) {
2404
+ return go(gobj, "", "", false, false);
2405
+ }
2406
+ if(tail === DIAGRAM_SEG) {
2407
+ return go(gobj, treedb_id, "", true, false);
2408
+ }
2409
+ return go(gobj, treedb_id, tail, false, false);
2410
+ }
2411
+
2412
+
2413
+
2414
+
2415
+ /***************************
2416
+ * Actions
2417
+ ***************************/
2418
+
2419
+
2420
+
2421
+
2422
+ /***************************************************************
2423
+ * An answer from the treedb.
2424
+ *
2425
+ * Which request it answers is read from the command stack, not
2426
+ * guessed: three of them are `nodes` and only the echoed
2427
+ * `topic_name` tells them apart.
2428
+ ***************************************************************/
2429
+ function ac_mt_command_answer(gobj, event, kw, src)
2430
+ {
2431
+ let priv = gobj.priv;
2432
+ let result = kw.result;
2433
+ let comment = kw.comment;
2434
+ let data = kw.data;
2435
+
2436
+ let __command__ = msg_iev_get_stack(gobj, kw, "command_stack", true);
2437
+ let command = kw_get_str(gobj, __command__, "command", "", 0);
2438
+ let kw_command = kw_get_dict(gobj, __command__, "kw", {}, 0);
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;
2447
+
2448
+ if(kw_get_str(gobj, md, "purpose", "", 0) !== PURPOSE) {
2449
+ return 0; /* another panel's answer on a shared transport */
2450
+ }
2451
+
2452
+ if(command === "nodes") {
2453
+ let topic_name = kw_get_str(gobj, md, "topic_name", "", 0);
2454
+ priv.pending--;
2455
+ if(result < 0) {
2456
+ priv.load_error = comment || t("cannot load the schemas");
2457
+ } else if(priv.records && Array.isArray(priv.records[topic_name])) {
2458
+ priv.records[topic_name] = Array.isArray(data) ? data : [];
2459
+ }
2460
+ if(priv.pending > 0) {
2461
+ return 0;
2462
+ }
2463
+ if(priv.load_error) {
2464
+ priv.model = null;
2465
+ gobj_change_state(gobj, "ST_IDLE");
2466
+ render(gobj);
2467
+ return 0;
2468
+ }
2469
+ build_model(gobj);
2470
+ /* Back to where the operator was: the load may be a refresh, or
2471
+ * the answer to a deep link that arrived before the model. */
2472
+ return go(gobj, priv.treedb_id, priv.topic_name, priv.diagram, false);
2473
+ }
2474
+
2475
+ if(command === "update-node" || command === "delete-node") {
2476
+ let topic_name = kw_get_str(gobj, md, "topic_name", "", 0);
2477
+ if(result < 0) {
2478
+ return end_writes(gobj, comment || t("the treedb refused the write"));
2479
+ }
2480
+ priv.save_wrote = true;
2481
+ if(command === "delete-node") {
2482
+ forget_record(gobj, topic_name, kw_get_str(gobj, md, "record_id", "", 0));
2483
+ } else {
2484
+ let record = is_object(data) ? data
2485
+ : (Array.isArray(data) && is_object(data[0]) ? data[0] : null);
2486
+ if(record) {
2487
+ patch_record(gobj, topic_name, record);
2488
+ } else {
2489
+ /* The treedb wrote it and did not describe it back: ask
2490
+ * again rather than draw a model that has drifted. */
2491
+ log_warning(`${gobj_short_name(gobj)}: '${command}' answered no record`);
2492
+ priv.save_queue = [];
2493
+ request_model(gobj);
2494
+ return 0;
2495
+ }
2496
+ }
2497
+ if(topic_name === T_TOPICS || topic_name === T_COLS) {
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);
2503
+ }
2504
+ priv.save_queue.shift();
2505
+ return run_next_write(gobj);
2506
+ }
2507
+
2508
+ log_error(`${gobj_short_name(gobj)}: unknown command answered: ${command}`);
2509
+ return 0;
2510
+ }
2511
+
2512
+ /***************************************************************
2513
+ * Remember which topic this write belongs to, which is what
2514
+ * "its version still has to move" is asked about.
2515
+ ***************************************************************/
2516
+ function mark_written(gobj, topic_name, record)
2517
+ {
2518
+ let priv = gobj.priv;
2519
+ let topic = current_topic(gobj);
2520
+
2521
+ if(topic_name === T_TOPICS && record && record.id) {
2522
+ priv.written[record.id] = true;
2523
+ return;
2524
+ }
2525
+ if(topic) {
2526
+ priv.written[topic.id] = true;
2527
+ }
2528
+ }
2529
+
2530
+ /***************************************************************
2531
+ * The host says where we are (ROUTING.md §5): everything under
2532
+ * this view's route is ours.
2533
+ ***************************************************************/
2534
+ function ac_show(gobj, event, kw, src)
2535
+ {
2536
+ let priv = gobj.priv;
2537
+ let seg = (kw && kw.subpath) || "";
2538
+
2539
+ if(gobj_current_state(gobj) === "ST_SAVING") {
2540
+ priv.pending_seg = seg;
2541
+ return 0; /* applied when the write lands (end_writes) */
2542
+ }
2543
+ return apply_seg(gobj, seg);
2544
+ }
2545
+
2546
+ /***************************************************************
2547
+ * Hidden by the host: drop the overlays. A dialog outlives the
2548
+ * screen it was opened from otherwise, and answers into it land
2549
+ * nowhere.
2550
+ ***************************************************************/
2551
+ function ac_hide(gobj, event, kw, src)
2552
+ {
2553
+ close_dialog(gobj);
2554
+ return 0;
2555
+ }
2556
+
2557
+ /***************************************************************
2558
+ * The session came up or went down. The model stays either way
2559
+ * — a schema does not change under you — but a first load may
2560
+ * have had no transport to run on.
2561
+ ***************************************************************/
2562
+ function ac_transport_state(gobj, event, kw, src)
2563
+ {
2564
+ let priv = gobj.priv;
2565
+
2566
+ priv.connected = !!(kw && kw.connected);
2567
+ if(priv.connected && !priv.model && gobj_current_state(gobj) !== "ST_LOADING") {
2568
+ return request_model(gobj);
2569
+ }
2570
+ render_toolbar(gobj);
2571
+ return 0;
2572
+ }
2573
+
2574
+ /***************************************************************
2575
+ * The shell switched language. What this view draws is drawn by
2576
+ * itself, so it redraws: an i18n attribute reaches the chrome,
2577
+ * never a row this gclass built.
2578
+ ***************************************************************/
2579
+ function ac_language_changed(gobj, event, kw, src)
2580
+ {
2581
+ render(gobj);
2582
+ return 0;
2583
+ }
2584
+
2585
+ function ac_refresh(gobj, event, kw, src)
2586
+ {
2587
+ return request_model(gobj);
2588
+ }
2589
+
2590
+ /***************************************************************
2591
+ * Moving about.
2592
+ ***************************************************************/
2593
+ function ac_select_treedb(gobj, event, kw, src)
2594
+ {
2595
+ return go(gobj, kw.treedb, "", false, true);
2596
+ }
2597
+
2598
+ function ac_select_topic(gobj, event, kw, src)
2599
+ {
2600
+ return go(gobj, gobj.priv.treedb_id, kw.topic, false, true);
2601
+ }
2602
+
2603
+ function ac_back(gobj, event, kw, src)
2604
+ {
2605
+ let priv = gobj.priv;
2606
+
2607
+ if(priv.diagram) {
2608
+ return go(gobj, priv.treedb_id, "", false, true);
2609
+ }
2610
+ if(priv.topic_name) {
2611
+ return go(gobj, priv.treedb_id, "", false, true);
2612
+ }
2613
+ return go(gobj, "", "", false, true);
2614
+ }
2615
+
2616
+ function ac_toggle_diagram(gobj, event, kw, src)
2617
+ {
2618
+ let priv = gobj.priv;
2619
+
2620
+ return go(gobj, priv.treedb_id, priv.topic_name, !priv.diagram, true);
2621
+ }
2622
+
2623
+ /***************************************************************
2624
+ * A topic clicked in the drawing. The diagram is a CHILD here
2625
+ * and publishes to us, so the event is declared in this FSM —
2626
+ * and it means the same thing a click on a row means.
2627
+ ***************************************************************/
2628
+ function ac_node_click(gobj, event, kw, src)
2629
+ {
2630
+ let topic = kw && kw.topic;
2631
+
2632
+ if(!topic) {
2633
+ return 0;
2634
+ }
2635
+ return go(gobj, gobj.priv.treedb_id, topic, false, true);
2636
+ }
2637
+
2638
+ function ac_show_orphans(gobj, event, kw, src)
2639
+ {
2640
+ open_orphans(gobj);
2641
+ return 0;
2642
+ }
2643
+
2644
+ /***************************************************************
2645
+ * Ask, and turn the answer into an event.
2646
+ *
2647
+ * `kw` carries an IDENTITY and never an object: the machine
2648
+ * trace dumps it, and a gobj or a DOM node in there is a
2649
+ * circular structure that breaks the very trace the FSM exists
2650
+ * to feed.
2651
+ ***************************************************************/
2652
+ function confirm_then(gobj, message, detail, kw)
2653
+ {
2654
+ yui_shell_confirm_danger(yui_shell_of(gobj), message, {
2655
+ t: t,
2656
+ detail: detail
2657
+ }).then((yes) => {
2658
+ if(!yes || gobj_is_destroying(gobj)) {
2659
+ return;
2660
+ }
2661
+ gobj_send_event(gobj, "EV_CONFIRMED", kw, gobj);
2662
+ });
2663
+ }
2664
+
2665
+ /***************************************************************
2666
+ * Editing a column.
2667
+ ***************************************************************/
2668
+ function ac_add_column(gobj, event, kw, src)
2669
+ {
2670
+ let topic = current_topic(gobj);
2671
+
2672
+ if(refuse_if_readonly(gobj, event)) {
2673
+ return -1;
2674
+ }
2675
+ if(!topic) {
2676
+ log_error(`${gobj_short_name(gobj)}: ${event} with no topic open`);
2677
+ return -1;
2678
+ }
2679
+ open_column_form(gobj, topic, null);
2680
+ return 0;
2681
+ }
2682
+
2683
+ function ac_edit_column(gobj, event, kw, src)
2684
+ {
2685
+ let priv = gobj.priv;
2686
+ let topic = current_topic(gobj);
2687
+ let col = topic ? find_col(priv.model, priv.treedb_id, topic.name, kw.col) : null;
2688
+
2689
+ if(refuse_if_readonly(gobj, event)) {
2690
+ return -1;
2691
+ }
2692
+ if(!col) {
2693
+ log_error(`${gobj_short_name(gobj)}: ${event} names no column: ${kw.col}`);
2694
+ return -1;
2695
+ }
2696
+ open_column_form(gobj, topic, col);
2697
+ return 0;
2698
+ }
2699
+
2700
+ /***************************************************************
2701
+ * Duplicating a column: the whole definition, a name that says
2702
+ * it is a copy, and the form open on it. A schema is written by
2703
+ * variations on a column far more often than from nothing.
2704
+ ***************************************************************/
2705
+ function ac_duplicate_column(gobj, event, kw, src)
2706
+ {
2707
+ let priv = gobj.priv;
2708
+ let topic = current_topic(gobj);
2709
+ let col = topic ? find_col(priv.model, priv.treedb_id, topic.name, kw.col) : null;
2710
+
2711
+ if(refuse_if_readonly(gobj, event)) {
2712
+ return -1;
2713
+ }
2714
+ if(!col) {
2715
+ log_error(`${gobj_short_name(gobj)}: ${event} names no column: ${kw.col}`);
2716
+ return -1;
2717
+ }
2718
+
2719
+ let prefill = Object.assign({}, col.record);
2720
+ delete prefill.id;
2721
+ delete prefill.value;
2722
+ delete prefill.order;
2723
+ delete prefill.topics;
2724
+ prefill.__name__ = `${col.name}_copy`;
2725
+
2726
+ open_column_form(gobj, topic, null, prefill);
2727
+ return 0;
2728
+ }
2729
+
2730
+ function ac_save_column(gobj, event, kw, src)
2731
+ {
2732
+ let priv = gobj.priv;
2733
+ let topic = find_topic(priv.model, priv.treedb_id, kw.topic);
2734
+ let col = kw.creating ? null : find_col(priv.model, priv.treedb_id, kw.topic, kw.col);
2735
+
2736
+ if(refuse_if_readonly(gobj, event)) {
2737
+ return -1;
2738
+ }
2739
+ if(!topic || (!kw.creating && !col)) {
2740
+ log_error(`${gobj_short_name(gobj)}: ${event} names no column: ${kw.topic}.${kw.col}`);
2741
+ return -1;
2742
+ }
2743
+
2744
+ let values = kw.values || {};
2745
+ if(kw.creating && empty_string(String(values.value || "").trim())) {
2746
+ yui_shell_show_error(yui_shell_of(gobj), "a column needs a name", {t: t});
2747
+ return -1;
2748
+ }
2749
+ if(kw.creating && find_col(priv.model, priv.treedb_id, kw.topic, String(values.value).trim())) {
2750
+ yui_shell_show_error(yui_shell_of(gobj), "that column is already there", {t: t});
2751
+ return -1;
2752
+ }
2753
+
2754
+ close_dialog(gobj);
2755
+ return queue_writes(gobj, [{
2756
+ op: kw.creating ? "create" : "update",
2757
+ topic_name: T_COLS,
2758
+ record: column_record(gobj, topic, col, values)
2759
+ }].concat(version_writes(gobj, topic)));
2760
+ }
2761
+
2762
+ function ac_delete_column(gobj, event, kw, src)
2763
+ {
2764
+ let priv = gobj.priv;
2765
+ let topic = current_topic(gobj);
2766
+ let col = topic ? find_col(priv.model, priv.treedb_id, topic.name, kw.col) : null;
2767
+
2768
+ if(refuse_if_readonly(gobj, event)) {
2769
+ return -1;
2770
+ }
2771
+ if(!col) {
2772
+ log_error(`${gobj_short_name(gobj)}: ${event} names no column: ${kw.col}`);
2773
+ return -1;
2774
+ }
2775
+
2776
+ confirm_then(gobj, "delete this column?", `${topic.name}.${col.name}`,
2777
+ {what: "column", topic: topic.name, col: col.name});
2778
+ return 0;
2779
+ }
2780
+
2781
+ /***************************************************************
2782
+ * Reordering. `order` is a field, so a drag is a write — of the
2783
+ * rows whose place actually changed, which in a forty-column
2784
+ * topic is two or three of them.
2785
+ ***************************************************************/
2786
+ function ac_move_column(gobj, event, kw, src)
2787
+ {
2788
+ let topic = current_topic(gobj);
2789
+
2790
+ if(refuse_if_readonly(gobj, event)) {
2791
+ return -1;
2792
+ }
2793
+ if(!topic) {
2794
+ log_error(`${gobj_short_name(gobj)}: ${event} with no topic open`);
2795
+ return -1;
2796
+ }
2797
+
2798
+ let from = kw.from;
2799
+ let to = kw.to;
2800
+ let list = topic.cols.slice();
2801
+ if(from < 0 || from >= list.length || to < 0 || to > list.length) {
2802
+ log_error(`${gobj_short_name(gobj)}: ${event} out of range: ${from} -> ${to}`);
2803
+ return -1;
2804
+ }
2805
+ let moved = list.splice(from, 1)[0];
2806
+ list.splice(from < to ? to - 1 : to, 0, moved);
2807
+
2808
+ let writes = moved_orders(list).map((order) => {
2809
+ return {op: "update", topic_name: T_COLS,
2810
+ record: {id: order.id, order: order.order}};
2811
+ });
2812
+ if(writes.length === 0) {
2813
+ return 0; /* dropped where it already was */
2814
+ }
2815
+ return queue_writes(gobj, writes.concat(version_writes(gobj, topic)));
2816
+ }
2817
+
2818
+ /***************************************************************
2819
+ * Editing a topic.
2820
+ ***************************************************************/
2821
+ function ac_add_topic(gobj, event, kw, src)
2822
+ {
2823
+ let treedb = current_treedb(gobj);
2824
+
2825
+ if(refuse_if_readonly(gobj, event)) {
2826
+ return -1;
2827
+ }
2828
+ if(!treedb) {
2829
+ log_error(`${gobj_short_name(gobj)}: ${event} with no treedb open`);
2830
+ return -1;
2831
+ }
2832
+ open_topic_form(gobj, treedb, null);
2833
+ return 0;
2834
+ }
2835
+
2836
+ function ac_edit_topic(gobj, event, kw, src)
2837
+ {
2838
+ let priv = gobj.priv;
2839
+ let treedb = current_treedb(gobj);
2840
+ let topic = treedb ? find_topic(priv.model, priv.treedb_id, kw.topic) : null;
2841
+
2842
+ if(refuse_if_readonly(gobj, event)) {
2843
+ return -1;
2844
+ }
2845
+ if(!topic) {
2846
+ log_error(`${gobj_short_name(gobj)}: ${event} names no topic: ${kw.topic}`);
2847
+ return -1;
2848
+ }
2849
+ open_topic_form(gobj, treedb, topic);
2850
+ return 0;
2851
+ }
2852
+
2853
+ function ac_save_topic(gobj, event, kw, src)
2854
+ {
2855
+ let priv = gobj.priv;
2856
+ let treedb = current_treedb(gobj);
2857
+ let topic = kw.creating ? null : find_topic(priv.model, priv.treedb_id, kw.topic);
2858
+
2859
+ if(refuse_if_readonly(gobj, event)) {
2860
+ return -1;
2861
+ }
2862
+ if(!treedb || (!kw.creating && !topic)) {
2863
+ log_error(`${gobj_short_name(gobj)}: ${event} names no topic: ${kw.topic}`);
2864
+ return -1;
2865
+ }
2866
+
2867
+ let values = kw.values || {};
2868
+ if(kw.creating) {
2869
+ let name = String(values.value || "").trim();
2870
+ if(empty_string(name)) {
2871
+ yui_shell_show_error(yui_shell_of(gobj), "a topic needs a name", {t: t});
2872
+ return -1;
2873
+ }
2874
+ if(find_topic(priv.model, priv.treedb_id, name)) {
2875
+ yui_shell_show_error(yui_shell_of(gobj), "that topic is already there", {t: t});
2876
+ return -1;
2877
+ }
2878
+ }
2879
+
2880
+ close_dialog(gobj);
2881
+ /* The topic record carries the version the form asked for, so it is
2882
+ * not raised again here — only the treedb's. */
2883
+ return queue_writes(gobj, [{
2884
+ op: kw.creating ? "create" : "update",
2885
+ topic_name: T_TOPICS,
2886
+ record: topic_record(gobj, treedb, topic, values)
2887
+ }].concat(version_writes(gobj, null)));
2888
+ }
2889
+
2890
+ function ac_delete_topic(gobj, event, kw, src)
2891
+ {
2892
+ let priv = gobj.priv;
2893
+ let topic = find_topic(priv.model, priv.treedb_id, kw.topic);
2894
+
2895
+ if(refuse_if_readonly(gobj, event)) {
2896
+ return -1;
2897
+ }
2898
+ if(!topic) {
2899
+ log_error(`${gobj_short_name(gobj)}: ${event} names no topic: ${kw.topic}`);
2900
+ return -1;
2901
+ }
2902
+
2903
+ confirm_then(gobj, "delete this topic and its columns?",
2904
+ `${topic.name} (${topic.cols.length})`, {what: "topic", topic: topic.name});
2905
+ return 0;
2906
+ }
2907
+
2908
+ /***************************************************************
2909
+ * Raising a version by hand: what the banner offers when
2910
+ * something else wrote the topic and left its version alone.
2911
+ ***************************************************************/
2912
+ function ac_bump_version(gobj, event, kw, src)
2913
+ {
2914
+ let priv = gobj.priv;
2915
+ let topic = find_topic(priv.model, priv.treedb_id, kw.topic);
2916
+
2917
+ if(refuse_if_readonly(gobj, event)) {
2918
+ return -1;
2919
+ }
2920
+ if(!topic) {
2921
+ log_error(`${gobj_short_name(gobj)}: ${event} names no topic: ${kw.topic}`);
2922
+ return -1;
2923
+ }
2924
+ return queue_writes(gobj, version_writes(gobj, topic));
2925
+ }
2926
+
2927
+ /***************************************************************
2928
+ * Deleting a leftover.
2929
+ ***************************************************************/
2930
+ function ac_delete_orphan(gobj, event, kw, src)
2931
+ {
2932
+ if(refuse_if_readonly(gobj, event)) {
2933
+ return -1;
2934
+ }
2935
+ if(empty_string(kw.id)) {
2936
+ log_error(`${gobj_short_name(gobj)}: ${event} names nothing`);
2937
+ return -1;
2938
+ }
2939
+ close_dialog(gobj);
2940
+ return queue_writes(gobj, [{
2941
+ op: "delete",
2942
+ topic_name: kw.kind === "topic" ? T_TOPICS : T_COLS,
2943
+ record: {id: kw.id}
2944
+ }]);
2945
+ }
2946
+
2947
+ /***************************************************************
2948
+ * Checking, exporting, importing.
2949
+ ***************************************************************/
2950
+ function ac_validate(gobj, event, kw, src)
2951
+ {
2952
+ let priv = gobj.priv;
2953
+ let treedb = current_treedb(gobj);
2954
+
2955
+ if(!treedb) {
2956
+ log_error(`${gobj_short_name(gobj)}: ${event} with no treedb open`);
2957
+ return -1;
2958
+ }
2959
+ let findings = validate_schema(treedb, {
2960
+ written_topics: Object.keys(priv.written),
2961
+ baseline: priv.baseline
2962
+ });
2963
+ open_report(gobj, findings, treedb.id);
2964
+ if(has_errors(findings)) {
2965
+ log_warning(`${gobj_short_name(gobj)}: ${treedb.id} would be refused as it is`);
2966
+ }
2967
+ return 0;
2968
+ }
2969
+
2970
+ function ac_export(gobj, event, kw, src)
2971
+ {
2972
+ let treedb = current_treedb(gobj);
2973
+
2974
+ if(!treedb) {
2975
+ log_error(`${gobj_short_name(gobj)}: ${event} with no treedb open`);
2976
+ return -1;
2977
+ }
2978
+ open_export(gobj, treedb);
2979
+ return 0;
2980
+ }
2981
+
2982
+ function ac_import(gobj, event, kw, src)
2983
+ {
2984
+ let treedb = current_treedb(gobj);
2985
+
2986
+ if(refuse_if_readonly(gobj, event)) {
2987
+ return -1;
2988
+ }
2989
+ if(!treedb) {
2990
+ log_error(`${gobj_short_name(gobj)}: ${event} with no treedb open`);
2991
+ return -1;
2992
+ }
2993
+ open_import(gobj, treedb);
2994
+ return 0;
2995
+ }
2996
+
2997
+ /***************************************************************
2998
+ * The paste, read and planned. Nothing is written here: what is
2999
+ * shown is the plan, and the plan is what the next event runs.
3000
+ ***************************************************************/
3001
+ function ac_preview_import(gobj, event, kw, src)
3002
+ {
3003
+ let priv = gobj.priv;
3004
+ let treedb = current_treedb(gobj);
3005
+ let incoming = null;
3006
+
3007
+ if(!treedb) {
3008
+ log_error(`${gobj_short_name(gobj)}: ${event} with no treedb open`);
3009
+ return -1;
3010
+ }
3011
+ try {
3012
+ incoming = JSON.parse(String(kw.text || ""));
3013
+ } catch(e) {
3014
+ priv.import_plan = null;
3015
+ render_import_plan(gobj, {writes: [], summary: {},
3016
+ conflicts: [{code: "that is not json", topic: "", col: ""}]});
3017
+ return -1;
3018
+ }
3019
+
3020
+ let plan = plan_import(treedb, incoming, {prune: !!kw.prune});
3021
+ priv.import_plan = plan;
3022
+ render_import_plan(gobj, plan);
3023
+ return 0;
3024
+ }
3025
+
3026
+ function ac_apply_import(gobj, event, kw, src)
3027
+ {
3028
+ let priv = gobj.priv;
3029
+ let plan = priv.import_plan;
3030
+
3031
+ if(refuse_if_readonly(gobj, event)) {
3032
+ return -1;
3033
+ }
3034
+ if(!plan || plan.writes.length === 0) {
3035
+ log_error(`${gobj_short_name(gobj)}: ${event} with no plan to run`);
3036
+ return -1;
3037
+ }
3038
+
3039
+ let deletes = plan_deletes(plan);
3040
+ if(deletes.length === 0) {
3041
+ return run_import(gobj);
3042
+ }
3043
+ confirm_then(gobj, "this import deletes part of the schema", `${deletes.length}`,
3044
+ {what: "import"});
3045
+ return 0;
3046
+ }
3047
+
3048
+ /***************************************************************
3049
+ * Run the plan the dialog showed. Reached only through
3050
+ * EV_CONFIRMED or from an import with nothing to delete.
3051
+ ***************************************************************/
3052
+ function run_import(gobj)
3053
+ {
3054
+ let priv = gobj.priv;
3055
+ let plan = priv.import_plan;
3056
+
3057
+ if(!plan) {
3058
+ log_error(`${gobj_short_name(gobj)}: the import plan is gone`);
3059
+ return -1;
3060
+ }
3061
+ close_dialog(gobj);
3062
+ priv.import_plan = null;
3063
+ return queue_writes(gobj, plan.writes.map((write) => {
3064
+ return {op: write.op, topic_name: write.topic_name, record: write.record};
3065
+ }));
3066
+ }
3067
+
3068
+ /***************************************************************
3069
+ * A confirmation the operator gave.
3070
+ *
3071
+ * The dialog's answer comes back as an EVENT and not as a
3072
+ * continuation: a deletion decided in a modal is an action like
3073
+ * any other, and an action that never crosses the machine
3074
+ * leaves the `machine` trace saying nothing about the one thing
3075
+ * in this view that destroys something.
3076
+ ***************************************************************/
3077
+ function ac_confirmed(gobj, event, kw, src)
3078
+ {
3079
+ let priv = gobj.priv;
3080
+
3081
+ if(refuse_if_readonly(gobj, event)) {
3082
+ return -1;
3083
+ }
3084
+ if(kw.what === "import") {
3085
+ return run_import(gobj);
3086
+ }
3087
+ if(kw.what === "topic") {
3088
+ let topic = find_topic(priv.model, priv.treedb_id, kw.topic);
3089
+ if(!topic) {
3090
+ log_error(`${gobj_short_name(gobj)}: ${event} names no topic: ${kw.topic}`);
3091
+ return -1;
3092
+ }
3093
+ /* The columns first: a topic deleted with children behind it
3094
+ * leaves them belonging to nothing. */
3095
+ let writes = topic.cols.map((col) => {
3096
+ return {op: "delete", topic_name: T_COLS, record: {id: col.id}};
3097
+ });
3098
+ writes.push({op: "delete", topic_name: T_TOPICS, record: {id: topic.id}});
3099
+ return queue_writes(gobj, writes.concat(version_writes(gobj, null)));
3100
+ }
3101
+ if(kw.what === "column") {
3102
+ let topic = find_topic(priv.model, priv.treedb_id, kw.topic);
3103
+ let col = topic ? find_col(priv.model, priv.treedb_id, kw.topic, kw.col) : null;
3104
+ if(!col) {
3105
+ log_error(`${gobj_short_name(gobj)}: ${event} names no column: ${kw.col}`);
3106
+ return -1;
3107
+ }
3108
+ return queue_writes(gobj, [{
3109
+ op: "delete", topic_name: T_COLS, record: {id: col.id}
3110
+ }].concat(version_writes(gobj, topic)));
3111
+ }
3112
+
3113
+ log_error(`${gobj_short_name(gobj)}: ${event} of nothing this view asked about: ${kw.what}`);
3114
+ return -1;
3115
+ }
3116
+
3117
+
3118
+
3119
+
3120
+ /***************************************************************
3121
+ * FSM
3122
+ ***************************************************************/
3123
+ /*---------------------------------------------*
3124
+ * Global methods table
3125
+ *---------------------------------------------*/
3126
+ const gmt = {
3127
+ mt_create: mt_create,
3128
+ mt_start: mt_start,
3129
+ mt_stop: mt_stop,
3130
+ mt_destroy: mt_destroy
3131
+ };
3132
+
3133
+ /***************************************************************
3134
+ * Create the GClass
3135
+ ***************************************************************/
3136
+ function create_gclass(gclass_name)
3137
+ {
3138
+ if(__gclass__) {
3139
+ log_error(`GClass ALREADY created: ${gclass_name}`);
3140
+ return -1;
3141
+ }
3142
+
3143
+ /* What every screen answers: where the host says we are, whether
3144
+ * the session is up, the language, and the treedb's answers. */
3145
+ const COMMON = [
3146
+ ["EV_MT_COMMAND_ANSWER", ac_mt_command_answer, null],
3147
+ ["EV_SHOW", ac_show, null],
3148
+ ["EV_HIDE", ac_hide, null],
3149
+ ["EV_TRANSPORT_STATE", ac_transport_state, null],
3150
+ ["EV_LANGUAGE_CHANGED", ac_language_changed, null],
3151
+ ["EV_REFRESH", ac_refresh, null]
3152
+ ];
3153
+
3154
+ /* What a treedb is open for: reading it whole, and writing it
3155
+ * back. Legal wherever a treedb is open, which is every screen
3156
+ * below the first one. */
3157
+ const TREEDB_WIDE = [
3158
+ ["EV_BACK", ac_back, null],
3159
+ ["EV_TOGGLE_DIAGRAM", ac_toggle_diagram, null],
3160
+ ["EV_VALIDATE", ac_validate, null],
3161
+ ["EV_EXPORT", ac_export, null],
3162
+ ["EV_IMPORT", ac_import, null],
3163
+ ["EV_PREVIEW_IMPORT", ac_preview_import, null],
3164
+ ["EV_APPLY_IMPORT", ac_apply_import, null],
3165
+ ["EV_CONFIRMED", ac_confirmed, null]
3166
+ ];
3167
+
3168
+ /*---------------------------------------------*
3169
+ * States
3170
+ *
3171
+ * Each is a screen and a set of legal actions. An
3172
+ * event arriving in the wrong one is NOT swallowed
3173
+ * by a no-op action: the framework says so, loudly,
3174
+ * and names the sender — which is the only reason
3175
+ * a click is worth routing through a machine.
3176
+ *---------------------------------------------*/
3177
+ const states = [
3178
+ ["ST_IDLE", COMMON.slice()],
3179
+
3180
+ ["ST_LOADING", COMMON.slice()],
3181
+
3182
+ ["ST_EMPTY", COMMON.slice()],
3183
+
3184
+ ["ST_TREEDBS", COMMON.concat([
3185
+ ["EV_SELECT_TREEDB", ac_select_treedb, null],
3186
+ ["EV_SHOW_ORPHANS", ac_show_orphans, null],
3187
+ ["EV_DELETE_ORPHAN", ac_delete_orphan, null],
3188
+ ["EV_CONFIRMED", ac_confirmed, null]
3189
+ ])],
3190
+
3191
+ ["ST_TOPICS", COMMON.concat(TREEDB_WIDE).concat([
3192
+ ["EV_SELECT_TOPIC", ac_select_topic, null],
3193
+ ["EV_ADD_TOPIC", ac_add_topic, null],
3194
+ ["EV_EDIT_TOPIC", ac_edit_topic, null],
3195
+ ["EV_DELETE_TOPIC", ac_delete_topic, null],
3196
+ ["EV_SAVE_TOPIC", ac_save_topic, null]
3197
+ ])],
3198
+
3199
+ ["ST_DIAGRAM", COMMON.concat(TREEDB_WIDE).concat([
3200
+ ["EV_NODE_CLICK", ac_node_click, null]
3201
+ ])],
3202
+
3203
+ ["ST_COLUMNS", COMMON.concat(TREEDB_WIDE).concat([
3204
+ ["EV_ADD_COLUMN", ac_add_column, null],
3205
+ ["EV_EDIT_COLUMN", ac_edit_column, null],
3206
+ ["EV_DUPLICATE_COLUMN", ac_duplicate_column, null],
3207
+ ["EV_DELETE_COLUMN", ac_delete_column, null],
3208
+ ["EV_SAVE_COLUMN", ac_save_column, null],
3209
+ ["EV_MOVE_COLUMN", ac_move_column, null],
3210
+ ["EV_BUMP_VERSION", ac_bump_version, null]
3211
+ ])],
3212
+
3213
+ /* A write, or a plan of them, in flight. Only the answers and
3214
+ * the host are heard: every other action would be computed
3215
+ * against a model the answer still in the air is about to
3216
+ * change. */
3217
+ ["ST_SAVING", COMMON.slice()]
3218
+ ];
3219
+
3220
+ /*---------------------------------------------*
3221
+ * Events
3222
+ *---------------------------------------------*/
3223
+ const event_types = [
3224
+ /* From the backend. PUBLIC, or the ievent drops it before this
3225
+ * gclass is reached. */
3226
+ ["EV_MT_COMMAND_ANSWER", event_flag_t.EVF_PUBLIC_EVENT],
3227
+
3228
+ /* From the host. */
3229
+ ["EV_SHOW", 0],
3230
+ ["EV_HIDE", 0],
3231
+ ["EV_TRANSPORT_STATE", 0],
3232
+ ["EV_LANGUAGE_CHANGED", 0],
3233
+ ["EV_REFRESH", 0],
3234
+
3235
+ /* From this view's own screens. */
3236
+ ["EV_SELECT_TREEDB", 0],
3237
+ ["EV_SELECT_TOPIC", 0],
3238
+ ["EV_BACK", 0],
3239
+ ["EV_TOGGLE_DIAGRAM", 0],
3240
+ ["EV_SHOW_ORPHANS", 0],
3241
+ ["EV_DELETE_ORPHAN", 0],
3242
+ ["EV_ADD_TOPIC", 0],
3243
+ ["EV_EDIT_TOPIC", 0],
3244
+ ["EV_DELETE_TOPIC", 0],
3245
+ ["EV_SAVE_TOPIC", 0],
3246
+ ["EV_ADD_COLUMN", 0],
3247
+ ["EV_EDIT_COLUMN", 0],
3248
+ ["EV_DUPLICATE_COLUMN", 0],
3249
+ ["EV_DELETE_COLUMN", 0],
3250
+ ["EV_SAVE_COLUMN", 0],
3251
+ ["EV_MOVE_COLUMN", 0],
3252
+ ["EV_BUMP_VERSION", 0],
3253
+ ["EV_VALIDATE", 0],
3254
+ ["EV_EXPORT", 0],
3255
+ ["EV_IMPORT", 0],
3256
+ ["EV_PREVIEW_IMPORT", 0],
3257
+ ["EV_APPLY_IMPORT", 0],
3258
+ ["EV_CONFIRMED", 0],
3259
+
3260
+ /* From the drawing, which is a child of this gobj. */
3261
+ ["EV_NODE_CLICK", 0],
3262
+
3263
+ /* Outwards. The host mirrors the position into the url and
3264
+ * carries the write up to whoever owns the Apply — both are
3265
+ * optional, so neither warns when nobody listens. */
3266
+ ["EV_POSITION_CHANGED", event_flag_t.EVF_OUTPUT_EVENT|
3267
+ event_flag_t.EVF_NO_WARN_SUBS],
3268
+ ["EV_RECORD_WRITTEN", event_flag_t.EVF_OUTPUT_EVENT|
3269
+ event_flag_t.EVF_NO_WARN_SUBS],
3270
+ ["EV_SCHEMA_CHECKED", event_flag_t.EVF_OUTPUT_EVENT|
3271
+ event_flag_t.EVF_NO_WARN_SUBS]
3272
+ ];
3273
+
3274
+ __gclass__ = gclass_create(
3275
+ gclass_name,
3276
+ event_types,
3277
+ states,
3278
+ gmt,
3279
+ 0, // lmt,
3280
+ attrs_table,
3281
+ PRIVATE_DATA,
3282
+ 0, // authz_table,
3283
+ 0, // command_table,
3284
+ 0, // s_user_trace_level
3285
+ 0 // gclass_flag
3286
+ );
3287
+
3288
+ if(!__gclass__) {
3289
+ return -1;
3290
+ }
3291
+
3292
+ return 0;
3293
+ }
3294
+
3295
+ /***************************************************************
3296
+ * Register GClass
3297
+ ***************************************************************/
3298
+ function register_c_yui_schema_editor()
3299
+ {
3300
+ return create_gclass(GCLASS_NAME);
3301
+ }
3302
+
3303
+ export {register_c_yui_schema_editor};