@yuneta/gobj-ui 7.26.39 → 7.26.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -2
- package/dist/gobj-ui.cjs.js +135 -75
- package/dist/gobj-ui.es.js +135 -75
- package/package.json +1 -1
- package/src/c_yui_form.js +80 -68
- package/src/c_yui_uplot.js +8 -58
- package/src/form_table_edits_ask.wiring.test.js +398 -0
- package/src/form_table_rearm.wiring.test.js +9 -7
- package/src/uplot_ink.test.js +105 -0
- package/src/yui_tabulator_i18n.js +21 -1
- package/src/yui_tabulator_i18n.test.js +39 -0
- package/src/yui_text.js +10 -0
- package/src/yui_text.test.js +13 -0
- package/src/yui_uplot_ink.js +94 -0
package/src/c_yui_uplot.js
CHANGED
|
@@ -30,62 +30,12 @@ import {
|
|
|
30
30
|
import uPlot from "uplot";
|
|
31
31
|
import "uplot/dist/uPlot.min.css";
|
|
32
32
|
|
|
33
|
-
import {
|
|
33
|
+
import {yui_watch_theme} from "./yui_theme.js";
|
|
34
|
+
/* The ink of the axes -- the X and the Y -- and the palette of the
|
|
35
|
+
* series, on both schemes: see yui_uplot_ink.js. */
|
|
36
|
+
import {yui_uplot_axes, YUI_UPLOT_PALETTE} from "./yui_uplot_ink.js";
|
|
34
37
|
|
|
35
38
|
|
|
36
|
-
/***************************************************************
|
|
37
|
-
* The ink of a chart, and its palette.
|
|
38
|
-
*
|
|
39
|
-
* uPlot is built for a white page: its axis labels are `#000`
|
|
40
|
-
* and its grid `rgba(0,0,0,0.07)`, which on the dark scheme are
|
|
41
|
-
* 1.16:1 and 1.04 -- an axis nobody can read and a grid that is
|
|
42
|
-
* not there. And the series were CSS colour names: `blue` is
|
|
43
|
-
* 2.11:1 on the dark ground, `orange` 1.97 on the light one, so
|
|
44
|
-
* whichever scheme you were in, one of the lines was a rumour.
|
|
45
|
-
*
|
|
46
|
-
* These are given to uPlot as FUNCTIONS: it calls them when it
|
|
47
|
-
* draws, so the value can never be stale -- what goes stale is
|
|
48
|
-
* the canvas, and that is what EV_THEME redraws.
|
|
49
|
-
*
|
|
50
|
-
* The palette is six colours that clear 3:1 on BOTH grounds
|
|
51
|
-
* (worst 3.39 on white, 3.71 on near-black), ordered so the
|
|
52
|
-
* first two are the pair that survives colour blindness -- blue
|
|
53
|
-
* and orange, never red and green as the opening pair.
|
|
54
|
-
***************************************************************/
|
|
55
|
-
function axis_ink()
|
|
56
|
-
{
|
|
57
|
-
return yui_is_dark()? "#c7cdd8" : "#2e333d";
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function grid_ink()
|
|
61
|
-
{
|
|
62
|
-
return yui_is_dark()? "rgba(199,205,216,0.16)" : "rgba(46,51,61,0.12)";
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const CHART_PALETTE = [
|
|
66
|
-
{stroke: "#2f6fd0", fill: "rgba(47,111,208,0.10)"}, /* 4.88 / 3.71 */
|
|
67
|
-
{stroke: "#b8620a", fill: "rgba(184,98,10,0.10)"}, /* 4.38 / 4.13 */
|
|
68
|
-
{stroke: "#2f8f4e", fill: "rgba(47,143,78,0.10)"}, /* 4.07 / 4.45 */
|
|
69
|
-
{stroke: "#8b5cf6", fill: "rgba(139,92,246,0.10)"}, /* 4.23 / 4.28 */
|
|
70
|
-
{stroke: "#0e9aa7", fill: "rgba(14,154,167,0.10)"}, /* 3.39 / 5.35 */
|
|
71
|
-
{stroke: "#d1344b", fill: "rgba(209,52,75,0.10)"}, /* 4.88 / 3.71 */
|
|
72
|
-
];
|
|
73
|
-
|
|
74
|
-
/* The axes the host asked for, with the ink it did not ask about. */
|
|
75
|
-
function themed_axes(axes)
|
|
76
|
-
{
|
|
77
|
-
let list = Array.isArray(axes)? axes : [axes || {}];
|
|
78
|
-
return list.map((ax) => {
|
|
79
|
-
let a = Object.assign({}, ax || {});
|
|
80
|
-
if(a.stroke === undefined) {
|
|
81
|
-
a.stroke = axis_ink;
|
|
82
|
-
}
|
|
83
|
-
a.grid = Object.assign({stroke: grid_ink}, a.grid || {});
|
|
84
|
-
a.ticks = Object.assign({stroke: grid_ink}, a.ticks || {});
|
|
85
|
-
return a;
|
|
86
|
-
});
|
|
87
|
-
}
|
|
88
|
-
|
|
89
39
|
/***************************************************************
|
|
90
40
|
* Constants
|
|
91
41
|
***************************************************************/
|
|
@@ -104,7 +54,7 @@ SDATA(data_type_t.DTP_JSON, "series", 0, [{}], "Data series"),
|
|
|
104
54
|
SDATA(data_type_t.DTP_JSON, "axes", 0, [{}], "Chart axes"),
|
|
105
55
|
SDATA(data_type_t.DTP_JSON, "scales", 0, {}, "Chart scales"),
|
|
106
56
|
SDATA(data_type_t.DTP_JSON, "data", 0, [[]], "Chart data"),
|
|
107
|
-
SDATA(data_type_t.DTP_JSON, "colors", 0,
|
|
57
|
+
SDATA(data_type_t.DTP_JSON, "colors", 0, YUI_UPLOT_PALETTE,
|
|
108
58
|
"Series colours, in order. The default palette clears 3:1 on both schemes and opens with the colour-blind-safe pair (blue, orange)"),
|
|
109
59
|
SDATA(data_type_t.DTP_POINTER, "$container", 0, null, "HTML container for the chart"),
|
|
110
60
|
SDATA(data_type_t.DTP_BOOLEAN, "own_container", 0, false, "If true, container is gobj-managed"),
|
|
@@ -138,7 +88,7 @@ function mt_create(gobj)
|
|
|
138
88
|
build_ui(gobj);
|
|
139
89
|
|
|
140
90
|
/* A canvas does not restyle itself: the ink is read when uPlot
|
|
141
|
-
* DRAWS (
|
|
91
|
+
* DRAWS (yui_uplot_ink.js), so a theme change only has to reach the
|
|
142
92
|
* machine and ask for a redraw. Same wiring as the graphs. */
|
|
143
93
|
gobj.priv.theme_observer = yui_watch_theme(gobj);
|
|
144
94
|
|
|
@@ -220,7 +170,7 @@ function build_ui(gobj)
|
|
|
220
170
|
width: gobj_read_attr(gobj, "width"),
|
|
221
171
|
height: gobj_read_attr(gobj, "height"),
|
|
222
172
|
series: gobj_read_attr(gobj, "series"),
|
|
223
|
-
axes:
|
|
173
|
+
axes: yui_uplot_axes(gobj_read_attr(gobj, "axes")),
|
|
224
174
|
scales: gobj_read_attr(gobj, "scales"),
|
|
225
175
|
};
|
|
226
176
|
let uplot = new uPlot(
|
|
@@ -444,7 +394,7 @@ function ac_add_serie(gobj, event, kw, src)
|
|
|
444
394
|
}
|
|
445
395
|
let colors = gobj_read_attr(gobj, "colors");
|
|
446
396
|
if(!Array.isArray(colors) || !colors.length) {
|
|
447
|
-
colors =
|
|
397
|
+
colors = YUI_UPLOT_PALETTE;
|
|
448
398
|
}
|
|
449
399
|
/* `idx` counts uPlot's series, and series[0] is the X AXIS -- so
|
|
450
400
|
* the first line drawn was taking colours[1] and the palette's
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
/***********************************************************************
|
|
2
|
+
* form_table_edits_ask.wiring.test.js
|
|
3
|
+
*
|
|
4
|
+
* What the reader does to the TABLES of a C_YUI_FORM is `edited`,
|
|
5
|
+
* whatever the record the form opened with -- so closing asks.
|
|
6
|
+
*
|
|
7
|
+
* A form opens CHANGED when its record lacks a field the template
|
|
8
|
+
* fills with its default. Until 7.26.40 that also unwired the
|
|
9
|
+
* tables' `dataChanged` (and only a save, an undo or a load of what
|
|
10
|
+
* is stored wired it again), so adding a row, editing a cell,
|
|
11
|
+
* deleting a row or editing a nested table marked nothing: since
|
|
12
|
+
* 7.26.39 the close asks only for `edited`, and it closed without a
|
|
13
|
+
* question -- the edit lost. In estadodelaire that is the settings of
|
|
14
|
+
* every device without its alarm colours, whose alarms are tables.
|
|
15
|
+
*
|
|
16
|
+
* And the column titles of those tables change language: they were
|
|
17
|
+
* drawn at build and never again.
|
|
18
|
+
*
|
|
19
|
+
* Driven through the real gclass on a document double, with a
|
|
20
|
+
* Tabulator double that, like Tabulator, draws a row (its formatter
|
|
21
|
+
* runs) and says `dataChanged` INSIDE addRow(), synchronously.
|
|
22
|
+
*
|
|
23
|
+
* Copyright (c) 2026, ArtGins.
|
|
24
|
+
* All Rights Reserved.
|
|
25
|
+
***********************************************************************/
|
|
26
|
+
import {describe, test, expect, beforeAll, beforeEach, vi} from "vitest";
|
|
27
|
+
import {install_dom_double} from "../test/dom_double.js";
|
|
28
|
+
|
|
29
|
+
install_dom_double();
|
|
30
|
+
|
|
31
|
+
if(typeof globalThis.ResizeObserver === "undefined") {
|
|
32
|
+
globalThis.ResizeObserver = class {
|
|
33
|
+
observe() {}
|
|
34
|
+
unobserve() {}
|
|
35
|
+
disconnect() {}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const tables = vi.hoisted(() => []);
|
|
40
|
+
vi.mock("tabulator-tables", () => {
|
|
41
|
+
class FakeTabulator {
|
|
42
|
+
constructor($el, settings) {
|
|
43
|
+
this.element = $el;
|
|
44
|
+
this.settings = settings;
|
|
45
|
+
this.options = {};
|
|
46
|
+
this.handlers = {};
|
|
47
|
+
this.initialized = true;
|
|
48
|
+
this.rows = [];
|
|
49
|
+
this.installed = {};
|
|
50
|
+
this.locale = null;
|
|
51
|
+
this.modules = {
|
|
52
|
+
localize: {
|
|
53
|
+
installLang: (name, strings) => {
|
|
54
|
+
this.installed[name] = strings;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
tables.push(this);
|
|
59
|
+
return new Proxy(this, {
|
|
60
|
+
get(target, prop) {
|
|
61
|
+
if(prop in target) {
|
|
62
|
+
return target[prop];
|
|
63
|
+
}
|
|
64
|
+
return () => [];
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
on(ev, fn) {
|
|
69
|
+
(this.handlers[ev] = this.handlers[ev] || []).push(fn);
|
|
70
|
+
}
|
|
71
|
+
off(ev) {
|
|
72
|
+
delete this.handlers[ev];
|
|
73
|
+
}
|
|
74
|
+
emit(ev) {
|
|
75
|
+
for(const fn of (this.handlers[ev] || [])) {
|
|
76
|
+
fn([]);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
addRow(record) {
|
|
80
|
+
/* The row's element lives in the table's, as in Tabulator:
|
|
81
|
+
* a nested table drawn in it is inside the form. */
|
|
82
|
+
const $row = document.createElement("div");
|
|
83
|
+
this.element.appendChild($row);
|
|
84
|
+
const row = {getElement: () => $row, getData: () => record};
|
|
85
|
+
if(this.settings && typeof this.settings.rowFormatter === "function") {
|
|
86
|
+
this.settings.rowFormatter(row);
|
|
87
|
+
}
|
|
88
|
+
this.rows.push(row);
|
|
89
|
+
this.emit("dataChanged");
|
|
90
|
+
return Promise.resolve(row);
|
|
91
|
+
}
|
|
92
|
+
/* What the trash of a row ends in (row.delete()). */
|
|
93
|
+
delete_row() {
|
|
94
|
+
const row = this.rows.pop();
|
|
95
|
+
row.getElement().remove();
|
|
96
|
+
this.emit("dataChanged");
|
|
97
|
+
}
|
|
98
|
+
/* What an edited cell says. */
|
|
99
|
+
edit_cell() {
|
|
100
|
+
this.emit("dataChanged");
|
|
101
|
+
}
|
|
102
|
+
validate() {
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
clearData() {
|
|
106
|
+
for(const row of this.rows) {
|
|
107
|
+
row.getElement().remove();
|
|
108
|
+
}
|
|
109
|
+
this.rows = [];
|
|
110
|
+
}
|
|
111
|
+
setLocale(name) {
|
|
112
|
+
this.locale = name;
|
|
113
|
+
}
|
|
114
|
+
destroy() {
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return {TabulatorFull: FakeTabulator, Tabulator: FakeTabulator};
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const {
|
|
121
|
+
SDATA_END, gclass_create,
|
|
122
|
+
gobj_start_up, gobj_create_yuno, gobj_create_pure_child,
|
|
123
|
+
gobj_start, gobj_send_event, gobj_read_attr,
|
|
124
|
+
set_log_callback,
|
|
125
|
+
} = await import("@yuneta/gobj-js");
|
|
126
|
+
const {register_c_yui_form} = await import("./c_yui_form.js");
|
|
127
|
+
const i18next = (await import("i18next")).default;
|
|
128
|
+
|
|
129
|
+
const logged = [];
|
|
130
|
+
let yuno = null;
|
|
131
|
+
|
|
132
|
+
/* A device's settings, cut down: a defaulted field, and the alarms as
|
|
133
|
+
* a table whose rows carry a nested table of triggers. */
|
|
134
|
+
const TEMPLATE = {
|
|
135
|
+
template_version: {
|
|
136
|
+
type: "integer",
|
|
137
|
+
default: 7,
|
|
138
|
+
flag: ["hidden", "persistent"]
|
|
139
|
+
},
|
|
140
|
+
level: {
|
|
141
|
+
type: "integer",
|
|
142
|
+
default: 3,
|
|
143
|
+
flag: ["writable", "persistent"]
|
|
144
|
+
},
|
|
145
|
+
alarms: {
|
|
146
|
+
type: "array",
|
|
147
|
+
flag: ["writable", "persistent", "table"],
|
|
148
|
+
table: {
|
|
149
|
+
name: {
|
|
150
|
+
type: "string",
|
|
151
|
+
header: "alarm name",
|
|
152
|
+
flag: ["writable", "persistent"]
|
|
153
|
+
},
|
|
154
|
+
triggers: {
|
|
155
|
+
type: "array",
|
|
156
|
+
flag: ["writable", "persistent", "table"],
|
|
157
|
+
table: {
|
|
158
|
+
measure: {
|
|
159
|
+
type: "string",
|
|
160
|
+
header: "measure",
|
|
161
|
+
flag: ["writable", "persistent"]
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/* `level` missing: the form opens changed. */
|
|
170
|
+
const LACKING = {
|
|
171
|
+
template_version: 7,
|
|
172
|
+
alarms: [{name: "hot", triggers: [{measure: "temperature"}]}]
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const COMPLETE = {
|
|
176
|
+
template_version: 7,
|
|
177
|
+
level: 3,
|
|
178
|
+
alarms: [{name: "hot", triggers: [{measure: "temperature"}]}]
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
beforeAll(async () => {
|
|
182
|
+
await i18next.init({
|
|
183
|
+
lng: "en",
|
|
184
|
+
resources: {
|
|
185
|
+
en: {translation: {"alarm name": "Alarm name", "measure": "Measure"}},
|
|
186
|
+
es: {translation: {"alarm name": "Nombre de la alarma", "measure": "Medida"}}
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
gobj_start_up(null, null, null, null, null, null, null);
|
|
190
|
+
set_log_callback((level, msg) => {
|
|
191
|
+
logged.push({level: String(level), msg: String(msg)});
|
|
192
|
+
});
|
|
193
|
+
gclass_create("C_TEST_TE_HOST", [], [["ST_IDLE", []]], {}, 0, [SDATA_END()], {}, 0, 0, 0, 0);
|
|
194
|
+
gclass_create(
|
|
195
|
+
"C_TEST_TE_APP",
|
|
196
|
+
[["EV_SAVE_RECORD", 0]],
|
|
197
|
+
[["ST_IDLE", [
|
|
198
|
+
["EV_SAVE_RECORD", () => 0, null],
|
|
199
|
+
]]],
|
|
200
|
+
{}, 0, [SDATA_END()], {}, 0, 0, 0, 0
|
|
201
|
+
);
|
|
202
|
+
register_c_yui_form();
|
|
203
|
+
yuno = gobj_create_yuno("form_table_edits_yuno", "C_TEST_TE_HOST", {});
|
|
204
|
+
gobj_start(yuno);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
beforeEach(async () => {
|
|
208
|
+
logged.length = 0;
|
|
209
|
+
await i18next.changeLanguage("en");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
let seq = 0;
|
|
213
|
+
|
|
214
|
+
function build(record)
|
|
215
|
+
{
|
|
216
|
+
const first = tables.length;
|
|
217
|
+
const app = gobj_create_pure_child(`teapp${++seq}`, "C_TEST_TE_APP", {}, yuno);
|
|
218
|
+
gobj_start(app);
|
|
219
|
+
const form = gobj_create_pure_child(`teform${seq}`, "C_YUI_FORM", {
|
|
220
|
+
template: TEMPLATE,
|
|
221
|
+
record: record,
|
|
222
|
+
editable: true,
|
|
223
|
+
}, app);
|
|
224
|
+
gobj_start(form);
|
|
225
|
+
return {form, first};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/* The tables the form built since `first`: the alarms table first,
|
|
229
|
+
* then a triggers table per alarm row drawn. */
|
|
230
|
+
function built(first)
|
|
231
|
+
{
|
|
232
|
+
return tables.slice(first);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/* The microtasks of addRow().then(): the nested rows load there. */
|
|
236
|
+
async function settle()
|
|
237
|
+
{
|
|
238
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function close_asks(form)
|
|
242
|
+
{
|
|
243
|
+
const kw = {abort_close: false};
|
|
244
|
+
gobj_send_event(form, "EV_WINDOW_TO_CLOSE", kw, form);
|
|
245
|
+
return kw.abort_close ? (kw.warning || "(no warning)") : "";
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function errors()
|
|
249
|
+
{
|
|
250
|
+
return logged.filter(l => /error/i.test(l.level) || /ERROR/.test(l.msg));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
describe("a form that opens changed hears what the reader does to its tables", () => {
|
|
254
|
+
test("it opens changed, not edited, and closes without asking", async () => {
|
|
255
|
+
const {form, first} = build(LACKING);
|
|
256
|
+
await settle();
|
|
257
|
+
expect(gobj_read_attr(form, "changes")).toBe(true);
|
|
258
|
+
expect(gobj_read_attr(form, "edited")).toBe(false);
|
|
259
|
+
expect(built(first).length).toBe(2); // the alarms, and the triggers of its row
|
|
260
|
+
expect(close_asks(form)).toBe("");
|
|
261
|
+
expect(errors()).toEqual([]);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("an edited cell makes it ask", async () => {
|
|
265
|
+
const {form, first} = build(LACKING);
|
|
266
|
+
await settle();
|
|
267
|
+
built(first)[0].edit_cell();
|
|
268
|
+
expect(gobj_read_attr(form, "edited")).toBe(true);
|
|
269
|
+
expect(close_asks(form)).toBe("all changes will be lost");
|
|
270
|
+
expect(errors()).toEqual([]);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("a deleted row makes it ask", async () => {
|
|
274
|
+
const {form, first} = build(LACKING);
|
|
275
|
+
await settle();
|
|
276
|
+
built(first)[0].delete_row();
|
|
277
|
+
expect(close_asks(form)).toBe("all changes will be lost");
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("a row the reader adds makes it ask; the rows the form loads do not", async () => {
|
|
281
|
+
const {form, first} = build(LACKING);
|
|
282
|
+
await settle();
|
|
283
|
+
const alarms = built(first)[0];
|
|
284
|
+
expect(alarms.rows.length).toBe(1); // loaded: no question
|
|
285
|
+
expect(close_asks(form)).toBe("");
|
|
286
|
+
|
|
287
|
+
/* What the table's add button sends: the reader's row. */
|
|
288
|
+
const tables_before = tables.length;
|
|
289
|
+
gobj_send_event(form, "EV_ADD_TABLE_ROW", {
|
|
290
|
+
table_id: alarms.element.yui_table_id,
|
|
291
|
+
record: {},
|
|
292
|
+
loading: false
|
|
293
|
+
}, form);
|
|
294
|
+
expect(alarms.rows.length).toBe(2);
|
|
295
|
+
expect(tables.length).toBe(tables_before + 1); // the new row's triggers table
|
|
296
|
+
expect(gobj_read_attr(form, "edited")).toBe(true);
|
|
297
|
+
expect(close_asks(form)).toBe("all changes will be lost");
|
|
298
|
+
expect(errors()).toEqual([]);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("an edit of a NESTED table makes it ask, also one drawn after the load", async () => {
|
|
302
|
+
const loaded = build(LACKING);
|
|
303
|
+
await settle();
|
|
304
|
+
const triggers = built(loaded.first)[1];
|
|
305
|
+
expect(triggers.rows.length).toBe(1); // loaded in its row: no question
|
|
306
|
+
expect(close_asks(loaded.form)).toBe("");
|
|
307
|
+
triggers.edit_cell();
|
|
308
|
+
expect(close_asks(loaded.form)).toBe("all changes will be lost");
|
|
309
|
+
|
|
310
|
+
/* An undo draws the rows again, and each row builds its triggers
|
|
311
|
+
* table then: a table that did not exist when the form loaded. */
|
|
312
|
+
const before_undo = tables.length;
|
|
313
|
+
gobj_send_event(loaded.form, "EV_UNDO_RECORD", {}, loaded.form);
|
|
314
|
+
await settle();
|
|
315
|
+
expect(close_asks(loaded.form)).toBe("");
|
|
316
|
+
const redrawn = tables.slice(before_undo);
|
|
317
|
+
expect(redrawn.length).toBe(1);
|
|
318
|
+
expect(redrawn[0].rows.length).toBe(1);
|
|
319
|
+
redrawn[0].edit_cell();
|
|
320
|
+
expect(close_asks(loaded.form)).toBe("all changes will be lost");
|
|
321
|
+
expect(errors()).toEqual([]);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("a record loaded LATER that lacks a field (a reload, a refused save) still hears them", async () => {
|
|
325
|
+
const {form, first} = build(COMPLETE);
|
|
326
|
+
await settle();
|
|
327
|
+
expect(gobj_read_attr(form, "changes")).toBe(false);
|
|
328
|
+
gobj_send_event(form, "EV_LOAD_RECORD", LACKING, form);
|
|
329
|
+
await settle();
|
|
330
|
+
expect(gobj_read_attr(form, "changes")).toBe(true);
|
|
331
|
+
expect(gobj_read_attr(form, "edited")).toBe(false);
|
|
332
|
+
expect(close_asks(form)).toBe("");
|
|
333
|
+
built(first)[0].edit_cell();
|
|
334
|
+
expect(close_asks(form)).toBe("all changes will be lost");
|
|
335
|
+
expect(errors()).toEqual([]);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("a table built after the load (tableBuilt) takes its rows without a question", async () => {
|
|
339
|
+
const {form, first} = build(COMPLETE);
|
|
340
|
+
await settle();
|
|
341
|
+
const alarms = built(first)[0];
|
|
342
|
+
/* A table not built yet: no rows, and the ones it is given wait
|
|
343
|
+
* for its `tableBuilt`. */
|
|
344
|
+
alarms.clearData();
|
|
345
|
+
alarms.initialized = false;
|
|
346
|
+
gobj_send_event(form, "EV_LOAD_RECORD", LACKING, form);
|
|
347
|
+
await settle();
|
|
348
|
+
expect(alarms.rows.length).toBe(0);
|
|
349
|
+
/* Tabulator builds the table now, long after the load returned. */
|
|
350
|
+
alarms.initialized = true;
|
|
351
|
+
alarms.emit("tableBuilt");
|
|
352
|
+
await settle();
|
|
353
|
+
expect(alarms.rows.length).toBe(1);
|
|
354
|
+
expect(gobj_read_attr(form, "edited")).toBe(false);
|
|
355
|
+
expect(close_asks(form)).toBe("");
|
|
356
|
+
expect(errors()).toEqual([]);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("with a complete record it asks as before", async () => {
|
|
360
|
+
/* The waits are the 200 ms after which 7.26.40 wired the tables
|
|
361
|
+
* again: with them this case passes on the code before too. */
|
|
362
|
+
const wait = () => new Promise(resolve => setTimeout(resolve, 260));
|
|
363
|
+
const {form, first} = build(COMPLETE);
|
|
364
|
+
await wait();
|
|
365
|
+
expect(gobj_read_attr(form, "changes")).toBe(false);
|
|
366
|
+
built(first)[1].edit_cell();
|
|
367
|
+
expect(close_asks(form)).toBe("all changes will be lost");
|
|
368
|
+
gobj_send_event(form, "EV_SAVE_RECORD", {}, form);
|
|
369
|
+
expect(close_asks(form)).toBe("");
|
|
370
|
+
await wait();
|
|
371
|
+
built(first)[0].edit_cell();
|
|
372
|
+
expect(close_asks(form)).toBe("all changes will be lost");
|
|
373
|
+
expect(errors()).toEqual([]);
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
describe("the column titles of the tables change language", () => {
|
|
378
|
+
test("each table, nested ones too, is given its titles in the new language", async () => {
|
|
379
|
+
const {form, first} = build(COMPLETE);
|
|
380
|
+
await settle();
|
|
381
|
+
const [alarms, triggers] = built(first);
|
|
382
|
+
|
|
383
|
+
await i18next.changeLanguage("es");
|
|
384
|
+
gobj_send_event(form, "EV_LANGUAGE_CHANGED", {}, form);
|
|
385
|
+
|
|
386
|
+
expect(alarms.locale).toBeTruthy();
|
|
387
|
+
expect(alarms.installed[alarms.locale].columns).toEqual({
|
|
388
|
+
name: "Nombre de la alarma",
|
|
389
|
+
triggers: "triggers"
|
|
390
|
+
});
|
|
391
|
+
expect(triggers.installed[triggers.locale].columns).toEqual({measure: "Medida"});
|
|
392
|
+
/* No column was rebuilt to get there: the rows (and the nested
|
|
393
|
+
* table) are the ones loaded. */
|
|
394
|
+
expect(alarms.rows.length).toBe(1);
|
|
395
|
+
expect(triggers.rows.length).toBe(1);
|
|
396
|
+
expect(errors()).toEqual([]);
|
|
397
|
+
});
|
|
398
|
+
});
|
|
@@ -5,11 +5,13 @@
|
|
|
5
5
|
* a save or an undo -- which is what a dialog does: it closes the
|
|
6
6
|
* moment the record is saved.
|
|
7
7
|
*
|
|
8
|
-
* set_changed_stated(false) re-
|
|
9
|
-
* a 200 ms timer. Since 7.26.7 destroy_ui() destroys the widgets
|
|
10
|
-
* nulls `$table.tabulator`, and the timer, left running, called
|
|
11
|
-
* `.on()` on that null: an uncaught TypeError.
|
|
12
|
-
*
|
|
8
|
+
* set_changed_stated(false) used to re-wire the tables' `dataChanged`
|
|
9
|
+
* from a 200 ms timer. Since 7.26.7 destroy_ui() destroys the widgets
|
|
10
|
+
* and nulls `$table.tabulator`, and the timer, left running, called
|
|
11
|
+
* `.on()` on that null: an uncaught TypeError. Since 7.26.41 there is
|
|
12
|
+
* no timer: each table is wired once, when it is built, and stays
|
|
13
|
+
* wired (form_table_edits_ask.wiring.test.js says why). These cases
|
|
14
|
+
* keep guarding the destroy right after a save or an undo.
|
|
13
15
|
*
|
|
14
16
|
* Copyright (c) 2026, ArtGins.
|
|
15
17
|
* All Rights Reserved.
|
|
@@ -117,7 +119,7 @@ function build(rows)
|
|
|
117
119
|
}
|
|
118
120
|
|
|
119
121
|
describe("a form with a table, destroyed right after", () => {
|
|
120
|
-
test("an undo
|
|
122
|
+
test("an undo, then the destroy: nothing left behind throws", async () => {
|
|
121
123
|
const {form} = build();
|
|
122
124
|
expect(tables.length).toBeGreaterThan(0);
|
|
123
125
|
const errors = [];
|
|
@@ -135,7 +137,7 @@ describe("a form with a table, destroyed right after", () => {
|
|
|
135
137
|
expect(errors).toEqual([]);
|
|
136
138
|
});
|
|
137
139
|
|
|
138
|
-
test("a live form
|
|
140
|
+
test("a live form's table stays wired, once", async () => {
|
|
139
141
|
const {form} = build();
|
|
140
142
|
const table = tables[tables.length - 1];
|
|
141
143
|
gobj_send_event(form, "EV_UNDO_RECORD", {}, form);
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/***********************************************************************
|
|
2
|
+
* uplot_ink.test.js
|
|
3
|
+
*
|
|
4
|
+
* The axes of a chart carry the ink of the theme -- the Y axis
|
|
5
|
+
* too. uPlot always draws one (its setDefaults() makes axes[1]
|
|
6
|
+
* whether it was given or not, with its own black), and
|
|
7
|
+
* C_YUI_UPLOT inked only the axes it was handed: by default one,
|
|
8
|
+
* the X. In the dark scheme the values of every Y axis of the
|
|
9
|
+
* library were black on near-black (a user's report, the cards of
|
|
10
|
+
* estadodelaire).
|
|
11
|
+
*
|
|
12
|
+
* Copyright (c) 2026, ArtGins.
|
|
13
|
+
* All Rights Reserved.
|
|
14
|
+
***********************************************************************/
|
|
15
|
+
import {describe, test, expect, beforeAll, beforeEach, afterEach, vi} from "vitest";
|
|
16
|
+
import {install_dom_double} from "../test/dom_double.js";
|
|
17
|
+
|
|
18
|
+
install_dom_double();
|
|
19
|
+
|
|
20
|
+
/* What C_YUI_UPLOT hands to uPlot, kept by the stand-in. */
|
|
21
|
+
let given = null;
|
|
22
|
+
vi.mock("uplot", () => ({
|
|
23
|
+
default: class {
|
|
24
|
+
constructor(opts) {
|
|
25
|
+
given = opts;
|
|
26
|
+
this.series = [{}];
|
|
27
|
+
}
|
|
28
|
+
setSize() {}
|
|
29
|
+
addSeries() {}
|
|
30
|
+
redraw() {}
|
|
31
|
+
destroy() {}
|
|
32
|
+
}
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
const {
|
|
36
|
+
gobj_start_up, gobj_create_yuno, gobj_create_pure_child, gclass_create,
|
|
37
|
+
set_log_callback, SDATA_END,
|
|
38
|
+
} = await import("@yuneta/gobj-js");
|
|
39
|
+
const {register_c_yui_uplot} = await import("./c_yui_uplot.js");
|
|
40
|
+
const {
|
|
41
|
+
yui_uplot_axes, yui_uplot_axis_ink, yui_uplot_grid_ink,
|
|
42
|
+
} = await import("./yui_uplot_ink.js");
|
|
43
|
+
|
|
44
|
+
const logged = [];
|
|
45
|
+
let yuno = null;
|
|
46
|
+
|
|
47
|
+
beforeAll(() => {
|
|
48
|
+
gobj_start_up(null, null, null, null, null, null, null);
|
|
49
|
+
set_log_callback((level, msg) => {
|
|
50
|
+
logged.push({level: String(level), msg: String(msg)});
|
|
51
|
+
});
|
|
52
|
+
gclass_create("C_TEST_INK_HOST", [], [["ST_IDLE", []]], {}, 0, [SDATA_END()], {}, 0, 0, 0, 0);
|
|
53
|
+
register_c_yui_uplot();
|
|
54
|
+
yuno = gobj_create_yuno("ink_yuno", "C_TEST_INK_HOST", {});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
logged.length = 0;
|
|
59
|
+
given = null;
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
document.documentElement.removeAttribute("data-theme");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("yui_uplot_axes()", () => {
|
|
67
|
+
test("gives the X and the Y axes, the two uPlot draws", () => {
|
|
68
|
+
expect(yui_uplot_axes([{}]).length).toBe(2);
|
|
69
|
+
expect(yui_uplot_axes(undefined).length).toBe(2);
|
|
70
|
+
expect(yui_uplot_axes([]).length).toBe(2);
|
|
71
|
+
expect(yui_uplot_axes([{}, {}, {scale: "%"}]).length).toBe(3);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("inks each one with the theme, read when uPlot draws", () => {
|
|
75
|
+
const axes = yui_uplot_axes([{}]);
|
|
76
|
+
for(const a of axes) {
|
|
77
|
+
expect(a.stroke).toBe(yui_uplot_axis_ink);
|
|
78
|
+
expect(a.grid.stroke).toBe(yui_uplot_grid_ink);
|
|
79
|
+
expect(a.ticks.stroke).toBe(yui_uplot_grid_ink);
|
|
80
|
+
}
|
|
81
|
+
document.documentElement.setAttribute("data-theme", "light");
|
|
82
|
+
expect(axes[1].stroke()).toBe("#2e333d");
|
|
83
|
+
document.documentElement.setAttribute("data-theme", "dark");
|
|
84
|
+
expect(axes[1].stroke()).toBe("#c7cdd8");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("keeps what the host gave, and does not touch its objects", () => {
|
|
88
|
+
const mine = [{stroke: "red", grid: {show: false}}];
|
|
89
|
+
const axes = yui_uplot_axes(mine);
|
|
90
|
+
expect(axes[0].stroke).toBe("red");
|
|
91
|
+
expect(axes[0].grid).toEqual({stroke: yui_uplot_grid_ink, show: false});
|
|
92
|
+
expect(mine).toEqual([{stroke: "red", grid: {show: false}}]);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("C_YUI_UPLOT", () => {
|
|
97
|
+
test("hands uPlot a Y axis with the ink of the theme", () => {
|
|
98
|
+
gobj_create_pure_child("ink_chart", "C_YUI_UPLOT", {}, yuno);
|
|
99
|
+
expect(given).toBeTruthy();
|
|
100
|
+
expect(given.axes.length).toBe(2);
|
|
101
|
+
expect(given.axes[1].stroke).toBe(yui_uplot_axis_ink);
|
|
102
|
+
expect(given.axes[1].grid.stroke).toBe(yui_uplot_grid_ink);
|
|
103
|
+
expect(logged.filter((l) => /error/i.test(l.level))).toEqual([]);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -14,6 +14,12 @@
|
|
|
14
14
|
* new Tabulator($el, {..., ...yui_tabulator_lang(t)});
|
|
15
15
|
* yui_tabulator_relocalize(table, t); // on a language change
|
|
16
16
|
*
|
|
17
|
+
* A view whose COLUMN TITLES change language hands them too, in the
|
|
18
|
+
* new language, by field -- Tabulator binds each column's title to its
|
|
19
|
+
* locale, so they change with it and no column is rebuilt:
|
|
20
|
+
*
|
|
21
|
+
* yui_tabulator_relocalize(table, t, {device_id: t("device id")});
|
|
22
|
+
*
|
|
17
23
|
* Every key carries an English `defaultValue`, so an app that does not
|
|
18
24
|
* define it renders exactly what Tabulator rendered before — the
|
|
19
25
|
* keys are an OPPORTUNITY to translate, never a requirement.
|
|
@@ -236,7 +242,18 @@ function yui_tabulator_name_editor(column_def, name_of)
|
|
|
236
242
|
};
|
|
237
243
|
}
|
|
238
244
|
|
|
239
|
-
|
|
245
|
+
/***************************************************************
|
|
246
|
+
* yui_tabulator_relocalize() -- see "Put an EXISTING table in the
|
|
247
|
+
* current language" above.
|
|
248
|
+
*
|
|
249
|
+
* `titles`, optional: {field: title}, the titles of the columns in
|
|
250
|
+
* the new language, already translated. They go in the locale
|
|
251
|
+
* (`columns`), where Tabulator binds each column's title: setLocale()
|
|
252
|
+
* draws them again without rebuilding a column -- updateDefinition()
|
|
253
|
+
* re-renders every row, and a row whose formatter builds something (a
|
|
254
|
+
* nested table) builds it anew. A field left out keeps its title.
|
|
255
|
+
***************************************************************/
|
|
256
|
+
function yui_tabulator_relocalize(table, t, titles)
|
|
240
257
|
{
|
|
241
258
|
if(!table) {
|
|
242
259
|
return;
|
|
@@ -244,6 +261,9 @@ function yui_tabulator_relocalize(table, t)
|
|
|
244
261
|
try {
|
|
245
262
|
let name = next_lang_name();
|
|
246
263
|
let strings = tabulator_strings(t);
|
|
264
|
+
if(titles && typeof titles === "object") {
|
|
265
|
+
strings.columns = Object.assign({}, titles);
|
|
266
|
+
}
|
|
247
267
|
|
|
248
268
|
/* Tabulator DEEP-CLONES options.langs into its localize module when
|
|
249
269
|
* the table is built, and never looks at the option again: writing a
|