@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,405 @@
|
|
|
1
|
+
// FROZEN COPY of the published @keemakr/ui-sdk@1.1.0 src/schema.ts (git tag @keemakr/ui-sdk@1.1.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 server-driven UI contract between a module runtime and keemakr-core.
|
|
6
|
+
*
|
|
7
|
+
* A module's `load` handler returns a ScreenTree; core validates it against
|
|
8
|
+
* this schema BEFORE rendering — that validation is the security boundary.
|
|
9
|
+
* Nothing here ever becomes raw HTML: every node maps to a platform-owned
|
|
10
|
+
* component, so module output is data, not code.
|
|
11
|
+
*
|
|
12
|
+
* Versioning: `version` is a literal. A breaking change to any node shape
|
|
13
|
+
* bumps UI_SCHEMA_VERSION and adds a new literal; core keeps rendering old
|
|
14
|
+
* versions until modules migrate.
|
|
15
|
+
*/
|
|
16
|
+
import { z } from 'zod';
|
|
17
|
+
export const UI_SCHEMA_VERSION = 1;
|
|
18
|
+
const slug = z
|
|
19
|
+
.string()
|
|
20
|
+
.regex(/^[a-z][a-z0-9_-]*$/, 'lowercase slug (a-z, 0-9, -, _)')
|
|
21
|
+
.max(64);
|
|
22
|
+
/** What a button (or table row) does when pressed. */
|
|
23
|
+
export const pressSchema = z.discriminatedUnion('kind', [
|
|
24
|
+
z.object({
|
|
25
|
+
kind: z.literal('action'),
|
|
26
|
+
/** Key into the screen's `actions` map on the module runtime. */
|
|
27
|
+
action: slug,
|
|
28
|
+
input: z.record(z.string(), z.unknown()).optional(),
|
|
29
|
+
/** If set, core shows this confirmation before dispatching. */
|
|
30
|
+
confirm: z.string().max(500).optional(),
|
|
31
|
+
}),
|
|
32
|
+
z.object({
|
|
33
|
+
kind: z.literal('navigate'),
|
|
34
|
+
/** Another screen key of the same module. */
|
|
35
|
+
screen: slug,
|
|
36
|
+
params: z.record(z.string(), z.string()).optional(),
|
|
37
|
+
}),
|
|
38
|
+
]);
|
|
39
|
+
const tone = z.enum(['default', 'success', 'warning', 'destructive']);
|
|
40
|
+
export const buttonSchema = z.object({
|
|
41
|
+
type: z.literal('button'),
|
|
42
|
+
label: z.string().max(80),
|
|
43
|
+
onPress: pressSchema,
|
|
44
|
+
variant: z.enum(['primary', 'secondary', 'destructive']).default('secondary'),
|
|
45
|
+
});
|
|
46
|
+
/** A table cell. Dates are ISO 8601; core formats them tenant-locale-aware. */
|
|
47
|
+
export const cellSchema = z.discriminatedUnion('kind', [
|
|
48
|
+
z.object({ kind: z.literal('text'), value: z.string().max(500) }),
|
|
49
|
+
z.object({ kind: z.literal('badge'), value: z.string().max(40), tone: tone.default('default') }),
|
|
50
|
+
z.object({ kind: z.literal('date'), value: z.iso.datetime() }),
|
|
51
|
+
]);
|
|
52
|
+
const choiceCardsSchema = z.object({
|
|
53
|
+
type: z.literal('choice_cards'),
|
|
54
|
+
choices: z
|
|
55
|
+
.array(z.object({
|
|
56
|
+
id: z.string().min(1).max(64),
|
|
57
|
+
title: z.string().min(1).max(120),
|
|
58
|
+
description: z.string().max(500).optional(),
|
|
59
|
+
meta: z.string().max(120).optional(),
|
|
60
|
+
selected: z.boolean().default(false),
|
|
61
|
+
onPress: pressSchema,
|
|
62
|
+
}))
|
|
63
|
+
.min(1)
|
|
64
|
+
.max(20),
|
|
65
|
+
});
|
|
66
|
+
const progressSchema = z.object({
|
|
67
|
+
type: z.literal('progress'),
|
|
68
|
+
steps: z
|
|
69
|
+
.array(z.object({
|
|
70
|
+
label: z.string().min(1).max(200),
|
|
71
|
+
status: z.enum(['pending', 'active', 'done', 'error']),
|
|
72
|
+
detail: z.string().max(1000).optional(),
|
|
73
|
+
}))
|
|
74
|
+
.min(1)
|
|
75
|
+
.max(100),
|
|
76
|
+
});
|
|
77
|
+
const chartSchema = z
|
|
78
|
+
.object({
|
|
79
|
+
type: z.literal('chart'),
|
|
80
|
+
chartType: z.enum(['bar', 'line']),
|
|
81
|
+
title: z.string().max(120).optional(),
|
|
82
|
+
categories: z.array(z.string().max(80)).min(1).max(50),
|
|
83
|
+
series: z
|
|
84
|
+
.array(z.object({
|
|
85
|
+
name: z.string().min(1).max(80),
|
|
86
|
+
values: z.array(z.number().finite()).min(1).max(50),
|
|
87
|
+
}))
|
|
88
|
+
.min(1)
|
|
89
|
+
.max(8),
|
|
90
|
+
})
|
|
91
|
+
.superRefine((chart, ctx) => {
|
|
92
|
+
for (let i = 0; i < chart.series.length; i += 1) {
|
|
93
|
+
const series = chart.series[i];
|
|
94
|
+
if (series && series.values.length !== chart.categories.length) {
|
|
95
|
+
ctx.addIssue({
|
|
96
|
+
code: 'custom',
|
|
97
|
+
path: ['series', i, 'values'],
|
|
98
|
+
message: 'must contain one value per category',
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
const headingSchema = z.object({ type: z.literal('heading'), text: z.string().max(120) });
|
|
104
|
+
const textSchema = z.object({
|
|
105
|
+
type: z.literal('text'),
|
|
106
|
+
value: z.string().max(5000),
|
|
107
|
+
muted: z.boolean().default(false),
|
|
108
|
+
});
|
|
109
|
+
/** Rendered by core's sanitizing markdown renderer — never raw HTML. */
|
|
110
|
+
const markdownSchema = z.object({ type: z.literal('markdown'), value: z.string().max(20000) });
|
|
111
|
+
const statSchema = z.object({
|
|
112
|
+
type: z.literal('stat'),
|
|
113
|
+
label: z.string().max(80),
|
|
114
|
+
value: z.string().max(40),
|
|
115
|
+
hint: z.string().max(120).optional(),
|
|
116
|
+
});
|
|
117
|
+
/**
|
|
118
|
+
* What a table row may reveal in place. Deliberately NOT the leaf union and NOT
|
|
119
|
+
* `nodeSchema`: `table` is itself a leaf, so either reference re-opens the
|
|
120
|
+
* recursion — both were measured accepting a 200-level `table -> expand -> table`
|
|
121
|
+
* chain, escaping MAX_SECTION_DEPTH entirely, and both collapsed type inference
|
|
122
|
+
* (TS7022 on the leaf union, TS7024 on the lazy callback). Naming the five
|
|
123
|
+
* content branches explicitly keeps this non-recursive by construction, which is
|
|
124
|
+
* why the depth test asserts REJECTION rather than slowness: the validator
|
|
125
|
+
* handles 200 levels in tens of milliseconds, so nothing upstream would catch it.
|
|
126
|
+
*
|
|
127
|
+
* The general rule: any leaf that references the leaf union re-opens the recursion.
|
|
128
|
+
*/
|
|
129
|
+
const expandContentSchema = z.discriminatedUnion('type', [
|
|
130
|
+
headingSchema,
|
|
131
|
+
textSchema,
|
|
132
|
+
markdownSchema,
|
|
133
|
+
statSchema,
|
|
134
|
+
buttonSchema,
|
|
135
|
+
]);
|
|
136
|
+
const leafNodeSchema = z.discriminatedUnion('type', [
|
|
137
|
+
headingSchema,
|
|
138
|
+
textSchema,
|
|
139
|
+
markdownSchema,
|
|
140
|
+
statSchema,
|
|
141
|
+
buttonSchema,
|
|
142
|
+
z.object({
|
|
143
|
+
type: z.literal('form'),
|
|
144
|
+
key: slug,
|
|
145
|
+
fields: z
|
|
146
|
+
.array(z.object({
|
|
147
|
+
kind: z.enum(['text', 'textarea', 'select']),
|
|
148
|
+
name: slug,
|
|
149
|
+
label: z.string().max(80),
|
|
150
|
+
value: z.string().max(20000).optional(),
|
|
151
|
+
options: z.array(z.object({ value: z.string(), label: z.string() })).optional(),
|
|
152
|
+
required: z.boolean().default(false),
|
|
153
|
+
}))
|
|
154
|
+
.max(30),
|
|
155
|
+
/** Field values are posted as the action's `input`, keyed by field name. */
|
|
156
|
+
submit: z.object({ label: z.string().max(80), action: slug }),
|
|
157
|
+
}),
|
|
158
|
+
z.object({
|
|
159
|
+
type: z.literal('table'),
|
|
160
|
+
columns: z.array(z.object({ key: slug, label: z.string().max(80) })).max(12),
|
|
161
|
+
rows: z
|
|
162
|
+
.array(z.object({
|
|
163
|
+
key: z.string().max(128),
|
|
164
|
+
cells: z.record(slug, cellSchema),
|
|
165
|
+
onPress: pressSchema.optional(),
|
|
166
|
+
/**
|
|
167
|
+
* Content the row reveals in place, no navigation hop. Content nodes
|
|
168
|
+
* only — no nested `table`, no `section`.
|
|
169
|
+
*
|
|
170
|
+
* A row declaring `expand` WITH CONTENT toggles disclosure when
|
|
171
|
+
* activated, and its `onPress` is IGNORED. The renderer already gives a
|
|
172
|
+
* pressable row `role="button"` with an Enter/Space handler, so a second
|
|
173
|
+
* focusable control inside it would be invalid ARIA with two handlers on
|
|
174
|
+
* one key. Expansion is local view state; a press means "call my
|
|
175
|
+
* runtime". They stay apart deliberately.
|
|
176
|
+
*
|
|
177
|
+
* Zero children is allowed and means NO disclosure — the row behaves
|
|
178
|
+
* exactly as if `expand` were absent, `onPress` included. A body built
|
|
179
|
+
* from data legitimately drops to nothing, and rejecting that would fail
|
|
180
|
+
* the whole screen load rather than degrade one row (the same cliff the
|
|
181
|
+
* tab strip's minimum had). The maximum stays: an oversize body has no
|
|
182
|
+
* safe degradation — truncating it would be silent data loss.
|
|
183
|
+
*/
|
|
184
|
+
expand: z.object({ children: z.array(expandContentSchema).max(20) }).optional(),
|
|
185
|
+
}))
|
|
186
|
+
.max(500),
|
|
187
|
+
/** Shown when `rows` is empty — every data-bound surface needs an empty state. */
|
|
188
|
+
empty: z.string().max(200),
|
|
189
|
+
}),
|
|
190
|
+
z.object({
|
|
191
|
+
type: z.literal('timeline'),
|
|
192
|
+
items: z
|
|
193
|
+
.array(z.object({
|
|
194
|
+
label: z.string().max(200),
|
|
195
|
+
status: z.enum(['pending', 'active', 'done', 'error']),
|
|
196
|
+
at: z.iso.datetime().optional(),
|
|
197
|
+
detail: z.string().max(1000).optional(),
|
|
198
|
+
}))
|
|
199
|
+
.max(200),
|
|
200
|
+
}),
|
|
201
|
+
/**
|
|
202
|
+
* A HITL approval slot — a REFERENCE, never content. The module places the
|
|
203
|
+
* request id where the card should appear; core resolves it at render time
|
|
204
|
+
* (tenant + this-install + still-pending checks) and renders the platform
|
|
205
|
+
* card wired to the respond endpoint. The module cannot author the card
|
|
206
|
+
* (a forged prompt/options here is ignored — this node carries no fields
|
|
207
|
+
* for them) and the tree transports no PHI, only the opaque id.
|
|
208
|
+
*/
|
|
209
|
+
z.object({ type: z.literal('pending_request'), requestId: z.string().min(1).max(128) }),
|
|
210
|
+
/**
|
|
211
|
+
* The zero-bookkeeping HITL slot: "render ALL of this module's still-pending
|
|
212
|
+
* approvals here." Core fills it from its own pending store — the module
|
|
213
|
+
* needs no request-id tracking (which the id-form above requires). Requests
|
|
214
|
+
* already placed individually via `pending_request` elsewhere in the same
|
|
215
|
+
* tree are not repeated here.
|
|
216
|
+
*/
|
|
217
|
+
z.object({ type: z.literal('pending_requests') }),
|
|
218
|
+
choiceCardsSchema,
|
|
219
|
+
progressSchema,
|
|
220
|
+
chartSchema,
|
|
221
|
+
]);
|
|
222
|
+
const MAX_SECTION_DEPTH = 8;
|
|
223
|
+
const nodeSchemas = new Map();
|
|
224
|
+
/** Shared by the chain and by the root union, so the two cannot drift apart. */
|
|
225
|
+
const sectionBranch = (children) => z.object({
|
|
226
|
+
type: z.literal('section'),
|
|
227
|
+
title: z.string().max(120).optional(),
|
|
228
|
+
description: z.string().max(300).optional(),
|
|
229
|
+
layout: z.enum(['stack', 'grid']),
|
|
230
|
+
columns: z.number().int().min(1).max(4),
|
|
231
|
+
children: z.array(children).max(100),
|
|
232
|
+
});
|
|
233
|
+
const tabsBranch = (children) => z
|
|
234
|
+
.object({
|
|
235
|
+
type: z.literal('tabs'),
|
|
236
|
+
key: slug,
|
|
237
|
+
/** A hint only — the renderer owns selection, and it survives no reload. */
|
|
238
|
+
active: slug.optional(),
|
|
239
|
+
// One tab is legitimate: a strip generated per live queue drops to one when
|
|
240
|
+
// only one queue exists, and rejecting that fails the WHOLE screen load.
|
|
241
|
+
tabs: z
|
|
242
|
+
.array(z.object({
|
|
243
|
+
key: slug,
|
|
244
|
+
label: z.string().min(1).max(40),
|
|
245
|
+
/** Sits beside the label — a count, not prose. */
|
|
246
|
+
badge: z.string().max(12).optional(),
|
|
247
|
+
children: z.array(children).max(100),
|
|
248
|
+
}))
|
|
249
|
+
.min(1)
|
|
250
|
+
.max(6),
|
|
251
|
+
})
|
|
252
|
+
.superRefine((node, ctx) => {
|
|
253
|
+
const keys = new Set();
|
|
254
|
+
for (const [index, tab] of node.tabs.entries()) {
|
|
255
|
+
if (keys.has(tab.key)) {
|
|
256
|
+
ctx.addIssue({
|
|
257
|
+
code: 'custom',
|
|
258
|
+
path: ['tabs', index, 'key'],
|
|
259
|
+
message: `duplicate tab key "${tab.key}"`,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
keys.add(tab.key);
|
|
263
|
+
}
|
|
264
|
+
if (node.active !== undefined && !keys.has(node.active)) {
|
|
265
|
+
ctx.addIssue({
|
|
266
|
+
code: 'custom',
|
|
267
|
+
path: ['active'],
|
|
268
|
+
message: `active tab "${node.active}" is not one of the declared tabs`,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
function nodeSchemaAtDepth(depth) {
|
|
273
|
+
const cached = nodeSchemas.get(depth);
|
|
274
|
+
if (cached)
|
|
275
|
+
return cached;
|
|
276
|
+
if (depth === MAX_SECTION_DEPTH)
|
|
277
|
+
return leafNodeSchema;
|
|
278
|
+
const schema = z.discriminatedUnion('type', [
|
|
279
|
+
leafNodeSchema,
|
|
280
|
+
sectionBranch(z.lazy(() => nodeSchemaAtDepth(depth + 1))),
|
|
281
|
+
]);
|
|
282
|
+
nodeSchemas.set(depth, schema);
|
|
283
|
+
return schema;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* The root union: the chain's own membership plus `tabs`. Both container
|
|
287
|
+
* branches descend into the chain at depth 1, so nesting stays bounded by
|
|
288
|
+
* MAX_SECTION_DEPTH exactly as it was before `tabs` existed.
|
|
289
|
+
*/
|
|
290
|
+
export const nodeSchema = z.discriminatedUnion('type', [
|
|
291
|
+
leafNodeSchema,
|
|
292
|
+
sectionBranch(z.lazy(() => nodeSchemaAtDepth(1))),
|
|
293
|
+
tabsBranch(z.lazy(() => nodeSchemaAtDepth(1))),
|
|
294
|
+
]);
|
|
295
|
+
export const screenTreeSchema = z.object({
|
|
296
|
+
version: z.literal(UI_SCHEMA_VERSION),
|
|
297
|
+
title: z.string().max(120),
|
|
298
|
+
description: z.string().max(300).optional(),
|
|
299
|
+
/** Page-level actions, rendered in the header (e.g. "New draft"). */
|
|
300
|
+
actions: z.array(buttonSchema).max(4).optional(),
|
|
301
|
+
children: z.array(nodeSchema).max(100),
|
|
302
|
+
// ponytail: polling keeps live views (agent activity) simple; switch to the
|
|
303
|
+
// existing run-event NDJSON stream when polling measurably falls short.
|
|
304
|
+
refreshSeconds: z.number().int().min(2).max(300).optional(),
|
|
305
|
+
});
|
|
306
|
+
/** What an action handler returns. `refresh` re-runs `load` after the action. */
|
|
307
|
+
export const actionResultSchema = z.object({
|
|
308
|
+
toast: z.string().max(200).optional(),
|
|
309
|
+
navigate: z
|
|
310
|
+
.object({ screen: slug, params: z.record(z.string(), z.string()).optional() })
|
|
311
|
+
.optional(),
|
|
312
|
+
refresh: z.boolean().default(true),
|
|
313
|
+
});
|
|
314
|
+
/* --------------------------------------------------------------------- */
|
|
315
|
+
/* Wire protocol: core proxy <-> module runtime (grant in Authorization). */
|
|
316
|
+
/* eve runtimes serve these from agent/channels/screens.ts (custom channel */
|
|
317
|
+
/* routes mount at the runtime root): */
|
|
318
|
+
/* POST {runtime}/screens/{screen}/load -> ScreenLoadResponse */
|
|
319
|
+
/* POST {runtime}/screens/{screen}/actions/{action} -> ActionResult */
|
|
320
|
+
/* --------------------------------------------------------------------- */
|
|
321
|
+
export const screenLoadRequestSchema = z.object({
|
|
322
|
+
params: z.record(z.string(), z.string()).default({}),
|
|
323
|
+
});
|
|
324
|
+
export const screenLoadResponseSchema = z.object({ tree: screenTreeSchema });
|
|
325
|
+
export const screenActionRequestSchema = z.object({
|
|
326
|
+
params: z.record(z.string(), z.string()).default({}),
|
|
327
|
+
input: z.record(z.string(), z.unknown()).default({}),
|
|
328
|
+
/** Core-generated, forwarded so the handler can be idempotent on retries too
|
|
329
|
+
* (core already dedupes via command_log; this is the belt-and-suspenders
|
|
330
|
+
* half of at-least-once). Optional for runtime version-skew tolerance. */
|
|
331
|
+
idempotencyKey: z.string().max(200).optional(),
|
|
332
|
+
});
|
|
333
|
+
export const interfaceFamilySchema = z.enum(['dashboard', 'client']);
|
|
334
|
+
export const surfaceDefinitionSchema = z.object({
|
|
335
|
+
label: z.string().min(1).max(60),
|
|
336
|
+
/** Lucide icon name, same convention as entry.json `icon`. */
|
|
337
|
+
icon: z.string().max(60).optional(),
|
|
338
|
+
description: z.string().max(200).optional(),
|
|
339
|
+
availableOn: z
|
|
340
|
+
.array(interfaceFamilySchema)
|
|
341
|
+
.min(1)
|
|
342
|
+
.max(2)
|
|
343
|
+
.refine((families) => new Set(families).size === families.length, {
|
|
344
|
+
message: 'interface families must be unique',
|
|
345
|
+
}),
|
|
346
|
+
});
|
|
347
|
+
/**
|
|
348
|
+
* Manifest addition: `manifest.surfaces[]` in entry.json. Each surface is a
|
|
349
|
+
* side-menu entry pointing at an entry screen. Core's marketplace
|
|
350
|
+
* validateSubmission composes this in (wired in the proxy phase).
|
|
351
|
+
*/
|
|
352
|
+
export const surfaceSchema = surfaceDefinitionSchema.extend({
|
|
353
|
+
key: slug,
|
|
354
|
+
screen: slug,
|
|
355
|
+
});
|
|
356
|
+
export const screenRegistrationSchema = z.object({
|
|
357
|
+
screen: slug,
|
|
358
|
+
surface: slug,
|
|
359
|
+
});
|
|
360
|
+
/** The complete generated UI portion of a module manifest. */
|
|
361
|
+
export const uiManifestSchema = z
|
|
362
|
+
.object({
|
|
363
|
+
surfaces: z.array(surfaceSchema).min(1),
|
|
364
|
+
screenRegistry: z.array(screenRegistrationSchema).min(1).max(256),
|
|
365
|
+
})
|
|
366
|
+
.superRefine((manifest, ctx) => {
|
|
367
|
+
const surfaceKeys = new Set();
|
|
368
|
+
for (const [index, surface] of manifest.surfaces.entries()) {
|
|
369
|
+
if (surfaceKeys.has(surface.key)) {
|
|
370
|
+
ctx.addIssue({
|
|
371
|
+
code: 'custom',
|
|
372
|
+
path: ['surfaces', index, 'key'],
|
|
373
|
+
message: `duplicate surface key "${surface.key}"`,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
surfaceKeys.add(surface.key);
|
|
377
|
+
}
|
|
378
|
+
const screens = new Map();
|
|
379
|
+
for (const [index, registration] of manifest.screenRegistry.entries()) {
|
|
380
|
+
if (!surfaceKeys.has(registration.surface)) {
|
|
381
|
+
ctx.addIssue({
|
|
382
|
+
code: 'custom',
|
|
383
|
+
path: ['screenRegistry', index, 'surface'],
|
|
384
|
+
message: `unknown surface "${registration.surface}"`,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
if (screens.has(registration.screen)) {
|
|
388
|
+
ctx.addIssue({
|
|
389
|
+
code: 'custom',
|
|
390
|
+
path: ['screenRegistry', index, 'screen'],
|
|
391
|
+
message: `duplicate screen identity "${registration.screen}"`,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
screens.set(registration.screen, registration.surface);
|
|
395
|
+
}
|
|
396
|
+
for (const [index, surface] of manifest.surfaces.entries()) {
|
|
397
|
+
if (screens.get(surface.screen) !== surface.key) {
|
|
398
|
+
ctx.addIssue({
|
|
399
|
+
code: 'custom',
|
|
400
|
+
path: ['surfaces', index, 'screen'],
|
|
401
|
+
message: `root screen "${surface.screen}" must be registered to surface "${surface.key}"`,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `dataset` node, and how a dataset becomes blocks.
|
|
3
|
+
*
|
|
4
|
+
* A screen places one of its module's datasets with a single node:
|
|
5
|
+
*
|
|
6
|
+
* { type: 'dataset', dataset: 'leads', layout: 'table' }
|
|
7
|
+
*
|
|
8
|
+
* Core expands the node before any renderer sees it: it queries the dataset
|
|
9
|
+
* with the viewer's grant and replaces the node with blocks every app already
|
|
10
|
+
* draws. So a new layout never needs a new client. The layouts:
|
|
11
|
+
*
|
|
12
|
+
* - `table`: a table with sort and filter headers and a pager.
|
|
13
|
+
* - `board`: cards in columns, grouped by a stage or a status.
|
|
14
|
+
* - `list`: a table with filter chips over its stage or status. Pressing a
|
|
15
|
+
* row opens it as a record beside the list, in the screen's right rail.
|
|
16
|
+
* - `record`: one row, named by a screen param, as a page: its title, its
|
|
17
|
+
* status, and its other fields as facts. This file is the pure
|
|
18
|
+
* half of that: the node's schema, how a screen's params become a query, and
|
|
19
|
+
* how one page of rows becomes blocks. It runs on the server and in tests.
|
|
20
|
+
*
|
|
21
|
+
* Interaction is navigation. A sort header, a filter menu and the pager each
|
|
22
|
+
* navigate back to the same screen with params (`sort`, `direction`, a field
|
|
23
|
+
* name, the page param), and the next expansion reads them. One interactive
|
|
24
|
+
* dataset node per screen: two would share those param names.
|
|
25
|
+
*/
|
|
26
|
+
import { z } from 'zod';
|
|
27
|
+
import { type DatasetDeclaration, type DatasetField, type DatasetQueryRequestInput, type DatasetQueryResponse, type DatasetRow } from './dataset.js';
|
|
28
|
+
export declare const DATASET_LAYOUTS: readonly ["table", "board", "list", "record", "chart"];
|
|
29
|
+
export type DatasetLayout = (typeof DATASET_LAYOUTS)[number];
|
|
30
|
+
/** Deepest page a table reaches. Paging walks cursors from page 1. */
|
|
31
|
+
export declare const DATASET_MAX_PAGE = 20;
|
|
32
|
+
export declare const datasetNodeSchema: z.ZodObject<{
|
|
33
|
+
type: z.ZodLiteral<"dataset">;
|
|
34
|
+
dataset: z.ZodString;
|
|
35
|
+
layout: z.ZodDefault<z.ZodEnum<{
|
|
36
|
+
record: "record";
|
|
37
|
+
table: "table";
|
|
38
|
+
board: "board";
|
|
39
|
+
list: "list";
|
|
40
|
+
chart: "chart";
|
|
41
|
+
}>>;
|
|
42
|
+
fields: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
43
|
+
filters: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
44
|
+
sort: z.ZodOptional<z.ZodObject<{
|
|
45
|
+
field: z.ZodString;
|
|
46
|
+
direction: z.ZodDefault<z.ZodEnum<{
|
|
47
|
+
asc: "asc";
|
|
48
|
+
desc: "desc";
|
|
49
|
+
}>>;
|
|
50
|
+
}, z.core.$strip>>;
|
|
51
|
+
limit: z.ZodDefault<z.ZodNumber>;
|
|
52
|
+
groupBy: z.ZodOptional<z.ZodString>;
|
|
53
|
+
chart: z.ZodOptional<z.ZodObject<{
|
|
54
|
+
form: z.ZodDefault<z.ZodEnum<{
|
|
55
|
+
bars: "bars";
|
|
56
|
+
funnel: "funnel";
|
|
57
|
+
}>>;
|
|
58
|
+
measure: z.ZodOptional<z.ZodString>;
|
|
59
|
+
}, z.core.$strip>>;
|
|
60
|
+
open: z.ZodOptional<z.ZodObject<{
|
|
61
|
+
screen: z.ZodString;
|
|
62
|
+
param: z.ZodDefault<z.ZodString>;
|
|
63
|
+
}, z.core.$strip>>;
|
|
64
|
+
param: z.ZodDefault<z.ZodString>;
|
|
65
|
+
empty: z.ZodDefault<z.ZodString>;
|
|
66
|
+
create: z.ZodDefault<z.ZodBoolean>;
|
|
67
|
+
}, z.core.$strip>;
|
|
68
|
+
export type DatasetNode = z.infer<typeof datasetNodeSchema>;
|
|
69
|
+
export type DatasetNodeInput = z.input<typeof datasetNodeSchema>;
|
|
70
|
+
/** The screen a dataset node sits on: where its presses navigate back to. */
|
|
71
|
+
export interface DatasetScreenContext {
|
|
72
|
+
screen: string;
|
|
73
|
+
params: Record<string, string>;
|
|
74
|
+
}
|
|
75
|
+
/** Problems with a node against its dataset. Core shows these, never a broken view. */
|
|
76
|
+
export declare function datasetNodeIssues(node: DatasetNode, declaration: DatasetDeclaration): string[];
|
|
77
|
+
/** Which page of a table the params ask for, 1 when none. */
|
|
78
|
+
export declare function datasetPage(node: DatasetNode, params: Record<string, string>): number;
|
|
79
|
+
/**
|
|
80
|
+
* The key of the row a record shows, or a list opens beside it. Undefined when
|
|
81
|
+
* the node shows no single row, or the param is empty.
|
|
82
|
+
*/
|
|
83
|
+
export declare function datasetRecordKey(node: DatasetNode, params: Record<string, string>): string | undefined;
|
|
84
|
+
/** The query for the one row a record shows. */
|
|
85
|
+
export declare function datasetRecordQuery(key: string): DatasetQueryRequestInput;
|
|
86
|
+
/**
|
|
87
|
+
* The query a node and the screen's params make. The node's own filters win
|
|
88
|
+
* over a param of the same name; a param for a field the dataset does not let
|
|
89
|
+
* a person filter is ignored, never forwarded.
|
|
90
|
+
*/
|
|
91
|
+
export declare function datasetQueryFor(node: DatasetNode, declaration: DatasetDeclaration, params: Record<string, string>): DatasetQueryRequestInput;
|
|
92
|
+
type Cell = {
|
|
93
|
+
kind: 'text';
|
|
94
|
+
value: string;
|
|
95
|
+
} | {
|
|
96
|
+
kind: 'badge';
|
|
97
|
+
value: string;
|
|
98
|
+
tone: 'default' | 'success' | 'warning' | 'destructive';
|
|
99
|
+
} | {
|
|
100
|
+
kind: 'date';
|
|
101
|
+
value: string;
|
|
102
|
+
};
|
|
103
|
+
/** One value as a table cell, or null when the row has none. */
|
|
104
|
+
export declare function datasetCell(field: DatasetField, value: DatasetRow[string]): Cell | null;
|
|
105
|
+
/**
|
|
106
|
+
* One row as a record: its title, its subtitle, a tag for its status and its
|
|
107
|
+
* stage, and its other fields as facts. `row` undefined means the module did
|
|
108
|
+
* not find the key, and the record says so.
|
|
109
|
+
*/
|
|
110
|
+
export declare function compileDatasetRecord(opts: {
|
|
111
|
+
declaration: DatasetDeclaration;
|
|
112
|
+
row: DatasetRow | undefined;
|
|
113
|
+
}): Record<string, unknown>[];
|
|
114
|
+
/** The key of the root section that holds the record a list opened. */
|
|
115
|
+
export declare const DATASET_RECORD_SECTION = "dataset-record";
|
|
116
|
+
/**
|
|
117
|
+
* The record a list opened, as the section the screen's right rail shows,
|
|
118
|
+
* with a button that closes it.
|
|
119
|
+
*/
|
|
120
|
+
export declare function compileDatasetRecordPanel(opts: {
|
|
121
|
+
node: DatasetNode;
|
|
122
|
+
declaration: DatasetDeclaration;
|
|
123
|
+
row: DatasetRow | undefined;
|
|
124
|
+
screen: DatasetScreenContext;
|
|
125
|
+
}): Record<string, unknown>;
|
|
126
|
+
/**
|
|
127
|
+
* One page of rows as screen blocks. `rows` is the page to show; `total` and
|
|
128
|
+
* `hasMore` drive the pager. The output is plain screen nodes, ready to put in
|
|
129
|
+
* the node's place.
|
|
130
|
+
*/
|
|
131
|
+
export declare function compileDatasetNode(opts: {
|
|
132
|
+
node: DatasetNode;
|
|
133
|
+
declaration: DatasetDeclaration;
|
|
134
|
+
response: DatasetQueryResponse;
|
|
135
|
+
screen: DatasetScreenContext;
|
|
136
|
+
page: number;
|
|
137
|
+
hasMore: boolean;
|
|
138
|
+
}): Record<string, unknown>[];
|
|
139
|
+
/**
|
|
140
|
+
* The screen param that opens a dataset's create form: its value is the
|
|
141
|
+
* dataset's name. Reserved on every screen that shows a dataset.
|
|
142
|
+
*/
|
|
143
|
+
export declare const DATASET_NEW_PARAM = "new";
|
|
144
|
+
/**
|
|
145
|
+
* The action a create form submits. Core answers it itself: it checks the input
|
|
146
|
+
* against the declaration and calls the module's `create`. A module never
|
|
147
|
+
* receives it, so no module screen may name an action this.
|
|
148
|
+
*/
|
|
149
|
+
export declare const DATASET_CREATE_ACTION = "kee-dataset-create";
|
|
150
|
+
/**
|
|
151
|
+
* The create affordance for a dataset node: a button that opens the form, or,
|
|
152
|
+
* once the `new` param names this dataset, the form itself with Cancel. Empty
|
|
153
|
+
* when the dataset takes no new rows, the node turns it off, or it is a record.
|
|
154
|
+
*/
|
|
155
|
+
export declare function compileDatasetCreate(opts: {
|
|
156
|
+
node: DatasetNode;
|
|
157
|
+
declaration: DatasetDeclaration;
|
|
158
|
+
screen: DatasetScreenContext;
|
|
159
|
+
}): Record<string, unknown>[];
|
|
160
|
+
export {};
|