@svgrid/enterprise 2.3.0 → 2.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/dist/cdn/{pdfmake-C6pyKjX0.js → pdfmake-DVbZqKeD.js} +2297 -2327
  2. package/dist/cdn/rolldown-runtime-DtPi1Y-2.js +13 -0
  3. package/dist/cdn/{smart.export-CfW2zpyq.js → smart.export-D9FF4wAB.js} +54 -51
  4. package/dist/cdn/svgrid-enterprise.svelte-external.js +11308 -7898
  5. package/dist/cdn/{vfs_fonts-BktEULEs.js → vfs_fonts-iHMTEkpG.js} +1 -1
  6. package/dist/designer/assets/GridMenus-CW-rnHS9.js +7 -0
  7. package/dist/designer/assets/SvDateTimePicker-Cygij-dr.css +1 -0
  8. package/dist/designer/assets/SvDateTimePicker-DOBYyLeN.js +1 -0
  9. package/dist/designer/assets/SvGridChart-BEDNTdc5.js +2 -0
  10. package/dist/designer/assets/SvGridChart-Vu4Nvjiv.css +1 -0
  11. package/dist/designer/assets/SvGridChartPanel-x3lzSxuf.js +10 -0
  12. package/dist/designer/assets/SvGridChartView-CAKhPELi.js +1 -0
  13. package/dist/designer/assets/SvGridChartView-DX9HfkBR.css +1 -0
  14. package/dist/designer/assets/SvGridDropdown-CAYG7Y-O.js +1 -0
  15. package/dist/designer/assets/SvGridDropdown-Crwb2P3Q.css +1 -0
  16. package/dist/designer/assets/SvListBox-BMSfFJJT.css +1 -0
  17. package/dist/designer/assets/SvListBox-BVzVfCG3.js +1 -0
  18. package/dist/designer/assets/chart-DHEgt4lN.js +1 -0
  19. package/dist/designer/assets/disclose-version-CIm4S9xS.js +2 -0
  20. package/dist/designer/assets/dismissable-2EZKoens.js +1 -0
  21. package/dist/designer/assets/dismissable-BF-9TJSd.css +1 -0
  22. package/dist/designer/assets/easing-zaypKrr0.js +3 -0
  23. package/dist/designer/assets/export-format-DwjuTfrQ.js +6 -0
  24. package/dist/designer/assets/index-DVrv9dH6.js +75797 -0
  25. package/dist/designer/assets/index-DjPp1mn7.css +1 -0
  26. package/dist/designer/assets/js-scroller.svelte-tHanKJx0.js +110 -0
  27. package/dist/designer/assets/jszip.min-CX2fPs3O.js +2 -0
  28. package/dist/designer/assets/pdfmake-BpIYFHoG.js +242 -0
  29. package/dist/designer/assets/popover-CUkLsvRX.js +1 -0
  30. package/dist/designer/assets/rolldown-runtime-Dd_uD5pT.js +1 -0
  31. package/dist/designer/assets/smart.export-CDCwbFH4.js +37 -0
  32. package/dist/designer/assets/src-BRKu53tV.js +2842 -0
  33. package/dist/designer/assets/src-DseB1VRN.css +1 -0
  34. package/dist/designer/assets/vfs_fonts-BV-Ul9v2.js +1 -0
  35. package/dist/designer/index.html +32 -0
  36. package/dist/node/studio.js +16778 -12672
  37. package/package.json +10 -7
  38. package/src/SvGridBoard.svelte +4 -1
  39. package/src/SvGridScheduler.svelte +114 -90
  40. package/src/ai-export-pdf.dom.test.ts +77 -77
  41. package/src/ai-export-xlsx.dom.test.ts +90 -90
  42. package/src/ai-export.dom.test.ts +114 -114
  43. package/src/export-ooxml.ts +4 -0
  44. package/src/export-xls.ts +3 -0
  45. package/src/expressions/evaluate.ts +9 -0
  46. package/src/import.test.ts +1 -1
  47. package/src/import.ts +1 -1
  48. package/src/index.ts +12 -0
  49. package/src/pivot.test.ts +0 -1
  50. package/src/schema-designer.ts +1 -1
  51. package/src/sources/introspect-supabase.ts +2 -2
  52. package/src/studio/emit-project.test.ts +298 -7
  53. package/src/studio/emit-project.ts +484 -30
  54. package/src/studio/emit-schema.ts +20 -10
  55. package/src/studio/index.ts +34 -0
  56. package/src/studio/init-flow.test.ts +364 -0
  57. package/src/studio/init-flow.ts +409 -0
  58. package/src/studio/project.test.ts +0 -1
  59. package/src/studio/project.ts +109 -2
  60. package/src/studio/samples/datasets.test.ts +84 -0
  61. package/src/studio/samples/datasets.ts +340 -0
  62. package/src/studio/samples/live-data.test.ts +1 -1
  63. package/src/studio/samples/samples.test.ts +268 -268
  64. package/src/studio/samples/shared.ts +7 -150
  65. package/src/studio/screen-suites.test.ts +226 -0
  66. package/src/studio/screen-suites.ts +445 -0
  67. package/src/studio/ui-components-surface.test.ts +2 -2
  68. package/src/studio/ui-components.generated.ts +1643 -106
  69. package/src/studio/ui-components.ts +742 -641
  70. package/src/sveltekit/index.ts +1 -0
  71. package/src/sveltekit/sql-source.ts +1 -1
  72. package/src/sveltekit/transport-scope.test.ts +125 -0
  73. package/src/sveltekit/transport.ts +76 -2
