@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.
@@ -0,0 +1,199 @@
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 429's numeric Retry-After is honoured up to this long, and never past the expiry. */
8
+ export const RETRY_AFTER_CAP_MS = 60000;
9
+ /** A status request that hangs this long is given up as a network error, so the polling goes on. */
10
+ export const STATUS_TIMEOUT_MS = 10000;
11
+ const CSS = `
12
+ .banner{display:flex;flex-wrap:wrap;align-items:center;gap:.75rem;padding:.5rem 1rem;
13
+ border-bottom:1px solid var(--cube-color-border);border-left:4px solid var(--cube-color-warning);
14
+ background:var(--cube-color-surface);font-size:.875rem}
15
+ .banner p{margin:0;flex:1 1 16rem}
16
+ .banner[data-state=expired],.banner[data-state=closed]{border-left-color:var(--cube-color-danger)}
17
+ `;
18
+ /**
19
+ * Puts `cube-trial-banner` at the top of `<body>` and polls the trial's status: it says what the page is, ends when
20
+ * the trial does, and follows a newer version (by itself only when that one reads nothing more).
21
+ * Returns a function that stops the polling.
22
+ */
23
+ export function showTrialBanner(scope, config) {
24
+ const doc = scope.document;
25
+ const now = scope.now ?? Date.now;
26
+ const expiresAt = Date.parse(config.expiresAt);
27
+ const known = Number.isFinite(expiresAt);
28
+ const time = known
29
+ ? new Intl.DateTimeFormat('nl-NL', { hour: '2-digit', minute: '2-digit', timeZoneName: 'short' }).format(new Date(expiresAt))
30
+ : null;
31
+ const host = doc.createElement('cube-trial-banner');
32
+ const root = host.attachShadow({ mode: 'open' });
33
+ const sheets = [BASE_CSS, CSS].map((css) => {
34
+ const sheet = new scope.CSSStyleSheet();
35
+ sheet.replaceSync(css);
36
+ return sheet;
37
+ });
38
+ const message = h(doc, 'p', {}, NL.trial.banner(time, config.leest));
39
+ const region = h(doc, 'div', { class: 'banner', role: 'status', 'aria-live': 'polite' }, message);
40
+ let timer = null;
41
+ let ended = false;
42
+ let inFlight = false;
43
+ let abort = null;
44
+ /** Until when a 429 asked to wait; 0 when no back-off holds. */
45
+ let backOffUntil = 0;
46
+ root.adoptedStyleSheets = sheets;
47
+ root.appendChild(region);
48
+ const insert = () => {
49
+ doc.body.insertBefore(host, doc.body.firstChild);
50
+ };
51
+ const visible = () => doc.visibilityState !== 'hidden';
52
+ const stop = () => {
53
+ ended = true;
54
+ abort?.abort();
55
+ if (timer !== null)
56
+ scope.clearTimeout(timer);
57
+ timer = null;
58
+ doc.removeEventListener('visibilitychange', onVisibility);
59
+ };
60
+ const end = (state, action) => {
61
+ stop();
62
+ // The app may have replaced <body>'s children: without the banner the end state would go unseen.
63
+ if (!host.isConnected && doc.body !== null)
64
+ insert();
65
+ region.setAttribute('data-state', state);
66
+ clear(message);
67
+ message.appendChild(doc.createTextNode(NL.trial[state]));
68
+ if (action !== undefined) {
69
+ region.appendChild(h(doc, 'button', { type: 'button', onclick: action[1] }, action[0]));
70
+ }
71
+ };
72
+ const schedule = (beat = visible() ? POLL_VISIBLE_MS : POLL_HIDDEN_MS) => {
73
+ if (ended)
74
+ return;
75
+ if (timer !== null)
76
+ scope.clearTimeout(timer);
77
+ // Capped again: a wall clock that jumps back must not stretch the back-off.
78
+ const wait = Math.max(beat, Math.min(RETRY_AFTER_CAP_MS, backOffUntil - now()));
79
+ timer = scope.setTimeout(tick, known ? Math.max(0, Math.min(wait, expiresAt - now())) : wait);
80
+ };
81
+ const tick = async () => {
82
+ timer = null;
83
+ if (ended)
84
+ return;
85
+ // The hour is over: no request needed to know it.
86
+ if (known && now() >= expiresAt) {
87
+ end('expired');
88
+ return;
89
+ }
90
+ // A back-off governs only the request it delays; the answer to this one sets the next.
91
+ backOffUntil = 0;
92
+ inFlight = true;
93
+ const controller = scope.AbortController === undefined ? null : new scope.AbortController();
94
+ abort = controller;
95
+ const timeout = controller === null ? null : scope.setTimeout(() => controller.abort(), STATUS_TIMEOUT_MS);
96
+ try {
97
+ const response = await scope.fetch(config.statusUrl, {
98
+ headers: { Accept: 'application/json', Authorization: `Bearer ${config.token}` },
99
+ cache: 'no-store',
100
+ ...(controller === null ? {} : { signal: controller.signal }),
101
+ });
102
+ await answer(response);
103
+ }
104
+ catch {
105
+ // Offline for a moment, or the request hung and was aborted: try again on the next beat.
106
+ schedule();
107
+ }
108
+ finally {
109
+ inFlight = false;
110
+ abort = null;
111
+ if (timeout !== null)
112
+ scope.clearTimeout(timeout);
113
+ }
114
+ };
115
+ const answer = async (response) => {
116
+ if (ended)
117
+ return;
118
+ if (response.ok)
119
+ backOffUntil = 0;
120
+ if (response.status === 429) {
121
+ const seconds = (response.headers.get('Retry-After') ?? '').trim();
122
+ if (/^\d+$/.test(seconds))
123
+ backOffUntil = now() + Math.min(RETRY_AFTER_CAP_MS, Number(seconds) * 1000);
124
+ }
125
+ if (response.ok || response.status === 429 || response.status >= 500) {
126
+ schedule();
127
+ return;
128
+ }
129
+ let problem = {};
130
+ try {
131
+ const body = await response.json();
132
+ if (body !== null && typeof body === 'object')
133
+ problem = body;
134
+ }
135
+ catch {
136
+ // No problem body: treated as closed below.
137
+ }
138
+ const code = problem.code;
139
+ let newerUrl = null;
140
+ if (code === 'trial_expired') {
141
+ end('expired');
142
+ }
143
+ else if (code === 'trial_elsewhere') {
144
+ end('elsewhere', [NL.trial.reload, () => scope.location.reload()]);
145
+ }
146
+ else if (code === 'trial_replaced' && (newerUrl = trustedNewer(problem.newer, config.dmpUrl)) !== null) {
147
+ const newer = newerUrl.href;
148
+ if (problem.wider === false) {
149
+ end('refreshing');
150
+ scope.location.assign(newer);
151
+ }
152
+ else {
153
+ // Reads more than the open one: only the maker's click may widen what this tab reads.
154
+ end('wider', [NL.trial.load, () => scope.location.assign(newer)]);
155
+ }
156
+ }
157
+ else {
158
+ end('closed');
159
+ }
160
+ };
161
+ function onVisibility() {
162
+ if (ended || inFlight)
163
+ return;
164
+ if (!visible()) {
165
+ schedule();
166
+ return;
167
+ }
168
+ // Back in view during a back-off: wait out its rest.
169
+ if (backOffUntil > now()) {
170
+ schedule(0);
171
+ return;
172
+ }
173
+ // Back in view: ask right away instead of waiting out the hidden interval.
174
+ if (timer !== null)
175
+ scope.clearTimeout(timer);
176
+ void tick();
177
+ }
178
+ doc.addEventListener('visibilitychange', onVisibility);
179
+ if (doc.body === null) {
180
+ doc.addEventListener('DOMContentLoaded', insert, { once: true });
181
+ }
182
+ else {
183
+ insert();
184
+ }
185
+ schedule();
186
+ return stop;
187
+ }
188
+ /** Only the DMP's own open route: same origin as `dmpUrl`, under `/apps/proef/`. */
189
+ function trustedNewer(newer, dmpUrl) {
190
+ if (typeof newer !== 'string')
191
+ return null;
192
+ try {
193
+ const url = new URL(newer);
194
+ return url.origin === new URL(dmpUrl).origin && url.pathname.startsWith('/apps/proef/') ? url : null;
195
+ }
196
+ catch {
197
+ return null;
198
+ }
199
+ }
@@ -12,8 +12,8 @@ export declare function attemptFor(earlier: Attempt | null, fingerprint: string)
12
12
  export declare function showAlert(doc: Document, target: HTMLElement, message: string, action?: HTMLElement | null): void;
