@schukai/monster 4.71.0 → 4.72.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/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
 
4
4
 
5
+ ## [4.72.0] - 2026-01-03
6
+
7
+ ### Add Features
8
+
9
+ - Implement new fetch columns feature for issue [#360](https://gitlab.schukai.com/oss/libraries/javascript/monster/issues/360)
10
+ ### Changes
11
+
12
+ - update tests
13
+
14
+
15
+
5
16
  ## [4.71.0] - 2026-01-03
6
17
 
7
18
  ### Add Features
package/package.json CHANGED
@@ -1 +1 @@
1
- {"author":"Volker Schukai","dependencies":{"@floating-ui/dom":"^1.7.4","@popperjs/core":"^2.11.8"},"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.71.0"}
1
+ {"author":"Volker Schukai","dependencies":{"@floating-ui/dom":"^1.7.4","@popperjs/core":"^2.11.8"},"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.72.0"}
@@ -30,7 +30,7 @@ import { findElementWithIdUpwards } from "../../../dom/util.mjs";
30
30
  import { Observer } from "../../../types/observer.mjs";
31
31
  import { Pathfinder } from "../../../data/pathfinder.mjs";
32
32
  import { fireCustomEvent } from "../../../dom/events.mjs";
33
- import { isObject } from "../../../types/is.mjs";
33
+ import { isArray, isFunction, isObject, isString } from "../../../types/is.mjs";
34
34
 
35
35
  export { Rest };
36
36
 
@@ -64,6 +64,8 @@ const intersectionObserverObserverSymbol = Symbol(
64
64
  const filterObserverSymbol = Symbol("filterObserver");
65
65
  const readRequestIdSymbol = Symbol("readRequestId");
66
66
  const writeRequestIdSymbol = Symbol("writeRequestId");
67
+ const lookupCacheSymbol = Symbol("lookupCache");
68
+ const lookupPendingSymbol = Symbol("lookupPending");
67
69
 
68
70
  /**
69
71
  * A rest api datasource
@@ -174,6 +176,27 @@ class Rest extends Datasource {
174
176
  validation: {
175
177
  map: {},
176
178
  },
179
+
180
+ lookups: {
181
+ enabled: false,
182
+ sourcePath: "dataset",
183
+ request: {
184
+ idsParam: "ids",
185
+ idsSeparator: ",",
186
+ },
187
+ response: {
188
+ path: "dataset",
189
+ id: "id",
190
+ },
191
+ format: {
192
+ template: "${name}",
193
+ marker: {
194
+ open: ["${"],
195
+ close: ["}"],
196
+ },
197
+ },
198
+ columns: {},
199
+ },
177
200
  });
178
201
  }
179
202
 
@@ -315,6 +338,7 @@ class Rest extends Datasource {
315
338
  return;
316
339
  }
317
340
  this[dataSourceSymbol].set(transformedPayload);
341
+ applyLookups.call(this, requestId).catch(() => {});
318
342
  };
319
343
  this[dataSourceSymbol].setOption("read", opt);
320
344
 
@@ -708,6 +732,344 @@ function handleValidationError(error) {
708
732
  .catch(() => {});
709
733
  }
710
734
 
735
+ /**
736
+ * @private
737
+ * @param {number} requestId
738
+ * @return {Promise<void>}
739
+ */
740
+ async function applyLookups(requestId) {
741
+ if (!this.getOption("lookups.enabled", false)) {
742
+ return;
743
+ }
744
+
745
+ const columns = this.getOption("lookups.columns", {});
746
+ if (!isObject(columns)) {
747
+ return;
748
+ }
749
+
750
+ const columnEntries = Object.entries(columns).filter(([, cfg]) =>
751
+ isObject(cfg),
752
+ );
753
+ if (columnEntries.length === 0) {
754
+ return;
755
+ }
756
+
757
+ const resolverColumns = [];
758
+ const remoteColumns = [];
759
+ for (const [name, cfg] of columnEntries) {
760
+ if (isFunction(cfg.resolve)) {
761
+ resolverColumns.push([name, cfg]);
762
+ } else if (isString(cfg.url) && cfg.url !== "") {
763
+ remoteColumns.push([name, cfg]);
764
+ }
765
+ }
766
+
767
+ if (resolverColumns.length > 0) {
768
+ updateLookupRows.call(this, requestId, (rows) => {
769
+ for (const [, cfg] of resolverColumns) {
770
+ const key = cfg.key;
771
+ const target = cfg.target;
772
+ if (!isString(key) || !isString(target)) {
773
+ continue;
774
+ }
775
+
776
+ for (const row of rows) {
777
+ const entry = cfg.resolve(row[key], row);
778
+ if (!entry) {
779
+ continue;
780
+ }
781
+ row[target] = formatLookupValue.call(this, cfg, entry, row);
782
+ if (isString(cfg.loadingKey)) {
783
+ row[cfg.loadingKey] = false;
784
+ }
785
+ }
786
+ }
787
+ });
788
+ }
789
+
790
+ if (remoteColumns.length === 0) {
791
+ return;
792
+ }
793
+
794
+ await Promise.all(
795
+ remoteColumns.map(([name, cfg]) =>
796
+ resolveRemoteLookup.call(this, requestId, name, cfg),
797
+ ),
798
+ );
799
+ }
800
+
801
+ /**
802
+ * @private
803
+ * @param {number} requestId
804
+ * @param {string} name
805
+ * @param {object} cfg
806
+ * @return {Promise<void>}
807
+ */
808
+ async function resolveRemoteLookup(requestId, name, cfg) {
809
+ const key = cfg.key;
810
+ const target = cfg.target;
811
+ if (!isString(key) || !isString(target)) {
812
+ return;
813
+ }
814
+
815
+ const cache = getLookupCache.call(this, name);
816
+ const pending = getLookupPending.call(this, name);
817
+
818
+ const ids = collectLookupIds.call(this, key);
819
+ const missingIds = ids.filter((id) => !cache.has(id) && !pending.has(id));
820
+
821
+ if (missingIds.length === 0) {
822
+ applyLookupCache.call(this, requestId, cfg, cache);
823
+ return;
824
+ }
825
+
826
+ updateLookupRows.call(this, requestId, (rows) => {
827
+ if (!isString(cfg.loadingKey)) return;
828
+ for (const row of rows) {
829
+ if (missingIds.includes(String(row[key]))) {
830
+ row[cfg.loadingKey] = true;
831
+ }
832
+ }
833
+ });
834
+
835
+ missingIds.forEach((id) => pending.add(id));
836
+
837
+ const response = await fetchLookupEntries.call(this, cfg, missingIds);
838
+ response.forEach((entry, id) => cache.set(id, entry));
839
+ missingIds.forEach((id) => pending.delete(id));
840
+
841
+ applyLookupCache.call(this, requestId, cfg, cache);
842
+ }
843
+
844
+ /**
845
+ * @private
846
+ * @param {number} requestId
847
+ * @param {object} cfg
848
+ * @param {Map} cache
849
+ */
850
+ function applyLookupCache(requestId, cfg, cache) {
851
+ const key = cfg.key;
852
+ const target = cfg.target;
853
+
854
+ updateLookupRows.call(this, requestId, (rows) => {
855
+ for (const row of rows) {
856
+ const entry = cache.get(String(row[key]));
857
+ if (entry) {
858
+ row[target] = formatLookupValue.call(this, cfg, entry, row);
859
+ }
860
+ if (isString(cfg.loadingKey)) {
861
+ row[cfg.loadingKey] = false;
862
+ }
863
+ }
864
+ });
865
+ }
866
+
867
+ /**
868
+ * @private
869
+ * @param {string} key
870
+ * @return {string[]}
871
+ */
872
+ function collectLookupIds(key) {
873
+ const rows = resolveLookupRows.call(this);
874
+ if (!isArray(rows)) {
875
+ return [];
876
+ }
877
+
878
+ const ids = new Set();
879
+ for (const row of rows) {
880
+ const value = row?.[key];
881
+ if (value === undefined || value === null || value === "") {
882
+ continue;
883
+ }
884
+ if (isArray(value)) {
885
+ value.forEach((entry) => ids.add(String(entry)));
886
+ } else {
887
+ ids.add(String(value));
888
+ }
889
+ }
890
+
891
+ return Array.from(ids);
892
+ }
893
+
894
+ /**
895
+ * @private
896
+ * @param {object} cfg
897
+ * @param {string[]} ids
898
+ * @return {Promise<Map<string, object>>}
899
+ */
900
+ async function fetchLookupEntries(cfg, ids) {
901
+ const requestDefaults = this.getOption("lookups.request", {});
902
+ const request = {
903
+ ...requestDefaults,
904
+ ...(cfg.request || {}),
905
+ };
906
+
907
+ const init = isObject(request.init) ? request.init : {};
908
+ const url = buildLookupUrl(cfg.url, ids, request);
909
+
910
+ const response = await fetch(url, init);
911
+ if (!response.ok) {
912
+ return new Map();
913
+ }
914
+
915
+ let payload;
916
+ try {
917
+ payload = await response.json();
918
+ } catch (_error) {
919
+ return new Map();
920
+ }
921
+
922
+ const responseDefaults = this.getOption("lookups.response", {});
923
+ const responseConfig = {
924
+ ...responseDefaults,
925
+ ...(cfg.response || {}),
926
+ };
927
+
928
+ let entries = payload;
929
+ if (isString(responseConfig.path) && responseConfig.path !== "") {
930
+ entries = new Pathfinder(payload).getVia(responseConfig.path);
931
+ }
932
+
933
+ if (!isArray(entries)) {
934
+ return new Map();
935
+ }
936
+
937
+ const idKey = responseConfig.id || "id";
938
+ const result = new Map();
939
+ for (const entry of entries) {
940
+ if (!entry || entry[idKey] === undefined || entry[idKey] === null) {
941
+ continue;
942
+ }
943
+ result.set(String(entry[idKey]), entry);
944
+ }
945
+
946
+ return result;
947
+ }
948
+
949
+ /**
950
+ * @private
951
+ * @param {string} url
952
+ * @param {string[]} ids
953
+ * @param {object} request
954
+ * @return {string}
955
+ */
956
+ function buildLookupUrl(url, ids, request) {
957
+ const idsParam = request.idsParam || "ids";
958
+ const idsSeparator = request.idsSeparator || ",";
959
+ const idsValue = ids.join(idsSeparator);
960
+
961
+ if (url.includes("${")) {
962
+ const formatter = new Formatter({ ids: idsValue });
963
+ return formatter.format(url);
964
+ }
965
+
966
+ const separator = url.includes("?") ? "&" : "?";
967
+ return `${url}${separator}${encodeURIComponent(idsParam)}=${encodeURIComponent(
968
+ idsValue,
969
+ )}`;
970
+ }
971
+
972
+ /**
973
+ * @private
974
+ * @param {object} cfg
975
+ * @param {object} entry
976
+ * @param {object} row
977
+ * @return {string}
978
+ */
979
+ function formatLookupValue(cfg, entry, row) {
980
+ if (isFunction(cfg.format)) {
981
+ return cfg.format(entry, row);
982
+ }
983
+
984
+ const formatDefaults = this.getOption("lookups.format", {});
985
+ const format = isObject(cfg.format) ? cfg.format : {};
986
+ const template = format.template || formatDefaults.template || "${name}";
987
+ if (!isString(template)) {
988
+ return "";
989
+ }
990
+
991
+ const formatter = new Formatter({ ...entry, row });
992
+ const marker = format.marker || formatDefaults.marker;
993
+ if (marker?.open) {
994
+ formatter.setMarker(marker.open, marker.close);
995
+ }
996
+
997
+ return formatter.format(template);
998
+ }
999
+
1000
+ /**
1001
+ * @private
1002
+ * @param {number} requestId
1003
+ * @param {Function} update
1004
+ */
1005
+ function updateLookupRows(requestId, update) {
1006
+ if (this[readRequestIdSymbol] !== requestId) {
1007
+ return;
1008
+ }
1009
+
1010
+ const data = this[dataSourceSymbol].get();
1011
+ const sourcePath = this.getOption("lookups.sourcePath", "dataset");
1012
+ const next = clone(data);
1013
+ const rows = resolveLookupRows.call(this, next, sourcePath);
1014
+
1015
+ if (!isArray(rows)) {
1016
+ return;
1017
+ }
1018
+
1019
+ update(rows);
1020
+ this[dataSourceSymbol].set(next);
1021
+ }
1022
+
1023
+ /**
1024
+ * @private
1025
+ * @param {object} [data]
1026
+ * @param {string} [sourcePath]
1027
+ * @return {Array|undefined}
1028
+ */
1029
+ function resolveLookupRows(data, sourcePath) {
1030
+ const source = data || this[dataSourceSymbol].get();
1031
+ if (isString(sourcePath) && sourcePath !== "") {
1032
+ return new Pathfinder(source).getVia(sourcePath);
1033
+ }
1034
+ if (isArray(source)) {
1035
+ return source;
1036
+ }
1037
+ if (isObject(source) && isArray(source.dataset)) {
1038
+ return source.dataset;
1039
+ }
1040
+ return undefined;
1041
+ }
1042
+
1043
+ /**
1044
+ * @private
1045
+ * @param {string} name
1046
+ * @return {Map<string, object>}
1047
+ */
1048
+ function getLookupCache(name) {
1049
+ if (!this[lookupCacheSymbol]) {
1050
+ this[lookupCacheSymbol] = new Map();
1051
+ }
1052
+ if (!this[lookupCacheSymbol].has(name)) {
1053
+ this[lookupCacheSymbol].set(name, new Map());
1054
+ }
1055
+ return this[lookupCacheSymbol].get(name);
1056
+ }
1057
+
1058
+ /**
1059
+ * @private
1060
+ * @param {string} name
1061
+ * @return {Set<string>}
1062
+ */
1063
+ function getLookupPending(name) {
1064
+ if (!this[lookupPendingSymbol]) {
1065
+ this[lookupPendingSymbol] = new Map();
1066
+ }
1067
+ if (!this[lookupPendingSymbol].has(name)) {
1068
+ this[lookupPendingSymbol].set(name, new Set());
1069
+ }
1070
+ return this[lookupPendingSymbol].get(name);
1071
+ }
1072
+
711
1073
  /**
712
1074
  * @private
713
1075
  * @return {string}
@@ -34,9 +34,13 @@ function initJSDOM(options) {
34
34
  const {window} = new JSDOM(`<!DOCTYPE html><html lang="en"><head><title>Test</title></head><body><div id="mocks"></div></body></html>`, options);
35
35
 
36
36
  g['window'] = window;
37
-
38
- return new Promise((resolve, reject) =>
39
- window.addEventListener("load", () => {
37
+
38
+ return new Promise((resolve, reject) => {
39
+ let settled = false;
40
+
41
+ const finalize = () => {
42
+ if (settled) return;
43
+ settled = true;
40
44
 
41
45
  [
42
46
  'Blob',
@@ -78,8 +82,6 @@ function initJSDOM(options) {
78
82
  'XMLSerializer',
79
83
  ].forEach(key => {
80
84
  try {
81
- console.log("setting key", key);
82
-
83
85
  g[key] = window[key]
84
86
  } catch(e) {
85
87
  console.error("Error setting key", key, e);
@@ -128,16 +130,26 @@ function initJSDOM(options) {
128
130
  ensureStorage("sessionStorage");
129
131
 
130
132
  resolve(g);
131
-
133
+
132
134
  }).catch(e => {
133
135
  console.error("Error loading dom-storage", e);
134
136
  reject(e);
135
137
  });
138
+ };
136
139
 
137
- })
138
- );
140
+ const onLoad = () => {
141
+ window.removeEventListener("load", onLoad);
142
+ finalize();
143
+ };
139
144
 
145
+ window.addEventListener("load", onLoad);
140
146
 
147
+ if (window.document.readyState === "complete") {
148
+ queueMicrotask(finalize);
149
+ } else {
150
+ setTimeout(finalize, 50);
151
+ }
152
+ });
141
153
  });
142
154
  }
143
155