@schukai/monster 4.71.0 → 4.73.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,25 @@
2
2
 
3
3
 
4
4
 
5
+ ## [4.73.0] - 2026-01-03
6
+
7
+ ### Add Features
8
+
9
+ - Enhance lookup column functionality with templating and debugging options
10
+
11
+
12
+
13
+ ## [4.72.0] - 2026-01-03
14
+
15
+ ### Add Features
16
+
17
+ - Implement new fetch columns feature for issue [#360](https://gitlab.schukai.com/oss/libraries/javascript/monster/issues/360)
18
+ ### Changes
19
+
20
+ - update tests
21
+
22
+
23
+
5
24
  ## [4.71.0] - 2026-01-03
6
25
 
7
26
  ### 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.73.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,28 @@ class Rest extends Datasource {
174
176
  validation: {
175
177
  map: {},
176
178
  },
179
+
180
+ lookups: {
181
+ enabled: false,
182
+ debug: false,
183
+ sourcePath: "dataset",
184
+ request: {
185
+ idsParam: "ids",
186
+ idsSeparator: ",",
187
+ },
188
+ response: {
189
+ path: "dataset",
190
+ id: "id",
191
+ },
192
+ format: {
193
+ template: "${name}",
194
+ marker: {
195
+ open: ["${"],
196
+ close: ["}"],
197
+ },
198
+ },
199
+ columns: {},
200
+ },
177
201
  });
178
202
  }
179
203
 
@@ -315,6 +339,7 @@ class Rest extends Datasource {
315
339
  return;
316
340
  }
317
341
  this[dataSourceSymbol].set(transformedPayload);
342
+ applyLookups.call(this, requestId).catch(() => {});
318
343
  };
319
344
  this[dataSourceSymbol].setOption("read", opt);
320
345
 
@@ -708,6 +733,406 @@ function handleValidationError(error) {
708
733
  .catch(() => {});
709
734
  }
710
735
 
