@cube-nl/dmp-sdk 1.1.0-rc.1 → 1.1.0-rc.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +115 -27
- package/dist/app.d.ts +7 -4
- package/dist/app.js +89 -33
- package/dist/browser.js +8 -0
- package/dist/client.js +5 -1
- package/dist/cube-sdk.js +32 -22
- package/dist/cube-ui.manifest.json +12 -5
- package/dist/preview.d.ts +2 -0
- package/dist/preview.js +21 -6
- package/dist/size.json +3 -3
- package/dist/trial.d.ts +32 -0
- package/dist/trial.js +55 -0
- package/dist/ui/aggregate-block.js +2 -1
- package/dist/ui/attributes.js +1 -1
- package/dist/ui/author-error.d.ts +3 -0
- package/dist/ui/author-error.js +3 -0
- package/dist/ui/block.d.ts +8 -2
- package/dist/ui/block.js +14 -5
- package/dist/ui/blocks/app-shell.js +1 -1
- package/dist/ui/blocks/board.d.ts +1 -1
- package/dist/ui/blocks/board.js +129 -16
- package/dist/ui/blocks/calc.js +35 -12
- package/dist/ui/blocks/kpi.js +2 -1
- package/dist/ui/blocks/table.js +82 -23
- package/dist/ui/dom.js +2 -1
- package/dist/ui/format.js +3 -2
- package/dist/ui/manifest.js +14 -3
- package/dist/ui/query.js +7 -6
- package/dist/ui/texts.d.ts +12 -0
- package/dist/ui/texts.js +16 -0
- package/dist/ui/trial-banner.d.ts +27 -0
- package/dist/ui/trial-banner.js +199 -0
- package/dist/ui/write.d.ts +2 -2
- package/dist/ui/write.js +2 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/wireframe.js +53 -16
- package/package.json +5 -2
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest": 1,
|
|
3
3
|
"package": "@cube-nl/dmp-sdk",
|
|
4
|
-
"version": "1.1.0-rc.
|
|
4
|
+
"version": "1.1.0-rc.3",
|
|
5
5
|
"file": "cube-sdk.js",
|
|
6
6
|
"blocks": {
|
|
7
7
|
"cube-state": {
|
|
@@ -665,7 +665,7 @@
|
|
|
665
665
|
"cube-result"
|
|
666
666
|
],
|
|
667
667
|
"properties": {
|
|
668
|
-
"formula": "(values) => results, a synchronous function: an async one
|
|
668
|
+
"formula": "(values) => results, a synchronous function: an async one is refused with an error. Set from a script, never as an attribute: a string is refused, nothing is evaluated. values has each cube-input by name: a number, a text, a boolean, or null when empty or invalid. Return an object by cube-result name, or one number or text when there is a single cube-result. null, undefined, NaN and Infinity show \"—\"; an object as the value of a number, integer, currency or percent result shows \"—\" like NaN. Names of cube-inputs, and of cube-results, must be unique.",
|
|
669
669
|
"values": "Read-only: the values the formula got last.",
|
|
670
670
|
"results": "Read-only: the results shown last, by cube-result name (null for \"—\")."
|
|
671
671
|
},
|
|
@@ -788,7 +788,7 @@
|
|
|
788
788
|
"cube-board": {
|
|
789
789
|
"kind": "block",
|
|
790
790
|
"since": "1.1.0",
|
|
791
|
-
"summary": "The rows of a resource as cards in a column per value of a status field, like a kanban board. Columns come from cube-column children, else from the enum of the status field (preview only: live, write cube-columns). A card moves by drag and drop or by a button per other column, named \"Verplaats naar …\": at once on screen, then saved; a refusal puts it back with an alert. Read-only without update or with a readonly status field: update is optional, a view-only board needs only list. Columns stack on a narrow screen.",
|
|
791
|
+
"summary": "The rows of a resource as cards in a column per value of a status field, like a kanban board. Columns come from cube-column children, else from the enum of the status field (preview only: live, write cube-columns). A card moves by drag and drop or by a button per other column, named \"Verplaats naar …\": at once on screen, then saved; a refusal puts it back with an alert. Read-only without update or with a readonly status field: update is optional, a view-only board needs only list. Choosing a card (click, Enter or Space) emits cube-select and can fill a cube-detail. Columns stack on a narrow screen.",
|
|
792
792
|
"actions": [
|
|
793
793
|
"list",
|
|
794
794
|
"update"
|
|
@@ -825,7 +825,7 @@
|
|
|
825
825
|
"sort": {
|
|
826
826
|
"type": "string",
|
|
827
827
|
"required": false,
|
|
828
|
-
"summary": "The order of the cards in each column: a field that is sortable on the resource, with - in front for descending.",
|
|
828
|
+
"summary": "The order of the cards in each column: a field that is sortable on the resource, with - in front for descending; a moved card takes its place in that order; without sort it goes on top.",
|
|
829
829
|
"example": "-due_on"
|
|
830
830
|
},
|
|
831
831
|
"filter": {
|
|
@@ -839,6 +839,12 @@
|
|
|
839
839
|
"required": false,
|
|
840
840
|
"summary": "Cards shown per column, at most 200; a column with more says \"en nog N\".",
|
|
841
841
|
"default": "50"
|
|
842
|
+
},
|
|
843
|
+
"detail": {
|
|
844
|
+
"type": "selector",
|
|
845
|
+
"required": false,
|
|
846
|
+
"summary": "CSS selector of a cube-detail whose record-id is set to the id of the chosen card.",
|
|
847
|
+
"example": "#taak"
|
|
842
848
|
}
|
|
843
849
|
},
|
|
844
850
|
"children": [
|
|
@@ -846,7 +852,8 @@
|
|
|
846
852
|
"cube-field"
|
|
847
853
|
],
|
|
848
854
|
"events": {
|
|
849
|
-
"cube-move": "A move was saved; detail is { resource, id, from, to } with the old and new status values. cube-data-change follows, so tables of the same resource reload."
|
|
855
|
+
"cube-move": "A move was saved; detail is { resource, id, from, to } with the old and new status values. cube-data-change follows, so tables of the same resource reload.",
|
|
856
|
+
"cube-select": "The user chose a card (click, Enter or Space; not a move); detail is { resource, id, row } with the row as listed."
|
|
850
857
|
},
|
|
851
858
|
"example": "<cube-board resource=\"tasks\" status-field=\"status\" title-field=\"title\" fields=\"owner\"><cube-column value=\"todo\" label=\"Te doen\"></cube-column><cube-column value=\"doing\" label=\"Bezig\"></cube-column><cube-column value=\"done\" label=\"Klaar\"></cube-column></cube-board>"
|
|
852
859
|
},
|
package/dist/preview.d.ts
CHANGED
package/dist/preview.js
CHANGED
|
@@ -2,7 +2,14 @@ import { DmpError } from './errors.js';
|
|
|
2
2
|
import { buildQuery } from './query.js';
|
|
3
3
|
const RESOURCE_NAME = /^[a-z][a-z0-9_]*$/;
|
|
4
4
|
const OPERATORS = ['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'in', 'like', 'ilike', 'is', 'between'];
|
|
5
|
-
const SYMBOLS = [
|
|
5
|
+
const SYMBOLS = [
|
|
6
|
+
['>=', 'gte'],
|
|
7
|
+
['<=', 'lte'],
|
|
8
|
+
['<>', 'neq'],
|
|
9
|
+
['>', 'gt'],
|
|
10
|
+
['<', 'lt'],
|
|
11
|
+
['=', 'eq'],
|
|
12
|
+
];
|
|
6
13
|
const FUNCTIONS = ['count', 'sum', 'avg', 'min', 'max'];
|
|
7
14
|
const UNITS = ['day', 'week', 'month', 'quarter', 'year'];
|
|
8
15
|
const MAX_FILTERS = 20;
|
|
@@ -176,7 +183,9 @@ function table(name, resource) {
|
|
|
176
183
|
for (const [fieldName, choice] of choiceEntries) {
|
|
177
184
|
choices.set(fieldName, choice.values);
|
|
178
185
|
}
|
|
179
|
-
const known = (list) => Array.isArray(list)
|
|
186
|
+
const known = (list) => Array.isArray(list)
|
|
187
|
+
? list.filter((item) => typeof item === 'string' && fields.has(item))
|
|
188
|
+
: null;
|
|
180
189
|
return {
|
|
181
190
|
name,
|
|
182
191
|
actions: Array.isArray(schema.actions)
|
|
@@ -502,7 +511,9 @@ function aggregator(table, fn) {
|
|
|
502
511
|
}
|
|
503
512
|
const allowed = table.aggregatable.get(target);
|
|
504
513
|
if (allowed === undefined) {
|
|
505
|
-
throw problem(422, 'query_invalid', `Unknown aggregatable field: ${target}.`, {
|
|
514
|
+
throw problem(422, 'query_invalid', `Unknown aggregatable field: ${target}.`, {
|
|
515
|
+
fn: [aggregatableHint(table)],
|
|
516
|
+
});
|
|
506
517
|
}
|
|
507
518
|
if (!allowed.includes(name)) {
|
|
508
519
|
throw problem(422, 'query_invalid', `${name} is not allowed on ${target}.`, {
|
|
@@ -521,9 +532,13 @@ function aggregator(table, fn) {
|
|
|
521
532
|
case 'avg':
|
|
522
533
|
return numbers.length === 0 ? null : numbers.reduce((sum, value) => sum + value, 0) / numbers.length;
|
|
523
534
|
case 'min':
|
|
524
|
-
return values.length === 0
|
|
535
|
+
return values.length === 0
|
|
536
|
+
? null
|
|
537
|
+
: values.reduce((low, value) => (compare(value, low) < 0 ? value : low));
|
|
525
538
|
default:
|
|
526
|
-
return values.length === 0
|
|
539
|
+
return values.length === 0
|
|
540
|
+
? null
|
|
541
|
+
: values.reduce((high, value) => (compare(value, high) > 0 ? value : high));
|
|
527
542
|
}
|
|
528
543
|
};
|
|
529
544
|
}
|
|
@@ -816,7 +831,7 @@ function same(actual, expected) {
|
|
|
816
831
|
return actual === expected || (typeof actual !== 'object' && String(actual) === String(expected));
|
|
817
832
|
}
|
|
818
833
|
/** Nulls first, numbers as numbers, everything else as text (ISO dates sort correctly as text). */
|
|
819
|
-
function compare(a, b) {
|
|
834
|
+
export function compare(a, b) {
|
|
820
835
|
if (a == null || b == null) {
|
|
821
836
|
return a == null ? (b == null ? 0 : -1) : 1;
|
|
822
837
|
}
|
package/dist/size.json
CHANGED
package/dist/trial.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the DMP writes into a trial page (`<script type="application/json" id="cube-trial-config">`): the maker's
|
|
3
|
+
* app on the maker's own data, read-only, with a token that lives only in this page.
|
|
4
|
+
*/
|
|
5
|
+
export interface TrialConfig {
|
|
6
|
+
mode: 'trial';
|
|
7
|
+
/** The DMP's origin. */
|
|
8
|
+
dmpUrl: string;
|
|
9
|
+
/** The REST face's base URL. */
|
|
10
|
+
apiUrl: string;
|
|
11
|
+
/** The read-only trial token, `cubeproef_…`. Never stored, never logged. */
|
|
12
|
+
token: string;
|
|
13
|
+
/** ISO 8601. */
|
|
14
|
+
expiresAt: string;
|
|
15
|
+
/** `GET` answers whether the trial is still open (the auto-refresh polls it). */
|
|
16
|
+
statusUrl: string;
|
|
17
|
+
version: string | null;
|
|
18
|
+
soort: string | null;
|
|
19
|
+
/** The DMP's user id of the maker. */
|
|
20
|
+
userId: string | null;
|
|
21
|
+
/** The resources the trial reads, for the banner. */
|
|
22
|
+
leest: string[];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Reads the injected config and removes its element, so the token is out of the DOM. The SDK calls it once when the
|
|
26
|
+
* script evaluates; the result is what `trialConfig()` answers from then on (a later call that finds an element
|
|
27
|
+
* replaces it, one that finds none changes nothing and answers null). Anything but a complete `mode: 'trial'` config
|
|
28
|
+
* is warned about and means no trial: the app starts as it would without one.
|
|
29
|
+
*/
|
|
30
|
+
export declare function readTrialConfig(doc?: Document): TrialConfig | null;
|
|
31
|
+
/** The trial config this page was served with, or null outside a trial. */
|
|
32
|
+
export declare function trialConfig(): TrialConfig | null;
|
package/dist/trial.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
const ELEMENT_ID = 'cube-trial-config';
|
|
2
|
+
const REQUIRED = ['dmpUrl', 'apiUrl', 'token', 'expiresAt', 'statusUrl'];
|
|
3
|
+
let current = null;
|
|
4
|
+
/**
|
|
5
|
+
* Reads the injected config and removes its element, so the token is out of the DOM. The SDK calls it once when the
|
|
6
|
+
* script evaluates; the result is what `trialConfig()` answers from then on (a later call that finds an element
|
|
7
|
+
* replaces it, one that finds none changes nothing and answers null). Anything but a complete `mode: 'trial'` config
|
|
8
|
+
* is warned about and means no trial: the app starts as it would without one.
|
|
9
|
+
*/
|
|
10
|
+
export function readTrialConfig(doc) {
|
|
11
|
+
const element = doc?.getElementById(ELEMENT_ID) ?? null;
|
|
12
|
+
// Without the element there is nothing new to read: the config read before stays (the element is gone by now).
|
|
13
|
+
if (element === null) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
current = null;
|
|
17
|
+
element.remove();
|
|
18
|
+
let value = null;
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(element.textContent ?? '');
|
|
21
|
+
value = parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
22
|
+
? parsed
|
|
23
|
+
: null;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// Reported below, without the content: it holds the token.
|
|
27
|
+
}
|
|
28
|
+
if (value?.mode !== 'trial' || REQUIRED.some((key) => typeof value[key] !== 'string' || value[key] === '')) {
|
|
29
|
+
console.warn(`#${ELEMENT_ID} is not a complete trial config; the app starts without it.`);
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
const text = (key) => (typeof value[key] === 'string' ? value[key] : null);
|
|
33
|
+
const leest = value.leest;
|
|
34
|
+
current = {
|
|
35
|
+
mode: 'trial',
|
|
36
|
+
dmpUrl: value.dmpUrl,
|
|
37
|
+
apiUrl: value.apiUrl,
|
|
38
|
+
token: value.token,
|
|
39
|
+
expiresAt: value.expiresAt,
|
|
40
|
+
statusUrl: value.statusUrl,
|
|
41
|
+
version: text('version'),
|
|
42
|
+
soort: text('soort'),
|
|
43
|
+
userId: text('userId'),
|
|
44
|
+
leest: Array.isArray(leest) ? leest.filter((name) => typeof name === 'string') : [],
|
|
45
|
+
};
|
|
46
|
+
return current;
|
|
47
|
+
}
|
|
48
|
+
/** The trial config this page was served with, or null outside a trial. */
|
|
49
|
+
export function trialConfig() {
|
|
50
|
+
return current;
|
|
51
|
+
}
|
|
52
|
+
// At evaluation, before any app code runs: the element must be gone before the app could read or dump the DOM.
|
|
53
|
+
if (typeof document !== 'undefined') {
|
|
54
|
+
readTrialConfig(document);
|
|
55
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// What the aggregate blocks (cube-kpi, cube-chart, cube-top) share: their required attributes, the measure and
|
|
2
2
|
// filter they ask with, the period picker they follow and how they write a value.
|
|
3
3
|
import { isDmpError } from '../errors.js';
|
|
4
|
+
import { AuthorError } from './author-error.js';
|
|
4
5
|
import { BlockStateError } from './block.js';
|
|
5
6
|
import { formatValue, parseMeasure } from './format.js';
|
|
6
7
|
import { blockFilter, dateFieldFor, lookupLabels, periodFilter, resolvePeriodSource, } from './query.js';
|
|
@@ -19,7 +20,7 @@ export const LABEL_CSS = `
|
|
|
19
20
|
export function requiredAttr(el, name) {
|
|
20
21
|
const value = el.getAttribute(name)?.trim() ?? '';
|
|
21
22
|
if (value === '')
|
|
22
|
-
throw new
|
|
23
|
+
throw new AuthorError(NL.invalid.missing(el.localName, name));
|
|
23
24
|
return value;
|
|
24
25
|
}
|
|
25
26
|
/** The fields an aggregate block reads: the measured one, the `by` field (without its truncation), the date field. */
|
package/dist/ui/attributes.js
CHANGED
|
@@ -66,7 +66,7 @@ export const BLOCK_ATTRIBUTES = {
|
|
|
66
66
|
'cube-calc': ['label'],
|
|
67
67
|
'cube-input': ['name', 'label', 'type', 'value', 'min', 'max', 'step', 'options'],
|
|
68
68
|
'cube-result': ['name', 'label', 'format', 'currency', 'emphasis'],
|
|
69
|
-
'cube-board': ['resource', 'status-field', 'title-field', 'fields', 'sort', 'filter', 'page-size'],
|
|
69
|
+
'cube-board': ['resource', 'status-field', 'title-field', 'fields', 'sort', 'filter', 'page-size', 'detail'],
|
|
70
70
|
'cube-column': ['value', 'label'],
|
|
71
71
|
'cube-gap-list': ['scherm'],
|
|
72
72
|
};
|
package/dist/ui/block.d.ts
CHANGED
|
@@ -13,8 +13,9 @@ export declare class BlockStateError extends Error {
|
|
|
13
13
|
constructor(state: BlockState);
|
|
14
14
|
}
|
|
15
15
|
/**
|
|
16
|
-
* What a failure looks like to the person on the page. Only
|
|
17
|
-
* those for a wrong attribute, in Dutch. A server's text
|
|
16
|
+
* What a failure looks like to the person on the page. Only an AuthorError carries its message: the blocks throw
|
|
17
|
+
* those for a wrong attribute, in Dutch. A server's text, a network failure or a client's English programmer error
|
|
18
|
+
* never does.
|
|
18
19
|
*/
|
|
19
20
|
export declare function stateFor(error: unknown): BlockState;
|
|
20
21
|
/**
|
|
@@ -29,6 +30,11 @@ export declare abstract class CubeBlockShape extends HTMLElement {
|
|
|
29
30
|
static css: string;
|
|
30
31
|
/** False for blocks that render without data (cube-state): they never take the session to render. */
|
|
31
32
|
static needsSession: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* False for a block that already shows what its own cube-data-change reports (cube-board): it does not reload on
|
|
35
|
+
* it.
|
|
36
|
+
*/
|
|
37
|
+
static reloadsOnOwnChange: boolean;
|
|
32
38
|
static observedAttributes: string[];
|
|
33
39
|
/** The block's content root inside its shadow root. */
|
|
34
40
|
readonly body: HTMLElement;
|
package/dist/ui/block.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { CubeApp } from '../app.js';
|
|
2
2
|
import { isDmpError } from '../errors.js';
|
|
3
3
|
import { BLOCK_ATTRIBUTES } from './attributes.js';
|
|
4
|
+
import { AuthorError } from './author-error.js';
|
|
4
5
|
import { clear, h } from './dom.js';
|
|
5
6
|
import { BASE_CSS, sheet } from './styles.js';
|
|
6
7
|
import { NL } from './texts.js';
|
|
@@ -13,8 +14,9 @@ export class BlockStateError extends Error {
|
|
|
13
14
|
}
|
|
14
15
|
}
|
|
15
16
|
/**
|
|
16
|
-
* What a failure looks like to the person on the page. Only
|
|
17
|
-
* those for a wrong attribute, in Dutch. A server's text
|
|
17
|
+
* What a failure looks like to the person on the page. Only an AuthorError carries its message: the blocks throw
|
|
18
|
+
* those for a wrong attribute, in Dutch. A server's text, a network failure or a client's English programmer error
|
|
19
|
+
* never does.
|
|
18
20
|
*/
|
|
19
21
|
export function stateFor(error) {
|
|
20
22
|
if (error instanceof BlockStateError)
|
|
@@ -25,7 +27,7 @@ export function stateFor(error) {
|
|
|
25
27
|
if (error.status === 403)
|
|
26
28
|
return { kind: 'forbidden', detail: null };
|
|
27
29
|
}
|
|
28
|
-
if (error instanceof
|
|
30
|
+
if (error instanceof AuthorError)
|
|
29
31
|
return { kind: 'error', detail: error.message };
|
|
30
32
|
return { kind: 'error', detail: null };
|
|
31
33
|
}
|
|
@@ -56,6 +58,7 @@ export function blockBase(scope, options = {}) {
|
|
|
56
58
|
static tag = '';
|
|
57
59
|
static css = '';
|
|
58
60
|
static needsSession = true;
|
|
61
|
+
static reloadsOnOwnChange = true;
|
|
59
62
|
static get observedAttributes() {
|
|
60
63
|
return [...(BLOCK_ATTRIBUTES[this.tag] ?? [])];
|
|
61
64
|
}
|
|
@@ -67,6 +70,8 @@ export function blockBase(scope, options = {}) {
|
|
|
67
70
|
badge = null;
|
|
68
71
|
onSessionChange = () => this.schedule();
|
|
69
72
|
onDataChange = (event) => {
|
|
73
|
+
if (event.composedPath()[0] === this && !this.constructor.reloadsOnOwnChange)
|
|
74
|
+
return;
|
|
70
75
|
const resource = event.detail?.resource;
|
|
71
76
|
const own = this.getAttribute('resource')?.trim() ?? '';
|
|
72
77
|
if (typeof resource === 'string' && own !== '' && resource.trim() === own)
|
|
@@ -153,6 +158,10 @@ export function blockBase(scope, options = {}) {
|
|
|
153
158
|
return;
|
|
154
159
|
this.badge?.remove();
|
|
155
160
|
this.showState(stateFor(error));
|
|
161
|
+
// The page shows only the generic text; the builder finds the real reason in the console.
|
|
162
|
+
if (error instanceof TypeError && !(error instanceof AuthorError)) {
|
|
163
|
+
console.warn(`${this.localName}:`, error);
|
|
164
|
+
}
|
|
156
165
|
}
|
|
157
166
|
finally {
|
|
158
167
|
// An aborted run leaves the flag to the newer run (or to disconnect).
|
|
@@ -206,7 +215,7 @@ export function blockBase(scope, options = {}) {
|
|
|
206
215
|
return fallback;
|
|
207
216
|
const value = Number(raw);
|
|
208
217
|
if (!Number.isFinite(value))
|
|
209
|
-
throw new
|
|
218
|
+
throw new AuthorError(NL.invalid.number(this.localName, name));
|
|
210
219
|
return value;
|
|
211
220
|
}
|
|
212
221
|
json(name) {
|
|
@@ -217,7 +226,7 @@ export function blockBase(scope, options = {}) {
|
|
|
217
226
|
return JSON.parse(raw);
|
|
218
227
|
}
|
|
219
228
|
catch {
|
|
220
|
-
throw new
|
|
229
|
+
throw new AuthorError(NL.invalid.json(this.localName, name));
|
|
221
230
|
}
|
|
222
231
|
}
|
|
223
232
|
emit(name, detail = null) {
|
|
@@ -126,7 +126,7 @@ export function appShellBlock(Base, scope) {
|
|
|
126
126
|
let wanted = '';
|
|
127
127
|
if (hash.startsWith(PREFIX)) {
|
|
128
128
|
try {
|
|
129
|
-
wanted = decodeURIComponent(hash.slice(PREFIX.length));
|
|
129
|
+
wanted = decodeURIComponent(hash.slice(PREFIX.length).split('?')[0] ?? '');
|
|
130
130
|
}
|
|
131
131
|
catch {
|
|
132
132
|
wanted = '';
|
|
@@ -3,6 +3,6 @@ import type { BlockScope } from '../define.js';
|
|
|
3
3
|
/**
|
|
4
4
|
* `<cube-board>`: the rows of a resource as cards in a column per value of a status field. A card moves by drag
|
|
5
5
|
* and drop or by a button per other column ("Verplaats naar …"): at once on screen, then saved; a refusal puts it
|
|
6
|
-
* back.
|
|
6
|
+
* back. A click on a card, or Enter or Space on it, chooses it: cube-select, and `detail` shows it.
|
|
7
7
|
*/
|
|
8
8
|
export declare function boardBlock(Base: BlockClass, scope: BlockScope): BlockClass;
|
package/dist/ui/blocks/board.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isDmpError } from '../../errors.js';
|
|
2
2
|
import { CARD_CSS, requiredAttr, wholeAttr } from '../aggregate-block.js';
|
|
3
|
+
import { AuthorError } from '../author-error.js';
|
|
3
4
|
import { BlockStateError, stateFor } from '../block.js';
|
|
4
5
|
import { clear, h } from '../dom.js';
|
|
5
6
|
import { columnsFor, fieldList, readFieldConfigs } from '../fields.js';
|
|
@@ -14,8 +15,11 @@ position:relative;overflow-x:auto}
|
|
|
14
15
|
section{min-width:0}
|
|
15
16
|
h3{margin:0 0 .5rem;font-size:1rem}
|
|
16
17
|
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
|
+
border-radius:var(--cube-radius-small);background:var(--cube-color-bg);overflow-wrap:anywhere;
|
|
19
|
+
cursor:pointer}
|
|
18
20
|
article[draggable=true]{cursor:grab}
|
|
21
|
+
article.chosen{border-color:var(--cube-color-primary);background:var(--cube-color-primary-subtle);
|
|
22
|
+
box-shadow:inset 4px 0 0 var(--cube-color-primary)}
|
|
19
23
|
.title{margin:0;font-weight:600}
|
|
20
24
|
dl{display:grid;grid-template-columns:auto 1fr;gap:0 .5rem;margin:.25rem 0 0;font-size:.875rem}
|
|
21
25
|
dd{margin:0;min-width:0}
|
|
@@ -26,6 +30,38 @@ dd{margin:0;min-width:0}
|
|
|
26
30
|
.not-saved{display:inline-block;margin-bottom:.75rem}
|
|
27
31
|
`;
|
|
28
32
|
const keyOf = (row) => String(row.id);
|
|
33
|
+
/** As the server reads `sort`: comma-separated, a leading - for descending. */
|
|
34
|
+
function sortKeys(sort) {
|
|
35
|
+
return sort.split(',').map((item) => item.trim()).filter((item) => item !== '').map((item) => ({
|
|
36
|
+
field: item.replace(/^[-+]/, ''),
|
|
37
|
+
descending: item.startsWith('-'),
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
/** As the preview sorts: nulls first, numbers as numbers, booleans as 0/1, everything else as text. */
|
|
41
|
+
function compare(a, b) {
|
|
42
|
+
if (a == null || b == null)
|
|
43
|
+
return a == null ? (b == null ? 0 : -1) : 1;
|
|
44
|
+
if (typeof a === 'number' && typeof b === 'number')
|
|
45
|
+
return a - b;
|
|
46
|
+
if (typeof a === 'boolean' || typeof b === 'boolean') {
|
|
47
|
+
return Number(a === true || a === 1 || a === '1') - Number(b === true || b === 1 || b === '1');
|
|
48
|
+
}
|
|
49
|
+
const left = String(a);
|
|
50
|
+
const right = String(b);
|
|
51
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
52
|
+
}
|
|
53
|
+
function sortOrder(order, a, b) {
|
|
54
|
+
for (const { field, descending } of order) {
|
|
55
|
+
const result = compare(a[field], b[field]);
|
|
56
|
+
if (result !== 0)
|
|
57
|
+
return descending ? -result : result;
|
|
58
|
+
}
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
/** A column value as the status field holds it: a number for a numeric field. */
|
|
62
|
+
function statusValue(loaded, value) {
|
|
63
|
+
return loaded.numeric && Number.isFinite(Number(value)) ? Number(value) : value;
|
|
64
|
+
}
|
|
29
65
|
/** Why a move was refused, in words for the person; never the server's or the browser's own text. */
|
|
30
66
|
function reason(error) {
|
|
31
67
|
if (isDmpError(error) && (error.status === 409 || error.status === 412))
|
|
@@ -36,13 +72,14 @@ function reason(error) {
|
|
|
36
72
|
/**
|
|
37
73
|
* `<cube-board>`: the rows of a resource as cards in a column per value of a status field. A card moves by drag
|
|
38
74
|
* and drop or by a button per other column ("Verplaats naar …"): at once on screen, then saved; a refusal puts it
|
|
39
|
-
* back.
|
|
75
|
+
* back. A click on a card, or Enter or Space on it, chooses it: cube-select, and `detail` shows it.
|
|
40
76
|
*/
|
|
41
77
|
export function boardBlock(Base, scope) {
|
|
42
78
|
const doc = scope.document;
|
|
43
79
|
class CubeBoard extends Base {
|
|
44
80
|
static tag = 'cube-board';
|
|
45
81
|
static css = CSS;
|
|
82
|
+
static reloadsOnOwnChange = false;
|
|
46
83
|
/** Outside the body, so the reload after a refusal keeps the reason on screen. */
|
|
47
84
|
alert = h(doc, 'div', { class: 'alert', role: 'alert', hidden: true });
|
|
48
85
|
loaded = null;
|
|
@@ -50,13 +87,14 @@ export function boardBlock(Base, scope) {
|
|
|
50
87
|
/** Moves queued or on their way: a reload waits for them, or it would show a card where it no longer is. */
|
|
51
88
|
pending = 0;
|
|
52
89
|
stale = false;
|
|
53
|
-
/** Set while its own cube-data-change is under way: the board already shows what it saved. */
|
|
54
|
-
own = false;
|
|
55
90
|
dragged = null;
|
|
56
91
|
/** The card to focus once the board is drawn again. */
|
|
57
92
|
focusKey = null;
|
|
58
93
|
attempt = null;
|
|
59
94
|
ids = 0;
|
|
95
|
+
/** The resource the choice belongs to, and the id of the chosen card. */
|
|
96
|
+
shownResource = null;
|
|
97
|
+
chosen = null;
|
|
60
98
|
constructor() {
|
|
61
99
|
super();
|
|
62
100
|
this.shadowRoot?.insertBefore(this.alert, this.body);
|
|
@@ -66,8 +104,6 @@ export function boardBlock(Base, scope) {
|
|
|
66
104
|
return loaded === null ? [] : [loaded.status, loaded.title, ...loaded.fields.map((field) => field.name)];
|
|
67
105
|
}
|
|
68
106
|
refresh() {
|
|
69
|
-
if (this.own)
|
|
70
|
-
return Promise.resolve();
|
|
71
107
|
if (this.pending > 0) {
|
|
72
108
|
this.stale = true;
|
|
73
109
|
return Promise.resolve();
|
|
@@ -85,17 +121,31 @@ export function boardBlock(Base, scope) {
|
|
|
85
121
|
const resource = requiredAttr(this, 'resource');
|
|
86
122
|
const status = requiredAttr(this, 'status-field');
|
|
87
123
|
const title = requiredAttr(this, 'title-field');
|
|
124
|
+
if (resource !== this.shownResource) {
|
|
125
|
+
this.chosen = null;
|
|
126
|
+
this.shownResource = resource;
|
|
127
|
+
}
|
|
128
|
+
this.detailTarget();
|
|
88
129
|
const filter = blockFilter(this);
|
|
89
130
|
const size = Math.min(wholeAttr(this, 'page-size', 1, 50), 200);
|
|
90
131
|
const sort = this.attr('sort')?.trim() ?? '';
|
|
132
|
+
const order = sortKeys(sort);
|
|
91
133
|
const schema = await session.schema(resource);
|
|
92
134
|
if (signal.aborted)
|
|
93
135
|
return;
|
|
94
136
|
const field = schema?.fields.find((candidate) => candidate.name === status);
|
|
137
|
+
// A card cannot be updated without its id; a resource without one gives a read-only board.
|
|
138
|
+
const hasId = schema === null || schema.fields.some((candidate) => candidate.name === 'id');
|
|
95
139
|
const names = fieldList(this.attr('fields'));
|
|
96
140
|
const fields = names === null ? [] : columnsFor(schema, names, readFieldConfigs(this));
|
|
97
141
|
const query = {
|
|
98
|
-
fields: [...new Set([
|
|
142
|
+
fields: [...new Set([
|
|
143
|
+
...(hasId ? ['id'] : []),
|
|
144
|
+
title,
|
|
145
|
+
status,
|
|
146
|
+
...fields.map((column) => column.name),
|
|
147
|
+
...order.map((key) => key.field),
|
|
148
|
+
])],
|
|
99
149
|
page: { size },
|
|
100
150
|
};
|
|
101
151
|
if (sort !== '')
|
|
@@ -117,7 +167,9 @@ export function boardBlock(Base, scope) {
|
|
|
117
167
|
title,
|
|
118
168
|
fields,
|
|
119
169
|
columns: answers,
|
|
120
|
-
canMove: schema !== null && schema.actions.includes('update') && field?.readonly === false,
|
|
170
|
+
canMove: hasId && schema !== null && schema.actions.includes('update') && field?.readonly === false,
|
|
171
|
+
order,
|
|
172
|
+
numeric: field?.type === 'integer' || field?.type === 'number',
|
|
121
173
|
};
|
|
122
174
|
this.loaded = loaded;
|
|
123
175
|
this.tracks.clear();
|
|
@@ -150,6 +202,7 @@ export function boardBlock(Base, scope) {
|
|
|
150
202
|
const more = column.total - column.rows.length;
|
|
151
203
|
return h(doc, 'section', {
|
|
152
204
|
class: 'card',
|
|
205
|
+
role: 'group',
|
|
153
206
|
'aria-labelledby': heading,
|
|
154
207
|
ondragover: (event) => {
|
|
155
208
|
if (loaded.canMove && this.dragged !== null)
|
|
@@ -169,11 +222,28 @@ export function boardBlock(Base, scope) {
|
|
|
169
222
|
card(loaded, column, row, cards) {
|
|
170
223
|
const key = keyOf(row);
|
|
171
224
|
const title = `b${++this.ids}`;
|
|
225
|
+
const chosen = row.id != null && key === this.chosen;
|
|
172
226
|
const card = h(doc, 'article', {
|
|
173
227
|
tabindex: '0',
|
|
174
228
|
draggable: loaded.canMove ? 'true' : 'false',
|
|
175
229
|
'data-id': key,
|
|
176
230
|
'aria-labelledby': title,
|
|
231
|
+
class: chosen ? 'chosen' : null,
|
|
232
|
+
'aria-current': chosen ? 'true' : null,
|
|
233
|
+
// A move button is not a choice.
|
|
234
|
+
onclick: (event) => {
|
|
235
|
+
if (event.target.closest('button') === null)
|
|
236
|
+
this.select(loaded, row);
|
|
237
|
+
},
|
|
238
|
+
onkeydown: ((event) => {
|
|
239
|
+
if (event.target !== card || event.altKey || event.ctrlKey || event.metaKey)
|
|
240
|
+
return;
|
|
241
|
+
if (event.key !== 'Enter' && event.key !== ' ')
|
|
242
|
+
return;
|
|
243
|
+
// Space would scroll the page.
|
|
244
|
+
event.preventDefault();
|
|
245
|
+
this.select(loaded, row);
|
|
246
|
+
}),
|
|
177
247
|
ondragstart: (event) => {
|
|
178
248
|
this.dragged = key;
|
|
179
249
|
event.dataTransfer?.setData('text/plain', key);
|
|
@@ -200,7 +270,47 @@ export function boardBlock(Base, scope) {
|
|
|
200
270
|
cards.set(key, card);
|
|
201
271
|
return card;
|
|
202
272
|
}
|
|
203
|
-
/**
|
|
273
|
+
/** The element `detail` names, or null without one; an invalid selector is the author's error. */
|
|
274
|
+
detailTarget() {
|
|
275
|
+
const selector = this.attr('detail')?.trim() ?? '';
|
|
276
|
+
if (selector === '')
|
|
277
|
+
return null;
|
|
278
|
+
try {
|
|
279
|
+
return doc.querySelector(selector);
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
throw new AuthorError(NL.invalid.selector(this.localName, 'detail', selector));
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
select(loaded, row) {
|
|
286
|
+
const id = row.id ?? null;
|
|
287
|
+
this.chosen = id === null ? null : String(id);
|
|
288
|
+
for (const card of Array.from(this.body.querySelectorAll('article'))) {
|
|
289
|
+
const chosen = card.getAttribute('data-id') === this.chosen;
|
|
290
|
+
card.classList.toggle('chosen', chosen);
|
|
291
|
+
if (chosen)
|
|
292
|
+
card.setAttribute('aria-current', 'true');
|
|
293
|
+
else
|
|
294
|
+
card.removeAttribute('aria-current');
|
|
295
|
+
}
|
|
296
|
+
this.emit('cube-select', { resource: loaded.resource, id, row });
|
|
297
|
+
if (id === null)
|
|
298
|
+
return;
|
|
299
|
+
let target = null;
|
|
300
|
+
try {
|
|
301
|
+
target = this.detailTarget();
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
// Checked on load; an attribute changed since is reported the same way.
|
|
305
|
+
this.showState(stateFor(error));
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
target?.setAttribute('record-id', String(id));
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Takes the card out of its column and puts it in `to`: at its place by `sort`, else on top; null when it is
|
|
312
|
+
* not on the board.
|
|
313
|
+
*/
|
|
204
314
|
place(loaded, key, to) {
|
|
205
315
|
const target = loaded.columns.find((column) => column.value === to);
|
|
206
316
|
for (const column of loaded.columns) {
|
|
@@ -210,7 +320,15 @@ export function boardBlock(Base, scope) {
|
|
|
210
320
|
continue;
|
|
211
321
|
column.rows.splice(index, 1);
|
|
212
322
|
column.total--;
|
|
213
|
-
|
|
323
|
+
// The board does not reload after its own move: the row as listed must say where it is now.
|
|
324
|
+
row[loaded.status] = statusValue(loaded, to);
|
|
325
|
+
if (loaded.order.length === 0) {
|
|
326
|
+
target.rows.unshift(row);
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
const after = target.rows.findIndex((other) => sortOrder(loaded.order, other, row) > 0);
|
|
330
|
+
target.rows.splice(after === -1 ? target.rows.length : after, 0, row);
|
|
331
|
+
}
|
|
214
332
|
target.total++;
|
|
215
333
|
return { row, from: column.value };
|
|
216
334
|
}
|
|
@@ -244,7 +362,7 @@ export function boardBlock(Base, scope) {
|
|
|
244
362
|
}
|
|
245
363
|
async send(loaded, track, row, from, to) {
|
|
246
364
|
const key = keyOf(row);
|
|
247
|
-
const body = { [loaded.status]: to };
|
|
365
|
+
const body = { [loaded.status]: statusValue(loaded, to) };
|
|
248
366
|
const attempt = attemptFor(this.attempt, JSON.stringify([loaded.resource, key, body]));
|
|
249
367
|
this.attempt = attempt;
|
|
250
368
|
try {
|
|
@@ -262,12 +380,7 @@ export function boardBlock(Base, scope) {
|
|
|
262
380
|
this.attempt = null;
|
|
263
381
|
track.confirmed = to;
|
|
264
382
|
this.emit('cube-move', { resource: loaded.resource, id: row.id, from, to });
|
|
265
|
-
this.own = true;
|
|
266
383
|
this.emit('cube-data-change', { resource: loaded.resource });
|
|
267
|
-
// After the reload the event scheduled, which is a microtask queued before this one.
|
|
268
|
-
queueMicrotask(() => {
|
|
269
|
-
this.own = false;
|
|
270
|
-
});
|
|
271
384
|
}
|
|
272
385
|
catch (error) {
|
|
273
386
|
// The DMP answered, so the attempt is over; without an answer the same move may go again with its key.
|