@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.
Files changed (96) hide show
  1. package/README.md +372 -9
  2. package/dist/app.d.ts +41 -4
  3. package/dist/app.js +252 -14
  4. package/dist/browser.js +14 -1
  5. package/dist/client.d.ts +2 -0
  6. package/dist/client.js +5 -1
  7. package/dist/cube-sdk.js +186 -2
  8. package/dist/cube-ui.manifest.json +971 -0
  9. package/dist/index.d.ts +6 -0
  10. package/dist/index.js +4 -0
  11. package/dist/preview.d.ts +11 -0
  12. package/dist/preview.js +60 -7
  13. package/dist/size.json +6 -0
  14. package/dist/trial.d.ts +32 -0
  15. package/dist/trial.js +55 -0
  16. package/dist/ui/aggregate-block.d.ts +73 -0
  17. package/dist/ui/aggregate-block.js +139 -0
  18. package/dist/ui/attributes.d.ts +5 -0
  19. package/dist/ui/attributes.js +72 -0
  20. package/dist/ui/author-error.d.ts +3 -0
  21. package/dist/ui/author-error.js +3 -0
  22. package/dist/ui/block.d.ts +69 -0
  23. package/dist/ui/block.js +238 -0
  24. package/dist/ui/blocks/app-shell.d.ts +4 -0
  25. package/dist/ui/blocks/app-shell.js +167 -0
  26. package/dist/ui/blocks/board.d.ts +8 -0
  27. package/dist/ui/blocks/board.js +333 -0
  28. package/dist/ui/blocks/calc.d.ts +9 -0
  29. package/dist/ui/blocks/calc.js +263 -0
  30. package/dist/ui/blocks/chart.d.ts +7 -0
  31. package/dist/ui/blocks/chart.js +166 -0
  32. package/dist/ui/blocks/detail.d.ts +8 -0
  33. package/dist/ui/blocks/detail.js +270 -0
  34. package/dist/ui/blocks/field.d.ts +8 -0
  35. package/dist/ui/blocks/field.js +16 -0
  36. package/dist/ui/blocks/form.d.ts +8 -0
  37. package/dist/ui/blocks/form.js +153 -0
  38. package/dist/ui/blocks/gap-list.d.ts +4 -0
  39. package/dist/ui/blocks/gap-list.js +36 -0
  40. package/dist/ui/blocks/kpi.d.ts +4 -0
  41. package/dist/ui/blocks/kpi.js +75 -0
  42. package/dist/ui/blocks/period-picker.d.ts +4 -0
  43. package/dist/ui/blocks/period-picker.js +155 -0
  44. package/dist/ui/blocks/state.d.ts +4 -0
  45. package/dist/ui/blocks/state.js +135 -0
  46. package/dist/ui/blocks/table.d.ts +8 -0
  47. package/dist/ui/blocks/table.js +666 -0
  48. package/dist/ui/blocks/top.d.ts +4 -0
  49. package/dist/ui/blocks/top.js +67 -0
  50. package/dist/ui/chart-svg.d.ts +28 -0
  51. package/dist/ui/chart-svg.js +111 -0
  52. package/dist/ui/csv.d.ts +9 -0
  53. package/dist/ui/csv.js +55 -0
  54. package/dist/ui/define.d.ts +24 -0
  55. package/dist/ui/define.js +107 -0
  56. package/dist/ui/dom.d.ts +12 -0
  57. package/dist/ui/dom.js +58 -0
  58. package/dist/ui/fields.d.ts +39 -0
  59. package/dist/ui/fields.js +73 -0
  60. package/dist/ui/format.d.ts +44 -0
  61. package/dist/ui/format.js +215 -0
  62. package/dist/ui/inputs.d.ts +35 -0
  63. package/dist/ui/inputs.js +161 -0
  64. package/dist/ui/manifest.d.ts +40 -0
  65. package/dist/ui/manifest.js +880 -0
  66. package/dist/ui/period.d.ts +18 -0
  67. package/dist/ui/period.js +52 -0
  68. package/dist/ui/query.d.ts +26 -0
  69. package/dist/ui/query.js +99 -0
  70. package/dist/ui/schema.d.ts +23 -0
  71. package/dist/ui/schema.js +21 -0
  72. package/dist/ui/styles.d.ts +8 -0
  73. package/dist/ui/styles.js +51 -0
  74. package/dist/ui/texts.d.ts +146 -0
  75. package/dist/ui/texts.js +138 -0
  76. package/dist/ui/theme.d.ts +58 -0
  77. package/dist/ui/theme.js +311 -0
  78. package/dist/ui/trial-banner.d.ts +25 -0
  79. package/dist/ui/trial-banner.js +180 -0
  80. package/dist/ui/write.d.ts +20 -0
  81. package/dist/ui/write.js +54 -0
  82. package/dist/version.d.ts +1 -1
  83. package/dist/version.js +1 -1
  84. package/dist/wireframe.d.ts +37 -0
  85. package/dist/wireframe.js +352 -0
  86. package/fonts/OFL-albert-sans.txt +93 -0
  87. package/fonts/OFL-ibm-plex-sans.txt +93 -0
  88. package/fonts/OFL-inter.txt +93 -0
  89. package/fonts/OFL-nunito-sans.txt +93 -0
  90. package/fonts/OFL-source-sans-3.txt +93 -0
  91. package/fonts/albert-sans.woff2 +0 -0
  92. package/fonts/ibm-plex-sans.woff2 +0 -0
  93. package/fonts/inter.woff2 +0 -0
  94. package/fonts/nunito-sans.woff2 +0 -0
  95. package/fonts/source-sans-3.woff2 +0 -0
  96. package/package.json +12 -4
