@schukai/monster 4.103.1 → 4.105.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,27 @@
2
2
 
3
3
 
4
4
 
5
+ ## [4.105.0] - 2026-01-22
6
+
7
+ ### Add Features
8
+
9
+ - Shorten tab labels for better readability
10
+ - Add write-back sanitizer for cleaner dataset diffs
11
+ ### Bug Fixes
12
+
13
+ - tabs labels
14
+ - update
15
+
16
+
17
+
18
+ ## [4.104.0] - 2026-01-19
19
+
20
+ ### Add Features
21
+
22
+ - Add batching feature for updates in the DataTable and CustomElement
23
+
24
+
25
+
5
26
  ## [4.103.1] - 2026-01-19
6
27
 
7
28
  ### Bug Fixes
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.103.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.105.0"}
@@ -26,7 +26,7 @@ import {
26
26
  getDocument,
27
27
  getWindow,
28
28
  } from "../../dom/util.mjs";
29
- import { isString } from "../../types/is.mjs";
29
+ import { isArray, isObject, isString } from "../../types/is.mjs";
30
30
  import { Observer } from "../../types/observer.mjs";
31
31
  import {
32
32
  ATTRIBUTE_DATASOURCE_SELECTOR,
@@ -40,6 +40,8 @@ import {
40
40
  } from "./util.mjs";
41
41
  import { FormStyleSheet } from "../stylesheet/form.mjs";
42
42
  import { addErrorAttribute } from "../../dom/error.mjs";
43
+ import { Pipe } from "../../data/pipe.mjs";
44
+ import { clone } from "../../util/clone.mjs";
43
45
 
44
46
  export { DataSet };
45
47
 
@@ -135,6 +137,11 @@ class DataSet extends CustomElement {
135
137
  "input, select, textarea, monster-select, monster-toggle-switch",
136
138
  },
137
139
 
140
+ writeBack: {
141
+ transformer: null,
142
+ callbacks: null,
143
+ },
144
+
138
145
  data: {},
139
146
  });
140
147
 
@@ -204,6 +211,17 @@ class DataSet extends CustomElement {
204
211
  pathWithIndex = String(index);
205
212
  }
206
213
 
214
+ let sanitizedData;
215
+ try {
216
+ sanitizedData = applyWriteBackTransform.call(
217
+ this,
218
+ clone(internalData),
219
+ );
220
+ } catch (e) {
221
+ reject(e);
222
+ return;
223
+ }
224
+
207
225
  if (this.getOption("features.skipNoopWrite") === true) {
208
226
  const currentData = this[datasourceLinkedElementSymbol]?.data;
209
227
  let currentValue;
@@ -214,7 +232,7 @@ class DataSet extends CustomElement {
214
232
  currentValue = undefined;
215
233
  }
216
234
  }
