@schukai/monster 4.142.3 → 4.143.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.142.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.143.0"}
@@ -76,7 +76,7 @@ import {
76
76
  import { getDocumentTranslations } from "../../i18n/translations.mjs";
77
77
  import "../state/state.mjs";
78
78
  import "../host/collapse.mjs";
79
- import { generateUniqueConfigKey } from "../host/util.mjs";
79
+ import { generateComponentConfigKey } from "../host/util.mjs";
80
80
 
81
81
  import "./datasource/dom.mjs";
82
82
  import "./datasource/rest.mjs";
@@ -279,6 +279,10 @@ class DataTable extends CustomElement {
279
279
  "row-key": null,
280
280
  "filter-id": null,
281
281
  },
282
+
283
+ config: {
284
+ instanceKey: null,
285
+ },
282
286
  },
283
287
  initOptionsFromArguments.call(this),
284
288
  );
@@ -659,15 +663,21 @@ class DataTable extends CustomElement {
659
663
  * @return {string}
660
664
  */
661
665
  function getColumnVisibilityConfigKey() {
662
- return generateUniqueConfigKey("datatable", this?.id, "columns-visibility");
666
+ return generateComponentConfigKey("datatable", this?.id, "columns-visibility", {
667
+ instanceKey: this.getOption("config.instanceKey"),
668
+ });
663
669
  }
664
670
 
665
671
  /**
666
672
  * @private
667
673
  * @return {string}
668
674
  */
669
- function getFilterConfigKey() {
670
- return generateUniqueConfigKey("datatable", this?.id, "filter");
675
+ function hasConfigIdentity() {
676
+ return (
677
+ (isString(this.id) && this.id !== "") ||
678
+ (isString(this.getOption("config.instanceKey")) &&
679
+ this.getOption("config.instanceKey").trim() !== "")
680
+ );
671
681
  }
672
682
 
673
683
  /**
@@ -682,8 +692,11 @@ function getHostConfig(callback) {
682
692
  return Promise.resolve({});
683
693
  }
684
694
 
685
- if (!this.id) {
686
- addErrorAttribute(this, "no id found; id is required for config");
695
+ if (!hasConfigIdentity.call(this)) {
696
+ addErrorAttribute(
697
+ this,
698
+ "no id or config.instanceKey found; one is required for config",
699
+ );
687
700
  return Promise.resolve({});
688
701
  }
689
702
 
@@ -782,7 +795,7 @@ function updateConfigColumnBar() {
782
795
  }
783
796
 
784
797
  const host = findElementWithSelectorUpwards(this, "monster-host");
785
- if (!(host && this.id)) {
798
+ if (!(host && hasConfigIdentity.call(this))) {
786
799
  return;
787
800
  }
788
801
  const configKey = getColumnVisibilityConfigKey.call(this);
@@ -1616,7 +1629,9 @@ function getTranslations() {
1616
1629
  * @return {string}
1617
1630
  */
1618
1631
  export function getStoredOrderConfigKey() {
1619
- return generateUniqueConfigKey("datatable", this?.id, "stored-order");
1632
+ return generateComponentConfigKey("datatable", this?.id, "stored-order", {
1633
+ instanceKey: this.getOption("config.instanceKey"),
1634
+ });
1620
1635
  }
1621
1636
 
1622
1637
  /**
@@ -1629,7 +1644,7 @@ function storeOrderStatement(doFetch) {
1629
1644
  setDataSource.call(this, { order: statement }, doFetch);
1630
1645
 
1631
1646
  const host = findElementWithSelectorUpwards(this, "monster-host");
1632
- if (!(host && this.id)) {
1647
+ if (!(host && hasConfigIdentity.call(this))) {
1633
1648
  return;
1634
1649
  }
1635
1650
 
@@ -12,14 +12,16 @@
12
12
  * SPDX-License-Identifier: AGPL-3.0
13
13
  */
14
14
 
15
- import { generateUniqueConfigKey } from "../../host/util.mjs";
15
+ import { generateComponentConfigKey } from "../../host/util.mjs";
16
16
 
17
17
  /**
18
18
  * @private
19
19
  * @return {string}
20
20
  */
21
21
  export function getFilterConfigKey() {
22
- return generateUniqueConfigKey("datatable", this?.id, "filter");
22
+ return generateComponentConfigKey("datatable", this?.id, "filter", {
23
+ instanceKey: this.getOption("config.instanceKey"),
24
+ });
23
25
  }
24
26
 
25
27
  /**
@@ -27,7 +29,9 @@ export function getFilterConfigKey() {
27
29
  * @return {string}
28
30
  */
29
31
  export function getStoredFilterConfigKey() {
30
- return generateUniqueConfigKey("datatable", this?.id, "stored-filter");
32
+ return generateComponentConfigKey("datatable", this?.id, "stored-filter", {
33
+ instanceKey: this.getOption("config.instanceKey"),
34
+ });
31
35
  }
32
36
 
33
37
  /**
@@ -293,6 +293,10 @@ class Filter extends CustomElement {
293
293
  selector: "",
294
294
  },
295
295
 
296
+ config: {
297
+ instanceKey: null,
298
+ },
299
+
296
300
  timeouts: {
297
301
  message: 4000,
298
302
  },
@@ -915,7 +919,7 @@ function initEventHandler() {
915
919
  .then(() => {
916
920
  const configKey = getStoredFilterConfigKey.call(self);
917
921
  const host = getDocument().querySelector("monster-host");
918
- if (!host) {
922
+ if (!(host && hasConfigIdentity.call(self))) {
919
923
  return;
920
924
  }
921
925
 
@@ -1070,7 +1074,7 @@ function initTabEvents() {
1070
1074
  }
1071
1075
 
1072
1076
  const host = findElementWithSelectorUpwards(this, "monster-host");
1073
- if (!(host && this.id)) {
1077
+ if (!(host && hasConfigIdentity.call(this))) {
1074
1078
  return;
1075
1079
  }
1076
1080
 
@@ -1111,7 +1115,7 @@ function updateFilterTabs() {
1111
1115
  }
1112
1116
 
1113
1117
  const host = findElementWithSelectorUpwards(this, "monster-host");
1114
- if (!(host && this.id)) {
1118
+ if (!(host && hasConfigIdentity.call(this))) {
1115
1119
  return;
1116
1120
  }
1117
1121
 
@@ -1580,7 +1584,7 @@ function getControlValuesFromLabel(label) {
1580
1584
  function initFromConfig() {
1581
1585
  const host = findElementWithSelectorUpwards(this, "monster-host");
1582
1586
 
1583
- if (!(isInstance(host, Host) && this.id)) {
1587
+ if (!(isInstance(host, Host) && hasConfigIdentity.call(this))) {
1584
1588
  return Promise.resolve();
1585
1589
  }
1586
1590
 
@@ -1622,7 +1626,7 @@ function initFromConfig() {
1622
1626
  */
1623
1627
  function updateConfig() {
1624
1628
  const host = findElementWithSelectorUpwards(this, "monster-host");
1625
- if (!(host && this.id)) {
1629
+ if (!(host && hasConfigIdentity.call(this))) {
1626
1630
  return;
1627
1631
  }
1628
1632
  const configKey = getFilterConfigKey.call(this);
@@ -1634,6 +1638,18 @@ function updateConfig() {
1634
1638
  }
1635
1639
  }
1636
1640
 
1641
+ /**
1642
+ * @private
1643
+ * @return {boolean}
1644
+ */
1645
+ function hasConfigIdentity() {
1646
+ return (
1647
+ (isString(this.id) && this.id !== "") ||
1648
+ (isString(this.getOption("config.instanceKey")) &&
1649
+ this.getOption("config.instanceKey").trim() !== "")
1650
+ );
1651
+ }
1652
+
1637
1653
  /**
1638
1654
  * @private
1639
1655
  * @return {string}
@@ -2088,6 +2088,12 @@ function fetchIt(url, controlOptions) {
2088
2088
 
2089
2089
  queueMicrotask(() => {
2090
2090
  checkOptionState.call(this);
2091
+ if (
2092
+ getFilterMode.call(this) === FILTER_MODE_REMOTE &&
2093
+ this.getOption("options", []).length === 0
2094
+ ) {
2095
+ calcAndSetOptionsDimension.call(this);
2096
+ }
2091
2097
  setTotalText.call(this);
2092
2098
  updatePopper.call(this);
2093
2099
  setStatusOrRemoveBadges.call(this, "closed");
@@ -2509,14 +2515,22 @@ function scheduleSelectLayoutCycle(
2509
2515
  }
2510
2516
 
2511
2517
  function hasConfiguredOptionsWaitingForRender() {
2518
+ if (getFilterMode.call(this) === FILTER_MODE_REMOTE) {
2519
+ return false;
2520
+ }
2521
+
2512
2522
  const options = this.getOption("options");
2513
2523
 
2514
2524
  if (isArray(options)) {
2515
- return options.length > 0;
2525
+ if (options.length > 0) {
2526
+ return true;
2527
+ }
2528
+
2529
+ return getSlottedElements.call(this, "div").length > 0;
2516
2530
  }
2517
2531
 
2518
2532
  if (!isIterable(options) || isString(options)) {
2519
- return false;
2533
+ return getSlottedElements.call(this, "div").length > 0;
2520
2534
  }
2521
2535
 
2522
2536
  for (const option of options) {
@@ -2546,7 +2560,9 @@ function retryPendingOpenIntent() {
2546
2560
  }
2547
2561
 
2548
2562
  if (getOptionElements.call(this).length === 0) {
2549
- clearPendingOpenIntent.call(this);
2563
+ if (!hasConfiguredOptionsWaitingForRender.call(this)) {
2564
+ clearPendingOpenIntent.call(this);
2565
+ }
2550
2566
  return;
2551
2567
  }
2552
2568
 
@@ -3658,6 +3674,8 @@ function getSelectPopperPositionOptions() {
3658
3674
  popperOptions.placement = getDefaultSelectPopperPositionProfile().placement;
3659
3675
  }
3660
3676
 
3677
+ popperOptions.adaptiveSize = false;
3678
+
3661
3679
  if (
3662
3680
  resolveParentPopperContentBoundary(
3663
3681
  this[controlElementSymbol],
@@ -4969,6 +4987,8 @@ function show() {
4969
4987
  const options = getOptionElements.call(this);
4970
4988
  if (options.length === 0 && hasPopperFilterFlag === false) {
4971
4989
  if (hasConfiguredOptionsWaitingForRender.call(this)) {
4990
+ removeErrorAttribute(this, "No options available.");
4991
+ setStatusOrRemoveBadges.call(this, "loading");
4972
4992
  queuePendingOpenIntent.call(this);
4973
4993
  }
4974
4994
  return;
@@ -27,6 +27,12 @@ const jobs = new Map();
27
27
  let queueFrame = null;
28
28
  let queueTimeout = null;
29
29
  let flushing = false;
30
+ const OSCILLATION_HISTORY_LIMIT = 4;
31
+ const OSCILLATION_TIME_WINDOW_MS = 1000;
32
+ const OSCILLATION_SAFE_REASONS =
33
+ FLOATING_LAYOUT_REASON.POSITION |
34
+ FLOATING_LAYOUT_REASON.RESIZE |
35
+ FLOATING_LAYOUT_REASON.SETTLE;
30
36
 
31
37
  function enqueueFloatingLayout({
32
38
  popperElement,
@@ -105,6 +111,7 @@ function createJob(popperElement) {
105
111
  running: false,
106
112
  pending: false,
107
113
  cancelled: false,
114
+ layoutSignatures: [],
108
115
  promise,
109
116
  resolve,
110
117
  };
@@ -220,9 +227,67 @@ async function flushJob(job) {
220
227
  }
221
228
  } finally {
222
229
  job.running = false;
230
+ recordLayoutSignature(job);
231
+ if (job.pending && shouldSuppressOscillatingPendingLayout(job)) {
232
+ job.pending = false;
233
+ job.reasons = 0;
234
+ }
223
235
  if (!job.pending && job.reasons === 0) {
224
236
  job.resolve();
225
237
  jobs.delete(job.popperElement);
226
238
  }
227
239
  }
228
240
  }
241
+
242
+ function recordLayoutSignature(job) {
243
+ if (!(job?.popperElement instanceof HTMLElement)) {
244
+ return;
245
+ }
246
+
247
+ const rect = job.popperElement.getBoundingClientRect();
248
+ const signature = [
249
+ Math.round(rect.x),
250
+ Math.round(rect.y),
251
+ Math.round(rect.width),
252
+ Math.round(rect.height),
253
+ ].join("/");
254
+
255
+ job.layoutSignatures.push({
256
+ signature,
257
+ time: performanceNow(),
258
+ });
259
+ if (job.layoutSignatures.length > OSCILLATION_HISTORY_LIMIT) {
260
+ job.layoutSignatures.shift();
261
+ }
262
+ }
263
+
264
+ function shouldSuppressOscillatingPendingLayout(job) {
265
+ if ((job.reasons & ~OSCILLATION_SAFE_REASONS) !== 0) {
266
+ return false;
267
+ }
268
+
269
+ const signatures = job.layoutSignatures;
270
+ if (signatures.length < OSCILLATION_HISTORY_LIMIT) {
271
+ return false;
272
+ }
273
+
274
+ const [a, b, c, d] = signatures;
275
+ if (d.time - a.time > OSCILLATION_TIME_WINDOW_MS) {
276
+ return false;
277
+ }
278
+
279
+ return (
280
+ a.signature === c.signature &&
281
+ b.signature === d.signature &&
282
+ a.signature !== b.signature
283
+ );
284
+ }
285
+
286
+ function performanceNow() {
287
+ const globalObject = getGlobal();
288
+ if (globalObject?.performance?.now instanceof Function) {
289
+ return globalObject.performance.now();
290
+ }
291
+
292
+ return Date.now();
293
+ }
@@ -132,9 +132,12 @@ function syncFloatingPopover(
132
132
  config.detectOverflowOptions,
133
133
  popperElement,
134
134
  syncCycleId,
135
+ config.adaptiveSize,
135
136
  );
136
137
 
137
- resetAdaptiveFloatingElementSize(popperElement);
138
+ if (config.adaptiveSize !== false) {
139
+ resetAdaptiveFloatingElementSize(popperElement);
140
+ }
138
141
 
139
142
  if (
140
143
  arrowElement instanceof HTMLElement &&
@@ -143,7 +146,10 @@ function syncFloatingPopover(
143
146
  floatingMiddleware.push(arrow({ element: arrowElement }));
144
147
  }
145
148
 
146
- if (!config.middlewareTokens.includes("size")) {
149
+ if (
150
+ config.adaptiveSize !== false &&
151
+ !config.middlewareTokens.includes("size")
152
+ ) {
147
153
  floatingMiddleware.push(
148
154
  createAdaptiveSizeMiddleware(
149
155
  config.detectOverflowOptions,
@@ -206,6 +212,7 @@ function normalizePopperConfig(options, controlElement, popperElement) {
206
212
  placement: "top",
207
213
  engine: "floating",
208
214
  strategy: "absolute",
215
+ adaptiveSize: true,
209
216
  },
210
217
  options,
211
218
  );
@@ -247,6 +254,7 @@ function buildFloatingMiddleware(
247
254
  detectOverflowOptions,
248
255
  popperElement,
249
256
  syncCycleId = null,
257
+ adaptiveSize = true,
250
258
  ) {
251
259
  const result = [...middleware];
252
260
 
@@ -272,7 +280,7 @@ function buildFloatingMiddleware(
272
280
  case "shift":
273
281
  result[key] = shift(normalizeShiftOptions(kv, detectOverflowOptions));
274
282
  break;
275
- case "autoPlacement":
283
+ case "autoPlacement": {
276
284
  let defaultAllowedPlacements = ["top", "bottom", "left", "right"];
277
285
 
278
286
  const defPlacement = kv?.shift();
@@ -298,18 +306,22 @@ function buildFloatingMiddleware(
298
306
  }),
299
307
  );
300
308
  break;
309
+ }
301
310
  case "arrow":
302
311
  result[key] = null;
303
312
  break;
304
313
  case "size":
305
- result[key] = createAdaptiveSizeMiddleware(
306
- detectOverflowOptions,
307
- popperElement,
308
- syncCycleId,
309
- );
314
+ result[key] =
315
+ adaptiveSize === false
316
+ ? null
317
+ : createAdaptiveSizeMiddleware(
318
+ detectOverflowOptions,
319
+ popperElement,
320
+ syncCycleId,
321
+ );
310
322
  break;
311
323
  case "offset":
312
- result[key] = offset(parseInt(kv?.shift()) || 10);
324
+ result[key] = offset(parseInt(kv?.shift(), 10) || 10);
313
325
  break;
314
326
  case "hide":
315
327
  result[key] = hide(detectOverflowOptions);
@@ -21,6 +21,7 @@ import { ConfigManagerStyleSheet } from "./stylesheet/config-manager.mjs";
21
21
  import { getWindow } from "../../dom/util.mjs";
22
22
  import { instanceSymbol } from "../../constants.mjs";
23
23
  import { diff } from "../../data/diff.mjs";
24
+ import { isFunction, isObject } from "../../types/is.mjs";
24
25
 
25
26
  export { ConfigManager };
26
27
 
@@ -108,17 +109,42 @@ class ConfigManager extends CustomElement {
108
109
  keyPath: "key",
109
110
  },
110
111
  },
112
+ storage: {
113
+ adapter: null,
114
+ serverAuthoritative: false,
115
+ },
111
116
  });
112
117
  }
113
118
 
119
+ /**
120
+ * Register an external host configuration storage adapter.
121
+ *
122
+ * Supported adapter methods are getConfig/hasConfig/setConfig/deleteConfig
123
+ * or their short aliases get/has/set/delete. If managesConfigKey, managesKey
124
+ * or manages is present, it is used to decide whether a key is handled by
125
+ * the adapter. Without such a method, an adapter handles all keys.
126
+ *
127
+ * @param {Object|null} adapter
128
+ * @return {ConfigManager}
129
+ */
130
+ setStorageAdapter(adapter) {
131
+ this.setOption("storage.adapter", adapter);
132
+ return this;
133
+ }
134
+
135
+ /**
136
+ * @return {Object|null}
137
+ */
138
+ getStorageAdapter() {
139
+ return getStorageAdapter.call(this);
140
+ }
141
+
114
142
  /**
115
143
  * @param {string} key
116
144
  * @return {Promise<unknown>}
117
145
  */
118
146
  getConfig(key) {
119
- return this.ready().then(() => {
120
- return getBlob.call(this, key);
121
- });
147
+ return this.ready().then(() => readConfig.call(this, key));
122
148
  }
123
149
 
124
150
  /**
@@ -126,16 +152,7 @@ class ConfigManager extends CustomElement {
126
152
  * @return {Promise<boolean>}
127
153
  */
128
154
  hasConfig(key) {
129
- return this.ready()
130
- .then(() => {
131
- return getBlob.call(this, key);
132
- })
133
- .then(() => {
134
- return true;
135
- })
136
- .catch(() => {
137
- return false;
138
- });
155
+ return this.ready().then(() => hasConfig.call(this, key));
139
156
  }
140
157
 
141
158
  /**
@@ -144,28 +161,11 @@ class ConfigManager extends CustomElement {
144
161
  * @return {Promise<unknown>}
145
162
  */
146
163
  setConfig(key, value) {
147
- return this.ready().then(() => {
148
- return getBlob
149
- .call(this, key)
150
- .then((storedValue) => {
151
- if (diff(storedValue, value).length === 0) {
152
- return;
153
- }
154
- return setBlob.call(this, key, value);
155
- })
156
- .catch((error) => {
157
- if (error?.message?.match(/is not defined/)) {
158
- return setBlob.call(this, key, value);
159
- }
160
- throw error;
161
- });
162
- });
164
+ return this.ready().then(() => writeConfig.call(this, key, value));
163
165
  }
164
166
 
165
167
  deleteConfig(key) {
166
- return this.ready().then(() => {
167
- return deleteBlob.call(this, key);
168
- });
168
+ return this.ready().then(() => removeConfig.call(this, key));
169
169
  }
170
170
 
171
171
  /**
@@ -190,6 +190,192 @@ class ConfigManager extends CustomElement {
190
190
  }
191
191
  }
192
192
 
193
+ /**
194
+ * @private
195
+ * @return {Object|null}
196
+ */
197
+ function getStorageAdapter() {
198
+ const adapter = this.getOption("storage.adapter");
199
+ return isObject(adapter) ? adapter : null;
200
+ }
201
+
202
+ /**
203
+ * @private
204
+ * @param {Object} adapter
205
+ * @param {string} key
206
+ * @return {boolean}
207
+ */
208
+ function adapterManagesKey(adapter, key) {
209
+ for (const methodName of ["managesConfigKey", "managesKey", "manages"]) {
210
+ if (isFunction(adapter?.[methodName])) {
211
+ return adapter[methodName](key) === true;
212
+ }
213
+ }
214
+
215
+ return true;
216
+ }
217
+
218
+ /**
219
+ * @private
220
+ * @param {Object} adapter
221
+ * @param {string[]} methodNames
222
+ * @param {Array} args
223
+ * @return {Promise<unknown>}
224
+ */
225
+ function callAdapter(adapter, methodNames, args) {
226
+ for (const methodName of methodNames) {
227
+ if (isFunction(adapter?.[methodName])) {
228
+ return Promise.resolve(adapter[methodName](...args));
229
+ }
230
+ }
231
+
232
+ return Promise.reject(new Error("The storage adapter method is not defined."));
233
+ }
234
+
235
+ /**
236
+ * @private
237
+ * @return {boolean}
238
+ */
239
+ function isServerAuthoritative() {
240
+ return this.getOption("storage.serverAuthoritative") === true;
241
+ }
242
+
243
+ /**
244
+ * @private
245
+ * @param {string} key
246
+ * @return {Promise<unknown>}
247
+ */
248
+ function readConfig(key) {
249
+ const adapter = getStorageAdapter.call(this);
250
+ const managed = adapter && adapterManagesKey(adapter, key);
251
+
252
+ if (managed) {
253
+ return callAdapter(adapter, ["getConfig", "get"], [key]).catch((error) => {
254
+ if (isServerAuthoritative.call(this)) {
255
+ throw error;
256
+ }
257
+ return getBlob.call(this, key);
258
+ });
259
+ }
260
+
261
+ return getBlob.call(this, key);
262
+ }
263
+
264
+ /**
265
+ * @private
266
+ * @param {string} key
267
+ * @return {Promise<boolean>}
268
+ */
269
+ function hasConfig(key) {
270
+ const adapter = getStorageAdapter.call(this);
271
+ const managed = adapter && adapterManagesKey(adapter, key);
272
+
273
+ if (managed) {
274
+ return callAdapter(adapter, ["hasConfig", "has"], [key])
275
+ .catch(() => {
276
+ return callAdapter(adapter, ["getConfig", "get"], [key]).then(
277
+ () => true,
278
+ );
279
+ })
280
+ .then((result) => {
281
+ if (result === true || isServerAuthoritative.call(this)) {
282
+ return result === true;
283
+ }
284
+ return hasLocalConfig.call(this, key);
285
+ })
286
+ .catch(() => {
287
+ if (isServerAuthoritative.call(this)) {
288
+ return false;
289
+ }
290
+ return hasLocalConfig.call(this, key);
291
+ });
292
+ }
293
+
294
+ return hasLocalConfig.call(this, key);
295
+ }
296
+
297
+ /**
298
+ * @private
299
+ * @param {string} key
300
+ * @return {Promise<boolean>}
301
+ */
302
+ function hasLocalConfig(key) {
303
+ return getBlob
304
+ .call(this, key)
305
+ .then(() => true)
306
+ .catch(() => false);
307
+ }
308
+
309
+ /**
310
+ * @private
311
+ * @param {string} key
312
+ * @param {*} value
313
+ * @return {Promise<unknown>}
314
+ */
315
+ function writeConfig(key, value) {
316
+ const adapter = getStorageAdapter.call(this);
317
+ const managed = adapter && adapterManagesKey(adapter, key);
318
+
319
+ if (managed) {
320
+ return callAdapter(adapter, ["setConfig", "set"], [key, value]).catch(
321
+ (error) => {
322
+ if (isServerAuthoritative.call(this)) {
323
+ throw error;
324
+ }
325
+ return writeLocalConfig.call(this, key, value);
326
+ },
327
+ );
328
+ }
329
+
330
+ return writeLocalConfig.call(this, key, value);
331
+ }
332
+
333
+ /**
334
+ * @private
335
+ * @param {string} key
336
+ * @param {*} value
337
+ * @return {Promise<unknown>}
338
+ */
339
+ function writeLocalConfig(key, value) {
340
+ return getBlob
341
+ .call(this, key)
342
+ .then((storedValue) => {
343
+ if (diff(storedValue, value).length === 0) {
344
+ return;
345
+ }
346
+ return setBlob.call(this, key, value);
347
+ })
348
+ .catch((error) => {
349
+ if (error?.message?.match(/is not defined/)) {
350
+ return setBlob.call(this, key, value);
351
+ }
352
+ throw error;
353
+ });
354
+ }
355
+
356
+ /**
357
+ * @private
358
+ * @param {string} key
359
+ * @return {Promise<unknown>}
360
+ */
361
+ function removeConfig(key) {
362
+ const adapter = getStorageAdapter.call(this);
363
+ const managed = adapter && adapterManagesKey(adapter, key);
364
+
365
+ if (managed) {
366
+ return callAdapter(adapter, ["deleteConfig", "delete", "remove"], [
367
+ key,
368
+ ]).catch((error) => {
369
+ if (isServerAuthoritative.call(this)) {
370
+ throw error;
371
+ }
372
+ return deleteBlob.call(this, key);
373
+ });
374
+ }
375
+
376
+ return deleteBlob.call(this, key);
377
+ }
378
+
193
379
  /**
194
380
  * @private
195
381
  * @returns {Promise<unknown>}
@@ -206,6 +392,10 @@ function openDatabase() {
206
392
  throw new Error("The database name and version must be set.");
207
393
  }
208
394
 
395
+ if (!window.indexedDB) {
396
+ return Promise.resolve(null);
397
+ }
398
+
209
399
  const request = window.indexedDB.open(name, version);
210
400
 
211
401
  return new Promise((resolve, reject) => {
@@ -194,6 +194,32 @@ class Host extends CustomElement {
194
194
  return this[configManagerElementSymbol].setConfig(key, value);
195
195
  }
196
196
 
197
+ /**
198
+ * Register an external storage adapter for host configuration.
199
+ *
200
+ * @param {Object|null} adapter
201
+ * @return {Host}
202
+ */
203
+ setConfigStorageAdapter(adapter) {
204
+ if (this[configManagerElementSymbol] instanceof HTMLElement === false) {
205
+ throw new Error("There is no config manager element");
206
+ }
207
+
208
+ this[configManagerElementSymbol].setStorageAdapter(adapter);
209
+ return this;
210
+ }
211
+
212
+ /**
213
+ * @return {Object|null}
214
+ */
215
+ getConfigStorageAdapter() {
216
+ if (this[configManagerElementSymbol] instanceof HTMLElement === false) {
217
+ throw new Error("There is no config manager element");
218
+ }
219
+
220
+ return this[configManagerElementSymbol].getStorageAdapter();
221
+ }
222
+
197
223
  /**
198
224
  * @private
199
225
  * @fires Host#monster-host-connected
@@ -13,8 +13,14 @@
13
13
  */
14
14
 
15
15
  import { getWindow } from "../../dom/util.mjs";
16
+ import { isString } from "../../types/is.mjs";
16
17
 
17
- export { generateUniqueConfigKey };
18
+ export {
19
+ generateConfigKey,
20
+ generateComponentConfigKey,
21
+ generateUniqueConfigKey,
22
+ sanitizeConfigKey,
23
+ };
18
24
 
19
25
  /**
20
26
  * Generate a unique configuration key based on the current browser location,
@@ -38,5 +44,49 @@ function generateUniqueConfigKey(componentName, id, prefix) {
38
44
  const uniqueKey = `${prefix}_${urlWithoutParamsAndHash}_${componentName}_${id}`;
39
45
 
40
46
  // Replace any special characters and spaces with underscores
41
- return uniqueKey.replace(/[^\w\s]/gi, "_").replace(/\s+/g, "_");
47
+ return sanitizeConfigKey(uniqueKey);
48
+ }
49
+
50
+ /**
51
+ * Generate a stable host configuration key from an explicit technical instance key.
52
+ *
53
+ * @param {string} componentName - The name of the component.
54
+ * @param {string} instanceKey - Technical, host-defined instance key.
55
+ * @param {string} scope - Configuration scope, for example "filter".
56
+ * @return {string}
57
+ */
58
+ function generateConfigKey(componentName, instanceKey, scope) {
59
+ return sanitizeConfigKey(`${scope}_${componentName}_${instanceKey}`);
60
+ }
61
+
62
+ /**
63
+ * Generate a component host configuration key.
64
+ *
65
+ * If an explicit instance key is provided, the key is independent from the
66
+ * current URL and DOM id. Without an instance key, this keeps the historical
67
+ * URL-derived key for backwards compatibility.
68
+ *
69
+ * @param {string} componentName
70
+ * @param {string} id
71
+ * @param {string} scope
72
+ * @param {object} [options]
73
+ * @param {string} [options.instanceKey]
74
+ * @return {string}
75
+ */
76
+ function generateComponentConfigKey(componentName, id, scope, options = {}) {
77
+ if (isString(options.instanceKey) && options.instanceKey.trim() !== "") {
78
+ return generateConfigKey(componentName, options.instanceKey.trim(), scope);
79
+ }
80
+
81
+ return generateUniqueConfigKey(componentName, id, scope);
82
+ }
83
+
84
+ /**
85
+ * @param {string} key
86
+ * @return {string}
87
+ */
88
+ function sanitizeConfigKey(key) {
89
+ return String(key)
90
+ .replace(/[^\w\s]/gi, "_")
91
+ .replace(/\s+/g, "_");
42
92
  }
@@ -0,0 +1,47 @@
1
+ import { expect } from "chai";
2
+ import { initJSDOM } from "../../../util/jsdom.mjs";
3
+
4
+ let getStoredOrderConfigKey;
5
+ let getFilterConfigKey;
6
+ let getStoredFilterConfigKey;
7
+
8
+ describe("Datatable config keys", function () {
9
+ before(function (done) {
10
+ initJSDOM().then(() => {
11
+ import("../../../../source/components/datatable/datatable.mjs")
12
+ .then((m) => {
13
+ getStoredOrderConfigKey = m.getStoredOrderConfigKey;
14
+ return import(
15
+ "../../../../source/components/datatable/filter/util.mjs"
16
+ );
17
+ })
18
+ .then((m) => {
19
+ getFilterConfigKey = m.getFilterConfigKey;
20
+ getStoredFilterConfigKey = m.getStoredFilterConfigKey;
21
+ done();
22
+ })
23
+ .catch(done);
24
+ });
25
+ });
26
+
27
+ it("uses the same explicit instance-key contract for filter, stored filters and sorting", function () {
28
+ const context = {
29
+ id: "dom-id",
30
+ getOption(path) {
31
+ if (path === "config.instanceKey") {
32
+ return "orders.default";
33
+ }
34
+ },
35
+ };
36
+
37
+ expect(getFilterConfigKey.call(context)).to.equal(
38
+ "filter_datatable_orders_default",
39
+ );
40
+ expect(getStoredFilterConfigKey.call(context)).to.equal(
41
+ "stored_filter_datatable_orders_default",
42
+ );
43
+ expect(getStoredOrderConfigKey.call(context)).to.equal(
44
+ "stored_order_datatable_orders_default",
45
+ );
46
+ });
47
+ });
@@ -0,0 +1,86 @@
1
+ import * as chai from "chai";
2
+ import { initJSDOM } from "../../../util/jsdom.mjs";
3
+
4
+ const expect = chai.expect;
5
+
6
+ describe("ConfigManager storage adapter", function () {
7
+ before(function (done) {
8
+ initJSDOM().then(() => {
9
+ import("../../../../source/components/host/config-manager.mjs")
10
+ .then(() => {
11
+ done();
12
+ })
13
+ .catch((e) => done(e));
14
+ });
15
+ });
16
+
17
+ afterEach(() => {
18
+ let mocks = document.getElementById("mocks");
19
+ mocks.innerHTML = "";
20
+ });
21
+
22
+ it("uses an external storage adapter for managed keys", function (done) {
23
+ const config = document.createElement("monster-config-manager");
24
+ const values = new Map();
25
+ const calls = [];
26
+
27
+ config.setStorageAdapter({
28
+ managesConfigKey: (key) => key.startsWith("server_"),
29
+ hasConfig: (key) => values.has(key),
30
+ getConfig: (key) => values.get(key),
31
+ setConfig: (key, value) => {
32
+ calls.push(["set", key, value]);
33
+ values.set(key, value);
34
+ },
35
+ deleteConfig: (key) => {
36
+ calls.push(["delete", key]);
37
+ values.delete(key);
38
+ },
39
+ });
40
+
41
+ config.setOption("storage.serverAuthoritative", true);
42
+
43
+ config
44
+ .setConfig("server_table_filter", { visible: true })
45
+ .then(() => config.hasConfig("server_table_filter"))
46
+ .then((hasConfig) => {
47
+ expect(hasConfig).to.equal(true);
48
+ return config.getConfig("server_table_filter");
49
+ })
50
+ .then((value) => {
51
+ expect(value).to.deep.equal({ visible: true });
52
+ expect(calls).to.deep.equal([
53
+ ["set", "server_table_filter", { visible: true }],
54
+ ]);
55
+ return config.deleteConfig("server_table_filter");
56
+ })
57
+ .then(() => {
58
+ expect(values.has("server_table_filter")).to.equal(false);
59
+ done();
60
+ })
61
+ .catch(done);
62
+ });
63
+
64
+ it("does not fall back to IndexedDB for server-authoritative adapter keys", function (done) {
65
+ const config = document.createElement("monster-config-manager");
66
+
67
+ config.setStorageAdapter({
68
+ managesConfigKey: (key) => key === "server_key",
69
+ setConfig: () => Promise.reject(new Error("server unavailable")),
70
+ });
71
+ config.setOption("storage.serverAuthoritative", true);
72
+ config.setOption("indexDB.objectStore.name", "missing-store");
73
+
74
+ config
75
+ .setConfig("server_key", { value: 1 })
76
+ .then(() => done(new Error("setConfig should reject")))
77
+ .catch((error) => {
78
+ try {
79
+ expect(error.message).to.equal("server unavailable");
80
+ done();
81
+ } catch (e) {
82
+ done(e);
83
+ }
84
+ });
85
+ });
86
+ });
@@ -1,79 +1,105 @@
1
1
  // Import the required libraries
2
- import { expect } from 'chai';
2
+ import { expect } from "chai";
3
3
  //import { JSDOM } from 'jsdom';
4
- import { generateUniqueConfigKey } from '../../../../source/components/host/util.mjs';
5
- import {initJSDOM} from "../../../util/jsdom.mjs";
4
+ import {
5
+ generateComponentConfigKey,
6
+ generateConfigKey,
7
+ generateUniqueConfigKey,
8
+ } from "../../../../source/components/host/util.mjs";
9
+ import { initJSDOM } from "../../../util/jsdom.mjs";
6
10
 
7
11
  // Create a JSDOM instance to simulate the browser environment
8
12
  //const dom = new JSDOM();
9
13
 
10
-
11
14
  // Test suite for the generateUniqueConfigKey function
12
- describe('generateUniqueConfigKey', () => {
13
-
14
- //let originalWindow;
15
-
16
- // before(() => {
17
- // // Store the original window object
18
- // originalWindow = globalThis.window;
19
- //
20
- // // Create a JSDOM instance to simulate the browser environment
21
- // const dom = new JSDOM();
22
- // globalThis.window = dom.window;
23
- // });
24
-
25
-
26
- before(function (done) {
27
- initJSDOM().then(() => {
28
- done();
29
- });
30
- })
31
-
32
- // ... (same test cases as before)
33
-
34
- after(() => {
35
- // Restore the original window object
36
- // globalThis.window = originalWindow;
37
- });
38
-
39
- it('should generate a unique key with the given parameters', () => {
40
- const componentName = 'MyComponent';
41
- const id = '123';
42
- const prefix = 'myPrefix';
43
-
44
- const uniqueKey = generateUniqueConfigKey(componentName, id, prefix);
45
-
46
- // Ensure the unique key contains the given parameters and follows the expected format
47
- expect(uniqueKey).to.include(prefix);
48
- expect(uniqueKey).to.include(componentName);
49
- expect(uniqueKey).to.include(id);
50
- expect(uniqueKey).to.match(/^[a-zA-Z0-9_]+$/);
51
- });
52
-
53
- it('should replace special characters and spaces with underscores', () => {
54
- const componentName = 'My$Component';
55
- const id = '12#3';
56
- const prefix = 'my Prefix';
57
-
58
- const uniqueKey = generateUniqueConfigKey(componentName, id, prefix);
59
-
60
- // Ensure the unique key does not contain any special characters or spaces
61
- expect(uniqueKey).to.match(/^[a-zA-Z0-9_]+$/);
62
- });
63
-
64
- it('should include the browser location without parameters', () => {
65
- const componentName = 'MyComponent';
66
- const id = '123';
67
- const prefix = 'myPrefix';
68
-
69
- const uniqueKey = generateUniqueConfigKey(componentName, id, prefix);
70
-
71
- // Ensure the unique key contains the browser location without parameters
72
- const urlWithoutParams = window.location.href.split('?')[0];
73
- const sanitizedUrl = urlWithoutParams.replace(/[^\w\s]/gi, '_').replace(/\s+/g, '_');
74
- expect(uniqueKey).to.include(sanitizedUrl);
75
- });
76
-
77
-
78
-
79
- })
15
+ describe("generateUniqueConfigKey", () => {
16
+ //let originalWindow;
17
+
18
+ // before(() => {
19
+ // // Store the original window object
20
+ // originalWindow = globalThis.window;
21
+ //
22
+ // // Create a JSDOM instance to simulate the browser environment
23
+ // const dom = new JSDOM();
24
+ // globalThis.window = dom.window;
25
+ // });
26
+
27
+ before(function (done) {
28
+ initJSDOM().then(() => {
29
+ done();
30
+ });
31
+ });
32
+
33
+ // ... (same test cases as before)
34
+
35
+ after(() => {
36
+ // Restore the original window object
37
+ // globalThis.window = originalWindow;
38
+ });
39
+
40
+ it("should generate a unique key with the given parameters", () => {
41
+ const componentName = "MyComponent";
42
+ const id = "123";
43
+ const prefix = "myPrefix";
44
+
45
+ const uniqueKey = generateUniqueConfigKey(componentName, id, prefix);
46
+
47
+ // Ensure the unique key contains the given parameters and follows the expected format
48
+ expect(uniqueKey).to.include(prefix);
49
+ expect(uniqueKey).to.include(componentName);
50
+ expect(uniqueKey).to.include(id);
51
+ expect(uniqueKey).to.match(/^[a-zA-Z0-9_]+$/);
52
+ });
53
+
54
+ it("should replace special characters and spaces with underscores", () => {
55
+ const componentName = "My$Component";
56
+ const id = "12#3";
57
+ const prefix = "my Prefix";
58
+
59
+ const uniqueKey = generateUniqueConfigKey(componentName, id, prefix);
60
+
61
+ // Ensure the unique key does not contain any special characters or spaces
62
+ expect(uniqueKey).to.match(/^[a-zA-Z0-9_]+$/);
63
+ });
64
+
65
+ it("should include the browser location without parameters", () => {
66
+ const componentName = "MyComponent";
67
+ const id = "123";
68
+ const prefix = "myPrefix";
69
+
70
+ const uniqueKey = generateUniqueConfigKey(componentName, id, prefix);
71
+
72
+ // Ensure the unique key contains the browser location without parameters
73
+ const urlWithoutParams = window.location.href.split("?")[0];
74
+ const sanitizedUrl = urlWithoutParams
75
+ .replace(/[^\w\s]/gi, "_")
76
+ .replace(/\s+/g, "_");
77
+ expect(uniqueKey).to.include(sanitizedUrl);
78
+ });
79
+
80
+ it("should generate explicit instance keys without the browser location", () => {
81
+ const uniqueKey = generateConfigKey(
82
+ "datatable",
83
+ "orders.default",
84
+ "filter",
85
+ );
86
+
87
+ expect(uniqueKey).to.equal("filter_datatable_orders_default");
88
+ expect(uniqueKey).to.not.include("example_test");
89
+ });
90
+
91
+ it("should prefer explicit instance keys over legacy URL keys", () => {
92
+ const uniqueKey = generateComponentConfigKey(
93
+ "datatable",
94
+ "dom-id",
95
+ "stored-order",
96
+ {
97
+ instanceKey: "orders.default",
98
+ },
99
+ );
100
+
101
+ expect(uniqueKey).to.equal("stored_order_datatable_orders_default");
102
+ expect(uniqueKey).to.not.include("dom-id");
103
+ expect(uniqueKey).to.not.include("example_test");
104
+ });
105
+ });
@@ -25,8 +25,10 @@ import "../cases/components/notify/message.mjs";
25
25
  import "../cases/components/notify/notify.mjs";
26
26
  import "../cases/components/host/host.mjs";
27
27
  import "../cases/components/host/overlay.mjs";
28
+ import "../cases/components/host/config-manager.mjs";
28
29
  import "../cases/components/host/util.mjs";
29
30
  import "../cases/components/host/details.mjs";
31
+ import "../cases/components/datatable/config-keys.mjs";
30
32
  import "../cases/components/datatable/writeback-sanitizer.mjs";
31
33
  import "../cases/components/datatable/pagination.mjs";
32
34
  import "../cases/components/navigation/site-navigation.mjs";