package/dist/app.js CHANGED
@@ -2,34 +2,171 @@ import { createOAuth, memoryTokenStore, } from './auth.js';
2
2
  import { createClient } from './client.js';
3
3
  import { DmpError } from './errors.js';
4
4
  import { PreviewStore } from './preview.js';
5
+ import { schemaFromCapability } from './ui/schema.js';
6
+ import { PREVIEW_USER_NAME } from './ui/texts.js';
7
+ import { resolveTheme, setTheme } from './ui/theme.js';
8
+ import { trialConfig } from './trial.js';
5
9
  import { MANIFEST_SDK, VERSION } from './version.js';
10
+ import { mixedResource, readGaps, simulatedWrites, withGaps } from './wireframe.js';
6
11
  const CONFIG_FILE = './cube-app.config.json';
7
12
  const MOCK_FILE = './cube-mock.json';
8
13
  const MANIFEST_FILE = './cube-app.json';
9
- const PREVIEW_USER = { id: 'preview', name: 'Voorbeeldgebruiker', scopes: [] };
14
+ const THEME_FILE = './cube-theme.json';
15
+ const PREVIEW_USER = { id: 'preview', name: PREVIEW_USER_NAME, scopes: [] };
10
16
  let registeredMock = null;
17
+ let registeredTheme = null;
18
+ let pageSession = null;
19
+ /** The session `ready()` connected with the defaults, while it is the page session. */
20
+ let fallbackSession = null;
21
+ /** A `ready()` waiting for the page to load before it connects with the defaults. */
22
+ let waitingSession = null;
23
+ /** Counts start-ups: only the newest may apply its theme, so a slower, replaced one cannot overwrite it. */
24
+ let starts = 0;
11
25
  /**
12
26
  * Starts the app. Reads `./cube-app.config.json` next to the page: absent, the app runs in the
13
27
  * preview on mock data and makes no request to any DMP; present, it signs the end user in with
14
28
  * OAuth (authorization code + PKCE) and talks to the DMP's REST face. The app's code is the same.
15
29
  */
