@optionfactory/ful 0.90.0 → 0.91.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/dist/ful.mjs CHANGED
@@ -70,21 +70,51 @@ class Hex {
70
70
  }
71
71
  }
72
72
 
73
+ /**
74
+ * @typedef {{ type: string; context: string?; reason: string; details: any?; }} Problem
75
+ */
73
76
  class Failure extends Error {
74
- constructor(name, problems, cause) {
75
- super(JSON.stringify(problems), { cause });
76
- this.name = name;
77
+ /**
78
+ *
79
+ * @param {string} message
80
+ * @param {Problem[]} problems
81
+ * @param {*} cause
82
+ */
83
+ constructor(message, problems, cause) {
84
+ super(message, { cause });
85
+ this.name = 'Failure';
77
86
  this.problems = problems;
78
87
  }
79
88
  }
80
89
 
90
+ /**
91
+ * @typedef {Int8Array| Uint8Array| Uint8ClampedArray| Int16Array| Uint16Array| Int32Array| Uint32Array| Float32Array| Float64Array| BigInt64Array| BigUint64Array} TypedArray
92
+ */
93
+ /**
94
+ * @typedef HttpInterceptor
95
+ * @property {function(Request,HttpInterceptorChain):Promise<Response>} intercept
96
+ */
97
+
81
98
  class HttpClientError extends Failure {
82
- constructor(status, problems, cause) {
83
- super(`HttpClientError:${status}`, problems, cause);
99
+ /**
100
+ * @param {string} message
101
+ * @param {number} status
102
+ * @param {{ type: string; context: string?; reason: string; details: any?; }[]} problems
103
+ * @param {Error|undefined} [cause]
104
+ */
105
+ constructor(message, status, problems, cause) {
106
+ super(message, problems, cause);
107
+ this.name = 'HttpClientError';
84
108
  this.status = status;
85
109
  }
110
+ /**
111
+ *
112
+ * @param {string} type
113
+ * @param {any} cause
114
+ * @returns
115
+ */
86
116
  static of(type, cause) {
87
- return new HttpClientError(0, [{
117
+ return new HttpClientError(cause.message, 0, [{
88
118
  type,
89
119
  context: null,
90
120
  reason: cause.message,
@@ -98,47 +128,61 @@ class HttpClientError extends Failure {
98
128
  */
99
129
  static async fromResponse(response) {
100
130
  const text = await response.text();
101
- const def = [{
131
+ const message = `${response.status} ${response.statusText}: ${text}`;
132
+ const fallback = [{
102
133
  type: "GENERIC_PROBLEM",
103
134
  context: null,
104
- reason: `${response.status} ${response.statusText}: ${text}`,
135
+ reason: message,
105
136
  details: null
106
137
  }];
107
138
  try {
108
- return new HttpClientError(response.status, text ? JSON.parse(text) : def);
139
+ return new HttpClientError(message, response.status, text ? JSON.parse(text) : fallback);
109
140
  } catch (e) {
110
- return new HttpClientError(response.status, def);
141
+ return new HttpClientError(message, response.status, fallback);
111
142
  }
112
143
  }
113
144
  }
114
145
 
146
+ /**
147
+ * @implements {HttpInterceptor}
148
+ */
115
149
  class CsrfTokenInterceptor {
116
150
  #k; #v;
117
151
  constructor() {
118
- this.#k = document.querySelector("meta[name='_csrf_header']").getAttribute("content");
119
- this.#v = document.querySelector("meta[name='_csrf']").getAttribute("content");
120
- }
152
+ this.#k = document.querySelector("meta[name='_csrf_header']")?.getAttribute("content");
153
+ this.#v = document.querySelector("meta[name='_csrf']")?.getAttribute("content");
154
+ }
121
155
  async intercept(request, chain) {
122
- request.headers.set(this.#k, this.#v);
156
+ if(this.#k && this.#v) {
157
+ request.headers.set(this.#k, this.#v);
158
+ }
123
159
  return await chain.proceed(request);
124
160
  }
125
161
  }
126
-
162
+ /**
163
+ * @implements {HttpInterceptor}
164
+ */
127
165
  class RedirectOnUnauthorizedInterceptor {
128
166
  #redirectUri;
167
+ /**
168
+ * @param {string} redirectUri
169
+ */
129
170
  constructor(redirectUri) {
130
171
  this.#redirectUri = redirectUri;
131
172
  }
132
173
  async intercept(request, chain) {
133
174
  const response = await chain.proceed(request);
134
- if (response.status !== 401) {
135
- return response;
175
+ if (response.status === 401) {
176
+ window.location.href = this.#redirectUri;
136
177
  }
137
- window.location.href = this.#redirectUri;
178
+ return response;
138
179
  }
139
180
  }
140
181
 
141
182
  class HttpClientBuilder {
183
+ /**
184
+ * @type {HttpInterceptor[]}
185
+ */
142
186
  #interceptors;
143
187
  constructor() {
144
188
  this.#interceptors = [];
@@ -151,6 +195,9 @@ class HttpClientBuilder {
151
195
  this.#interceptors.push(new RedirectOnUnauthorizedInterceptor(redirectUri));
152
196
  return this;
153
197
  }
198
+ /**
199
+ * @param {...HttpInterceptor} interceptors
200
+ */
154
201
  withInterceptors(...interceptors) {
155
202
  this.#interceptors.push(...interceptors);
156
203
  return this;
@@ -160,27 +207,35 @@ class HttpClientBuilder {
160
207
  }
161
208
  }
162
209
 
210
+ /**
211
+ * @implements {HttpInterceptor}
212
+ */
163
213
  class HttpCall {
164
- /**
165
- *
166
- * @async
167
- * @param {Request} request
168
- * @param {HttpInterceptorChain} chain
169
- * @returns {Promise<Response>} the response
170
- */
171
214
  async intercept(request, chain) {
172
215
  return await fetch(request);
173
216
  }
174
217
  }
175
218
 
176
219
  class HttpInterceptorChain {
220
+ #interceptors;
221
+ #current;
222
+ /**
223
+ *
224
+ * @param {HttpInterceptor[]} interceptors
225
+ * @param {number} current
226
+ */
177
227
  constructor(interceptors, current) {
178
- this.interceptors = interceptors;
179
- this.current = current;
228
+ this.#interceptors = interceptors;
229
+ this.#current = current;
180
230
  }
231
+ /**
232
+ *
233
+ * @param {Request} request
234
+ * @returns {Promise<Response>} the response
235
+ */
181
236
  async proceed(request) {
182
- const interceptor = this.interceptors[this.current];
183
- return await interceptor.intercept(request, new HttpInterceptorChain(this.interceptors, this.current + 1));
237
+ const interceptor = this.#interceptors[this.#current];
238
+ return await interceptor.intercept(request, new HttpInterceptorChain(this.#interceptors, this.#current + 1));
184
239
  }
185
240
  }
186
241
 
@@ -188,14 +243,14 @@ class HttpClient {
188
243
  #interceptors;
189
244
  /**
190
245
  * Creates a builder for an HttpClient.
191
- * @returns {HttpRequestBuilder} the client builder
246
+ * @returns {HttpClientBuilder} the client builder
192
247
  */
193
248
  static builder() {
194
249
  return new HttpClientBuilder();
195
250
  }
196
251
  /**
197
252
  * Creates an HttpClient.
198
- * @returns {[HttpInterceptor]} interceptors - a list of interceptors to be registered for every request performed by the created client.
253
+ * @param {HttpInterceptor[]|undefined} interceptors - a list of interceptors to be registered for every request performed by the created client.
199
254
  */
200
255
  constructor(interceptors) {
201
256
  this.#interceptors = interceptors || [];
@@ -205,7 +260,7 @@ class HttpClient {
205
260
  * @async
206
261
  * @param {string} uri - the (possibly relative) request url
207
262
  * @param {RequestInit|undefined} options - fetch options
208
- * @param {[any]|undefined} interceptors - the HttpInterceptors to be registered for this request.
263
+ * @param {HttpInterceptor[]|undefined} interceptors - the HttpInterceptors to be registered for this exchange.
209
264
  * @returns {Promise<Response>} the response
210
265
  */
211
266
  async exchange(uri, options, interceptors) {
@@ -272,12 +327,17 @@ class HttpClient {
272
327
  }
273
328
  }
274
329
 
275
-
330
+ /**
331
+ *
332
+ * @param {Response} response
333
+ * @param {'text'|'json'|'blob'|'arrayBuffer'} type
334
+ * @returns
335
+ */
276
336
  const unmarshal = async (response, type) => {
277
337
  try {
278
338
  return await response[type]();
279
- } catch (e) {
280
- throw HttpClientError.of("UNMARSHALING_PROBLEM", e);
339
+ } catch (ex) {
340
+ throw HttpClientError.of("UNMARSHALING_PROBLEM", ex);
281
341
  }
282
342
  };
283
343
 
@@ -319,7 +379,7 @@ class HttpRequestBuilder {
319
379
  * @param {Headers} headers
320
380
  * @param {any} body
321
381
  * @param {Omit<RequestInit,"headers"|"method"|"body">} options
322
- * @param {[HttpInterceptor]} interceptors
382
+ * @param {HttpInterceptor[]} interceptors
323
383
  */
324
384
  constructor(client, method, uri, params, headers, body, options, interceptors) {
325
385
  this.#client = client;
@@ -333,7 +393,7 @@ class HttpRequestBuilder {
333
393
  }
334
394
  /**
335
395
  * Add all passed headers to the request, overriding existing ones if that key already exists.
336
- * @param {headersInit} hs
396
+ * @param {HeadersInit} hs
337
397
  * @returns {HttpRequestBuilder} this builder
338
398
  */
339
399
  headers(hs) {
@@ -395,6 +455,17 @@ class HttpRequestBuilder {
395
455
  this.#body = JSON.stringify(body);
396
456
  return this;
397
457
  }
458
+ /**
459
+ * Sets the request body as a FormData configured using the callback.
460
+ * `Content-Type: multipart/form-data` header is automatically added by fetch if not explicitly set.
461
+ * @param {function(HttpMultipartRequestCustomizer):void} callback
462
+ */
463
+ multipart(callback) {
464
+ const formData = new FormData();
465
+ const builder = new HttpMultipartRequestCustomizer(formData);
466
+ callback(builder);
467
+ this.#body = formData;
468
+ }
398
469
  /**
399
470
  * Sets a fetch options for the request.
400
471
  * @param {Omit<RequestInit,"headers"|"method"|"body">} kvs
@@ -402,6 +473,7 @@ class HttpRequestBuilder {
402
473
  */
403
474
  options(kvs) {
404
475
  for (const [k, v] of Object.entries(kvs)) {
476
+ // @ts-ignore
405
477
  this.#options[k] = v;
406
478
  }
407
479
  return this;
@@ -468,11 +540,11 @@ class HttpRequestBuilder {
468
540
  throw await HttpClientError.fromResponse(response);
469
541
  }
470
542
  return response;
471
- } catch (e) {
472
- if (e instanceof Failure) {
473
- throw e;
543
+ } catch (ex) {
544
+ if (ex instanceof Failure) {
545
+ throw ex;
474
546
  }
475
- throw HttpClientError.of("CONNECTION_PROBLEM", e);
547
+ throw HttpClientError.of("CONNECTION_PROBLEM", ex);
476
548
  }
477
549
  }
478
550
  /**
@@ -491,14 +563,6 @@ class HttpRequestBuilder {
491
563
  const response = await this.fetch();
492
564
  return await unmarshal(response, 'json');
493
565
  }
494
- /**
495
- * Performs an HTTP exchange using the configured client request, and interceptos throwing a failure when response status is not in the 200-299 range.
496
- * @returns {Promise<Uint8Array>} the response body, as an Uint8Array
497
- */
498
- async fetchBytes() {
499
- const response = await this.fetch();
500
- return await unmarshal(response, 'bytes');
501
- }
502
566
  /**
503
567
  * Performs an HTTP exchange using the configured client request, and interceptos throwing a failure when response status is not in the 200-299 range.
504
568
  * @returns {Promise<Blob>} the response body, as a Blob
@@ -517,6 +581,54 @@ class HttpRequestBuilder {
517
581
  }
518
582
  }
519
583
 
584
+
585
+ class HttpMultipartRequestCustomizer {
586
+ #formData;
587
+ /**
588
+ *
589
+ * @param {FormData} formData
590
+ */
591
+ constructor(formData){
592
+ this.#formData = formData;
593
+ }
594
+ /**
595
+ * Appends a value to the FormData.
596
+ * @param {string} name
597
+ * @param {*} value
598
+ * @returns this builder
599
+ */
600
+ field(name, value){
601
+ this.#formData.append(name, value);
602
+ return this;
603
+ }
604
+ /**
605
+ * Appends a Blob to the FormData.
606
+ * If `filename` is omitted, FormData defaults are applied:
607
+ * The default filename for Blob objects is "blob";
608
+ * The default filename for File objects is the file's filename.
609
+ * @param {string} name
610
+ * @param {Blob} value
611
+ * @param {string|undefined} filename
612
+ * @returns this builder
613
+ */
614
+ blob(name, value, filename){
615
+ this.#formData.append(name, value, filename);
616
+ return this;
617
+ }
618
+ /**
619
+ * Appends a JSON serialized blob to the FormData.
620
+ * @param {string} name
621
+ * @param {any} value
622
+ * @param {string|undefined} filename
623
+ * @returns this builder
624
+ */
625
+ json(name, value, filename){
626
+ const blob = new Blob([JSON.stringify(value)], {type: 'application/json'});
627
+ this.#formData.append(name, blob, filename);
628
+ return this;
629
+ }
630
+ }
631
+
520
632
  class Storage {
521
633
  constructor(prefix, storage) {
522
634
  this.prefix = prefix;
@@ -613,7 +725,7 @@ class AuthorizationCodeFlow {
613
725
  Object.entries(additionalParams || {}).forEach(kv => {
614
726
  url.searchParams.set(kv[0], kv[1]);
615
727
  });
616
- window.location = url;
728
+ window.location.href = url.toString();
617
729
  }
618
730
  async registration(additionalParams){
619
731
  await this.action(this.uri.registration, additionalParams);
@@ -623,7 +735,7 @@ class AuthorizationCodeFlow {
623
735
  kc_action: kcAction
624
736
  });
625
737
  }
626
- async _tokenExchange(code, state) {
738
+ async #tokenExchange(code, state) {
627
739
  window.history.replaceState('', "", this.uri.redirect);
628
740
  const stateAndVerifier = this.storage.pop(AuthorizationCodeFlow.PKCE_AND_STATE_KEY);
629
741
  if (stateAndVerifier.state !== state) {
@@ -656,7 +768,7 @@ class AuthorizationCodeFlow {
656
768
  if (code && this.storage.load(AuthorizationCodeFlow.PKCE_AND_STATE_KEY)) {
657
769
  //if callback from keycloak and we have our state still stored
658
770
  const state = url.searchParams.get("state");
659
- return await this._tokenExchange(code, state);
771
+ return await this.#tokenExchange(code, state);
660
772
  }
661
773
  //if not authorized
662
774
  await this.action(this.uri.auth, {});
@@ -727,7 +839,7 @@ class AuthorizationCodeFlowSession {
727
839
  const url = new URL(this.uri.logout);
728
840
  url.searchParams.set("post_logout_redirect_uri", this.uri.redirect);
729
841
  url.searchParams.set("id_token_hint", this.token.id_token);
730
- window.location = url;
842
+ window.location.href = url.toString();
731
843
  }
732
844
 
733
845
  bearerToken() {
@@ -786,7 +898,7 @@ const timing = {
786
898
  };
787
899
 
788
900
  return function () {
789
- args = arguments;
901
+ args = [...arguments];
790
902
  previousTimestamp = new Date().getTime();
791
903
  if (tid === null) {
792
904
  tid = setTimeout(later, timeoutMs);
@@ -820,7 +932,7 @@ const timing = {
820
932
  previousTimestamp = now;
821
933
  }
822
934
  const remaining = timeoutMs - (now - previousTimestamp);
823
- args = arguments;
935
+ args = [...arguments];
824
936
  if (remaining <= 0 || remaining > timeoutMs) {
825
937
  if (tid !== null) {
826
938
  clearTimeout(tid);
@@ -926,16 +1038,18 @@ class Attributes {
926
1038
  .forEach(a => {
927
1039
  const target = a.substring(prefix.length);
928
1040
  if (target === 'class') {
929
- to.classList.add(...from.getAttribute(prefix + "class").split(" ").filter(a => a.length));
1041
+ const classes = from.getAttribute(prefix + "class")?.split(" ").filter(a => a.length) ?? [];
1042
+ to.classList.add(...classes);
930
1043
  return;
931
1044
  }
1045
+ // @ts-ignore
932
1046
  to.setAttribute(target, from.getAttribute(a));
933
1047
  });
934
1048
  }
935
1049
  /**
936
1050
  *
937
1051
  * @param {HTMLElement} el
938
- * @param {stirng} attr
1052
+ * @param {string} attr
939
1053
  * @param {boolean} value
940
1054
  */
941
1055
  static toggle(el, attr, value) {
@@ -962,19 +1076,21 @@ class LightSlots {
962
1076
  * @returns the slots
963
1077
  */
964
1078
  static from(el) {
1079
+ /** @type [string, Element][] */
965
1080
  const namedSlots = Array.from(el.childNodes)
966
- .filter(el => el.matches && el.matches('[slot]'))
1081
+ .filter(el => el instanceof Element)
1082
+ .filter(el => el.matches('[slot]'))
967
1083
  .map(el => {
968
1084
  el.remove();
969
1085
  const slot = el.getAttribute("slot");
970
1086
  el.removeAttribute("slot");
971
- return [slot, el];
1087
+ return [slot ?? 'unnamed', el];
972
1088
  });
973
1089
  const slots = {};
974
1090
  slots.default = new DocumentFragment();
975
1091
  slots.default.append(...el.childNodes);
976
- for(const [name,el] of namedSlots){
977
- if(!(name in slots)){
1092
+ for (const [name, el] of namedSlots) {
1093
+ if (!(name in slots)) {
978
1094
  slots[name] = new DocumentFragment();
979
1095
  }
980
1096
  slots[name].append(el);
@@ -1000,7 +1116,8 @@ class TemplatesRegistry {
1000
1116
  #ec;
1001
1117
  put(k, fragment) {
1002
1118
  if (this.#ec) {
1003
- this.#idToTemplate[k] = Template.fromFragment(fragment, ec);
1119
+ // @ts-ignore
1120
+ this.#idToTemplate[k] = ftl.Template.fromFragment(fragment, this.#ec);
1004
1121
  return;
1005
1122
  }
1006
1123
  this.#idToFragment[k] = fragment;
@@ -1019,6 +1136,7 @@ class TemplatesRegistry {
1019
1136
  this.#ec = ec;
1020
1137
  for (const [k, fragment] of Object.entries(this.#idToFragment)) {
1021
1138
  delete this.#idToFragment[k];
1139
+ // @ts-ignore
1022
1140
  this.#idToTemplate[k] = ftl.Template.fromFragment(fragment, ec, ...data);
1023
1141
  }
1024
1142
  }
@@ -1122,8 +1240,8 @@ const ParsedElement = (conf) => {
1122
1240
  #initialized;
1123
1241
  #reflecting;
1124
1242
  #internals;
1125
- constructor(...args) {
1126
- super(...args);
1243
+ constructor() {
1244
+ super();
1127
1245
  this.#internals = this.attachInternals();
1128
1246
  }
1129
1247
  get initialized() {
@@ -1151,6 +1269,7 @@ const ParsedElement = (conf) => {
1151
1269
  observer.disconnect();
1152
1270
  upgradeQueue.enqueue(this);
1153
1271
  });
1272
+ // @ts-ignore
1154
1273
  observer.observe(this.parentNode, { childList: true, subtree: true });
1155
1274
  }
1156
1275
  attributeChangedCallback(attr, oldValue, newValue) {
@@ -1176,6 +1295,7 @@ const ParsedElement = (conf) => {
1176
1295
  return;
1177
1296
  }
1178
1297
  this.#parsed = true;
1298
+ // @ts-ignore
1179
1299
  await this.render(elements.template(templateId), slots ? LightSlots.from(this) : undefined);
1180
1300
 
1181
1301
  for (const [attr, mapper] of attrsAndMappers) {
@@ -1327,7 +1447,7 @@ class Form extends ParsedElement() {
1327
1447
  }
1328
1448
  get values() {
1329
1449
  return Array.from(this.querySelectorAll('[name]'))
1330
- .filter((el) => {
1450
+ .filter(el => {
1331
1451
  if (el.dataset['fulBindInclude'] === 'never') {
1332
1452
  return false;
1333
1453
  }
@@ -1338,14 +1458,14 @@ class Form extends ParsedElement() {
1338
1458
  }, {});
1339
1459
  }
1340
1460
  set errors(es) {
1341
- const fieldErrors = es.filter((e) => e.type === 'FIELD_ERROR' || e.type === 'INVALID_FORMAT');
1342
- const globalErrors = es.filter((e) => e.type !== 'FIELD_ERROR' && e.type !== 'INVALID_FORMAT');
1461
+ const fieldErrors = es.filter(e => e.type === 'FIELD_ERROR' || e.type === 'INVALID_FORMAT');
1462
+ const globalErrors = es.filter(e => e.type !== 'FIELD_ERROR' && e.type !== 'INVALID_FORMAT');
1343
1463
  this.querySelectorAll(`.${Form.INVALID_CLASS}`).forEach(el => el.classList.remove(Form.INVALID_CLASS));
1344
1464
  this.querySelectorAll("ful-errors").forEach(el => {
1345
1465
  el.replaceChildren();
1346
1466
  el.setAttribute('hidden', '');
1347
1467
  });
1348
- fieldErrors.forEach((e) => {
1468
+ fieldErrors.forEach(e => {
1349
1469
  const name = e.context.replace("[", ".").replace("].", ".");
1350
1470
  const validationTargetsSelector = `[name='${CSS.escape(name)}'] [ful-validation-target],[name='${CSS.escape(name)}']:not(:has([ful-validation-target]))`;
1351
1471
  this.querySelectorAll(validationTargetsSelector).forEach(input => input.classList.add(Form.INVALID_CLASS));
@@ -1416,6 +1536,7 @@ class Input extends ParsedElement({
1416
1536
  slots: true,
1417
1537
  template: INPUT_TEMPLATE
1418
1538
  }){
1539
+ input;
1419
1540
  render(template, slots) {
1420
1541
  const fragment = makeInputFragment(this, template, slots);
1421
1542
  this.replaceChildren(fragment);
@@ -1586,27 +1707,40 @@ class RadioGroup extends ParsedElement({
1586
1707
  input.addEventListener('change', evt => {
1587
1708
  evt.stopPropagation();
1588
1709
  //change is not cancelable
1589
- this.dispatchEvent(new CustomEvent('change', {
1590
- bubbles: true,
1591
- cancelable: false,
1710
+ this.dispatchEvent(new CustomEvent('change', {
1711
+ bubbles: true,
1712
+ cancelable: false,
1592
1713
  detail: {
1593
1714
  value: this.value
1594
1715
  }
1595
- }));
1596
- });
1716
+ }));
1717
+ });
1597
1718
  const label = Fragments.fromChildNodes(el);
1598
1719
  return [input, label];
1599
1720
  });
1600
1721
 
1601
1722
  radioEls.forEach(el => el.remove());
1602
- template.renderTo(this, {name, slots, inputsAndLabels});
1723
+ template.renderTo(this, { name, slots, inputsAndLabels });
1603
1724
  }
1604
1725
  get value() {
1726
+ /** @type {HTMLInputElement|null} */
1605
1727
  const checked = this.querySelector('input[type=radio]:checked');
1606
1728
  return checked ? checked.value : null;
1607
1729
  }
1608
1730
  set value(value) {
1609
- this.querySelector(`input[type=radio][value=${CSS.escape(value)}]`).checked = true;
1731
+ if (value === null) {
1732
+ /** @type {HTMLInputElement[]} */
1733
+ this.querySelectorAll(`input[type=radio]`).forEach(el => {
1734
+ // @ts-ignore
1735
+ el.checked = false;
1736
+ });
1737
+ return;
1738
+ }
1739
+ /** @type {HTMLInputElement|null} */
1740
+ const el = this.querySelector(`input[type=radio][value=${CSS.escape(value)}]`);
1741
+ if (el) {
1742
+ el.checked = true;
1743
+ }
1610
1744
  }
1611
1745
  }
1612
1746