@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,4 +1,5 @@
1
1
  import { CARD_CSS, PeriodFollower, requiredAttr, wholeAttr } from '../aggregate-block.js';
2
+ import { AuthorError } from '../author-error.js';
2
3
  import { exampleBadge, stateFor } from '../block.js';
3
4
  import { downloadCsv, toCsv } from '../csv.js';
4
5
  import { clear, h } from '../dom.js';
@@ -20,8 +21,8 @@ th,td{padding:.5rem .75rem;text-align:start;border-bottom:1px solid var(--cube-c
20
21
  th{font-weight:600;white-space:nowrap}
21
22
  th button{min-height:auto;padding:0;border:0;background:none;color:inherit;font:inherit;font-weight:600}
22
23
  th button:hover{background:none;text-decoration:underline}
23
- th[aria-sort=ascending] button::after{content:" ▲";content:" ▲"/""}
24
- th[aria-sort=descending] button::after{content:" ▼";content:" ▼"/""}
24
+ th[aria-sort=ascending] .arrow::after{content:" ▲"}
25
+ th[aria-sort=descending] .arrow::after{content:" ▼"}
25
26
  .num{text-align:end;white-space:nowrap}
26
27
  tbody tr{cursor:pointer}
27
28
  tbody tr:hover{background:var(--cube-color-primary-subtle)}
@@ -51,13 +52,19 @@ function merge(target, field, value) {
51
52
  const current = target[field];
52
53
  target[field] = isOps(current) && isOps(value) ? { ...current, ...value } : value;
53
54
  }
54
- /** -1, 0 or 1; numbers as numbers, anything else (ISO dates) as text. */
55
+ /** -1, 0 or 1; numbers (also a number written as text) as numbers, anything else (ISO dates) as text. */
55
56
  function compare(a, b) {
56
- if (typeof a === 'number' && typeof b === 'number')
57
- return Math.sign(a - b);
58
- const x = String(a);
59
- const y = String(b);
60
- return x < y ? -1 : x > y ? 1 : 0;
57
+ const x = toNumber(a);
58
+ const y = toNumber(b);
59
+ if (x !== null && y !== null)
60
+ return Math.sign(x - y);
61
+ const p = String(a);
62
+ const q = String(b);
63
+ return p < q ? -1 : p > q ? 1 : 0;
64
+ }
65
+ /** Whether a base value leaves a "contains" (ilike) control nothing: a fixed value, or an ilike of its own. */
66
+ function textFixed(base) {
67
+ return base !== undefined && (!isOps(base) || 'ilike' in base);
61
68
  }
62
69
  /**
63
70
  * What the controls ask for, within the base (the `filter` attribute and the followed period): a bound takes the
@@ -112,6 +119,8 @@ export function tableBlock(Base, scope) {
112
119
  chosen = null;
113
120
  shownIds = null;
114
121
  updates = 0;
122
+ /** The builder warnings given already: a reload does not repeat them. */
123
+ warned = new Set();
115
124
  shownFields() {
116
125
  return this.context?.columns.map((column) => column.name) ?? [];
117
126
  }
@@ -200,7 +209,7 @@ export function tableBlock(Base, scope) {
200
209
  named.push(['search', search]);
201
210
  for (const [attribute, field] of named) {
202
211
  if (!schema.filterable.includes(field)) {
203
- throw new TypeError(NL.invalid.notFilterable(this.localName, attribute, field));
212
+ throw new AuthorError(NL.invalid.notFilterable(this.localName, attribute, field));
204
213
  }
205
214
  }
206
215
  }
@@ -213,7 +222,7 @@ export function tableBlock(Base, scope) {
213
222
  return doc.querySelector(selector);
214
223
  }
215
224
  catch {
216
- throw new TypeError(NL.invalid.selector(this.localName, 'detail', selector));
225
+ throw new AuthorError(NL.invalid.selector(this.localName, 'detail', selector));
217
226
  }
218
227
  }
219
228
  /** The list query of the controls as they are now. */
@@ -289,7 +298,7 @@ export function tableBlock(Base, scope) {
289
298
  catch (error) {
290
299
  if (run !== this.updates || error?.name === 'AbortError')
291
300
  return;
292
- this.showRowsState(fetchState(error));
301
+ this.showRowsState(stateFor(error));
293
302
  }
294
303
  }
295
304
  later(resetPage) {
@@ -309,11 +318,13 @@ export function tableBlock(Base, scope) {
309
318
  const tools = [];
310
319
  const searchField = this.attr('search')?.trim() ?? '';
311
320
  if (searchField !== '') {
321
+ if (textFixed(context.base[searchField]))
322
+ this.warnFixed('search', searchField);
312
323
  const input = h(doc, 'input', { type: 'search' });
313
324
  tools.push(this.control(input, SEARCH, NL.search, true));
314
325
  }
315
326
  for (const column of filterColumns)
316
- tools.push(this.filterControl(column));
327
+ tools.push(this.filterControl(column, context.base[column.name]));
317
328
  if (this.flag('export')) {
318
329
  this.exportButton = h(doc, 'button', {
319
330
  type: 'button',
@@ -344,7 +355,9 @@ export function tableBlock(Base, scope) {
344
355
  const thead = h(doc, 'thead', {}, h(doc, 'tr', {}, ...context.columns.map((column) => {
345
356
  const sortable = schema?.sortable.includes(column.name) === true;
346
357
  const th = h(doc, 'th', { scope: 'col', class: NUMERIC.includes(column.format) ? 'num' : null }, sortable
347
- ? h(doc, 'button', { type: 'button', onclick: () => this.sortOn(column.name) }, column.label)
358
+ ? h(doc, 'button', { type: 'button', onclick: () => this.sortOn(column.name) }, column.label,
359
+ // The arrow is decoration: aria-sort already says the direction.
360
+ h(doc, 'span', { class: 'arrow', 'aria-hidden': 'true' }))
348
361
  : column.label, ...(this.exampleField(context.session, context.resource, column.name)
349
362
  ? [' ', exampleBadge(doc)]
350
363
  : []));
@@ -376,13 +389,28 @@ export function tableBlock(Base, scope) {
376
389
  });
377
390
  return h(doc, 'div', { class: 'control' }, h(doc, 'label', { for: id }, label), control);
378
391
  }
379
- /** The control for one `filters` field, and its part of the query. */
380
- filterControl(column) {
392
+ /**
393
+ * The `filter` attribute keeps what it fixes (the base wins), so a control on such a field changes nothing:
394
+ * the builder is told once, in the console.
395
+ */
396
+ warnFixed(attribute, field) {
397
+ const name = this.localName;
398
+ const message = `${name}: filter already fixes "${field}", so ${attribute} on it changes nothing.`;
399
+ if (this.warned.has(message))
400
+ return;
401
+ this.warned.add(message);
402
+ console.warn(message);
403
+ }
404
+ /** The control for one `filters` field, and its part of the query; `base` is what the base holds for it. */
405
+ filterControl(column, base) {
381
406
  const name = column.name;
382
407
  const value = (key) => this.values.get(key)?.trim() ?? '';
383
408
  const choices = choicesFor(column);
384
409
  const boolean = choices === null && column.format === 'boolean';
385
410
  if (choices !== null || boolean) {
411
+ // `within` keeps an operator base over the chosen value.
412
+ if (isOps(base))
413
+ this.warnFixed('filters', name);
386
414
  const options = choices ?? [{ value: 'true', label: NL.yes }, { value: 'false', label: NL.no }];
387
415
  const select = h(doc, 'select', {}, h(doc, 'option', { value: '' }, NL.all), ...options.map((option) => h(doc, 'option', { value: option.value }, option.label)));
388
416
  const numeric = NUMERIC.includes(column.format);
@@ -396,6 +424,8 @@ export function tableBlock(Base, scope) {
396
424
  }
397
425
  const dates = column.format === 'date' || column.format === 'datetime';
398
426
  if (!dates && !NUMERIC.includes(column.format)) {
427
+ if (textFixed(base))
428
+ this.warnFixed('filters', name);
399
429
  this.parts.set(name, () => (value(name) === '' ? null : { ilike: `%${value(name)}%` }));
400
430
  return this.control(h(doc, 'input', { type: 'text' }), name, column.label, true);
401
431
  }
@@ -472,6 +502,10 @@ export function tableBlock(Base, scope) {
472
502
  return;
473
503
  }
474
504
  const tbody = h(doc, 'tbody', {}, ...response.data.map((row) => this.row(context, row)));
505
+ // Roving tabindex: one row is in the Tab order, the chosen one if shown, else the first.
506
+ if (tbody.querySelector('tr[tabindex="0"]') === null) {
507
+ tbody.firstElementChild?.setAttribute('tabindex', '0');
508
+ }
475
509
  table.replaceChild(tbody, table.querySelector('tbody'));
476
510
  frame.appendChild(table);
477
511
  if (this.pager !== null)
@@ -500,16 +534,28 @@ export function tableBlock(Base, scope) {
500
534
  const example = (column) => this.exampleField(context.session, context.resource, column.name);
501
535
  const chosen = this.chosen !== null && String(row.id ?? '') === this.chosen;
502
536
  const tr = h(doc, 'tr', {
503
- tabindex: '0',
537
+ tabindex: chosen ? '0' : '-1',
504
538
  class: chosen ? 'chosen' : null,
505
539
  'aria-current': chosen ? 'true' : null,
506
540
  onclick: choose,
507
541
  onkeydown: ((event) => {
508
- if (event.key !== 'Enter' && event.key !== ' ')
542
+ if (event.altKey || event.ctrlKey || event.metaKey)
543
+ return;
544
+ if (event.key === 'Enter' || event.key === ' ') {
545
+ // Space would scroll the page.
546
+ event.preventDefault();
547
+ choose();
548
+ return;
549
+ }
550
+ const target = this.rowFor(tr, event.key);
551
+ if (target === undefined)
509
552
  return;
510
- // Space would scroll the page.
511
553
  event.preventDefault();
512
- choose();
554
+ if (target === null)
555
+ return;
556
+ tr.setAttribute('tabindex', '-1');
557
+ target.setAttribute('tabindex', '0');
558
+ target.focus();
513
559
  }),
514
560
  }, ...context.columns.map((column) => {
515
561
  const classes = [NUMERIC.includes(column.format) && 'num', example(column) && 'example'];
@@ -517,11 +563,28 @@ export function tableBlock(Base, scope) {
517
563
  }));
518
564
  return tr;
519
565
  }
566
+ /** The row an arrow, Home or End key moves to: null at an end, undefined for any other key. */
567
+ rowFor(tr, key) {
568
+ const body = tr.parentElement;
569
+ switch (key) {
570
+ case 'ArrowDown':
571
+ return tr.nextElementSibling;
572
+ case 'ArrowUp':
573
+ return tr.previousElementSibling;
574
+ case 'Home':
575
+ return body?.firstElementChild;
576
+ case 'End':
577
+ return body?.lastElementChild;
578
+ default:
579
+ return undefined;
580
+ }
581
+ }
520
582
  select(context, row, tr) {
521
583
  const id = row.id ?? null;
522
584
  this.chosen = id === null ? null : String(id);
523
585
  for (const other of Array.from(tr.parentElement?.children ?? [])) {
524
586
  other.classList.toggle('chosen', other === tr);
587
+ other.setAttribute('tabindex', other === tr ? '0' : '-1');
525
588
  if (other === tr)
526
589
  other.setAttribute('aria-current', 'true');
527
590
  else
@@ -601,7 +664,3 @@ export function tableBlock(Base, scope) {
601
664
  return CubeTable;
602
665
  }
603
666
  let controlIds = 0;
604
- /** The state for a failed request: the browser's own (English) text of a network failure is never shown. */
605
- function fetchState(error) {
606
- return error instanceof TypeError ? { kind: 'error', detail: null } : stateFor(error);
607
- }
package/dist/ui/dom.js CHANGED
@@ -1,4 +1,5 @@
1
1
  // The UI builds every node through these helpers: no HTML string is ever parsed, so row data cannot become markup.
2
+ import { AuthorError } from './author-error.js';
2
3
  import { NL } from './texts.js';
3
4
  const SVG_NS = 'http://www.w3.org/2000/svg';
4
5
  /**
@@ -37,7 +38,7 @@ export function svg(doc, tag, attrs = {}, ...children) {
37
38
  function refuseUnsafe(name, setsAttribute) {
38
39
  const lower = name.toLowerCase();
39
40
  if (lower === 'style' || (setsAttribute && lower.startsWith('on'))) {
40
- throw new TypeError(NL.invalid.attribute(name));
41
+ throw new AuthorError(NL.invalid.attribute(name));
41
42
  }
42
43
  }
43
44
  export function clear(node) {
package/dist/ui/format.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { AuthorError } from './author-error.js';
1
2
  import { NL } from './texts.js';
2
3
  /** The formats that hold a number: right-aligned, read and written as numbers. */
3
4
  export const NUMERIC_FORMATS = ['number', 'integer', 'currency', 'percent'];
@@ -132,7 +133,7 @@ export function parseMeasure(measure) {
132
133
  const m = MEASURE.exec(measure.trim());
133
134
  const fn = m?.[1];
134
135
  if (fn === undefined || (fn !== 'count' && m?.[2] === undefined)) {
135
- throw new TypeError(NL.invalid.measure(measure));
136
+ throw new AuthorError(NL.invalid.measure(measure));
136
137
  }
137
138
  return { fn, field: m?.[2] ?? null };
138
139
  }
@@ -142,7 +143,7 @@ export function parseDimension(by) {
142
143
  const field = m?.[1];
143
144
  const truncation = m?.[2];
144
145
  if (field === undefined || (truncation !== undefined && !TRUNCATIONS.includes(truncation))) {
145
- throw new TypeError(NL.invalid.dimension(by));
146
+ throw new AuthorError(NL.invalid.dimension(by));
146
147
  }
147
148
  return { field, truncation: truncation ?? null };
148
149
  }
@@ -628,11 +628,13 @@ export const BLOCK_SPECS = {
628
628
  },
629
629
  children: ['cube-input', 'cube-result'],
630
630
  properties: {
631
- formula: '(values) => results, a synchronous function: an async one gives a Promise, which shows "—". '
631
+ formula: '(values) => results, a synchronous function: an async one is refused with an error. '
632
632
  + 'Set from a script, never as an attribute: a string is refused, nothing is '
633
633
  + 'evaluated. values has each cube-input by name: a number, a text, a boolean, or null when empty '
634
634
  + 'or invalid. Return an object by cube-result name, or one number or text when there is a single '
635
- + 'cube-result. null, undefined, NaN and Infinity show "—".',
635
+ + 'cube-result. null, undefined, NaN and Infinity show "—"; an object as the value of a number, '
636
+ + 'integer, currency or percent result shows "—" like NaN. Names of cube-inputs, and of '
637
+ + 'cube-results, must be unique.',
636
638
  values: 'Read-only: the values the formula got last.',
637
639
  results: 'Read-only: the results shown last, by cube-result name (null for "—").',
638
640
  },
@@ -792,7 +794,7 @@ export const BLOCK_SPECS = {
792
794
  type: 'string',
793
795
  required: false,
794
796
  summary: 'The order of the cards in each column: a field that is sortable on the resource, with - in '
795
- + 'front for descending.',
797
+ + 'front for descending; a moved card takes its place in that order; without sort it goes on top.',
796
798
  example: '-due_on',
797
799
  },
798
800
  filter: {
package/dist/ui/query.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { AuthorError } from './author-error.js';
1
2
  import { parseDimension, isoDate, parseLocalDate } from './format.js';
2
3
  import { NL } from './texts.js';
3
4
  const LOOKUP = /^([a-z][a-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)$/;
@@ -15,7 +16,7 @@ export function blockFilter(el) {
15
16
  filter = null;
16
17
  }
17
18
  if (filter === null || typeof filter !== 'object' || Array.isArray(filter)) {
18
- throw new TypeError(NL.invalid.json(el.localName, 'filter'));
19
+ throw new AuthorError(NL.invalid.json(el.localName, 'filter'));
19
20
  }
20
21
  return filter;
21
22
  }
@@ -28,7 +29,7 @@ function isPeriod(value) {
28
29
  export function periodFilter(field, range) {
29
30
  const to = parseLocalDate(range.to);
30
31
  if (to === null)
31
- throw new TypeError(NL.invalid.periodEnd(range.to));
32
+ throw new AuthorError(NL.invalid.periodEnd(range.to));
32
33
  to.setDate(to.getDate() + 1);
33
34
  return { [field]: { gte: range.from, lt: isoDate(to) } };
34
35
  }
@@ -42,12 +43,12 @@ export async function resolvePeriodSource(host, selector) {
42
43
  const source = host.ownerDocument.querySelector(selector);
43
44
  const registry = (host.ownerDocument.defaultView ?? window).customElements;
44
45
  if (source === null || registry.get(source.localName) === undefined) {
45
- throw new TypeError(NL.invalid.periodSource(selector));
46
+ throw new AuthorError(NL.invalid.periodSource(selector));
46
47
  }
47
48
  const picker = source;
48
49
  await picker.updateComplete;
49
50
  if (!isPeriod(picker.value))
50
- throw new TypeError(NL.invalid.periodSource(selector));
51
+ throw new AuthorError(NL.invalid.periodSource(selector));
51
52
  return picker;
52
53
  }
53
54
  /** The date field a period filters on: explicit, else the field of a truncated `by`. */
@@ -59,7 +60,7 @@ export function dateFieldFor(dateField, by, tag) {
59
60
  if (dimension.truncation !== null)
60
61
  return dimension.field;
61
62
  }
62
- throw new TypeError(NL.invalid.dateField(tag));
63
+ throw new AuthorError(NL.invalid.dateField(tag));
63
64
  }
64
65
  /**
65
66
  * Labels for ids from `<resource>.<field>`: one list per 200 ids. An id without a row, or whose list was refused
@@ -70,7 +71,7 @@ export async function lookupLabels(session, lookup, ids, signal) {
70
71
  const resource = match?.[1];
71
72
  const field = match?.[2];
72
73
  if (resource === undefined || field === undefined)
73
- throw new TypeError(NL.invalid.lookup(lookup));
74
+ throw new AuthorError(NL.invalid.lookup(lookup));
74
75
  const unique = [...new Set(ids.filter((id) => id !== null && id !== undefined).map((id) => id))];
75
76
  const labels = new Map();
76
77
  for (let start = 0; start < unique.length; start += LOOKUP_CHUNK) {
@@ -107,6 +107,16 @@ export declare const NL: {
107
107
  readonly 'ja/nee': "Ja/nee";
108
108
  };
109
109
  };
110
+ readonly trial: {
111
+ readonly banner: (time: string | null, leest: readonly string[]) => string;
112
+ readonly expired: "Deze proefversie is verlopen. Vraag in je gesprek om een nieuwe.";
113
+ readonly closed: "Deze proefversie is gesloten.";
114
+ readonly refreshing: "Nieuwe versie wordt geladen…";
115
+ readonly wider: "Er is een nieuwe versie die meer gegevens leest.";
116
+ readonly load: "Laden";
117
+ readonly elsewhere: "Laad de pagina opnieuw.";
118
+ readonly reload: "Opnieuw laden";
119
+ };
110
120
  readonly invalid: {
111
121
  readonly json: (tag: string, name: string) => string;
112
122
  readonly number: (tag: string, name: string) => string;
@@ -116,6 +126,8 @@ export declare const NL: {
116
126
  readonly value: (tag: string, name: string, value: string) => string;
117
127
  readonly formula: "cube-calc: formula moet een functie zijn";
118
128
  readonly calcSingle: "cube-calc: formula gaf één waarde, maar er zijn meer cube-results; geef een object met hun namen";
129
+ readonly duplicate: (tag: string, value: string) => string;
130
+ readonly calcAsync: "cube-calc: formula gaf een Promise; geef de uitkomst direct terug, niet async";
119
131
  readonly dateField: (tag: string) => string;
120
132
  readonly compare: (tag: string) => string;
121
133
  readonly periodSource: (selector: string) => string;
package/dist/ui/texts.js CHANGED
@@ -100,6 +100,20 @@ export const NL = {
100
100
  none: 'Er ontbreekt niets: alles in dit voorstel bestaat al in de DMP.',
101
101
  types: { tekst: 'Tekst', getal: 'Getal', bedrag: 'Bedrag', datum: 'Datum', keuze: 'Keuze', 'ja/nee': 'Ja/nee' },
102
102
  },
103
+ trial: {
104
+ banner: (time, leest) => ['Proefversie', 'alleen jij', 'alleen lezen']
105
+ .concat(time === null ? [] : [`verloopt om ${time}`])
106
+ .concat(leest.length === 0 ? [] : [`leest: ${leest.join(', ')}`])
107
+ .concat('Plak geen gegevens uit deze proefversie in je AI-gesprek.')
108
+ .join(' · '),
109
+ expired: 'Deze proefversie is verlopen. Vraag in je gesprek om een nieuwe.',
110
+ closed: 'Deze proefversie is gesloten.',
111
+ refreshing: 'Nieuwe versie wordt geladen…',
112
+ wider: 'Er is een nieuwe versie die meer gegevens leest.',
113
+ load: 'Laden',
114
+ elsewhere: 'Laad de pagina opnieuw.',
115
+ reload: 'Opnieuw laden',
116
+ },
103
117
  invalid: {
104
118
  json: (tag, name) => `${tag}: ${name} is geen geldige JSON`,
105
119
  number: (tag, name) => `${tag}: ${name} is geen geldig getal`,
@@ -109,6 +123,8 @@ export const NL = {
109
123
  value: (tag, name, value) => `${tag}: ${name} "${value}" is geen geldige waarde`,
110
124
  formula: 'cube-calc: formula moet een functie zijn',
111
125
  calcSingle: 'cube-calc: formula gaf één waarde, maar er zijn meer cube-results; geef een object met hun namen',
126
+ duplicate: (tag, value) => `${tag}: name "${value}" komt twee keer voor`,
127
+ calcAsync: 'cube-calc: formula gaf een Promise; geef de uitkomst direct terug, niet async',
112
128
  dateField: (tag) => `${tag}: date-field ontbreekt bij period`,
113
129
  compare: (tag) => `${tag}: compare werkt alleen samen met period`,
114
130
  periodSource: (selector) => `Geen periodekiezer gevonden voor "${selector}"`,
@@ -0,0 +1,25 @@
1
+ import type { TrialConfig } from '../trial.js';
2
+ /** What the banner needs from the window: the page's own, or a test's with fake timers and a fake fetch. */
3
+ export interface TrialScope {
4
+ document: Document;
5
+ CSSStyleSheet: typeof CSSStyleSheet;
6
+ setTimeout: typeof setTimeout;
7
+ clearTimeout: typeof clearTimeout;
8
+ fetch: typeof fetch;
9
+ location: Pick<Location, 'assign' | 'reload'>;
10
+ /** Without it a status request has no timeout. */
11
+ AbortController?: typeof AbortController;
12
+ /** Default `Date.now`. */
13
+ now?: () => number;
14
+ }
15
+ /** Spec section 7: every 5 s while the page is visible, every 60 s while it is hidden. */
16
+ export declare const POLL_VISIBLE_MS = 5000;
17
+ export declare const POLL_HIDDEN_MS = 60000;
18
+ /** A status request that hangs this long is given up as a network error, so the polling goes on. */
19
+ export declare const STATUS_TIMEOUT_MS = 10000;
20
+ /**
21
+ * Puts `cube-trial-banner` at the top of `<body>` and polls the trial's status: it says what the page is, ends when
22
+ * the trial does, and follows a newer version (by itself only when that one reads nothing more).
23
+ * Returns a function that stops the polling.
24
+ */
25
+ export declare function showTrialBanner(scope: TrialScope, config: TrialConfig): () => void;
@@ -0,0 +1,180 @@
1
+ import { clear, h } from './dom.js';
2
+ import { BASE_CSS } from './styles.js';
3
+ import { NL } from './texts.js';
4
+ /** Spec section 7: every 5 s while the page is visible, every 60 s while it is hidden. */
5
+ export const POLL_VISIBLE_MS = 5000;
6
+ export const POLL_HIDDEN_MS = 60000;
7
+ /** A status request that hangs this long is given up as a network error, so the polling goes on. */
8
+ export const STATUS_TIMEOUT_MS = 10000;
9
+ const CSS = `
10
+ .banner{display:flex;flex-wrap:wrap;align-items:center;gap:.75rem;padding:.5rem 1rem;
11
+ border-bottom:1px solid var(--cube-color-border);border-left:4px solid var(--cube-color-warning);
12
+ background:var(--cube-color-surface);font-size:.875rem}
13
+ .banner p{margin:0;flex:1 1 16rem}
14
+ .banner[data-state=expired],.banner[data-state=closed]{border-left-color:var(--cube-color-danger)}
15
+ `;
16
+ /**
17
+ * Puts `cube-trial-banner` at the top of `<body>` and polls the trial's status: it says what the page is, ends when
18
+ * the trial does, and follows a newer version (by itself only when that one reads nothing more).
19
+ * Returns a function that stops the polling.
20
+ */
21
+ export function showTrialBanner(scope, config) {
22
+ const doc = scope.document;
23
+ const now = scope.now ?? Date.now;
24
+ const expiresAt = Date.parse(config.expiresAt);
25
+ const known = Number.isFinite(expiresAt);
26
+ const time = known
27
+ ? new Intl.DateTimeFormat('nl-NL', { hour: '2-digit', minute: '2-digit' }).format(new Date(expiresAt))
28
+ : null;
29
+ const host = doc.createElement('cube-trial-banner');
30
+ const root = host.attachShadow({ mode: 'open' });
31
+ const sheets = [BASE_CSS, CSS].map((css) => {
32
+ const sheet = new scope.CSSStyleSheet();
33
+ sheet.replaceSync(css);
34
+ return sheet;
35
+ });
36
+ const message = h(doc, 'p', {}, NL.trial.banner(time, config.leest));
37
+ const region = h(doc, 'div', { class: 'banner', role: 'status', 'aria-live': 'polite' }, message);
38
+ let timer = null;
39
+ let ended = false;
40
+ let inFlight = false;
41
+ let abort = null;
42
+ root.adoptedStyleSheets = sheets;
43
+ root.appendChild(region);
44
+ const insert = () => {
45
+ doc.body.insertBefore(host, doc.body.firstChild);
46
+ };
47
+ const visible = () => doc.visibilityState !== 'hidden';
48
+ const stop = () => {
49
+ ended = true;
50
+ abort?.abort();
51
+ if (timer !== null)
52
+ scope.clearTimeout(timer);
53
+ timer = null;
54
+ doc.removeEventListener('visibilitychange', onVisibility);
55
+ };
56
+ const end = (state, action) => {
57
+ stop();
58
+ // The app may have replaced <body>'s children: without the banner the end state would go unseen.
59
+ if (!host.isConnected && doc.body !== null)
60
+ insert();
61
+ region.setAttribute('data-state', state);
62
+ clear(message);
63
+ message.appendChild(doc.createTextNode(NL.trial[state]));
64
+ if (action !== undefined) {
65
+ region.appendChild(h(doc, 'button', { type: 'button', onclick: action[1] }, action[0]));
66
+ }
67
+ };
68
+ const schedule = () => {
69
+ if (ended)
70
+ return;
71
+ if (timer !== null)
72
+ scope.clearTimeout(timer);
73
+ const interval = visible() ? POLL_VISIBLE_MS : POLL_HIDDEN_MS;
74
+ timer = scope.setTimeout(tick, known ? Math.max(0, Math.min(interval, expiresAt - now())) : interval);
75
+ };
76
+ const tick = async () => {
77
+ timer = null;
78
+ if (ended)
79
+ return;
80
+ // The hour is over: no request needed to know it.
81
+ if (known && now() >= expiresAt) {
82
+ end('expired');
83
+ return;
84
+ }
85
+ inFlight = true;
86
+ const controller = scope.AbortController === undefined ? null : new scope.AbortController();
87
+ abort = controller;
88
+ const timeout = controller === null ? null : scope.setTimeout(() => controller.abort(), STATUS_TIMEOUT_MS);
89
+ try {
90
+ const response = await scope.fetch(config.statusUrl, {
91
+ headers: { Accept: 'application/json', Authorization: `Bearer ${config.token}` },
92
+ cache: 'no-store',
93
+ ...(controller === null ? {} : { signal: controller.signal }),
94
+ });
95
+ await answer(response);
96
+ }
97
+ catch {
98
+ // Offline for a moment, or the request hung and was aborted: try again on the next beat.
99
+ schedule();
100
+ }
101
+ finally {
102
+ inFlight = false;
103
+ abort = null;
104
+ if (timeout !== null)
105
+ scope.clearTimeout(timeout);
106
+ }
107
+ };
108
+ const answer = async (response) => {
109
+ if (ended)
110
+ return;
111
+ if (response.ok || response.status === 429 || response.status >= 500) {
112
+ schedule();
113
+ return;
114
+ }
115
+ let problem = {};
116
+ try {
117
+ const body = await response.json();
118
+ if (body !== null && typeof body === 'object')
119
+ problem = body;
120
+ }
121
+ catch {
122
+ // No problem body: treated as closed below.
123
+ }
124
+ const code = problem.code;
125
+ let newerUrl = null;
126
+ if (code === 'trial_expired') {
127
+ end('expired');
128
+ }
129
+ else if (code === 'trial_elsewhere') {
130
+ end('elsewhere', [NL.trial.reload, () => scope.location.reload()]);
131
+ }
132
+ else if (code === 'trial_replaced' && (newerUrl = trustedNewer(problem.newer, config.dmpUrl)) !== null) {
133
+ const newer = newerUrl.href;
134
+ if (problem.wider === false) {
135
+ end('refreshing');
136
+ scope.location.assign(newer);
137
+ }
138
+ else {
139
+ // Reads more than the open one: only the maker's click may widen what this tab reads.
140
+ end('wider', [NL.trial.load, () => scope.location.assign(newer)]);
141
+ }
142
+ }
143
+ else {
144
+ end('closed');
145
+ }
146
+ };
147
+ function onVisibility() {
148
+ if (ended || inFlight)
149
+ return;
150
+ if (!visible()) {
151
+ schedule();
152
+ return;
153
+ }
154
+ // Back in view: ask right away instead of waiting out the hidden interval.
155
+ if (timer !== null)
156
+ scope.clearTimeout(timer);
157
+ void tick();
158
+ }
159
+ doc.addEventListener('visibilitychange', onVisibility);
160
+ if (doc.body === null) {
161
+ doc.addEventListener('DOMContentLoaded', insert, { once: true });
162
+ }
163
+ else {
164
+ insert();
165
+ }
166
+ schedule();
167
+ return stop;
168
+ }
169
+ /** Only the DMP's own open route: same origin as `dmpUrl`, under `/apps/proef/`. */
170
+ function trustedNewer(newer, dmpUrl) {
171
+ if (typeof newer !== 'string')
172
+ return null;
173
+ try {
174
+ const url = new URL(newer);
175
+ return url.origin === new URL(dmpUrl).origin && url.pathname.startsWith('/apps/proef/') ? url : null;
176
+ }
177
+ catch {
178
+ return null;
179
+ }
180
+ }
@@ -12,8 +12,8 @@ export declare function attemptFor(earlier: Attempt | null, fingerprint: string)
12
12
  export declare function showAlert(doc: Document, target: HTMLElement, message: string, action?: HTMLElement | null): void;
13
13
  /**
14
14
  * Shows why a write failed: a 422 on the fields it names, with the form-level line, and the messages for fields
15
- * without an editor listed in the alert (`label` names them); anything else as one line. Never the browser's own
16
- * (English) text of a network failure: stateFor would show a TypeError's message.
15
+ * without an editor listed in the alert (`label` names them); anything else as one line. Only the kind of stateFor
16
+ * is used: the alert never shows an error's own text.
17
17
  */
18
18
  export declare function showWriteFailure(doc: Document, alert: HTMLElement, error: unknown, editors: Editor[], label: (field: string) => string): void;
19
19
  /** Who is signed in, as a key: null when nobody is. A write must not go out as somebody who signed in since. */
package/dist/ui/write.js CHANGED
@@ -29,8 +29,8 @@ export function showAlert(doc, target, message, action) {
29
29
  }
30
30
  /**
31
31
  * Shows why a write failed: a 422 on the fields it names, with the form-level line, and the messages for fields
32
- * without an editor listed in the alert (`label` names them); anything else as one line. Never the browser's own
33
- * (English) text of a network failure: stateFor would show a TypeError's message.
32
+ * without an editor listed in the alert (`label` names them); anything else as one line. Only the kind of stateFor
33
+ * is used: the alert never shows an error's own text.
34
34
  */
35
35
  export function showWriteFailure(doc, alert, error, editors, label) {
36
36
  const errors = fieldErrors(error);
package/dist/version.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  /** This package's version. A test holds it equal to package.json's. */
2
- export declare const VERSION = "1.1.0-rc.1";
2
+ export declare const VERSION = "1.1.0-rc.2";
3
3
  /** The `"sdk"` value a `cube-app.json` manifest names for the API `CubeApp` offers here. */
4
4
  export declare const MANIFEST_SDK = "1";
package/dist/version.js CHANGED
@@ -1,4 +1,4 @@
1
1
  /** This package's version. A test holds it equal to package.json's. */
2
- export const VERSION = '1.1.0-rc.1';
2
+ export const VERSION = '1.1.0-rc.2';
3
3
  /** The `"sdk"` value a `cube-app.json` manifest names for the API `CubeApp` offers here. */
4
4
  export const MANIFEST_SDK = '1';