@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
package/dist/ui/theme.js
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { THEME_TEXTS } from './texts.js';
|
|
2
|
+
export const TOKEN_NAMES = [
|
|
3
|
+
'color-primary',
|
|
4
|
+
'color-primary-hover',
|
|
5
|
+
'color-primary-subtle',
|
|
6
|
+
'color-primary-text',
|
|
7
|
+
'color-on-primary',
|
|
8
|
+
'color-accent',
|
|
9
|
+
'color-on-accent',
|
|
10
|
+
'color-text',
|
|
11
|
+
'color-text-muted',
|
|
12
|
+
'color-bg',
|
|
13
|
+
'color-surface',
|
|
14
|
+
'color-border',
|
|
15
|
+
'color-focus',
|
|
16
|
+
'color-danger',
|
|
17
|
+
'color-success',
|
|
18
|
+
'color-warning',
|
|
19
|
+
'color-example-bg',
|
|
20
|
+
'color-example-text',
|
|
21
|
+
'color-series-1',
|
|
22
|
+
'color-series-2',
|
|
23
|
+
'color-series-3',
|
|
24
|
+
'color-series-4',
|
|
25
|
+
'color-series-5',
|
|
26
|
+
'color-series-6',
|
|
27
|
+
'radius',
|
|
28
|
+
'radius-small',
|
|
29
|
+
'font-family',
|
|
30
|
+
];
|
|
31
|
+
export const NEUTRAL_SOURCE = { primary_colour: '#475467', corners: 'normaal', font: 'system' };
|
|
32
|
+
export const FONTS = {
|
|
33
|
+
inter: {
|
|
34
|
+
family: 'Inter',
|
|
35
|
+
files: [{ file: 'inter.woff2', weight: '100 900', style: 'normal' }],
|
|
36
|
+
},
|
|
37
|
+
'albert-sans': {
|
|
38
|
+
family: 'Albert Sans',
|
|
39
|
+
files: [{ file: 'albert-sans.woff2', weight: '100 900', style: 'normal' }],
|
|
40
|
+
},
|
|
41
|
+
'source-sans-3': {
|
|
42
|
+
family: 'Source Sans 3',
|
|
43
|
+
files: [{ file: 'source-sans-3.woff2', weight: '200 900', style: 'normal' }],
|
|
44
|
+
},
|
|
45
|
+
'nunito-sans': {
|
|
46
|
+
family: 'Nunito Sans',
|
|
47
|
+
files: [{ file: 'nunito-sans.woff2', weight: '200 1000', style: 'normal' }],
|
|
48
|
+
},
|
|
49
|
+
'ibm-plex-sans': {
|
|
50
|
+
family: 'IBM Plex Sans',
|
|
51
|
+
files: [{ file: 'ibm-plex-sans.woff2', weight: '100 700', style: 'normal' }],
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
const FONT_FALLBACK = 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif';
|
|
55
|
+
const SURFACE = '#f6f8fa';
|
|
56
|
+
function parseHex(value) {
|
|
57
|
+
if (typeof value !== 'string')
|
|
58
|
+
return null;
|
|
59
|
+
const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(value);
|
|
60
|
+
if (!m || m[1] === undefined)
|
|
61
|
+
return null;
|
|
62
|
+
let h = m[1].toLowerCase();
|
|
63
|
+
if (h.length === 3)
|
|
64
|
+
h = [...h].map((c) => c + c).join('');
|
|
65
|
+
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
|
|
66
|
+
}
|
|
67
|
+
function toHex(rgb) {
|
|
68
|
+
return '#' + rgb.map((c) => Math.max(0, Math.min(255, c)).toString(16).padStart(2, '0')).join('');
|
|
69
|
+
}
|
|
70
|
+
function luminance(rgb) {
|
|
71
|
+
const [r, g, b] = rgb.map((c) => {
|
|
72
|
+
const s = c / 255;
|
|
73
|
+
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
|
74
|
+
});
|
|
75
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
76
|
+
}
|
|
77
|
+
export function contrastRatio(a, b) {
|
|
78
|
+
const ra = parseHex(a);
|
|
79
|
+
const rb = parseHex(b);
|
|
80
|
+
if (!ra || !rb)
|
|
81
|
+
return 1;
|
|
82
|
+
const la = luminance(ra);
|
|
83
|
+
const lb = luminance(rb);
|
|
84
|
+
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
|
|
85
|
+
}
|
|
86
|
+
function mix(a, b, t) {
|
|
87
|
+
const ra = parseHex(a);
|
|
88
|
+
const rb = parseHex(b);
|
|
89
|
+
return toHex(ra.map((c, i) => Math.round(c * (1 - t) + rb[i] * t)));
|
|
90
|
+
}
|
|
91
|
+
function on(c) {
|
|
92
|
+
return contrastRatio(c, '#ffffff') >= contrastRatio(c, '#000000') ? '#ffffff' : '#000000';
|
|
93
|
+
}
|
|
94
|
+
function rotateHue(hex, degrees) {
|
|
95
|
+
const [r, g, b] = parseHex(hex).map((c) => c / 255);
|
|
96
|
+
const max = Math.max(r, g, b);
|
|
97
|
+
const min = Math.min(r, g, b);
|
|
98
|
+
const l = (max + min) / 2;
|
|
99
|
+
const d = max - min;
|
|
100
|
+
let h = 0;
|
|
101
|
+
let s = 0;
|
|
102
|
+
if (d !== 0) {
|
|
103
|
+
s = d / (1 - Math.abs(2 * l - 1));
|
|
104
|
+
if (max === r)
|
|
105
|
+
h = ((g - b) / d) % 6;
|
|
106
|
+
else if (max === g)
|
|
107
|
+
h = (b - r) / d + 2;
|
|
108
|
+
else
|
|
109
|
+
h = (r - g) / d + 4;
|
|
110
|
+
h *= 60;
|
|
111
|
+
}
|
|
112
|
+
h = (((h + degrees) % 360) + 360) % 360;
|
|
113
|
+
const c = (1 - Math.abs(2 * l - 1)) * s;
|
|
114
|
+
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
|
115
|
+
const m = l - c / 2;
|
|
116
|
+
const sector = Math.floor(h / 60) % 6;
|
|
117
|
+
const [r1, g1, b1] = [
|
|
118
|
+
[c, x, 0],
|
|
119
|
+
[x, c, 0],
|
|
120
|
+
[0, c, x],
|
|
121
|
+
[0, x, c],
|
|
122
|
+
[x, 0, c],
|
|
123
|
+
[c, 0, x],
|
|
124
|
+
][sector];
|
|
125
|
+
return toHex([r1, g1, b1].map((v) => Math.round((v + m) * 255)));
|
|
126
|
+
}
|
|
127
|
+
function normalise(value) {
|
|
128
|
+
const rgb = parseHex(value);
|
|
129
|
+
return rgb ? toHex(rgb) : null;
|
|
130
|
+
}
|
|
131
|
+
export function deriveTheme(source) {
|
|
132
|
+
const warnings = [];
|
|
133
|
+
let p = normalise(source.primary_colour);
|
|
134
|
+
if (p === null) {
|
|
135
|
+
p = normalise(NEUTRAL_SOURCE.primary_colour);
|
|
136
|
+
warnings.push({ code: 'invalid-primary', message: THEME_TEXTS.invalidPrimary });
|
|
137
|
+
}
|
|
138
|
+
let accent = null;
|
|
139
|
+
if (source.accent_colour !== undefined && source.accent_colour !== null) {
|
|
140
|
+
accent = normalise(source.accent_colour);
|
|
141
|
+
if (accent === null)
|
|
142
|
+
warnings.push({ code: 'invalid-accent', message: THEME_TEXTS.invalidAccent });
|
|
143
|
+
}
|
|
144
|
+
accent ??= rotateHue(p, 330);
|
|
145
|
+
if (contrastRatio(p, '#ffffff') < 3)
|
|
146
|
+
warnings.push({ code: 'primary-light', message: THEME_TEXTS.primaryLight });
|
|
147
|
+
// Primary text sits on the surface and on the subtle primary (secondary-button hover, badges): AA on both.
|
|
148
|
+
const subtle = mix(p, '#ffffff', 0.9);
|
|
149
|
+
let primaryText = p;
|
|
150
|
+
for (let i = 0; i <= 20; i++) {
|
|
151
|
+
primaryText = mix(p, '#000000', i / 20);
|
|
152
|
+
if (contrastRatio(primaryText, SURFACE) >= 4.5 && contrastRatio(primaryText, subtle) >= 4.5)
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
if (primaryText !== p)
|
|
156
|
+
warnings.push({ code: 'primary-darkened-for-text', message: THEME_TEXTS.primaryDarkened });
|
|
157
|
+
const onPrimary = on(p);
|
|
158
|
+
const radii = {
|
|
159
|
+
scherp: ['0px', '0px'],
|
|
160
|
+
normaal: ['6px', '4px'],
|
|
161
|
+
rond: ['12px', '8px'],
|
|
162
|
+
};
|
|
163
|
+
const corners = typeof source.corners === 'string' && Object.hasOwn(radii, source.corners) ? source.corners : 'normaal';
|
|
164
|
+
const [radius, radiusSmall] = radii[corners];
|
|
165
|
+
const font = typeof source.font === 'string' && Object.hasOwn(FONTS, source.font) ? source.font : 'system';
|
|
166
|
+
const fontFamily = font === 'system' ? FONT_FALLBACK : `"${FONTS[font].family}", ${FONT_FALLBACK}`;
|
|
167
|
+
const tokens = {
|
|
168
|
+
'color-primary': p,
|
|
169
|
+
'color-primary-hover': mix(p, onPrimary === '#ffffff' ? '#000000' : '#ffffff', 0.15),
|
|
170
|
+
'color-primary-subtle': subtle,
|
|
171
|
+
'color-primary-text': primaryText,
|
|
172
|
+
'color-on-primary': onPrimary,
|
|
173
|
+
'color-accent': accent,
|
|
174
|
+
'color-on-accent': on(accent),
|
|
175
|
+
'color-text': '#1f2328',
|
|
176
|
+
'color-text-muted': '#59636e',
|
|
177
|
+
'color-bg': '#ffffff',
|
|
178
|
+
'color-surface': SURFACE,
|
|
179
|
+
'color-border': '#d1d9e0',
|
|
180
|
+
'color-focus': primaryText,
|
|
181
|
+
'color-danger': '#b42318',
|
|
182
|
+
'color-success': '#067647',
|
|
183
|
+
'color-warning': '#b54708',
|
|
184
|
+
'color-example-bg': '#fef0c7',
|
|
185
|
+
'color-example-text': '#7a2e0e',
|
|
186
|
+
'color-series-1': p,
|
|
187
|
+
'color-series-2': accent,
|
|
188
|
+
'color-series-3': '#0e9384',
|
|
189
|
+
'color-series-4': '#dc6803',
|
|
190
|
+
'color-series-5': '#7a5af8',
|
|
191
|
+
'color-series-6': '#475467',
|
|
192
|
+
radius,
|
|
193
|
+
'radius-small': radiusSmall,
|
|
194
|
+
'font-family': fontFamily,
|
|
195
|
+
};
|
|
196
|
+
return { tokens, warnings };
|
|
197
|
+
}
|
|
198
|
+
const COLOR_TOKEN = /^#[0-9a-f]{6}$/i;
|
|
199
|
+
const RADIUS_TOKEN = /^\d+(\.\d+)?(px|rem)$/;
|
|
200
|
+
const INLINE_IMAGE = /^data:image\/(svg\+xml|png);/;
|
|
201
|
+
// `/*` could swallow the rest of the stylesheet; the brief's list covers the other ways out of a declaration.
|
|
202
|
+
const UNSAFE_FONT_FAMILY = /[;{}<>\\\u0000-\u001f]|\/\*/;
|
|
203
|
+
const UNSAFE_URL = /["'()\\\s]/;
|
|
204
|
+
// A comma-separated list of quoted names and bare words: an unbalanced quote would run on through the stylesheet.
|
|
205
|
+
const FONT_FAMILY_ITEM = String.raw `(?:\s*"[^"]*"\s*|\s*'[^']*'\s*|[^"',]+)`;
|
|
206
|
+
const FONT_FAMILY_LIST = new RegExp(`^${FONT_FAMILY_ITEM}(?:,${FONT_FAMILY_ITEM})*$`);
|
|
207
|
+
function isRecord(value) {
|
|
208
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
209
|
+
}
|
|
210
|
+
function validToken(name, value) {
|
|
211
|
+
if (typeof value !== 'string')
|
|
212
|
+
return false;
|
|
213
|
+
if (name.startsWith('color-'))
|
|
214
|
+
return COLOR_TOKEN.test(value);
|
|
215
|
+
if (name.startsWith('radius'))
|
|
216
|
+
return RADIUS_TOKEN.test(value);
|
|
217
|
+
return (value.length > 0 && value.length <= 200 && !UNSAFE_FONT_FAMILY.test(value) && FONT_FAMILY_LIST.test(value));
|
|
218
|
+
}
|
|
219
|
+
function safeUrl(value, baseUrl, allowInline) {
|
|
220
|
+
if (typeof value !== 'string' || value === '')
|
|
221
|
+
return null;
|
|
222
|
+
if (allowInline && INLINE_IMAGE.test(value))
|
|
223
|
+
return UNSAFE_URL.test(value) ? null : value;
|
|
224
|
+
try {
|
|
225
|
+
const base = new URL(baseUrl);
|
|
226
|
+
const url = new URL(value, base);
|
|
227
|
+
const sameOrigin = (url.protocol === 'https:' || url.protocol === 'http:') && url.origin === base.origin;
|
|
228
|
+
if (sameOrigin && !UNSAFE_URL.test(url.href))
|
|
229
|
+
return url.href;
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
// An unparsable address is simply dropped.
|
|
233
|
+
}
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
function resolveFontFiles(files, baseUrl) {
|
|
237
|
+
const resolved = [];
|
|
238
|
+
for (const entry of Array.isArray(files) ? files : []) {
|
|
239
|
+
if (!isRecord(entry))
|
|
240
|
+
continue;
|
|
241
|
+
const src = safeUrl(entry.src, baseUrl, false);
|
|
242
|
+
if (src === null)
|
|
243
|
+
continue;
|
|
244
|
+
const weight = typeof entry.weight === 'string' && /^\d{1,4}( \d{1,4})?$/.test(entry.weight) ? entry.weight : '400';
|
|
245
|
+
resolved.push({ src, weight, style: entry.style === 'italic' ? 'italic' : 'normal' });
|
|
246
|
+
}
|
|
247
|
+
return resolved;
|
|
248
|
+
}
|
|
249
|
+
export function resolveTheme(input, baseUrl) {
|
|
250
|
+
const raw = input;
|
|
251
|
+
let source = NEUTRAL_SOURCE;
|
|
252
|
+
let tokenOverrides = null;
|
|
253
|
+
let fontFiles = null;
|
|
254
|
+
if (isRecord(raw)) {
|
|
255
|
+
if (Object.hasOwn(raw, 'source') && isRecord(raw.source)) {
|
|
256
|
+
source = raw.source;
|
|
257
|
+
tokenOverrides = Object.hasOwn(raw, 'tokens') ? raw.tokens : null;
|
|
258
|
+
fontFiles = Object.hasOwn(raw, 'font_files') ? raw.font_files : null;
|
|
259
|
+
}
|
|
260
|
+
else if (Object.hasOwn(raw, 'primary_colour')) {
|
|
261
|
+
source = raw;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const { tokens, warnings } = deriveTheme(source);
|
|
265
|
+
if (isRecord(tokenOverrides)) {
|
|
266
|
+
for (const name of TOKEN_NAMES) {
|
|
267
|
+
if (Object.hasOwn(tokenOverrides, name) && validToken(name, tokenOverrides[name])) {
|
|
268
|
+
tokens[name] = tokenOverrides[name];
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const font = typeof source.font === 'string' && Object.hasOwn(FONTS, source.font)
|
|
273
|
+
? source.font
|
|
274
|
+
: null;
|
|
275
|
+
let files = resolveFontFiles(fontFiles, baseUrl);
|
|
276
|
+
if (font !== null && files.length === 0 && !Array.isArray(fontFiles)) {
|
|
277
|
+
files = FONTS[font].files.flatMap((entry) => {
|
|
278
|
+
const src = safeUrl('cube-theme/' + entry.file, baseUrl, false);
|
|
279
|
+
return src === null ? [] : [{ src, weight: entry.weight, style: entry.style }];
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
return {
|
|
283
|
+
tokens,
|
|
284
|
+
logo: safeUrl(source.logo, baseUrl, true),
|
|
285
|
+
logoDark: safeUrl(source.logo_dark, baseUrl, true),
|
|
286
|
+
fontFamily: font === null ? null : FONTS[font].family,
|
|
287
|
+
fontFiles: files,
|
|
288
|
+
warnings,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
export function themeCss(theme) {
|
|
292
|
+
// Zero specificity: an app's own `:root { --cube-... }` overrides a token although this sheet comes later.
|
|
293
|
+
return ':where(:root){' + TOKEN_NAMES.map((name) => `--cube-${name}:${theme.tokens[name]}`).join(';') + '}';
|
|
294
|
+
}
|
|
295
|
+
let current = null;
|
|
296
|
+
const listeners = new Set();
|
|
297
|
+
export function currentTheme() {
|
|
298
|
+
current ??= resolveTheme(null, 'http://localhost/');
|
|
299
|
+
return current;
|
|
300
|
+
}
|
|
301
|
+
export function setTheme(theme) {
|
|
302
|
+
current = theme;
|
|
303
|
+
for (const listener of [...listeners])
|
|
304
|
+
listener(theme);
|
|
305
|
+
}
|
|
306
|
+
export function onTheme(listener) {
|
|
307
|
+
listeners.add(listener);
|
|
308
|
+
return () => {
|
|
309
|
+
listeners.delete(listener);
|
|
310
|
+
};
|
|
311
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { TrialConfig } from '../trial.js';
|
|
2
|
+
/** What the banner needs from the window: the page's own, or a test's with fake timers and a fake fetch. */
|
|
3
|
+
export interface TrialScope {
|
|
4
|
+
document: Document;
|
|
5
|
+
CSSStyleSheet: typeof CSSStyleSheet;
|
|
6
|
+
setTimeout: typeof setTimeout;
|
|
7
|
+
clearTimeout: typeof clearTimeout;
|
|
8
|
+
fetch: typeof fetch;
|
|
9
|
+
location: Pick<Location, 'assign' | 'reload'>;
|
|
10
|
+
/** Without it a status request has no timeout. */
|
|
11
|
+
AbortController?: typeof AbortController;
|
|
12
|
+
/** Default `Date.now`. */
|
|
13
|
+
now?: () => number;
|
|
14
|
+
}
|
|
15
|
+
/** Spec section 7: every 5 s while the page is visible, every 60 s while it is hidden. */
|
|
16
|
+
export declare const POLL_VISIBLE_MS = 5000;
|
|
17
|
+
export declare const POLL_HIDDEN_MS = 60000;
|
|
18
|
+
/** A status request that hangs this long is given up as a network error, so the polling goes on. */
|
|
19
|
+
export declare const STATUS_TIMEOUT_MS = 10000;
|
|
20
|
+
/**
|
|
21
|
+
* Puts `cube-trial-banner` at the top of `<body>` and polls the trial's status: it says what the page is, ends when
|
|
22
|
+
* the trial does, and follows a newer version (by itself only when that one reads nothing more).
|
|
23
|
+
* Returns a function that stops the polling.
|
|
24
|
+
*/
|
|
25
|
+
export declare function showTrialBanner(scope: TrialScope, config: TrialConfig): () => void;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { clear, h } from './dom.js';
|
|
2
|
+
import { BASE_CSS } from './styles.js';
|
|
3
|
+
import { NL } from './texts.js';
|
|
4
|
+
/** Spec section 7: every 5 s while the page is visible, every 60 s while it is hidden. */
|
|
5
|
+
export const POLL_VISIBLE_MS = 5000;
|
|
6
|
+
export const POLL_HIDDEN_MS = 60000;
|
|
7
|
+
/** A status request that hangs this long is given up as a network error, so the polling goes on. */
|
|
8
|
+
export const STATUS_TIMEOUT_MS = 10000;
|
|
9
|
+
const CSS = `
|
|
10
|
+
.banner{display:flex;flex-wrap:wrap;align-items:center;gap:.75rem;padding:.5rem 1rem;
|
|
11
|
+
border-bottom:1px solid var(--cube-color-border);border-left:4px solid var(--cube-color-warning);
|
|
12
|
+
background:var(--cube-color-surface);font-size:.875rem}
|
|
13
|
+
.banner p{margin:0;flex:1 1 16rem}
|
|
14
|
+
.banner[data-state=expired],.banner[data-state=closed]{border-left-color:var(--cube-color-danger)}
|
|
15
|
+
`;
|
|
16
|
+
/**
|
|
17
|
+
* Puts `cube-trial-banner` at the top of `<body>` and polls the trial's status: it says what the page is, ends when
|
|
18
|
+
* the trial does, and follows a newer version (by itself only when that one reads nothing more).
|
|
19
|
+
* Returns a function that stops the polling.
|
|
20
|
+
*/
|
|
21
|
+
export function showTrialBanner(scope, config) {
|
|
22
|
+
const doc = scope.document;
|
|
23
|
+
const now = scope.now ?? Date.now;
|
|
24
|
+
const expiresAt = Date.parse(config.expiresAt);
|
|
25
|
+
const known = Number.isFinite(expiresAt);
|
|
26
|
+
const time = known
|
|
27
|
+
? new Intl.DateTimeFormat('nl-NL', { hour: '2-digit', minute: '2-digit' }).format(new Date(expiresAt))
|
|
28
|
+
: null;
|
|
29
|
+
const host = doc.createElement('cube-trial-banner');
|
|
30
|
+
const root = host.attachShadow({ mode: 'open' });
|
|
31
|
+
const sheets = [BASE_CSS, CSS].map((css) => {
|
|
32
|
+
const sheet = new scope.CSSStyleSheet();
|
|
33
|
+
sheet.replaceSync(css);
|
|
34
|
+
return sheet;
|
|
35
|
+
});
|
|
36
|
+
const message = h(doc, 'p', {}, NL.trial.banner(time, config.leest));
|
|
37
|
+
const region = h(doc, 'div', { class: 'banner', role: 'status', 'aria-live': 'polite' }, message);
|
|
38
|
+
let timer = null;
|
|
39
|
+
let ended = false;
|
|
40
|
+
let inFlight = false;
|
|
41
|
+
let abort = null;
|
|
42
|
+
root.adoptedStyleSheets = sheets;
|
|
43
|
+
root.appendChild(region);
|
|
44
|
+
const insert = () => {
|
|
45
|
+
doc.body.insertBefore(host, doc.body.firstChild);
|
|
46
|
+
};
|
|
47
|
+
const visible = () => doc.visibilityState !== 'hidden';
|
|
48
|
+
const stop = () => {
|
|
49
|
+
ended = true;
|
|
50
|
+
abort?.abort();
|
|
51
|
+
if (timer !== null)
|
|
52
|
+
scope.clearTimeout(timer);
|
|
53
|
+
timer = null;
|
|
54
|
+
doc.removeEventListener('visibilitychange', onVisibility);
|
|
55
|
+
};
|
|
56
|
+
const end = (state, action) => {
|
|
57
|
+
stop();
|
|
58
|
+
// The app may have replaced <body>'s children: without the banner the end state would go unseen.
|
|
59
|
+
if (!host.isConnected && doc.body !== null)
|
|
60
|
+
insert();
|
|
61
|
+
region.setAttribute('data-state', state);
|
|
62
|
+
clear(message);
|
|
63
|
+
message.appendChild(doc.createTextNode(NL.trial[state]));
|
|
64
|
+
if (action !== undefined) {
|
|
65
|
+
region.appendChild(h(doc, 'button', { type: 'button', onclick: action[1] }, action[0]));
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
const schedule = () => {
|
|
69
|
+
if (ended)
|
|
70
|
+
return;
|
|
71
|
+
if (timer !== null)
|
|
72
|
+
scope.clearTimeout(timer);
|
|
73
|
+
const interval = visible() ? POLL_VISIBLE_MS : POLL_HIDDEN_MS;
|
|
74
|
+
timer = scope.setTimeout(tick, known ? Math.max(0, Math.min(interval, expiresAt - now())) : interval);
|
|
75
|
+
};
|
|
76
|
+
const tick = async () => {
|
|
77
|
+
timer = null;
|
|
78
|
+
if (ended)
|
|
79
|
+
return;
|
|
80
|
+
// The hour is over: no request needed to know it.
|
|
81
|
+
if (known && now() >= expiresAt) {
|
|
82
|
+
end('expired');
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
inFlight = true;
|
|
86
|
+
const controller = scope.AbortController === undefined ? null : new scope.AbortController();
|
|
87
|
+
abort = controller;
|
|
88
|
+
const timeout = controller === null ? null : scope.setTimeout(() => controller.abort(), STATUS_TIMEOUT_MS);
|
|
89
|
+
try {
|
|
90
|
+
const response = await scope.fetch(config.statusUrl, {
|
|
91
|
+
headers: { Accept: 'application/json', Authorization: `Bearer ${config.token}` },
|
|
92
|
+
cache: 'no-store',
|
|
93
|
+
...(controller === null ? {} : { signal: controller.signal }),
|
|
94
|
+
});
|
|
95
|
+
await answer(response);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// Offline for a moment, or the request hung and was aborted: try again on the next beat.
|
|
99
|
+
schedule();
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
inFlight = false;
|
|
103
|
+
abort = null;
|
|
104
|
+
if (timeout !== null)
|
|
105
|
+
scope.clearTimeout(timeout);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
const answer = async (response) => {
|
|
109
|
+
if (ended)
|
|
110
|
+
return;
|
|
111
|
+
if (response.ok || response.status === 429 || response.status >= 500) {
|
|
112
|
+
schedule();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
let problem = {};
|
|
116
|
+
try {
|
|
117
|
+
const body = await response.json();
|
|
118
|
+
if (body !== null && typeof body === 'object')
|
|
119
|
+
problem = body;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// No problem body: treated as closed below.
|
|
123
|
+
}
|
|
124
|
+
const code = problem.code;
|
|
125
|
+
let newerUrl = null;
|
|
126
|
+
if (code === 'trial_expired') {
|
|
127
|
+
end('expired');
|
|
128
|
+
}
|
|
129
|
+
else if (code === 'trial_elsewhere') {
|
|
130
|
+
end('elsewhere', [NL.trial.reload, () => scope.location.reload()]);
|
|
131
|
+
}
|
|
132
|
+
else if (code === 'trial_replaced' && (newerUrl = trustedNewer(problem.newer, config.dmpUrl)) !== null) {
|
|
133
|
+
const newer = newerUrl.href;
|
|
134
|
+
if (problem.wider === false) {
|
|
135
|
+
end('refreshing');
|
|
136
|
+
scope.location.assign(newer);
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
// Reads more than the open one: only the maker's click may widen what this tab reads.
|
|
140
|
+
end('wider', [NL.trial.load, () => scope.location.assign(newer)]);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
end('closed');
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
function onVisibility() {
|
|
148
|
+
if (ended || inFlight)
|
|
149
|
+
return;
|
|
150
|
+
if (!visible()) {
|
|
151
|
+
schedule();
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
// Back in view: ask right away instead of waiting out the hidden interval.
|
|
155
|
+
if (timer !== null)
|
|
156
|
+
scope.clearTimeout(timer);
|
|
157
|
+
void tick();
|
|
158
|
+
}
|
|
159
|
+
doc.addEventListener('visibilitychange', onVisibility);
|
|
160
|
+
if (doc.body === null) {
|
|
161
|
+
doc.addEventListener('DOMContentLoaded', insert, { once: true });
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
insert();
|
|
165
|
+
}
|
|
166
|
+
schedule();
|
|
167
|
+
return stop;
|
|
168
|
+
}
|
|
169
|
+
/** Only the DMP's own open route: same origin as `dmpUrl`, under `/apps/proef/`. */
|
|
170
|
+
function trustedNewer(newer, dmpUrl) {
|
|
171
|
+
if (typeof newer !== 'string')
|
|
172
|
+
return null;
|
|
173
|
+
try {
|
|
174
|
+
const url = new URL(newer);
|
|
175
|
+
return url.origin === new URL(dmpUrl).origin && url.pathname.startsWith('/apps/proef/') ? url : null;
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { CubeAppSession } from '../app.js';
|
|
2
|
+
import { type Editor } from './inputs.js';
|
|
3
|
+
export declare const WRITE_CSS = "\nform{display:flex;flex-direction:column;gap:1rem}\n.actions{display:flex;flex-wrap:wrap;gap:.5rem;margin-top:1rem}\n.alert{margin:0 0 1rem;padding:.75rem 1rem;border:1px solid var(--cube-color-danger,#b42318);\nborder-radius:var(--cube-radius-small)}\n.alert p{margin:0}\n.alert ul{margin:.5rem 0 0;padding-inline-start:1.25rem}\n.alert button{margin-top:.5rem}\n.status{margin:.5rem 0 0}\n.status:empty{margin:0}\n";
|
|
4
|
+
/** One key per write attempt: kept only while the same body is sent again after the answer never came. */
|
|
5
|
+
export interface Attempt {
|
|
6
|
+
key: string;
|
|
7
|
+
body: string;
|
|
8
|
+
}
|
|
9
|
+
/** The attempt for `fingerprint`: the earlier one when it is the same body, else a new key. */
|
|
10
|
+
export declare function attemptFor(earlier: Attempt | null, fingerprint: string): Attempt;
|
|
11
|
+
/** Shows `message` (and `action`) in the alert above a form and moves focus there. */
|
|
12
|
+
export declare function showAlert(doc: Document, target: HTMLElement, message: string, action?: HTMLElement | null): void;
|
|
13
|
+
/**
|
|
14
|
+
* Shows why a write failed: a 422 on the fields it names, with the form-level line, and the messages for fields
|
|
15
|
+
* without an editor listed in the alert (`label` names them); anything else as one line. Only the kind of stateFor
|
|
16
|
+
* is used: the alert never shows an error's own text.
|
|
17
|
+
*/
|
|
18
|
+
export declare function showWriteFailure(doc: Document, alert: HTMLElement, error: unknown, editors: Editor[], label: (field: string) => string): void;
|
|
19
|
+
/** Who is signed in, as a key: null when nobody is. A write must not go out as somebody who signed in since. */
|
|
20
|
+
export declare function userOf(session: CubeAppSession | undefined): string | null;
|
package/dist/ui/write.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// What the blocks that write (cube-detail, cube-form, cube-board) share: the form frame, the alert above it, the
|
|
2
|
+
// message for a failed write, the idempotency key per attempt and who a write goes out as.
|
|
3
|
+
import { newIdempotencyKey } from '../client.js';
|
|
4
|
+
import { stateFor } from './block.js';
|
|
5
|
+
import { clear, h } from './dom.js';
|
|
6
|
+
import { fieldErrors } from './inputs.js';
|
|
7
|
+
import { NL } from './texts.js';
|
|
8
|
+
export const WRITE_CSS = `
|
|
9
|
+
form{display:flex;flex-direction:column;gap:1rem}
|
|
10
|
+
.actions{display:flex;flex-wrap:wrap;gap:.5rem;margin-top:1rem}
|
|
11
|
+
.alert{margin:0 0 1rem;padding:.75rem 1rem;border:1px solid var(--cube-color-danger,#b42318);
|
|
12
|
+
border-radius:var(--cube-radius-small)}
|
|
13
|
+
.alert p{margin:0}
|
|
14
|
+
.alert ul{margin:.5rem 0 0;padding-inline-start:1.25rem}
|
|
15
|
+
.alert button{margin-top:.5rem}
|
|
16
|
+
.status{margin:.5rem 0 0}
|
|
17
|
+
.status:empty{margin:0}
|
|
18
|
+
`;
|
|
19
|
+
/** The attempt for `fingerprint`: the earlier one when it is the same body, else a new key. */
|
|
20
|
+
export function attemptFor(earlier, fingerprint) {
|
|
21
|
+
return earlier?.body === fingerprint ? earlier : { key: newIdempotencyKey(), body: fingerprint };
|
|
22
|
+
}
|
|
23
|
+
/** Shows `message` (and `action`) in the alert above a form and moves focus there. */
|
|
24
|
+
export function showAlert(doc, target, message, action) {
|
|
25
|
+
clear(target);
|
|
26
|
+
target.append(h(doc, 'p', {}, message), ...(action === undefined || action === null ? [] : [action]));
|
|
27
|
+
target.hidden = false;
|
|
28
|
+
target.focus();
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Shows why a write failed: a 422 on the fields it names, with the form-level line, and the messages for fields
|
|
32
|
+
* without an editor listed in the alert (`label` names them); anything else as one line. Only the kind of stateFor
|
|
33
|
+
* is used: the alert never shows an error's own text.
|
|
34
|
+
*/
|
|
35
|
+
export function showWriteFailure(doc, alert, error, editors, label) {
|
|
36
|
+
const errors = fieldErrors(error);
|
|
37
|
+
if (errors !== null) {
|
|
38
|
+
for (const editor of editors)
|
|
39
|
+
editor.setError(errors[editor.name] ?? null);
|
|
40
|
+
const rest = Object.entries(errors).filter(([field]) => !editors.some((editor) => editor.name === field));
|
|
41
|
+
const list = rest.length === 0
|
|
42
|
+
? null
|
|
43
|
+
: h(doc, 'ul', {}, ...rest.map(([field, message]) => h(doc, 'li', {}, `${label(field)}: ${message}`)));
|
|
44
|
+
showAlert(doc, alert, NL.formInvalid, list);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const kind = stateFor(error).kind;
|
|
48
|
+
showAlert(doc, alert, kind === 'forbidden' ? NL.forbidden : kind === 'signed-out' ? NL.signedOut : NL.saveFailed);
|
|
49
|
+
}
|
|
50
|
+
/** Who is signed in, as a key: null when nobody is. A write must not go out as somebody who signed in since. */
|
|
51
|
+
export function userOf(session) {
|
|
52
|
+
const user = session?.user() ?? null;
|
|
53
|
+
return user === null ? null : String(user.id);
|
|
54
|
+
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
/** This package's version. A test holds it equal to package.json's. */
|
|
2
|
-
export declare const VERSION = "1.
|
|
2
|
+
export declare const VERSION = "1.1.0-rc.2";
|
|
3
3
|
/** The `"sdk"` value a `cube-app.json` manifest names for the API `CubeApp` offers here. */
|
|
4
4
|
export declare const MANIFEST_SDK = "1";
|
package/dist/version.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
/** This package's version. A test holds it equal to package.json's. */
|
|
2
|
-
export const VERSION = '1.
|
|
2
|
+
export const VERSION = '1.1.0-rc.2';
|
|
3
3
|
/** The `"sdk"` value a `cube-app.json` manifest names for the API `CubeApp` offers here. */
|
|
4
4
|
export const MANIFEST_SDK = '1';
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ResourceClient } from './client.js';
|
|
2
|
+
import { PreviewStore } from './preview.js';
|
|
3
|
+
import type { DmpRecord } from './types.js';
|
|
4
|
+
import { type Format } from './ui/format.js';
|
|
5
|
+
import type { ResourceSchema } from './ui/schema.js';
|
|
6
|
+
export type GapType = 'tekst' | 'getal' | 'bedrag' | 'datum' | 'keuze' | 'ja/nee';
|
|
7
|
+
/** One entry of `ontbreekt[]` in cube-app.json: a field (or a whole resource) the DMP does not have yet. */
|
|
8
|
+
export interface GapEntry {
|
|
9
|
+
resource: string;
|
|
10
|
+
veld?: string;
|
|
11
|
+
type: GapType;
|
|
12
|
+
waarden?: string[];
|
|
13
|
+
scherm: string;
|
|
14
|
+
reden: string;
|
|
15
|
+
}
|
|
16
|
+
/** The valid gap entries of `ontbreekt`; malformed ones are dropped with a single warning. */
|
|
17
|
+
export declare function readGaps(value: unknown): GapEntry[];
|
|
18
|
+
export declare function gapFormat(type: GapType): Format;
|
|
19
|
+
/**
|
|
20
|
+
* A schema with the missing fields of its resource added, so a block that takes its columns from the schema shows
|
|
21
|
+
* them, and can filter and sort on them (the page only). They are writable: a wireframe's writes stay in the browser.
|
|
22
|
+
*/
|
|
23
|
+
export declare function withGaps(schema: ResourceSchema | null, gaps: readonly GapEntry[]): ResourceSchema | null;
|
|
24
|
+
/** An example value for a missing field of row `id`: from mock rows that carry the field, else made up by type. */
|
|
25
|
+
export declare function inventValue(gap: GapEntry, id: unknown, mockRows: readonly DmpRecord[]): unknown;
|
|
26
|
+
/**
|
|
27
|
+
* A live resource some of whose fields do not exist yet. Those fields never reach the server (naming one would be a
|
|
28
|
+
* 422): each row gets an invented value, a filter or sort on one applies to the returned page only, and an aggregate
|
|
29
|
+
* that names one is answered from the mock rows.
|
|
30
|
+
*/
|
|
31
|
+
export declare function mixedResource<T extends DmpRecord>(live: ResourceClient<T>, gaps: readonly GapEntry[], mock: PreviewStore, mockRows: readonly DmpRecord[]): ResourceClient<T>;
|
|
32
|
+
/**
|
|
33
|
+
* A wireframe's writes, answered in this browser and never sent: what is created, changed or deleted lies over the
|
|
34
|
+
* wrapped client's reads until the page reloads. A created row's id is `voorbeeld-<n>`. A list shows a created or
|
|
35
|
+
* changed row only where the query's filter puts it, so a moved board card lands in its new column.
|
|
36
|
+
*/
|
|
37
|
+
export declare function simulatedWrites<T extends DmpRecord>(client: ResourceClient<T>, name: string): ResourceClient<T>;
|