@cube-nl/dmp-sdk 1.1.0-rc.2 → 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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest": 1,
3
3
  "package": "@cube-nl/dmp-sdk",
4
- "version": "1.1.0-rc.2",
4
+ "version": "1.1.0-rc.3",
5
5
  "file": "cube-sdk.js",
6
6
  "blocks": {
7
7
  "cube-state": {
@@ -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"
@@ -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/size.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "core_gzip": 18169,
3
- "total_gzip": 46472,
4
- "ui_gzip": 28303,
3
+ "total_gzip": 46959,
4
+ "ui_gzip": 28790,
5
5
  "budget_gzip": 40960
6
6
  }
@@ -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
  };
@@ -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;
@@ -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}
@@ -54,6 +58,10 @@ function sortOrder(order, a, b) {
54
58
  }
55
59
  return 0;
56
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
+ }
57
65
  /** Why a move was refused, in words for the person; never the server's or the browser's own text. */
58
66
  function reason(error) {
59
67
  if (isDmpError(error) && (error.status === 409 || error.status === 412))
@@ -64,7 +72,7 @@ function reason(error) {
64
72
  /**
65
73
  * `<cube-board>`: the rows of a resource as cards in a column per value of a status field. A card moves by drag
66
74
  * and drop or by a button per other column ("Verplaats naar …"): at once on screen, then saved; a refusal puts it
67
- * back.
75
+ * back. A click on a card, or Enter or Space on it, chooses it: cube-select, and `detail` shows it.
68
76
  */
69
77
  export function boardBlock(Base, scope) {
70
78
  const doc = scope.document;
@@ -84,6 +92,9 @@ export function boardBlock(Base, scope) {
84
92
  focusKey = null;
85
93
  attempt = null;
86
94
  ids = 0;
95
+ /** The resource the choice belongs to, and the id of the chosen card. */
96
+ shownResource = null;
97
+ chosen = null;
87
98
  constructor() {
88
99
  super();
89
100
  this.shadowRoot?.insertBefore(this.alert, this.body);
@@ -110,6 +121,11 @@ export function boardBlock(Base, scope) {
110
121
  const resource = requiredAttr(this, 'resource');
111
122
  const status = requiredAttr(this, 'status-field');
112
123
  const title = requiredAttr(this, 'title-field');
124
+ if (resource !== this.shownResource) {
125
+ this.chosen = null;
126
+ this.shownResource = resource;
127
+ }
128
+ this.detailTarget();
113
129
  const filter = blockFilter(this);
114
130
  const size = Math.min(wholeAttr(this, 'page-size', 1, 50), 200);
115
131
  const sort = this.attr('sort')?.trim() ?? '';
@@ -206,11 +222,28 @@ export function boardBlock(Base, scope) {
206
222
  card(loaded, column, row, cards) {
207
223
  const key = keyOf(row);
208
224
  const title = `b${++this.ids}`;
225
+ const chosen = row.id != null && key === this.chosen;
209
226
  const card = h(doc, 'article', {
210
227
  tabindex: '0',
211
228
  draggable: loaded.canMove ? 'true' : 'false',
212
229
  'data-id': key,
213
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
+ }),
214
247
  ondragstart: (event) => {
215
248
  this.dragged = key;
216
249
  event.dataTransfer?.setData('text/plain', key);
@@ -237,6 +270,43 @@ export function boardBlock(Base, scope) {
237
270
  cards.set(key, card);
238
271
  return card;
239
272
  }
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
+ }
240
310
  /**
241
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
242
312
  * not on the board.
@@ -250,6 +320,8 @@ export function boardBlock(Base, scope) {
250
320
  continue;
251
321
  column.rows.splice(index, 1);
252
322
  column.total--;
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);
253
325
  if (loaded.order.length === 0) {
254
326
  target.rows.unshift(row);
255
327
  }
@@ -290,7 +362,7 @@ export function boardBlock(Base, scope) {
290
362
  }
291
363
  async send(loaded, track, row, from, to) {
292
364
  const key = keyOf(row);
293
- const body = { [loaded.status]: loaded.numeric && Number.isFinite(Number(to)) ? Number(to) : to };
365
+ const body = { [loaded.status]: statusValue(loaded, to) };
294
366
  const attempt = attemptFor(this.attempt, JSON.stringify([loaded.resource, key, body]));
295
367
  this.attempt = attempt;
296
368
  try {
@@ -761,6 +761,7 @@ export const BLOCK_SPECS = {
761
761
  + 'live, write cube-columns). A card moves by drag and drop or by a button per other column, named '
762
762
  + '"Verplaats naar …": at once on screen, then saved; a refusal puts it back with an alert. Read-only '
763
763
  + 'without update or with a readonly status field: update is optional, a view-only board needs only list. '
764
+ + 'Choosing a card (click, Enter or Space) emits cube-select and can fill a cube-detail. '
764
765
  + 'Columns stack on a narrow screen.',
765
766
  actions: ['list', 'update'],
766
767
  attributes: {
@@ -809,11 +810,19 @@ export const BLOCK_SPECS = {
809
810
  summary: 'Cards shown per column, at most 200; a column with more says "en nog N".',
810
811
  default: '50',
811
812
  },
813
+ detail: {
814
+ type: 'selector',
815
+ required: false,
816
+ summary: 'CSS selector of a cube-detail whose record-id is set to the id of the chosen card.',
817
+ example: '#taak',
818
+ },
812
819
  },
813
820
  children: ['cube-column', 'cube-field'],
814
821
  events: {
815
822
  'cube-move': 'A move was saved; detail is { resource, id, from, to } with the old and new status values. '
816
823
  + 'cube-data-change follows, so tables of the same resource reload.',
824
+ 'cube-select': 'The user chose a card (click, Enter or Space; not a move); detail is { resource, id, row } with the '
825
+ + 'row as listed.',
817
826
  },
818
827
  example: '<cube-board resource="tasks" status-field="status" title-field="title" fields="owner">'
819
828
  + '<cube-column value="todo" label="Te doen"></cube-column>'
@@ -15,6 +15,8 @@ export interface TrialScope {
15
15
  /** Spec section 7: every 5 s while the page is visible, every 60 s while it is hidden. */
16
16
  export declare const POLL_VISIBLE_MS = 5000;
17
17
  export declare const POLL_HIDDEN_MS = 60000;
18
+ /** A 429's numeric Retry-After is honoured up to this long, and never past the expiry. */
19
+ export declare const RETRY_AFTER_CAP_MS = 60000;
18
20
  /** A status request that hangs this long is given up as a network error, so the polling goes on. */
19
21
  export declare const STATUS_TIMEOUT_MS = 10000;
20
22
  /**
@@ -4,6 +4,8 @@ import { NL } from './texts.js';
4
4
  /** Spec section 7: every 5 s while the page is visible, every 60 s while it is hidden. */
5
5
  export const POLL_VISIBLE_MS = 5000;
6
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;
7
9
  /** A status request that hangs this long is given up as a network error, so the polling goes on. */
8
10
  export const STATUS_TIMEOUT_MS = 10000;
9
11
  const CSS = `
@@ -24,7 +26,7 @@ export function showTrialBanner(scope, config) {
24
26
  const expiresAt = Date.parse(config.expiresAt);
25
27
  const known = Number.isFinite(expiresAt);
26
28
  const time = known
27
- ? new Intl.DateTimeFormat('nl-NL', { hour: '2-digit', minute: '2-digit' }).format(new Date(expiresAt))
29
+ ? new Intl.DateTimeFormat('nl-NL', { hour: '2-digit', minute: '2-digit', timeZoneName: 'short' }).format(new Date(expiresAt))
28
30
  : null;
29
31
  const host = doc.createElement('cube-trial-banner');
30
32
  const root = host.attachShadow({ mode: 'open' });
@@ -39,6 +41,8 @@ export function showTrialBanner(scope, config) {
39
41
  let ended = false;
40
42
  let inFlight = false;
41
43
  let abort = null;
44
+ /** Until when a 429 asked to wait; 0 when no back-off holds. */
45
+ let backOffUntil = 0;
42
46
  root.adoptedStyleSheets = sheets;
43
47
  root.appendChild(region);
44
48
  const insert = () => {
@@ -65,13 +69,14 @@ export function showTrialBanner(scope, config) {
65
69
  region.appendChild(h(doc, 'button', { type: 'button', onclick: action[1] }, action[0]));
66
70
  }
67
71
  };
68
- const schedule = () => {
72
+ const schedule = (beat = visible() ? POLL_VISIBLE_MS : POLL_HIDDEN_MS) => {
69
73
  if (ended)
70
74
  return;
71
75
  if (timer !== null)
72
76
  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);
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);
75
80
  };
76
81
  const tick = async () => {
77
82
  timer = null;
@@ -82,6 +87,8 @@ export function showTrialBanner(scope, config) {
82
87
  end('expired');
83
88
  return;
84
89
  }
90
+ // A back-off governs only the request it delays; the answer to this one sets the next.
91
+ backOffUntil = 0;
85
92
  inFlight = true;
86
93
  const controller = scope.AbortController === undefined ? null : new scope.AbortController();
87
94
  abort = controller;
@@ -108,6 +115,13 @@ export function showTrialBanner(scope, config) {
108
115
  const answer = async (response) => {
109
116
  if (ended)
110
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
+ }
111
125
  if (response.ok || response.status === 429 || response.status >= 500) {
112
126
  schedule();
113
127
  return;
@@ -151,6 +165,11 @@ export function showTrialBanner(scope, config) {
151
165
  schedule();
152
166
  return;
153
167
  }
168
+ // Back in view during a back-off: wait out its rest.
169
+ if (backOffUntil > now()) {
170
+ schedule(0);
171
+ return;
172
+ }
154
173
  // Back in view: ask right away instead of waiting out the hidden interval.
155
174
  if (timer !== null)
156
175
  scope.clearTimeout(timer);
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.2";
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.2';
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cube-nl/dmp-sdk",
3
- "version": "1.1.0-rc.2",
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",