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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +298 -6
  2. package/dist/app.d.ts +36 -2
  3. package/dist/app.js +192 -10
  4. package/dist/browser.js +6 -1
  5. package/dist/client.d.ts +2 -0
  6. package/dist/cube-sdk.js +179 -2
  7. package/dist/cube-ui.manifest.json +971 -0
  8. package/dist/index.d.ts +6 -0
  9. package/dist/index.js +4 -0
  10. package/dist/preview.d.ts +9 -0
  11. package/dist/preview.js +39 -1
  12. package/dist/size.json +6 -0
  13. package/dist/ui/aggregate-block.d.ts +73 -0
  14. package/dist/ui/aggregate-block.js +138 -0
  15. package/dist/ui/attributes.d.ts +5 -0
  16. package/dist/ui/attributes.js +72 -0
  17. package/dist/ui/block.d.ts +63 -0
  18. package/dist/ui/block.js +229 -0
  19. package/dist/ui/blocks/app-shell.d.ts +4 -0
  20. package/dist/ui/blocks/app-shell.js +167 -0
  21. package/dist/ui/blocks/board.d.ts +8 -0
  22. package/dist/ui/blocks/board.js +292 -0
  23. package/dist/ui/blocks/calc.d.ts +9 -0
  24. package/dist/ui/blocks/calc.js +240 -0
  25. package/dist/ui/blocks/chart.d.ts +7 -0
  26. package/dist/ui/blocks/chart.js +166 -0
  27. package/dist/ui/blocks/detail.d.ts +8 -0
  28. package/dist/ui/blocks/detail.js +270 -0
  29. package/dist/ui/blocks/field.d.ts +8 -0
  30. package/dist/ui/blocks/field.js +16 -0
  31. package/dist/ui/blocks/form.d.ts +8 -0
  32. package/dist/ui/blocks/form.js +153 -0
  33. package/dist/ui/blocks/gap-list.d.ts +4 -0
  34. package/dist/ui/blocks/gap-list.js +36 -0
  35. package/dist/ui/blocks/kpi.d.ts +4 -0
  36. package/dist/ui/blocks/kpi.js +74 -0
  37. package/dist/ui/blocks/period-picker.d.ts +4 -0
  38. package/dist/ui/blocks/period-picker.js +155 -0
  39. package/dist/ui/blocks/state.d.ts +4 -0
  40. package/dist/ui/blocks/state.js +135 -0
  41. package/dist/ui/blocks/table.d.ts +8 -0
  42. package/dist/ui/blocks/table.js +607 -0
  43. package/dist/ui/blocks/top.d.ts +4 -0
  44. package/dist/ui/blocks/top.js +67 -0
  45. package/dist/ui/chart-svg.d.ts +28 -0
  46. package/dist/ui/chart-svg.js +111 -0
  47. package/dist/ui/csv.d.ts +9 -0
  48. package/dist/ui/csv.js +55 -0
  49. package/dist/ui/define.d.ts +24 -0
  50. package/dist/ui/define.js +107 -0
  51. package/dist/ui/dom.d.ts +12 -0
  52. package/dist/ui/dom.js +57 -0
  53. package/dist/ui/fields.d.ts +39 -0
  54. package/dist/ui/fields.js +73 -0
  55. package/dist/ui/format.d.ts +44 -0
  56. package/dist/ui/format.js +214 -0
  57. package/dist/ui/inputs.d.ts +35 -0
  58. package/dist/ui/inputs.js +161 -0
  59. package/dist/ui/manifest.d.ts +40 -0
  60. package/dist/ui/manifest.js +878 -0
  61. package/dist/ui/period.d.ts +18 -0
  62. package/dist/ui/period.js +52 -0
  63. package/dist/ui/query.d.ts +26 -0
  64. package/dist/ui/query.js +98 -0
  65. package/dist/ui/schema.d.ts +23 -0
  66. package/dist/ui/schema.js +21 -0
  67. package/dist/ui/styles.d.ts +8 -0
  68. package/dist/ui/styles.js +51 -0
  69. package/dist/ui/texts.d.ts +134 -0
  70. package/dist/ui/texts.js +122 -0
  71. package/dist/ui/theme.d.ts +58 -0
  72. package/dist/ui/theme.js +311 -0
  73. package/dist/ui/write.d.ts +20 -0
  74. package/dist/ui/write.js +54 -0
  75. package/dist/version.d.ts +1 -1
  76. package/dist/version.js +1 -1
  77. package/dist/wireframe.d.ts +37 -0
  78. package/dist/wireframe.js +315 -0
  79. package/fonts/OFL-albert-sans.txt +93 -0
  80. package/fonts/OFL-ibm-plex-sans.txt +93 -0
  81. package/fonts/OFL-inter.txt +93 -0
  82. package/fonts/OFL-nunito-sans.txt +93 -0
  83. package/fonts/OFL-source-sans-3.txt +93 -0
  84. package/fonts/albert-sans.woff2 +0 -0
  85. package/fonts/ibm-plex-sans.woff2 +0 -0
  86. package/fonts/inter.woff2 +0 -0
  87. package/fonts/nunito-sans.woff2 +0 -0
  88. package/fonts/source-sans-3.woff2 +0 -0
  89. package/package.json +8 -3
