@richardmcquiston01/dashboard-widgets-toolkit 0.2.0 → 0.4.1

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/core.js CHANGED
@@ -190,7 +190,395 @@ function kpiDelta(current, previous, options = {}) {
190
190
  };
191
191
  }
192
192
 
193
+ // src/core/result.ts
194
+ function ok(value) {
195
+ return { ok: true, value };
196
+ }
197
+ function err(error) {
198
+ return { ok: false, error };
199
+ }
200
+ function describeUnknownError(cause) {
201
+ if (cause instanceof Error) {
202
+ return cause.message === "" ? cause.name : cause.message;
203
+ }
204
+ if (typeof cause === "string") {
205
+ return cause;
206
+ }
207
+ try {
208
+ const json = JSON.stringify(cause);
209
+ return json ?? String(cause);
210
+ } catch {
211
+ return String(cause);
212
+ }
213
+ }
214
+
215
+ // src/core/url.ts
216
+ var SCHEME_PATTERN = /^([a-z][a-z0-9+.-]*):/i;
217
+ var IGNORED_IN_SCHEME = /[\u0000- \u007f]/g;
218
+ function schemeOf(url) {
219
+ const match = SCHEME_PATTERN.exec(
220
+ url.replace(IGNORED_IN_SCHEME, "")
221
+ );
222
+ return match === null ? null : (match[1] ?? "").toLowerCase();
223
+ }
224
+ function isSafeHref(url) {
225
+ if (url.trim() === "") {
226
+ return false;
227
+ }
228
+ const scheme = schemeOf(url);
229
+ return scheme === null || scheme === "http" || scheme === "https" || scheme === "mailto";
230
+ }
231
+ function isSafeImageUrl(url) {
232
+ if (url.trim() === "") {
233
+ return false;
234
+ }
235
+ const scheme = schemeOf(url);
236
+ return scheme === null || scheme === "http" || scheme === "https";
237
+ }
238
+
239
+ // src/core/detail.ts
240
+ var DETAIL_MODES = ["client", "server"];
241
+ var DEFAULT_DETAIL_PAGE_SIZE = 25;
242
+ var MAX_DETAIL_PAGE_SIZE = 200;
243
+ function resolveDetailOptions(definition) {
244
+ const detail = definition.detail;
245
+ if (detail === void 0 || detail === false) return void 0;
246
+ const options = detail === true ? {} : detail;
247
+ return {
248
+ title: options.title ?? definition.title,
249
+ pageSize: options.pageSize ?? DEFAULT_DETAIL_PAGE_SIZE,
250
+ mode: options.mode ?? "client"
251
+ };
252
+ }
253
+ function defaultDetailQuery(pageSize = DEFAULT_DETAIL_PAGE_SIZE) {
254
+ return { page: 1, pageSize };
255
+ }
256
+ function fold(text) {
257
+ return text.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase();
258
+ }
259
+ function cellValue(cell) {
260
+ return cell.value ?? cell.text;
261
+ }
262
+ function cellMatches(cell, needle) {
263
+ return fold(String(cellValue(cell))).includes(needle);
264
+ }
265
+ function compareCells(a, b, collator) {
266
+ const left = cellValue(a);
267
+ const right = cellValue(b);
268
+ if (typeof left === "number" && typeof right === "number") {
269
+ return left - right;
270
+ }
271
+ if (typeof left === "number") return -1;
272
+ if (typeof right === "number") return 1;
273
+ return collator.compare(left, right);
274
+ }
275
+ function queryRows(data, query, locale) {
276
+ const indexOf = new Map(
277
+ data.columns.map((column, index) => [column.key, index])
278
+ );
279
+ const filterEntries = [];
280
+ for (const [key, text] of Object.entries(query.filters ?? {})) {
281
+ const index = indexOf.get(key);
282
+ if (index === void 0) {
283
+ return err(
284
+ `Detail query: filter column "${key}" does not exist. Columns: ${listKeys(data)}.`
285
+ );
286
+ }
287
+ const needle = fold(text.trim());
288
+ if (needle !== "") filterEntries.push({ index, needle });
289
+ }
290
+ let sortIndex = -1;
291
+ if (query.sort !== void 0) {
292
+ const found = indexOf.get(query.sort.column);
293
+ const column = found === void 0 ? void 0 : data.columns[found];
294
+ if (found === void 0 || column === void 0) {
295
+ return err(
296
+ `Detail query: sort column "${query.sort.column}" does not exist. Columns: ${listKeys(data)}.`
297
+ );
298
+ }
299
+ if (column.sortable === false) {
300
+ return err(
301
+ `Detail query: column "${column.key}" is not sortable (sortable: false).`
302
+ );
303
+ }
304
+ sortIndex = found;
305
+ }
306
+ const search = fold((query.search ?? "").trim());
307
+ let rows = data.rows.filter(
308
+ (row) => filterEntries.every(({ index, needle }) => {
309
+ const cell = row[index];
310
+ return cell !== void 0 && cellMatches(cell, needle);
311
+ }) && (search === "" || row.some((cell) => cellMatches(cell, search)))
312
+ );
313
+ if (query.sort !== void 0 && sortIndex >= 0) {
314
+ const collator = new Intl.Collator(locale, {
315
+ numeric: true,
316
+ sensitivity: "base"
317
+ });
318
+ const sign = query.sort.direction === "desc" ? -1 : 1;
319
+ rows = rows.map((row, position) => ({ row, position })).sort((a, b) => {
320
+ const left = a.row[sortIndex];
321
+ const right = b.row[sortIndex];
322
+ const order = left === void 0 || right === void 0 ? 0 : sign * compareCells(left, right, collator);
323
+ return order || a.position - b.position;
324
+ }).map(({ row }) => row);
325
+ }
326
+ const pageSize = Math.max(1, Math.floor(query.pageSize));
327
+ const pageCount = Math.max(1, Math.ceil(rows.length / pageSize));
328
+ const page = Math.min(Math.max(1, Math.floor(query.page)), pageCount);
329
+ return ok({
330
+ rows: rows.slice((page - 1) * pageSize, page * pageSize),
331
+ totalRows: rows.length,
332
+ page,
333
+ pageCount
334
+ });
335
+ }
336
+ function listKeys(data) {
337
+ return data.columns.map((column) => `"${column.key}"`).join(", ");
338
+ }
339
+ var FILTER_PREFIX = "f.";
340
+ function serializeDetailQuery(query) {
341
+ const parts = [];
342
+ const add = (key, value) => {
343
+ parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
344
+ };
345
+ if (query.search !== void 0 && query.search !== "") {
346
+ add("q", query.search);
347
+ }
348
+ if (query.sort !== void 0) {
349
+ add("sort", `${query.sort.column}:${query.sort.direction}`);
350
+ }
351
+ for (const [column, text] of Object.entries(query.filters ?? {})) {
352
+ if (text !== "") add(`${FILTER_PREFIX}${column}`, text);
353
+ }
354
+ if (query.page > 1) add("page", String(query.page));
355
+ return parts.join("&");
356
+ }
357
+ function parseDetailQuery(queryString, pageSize = DEFAULT_DETAIL_PAGE_SIZE, data) {
358
+ let search;
359
+ let sort;
360
+ let page = 1;
361
+ const filters = {};
362
+ const columnKeys = data === void 0 ? void 0 : new Set(data.columns.map((column) => column.key));
363
+ const text = queryString.startsWith("?") ? queryString.slice(1) : queryString;
364
+ for (const pair of text.split("&")) {
365
+ if (pair === "") continue;
366
+ const equals = pair.indexOf("=");
367
+ const rawKey = equals < 0 ? pair : pair.slice(0, equals);
368
+ const rawValue = equals < 0 ? "" : pair.slice(equals + 1);
369
+ let key;
370
+ let value;
371
+ try {
372
+ key = decodeURIComponent(rawKey.replace(/\+/g, " "));
373
+ value = decodeURIComponent(rawValue.replace(/\+/g, " "));
374
+ } catch {
375
+ return err(
376
+ `Detail query: "${pair}" is not valid percent-encoding in the URL.`
377
+ );
378
+ }
379
+ if (key === "q") {
380
+ search = value;
381
+ } else if (key === "page") {
382
+ if (!/^[1-9]\d*$/.test(value)) {
383
+ return err(
384
+ `Detail query: page must be a whole number from 1, got "${value}".`
385
+ );
386
+ }
387
+ page = Number(value);
388
+ } else if (key === "sort") {
389
+ const colon = value.lastIndexOf(":");
390
+ const column = colon < 0 ? value : value.slice(0, colon);
391
+ const direction = colon < 0 ? "asc" : value.slice(colon + 1);
392
+ if (column === "" || direction !== "asc" && direction !== "desc") {
393
+ return err(
394
+ `Detail query: sort must look like "column:asc" or "column:desc", got "${value}".`
395
+ );
396
+ }
397
+ if (columnKeys !== void 0 && !columnKeys.has(column)) {
398
+ return err(`Detail query: sort column "${column}" does not exist.`);
399
+ }
400
+ sort = { column, direction };
401
+ } else if (key.startsWith(FILTER_PREFIX)) {
402
+ const column = key.slice(FILTER_PREFIX.length);
403
+ if (columnKeys !== void 0 && !columnKeys.has(column)) {
404
+ return err(`Detail query: filter column "${column}" does not exist.`);
405
+ }
406
+ if (value !== "") filters[column] = value;
407
+ }
408
+ }
409
+ return ok({
410
+ ...search === void 0 || search === "" ? {} : { search },
411
+ ...Object.keys(filters).length === 0 ? {} : { filters },
412
+ ...sort === void 0 ? {} : { sort },
413
+ page,
414
+ pageSize
415
+ });
416
+ }
417
+ function describeType(value) {
418
+ if (value === null) return "null";
419
+ if (Array.isArray(value)) return "array";
420
+ return typeof value;
421
+ }
422
+ function validateDetailData(data, subject = "Detail data") {
423
+ const problems = [];
424
+ if (typeof data !== "object" || data === null || Array.isArray(data)) {
425
+ return err(`${subject}: must be an object, got ${describeType(data)}.`);
426
+ }
427
+ const record = data;
428
+ const columns = record["columns"];
429
+ const rows = record["rows"];
430
+ const keys = /* @__PURE__ */ new Set();
431
+ let columnCount = 0;
432
+ if (!Array.isArray(columns) || columns.length === 0) {
433
+ problems.push(
434
+ `columns must be a non-empty array, got ${describeType(columns)}.`
435
+ );
436
+ } else {
437
+ columnCount = columns.length;
438
+ columns.forEach((column, index) => {
439
+ const at = `columns[${index}]`;
440
+ if (typeof column !== "object" || column === null) {
441
+ problems.push(`${at} must be an object, got ${describeType(column)}.`);
442
+ return;
443
+ }
444
+ const fields = column;
445
+ const key = fields["key"];
446
+ if (typeof key !== "string" || key === "") {
447
+ problems.push(`${at}.key must be a non-empty string.`);
448
+ } else if (keys.has(key)) {
449
+ problems.push(`${at}.key "${key}" is used by an earlier column.`);
450
+ } else {
451
+ keys.add(key);
452
+ }
453
+ if (typeof fields["label"] !== "string") {
454
+ problems.push(
455
+ `${at}.label must be a string, got ${describeType(fields["label"])}.`
456
+ );
457
+ }
458
+ for (const flag of ["numeric", "sortable", "filterable"]) {
459
+ const flagValue = fields[flag];
460
+ if (flagValue !== void 0 && typeof flagValue !== "boolean") {
461
+ problems.push(
462
+ `${at}.${flag} must be a boolean, got ${describeType(flagValue)}.`
463
+ );
464
+ }
465
+ }
466
+ });
467
+ }
468
+ if (!Array.isArray(rows)) {
469
+ problems.push(`rows must be an array, got ${describeType(rows)}.`);
470
+ } else {
471
+ rows.forEach((row, rowIndex) => {
472
+ if (!Array.isArray(row)) {
473
+ problems.push(
474
+ `rows[${rowIndex}] must be an array of cells, got ${describeType(row)}.`
475
+ );
476
+ return;
477
+ }
478
+ if (columnCount > 0 && row.length !== columnCount) {
479
+ problems.push(
480
+ `rows[${rowIndex}] has ${row.length} cells but there are ${columnCount} columns.`
481
+ );
482
+ }
483
+ row.forEach((cell, cellIndex) => {
484
+ const at = `rows[${rowIndex}][${cellIndex}]`;
485
+ if (typeof cell !== "object" || cell === null) {
486
+ problems.push(`${at} must be an object, got ${describeType(cell)}.`);
487
+ return;
488
+ }
489
+ const fields = cell;
490
+ if (typeof fields["text"] !== "string") {
491
+ problems.push(
492
+ `${at}.text must be a string, got ${describeType(fields["text"])}.`
493
+ );
494
+ }
495
+ const value = fields["value"];
496
+ if (value !== void 0 && typeof value !== "string" && !(typeof value === "number" && Number.isFinite(value))) {
497
+ problems.push(
498
+ `${at}.value must be a string or a finite number, got ${describeType(value)}.`
499
+ );
500
+ }
501
+ const href = fields["href"];
502
+ if (href !== void 0) {
503
+ if (typeof href !== "string") {
504
+ problems.push(
505
+ `${at}.href must be a string, got ${describeType(href)}.`
506
+ );
507
+ } else if (!isSafeHref(href)) {
508
+ problems.push(
509
+ `${at}.href "${href}" is not allowed (only http, https, mailto and relative URLs).`
510
+ );
511
+ }
512
+ }
513
+ });
514
+ });
515
+ }
516
+ const totalRows = record["totalRows"];
517
+ if (totalRows !== void 0 && !(typeof totalRows === "number" && Number.isInteger(totalRows) && totalRows >= 0)) {
518
+ problems.push(
519
+ `totalRows must be a non-negative whole number, got ${describeType(totalRows)}.`
520
+ );
521
+ }
522
+ if (problems.length > 0) {
523
+ return err(`${subject}: ${problems.join(" ")}`);
524
+ }
525
+ return ok(data);
526
+ }
527
+ function deriveDetailData(data, locale) {
528
+ if (data.kind === "TABLE") {
529
+ if (data.footer !== void 0) return void 0;
530
+ return {
531
+ columns: data.columns.map((column, index) => ({
532
+ key: `c${index}`,
533
+ label: column.label,
534
+ ...column.numeric === void 0 ? {} : { numeric: column.numeric }
535
+ })),
536
+ rows: data.rows
537
+ };
538
+ }
539
+ if (data.kind === "BAR_LIST") {
540
+ return {
541
+ columns: [
542
+ { key: "label", label: "Name" },
543
+ { key: "value", label: "Value", numeric: true }
544
+ ],
545
+ rows: data.items.map((item) => [
546
+ { text: item.label },
547
+ {
548
+ text: item.display ?? formatValue(item.value, "number", { locale }),
549
+ value: item.value
550
+ }
551
+ ])
552
+ };
553
+ }
554
+ return void 0;
555
+ }
556
+
193
557
  // src/core/grid.ts
