@cube-nl/dmp-sdk 1.1.0-rc.1 → 1.1.0-rc.3
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/README.md +115 -27
- package/dist/app.d.ts +7 -4
- package/dist/app.js +89 -33
- package/dist/browser.js +8 -0
- package/dist/client.js +5 -1
- package/dist/cube-sdk.js +32 -22
- package/dist/cube-ui.manifest.json +12 -5
- package/dist/preview.d.ts +2 -0
- package/dist/preview.js +21 -6
- package/dist/size.json +3 -3
- package/dist/trial.d.ts +32 -0
- package/dist/trial.js +55 -0
- package/dist/ui/aggregate-block.js +2 -1
- package/dist/ui/attributes.js +1 -1
- package/dist/ui/author-error.d.ts +3 -0
- package/dist/ui/author-error.js +3 -0
- package/dist/ui/block.d.ts +8 -2
- package/dist/ui/block.js +14 -5
- package/dist/ui/blocks/app-shell.js +1 -1
- package/dist/ui/blocks/board.d.ts +1 -1
- package/dist/ui/blocks/board.js +129 -16
- package/dist/ui/blocks/calc.js +35 -12
- package/dist/ui/blocks/kpi.js +2 -1
- package/dist/ui/blocks/table.js +82 -23
- package/dist/ui/dom.js +2 -1
- package/dist/ui/format.js +3 -2
- package/dist/ui/manifest.js +14 -3
- package/dist/ui/query.js +7 -6
- package/dist/ui/texts.d.ts +12 -0
- package/dist/ui/texts.js +16 -0
- package/dist/ui/trial-banner.d.ts +27 -0
- package/dist/ui/trial-banner.js +199 -0
- package/dist/ui/write.d.ts +2 -2
- package/dist/ui/write.js +2 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/wireframe.js +53 -16
- package/package.json +5 -2
package/dist/ui/blocks/calc.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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 });
|
package/dist/ui/blocks/kpi.js
CHANGED
|
@@ -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
|
|
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;
|
package/dist/ui/blocks/table.js
CHANGED
|
@@ -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]
|
|
24
|
-
th[aria-sort=descending]
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
/**
|
|
380
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
146
|
+
throw new AuthorError(NL.invalid.dimension(by));
|
|
146
147
|
}
|
|
147
148
|
return { field, truncation: truncation ?? null };
|
|
148
149
|
}
|
package/dist/ui/manifest.js
CHANGED
|
@@ -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
|
|
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
|
},
|
|
@@ -759,6 +761,7 @@ export const BLOCK_SPECS = {
|
|
|
759
761
|
+ 'live, write cube-columns). A card moves by drag and drop or by a button per other column, named '
|
|
760
762
|
+ '"Verplaats naar …": at once on screen, then saved; a refusal puts it back with an alert. Read-only '
|
|
761
763
|
+ 'without update or with a readonly status field: update is optional, a view-only board needs only list. '
|
|
764
|
+
+ 'Choosing a card (click, Enter or Space) emits cube-select and can fill a cube-detail. '
|
|
762
765
|
+ 'Columns stack on a narrow screen.',
|
|
763
766
|
actions: ['list', 'update'],
|
|
764
767
|
attributes: {
|
|
@@ -792,7 +795,7 @@ export const BLOCK_SPECS = {
|
|
|
792
795
|
type: 'string',
|
|
793
796
|
required: false,
|
|
794
797
|
summary: 'The order of the cards in each column: a field that is sortable on the resource, with - in '
|
|
795
|
-
+ 'front for descending.',
|
|
798
|
+
+ 'front for descending; a moved card takes its place in that order; without sort it goes on top.',
|
|
796
799
|
example: '-due_on',
|
|
797
800
|
},
|
|
798
801
|
filter: {
|
|
@@ -807,11 +810,19 @@ export const BLOCK_SPECS = {
|
|
|
807
810
|
summary: 'Cards shown per column, at most 200; a column with more says "en nog N".',
|
|
808
811
|
default: '50',
|
|
809
812
|
},
|
|
813
|
+
detail: {
|
|
814
|
+
type: 'selector',
|
|
815
|
+
required: false,
|
|
816
|
+
summary: 'CSS selector of a cube-detail whose record-id is set to the id of the chosen card.',
|
|
817
|
+
example: '#taak',
|
|
818
|
+
},
|
|
810
819
|
},
|
|
811
820
|
children: ['cube-column', 'cube-field'],
|
|
812
821
|
events: {
|
|
813
822
|
'cube-move': 'A move was saved; detail is { resource, id, from, to } with the old and new status values. '
|
|
814
823
|
+ 'cube-data-change follows, so tables of the same resource reload.',
|
|
824
|
+
'cube-select': 'The user chose a card (click, Enter or Space; not a move); detail is { resource, id, row } with the '
|
|
825
|
+
+ 'row as listed.',
|
|
815
826
|
},
|
|
816
827
|
example: '<cube-board resource="tasks" status-field="status" title-field="title" fields="owner">'
|
|
817
828
|
+ '<cube-column value="todo" label="Te doen"></cube-column>'
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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) {
|
package/dist/ui/texts.d.ts
CHANGED
|
@@ -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,27 @@
|
|
|
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 429's numeric Retry-After is honoured up to this long, and never past the expiry. */
|
|
19
|
+
export declare const RETRY_AFTER_CAP_MS = 60000;
|
|
20
|
+
/** A status request that hangs this long is given up as a network error, so the polling goes on. */
|
|
21
|
+
export declare const STATUS_TIMEOUT_MS = 10000;
|
|
22
|
+
/**
|
|
23
|
+
* Puts `cube-trial-banner` at the top of `<body>` and polls the trial's status: it says what the page is, ends when
|
|
24
|
+
* the trial does, and follows a newer version (by itself only when that one reads nothing more).
|
|
25
|
+
* Returns a function that stops the polling.
|
|
26
|
+
*/
|
|
27
|
+
export declare function showTrialBanner(scope: TrialScope, config: TrialConfig): () => void;
|