@cube-nl/dmp-sdk 1.0.0-rc.3 → 1.1.0-rc.1

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 (89) hide show
  1. package/README.md +347 -25
  2. package/dist/app.d.ts +36 -2
  3. package/dist/app.js +192 -10
  4. package/dist/browser.js +6 -1
  5. package/dist/client.d.ts +2 -0
  6. package/dist/cube-sdk.js +179 -2
  7. package/dist/cube-ui.manifest.json +971 -0
  8. package/dist/index.d.ts +6 -0
  9. package/dist/index.js +4 -0
  10. package/dist/preview.d.ts +18 -0
  11. package/dist/preview.js +199 -34
  12. package/dist/size.json +6 -0
  13. package/dist/ui/aggregate-block.d.ts +73 -0
  14. package/dist/ui/aggregate-block.js +138 -0
  15. package/dist/ui/attributes.d.ts +5 -0
  16. package/dist/ui/attributes.js +72 -0
  17. package/dist/ui/block.d.ts +63 -0
  18. package/dist/ui/block.js +229 -0
  19. package/dist/ui/blocks/app-shell.d.ts +4 -0
  20. package/dist/ui/blocks/app-shell.js +167 -0
  21. package/dist/ui/blocks/board.d.ts +8 -0
  22. package/dist/ui/blocks/board.js +292 -0
  23. package/dist/ui/blocks/calc.d.ts +9 -0
  24. package/dist/ui/blocks/calc.js +240 -0
  25. package/dist/ui/blocks/chart.d.ts +7 -0
  26. package/dist/ui/blocks/chart.js +166 -0
  27. package/dist/ui/blocks/detail.d.ts +8 -0
  28. package/dist/ui/blocks/detail.js +270 -0
  29. package/dist/ui/blocks/field.d.ts +8 -0
  30. package/dist/ui/blocks/field.js +16 -0
  31. package/dist/ui/blocks/form.d.ts +8 -0
  32. package/dist/ui/blocks/form.js +153 -0
  33. package/dist/ui/blocks/gap-list.d.ts +4 -0
  34. package/dist/ui/blocks/gap-list.js +36 -0
  35. package/dist/ui/blocks/kpi.d.ts +4 -0
  36. package/dist/ui/blocks/kpi.js +74 -0
  37. package/dist/ui/blocks/period-picker.d.ts +4 -0
  38. package/dist/ui/blocks/period-picker.js +155 -0
  39. package/dist/ui/blocks/state.d.ts +4 -0
  40. package/dist/ui/blocks/state.js +135 -0
  41. package/dist/ui/blocks/table.d.ts +8 -0
  42. package/dist/ui/blocks/table.js +607 -0
  43. package/dist/ui/blocks/top.d.ts +4 -0
  44. package/dist/ui/blocks/top.js +67 -0
  45. package/dist/ui/chart-svg.d.ts +28 -0
  46. package/dist/ui/chart-svg.js +111 -0
  47. package/dist/ui/csv.d.ts +9 -0
  48. package/dist/ui/csv.js +55 -0
  49. package/dist/ui/define.d.ts +24 -0
  50. package/dist/ui/define.js +107 -0
  51. package/dist/ui/dom.d.ts +12 -0
  52. package/dist/ui/dom.js +57 -0
  53. package/dist/ui/fields.d.ts +39 -0
  54. package/dist/ui/fields.js +73 -0
  55. package/dist/ui/format.d.ts +44 -0
  56. package/dist/ui/format.js +214 -0
  57. package/dist/ui/inputs.d.ts +35 -0
  58. package/dist/ui/inputs.js +161 -0
  59. package/dist/ui/manifest.d.ts +40 -0
  60. package/dist/ui/manifest.js +878 -0
  61. package/dist/ui/period.d.ts +18 -0
  62. package/dist/ui/period.js +52 -0
  63. package/dist/ui/query.d.ts +26 -0
  64. package/dist/ui/query.js +98 -0
  65. package/dist/ui/schema.d.ts +23 -0
  66. package/dist/ui/schema.js +21 -0
  67. package/dist/ui/styles.d.ts +8 -0
  68. package/dist/ui/styles.js +51 -0
  69. package/dist/ui/texts.d.ts +134 -0
  70. package/dist/ui/texts.js +122 -0
  71. package/dist/ui/theme.d.ts +58 -0
  72. package/dist/ui/theme.js +311 -0
  73. package/dist/ui/write.d.ts +20 -0
  74. package/dist/ui/write.js +54 -0
  75. package/dist/version.d.ts +1 -1
  76. package/dist/version.js +1 -1
  77. package/dist/wireframe.d.ts +37 -0
  78. package/dist/wireframe.js +315 -0
  79. package/fonts/OFL-albert-sans.txt +93 -0
  80. package/fonts/OFL-ibm-plex-sans.txt +93 -0
  81. package/fonts/OFL-inter.txt +93 -0
  82. package/fonts/OFL-nunito-sans.txt +93 -0
  83. package/fonts/OFL-source-sans-3.txt +93 -0
  84. package/fonts/albert-sans.woff2 +0 -0
  85. package/fonts/ibm-plex-sans.woff2 +0 -0
  86. package/fonts/inter.woff2 +0 -0
  87. package/fonts/nunito-sans.woff2 +0 -0
  88. package/fonts/source-sans-3.woff2 +0 -0
  89. package/package.json +8 -3
