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