@keemakr/ui-sdk 1.5.0 → 1.6.0-dev-20261026112734

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 (73) hide show
  1. package/README.md +118 -0
  2. package/dist/badges.d.ts +65 -0
  3. package/dist/badges.js +96 -0
  4. package/dist/build.js +2 -2
  5. package/dist/cells.d.ts +31 -0
  6. package/dist/cells.js +45 -0
  7. package/dist/chart.d.ts +20 -0
  8. package/dist/chart.js +38 -0
  9. package/dist/conform.d.ts +21 -0
  10. package/dist/conform.js +310 -0
  11. package/dist/dataset.d.ts +6 -6
  12. package/dist/date-format.d.ts +64 -0
  13. package/dist/date-format.js +132 -0
  14. package/dist/drawer.d.ts +22 -0
  15. package/dist/drawer.js +21 -0
  16. package/dist/edit.d.ts +60 -0
  17. package/dist/edit.js +60 -0
  18. package/dist/filters.d.ts +54 -0
  19. package/dist/filters.js +81 -0
  20. package/dist/form.d.ts +45 -1
  21. package/dist/form.js +39 -0
  22. package/dist/heatmap.d.ts +36 -0
  23. package/dist/heatmap.js +56 -0
  24. package/dist/icon.d.ts +20 -0
  25. package/dist/icon.js +43 -0
  26. package/dist/index.d.ts +37 -4
  27. package/dist/index.js +21 -2
  28. package/dist/markdown-links.d.ts +29 -0
  29. package/dist/markdown-links.js +99 -0
  30. package/dist/media.js +7 -1
  31. package/dist/negotiate.d.ts +302 -0
  32. package/dist/negotiate.js +1092 -0
  33. package/dist/nodes.d.ts +1 -1
  34. package/dist/nodes.js +3 -0
  35. package/dist/pager.d.ts +2 -2
  36. package/dist/player.d.ts +46 -0
  37. package/dist/player.js +86 -0
  38. package/dist/published/index.d.ts +4 -0
  39. package/dist/published/index.js +18 -0
  40. package/dist/published/v1.1.0/schema.d.ts +439 -0
  41. package/dist/published/v1.1.0/schema.js +405 -0
  42. package/dist/published/v1.4.0/dataset-view.d.ts +160 -0
  43. package/dist/published/v1.4.0/dataset-view.js +702 -0
  44. package/dist/published/v1.4.0/dataset.d.ts +331 -0
  45. package/dist/published/v1.4.0/dataset.js +628 -0
  46. package/dist/published/v1.4.0/schema.d.ts +1132 -0
  47. package/dist/published/v1.4.0/schema.js +1410 -0
  48. package/dist/schema.d.ts +1638 -20
  49. package/dist/schema.js +1275 -72
  50. package/dist/section.d.ts +65 -0
  51. package/dist/section.js +81 -0
  52. package/dist/stacked-bar.d.ts +34 -0
  53. package/dist/stacked-bar.js +20 -0
  54. package/dist/stat.d.ts +24 -0
  55. package/dist/stat.js +27 -0
  56. package/dist/table-row.d.ts +24 -0
  57. package/dist/table-row.js +23 -0
  58. package/dist/tabs.d.ts +36 -0
  59. package/dist/tabs.js +83 -0
  60. package/dist/testkit.d.ts +38 -0
  61. package/dist/testkit.js +68 -0
  62. package/dist/timeline.d.ts +5 -0
  63. package/dist/timeline.js +35 -0
  64. package/examples/campaigns/README.md +22 -1
  65. package/examples/campaigns/approvals/index.ts +23 -0
  66. package/examples/campaigns/approvals/launch.ts +38 -0
  67. package/examples/campaigns/brief/index.ts +23 -0
  68. package/examples/campaigns/brief/ready.ts +43 -0
  69. package/examples/campaigns/demo-approvals.ts +35 -0
  70. package/examples/campaigns/demo-brief.ts +23 -0
  71. package/examples/campaigns/entry.json +71 -1
  72. package/examples/campaigns/tsconfig.json +8 -1
  73. package/package.json +6 -1
