@schukai/monster 4.148.5 → 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 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.5"}
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"}
@@ -75,8 +75,9 @@ const originValuesSymbol = Symbol.for(
75
75
  */
76
76
  const badgeElementSymbol = Symbol("badgeElement");
77
77
  const saveInFlightSymbol = Symbol("saveInFlight");
78
- const pendingResetSymbol = Symbol("pendingReset");
79
78
  const fetchInFlightSymbol = Symbol("fetchInFlight");
79
+ const readRevisionSymbol = Symbol("readRevision");
80
+ const pendingWriteSymbol = Symbol("pendingWrite");
80
81
  const originInitializedSymbol = Symbol("originInitialized");
81
82
  const internalDisableTimeoutSymbol = Symbol("internalDisableTimeout");
82
83
  const internalDisableVersionSymbol = Symbol("internalDisableVersion");
@@ -206,33 +207,84 @@ class SaveButton extends CustomElement {
206
207
  }
207
208
 
208
209
  if (element instanceof RestDatasource) {
209
- element.addEventListener("monster-datasource-fetch", (event) => {
210
- if (event?.detail?.operation === DATASOURCE_OPERATION_WRITE) {
211
- return;
212
- }
213
- if (self[saveInFlightSymbol]) {
214
- self[pendingResetSymbol] = true;
215
- return;
216
- }
217
- self[fetchInFlightSymbol] = true;
218
- clearOriginValues.call(self);
219
- });
220
- element.addEventListener("monster-datasource-fetched", (event) => {
221
- if (event?.detail?.operation !== DATASOURCE_OPERATION_WRITE) {
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
+ }
222
254
  self[fetchInFlightSymbol] = false;
223
- }
224
- setOriginValues.call(
225
- self,
226
- clone(self[datasourceLinkedElementSymbol].data),
227
- );
228
- updateChangesState.call(self);
229
- });
230
- element.addEventListener("monster-datasource-error", (event) => {
231
- if (event?.detail?.operation === DATASOURCE_OPERATION_WRITE) {
232
- return;
233
- }
234
- self[fetchInFlightSymbol] = false;
235
- });
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
+ );
236
288
  }
237
289
 
238
290
  this[datasourceLinkedElementSymbol] = element;
@@ -427,35 +479,15 @@ function initEventHandler() {
427
479
  .call(this)
428
480
  .then(() => this[datasourceLinkedElementSymbol].write())
429
481
  .then(() => {
430
- clearOriginValues.call(this);
431
- setOriginValues.call(
432
- this,
433
- clone(this[datasourceLinkedElementSymbol].data),
434
- );
435
- this[stateButtonElementSymbol].removeState();
436
- this[stateButtonElementSymbol].setOption(
437
- "disabled",
438
- shouldDisableWhenNoChanges.call(this),
439
- );
440
- this.setOption("changes", "");
441
- this.setOption(
442
- "classes.badge",
443
- new TokenList(this.getOption("classes.badge"))
444
- .add("hidden")
445
- .toString(),
446
- );
482
+ updateChangesState.call(this);
447
483
  })
448
484
  .catch((error) => {
449
485
  addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, error.toString());
450
- this[stateButtonElementSymbol].setOption("disabled", false);
486
+ updateChangesState.call(this);
451
487
  })
452
488
  .finally(() => {
453
489
  this[saveInFlightSymbol] = false;
454
490
  clearInternalDisableTimeout.call(this);
455
- if (this[pendingResetSymbol]) {
456
- this[pendingResetSymbol] = false;
457
- clearOriginValues.call(this);
458
- }
459
491
  });
460
492
  });
461
493
  });
@@ -59,6 +59,16 @@ function clickSaveButton(button) {
59
59
  action();
60
60
  }
61
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
+
62
72
  function expectPendingChanges(button) {
63
73
  expect(Number(button.getOption("changes"))).to.be.greaterThan(0);
64
74
  expect(getStateButton(button)?.getOption("disabled")).to.equal(false);
@@ -215,6 +225,34 @@ describe("REST datasource lifecycle", function () {
215
225
  expectNoPendingChanges(second);
216
226
  });
217
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
+
218
256
  it("replaces every save button baseline after a read", async function () {
219
257
  const datasource = createDatasource("read-baseline-datasource");
220
258
  const loaded = { data: [{ id: "area-1", name: "Inbound" }] };
@@ -296,4 +334,141 @@ describe("REST datasource lifecycle", function () {
296
334
  expectNoPendingChanges(first);
297
335
  expectNoPendingChanges(second);
298
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
+ });
299
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 waitForLayout();
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 waitForLayout();
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 waitForLayout();
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 waitForLayout();
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 waitForLayout();
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;