@svgrid/enterprise 2.0.4 → 2.2.0
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 +18 -6
- package/dist/cdn/svgrid-enterprise.svelte-external.js +14024 -6835
- package/dist/node/studio.js +7888 -2459
- package/package.json +9 -4
- package/src/SvGridMasterDetail.svelte +24 -3
- package/src/SvGridScheduler.svelte +4410 -0
- package/src/SvPivotDesigner.svelte +1990 -1045
- package/src/SvSchemaChart.svelte +10 -9
- package/src/ai.test.ts +522 -522
- package/src/ai.ts +202 -2
- package/src/index.ts +409 -384
- package/src/install.ts +10 -0
- package/src/pivot-chart.test.ts +86 -0
- package/src/pivot-chart.ts +112 -0
- package/src/scheduler.ts +37 -0
- package/src/scheduling.test.ts +194 -0
- package/src/scheduling.ts +293 -0
- package/src/sources/filters.ts +6 -0
- package/src/studio/HANDLERS-DESIGN.md +142 -0
- package/src/studio/cli.ts +7 -2
- package/src/studio/emit-project.test.ts +1447 -13
- package/src/studio/emit-project.ts +3995 -1273
- package/src/studio/emit-schema.ts +146 -29
- package/src/studio/index.ts +320 -195
- package/src/studio/project.test.ts +370 -0
- package/src/studio/project.ts +1146 -26
- package/src/studio/sample-data.ts +4 -1
- package/src/studio/samples/ats.ts +2 -2
- package/src/studio/samples/clinic.ts +4 -2
- package/src/studio/samples/crm.ts +16 -8
- package/src/studio/samples/events.ts +4 -2
- package/src/studio/samples/fleet.ts +4 -2
- package/src/studio/samples/gym.ts +4 -2
- package/src/studio/samples/hr.ts +3 -1
- package/src/studio/samples/live-data.ts +308 -308
- package/src/studio/samples/projects.ts +2 -2
- package/src/studio/samples/restaurant.ts +4 -2
- package/src/studio/samples/samples.test.ts +13 -5
- package/src/studio/samples/shared.ts +346 -305
- package/src/studio/samples/support.ts +3 -1
- package/src/studio/scaffold.test.ts +15 -1
- package/src/studio/scaffold.ts +16 -0
- package/src/studio/themes.ts +7 -0
- package/src/studio/ui-components.ts +472 -0
- package/src/sveltekit/transport.test.ts +26 -0
- package/src/sveltekit/transport.ts +50 -5
- package/dist/designer/assets/index-Dp44bTid.js +0 -939
- package/dist/designer/assets/index-RJp6x8tw.css +0 -1
- package/dist/designer/assets/jszip.min-CjMo-QGg.js +0 -2
- package/dist/designer/index.html +0 -13
|
@@ -1,305 +1,346 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared helpers for the curated sample apps. A `SampleApp` is a ready-made
|
|
3
|
-
* `StudioProject` (entities + screens + curated seed + theme) that a user loads
|
|
4
|
-
* to see a beautiful, working multi-entity app in a couple of clicks.
|
|
5
|
-
*
|
|
6
|
-
* Pure + node-safe (studio subtree): each sample is plain data built from the
|
|
7
|
-
* same immutable ops the designer uses, so it round-trips through
|
|
8
|
-
* parse/serialize and generates a runnable app with no special-casing.
|
|
9
|
-
*/
|
|
10
|
-
import type { EntitySchema } from '../../schema.js'
|
|
11
|
-
import type { ChartType } from '@svgrid/grid'
|
|
12
|
-
import { generateValue } from '../sample-data.js'
|
|
13
|
-
import { defaultBlockConfig, sanitizeProject, screenFromTemplate, type Block, type BlockConfig, type DetailRelated, type FormatRule, type GridConfig, type GridDensity, type KpiFormat, type Reduce, type RowAction, type RowLink, type Screen, type ScreenTemplate, type StudioProject, type EntityDataSource } from '../project.js'
|
|
14
|
-
|
|
15
|
-
type Row = Record<string, unknown>
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Extend a curated seed to `target` rows so grids scroll and charts / KPIs look
|
|
19
|
-
* substantial (real dashboards have data). The hand-authored rows stay first;
|
|
20
|
-
* extra rows get realistic generated values, valid ids, and foreign keys drawn
|
|
21
|
-
* from `fkPools` (relation field -> list of parent ids).
|
|
22
|
-
*/
|
|
23
|
-
export function pad(schema: EntitySchema, rows: Row[], target: number, fkPools: Record<string, string[]> = {}): Row[] {
|
|
24
|
-
if (rows.length >= target) return rows
|
|
25
|
-
const pk = schema.idField ?? schema.fields.find((f) => f.primaryKey)?.field ?? 'id'
|
|
26
|
-
const prefix = String(rows[0]?.[pk] ?? 'r').replace(/\d+$/, '') || 'r'
|
|
27
|
-
// Title-like text fields (a record's display name) must stay ON-DOMAIN - the
|
|
28
|
-
// generic generator would put "Premium Backpack" on a property board. So cycle
|
|
29
|
-
// the entity's own curated seed values (with a numeric suffix on wrap) instead.
|
|
30
|
-
// Exclude identifier-shaped fields (ref / number / sku / code) which have formats.
|
|
31
|
-
const firstText = schema.fields.find((f) => f.type === 'text' && f.field !== pk && !/id$|ref|no$|number|code|sku/i.test(f.field))?.field
|
|
32
|
-
const titleLike = new Set(schema.fields.filter((f) => f.type === 'text' && (/^(name|title|subject|headline|label)$/i.test(f.field) || f.field === firstText)).map((f) => f.field))
|
|
33
|
-
const seedVals = (field: string) => rows.map((r) => r[field]).filter((v) => v != null && v !== '')
|
|
34
|
-
const out = [...rows]
|
|
35
|
-
for (let i = rows.length; i < target; i++) {
|
|
36
|
-
const row: Row = {}
|
|
37
|
-
for (const f of schema.fields) {
|
|
38
|
-
if (f.field === pk) { row[f.field] = `${prefix}${i + 1}`; continue }
|
|
39
|
-
if (f.computed || f.formula) continue // derived at runtime - never seeded
|
|
40
|
-
if (f.type === 'relation') {
|
|
41
|
-
const pool = fkPools[f.field] ?? []
|
|
42
|
-
row[f.field] = pool.length ? pool[i % pool.length] : (rows[i % rows.length]?.[f.field] ?? '')
|
|
43
|
-
continue
|
|
44
|
-
}
|
|
45
|
-
if (titleLike.has(f.field)) {
|
|
46
|
-
const vals = seedVals(f.field)
|
|
47
|
-
if (vals.length) { const round = Math.floor(i / vals.length); row[f.field] = round === 0 ? vals[i] : `${vals[i % vals.length]} ${round + 1}`; continue }
|
|
48
|
-
}
|
|
49
|
-
row[f.field] = generateValue(f, i)
|
|
50
|
-
}
|
|
51
|
-
out.push(row)
|
|
52
|
-
}
|
|
53
|
-
return out
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** The `id` values of a seed array (for building foreign-key pools). */
|
|
57
|
-
export const ids = (rows: Row[]): string[] => rows.map((r) => String(r.id))
|
|
58
|
-
|
|
59
|
-
/** A ready-made app shown in the gallery. */
|
|
60
|
-
export type SampleApp = {
|
|
61
|
-
/** Stable id (also the `--template` value). */
|
|
62
|
-
id: string
|
|
63
|
-
/** Display name, e.g. "CRM". */
|
|
64
|
-
name: string
|
|
65
|
-
/** One-line description for the gallery card. */
|
|
66
|
-
description: string
|
|
67
|
-
/** A single emoji used as the card icon. */
|
|
68
|
-
emoji: string
|
|
69
|
-
/** Accent color (themes the built app). */
|
|
70
|
-
accent: string
|
|
71
|
-
/** Build the full project (fresh each call). */
|
|
72
|
-
build: () => StudioProject
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** Rich-grid options an enterprise screen layers onto the default grid: status
|
|
76
|
-
* pills / thresholds (formatRules), a totals row, row actions, row-click drill,
|
|
77
|
-
* and density. */
|
|
78
|
-
export type GridOpts = {
|
|
79
|
-
format?: FormatRule[]
|
|
80
|
-
rowActions?: RowAction[]
|
|
81
|
-
rowLink?: RowLink
|
|
82
|
-
summaries?: boolean
|
|
83
|
-
density?: GridDensity
|
|
84
|
-
pageSize?: number
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** A dashboard tile: KPI card (with trend/target/format), chart, gauge, pivot,
|
|
88
|
-
* tree, a rich grid, or a tabbed group of display tiles. */
|
|
89
|
-
export type Tile =
|
|
90
|
-
| { kpi: string; measure?: string; reduce: Reduce; format?: KpiFormat; trendField?: string; trendReduce?: Reduce; target?: number; span?: 1 | 2 | 3 }
|
|
91
|
-
| { chart: string; measure?: string; reduce?: Reduce; type?: ChartType; span?: 1 | 2 | 3 }
|
|
92
|
-
| { gauge: string; measure?: string; reduce: Reduce; min?: number; max?: number; unit?: string; span?: 1 | 2 | 3 }
|
|
93
|
-
| { pivot: { rows: string[]; cols: string[]; measure?: string; aggregate?: Reduce }; span?: 1 | 2 | 3 }
|
|
94
|
-
| { tree: { labelField: string; parentField: string }; span?: 1 | 2 | 3 }
|
|
95
|
-
| ({ grid: true; span?: 1 | 2 | 3 } & GridOpts)
|
|
96
|
-
| { filter: string[]; span?: 1 | 2 | 3 }
|
|
97
|
-
| { tabs: { label: string; tiles: Tile[] }[]; span?: 1 | 2 | 3 }
|
|
98
|
-
|
|
99
|
-
/** The default grid config with enterprise options merged on. */
|
|
100
|
-
function gridConfig(entity: EntitySchema, opts: GridOpts): BlockConfig {
|
|
101
|
-
const base = defaultBlockConfig('grid', entity) as GridConfig
|
|
102
|
-
return {
|
|
103
|
-
...base,
|
|
104
|
-
...(opts.format ? { formatRules: opts.format } : {}),
|
|
105
|
-
...(opts.rowActions ? { rowActions: opts.rowActions } : {}),
|
|
106
|
-
...(opts.rowLink ? { rowLink: opts.rowLink } : {}),
|
|
107
|
-
...(opts.summaries ? { rowSummaries: true } : {}),
|
|
108
|
-
...(opts.density ? { density: opts.density } : {}),
|
|
109
|
-
...(opts.pageSize ? { pageSize: opts.pageSize } : {}),
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** One dashboard tile -> a Block. Recurses for `tabs` (whose children must be
|
|
114
|
-
* display blocks: chart / kpi / gauge / pivot / tree). */
|
|
115
|
-
function tileBlock(entity: EntitySchema, t: Tile, i: number): Block {
|
|
116
|
-
const id = `blk-${i + 1}`
|
|
117
|
-
if ('kpi' in t) {
|
|
118
|
-
const config: BlockConfig = {
|
|
119
|
-
kind: 'kpi', label: t.kpi, ...(t.measure ? { measure: t.measure } : {}), reduce: t.reduce,
|
|
120
|
-
...(t.format ? { format: t.format } : {}), ...(t.trendField ? { trendField: t.trendField } : {}),
|
|
121
|
-
...(t.trendReduce ? { trendReduce: t.trendReduce } : {}), ...(t.target != null ? { target: t.target } : {}),
|
|
122
|
-
}
|
|
123
|
-
return { id, span: t.span ?? 1, config }
|
|
124
|
-
}
|
|
125
|
-
if ('chart' in t) {
|
|
126
|
-
return { id, span: t.span ?? 2, config: { kind: 'chart', dimension: t.chart, ...(t.measure ? { measure: t.measure } : {}), reduce: t.reduce ?? 'sum', type: t.type ?? 'bar' } }
|
|
127
|
-
}
|
|
128
|
-
if ('gauge' in t) {
|
|
129
|
-
return { id, span: t.span ?? 1, config: { kind: 'gauge', label: t.gauge, ...(t.measure ? { measure: t.measure } : {}), reduce: t.reduce, min: t.min ?? 0, max: t.max ?? 100, ...(t.unit ? { unit: t.unit } : {}) } }
|
|
130
|
-
}
|
|
131
|
-
if ('pivot' in t) {
|
|
132
|
-
return { id, span: t.span ?? 3, config: { kind: 'pivot', rows: t.pivot.rows, cols: t.pivot.cols, ...(t.pivot.measure ? { measure: t.pivot.measure } : {}), aggregate: t.pivot.aggregate ?? 'sum' } }
|
|
133
|
-
}
|
|
134
|
-
if ('tree' in t) {
|
|
135
|
-
return { id, span: t.span ?? 1, config: { kind: 'tree', labelField: t.tree.labelField, parentField: t.tree.parentField } }
|
|
136
|
-
}
|
|
137
|
-
if ('filter' in t) {
|
|
138
|
-
return { id, span: t.span ?? 3, config: { kind: 'filter', fields: t.filter } }
|
|
139
|
-
}
|
|
140
|
-
if ('tabs' in t) {
|
|
141
|
-
return { id, span: t.span ?? 3, config: { kind: 'tabs', tabs: t.tabs.map((tab) => ({ label: tab.label, blocks: tab.tiles.map((tt, j) => tileBlock(entity, tt, j)) })) } }
|
|
142
|
-
}
|
|
143
|
-
return { id, span: t.span ?? 3, config: gridConfig(entity, t) }
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/** Compose a dashboard screen from explicit tiles (KPI cards + gauges + charts +
|
|
147
|
-
* pivots + trees + a rich grid). */
|
|
148
|
-
export function dashScreen(
|
|
149
|
-
entity: EntitySchema,
|
|
150
|
-
meta: { id: string; title: string; order: number },
|
|
151
|
-
tiles: Tile[],
|
|
152
|
-
): Screen {
|
|
153
|
-
const blocks = tiles.map((t, i) => tileBlock(entity, t, i))
|
|
154
|
-
return { id: meta.id, entity: entity.name, title: meta.title, route: meta.id, blocks, nav: { show: true, label: meta.title, order: meta.order } }
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/** FormatRule[] that color-code an enum field's cells by its option colors -
|
|
158
|
-
* turns a plain status column into enterprise status pills. */
|
|
159
|
-
export function statusPills(entity: EntitySchema, field: string): FormatRule[] {
|
|
160
|
-
const f = entity.fields.find((x) => x.field === field)
|
|
161
|
-
if (!f?.options) return []
|
|
162
|
-
return f.options
|
|
163
|
-
.filter((o) => o.color)
|
|
164
|
-
.map((o) => ({ field, op: 'eq' as const, value: o.value as string | number, background: `color-mix(in srgb, ${o.color} 18%, transparent)`, color: o.color, bold: true }))
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/** A list screen: an optional faceted filter panel + a rich grid (status pills,
|
|
168
|
-
* totals, row actions, drill-through). */
|
|
169
|
-
export function listScreen(
|
|
170
|
-
entity: EntitySchema,
|
|
171
|
-
meta: { id: string; title: string; order: number },
|
|
172
|
-
opts: { filter?: string[]; grid?: GridOpts } = {},
|
|
173
|
-
): Screen {
|
|
174
|
-
const blocks: Block[] = []
|
|
175
|
-
if (opts.filter?.length) blocks.push({ id: 'filter-1', span: 3, config: { kind: 'filter', fields: opts.filter } })
|
|
176
|
-
blocks.push({ id: 'grid-1', span: 3, config: gridConfig(entity, opts.grid ?? {}) })
|
|
177
|
-
return { id: meta.id, entity: entity.name, title: meta.title, route: meta.id, blocks, nav: { show: true, label: meta.title, order: meta.order } }
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* A form-showcase screen: a grid to pick a row + an editable record panel that
|
|
182
|
-
* renders the full field form (phone / rating / tags / mask / slider editors)
|
|
183
|
-
* inline. Lets a user see and try the rich editors on load, not just on edit.
|
|
184
|
-
*/
|
|
185
|
-
export function formScreen(
|
|
186
|
-
entity: EntitySchema,
|
|
187
|
-
meta: { id: string; title: string; order: number },
|
|
188
|
-
fields?: string[],
|
|
189
|
-
grid?: GridOpts,
|
|
190
|
-
filter?: string[],
|
|
191
|
-
): Screen {
|
|
192
|
-
const blocks: Block[] = []
|
|
193
|
-
if (filter?.length) blocks.push({ id: 'filter-1', span: 3, config: { kind: 'filter', fields: filter } })
|
|
194
|
-
blocks.push({ id: 'grid-1', span: 3, config: gridConfig(entity, grid ?? {}) })
|
|
195
|
-
blocks.push({ id: 'record-1', span: 3, config: { kind: 'record', editable: true, ...(fields ? { fields } : {}) } })
|
|
196
|
-
return { id: meta.id, entity: entity.name, title: meta.title, route: meta.id, blocks, nav: { show: true, label: meta.title, order: meta.order } }
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/** A Kanban board screen: an optional filter panel + a board grouping rows by an
|
|
200
|
-
* enum field into draggable columns (the signature "pipeline" view). */
|
|
201
|
-
export function boardScreen(
|
|
202
|
-
entity: EntitySchema,
|
|
203
|
-
meta: { id: string; title: string; order: number },
|
|
204
|
-
opts: { groupBy: string; titleField: string; badgeField?: string; subtitleField?: string; filter?: string[]; openScreen?: string },
|
|
205
|
-
): Screen {
|
|
206
|
-
const blocks: Block[] = []
|
|
207
|
-
if (opts.filter?.length) blocks.push({ id: 'filter-1', span: 3, config: { kind: 'filter', fields: opts.filter } })
|
|
208
|
-
blocks.push({ id: 'board-1', span: 3, config: {
|
|
209
|
-
kind: 'board', groupBy: opts.groupBy, titleField: opts.titleField,
|
|
210
|
-
...(opts.badgeField ? { badgeField: opts.badgeField } : {}),
|
|
211
|
-
...(opts.subtitleField ? { subtitleField: opts.subtitleField } : {}),
|
|
212
|
-
...(opts.openScreen ? { openScreen: opts.openScreen } : {}),
|
|
213
|
-
} })
|
|
214
|
-
return { id: meta.id, entity: entity.name, title: meta.title, route: meta.id, blocks, nav: { show: true, label: meta.title, order: meta.order } }
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
/** A calendar screen: an optional filter + a month event-calendar placing rows by
|
|
218
|
-
* a date field (the signature "schedule" view). */
|
|
219
|
-
export function calendarScreen(
|
|
220
|
-
entity: EntitySchema,
|
|
221
|
-
meta: { id: string; title: string; order: number },
|
|
222
|
-
opts: { dateField: string; titleField: string; colorField?: string; filter?: string[]; openScreen?: string },
|
|
223
|
-
): Screen {
|
|
224
|
-
const blocks: Block[] = []
|
|
225
|
-
if (opts.filter?.length) blocks.push({ id: 'filter-1', span: 3, config: { kind: 'filter', fields: opts.filter } })
|
|
226
|
-
blocks.push({ id: 'calendar-1', span: 3, config: {
|
|
227
|
-
kind: 'calendar', dateField: opts.dateField, titleField: opts.titleField,
|
|
228
|
-
...(opts.colorField ? { colorField: opts.colorField } : {}),
|
|
229
|
-
...(opts.openScreen ? { openScreen: opts.openScreen } : {}),
|
|
230
|
-
} })
|
|
231
|
-
return { id: meta.id, entity: entity.name, title: meta.title, route: meta.id, blocks, nav: { show: true, label: meta.title, order: meta.order } }
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/** A
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
export function
|
|
238
|
-
entity: EntitySchema,
|
|
239
|
-
meta: { id: string; title: string; order: number },
|
|
240
|
-
opts: {
|
|
241
|
-
): Screen {
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
...(opts.
|
|
246
|
-
...(opts.
|
|
247
|
-
...(opts.
|
|
248
|
-
...(opts.
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for the curated sample apps. A `SampleApp` is a ready-made
|
|
3
|
+
* `StudioProject` (entities + screens + curated seed + theme) that a user loads
|
|
4
|
+
* to see a beautiful, working multi-entity app in a couple of clicks.
|
|
5
|
+
*
|
|
6
|
+
* Pure + node-safe (studio subtree): each sample is plain data built from the
|
|
7
|
+
* same immutable ops the designer uses, so it round-trips through
|
|
8
|
+
* parse/serialize and generates a runnable app with no special-casing.
|
|
9
|
+
*/
|
|
10
|
+
import type { EntitySchema } from '../../schema.js'
|
|
11
|
+
import type { ChartType } from '@svgrid/grid'
|
|
12
|
+
import { generateValue } from '../sample-data.js'
|
|
13
|
+
import { defaultBlockConfig, sanitizeProject, screenFromTemplate, buildDockLayout, type Block, type BlockConfig, type DetailRelated, type FormatRule, type GridConfig, type GridDensity, type KpiFormat, type Reduce, type RowAction, type RowLink, type Screen, type ScreenTemplate, type StudioProject, type EntityDataSource, type ShellStyle, type SchedulerViewConfig, type SchedulerViewMode } from '../project.js'
|
|
14
|
+
|
|
15
|
+
type Row = Record<string, unknown>
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Extend a curated seed to `target` rows so grids scroll and charts / KPIs look
|
|
19
|
+
* substantial (real dashboards have data). The hand-authored rows stay first;
|
|
20
|
+
* extra rows get realistic generated values, valid ids, and foreign keys drawn
|
|
21
|
+
* from `fkPools` (relation field -> list of parent ids).
|
|
22
|
+
*/
|
|
23
|
+
export function pad(schema: EntitySchema, rows: Row[], target: number, fkPools: Record<string, string[]> = {}): Row[] {
|
|
24
|
+
if (rows.length >= target) return rows
|
|
25
|
+
const pk = schema.idField ?? schema.fields.find((f) => f.primaryKey)?.field ?? 'id'
|
|
26
|
+
const prefix = String(rows[0]?.[pk] ?? 'r').replace(/\d+$/, '') || 'r'
|
|
27
|
+
// Title-like text fields (a record's display name) must stay ON-DOMAIN - the
|
|
28
|
+
// generic generator would put "Premium Backpack" on a property board. So cycle
|
|
29
|
+
// the entity's own curated seed values (with a numeric suffix on wrap) instead.
|
|
30
|
+
// Exclude identifier-shaped fields (ref / number / sku / code) which have formats.
|
|
31
|
+
const firstText = schema.fields.find((f) => f.type === 'text' && f.field !== pk && !/id$|ref|no$|number|code|sku/i.test(f.field))?.field
|
|
32
|
+
const titleLike = new Set(schema.fields.filter((f) => f.type === 'text' && (/^(name|title|subject|headline|label)$/i.test(f.field) || f.field === firstText)).map((f) => f.field))
|
|
33
|
+
const seedVals = (field: string) => rows.map((r) => r[field]).filter((v) => v != null && v !== '')
|
|
34
|
+
const out = [...rows]
|
|
35
|
+
for (let i = rows.length; i < target; i++) {
|
|
36
|
+
const row: Row = {}
|
|
37
|
+
for (const f of schema.fields) {
|
|
38
|
+
if (f.field === pk) { row[f.field] = `${prefix}${i + 1}`; continue }
|
|
39
|
+
if (f.computed || f.formula) continue // derived at runtime - never seeded
|
|
40
|
+
if (f.type === 'relation') {
|
|
41
|
+
const pool = fkPools[f.field] ?? []
|
|
42
|
+
row[f.field] = pool.length ? pool[i % pool.length] : (rows[i % rows.length]?.[f.field] ?? '')
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
if (titleLike.has(f.field)) {
|
|
46
|
+
const vals = seedVals(f.field)
|
|
47
|
+
if (vals.length) { const round = Math.floor(i / vals.length); row[f.field] = round === 0 ? vals[i] : `${vals[i % vals.length]} ${round + 1}`; continue }
|
|
48
|
+
}
|
|
49
|
+
row[f.field] = generateValue(f, i)
|
|
50
|
+
}
|
|
51
|
+
out.push(row)
|
|
52
|
+
}
|
|
53
|
+
return out
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The `id` values of a seed array (for building foreign-key pools). */
|
|
57
|
+
export const ids = (rows: Row[]): string[] => rows.map((r) => String(r.id))
|
|
58
|
+
|
|
59
|
+
/** A ready-made app shown in the gallery. */
|
|
60
|
+
export type SampleApp = {
|
|
61
|
+
/** Stable id (also the `--template` value). */
|
|
62
|
+
id: string
|
|
63
|
+
/** Display name, e.g. "CRM". */
|
|
64
|
+
name: string
|
|
65
|
+
/** One-line description for the gallery card. */
|
|
66
|
+
description: string
|
|
67
|
+
/** A single emoji used as the card icon. */
|
|
68
|
+
emoji: string
|
|
69
|
+
/** Accent color (themes the built app). */
|
|
70
|
+
accent: string
|
|
71
|
+
/** Build the full project (fresh each call). */
|
|
72
|
+
build: () => StudioProject
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Rich-grid options an enterprise screen layers onto the default grid: status
|
|
76
|
+
* pills / thresholds (formatRules), a totals row, row actions, row-click drill,
|
|
77
|
+
* and density. */
|
|
78
|
+
export type GridOpts = {
|
|
79
|
+
format?: FormatRule[]
|
|
80
|
+
rowActions?: RowAction[]
|
|
81
|
+
rowLink?: RowLink
|
|
82
|
+
summaries?: boolean
|
|
83
|
+
density?: GridDensity
|
|
84
|
+
pageSize?: number
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A dashboard tile: KPI card (with trend/target/format), chart, gauge, pivot,
|
|
88
|
+
* tree, a rich grid, or a tabbed group of display tiles. */
|
|
89
|
+
export type Tile =
|
|
90
|
+
| { kpi: string; measure?: string; reduce: Reduce; format?: KpiFormat; trendField?: string; trendReduce?: Reduce; target?: number; span?: 1 | 2 | 3 }
|
|
91
|
+
| { chart: string; measure?: string; reduce?: Reduce; type?: ChartType; span?: 1 | 2 | 3 }
|
|
92
|
+
| { gauge: string; measure?: string; reduce: Reduce; min?: number; max?: number; unit?: string; span?: 1 | 2 | 3 }
|
|
93
|
+
| { pivot: { rows: string[]; cols: string[]; measure?: string; aggregate?: Reduce }; span?: 1 | 2 | 3 }
|
|
94
|
+
| { tree: { labelField: string; parentField: string }; span?: 1 | 2 | 3 }
|
|
95
|
+
| ({ grid: true; span?: 1 | 2 | 3 } & GridOpts)
|
|
96
|
+
| { filter: string[]; span?: 1 | 2 | 3 }
|
|
97
|
+
| { tabs: { label: string; tiles: Tile[] }[]; span?: 1 | 2 | 3 }
|
|
98
|
+
|
|
99
|
+
/** The default grid config with enterprise options merged on. */
|
|
100
|
+
function gridConfig(entity: EntitySchema, opts: GridOpts): BlockConfig {
|
|
101
|
+
const base = defaultBlockConfig('grid', entity) as GridConfig
|
|
102
|
+
return {
|
|
103
|
+
...base,
|
|
104
|
+
...(opts.format ? { formatRules: opts.format } : {}),
|
|
105
|
+
...(opts.rowActions ? { rowActions: opts.rowActions } : {}),
|
|
106
|
+
...(opts.rowLink ? { rowLink: opts.rowLink } : {}),
|
|
107
|
+
...(opts.summaries ? { rowSummaries: true } : {}),
|
|
108
|
+
...(opts.density ? { density: opts.density } : {}),
|
|
109
|
+
...(opts.pageSize ? { pageSize: opts.pageSize } : {}),
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** One dashboard tile -> a Block. Recurses for `tabs` (whose children must be
|
|
114
|
+
* display blocks: chart / kpi / gauge / pivot / tree). */
|
|
115
|
+
function tileBlock(entity: EntitySchema, t: Tile, i: number): Block {
|
|
116
|
+
const id = `blk-${i + 1}`
|
|
117
|
+
if ('kpi' in t) {
|
|
118
|
+
const config: BlockConfig = {
|
|
119
|
+
kind: 'kpi', label: t.kpi, ...(t.measure ? { measure: t.measure } : {}), reduce: t.reduce,
|
|
120
|
+
...(t.format ? { format: t.format } : {}), ...(t.trendField ? { trendField: t.trendField } : {}),
|
|
121
|
+
...(t.trendReduce ? { trendReduce: t.trendReduce } : {}), ...(t.target != null ? { target: t.target } : {}),
|
|
122
|
+
}
|
|
123
|
+
return { id, span: t.span ?? 1, config }
|
|
124
|
+
}
|
|
125
|
+
if ('chart' in t) {
|
|
126
|
+
return { id, span: t.span ?? 2, config: { kind: 'chart', dimension: t.chart, ...(t.measure ? { measure: t.measure } : {}), reduce: t.reduce ?? 'sum', type: t.type ?? 'bar' } }
|
|
127
|
+
}
|
|
128
|
+
if ('gauge' in t) {
|
|
129
|
+
return { id, span: t.span ?? 1, config: { kind: 'gauge', label: t.gauge, ...(t.measure ? { measure: t.measure } : {}), reduce: t.reduce, min: t.min ?? 0, max: t.max ?? 100, ...(t.unit ? { unit: t.unit } : {}) } }
|
|
130
|
+
}
|
|
131
|
+
if ('pivot' in t) {
|
|
132
|
+
return { id, span: t.span ?? 3, config: { kind: 'pivot', rows: t.pivot.rows, cols: t.pivot.cols, ...(t.pivot.measure ? { measure: t.pivot.measure } : {}), aggregate: t.pivot.aggregate ?? 'sum' } }
|
|
133
|
+
}
|
|
134
|
+
if ('tree' in t) {
|
|
135
|
+
return { id, span: t.span ?? 1, config: { kind: 'tree', labelField: t.tree.labelField, parentField: t.tree.parentField } }
|
|
136
|
+
}
|
|
137
|
+
if ('filter' in t) {
|
|
138
|
+
return { id, span: t.span ?? 3, config: { kind: 'filter', fields: t.filter } }
|
|
139
|
+
}
|
|
140
|
+
if ('tabs' in t) {
|
|
141
|
+
return { id, span: t.span ?? 3, config: { kind: 'tabs', tabs: t.tabs.map((tab) => ({ label: tab.label, blocks: tab.tiles.map((tt, j) => tileBlock(entity, tt, j)) })) } }
|
|
142
|
+
}
|
|
143
|
+
return { id, span: t.span ?? 3, config: gridConfig(entity, t) }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Compose a dashboard screen from explicit tiles (KPI cards + gauges + charts +
|
|
147
|
+
* pivots + trees + a rich grid). */
|
|
148
|
+
export function dashScreen(
|
|
149
|
+
entity: EntitySchema,
|
|
150
|
+
meta: { id: string; title: string; order: number },
|
|
151
|
+
tiles: Tile[],
|
|
152
|
+
): Screen {
|
|
153
|
+
const blocks = tiles.map((t, i) => tileBlock(entity, t, i))
|
|
154
|
+
return { id: meta.id, entity: entity.name, title: meta.title, route: meta.id, blocks, nav: { show: true, label: meta.title, order: meta.order } }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** FormatRule[] that color-code an enum field's cells by its option colors -
|
|
158
|
+
* turns a plain status column into enterprise status pills. */
|
|
159
|
+
export function statusPills(entity: EntitySchema, field: string): FormatRule[] {
|
|
160
|
+
const f = entity.fields.find((x) => x.field === field)
|
|
161
|
+
if (!f?.options) return []
|
|
162
|
+
return f.options
|
|
163
|
+
.filter((o) => o.color)
|
|
164
|
+
.map((o) => ({ field, op: 'eq' as const, value: o.value as string | number, background: `color-mix(in srgb, ${o.color} 18%, transparent)`, color: o.color, bold: true }))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** A list screen: an optional faceted filter panel + a rich grid (status pills,
|
|
168
|
+
* totals, row actions, drill-through). */
|
|
169
|
+
export function listScreen(
|
|
170
|
+
entity: EntitySchema,
|
|
171
|
+
meta: { id: string; title: string; order: number },
|
|
172
|
+
opts: { filter?: string[]; grid?: GridOpts } = {},
|
|
173
|
+
): Screen {
|
|
174
|
+
const blocks: Block[] = []
|
|
175
|
+
if (opts.filter?.length) blocks.push({ id: 'filter-1', span: 3, config: { kind: 'filter', fields: opts.filter } })
|
|
176
|
+
blocks.push({ id: 'grid-1', span: 3, config: gridConfig(entity, opts.grid ?? {}) })
|
|
177
|
+
return { id: meta.id, entity: entity.name, title: meta.title, route: meta.id, blocks, nav: { show: true, label: meta.title, order: meta.order } }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* A form-showcase screen: a grid to pick a row + an editable record panel that
|
|
182
|
+
* renders the full field form (phone / rating / tags / mask / slider editors)
|
|
183
|
+
* inline. Lets a user see and try the rich editors on load, not just on edit.
|
|
184
|
+
*/
|
|
185
|
+
export function formScreen(
|
|
186
|
+
entity: EntitySchema,
|
|
187
|
+
meta: { id: string; title: string; order: number },
|
|
188
|
+
fields?: string[],
|
|
189
|
+
grid?: GridOpts,
|
|
190
|
+
filter?: string[],
|
|
191
|
+
): Screen {
|
|
192
|
+
const blocks: Block[] = []
|
|
193
|
+
if (filter?.length) blocks.push({ id: 'filter-1', span: 3, config: { kind: 'filter', fields: filter } })
|
|
194
|
+
blocks.push({ id: 'grid-1', span: 3, config: gridConfig(entity, grid ?? {}) })
|
|
195
|
+
blocks.push({ id: 'record-1', span: 3, config: { kind: 'record', editable: true, ...(fields ? { fields } : {}) } })
|
|
196
|
+
return { id: meta.id, entity: entity.name, title: meta.title, route: meta.id, blocks, nav: { show: true, label: meta.title, order: meta.order } }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** A Kanban board screen: an optional filter panel + a board grouping rows by an
|
|
200
|
+
* enum field into draggable columns (the signature "pipeline" view). */
|
|
201
|
+
export function boardScreen(
|
|
202
|
+
entity: EntitySchema,
|
|
203
|
+
meta: { id: string; title: string; order: number },
|
|
204
|
+
opts: { groupBy: string; titleField: string; badgeField?: string; subtitleField?: string; filter?: string[]; openScreen?: string },
|
|
205
|
+
): Screen {
|
|
206
|
+
const blocks: Block[] = []
|
|
207
|
+
if (opts.filter?.length) blocks.push({ id: 'filter-1', span: 3, config: { kind: 'filter', fields: opts.filter } })
|
|
208
|
+
blocks.push({ id: 'board-1', span: 3, config: {
|
|
209
|
+
kind: 'board', groupBy: opts.groupBy, titleField: opts.titleField,
|
|
210
|
+
...(opts.badgeField ? { badgeField: opts.badgeField } : {}),
|
|
211
|
+
...(opts.subtitleField ? { subtitleField: opts.subtitleField } : {}),
|
|
212
|
+
...(opts.openScreen ? { openScreen: opts.openScreen } : {}),
|
|
213
|
+
} })
|
|
214
|
+
return { id: meta.id, entity: entity.name, title: meta.title, route: meta.id, blocks, nav: { show: true, label: meta.title, order: meta.order } }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** A calendar screen: an optional filter + a month event-calendar placing rows by
|
|
218
|
+
* a date field (the signature "schedule" view). */
|
|
219
|
+
export function calendarScreen(
|
|
220
|
+
entity: EntitySchema,
|
|
221
|
+
meta: { id: string; title: string; order: number },
|
|
222
|
+
opts: { dateField: string; titleField: string; colorField?: string; filter?: string[]; openScreen?: string },
|
|
223
|
+
): Screen {
|
|
224
|
+
const blocks: Block[] = []
|
|
225
|
+
if (opts.filter?.length) blocks.push({ id: 'filter-1', span: 3, config: { kind: 'filter', fields: opts.filter } })
|
|
226
|
+
blocks.push({ id: 'calendar-1', span: 3, config: {
|
|
227
|
+
kind: 'calendar', dateField: opts.dateField, titleField: opts.titleField,
|
|
228
|
+
...(opts.colorField ? { colorField: opts.colorField } : {}),
|
|
229
|
+
...(opts.openScreen ? { openScreen: opts.openScreen } : {}),
|
|
230
|
+
} })
|
|
231
|
+
return { id: meta.id, entity: entity.name, title: meta.title, route: meta.id, blocks, nav: { show: true, label: meta.title, order: meta.order } }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** A scheduler screen: the grid rendered as a Week / Day / Timeline calendar with optional
|
|
235
|
+
* per-resource columns and drag-to-reschedule. Richer than calendarScreen (start + end,
|
|
236
|
+
* resources, editable write-back) - showcases the grid's scheduler view. */
|
|
237
|
+
export function schedulerScreen(
|
|
238
|
+
entity: EntitySchema,
|
|
239
|
+
meta: { id: string; title: string; order: number },
|
|
240
|
+
opts: { startField: string; endField?: string; titleField?: string; colorField?: string; resourceField?: string; initialView?: SchedulerViewMode; editable?: boolean },
|
|
241
|
+
): Screen {
|
|
242
|
+
const grid = gridConfig(entity, {}) as GridConfig
|
|
243
|
+
const scheduler: SchedulerViewConfig = {
|
|
244
|
+
startField: opts.startField,
|
|
245
|
+
...(opts.endField ? { endField: opts.endField } : {}),
|
|
246
|
+
...(opts.titleField ? { titleField: opts.titleField } : {}),
|
|
247
|
+
...(opts.colorField ? { colorField: opts.colorField } : {}),
|
|
248
|
+
...(opts.resourceField ? { resourceField: opts.resourceField } : {}),
|
|
249
|
+
initialView: opts.initialView ?? 'week',
|
|
250
|
+
editable: opts.editable ?? true,
|
|
251
|
+
drawer: true,
|
|
252
|
+
}
|
|
253
|
+
const block: Block = { id: 'grid-1', span: 3, config: { ...grid, scheduler } }
|
|
254
|
+
return { id: meta.id, entity: entity.name, title: meta.title, route: meta.id, blocks: [block], nav: { show: true, label: meta.title, order: meta.order } }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** A docking-workspace screen: a filter + grid + record panel arranged as a dockable
|
|
258
|
+
* console (SvDockManager). The smart auto-layout puts filters left, the grid centre, the
|
|
259
|
+
* record panel right; the user can float / pin / rearrange. Showcases screen `layout: 'dock'`. */
|
|
260
|
+
export function workspaceScreen(
|
|
261
|
+
entity: EntitySchema,
|
|
262
|
+
meta: { id: string; title: string; order: number },
|
|
263
|
+
opts: { filter?: string[]; record?: boolean; grid?: GridOpts; mode?: 'dock' | 'split' } = {},
|
|
264
|
+
): Screen {
|
|
265
|
+
const blocks: Block[] = []
|
|
266
|
+
if (opts.filter?.length) blocks.push({ id: 'filter-1', span: 3, config: { kind: 'filter', fields: opts.filter } })
|
|
267
|
+
blocks.push({ id: 'grid-1', span: 3, config: gridConfig(entity, opts.grid ?? {}) })
|
|
268
|
+
if (opts.record !== false) blocks.push({ id: 'record-1', span: 3, config: { kind: 'record', editable: true } })
|
|
269
|
+
// `split` = fixed resizable panes (a console); `dock` = the full floatable manager.
|
|
270
|
+
const layout = opts.mode ?? 'dock'
|
|
271
|
+
const base: Screen = { id: meta.id, entity: entity.name, title: meta.title, route: meta.id, blocks, nav: { show: true, label: meta.title, order: meta.order }, layout }
|
|
272
|
+
return { ...base, dock: buildDockLayout(base) }
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** A record detail-page screen: a full record view (header + status pill + metric
|
|
276
|
+
* tiles + tabbed Overview / related timelines) - the signature view for
|
|
277
|
+
* relation-heavy records (a customer / deal / patient / trip page). */
|
|
278
|
+
export function detailScreen(
|
|
279
|
+
entity: EntitySchema,
|
|
280
|
+
meta: { id: string; title: string; order: number },
|
|
281
|
+
opts: { titleField: string; subtitleField?: string; statusField?: string; metricFields?: string[]; sections?: { label: string; fields: string[] }[]; related?: DetailRelated[] },
|
|
282
|
+
): Screen {
|
|
283
|
+
const blocks: Block[] = [{ id: 'detail-1', span: 3, config: {
|
|
284
|
+
kind: 'detail', titleField: opts.titleField,
|
|
285
|
+
...(opts.subtitleField ? { subtitleField: opts.subtitleField } : {}),
|
|
286
|
+
...(opts.statusField ? { statusField: opts.statusField } : {}),
|
|
287
|
+
...(opts.metricFields?.length ? { metricFields: opts.metricFields } : {}),
|
|
288
|
+
...(opts.sections?.length ? { sections: opts.sections } : {}),
|
|
289
|
+
...(opts.related?.length ? { related: opts.related } : {}),
|
|
290
|
+
} }]
|
|
291
|
+
return { id: meta.id, entity: entity.name, title: meta.title, route: meta.id, blocks, nav: { show: true, label: meta.title, order: meta.order } }
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Build a screen from a template, then override its id / route / title / nav.
|
|
295
|
+
* `linkScreen` wires a master-detail parent row to drill into a detail screen. */
|
|
296
|
+
export function screen(
|
|
297
|
+
entity: EntitySchema,
|
|
298
|
+
template: ScreenTemplate,
|
|
299
|
+
over: { id: string; title: string; route?: string; order: number; child?: EntitySchema; foreignKey?: string; linkScreen?: string },
|
|
300
|
+
): Screen {
|
|
301
|
+
const base = screenFromTemplate(entity, template, { child: over.child, foreignKey: over.foreignKey })
|
|
302
|
+
const blocks = over.linkScreen
|
|
303
|
+
? base.blocks.map((b) => (b.config.kind === 'master-detail' ? { ...b, config: { ...b.config, linkScreen: over.linkScreen } } : b))
|
|
304
|
+
: base.blocks
|
|
305
|
+
return {
|
|
306
|
+
...base,
|
|
307
|
+
blocks,
|
|
308
|
+
id: over.id,
|
|
309
|
+
title: over.title,
|
|
310
|
+
route: over.route ?? over.id,
|
|
311
|
+
nav: { show: true, label: over.title, order: over.order },
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Assemble a sample project: entities + screens + per-entity curated seed + theme/shell. */
|
|
316
|
+
export function project(opts: {
|
|
317
|
+
title: string
|
|
318
|
+
brand: string
|
|
319
|
+
accent: string
|
|
320
|
+
footer?: string
|
|
321
|
+
navStyle?: ShellStyle
|
|
322
|
+
navPosition?: 'left' | 'right'
|
|
323
|
+
/** A design-system preset id (font + radius + palette) from `studioThemes` -
|
|
324
|
+
* what makes each app read as a distinct product (Salesforce vs Excel vs Linear). */
|
|
325
|
+
preset?: string
|
|
326
|
+
mode?: 'light' | 'dark'
|
|
327
|
+
entities: EntitySchema[]
|
|
328
|
+
screens: Screen[]
|
|
329
|
+
seed: Record<string, Record<string, unknown>[]>
|
|
330
|
+
}): StudioProject {
|
|
331
|
+
const dataSources: Record<string, EntityDataSource> = {}
|
|
332
|
+
for (const e of opts.entities) dataSources[e.name] = { kind: 'memory', seed: opts.seed[e.name] ?? [] }
|
|
333
|
+
return sanitizeProject({
|
|
334
|
+
title: opts.title,
|
|
335
|
+
entities: opts.entities,
|
|
336
|
+
screens: opts.screens,
|
|
337
|
+
dataSource: 'memory',
|
|
338
|
+
dataSources,
|
|
339
|
+
theme: {
|
|
340
|
+
accent: opts.accent,
|
|
341
|
+
...(opts.preset ? { preset: opts.preset } : {}),
|
|
342
|
+
...(opts.mode ? { mode: opts.mode } : {}),
|
|
343
|
+
shell: { style: opts.navStyle ?? 'sidebar', brand: opts.brand, footer: opts.footer ?? '', navPosition: opts.navPosition ?? 'left' },
|
|
344
|
+
},
|
|
345
|
+
})
|
|
346
|
+
}
|