16
- async function connect(options = {}) {
30
+ function connect(options = {}) {
31
+ const replacesFallback = pageSession !== null && pageSession === fallbackSession;
32
+ const generation = ++starts;
33
+ const session = start(options, () => generation === starts);
34
+ pageSession = session;
35
+ fallbackSession = null;
36
+ // A failed start is not the page session: a later ready() tries again.
37
+ session.then(() => {
38
+ // Blocks took the default session already: tell them to take the app's own.
39
+ if (replacesFallback && pageSession === session)
40
+ announceSessionChange();
41
+ }, () => {
42
+ if (pageSession === session) {
43
+ pageSession = null;
44
+ }
45
+ });
46
+ return session;
47
+ }
48
+ /**
49
+ * The session `connect()` made for this page, or one with the defaults when the app never connected. The default one
50
+ * starts only once the page has loaded (and one task later), so an app that awaits something before it calls
51
+ * `connect()` still gets there first and the page signs in once.
52
+ */
53
+ function ready() {
54
+ if (pageSession !== null) {
55
+ return pageSession;
56
+ }
57
+ waitingSession ??= pageLoaded().then(() => {
58
+ waitingSession = null;
59
+ if (pageSession !== null) {
60
+ return pageSession;
61
+ }
62
+ const session = connect();
63
+ fallbackSession = session;
64
+ return session;
65
+ });
66
+ return waitingSession;
67
+ }
68
+ /** Resolves one task after the window's `load` (right away outside a page). */
69
+ function pageLoaded() {
70
+ const page = globalThis;
71
+ return new Promise((resolve) => {
72
+ const later = () => {
73
+ setTimeout(resolve, 0);
74
+ };
75
+ const loaded = page.document === undefined || page.document.readyState === 'complete';
76
+ if (loaded || page.addEventListener === undefined) {
77
+ later();
78
+ }
79
+ else {
80
+ page.addEventListener('load', later, { once: true });
81
+ }
82
+ });
83
+ }
84
+ function announceSessionChange() {
85
+ const doc = globalThis.document;
86
+ if (doc === undefined) {
87
+ return;
88
+ }
89
+ const View = doc.defaultView?.CustomEvent ?? CustomEvent;
90
+ doc.dispatchEvent(new View('cube-session-change'));
91
+ }
92
+ async function start(options, isCurrent) {
17
93
  const doFetch = options.fetch ?? globalThis.fetch.bind(globalThis);
18
94
  const pageUrl = options.pageUrl ?? currentPage();
19
- const config = await readJson(doFetch, resolve(CONFIG_FILE, pageUrl));
95
+ // A trial page never has cube-app.config.json and never signs in with OAuth: the DMP wrote the token into it.
96
+ const trial = trialConfig();
97
+ const [config, theme] = await Promise.all([
98
+ trial === null ? readJson(doFetch, resolve(CONFIG_FILE, pageUrl)) : null,
99
+ loadTheme(options, doFetch, pageUrl),
100
+ ]);
101
+ if (isCurrent()) {
102
+ setTheme(theme);
103
+ }
104
+ if (trial !== null) {
105
+ return trialSession(trial, options, doFetch, pageUrl);
106
+ }
20
107
  if (config === null) {
21
108
  return preview(options, doFetch, pageUrl);
22
109
  }
23
110
  return live(asConfig(config), options, doFetch, pageUrl);
24
111
  }
25
- async function preview(options, doFetch, pageUrl) {
26
- const mock = options.mock ?? registeredMock ?? (await readJson(doFetch, resolve(MOCK_FILE, pageUrl))) ?? {};
112
+ /** Never rejects: a broken theme file must not stop the app, it only costs the house style. */
113
+ async function loadTheme(options, doFetch, pageUrl) {
114
+ try {
115
+ if (options.theme === false) {
116
+ return resolveTheme(null, pageUrl);
117
+ }
118
+ const given = options.theme ?? registeredTheme;
119
+ if (given !== null) {
120
+ return resolveTheme(given, pageUrl);
121
+ }
122
+ const file = await readJson(doFetch, resolve(THEME_FILE, pageUrl));
123
+ if (file !== null && (file.theme !== 1 || typeof file.source !== 'object' || file.source === null)) {
124
+ throw new TypeError('cube-theme.json is not a theme file.');
125
+ }
126
+ return resolveTheme(file, pageUrl);
127
+ }
128
+ catch (error) {
129
+ console.warn(`cube-theme.json could not be used, the neutral theme applies: ${String(error)}`);
130
+ return resolveTheme(null, pageUrl);
131
+ }
132
+ }
133
+ /** The mock data and the manifest, from the options, `CubeApp.mock()` or the files next to the page. */
134
+ async function appFiles(options, doFetch, pageUrl) {
135
+ const mock = options.mock
136
+ ?? registeredMock
137
+ ?? (await readJson(doFetch, resolve(MOCK_FILE, pageUrl)))
138
+ ?? {};
27
139
  const manifest = options.manifest === false
28
140
  ? null
29
141
  : options.manifest ?? (await readJson(doFetch, resolve(MANIFEST_FILE, pageUrl)));
142
+ return [mock, manifest];
143
+ }
144
+ /** Only a wireframe has gaps: any other app marks nothing, whatever its manifest lists. */
145
+ function marks(soort, manifest) {
146
+ const wireframe = soort === 'wireframe';
147
+ const gaps = wireframe ? readGaps(manifest?.ontbreekt) : [];
148
+ return {
149
+ soort: soort ?? null,
150
+ gaps,
151
+ simulatesWrites: wireframe,
152
+ isExample: (resource, field) => gaps.some((gap) => gap.resource === resource && (gap.veld === undefined || gap.veld === field)),
153
+ };
154
+ }
155
+ /** A wireframe answers every write in the browser: one simulating client per resource, kept as long as the session. */
156
+ function writes(on, open) {
157
+ const clients = new Map();
158
+ const simulating = (name) => {
159
+ const client = clients.get(name) ?? simulatedWrites(open(name), name);
160
+ clients.set(name, client);
161
+ return client;
162
+ };
163
+ return (on ? simulating : open);
164
+ }
165
+ async function preview(options, doFetch, pageUrl) {
166
+ const [mock, manifest] = await appFiles(options, doFetch, pageUrl);
30
167
  let signedIn = true;
31
168
  // Signed out, the preview refuses as the API refuses a request without a token.
32
- const store = new PreviewStore(mock, manifest, () => {
169
+ const guard = () => {
33
170
  if (!signedIn) {
34
171
  throw new DmpError(401, new Headers({ 'Content-Type': 'application/problem+json' }), {
35
172
  type: 'https://docs.cube.nl/dmp-api/problems/token_invalid',
@@ -39,8 +176,14 @@ async function preview(options, doFetch, pageUrl) {
39
176
  detail: 'Unauthenticated.',
40
177
  });
41
178
  }
42
- });
179
+ };
180
+ const marked = marks(manifest?.soort, manifest);
181
+ const ceiling = new PreviewStore(mock, manifest, guard);
182
+ // As live: a resource the DMP does not have yet is not in `uses`, so its rows come without that ceiling.
183
+ const examples = new PreviewStore(mock, null, guard);
184
+ const store = (name) => (marked.isExample(name) ? examples : ceiling);
43
185
  return {
186
+ ...marked,
44
187
  mode: 'preview',
45
188
  signInError: null,
46
189
  user: () => (signedIn ? { ...PREVIEW_USER } : null),
@@ -50,7 +193,56 @@ async function preview(options, doFetch, pageUrl) {
50
193
  async signOut() {
51
194
  signedIn = false;
52
195
  },
53
- resource: (name) => store.resource(name),
196
+ resource: writes(marked.simulatesWrites, (name) => {
197
+ const own = store(name);
198
+ const client = own.resource(name);
199
+ const known = own.describe(name)?.fields ?? [];
200
+ // A missing field the mock data does not hold either is invented, as live: naming it would be a 422.
201
+ const absent = marked.gaps.filter(({ resource, veld }) => resource === name && veld !== undefined && !known.some((field) => field.name === veld));
202
+ return absent.length > 0 ? mixedResource(client, absent, examples, examples.rows(name)) : client;
203
+ }),
204
+ schema: async (name) => withGaps(store(name).describe(name), marked.gaps),
205
+ };
206
+ }
207
+ /** A wireframe's marks and its mock data. Only a wireframe reads the mock: an ordinary app never fetches it. */
208
+ async function wireframeParts(soort, options, doFetch, pageUrl) {
209
+ const [mock, manifest] = soort === 'wireframe' ? await appFiles(options, doFetch, pageUrl) : [{}, null];
210
+ // The manifest's ceiling is the live client's: a missing resource is not in it, so the store checks none.
211
+ return { marked: marks(soort, manifest), examples: new PreviewStore(mock) };
212
+ }
213
+ /** The session on the DMP, live or trial: a wireframe mixes in its gaps, everything else goes to the client. */
214
+ function onDmp(connection, { marked, examples }) {
215
+ const { client } = connection;
216
+ let capabilities = null;
217
+ return {
218
+ ...marked,
219
+ mode: connection.mode,
220
+ get signInError() {
221
+ return connection.signInError();
222
+ },
223
+ user: connection.user,
224
+ signIn: connection.signIn,
225
+ signOut: connection.signOut,
226
+ resource: writes(marked.simulatesWrites, (name) => marked.isExample(name)
227
+ ? examples.resource(name)
228
+ : marked.gaps.some((gap) => gap.resource === name)
229
+ ? mixedResource(client.resources(name), marked.gaps, examples, examples.rows(name))
230
+ : client.resources(name)),
231
+ async schema(name) {
232
+ if (marked.isExample(name)) {
233
+ return withGaps(examples.describe(name), marked.gaps);
234
+ }
235
+ capabilities ??= client.capabilities();
236
+ try {
237
+ const found = (await capabilities).resources.find((resource) => resource.name === name);
238
+ return found === undefined ? null : withGaps(schemaFromCapability(found), marked.gaps);
239
+ }
240
+ catch (error) {
241
+ // A failed call (signed out, offline) must not stay cached for the whole session.
242
+ capabilities = null;
243
+ throw error;
244
+ }
245
+ },
54
246
  };
55
247
  }
56
248
  async function live(config, options, doFetch, pageUrl) {
@@ -70,6 +262,7 @@ async function live(config, options, doFetch, pageUrl) {
70
262
  });
71
263
  const client = createClient({ baseUrl: config.apiUrl ?? `${dmpUrl}/apps/api/v1`, auth: oauth, fetch: doFetch });
72
264
  let signInError = null;
265
+ const parts = await wireframeParts(config.soort, options, doFetch, pageUrl);
73
266
  tokens = await store.get();
74
267
  try {
75
268
  await oauth.handleCallback();
@@ -81,11 +274,10 @@ async function live(config, options, doFetch, pageUrl) {
81
274
  if (tokens === null && signInError === null && options.autoSignIn === true) {
82
275
  await oauth.signIn();
83
276
  }
84
- return {
277
+ return onDmp({
85
278
  mode: 'live',
86
- get signInError() {
87
- return signInError;
88
- },
279
+ client,
280
+ signInError: () => signInError,
89
281
  user: () => (tokens === null ? null : userFrom(tokens)),
90
282
  async signIn() {
91
283
  signInError = null;
@@ -94,9 +286,44 @@ async function live(config, options, doFetch, pageUrl) {
94
286
  async signOut() {
95
287
  await oauth.signOut();
96
288
  },
97
- resource: (name) => client.resources(name),
289
+ }, parts);
290
+ }
291
+ /**
292
+ * A trial reads only. A write never leaves the page (the browser would block it at the CORS preflight and the app would
293
+ * see a TypeError): it answers the 403 the DMP would, so it surfaces as a DmpError like any refusal.
294
+ */
295
+ function readOnly(doFetch) {
296
+ return async (input, init) => {
297
+ const method = (init?.method ?? 'GET').toUpperCase();
298
+ if (method === 'GET' || method === 'HEAD') {
299
+ return doFetch(input, init);
300
+ }
301
+ return new Response(JSON.stringify({ status: 403, code: 'insufficient_scope' }), {
302
+ status: 403,
303
+ headers: { 'Content-Type': 'application/problem+json' },
304
+ });
98
305
  };
99
306
  }
307
+ /** The live session with the injected token instead of OAuth: no discovery, no callback, no storage. */
308
+ async function trialSession(config, options, doFetch, pageUrl) {
309
+ let token = config.token;
310
+ // Read on every request: once signed out no token goes out, so the DMP answers 401.
311
+ const client = createClient({ baseUrl: config.apiUrl, auth: () => token, fetch: readOnly(doFetch) });
312
+ const parts = await wireframeParts(config.soort ?? undefined, options, doFetch, pageUrl);
313
+ return onDmp({
314
+ mode: 'trial',
315
+ client,
316
+ signInError: () => null,
317
+ user: () => (token === null ? null : { id: config.userId, name: null, scopes: ['api:read'] }),
318
+ // The DMP writes a token into the page on every load: a reload is the sign-in.
319
+ async signIn() {
320
+ globalThis.location?.reload();
321
+ },
322
+ async signOut() {
323
+ token = null;
324
+ },
325
+ }, parts);
326
+ }
100
327
  /**
101
328
  * Who the token belongs to, read from the access token itself (cube/oauth-server issues JWTs).
102
329
  * For display only: the DMP checks the token on every request, this SDK does not verify it.
@@ -234,11 +461,22 @@ function resolve(path, pageUrl) {
234
461
  function mock(data) {
235
462
  registeredMock = data;
236
463
  }
464
+ /**
465
+ * Registers the house style before `connect()`, for an app that holds it in code instead of shipping
466
+ * `cube-theme.json`, and applies it right away. `null` forgets it again (neutral).
467
+ */
468
+ function theme(input) {
469
+ registeredTheme = input;
470
+ setTheme(resolveTheme(input, currentPage()));
471
+ }
237
472
  /** What `cube-sdk.js` puts on `window`, and what this package exports as `CubeApp`. */
238
473
  export const CubeApp = Object.freeze({
239
474
  connect,
475
+ /** The session the app connected, or one connected with the defaults. What blocks use. */
476
+ ready,
240
477
  mock,
241
- /** This SDK's version, e.g. `'1.0.0-rc.4'`. */
478
+ theme,
479
+ /** This SDK's version, e.g. `'1.1.0-rc.2'`. */
242
480
  version: VERSION,
243
481
  /** The manifest `"sdk"` value this build serves. */
244
482
  sdk: MANIFEST_SDK,
package/dist/browser.js CHANGED
@@ -1,3 +1,16 @@
1
- import { CubeApp } from './app.js';
1
+ import { CubeApp as Core } from './app.js';
2
+ import { trialConfig } from './trial.js';
3
+ import { defineBlocks } from './ui/define.js';
4
+ import { showTrialBanner } from './ui/trial-banner.js';
2
5
  // The entry of dist/cube-sdk.js: one classic script, no imports, `window.CubeApp` for a page without a build step.
6
+ const CubeApp = Object.freeze({ ...Core, defineBlocks });
3
7
  globalThis.CubeApp = CubeApp;
8
+ // An engine without custom elements (an old browser, a script run outside a page) keeps the plain CubeApp.
9
+ if (typeof customElements !== 'undefined')
10
+ defineBlocks();
11
+ // A trial page (the config was read when trial.ts evaluated): say so at the top, and follow the trial's status.
12
+ const trial = trialConfig();
13
+ const page = globalThis;
14
+ if (trial !== null && page.document !== undefined && page.CSSStyleSheet !== undefined) {
15
+ showTrialBanner(page, trial);
16
+ }
package/dist/client.d.ts CHANGED
@@ -37,6 +37,8 @@ export interface CreateOptions {
37
37
  export interface WriteOptions {
38
38
  /** A `recordVersion` from an earlier response (never the ETag). A stale one answers 409 `conflict`. */
39
39
  ifMatch?: string;
40
+ /** One key per save attempt (`newIdempotencyKey()`); send it again only to retry that same body. */
41
+ idempotencyKey?: string;
40
42
  fields?: readonly string[];
41
43
  }
42
44
  export interface OperationOptions {
package/dist/client.js CHANGED
@@ -99,7 +99,11 @@ export function createClient(options) {
99
99
  body: input ?? {},
100
100
  headers: writeHeaders(opts),
101
101
  });
102
- return { ...response, data: response.status === 204 ? null : response.data, accepted: response.status === 202 };
102
+ return {
103
+ ...response,
104
+ data: response.status === 204 ? null : response.data,
105
+ accepted: response.status === 202,
106
+ };
103
107
  };
104
108
  return {
105
109
  name,