@@ -0,0 +1,292 @@
1
+ import { isDmpError } from '../../errors.js';
2
+ import { CARD_CSS, requiredAttr, wholeAttr } from '../aggregate-block.js';
3
+ import { BlockStateError, stateFor } from '../block.js';
4
+ import { clear, h } from '../dom.js';
5
+ import { columnsFor, fieldList, readFieldConfigs } from '../fields.js';
6
+ import { formatValue, humanize } from '../format.js';
7
+ import { blockFilter } from '../query.js';
8
+ import { NL } from '../texts.js';
9
+ import { attemptFor, userOf, WRITE_CSS } from '../write.js';
10
+ const CSS = `${CARD_CSS}${WRITE_CSS}
11
+ .board{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(14rem,1fr);gap:1rem;
12
+ position:relative;overflow-x:auto}
13
+ @media (max-width:640px){.board{grid-auto-flow:row;grid-auto-columns:auto}}
14
+ section{min-width:0}
15
+ h3{margin:0 0 .5rem;font-size:1rem}
16
+ article{margin-top:.5rem;padding:.75rem;border:1px solid var(--cube-color-border);
17
+ border-radius:var(--cube-radius-small);background:var(--cube-color-bg);overflow-wrap:anywhere}
18
+ article[draggable=true]{cursor:grab}
19
+ .title{margin:0;font-weight:600}
20
+ dl{display:grid;grid-template-columns:auto 1fr;gap:0 .5rem;margin:.25rem 0 0;font-size:.875rem}
21
+ dd{margin:0;min-width:0}
22
+ .moves{display:flex;flex-wrap:wrap;gap:.25rem;margin-top:.5rem}
23
+ .moves button{min-height:2rem;padding:.25rem .5rem;font-size:.875rem}
24
+ .moves button::before{content:"\\2192 "}
25
+ .muted{margin:.5rem 0 0}
26
+ .not-saved{display:inline-block;margin-bottom:.75rem}
27
+ `;
28
+ const keyOf = (row) => String(row.id);
29
+ /** Why a move was refused, in words for the person; never the server's or the browser's own text. */
30
+ function reason(error) {
31
+ if (isDmpError(error) && (error.status === 409 || error.status === 412))
32
+ return NL.changedMeanwhile;
33
+ const kind = stateFor(error).kind;
34
+ return kind === 'forbidden' ? NL.forbidden : kind === 'signed-out' ? NL.signedOut : NL.tryAgain;
35
+ }
36
+ /**
37
+ * `<cube-board>`: the rows of a resource as cards in a column per value of a status field. A card moves by drag
38
+ * and drop or by a button per other column ("Verplaats naar …"): at once on screen, then saved; a refusal puts it
39
+ * back.
40
+ */
41
+ export function boardBlock(Base, scope) {
42
+ const doc = scope.document;
43
+ class CubeBoard extends Base {
44
+ static tag = 'cube-board';
45
+ static css = CSS;
46
+ /** Outside the body, so the reload after a refusal keeps the reason on screen. */
47
+ alert = h(doc, 'div', { class: 'alert', role: 'alert', hidden: true });
48
+ loaded = null;
49
+ tracks = new Map();
50
+ /** Moves queued or on their way: a reload waits for them, or it would show a card where it no longer is. */
51
+ pending = 0;
52
+ stale = false;
53
+ /** Set while its own cube-data-change is under way: the board already shows what it saved. */
54
+ own = false;
55
+ dragged = null;
56
+ /** The card to focus once the board is drawn again. */
57
+ focusKey = null;
58
+ attempt = null;
59
+ ids = 0;
60
+ constructor() {
61
+ super();
62
+ this.shadowRoot?.insertBefore(this.alert, this.body);
63
+ }
64
+ shownFields() {
65
+ const loaded = this.loaded;
66
+ return loaded === null ? [] : [loaded.status, loaded.title, ...loaded.fields.map((field) => field.name)];
67
+ }
68
+ refresh() {
69
+ if (this.own)
70
+ return Promise.resolve();
71
+ if (this.pending > 0) {
72
+ this.stale = true;
73
+ return Promise.resolve();
74
+ }
75
+ this.stale = false;
76
+ const focused = this.shadowRoot?.activeElement?.closest('article')?.getAttribute('data-id');
77
+ if (focused !== undefined && focused !== null)
78
+ this.focusKey = focused;
79
+ return super.refresh();
80
+ }
81
+ async load(signal, session) {
82
+ if (session === null)
83
+ return;
84
+ this.loaded = null;
85
+ const resource = requiredAttr(this, 'resource');
86
+ const status = requiredAttr(this, 'status-field');
87
+ const title = requiredAttr(this, 'title-field');
88
+ const filter = blockFilter(this);
89
+ const size = Math.min(wholeAttr(this, 'page-size', 1, 50), 200);
90
+ const sort = this.attr('sort')?.trim() ?? '';
91
+ const schema = await session.schema(resource);
92
+ if (signal.aborted)
93
+ return;
94
+ const field = schema?.fields.find((candidate) => candidate.name === status);
95
+ const names = fieldList(this.attr('fields'));
96
+ const fields = names === null ? [] : columnsFor(schema, names, readFieldConfigs(this));
97
+ const query = {
98
+ fields: [...new Set(['id', title, status, ...fields.map((column) => column.name)])],
99
+ page: { size },
100
+ };
101
+ if (sort !== '')
102
+ query.sort = sort;
103
+ const client = session.resource(resource);
104
+ const answers = await Promise.all(this.columnValues(field?.enum ?? null).map(async (column) => {
105
+ const { data, meta } = await client.list({ ...query, filter: { ...filter, [status]: column.value } }, {
106
+ signal,
107
+ });
108
+ return { ...column, rows: [...data], total: meta.page?.total ?? data.length };
109
+ }));
110
+ if (signal.aborted)
111
+ return;
112
+ const loaded = {
113
+ session,
114
+ user: userOf(session),
115
+ resource,
116
+ status,
117
+ title,
118
+ fields,
119
+ columns: answers,
120
+ canMove: schema !== null && schema.actions.includes('update') && field?.readonly === false,
121
+ };
122
+ this.loaded = loaded;
123
+ this.tracks.clear();
124
+ this.render(loaded);
125
+ }
126
+ /** The `cube-column` children, else the status field's enum; without either the page must say. */
127
+ columnValues(values) {
128
+ const children = Array.from(this.children).filter((child) => child.localName === 'cube-column');
129
+ const columns = children.length > 0
130
+ ? children.map((child) => {
131
+ const value = requiredAttr(child, 'value');
132
+ return { value, label: child.getAttribute('label')?.trim() || humanize(value) };
133
+ })
134
+ : (values ?? []).map((value) => ({ value, label: humanize(value) }));
135
+ if (columns.length === 0)
136
+ throw new BlockStateError({ kind: 'error', detail: NL.noColumns, retry: false });
137
+ return columns;
138
+ }
139
+ render(loaded) {
140
+ const cards = new Map();
141
+ clear(this.body);
142
+ this.body.append(...[loaded.canMove && this.notSaved(loaded.session), h(doc, 'div', { class: 'board' }, ...loaded.columns.map((column) => this.column(loaded, column, cards)))].filter((node) => !!node));
143
+ const focus = this.focusKey;
144
+ this.focusKey = null;
145
+ if (focus !== null)
146
+ cards.get(focus)?.focus();
147
+ }
148
+ column(loaded, column, cards) {
149
+ const heading = `b${++this.ids}`;
150
+ const more = column.total - column.rows.length;
151
+ return h(doc, 'section', {
152
+ class: 'card',
153
+ 'aria-labelledby': heading,
154
+ ondragover: (event) => {
155
+ if (loaded.canMove && this.dragged !== null)
156
+ event.preventDefault();
157
+ },
158
+ ondrop: (event) => {
159
+ event.preventDefault();
160
+ const key = this.dragged;
161
+ this.dragged = null;
162
+ if (key !== null)
163
+ this.move(loaded, key, column.value);
164
+ },
165
+ }, h(doc, 'h3', { id: heading }, column.label, ' ', h(doc, 'span', { class: 'badge' }, `${column.total}`)), ...(column.rows.length === 0
166
+ ? [h(doc, 'p', { class: 'muted' }, NL.emptyColumn)]
167
+ : column.rows.map((row) => this.card(loaded, column, row, cards))), more > 0 ? h(doc, 'p', { class: 'muted' }, NL.more(more)) : null);
168
+ }
169
+ card(loaded, column, row, cards) {
170
+ const key = keyOf(row);
171
+ const title = `b${++this.ids}`;
172
+ const card = h(doc, 'article', {
173
+ tabindex: '0',
174
+ draggable: loaded.canMove ? 'true' : 'false',
175
+ 'data-id': key,
176
+ 'aria-labelledby': title,
177
+ ondragstart: (event) => {
178
+ this.dragged = key;
179
+ event.dataTransfer?.setData('text/plain', key);
180
+ },
181
+ ondragend: () => {
182
+ this.dragged = null;
183
+ },
184
+ }, h(doc, 'p', { class: 'title', id: title }, formatValue(row[loaded.title], 'text')), loaded.fields.length === 0
185
+ ? null
186
+ : h(doc, 'dl', {}, ...loaded.fields.flatMap((field) => [
187
+ h(doc, 'dt', {}, field.label),
188
+ h(doc, 'dd', {}, formatValue(row[field.name], field.format)),
189
+ ])), loaded.canMove
190
+ ? h(doc, 'div', { class: 'moves' }, ...loaded.columns
191
+ .filter((other) => other !== column)
192
+ .map((other) => h(doc, 'button', {
193
+ type: 'button',
194
+ class: 'secondary',
195
+ 'aria-label': NL.moveTo(other.label),
196
+ 'aria-describedby': title,
197
+ onclick: () => this.move(loaded, key, other.value),
198
+ }, other.label)))
199
+ : null);
200
+ cards.set(key, card);
201
+ return card;
202
+ }
203
+ /** Takes the card out of its column and puts it on top of `to`; null when it is not on the board. */
204
+ place(loaded, key, to) {
205
+ const target = loaded.columns.find((column) => column.value === to);
206
+ for (const column of loaded.columns) {
207
+ const index = column.rows.findIndex((row) => keyOf(row) === key);
208
+ const row = column.rows[index];
209
+ if (row === undefined || target === undefined)
210
+ continue;
211
+ column.rows.splice(index, 1);
212
+ column.total--;
213
+ target.rows.unshift(row);
214
+ target.total++;
215
+ return { row, from: column.value };
216
+ }
217
+ return null;
218
+ }
219
+ move(loaded, key, to) {
220
+ const current = loaded.columns.find((column) => column.rows.some((row) => keyOf(row) === key));
221
+ if (!loaded.canMove || this.loaded !== loaded || current === undefined || current.value === to)
222
+ return;
223
+ const placed = this.place(loaded, key, to);
224
+ if (placed === null)
225
+ return;
226
+ const { row, from } = placed;
227
+ this.alert.hidden = true;
228
+ this.focusKey = key;
229
+ this.render(loaded);
230
+ let track = this.tracks.get(key);
231
+ if (track === undefined) {
232
+ track = { confirmed: from, tail: Promise.resolve(), generation: 0 };
233
+ this.tracks.set(key, track);
234
+ }
235
+ const queued = track;
236
+ const generation = queued.generation;
237
+ this.pending++;
238
+ queued.tail = queued.tail
239
+ .then(() => (generation === queued.generation ? this.send(loaded, queued, row, from, to) : undefined))
240
+ .finally(() => {
241
+ if (--this.pending === 0 && this.stale)
242
+ void this.refresh();
243
+ });
244
+ }
245
+ async send(loaded, track, row, from, to) {
246
+ const key = keyOf(row);
247
+ const body = { [loaded.status]: to };
248
+ const attempt = attemptFor(this.attempt, JSON.stringify([loaded.resource, key, body]));
249
+ this.attempt = attempt;
250
+ try {
251
+ // The session as it is now: one that changed meanwhile must not send what the old one queued.
252
+ const session = await this.session();
253
+ if (userOf(session) !== loaded.user) {
254
+ track.generation++;
255
+ this.stale = true;
256
+ return;
257
+ }
258
+ await session.resource(loaded.resource).update(row.id, body, {
259
+ idempotencyKey: attempt.key,
260
+ });
261
+ if (this.attempt === attempt)
262
+ this.attempt = null;
263
+ track.confirmed = to;
264
+ this.emit('cube-move', { resource: loaded.resource, id: row.id, from, to });
265
+ this.own = true;
266
+ 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
+ }
272
+ catch (error) {
273
+ // The DMP answered, so the attempt is over; without an answer the same move may go again with its key.
274
+ if (isDmpError(error) && this.attempt === attempt)
275
+ this.attempt = null;
276
+ // The moves queued after this one counted on it.
277
+ track.generation++;
278
+ if (isDmpError(error) && (error.status === 409 || error.status === 412))
279
+ this.stale = true;
280
+ if (this.loaded !== loaded)
281
+ return;
282
+ this.place(loaded, key, track.confirmed);
283
+ this.focusKey = key;
284
+ this.render(loaded);
285
+ clear(this.alert);
286
+ this.alert.append(`${NL.moveFailed} ${reason(error)}`);
287
+ this.alert.hidden = false;
288
+ }
289
+ }
290
+ }
291
+ return CubeBoard;
292
+ }
@@ -0,0 +1,9 @@
1
+ import { type BlockClass } from '../block.js';
2
+ import type { BlockScope } from '../define.js';
3
+ export type CalcValues = Record<string, number | string | boolean | null>;
4
+ export type CalcFormula = (values: CalcValues) => Record<string, unknown> | number | string | null;
5
+ /**
6
+ * `<cube-calc>`: a calculator. Its `cube-input`s are editors, its `cube-result`s outputs, and `formula` (a function
7
+ * the app sets; never a string, which would need eval) turns the one into the other on every keystroke.
8
+ */
9
+ export declare function calcBlock(Base: BlockClass, scope: BlockScope): BlockClass;
@@ -0,0 +1,240 @@
1
+ import { CARD_CSS, LABEL_CSS, requiredAttr } from '../aggregate-block.js';
2
+ import { BlockStateError } from '../block.js';
3
+ import { clear, h } from '../dom.js';
4
+ import { parseOptions } from '../fields.js';
5
+ import { formatValue } from '../format.js';
6
+ import { EDITOR_CSS, editorFor, parseInput } from '../inputs.js';
7
+ import { NL } from '../texts.js';
8
+ const CSS = `${CARD_CSS}${LABEL_CSS}${EDITOR_CSS}
9
+ .inputs{display:grid;gap:1rem;grid-template-columns:repeat(auto-fit,minmax(12rem,1fr))}
10
+ .inputs:empty{display:none}
11
+ .results{display:flex;flex-wrap:wrap;gap:1rem 2rem;margin-top:1.25rem;padding-top:1rem;
12
+ border-top:1px solid var(--cube-color-border)}
13
+ .inputs:empty+.results{margin-top:0;padding-top:0;border-top:0}
14
+ .result{display:flex;flex-direction:column;min-width:0}
15
+ .result label{font-size:.875rem;font-weight:600;color:var(--cube-color-text-muted)}
16
+ .result output{font-size:1.25rem;font-weight:600;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}
17
+ .result.emphasis output{font-size:2rem;font-weight:700;line-height:1.2}
18
+ .alert{margin:1rem 0 0;color:var(--cube-color-danger,#b42318)}
19
+ `;
20
+ const INPUT_TYPES = ['number', 'integer', 'currency', 'percent', 'text', 'select', 'checkbox'];
21
+ const RESULT_FORMATS = ['number', 'integer', 'currency', 'percent', 'text'];
22
+ let nextId = 0;
23
+ /** An enum attribute: its default when absent or blank, a TypeError naming the value when it is none of them. */
24
+ function choice(el, name, values) {
25
+ const raw = el.getAttribute(name)?.trim() ?? '';
26
+ const value = raw === '' ? values[0] : raw;
27
+ if (!values.includes(value))
28
+ throw new TypeError(NL.invalid.value(el.localName, name, value));
29
+ return value;
30
+ }
31
+ function numberAttr(el, name) {
32
+ const raw = el.getAttribute(name)?.trim() ?? '';
33
+ if (raw === '')
34
+ return null;
35
+ const value = Number(raw);
36
+ if (!Number.isFinite(value) || (name === 'step' && value <= 0)) {
37
+ throw new TypeError(NL.invalid.number(el.localName, name));
38
+ }
39
+ return value;
40
+ }
41
+ const plain = (n) => formatValue(n, 'number');
42
+ /** What the formula gets for one input: null for nothing, for what is no number, and outside min, max or step. */
43
+ function readInput(input) {
44
+ const read = input.editor.read();
45
+ if ('error' in read || typeof read.value !== 'number')
46
+ return read;
47
+ // Bounds are in what the person types: a percentage's 21, not the 0.21 the formula gets.
48
+ const typed = input.format === 'percent' ? Number((read.value * 100).toPrecision(12)) : read.value;
49
+ const { min, max, step } = input;
50
+ if ((min !== null && typed < min) || (max !== null && typed > max)) {
51
+ return {
52
+ error: min === null ? NL.atMost(plain(max))
53
+ : max === null ? NL.atLeast(plain(min)) : NL.range(plain(min), plain(max)),
54
+ };
55
+ }
56
+ if (step !== null) {
57
+ const steps = (typed - (min ?? 0)) / step;
58
+ if (Math.abs(steps - Math.round(steps)) > 1e-9)
59
+ return { error: NL.step(plain(step)) };
60
+ }
61
+ return read;
62
+ }
63
+ /**
64
+ * `<cube-calc>`: a calculator. Its `cube-input`s are editors, its `cube-result`s outputs, and `formula` (a function
65
+ * the app sets; never a string, which would need eval) turns the one into the other on every keystroke.
66
+ */
67
+ export function calcBlock(Base, scope) {
68
+ const doc = scope.document;
69
+ class CubeCalc extends Base {
70
+ static tag = 'cube-calc';
71
+ static css = CSS;
72
+ static needsSession = false;
73
+ fn = null;
74
+ drawn = null;
75
+ shownValues = {};
76
+ shownResults = {};
77
+ constructor() {
78
+ super();
79
+ // A page script that ran before the tag was defined set a plain property; take it over.
80
+ const early = Object.getOwnPropertyDescriptor(this, 'formula');
81
+ if (early !== undefined) {
82
+ delete this.formula;
83
+ this.fn = early.value;
84
+ }
85
+ }
86
+ get formula() {
87
+ return typeof this.fn === 'function' ? this.fn : null;
88
+ }
89
+ set formula(value) {
90
+ this.fn = value;
91
+ if (this.drawn !== null && (value === null || typeof value === 'function')) {
92
+ this.calculate(true);
93
+ }
94
+ else {
95
+ this.drawn = null;
96
+ void this.refresh();
97
+ }
98
+ }
99
+ get values() {
100
+ return { ...this.shownValues };
101
+ }
102
+ get results() {
103
+ return { ...this.shownResults };
104
+ }
105
+ refresh() {
106
+ // A session change or a reconnect must not wipe what was typed; only a new label redraws.
107
+ if (this.drawn !== null && this.drawn.label === this.attr('label'))
108
+ return Promise.resolve();
109
+ this.drawn = null;
110
+ return super.refresh();
111
+ }
112
+ async load() {
113
+ this.drawn = null;
114
+ if (this.fn !== null && typeof this.fn !== 'function') {
115
+ // Only a new formula helps, so no retry.
116
+ throw new BlockStateError({ kind: 'error', detail: NL.invalid.formula, retry: false });
117
+ }
118
+ const children = Array.from(this.children);
119
+ const inputs = children.filter((el) => el.localName === 'cube-input').map(inputFor);
120
+ const results = children.filter((el) => el.localName === 'cube-result').map(resultFor);
121
+ if (results.length === 0)
122
+ throw new TypeError(NL.invalid.missing(this.localName, 'cube-result'));
123
+ const label = this.attr('label');
124
+ const heading = label === null || label.trim() === ''
125
+ ? null
126
+ : h(doc, 'p', { class: 'label', id: `cube-calc-${++nextId}` }, label);
127
+ const alert = h(doc, 'p', { class: 'alert', role: 'alert', hidden: true });
128
+ 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
+ form.addEventListener('submit', (event) => event.preventDefault());
130
+ form.addEventListener('input', () => this.calculate(true));
131
+ form.addEventListener('change', () => this.showErrors());
132
+ clear(this.body);
133
+ this.body.appendChild(h(doc, 'div', { class: 'card' }, heading, form));
134
+ this.drawn = { label, inputs, results, alert };
135
+ this.showErrors();
136
+ this.calculate(false);
137
+ }
138
+ /** Shows the message of every input that has one: what the page set at once, what was typed on change. */
139
+ showErrors() {
140
+ for (const input of this.drawn?.inputs ?? []) {
141
+ const read = readInput(input);
142
+ input.shown = 'error' in read;
143
+ input.editor.setError('error' in read ? read.error : null);
144
+ }
145
+ }
146
+ calculate(announce) {
147
+ const drawn = this.drawn;
148
+ if (drawn === null)
149
+ return;
150
+ const values = {};
151
+ for (const input of drawn.inputs) {
152
+ const read = readInput(input);
153
+ values[input.editor.name] = 'error' in read ? null : read.value;
154
+ // While typing a message may change or go, but not appear: half a number is no mistake yet.
155
+ if (!input.shown)
156
+ continue;
157
+ input.shown = 'error' in read;
158
+ input.editor.setError('error' in read ? read.error : null);
159
+ }
160
+ const results = {};
161
+ let failed = false;
162
+ try {
163
+ const fn = this.formula;
164
+ const out = fn === null ? null : fn({ ...values });
165
+ const single = out === null || typeof out !== 'object';
166
+ if (single && out !== null && out !== undefined && drawn.results.length > 1) {
167
+ this.drawn = null;
168
+ this.showState({ kind: 'error', detail: NL.invalid.calcSingle, retry: false });
169
+ return;
170
+ }
171
+ for (const result of drawn.results) {
172
+ const value = single ? out : out[result.name];
173
+ const broken = typeof value === 'number' && !Number.isFinite(value);
174
+ failed ||= broken;
175
+ results[result.name] = broken || value === undefined ? null : value;
176
+ }
177
+ }
178
+ catch {
179
+ failed = true;
180
+ for (const result of drawn.results)
181
+ results[result.name] = null;
182
+ }
183
+ for (const result of drawn.results) {
184
+ result.output.textContent = formatValue(results[result.name], result.format, {
185
+ currency: result.currency,
186
+ });
187
+ }
188
+ // 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)
190
+ drawn.alert.textContent = NL.calcFailed;
191
+ if (!failed)
192
+ drawn.alert.textContent = '';
193
+ drawn.alert.hidden = !failed;
194
+ this.shownValues = values;
195
+ this.shownResults = results;
196
+ if (announce)
197
+ this.emit('cube-change', { values: { ...values }, results: { ...results } });
198
+ }
199
+ }
200
+ function inputFor(el) {
201
+ const name = requiredAttr(el, 'name');
202
+ const label = requiredAttr(el, 'label');
203
+ const type = choice(el, 'type', INPUT_TYPES);
204
+ const format = (type === 'select' ? 'text' : type === 'checkbox' ? 'boolean' : type);
205
+ const options = type === 'checkbox' ? null : parseOptions(el.getAttribute('options'));
206
+ if (type === 'select' && options === null)
207
+ throw new TypeError(NL.invalid.missing(el.localName, 'options'));
208
+ // `value` is written as a person would type it: a percentage as 21.
209
+ const raw = el.getAttribute('value');
210
+ const parsed = raw === null ? { value: null } : parseInput(raw, format);
211
+ const config = { name, label, format, multiline: false, options, hidden: false };
212
+ const column = { name, label, format, field: undefined, config };
213
+ return {
214
+ editor: editorFor(doc, column, 'error' in parsed ? raw : parsed.value, false),
215
+ format,
216
+ min: numberAttr(el, 'min'),
217
+ max: numberAttr(el, 'max'),
218
+ step: numberAttr(el, 'step'),
219
+ shown: false,
220
+ };
221
+ }
222
+ function resultFor(el) {
223
+ const name = requiredAttr(el, 'name');
224
+ const label = requiredAttr(el, 'label');
225
+ const format = choice(el, 'format', RESULT_FORMATS);
226
+ const currency = el.getAttribute('currency')?.trim().toUpperCase() || 'EUR';
227
+ try {
228
+ formatValue(0, 'currency', { currency });
229
+ }
230
+ catch {
231
+ throw new TypeError(NL.invalid.value(el.localName, 'currency', currency));
232
+ }
233
+ const id = `cube-result-${++nextId}`;
234
+ const output = h(doc, 'output', { id, name });
235
+ const emphasis = el.hasAttribute('emphasis') && el.getAttribute('emphasis') !== 'false';
236
+ const element = h(doc, 'div', { class: emphasis ? 'result emphasis' : 'result' }, h(doc, 'label', { for: id }, label), output);
237
+ return { name, format, currency, element, output };
238
+ }
239
+ return CubeCalc;
240
+ }
@@ -0,0 +1,7 @@
1
+ import type { BlockClass } from '../block.js';
2
+ import type { BlockScope } from '../define.js';
3
+ import { type Truncation } from '../format.js';
4
+ /** Every bucket key from `from` to `to` (ISO dates, inclusive), in order. */
5
+ export declare function periodBuckets(from: string, to: string, unit: Truncation): string[];
6
+ /** `<cube-chart>`: one measure over time (a line) or per category (bars), with a table for screen readers. */
7
+ export declare function chartBlock(Base: BlockClass, scope: BlockScope): BlockClass;