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