package/dist/index.d.ts CHANGED
@@ -5,4 +5,10 @@ export { buildQuery } from './query.js';
5
5
  export type * from './types.js';
6
6
  export { CubeApp, type ConnectOptions, type CubeAppConfig, type CubeAppSession, type CubeAppStatic, type CubeAppUser, } from './app.js';
7
7
  export type { AppManifest, MockData, MockField, MockResource, MockSchema } from './preview.js';
8
+ export { defineBlocks, type BlockScope, type DefineOptions } from './ui/define.js';
9
+ export { contrastRatio, deriveTheme, FONTS, TOKEN_NAMES } from './ui/theme.js';
10
+ export type { Corners, FontFile, FontId, ResolvedTheme, ThemeFile, ThemeSource, ThemeTokens, ThemeWarning, TokenName, } from './ui/theme.js';
11
+ export { formatValue, type Format } from './ui/format.js';
12
+ export type { FieldInfo, ResourceSchema } from './ui/schema.js';
13
+ export { blockManifest, type AttributeSpec, type BlockManifest, type BlockSpec } from './ui/manifest.js';
8
14
  export { VERSION } from './version.js';
package/dist/index.js CHANGED
@@ -3,4 +3,8 @@ export { bearer, createOAuth, discoverMetadata, memoryKeyValueStore, memoryToken
3
3
  export { DmpError, isDmpError } from './errors.js';
4
4
  export { buildQuery } from './query.js';
5
5
  export { CubeApp, } from './app.js';
6
+ export { defineBlocks } from './ui/define.js';
7
+ export { contrastRatio, deriveTheme, FONTS, TOKEN_NAMES } from './ui/theme.js';
8
+ export { formatValue } from './ui/format.js';
9
+ export { blockManifest } from './ui/manifest.js';
6
10
  export { VERSION } from './version.js';
package/dist/preview.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ResourceClient } from './client.js';
2
2
  import type { DmpRecord } from './types.js';
3
+ import type { ResourceSchema } from './ui/schema.js';
3
4
  /**
4
5
  * One field as `dmp.describe` (and so `cube.mock_data`) writes it: a JSON Schema. `type` is a
5
6
  * name, or a list of names where `'null'` makes it nullable. `_capabilities`' own field shape
@@ -25,6 +26,15 @@ export interface MockSchema {
25
26
  readonly?: readonly string[];
26
27
  /** What a row carries when the query names no `fields`, as on the REST face. Absent: every field. */
27
28
  summary_fields?: readonly string[];
29
+ /** The fields `group` may name. Absent: every field. */
30
+ groupable?: readonly string[];
31
+ /** Per field the functions `fn` may apply. Absent: every field, every function. Bare `count` is always allowed. */
32
+ aggregatable?: Record<string, readonly string[]>;
33
+ /** The allowed values of each choice field, and whether a write may set it (apps#93). */
34
+ choices?: Record<string, {
35
+ values: readonly unknown[];
36
+ writable: boolean;
37
+ }>;
28
38
  max_results?: number;
29
39
  fields?: Record<string, MockField> | ReadonlyArray<MockField & {
30
40
  name: string;
@@ -44,6 +54,10 @@ export interface AppManifest {
44
54
  description?: string;
45
55
  uses?: Record<string, readonly string[]>;
46
56
  sdk?: string;
57
+ /** `'wireframe'`: a proposal whose data partly does not exist yet; see `ontbreekt`. */
58
+ soort?: string;
59
+ /** What a wireframe needs that the DMP does not have yet: the gap entries (`readGaps`). */
60
+ ontbreekt?: unknown[];
47
61
  [key: string]: unknown;
48
62
  }
49
63
  /**
@@ -59,7 +73,11 @@ export declare class PreviewStore {
59
73
  constructor(data: MockData, manifest?: AppManifest | null,
60
74
  /** Runs before every call; throw to refuse it (the preview's signed-out state). */
61
75
  guard?: () => void);
76
+ /** The rows a resource holds now; empty for an unknown resource. */
77
+ rows(name: string): readonly DmpRecord[];
62
78
  resourceNames(): string[];
79
+ /** The resource as a block sees it, built from the parsed schema; null for an unknown resource. */
80
+ describe(name: string): ResourceSchema | null;
63
81
  resource<T extends DmpRecord = DmpRecord>(name: string): ResourceClient<T>;
