@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.
- package/README.md +298 -6
- package/dist/app.d.ts +36 -2
- package/dist/app.js +192 -10
- package/dist/browser.js +6 -1
- package/dist/client.d.ts +2 -0
- package/dist/cube-sdk.js +179 -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 +9 -0
- package/dist/preview.js +39 -1
- package/dist/size.json +6 -0
- package/dist/ui/aggregate-block.d.ts +73 -0
- package/dist/ui/aggregate-block.js +138 -0
- package/dist/ui/attributes.d.ts +5 -0
- package/dist/ui/attributes.js +72 -0
- package/dist/ui/block.d.ts +63 -0
- package/dist/ui/block.js +229 -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 +292 -0
- package/dist/ui/blocks/calc.d.ts +9 -0
- package/dist/ui/blocks/calc.js +240 -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 +74 -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 +607 -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 +57 -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 +214 -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 +878 -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 +98 -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 +134 -0
- package/dist/ui/texts.js +122 -0
- package/dist/ui/theme.d.ts +58 -0
- package/dist/ui/theme.js +311 -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 +315 -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 +8 -3
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
import { CARD_CSS, PeriodFollower, requiredAttr, wholeAttr } from '../aggregate-block.js';
|
|
2
|
+
import { exampleBadge, stateFor } from '../block.js';
|
|
3
|
+
import { downloadCsv, toCsv } from '../csv.js';
|
|
4
|
+
import { clear, h } from '../dom.js';
|
|
5
|
+
import { choicesFor, columnsFor, fieldList, readFieldConfigs } from '../fields.js';
|
|
6
|
+
import { formatValue, humanize, isoDate, NUMERIC_FORMATS, parseLocalDate, toNumber } from '../format.js';
|
|
7
|
+
import { parseInput } from '../inputs.js';
|
|
8
|
+
import { blockFilter, dateFieldFor, periodFilter, resolvePeriodSource } from '../query.js';
|
|
9
|
+
import { NL } from '../texts.js';
|
|
10
|
+
const CSS = `${CARD_CSS}
|
|
11
|
+
.tools{display:flex;flex-wrap:wrap;align-items:flex-end;gap:.75rem;margin-bottom:.75rem}
|
|
12
|
+
.control{display:flex;flex-direction:column;gap:.25rem;min-width:0;max-width:100%}
|
|
13
|
+
.control>label,legend{font-weight:600}
|
|
14
|
+
fieldset{display:flex;flex-wrap:wrap;gap:.5rem;min-width:0;margin:0;padding:0;border:0}
|
|
15
|
+
fieldset legend{padding:0;margin-bottom:.25rem}
|
|
16
|
+
fieldset.control{flex-direction:row;align-items:flex-end}
|
|
17
|
+
.scroll{position:relative;overflow-x:auto;max-width:100%}
|
|
18
|
+
table{width:100%;border-collapse:collapse;font-variant-numeric:tabular-nums}
|
|
19
|
+
th,td{padding:.5rem .75rem;text-align:start;border-bottom:1px solid var(--cube-color-border)}
|
|
20
|
+
th{font-weight:600;white-space:nowrap}
|
|
21
|
+
th button{min-height:auto;padding:0;border:0;background:none;color:inherit;font:inherit;font-weight:600}
|
|
22
|
+
th button:hover{background:none;text-decoration:underline}
|
|
23
|
+
th[aria-sort=ascending] button::after{content:" ▲";content:" ▲"/""}
|
|
24
|
+
th[aria-sort=descending] button::after{content:" ▼";content:" ▼"/""}
|
|
25
|
+
.num{text-align:end;white-space:nowrap}
|
|
26
|
+
tbody tr{cursor:pointer}
|
|
27
|
+
tbody tr:hover{background:var(--cube-color-primary-subtle)}
|
|
28
|
+
tbody tr:focus-visible{outline:3px solid var(--cube-color-focus);outline-offset:-3px}
|
|
29
|
+
tbody tr.chosen{background:var(--cube-color-primary-subtle);box-shadow:inset 4px 0 0 var(--cube-color-primary)}
|
|
30
|
+
.pager{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem;margin-top:.75rem}
|
|
31
|
+
.pager p,.export-status{margin:0;color:var(--cube-color-text-muted)}
|
|
32
|
+
.pager p{margin-inline-end:auto}
|
|
33
|
+
`;
|
|
34
|
+
const NUMERIC = NUMERIC_FORMATS;
|
|
35
|
+
const MAX_PAGE = 200;
|
|
36
|
+
const EXPORT_PAGES = 50;
|
|
37
|
+
const DEBOUNCE = 300;
|
|
38
|
+
/** The key of the search box in `values`; no field name contains a space. */
|
|
39
|
+
const SEARCH = ' search';
|
|
40
|
+
/** The day after `day`, for an `lt` bound that keeps the whole of `day` for dates and date-times alike. */
|
|
41
|
+
function dayAfter(day) {
|
|
42
|
+
const date = parseLocalDate(day.slice(0, 10));
|
|
43
|
+
if (date === null)
|
|
44
|
+
return null;
|
|
45
|
+
date.setDate(date.getDate() + 1);
|
|
46
|
+
return isoDate(date);
|
|
47
|
+
}
|
|
48
|
+
const isOps = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
49
|
+
/** Two operator objects on one field are joined; anything else is replaced by the later one. */
|
|
50
|
+
function merge(target, field, value) {
|
|
51
|
+
const current = target[field];
|
|
52
|
+
target[field] = isOps(current) && isOps(value) ? { ...current, ...value } : value;
|
|
53
|
+
}
|
|
54
|
+
/** -1, 0 or 1; numbers as numbers, anything else (ISO dates) as text. */
|
|
55
|
+
function compare(a, b) {
|
|
56
|
+
if (typeof a === 'number' && typeof b === 'number')
|
|
57
|
+
return Math.sign(a - b);
|
|
58
|
+
const x = String(a);
|
|
59
|
+
const y = String(b);
|
|
60
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* What the controls ask for, within the base (the `filter` attribute and the followed period): a bound takes the
|
|
64
|
+
* stricter of the two, and the base keeps any value it fixes. The controls can narrow the base, never widen it.
|
|
65
|
+
*/
|
|
66
|
+
function within(base, value) {
|
|
67
|
+
if (!isOps(base) || !isOps(value))
|
|
68
|
+
return base;
|
|
69
|
+
const result = { ...value, ...base };
|
|
70
|
+
for (const [op, pick] of [['gte', 1], ['gt', 1], ['lte', -1], ['lt', -1]]) {
|
|
71
|
+
const a = base[op];
|
|
72
|
+
const b = value[op];
|
|
73
|
+
if (a !== undefined && a !== null && b !== undefined && b !== null) {
|
|
74
|
+
result[op] = compare(b, a) === pick ? b : a;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* `<cube-table>`: the rows of a resource, a page at a time, with search, filters, sortable headers and a CSV export.
|
|
81
|
+
* The controls are built once per load; what they change only asks for rows again, and only the newest answer is
|
|
82
|
+
* shown.
|
|
83
|
+
*/
|
|
84
|
+
export function tableBlock(Base, scope) {
|
|
85
|
+
const doc = scope.document;
|
|
86
|
+
class CubeTable extends Base {
|
|
87
|
+
static tag = 'cube-table';
|
|
88
|
+
static css = CSS;
|
|
89
|
+
period = new PeriodFollower(this);
|
|
90
|
+
context = null;
|
|
91
|
+
parts = new Map();
|
|
92
|
+
/** What the person typed or chose in each control, by field (`placed_on:min` for a bound). */
|
|
93
|
+
values = new Map();
|
|
94
|
+
sortBy = null;
|
|
95
|
+
page = 1;
|
|
96
|
+
requests = 0;
|
|
97
|
+
rowsController = null;
|
|
98
|
+
debounce = null;
|
|
99
|
+
exporting = null;
|
|
100
|
+
headers = new Map();
|
|
101
|
+
frame = null;
|
|
102
|
+
table = null;
|
|
103
|
+
pagerText = null;
|
|
104
|
+
previous = null;
|
|
105
|
+
next = null;
|
|
106
|
+
pager = null;
|
|
107
|
+
exportButton = null;
|
|
108
|
+
exportStatus = null;
|
|
109
|
+
/** The resource the kept values, sort and page belong to. */
|
|
110
|
+
shownResource = null;
|
|
111
|
+
/** The id of the chosen row, and the ids of the rows shown: a different set clears the choice. */
|
|
112
|
+
chosen = null;
|
|
113
|
+
shownIds = null;
|
|
114
|
+
updates = 0;
|
|
115
|
+
shownFields() {
|
|
116
|
+
return this.context?.columns.map((column) => column.name) ?? [];
|
|
117
|
+
}
|
|
118
|
+
disconnectedCallback() {
|
|
119
|
+
super.disconnectedCallback();
|
|
120
|
+
this.stopDebounce();
|
|
121
|
+
this.rowsController?.abort();
|
|
122
|
+
this.exporting?.abort();
|
|
123
|
+
this.exporting = null;
|
|
124
|
+
}
|
|
125
|
+
async load(signal, session) {
|
|
126
|
+
if (session === null)
|
|
127
|
+
return;
|
|
128
|
+
// The old controls are gone (the loading state replaced them): nothing they asked for may land now.
|
|
129
|
+
this.stopDebounce();
|
|
130
|
+
this.context = null;
|
|
131
|
+
this.rowsController?.abort();
|
|
132
|
+
const resource = requiredAttr(this, 'resource');
|
|
133
|
+
++this.updates;
|
|
134
|
+
if (resource !== this.shownResource) {
|
|
135
|
+
// Another resource: what the person chose belonged to the old one.
|
|
136
|
+
this.values.clear();
|
|
137
|
+
this.sortBy = null;
|
|
138
|
+
this.page = 1;
|
|
139
|
+
this.chosen = null;
|
|
140
|
+
this.shownIds = null;
|
|
141
|
+
this.shownResource = resource;
|
|
142
|
+
}
|
|
143
|
+
this.detailTarget();
|
|
144
|
+
const selector = this.attr('period');
|
|
145
|
+
const dateField = selector === null || selector === ''
|
|
146
|
+
? null
|
|
147
|
+
: dateFieldFor(this.attr('date-field'), null, this.localName);
|
|
148
|
+
const filter = blockFilter(this);
|
|
149
|
+
const source = await resolvePeriodSource(this, selector);
|
|
150
|
+
if (signal.aborted)
|
|
151
|
+
return;
|
|
152
|
+
this.period.follow(source);
|
|
153
|
+
const base = source === null || dateField === null
|
|
154
|
+
? filter
|
|
155
|
+
: { ...filter, ...periodFilter(dateField, source.value) };
|
|
156
|
+
const schema = await session.schema(resource);
|
|
157
|
+
if (signal.aborted)
|
|
158
|
+
return;
|
|
159
|
+
const names = fieldList(this.attr('fields'));
|
|
160
|
+
const hasId = schema === null || schema.fields.some((field) => field.name === 'id');
|
|
161
|
+
const context = {
|
|
162
|
+
session,
|
|
163
|
+
resource,
|
|
164
|
+
columns: [],
|
|
165
|
+
base,
|
|
166
|
+
fields: names === null ? undefined : [...new Set([...(hasId ? ['id'] : []), ...names])],
|
|
167
|
+
size: Math.min(wholeAttr(this, 'page-size', 1, 25), MAX_PAGE),
|
|
168
|
+
};
|
|
169
|
+
this.exporting?.abort();
|
|
170
|
+
this.exporting = null;
|
|
171
|
+
signal.addEventListener('abort', () => this.rowsController?.abort(), { once: true });
|
|
172
|
+
const asked = fieldList(this.attr('filters')) ?? [];
|
|
173
|
+
this.checkFilterable(schema, asked);
|
|
174
|
+
// A field the `filter` fixes to one value has nothing left to choose.
|
|
175
|
+
const filterNames = asked.filter((name) => !(name in filter) || isOps(filter[name]));
|
|
176
|
+
for (const name of [...this.parts.keys()])
|
|
177
|
+
if (!filterNames.includes(name))
|
|
178
|
+
this.parts.delete(name);
|
|
179
|
+
const configs = readFieldConfigs(this);
|
|
180
|
+
// The controls exist only after this answer, so they cannot have asked for anything yet.
|
|
181
|
+
this.context = context;
|
|
182
|
+
const first = await this.fetchRows(context, this.query(context, context.size, this.page));
|
|
183
|
+
const response = first === null ? null : await this.lastPageIfPast(context, first);
|
|
184
|
+
if (response === null || signal.aborted)
|
|
185
|
+
return;
|
|
186
|
+
const sample = response.data[0];
|
|
187
|
+
context.columns = columnsFor(schema, names, configs, sample);
|
|
188
|
+
const unhidden = new Map([...configs].map(([name, config]) => [name, { ...config, hidden: false }]));
|
|
189
|
+
const filterColumns = columnsFor(schema, filterNames, unhidden, sample);
|
|
190
|
+
this.build(context, schema, filterColumns);
|
|
191
|
+
this.renderRows(context, response);
|
|
192
|
+
}
|
|
193
|
+
/** `filters` and `search` may only name fields the resource can be filtered on (when the schema says). */
|
|
194
|
+
checkFilterable(schema, filters) {
|
|
195
|
+
if (schema === null)
|
|
196
|
+
return;
|
|
197
|
+
const search = this.attr('search')?.trim() ?? '';
|
|
198
|
+
const named = filters.map((name) => ['filters', name]);
|
|
199
|
+
if (search !== '')
|
|
200
|
+
named.push(['search', search]);
|
|
201
|
+
for (const [attribute, field] of named) {
|
|
202
|
+
if (!schema.filterable.includes(field)) {
|
|
203
|
+
throw new TypeError(NL.invalid.notFilterable(this.localName, attribute, field));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/** The element `detail` names, or null without one; an invalid selector is the author's error. */
|
|
208
|
+
detailTarget() {
|
|
209
|
+
const selector = this.attr('detail')?.trim() ?? '';
|
|
210
|
+
if (selector === '')
|
|
211
|
+
return null;
|
|
212
|
+
try {
|
|
213
|
+
return doc.querySelector(selector);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
throw new TypeError(NL.invalid.selector(this.localName, 'detail', selector));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
/** The list query of the controls as they are now. */
|
|
220
|
+
query(context, size, number) {
|
|
221
|
+
const asked = {};
|
|
222
|
+
const searchField = this.attr('search')?.trim() ?? '';
|
|
223
|
+
const term = this.values.get(SEARCH)?.trim() ?? '';
|
|
224
|
+
if (searchField !== '' && term !== '')
|
|
225
|
+
merge(asked, searchField, { ilike: `%${term}%` });
|
|
226
|
+
for (const [field, part] of this.parts) {
|
|
227
|
+
const value = part();
|
|
228
|
+
if (value !== null)
|
|
229
|
+
merge(asked, field, value);
|
|
230
|
+
}
|
|
231
|
+
const filter = { ...context.base };
|
|
232
|
+
for (const [field, value] of Object.entries(asked)) {
|
|
233
|
+
const base = filter[field];
|
|
234
|
+
filter[field] = base === undefined ? value : within(base, value);
|
|
235
|
+
}
|
|
236
|
+
const sort = this.sortBy ?? (this.attr('sort')?.trim() || null);
|
|
237
|
+
const query = { filter, page: { size, number } };
|
|
238
|
+
if (sort !== null)
|
|
239
|
+
query.sort = sort;
|
|
240
|
+
if (context.fields !== undefined)
|
|
241
|
+
query.fields = context.fields;
|
|
242
|
+
return query;
|
|
243
|
+
}
|
|
244
|
+
/** Asks for one page; null when a newer request was made meanwhile (its answer is the one shown). */
|
|
245
|
+
async fetchRows(context, query) {
|
|
246
|
+
const id = ++this.requests;
|
|
247
|
+
this.rowsController?.abort();
|
|
248
|
+
const controller = new AbortController();
|
|
249
|
+
this.rowsController = controller;
|
|
250
|
+
this.setAttribute('aria-busy', 'true');
|
|
251
|
+
try {
|
|
252
|
+
const response = await context.session.resource(context.resource).list(query, {
|
|
253
|
+
signal: controller.signal,
|
|
254
|
+
});
|
|
255
|
+
return id === this.requests && !controller.signal.aborted ? response : null;
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
if (id === this.requests)
|
|
259
|
+
this.removeAttribute('aria-busy');
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* An empty page past the end (rows were removed while it was open, say) when the set has rows: the last page
|
|
264
|
+
* instead, so the pager stays usable. Null when a newer request was made meanwhile.
|
|
265
|
+
*/
|
|
266
|
+
async lastPageIfPast(context, response) {
|
|
267
|
+
const meta = response.meta.page;
|
|
268
|
+
if (response.data.length > 0 || this.page <= 1 || meta === undefined || meta === null || meta.total === 0) {
|
|
269
|
+
return response;
|
|
270
|
+
}
|
|
271
|
+
this.page = Math.max(1, meta.pages);
|
|
272
|
+
return this.fetchRows(context, this.query(context, context.size, this.page));
|
|
273
|
+
}
|
|
274
|
+
/** After a control changed: the rows again, or a state in their place. The controls stay. */
|
|
275
|
+
async update(resetPage) {
|
|
276
|
+
const context = this.context;
|
|
277
|
+
if (context === null || !this.isConnected)
|
|
278
|
+
return;
|
|
279
|
+
if (resetPage)
|
|
280
|
+
this.page = 1;
|
|
281
|
+
// Per update, not per request: the retry for the last page is part of this one, and so is its failure.
|
|
282
|
+
const run = ++this.updates;
|
|
283
|
+
try {
|
|
284
|
+
const first = await this.fetchRows(context, this.query(context, context.size, this.page));
|
|
285
|
+
const response = first === null ? null : await this.lastPageIfPast(context, first);
|
|
286
|
+
if (response !== null)
|
|
287
|
+
this.renderRows(context, response);
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
if (run !== this.updates || error?.name === 'AbortError')
|
|
291
|
+
return;
|
|
292
|
+
this.showRowsState(fetchState(error));
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
later(resetPage) {
|
|
296
|
+
this.stopDebounce();
|
|
297
|
+
this.debounce = scope.setTimeout(() => {
|
|
298
|
+
this.debounce = null;
|
|
299
|
+
void this.update(resetPage);
|
|
300
|
+
}, DEBOUNCE);
|
|
301
|
+
}
|
|
302
|
+
stopDebounce() {
|
|
303
|
+
if (this.debounce !== null)
|
|
304
|
+
scope.clearTimeout(this.debounce);
|
|
305
|
+
this.debounce = null;
|
|
306
|
+
}
|
|
307
|
+
build(context, schema, filterColumns) {
|
|
308
|
+
this.parts = new Map();
|
|
309
|
+
const tools = [];
|
|
310
|
+
const searchField = this.attr('search')?.trim() ?? '';
|
|
311
|
+
if (searchField !== '') {
|
|
312
|
+
const input = h(doc, 'input', { type: 'search' });
|
|
313
|
+
tools.push(this.control(input, SEARCH, NL.search, true));
|
|
314
|
+
}
|
|
315
|
+
for (const column of filterColumns)
|
|
316
|
+
tools.push(this.filterControl(column));
|
|
317
|
+
if (this.flag('export')) {
|
|
318
|
+
this.exportButton = h(doc, 'button', {
|
|
319
|
+
type: 'button',
|
|
320
|
+
class: 'secondary',
|
|
321
|
+
onclick: () => this.toggleExport(),
|
|
322
|
+
}, NL.exportCsv);
|
|
323
|
+
this.exportStatus = h(doc, 'p', { class: 'export-status', role: 'status' });
|
|
324
|
+
tools.push(this.exportButton, this.exportStatus);
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
this.exportButton = null;
|
|
328
|
+
this.exportStatus = null;
|
|
329
|
+
}
|
|
330
|
+
this.headers = new Map();
|
|
331
|
+
this.frame = h(doc, 'div', { class: 'scroll' });
|
|
332
|
+
this.pagerText = h(doc, 'p', { 'aria-live': 'polite' });
|
|
333
|
+
this.previous = h(doc, 'button', {
|
|
334
|
+
type: 'button',
|
|
335
|
+
class: 'secondary',
|
|
336
|
+
onclick: () => this.go(-1),
|
|
337
|
+
}, NL.previous);
|
|
338
|
+
this.next = h(doc, 'button', {
|
|
339
|
+
type: 'button',
|
|
340
|
+
class: 'secondary',
|
|
341
|
+
onclick: () => this.go(1),
|
|
342
|
+
}, NL.next);
|
|
343
|
+
this.pager = h(doc, 'div', { class: 'pager' }, this.pagerText, this.previous, this.next);
|
|
344
|
+
const thead = h(doc, 'thead', {}, h(doc, 'tr', {}, ...context.columns.map((column) => {
|
|
345
|
+
const sortable = schema?.sortable.includes(column.name) === true;
|
|
346
|
+
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)
|
|
348
|
+
: column.label, ...(this.exampleField(context.session, context.resource, column.name)
|
|
349
|
+
? [' ', exampleBadge(doc)]
|
|
350
|
+
: []));
|
|
351
|
+
if (sortable)
|
|
352
|
+
this.headers.set(column.name, th);
|
|
353
|
+
return th;
|
|
354
|
+
})));
|
|
355
|
+
this.table = h(doc, 'table', {}, h(doc, 'caption', { class: 'sr-only' }, humanize(context.resource)), thead, h(doc, 'tbody'));
|
|
356
|
+
this.markSort();
|
|
357
|
+
clear(this.body);
|
|
358
|
+
this.body.appendChild(h(doc, 'div', { class: 'card' }, tools.length > 0 ? h(doc, 'div', { class: 'tools' }, ...tools) : null, this.frame, this.pager));
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* A labelled control whose text is kept in `values` under `key`, so a reload (an edit elsewhere, a changed
|
|
362
|
+
* attribute) rebuilds it as the person left it. `wait` debounces typing; otherwise a change asks at once.
|
|
363
|
+
*/
|
|
364
|
+
control(control, key, label, wait, onInput) {
|
|
365
|
+
const id = `cube-table-${++controlIds}`;
|
|
366
|
+
control.id = id;
|
|
367
|
+
control.value = this.values.get(key) ?? '';
|
|
368
|
+
onInput?.(control);
|
|
369
|
+
control.addEventListener(wait ? 'input' : 'change', () => {
|
|
370
|
+
this.values.set(key, control.value);
|
|
371
|
+
onInput?.(control);
|
|
372
|
+
if (wait)
|
|
373
|
+
this.later(true);
|
|
374
|
+
else
|
|
375
|
+
void this.update(true);
|
|
376
|
+
});
|
|
377
|
+
return h(doc, 'div', { class: 'control' }, h(doc, 'label', { for: id }, label), control);
|
|
378
|
+
}
|
|
379
|
+
/** The control for one `filters` field, and its part of the query. */
|
|
380
|
+
filterControl(column) {
|
|
381
|
+
const name = column.name;
|
|
382
|
+
const value = (key) => this.values.get(key)?.trim() ?? '';
|
|
383
|
+
const choices = choicesFor(column);
|
|
384
|
+
const boolean = choices === null && column.format === 'boolean';
|
|
385
|
+
if (choices !== null || boolean) {
|
|
386
|
+
const options = choices ?? [{ value: 'true', label: NL.yes }, { value: 'false', label: NL.no }];
|
|
387
|
+
const select = h(doc, 'select', {}, h(doc, 'option', { value: '' }, NL.all), ...options.map((option) => h(doc, 'option', { value: option.value }, option.label)));
|
|
388
|
+
const numeric = NUMERIC.includes(column.format);
|
|
389
|
+
this.parts.set(name, () => {
|
|
390
|
+
const chosen = value(name);
|
|
391
|
+
if (chosen === '')
|
|
392
|
+
return null;
|
|
393
|
+
return boolean ? chosen === 'true' : numeric ? (toNumber(chosen) ?? chosen) : chosen;
|
|
394
|
+
});
|
|
395
|
+
return this.control(select, name, column.label, false);
|
|
396
|
+
}
|
|
397
|
+
const dates = column.format === 'date' || column.format === 'datetime';
|
|
398
|
+
if (!dates && !NUMERIC.includes(column.format)) {
|
|
399
|
+
this.parts.set(name, () => (value(name) === '' ? null : { ilike: `%${value(name)}%` }));
|
|
400
|
+
return this.control(h(doc, 'input', { type: 'text' }), name, column.label, true);
|
|
401
|
+
}
|
|
402
|
+
// Typed the Dutch way (1.234,5); what cannot be read as a number is marked and left out of the query.
|
|
403
|
+
const number = (key) => {
|
|
404
|
+
const parsed = parseInput(value(key), column.format);
|
|
405
|
+
return 'value' in parsed && typeof parsed.value === 'number' ? parsed.value : null;
|
|
406
|
+
};
|
|
407
|
+
const mark = (key) => (input) => {
|
|
408
|
+
if (value(key) !== '' && number(key) === null)
|
|
409
|
+
input.setAttribute('aria-invalid', 'true');
|
|
410
|
+
else
|
|
411
|
+
input.removeAttribute('aria-invalid');
|
|
412
|
+
};
|
|
413
|
+
const bound = (role) => {
|
|
414
|
+
const key = `${name}:${role}`;
|
|
415
|
+
const input = h(doc, 'input', {
|
|
416
|
+
type: dates ? 'date' : 'text',
|
|
417
|
+
inputmode: dates ? null : 'decimal',
|
|
418
|
+
// Two filters both have a Vanaf: the name says which field it bounds.
|
|
419
|
+
'aria-label': `${NL[role]} (${column.label})`,
|
|
420
|
+
});
|
|
421
|
+
return this.control(input, key, NL[role], !dates, dates ? undefined : mark(key));
|
|
422
|
+
};
|
|
423
|
+
this.parts.set(name, () => {
|
|
424
|
+
const ops = {};
|
|
425
|
+
const low = dates ? value(`${name}:min`) || null : number(`${name}:min`);
|
|
426
|
+
const high = dates ? dayAfter(value(`${name}:max`)) : number(`${name}:max`);
|
|
427
|
+
if (low !== null)
|
|
428
|
+
ops.gte = low;
|
|
429
|
+
if (high !== null)
|
|
430
|
+
ops[dates ? 'lt' : 'lte'] = high;
|
|
431
|
+
return Object.keys(ops).length > 0 ? ops : null;
|
|
432
|
+
});
|
|
433
|
+
const legend = h(doc, 'legend', {}, column.label);
|
|
434
|
+
return h(doc, 'fieldset', { class: 'control' }, legend, bound('min'), bound('max'));
|
|
435
|
+
}
|
|
436
|
+
sortOn(field) {
|
|
437
|
+
const current = this.sortBy ?? (this.attr('sort')?.trim() || null);
|
|
438
|
+
this.sortBy = current === field ? `-${field}` : field;
|
|
439
|
+
this.markSort();
|
|
440
|
+
void this.update(true);
|
|
441
|
+
}
|
|
442
|
+
markSort() {
|
|
443
|
+
const sort = this.sortBy ?? (this.attr('sort')?.trim() || '');
|
|
444
|
+
const field = sort.replace(/^-/, '');
|
|
445
|
+
for (const [name, th] of this.headers) {
|
|
446
|
+
if (name === field)
|
|
447
|
+
th.setAttribute('aria-sort', sort.startsWith('-') ? 'descending' : 'ascending');
|
|
448
|
+
else
|
|
449
|
+
th.removeAttribute('aria-sort');
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
go(step) {
|
|
453
|
+
this.page = Math.max(1, this.page + step);
|
|
454
|
+
void this.update(false);
|
|
455
|
+
}
|
|
456
|
+
renderRows(context, response) {
|
|
457
|
+
const frame = this.frame;
|
|
458
|
+
const table = this.table;
|
|
459
|
+
if (frame === null || table === null)
|
|
460
|
+
return;
|
|
461
|
+
const meta = response.meta.page;
|
|
462
|
+
const total = meta?.total ?? response.data.length;
|
|
463
|
+
const pages = Math.max(1, meta?.pages ?? 1);
|
|
464
|
+
this.page = meta?.number ?? this.page;
|
|
465
|
+
clear(frame);
|
|
466
|
+
const ids = JSON.stringify(response.data.map((row) => row.id ?? null));
|
|
467
|
+
if (ids !== this.shownIds)
|
|
468
|
+
this.chosen = null;
|
|
469
|
+
this.shownIds = ids;
|
|
470
|
+
if (response.data.length === 0) {
|
|
471
|
+
this.showRowsState({ kind: 'empty', detail: null });
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
const tbody = h(doc, 'tbody', {}, ...response.data.map((row) => this.row(context, row)));
|
|
475
|
+
table.replaceChild(tbody, table.querySelector('tbody'));
|
|
476
|
+
frame.appendChild(table);
|
|
477
|
+
if (this.pager !== null)
|
|
478
|
+
this.pager.hidden = false;
|
|
479
|
+
if (this.pagerText !== null)
|
|
480
|
+
this.pagerText.textContent = NL.pageOf(this.page, pages, total);
|
|
481
|
+
if (this.previous !== null)
|
|
482
|
+
this.previous.disabled = this.page <= 1;
|
|
483
|
+
if (this.next !== null)
|
|
484
|
+
this.next.disabled = this.page >= pages;
|
|
485
|
+
}
|
|
486
|
+
showRowsState(state) {
|
|
487
|
+
if (this.frame === null)
|
|
488
|
+
return;
|
|
489
|
+
clear(this.frame);
|
|
490
|
+
this.frame.appendChild(h(doc, 'cube-state', {
|
|
491
|
+
kind: state.kind,
|
|
492
|
+
message: state.detail,
|
|
493
|
+
retry: state.retry === false ? 'false' : null,
|
|
494
|
+
}));
|
|
495
|
+
if (this.pager !== null)
|
|
496
|
+
this.pager.hidden = true;
|
|
497
|
+
}
|
|
498
|
+
row(context, row) {
|
|
499
|
+
const choose = () => this.select(context, row, tr);
|
|
500
|
+
const example = (column) => this.exampleField(context.session, context.resource, column.name);
|
|
501
|
+
const chosen = this.chosen !== null && String(row.id ?? '') === this.chosen;
|
|
502
|
+
const tr = h(doc, 'tr', {
|
|
503
|
+
tabindex: '0',
|
|
504
|
+
class: chosen ? 'chosen' : null,
|
|
505
|
+
'aria-current': chosen ? 'true' : null,
|
|
506
|
+
onclick: choose,
|
|
507
|
+
onkeydown: ((event) => {
|
|
508
|
+
if (event.key !== 'Enter' && event.key !== ' ')
|
|
509
|
+
return;
|
|
510
|
+
// Space would scroll the page.
|
|
511
|
+
event.preventDefault();
|
|
512
|
+
choose();
|
|
513
|
+
}),
|
|
514
|
+
}, ...context.columns.map((column) => {
|
|
515
|
+
const classes = [NUMERIC.includes(column.format) && 'num', example(column) && 'example'];
|
|
516
|
+
return h(doc, 'td', { class: classes.filter(Boolean).join(' ') || null }, formatValue(row[column.name], column.format));
|
|
517
|
+
}));
|
|
518
|
+
return tr;
|
|
519
|
+
}
|
|
520
|
+
select(context, row, tr) {
|
|
521
|
+
const id = row.id ?? null;
|
|
522
|
+
this.chosen = id === null ? null : String(id);
|
|
523
|
+
for (const other of Array.from(tr.parentElement?.children ?? [])) {
|
|
524
|
+
other.classList.toggle('chosen', other === tr);
|
|
525
|
+
if (other === tr)
|
|
526
|
+
other.setAttribute('aria-current', 'true');
|
|
527
|
+
else
|
|
528
|
+
other.removeAttribute('aria-current');
|
|
529
|
+
}
|
|
530
|
+
this.emit('cube-select', { resource: context.resource, id, row });
|
|
531
|
+
if (id === null)
|
|
532
|
+
return;
|
|
533
|
+
let target = null;
|
|
534
|
+
try {
|
|
535
|
+
target = this.detailTarget();
|
|
536
|
+
}
|
|
537
|
+
catch (error) {
|
|
538
|
+
// Checked on load; an attribute changed since is reported the same way.
|
|
539
|
+
this.showState(stateFor(error));
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
target?.setAttribute('record-id', String(id));
|
|
543
|
+
}
|
|
544
|
+
/** Starts the export, or cancels the running one. */
|
|
545
|
+
toggleExport() {
|
|
546
|
+
if (this.exporting !== null) {
|
|
547
|
+
this.exporting.abort();
|
|
548
|
+
this.exporting = null;
|
|
549
|
+
if (this.exportButton !== null)
|
|
550
|
+
this.exportButton.textContent = NL.exportCsv;
|
|
551
|
+
if (this.exportStatus !== null)
|
|
552
|
+
this.exportStatus.textContent = '';
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
void this.runExport();
|
|
556
|
+
}
|
|
557
|
+
async runExport() {
|
|
558
|
+
const context = this.context;
|
|
559
|
+
const button = this.exportButton;
|
|
560
|
+
const status = this.exportStatus;
|
|
561
|
+
if (context === null || button === null || status === null)
|
|
562
|
+
return;
|
|
563
|
+
const controller = new AbortController();
|
|
564
|
+
this.exporting = controller;
|
|
565
|
+
const { signal } = controller;
|
|
566
|
+
button.textContent = NL.cancel;
|
|
567
|
+
status.textContent = NL.exporting(0);
|
|
568
|
+
const rows = [];
|
|
569
|
+
let message = '';
|
|
570
|
+
try {
|
|
571
|
+
let pages = 0;
|
|
572
|
+
const query = this.query(context, MAX_PAGE, 1);
|
|
573
|
+
for await (const page of context.session.resource(context.resource).pages(query, { signal })) {
|
|
574
|
+
// The preview ignores the signal: stop here when the user cancelled meanwhile.
|
|
575
|
+
if (signal.aborted)
|
|
576
|
+
break;
|
|
577
|
+
rows.push(...page.data);
|
|
578
|
+
status.textContent = NL.exporting(rows.length);
|
|
579
|
+
if (++pages >= EXPORT_PAGES)
|
|
580
|
+
break;
|
|
581
|
+
}
|
|
582
|
+
if (!signal.aborted) {
|
|
583
|
+
downloadCsv(doc, `${context.resource}-${isoDate(new Date())}.csv`, toCsv(context.columns, rows));
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
catch (error) {
|
|
587
|
+
if (!signal.aborted && error?.name !== 'AbortError') {
|
|
588
|
+
message = NL.exportFailed;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
finally {
|
|
592
|
+
// A cancelled run was reset when it was cancelled; a newer run owns the button by now.
|
|
593
|
+
if (this.exporting === controller) {
|
|
594
|
+
this.exporting = null;
|
|
595
|
+
button.textContent = NL.exportCsv;
|
|
596
|
+
status.textContent = message;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
return CubeTable;
|
|
602
|
+
}
|
|
603
|
+
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
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { BlockClass } from '../block.js';
|
|
2
|
+
import type { BlockScope } from '../define.js';
|
|
3
|
+
/** `<cube-top>`: the groups of a resource ranked by one measure, with a bar each; sorted and cut in the browser. */
|
|
4
|
+
export declare function topBlock(Base: BlockClass, scope: BlockScope): BlockClass;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { aggregateFields, aggregateQuery, CARD_CSS, groupedAggregate, LABEL_CSS, PeriodFollower, rankGroups, requiredAttr, valueFormatter, wholeAttr, } from '../aggregate-block.js';
|
|
2
|
+
import { clear, h, svg } from '../dom.js';
|
|
3
|
+
import { NL } from '../texts.js';
|
|
4
|
+
const CSS = `${CARD_CSS}${LABEL_CSS}
|
|
5
|
+
ol{margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:.25rem}
|
|
6
|
+
button{display:grid;grid-template-columns:1fr auto;gap:.125rem .75rem;width:100%;padding:.5rem .75rem;
|
|
7
|
+
border:0;border-radius:var(--cube-radius);background:none;color:inherit;font:inherit;text-align:start;cursor:pointer}
|
|
8
|
+
button:hover{background:var(--cube-color-primary-subtle)}
|
|
9
|
+
button:focus-visible{outline:2px solid var(--cube-color-focus);outline-offset:2px}
|
|
10
|
+
.name{overflow-wrap:anywhere}
|
|
11
|
+
.value{font-weight:600;font-variant-numeric:tabular-nums}
|
|
12
|
+
svg{grid-column:1/-1;display:block;width:100%;height:.5rem}
|
|
13
|
+
rect.bar{fill:var(--cube-color-series-1)}
|
|
14
|
+
.note{margin:.5rem 0 0;font-size:.875rem;color:var(--cube-color-text-muted)}
|
|
15
|
+
`;
|
|
16
|
+
/** `<cube-top>`: the groups of a resource ranked by one measure, with a bar each; sorted and cut in the browser. */
|
|
17
|
+
export function topBlock(Base, scope) {
|
|
18
|
+
class CubeTop extends Base {
|
|
19
|
+
static tag = 'cube-top';
|
|
20
|
+
static css = CSS;
|
|
21
|
+
shownFields() {
|
|
22
|
+
return aggregateFields(this);
|
|
23
|
+
}
|
|
24
|
+
period = new PeriodFollower(this);
|
|
25
|
+
async load(signal, session) {
|
|
26
|
+
if (session === null)
|
|
27
|
+
return;
|
|
28
|
+
const resource = requiredAttr(this, 'resource');
|
|
29
|
+
const by = requiredAttr(this, 'by');
|
|
30
|
+
const limit = wholeAttr(this, 'limit', 1, 5);
|
|
31
|
+
const query = await aggregateQuery(this, this.period, signal, by);
|
|
32
|
+
if (query === null)
|
|
33
|
+
return;
|
|
34
|
+
const { data, meta } = await groupedAggregate(session, resource, by, query, signal);
|
|
35
|
+
if (signal.aborted)
|
|
36
|
+
return;
|
|
37
|
+
const order = { descending: this.attr('order') !== 'asc', limit };
|
|
38
|
+
const ranked = await rankGroups(this, session, data, { by, fn: query.fn }, order, signal);
|
|
39
|
+
if (ranked === null)
|
|
40
|
+
return;
|
|
41
|
+
if (ranked.length === 0) {
|
|
42
|
+
this.showState('empty');
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const incomplete = (meta?.groups ?? 0) > data.length;
|
|
46
|
+
this.render(ranked, by, valueFormatter(this, query.count), incomplete);
|
|
47
|
+
}
|
|
48
|
+
render(items, by, format, incomplete) {
|
|
49
|
+
const doc = scope.document;
|
|
50
|
+
const largest = Math.max(...items.map((item) => item.value));
|
|
51
|
+
const label = this.attr('label');
|
|
52
|
+
const listAttrs = {};
|
|
53
|
+
if (label !== null && label !== '')
|
|
54
|
+
listAttrs['aria-label'] = label;
|
|
55
|
+
if (incomplete)
|
|
56
|
+
listAttrs['aria-describedby'] = 'incomplete';
|
|
57
|
+
clear(this.body);
|
|
58
|
+
this.body.appendChild(h(doc, 'div', { class: 'card' }, ...(label === null || label === '' ? [] : [h(doc, 'p', { class: 'label' }, label)]), h(doc, 'ol', listAttrs, ...items.map((item) => h(doc, 'li', {}, h(doc, 'button', { type: 'button', onclick: () => void this.emit('cube-select', { by, key: item.key }) }, h(doc, 'span', { class: 'name' }, item.label), ' ', h(doc, 'span', { class: 'value' }, format(item.value)), this.bar(item.value, largest))))), ...(incomplete ? [h(doc, 'p', { class: 'note', id: 'incomplete', role: 'note' }, NL.incomplete)] : [])));
|
|
59
|
+
}
|
|
60
|
+
/** A bar as long as the value is against the largest; a value of zero or less has none. */
|
|
61
|
+
bar(value, largest) {
|
|
62
|
+
const width = largest > 0 && value > 0 ? (value / largest) * 100 : 0;
|
|
63
|
+
return svg(scope.document, 'svg', { viewBox: '0 0 100 4', preserveAspectRatio: 'none', 'aria-hidden': 'true', focusable: 'false' }, svg(scope.document, 'rect', { class: 'bar', x: 0, y: 0, width, height: 4 }));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return CubeTop;
|
|
67
|
+
}
|