@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
@@ -0,0 +1,166 @@
1
+ import { aggregateFields, aggregateQuery, CARD_CSS, groupedAggregate, LABEL_CSS, PeriodFollower, rankGroups, requiredAttr, toNumber, valueFormatter, wholeAttr, } from '../aggregate-block.js';
2
+ import { renderBars, renderLine } from '../chart-svg.js';
3
+ import { clear, h } from '../dom.js';
4
+ import { formatBucket, humanize, parseDimension } from '../format.js';
5
+ import { NL } from '../texts.js';
6
+ const DAY_MS = 86_400_000;
7
+ /** The width drawn at before (or without) a measurement: a test DOM, a chart not laid out yet. */
8
+ const FALLBACK_WIDTH = 600;
9
+ const KINDS = ['line', 'bar'];
10
+ const CSS = `${CARD_CSS}${LABEL_CSS}
11
+ svg{display:block;overflow:visible}
12
+ .grid{stroke:var(--cube-color-border)}
13
+ .tick,.x{font-size:12px;fill:var(--cube-color-text-muted)}
14
+ .tick{text-anchor:end}
15
+ .x{text-anchor:middle}
16
+ rect.s1,circle.s1{fill:var(--cube-color-series-1)}
17
+ path.s1{fill:none;stroke:var(--cube-color-series-1);stroke-width:2}
18
+ `;
19
+ /** The API's bucket key for a UTC day (`2026-W01` is the ISO week, its year the ISO year). */
20
+ function bucketKey(date, unit) {
21
+ const day = date.toISOString().slice(0, 10);
22
+ switch (unit) {
23
+ case 'day':
24
+ return day;
25
+ case 'week': {
26
+ const thursday = new Date(date.getTime() + (3 - ((date.getUTCDay() + 6) % 7)) * DAY_MS);
27
+ const year = thursday.getUTCFullYear();
28
+ const week = 1 + Math.floor((thursday.getTime() - Date.UTC(year, 0, 1)) / (7 * DAY_MS));
29
+ return `${year}-W${String(week).padStart(2, '0')}`;
30
+ }
31
+ case 'month':
32
+ return day.slice(0, 7);
33
+ case 'quarter':
34
+ return `${day.slice(0, 4)}-Q${Math.ceil((date.getUTCMonth() + 1) / 3)}`;
35
+ case 'year':
36
+ return day.slice(0, 4);
37
+ }
38
+ }
39
+ /** The axis text of a month: short, with the year on the first point and on January (`nov '25`, `dec`, `jan '26`). */
40
+ function monthTick(key, first) {
41
+ const short = formatBucket(key, 'month', { short: true });
42
+ return first || key.endsWith('-01') ? NL.monthYear(short, key.slice(0, 4)) : short;
43
+ }
44
+ /** Every bucket key from `from` to `to` (ISO dates, inclusive), in order. */
45
+ export function periodBuckets(from, to, unit) {
46
+ const keys = new Set();
47
+ const end = Date.parse(`${to}T00:00:00Z`);
48
+ for (let time = Date.parse(`${from}T00:00:00Z`); time <= end; time += DAY_MS) {
49
+ keys.add(bucketKey(new Date(time), unit));
50
+ }
51
+ return [...keys];
52
+ }
53
+ /** `<cube-chart>`: one measure over time (a line) or per category (bars), with a table for screen readers. */
54
+ export function chartBlock(Base, scope) {
55
+ class CubeChart extends Base {
56
+ static tag = 'cube-chart';
57
+ static css = CSS;
58
+ shownFields() {
59
+ return aggregateFields(this);
60
+ }
61
+ period = new PeriodFollower(this);
62
+ /** Holds the drawing; its width is the width the chart draws in, one unit per CSS pixel. */
63
+ plot = h(scope.document, 'div', { class: 'plot' });
64
+ observer = null;
65
+ /** The last width the observer reported, and the width of the drawing now shown. */
66
+ observed = 0;
67
+ drawn = 0;
68
+ drawing = null;
69
+ connectedCallback() {
70
+ super.connectedCallback();
71
+ if (this.observer !== null || scope.ResizeObserver === undefined)
72
+ return;
73
+ this.observer = new scope.ResizeObserver((entries) => {
74
+ const width = Math.round(entries.at(-1)?.contentRect.width ?? 0);
75
+ if (width <= 0)
76
+ return;
77
+ this.observed = width;
78
+ if (width !== this.drawn)
79
+ this.draw();
80
+ });
81
+ this.observer.observe(this.plot);
82
+ }
83
+ disconnectedCallback() {
84
+ super.disconnectedCallback();
85
+ this.observer?.disconnect();
86
+ this.observer = null;
87
+ }
88
+ async load(signal, session) {
89
+ if (session === null)
90
+ return;
91
+ const resource = requiredAttr(this, 'resource');
92
+ const by = requiredAttr(this, 'by');
93
+ const { field, truncation } = parseDimension(by);
94
+ const height = wholeAttr(this, 'height', 80, 240);
95
+ const limit = wholeAttr(this, 'limit', 1, 12);
96
+ const query = await aggregateQuery(this, this.period, signal, by);
97
+ if (query === null)
98
+ return;
99
+ const { fn, period } = query;
100
+ const { data } = await groupedAggregate(session, resource, by, query, signal);
101
+ if (signal.aborted)
102
+ return;
103
+ const found = data.filter((row) => row[by] !== null && row[by] !== undefined);
104
+ if (found.length === 0) {
105
+ this.showState('empty');
106
+ return;
107
+ }
108
+ let points;
109
+ if (truncation !== null) {
110
+ const values = new Map(found.map((row) => [String(row[by]), toNumber(row[fn])]));
111
+ const keys = period === null
112
+ ? [...values.keys()].sort()
113
+ : periodBuckets(period.from, period.to, truncation);
114
+ // A bucket that starts after today has no data yet: a gap, not a drop to 0. Past gaps are 0.
115
+ const today = period?.today ?? period?.to ?? '';
116
+ const started = period === null || today < period.from
117
+ ? new Set()
118
+ : new Set(periodBuckets(period.from, today < period.to ? today : period.to, truncation));
119
+ points = keys.map((key, index) => ({
120
+ key,
121
+ label: formatBucket(key, truncation),
122
+ ...(truncation === 'month' ? { tick: monthTick(key, index === 0) } : {}),
123
+ value: period === null || !started.has(key) ? (values.get(key) ?? null) : (values.get(key) ?? 0),
124
+ }));
125
+ }
126
+ else {
127
+ const ranked = await rankGroups(this, session, found, { by, fn }, { descending: true, limit }, signal);
128
+ if (ranked === null)
129
+ return;
130
+ if (ranked.length === 0) {
131
+ this.showState('empty');
132
+ return;
133
+ }
134
+ points = ranked;
135
+ }
136
+ const label = this.attr('label') ?? humanize(query.field ?? fn);
137
+ const kind = this.attr('kind');
138
+ this.render({ label, points, categories: truncation === null }, height, {
139
+ line: KINDS.includes(kind ?? '') ? kind === 'line' : truncation !== null,
140
+ format: valueFormatter(this, query.count),
141
+ byLabel: humanize(field),
142
+ });
143
+ }
144
+ render(series, height, options) {
145
+ const doc = scope.document;
146
+ this.drawing = { series, height, line: options.line, format: options.format };
147
+ const value = (n) => (n === null ? NL.emptyValue : options.format(n));
148
+ clear(this.body);
149
+ this.body.appendChild(h(doc, 'div', { class: 'card' }, this.attr('label') === null ? '' : h(doc, 'p', { class: 'label', 'aria-hidden': 'true' }, series.label), this.plot,
150
+ // In a clipped wrapper: an absolutely positioned table sizes to its content and would widen the page.
151
+ h(doc, 'div', { class: 'sr-only' }, h(doc, 'table', {}, h(doc, 'caption', {}, NL.asTable), h(doc, 'thead', {}, h(doc, 'tr', {}, h(doc, 'th', { scope: 'col' }, options.byLabel), h(doc, 'th', { scope: 'col' }, series.label))), h(doc, 'tbody', {}, ...series.points.map((point) => h(doc, 'tr', {}, h(doc, 'th', { scope: 'row' }, point.label), h(doc, 'td', {}, value(point.value)))))))));
152
+ this.draw();
153
+ }
154
+ /** Draws at the plot's width: as observed, else as laid out now, else the fallback. */
155
+ draw() {
156
+ if (this.drawing === null)
157
+ return;
158
+ const { series, height, line, format } = this.drawing;
159
+ const width = this.observed || Math.round(this.plot.clientWidth) || FALLBACK_WIDTH;
160
+ this.drawn = width;
161
+ clear(this.plot);
162
+ this.plot.appendChild((line ? renderLine : renderBars)(scope.document, series, { width, height }, format));
163
+ }
164
+ }
165
+ return CubeChart;
166
+ }
@@ -0,0 +1,8 @@
1
+ import { type BlockClass } from '../block.js';
2
+ import type { BlockScope } from '../define.js';
3
+ /**
4
+ * `<cube-detail>`: one row of a resource as a list of label and value. With `editable` (and `update` allowed) the
5
+ * writable fields can be edited; a save sends only what changed, with the version the row was loaded with, so an
6
+ * edit made by someone else meanwhile is never overwritten.
7
+ */
8
+ export declare function detailBlock(Base: BlockClass, scope: BlockScope): BlockClass;
@@ -0,0 +1,270 @@
1
+ import { isDmpError } from '../../errors.js';
2
+ import { CARD_CSS, requiredAttr } from '../aggregate-block.js';
3
+ import { exampleBadge } from '../block.js';
4
+ import { clear, h } from '../dom.js';
5
+ import { columnsFor, fieldLabel, fieldList, readFieldConfigs } from '../fields.js';
6
+ import { formatValue } from '../format.js';
7
+ import { EDITOR_CSS, editorFor } from '../inputs.js';
8
+ import { NL } from '../texts.js';
9
+ import { attemptFor, showAlert, showWriteFailure, userOf, WRITE_CSS } from '../write.js';
10
+ const CSS = `${CARD_CSS}${EDITOR_CSS}${WRITE_CSS}
11
+ dl{display:grid;grid-template-columns:minmax(8rem,max-content) 1fr;gap:.5rem 1rem;margin:0}
12
+ dt{font-weight:600}
13
+ dd{margin:0;min-width:0;overflow-wrap:anywhere}
14
+ `;
15
+ const writable = (column) => column.field !== undefined && !column.field.readonly;
16
+ /** Whether `next` (as an editor read it) is what the row already holds, so it need not be sent. */
17
+ function unchanged(column, before, next) {
18
+ const was = before === undefined || before === '' ? null : before;
19
+ if (was === next)
20
+ return true;
21
+ // A checkbox cannot show "nothing": unticked over an empty value is no change of its own.
22
+ if (was === null && next === false && column.format === 'boolean')
23
+ return true;
24
+ if (was === null || next === null)
25
+ return false;
26
+ // A date the DMP sends as a full timestamp is the same day the date picker shows.
27
+ if (column.format === 'date' && typeof was === 'string' && typeof next === 'string') {
28
+ return was.slice(0, 10) === next;
29
+ }
30
+ if (typeof next === 'number' && typeof was === 'string')
31
+ return Number(was) === next;
32
+ // A text box shows a stored number as its digits: the same digits are no change.
33
+ if (typeof next === 'string' && typeof was === 'number')
34
+ return String(was) === next;
35
+ // The picker shows minutes: seconds the DMP stored are not a change the person made.
36
+ if (column.format === 'datetime' && typeof was === 'string' && typeof next === 'string') {
37
+ const minute = (text) => Math.floor(Date.parse(text) / 60000);
38
+ return minute(was) === minute(next);
39
+ }
40
+ return typeof was === 'object' && JSON.stringify(was) === JSON.stringify(next);
41
+ }
42
+ /**
43
+ * `<cube-detail>`: one row of a resource as a list of label and value. With `editable` (and `update` allowed) the
44
+ * writable fields can be edited; a save sends only what changed, with the version the row was loaded with, so an
45
+ * edit made by someone else meanwhile is never overwritten.
46
+ */
47
+ export function detailBlock(Base, scope) {
48
+ const doc = scope.document;
49
+ class CubeDetail extends Base {
50
+ static tag = 'cube-detail';
51
+ static css = CSS;
52
+ /** Outside the body, so a reload after saving keeps "Opgeslagen." where a screen reader already watches. */
53
+ status = h(doc, 'p', { class: 'status', role: 'status' });
54
+ loaded = null;
55
+ /**
56
+ * The record being edited: while it is open, a reload of that same record would wipe what was typed, so it
57
+ * waits until the edit closes. That includes a session change and a changed fields or editable attribute.
58
+ */
59
+ editing = null;
60
+ /** One key per save attempt; kept only to retry the same body after the answer never came. */
61
+ attempt = null;
62
+ /** The record a save just switched back to view: the reload that follows keeps "Opgeslagen." and focus. */
63
+ saved = null;
64
+ edit = null;
65
+ constructor() {
66
+ super();
67
+ this.shadowRoot?.appendChild(this.status);
68
+ }
69
+ disconnectedCallback() {
70
+ super.disconnectedCallback();
71
+ this.editing = null;
72
+ }
73
+ refresh() {
74
+ const open = this.editing;
75
+ if (open !== null
76
+ && open.resource === this.attr('resource')?.trim()
77
+ && open.id === this.recordId()
78
+ && open.user === userOf(this.loaded?.session)) {
79
+ return Promise.resolve();
80
+ }
81
+ this.editing = null;
82
+ return super.refresh();
83
+ }
84
+ shownFields() {
85
+ return this.loaded?.columns.map((column) => column.name) ?? [];
86
+ }
87
+ recordId() {
88
+ const id = this.attr('record-id')?.trim() ?? '';
89
+ return id === '' ? null : id;
90
+ }
91
+ async load(signal, session) {
92
+ if (session === null)
93
+ return;
94
+ this.loaded = null;
95
+ const resource = requiredAttr(this, 'resource');
96
+ const id = this.recordId();
97
+ const notice = this.saved !== null && this.saved.resource === resource && this.saved.id === id;
98
+ this.saved = null;
99
+ if (!notice)
100
+ this.status.textContent = '';
101
+ if (id === null) {
102
+ this.showState('empty', NL.chooseRow);
103
+ return;
104
+ }
105
+ const schema = await session.schema(resource);
106
+ if (signal.aborted)
107
+ return;
108
+ const names = fieldList(this.attr('fields'));
109
+ const response = await session
110
+ .resource(resource)
111
+ .get(id, names === null ? {} : { fields: names }, { signal });
112
+ if (signal.aborted)
113
+ return;
114
+ const row = response.data;
115
+ const loaded = {
116
+ session,
117
+ resource,
118
+ id,
119
+ columns: columnsFor(schema, names, readFieldConfigs(this), row),
120
+ row,
121
+ version: response.recordVersion,
122
+ canEdit: this.flag('editable') && this.allowsUpdate(schema),
123
+ };
124
+ this.loaded = loaded;
125
+ this.renderView(loaded);
126
+ if (notice)
127
+ this.edit?.focus();
128
+ }
129
+ allowsUpdate(schema) {
130
+ return schema !== null && schema.actions.includes('update');
131
+ }
132
+ renderView(loaded) {
133
+ this.edit = loaded.canEdit && loaded.columns.some(writable)
134
+ ? h(doc, 'button', { type: 'button', onclick: () => this.startEditing(loaded) }, NL.edit)
135
+ : null;
136
+ clear(this.body);
137
+ this.body.appendChild(h(doc, 'div', { class: 'card' }, h(doc, 'dl', {}, ...loaded.columns.flatMap((column) => this.pair(loaded, column))), this.edit === null
138
+ ? null
139
+ : h(doc, 'div', { class: 'actions' }, this.edit, this.notSaved(loaded.session))));
140
+ }
141
+ pair(loaded, column) {
142
+ const example = this.exampleField(loaded.session, loaded.resource, column.name);
143
+ return [
144
+ h(doc, 'dt', {}, column.label, ...(example ? [' ', exampleBadge(doc)] : [])),
145
+ h(doc, 'dd', { class: example ? 'example' : null }, formatValue(loaded.row[column.name], column.format)),
146
+ ];
147
+ }
148
+ startEditing(loaded) {
149
+ this.editing = { resource: loaded.resource, id: loaded.id, user: userOf(loaded.session) };
150
+ this.status.textContent = '';
151
+ const editors = [];
152
+ const items = loaded.columns.map((column) => {
153
+ if (!writable(column))
154
+ return h(doc, 'dl', {}, ...this.pair(loaded, column));
155
+ const editor = editorFor(doc, column, loaded.row[column.name], column.field?.nullable === false);
156
+ editors.push(editor);
157
+ return editor.element;
158
+ });
159
+ const alert = h(doc, 'div', { class: 'alert', role: 'alert', tabindex: '-1', hidden: true });
160
+ const save = h(doc, 'button', { type: 'submit' }, NL.save);
161
+ const cancel = h(doc, 'button', {
162
+ type: 'button',
163
+ class: 'secondary',
164
+ onclick: () => {
165
+ this.editing = null;
166
+ this.attempt = null;
167
+ this.renderView(loaded);
168
+ this.edit?.focus();
169
+ },
170
+ }, NL.cancel);
171
+ const form = h(doc, 'form', { novalidate: true }, alert, ...items, h(doc, 'div', { class: 'actions' }, save, cancel, this.notSaved(loaded.session)));
172
+ form.addEventListener('submit', (event) => {
173
+ event.preventDefault();
174
+ void this.save(loaded, editors, { alert, save, cancel: cancel });
175
+ });
176
+ clear(this.body);
177
+ this.body.appendChild(h(doc, 'div', { class: 'card' }, form));
178
+ editors[0]?.element.querySelector('input,select,textarea')?.focus();
179
+ }
180
+ async save(loaded, editors, ui) {
181
+ if (ui.save.disabled)
182
+ return;
183
+ const body = {};
184
+ let invalid = false;
185
+ for (const editor of editors) {
186
+ const read = editor.read();
187
+ const column = loaded.columns.find((candidate) => candidate.name === editor.name);
188
+ if ('error' in read) {
189
+ editor.setError(read.error);
190
+ invalid = true;
191
+ continue;
192
+ }
193
+ editor.setError(null);
194
+ if (!unchanged(column, loaded.row[editor.name], read.value))
195
+ body[editor.name] = read.value;
196
+ }
197
+ if (invalid) {
198
+ showAlert(doc, ui.alert, NL.formInvalid);
199
+ return;
200
+ }
201
+ if (Object.keys(body).length === 0) {
202
+ this.editing = null;
203
+ this.renderView(loaded);
204
+ this.edit?.focus();
205
+ return;
206
+ }
207
+ const fingerprint = JSON.stringify([loaded.resource, loaded.id, body]);
208
+ this.attempt = attemptFor(this.attempt, fingerprint);
209
+ const options = { idempotencyKey: this.attempt.key };
210
+ if (loaded.version !== null)
211
+ options.ifMatch = loaded.version;
212
+ ui.save.disabled = true;
213
+ ui.cancel.disabled = true;
214
+ ui.alert.hidden = true;
215
+ try {
216
+ // The session as it is now: one that changed meanwhile drops this edit, and must not send it.
217
+ const session = await this.session();
218
+ if (this.editing !== null && this.editing.user !== userOf(session)) {
219
+ this.editing = null;
220
+ void this.refresh();
221
+ return;
222
+ }
223
+ const response = await session.resource(loaded.resource).update(loaded.id, body, options);
224
+ this.attempt = null;
225
+ const row = response.data;
226
+ // Only while this edit is still on screen; the row was saved either way, so it is always announced.
227
+ if (this.loaded === loaded && this.editing !== null) {
228
+ this.editing = null;
229
+ const merged = { ...loaded.row, ...body, ...row };
230
+ const saved = { ...loaded, row: merged, version: response.recordVersion };
231
+ this.loaded = saved;
232
+ this.saved = { resource: loaded.resource, id: loaded.id };
233
+ this.renderView(saved);
234
+ this.status.textContent = NL.saved;
235
+ this.edit?.focus();
236
+ }
237
+ this.emit('cube-saved', { resource: loaded.resource, id: row.id ?? loaded.id, row });
238
+ // The detail listens for this too: its reload only brings the data up to date.
239
+ this.emit('cube-data-change', { resource: loaded.resource });
240
+ }
241
+ catch (error) {
242
+ if (this.loaded !== loaded || this.editing === null)
243
+ return;
244
+ ui.cancel.disabled = false;
245
+ this.failed(error, editors, ui);
246
+ }
247
+ }
248
+ failed(error, editors, ui) {
249
+ // The DMP answered, so the attempt is over; without an answer the same body may go again with its key.
250
+ if (isDmpError(error))
251
+ this.attempt = null;
252
+ if (isDmpError(error) && (error.status === 409 || error.status === 412)) {
253
+ // Saving again would only meet the same newer version: the way on is to load it first.
254
+ const reload = h(doc, 'button', {
255
+ type: 'button',
256
+ class: 'secondary',
257
+ onclick: () => {
258
+ this.editing = null;
259
+ void this.refresh();
260
+ },
261
+ }, NL.reload);
262
+ showAlert(doc, ui.alert, NL.conflict, reload);
263
+ return;
264
+ }
265
+ ui.save.disabled = false;
266
+ showWriteFailure(doc, ui.alert, error, editors, (field) => fieldLabel(readFieldConfigs(this), field));
267
+ }
268
+ }
269
+ return CubeDetail;
270
+ }
@@ -0,0 +1,8 @@
1
+ import type { BlockScope } from '../define.js';
2
+ /** Set on a cube-field once connected: whether the page itself marked the field `hidden`. */
3
+ export declare const AUTHOR_HIDDEN: unique symbol;
4
+ /**
5
+ * `<cube-field>`: configuration a table, detail or form reads; it renders nothing. It takes `hidden` on connect
6
+ * so stray text inside never shows, which is why the page's own `hidden` (leave the field out) is kept aside first.
7
+ */
8
+ export declare function fieldElement(scope: BlockScope): CustomElementConstructor;
@@ -0,0 +1,16 @@
1
+ /** Set on a cube-field once connected: whether the page itself marked the field `hidden`. */
2
+ export const AUTHOR_HIDDEN = Symbol('cube-field hidden');
3
+ /**
4
+ * `<cube-field>`: configuration a table, detail or form reads; it renders nothing. It takes `hidden` on connect
5
+ * so stray text inside never shows, which is why the page's own `hidden` (leave the field out) is kept aside first.
6
+ */
7
+ export function fieldElement(scope) {
8
+ return class CubeField extends scope.HTMLElement {
9
+ [AUTHOR_HIDDEN];
10
+ connectedCallback() {
11
+ if (this[AUTHOR_HIDDEN] === undefined)
12
+ this[AUTHOR_HIDDEN] = this.hasAttribute('hidden');
13
+ this.hidden = true;
14
+ }
15
+ };
16
+ }
@@ -0,0 +1,8 @@
1
+ import type { BlockClass } from '../block.js';
2
+ import type { BlockScope } from '../define.js';
3
+ /**
4
+ * `<cube-form>`: a new row of a resource. Only writable fields get an editor; read-only fields and `id` are never
5
+ * shown and never sent. One submit makes one row: a double click sends once, and a retry after a network error
6
+ * sends the same idempotency key, so the DMP never creates it twice.
7
+ */
8
+ export declare function formBlock(Base: BlockClass, scope: BlockScope): BlockClass;
@@ -0,0 +1,153 @@
1
+ import { isDmpError } from '../../errors.js';
2
+ import { CARD_CSS, requiredAttr } from '../aggregate-block.js';
3
+ import { clear, h } from '../dom.js';
4
+ import { columnsFor, fieldLabel, fieldList, readFieldConfigs } from '../fields.js';
5
+ import { EDITOR_CSS, editorFor } from '../inputs.js';
6
+ import { NL } from '../texts.js';
7
+ import { attemptFor, showWriteFailure, WRITE_CSS } from '../write.js';
8
+ const CSS = `${CARD_CSS}${EDITOR_CSS}${WRITE_CSS}`;
9
+ /** The attributes the form was drawn for (with the user): a load for the same key would only wipe what was typed. */
10
+ const SHOWN_BY = ['resource', 'fields', 'submit-label', 'success-text'];
11
+ /**
12
+ * `<cube-form>`: a new row of a resource. Only writable fields get an editor; read-only fields and `id` are never
13
+ * shown and never sent. One submit makes one row: a double click sends once, and a retry after a network error
14
+ * sends the same idempotency key, so the DMP never creates it twice.
15
+ */
16
+ export function formBlock(Base, scope) {
17
+ const doc = scope.document;
18
+ class CubeForm extends Base {
19
+ static tag = 'cube-form';
20
+ static css = CSS;
21
+ /** Outside the body, so a screen reader watching it already hears the confirmation. */
22
+ status = h(doc, 'p', { class: 'status', role: 'status' });
23
+ drawn = null;
24
+ shown = null;
25
+ attempt = null;
26
+ constructor() {
27
+ super();
28
+ this.shadowRoot?.appendChild(this.status);
29
+ }
30
+ shownFields() {
31
+ return this.drawn?.columns.map((column) => column.name) ?? [];
32
+ }
33
+ key() {
34
+ // Signing out or in redraws: what someone else typed is not theirs to send.
35
+ const user = this.drawn?.session.user()?.id ?? null;
36
+ return JSON.stringify([...SHOWN_BY.map((name) => this.attr(name)), user]);
37
+ }
38
+ /** A text attribute, or `fallback` when it is absent or blank. */
39
+ text(name, fallback) {
40
+ const value = this.attr(name)?.trim() ?? '';
41
+ return value === '' ? fallback : value;
42
+ }
43
+ refresh() {
44
+ // A data change (this form's own create, or another block's save) must not wipe the form or its thanks.
45
+ if (this.shown === this.key())
46
+ return Promise.resolve();
47
+ this.shown = null;
48
+ return super.refresh();
49
+ }
50
+ async load(signal, session) {
51
+ if (session === null)
52
+ return;
53
+ this.drawn = null;
54
+ this.attempt = null;
55
+ const resource = requiredAttr(this, 'resource');
56
+ const schema = await session.schema(resource);
57
+ if (signal.aborted)
58
+ return;
59
+ if (schema === null || !schema.actions.includes('create')) {
60
+ this.showState({ kind: 'forbidden', detail: null, retry: false });
61
+ return;
62
+ }
63
+ const writable = schema.fields.filter((field) => !field.readonly && field.name !== 'id');
64
+ const listed = fieldList(this.attr('fields'));
65
+ const names = (listed ?? writable.map((field) => field.name))
66
+ .filter((name) => writable.some((field) => field.name === name));
67
+ const columns = columnsFor(schema, names, readFieldConfigs(this));
68
+ if (columns.length === 0) {
69
+ this.showState({ kind: 'error', detail: NL.noWritableFields, retry: false });
70
+ return;
71
+ }
72
+ this.drawn = { session, resource, columns };
73
+ this.shown = this.key();
74
+ this.renderForm(false);
75
+ }
76
+ renderForm(focus) {
77
+ const drawn = this.drawn;
78
+ this.status.textContent = '';
79
+ const editors = drawn.columns.map((column) => editorFor(doc, column, null, column.field?.nullable === false && column.format !== 'boolean'));
80
+ const alert = h(doc, 'div', { class: 'alert', role: 'alert', tabindex: '-1', hidden: true });
81
+ const save = h(doc, 'button', { type: 'submit' }, this.text('submit-label', NL.save));
82
+ const form = h(doc, 'form', { novalidate: true }, alert, ...editors.map((editor) => editor.element), h(doc, 'div', { class: 'actions' }, save, this.notSaved(drawn.session)));
83
+ form.addEventListener('submit', (event) => {
84
+ event.preventDefault();
85
+ void this.submit(drawn, editors, alert, save);
86
+ });
87
+ clear(this.body);
88
+ this.body.appendChild(h(doc, 'div', { class: 'card' }, form));
89
+ if (focus)
90
+ inputOf(editors[0])?.focus();
91
+ }
92
+ async submit(drawn, editors, alert, save) {
93
+ // Disabled synchronously below, before the first await: a second submit in the same tick stops here.
94
+ if (save.disabled)
95
+ return;
96
+ const body = {};
97
+ let firstInvalid = null;
98
+ for (const editor of editors) {
99
+ const read = editor.read();
100
+ editor.setError('error' in read ? read.error : null);
101
+ if ('error' in read)
102
+ firstInvalid ??= editor;
103
+ else if (read.value !== null)
104
+ body[editor.name] = read.value;
105
+ }
106
+ if (firstInvalid !== null) {
107
+ alert.hidden = true;
108
+ inputOf(firstInvalid)?.focus();
109
+ return;
110
+ }
111
+ this.attempt = attemptFor(this.attempt, JSON.stringify([drawn.resource, body]));
112
+ const key = this.attempt.key;
113
+ save.disabled = true;
114
+ alert.hidden = true;
115
+ try {
116
+ const session = await this.session();
117
+ const response = await session.resource(drawn.resource).create(body, { idempotencyKey: key });
118
+ this.attempt = null;
119
+ const row = response.data;
120
+ // The row exists either way; only the thanks belongs to the form it was sent from.
121
+ if (this.drawn === drawn)
122
+ this.done();
123
+ this.emit('cube-created', { resource: drawn.resource, row });
124
+ this.emit('cube-data-change', { resource: drawn.resource });
125
+ }
126
+ catch (error) {
127
+ // The DMP answered, so the attempt is over; without an answer the same body may go again with its key.
128
+ if (isDmpError(error))
129
+ this.attempt = null;
130
+ if (this.drawn !== drawn)
131
+ return;
132
+ save.disabled = false;
133
+ showWriteFailure(doc, alert, error, editors, (field) => fieldLabel(readFieldConfigs(this), field));
134
+ }
135
+ }
136
+ done() {
137
+ const another = h(doc, 'button', { type: 'button', onclick: () => this.renderForm(true) }, NL.another);
138
+ clear(this.body);
139
+ const note = this.drawn === null ? null : this.notSaved(this.drawn.session);
140
+ this.body.appendChild(h(doc, 'div', { class: 'card' }, h(doc, 'div', { class: 'actions' }, another, note)));
141
+ this.status.textContent = this.text('success-text', NL.formSaved);
142
+ // A tick later, so the polite status is queued before the focus change makes the reader speak the button.
143
+ scope.setTimeout(() => {
144
+ if (another.isConnected)
145
+ another.focus();
146
+ }, 0);
147
+ }
148
+ }
149
+ return CubeForm;
150
+ }
151
+ function inputOf(editor) {
152
+ return editor?.element.querySelector('input,select,textarea') ?? null;
153
+ }
@@ -0,0 +1,4 @@
1
+ import type { BlockClass } from '../block.js';
2
+ import type { BlockScope } from '../define.js';
3
+ /** `<cube-gap-list>`: what a wireframe shows that the DMP does not have yet, and why, for the people who build it. */
4
+ export declare function gapListBlock(Base: BlockClass, scope: BlockScope): BlockClass;