64
82
  /**
65
83
  * Unknown resource first (404, as the REST face answers it), then the manifest: live, the app's
package/dist/preview.js CHANGED
@@ -3,7 +3,11 @@ import { buildQuery } from './query.js';
3
3
  const RESOURCE_NAME = /^[a-z][a-z0-9_]*$/;
4
4
  const OPERATORS = ['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'in', 'like', 'ilike', 'is', 'between'];
5
5
  const SYMBOLS = [['>=', 'gte'], ['<=', 'lte'], ['<>', 'neq'], ['>', 'gt'], ['<', 'lt'], ['=', 'eq']];
6
+ const FUNCTIONS = ['count', 'sum', 'avg', 'min', 'max'];
7
+ const UNITS = ['day', 'week', 'month', 'quarter', 'year'];
6
8
  const MAX_FILTERS = 20;
9
+ const MAX_DIMENSIONS = 5;
10
+ const MAX_GROUPS = 1000;
7
11
  const MAX_IN_VALUES = 200;
8
12
  const DEFAULT_PAGE_SIZE = 50;
9
13
  const MAX_PAGE_SIZE = 200;
@@ -33,9 +37,42 @@ export class PreviewStore {
33
37
  }
34
38
  }
35
39
  }
40
+ /** The rows a resource holds now; empty for an unknown resource. */
41
+ rows(name) {
42
+ return this.tables.get(name)?.rows ?? [];
43
+ }
36
44
  resourceNames() {
37
45
  return [...this.tables.keys()];
38
46
  }
