@schukai/monster 4.148.4 → 4.148.6
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/package.json +1 -1
- package/source/components/datatable/constants.mjs +18 -0
- package/source/components/datatable/datasource/rest.mjs +27 -3
- package/source/components/datatable/save-button.mjs +86 -43
- package/test/cases/components/datatable/datasource-rest-lifecycle.mjs +474 -0
- package/test/cases/components/form/control-bar.mjs +38 -5
package/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"author":"Volker Schukai","dependencies":{"@floating-ui/dom":"^1.7.6"},"description":"Monster is a simple library for creating fast, robust and lightweight websites.","homepage":"https://monsterjs.org/","keywords":["framework","web","dom","css","sass","mobile-first","app","front-end","templates","schukai","core","shopcloud","alvine","monster","buildmap","stack","observer","observable","uuid","node","nodelist","css-in-js","logger","log","theme"],"license":"AGPL 3.0","main":"source/monster.mjs","module":"source/monster.mjs","name":"@schukai/monster","repository":{"type":"git","url":"https://gitlab.schukai.com/oss/libraries/javascript/monster.git"},"type":"module","version":"4.148.
|
|
1
|
+
{"author":"Volker Schukai","dependencies":{"@floating-ui/dom":"^1.7.6"},"description":"Monster is a simple library for creating fast, robust and lightweight websites.","homepage":"https://monsterjs.org/","keywords":["framework","web","dom","css","sass","mobile-first","app","front-end","templates","schukai","core","shopcloud","alvine","monster","buildmap","stack","observer","observable","uuid","node","nodelist","css-in-js","logger","log","theme"],"license":"AGPL 3.0","main":"source/monster.mjs","module":"source/monster.mjs","name":"@schukai/monster","repository":{"type":"git","url":"https://gitlab.schukai.com/oss/libraries/javascript/monster.git"},"type":"module","version":"4.148.6"}
|
|
@@ -20,6 +20,22 @@ import { ATTRIBUTE_PREFIX } from "../../dom/constants.mjs";
|
|
|
20
20
|
*/
|
|
21
21
|
const STYLE_DISPLAY_MODE_BLOCK = "block";
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Datasource lifecycle operation for read requests.
|
|
25
|
+
*
|
|
26
|
+
* @since 4.148.5
|
|
27
|
+
* @type {string}
|
|
28
|
+
*/
|
|
29
|
+
const DATASOURCE_OPERATION_READ = "read";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Datasource lifecycle operation for write requests.
|
|
33
|
+
*
|
|
34
|
+
* @since 4.148.5
|
|
35
|
+
* @type {string}
|
|
36
|
+
*/
|
|
37
|
+
const DATASOURCE_OPERATION_WRITE = "write";
|
|
38
|
+
|
|
23
39
|
/**
|
|
24
40
|
* This attribute `data-monster-datasource` can be used to pass a datasource.
|
|
25
41
|
*
|
|
@@ -105,6 +121,8 @@ const ATTRIBUTE_DATATABLE_MODE_HIDDEN = "hidden";
|
|
|
105
121
|
const ATTRIBUTE_DATATABLE_MODE_VISIBLE = "visible";
|
|
106
122
|
|
|
107
123
|
export {
|
|
124
|
+
DATASOURCE_OPERATION_READ,
|
|
125
|
+
DATASOURCE_OPERATION_WRITE,
|
|
108
126
|
ATTRIBUTE_DATASOURCE,
|
|
109
127
|
ATTRIBUTE_DATASOURCE_SELECTOR,
|
|
110
128
|
ATTRIBUTE_DATASOURCE_ARGUMENTS,
|
|
@@ -31,6 +31,10 @@ import { Observer } from "../../../types/observer.mjs";
|
|
|
31
31
|
import { Pathfinder } from "../../../data/pathfinder.mjs";
|
|
32
32
|
import { fireCustomEvent } from "../../../dom/events.mjs";
|
|
33
33
|
import { isArray, isFunction, isObject, isString } from "../../../types/is.mjs";
|
|
34
|
+
import {
|
|
35
|
+
DATASOURCE_OPERATION_READ,
|
|
36
|
+
DATASOURCE_OPERATION_WRITE,
|
|
37
|
+
} from "../constants.mjs";
|
|
34
38
|
|
|
35
39
|
export { Rest };
|
|
36
40
|
|
|
@@ -318,6 +322,9 @@ class Rest extends Datasource {
|
|
|
318
322
|
* @fires monster-datasource-fetch
|
|
319
323
|
* @fires monster-datasource-fetched
|
|
320
324
|
* @fires monster-datasource-error
|
|
325
|
+
* @fires monster-datasource-validation
|
|
326
|
+
* @description Lifecycle event details contain `datasource` and
|
|
327
|
+
* `operation: "read"` for this request.
|
|
321
328
|
*/
|
|
322
329
|
read() {
|
|
323
330
|
const opt = clone(this.getOption("read"));
|
|
@@ -372,6 +379,7 @@ class Rest extends Datasource {
|
|
|
372
379
|
return new Promise((resolve, reject) => {
|
|
373
380
|
fireCustomEvent(this, "monster-datasource-fetch", {
|
|
374
381
|
datasource: this,
|
|
382
|
+
operation: DATASOURCE_OPERATION_READ,
|
|
375
383
|
});
|
|
376
384
|
|
|
377
385
|
queueMicrotask(() => {
|
|
@@ -381,15 +389,18 @@ class Rest extends Datasource {
|
|
|
381
389
|
if (this[readRequestIdSymbol] === requestId) {
|
|
382
390
|
fireCustomEvent(this, "monster-datasource-fetched", {
|
|
383
391
|
datasource: this,
|
|
392
|
+
operation: DATASOURCE_OPERATION_READ,
|
|
384
393
|
});
|
|
385
394
|
}
|
|
386
395
|
|
|
387
396
|
resolve(response);
|
|
388
397
|
})
|
|
389
398
|
.catch((error) => {
|
|
390
|
-
handleValidationError.call(this, error);
|
|
399
|
+
handleValidationError.call(this, error, DATASOURCE_OPERATION_READ);
|
|
391
400
|
if (this[readRequestIdSymbol] === requestId) {
|
|
392
401
|
fireCustomEvent(this, "monster-datasource-error", {
|
|
402
|
+
datasource: this,
|
|
403
|
+
operation: DATASOURCE_OPERATION_READ,
|
|
393
404
|
error: error,
|
|
394
405
|
});
|
|
395
406
|
|
|
@@ -404,6 +415,12 @@ class Rest extends Datasource {
|
|
|
404
415
|
/**
|
|
405
416
|
* Writes the data to the rest api.
|
|
406
417
|
* @return {Promise}
|
|
418
|
+
* @fires monster-datasource-fetch
|
|
419
|
+
* @fires monster-datasource-fetched
|
|
420
|
+
* @fires monster-datasource-error
|
|
421
|
+
* @fires monster-datasource-validation
|
|
422
|
+
* @description Lifecycle event details contain `datasource` and
|
|
423
|
+
* `operation: "write"` for this request.
|
|
407
424
|
*/
|
|
408
425
|
write() {
|
|
409
426
|
const opt = clone(this.getOption("write"));
|
|
@@ -435,6 +452,7 @@ class Rest extends Datasource {
|
|
|
435
452
|
return new Promise((resolve, reject) => {
|
|
436
453
|
fireCustomEvent(this, "monster-datasource-fetch", {
|
|
437
454
|
datasource: this,
|
|
455
|
+
operation: DATASOURCE_OPERATION_WRITE,
|
|
438
456
|
});
|
|
439
457
|
|
|
440
458
|
queueMicrotask(() => {
|
|
@@ -444,15 +462,18 @@ class Rest extends Datasource {
|
|
|
444
462
|
if (this[writeRequestIdSymbol] === requestId) {
|
|
445
463
|
fireCustomEvent(this, "monster-datasource-fetched", {
|
|
446
464
|
datasource: this,
|
|
465
|
+
operation: DATASOURCE_OPERATION_WRITE,
|
|
447
466
|
});
|
|
448
467
|
}
|
|
449
468
|
|
|
450
469
|
resolve(response);
|
|
451
470
|
})
|
|
452
471
|
.catch((error) => {
|
|
453
|
-
handleValidationError.call(this, error);
|
|
472
|
+
handleValidationError.call(this, error, DATASOURCE_OPERATION_WRITE);
|
|
454
473
|
if (this[writeRequestIdSymbol] === requestId) {
|
|
455
474
|
fireCustomEvent(this, "monster-datasource-error", {
|
|
475
|
+
datasource: this,
|
|
476
|
+
operation: DATASOURCE_OPERATION_WRITE,
|
|
456
477
|
error: error,
|
|
457
478
|
});
|
|
458
479
|
|
|
@@ -662,9 +683,10 @@ function initIntersectionObserver() {
|
|
|
662
683
|
/**
|
|
663
684
|
* @private
|
|
664
685
|
* @param {Error} error
|
|
686
|
+
* @param {string} operation
|
|
665
687
|
* @return {Promise<void>}
|
|
666
688
|
*/
|
|
667
|
-
function handleValidationError(error) {
|
|
689
|
+
function handleValidationError(error, operation) {
|
|
668
690
|
const path = this.getOption("response.path.validationErrors");
|
|
669
691
|
if (!path) {
|
|
670
692
|
return Promise.resolve();
|
|
@@ -723,6 +745,8 @@ function handleValidationError(error) {
|
|
|
723
745
|
new CustomEvent("monster-datasource-validation", {
|
|
724
746
|
bubbles: true,
|
|
725
747
|
detail: {
|
|
748
|
+
datasource: this,
|
|
749
|
+
operation,
|
|
726
750
|
errors: normalizedErrors,
|
|
727
751
|
raw: rawErrors,
|
|
728
752
|
message,
|
|
@@ -37,7 +37,10 @@ import { Observer } from "../../types/observer.mjs";
|
|
|
37
37
|
import { TokenList } from "../../types/tokenlist.mjs";
|
|
38
38
|
import { clone } from "../../util/clone.mjs";
|
|
39
39
|
import { State } from "../form/types/state.mjs";
|
|
40
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
ATTRIBUTE_DATASOURCE_SELECTOR,
|
|
42
|
+
DATASOURCE_OPERATION_WRITE,
|
|
43
|
+
} from "./constants.mjs";
|
|
41
44
|
import { Datasource } from "./datasource.mjs";
|
|
42
45
|
import { Rest as RestDatasource } from "./datasource/rest.mjs";
|
|
43
46
|
import { BadgeStyleSheet } from "../stylesheet/badge.mjs";
|
|
@@ -72,8 +75,9 @@ const originValuesSymbol = Symbol.for(
|
|
|
72
75
|
*/
|
|
73
76
|
const badgeElementSymbol = Symbol("badgeElement");
|
|
74
77
|
const saveInFlightSymbol = Symbol("saveInFlight");
|
|
75
|
-
const pendingResetSymbol = Symbol("pendingReset");
|
|
76
78
|
const fetchInFlightSymbol = Symbol("fetchInFlight");
|
|
79
|
+
const readRevisionSymbol = Symbol("readRevision");
|
|
80
|
+
const pendingWriteSymbol = Symbol("pendingWrite");
|
|
77
81
|
const originInitializedSymbol = Symbol("originInitialized");
|
|
78
82
|
const internalDisableTimeoutSymbol = Symbol("internalDisableTimeout");
|
|
79
83
|
const internalDisableVersionSymbol = Symbol("internalDisableVersion");
|
|
@@ -203,25 +207,84 @@ class SaveButton extends CustomElement {
|
|
|
203
207
|
}
|
|
204
208
|
|
|
205
209
|
if (element instanceof RestDatasource) {
|
|
206
|
-
element.addEventListener(
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
210
|
+
element.addEventListener(
|
|
211
|
+
"monster-datasource-fetch",
|
|
212
|
+
(event) => {
|
|
213
|
+
if (
|
|
214
|
+
event?.detail?.operation ===
|
|
215
|
+
DATASOURCE_OPERATION_WRITE
|
|
216
|
+
) {
|
|
217
|
+
self[pendingWriteSymbol] = {
|
|
218
|
+
data: clone(
|
|
219
|
+
self[datasourceLinkedElementSymbol].data,
|
|
220
|
+
),
|
|
221
|
+
readRevision: self[readRevisionSymbol] || 0,
|
|
222
|
+
};
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
self[fetchInFlightSymbol] = true;
|
|
226
|
+
clearOriginValues.call(self);
|
|
227
|
+
},
|
|
228
|
+
);
|
|
229
|
+
element.addEventListener(
|
|
230
|
+
"monster-datasource-fetched",
|
|
231
|
+
(event) => {
|
|
232
|
+
if (
|
|
233
|
+
event?.detail?.operation ===
|
|
234
|
+
DATASOURCE_OPERATION_WRITE
|
|
235
|
+
) {
|
|
236
|
+
const pendingWrite = self[pendingWriteSymbol];
|
|
237
|
+
if (self[fetchInFlightSymbol] === true) {
|
|
238
|
+
if (pendingWrite) {
|
|
239
|
+
pendingWrite.completed = true;
|
|
240
|
+
}
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
self[pendingWriteSymbol] = null;
|
|
244
|
+
if (
|
|
245
|
+
pendingWrite &&
|
|
246
|
+
pendingWrite.readRevision ===
|
|
247
|
+
(self[readRevisionSymbol] || 0)
|
|
248
|
+
) {
|
|
249
|
+
setOriginValues.call(self, pendingWrite.data);
|
|
250
|
+
updateChangesState.call(self);
|
|
251
|
+
}
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
self[fetchInFlightSymbol] = false;
|
|
255
|
+
self[readRevisionSymbol] =
|
|
256
|
+
(self[readRevisionSymbol] || 0) + 1;
|
|
257
|
+
self[pendingWriteSymbol] = null;
|
|
258
|
+
setOriginValues.call(
|
|
259
|
+
self,
|
|
260
|
+
clone(self[datasourceLinkedElementSymbol].data),
|
|
261
|
+
);
|
|
262
|
+
updateChangesState.call(self);
|
|
263
|
+
},
|
|
264
|
+
);
|
|
265
|
+
element.addEventListener(
|
|
266
|
+
"monster-datasource-error",
|
|
267
|
+
(event) => {
|
|
268
|
+
if (
|
|
269
|
+
event?.detail?.operation ===
|
|
270
|
+
DATASOURCE_OPERATION_WRITE
|
|
271
|
+
) {
|
|
272
|
+
self[pendingWriteSymbol] = null;
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
self[fetchInFlightSymbol] = false;
|
|
276
|
+
const pendingWrite = self[pendingWriteSymbol];
|
|
277
|
+
if (
|
|
278
|
+
pendingWrite?.completed === true &&
|
|
279
|
+
pendingWrite.readRevision ===
|
|
280
|
+
(self[readRevisionSymbol] || 0)
|
|
281
|
+
) {
|
|
282
|
+
self[pendingWriteSymbol] = null;
|
|
283
|
+
setOriginValues.call(self, pendingWrite.data);
|
|
284
|
+
updateChangesState.call(self);
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
);
|
|
225
288
|
}
|
|
226
289
|
|
|
227
290
|
this[datasourceLinkedElementSymbol] = element;
|
|
@@ -416,35 +479,15 @@ function initEventHandler() {
|
|
|
416
479
|
.call(this)
|
|
417
480
|
.then(() => this[datasourceLinkedElementSymbol].write())
|
|
418
481
|
.then(() => {
|
|
419
|
-
|
|
420
|
-
setOriginValues.call(
|
|
421
|
-
this,
|
|
422
|
-
clone(this[datasourceLinkedElementSymbol].data),
|
|
423
|
-
);
|
|
424
|
-
this[stateButtonElementSymbol].removeState();
|
|
425
|
-
this[stateButtonElementSymbol].setOption(
|
|
426
|
-
"disabled",
|
|
427
|
-
shouldDisableWhenNoChanges.call(this),
|
|
428
|
-
);
|
|
429
|
-
this.setOption("changes", "");
|
|
430
|
-
this.setOption(
|
|
431
|
-
"classes.badge",
|
|
432
|
-
new TokenList(this.getOption("classes.badge"))
|
|
433
|
-
.add("hidden")
|
|
434
|
-
.toString(),
|
|
435
|
-
);
|
|
482
|
+
updateChangesState.call(this);
|
|
436
483
|
})
|
|
437
484
|
.catch((error) => {
|
|
438
485
|
addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, error.toString());
|
|
439
|
-
|
|
486
|
+
updateChangesState.call(this);
|
|
440
487
|
})
|
|
441
488
|
.finally(() => {
|
|
442
489
|
this[saveInFlightSymbol] = false;
|
|
443
490
|
clearInternalDisableTimeout.call(this);
|
|
444
|
-
if (this[pendingResetSymbol]) {
|
|
445
|
-
this[pendingResetSymbol] = false;
|
|
446
|
-
clearOriginValues.call(this);
|
|
447
|
-
}
|
|
448
491
|
});
|
|
449
492
|
});
|
|
450
493
|
});
|
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
import * as chai from "chai";
|
|
2
|
+
import { chaiDom } from "../../../util/chai-dom.mjs";
|
|
3
|
+
import { initJSDOM } from "../../../util/jsdom.mjs";
|
|
4
|
+
|
|
5
|
+
const expect = chai.expect;
|
|
6
|
+
chai.use(chaiDom);
|
|
7
|
+
|
|
8
|
+
let DataFetchError;
|
|
9
|
+
|
|
10
|
+
const waitForUpdates = () => new Promise((resolve) => setTimeout(resolve, 30));
|
|
11
|
+
|
|
12
|
+
function createDatasource(id) {
|
|
13
|
+
const datasource = document.createElement("monster-datasource-rest");
|
|
14
|
+
datasource.id = id;
|
|
15
|
+
datasource.setOption("features.autoInit", false);
|
|
16
|
+
datasource.setOption("read.url", "/records/1");
|
|
17
|
+
datasource.setOption("write.url", "/records/1");
|
|
18
|
+
return datasource;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function listenForLifecycle(datasource) {
|
|
22
|
+
const events = [];
|
|
23
|
+
for (const type of [
|
|
24
|
+
"monster-datasource-fetch",
|
|
25
|
+
"monster-datasource-fetched",
|
|
26
|
+
"monster-datasource-error",
|
|
27
|
+
"monster-datasource-validation",
|
|
28
|
+
]) {
|
|
29
|
+
datasource.addEventListener(type, (event) => {
|
|
30
|
+
events.push({ type, detail: event.detail });
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return events;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function createSaveButton(datasourceSelector) {
|
|
37
|
+
const button = document.createElement("monster-datasource-save-button");
|
|
38
|
+
button.setOption("datasource.selector", datasourceSelector);
|
|
39
|
+
button.setOption("disableWhenNoChanges", true);
|
|
40
|
+
return button;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function getStateButton(button) {
|
|
44
|
+
return button.shadowRoot?.querySelector("monster-state-button");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function loadDatasource(datasource, data) {
|
|
48
|
+
datasource.datasource.read = () => {
|
|
49
|
+
datasource.data = data;
|
|
50
|
+
return Promise.resolve({ ok: true });
|
|
51
|
+
};
|
|
52
|
+
await datasource.read();
|
|
53
|
+
await waitForUpdates();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function clickSaveButton(button) {
|
|
57
|
+
const action = getStateButton(button)?.getOption("actions.click");
|
|
58
|
+
expect(action).to.be.a("function");
|
|
59
|
+
action();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function deferred() {
|
|
63
|
+
let resolve;
|
|
64
|
+
let reject;
|
|
65
|
+
const promise = new Promise((promiseResolve, promiseReject) => {
|
|
66
|
+
resolve = promiseResolve;
|
|
67
|
+
reject = promiseReject;
|
|
68
|
+
});
|
|
69
|
+
return { promise, reject, resolve };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function expectPendingChanges(button) {
|
|
73
|
+
expect(Number(button.getOption("changes"))).to.be.greaterThan(0);
|
|
74
|
+
expect(getStateButton(button)?.getOption("disabled")).to.equal(false);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function expectNoPendingChanges(button) {
|
|
78
|
+
expect(button.getOption("changes")).to.equal("");
|
|
79
|
+
expect(getStateButton(button)?.getOption("disabled")).to.equal(true);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe("REST datasource lifecycle", function () {
|
|
83
|
+
before(async function () {
|
|
84
|
+
await initJSDOM();
|
|
85
|
+
globalThis.location = window.location;
|
|
86
|
+
await import("element-internals-polyfill").catch(() => {});
|
|
87
|
+
await import("../../../../source/components/datatable/datasource/rest.mjs");
|
|
88
|
+
await import("../../../../source/components/datatable/save-button.mjs");
|
|
89
|
+
({ DataFetchError } = await import(
|
|
90
|
+
"../../../../source/data/datasource/server/restapi/data-fetch-error.mjs"
|
|
91
|
+
));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
beforeEach(() => {
|
|
95
|
+
document.getElementById("mocks").innerHTML = "";
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
afterEach(() => {
|
|
99
|
+
document.getElementById("mocks").innerHTML = "";
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("identifies read and write lifecycle events without changing event names", async function () {
|
|
103
|
+
const datasource = createDatasource("lifecycle-datasource");
|
|
104
|
+
const events = listenForLifecycle(datasource);
|
|
105
|
+
document.getElementById("mocks").appendChild(datasource);
|
|
106
|
+
|
|
107
|
+
datasource.datasource.read = () => Promise.resolve({ ok: true });
|
|
108
|
+
await datasource.read();
|
|
109
|
+
|
|
110
|
+
datasource.datasource.write = () => Promise.resolve({ ok: true });
|
|
111
|
+
await datasource.write();
|
|
112
|
+
|
|
113
|
+
expect(
|
|
114
|
+
events.map(({ type, detail }) => [type, detail.operation]),
|
|
115
|
+
).to.deep.equal([
|
|
116
|
+
["monster-datasource-fetch", "read"],
|
|
117
|
+
["monster-datasource-fetched", "read"],
|
|
118
|
+
["monster-datasource-fetch", "write"],
|
|
119
|
+
["monster-datasource-fetched", "write"],
|
|
120
|
+
]);
|
|
121
|
+
events.forEach(({ detail }) => {
|
|
122
|
+
expect(detail.datasource).to.equal(datasource);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("identifies write validation and error events", async function () {
|
|
127
|
+
const datasource = createDatasource("validation-datasource");
|
|
128
|
+
datasource.setOption(
|
|
129
|
+
"response.path.validationErrors",
|
|
130
|
+
"meta.validationErrors",
|
|
131
|
+
);
|
|
132
|
+
datasource.setOption("response.path.validationMessage", "meta.message");
|
|
133
|
+
const events = listenForLifecycle(datasource);
|
|
134
|
+
document.getElementById("mocks").appendChild(datasource);
|
|
135
|
+
|
|
136
|
+
const response = new Response(
|
|
137
|
+
JSON.stringify({
|
|
138
|
+
meta: {
|
|
139
|
+
message: "Input data is invalid",
|
|
140
|
+
validationErrors: {
|
|
141
|
+
parentId: { code: "VAL_INVALID", message: "Invalid parent" },
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
}),
|
|
145
|
+
{ status: 400, headers: { "content-type": "application/json" } },
|
|
146
|
+
);
|
|
147
|
+
datasource.datasource.write = () =>
|
|
148
|
+
Promise.reject(new DataFetchError("Input data is invalid", response));
|
|
149
|
+
|
|
150
|
+
await datasource.write().catch(() => {});
|
|
151
|
+
await waitForUpdates();
|
|
152
|
+
|
|
153
|
+
const validation = events.find(
|
|
154
|
+
({ type }) => type === "monster-datasource-validation",
|
|
155
|
+
);
|
|
156
|
+
const error = events.find(
|
|
157
|
+
({ type }) => type === "monster-datasource-error",
|
|
158
|
+
);
|
|
159
|
+
expect(validation?.detail.operation).to.equal("write");
|
|
160
|
+
expect(validation?.detail.datasource).to.equal(datasource);
|
|
161
|
+
expect(validation?.detail.errors.parentId).to.deep.equal({
|
|
162
|
+
code: "VAL_INVALID",
|
|
163
|
+
message: "Invalid parent",
|
|
164
|
+
});
|
|
165
|
+
expect(error?.detail.operation).to.equal("write");
|
|
166
|
+
expect(error?.detail.datasource).to.equal(datasource);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("keeps every save button on the loaded baseline after a rejected write", async function () {
|
|
170
|
+
const datasource = createDatasource("rejected-write-datasource");
|
|
171
|
+
const loaded = {
|
|
172
|
+
data: [{ id: "area-1", name: "Inbound", parentAreaId: "" }],
|
|
173
|
+
};
|
|
174
|
+
const invalid = {
|
|
175
|
+
data: [{ id: "area-1", name: "Inbound changed", parentAreaId: "area-1" }],
|
|
176
|
+
};
|
|
177
|
+
const corrected = {
|
|
178
|
+
data: [{ id: "area-1", name: "Inbound changed", parentAreaId: "" }],
|
|
179
|
+
};
|
|
180
|
+
const first = createSaveButton("#rejected-write-datasource");
|
|
181
|
+
const second = createSaveButton("#rejected-write-datasource");
|
|
182
|
+
const mocks = document.getElementById("mocks");
|
|
183
|
+
mocks.append(datasource, first, second);
|
|
184
|
+
await loadDatasource(datasource, loaded);
|
|
185
|
+
expectNoPendingChanges(first);
|
|
186
|
+
expectNoPendingChanges(second);
|
|
187
|
+
|
|
188
|
+
datasource.data = invalid;
|
|
189
|
+
await waitForUpdates();
|
|
190
|
+
expectPendingChanges(first);
|
|
191
|
+
expectPendingChanges(second);
|
|
192
|
+
|
|
193
|
+
datasource.datasource.write = () =>
|
|
194
|
+
Promise.reject(new Error("Rejected write"));
|
|
195
|
+
clickSaveButton(first);
|
|
196
|
+
await waitForUpdates();
|
|
197
|
+
expectPendingChanges(first);
|
|
198
|
+
expectPendingChanges(second);
|
|
199
|
+
|
|
200
|
+
datasource.data = corrected;
|
|
201
|
+
await waitForUpdates();
|
|
202
|
+
expectPendingChanges(first);
|
|
203
|
+
expectPendingChanges(second);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("establishes the current data as every save button baseline after a successful write", async function () {
|
|
207
|
+
const datasource = createDatasource("successful-write-datasource");
|
|
208
|
+
const loaded = { data: [{ id: "area-1", name: "Inbound" }] };
|
|
209
|
+
const edited = { data: [{ id: "area-1", name: "Inbound changed" }] };
|
|
210
|
+
const first = createSaveButton("#successful-write-datasource");
|
|
211
|
+
const second = createSaveButton("#successful-write-datasource");
|
|
212
|
+
const mocks = document.getElementById("mocks");
|
|
213
|
+
mocks.append(datasource, first, second);
|
|
214
|
+
await loadDatasource(datasource, loaded);
|
|
215
|
+
datasource.data = edited;
|
|
216
|
+
await waitForUpdates();
|
|
217
|
+
expectPendingChanges(first);
|
|
218
|
+
expectPendingChanges(second);
|
|
219
|
+
|
|
220
|
+
datasource.datasource.write = () => Promise.resolve({ ok: true });
|
|
221
|
+
clickSaveButton(first);
|
|
222
|
+
await waitForUpdates();
|
|
223
|
+
|
|
224
|
+
expectNoPendingChanges(first);
|
|
225
|
+
expectNoPendingChanges(second);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("keeps edits made during a successful write pending", async function () {
|
|
229
|
+
const datasource = createDatasource("write-snapshot-datasource");
|
|
230
|
+
const loaded = { data: [{ id: "area-1", name: "Inbound" }] };
|
|
231
|
+
const written = { data: [{ id: "area-1", name: "Written change" }] };
|
|
232
|
+
const editedDuringWrite = {
|
|
233
|
+
data: [{ id: "area-1", name: "Edited during write" }],
|
|
234
|
+
};
|
|
235
|
+
const button = createSaveButton("#write-snapshot-datasource");
|
|
236
|
+
document.getElementById("mocks").append(datasource, button);
|
|
237
|
+
await loadDatasource(datasource, loaded);
|
|
238
|
+
|
|
239
|
+
datasource.data = written;
|
|
240
|
+
await waitForUpdates();
|
|
241
|
+
expectPendingChanges(button);
|
|
242
|
+
|
|
243
|
+
const writeResult = deferred();
|
|
244
|
+
datasource.datasource.write = () => writeResult.promise;
|
|
245
|
+
clickSaveButton(button);
|
|
246
|
+
await waitForUpdates();
|
|
247
|
+
|
|
248
|
+
datasource.data = editedDuringWrite;
|
|
249
|
+
await waitForUpdates();
|
|
250
|
+
writeResult.resolve({ ok: true });
|
|
251
|
+
await waitForUpdates();
|
|
252
|
+
|
|
253
|
+
expectPendingChanges(button);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("replaces every save button baseline after a read", async function () {
|
|
257
|
+
const datasource = createDatasource("read-baseline-datasource");
|
|
258
|
+
const loaded = { data: [{ id: "area-1", name: "Inbound" }] };
|
|
259
|
+
const edited = { data: [{ id: "area-1", name: "Inbound changed" }] };
|
|
260
|
+
const refreshed = { data: [{ id: "area-1", name: "Inbound from server" }] };
|
|
261
|
+
const first = createSaveButton("#read-baseline-datasource");
|
|
262
|
+
const second = createSaveButton("#read-baseline-datasource");
|
|
263
|
+
const mocks = document.getElementById("mocks");
|
|
264
|
+
mocks.append(datasource, first, second);
|
|
265
|
+
await loadDatasource(datasource, loaded);
|
|
266
|
+
datasource.data = edited;
|
|
267
|
+
await waitForUpdates();
|
|
268
|
+
expectPendingChanges(first);
|
|
269
|
+
expectPendingChanges(second);
|
|
270
|
+
|
|
271
|
+
datasource.datasource.read = () => {
|
|
272
|
+
datasource.data = refreshed;
|
|
273
|
+
return Promise.resolve({ ok: true });
|
|
274
|
+
};
|
|
275
|
+
await datasource.read();
|
|
276
|
+
await waitForUpdates();
|
|
277
|
+
|
|
278
|
+
expectNoPendingChanges(first);
|
|
279
|
+
expectNoPendingChanges(second);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("does not let write completion end an in-flight read", async function () {
|
|
283
|
+
const datasource = createDatasource("concurrent-datasource");
|
|
284
|
+
const loaded = { data: [{ id: "area-1", name: "Inbound" }] };
|
|
285
|
+
const firstReadValue = {
|
|
286
|
+
data: [{ id: "area-1", name: "Inbound from server" }],
|
|
287
|
+
};
|
|
288
|
+
const finalReadValue = {
|
|
289
|
+
data: [{ id: "area-1", name: "Inbound from server (final)" }],
|
|
290
|
+
};
|
|
291
|
+
const first = createSaveButton("#concurrent-datasource");
|
|
292
|
+
const second = createSaveButton("#concurrent-datasource");
|
|
293
|
+
const mocks = document.getElementById("mocks");
|
|
294
|
+
mocks.append(datasource, first, second);
|
|
295
|
+
await loadDatasource(datasource, loaded);
|
|
296
|
+
expectNoPendingChanges(first);
|
|
297
|
+
expectNoPendingChanges(second);
|
|
298
|
+
|
|
299
|
+
let resolveRead;
|
|
300
|
+
datasource.datasource.read = () =>
|
|
301
|
+
new Promise((resolve) => {
|
|
302
|
+
resolveRead = resolve;
|
|
303
|
+
});
|
|
304
|
+
const read = datasource.read();
|
|
305
|
+
await waitForUpdates();
|
|
306
|
+
expectNoPendingChanges(first);
|
|
307
|
+
expectNoPendingChanges(second);
|
|
308
|
+
|
|
309
|
+
datasource.datasource.write = () => Promise.resolve({ ok: true });
|
|
310
|
+
await datasource.write();
|
|
311
|
+
expectNoPendingChanges(first);
|
|
312
|
+
expectNoPendingChanges(second);
|
|
313
|
+
|
|
314
|
+
datasource.data = firstReadValue;
|
|
315
|
+
await waitForUpdates();
|
|
316
|
+
expectNoPendingChanges(first);
|
|
317
|
+
expectNoPendingChanges(second);
|
|
318
|
+
|
|
319
|
+
datasource.datasource.write = () =>
|
|
320
|
+
Promise.reject(new Error("Rejected concurrent write"));
|
|
321
|
+
await datasource.write().catch(() => {});
|
|
322
|
+
expectNoPendingChanges(first);
|
|
323
|
+
expectNoPendingChanges(second);
|
|
324
|
+
|
|
325
|
+
datasource.data = finalReadValue;
|
|
326
|
+
await waitForUpdates();
|
|
327
|
+
|
|
328
|
+
expectNoPendingChanges(first);
|
|
329
|
+
expectNoPendingChanges(second);
|
|
330
|
+
|
|
331
|
+
resolveRead({ ok: true });
|
|
332
|
+
await read;
|
|
333
|
+
await waitForUpdates();
|
|
334
|
+
expectNoPendingChanges(first);
|
|
335
|
+
expectNoPendingChanges(second);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("preserves a read baseline when the save write finishes later", async function () {
|
|
339
|
+
const datasource = createDatasource("read-first-datasource");
|
|
340
|
+
const loaded = { data: [{ id: "area-1", name: "Inbound" }] };
|
|
341
|
+
const written = { data: [{ id: "area-1", name: "Written change" }] };
|
|
342
|
+
const refreshed = { data: [{ id: "area-1", name: "Server refresh" }] };
|
|
343
|
+
const editedAfterRead = {
|
|
344
|
+
data: [{ id: "area-1", name: "Edited after refresh" }],
|
|
345
|
+
};
|
|
346
|
+
const button = createSaveButton("#read-first-datasource");
|
|
347
|
+
document.getElementById("mocks").append(datasource, button);
|
|
348
|
+
await loadDatasource(datasource, loaded);
|
|
349
|
+
|
|
350
|
+
datasource.data = written;
|
|
351
|
+
await waitForUpdates();
|
|
352
|
+
expectPendingChanges(button);
|
|
353
|
+
|
|
354
|
+
const writeResult = deferred();
|
|
355
|
+
datasource.datasource.write = () => writeResult.promise;
|
|
356
|
+
clickSaveButton(button);
|
|
357
|
+
await waitForUpdates();
|
|
358
|
+
|
|
359
|
+
const readResult = deferred();
|
|
360
|
+
datasource.datasource.read = () => readResult.promise;
|
|
361
|
+
const read = datasource.read();
|
|
362
|
+
await waitForUpdates();
|
|
363
|
+
datasource.data = refreshed;
|
|
364
|
+
readResult.resolve({ ok: true });
|
|
365
|
+
await read;
|
|
366
|
+
await waitForUpdates();
|
|
367
|
+
expectNoPendingChanges(button);
|
|
368
|
+
|
|
369
|
+
datasource.data = editedAfterRead;
|
|
370
|
+
await waitForUpdates();
|
|
371
|
+
expectPendingChanges(button);
|
|
372
|
+
|
|
373
|
+
writeResult.resolve({ ok: true });
|
|
374
|
+
await waitForUpdates();
|
|
375
|
+
expectPendingChanges(button);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("establishes the read baseline when the save write finishes first", async function () {
|
|
379
|
+
const datasource = createDatasource("write-first-datasource");
|
|
380
|
+
const loaded = { data: [{ id: "area-1", name: "Inbound" }] };
|
|
381
|
+
const written = { data: [{ id: "area-1", name: "Written change" }] };
|
|
382
|
+
const refreshed = { data: [{ id: "area-1", name: "Server refresh" }] };
|
|
383
|
+
const editedAfterRead = {
|
|
384
|
+
data: [{ id: "area-1", name: "Edited after refresh" }],
|
|
385
|
+
};
|
|
386
|
+
const button = createSaveButton("#write-first-datasource");
|
|
387
|
+
document.getElementById("mocks").append(datasource, button);
|
|
388
|
+
await loadDatasource(datasource, loaded);
|
|
389
|
+
|
|
390
|
+
datasource.data = written;
|
|
391
|
+
await waitForUpdates();
|
|
392
|
+
expectPendingChanges(button);
|
|
393
|
+
|
|
394
|
+
const writeResult = deferred();
|
|
395
|
+
datasource.datasource.write = () => writeResult.promise;
|
|
396
|
+
clickSaveButton(button);
|
|
397
|
+
await waitForUpdates();
|
|
398
|
+
|
|
399
|
+
const readResult = deferred();
|
|
400
|
+
datasource.datasource.read = () => readResult.promise;
|
|
401
|
+
const read = datasource.read();
|
|
402
|
+
await waitForUpdates();
|
|
403
|
+
writeResult.resolve({ ok: true });
|
|
404
|
+
await waitForUpdates();
|
|
405
|
+
expectNoPendingChanges(button);
|
|
406
|
+
|
|
407
|
+
datasource.data = refreshed;
|
|
408
|
+
readResult.resolve({ ok: true });
|
|
409
|
+
await read;
|
|
410
|
+
await waitForUpdates();
|
|
411
|
+
expectNoPendingChanges(button);
|
|
412
|
+
|
|
413
|
+
datasource.data = editedAfterRead;
|
|
414
|
+
await waitForUpdates();
|
|
415
|
+
expectPendingChanges(button);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it("falls back to the written snapshot when a concurrent read fails", async function () {
|
|
419
|
+
const datasource = createDatasource("failed-read-datasource");
|
|
420
|
+
const loaded = { data: [{ id: "area-1", name: "Inbound" }] };
|
|
421
|
+
const written = { data: [{ id: "area-1", name: "Written change" }] };
|
|
422
|
+
const editedAfterFailure = {
|
|
423
|
+
data: [{ id: "area-1", name: "Edited after failed read" }],
|
|
424
|
+
};
|
|
425
|
+
const button = createSaveButton("#failed-read-datasource");
|
|
426
|
+
document.getElementById("mocks").append(datasource, button);
|
|
427
|
+
await loadDatasource(datasource, loaded);
|
|
428
|
+
|
|
429
|
+
datasource.data = written;
|
|
430
|
+
await waitForUpdates();
|
|
431
|
+
const writeResult = deferred();
|
|
432
|
+
datasource.datasource.write = () => writeResult.promise;
|
|
433
|
+
clickSaveButton(button);
|
|
434
|
+
await waitForUpdates();
|
|
435
|
+
|
|
436
|
+
const readResult = deferred();
|
|
437
|
+
datasource.datasource.read = () => readResult.promise;
|
|
438
|
+
const read = datasource.read().catch(() => {});
|
|
439
|
+
await waitForUpdates();
|
|
440
|
+
writeResult.resolve({ ok: true });
|
|
441
|
+
await waitForUpdates();
|
|
442
|
+
readResult.reject(new Error("Rejected concurrent read"));
|
|
443
|
+
await read;
|
|
444
|
+
await waitForUpdates();
|
|
445
|
+
expectNoPendingChanges(button);
|
|
446
|
+
|
|
447
|
+
datasource.data = editedAfterFailure;
|
|
448
|
+
await waitForUpdates();
|
|
449
|
+
expectPendingChanges(button);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it("treats lifecycle events without an operation as read events", async function () {
|
|
453
|
+
const datasource = createDatasource("legacy-lifecycle-datasource");
|
|
454
|
+
const loaded = { data: [{ id: "area-1", name: "Inbound" }] };
|
|
455
|
+
const refreshed = { data: [{ id: "area-1", name: "Server refresh" }] };
|
|
456
|
+
const edited = { data: [{ id: "area-1", name: "Edited refresh" }] };
|
|
457
|
+
const button = createSaveButton("#legacy-lifecycle-datasource");
|
|
458
|
+
document.getElementById("mocks").append(datasource, button);
|
|
459
|
+
await loadDatasource(datasource, loaded);
|
|
460
|
+
|
|
461
|
+
datasource.dispatchEvent(new CustomEvent("monster-datasource-fetch"));
|
|
462
|
+
datasource.data = refreshed;
|
|
463
|
+
await waitForUpdates();
|
|
464
|
+
expectNoPendingChanges(button);
|
|
465
|
+
|
|
466
|
+
datasource.dispatchEvent(new CustomEvent("monster-datasource-fetched"));
|
|
467
|
+
await waitForUpdates();
|
|
468
|
+
expectNoPendingChanges(button);
|
|
469
|
+
|
|
470
|
+
datasource.data = edited;
|
|
471
|
+
await waitForUpdates();
|
|
472
|
+
expectPendingChanges(button);
|
|
473
|
+
});
|
|
474
|
+
});
|
|
@@ -9,6 +9,29 @@ chai.use(chaiDom);
|
|
|
9
9
|
const waitForLayout = (timeout = 120) =>
|
|
10
10
|
new Promise((resolve) => setTimeout(resolve, timeout));
|
|
11
11
|
|
|
12
|
+
function waitForCondition(check, { timeout = 4000, interval = 25 } = {}) {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
const start = Date.now();
|
|
15
|
+
const poll = () => {
|
|
16
|
+
try {
|
|
17
|
+
if (check()) {
|
|
18
|
+
resolve();
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
} catch {}
|
|
22
|
+
|
|
23
|
+
if (Date.now() - start >= timeout) {
|
|
24
|
+
reject(new Error("Timed out waiting for condition"));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
setTimeout(poll, interval);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
poll();
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
12
35
|
const html = `
|
|
13
36
|
<div id="test1">
|
|
14
37
|
<monster-control-bar id="bar">
|
|
@@ -115,7 +138,9 @@ describe("ControlBar", function () {
|
|
|
115
138
|
await waitForLayout();
|
|
116
139
|
|
|
117
140
|
bar.setOption("layout.hideWhenEmpty", true);
|
|
118
|
-
await
|
|
141
|
+
await waitForCondition(() =>
|
|
142
|
+
bar.hasAttribute("data-monster-empty-hidden"),
|
|
143
|
+
);
|
|
119
144
|
|
|
120
145
|
expect(bar.hasAttribute("hidden")).to.be.true;
|
|
121
146
|
expect(bar.hasAttribute("data-monster-empty-hidden")).to.be.true;
|
|
@@ -131,7 +156,9 @@ describe("ControlBar", function () {
|
|
|
131
156
|
`;
|
|
132
157
|
const bar = document.getElementById("empty-auto-hidden-bar");
|
|
133
158
|
|
|
134
|
-
await
|
|
159
|
+
await waitForCondition(() =>
|
|
160
|
+
bar.hasAttribute("data-monster-empty-hidden"),
|
|
161
|
+
);
|
|
135
162
|
|
|
136
163
|
expect(bar.getOption("layout.hideWhenEmpty")).to.equal(true);
|
|
137
164
|
expect(bar.hasAttribute("hidden")).to.be.true;
|
|
@@ -151,7 +178,9 @@ describe("ControlBar", function () {
|
|
|
151
178
|
const bar = document.getElementById("dynamic-auto-hidden-bar");
|
|
152
179
|
const button = document.getElementById("dynamic-auto-hidden-button");
|
|
153
180
|
|
|
154
|
-
await
|
|
181
|
+
await waitForCondition(() =>
|
|
182
|
+
bar.hasAttribute("data-monster-empty-hidden"),
|
|
183
|
+
);
|
|
155
184
|
|
|
156
185
|
expect(bar.hasAttribute("hidden")).to.be.true;
|
|
157
186
|
expect(bar.hasAttribute("data-monster-empty-hidden")).to.be.true;
|
|
@@ -179,12 +208,16 @@ describe("ControlBar", function () {
|
|
|
179
208
|
`;
|
|
180
209
|
const bar = document.getElementById("option-auto-hidden-bar");
|
|
181
210
|
|
|
182
|
-
await
|
|
211
|
+
await waitForCondition(() =>
|
|
212
|
+
bar.hasAttribute("data-monster-empty-hidden"),
|
|
213
|
+
);
|
|
183
214
|
|
|
184
215
|
expect(bar.hasAttribute("hidden")).to.be.true;
|
|
185
216
|
|
|
186
217
|
bar.setOption("layout.hideWhenEmpty", false);
|
|
187
|
-
await
|
|
218
|
+
await waitForCondition(
|
|
219
|
+
() => !bar.hasAttribute("data-monster-empty-hidden"),
|
|
220
|
+
);
|
|
188
221
|
|
|
189
222
|
expect(bar.hasAttribute("hidden")).to.be.false;
|
|
190
223
|
expect(bar.hasAttribute("data-monster-empty-hidden")).to.be.false;
|