@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.
- package/README.md +118 -0
- package/dist/badges.d.ts +65 -0
- package/dist/badges.js +96 -0
- package/dist/build.js +2 -2
- package/dist/cells.d.ts +31 -0
- package/dist/cells.js +45 -0
- package/dist/chart.d.ts +20 -0
- package/dist/chart.js +38 -0
- package/dist/conform.d.ts +21 -0
- package/dist/conform.js +310 -0
- package/dist/dataset.d.ts +6 -6
- package/dist/date-format.d.ts +64 -0
- package/dist/date-format.js +132 -0
- package/dist/drawer.d.ts +22 -0
- package/dist/drawer.js +21 -0
- package/dist/edit.d.ts +60 -0
- package/dist/edit.js +60 -0
- package/dist/filters.d.ts +54 -0
- package/dist/filters.js +81 -0
- package/dist/form.d.ts +45 -1
- package/dist/form.js +39 -0
- package/dist/heatmap.d.ts +36 -0
- package/dist/heatmap.js +56 -0
- package/dist/icon.d.ts +20 -0
- package/dist/icon.js +43 -0
- package/dist/index.d.ts +37 -4
- package/dist/index.js +21 -2
- package/dist/markdown-links.d.ts +29 -0
- package/dist/markdown-links.js +99 -0
- package/dist/media.js +7 -1
- package/dist/negotiate.d.ts +302 -0
- package/dist/negotiate.js +1092 -0
- package/dist/nodes.d.ts +1 -1
- package/dist/nodes.js +3 -0
- package/dist/pager.d.ts +2 -2
- package/dist/player.d.ts +46 -0
- package/dist/player.js +86 -0
- package/dist/published/index.d.ts +4 -0
- package/dist/published/index.js +18 -0
- package/dist/published/v1.1.0/schema.d.ts +439 -0
- package/dist/published/v1.1.0/schema.js +405 -0
- package/dist/published/v1.4.0/dataset-view.d.ts +160 -0
- package/dist/published/v1.4.0/dataset-view.js +702 -0
- package/dist/published/v1.4.0/dataset.d.ts +331 -0
- package/dist/published/v1.4.0/dataset.js +628 -0
- package/dist/published/v1.4.0/schema.d.ts +1132 -0
- package/dist/published/v1.4.0/schema.js +1410 -0
- package/dist/schema.d.ts +1638 -20
- package/dist/schema.js +1275 -72
- package/dist/section.d.ts +65 -0
- package/dist/section.js +81 -0
- package/dist/stacked-bar.d.ts +34 -0
- package/dist/stacked-bar.js +20 -0
- package/dist/stat.d.ts +24 -0
- package/dist/stat.js +27 -0
- package/dist/table-row.d.ts +24 -0
- package/dist/table-row.js +23 -0
- package/dist/tabs.d.ts +36 -0
- package/dist/tabs.js +83 -0
- package/dist/testkit.d.ts +38 -0
- package/dist/testkit.js +68 -0
- package/dist/timeline.d.ts +5 -0
- package/dist/timeline.js +35 -0
- package/examples/campaigns/README.md +22 -1
- package/examples/campaigns/approvals/index.ts +23 -0
- package/examples/campaigns/approvals/launch.ts +38 -0
- package/examples/campaigns/brief/index.ts +23 -0
- package/examples/campaigns/brief/ready.ts +43 -0
- package/examples/campaigns/demo-approvals.ts +35 -0
- package/examples/campaigns/demo-brief.ts +23 -0
- package/examples/campaigns/entry.json +71 -1
- package/examples/campaigns/tsconfig.json +8 -1
- 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
|
+
}
|