@@ -0,0 +1,702 @@
1
+ // FROZEN COPY of the published @keemakr/ui-sdk@1.4.0 src/dataset-view.ts (git tag @keemakr/ui-sdk@1.4.0).
2
+ // Core checks the tree it sends an older app against the schema that app really parses with.
3
+ // Do not edit: tests/sdk/ui-published-parsers.test.ts pins the bytes below this header.
4
+ /**
5
+ * The `dataset` node, and how a dataset becomes blocks.
6
+ *
7
+ * A screen places one of its module's datasets with a single node:
8
+ *
9
+ * { type: 'dataset', dataset: 'leads', layout: 'table' }
10
+ *
11
+ * Core expands the node before any renderer sees it: it queries the dataset
12
+ * with the viewer's grant and replaces the node with blocks every app already
13
+ * draws. So a new layout never needs a new client. The layouts:
14
+ *
15
+ * - `table`: a table with sort and filter headers and a pager.
16
+ * - `board`: cards in columns, grouped by a stage or a status.
17
+ * - `list`: a table with filter chips over its stage or status. Pressing a
18
+ * row opens it as a record beside the list, in the screen's right rail.
19
+ * - `record`: one row, named by a screen param, as a page: its title, its
20
+ * status, and its other fields as facts. This file is the pure
21
+ * half of that: the node's schema, how a screen's params become a query, and
22
+ * how one page of rows becomes blocks. It runs on the server and in tests.
23
+ *
24
+ * Interaction is navigation. A sort header, a filter menu and the pager each
25
+ * navigate back to the same screen with params (`sort`, `direction`, a field
26
+ * name, the page param), and the next expansion reads them. One interactive
27
+ * dataset node per screen: two would share those param names.
28
+ */
29
+ import { z } from 'zod';
30
+ import { DATASET_LIMITS, } from './dataset.js';
31
+ const slug = z
32
+ .string()
33
+ .regex(/^[a-z][a-z0-9_-]*$/, 'lowercase slug (a-z, 0-9, -, _)')
34
+ .max(64);
35
+ const fieldName = z
36
+ .string()
37
+ .max(64)
38
+ .regex(/^[a-z][a-z0-9_]*$/, 'must be lowercase snake_case, starting with a letter');
39
+ export const DATASET_LAYOUTS = ['table', 'board', 'list', 'record', 'chart'];
40
+ /** Deepest page a table reaches. Paging walks cursors from page 1. */
41
+ export const DATASET_MAX_PAGE = 20;
42
+ export const datasetNodeSchema = z.object({
43
+ type: z.literal('dataset'),
44
+ /** A dataset this module declares in ui/datasets/. */
45
+ dataset: slug,
46
+ layout: z.enum(DATASET_LAYOUTS).default('table'),
47
+ /**
48
+ * The fields to show, in order. Omitted: the title, then the fields with a
49
+ * role, then the rest, up to 8. The key field shows only when named here.
50
+ */
51
+ fields: z.array(fieldName).min(1).max(12).optional(),
52
+ /** Filters the module fixes for this view. A person cannot clear them. */
53
+ filters: z.record(fieldName, z.string().max(200)).optional(),
54
+ /** The order before a person picks one. Defaults to the dataset's defaultSort. */
55
+ sort: z
56
+ .object({ field: fieldName, direction: z.enum(['asc', 'desc']).default('asc') })
57
+ .optional(),
58
+ /** Rows per page for a table; the most cards for a board. */
59
+ limit: z.number().int().min(1).max(100).default(25),
60
+ /**
61
+ * The board's columns, or the chart's bars: an enum field. Defaults to the
62
+ * stage field, then the status field.
63
+ */
64
+ groupBy: fieldName.optional(),
65
+ /**
66
+ * For a chart. `bars`: one bar per option of `groupBy`. `funnel`: how many
67
+ * reached each step, in option order; an option with a destructive tone (a
68
+ * lost deal) is an exit, not a step. `measure` sums a number field instead
69
+ * of counting rows. A chart reads up to 200 rows.
70
+ */
71
+ chart: z
72
+ .object({
73
+ form: z.enum(['bars', 'funnel']).default('bars'),
74
+ measure: fieldName.optional(),
75
+ })
76
+ .optional(),
77
+ /**
78
+ * Pressing a row or card opens this screen, with the row's key under `param`.
79
+ * A list without it opens the row beside the list.
80
+ */
81
+ open: z.object({ screen: slug, param: slug.default('id') }).optional(),
82
+ /**
83
+ * The screen param that holds a row's key. A record shows that row. A list
84
+ * shows that row beside the list, and puts a pressed row's key there.
85
+ */
86
+ param: slug.default('id'),
87
+ /** Shown when no rows match. */
88
+ empty: z.string().min(1).max(80).default('Nothing here yet'),
89
+ /**
90
+ * Offer the dataset's create form above the rows, when the dataset declares
91
+ * `accepts.create`. False keeps this view read-only.
92
+ */
93
+ create: z.boolean().default(true),
94
+ });
95
+ /** Problems with a node against its dataset. Core shows these, never a broken view. */
96
+ export function datasetNodeIssues(node, declaration) {
97
+ const issues = [];
98
+ const byName = new Map(declaration.fields.map((field) => [field.name, field]));
99
+ for (const name of node.fields ?? []) {
100
+ if (!byName.has(name))
101
+ issues.push(`"${name}" is not a field of ${declaration.name}`);
102
+ }
103
+ if (node.layout === 'board') {
104
+ const group = boardGroupField(node, declaration);
105
+ if (!group)
106
+ issues.push('a board needs an enum field to group by (a stage or a status)');
107
+ else if ((group.options?.length ?? 0) > 8)
108
+ issues.push('a board shows at most 8 columns');
109
+ }
110
+ if (node.layout === 'chart') {
111
+ if (!boardGroupField(node, declaration)) {
112
+ issues.push('a chart needs an enum field to group by (a stage or a status)');
113
+ }
114
+ const measure = node.chart?.measure;
115
+ if (measure && byName.get(measure)?.type !== 'number') {
116
+ issues.push(`a chart can only add up a number field, and "${measure}" is not one`);
117
+ }
118
+ }
119
+ if (node.chart && node.layout !== 'chart')
120
+ issues.push('only a chart takes chart settings');
121
+ return issues;
122
+ }
123
+ function boardGroupField(node, declaration) {
124
+ const enums = declaration.fields.filter((field) => field.type === 'enum');
125
+ if (node.groupBy)
126
+ return enums.find((field) => field.name === node.groupBy);
127
+ return enums.find((f) => f.role === 'stage') ?? enums.find((f) => f.role === 'status');
128
+ }
129
+ const NUMBER = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 });
130
+ /** Bars or a funnel over the rows, with the totals above them. */
131
+ function compileDatasetChart(node, declaration, response, hasMore) {
132
+ const group = boardGroupField(node, declaration);
133
+ const measure = node.chart?.measure
134
+ ? declaration.fields.find((field) => field.name === node.chart?.measure)
135
+ : undefined;
136
+ const funnel = node.chart?.form === 'funnel';
137
+ const valueOf = (row) => {
138
+ if (!measure)
139
+ return 1;
140
+ const value = row[measure.name];
141
+ return typeof value === 'number' ? value : 0;
142
+ };
143
+ const options = (group.options ?? []).filter((option) => !funnel || option.tone !== 'destructive');
144
+ const index = new Map(options.map((option, i) => [option.value, i]));
145
+ const values = options.map(() => 0);
146
+ for (const row of response.rows) {
147
+ const at = index.get(String(row[group.name]));
148
+ if (at === undefined)
149
+ continue;
150
+ // A funnel counts a row at its own step and at every step before it: it reached them.
151
+ if (funnel)
152
+ for (let i = 0; i <= at; i += 1)
153
+ values[i] += valueOf(row);
154
+ else
155
+ values[at] += valueOf(row);
156
+ }
157
+ const rounded = values.map((value) => Math.round(value * 100) / 100);
158
+ const total = response.rows.reduce((sum, row) => sum + valueOf(row), 0);
159
+ const unit = measure ? measure.label : declaration.label;
160
+ const stats = [
161
+ {
162
+ type: 'stat',
163
+ label: declaration.label.slice(0, 80),
164
+ value: NUMBER.format(response.rows.length),
165
+ },
166
+ ...(measure
167
+ ? [
168
+ {
169
+ type: 'stat',
170
+ label: `Total ${measure.label}`.slice(0, 80),
171
+ value: NUMBER.format(total),
172
+ },
173
+ ]
174
+ : []),
175
+ ];
176
+ const out = [
177
+ { type: 'section', layout: 'row', children: stats },
178
+ {
179
+ type: 'chart',
180
+ chartType: 'bar',
181
+ title: `${unit} ${funnel ? 'reaching each' : 'by'} ${group.label.toLowerCase()}`.slice(0, 120),
182
+ orientation: funnel ? 'horizontal' : 'vertical',
183
+ showValues: true,
184
+ categories: options.map((option) => option.label.slice(0, 80)),
185
+ series: [{ name: unit.slice(0, 80), values: rounded }],
186
+ },
187
+ ];
188
+ if (funnel && options.length > 1) {
189
+ const steps = options.slice(1).map((option, i) => {
190
+ const before = rounded[i];
191
+ const share = before > 0 ? Math.round((rounded[i + 1] / before) * 100) : 0;
192
+ return `${option.label}: ${share}% of ${options[i].label}`;
193
+ });
194
+ out.push({ type: 'text', value: steps.join(' · ').slice(0, 5000), muted: true });
195
+ }
196
+ if (hasMore) {
197
+ out.push({
198
+ type: 'text',
199
+ value: `Counts the first ${response.rows.length} rows only.`,
200
+ muted: true,
201
+ });
202
+ }
203
+ return out;
204
+ }
205
+ /** Which page of a table the params ask for, 1 when none. */
206
+ export function datasetPage(node, params) {
207
+ if (node.layout !== 'table' && node.layout !== 'list')
208
+ return 1;
209
+ const page = Number.parseInt(params.page ?? '1', 10);
210
+ return Number.isFinite(page) ? Math.min(Math.max(page, 1), DATASET_MAX_PAGE) : 1;
211
+ }
212
+ /**
213
+ * The key of the row a record shows, or a list opens beside it. Undefined when
214
+ * the node shows no single row, or the param is empty.
215
+ */
216
+ export function datasetRecordKey(node, params) {
217
+ if (node.layout !== 'record' && node.layout !== 'list')
218
+ return undefined;
219
+ if (node.layout === 'list' && node.open)
220
+ return undefined;
221
+ const key = params[node.param];
222
+ return key && key.length <= 128 ? key : undefined;
223
+ }
224
+ /** The query for the one row a record shows. */
225
+ export function datasetRecordQuery(key) {
226
+ return { keys: [key], limit: 1 };
227
+ }
228
+ /**
229
+ * The query a node and the screen's params make. The node's own filters win
230
+ * over a param of the same name; a param for a field the dataset does not let
231
+ * a person filter is ignored, never forwarded.
232
+ */
233
+ export function datasetQueryFor(node, declaration, params) {
234
+ const filters = {};
235
+ for (const field of declaration.fields) {
236
+ const value = params[field.name];
237
+ if (!field.filterable || !value)
238
+ continue;
239
+ if (field.type === 'enum' && !field.options?.some((option) => option.value === value))
240
+ continue;
241
+ if (field.type === 'boolean' && value !== 'true' && value !== 'false')
242
+ continue;
243
+ filters[field.name] = value;
244
+ }
245
+ Object.assign(filters, node.filters ?? {});
246
+ const byName = new Map(declaration.fields.map((field) => [field.name, field]));
247
+ const asked = params.sort && byName.get(params.sort)?.sortable ? params.sort : undefined;
248
+ const sort = asked
249
+ ? {
250
+ field: asked,
251
+ direction: params.direction === 'desc' ? 'desc' : 'asc',
252
+ }
253
+ : (node.sort ?? declaration.defaultSort);
254
+ return {
255
+ filters,
256
+ ...(sort ? { sort } : {}),
257
+ limit: node.layout === 'chart'
258
+ ? DATASET_LIMITS.rows
259
+ : node.layout === 'board'
260
+ ? Math.min(DATASET_LIMITS.rows, node.limit * 4)
261
+ : node.limit,
262
+ };
263
+ }
264
+ /** The fields a view shows, in order. */
265
+ function shownFields(node, declaration) {
266
+ const byName = new Map(declaration.fields.map((field) => [field.name, field]));
267
+ if (node.fields)
268
+ return node.fields.flatMap((name) => byName.get(name) ?? []);
269
+ const order = ['title', 'subtitle', 'status', 'stage', 'owner', 'due', 'amount'];
270
+ const withRole = declaration.fields
271
+ .filter((field) => field.role)
272
+ .sort((a, b) => order.indexOf(a.role) - order.indexOf(b.role));
273
+ const rest = declaration.fields.filter((field) => !field.role && field.name !== declaration.key);
274
+ return [...withRole, ...rest].slice(0, 8);
275
+ }
276
+ const DAY = new Intl.DateTimeFormat('en-GB', {
277
+ day: 'numeric',
278
+ month: 'short',
279
+ year: 'numeric',
280
+ timeZone: 'UTC',
281
+ });
282
+ /** One value as a table cell, or null when the row has none. */
283
+ export function datasetCell(field, value) {
284
+ if (value === null || value === undefined || value === '')
285
+ return null;
286
+ switch (field.type) {
287
+ case 'enum': {
288
+ const option = field.options?.find((item) => item.value === value);
289
+ return {
290
+ kind: 'badge',
291
+ value: (option?.label ?? String(value)).slice(0, 40),
292
+ tone: option?.tone ?? 'default',
293
+ };
294
+ }
295
+ case 'boolean':
296
+ return { kind: 'badge', value: value ? 'Yes' : 'No', tone: value ? 'success' : 'default' };
297
+ case 'datetime': {
298
+ const ms = Date.parse(String(value));
299
+ return Number.isNaN(ms)
300
+ ? { kind: 'text', value: String(value) }
301
+ : { kind: 'date', value: new Date(ms).toISOString() };
302
+ }
303
+ case 'date': {
304
+ // A calendar date has no time and no zone. It is written out, not sent as
305
+ // a date cell, because a date cell is an instant the viewer's zone moves.
306
+ const ms = Date.parse(`${String(value)}T00:00:00Z`);
307
+ return Number.isNaN(ms)
308
+ ? { kind: 'text', value: String(value) }
309
+ : { kind: 'text', value: DAY.format(ms) };
310
+ }
311
+ case 'number':
312
+ return {
313
+ kind: 'text',
314
+ value: typeof value === 'number' ? value.toLocaleString('en-US') : String(value),
315
+ };
316
+ case 'tags':
317
+ return Array.isArray(value) ? { kind: 'text', value: value.join(', ').slice(0, 500) } : null;
318
+ default:
319
+ return { kind: 'text', value: String(value).slice(0, 500) };
320
+ }
321
+ }
322
+ function plain(field, row) {
323
+ if (!field)
324
+ return undefined;
325
+ const cell = datasetCell(field, row[field.name]);
326
+ return cell && cell.kind !== 'date' ? cell.value : undefined;
327
+ }
328
+ function keepParams(params, drop) {
329
+ return Object.fromEntries(Object.entries(params).filter(([name]) => !drop.includes(name)));
330
+ }
331
+ /** The most chips a list shows: `All` plus five options, the segmented block's limit. */
332
+ const MAX_CHIP_OPTIONS = 5;
333
+ /**
334
+ * The field a list's chips filter on: the stage, else the status, when a
335
+ * person may filter on it, the module does not fix it, and its options fit.
336
+ */
337
+ function chipField(node, declaration) {
338
+ const fits = (field) => field &&
339
+ field.filterable &&
340
+ !node.filters?.[field.name] &&
341
+ (field.options?.length ?? 0) <= MAX_CHIP_OPTIONS
342
+ ? field
343
+ : undefined;
344
+ return (fits(declaration.fields.find((f) => f.role === 'stage')) ??
345
+ fits(declaration.fields.find((f) => f.role === 'status')));
346
+ }
347
+ /** The chip value that clears the chip filter. No option may hold it. */
348
+ function allChip(field) {
349
+ return field.options?.some((option) => option.value === 'all') ? '*' : 'all';
350
+ }
351
+ /** The order a record lists its facts in: these roles first, then the rest. */
352
+ const FACT_ROLES = ['owner', 'due', 'amount'];
353
+ /** Roles a record shows in its header, not as facts. */
354
+ const HEADER_ROLES = ['title', 'subtitle', 'status', 'stage'];
355
+ /**
356
+ * One row as a record: its title, its subtitle, a tag for its status and its
357
+ * stage, and its other fields as facts. `row` undefined means the module did
358
+ * not find the key, and the record says so.
359
+ */
360
+ export function compileDatasetRecord(opts) {
361
+ const { declaration, row } = opts;
362
+ if (!row) {
363
+ return [
364
+ {
365
+ type: 'alert',
366
+ tone: 'warning',
367
+ text: `This item is not in ${declaration.label} any more.`,
368
+ },
369
+ ];
370
+ }
371
+ const role = (name) => declaration.fields.find((field) => field.role === name);
372
+ const subtitle = plain(role('subtitle'), row);
373
+ const tags = ['status', 'stage'].flatMap((name) => {
374
+ const field = role(name);
375
+ const cell = field ? datasetCell(field, row[field.name]) : null;
376
+ return cell?.kind === 'badge' ? [{ label: cell.value, tone: cell.tone }] : [];
377
+ });
378
+ const rank = (field) => field.role && FACT_ROLES.includes(field.role)
379
+ ? FACT_ROLES.indexOf(field.role)
380
+ : FACT_ROLES.length;
381
+ const facts = declaration.fields
382
+ .filter((field) => field.name !== declaration.key && !(field.role && HEADER_ROLES.includes(field.role)))
383
+ .sort((a, b) => rank(a) - rank(b))
384
+ .flatMap((field) => {
385
+ const cell = datasetCell(field, row[field.name]);
386
+ return cell ? [{ label: field.label, value: cell }] : [];
387
+ })
388
+ .slice(0, 50);
389
+ return [
390
+ {
391
+ type: 'heading',
392
+ text: (plain(role('title'), row) || String(row[declaration.key])).slice(0, 120),
393
+ },
394
+ ...(subtitle ? [{ type: 'text', value: subtitle, muted: true }] : []),
395
+ ...(tags.length ? [{ type: 'tags', items: tags }] : []),
396
+ ...(facts.length ? [{ type: 'details', columns: 2, items: facts }] : []),
397
+ ];
398
+ }
399
+ /** The key of the root section that holds the record a list opened. */
400
+ export const DATASET_RECORD_SECTION = 'dataset-record';
401
+ /**
402
+ * The record a list opened, as the section the screen's right rail shows,
403
+ * with a button that closes it.
404
+ */
405
+ export function compileDatasetRecordPanel(opts) {
406
+ const { node, screen } = opts;
407
+ return {
408
+ type: 'section',
409
+ key: DATASET_RECORD_SECTION,
410
+ layout: 'stack',
411
+ children: [
412
+ ...compileDatasetRecord(opts),
413
+ {
414
+ type: 'button',
415
+ label: 'Close',
416
+ variant: 'secondary',
417
+ onPress: {
418
+ kind: 'navigate',
419
+ screen: screen.screen,
420
+ params: keepParams(screen.params, [node.param]),
421
+ },
422
+ },
423
+ ],
424
+ };
425
+ }
426
+ /**
427
+ * One page of rows as screen blocks. `rows` is the page to show; `total` and
428
+ * `hasMore` drive the pager. The output is plain screen nodes, ready to put in
429
+ * the node's place.
430
+ */
431
+ export function compileDatasetNode(opts) {
432
+ const { node, declaration, response, screen, page } = opts;
433
+ if (node.layout === 'record') {
434
+ const key = datasetRecordKey(node, screen.params);
435
+ return compileDatasetRecord({
436
+ declaration,
437
+ row: response.rows.find((row) => String(row[declaration.key]) === key),
438
+ });
439
+ }
440
+ const byName = new Map(declaration.fields.map((field) => [field.name, field]));
441
+ const titleField = declaration.fields.find((field) => field.role === 'title');
442
+ const open = (row) => {
443
+ const key = row[declaration.key];
444
+ if (key === null || key === undefined)
445
+ return {};
446
+ if (node.open) {
447
+ return {
448
+ onPress: {
449
+ kind: 'navigate',
450
+ screen: node.open.screen,
451
+ params: { [node.open.param]: String(key) },
452
+ },
453
+ };
454
+ }
455
+ // A list opens the row beside itself: the same screen and view, one more param.
456
+ return node.layout === 'list'
457
+ ? {
458
+ onPress: {
459
+ kind: 'navigate',
460
+ screen: screen.screen,
461
+ params: { ...screen.params, [node.param]: String(key) },
462
+ },
463
+ }
464
+ : {};
465
+ };
466
+ const note = response.note ? [{ type: 'text', value: response.note, muted: true }] : [];
467
+ if (node.layout === 'chart') {
468
+ return [...compileDatasetChart(node, declaration, response, opts.hasMore), ...note];
469
+ }
470
+ if (node.layout === 'board') {
471
+ const group = boardGroupField(node, declaration);
472
+ const subtitleField = declaration.fields.find((f) => f.role === 'subtitle') ??
473
+ declaration.fields.find((f) => f.role === 'owner');
474
+ const valueFields = declaration.fields
475
+ .filter((f) => (f.role === 'amount' || f.role === 'due' || f.role === 'status') && f !== group)
476
+ .slice(0, 3);
477
+ const columns = (group.options ?? []).map((option, index) => ({
478
+ key: /^[a-z][a-z0-9_-]{0,63}$/.test(option.value) ? option.value : `column-${index + 1}`,
479
+ label: option.label.slice(0, 40),
480
+ tone: option.tone,
481
+ cards: response.rows
482
+ .filter((row) => row[group.name] === option.value)
483
+ .slice(0, 100)
484
+ .map((row) => {
485
+ const subtitle = plain(subtitleField, row);
486
+ // A card has no column headers, so a bare "35" says nothing: label
487
+ // the text values. Badges and dates already read on their own.
488
+ const values = valueFields.flatMap((field) => {
489
+ const cell = datasetCell(field, row[field.name]);
490
+ if (!cell)
491
+ return [];
492
+ return cell.kind === 'text'
493
+ ? [{ kind: 'text', value: `${field.label}: ${cell.value}`.slice(0, 500) }]
494
+ : [cell];
495
+ });
496
+ return {
497
+ key: String(row[declaration.key]).slice(0, 128),
498
+ title: (plain(titleField, row) || String(row[declaration.key])).slice(0, 120),
499
+ ...(subtitle ? { subtitle: subtitle.slice(0, 200) } : {}),
500
+ ...(values.length ? { values } : {}),
501
+ ...open(row),
502
+ };
503
+ }),
504
+ }));
505
+ return [{ type: 'board', columns, empty: node.empty.slice(0, 80) }, ...note];
506
+ }
507
+ const fields = shownFields(node, declaration);
508
+ const here = { kind: 'navigate', screen: screen.screen };
509
+ const chips = node.layout === 'list' ? chipField(node, declaration) : undefined;
510
+ const chipBar = [];
511
+ if (chips) {
512
+ const all = allChip(chips);
513
+ const picked = screen.params[chips.name];
514
+ chipBar.push({
515
+ type: 'segmented',
516
+ name: chips.name,
517
+ label: chips.label,
518
+ value: picked && chips.options?.some((option) => option.value === picked) ? picked : all,
519
+ options: [
520
+ { value: all, label: 'All' },
521
+ ...(chips.options ?? []).map((option) => ({
522
+ value: option.value,
523
+ label: option.label.slice(0, 40),
524
+ })),
525
+ ],
526
+ // A new chip starts on page 1, and closes the open row: it may not match the chip.
527
+ onChange: { ...here, params: keepParams(screen.params, ['page', node.param]) },
528
+ });
529
+ }
530
+ const hasFilter = fields.some((field) => field.filterable && field.type === 'enum');
531
+ const hasSort = fields.some((field) => field.sortable);
532
+ const activeSort = screen.params.sort && byName.get(screen.params.sort)?.sortable
533
+ ? {
534
+ column: screen.params.sort,
535
+ direction: screen.params.direction === 'desc' ? 'desc' : 'asc',
536
+ }
537
+ : undefined;
538
+ const table = {
539
+ type: 'table',
540
+ columns: fields.map((field) => ({
541
+ key: field.name,
542
+ label: field.label,
543
+ sortable: field.sortable,
544
+ ...(field.filterable &&
545
+ field.type === 'enum' &&
546
+ !node.filters?.[field.name] &&
547
+ field.name !== chips?.name
548
+ ? {
549
+ filter: {
550
+ ...(screen.params[field.name] &&
551
+ field.options?.some((option) => option.value === screen.params[field.name])
552
+ ? { value: screen.params[field.name] }
553
+ : {}),
554
+ options: (field.options ?? []).map((option) => ({
555
+ value: option.value,
556
+ label: option.label,
557
+ })),
558
+ },
559
+ }
560
+ : {}),
561
+ })),
562
+ ...(activeSort && fields.some((f) => f.name === activeSort.column) ? { sort: activeSort } : {}),
563
+ ...(hasSort
564
+ ? { onSort: { ...here, params: keepParams(screen.params, ['sort', 'direction', 'page']) } }
565
+ : {}),
566
+ ...(hasFilter ? { onFilter: { ...here, params: keepParams(screen.params, ['page']) } } : {}),
567
+ rows: response.rows.map((row) => ({
568
+ key: String(row[declaration.key]).slice(0, 128),
569
+ cells: Object.fromEntries(fields.flatMap((field) => {
570
+ const cell = datasetCell(field, row[field.name]);
571
+ return cell ? [[field.name, cell]] : [];
572
+ })),
573
+ ...open(row),
574
+ })),
575
+ empty: node.empty,
576
+ };
577
+ // The numbered pager needs a true total. Without one, plain Previous and
578
+ // Next buttons say only what is known: whether there is another page.
579
+ const pageSize = node.limit;
580
+ const total = response.total;
581
+ const pageCount = total !== undefined ? Math.min(Math.max(1, Math.ceil(total / pageSize)), DATASET_MAX_PAGE) : 0;
582
+ const stay = keepParams(screen.params, ['page']);
583
+ const step = (label, to) => ({
584
+ type: 'button',
585
+ label,
586
+ variant: 'secondary',
587
+ onPress: { ...here, params: { ...stay, page: String(to) } },
588
+ });
589
+ const pager = total !== undefined
590
+ ? pageCount > 1
591
+ ? [
592
+ {
593
+ type: 'pagination',
594
+ page: Math.min(page, pageCount),
595
+ pageCount,
596
+ pageSize,
597
+ total,
598
+ onPage: { ...here, params: stay },
599
+ },
600
+ ]
601
+ : []
602
+ : [
603
+ ...(page > 1 ? [step('Previous page', page - 1)] : []),
604
+ ...(opts.hasMore && page < DATASET_MAX_PAGE ? [step('Next page', page + 1)] : []),
605
+ ];
606
+ return [...chipBar, table, ...pager, ...note];
607
+ }
608
+ /**
609
+ * The screen param that opens a dataset's create form: its value is the
610
+ * dataset's name. Reserved on every screen that shows a dataset.
611
+ */
612
+ export const DATASET_NEW_PARAM = 'new';
613
+ /**
614
+ * The action a create form submits. Core answers it itself: it checks the input
615
+ * against the declaration and calls the module's `create`. A module never
616
+ * receives it, so no module screen may name an action this.
617
+ */
618
+ export const DATASET_CREATE_ACTION = 'kee-dataset-create';
619
+ /** One form field for one dataset field. */
620
+ function formField(field, required) {
621
+ const base = { name: field.name, label: field.label, required };
622
+ switch (field.type) {
623
+ case 'enum':
624
+ return {
625
+ ...base,
626
+ kind: 'select',
627
+ options: (field.options ?? []).map((option) => ({
628
+ value: option.value,
629
+ label: option.label,
630
+ })),
631
+ };
632
+ case 'number':
633
+ return { ...base, kind: 'number' };
634
+ case 'boolean':
635
+ return { ...base, kind: 'checkbox', required: false };
636
+ case 'date':
637
+ return { ...base, kind: 'date' };
638
+ case 'datetime':
639
+ return { ...base, kind: 'datetime' };
640
+ case 'tags':
641
+ return { ...base, kind: 'list' };
642
+ case 'url':
643
+ return { ...base, kind: 'text', placeholder: 'https://' };
644
+ default:
645
+ return { ...base, kind: 'text' };
646
+ }
647
+ }
648
+ /**
649
+ * The create affordance for a dataset node: a button that opens the form, or,
650
+ * once the `new` param names this dataset, the form itself with Cancel. Empty
651
+ * when the dataset takes no new rows, the node turns it off, or it is a record.
652
+ */
653
+ export function compileDatasetCreate(opts) {
654
+ const { node, declaration, screen } = opts;
655
+ const create = declaration.accepts?.create;
656
+ if (!create || !node.create || node.layout === 'record' || node.layout === 'chart')
657
+ return [];
658
+ const here = { kind: 'navigate', screen: screen.screen };
659
+ if (screen.params[DATASET_NEW_PARAM] !== node.dataset) {
660
+ return [
661
+ {
662
+ type: 'button',
663
+ label: create.label,
664
+ variant: 'primary',
665
+ onPress: {
666
+ ...here,
667
+ params: {
668
+ ...keepParams(screen.params, ['page', node.param]),
669
+ [DATASET_NEW_PARAM]: node.dataset,
670
+ },
671
+ },
672
+ },
673
+ ];
674
+ }
675
+ const byName = new Map(declaration.fields.map((field) => [field.name, field]));
676
+ const required = new Set(create.required);
677
+ const fields = create.fields.flatMap((name) => {
678
+ const field = byName.get(name);
679
+ return field ? [formField(field, required.has(name))] : [];
680
+ });
681
+ return [
682
+ {
683
+ type: 'section',
684
+ title: create.label,
685
+ layout: 'stack',
686
+ children: [
687
+ {
688
+ type: 'form',
689
+ key: `new-${node.dataset}`.slice(0, 64),
690
+ fields,
691
+ submit: { label: 'Save', action: DATASET_CREATE_ACTION },
692
+ },
693
+ {
694
+ type: 'button',
695
+ label: 'Cancel',
696
+ variant: 'secondary',
697
+ onPress: { ...here, params: keepParams(screen.params, [DATASET_NEW_PARAM]) },
698
+ },
699
+ ],
700
+ },
701
+ ];
702
+ }