13
13
  /**
14
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. Never the browser's own
16
- * (English) text of a network failure: stateFor would show a TypeError's message.
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
17
  */
18
18
  export declare function showWriteFailure(doc: Document, alert: HTMLElement, error: unknown, editors: Editor[], label: (field: string) => string): void;
19
19
  /** Who is signed in, as a key: null when nobody is. A write must not go out as somebody who signed in since. */
package/dist/ui/write.js CHANGED
@@ -29,8 +29,8 @@ export function showAlert(doc, target, message, action) {
29
29
  }
30
30
  /**
31
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. Never the browser's own
33
- * (English) text of a network failure: stateFor would show a TypeError's message.
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
34
  */
35
35
  export function showWriteFailure(doc, alert, error, editors, label) {
36
36
  const errors = fieldErrors(error);
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.1.0-rc.1";
2
+ export declare const VERSION = "1.1.0-rc.3";
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.1.0-rc.1';
2
+ export const VERSION = '1.1.0-rc.3';
3
3
  /** The `"sdk"` value a `cube-app.json` manifest names for the API `CubeApp` offers here. */
4
4
  export const MANIFEST_SDK = '1';
package/dist/wireframe.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { DmpError } from './errors.js';
2
- import { PreviewStore } from './preview.js';
2
+ import { compare, PreviewStore } from './preview.js';
3
3
  import { isoDate } from './ui/format.js';
4
4
  const FORMATS = {
5
5
  tekst: 'text',
@@ -11,6 +11,8 @@ const FORMATS = {
11
11
  };
12
12
  const filled = (value) => typeof value === 'string' && value !== '';
13
13
  const own = (row, key) => Object.prototype.hasOwnProperty.call(row, key);
14
+ // `a, -b` and ['a', '-b'] alike: the items of a comma list, as the server reads it.
15
+ const items = (value = []) => [value].flat().join(',').split(',').map((item) => item.trim());
14
16
  function isGap(value) {
15
17
  if (typeof value !== 'object' || value === null) {
16
18
  return false;
@@ -29,11 +31,12 @@ function isGap(value) {
29
31
  }
30
32
  /** The valid gap entries of `ontbreekt`; malformed ones are dropped with a single warning. */
31
33
  export function readGaps(value) {
32
- if (!Array.isArray(value)) {
33
- return [];
34
+ const gaps = [];
35
+ const dropped = [];
36
+ // Anything but a list is one malformed entry: a typo must not silently mean "nothing is missing".
37
+ for (const entry of Array.isArray(value) ? value : value == null ? [] : [value]) {
38
+ (isGap(entry) ? gaps : dropped).push(entry);
34
39
  }
35
- const gaps = value.filter(isGap);
36
- const dropped = value.filter((entry) => !isGap(entry));
37
40
  if (dropped.length > 0) {
38
41
  console.warn(`cube-app.json ontbreekt: ${dropped.length} malformed entries ignored`, dropped);
39
42
  }
@@ -73,10 +76,11 @@ export function withGaps(schema, gaps) {
73
76
  const names = added.map((field) => field.name);
74
77
  return {
75
78
  ...schema,
76
- // Mock data may hold a missing field without its format: an amount there still shows as money.
79
+ // Money is not recognisable by its values, dates are (inferFormat): only an amount gets its format here, and a
80
+ // stale `datum` gap must not drop a live date-time's time.
77
81
  fields: [...schema.fields.map((field) => {
78
82
  const gap = gaps.find(({ resource, veld }) => resource === schema.name && veld === field.name);
79
- return gap !== undefined && field.format == null ? { ...field, format: shown(gap.type) } : field;
83
+ return gap?.type === 'bedrag' && field.format == null ? { ...field, format: 'currency' } : field;
80
84
  }), ...added],
81
85
  // An empty summary means "every field of a row", which already holds the invented ones.
82
86
  summary: schema.summary.length > 0 ? [...schema.summary, ...names] : schema.summary,
@@ -141,13 +145,14 @@ async function locally(name, rows, { filter = {}, sort = [] }) {
141
145
  export function mixedResource(live, gaps, mock, mockRows) {
142
146
  const { name } = live;
143
147
  const missing = new Map(gaps.filter((gap) => gap.resource === name && gap.veld !== undefined).map((gap) => [gap.veld, gap]));
144
- // `-field`, `sum(field)` and `field.month` all name `field`.
145
- const names = (item) => item.split(/[-+(),.]/).some((part) => missing.has(part));
148
+ // `-field`, `sum(field)` and `field.month` name `field`; `count` and `created_at.month` do not name a field
149
+ // called `count` or `month`.
150
+ const names = (item) => missing.has(/^[-+]?(?:\w+\()?([^.()]*)/.exec(item)?.[1] ?? '');
146
151
  const split = (query = {}) => {
147
152
  const selected = selection(query, name);
148
153
  const kept = selected?.filter((field) => !missing.has(field));
149
154
  const filters = Object.entries(query.filter ?? {});
150
- const sort = [query.sort ?? []].flat();
155
+ const sort = items(query.sort).filter((item) => item !== '');
151
156
  // The live part names no missing field; the local part only missing ones.
152
157
  const part = (local) => ({
153
158
  filter: Object.fromEntries(filters.filter(([key]) => missing.has(key) === local)),
@@ -181,7 +186,15 @@ export function mixedResource(live, gaps, mock, mockRows) {
181
186
  }
182
187
  }
183
188
  }
184
- return { ...page, data: data };
189
+ const { meta } = page;
190
+ return {
191
+ ...page,
192
+ data: data,
193
+ // A filter on a missing field covers this page only, so the count and the last page say so too.
194
+ meta: Object.keys(local.filter).length > 0
195
+ ? { ...meta, page: { ...meta.page, total: data.length, pages: meta.page.number } }
196
+ : meta,
197
+ };
185
198
  };
186
199
  const list = async (query, init) => {
187
200
  const parts = split(query);
@@ -206,9 +219,15 @@ export function mixedResource(live, gaps, mock, mockRows) {
206
219
  const parts = split(query);
207
220
  const response = await live.get(id, parts.live, init);
208
221
  fill(response.data, parts.shown);
222
+ // The id was only asked for the invented values.
223
+ if (!parts.shown('id')) {
224
+ delete response.data.id;
225
+ }
209
226
  return response;
210
227
  },
211
- aggregate: (query, init) => [query.fn, query.group ?? [], Object.keys(query.filter ?? {})].flat().some(names)
228
+ aggregate: (query, init) => [items(query.fn).map((fn) => fn.replace(/^\w+$/, '')), items(query.group), Object.keys(query.filter ?? {})]
229
+ .flat()
230
+ .some(names)
212
231
  ? mock.resource(name).aggregate(query)
213
232
  : live.aggregate(query, init),
214
233
  };
@@ -253,9 +272,27 @@ export function simulatedWrites(client, name) {
253
272
  .map((row) => local.get(key(row)) ?? row);
254
273
  const joined = fitting.filter((row) => !served.has(key(row)) && !listed.has(key(row)));
255
274
  const total = page.total - [...served].filter((id) => !fits.has(id)).length + joined.length;
275
+ let data = page.number === 1 ? [...joined, ...kept] : kept;
276
+ // No sort reads as one empty field, which no row has: then nothing moves.
277
+ const sorts = items(query.sort)
278
+ .map((item) => [item.replace(/^[-+]/, ''), item.startsWith('-') ? -1 : 1]);
279
+ // A row written here that the server did not list (created, or joining the list) or that changed in a sort
280
+ // field is placed by the sort; the others keep the server's order, whose collation may differ from ours.
281
+ const placed = (row) => local.has(key(row))
282
+ && (!listed.has(key(row)) || sorts.some(([field]) => origins.get(key(row))?.[field] !== row[field]));
283
+ const server = data.filter((row) => !placed(row));
284
+ // Only when the server's rows carry every sort field to compare on; a placed row without one sorts as empty.
285
+ if (sorts.every(([field]) => server.every((row) => own(row, field)))) {
286
+ for (const row of data.filter(placed)) {
287
+ // Before the first row (server or placed) it sorts before; -1 >>> 0 is past the end, so it appends.
288
+ const at = server.findIndex((other) => sorts.reduce((result, [field, sign]) => result || sign * compare(row[field], other[field]), 0) < 0);
289
+ server.splice(at >>> 0, 0, row);
290
+ }
291
+ data = server;
292
+ }
256
293
  return {
257
294
  ...response,
258
- data: (page.number === 1 ? [...joined, ...kept] : kept).map((row) => pick(row, query)),
295
+ data: data.map((row) => pick(row, query)),
259
296
  meta: { ...response.meta, page: { ...page, total } },
260
297
  };
261
298
  };
@@ -268,7 +305,7 @@ export function simulatedWrites(client, name) {
268
305
  return copy === undefined ? client.get(id, query, init) : answer(200, pick(copy, query));
269
306
  };
270
307
  // The row as it is here; a real one's server copy is kept the first time it changes.
271
- const own = async (id) => {
308
+ const current = async (id) => {
272
309
  const { data } = await get(id);
273
310
  if (!local.has(String(id))) {
274
311
  origins.set(String(id), data);
@@ -299,14 +336,14 @@ export function simulatedWrites(client, name) {
299
336
  return answer(201, { ...row });
300
337
  },
301
338
  async update(id, body) {
302
- const data = await own(id);
339
+ const data = await current(id);
303
340
  const row = { ...data, ...body, id: data.id };
304
341
  local.set(String(id), row);
305
342
  return answer(200, { ...row });
306
343
  },
307
344
  // As the server: an id that is not there is a 404, and a delete never lowers a count for nothing.
308
345
  async delete(id) {
309
- await own(id);
346
+ await current(id);
310
347
  local.delete(String(id));
311
348
  deleted.add(String(id));
312
349
  return answer(204, null);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cube-nl/dmp-sdk",
3
- "version": "1.1.0-rc.1",
3
+ "version": "1.1.0-rc.3",
4
4
  "description": "Dependency-free TypeScript client for the Cube DMP REST API (cube/api), with OAuth 2.1 + PKCE, and CubeApp for no-build apps (preview on mock data, live on the DMP).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -25,7 +25,10 @@
25
25
  "access": "public",
26
26
  "tag": "next"
27
27
  },
28
- "sideEffects": false,
28
+ "sideEffects": [
29
+ "./dist/trial.js",
30
+ "./dist/cube-sdk.js"
31
+ ],
29
32
  "engines": {
30
33
  "node": ">=20"
31
34
  },