217
- if (diff(currentValue, internalData).length === 0) {
235
+ if (diff(currentValue, sanitizedData).length === 0) {
218
236
  resolve();
219
237
  return;
220
238
  }
@@ -223,7 +241,7 @@ class DataSet extends CustomElement {
223
241
  if (this.getOption("logLevel") === "debug") {
224
242
  console.log("monster-dataset: write", {
225
243
  path: pathWithIndex,
226
- data: internalData,
244
+ data: sanitizedData,
227
245
  });
228
246
  }
229
247
 
@@ -236,7 +254,7 @@ class DataSet extends CustomElement {
236
254
  const unref = JSON.stringify(data);
237
255
  const ref = JSON.parse(unref);
238
256
 
239
- new Pathfinder(ref).setVia(pathWithIndex, internalData);
257
+ new Pathfinder(ref).setVia(pathWithIndex, sanitizedData);
240
258
 
241
259
  this[datasourceLinkedElementSymbol].data = ref;
242
260
 
@@ -447,4 +465,45 @@ function getTemplate() {
447
465
  `;
448
466
  }
449
467
 
468
+ /**
469
+ * @private
470
+ * @param {Object|Array} data
471
+ * @return {Object|Array}
472
+ */
473
+ function applyWriteBackTransform(data) {
474
+ const transformer = this.getOption("writeBack.transformer");
475
+ if (transformer === undefined || transformer === null || transformer === "") {
476
+ return clone(data);
477
+ }
478
+
479
+ const pipe = new Pipe(transformer);
480
+ const callbacks = this.getOption("writeBack.callbacks");
481
+
482
+ if (isArray(callbacks)) {
483
+ for (const callback of callbacks) {
484
+ if (typeof callback === "function") {
485
+ pipe.setCallback(callback);
486
+ }
487
+ }
488
+ }
489
+
490
+ if (isObject(callbacks)) {
491
+ for (const key in callbacks) {
492
+ if (
493
+ Object.prototype.hasOwnProperty.call(callbacks, key) &&
494
+ typeof callbacks[key] === "function"
495
+ ) {
496
+ pipe.setCallback(key, callbacks[key]);
497
+ }
498
+ }
499
+ }
500
+
501
+ const result = pipe.run(data);
502
+ if (typeof result === "undefined") {
503
+ return clone(data);
504
+ }
505
+
506
+ return clone(result);
507
+ }
508
+
450
509
  registerCustomElement(DataSet);
@@ -202,6 +202,8 @@ class DataTable extends CustomElement {
202
202
  * @property {string} copy.quoteOpen Quote open character
203
203
  * @property {string} copy.quoteClose Quote close character
204
204
  * @property {string} copy.rowBreak Row break character
205
+ * @property {Object} updater Updater configuration
206
+ * @property {boolean} updater.batchUpdates Enables batched updater content/attribute updates
205
207
  * @property {Object} templateMapping Template mapping
206
208
  * @property {string} templateMapping.row-key Row key
207
209
  * @property {string} templateMapping.filter-id Filter id
@@ -257,6 +259,10 @@ class DataTable extends CustomElement {
257
259
  rowBreak: "\n",
258
260
  },
259
261
 
262
+ updater: {
263
+ batchUpdates: true,
264
+ },
265
+
260
266
  templateMapping: {
261
267
  "row-key": null,
262
268
  "filter-id": null,
@@ -91,6 +91,8 @@ class Form extends DataSet {
91
91
 
92
92
  writeBack: {
93
93
  events: ["keyup", "click", "change", "drop", "touchend", "input"],
94
+ transformer: null,
95
+ callbacks: null,
94
96
  },
95
97
 
96
98
  reportValidity: {
@@ -1326,6 +1326,16 @@ function getButtonLabel(node) {
1326
1326
  label = "";
1327
1327
  }
1328
1328
 
1329
+ if (setLabel === true) {
1330
+ if (label.trim() === "") {
1331
+ label = node.textContent || "";
1332
+ }
1333
+ label = label.replace(/\s+/g, " ").trim();
1334
+ const words = label.split(" ").filter((word) => word !== "");
1335
+ if (words.length > 3) {
1336
+ label = `${words.slice(0, 3).join(" ")}…`;
1337
+ }
1338
+ }
1329
1339
  label = label.trim();
1330
1340
 
1331
1341
  if (label === "") {
@@ -314,6 +314,8 @@ class CustomElement extends HTMLElement {
314
314
  * @property {Function} templateFormatter.marker.close=null Specifies the closing marker for the templates.
315
315
  * @property {Boolean} templateFormatter.i18n=false Specifies whether the templates should be formatted with i18n.
316
316
  * @property {Boolean} eventProcessing=false Specifies whether the control processes events.
317
+ * @property {Object} updater Specifies updater options.
318
+ * @property {Boolean} updater.batchUpdates=false Batches updater content/attribute updates per diff.
317
319
  * @since 1.8.0
318
320
  */
319
321
  get defaults() {
@@ -334,6 +336,9 @@ class CustomElement extends HTMLElement {
334
336
  },
335
337
 
336
338
  eventProcessing: false,
339
+ updater: {
340
+ batchUpdates: false,
341
+ },
337
342
  };
338
343
  }
339
344
 
@@ -651,6 +656,9 @@ class CustomElement extends HTMLElement {
651
656
  if (this.getOption("eventProcessing") === true) {
652
657
  cfg.eventProcessing = true;
653
658
  }
659
+ if (this.getOption("updater.batchUpdates") === true) {
660
+ cfg.batchUpdates = true;
661
+ }
654
662
  addObjectWithUpdaterToElement.call(
655
663
  this,
656
664
  nodeList,
@@ -71,6 +71,8 @@ const pendingDiffsSymbol = Symbol("pendingDiffs");
71
71
  * @type {symbol}
72
72
  */
73
73
  const processingSymbol = Symbol("processing");
74
+ const processQueueSymbol = Symbol("processQueue");
75
+ const applyChangeSymbol = Symbol("applyChange");
74
76
  const updaterRootSymbol = Symbol.for("@schukai/monster/dom/@@updater-root");
75
77
 
76
78
  /**
@@ -125,6 +127,9 @@ class Updater extends Base {
125
127
  callbacks: new Map(),
126
128
  eventTypes: ["keyup", "click", "change", "drop", "touchend", "input"],
127
129
  subject: subject,
130
+ features: {
131
+ batchUpdates: false,
132
+ },
128
133
  };
129
134
 
130
135
  this[internalSymbol].callbacks.set(
@@ -144,7 +149,7 @@ class Updater extends Base {
144
149
  return Promise.resolve();
145
150
  }
146
151
  this[pendingDiffsSymbol].push(diffResult);
147
- return this.#processQueue();
152
+ return this[processQueueSymbol]();
148
153
  }),
149
154
  );
150
155
  }
@@ -153,7 +158,7 @@ class Updater extends Base {
153
158
  * @private
154
159
  * @return {Promise}
155
160
  */
156
- async #processQueue() {
161
+ async [processQueueSymbol]() {
157
162
  if (this[processingSymbol]) {
158
163
  return Promise.resolve();
159
164
  }
@@ -162,8 +167,34 @@ class Updater extends Base {
162
167
  try {
163
168
  while (this[pendingDiffsSymbol].length) {
164
169
  const diffResult = this[pendingDiffsSymbol].shift();
165
- for (const change of Object.values(diffResult)) {
166
- await this.#applyChange(change);
170
+ if (this[internalSymbol].features.batchUpdates === true) {
171
+ const updatePaths = new Map();
172
+ for (const change of Object.values(diffResult)) {
173
+ removeElement.call(this, change);
174
+ insertElement.call(this, change);
175
+
176
+ const path = isArray(change?.["path"]) ? change["path"] : null;
177
+ if (!path) {
178
+ continue;
179
+ }
180
+ if (path.length === 0) {
181
+ updatePaths.set("", path);
182
+ continue;
183
+ }
184
+ const root = path[0];
185
+ if (!updatePaths.has(root)) {
186
+ updatePaths.set(root, [root]);
187
+ }
188
+ }
189
+
190
+ for (const path of updatePaths.values()) {
191
+ updateContent.call(this, { path });
192
+ updateAttributes.call(this, { path });
193
+ }
194
+ } else {
195
+ for (const change of Object.values(diffResult)) {
196
+ await this[applyChangeSymbol](change);
197
+ }
167
198
  }
168
199
  }
169
200
  } catch (err) {
@@ -174,7 +205,7 @@ class Updater extends Base {
174
205
  }
175
206
 
176
207
  /** @private **/
177
- async #applyChange(change) {
208
+ async [applyChangeSymbol](change) {
178
209
  removeElement.call(this, change);
179
210
  insertElement.call(this, change);
180
211
  updateContent.call(this, change);
@@ -195,6 +226,18 @@ class Updater extends Base {
195
226
  return this;
196
227
  }
197
228
 
229
+ /**
230
+ * Enable or disable batched update processing.
231
+ *
232
+ * @since 4.104.0
233
+ * @param {boolean} enabled
234
+ * @return {Updater}
235
+ */
236
+ setBatchUpdates(enabled) {
237
+ this[internalSymbol].features.batchUpdates = enabled === true;
238
+ return this;
239
+ }
240
+
198
241
  /**
199
242
  * With this method, the eventlisteners are hooked in and the magic begins.
200
243
  *
@@ -1097,6 +1140,7 @@ function handleInputControlAttributeUpdate(element, name, value) {
1097
1140
  * @param {object} config
1098
1141
  *
1099
1142
  * Config: enableEventProcessing {boolean} - default: false - enables the event processing
1143
+ * Config: batchUpdates {boolean} - default: false - batches updateContent/updateAttributes per diff
1100
1144
  *
1101
1145
  * @return {Promise[]}
1102
1146
  * @license AGPLv3
@@ -1169,6 +1213,9 @@ function addObjectWithUpdaterToElement(elements, symbol, object, config = {}) {
1169
1213
 
1170
1214
  result.push(
1171
1215
  u.run().then(() => {
1216
+ if (config.batchUpdates === true) {
1217
+ u.setBatchUpdates(true);
1218
+ }
1172
1219
  if (config.eventProcessing === true) {
1173
1220
  u.enableEventProcessing();
1174
1221
  }
@@ -156,7 +156,7 @@ function getMonsterVersion() {
156
156
  }
157
157
 
158
158
  /** don't touch, replaced by make with package.json version */
159
- monsterVersion = new Version("4.94.0");
159
+ monsterVersion = new Version("4.104.0");
160
160
 
161
161
  return monsterVersion;
162
162
  }
@@ -0,0 +1,104 @@
1
+ import * as chai from 'chai';
2
+ import {chaiDom} from "../../../util/chai-dom.mjs";
3
+ import {initJSDOM} from "../../../util/jsdom.mjs";
4
+
5
+ let expect = chai.expect;
6
+ chai.use(chaiDom);
7
+
8
+ let Dataset;
9
+
10
+ function buildDatasource(id, payload) {
11
+ const ds = document.createElement('monster-datasource-dom');
12
+ ds.id = id;
13
+ ds.innerHTML = `<script type="application/json">${JSON.stringify(payload)}</script>`;
14
+ return ds;
15
+ }
16
+
17
+ describe('Dataset writeBack sanitizer', function () {
18
+
19
+ before(async function () {
20
+ await initJSDOM();
21
+ await import("element-internals-polyfill").catch(() => {});
22
+ await import("../../../../source/components/datatable/datasource/dom.mjs");
23
+ const mod = await import("../../../../source/components/datatable/dataset.mjs");
24
+ Dataset = mod['DataSet'];
25
+ });
26
+
27
+ beforeEach(() => {
28
+ const mocks = document.getElementById('mocks');
29
+ mocks.innerHTML = '';
30
+ });
31
+
32
+ afterEach(() => {
33
+ const mocks = document.getElementById('mocks');
34
+ mocks.innerHTML = '';
35
+ });
36
+
37
+ it('sanitizes data before write and avoids side effects', async function () {
38
+ const mocks = document.getElementById('mocks');
39
+ const ds = buildDatasource('ds-sanitize', [{}]);
40
+ mocks.appendChild(ds);
41
+
42
+ const dataset = document.createElement('monster-dataset');
43
+ expect(dataset).is.instanceof(Dataset);
44
+
45
+ dataset.setOption('datasource.selector', '#ds-sanitize');
46
+ dataset.setOption('mapping.data', '');
47
+
48
+ const initial = {name: '', email: 'a@b', tags: []};
49
+ dataset.setOption('data', initial);
50
+
51
+ dataset.setOption('writeBack.transformer', 'call:sanitize');
52
+ dataset.setOption('writeBack.callbacks', {
53
+ sanitize: (obj) => {
54
+ for (const [key, value] of Object.entries(obj)) {
55
+ if (value === '' || value === null || value === undefined) {
56
+ delete obj[key];
57
+ continue;
58
+ }
59
+ if (Array.isArray(value) && value.length === 0) {
60
+ delete obj[key];
61
+ }
62
+ }
63
+ return obj;
64
+ },
65
+ });
66
+
67
+ mocks.appendChild(dataset);
68
+
69
+ await new Promise((resolve) => setTimeout(resolve, 30));
70
+ await dataset.write();
71
+
72
+ expect(ds.data[0]).deep.equal({email: 'a@b'});
73
+
74
+ const internal = dataset.getInternalUpdateCloneData();
75
+ expect(internal.data).deep.equal({name: '', email: 'a@b', tags: []});
76
+ expect(initial).deep.equal({name: '', email: 'a@b', tags: []});
77
+ });
78
+
79
+ it('uses original data when sanitizer returns undefined', async function () {
80
+ const mocks = document.getElementById('mocks');
81
+ const ds = buildDatasource('ds-undefined', [{}]);
82
+ mocks.appendChild(ds);
83
+
84
+ const dataset = document.createElement('monster-dataset');
85
+ dataset.setOption('datasource.selector', '#ds-undefined');
86
+ dataset.setOption('mapping.data', '');
87
+
88
+ const initial = {name: '', email: 'a@b'};
89
+ dataset.setOption('data', initial);
90
+
91
+ dataset.setOption('writeBack.transformer', 'call:noop');
92
+ dataset.setOption('writeBack.callbacks', {
93
+ noop: () => undefined,
94
+ });
95
+
96
+ mocks.appendChild(dataset);
97
+
98
+ await new Promise((resolve) => setTimeout(resolve, 30));
99
+ await dataset.write();
100
+
101
+ expect(ds.data[0]).deep.equal({name: '', email: 'a@b'});
102
+ });
103
+
104
+ });
@@ -92,7 +92,30 @@ describe('Tabs', function () {
92
92
 
93
93
  });
94
94
 
95
+ it('should shorten label from content when no explicit label is provided', function (done) {
96
+
97
+ let mocks = document.getElementById('mocks');
98
+ mocks.innerHTML = html1;
99
+
100
+ setTimeout(() => {
101
+ try {
102
+ const tabs = document.getElementById('mytabs');
103
+ expect(tabs).is.instanceof(Tabs);
104
+
105
+ setTimeout(() => {
106
+ const buttons = tabs.shadowRoot.querySelectorAll('button[part=button]');
107
+ const labelSpan = buttons[2].querySelector('span[data-monster-replace]');
108
+ expect(labelSpan).to.not.equal(null);
109
+ expect(labelSpan.textContent.trim()).to.equal('Das ist tab…');
110
+ done();
111
+ }, 100);
112
+ } catch (e) {
113
+ return done(e);
114
+ }
115
+ }, 0);
116
+ });
117
+
95
118
  });
96
119
 
97
120
 
98
- });
121
+ });
@@ -7,7 +7,7 @@ describe('Monster', function () {
7
7
  let monsterVersion
8
8
 
9
9
  /** don´t touch, replaced by make with package.json version */
10
- monsterVersion = new Version("4.94.0")
10
+ monsterVersion = new Version("4.104.0")
11
11
 
12
12
  let m = getMonsterVersion();
13
13
 
@@ -9,8 +9,8 @@
9
9
  </head>
10
10
  <body>
11
11
  <div id="headline" style="display: flex;align-items: center;justify-content: center;flex-direction: column;">
12
- <h1 style='margin-bottom: 0.1em;'>Monster 4.94.0</h1>
13
- <div id="lastupdate" style='font-size:0.7em'>last update Di 13. Jan 13:09:31 CET 2026</div>
12
+ <h1 style='margin-bottom: 0.1em;'>Monster 4.104.0</h1>
13
+ <div id="lastupdate" style='font-size:0.7em'>last update Mi 21. Jan 21:19:58 CET 2026</div>
14
14
  </div>
15
15
  <div id="mocha-errors"
16
16
  style="color: red;font-weight: bold;display: flex;align-items: center;justify-content: center;flex-direction: column;margin:20px;"></div>