@cube-nl/dmp-sdk 1.1.0-rc.1 → 1.1.0-rc.2

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest": 1,
3
3
  "package": "@cube-nl/dmp-sdk",
4
- "version": "1.1.0-rc.1",
4
+ "version": "1.1.0-rc.2",
5
5
  "file": "cube-sdk.js",
6
6
  "blocks": {
7
7
  "cube-state": {
@@ -665,7 +665,7 @@
665
665
  "cube-result"
666
666
  ],
667
667
  "properties": {
668
- "formula": "(values) => results, a synchronous function: an async one gives a Promise, which shows \"—\". Set from a script, never as an attribute: a string is refused, nothing is evaluated. values has each cube-input by name: a number, a text, a boolean, or null when empty or invalid. Return an object by cube-result name, or one number or text when there is a single cube-result. null, undefined, NaN and Infinity show \"—\".",
668
+ "formula": "(values) => results, a synchronous function: an async one is refused with an error. Set from a script, never as an attribute: a string is refused, nothing is evaluated. values has each cube-input by name: a number, a text, a boolean, or null when empty or invalid. Return an object by cube-result name, or one number or text when there is a single cube-result. null, undefined, NaN and Infinity show \"—\"; an object as the value of a number, integer, currency or percent result shows \"—\" like NaN. Names of cube-inputs, and of cube-results, must be unique.",
669
669
  "values": "Read-only: the values the formula got last.",
670
670
  "results": "Read-only: the results shown last, by cube-result name (null for \"—\")."
671
671
  },
@@ -825,7 +825,7 @@
825
825
  "sort": {
826
826
  "type": "string",
827
827
  "required": false,
828
- "summary": "The order of the cards in each column: a field that is sortable on the resource, with - in front for descending.",
828
+ "summary": "The order of the cards in each column: a field that is sortable on the resource, with - in front for descending; a moved card takes its place in that order; without sort it goes on top.",
829
829
  "example": "-due_on"
830
830
  },
831
831
  "filter": {
package/dist/preview.d.ts CHANGED
@@ -85,3 +85,5 @@ export declare class PreviewStore {
85
85
  */
86
86
  private authorize;
87
87
  }
88
+ /** Nulls first, numbers as numbers, everything else as text (ISO dates sort correctly as text). */
89
+ export declare function compare(a: unknown, b: unknown): number;
package/dist/preview.js CHANGED
@@ -2,7 +2,14 @@ import { DmpError } from './errors.js';
2
2
  import { buildQuery } from './query.js';
3
3
  const RESOURCE_NAME = /^[a-z][a-z0-9_]*$/;
4
4
  const OPERATORS = ['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'in', 'like', 'ilike', 'is', 'between'];
5
- const SYMBOLS = [['>=', 'gte'], ['<=', 'lte'], ['<>', 'neq'], ['>', 'gt'], ['<', 'lt'], ['=', 'eq']];
5
+ const SYMBOLS = [
6
+ ['>=', 'gte'],
7
+ ['<=', 'lte'],
8
+ ['<>', 'neq'],
9
+ ['>', 'gt'],
10
+ ['<', 'lt'],
11
+ ['=', 'eq'],
12
+ ];
6
13
  const FUNCTIONS = ['count', 'sum', 'avg', 'min', 'max'];
7
14
  const UNITS = ['day', 'week', 'month', 'quarter', 'year'];
8
15
  const MAX_FILTERS = 20;
@@ -176,7 +183,9 @@ function table(name, resource) {
176
183
  for (const [fieldName, choice] of choiceEntries) {
177
184
  choices.set(fieldName, choice.values);
178
185
  }
179
- const known = (list) => Array.isArray(list) ? list.filter((item) => typeof item === 'string' && fields.has(item)) : null;
186
+ const known = (list) => Array.isArray(list)
187
+ ? list.filter((item) => typeof item === 'string' && fields.has(item))
188
+ : null;
180
189
  return {
181
190
  name,
182
191
  actions: Array.isArray(schema.actions)
@@ -502,7 +511,9 @@ function aggregator(table, fn) {
502
511
  }
503
512
  const allowed = table.aggregatable.get(target);
504
513
  if (allowed === undefined) {
505
- throw problem(422, 'query_invalid', `Unknown aggregatable field: ${target}.`, { fn: [aggregatableHint(table)] });
514
+ throw problem(422, 'query_invalid', `Unknown aggregatable field: ${target}.`, {
515
+ fn: [aggregatableHint(table)],
516
+ });
506
517
  }
507
518
  if (!allowed.includes(name)) {
508
519
  throw problem(422, 'query_invalid', `${name} is not allowed on ${target}.`, {
@@ -521,9 +532,13 @@ function aggregator(table, fn) {
521
532
  case 'avg':
522
533
  return numbers.length === 0 ? null : numbers.reduce((sum, value) => sum + value, 0) / numbers.length;
523
534
  case 'min':
524
- return values.length === 0 ? null : values.reduce((low, value) => (compare(value, low) < 0 ? value : low));
535
+ return values.length === 0
536
+ ? null
537
+ : values.reduce((low, value) => (compare(value, low) < 0 ? value : low));
525
538
  default:
526
- return values.length === 0 ? null : values.reduce((high, value) => (compare(value, high) > 0 ? value : high));
539
+ return values.length === 0
540
+ ? null
541
+ : values.reduce((high, value) => (compare(value, high) > 0 ? value : high));
527
542
  }
528
543
  };
529
544
  }
@@ -816,7 +831,7 @@ function same(actual, expected) {
816
831
  return actual === expected || (typeof actual !== 'object' && String(actual) === String(expected));
817
832
  }
818
833
  /** Nulls first, numbers as numbers, everything else as text (ISO dates sort correctly as text). */
819
- function compare(a, b) {
834
+ export function compare(a, b) {
820
835
  if (a == null || b == null) {
821
836
  return a == null ? (b == null ? 0 : -1) : 1;
822
837
  }
package/dist/size.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
- "core_gzip": 17364,
3
- "total_gzip": 43471,
4
- "ui_gzip": 26107,
2
+ "core_gzip": 18169,
3
+ "total_gzip": 46472,
4
+ "ui_gzip": 28303,
5
5
  "budget_gzip": 40960
6
6
  }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * What the DMP writes into a trial page (`<script type="application/json" id="cube-trial-config">`): the maker's
3
+ * app on the maker's own data, read-only, with a token that lives only in this page.
4
+ */
5
+ export interface TrialConfig {
6
+ mode: 'trial';
7
+ /** The DMP's origin. */
8
+ dmpUrl: string;
9
+ /** The REST face's base URL. */
10
+ apiUrl: string;
11
+ /** The read-only trial token, `cubeproef_…`. Never stored, never logged. */
12
+ token: string;
13
+ /** ISO 8601. */
14
+ expiresAt: string;
15
+ /** `GET` answers whether the trial is still open (the auto-refresh polls it). */
16
+ statusUrl: string;
17
+ version: string | null;
18
+ soort: string | null;
19
+ /** The DMP's user id of the maker. */
20
+ userId: string | null;
21
+ /** The resources the trial reads, for the banner. */
22
+ leest: string[];
23
+ }
24
+ /**
25
+ * Reads the injected config and removes its element, so the token is out of the DOM. The SDK calls it once when the
26
+ * script evaluates; the result is what `trialConfig()` answers from then on (a later call that finds an element
27
+ * replaces it, one that finds none changes nothing and answers null). Anything but a complete `mode: 'trial'` config
28
+ * is warned about and means no trial: the app starts as it would without one.
29
+ */
30
+ export declare function readTrialConfig(doc?: Document): TrialConfig | null;
31
+ /** The trial config this page was served with, or null outside a trial. */
32
+ export declare function trialConfig(): TrialConfig | null;
package/dist/trial.js ADDED
@@ -0,0 +1,55 @@
1
+ const ELEMENT_ID = 'cube-trial-config';
2
+ const REQUIRED = ['dmpUrl', 'apiUrl', 'token', 'expiresAt', 'statusUrl'];
3
+ let current = null;
4
+ /**
5
+ * Reads the injected config and removes its element, so the token is out of the DOM. The SDK calls it once when the
6
+ * script evaluates; the result is what `trialConfig()` answers from then on (a later call that finds an element
7
+ * replaces it, one that finds none changes nothing and answers null). Anything but a complete `mode: 'trial'` config
8
+ * is warned about and means no trial: the app starts as it would without one.
9
+ */
10
+ export function readTrialConfig(doc) {
11
+ const element = doc?.getElementById(ELEMENT_ID) ?? null;
12
+ // Without the element there is nothing new to read: the config read before stays (the element is gone by now).
13
+ if (element === null) {
14
+ return null;
15
+ }
16
+ current = null;
17
+ element.remove();
18
+ let value = null;
19
+ try {
20
+ const parsed = JSON.parse(element.textContent ?? '');
21
+ value = parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
22
+ ? parsed
23
+ : null;
24
+ }
25
+ catch {
26
+ // Reported below, without the content: it holds the token.
27
+ }
28
+ if (value?.mode !== 'trial' || REQUIRED.some((key) => typeof value[key] !== 'string' || value[key] === '')) {
29
+ console.warn(`#${ELEMENT_ID} is not a complete trial config; the app starts without it.`);
30
+ return null;
31
+ }
32
+ const text = (key) => (typeof value[key] === 'string' ? value[key] : null);
33
+ const leest = value.leest;
34
+ current = {
35
+ mode: 'trial',
36
+ dmpUrl: value.dmpUrl,
37
+ apiUrl: value.apiUrl,
38
+ token: value.token,
39
+ expiresAt: value.expiresAt,
40
+ statusUrl: value.statusUrl,
41
+ version: text('version'),
42
+ soort: text('soort'),
43
+ userId: text('userId'),
44
+ leest: Array.isArray(leest) ? leest.filter((name) => typeof name === 'string') : [],
45
+ };
46
+ return current;
47
+ }
48
+ /** The trial config this page was served with, or null outside a trial. */
49
+ export function trialConfig() {
50
+ return current;
51
+ }
52
+ // At evaluation, before any app code runs: the element must be gone before the app could read or dump the DOM.
53
+ if (typeof document !== 'undefined') {
54
+ readTrialConfig(document);
55
+ }
@@ -1,6 +1,7 @@
1
1
  // What the aggregate blocks (cube-kpi, cube-chart, cube-top) share: their required attributes, the measure and
2
2
  // filter they ask with, the period picker they follow and how they write a value.
3
3
  import { isDmpError } from '../errors.js';
4
+ import { AuthorError } from './author-error.js';
4
5
  import { BlockStateError } from './block.js';
5
6
  import { formatValue, parseMeasure } from './format.js';
6
7
  import { blockFilter, dateFieldFor, lookupLabels, periodFilter, resolvePeriodSource, } from './query.js';
@@ -19,7 +20,7 @@ export const LABEL_CSS = `
19
20
  export function requiredAttr(el, name) {
20
21
  const value = el.getAttribute(name)?.trim() ?? '';
21
22
  if (value === '')
22
- throw new TypeError(NL.invalid.missing(el.localName, name));
23
+ throw new AuthorError(NL.invalid.missing(el.localName, name));
23
24
  return value;
24
25
  }
25
26
  /** The fields an aggregate block reads: the measured one, the `by` field (without its truncation), the date field. */
@@ -0,0 +1,3 @@
1
+ /** A wrong attribute or markup by the app's builder: the one error whose (Dutch) message a block shows. */
2
+ export declare class AuthorError extends TypeError {
3
+ }
@@ -0,0 +1,3 @@
1
+ /** A wrong attribute or markup by the app's builder: the one error whose (Dutch) message a block shows. */
2
+ export class AuthorError extends TypeError {
3
+ }
@@ -13,8 +13,9 @@ export declare class BlockStateError extends Error {
13
13
  constructor(state: BlockState);
14
14
  }
15
15
  /**
16
- * What a failure looks like to the person on the page. Only a TypeError carries its message: the blocks throw
17
- * those for a wrong attribute, in Dutch. A server's text is never shown.
16
+ * What a failure looks like to the person on the page. Only an AuthorError carries its message: the blocks throw
17
+ * those for a wrong attribute, in Dutch. A server's text, a network failure or a client's English programmer error
18
+ * never does.
18
19
  */
19
20
  export declare function stateFor(error: unknown): BlockState;
20
21
  /**
@@ -29,6 +30,11 @@ export declare abstract class CubeBlockShape extends HTMLElement {
29
30
  static css: string;
30
31
  /** False for blocks that render without data (cube-state): they never take the session to render. */
31
32
  static needsSession: boolean;
33
+ /**
34
+ * False for a block that already shows what its own cube-data-change reports (cube-board): it does not reload on
35
+ * it.
36
+ */
37
+ static reloadsOnOwnChange: boolean;
32
38
  static observedAttributes: string[];
33
39
  /** The block's content root inside its shadow root. */
34
40
  readonly body: HTMLElement;
package/dist/ui/block.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { CubeApp } from '../app.js';
2
2
  import { isDmpError } from '../errors.js';
3
3
  import { BLOCK_ATTRIBUTES } from './attributes.js';
4
+ import { AuthorError } from './author-error.js';
4
5
  import { clear, h } from './dom.js';
5
6
  import { BASE_CSS, sheet } from './styles.js';
6
7
  import { NL } from './texts.js';
@@ -13,8 +14,9 @@ export class BlockStateError extends Error {
13
14
  }
14
15
  }
15
16
  /**
16
- * What a failure looks like to the person on the page. Only a TypeError carries its message: the blocks throw
17
- * those for a wrong attribute, in Dutch. A server's text is never shown.
17
+ * What a failure looks like to the person on the page. Only an AuthorError carries its message: the blocks throw
18
+ * those for a wrong attribute, in Dutch. A server's text, a network failure or a client's English programmer error
19
+ * never does.
18
20
  */
19
21
  export function stateFor(error) {
20
22
  if (error instanceof BlockStateError)
@@ -25,7 +27,7 @@ export function stateFor(error) {
25
27
  if (error.status === 403)
26
28
  return { kind: 'forbidden', detail: null };
27
29
  }
28
- if (error instanceof TypeError)
30
+ if (error instanceof AuthorError)
29
31
  return { kind: 'error', detail: error.message };
30
32
  return { kind: 'error', detail: null };
31
33
  }
@@ -56,6 +58,7 @@ export function blockBase(scope, options = {}) {
56
58
  static tag = '';
57
59
  static css = '';
58
60
  static needsSession = true;
61
+ static reloadsOnOwnChange = true;
59
62
  static get observedAttributes() {
60
63
  return [...(BLOCK_ATTRIBUTES[this.tag] ?? [])];
61
64
  }
@@ -67,6 +70,8 @@ export function blockBase(scope, options = {}) {
67
70
  badge = null;
68
71
  onSessionChange = () => this.schedule();
69
72
  onDataChange = (event) => {
73
+ if (event.composedPath()[0] === this && !this.constructor.reloadsOnOwnChange)
74
+ return;
70
75
  const resource = event.detail?.resource;
71
76
  const own = this.getAttribute('resource')?.trim() ?? '';
72
77
  if (typeof resource === 'string' && own !== '' && resource.trim() === own)
@@ -153,6 +158,10 @@ export function blockBase(scope, options = {}) {
153
158
  return;
154
159
  this.badge?.remove();
155
160
  this.showState(stateFor(error));
161
+ // The page shows only the generic text; the builder finds the real reason in the console.
162
+ if (error instanceof TypeError && !(error instanceof AuthorError)) {
163
+ console.warn(`${this.localName}:`, error);
164
+ }
156
165
  }
157
166
  finally {
158
167
  // An aborted run leaves the flag to the newer run (or to disconnect).
@@ -206,7 +215,7 @@ export function blockBase(scope, options = {}) {
206
215
  return fallback;
207
216
  const value = Number(raw);
208
217
  if (!Number.isFinite(value))
209
- throw new TypeError(NL.invalid.number(this.localName, name));
218
+ throw new AuthorError(NL.invalid.number(this.localName, name));
210
219
  return value;
211
220
  }
212
221
  json(name) {
@@ -217,7 +226,7 @@ export function blockBase(scope, options = {}) {
217
226
  return JSON.parse(raw);
218
227
  }
219
228
  catch {
220
- throw new TypeError(NL.invalid.json(this.localName, name));
229
+ throw new AuthorError(NL.invalid.json(this.localName, name));
221
230
  }
222
231
  }
223
232
  emit(name, detail = null) {
@@ -126,7 +126,7 @@ export function appShellBlock(Base, scope) {
126
126
  let wanted = '';
127
127
  if (hash.startsWith(PREFIX)) {
128
128
  try {
129
- wanted = decodeURIComponent(hash.slice(PREFIX.length));
129
+ wanted = decodeURIComponent(hash.slice(PREFIX.length).split('?')[0] ?? '');
130
130
  }
131
131
  catch {
132
132
  wanted = '';
@@ -26,6 +26,34 @@ dd{margin:0;min-width:0}
26
26
  .not-saved{display:inline-block;margin-bottom:.75rem}
27
27
  `;
28
28
  const keyOf = (row) => String(row.id);
29
+ /** As the server reads `sort`: comma-separated, a leading - for descending. */
30
+ function sortKeys(sort) {
31
+ return sort.split(',').map((item) => item.trim()).filter((item) => item !== '').map((item) => ({
32
+ field: item.replace(/^[-+]/, ''),
33
+ descending: item.startsWith('-'),
34
+ }));
35
+ }
36
+ /** As the preview sorts: nulls first, numbers as numbers, booleans as 0/1, everything else as text. */
37
+ function compare(a, b) {
38
+ if (a == null || b == null)
39
+ return a == null ? (b == null ? 0 : -1) : 1;
40
+ if (typeof a === 'number' && typeof b === 'number')
41
+ return a - b;
42
+ if (typeof a === 'boolean' || typeof b === 'boolean') {
43
+ return Number(a === true || a === 1 || a === '1') - Number(b === true || b === 1 || b === '1');
44
+ }
45
+ const left = String(a);
46
+ const right = String(b);
47
+ return left < right ? -1 : left > right ? 1 : 0;
48
+ }
49
+ function sortOrder(order, a, b) {
50
+ for (const { field, descending } of order) {
51
+ const result = compare(a[field], b[field]);
52
+ if (result !== 0)
53
+ return descending ? -result : result;
54
+ }
55
+ return 0;
56
+ }
29
57
  /** Why a move was refused, in words for the person; never the server's or the browser's own text. */
30
58
  function reason(error) {
31
59
  if (isDmpError(error) && (error.status === 409 || error.status === 412))
@@ -43,6 +71,7 @@ export function boardBlock(Base, scope) {
43
71
  class CubeBoard extends Base {
44
72
  static tag = 'cube-board';
45
73
  static css = CSS;
74
+ static reloadsOnOwnChange = false;
46
75
  /** Outside the body, so the reload after a refusal keeps the reason on screen. */
47
76
  alert = h(doc, 'div', { class: 'alert', role: 'alert', hidden: true });
48
77
  loaded = null;
@@ -50,8 +79,6 @@ export function boardBlock(Base, scope) {
50
79
  /** Moves queued or on their way: a reload waits for them, or it would show a card where it no longer is. */
51
80
  pending = 0;
52
81
  stale = false;
53
- /** Set while its own cube-data-change is under way: the board already shows what it saved. */
54
- own = false;
55
82
  dragged = null;
56
83
  /** The card to focus once the board is drawn again. */
57
84
  focusKey = null;
@@ -66,8 +93,6 @@ export function boardBlock(Base, scope) {
66
93
  return loaded === null ? [] : [loaded.status, loaded.title, ...loaded.fields.map((field) => field.name)];
67
94
  }
68
95
  refresh() {
69
- if (this.own)
70
- return Promise.resolve();
71
96
  if (this.pending > 0) {
72
97
  this.stale = true;
73
98
  return Promise.resolve();
@@ -88,14 +113,23 @@ export function boardBlock(Base, scope) {
88
113
  const filter = blockFilter(this);
89
114
  const size = Math.min(wholeAttr(this, 'page-size', 1, 50), 200);
90
115
  const sort = this.attr('sort')?.trim() ?? '';
116
+ const order = sortKeys(sort);
91
117
  const schema = await session.schema(resource);
92
118
  if (signal.aborted)
93
119
  return;
94
120
  const field = schema?.fields.find((candidate) => candidate.name === status);
121
+ // A card cannot be updated without its id; a resource without one gives a read-only board.
122
+ const hasId = schema === null || schema.fields.some((candidate) => candidate.name === 'id');
95
123
  const names = fieldList(this.attr('fields'));
96
124
  const fields = names === null ? [] : columnsFor(schema, names, readFieldConfigs(this));
97
125
  const query = {
98
- fields: [...new Set(['id', title, status, ...fields.map((column) => column.name)])],
126
+ fields: [...new Set([
127
+ ...(hasId ? ['id'] : []),
128
+ title,
129
+ status,
130
+ ...fields.map((column) => column.name),
131
+ ...order.map((key) => key.field),
132
+ ])],
99
133
  page: { size },
100
134
  };
101
135
  if (sort !== '')
@@ -117,7 +151,9 @@ export function boardBlock(Base, scope) {
117
151
  title,
118
152
  fields,
119
153
  columns: answers,
120
- canMove: schema !== null && schema.actions.includes('update') && field?.readonly === false,
154
+ canMove: hasId && schema !== null && schema.actions.includes('update') && field?.readonly === false,
155
+ order,
156
+ numeric: field?.type === 'integer' || field?.type === 'number',
121
157
  };
122
158
  this.loaded = loaded;
123
159
  this.tracks.clear();
@@ -150,6 +186,7 @@ export function boardBlock(Base, scope) {
150
186
  const more = column.total - column.rows.length;
151
187
  return h(doc, 'section', {
152
188
  class: 'card',
189
+ role: 'group',
153
190
  'aria-labelledby': heading,
154
191
  ondragover: (event) => {
155
192
  if (loaded.canMove && this.dragged !== null)
@@ -200,7 +237,10 @@ export function boardBlock(Base, scope) {
200
237
  cards.set(key, card);
201
238
  return card;
202
239
  }
203
- /** Takes the card out of its column and puts it on top of `to`; null when it is not on the board. */
240
+ /**
241
+ * Takes the card out of its column and puts it in `to`: at its place by `sort`, else on top; null when it is
242
+ * not on the board.
243
+ */
204
244
  place(loaded, key, to) {
205
245
  const target = loaded.columns.find((column) => column.value === to);
206
246
  for (const column of loaded.columns) {
@@ -210,7 +250,13 @@ export function boardBlock(Base, scope) {
210
250
  continue;
211
251
  column.rows.splice(index, 1);
212
252
  column.total--;
213
- target.rows.unshift(row);
253
+ if (loaded.order.length === 0) {
254
+ target.rows.unshift(row);
255
+ }
256
+ else {
257
+ const after = target.rows.findIndex((other) => sortOrder(loaded.order, other, row) > 0);
258
+ target.rows.splice(after === -1 ? target.rows.length : after, 0, row);
259
+ }
214
260
  target.total++;
215
261
  return { row, from: column.value };
216
262
  }
@@ -244,7 +290,7 @@ export function boardBlock(Base, scope) {
244
290
  }
245
291
  async send(loaded, track, row, from, to) {
246
292
  const key = keyOf(row);
247
- const body = { [loaded.status]: to };
293
+ const body = { [loaded.status]: loaded.numeric && Number.isFinite(Number(to)) ? Number(to) : to };
248
294
  const attempt = attemptFor(this.attempt, JSON.stringify([loaded.resource, key, body]));
249
295
  this.attempt = attempt;
250
296
  try {
@@ -262,12 +308,7 @@ export function boardBlock(Base, scope) {
262
308
  this.attempt = null;
263
309
  track.confirmed = to;
264
310
  this.emit('cube-move', { resource: loaded.resource, id: row.id, from, to });
265
- this.own = true;
266
311
  this.emit('cube-data-change', { resource: loaded.resource });
267
- // After the reload the event scheduled, which is a microtask queued before this one.
268
- queueMicrotask(() => {
269
- this.own = false;
270
- });
271
312
  }
272
313
  catch (error) {
273
314
  // The DMP answered, so the attempt is over; without an answer the same move may go again with its key.
@@ -1,4 +1,5 @@
1
1
  import { CARD_CSS, LABEL_CSS, requiredAttr } from '../aggregate-block.js';
2
+ import { AuthorError } from '../author-error.js';
2
3
  import { BlockStateError } from '../block.js';
3
4
  import { clear, h } from '../dom.js';
4
5
  import { parseOptions } from '../fields.js';
@@ -16,6 +17,7 @@ border-top:1px solid var(--cube-color-border)}
16
17
  .result output{font-size:1.25rem;font-weight:600;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}
17
18
  .result.emphasis output{font-size:2rem;font-weight:700;line-height:1.2}
18
19
  .alert{margin:1rem 0 0;color:var(--cube-color-danger,#b42318)}
20
+ .alert:empty{margin:0}
19
21
  `;
20
22
  const INPUT_TYPES = ['number', 'integer', 'currency', 'percent', 'text', 'select', 'checkbox'];
21
23
  const RESULT_FORMATS = ['number', 'integer', 'currency', 'percent', 'text'];
@@ -25,7 +27,7 @@ function choice(el, name, values) {
25
27
  const raw = el.getAttribute(name)?.trim() ?? '';
26
28
  const value = raw === '' ? values[0] : raw;
27
29
  if (!values.includes(value))
28
- throw new TypeError(NL.invalid.value(el.localName, name, value));
30
+ throw new AuthorError(NL.invalid.value(el.localName, name, value));
29
31
  return value;
30
32
  }
31
33
  function numberAttr(el, name) {
@@ -34,7 +36,7 @@ function numberAttr(el, name) {
34
36
  return null;
35
37
  const value = Number(raw);
36
38
  if (!Number.isFinite(value) || (name === 'step' && value <= 0)) {
37
- throw new TypeError(NL.invalid.number(el.localName, name));
39
+ throw new AuthorError(NL.invalid.number(el.localName, name));
38
40
  }
39
41
  return value;
40
42
  }
@@ -119,15 +121,18 @@ export function calcBlock(Base, scope) {
119
121
  const inputs = children.filter((el) => el.localName === 'cube-input').map(inputFor);
120
122
  const results = children.filter((el) => el.localName === 'cube-result').map(resultFor);
121
123
  if (results.length === 0)
122
- throw new TypeError(NL.invalid.missing(this.localName, 'cube-result'));
124
+ throw new AuthorError(NL.invalid.missing(this.localName, 'cube-result'));
125
+ unique('cube-input', inputs.map((input) => input.editor.name));
126
+ unique('cube-result', results.map((result) => result.name));
123
127
  const label = this.attr('label');
124
128
  const heading = label === null || label.trim() === ''
125
129
  ? null
126
130
  : h(doc, 'p', { class: 'label', id: `cube-calc-${++nextId}` }, label);
127
- const alert = h(doc, 'p', { class: 'alert', role: 'alert', hidden: true });
131
+ // Always in the DOM, so a screen reader already knows the live region when the first failure lands.
132
+ const alert = h(doc, 'p', { class: 'alert', role: 'alert' });
128
133
  const form = h(doc, 'form', { novalidate: true, 'aria-labelledby': heading?.id ?? null }, h(doc, 'div', { class: 'inputs' }, ...inputs.map((input) => input.editor.element)), h(doc, 'div', { class: 'results', 'aria-live': 'polite' }, ...results.map((r) => r.element)), alert);
129
134
  form.addEventListener('submit', (event) => event.preventDefault());
130
- form.addEventListener('input', () => this.calculate(true));
135
+ form.addEventListener('input', () => this.calculate(true, true));
131
136
  form.addEventListener('change', () => this.showErrors());
132
137
  clear(this.body);
133
138
  this.body.appendChild(h(doc, 'div', { class: 'card' }, heading, form));
@@ -143,7 +148,7 @@ export function calcBlock(Base, scope) {
143
148
  input.editor.setError('error' in read ? read.error : null);
144
149
  }
145
150
  }
146
- calculate(announce) {
151
+ calculate(announce, typing = false) {
147
152
  const drawn = this.drawn;
148
153
  if (drawn === null)
149
154
  return;
@@ -162,20 +167,34 @@ export function calcBlock(Base, scope) {
162
167
  try {
163
168
  const fn = this.formula;
164
169
  const out = fn === null ? null : fn({ ...values });
170
+ if ((typeof out === 'object' || typeof out === 'function') && out !== null
171
+ && typeof out.then === 'function') {
172
+ // Always async, so typing on would not help either.
173
+ this.drawn = null;
174
+ this.showState({ kind: 'error', detail: NL.invalid.calcAsync, retry: false });
175
+ return;
176
+ }
165
177
  const single = out === null || typeof out !== 'object';
166
178
  if (single && out !== null && out !== undefined && drawn.results.length > 1) {
179
+ // While typing the formula may just have taken another branch: a failed calculation, no error.
180
+ if (typing)
181
+ throw new AuthorError(NL.invalid.calcSingle);
167
182
  this.drawn = null;
168
183
  this.showState({ kind: 'error', detail: NL.invalid.calcSingle, retry: false });
169
184
  return;
170
185
  }
171
186
  for (const result of drawn.results) {
172
187
  const value = single ? out : out[result.name];
173
- const broken = typeof value === 'number' && !Number.isFinite(value);
188
+ const broken = (typeof value === 'number' && !Number.isFinite(value))
189
+ || (result.format !== 'text' && typeof value === 'object' && value !== null);
174
190
  failed ||= broken;
175
191
  results[result.name] = broken || value === undefined ? null : value;
176
192
  }
177
193
  }
178
- catch {
194
+ catch (error) {
195
+ // Once per failure streak, like the alert: the developer gets the cause, the reader a calm message.
196
+ if (drawn.alert.textContent === '')
197
+ console.warn(error);
179
198
  failed = true;
180
199
  for (const result of drawn.results)
181
200
  results[result.name] = null;
@@ -186,17 +205,21 @@ export function calcBlock(Base, scope) {
186
205
  });
187
206
  }
188
207
  // Set once per failure: a new text on every keystroke would make the reader say it again and again.
189
- if (failed && drawn.alert.hidden)
208
+ if (failed && drawn.alert.textContent === '')
190
209
  drawn.alert.textContent = NL.calcFailed;
191
210
  if (!failed)
192
211
  drawn.alert.textContent = '';
193
- drawn.alert.hidden = !failed;
194
212
  this.shownValues = values;
195
213
  this.shownResults = results;
196
214
  if (announce)
197
215
  this.emit('cube-change', { values: { ...values }, results: { ...results } });
198
216
  }
199
217
  }
218
+ function unique(tag, names) {
219
+ const twice = names.find((name, index) => names.indexOf(name) !== index);
220
+ if (twice !== undefined)
221
+ throw new AuthorError(NL.invalid.duplicate(tag, twice));
222
+ }
200
223
  function inputFor(el) {
201
224
  const name = requiredAttr(el, 'name');
202
225
  const label = requiredAttr(el, 'label');
@@ -204,7 +227,7 @@ export function calcBlock(Base, scope) {
204
227
  const format = (type === 'select' ? 'text' : type === 'checkbox' ? 'boolean' : type);
205
228
  const options = type === 'checkbox' ? null : parseOptions(el.getAttribute('options'));
206
229
  if (type === 'select' && options === null)
207
- throw new TypeError(NL.invalid.missing(el.localName, 'options'));
230
+ throw new AuthorError(NL.invalid.missing(el.localName, 'options'));
208
231
  // `value` is written as a person would type it: a percentage as 21.
209
232
  const raw = el.getAttribute('value');
210
233
  const parsed = raw === null ? { value: null } : parseInput(raw, format);
@@ -228,7 +251,7 @@ export function calcBlock(Base, scope) {
228
251
  formatValue(0, 'currency', { currency });
229
252
  }
230
253
  catch {
231
- throw new TypeError(NL.invalid.value(el.localName, 'currency', currency));
254
+ throw new AuthorError(NL.invalid.value(el.localName, 'currency', currency));
232
255
  }
233
256
  const id = `cube-result-${++nextId}`;
234
257
  const output = h(doc, 'output', { id, name });
@@ -1,4 +1,5 @@
1
1
  import { aggregateFields, aggregateQuery, CARD_CSS, PeriodFollower, requiredAttr, toNumber, valueFormatter, } from '../aggregate-block.js';
2
+ import { AuthorError } from '../author-error.js';
2
3
  import { clear, h } from '../dom.js';
3
4
  import { NL } from '../texts.js';
4
5
  const CHANGE = new Intl.NumberFormat('nl-NL', { minimumFractionDigits: 1, maximumFractionDigits: 1 });
@@ -25,7 +26,7 @@ export function kpiBlock(Base, scope) {
25
26
  return;
26
27
  const resource = requiredAttr(this, 'resource');
27
28
  if (this.flag('compare') && !this.attr('period'))
28
- throw new TypeError(NL.invalid.compare(this.localName));
29
+ throw new AuthorError(NL.invalid.compare(this.localName));
29
30
  const query = await aggregateQuery(this, this.period, signal);
30
31
  if (query === null)
31
32
  return;