558
+ var WIDTH_COLUMNS = 12;
559
+ var MIN_WIDGET_WIDTH = 2;
560
+ var MAX_WIDGET_WIDTH = 12;
561
+ function isWidgetWidth(value) {
562
+ return typeof value === "number" && Number.isInteger(value) && value >= MIN_WIDGET_WIDTH && value <= MAX_WIDGET_WIDTH;
563
+ }
564
+ function widthForSize(size) {
565
+ switch (size) {
566
+ case "small":
567
+ return 3;
568
+ case "large":
569
+ return 6;
570
+ case "full":
571
+ return 12;
572
+ default:
573
+ return 4;
574
+ }
575
+ }
576
+ function itemWidth(item) {
577
+ return isWidgetWidth(item.width) ? item.width : widthForSize(item.defaultSize);
578
+ }
579
+ function usesWidthColumns(items) {
580
+ return items.some((item) => item.width !== void 0);
581
+ }
194
582
  function fillsWidth(fill) {
195
583
  return fill === "width" || fill === "both";
196
584
  }
@@ -203,6 +591,16 @@ function baseColumnSpan(size, columns, largeSpan = 2) {
203
591
  return 1;
204
592
  }
205
593
  function fillColumnSpans(items, columns, largeSpan = 2) {
594
+ return placeAndFill(
595
+ items,
596
+ columns,
597
+ (item, trackCount) => baseColumnSpan(item.defaultSize, trackCount, largeSpan)
598
+ );
599
+ }
600
+ function fillWidthSpans(items) {
601
+ return placeAndFill(items, WIDTH_COLUMNS, itemWidth);
602
+ }
603
+ function placeAndFill(items, columns, spanOf) {
206
604
  const trackCount = Math.max(1, Math.floor(columns));
207
605
  const spans = /* @__PURE__ */ new Map();
208
606
  let row = [];
@@ -221,11 +619,7 @@ function fillColumnSpans(items, columns, largeSpan = 2) {
221
619
  used = 0;
222
620
  }
223
621
  for (const item of items) {
224
- const span = baseColumnSpan(
225
- item.defaultSize,
226
- trackCount,
227
- largeSpan
228
- );
622
+ const span = Math.min(spanOf(item, trackCount), trackCount);
229
623
  if (used + span > trackCount && row.length > 0) closeRow();
230
624
  row.push({ item, span });
231
625
  used += span;
@@ -376,55 +770,9 @@ function pruneLayout(definitions, layout) {
376
770
  return next.order.length === layout.order.length && next.hidden.length === layout.hidden.length && next.minimized.length === layout.minimized.length ? layout : next;
377
771
  }
378
772
 
379
- // src/core/result.ts
380
- function ok(value) {
381
- return { ok: true, value };
382
- }
383
- function err(error) {
384
- return { ok: false, error };
385
- }
386
- function describeUnknownError(cause) {
387
- if (cause instanceof Error) {
388
- return cause.message === "" ? cause.name : cause.message;
389
- }
390
- if (typeof cause === "string") {
391
- return cause;
392
- }
393
- try {
394
- const json = JSON.stringify(cause);
395
- return json ?? String(cause);
396
- } catch {
397
- return String(cause);
398
- }
399
- }
400
-
401
- // src/core/url.ts
402
- var SCHEME_PATTERN = /^([a-z][a-z0-9+.-]*):/i;
403
- var IGNORED_IN_SCHEME = /[\u0000- \u007f]/g;
404
- function schemeOf(url) {
405
- const match = SCHEME_PATTERN.exec(
406
- url.replace(IGNORED_IN_SCHEME, "")
407
- );
408
- return match === null ? null : (match[1] ?? "").toLowerCase();
409
- }
410
- function isSafeHref(url) {
411
- if (url.trim() === "") {
412
- return false;
413
- }
414
- const scheme = schemeOf(url);
415
- return scheme === null || scheme === "http" || scheme === "https" || scheme === "mailto";
416
- }
417
- function isSafeImageUrl(url) {
418
- if (url.trim() === "") {
419
- return false;
420
- }
421
- const scheme = schemeOf(url);
422
- return scheme === null || scheme === "http" || scheme === "https";
423
- }
424
-
425
773
  // src/core/validate.ts
426
774
  var MAX_REPORTED_PROBLEMS = 10;
427
- function describeType(value) {
775
+ function describeType2(value) {
428
776
  if (value === null) return "null";
429
777
  if (Array.isArray(value)) return "array";
430
778
  if (typeof value === "number" && !Number.isFinite(value)) {
@@ -442,7 +790,7 @@ function requireString(record, field, path, problems) {
442
790
  const value = record[field];
443
791
  if (typeof value !== "string") {
444
792
  problems.push(
445
- `${join(path, field)} must be a string, got ${describeType(value)}.`
793
+ `${join(path, field)} must be a string, got ${describeType2(value)}.`
446
794
  );
447
795
  }
448
796
  }
@@ -455,7 +803,7 @@ function requireNumber(record, field, path, problems) {
455
803
  const value = record[field];
456
804
  if (typeof value !== "number" || !Number.isFinite(value)) {
457
805
  problems.push(
458
- `${join(path, field)} must be a finite number, got ${describeType(value)}.`
806
+ `${join(path, field)} must be a finite number, got ${describeType2(value)}.`
459
807
  );
460
808
  }
461
809
  }
@@ -468,7 +816,7 @@ function optionalBoolean(record, field, path, problems) {
468
816
  const value = record[field];
469
817
  if (value !== void 0 && typeof value !== "boolean") {
470
818
  problems.push(
471
- `${join(path, field)} must be a boolean, got ${describeType(value)}.`
819
+ `${join(path, field)} must be a boolean, got ${describeType2(value)}.`
472
820
  );
473
821
  }
474
822
  }
@@ -477,7 +825,7 @@ function optionalUrl(record, field, path, problems, isSafe, allowed) {
477
825
  if (value === void 0) return;
478
826
  if (typeof value !== "string") {
479
827
  problems.push(
480
- `${join(path, field)} must be a string, got ${describeType(value)}.`
828
+ `${join(path, field)} must be a string, got ${describeType2(value)}.`
481
829
  );
482
830
  } else if (!isSafe(value)) {
483
831
  problems.push(
@@ -489,7 +837,7 @@ function requireArray(record, field, path, problems) {
489
837
  const value = record[field];
490
838
  if (!Array.isArray(value)) {
491
839
  problems.push(
492
- `${join(path, field)} must be an array, got ${describeType(value)}.`
840
+ `${join(path, field)} must be an array, got ${describeType2(value)}.`
493
841
  );
494
842
  return null;
495
843
  }
@@ -499,7 +847,7 @@ function requireOneOf(record, field, options, path, problems) {
499
847
  const value = record[field];
500
848
  if (typeof value !== "string" || !options.includes(value)) {
501
849
  problems.push(
502
- `${join(path, field)} must be one of ${options.map((o) => `"${o}"`).join(", ")}, got ${typeof value === "string" ? JSON.stringify(value) : describeType(value)}.`
850
+ `${join(path, field)} must be one of ${options.map((o) => `"${o}"`).join(", ")}, got ${typeof value === "string" ? JSON.stringify(value) : describeType2(value)}.`
503
851
  );
504
852
  }
505
853
  }
@@ -510,7 +858,7 @@ function eachRecord(items, path, problems, check) {
510
858
  check(item, itemPath);
511
859
  } else {
512
860
  problems.push(
513
- `${itemPath} must be an object, got ${describeType(item)}.`
861
+ `${itemPath} must be an object, got ${describeType2(item)}.`
514
862
  );
515
863
  }
516
864
  });
@@ -556,7 +904,7 @@ function checkPayloadFields(kind, data, problems) {
556
904
  const rowPath = `rows[${rowIndex}]`;
557
905
  if (!Array.isArray(row)) {
558
906
  problems.push(
559
- `${rowPath} must be an array of cells, got ${describeType(row)}.`
907
+ `${rowPath} must be an array of cells, got ${describeType2(row)}.`
560
908
  );
561
909
  return;
562
910
  }
@@ -652,6 +1000,25 @@ function checkPayloadFields(kind, data, problems) {
652
1000
  }
653
1001
  }
654
1002
  }
1003
+ function checkDetailOption(detail, problems) {
1004
+ if (detail === void 0 || typeof detail === "boolean") return;
1005
+ if (!isRecord(detail)) {
1006
+ problems.push(
1007
+ `detail must be true, false or an options object, got ${describeType2(detail)}.`
1008
+ );
1009
+ return;
1010
+ }
1011
+ optionalString(detail, "title", "detail", problems);
1012
+ const pageSize = detail["pageSize"];
1013
+ if (pageSize !== void 0 && !(typeof pageSize === "number" && Number.isInteger(pageSize) && pageSize >= 1 && pageSize <= MAX_DETAIL_PAGE_SIZE)) {
1014
+ problems.push(
1015
+ `detail.pageSize must be a whole number from 1 to ${MAX_DETAIL_PAGE_SIZE}, got ${typeof pageSize === "number" ? String(pageSize) : describeType2(pageSize)}.`
1016
+ );
1017
+ }
1018
+ if (detail["mode"] !== void 0) {
1019
+ requireOneOf(detail, "mode", DETAIL_MODES, "detail", problems);
1020
+ }
1021
+ }
655
1022
  function formatProblems(prefix, problems) {
656
1023
  const shown = problems.slice(0, MAX_REPORTED_PROBLEMS);
657
1024
  const more = problems.length - shown.length;
@@ -660,12 +1027,12 @@ function formatProblems(prefix, problems) {
660
1027
  function validateWidgetData(value, options = {}) {
661
1028
  const subject = options.widgetKey === void 0 ? "Widget data" : `Widget ${JSON.stringify(options.widgetKey)}`;
662
1029
  if (!isRecord(value)) {
663
- return err(`${subject}: must be an object, got ${describeType(value)}.`);
1030
+ return err(`${subject}: must be an object, got ${describeType2(value)}.`);
664
1031
  }
665
1032
  if (value["empty"] === true) {
666
1033
  if (typeof value["text"] !== "string") {
667
1034
  return err(
668
- `${subject} (empty): text must be a string, got ${describeType(value["text"])}.`
1035
+ `${subject} (empty): text must be a string, got ${describeType2(value["text"])}.`
669
1036
  );
670
1037
  }
671
1038
  return ok(value);
@@ -673,7 +1040,7 @@ function validateWidgetData(value, options = {}) {
673
1040
  const kind = value["kind"];
674
1041
  if (!isWidgetKind(kind)) {
675
1042
  return err(
676
- `${subject}: kind must be one of ${WIDGET_KINDS.join(", ")} (or the payload must be {"empty": true, "text": ...}), got ${typeof kind === "string" ? JSON.stringify(kind) : describeType(kind)}.`
1043
+ `${subject}: kind must be one of ${WIDGET_KINDS.join(", ")} (or the payload must be {"empty": true, "text": ...}), got ${typeof kind === "string" ? JSON.stringify(kind) : describeType2(kind)}.`
677
1044
  );
678
1045
  }
679
1046
  if (options.expectedKind !== void 0 && kind !== options.expectedKind) {
@@ -691,7 +1058,7 @@ function validateWidgetData(value, options = {}) {
691
1058
  function validateWidgetDefinition(value) {
692
1059
  if (!isRecord(value)) {
693
1060
  return err(
694
- `Widget definition: must be an object, got ${describeType(value)}.`
1061
+ `Widget definition: must be an object, got ${describeType2(value)}.`
695
1062
  );
696
1063
  }
697
1064
  const subject = typeof value["key"] === "string" ? `Widget definition ${JSON.stringify(value["key"])}` : "Widget definition";
@@ -709,7 +1076,7 @@ function validateWidgetDefinition(value) {
709
1076
  roles?.forEach((role, index) => {
710
1077
  if (typeof role !== "string") {
711
1078
  problems.push(
712
- `roles[${index}] must be a string, got ${describeType(role)}.`
1079
+ `roles[${index}] must be a string, got ${describeType2(role)}.`
713
1080
  );
714
1081
  }
715
1082
  });
@@ -718,6 +1085,12 @@ function validateWidgetDefinition(value) {
718
1085
  if (value["defaultSize"] !== void 0) {
719
1086
  requireOneOf(value, "defaultSize", WIDGET_SIZES, "", problems);
720
1087
  }
1088
+ if (value["width"] !== void 0 && !isWidgetWidth(value["width"])) {
1089
+ problems.push(
1090
+ `width must be an integer from ${MIN_WIDGET_WIDTH} to ${MAX_WIDGET_WIDTH} (twelfths of the row), got ${typeof value["width"] === "number" ? String(value["width"]) : describeType2(value["width"])}.`
1091
+ );
1092
+ }
1093
+ checkDetailOption(value["detail"], problems);
721
1094
  if (value["fill"] !== void 0) {
722
1095
  requireOneOf(value, "fill", WIDGET_FILLS, "", problems);
723
1096
  }
@@ -754,23 +1127,78 @@ function failedWidget(definition, error) {
754
1127
  function loadingWidgets(definitions) {
755
1128
  return definitions.map((definition) => ({ definition, status: "loading" }));
756
1129
  }
1130
+ function cacheKeyFor(definition, options) {
1131
+ return options.cacheKey === void 0 ? definition.key : options.cacheKey(definition);
1132
+ }
1133
+ var LoadInterruption = class extends Error {
1134
+ };
757
1135
  async function resolveWidget(definition, provider, context, options = {}) {
1136
+ const quotedKey = JSON.stringify(definition.key);
758
1137
  if (provider === void 0) {
759
1138
  return failedWidget(
760
1139
  definition,
761
- `Widget ${JSON.stringify(definition.key)}: no provider is registered for this key.`
1140
+ `Widget ${quotedKey}: no provider is registered for this key.`
1141
+ );
1142
+ }
1143
+ if (options.signal?.aborted === true) {
1144
+ return failedWidget(
1145
+ definition,
1146
+ `Widget ${quotedKey}: loading was canceled before it started.`
762
1147
  );
763
1148
  }
1149
+ const controller = new AbortController();
1150
+ const onExternalAbort = () => controller.abort(options.signal?.reason);
1151
+ options.signal?.addEventListener("abort", onExternalAbort, { once: true });
1152
+ let timedOut = false;
1153
+ const timeoutMs = options.timeoutMs !== void 0 && options.timeoutMs > 0 ? options.timeoutMs : void 0;
1154
+ const timer = timeoutMs === void 0 ? void 0 : setTimeout(() => {
1155
+ timedOut = true;
1156
+ controller.abort(new Error(`Timed out after ${timeoutMs} ms.`));
1157
+ }, timeoutMs);
1158
+ const interrupted = new Promise((_resolve, reject) => {
1159
+ controller.signal.addEventListener(
1160
+ "abort",
1161
+ () => reject(new LoadInterruption()),
1162
+ { once: true }
1163
+ );
1164
+ });
764
1165
  let payload;
765
1166
  try {
766
- payload = await provider(context, definition);
1167
+ payload = await Promise.race([
1168
+ Promise.resolve().then(() => {
1169
+ if (controller.signal.aborted) throw new LoadInterruption();
1170
+ return provider(context, definition, { signal: controller.signal });
1171
+ }),
1172
+ interrupted
1173
+ ]);
767
1174
  } catch (cause) {
1175
+ if (cause instanceof LoadInterruption) {
1176
+ return failedWidget(
1177
+ definition,
1178
+ timedOut ? `Widget ${quotedKey}: provider timed out after ${timeoutMs} ms.` : `Widget ${quotedKey}: loading was canceled.`
1179
+ );
1180
+ }
768
1181
  return failedWidget(
769
1182
  definition,
770
- `Widget ${JSON.stringify(definition.key)}: provider failed: ${describeUnknownError(cause)}`
1183
+ `Widget ${quotedKey}: provider failed: ${describeUnknownError(cause)}`
771
1184
  );
1185
+ } finally {
1186
+ if (timer !== void 0) clearTimeout(timer);
1187
+ options.signal?.removeEventListener("abort", onExternalAbort);
1188
+ }
1189
+ const resolved = resolvePayload(definition, payload, options);
1190
+ if (resolved.status !== "ok") return resolved;
1191
+ const storedAt = Date.now();
1192
+ if (options.cache !== void 0) {
1193
+ try {
1194
+ await options.cache.set(cacheKeyFor(definition, options), {
1195
+ payload: resolved.data,
1196
+ storedAt
1197
+ });
1198
+ } catch {
1199
+ }
772
1200
  }
773
- return resolvePayload(definition, payload, options);
1201
+ return { ...resolved, updatedAt: storedAt };
774
1202
  }
775
1203
  function widgetsFor(definitions, roles) {
776
1204
  return sortDefinitions(
@@ -791,6 +1219,131 @@ async function resolveWidgets(definitions, providers, context, options = {}) {
791
1219
  );
792
1220
  }
793
1221
 
1222
+ // src/core/loader.ts
1223
+ function createWidgetLoader(options) {
1224
+ const { definitions, providers, context, ...resolveOptions } = options;
1225
+ const visible = widgetsFor(definitions, context.roles);
1226
+ let snapshot = loadingWidgets(visible);
1227
+ const listeners = /* @__PURE__ */ new Set();
1228
+ const started = /* @__PURE__ */ new Set();
1229
+ const generations = /* @__PURE__ */ new Map();
1230
+ const inFlight = /* @__PURE__ */ new Map();
1231
+ let disposed = false;
1232
+ function publish(next) {
1233
+ snapshot = next;
1234
+ for (const listener of [...listeners]) {
1235
+ try {
1236
+ listener();
1237
+ } catch {
1238
+ }
1239
+ }
1240
+ }
1241
+ function replace(key, widget) {
1242
+ publish(
1243
+ snapshot.map(
1244
+ (current) => current.definition.key === key ? widget : current
1245
+ )
1246
+ );
1247
+ }
1248
+ function abortAll(reason) {
1249
+ for (const controller of inFlight.values()) controller.abort(reason);
1250
+ inFlight.clear();
1251
+ }
1252
+ resolveOptions.signal?.addEventListener(
1253
+ "abort",
1254
+ () => abortAll(resolveOptions.signal?.reason),
1255
+ { once: true }
1256
+ );
1257
+ async function run(definition, readCache) {
1258
+ const key = definition.key;
1259
+ const generation = (generations.get(key) ?? 0) + 1;
1260
+ generations.set(key, generation);
1261
+ inFlight.get(key)?.abort(new Error("Superseded by a newer load."));
1262
+ const controller = new AbortController();
1263
+ inFlight.set(key, controller);
1264
+ const isCurrent = () => !disposed && generations.get(key) === generation;
1265
+ if (readCache && resolveOptions.cache !== void 0) {
1266
+ try {
1267
+ const entry = await resolveOptions.cache.get(
1268
+ cacheKeyFor(definition, resolveOptions)
1269
+ );
1270
+ if (entry !== void 0 && isCurrent()) {
1271
+ const cached = resolvePayload(
1272
+ definition,
1273
+ entry.payload,
1274
+ resolveOptions
1275
+ );
1276
+ if (cached.status === "ok") {
1277
+ replace(key, { ...cached, updatedAt: entry.storedAt, stale: true });
1278
+ }
1279
+ }
1280
+ } catch {
1281
+ }
1282
+ }
1283
+ if (!isCurrent()) return;
1284
+ const provider = Object.prototype.hasOwnProperty.call(providers, key) ? providers[key] : void 0;
1285
+ const result = await resolveWidget(definition, provider, context, {
1286
+ ...resolveOptions,
1287
+ signal: controller.signal
1288
+ });
1289
+ if (!isCurrent()) return;
1290
+ inFlight.delete(key);
1291
+ replace(key, result);
1292
+ }
1293
+ function start(definition, readCache) {
1294
+ started.add(definition.key);
1295
+ void run(definition, readCache);
1296
+ }
1297
+ function definitionFor(key) {
1298
+ return visible.find((definition) => definition.key === key);
1299
+ }
1300
+ function load(key) {
1301
+ if (disposed || started.has(key)) return;
1302
+ const definition = definitionFor(key);
1303
+ if (definition !== void 0) start(definition, true);
1304
+ }
1305
+ function loadAll() {
1306
+ for (const definition of visible) load(definition.key);
1307
+ }
1308
+ function refresh(key, refreshOptions) {
1309
+ if (disposed) return;
1310
+ const keys = key === void 0 ? [...started] : [key];
1311
+ for (const target of keys) {
1312
+ const definition = definitionFor(target);
1313
+ if (definition === void 0) continue;
1314
+ if (refreshOptions?.skipInFlight === true && inFlight.has(target)) {
1315
+ continue;
1316
+ }
1317
+ const current = snapshot.find(
1318
+ (widget) => widget.definition.key === target
1319
+ );
1320
+ if (current?.status === "error") {
1321
+ replace(target, { definition, status: "loading" });
1322
+ }
1323
+ start(definition, false);
1324
+ }
1325
+ }
1326
+ function dispose() {
1327
+ disposed = true;
1328
+ abortAll(new Error("The widget loader was disposed."));
1329
+ listeners.clear();
1330
+ }
1331
+ function subscribe(listener) {
1332
+ listeners.add(listener);
1333
+ return () => {
1334
+ listeners.delete(listener);
1335
+ };
1336
+ }
1337
+ return {
1338
+ getSnapshot: () => snapshot,
1339
+ subscribe,
1340
+ load,
1341
+ loadAll,
1342
+ refresh,
1343
+ dispose
1344
+ };
1345
+ }
1346
+
794
1347
  // src/core/scale.ts
795
1348
  function niceStep(value) {
796
1349
  if (!(value > 0) || !Number.isFinite(value)) return 1;
@@ -821,6 +1374,6 @@ function labelStride(count, maxLabels = 6) {
821
1374
  return Math.max(1, Math.ceil(count / Math.max(1, maxLabels)));
822
1375
  }
823
1376
 
824
- export { DEFAULT_COMPARISON_LABEL, DEFAULT_CURRENCY, DEFAULT_EMPTY_TEXT, EMPTY_LAYOUT, MAX_GRAPH_SERIES, MAX_REPORTED_PROBLEMS, VALUE_FORMATS, WIDGET_FILLS, WIDGET_KINDS, WIDGET_SIZES, baseColumnSpan, defineWidget, describeUnknownError, emptyTextFor, emptyWidget, err, failedWidget, fillColumnSpans, fillsHeight, fillsWidth, formatCount, formatPercentChange, formatValue, fromLegacyDefinition, hiddenWidgets, hideWidget, isHidden, isMinimized, isSafeHref, isSafeImageUrl, isWidgetEmptyState, isWidgetKind, isWidgetVisibleTo, kpiDelta, labelStride, loadingWidgets, minimizeWidget, moveWidget, moveWidgetBy, moveWidgetDown, moveWidgetUp, niceDomain, niceStep, ok, parseLayout, percentChange, pruneLayout, resolvePayload, resolveWidget, resolveWidgets, restoreWidget, serializeLayout, shareOf, showWidget, sortDefinitions, toggleHidden, toggleMinimized, upgradeLegacyWidgetData, validateWidgetData, validateWidgetDefinition, visibleWidgets, widgetsFor };
1377
+ export { DEFAULT_COMPARISON_LABEL, DEFAULT_CURRENCY, DEFAULT_DETAIL_PAGE_SIZE, DEFAULT_EMPTY_TEXT, DETAIL_MODES, EMPTY_LAYOUT, MAX_DETAIL_PAGE_SIZE, MAX_GRAPH_SERIES, MAX_REPORTED_PROBLEMS, MAX_WIDGET_WIDTH, MIN_WIDGET_WIDTH, VALUE_FORMATS, WIDGET_FILLS, WIDGET_KINDS, WIDGET_SIZES, WIDTH_COLUMNS, baseColumnSpan, cacheKeyFor, createWidgetLoader, defaultDetailQuery, defineWidget, deriveDetailData, describeUnknownError, emptyTextFor, emptyWidget, err, failedWidget, fillColumnSpans, fillWidthSpans, fillsHeight, fillsWidth, formatCount, formatPercentChange, formatValue, fromLegacyDefinition, hiddenWidgets, hideWidget, isHidden, isMinimized, isSafeHref, isSafeImageUrl, isWidgetEmptyState, isWidgetKind, isWidgetVisibleTo, isWidgetWidth, itemWidth, kpiDelta, labelStride, loadingWidgets, minimizeWidget, moveWidget, moveWidgetBy, moveWidgetDown, moveWidgetUp, niceDomain, niceStep, ok, parseDetailQuery, parseLayout, percentChange, pruneLayout, queryRows, resolveDetailOptions, resolvePayload, resolveWidget, resolveWidgets, restoreWidget, serializeDetailQuery, serializeLayout, shareOf, showWidget, sortDefinitions, toggleHidden, toggleMinimized, upgradeLegacyWidgetData, usesWidthColumns, validateDetailData, validateWidgetData, validateWidgetDefinition, visibleWidgets, widgetsFor, widthForSize };
825
1378
  //# sourceMappingURL=core.js.map
826
1379
  //# sourceMappingURL=core.js.map