736
+ /**
737
+ * @private
738
+ * @return {boolean}
739
+ */
740
+ function lookupDebugEnabled() {
741
+ return this.getOption("lookups.debug", false) === true;
742
+ }
743
+
744
+ /**
745
+ * @private
746
+ * @param {number} requestId
747
+ * @return {Promise<void>}
748
+ */
749
+ async function applyLookups(requestId) {
750
+ if (!this.getOption("lookups.enabled", false)) {
751
+ return;
752
+ }
753
+
754
+ const columns = this.getOption("lookups.columns", {});
755
+ if (!isObject(columns)) {
756
+ return;
757
+ }
758
+
759
+ const columnEntries = Object.entries(columns).filter(([, cfg]) =>
760
+ isObject(cfg),
761
+ );
762
+ if (columnEntries.length === 0) {
763
+ return;
764
+ }
765
+
766
+ const resolverColumns = [];
767
+ const remoteColumns = [];
768
+ for (const [name, cfg] of columnEntries) {
769
+ if (isFunction(cfg.resolve)) {
770
+ resolverColumns.push([name, cfg]);
771
+ } else if (isString(cfg.url) && cfg.url !== "") {
772
+ remoteColumns.push([name, cfg]);
773
+ }
774
+ }
775
+
776
+ if (resolverColumns.length > 0) {
777
+ updateLookupRows.call(this, requestId, (rows) => {
778
+ for (const [, cfg] of resolverColumns) {
779
+ const key = cfg.key;
780
+ const target = cfg.target;
781
+ if (!isString(key) || !isString(target)) {
782
+ continue;
783
+ }
784
+
785
+ for (const row of rows) {
786
+ const entry = cfg.resolve(row[key], row);
787
+ if (!entry) {
788
+ continue;
789
+ }
790
+ row[target] = formatLookupValue.call(this, cfg, entry, row);
791
+ if (isString(cfg.loadingKey)) {
792
+ row[cfg.loadingKey] = false;
793
+ }
794
+ }
795
+ }
796
+ });
797
+ }
798
+
799
+ if (remoteColumns.length === 0) {
800
+ return;
801
+ }
802
+
803
+ await Promise.all(
804
+ remoteColumns.map(([name, cfg]) =>
805
+ resolveRemoteLookup.call(this, requestId, name, cfg),
806
+ ),
807
+ );
808
+ }
809
+
810
+ /**
811
+ * @private
812
+ * @param {number} requestId
813
+ * @param {string} name
814
+ * @param {object} cfg
815
+ * @return {Promise<void>}
816
+ */
817
+ async function resolveRemoteLookup(requestId, name, cfg) {
818
+ const key = cfg.key;
819
+ const target = cfg.target;
820
+ if (!isString(key) || !isString(target)) {
821
+ return;
822
+ }
823
+
824
+ const cache = getLookupCache.call(this, name);
825
+ const pending = getLookupPending.call(this, name);
826
+
827
+ const ids = collectLookupIds.call(this, key);
828
+ const missingIds = ids.filter((id) => !cache.has(id) && !pending.has(id));
829
+
830
+ if (missingIds.length === 0) {
831
+ applyLookupCache.call(this, requestId, cfg, cache);
832
+ return;
833
+ }
834
+
835
+ updateLookupRows.call(this, requestId, (rows) => {
836
+ if (!isString(cfg.loadingKey)) return;
837
+ for (const row of rows) {
838
+ if (missingIds.includes(String(row[key]))) {
839
+ row[cfg.loadingKey] = true;
840
+ }
841
+ }
842
+ });
843
+
844
+ missingIds.forEach((id) => pending.add(id));
845
+
846
+ const response = await fetchLookupEntries.call(this, cfg, missingIds);
847
+ response.forEach((entry, id) => cache.set(id, entry));
848
+ missingIds.forEach((id) => pending.delete(id));
849
+
850
+ applyLookupCache.call(this, requestId, cfg, cache);
851
+ }
852
+
853
+ /**
854
+ * @private
855
+ * @param {number} requestId
856
+ * @param {object} cfg
857
+ * @param {Map} cache
858
+ */
859
+ function applyLookupCache(requestId, cfg, cache) {
860
+ const key = cfg.key;
861
+ const target = cfg.target;
862
+
863
+ updateLookupRows.call(this, requestId, (rows) => {
864
+ for (const row of rows) {
865
+ const entry = cache.get(String(row[key]));
866
+ if (entry) {
867
+ row[target] = formatLookupValue.call(this, cfg, entry, row);
868
+ }
869
+ if (isString(cfg.loadingKey)) {
870
+ row[cfg.loadingKey] = false;
871
+ }
872
+ }
873
+ });
874
+ }
875
+
876
+ /**
877
+ * @private
878
+ * @param {string} key
879
+ * @return {string[]}
880
+ */
881
+ function collectLookupIds(key) {
882
+ const rows = resolveLookupRows.call(this);
883
+ if (!isArray(rows)) {
884
+ return [];
885
+ }
886
+
887
+ const ids = new Set();
888
+ for (const row of rows) {
889
+ const value = row?.[key];
890
+ if (value === undefined || value === null || value === "") {
891
+ continue;
892
+ }
893
+ if (isArray(value)) {
894
+ value.forEach((entry) => ids.add(String(entry)));
895
+ } else {
896
+ ids.add(String(value));
897
+ }
898
+ }
899
+
900
+ return Array.from(ids);
901
+ }
902
+
903
+ /**
904
+ * @private
905
+ * @param {object} cfg
906
+ * @param {string[]} ids
907
+ * @return {Promise<Map<string, object>>}
908
+ */
909
+ async function fetchLookupEntries(cfg, ids) {
910
+ const requestDefaults = this.getOption("lookups.request", {});
911
+ const request = {
912
+ ...requestDefaults,
913
+ ...(cfg.request || {}),
914
+ };
915
+
916
+ const init = isObject(request.init) ? request.init : {};
917
+ const url = buildLookupUrl(cfg.url, ids, request);
918
+ const debug = lookupDebugEnabled.call(this);
919
+ if (debug) {
920
+ console.debug("[monster-datasource-rest] lookup fetch", {
921
+ url,
922
+ ids,
923
+ });
924
+ }
925
+
926
+ const response = await fetch(url, init);
927
+ if (!response.ok) {
928
+ if (debug) {
929
+ console.debug("[monster-datasource-rest] lookup failed", {
930
+ url,
931
+ status: response.status,
932
+ });
933
+ }
934
+ return new Map();
935
+ }
936
+
937
+ let payload;
938
+ try {
939
+ payload = await response.json();
940
+ } catch (_error) {
941
+ if (debug) {
942
+ console.debug("[monster-datasource-rest] lookup invalid json", { url });
943
+ }
944
+ return new Map();
945
+ }
946
+
947
+ const responseDefaults = this.getOption("lookups.response", {});
948
+ const responseConfig = {
949
+ ...responseDefaults,
950
+ ...(cfg.response || {}),
951
+ };
952
+
953
+ let entries = payload;
954
+ if (isString(responseConfig.path) && responseConfig.path !== "") {
955
+ entries = new Pathfinder(payload).getVia(responseConfig.path);
956
+ }
957
+
958
+ if (!isArray(entries)) {
959
+ if (debug) {
960
+ console.debug("[monster-datasource-rest] lookup no entries", {
961
+ url,
962
+ path: responseConfig.path,
963
+ });
964
+ }
965
+ return new Map();
966
+ }
967
+
968
+ const idKey = responseConfig.id || "id";
969
+ const result = new Map();
970
+ for (const entry of entries) {
971
+ if (!entry || entry[idKey] === undefined || entry[idKey] === null) {
972
+ continue;
973
+ }
974
+ result.set(String(entry[idKey]), entry);
975
+ }
976
+ if (debug) {
977
+ console.debug("[monster-datasource-rest] lookup resolved", {
978
+ url,
979
+ entries: result.size,
980
+ });
981
+ }
982
+
983
+ return result;
984
+ }
985
+
986
+ /**
987
+ * @private
988
+ * @param {string} url
989
+ * @param {string[]} ids
990
+ * @param {object} request
991
+ * @return {string}
992
+ */
993
+ function buildLookupUrl(url, ids, request) {
994
+ const idsParam = request.idsParam || "ids";
995
+ const idsValue = buildLookupIdsValue(ids, request);
996
+
997
+ if (url.includes("${")) {
998
+ const formatter = new Formatter({ ids: idsValue });
999
+ return formatter.format(url);
1000
+ }
1001
+
1002
+ const separator = url.includes("?") ? "&" : "?";
1003
+ return `${url}${separator}${encodeURIComponent(idsParam)}=${encodeURIComponent(
1004
+ idsValue,
1005
+ )}`;
1006
+ }
1007
+
1008
+ /**
1009
+ * @private
1010
+ * @param {string[]} ids
1011
+ * @param {object} request
1012
+ * @return {string}
1013
+ */
1014
+ function buildLookupIdsValue(ids, request) {
1015
+ if (isFunction(request.queryBuilder)) {
1016
+ return request.queryBuilder(ids);
1017
+ }
1018
+
1019
+ const idsTemplate = request.idsTemplate;
1020
+ const idsSeparator = request.idsSeparator || ",";
1021
+ const wrapOpen = request.wrapOpen || "";
1022
+ const wrapClose = request.wrapClose || "";
1023
+
1024
+ let values = ids;
1025
+ if (isString(idsTemplate) && idsTemplate !== "") {
1026
+ values = ids.map((id) => {
1027
+ const formatter = new Formatter({ id });
1028
+ return formatter.format(idsTemplate);
1029
+ });
1030
+ }
1031
+
1032
+ return `${wrapOpen}${values.join(idsSeparator)}${wrapClose}`;
1033
+ }
1034
+
1035
+ /**
1036
+ * @private
1037
+ * @param {object} cfg
1038
+ * @param {object} entry
1039
+ * @param {object} row
1040
+ * @return {string}
1041
+ */
1042
+ function formatLookupValue(cfg, entry, row) {
1043
+ if (isFunction(cfg.format)) {
1044
+ return cfg.format(entry, row);
1045
+ }
1046
+
1047
+ const formatDefaults = this.getOption("lookups.format", {});
1048
+ const format = isObject(cfg.format) ? cfg.format : {};
1049
+ const template = format.template || formatDefaults.template || "${name}";
1050
+ if (!isString(template)) {
1051
+ return "";
1052
+ }
1053
+
1054
+ const formatter = new Formatter({ ...entry, row });
1055
+ const marker = format.marker || formatDefaults.marker;
1056
+ if (marker?.open) {
1057
+ formatter.setMarker(marker.open, marker.close);
1058
+ }
1059
+
1060
+ return formatter.format(template);
1061
+ }
1062
+
1063
+ /**
1064
+ * @private
1065
+ * @param {number} requestId
1066
+ * @param {Function} update
1067
+ */
1068
+ function updateLookupRows(requestId, update) {
1069
+ if (this[readRequestIdSymbol] !== requestId) {
1070
+ return;
1071
+ }
1072
+
1073
+ const data = this[dataSourceSymbol].get();
1074
+ const sourcePath = this.getOption("lookups.sourcePath", "dataset");
1075
+ const next = clone(data);
1076
+ const rows = resolveLookupRows.call(this, next, sourcePath);
1077
+
1078
+ if (!isArray(rows)) {
1079
+ return;
1080
+ }
1081
+
1082
+ update(rows);
1083
+ this[dataSourceSymbol].set(next);
1084
+ }
1085
+
1086
+ /**
1087
+ * @private
1088
+ * @param {object} [data]
1089
+ * @param {string} [sourcePath]
1090
+ * @return {Array|undefined}
1091
+ */
1092
+ function resolveLookupRows(data, sourcePath) {
1093
+ const source = data || this[dataSourceSymbol].get();
1094
+ if (isString(sourcePath) && sourcePath !== "") {
1095
+ return new Pathfinder(source).getVia(sourcePath);
1096
+ }
1097
+ if (isArray(source)) {
1098
+ return source;
1099
+ }
1100
+ if (isObject(source) && isArray(source.dataset)) {
1101
+ return source.dataset;
1102
+ }
1103
+ return undefined;
1104
+ }
1105
+
1106
+ /**
1107
+ * @private
1108
+ * @param {string} name
1109
+ * @return {Map<string, object>}
1110
+ */
1111
+ function getLookupCache(name) {
1112
+ if (!this[lookupCacheSymbol]) {
1113
+ this[lookupCacheSymbol] = new Map();
1114
+ }
1115
+ if (!this[lookupCacheSymbol].has(name)) {
1116
+ this[lookupCacheSymbol].set(name, new Map());
1117
+ }
1118
+ return this[lookupCacheSymbol].get(name);
1119
+ }
1120
+
1121
+ /**
1122
+ * @private
1123
+ * @param {string} name
1124
+ * @return {Set<string>}
1125
+ */
1126
+ function getLookupPending(name) {
1127
+ if (!this[lookupPendingSymbol]) {
1128
+ this[lookupPendingSymbol] = new Map();
1129
+ }
1130
+ if (!this[lookupPendingSymbol].has(name)) {
1131
+ this[lookupPendingSymbol].set(name, new Set());
1132
+ }
1133
+ return this[lookupPendingSymbol].get(name);
1134
+ }
1135
+
711
1136
  /**
712
1137
  * @private
713
1138
  * @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