@yuneta/gobj-ui 6.0.0 → 6.1.0

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