@@ -0,0 +1,445 @@
1
+ /**
2
+ * Screen factories + the CRUD-suite generator.
3
+ *
4
+ * Two layers live here:
5
+ *
6
+ * 1. The composable screen factories (`listScreen` / `formScreen` / `detailScreen`
7
+ * / `dashScreen`) the curated sample apps are built from. They take explicit,
8
+ * hand-picked options - a sample app knows its own domain.
9
+ * 2. The CRUD suite (`crudSuiteScreens` / `addCrudSuite` / `crudAppFromSchemas`),
10
+ * which derives those options from a schema and produces a linked multi-screen
11
+ * app: a list, an edit form, a record detail page, and an optional overview
12
+ * dashboard, with drill-through and related-child tabs wired from the entity's
13
+ * relations. This is what the guided onboarding (designer wizard + `svgrid-studio
14
+ * init`) calls, so both hosts scaffold identical apps.
15
+ *
16
+ * Pure + node-safe (studio subtree): plain data built from the same immutable ops
17
+ * the designer uses, so results round-trip through parse/serialize and generate a
18
+ * runnable app with no special-casing.
19
+ */
20
+ import type { EntityField, EntitySchema } from '../schema.js'
21
+ import type { ChartType } from '@svgrid/grid'
22
+ import {
23
+ defaultBlockConfig,
24
+ defaultEntitySource,
25
+ entityOf,
26
+ pickFacetFields,
27
+ sanitizeProject,
28
+ type Block,
29
+ type BlockConfig,
30
+ type DataSourceKind,
31
+ type DetailConfig,
32
+ type DetailRelated,
33
+ type EntityDataSource,
34
+ type FormatRule,
35
+ type GridConfig,
36
+ type GridDensity,
37
+ type GridEditing,
38
+ type KpiFormat,
39
+ type Presentation,
40
+ type ProjectTheme,
41
+ type Reduce,
42
+ type RowAction,
43
+ type RowLink,
44
+ type Screen,
45
+ type StudioProject,
46
+ } from './project.js'
47
+
48
+ // --- shared screen factories (also used by the curated sample apps) ----------
49
+
50
+ /** Rich-grid options an enterprise screen layers onto the default grid: status
51
+ * pills / thresholds (formatRules), a totals row, row actions, row-click drill,
52
+ * and density. */
53
+ export type GridOpts = {
54
+ format?: FormatRule[]
55
+ rowActions?: RowAction[]
56
+ rowLink?: RowLink
57
+ summaries?: boolean
58
+ density?: GridDensity
59
+ pageSize?: number
60
+ /** How rows are edited: in-cell, via a popup form, or not at all (read-only
61
+ * lists that drill into a detail page). Defaults to the grid default (`'form'`). */
62
+ editing?: GridEditing
63
+ /** Where the edit form opens when `editing` is `'form'`. */
64
+ formPresentation?: Presentation
65
+ }
66
+
67
+ /** A dashboard tile: KPI card (with trend/target/format), chart, gauge, pivot,
68
+ * tree, a rich grid, or a tabbed group of display tiles. */
69
+ export type Tile =
70
+ | { kpi: string; measure?: string; reduce: Reduce; format?: KpiFormat; trendField?: string; trendReduce?: Reduce; target?: number; span?: 1 | 2 | 3 }
71
+ | { chart: string; measure?: string; reduce?: Reduce; type?: ChartType; span?: 1 | 2 | 3 }
72
+ | { gauge: string; measure?: string; reduce: Reduce; min?: number; max?: number; unit?: string; span?: 1 | 2 | 3 }
73
+ | { pivot: { rows: string[]; cols: string[]; measure?: string; aggregate?: Reduce }; span?: 1 | 2 | 3 }
74
+ | { tree: { labelField: string; parentField: string }; span?: 1 | 2 | 3 }
75
+ | ({ grid: true; span?: 1 | 2 | 3 } & GridOpts)
76
+ | { filter: string[]; span?: 1 | 2 | 3 }
77
+ | { tabs: { label: string; tiles: Tile[] }[]; span?: 1 | 2 | 3 }
78
+
79
+ /** The default grid config with enterprise options merged on. */
80
+ export function gridConfig(entity: EntitySchema, opts: GridOpts): BlockConfig {
81
+ const base = defaultBlockConfig('grid', entity) as GridConfig
82
+ return {
83
+ ...base,
84
+ ...(opts.format ? { formatRules: opts.format } : {}),
85
+ ...(opts.rowActions ? { rowActions: opts.rowActions } : {}),
86
+ ...(opts.rowLink ? { rowLink: opts.rowLink } : {}),
87
+ ...(opts.summaries ? { rowSummaries: true } : {}),
88
+ ...(opts.density ? { density: opts.density } : {}),
89
+ ...(opts.pageSize ? { pageSize: opts.pageSize } : {}),
90
+ ...(opts.editing ? { editing: opts.editing } : {}),
91
+ ...(opts.formPresentation ? { formPresentation: opts.formPresentation } : {}),
92
+ }
93
+ }
94
+
95
+ /** One dashboard tile -> a Block. Recurses for `tabs` (whose children must be
96
+ * display blocks: chart / kpi / gauge / pivot / tree). `nextId` yields ids unique
97
+ * across the WHOLE screen (top-level tiles AND every nested tab child) - a per-level
98
+ * index would collide (top-level `blk-1` vs each tab's first child `blk-1`), which
99
+ * makes a keyed each throw `each_key_duplicate`. */
100
+ function tileBlock(entity: EntitySchema, t: Tile, nextId: () => string): Block {
101
+ const id = nextId()
102
+ if ('kpi' in t) {
103
+ const config: BlockConfig = {
104
+ kind: 'kpi', label: t.kpi, ...(t.measure ? { measure: t.measure } : {}), reduce: t.reduce,
105
+ ...(t.format ? { format: t.format } : {}), ...(t.trendField ? { trendField: t.trendField } : {}),
106
+ ...(t.trendReduce ? { trendReduce: t.trendReduce } : {}), ...(t.target != null ? { target: t.target } : {}),
107
+ }
108
+ return { id, span: t.span ?? 1, config }
109
+ }
110
+ if ('chart' in t) {
111
+ 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' } }
112
+ }
113
+ if ('gauge' in t) {
114
+ 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 } : {}) } }
115
+ }
116
+ if ('pivot' in t) {
117
+ 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' } }
118
+ }
119
+ if ('tree' in t) {
120
+ return { id, span: t.span ?? 1, config: { kind: 'tree', labelField: t.tree.labelField, parentField: t.tree.parentField } }
121
+ }
122
+ if ('filter' in t) {
123
+ return { id, span: t.span ?? 3, config: { kind: 'filter', fields: t.filter } }
124
+ }
125
+ if ('tabs' in t) {
126
+ return { id, span: t.span ?? 3, config: { kind: 'tabs', tabs: t.tabs.map((tab) => ({ label: tab.label, blocks: tab.tiles.map((tt) => tileBlock(entity, tt, nextId)) })) } }
127
+ }
128
+ return { id, span: t.span ?? 3, config: gridConfig(entity, t) }
129
+ }
130
+
131
+ /** Compose a dashboard screen from explicit tiles (KPI cards + gauges + charts +
132
+ * pivots + trees + a rich grid). */
133
+ export function dashScreen(
134
+ entity: EntitySchema,
135
+ meta: { id: string; title: string; order: number },
136
+ tiles: Tile[],
137
+ ): Screen {
138
+ let n = 0
139
+ const nextId = () => `blk-${++n}`
140
+ const blocks = tiles.map((t) => tileBlock(entity, t, nextId))
141
+ return { id: meta.id, entity: entity.name, title: meta.title, route: meta.id, blocks, nav: { show: true, label: meta.title, order: meta.order } }
142
+ }
143
+
144
+ /** FormatRule[] that color-code an enum field's cells by its option colors -
145
+ * turns a plain status column into enterprise status pills. */
146
+ export function statusPills(entity: EntitySchema, field: string): FormatRule[] {
147
+ const f = entity.fields.find((x) => x.field === field)
148
+ if (!f?.options) return []
149
+ return f.options
150
+ .filter((o) => o.color)
151
+ .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 }))
152
+ }
153
+
154
+ /** A list screen: an optional faceted filter panel + a rich grid (status pills,
155
+ * totals, row actions, drill-through). */
156
+ export function listScreen(
157
+ entity: EntitySchema,
158
+ meta: { id: string; title: string; order: number },
159
+ opts: { filter?: string[]; grid?: GridOpts } = {},
160
+ ): Screen {
161
+ const blocks: Block[] = []
162
+ if (opts.filter?.length) blocks.push({ id: 'filter-1', span: 3, config: { kind: 'filter', fields: opts.filter } })
163
+ blocks.push({ id: 'grid-1', span: 3, config: gridConfig(entity, opts.grid ?? {}) })
164
+ return { id: meta.id, entity: entity.name, title: meta.title, route: meta.id, blocks, nav: { show: true, label: meta.title, order: meta.order } }
165
+ }
166
+
167
+ /**
168
+ * A form-showcase screen: a grid to pick a row + an editable record panel that
169
+ * renders the full field form (phone / rating / tags / mask / slider editors)
170
+ * inline. Lets a user see and try the rich editors on load, not just on edit.
171
+ */
172
+ export function formScreen(
173
+ entity: EntitySchema,
174
+ meta: { id: string; title: string; order: number },
175
+ fields?: string[],
176
+ grid?: GridOpts,
177
+ filter?: string[],
178
+ ): Screen {
179
+ const blocks: Block[] = []
180
+ if (filter?.length) blocks.push({ id: 'filter-1', span: 3, config: { kind: 'filter', fields: filter } })
181
+ blocks.push({ id: 'grid-1', span: 3, config: gridConfig(entity, grid ?? {}) })
182
+ blocks.push({ id: 'record-1', span: 3, config: { kind: 'record', editable: true, ...(fields ? { fields } : {}) } })
183
+ return { id: meta.id, entity: entity.name, title: meta.title, route: meta.id, blocks, nav: { show: true, label: meta.title, order: meta.order } }
184
+ }
185
+
186
+ /** A record detail-page screen: a full record view (header + status pill + metric
187
+ * tiles + tabbed Overview / related timelines) - the signature view for
188
+ * relation-heavy records (a customer / deal / patient / trip page). */
189
+ export function detailScreen(
190
+ entity: EntitySchema,
191
+ meta: { id: string; title: string; order: number },
192
+ opts: { titleField: string; subtitleField?: string; statusField?: string; metricFields?: string[]; sections?: { label: string; fields: string[] }[]; related?: DetailRelated[] },
193
+ ): Screen {
194
+ const blocks: Block[] = [{ id: 'detail-1', span: 3, config: {
195
+ kind: 'detail', titleField: opts.titleField,
196
+ ...(opts.subtitleField ? { subtitleField: opts.subtitleField } : {}),
197
+ ...(opts.statusField ? { statusField: opts.statusField } : {}),
198
+ ...(opts.metricFields?.length ? { metricFields: opts.metricFields } : {}),
199
+ ...(opts.sections?.length ? { sections: opts.sections } : {}),
200
+ ...(opts.related?.length ? { related: opts.related } : {}),
201
+ } }]
202
+ return { id: meta.id, entity: entity.name, title: meta.title, route: meta.id, blocks, nav: { show: true, label: meta.title, order: meta.order } }
203
+ }
204
+
205
+ // --- the CRUD suite ---------------------------------------------------------
206
+
207
+ /** The screens a CRUD suite can contain. */
208
+ export type CrudScreenKind = 'list' | 'form' | 'detail' | 'dashboard'
209
+ /** How the suite's rows are edited. `'inline'` edits in the grid, `'form'` opens
210
+ * a popup form (the grid default), `'detail'` makes a row click navigate to the
211
+ * generated detail page (and leaves the list read-only). */
212
+ export type CrudEditingMode = 'inline' | 'form' | 'detail'
213
+
214
+ export type CrudSuiteOptions = {
215
+ /** Which screens to generate. Default: `['list', 'form']`, plus `'detail'` when
216
+ * another entity relates to this one (there are children worth showing). */
217
+ screens?: CrudScreenKind[]
218
+ /** Row editing mode. Default `'form'`. */
219
+ editing?: CrudEditingMode
220
+ /** Nav order of the suite's first screen; later screens follow it. Default 0. */
221
+ navOrder?: number
222
+ /** Show a faceted filter panel on the list screen. Defaults to on when the
223
+ * entity has filter-friendly (enum / boolean / text) fields. */
224
+ filter?: boolean
225
+ /** Ids and routes already in use. The suite picks non-colliding ones BEFORE it
226
+ * wires drill-through, so a rename can never orphan a `rowLink`. */
227
+ taken?: { ids?: ReadonlySet<string>; routes?: ReadonlySet<string> }
228
+ }
229
+
230
+ /** `base`, or the first free `${base}-${n}`. Records the result in `taken`. */
231
+ function claim(base: string, taken: Set<string>): string {
232
+ if (!taken.has(base)) { taken.add(base); return base }
233
+ let n = 2
234
+ while (taken.has(`${base}-${n}`)) n++
235
+ const out = `${base}-${n}`
236
+ taken.add(out)
237
+ return out
238
+ }
239
+
240
+ const primaryKeyOf = (entity: EntitySchema): string =>
241
+ entity.idField ?? entity.fields.find((f) => f.primaryKey)?.field ?? entity.fields[0]?.field ?? 'id'
242
+
243
+ const isNumeric = (f: EntityField): boolean => f.type === 'number' && !f.primaryKey
244
+
245
+ /** Every relation pointing AT `entity` - each becomes a related-children tab on
246
+ * the detail page (including a self-referential FK, which reads as a hierarchy). */
247
+ function relatedChildren(entity: EntitySchema, entities: readonly EntitySchema[]): DetailRelated[] {
248
+ const out: DetailRelated[] = []
249
+ for (const other of entities) {
250
+ for (const f of other.fields) {
251
+ if (f.type !== 'relation' || f.relation?.entity !== entity.name) continue
252
+ const title = other.fields.find((x) => x.type === 'text' && !x.primaryKey)?.field
253
+ const status = other.fields.find((x) => x.type === 'enum' && !x.primaryKey)?.field
254
+ const date = other.fields.find((x) => (x.type === 'date' || x.type === 'datetime' || x.type === 'dateString') && !x.primaryKey)?.field
255
+ out.push({
256
+ entity: other.name,
257
+ foreignKey: f.field,
258
+ label: other.label ?? other.name,
259
+ ...(title ? { titleField: title } : {}),
260
+ ...(status ? { statusField: status } : {}),
261
+ ...(date ? { dateField: date } : {}),
262
+ })
263
+ }
264
+ }
265
+ return out
266
+ }
267
+
268
+ /**
269
+ * The screens for one entity: a list, an edit form, a record detail page, and/or
270
+ * an overview dashboard - already linked to each other. `entities` is the full
271
+ * schema set so relations resolve (drill-through targets + child collections).
272
+ */
273
+ export function crudSuiteScreens(
274
+ entity: EntitySchema,
275
+ entities: readonly EntitySchema[],
276
+ opts: CrudSuiteOptions = {},
277
+ ): Screen[] {
278
+ const label = entity.label ?? entity.name
279
+ const pk = primaryKeyOf(entity)
280
+ const children = relatedChildren(entity, entities)
281
+ const kinds = opts.screens ?? (children.length ? ['list', 'form', 'detail'] : ['list', 'form'])
282
+ const wants = (k: CrudScreenKind) => kinds.includes(k)
283
+ const editing = opts.editing ?? 'form'
284
+ const order = opts.navOrder ?? 0
285
+
286
+ // Claim ids + routes up front so `rowLink` can point at the detail screen's
287
+ // FINAL id (deduping afterwards would leave the link dangling).
288
+ const ids = new Set(opts.taken?.ids ?? [])
289
+ const routes = new Set(opts.taken?.routes ?? [])
290
+ const idFor = (base: string) => claim(base, ids)
291
+ const routeFor = (base: string) => claim(base, routes)
292
+
293
+ const listId = wants('list') ? idFor(entity.name) : ''
294
+ const listRoute = wants('list') ? routeFor(entity.name) : ''
295
+ const formId = wants('form') ? idFor(`${entity.name}-manage`) : ''
296
+ const formRoute = wants('form') ? routeFor(`${entity.name}-manage`) : ''
297
+ const detailId = wants('detail') ? idFor(`${entity.name}-detail`) : ''
298
+ const detailRoute = wants('detail') ? routeFor(`${entity.name}-detail`) : ''
299
+ const dashId = wants('dashboard') ? idFor(`${entity.name}-overview`) : ''
300
+ const dashRoute = wants('dashboard') ? routeFor(`${entity.name}-overview`) : ''
301
+
302
+ const detailCfg = defaultBlockConfig('detail', entity) as DetailConfig
303
+ const facets = opts.filter === false ? [] : pickFacetFields(entity)
304
+ const pills = detailCfg.statusField ? statusPills(entity, detailCfg.statusField) : []
305
+ const hasNumeric = entity.fields.some(isNumeric)
306
+ // 'detail' editing = a read-only list whose rows drill into the detail page.
307
+ const drills = editing === 'detail' && !!detailId
308
+ const grid: GridOpts = {
309
+ ...(pills.length ? { format: pills } : {}),
310
+ ...(hasNumeric ? { summaries: true } : {}),
311
+ editing: drills ? 'none' : editing === 'inline' ? 'inline' : 'form',
312
+ ...(drills ? { rowLink: { screen: detailId, targetField: pk } satisfies RowLink } : {}),
313
+ }
314
+
315
+ // Nav order is assigned once at the end, from the order the screens were
316
+ // built, so no branch has to know how many ran before it.
317
+ const screens: Screen[] = []
318
+ if (wants('dashboard')) {
319
+ const measure = entity.fields.find(isNumeric)?.field
320
+ const dimension = detailCfg.statusField ?? entity.fields.find((f) => f.type === 'enum' && !f.primaryKey)?.field
321
+ const tiles: Tile[] = [{ kpi: `Total ${label}`, reduce: 'count' }]
322
+ if (measure) tiles.push({ kpi: `Total ${measure}`, measure, reduce: 'sum' })
323
+ if (dimension) tiles.push({ chart: dimension, ...(measure ? { measure } : {}), reduce: measure ? 'sum' : 'count', type: 'bar' })
324
+ tiles.push({ grid: true, ...(pills.length ? { format: pills } : {}) })
325
+ screens.push({ ...dashScreen(entity, { id: dashId, title: `${label} overview`, order: 0 }, tiles), route: dashRoute })
326
+ }
327
+ if (wants('list')) {
328
+ screens.push({
329
+ ...listScreen(entity, { id: listId, title: label, order: 0 }, { ...(facets.length ? { filter: facets } : {}), grid }),
330
+ route: listRoute,
331
+ })
332
+ }
333
+ if (wants('form')) {
334
+ screens.push({
335
+ ...formScreen(entity, { id: formId, title: `Manage ${label}`, order: 0 }, undefined, { editing: editing === 'inline' ? 'inline' : 'form' }),
336
+ route: formRoute,
337
+ })
338
+ }
339
+ if (wants('detail')) {
340
+ const base = detailScreen(entity, { id: detailId, title: `${label} detail`, order: 0 }, {
341
+ titleField: detailCfg.titleField,
342
+ ...(detailCfg.subtitleField ? { subtitleField: detailCfg.subtitleField } : {}),
343
+ ...(detailCfg.statusField ? { statusField: detailCfg.statusField } : {}),
344
+ ...(detailCfg.metricFields?.length ? { metricFields: detailCfg.metricFields } : {}),
345
+ ...(children.length ? { related: children } : {}),
346
+ })
347
+ // A drill-through target is reached by clicking a row, so it stays out of the
348
+ // nav; a detail page nobody links to keeps its nav entry.
349
+ screens.push({ ...base, route: detailRoute, nav: { ...base.nav, show: !drills } })
350
+ }
351
+ return screens.map((s, i) => ({ ...s, nav: { ...s.nav, order: order + i } }))
352
+ }
353
+
354
+ /** Append a full CRUD suite for an existing entity (immutable, like `addScreenFromTemplate`). */
355
+ export function addCrudSuite(project: StudioProject, entityName: string, opts: CrudSuiteOptions = {}): StudioProject {
356
+ const entity = entityOf(project, entityName)
357
+ if (!entity) return project
358
+ const taken = {
359
+ ids: new Set(project.screens.map((s) => s.id)),
360
+ routes: new Set(project.screens.map((s) => s.route)),
361
+ }
362
+ const navOrder = opts.navOrder ?? project.screens.length
363
+ const screens = crudSuiteScreens(entity, project.entities, { ...opts, navOrder, taken })
364
+ return { ...project, screens: [...project.screens, ...screens] }
365
+ }
366
+
367
+ export type CrudAppOptions = {
368
+ title?: string
369
+ /** Default source kind for entities with no explicit binding in `sources`. */
370
+ dataSource?: DataSourceKind
371
+ /** Per-entity source binding (a live SQL table, a REST endpoint, PGlite, ...). */
372
+ sources?: Record<string, EntityDataSource>
373
+ /** Per-entity seed rows, merged into memory / PGlite bindings. */
374
+ seed?: Record<string, Record<string, unknown>[]>
375
+ /** Per-entity suite overrides, keyed by entity name. */
376
+ perEntity?: Record<string, CrudSuiteOptions>
377
+ /** Lead with an overview dashboard over the most-referenced entity. Default true. */
378
+ overviewDashboard?: boolean
379
+ theme?: ProjectTheme
380
+ }
381
+
382
+ /** The entity the most relations point at (an app's hub - customers, projects,
383
+ * patients); falls back to the first schema. */
384
+ function hubEntity(schemas: readonly EntitySchema[]): EntitySchema | undefined {
385
+ let best: EntitySchema | undefined
386
+ let bestScore = -1
387
+ for (const e of schemas) {
388
+ const score = schemas.reduce(
389
+ (n, other) => n + other.fields.filter((f) => f.type === 'relation' && f.relation?.entity === e.name).length,
390
+ 0,
391
+ )
392
+ if (score > bestScore) { best = e; bestScore = score }
393
+ }
394
+ return best
395
+ }
396
+
397
+ /**
398
+ * A complete app from a set of schemas: an overview dashboard plus a CRUD suite
399
+ * per entity, bound to the given data sources. This is the guided-onboarding
400
+ * output - the designer wizard and `svgrid-studio init` both land here, so a
401
+ * database connected from either host scaffolds the same app.
402
+ */
403
+ export function crudAppFromSchemas(schemas: EntitySchema[], opts: CrudAppOptions = {}): StudioProject {
404
+ const kind = opts.dataSource ?? 'memory'
405
+ const hub = hubEntity(schemas)
406
+ const ids = new Set<string>()
407
+ const routes = new Set<string>()
408
+ const screens: Screen[] = []
409
+
410
+ if (opts.overviewDashboard !== false && hub) {
411
+ const [overview] = crudSuiteScreens(hub, schemas, {
412
+ screens: ['dashboard'],
413
+ navOrder: 0,
414
+ taken: { ids, routes },
415
+ })
416
+ // The app's front page: a plain "Overview" at the top of the nav.
417
+ if (overview) screens.push({ ...overview, title: 'Overview', nav: { ...overview.nav, label: 'Overview', order: 0 } })
418
+ }
419
+
420
+ for (const schema of schemas) {
421
+ screens.push(...crudSuiteScreens(schema, schemas, {
422
+ ...opts.perEntity?.[schema.name],
423
+ navOrder: screens.length,
424
+ taken: { ids, routes },
425
+ }))
426
+ }
427
+
428
+ const dataSources: Record<string, EntityDataSource> = {}
429
+ for (const schema of schemas) {
430
+ const source = opts.sources?.[schema.name] ?? defaultEntitySource(kind, schema.name)
431
+ const seed = opts.seed?.[schema.name]
432
+ dataSources[schema.name] = seed?.length && (source.kind === 'memory' || source.kind === 'pglite')
433
+ ? { ...source, seed }
434
+ : source
435
+ }
436
+
437
+ return sanitizeProject({
438
+ title: opts.title ?? 'My Studio App',
439
+ entities: [...schemas],
440
+ screens,
441
+ dataSource: kind,
442
+ dataSources,
443
+ ...(opts.theme ? { theme: opts.theme } : {}),
444
+ })
445
+ }
@@ -96,7 +96,7 @@ describe('full component surface (extractor-driven)', () => {
96
96
  it('the Grid exposes its full event surface as ctx.grid subscriptions', () => {
97
97
  // An entity screen's grid, made code-enabled by an onLoad body that subscribes.
98
98
  let p = createProject([customers])
99
- const sid = p.screens[0].id
99
+ const sid = p.screens[0]!.id
100
100
  p = setHandlerBody(p, sid, 'onLoad', 'ctx.grid.onCellClick = (e) => { console.log(e.row) }')
101
101
  const files = emitStudioProject(p)
102
102
  const page = files.find((f) => f.path.endsWith('+page.svelte'))!.contents
@@ -121,7 +121,7 @@ describe('full component surface (extractor-driven)', () => {
121
121
  it('ctx.grid exposes runtime-settable props (ctx.grid.sortable = true) with types + wiring', () => {
122
122
  // An entity screen's grid, made code-enabled by an onLoad body that sets a prop.
123
123
  let p = createProject([customers])
124
- const sid = p.screens[0].id
124
+ const sid = p.screens[0]!.id
125
125
  p = setHandlerBody(p, sid, 'onLoad', 'ctx.grid.sortable = true\n ctx.grid.zebraRows = true\n if (ctx.grid.sortable) ctx.grid.rowHeight = 40')
126
126
  const files = emitStudioProject(p)
127
127
  const page = files.find((f) => f.path.endsWith('+page.svelte'))!.contents