47
+ /** The resource as a block sees it, built from the parsed schema; null for an unknown resource. */
48
+ describe(name) {
49
+ const table = this.tables.get(name);
50
+ if (table === undefined) {
51
+ return null;
52
+ }
53
+ return {
54
+ name,
55
+ actions: [...table.actions],
56
+ fields: [...table.fields.values()].map((field) => {
57
+ const choices = table.choices.get(field.name);
58
+ return {
59
+ name: field.name,
60
+ type: field.types.join('|'),
61
+ format: field.format,
62
+ nullable: field.nullable,
63
+ readonly: !table.writable.has(field.name),
64
+ enum: choices === undefined ? null : choices.map(String),
65
+ description: field.description,
66
+ };
67
+ }),
68
+ summary: [...table.summary],
69
+ filterable: [...table.filterable],
70
+ sortable: [...table.fields.keys()],
71
+ groupable: [...table.groupable],
72
+ // Live lists the field functions only: a bare count needs no field, so `count` is never offered per field.
73
+ aggregatable: Object.fromEntries([...table.aggregatable].map(([key, fns]) => [key, fns.filter((fn) => fn !== 'count')])),
74
+ };
75
+ }
39
76
  resource(name) {
40
77
  if (!RESOURCE_NAME.test(name)) {
41
78
  throw new TypeError(`'${name}' is not a resource name: cube/api names match [a-z][a-z0-9_]*.`);
@@ -64,7 +101,7 @@ export class PreviewStore {
64
101
  }
65
102
  },
66
103
  get: (id, query = {}) => run('get', (table) => getRow(table, id, query)),
67
- aggregate: (query) => run('list', (table) => aggregateRows(table, query)),
104
+ aggregate: (query) => run('aggregate', (table) => aggregateRows(table, query)),
68
105
  create: (body, options = {}) => run('create', (table) => createRow(table, body, options)),
69
106
  update: (id, body, options = {}) => run('update', (table) => updateRow(table, id, body, options)),
70
107
  delete: (id, options = {}) => run('delete', (table) => deleteRow(table, id, options)),
@@ -115,21 +152,45 @@ function table(name, resource) {
115
152
  for (const row of fields.size === 0 ? rows : []) {
116
153
  for (const key of Object.keys(row)) {
117
154
  if (!fields.has(key)) {
118
- fields.set(key, { name: key, types: [], nullable: true });
155
+ fields.set(key, { name: key, types: [], nullable: true, format: null, description: null });
119
156
  }
120
157
  }
121
158
  }
122
159
  const names = [...fields.keys()];
160
+ // A malformed key or entry is ignored as if absent, so one bad line in a mock never breaks the preview.
161
+ const choiceEntries = (isObject(schema.choices) ? Object.entries(schema.choices) : []).filter((entry) => isObject(entry[1]) && Array.isArray(entry[1].values));
123
162
  const readonly = new Set([
124
163
  'id',
125
164
  ...(schema.readonly ?? []),
126
165
  ...declared.filter(([, definition]) => definition.readonly === true).map(([fieldName]) => fieldName),
166
+ ...choiceEntries
167
+ .filter(([, choice]) => choice.writable === false)
168
+ .map(([fieldName]) => fieldName),
127
169
  ]);
128
- const known = (list) => list === undefined ? null : list.filter((item) => fields.has(item));
170
+ const choices = new Map();
171
+ for (const [fieldName, definition] of declared) {
172
+ if (Array.isArray(definition.enum)) {
173
+ choices.set(fieldName, definition.enum);
174
+ }
175
+ }
176
+ for (const [fieldName, choice] of choiceEntries) {
177
+ choices.set(fieldName, choice.values);
178
+ }
179
+ const known = (list) => Array.isArray(list) ? list.filter((item) => typeof item === 'string' && fields.has(item)) : null;
129
180
  return {
130
181
  name,
182
+ actions: Array.isArray(schema.actions)
183
+ ? schema.actions.filter((action) => typeof action === 'string')
184
+ : ['list', 'get', 'create', 'update', 'delete'],
131
185
  fields,
132
186
  filterable: known(schema.filters) ?? names,
187
+ groupable: known(schema.groupable) ?? names,
188
+ aggregatable: new Map(isObject(schema.aggregatable)
189
+ ? Object.entries(schema.aggregatable)
190
+ .filter(([fieldName, allowed]) => fields.has(fieldName) && Array.isArray(allowed))
191
+ .map(([fieldName, allowed]) => [fieldName, [...allowed]])
192
+ : names.map((fieldName) => [fieldName, [...FUNCTIONS]])),
193
+ choices,
133
194
  writable: new Set(names.filter((fieldName) => !readonly.has(fieldName))),
134
195
  summary: (known(schema.summary_fields) ?? []).length > 0 ? known(schema.summary_fields) : names,
135
196
  rows,
@@ -144,6 +205,8 @@ function field(name, definition) {
144
205
  name,
145
206
  types: list.filter((type) => type !== 'null' && type !== ''),
146
207
  nullable: definition.nullable === true || list.includes('null'),
208
+ format: typeof definition.format === 'string' ? definition.format : null,
209
+ description: typeof definition.description === 'string' ? definition.description : null,
147
210
  };
148
211
  }
149
212
  /**
@@ -342,14 +405,25 @@ function readPage(params) {
342
405
  }
343
406
  function aggregateRows(table, query) {
344
407
  const params = parse(table, query);
345
- const filters = readFilters(table, params);
346
- const functions = (params.get('fn') ?? '').split(',').filter((item) => item !== '');
347
- const groups = (params.get('group') ?? '').split(',').filter((item) => item !== '');
348
- if (functions.length === 0) {
349
- throw problem(422, 'query_invalid', 'fn is required.', { fn: ['Name at least one function, e.g. fn=count.'] });
408
+ // As the wire reads a query: fn entries, then group entries, then filters.
409
+ const rawFn = (params.get('fn') ?? '').trim();
410
+ if (rawFn === '') {
411
+ throw problem(422, 'query_invalid', 'fn is required: name at least one aggregation function.', {
412
+ fn: [`Expected e.g. fn=count or fn=sum(field). ${aggregatableHint(table)}`],
413
+ });
350
414
  }
415
+ const functions = rawFn.split(',').map((item) => item.trim());
351
416
  const readers = functions.map((fn) => aggregator(table, fn));
417
+ rejectDuplicates(functions, 'fn');
418
+ const rawGroup = (params.get('group') ?? '').trim();
419
+ const groups = rawGroup === '' ? [] : rawGroup.split(',').map((item) => item.trim());
420
+ // Before the entries: the repair for too many dimensions is a coarser question, not a fixed name.
421
+ if (groups.length > MAX_DIMENSIONS) {
422
+ throw problem(422, 'cardinality_exceeded', `group may not exceed ${MAX_DIMENSIONS} dimensions, ${groups.length} given.`, { group: [`Use at most ${MAX_DIMENSIONS} dimensions.`] });
423
+ }
352
424
  const dimensions = groups.map((group) => dimension(table, group));
425
+ rejectDuplicates(groups, 'group');
426
+ const filters = readFilters(table, params);
353
427
  const buckets = new Map();
354
428
  for (const row of table.rows.filter((candidate) => filters.every((filter) => matches(table, candidate, filter)))) {
355
429
  const key = {};
@@ -364,39 +438,86 @@ function aggregateRows(table, query) {
364
438
  if (groups.length === 0 && buckets.size === 0) {
365
439
  buckets.set('[]', { key: {}, rows: [] });
366
440
  }
367
- const data = [...buckets.values()].map(({ key, rows }) => {
441
+ if (buckets.size > MAX_GROUPS) {
442
+ throw problem(422, 'cardinality_exceeded', 'The grouping produces more groups than the configured maximum.', {
443
+ groepen: [
444
+ 'The grouping produces more groups than the configured maximum.',
445
+ `Allowed values: groepeer grover, of filter eerst; maximaal ${MAX_GROUPS} groepen.`,
446
+ ],
447
+ }, 'too_many_groups');
448
+ }
449
+ const ordered = [...buckets.values()].sort((a, b) => {
450
+ for (const group of groups) {
451
+ const order = compare(a.key[group], b.key[group]);
452
+ if (order !== 0) {
453
+ return order;
454
+ }
455
+ }
456
+ return 0;
457
+ });
458
+ const data = ordered.map(({ key, rows }) => {
368
459
  const result = { ...key };
369
460
  for (const [index, fn] of functions.entries()) {
370
461
  result[fn] = readers[index](rows);
371
462
  }
372
463
  return result;
373
464
  });
374
- return response(200, data, { ...meta(table, null, []), groups: groups.length === 0 ? null : data.length });
465
+ return response(200, data, { ...meta(table, null, []), groups: data.length });
466
+ }
467
+ /** As live refuses it: two entries would collapse into one response key. */
468
+ function rejectDuplicates(entries, parameter) {
469
+ const duplicates = [...new Set(entries.filter((entry, index) => entries.indexOf(entry) !== index))];
470
+ if (duplicates.length > 0) {
471
+ throw problem(422, 'query_invalid', `Duplicate ${parameter} entry: ${duplicates.join(', ')}.`, {
472
+ [parameter]: [`List each ${parameter} entry once.`],
473
+ });
474
+ }
475
+ }
476
+ function aggregatableHint(table) {
477
+ return table.aggregatable.size === 0
478
+ ? 'This resource has no aggregatable fields; only bare count is available.'
479
+ : `Aggregatable fields: ${[...table.aggregatable.keys()].join(', ')}.`;
375
480
  }
376
481
  function aggregator(table, fn) {
377
- if (fn === 'count') {
482
+ const call = /^([a-z]+)\(([a-z0-9_]+)\)$/.exec(fn);
483
+ if (call === null && !/^[a-z]+$/.test(fn)) {
484
+ throw problem(422, 'query_invalid', `Unparseable fn entry: ${fn}.`, {
485
+ fn: ['Expected `count` or `function(field)`, e.g. sum(field).'],
486
+ });
487
+ }
488
+ const name = call === null ? fn : call[1];
489
+ const target = call === null ? null : call[2];
490
+ if (!FUNCTIONS.includes(name)) {
491
+ throw problem(422, 'query_invalid', `Unknown aggregation function: ${name}.`, {
492
+ fn: [`Allowed values: ${FUNCTIONS.join(', ')}.`],
493
+ });
494
+ }
495
+ if (target === null) {
496
+ if (name !== 'count') {
497
+ throw problem(422, 'query_invalid', `${name} must be applied to a field, e.g. ${name}(field).`, {
498
+ fn: [aggregatableHint(table)],
499
+ });
500
+ }
378
501
  return (rows) => rows.length;
379
502
  }
380
- const match = /^(count|sum|avg|min|max)\(([^)]+)\)$/.exec(fn);
381
- if (match === null) {
382
- throw problem(422, 'query_invalid', `Unknown function: ${fn}.`, {
383
- fn: ['Allowed: count, count(field), sum(field), avg(field), min(field), max(field).'],
384
- }, 'unknown_function');
503
+ const allowed = table.aggregatable.get(target);
504
+ if (allowed === undefined) {
505
+ throw problem(422, 'query_invalid', `Unknown aggregatable field: ${target}.`, { fn: [aggregatableHint(table)] });
385
506
  }
386
- const [, name, target] = match;
387
- if (!table.fields.has(target)) {
388
- throw problem(422, 'query_invalid', `Unknown fn: ${target}.`, {
389
- fn: [`Allowed values: ${[...table.fields.keys()].join(', ')}.`],
390
- }, 'not_aggregatable');
507
+ if (!allowed.includes(name)) {
508
+ throw problem(422, 'query_invalid', `${name} is not allowed on ${target}.`, {
509
+ fn: [`Allowed functions for ${target}: ${allowed.join(', ')}.`],
510
+ });
391
511
  }
392
512
  return (rows) => {
393
513
  const values = rows.map((row) => row[target]).filter((value) => value != null);
394
514
  const numbers = values.map(Number).filter((value) => Number.isFinite(value));
395
515
  switch (name) {
396
516
  case 'count':
397
- return values.length;
517
+ // Live emits count(*) whatever the field, so count(field) counts the group's rows, nulls included.
518
+ return rows.length;
398
519
  case 'sum':
399
- return numbers.reduce((sum, value) => sum + value, 0);
520
+ return numbers.length === 0 ? null : numbers.reduce((sum, value) => sum + value, 0);
400
521
  case 'avg':
401
522
  return numbers.length === 0 ? null : numbers.reduce((sum, value) => sum + value, 0) / numbers.length;
402
523
  case 'min':
@@ -407,23 +528,58 @@ function aggregator(table, fn) {
407
528
  };
408
529
  }
409
530
  function dimension(table, group) {
410
- const [name, truncation] = group.split('.');
411
- if (!table.fields.has(name)) {
531
+ const parts = group.split('.');
532
+ if (parts.length > 2) {
533
+ throw problem(422, 'query_invalid', `Unparseable group entry: ${group}.`, {
534
+ group: ['Expected `field` or `field.unit`, e.g. published_at.month.'],
535
+ });
536
+ }
537
+ const [name, truncation] = parts;
538
+ if (!table.groupable.includes(name)) {
412
539
  throw problem(422, 'query_invalid', `Unknown group: ${name}.`, {
413
- group: [`Allowed values: ${[...table.fields.keys()].join(', ')}.`],
540
+ group: [`Allowed values: ${table.groupable.join(', ')}.`],
414
541
  }, 'not_groupable');
415
542
  }
416
- const lengths = { year: 4, month: 7, day: 10 };
417
543
  if (truncation === undefined) {
418
544
  return (row) => row[name] ?? null;
419
545
  }
420
- const length = lengths[truncation];
421
- if (length === undefined) {
422
- throw problem(422, 'query_invalid', `Unknown truncation: ${truncation}.`, {
423
- group: ['Allowed truncations in the preview: year, month, day.'],
424
- }, 'unknown_truncation');
546
+ if (!UNITS.includes(truncation)) {
547
+ throw problem(422, 'query_invalid', `Unknown truncation unit: ${truncation}.`, {
548
+ group: [`Allowed values: ${UNITS.join(', ')}.`],
549
+ });
550
+ }
551
+ return (row) => bucket(row[name], truncation);
552
+ }
553
+ /** The date as written (first 10 characters), never shifted through a time zone; null when it is not a date. */
554
+ function bucket(value, unit) {
555
+ const match = typeof value === 'string' ? /^(\d{4})-(\d{2})-(\d{2})/.exec(value) : null;
556
+ if (match === null) {
557
+ return null;
558
+ }
559
+ const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])];
560
+ const date = new Date(Date.UTC(year, month - 1, day));
561
+ if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) {
562
+ return null;
563
+ }
564
+ switch (unit) {
565
+ case 'day':
566
+ return match[0];
567
+ case 'week': {
568
+ // ISO week: the Thursday of the Monday-based week decides the ISO year.
569
+ const thursday = new Date(date.getTime() + (3 - ((date.getUTCDay() + 6) % 7)) * 86_400_000);
570
+ const isoYear = thursday.getUTCFullYear();
571
+ const week = 1 + Math.floor((thursday.getTime() - Date.UTC(isoYear, 0, 1)) / (7 * 86_400_000));
572
+ return `${isoYear}-W${String(week).padStart(2, '0')}`;
573
+ }
574
+ case 'month':
575
+ return match[0].slice(0, 7);
576
+ case 'quarter':
577
+ return `${match[1]}-Q${Math.ceil(month / 3)}`;
578
+ case 'year':
579
+ return match[1];
580
+ default:
581
+ return null;
425
582
  }
426
- return (row) => (typeof row[name] === 'string' ? row[name].slice(0, length) : null);
427
583
  }
428
584
  // ---------------------------------------------------------------------------------------------
429
585
  // Writing
@@ -491,7 +647,8 @@ function validate(table, body) {
491
647
  }
492
648
  const errors = {};
493
649
  for (const [name, value] of Object.entries(body)) {
494
- const problemText = typeError(table.fields.get(name), value);
650
+ const definition = table.fields.get(name);
651
+ const problemText = typeError(definition, value) ?? choiceError(table, definition, name, value);
495
652
  if (problemText !== null) {
496
653
  errors[name] = [problemText];
497
654
  }
@@ -501,6 +658,14 @@ function validate(table, body) {
501
658
  }
502
659
  return clone(body);
503
660
  }
661
+ /** Laravel's `in` rule: a value outside the field's allowed values. Runs only after the type check passed. */
662
+ function choiceError(table, definition, name, value) {
663
+ const allowed = table.choices.get(name);
664
+ if (allowed === undefined || (value === null && definition?.nullable === true) || allowed.includes(value)) {
665
+ return null;
666
+ }
667
+ return `The selected ${name.replace(/_/g, ' ')} is invalid.`;
668
+ }
504
669
  /** Laravel's rules and wording for the one declared type, as the REST face's TypeRules applies them. */
505
670
  function typeError(definition, value) {
506
671
  if (definition === undefined || definition.types.length !== 1) {
package/dist/size.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "core_gzip": 17364,
3
+ "total_gzip": 43471,
4
+ "ui_gzip": 26107,
5
+ "budget_gzip": 40960
6
+ }
@@ -0,0 +1,73 @@
1
+ import type { CubeAppSession } from '../app.js';
2
+ import type { DmpRecord, DmpResponse, FilterValue } from '../types.js';
3
+ import type { Period } from './period.js';
4
+ import { type PeriodSourceLike } from './query.js';
5
+ /** The frame every aggregate block draws its content in, so a dashboard reads as one set of cards. */
6
+ export declare const CARD_CSS = "\n.card{padding:1rem 1.25rem;border:1px solid var(--cube-color-border);border-radius:var(--cube-radius);\nbackground:var(--cube-color-surface)}\n";
7
+ /** The visible title above a chart or a ranking. */
8
+ export declare const LABEL_CSS = "\n.label{margin:0 0 .5rem;font-weight:600}\n";
9
+ export interface AggregateQueryParts {
10
+ /** The `fn` the API is asked for, and the key of the value in each row: `count`, `sum(total)`. */
11
+ fn: string;
12
+ /** The measured field; null for a row count. */
13
+ field: string | null;
14
+ count: boolean;
15
+ /** The period of the followed picker, or null without one. */
16
+ period: Period | null;
17
+ /** The block's `filter`, narrowed to `range` on the date field when both are there. */
18
+ filterFor(range: {
19
+ from: string;
20
+ to: string;
21
+ } | null): Record<string, FilterValue>;
22
+ }
23
+ /** An attribute the block cannot do without; its absence is explained in the block. */
24
+ export declare function requiredAttr(el: Element, name: string): string;
25
+ /** The fields an aggregate block reads: the measured one, the `by` field (without its truncation), the date field. */
26
+ export declare function aggregateFields(el: Element): string[];
27
+ /** A number from an aggregate row (the API may send decimals as strings); null for nothing or not a number. */
28
+ export declare function toNumber(value: unknown): number | null;
29
+ /**
30
+ * Listens to one period picker at a time and reloads its block on a change. A block that was removed lets go at
31
+ * the next change instead of reloading, so the blocks need no disconnect step of their own for it.
32
+ */
33
+ export declare class PeriodFollower {
34
+ private readonly block;
35
+ private source;
36
+ private readonly onChange;
37
+ constructor(block: {
38
+ readonly isConnected: boolean;
39
+ refresh(): Promise<void>;
40
+ });
41
+ follow(source: PeriodSourceLike | null): void;
42
+ }
43
+ /**
44
+ * Reads `measure`, `filter`, `period` and `date-field` (falling back to the field of a truncated `by`), waits for
45
+ * the picker and follows it. Null when the load was aborted meanwhile: a newer load (or none) owns the block.
46
+ */
47
+ export declare function aggregateQuery(el: HTMLElement, follower: PeriodFollower, signal: AbortSignal, by?: string | null): Promise<AggregateQueryParts | null>;
48
+ /**
49
+ * The aggregate of a grouped block (chart, top) over its period. More groups than the DMP returns in one answer
50
+ * (1000) is refused with a 422; trying again gives the same answer, so the error says what to change instead.
51
+ */
52
+ export declare function groupedAggregate(session: CubeAppSession, resource: string, by: string, query: AggregateQueryParts, signal: AbortSignal): Promise<DmpResponse<DmpRecord[]>>;
53
+ /** A whole number attribute of at least `min`; absent, not a number or too small gives `fallback`. */
54
+ export declare function wholeAttr(el: Element, name: string, min: number, fallback: number): number;
55
+ export interface RankedGroup {
56
+ key: string;
57
+ value: number;
58
+ /** The lookup's label, `#<id>` for an id it has no row for, or the key itself without a lookup. */
59
+ label: string;
60
+ }
61
+ /**
62
+ * The groups of an aggregate ranked by value (`descending` highest first) and cut at `limit`, labelled through the
63
+ * block's `lookup`. A group without a key or without a value is left out. Null when the load was aborted meanwhile.
64
+ */
65
+ export declare function rankGroups(el: Element, session: CubeAppSession, rows: DmpRecord[], keys: {
66
+ by: string;
67
+ fn: string;
68
+ }, order: {
69
+ descending: boolean;
70
+ limit: number;
71
+ }, signal: AbortSignal): Promise<RankedGroup[] | null>;
72
+ /** How the block writes a value: its `format` (default integer for count, else number) and `currency`. */
73
+ export declare function valueFormatter(el: Element, count: boolean): (n: number, compact?: boolean) => string;
@@ -0,0 +1,138 @@
1
+ // What the aggregate blocks (cube-kpi, cube-chart, cube-top) share: their required attributes, the measure and
2
+ // filter they ask with, the period picker they follow and how they write a value.
3
+ import { isDmpError } from '../errors.js';
4
+ import { BlockStateError } from './block.js';
5
+ import { formatValue, parseMeasure } from './format.js';
6
+ import { blockFilter, dateFieldFor, lookupLabels, periodFilter, resolvePeriodSource, } from './query.js';
7
+ import { NL } from './texts.js';
8
+ const FORMATS = ['number', 'integer', 'currency', 'percent'];
9
+ /** The frame every aggregate block draws its content in, so a dashboard reads as one set of cards. */
10
+ export const CARD_CSS = `
11
+ .card{padding:1rem 1.25rem;border:1px solid var(--cube-color-border);border-radius:var(--cube-radius);
12
+ background:var(--cube-color-surface)}
13
+ `;
14
+ /** The visible title above a chart or a ranking. */
15
+ export const LABEL_CSS = `
16
+ .label{margin:0 0 .5rem;font-weight:600}
17
+ `;
18
+ /** An attribute the block cannot do without; its absence is explained in the block. */
19
+ export function requiredAttr(el, name) {
20
+ const value = el.getAttribute(name)?.trim() ?? '';
21
+ if (value === '')
22
+ throw new TypeError(NL.invalid.missing(el.localName, name));
23
+ return value;
24
+ }
25
+ /** The fields an aggregate block reads: the measured one, the `by` field (without its truncation), the date field. */
26
+ export function aggregateFields(el) {
27
+ const measured = /\(([^)]+)\)/.exec(el.getAttribute('measure') ?? '')?.[1]?.trim();
28
+ const by = el.getAttribute('by')?.split('.')[0];
29
+ return [measured, by, el.getAttribute('date-field')].filter((field) => !!field);
30
+ }
31
+ /** A number from an aggregate row (the API may send decimals as strings); null for nothing or not a number. */
32
+ export function toNumber(value) {
33
+ if (typeof value === 'string' && value.trim() === '')
34
+ return null;
35
+ const n = typeof value === 'number' || typeof value === 'string' ? Number(value) : NaN;
36
+ return Number.isFinite(n) ? n : null;
37
+ }
38
+ /**
39
+ * Listens to one period picker at a time and reloads its block on a change. A block that was removed lets go at
40
+ * the next change instead of reloading, so the blocks need no disconnect step of their own for it.
41
+ */
42
+ export class PeriodFollower {
43
+ block;
44
+ source = null;
45
+ onChange = () => {
46
+ if (this.block.isConnected)
47
+ void this.block.refresh();
48
+ else
49
+ this.follow(null);
50
+ };
51
+ constructor(block) {
52
+ this.block = block;
53
+ }
54
+ follow(source) {
55
+ if (source === this.source)
56
+ return;
57
+ this.source?.removeEventListener('cube-change', this.onChange);
58
+ this.source = source;
59
+ source?.addEventListener('cube-change', this.onChange);
60
+ }
61
+ }
62
+ /**
63
+ * Reads `measure`, `filter`, `period` and `date-field` (falling back to the field of a truncated `by`), waits for
64
+ * the picker and follows it. Null when the load was aborted meanwhile: a newer load (or none) owns the block.
65
+ */
66
+ export async function aggregateQuery(el, follower, signal, by = null) {
67
+ const measure = parseMeasure(requiredAttr(el, 'measure'));
68
+ const filter = blockFilter(el);
69
+ const selector = el.getAttribute('period');
70
+ const dateField = selector === null || selector === ''
71
+ ? null
72
+ : dateFieldFor(el.getAttribute('date-field'), by, el.localName);
73
+ const source = await resolvePeriodSource(el, selector);
74
+ if (signal.aborted)
75
+ return null;
76
+ follower.follow(source);
77
+ return {
78
+ fn: measure.field === null ? measure.fn : `${measure.fn}(${measure.field})`,
79
+ field: measure.field,
80
+ count: measure.fn === 'count',
81
+ period: source?.value ?? null,
82
+ filterFor: (range) => range === null || dateField === null ? filter : { ...filter, ...periodFilter(dateField, range) },
83
+ };
84
+ }
85
+ /**
86
+ * The aggregate of a grouped block (chart, top) over its period. More groups than the DMP returns in one answer
87
+ * (1000) is refused with a 422; trying again gives the same answer, so the error says what to change instead.
88
+ */
89
+ export async function groupedAggregate(session, resource, by, query, signal) {
90
+ try {
91
+ return await session
92
+ .resource(resource)
93
+ .aggregate({ fn: query.fn, group: by, filter: query.filterFor(query.period) }, { signal });
94
+ }
95
+ catch (error) {
96
+ if (isDmpError(error) && error.status === 422
97
+ && (error.reason === 'too_many_groups' || error.code === 'cardinality_exceeded')) {
98
+ throw new BlockStateError({ kind: 'error', detail: NL.tooManyGroups, retry: false });
99
+ }
100
+ throw error;
101
+ }
102
+ }
103
+ /** A whole number attribute of at least `min`; absent, not a number or too small gives `fallback`. */
104
+ export function wholeAttr(el, name, min, fallback) {
105
+ const raw = el.getAttribute(name);
106
+ const n = raw === null || raw.trim() === '' ? NaN : Number(raw);
107
+ return Number.isFinite(n) && n >= min ? Math.floor(n) : fallback;
108
+ }
109
+ /**
110
+ * The groups of an aggregate ranked by value (`descending` highest first) and cut at `limit`, labelled through the
111
+ * block's `lookup`. A group without a key or without a value is left out. Null when the load was aborted meanwhile.
112
+ */
113
+ export async function rankGroups(el, session, rows, keys, order, signal) {
114
+ const direction = order.descending ? -1 : 1;
115
+ const ranked = rows
116
+ .filter((row) => row[keys.by] !== null && row[keys.by] !== undefined)
117
+ .map((row) => ({ key: String(row[keys.by]), value: toNumber(row[keys.fn]) }))
118
+ .filter((group) => group.value !== null)
119
+ .sort((a, b) => direction * (a.value - b.value))
120
+ .slice(0, order.limit);
121
+ const lookup = el.getAttribute('lookup');
122
+ const labels = lookup === null || lookup === '' || ranked.length === 0
123
+ ? null
124
+ : await lookupLabels(session, lookup, ranked.map((group) => group.key), signal);
125
+ if (signal.aborted)
126
+ return null;
127
+ return ranked.map((group) => ({
128
+ ...group,
129
+ label: labels === null ? group.key : (labels.get(group.key) ?? `#${group.key}`),
130
+ }));
131
+ }
132
+ /** How the block writes a value: its `format` (default integer for count, else number) and `currency`. */
133
+ export function valueFormatter(el, count) {
134
+ const wanted = el.getAttribute('format');
135
+ const format = (wanted !== null && FORMATS.includes(wanted) ? wanted : count ? 'integer' : 'number');
136
+ const currency = el.getAttribute('currency') || 'EUR';
137
+ return (n, compact) => formatValue(n, format, { currency, compact: compact === true });
138
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * The attribute names each block observes, apart from manifest.ts so the browser bundle does not carry the
3
+ * manifest's documentation texts. test/ui-manifest.test.js keeps the two in step.
4
+ */
5
+ export declare const BLOCK_ATTRIBUTES: Readonly<Record<string, readonly string[]>>;