@schukai/monster 4.148.3 → 4.148.5

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 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.3"}
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.5"}
@@ -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 { ATTRIBUTE_DATASOURCE_SELECTOR } from "./constants.mjs";
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";
@@ -203,7 +206,10 @@ class SaveButton extends CustomElement {
203
206
  }
204
207
 
205
208
  if (element instanceof RestDatasource) {
206
- element.addEventListener("monster-datasource-fetch", () => {
209
+ element.addEventListener("monster-datasource-fetch", (event) => {
210
+ if (event?.detail?.operation === DATASOURCE_OPERATION_WRITE) {
211
+ return;
212
+ }
207
213
  if (self[saveInFlightSymbol]) {
208
214
  self[pendingResetSymbol] = true;
209
215
  return;
@@ -211,15 +217,20 @@ class SaveButton extends CustomElement {
211
217
  self[fetchInFlightSymbol] = true;
212
218
  clearOriginValues.call(self);
213
219
  });
214
- element.addEventListener("monster-datasource-fetched", () => {
215
- self[fetchInFlightSymbol] = false;
220
+ element.addEventListener("monster-datasource-fetched", (event) => {
221
+ if (event?.detail?.operation !== DATASOURCE_OPERATION_WRITE) {
222
+ self[fetchInFlightSymbol] = false;
223
+ }
216
224
  setOriginValues.call(
217
225
  self,
218
226
  clone(self[datasourceLinkedElementSymbol].data),
219
227
  );
220
228
  updateChangesState.call(self);
221
229
  });
222
- element.addEventListener("monster-datasource-error", () => {
230
+ element.addEventListener("monster-datasource-error", (event) => {
231
+ if (event?.detail?.operation === DATASOURCE_OPERATION_WRITE) {
232
+ return;
233
+ }
223
234
  self[fetchInFlightSymbol] = false;
224
235
  });
225
236
  }
@@ -113,6 +113,18 @@ const keyFilterEventSymbol = Symbol("keyFilterEvent");
113
113
  */
114
114
  const lazyLoadDoneSymbol = Symbol("lazyLoadDone");
115
115
 
116
+ /**
117
+ * @private
118
+ * @type {Symbol}
119
+ */
120
+ const lazyLoadRequestSymbol = Symbol("lazyLoadRequest");
121
+
122
+ /**
123
+ * @private
124
+ * @type {Symbol}
125
+ */
126
+ const lazyLoadErrorSymbol = Symbol("lazyLoadError");
127
+
116
128
  /**
117
129
  * @private
118
130
  * @type {Symbol}
@@ -300,6 +312,7 @@ const optionsMapVersionSnapshotSymbol = Symbol("optionsMapVersionSnapshot");
300
312
  const selectionVersionSymbol = Symbol("selectionVersion");
301
313
  const closeOnSelectAutoSymbol = Symbol("closeOnSelectAuto");
302
314
  const lastFilterValueSymbol = Symbol("lastFilterValue");
315
+ const filterInputVersionSymbol = Symbol("filterInputVersion");
303
316
 
304
317
  /**
305
318
  * @private
@@ -2051,6 +2064,7 @@ function invalidatePendingRemoteWork() {
2051
2064
  }
2052
2065
  this[isLoadingSymbol] = false;
2053
2066
  this[remoteInfoRequestSymbol] = null;
2067
+ delete this[lazyLoadRequestSymbol];
2054
2068
 
2055
2069
  if (this[keyFilterEventSymbol] instanceof DeadMansSwitch) {
2056
2070
  try {
@@ -4014,6 +4028,7 @@ function handleFilterInputEvents() {
4014
4028
  }
4015
4029
 
4016
4030
  this[lastFilterValueSymbol] = filterValue;
4031
+ bumpFilterInputVersion.call(this);
4017
4032
 
4018
4033
  if (this[keyFilterEventSymbol] instanceof DeadMansSwitch) {
4019
4034
  try {
@@ -4061,6 +4076,16 @@ function getCurrentFilterInputValue() {
4061
4076
  return undefined;
4062
4077
  }
4063
4078
 
4079
+ function getFilterInputVersion() {
4080
+ return Number.isInteger(this[filterInputVersionSymbol])
4081
+ ? this[filterInputVersionSymbol]
4082
+ : 0;
4083
+ }
4084
+
4085
+ function bumpFilterInputVersion() {
4086
+ this[filterInputVersionSymbol] = getFilterInputVersion.call(this) + 1;
4087
+ }
4088
+
4064
4089
  /**
4065
4090
  * @private
4066
4091
  */
@@ -4601,7 +4626,15 @@ function areOptionsAvailableAndInitInternal() {
4601
4626
  options === null ||
4602
4627
  (isArray(options) && options.length === 0)
4603
4628
  ) {
4604
- setStatusOrRemoveBadges.call(this, "empty");
4629
+ const lazyLoadPending = this[lazyLoadRequestSymbol] instanceof Promise;
4630
+ const lazyLoadError = isString(this[lazyLoadErrorSymbol]);
4631
+ if (lazyLoadError) {
4632
+ setStatusOrRemoveBadges.call(this, "error");
4633
+ } else if (lazyLoadPending) {
4634
+ setStatusOrRemoveBadges.call(this, "loading");
4635
+ } else {
4636
+ setStatusOrRemoveBadges.call(this, "empty");
4637
+ }
4605
4638
  if (getFilterMode.call(this) === FILTER_MODE_REMOTE) {
4606
4639
  if (this[isLoadingSymbol] !== true) {
4607
4640
  if (isInteger(this.getOption("total"))) {
@@ -4615,7 +4648,9 @@ function areOptionsAvailableAndInitInternal() {
4615
4648
 
4616
4649
  let msg = this.getOption("labels.no-options-available");
4617
4650
 
4618
- if (
4651
+ if (lazyLoadError) {
4652
+ msg = this.getOption("labels.cannot-be-loaded");
4653
+ } else if (
4619
4654
  this.getOption("url") !== null &&
4620
4655
  this.getOption("features.lazyLoad") === true &&
4621
4656
  this[lazyLoadDoneSymbol] !== true
@@ -4651,7 +4686,11 @@ function areOptionsAvailableAndInitInternal() {
4651
4686
  if (this.getOption("features.emptyValueIfNoOptions") === true) {
4652
4687
  this.value = "";
4653
4688
  }
4654
- if (this[isLoadingSymbol] !== true) {
4689
+ if (
4690
+ this[isLoadingSymbol] !== true &&
4691
+ lazyLoadPending === false &&
4692
+ lazyLoadError === false
4693
+ ) {
4655
4694
  addErrorAttribute(this, "No options available.");
4656
4695
  }
4657
4696
  return false;
@@ -5087,24 +5126,65 @@ function show() {
5087
5126
  this.getOption("features.lazyLoad") && this[lazyLoadDoneSymbol] !== true;
5088
5127
 
5089
5128
  if (lazyLoadFlag === true) {
5090
- this[lazyLoadDoneSymbol] = true;
5129
+ if (this[lazyLoadRequestSymbol] instanceof Promise) {
5130
+ return;
5131
+ }
5132
+
5133
+ if (isString(this[lazyLoadErrorSymbol])) {
5134
+ removeErrorAttribute(this, this[lazyLoadErrorSymbol]);
5135
+ delete this[lazyLoadErrorSymbol];
5136
+ }
5137
+ removeErrorAttribute(this, "No options available.");
5091
5138
  setStatusOrRemoveBadges.call(this, "loading");
5092
5139
 
5093
- new Processing(200, () => {
5094
- this.fetch()
5095
- .then(() => {
5096
- checkOptionState.call(this);
5097
- requestAnimationFrame(() => {
5140
+ const lifecycleVersion = getRemoteLifecycleVersion.call(this);
5141
+ const lazyLoadRequest = new Processing(200, () => {
5142
+ if (
5143
+ !isRemoteLifecycleCurrent.call(this, lifecycleVersion) ||
5144
+ this.isConnected !== true
5145
+ ) {
5146
+ return;
5147
+ }
5148
+
5149
+ return this.fetch();
5150
+ }).run();
5151
+ this[lazyLoadRequestSymbol] = lazyLoadRequest;
5152
+
5153
+ lazyLoadRequest
5154
+ .then(() => {
5155
+ if (
5156
+ this[lazyLoadRequestSymbol] !== lazyLoadRequest ||
5157
+ !isRemoteLifecycleCurrent.call(this, lifecycleVersion) ||
5158
+ this.isConnected !== true
5159
+ ) {
5160
+ return;
5161
+ }
5162
+
5163
+ delete this[lazyLoadRequestSymbol];
5164
+ this[lazyLoadDoneSymbol] = true;
5165
+ delete this[lazyLoadErrorSymbol];
5166
+ checkOptionState.call(this);
5167
+ requestAnimationFrame(() => {
5168
+ if (
5169
+ isRemoteLifecycleCurrent.call(this, lifecycleVersion) &&
5170
+ this.isConnected === true
5171
+ ) {
5098
5172
  show.call(this);
5099
- });
5100
- })
5101
- .catch((e) => {
5102
- addErrorAttribute(this, e);
5103
- setStatusOrRemoveBadges.call(this, "error");
5173
+ }
5104
5174
  });
5105
- })
5106
- .run()
5175
+ })
5107
5176
  .catch((e) => {
5177
+ if (
5178
+ this[lazyLoadRequestSymbol] !== lazyLoadRequest ||
5179
+ !isRemoteLifecycleCurrent.call(this, lifecycleVersion)
5180
+ ) {
5181
+ return;
5182
+ }
5183
+
5184
+ delete this[lazyLoadRequestSymbol];
5185
+ this[lazyLoadDoneSymbol] = false;
5186
+ this[lazyLoadErrorSymbol] =
5187
+ e instanceof Error ? e.message : String(e);
5108
5188
  addErrorAttribute(this, e);
5109
5189
  setStatusOrRemoveBadges.call(this, "error");
5110
5190
  });
@@ -5141,6 +5221,8 @@ function show() {
5141
5221
  addAttributeToken(this[controlElementSymbol], "class", "open");
5142
5222
  registerWithHost.call(this);
5143
5223
 
5224
+ const filterInputVersion = getFilterInputVersion.call(this);
5225
+
5144
5226
  new Processing(() => {
5145
5227
  const shouldLoadRemoteOptions =
5146
5228
  getFilterMode.call(self) === FILTER_MODE_REMOTE &&
@@ -5152,12 +5234,18 @@ function show() {
5152
5234
 
5153
5235
  if (shouldLoadDefaultOptions) {
5154
5236
  setTimeout(() => {
5237
+ if (getFilterInputVersion.call(this) !== filterInputVersion) {
5238
+ return;
5239
+ }
5155
5240
  loadDefaultOptionsFromUrl.call(self).catch((e) => {
5156
5241
  addErrorAttribute(self, e);
5157
5242
  });
5158
5243
  }, 0);
5159
5244
  } else if (shouldLoadRemoteOptions) {
5160
5245
  setTimeout(() => {
5246
+ if (getFilterInputVersion.call(this) !== filterInputVersion) {
5247
+ return;
5248
+ }
5161
5249
  self[cleanupOptionsListSymbol] = true;
5162
5250
  filterFromRemote.call(self).catch((e) => {
5163
5251
  addErrorAttribute(self, e);
@@ -0,0 +1,299 @@
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 expectPendingChanges(button) {
63
+ expect(Number(button.getOption("changes"))).to.be.greaterThan(0);
64
+ expect(getStateButton(button)?.getOption("disabled")).to.equal(false);
65
+ }
66
+
67
+ function expectNoPendingChanges(button) {
68
+ expect(button.getOption("changes")).to.equal("");
69
+ expect(getStateButton(button)?.getOption("disabled")).to.equal(true);
70
+ }
71
+
72
+ describe("REST datasource lifecycle", function () {
73
+ before(async function () {
74
+ await initJSDOM();
75
+ globalThis.location = window.location;
76
+ await import("element-internals-polyfill").catch(() => {});
77
+ await import("../../../../source/components/datatable/datasource/rest.mjs");
78
+ await import("../../../../source/components/datatable/save-button.mjs");
79
+ ({ DataFetchError } = await import(
80
+ "../../../../source/data/datasource/server/restapi/data-fetch-error.mjs"
81
+ ));
82
+ });
83
+
84
+ beforeEach(() => {
85
+ document.getElementById("mocks").innerHTML = "";
86
+ });
87
+
88
+ afterEach(() => {
89
+ document.getElementById("mocks").innerHTML = "";
90
+ });
91
+
92
+ it("identifies read and write lifecycle events without changing event names", async function () {
93
+ const datasource = createDatasource("lifecycle-datasource");
94
+ const events = listenForLifecycle(datasource);
95
+ document.getElementById("mocks").appendChild(datasource);
96
+
97
+ datasource.datasource.read = () => Promise.resolve({ ok: true });
98
+ await datasource.read();
99
+
100
+ datasource.datasource.write = () => Promise.resolve({ ok: true });
101
+ await datasource.write();
102
+
103
+ expect(
104
+ events.map(({ type, detail }) => [type, detail.operation]),
105
+ ).to.deep.equal([
106
+ ["monster-datasource-fetch", "read"],
107
+ ["monster-datasource-fetched", "read"],
108
+ ["monster-datasource-fetch", "write"],
109
+ ["monster-datasource-fetched", "write"],
110
+ ]);
111
+ events.forEach(({ detail }) => {
112
+ expect(detail.datasource).to.equal(datasource);
113
+ });
114
+ });
115
+
116
+ it("identifies write validation and error events", async function () {
117
+ const datasource = createDatasource("validation-datasource");
118
+ datasource.setOption(
119
+ "response.path.validationErrors",
120
+ "meta.validationErrors",
121
+ );
122
+ datasource.setOption("response.path.validationMessage", "meta.message");
123
+ const events = listenForLifecycle(datasource);
124
+ document.getElementById("mocks").appendChild(datasource);
125
+
126
+ const response = new Response(
127
+ JSON.stringify({
128
+ meta: {
129
+ message: "Input data is invalid",
130
+ validationErrors: {
131
+ parentId: { code: "VAL_INVALID", message: "Invalid parent" },
132
+ },
133
+ },
134
+ }),
135
+ { status: 400, headers: { "content-type": "application/json" } },
136
+ );
137
+ datasource.datasource.write = () =>
138
+ Promise.reject(new DataFetchError("Input data is invalid", response));
139
+
140
+ await datasource.write().catch(() => {});
141
+ await waitForUpdates();
142
+
143
+ const validation = events.find(
144
+ ({ type }) => type === "monster-datasource-validation",
145
+ );
146
+ const error = events.find(
147
+ ({ type }) => type === "monster-datasource-error",
148
+ );
149
+ expect(validation?.detail.operation).to.equal("write");
150
+ expect(validation?.detail.datasource).to.equal(datasource);
151
+ expect(validation?.detail.errors.parentId).to.deep.equal({
152
+ code: "VAL_INVALID",
153
+ message: "Invalid parent",
154
+ });
155
+ expect(error?.detail.operation).to.equal("write");
156
+ expect(error?.detail.datasource).to.equal(datasource);
157
+ });
158
+
159
+ it("keeps every save button on the loaded baseline after a rejected write", async function () {
160
+ const datasource = createDatasource("rejected-write-datasource");
161
+ const loaded = {
162
+ data: [{ id: "area-1", name: "Inbound", parentAreaId: "" }],
163
+ };
164
+ const invalid = {
165
+ data: [{ id: "area-1", name: "Inbound changed", parentAreaId: "area-1" }],
166
+ };
167
+ const corrected = {
168
+ data: [{ id: "area-1", name: "Inbound changed", parentAreaId: "" }],
169
+ };
170
+ const first = createSaveButton("#rejected-write-datasource");
171
+ const second = createSaveButton("#rejected-write-datasource");
172
+ const mocks = document.getElementById("mocks");
173
+ mocks.append(datasource, first, second);
174
+ await loadDatasource(datasource, loaded);
175
+ expectNoPendingChanges(first);
176
+ expectNoPendingChanges(second);
177
+
178
+ datasource.data = invalid;
179
+ await waitForUpdates();
180
+ expectPendingChanges(first);
181
+ expectPendingChanges(second);
182
+
183
+ datasource.datasource.write = () =>
184
+ Promise.reject(new Error("Rejected write"));
185
+ clickSaveButton(first);
186
+ await waitForUpdates();
187
+ expectPendingChanges(first);
188
+ expectPendingChanges(second);
189
+
190
+ datasource.data = corrected;
191
+ await waitForUpdates();
192
+ expectPendingChanges(first);
193
+ expectPendingChanges(second);
194
+ });
195
+
196
+ it("establishes the current data as every save button baseline after a successful write", async function () {
197
+ const datasource = createDatasource("successful-write-datasource");
198
+ const loaded = { data: [{ id: "area-1", name: "Inbound" }] };
199
+ const edited = { data: [{ id: "area-1", name: "Inbound changed" }] };
200
+ const first = createSaveButton("#successful-write-datasource");
201
+ const second = createSaveButton("#successful-write-datasource");
202
+ const mocks = document.getElementById("mocks");
203
+ mocks.append(datasource, first, second);
204
+ await loadDatasource(datasource, loaded);
205
+ datasource.data = edited;
206
+ await waitForUpdates();
207
+ expectPendingChanges(first);
208
+ expectPendingChanges(second);
209
+
210
+ datasource.datasource.write = () => Promise.resolve({ ok: true });
211
+ clickSaveButton(first);
212
+ await waitForUpdates();
213
+
214
+ expectNoPendingChanges(first);
215
+ expectNoPendingChanges(second);
216
+ });
217
+
218
+ it("replaces every save button baseline after a read", async function () {
219
+ const datasource = createDatasource("read-baseline-datasource");
220
+ const loaded = { data: [{ id: "area-1", name: "Inbound" }] };
221
+ const edited = { data: [{ id: "area-1", name: "Inbound changed" }] };
222
+ const refreshed = { data: [{ id: "area-1", name: "Inbound from server" }] };
223
+ const first = createSaveButton("#read-baseline-datasource");
224
+ const second = createSaveButton("#read-baseline-datasource");
225
+ const mocks = document.getElementById("mocks");
226
+ mocks.append(datasource, first, second);
227
+ await loadDatasource(datasource, loaded);
228
+ datasource.data = edited;
229
+ await waitForUpdates();
230
+ expectPendingChanges(first);
231
+ expectPendingChanges(second);
232
+
233
+ datasource.datasource.read = () => {
234
+ datasource.data = refreshed;
235
+ return Promise.resolve({ ok: true });
236
+ };
237
+ await datasource.read();
238
+ await waitForUpdates();
239
+
240
+ expectNoPendingChanges(first);
241
+ expectNoPendingChanges(second);
242
+ });
243
+
244
+ it("does not let write completion end an in-flight read", async function () {
245
+ const datasource = createDatasource("concurrent-datasource");
246
+ const loaded = { data: [{ id: "area-1", name: "Inbound" }] };
247
+ const firstReadValue = {
248
+ data: [{ id: "area-1", name: "Inbound from server" }],
249
+ };
250
+ const finalReadValue = {
251
+ data: [{ id: "area-1", name: "Inbound from server (final)" }],
252
+ };
253
+ const first = createSaveButton("#concurrent-datasource");
254
+ const second = createSaveButton("#concurrent-datasource");
255
+ const mocks = document.getElementById("mocks");
256
+ mocks.append(datasource, first, second);
257
+ await loadDatasource(datasource, loaded);
258
+ expectNoPendingChanges(first);
259
+ expectNoPendingChanges(second);
260
+
261
+ let resolveRead;
262
+ datasource.datasource.read = () =>
263
+ new Promise((resolve) => {
264
+ resolveRead = resolve;
265
+ });
266
+ const read = datasource.read();
267
+ await waitForUpdates();
268
+ expectNoPendingChanges(first);
269
+ expectNoPendingChanges(second);
270
+
271
+ datasource.datasource.write = () => Promise.resolve({ ok: true });
272
+ await datasource.write();
273
+ expectNoPendingChanges(first);
274
+ expectNoPendingChanges(second);
275
+
276
+ datasource.data = firstReadValue;
277
+ await waitForUpdates();
278
+ expectNoPendingChanges(first);
279
+ expectNoPendingChanges(second);
280
+
281
+ datasource.datasource.write = () =>
282
+ Promise.reject(new Error("Rejected concurrent write"));
283
+ await datasource.write().catch(() => {});
284
+ expectNoPendingChanges(first);
285
+ expectNoPendingChanges(second);
286
+
287
+ datasource.data = finalReadValue;
288
+ await waitForUpdates();
289
+
290
+ expectNoPendingChanges(first);
291
+ expectNoPendingChanges(second);
292
+
293
+ resolveRead({ ok: true });
294
+ await read;
295
+ await waitForUpdates();
296
+ expectNoPendingChanges(first);
297
+ expectNoPendingChanges(second);
298
+ });
299
+ });
@@ -97,6 +97,14 @@ function configureRemotePaginatedSelect(select) {
97
97
  select.setOption('mapping.objectsPerPage', 'pagination.perPage');
98
98
  }
99
99
 
100
+ function configureLazySelect(select) {
101
+ select.setOption('url', 'https://example.com/lazy-options');
102
+ select.setOption('features.lazyLoad', true);
103
+ select.setOption('mapping.selector', 'items.*');
104
+ select.setOption('mapping.labelTemplate', '${name}');
105
+ select.setOption('mapping.valueTemplate', '${id}');
106
+ }
107
+
100
108
  let Select,
101
109
  SelectStyleSheet,
102
110
  getDefaultSelectPopperPositionProfile,
@@ -643,6 +651,223 @@ describe('Select', function () {
643
651
 
644
652
  });
645
653
 
654
+ describe('Lazy load lifecycle', function () {
655
+ this.timeout(5000);
656
+
657
+ afterEach(() => {
658
+ const mocks = document.getElementById('mocks');
659
+ mocks.innerHTML = '';
660
+ global['fetch'] = fetchReference;
661
+ });
662
+
663
+ it('should open from the first click after a delayed lazy response renders', async function () {
664
+ const deferredResponse = createDeferred();
665
+ let requestCount = 0;
666
+ global['fetch'] = function () {
667
+ requestCount += 1;
668
+ return deferredResponse.promise;
669
+ };
670
+
671
+ const mocks = document.getElementById('mocks');
672
+ const select = document.createElement('monster-select');
673
+ configureLazySelect(select);
674
+ mocks.appendChild(select);
675
+
676
+ await waitForCondition(() => {
677
+ return select.shadowRoot.querySelector('[data-monster-role=container]') instanceof HTMLElement;
678
+ });
679
+
680
+ const container = select.shadowRoot.querySelector('[data-monster-role=container]');
681
+ const popper = select.shadowRoot.querySelector('[data-monster-role=popper]');
682
+ container.click();
683
+
684
+ await waitForCondition(() => requestCount === 1);
685
+ container.click();
686
+ await new Promise(resolve => setTimeout(resolve, 250));
687
+ expect(requestCount).to.equal(1);
688
+ deferredResponse.resolve(
689
+ await createJsonResponse({
690
+ items: [{id: 'alpha', name: 'Alpha'}]
691
+ })
692
+ );
693
+
694
+ await waitForCondition(() => {
695
+ return select.shadowRoot.querySelectorAll('[data-monster-role=option]').length === 1;
696
+ });
697
+ await waitForCondition(() => popper.style.display === 'block');
698
+
699
+ expect(requestCount).to.equal(1);
700
+ expect(popper.style.display).to.equal('block');
701
+ });
702
+
703
+ it('should keep a failed lazy load in the error state', async function () {
704
+ let requestCount = 0;
705
+ global['fetch'] = function () {
706
+ requestCount += 1;
707
+ return createJsonResponse({}, 500);
708
+ };
709
+
710
+ const mocks = document.getElementById('mocks');
711
+ const select = document.createElement('monster-select');
712
+ configureLazySelect(select);
713
+ mocks.appendChild(select);
714
+
715
+ await new Promise(resolve => setTimeout(resolve, 50));
716
+ select.shadowRoot.querySelector('[data-monster-role=container]').click();
717
+
718
+ await waitForCondition(() => requestCount === 1);
719
+ await waitForCondition(() => {
720
+ return (select.getAttribute('data-monster-error') ?? '').includes('HTTP error! status: 500');
721
+ });
722
+ await new Promise(resolve => setTimeout(resolve, 350));
723
+
724
+ expect(select.getOption('classes.statusOrRemoveBadge')).to.equal('error');
725
+ expect(select.getAttribute('data-monster-error')).to.not.contain('No options available.');
726
+ });
727
+
728
+ it('should retry a failed lazy load on the next click', async function () {
729
+ let requestCount = 0;
730
+ global['fetch'] = function () {
731
+ requestCount += 1;
732
+ if (requestCount === 1) {
733
+ return createJsonResponse({}, 500);
734
+ }
735
+
736
+ return createJsonResponse({
737
+ items: [{id: 'alpha', name: 'Alpha'}]
738
+ });
739
+ };
740
+
741
+ const mocks = document.getElementById('mocks');
742
+ const select = document.createElement('monster-select');
743
+ configureLazySelect(select);
744
+ mocks.appendChild(select);
745
+
746
+ await new Promise(resolve => setTimeout(resolve, 50));
747
+ const container = select.shadowRoot.querySelector('[data-monster-role=container]');
748
+ const popper = select.shadowRoot.querySelector('[data-monster-role=popper]');
749
+ container.click();
750
+
751
+ await waitForCondition(() => requestCount === 1);
752
+ await waitForCondition(() => {
753
+ return (select.getAttribute('data-monster-error') ?? '').includes('HTTP error! status: 500');
754
+ });
755
+
756
+ container.click();
757
+
758
+ await waitForCondition(() => requestCount === 2, {timeout: 1500});
759
+ await waitForCondition(() => {
760
+ return select.shadowRoot.querySelectorAll('[data-monster-role=option]').length === 1;
761
+ });
762
+ await waitForCondition(() => popper.style.display === 'block');
763
+
764
+ expect(requestCount).to.equal(2);
765
+ expect(select.getAttribute('data-monster-error') ?? '').to.not.contain('HTTP error! status: 500');
766
+ });
767
+
768
+ it('should retry lazy loading after disconnecting during the request', async function () {
769
+ const firstResponse = createDeferred();
770
+ let requestCount = 0;
771
+ global['fetch'] = function () {
772
+ requestCount += 1;
773
+ if (requestCount === 1) {
774
+ return firstResponse.promise;
775
+ }
776
+
777
+ return createJsonResponse({
778
+ items: [{id: 'fresh', name: 'Fresh'}]
779
+ });
780
+ };
781
+
782
+ const mocks = document.getElementById('mocks');
783
+ const select = document.createElement('monster-select');
784
+ configureLazySelect(select);
785
+ mocks.appendChild(select);
786
+
787
+ await new Promise(resolve => setTimeout(resolve, 50));
788
+ const container = select.shadowRoot.querySelector('[data-monster-role=container]');
789
+ container.click();
790
+ await waitForCondition(() => requestCount === 1);
791
+
792
+ select.remove();
793
+ mocks.appendChild(select);
794
+ firstResponse.resolve(
795
+ await createJsonResponse({
796
+ items: [{id: 'stale', name: 'Stale'}]
797
+ })
798
+ );
799
+ await new Promise(resolve => setTimeout(resolve, 100));
800
+
801
+ container.click();
802
+
803
+ await waitForCondition(() => requestCount === 2, {timeout: 1500});
804
+ await waitForCondition(() => {
805
+ return select.getOption('options').map(option => option.value).includes('fresh');
806
+ });
807
+ expect(select.getOption('options').map(option => option.value)).to.deep.equal(['fresh']);
808
+ });
809
+
810
+ it('should not start a delayed lazy request after disconnecting', async function () {
811
+ let requestCount = 0;
812
+ global['fetch'] = function () {
813
+ requestCount += 1;
814
+ return createJsonResponse({
815
+ items: [{id: 'fresh', name: 'Fresh'}]
816
+ });
817
+ };
818
+
819
+ const mocks = document.getElementById('mocks');
820
+ const select = document.createElement('monster-select');
821
+ configureLazySelect(select);
822
+ mocks.appendChild(select);
823
+
824
+ await new Promise(resolve => setTimeout(resolve, 50));
825
+ const container = select.shadowRoot.querySelector('[data-monster-role=container]');
826
+ container.click();
827
+ select.remove();
828
+
829
+ await new Promise(resolve => setTimeout(resolve, 300));
830
+ expect(requestCount).to.equal(0);
831
+
832
+ mocks.appendChild(select);
833
+ container.click();
834
+ await waitForCondition(() => requestCount === 1);
835
+ await waitForCondition(() => {
836
+ return select.getOption('options').map(option => option.value).includes('fresh');
837
+ });
838
+ });
839
+
840
+ it('should not reload a completed lazy select after reconnecting', async function () {
841
+ let requestCount = 0;
842
+ global['fetch'] = function () {
843
+ requestCount += 1;
844
+ return createJsonResponse({
845
+ items: [{id: 'alpha', name: 'Alpha'}]
846
+ });
847
+ };
848
+
849
+ const mocks = document.getElementById('mocks');
850
+ const select = document.createElement('monster-select');
851
+ configureLazySelect(select);
852
+ mocks.appendChild(select);
853
+
854
+ await new Promise(resolve => setTimeout(resolve, 50));
855
+ const container = select.shadowRoot.querySelector('[data-monster-role=container]');
856
+ const popper = select.shadowRoot.querySelector('[data-monster-role=popper]');
857
+ container.click();
858
+ await waitForCondition(() => popper.style.display === 'block');
859
+
860
+ container.click();
861
+ await waitForCondition(() => popper.style.display === 'none');
862
+ select.remove();
863
+ mocks.appendChild(select);
864
+ container.click();
865
+ await waitForCondition(() => popper.style.display === 'block');
866
+
867
+ expect(requestCount).to.equal(1);
868
+ });
869
+ });
870
+
646
871
  describe('Popper sizing', function () {
647
872
  this.timeout(5000);
648
873
 
@@ -1701,6 +1926,56 @@ describe('Select', function () {
1701
1926
  });
1702
1927
  });
1703
1928
 
1929
+ describe('Remote open lifecycle', function () {
1930
+ this.timeout(5000);
1931
+
1932
+ afterEach(() => {
1933
+ const mocks = document.getElementById('mocks');
1934
+ mocks.innerHTML = '';
1935
+ global['fetch'] = fetchReference;
1936
+ });
1937
+
1938
+ it('should request an immediately entered remote filter only once', async function () {
1939
+ const requests = [];
1940
+ global['fetch'] = function (url) {
1941
+ requests.push(String(url));
1942
+ return createJsonResponse({
1943
+ items: [{id: 'alpha', name: 'Alpha'}]
1944
+ });
1945
+ };
1946
+
1947
+ const mocks = document.getElementById('mocks');
1948
+ const select = document.createElement('monster-select');
1949
+ select.setOption('url', 'https://example.com/items?filter={filter}&page={page}');
1950
+ select.setOption('filter.mode', 'remote');
1951
+ select.setOption('filter.position', 'popper');
1952
+ select.setOption('mapping.selector', 'items.*');
1953
+ select.setOption('mapping.labelTemplate', '${name}');
1954
+ select.setOption('mapping.valueTemplate', '${id}');
1955
+ mocks.appendChild(select);
1956
+
1957
+ await waitForCondition(() => {
1958
+ return select.shadowRoot.querySelector('[data-monster-role=filter][name="popper-filter"]') instanceof HTMLInputElement;
1959
+ });
1960
+
1961
+ const container = select.shadowRoot.querySelector('[data-monster-role=container]');
1962
+ const filterInput = select.shadowRoot.querySelector('[data-monster-role=filter][name="popper-filter"]');
1963
+ container.click();
1964
+ filterInput.value = 'alpha';
1965
+ filterInput.dispatchEvent(new Event('input', {
1966
+ bubbles: true,
1967
+ composed: true
1968
+ }));
1969
+
1970
+ await waitForCondition(() => requests.length >= 1);
1971
+ await new Promise(resolve => setTimeout(resolve, 350));
1972
+
1973
+ expect(requests).to.deep.equal([
1974
+ 'https://example.com/items?filter=alpha&page=1'
1975
+ ]);
1976
+ });
1977
+ });
1978
+
1704
1979
  describe('document.createElement()', function () {
1705
1980
 
1706
1981
  afterEach(() => {