@svgrid/enterprise 2.2.1 → 2.3.1

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 (126) hide show
  1. package/README.md +95 -81
  2. package/dist/cdn/svgrid-enterprise.svelte-external.js +26642 -11252
  3. package/dist/designer/assets/GridMenus-BtWVk9Ab.js +7 -0
  4. package/dist/designer/assets/SvGridChartPanel-DWlBO2SD.js +10 -0
  5. package/dist/designer/assets/SvGridChartView-8c8Mb4j8.js +1 -0
  6. package/dist/designer/assets/SvGridChartView-DX9HfkBR.css +1 -0
  7. package/dist/designer/assets/index-DsDgp9Xq.js +78758 -0
  8. package/dist/designer/assets/index-tTY_Dx4P.css +1 -0
  9. package/dist/designer/assets/jszip.min-fkJdmAmj.js +2 -0
  10. package/dist/designer/assets/pdfmake-DeCsnyl9.js +242 -0
  11. package/dist/designer/assets/smart.export-BZlSCE8T.js +35 -0
  12. package/dist/designer/assets/vfs_fonts-eX2NpmfX.js +1 -0
  13. package/dist/designer/index.html +13 -0
  14. package/dist/node/studio.js +15304 -2708
  15. package/package.json +35 -11
  16. package/src/SvAlertRuleEditor.svelte +294 -0
  17. package/src/SvAlertsManager.svelte +210 -0
  18. package/src/SvAlertsPanel.svelte +129 -0
  19. package/src/SvBoard.svelte +3 -1
  20. package/src/SvExpressionEditor.svelte +341 -0
  21. package/src/SvGridAlerts.dom.test.ts +113 -0
  22. package/src/SvGridAlerts.svelte +265 -0
  23. package/src/SvGridBoard.svelte +2358 -0
  24. package/src/SvGridEditPanel.svelte +849 -794
  25. package/src/SvGridScheduler.svelte +5358 -4410
  26. package/src/SvPivotDesigner.svelte +3 -3
  27. package/src/SvRecordDetail.svelte +6 -2
  28. package/src/SvSchedule.svelte +3 -1
  29. package/src/{ai-export-pdf.test.ts → ai-export-pdf.dom.test.ts} +77 -74
  30. package/src/{ai-export-xlsx.test.ts → ai-export-xlsx.dom.test.ts} +90 -87
  31. package/src/{ai-export.test.ts → ai-export.dom.test.ts} +114 -110
  32. package/src/alerts/alert-engine-attach.ts +165 -0
  33. package/src/alerts/alert-engine.test.ts +135 -0
  34. package/src/alerts/alert-engine.ts +260 -0
  35. package/src/alerts/alert-formats.test.ts +87 -0
  36. package/src/alerts/alert-formats.ts +77 -0
  37. package/src/alerts/alert-observer.test.ts +189 -0
  38. package/src/alerts/alert-observer.ts +208 -0
  39. package/src/alerts/alert-scheduler.ts +96 -0
  40. package/src/alerts/alert-storage.test.ts +54 -0
  41. package/src/alerts/alert-storage.ts +116 -0
  42. package/src/alerts/alert-store.svelte.ts +80 -0
  43. package/src/alerts/alert-types.ts +94 -0
  44. package/src/alerts.ts +28 -0
  45. package/src/board.dom.test.ts +941 -0
  46. package/src/board.ts +36 -0
  47. package/src/export-ooxml.ts +4 -0
  48. package/src/export-xls.ts +3 -0
  49. package/src/export.ts +6 -0
  50. package/src/expressions/evaluate.test.ts +111 -0
  51. package/src/expressions/evaluate.ts +237 -0
  52. package/src/expressions/expression-columns.ts +133 -0
  53. package/src/expressions/expression-types.ts +84 -0
  54. package/src/expressions/parse.test.ts +106 -0
  55. package/src/expressions/parse.ts +610 -0
  56. package/src/import.test.ts +1 -1
  57. package/src/import.ts +3 -1
  58. package/src/index.ts +192 -46
  59. package/src/install.ts +12 -2
  60. package/src/pivot-enable.ts +44 -0
  61. package/src/pivot.test.ts +0 -1
  62. package/src/scheduler-assignments.test.ts +97 -0
  63. package/src/scheduler-assignments.ts +134 -0
  64. package/src/scheduler-axis.test.ts +108 -0
  65. package/src/scheduler-axis.ts +238 -0
  66. package/src/scheduler-booking.test.ts +57 -0
  67. package/src/scheduler-booking.ts +63 -0
  68. package/src/scheduler-config.ts +179 -0
  69. package/src/scheduler-dependencies.test.ts +155 -0
  70. package/src/scheduler-dependencies.ts +223 -0
  71. package/src/scheduler-freebusy.test.ts +41 -0
  72. package/src/scheduler-freebusy.ts +36 -0
  73. package/src/scheduler-heatmap.test.ts +39 -0
  74. package/src/scheduler-heatmap.ts +55 -0
  75. package/src/scheduler-resource-tree.test.ts +84 -0
  76. package/src/scheduler-resource-tree.ts +107 -0
  77. package/src/scheduler-slots.test.ts +53 -0
  78. package/src/scheduler-slots.ts +94 -0
  79. package/src/scheduler-summary.test.ts +47 -0
  80. package/src/scheduler-summary.ts +81 -0
  81. package/src/schema-designer.ts +1 -1
  82. package/src/sources/index.ts +1 -1
  83. package/src/sources/introspect-supabase.test.ts +13 -1
  84. package/src/sources/introspect-supabase.ts +20 -0
  85. package/src/studio/copilot-core.test.ts +45 -0
  86. package/src/studio/copilot-core.ts +65 -0
  87. package/src/studio/deploy-cli.test.ts +56 -0
  88. package/src/studio/deploy-cli.ts +100 -0
  89. package/src/studio/emit-project.test.ts +800 -24
  90. package/src/studio/emit-project.ts +1307 -135
  91. package/src/studio/emit-schema.test.ts +17 -0
  92. package/src/studio/emit-schema.ts +182 -41
  93. package/src/studio/index.ts +59 -1
  94. package/src/studio/init-flow.test.ts +239 -0
  95. package/src/studio/init-flow.ts +358 -0
  96. package/src/studio/introspect-openapi.test.ts +84 -0
  97. package/src/studio/introspect-openapi.ts +252 -0
  98. package/src/studio/project.test.ts +57 -1
  99. package/src/studio/project.ts +302 -8
  100. package/src/studio/samples/crm.ts +282 -258
  101. package/src/studio/samples/datasets.test.ts +84 -0
  102. package/src/studio/samples/datasets.ts +340 -0
  103. package/src/studio/samples/fleet.ts +213 -186
  104. package/src/studio/samples/insurance.ts +223 -195
  105. package/src/studio/samples/inventory.ts +213 -182
  106. package/src/studio/samples/live-data.test.ts +99 -98
  107. package/src/studio/samples/live-data.ts +8 -10
  108. package/src/studio/samples/projects.ts +212 -190
  109. package/src/studio/samples/samples.test.ts +268 -216
  110. package/src/studio/samples/shared.ts +22 -145
  111. package/src/studio/samples/starter.ts +251 -0
  112. package/src/studio/samples/support.ts +209 -184
  113. package/src/studio/screen-suites.test.ts +226 -0
  114. package/src/studio/screen-suites.ts +445 -0
  115. package/src/studio/ui-components-surface.test.ts +185 -0
  116. package/src/studio/ui-components.generated.ts +7154 -0
  117. package/src/studio/ui-components.ts +273 -3
  118. package/src/sveltekit/index.ts +1 -0
  119. package/src/sveltekit/sql-source.test.ts +13 -0
  120. package/src/sveltekit/sql-source.ts +12 -7
  121. package/src/sveltekit/transport-scope.test.ts +125 -0
  122. package/src/sveltekit/transport.ts +76 -2
  123. package/src/upgrade-prompt.ts +2 -2
  124. package/src/watermark.ts +2 -2
  125. package/src/ai.test.ts +0 -522
  126. package/src/ai.ts +0 -1388
@@ -0,0 +1,226 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import type { EntitySchema } from '../schema.js'
3
+ import { crudSuiteScreens, addCrudSuite, crudAppFromSchemas } from './screen-suites.js'
4
+ import {
5
+ createProject,
6
+ parseProject,
7
+ serializeProject,
8
+ validateProject,
9
+ type DetailConfig,
10
+ type GridConfig,
11
+ type Screen,
12
+ } from './project.js'
13
+
14
+ const customers: EntitySchema = {
15
+ name: 'customers',
16
+ label: 'Customer',
17
+ idField: 'id',
18
+ fields: [
19
+ { field: 'id', type: 'text', primaryKey: true, readonly: true },
20
+ { field: 'name', type: 'text', label: 'Customer' },
21
+ { field: 'city', type: 'text' },
22
+ { field: 'tier', type: 'enum', options: [
23
+ { value: 'free', label: 'Free', color: '#64748b' },
24
+ { value: 'pro', label: 'Pro', color: '#2563eb' },
25
+ ] },
26
+ { field: 'spend', type: 'number' },
27
+ ],
28
+ }
29
+
30
+ const orders: EntitySchema = {
31
+ name: 'orders',
32
+ label: 'Order',
33
+ idField: 'id',
34
+ fields: [
35
+ { field: 'id', type: 'text', primaryKey: true, readonly: true },
36
+ { field: 'reference', type: 'text' },
37
+ { field: 'customer_id', type: 'relation', relation: { entity: 'customers', labelField: 'name' } },
38
+ { field: 'total', type: 'number' },
39
+ { field: 'status', type: 'enum', options: [{ value: 'paid', label: 'Paid', color: '#16a34a' }] },
40
+ { field: 'placed', type: 'dateString' },
41
+ ],
42
+ }
43
+
44
+ const gridOf = (screen: Screen): GridConfig =>
45
+ screen.blocks.find((b) => b.config.kind === 'grid')!.config as GridConfig
46
+ const detailOf = (screen: Screen): DetailConfig =>
47
+ screen.blocks.find((b) => b.config.kind === 'detail')!.config as DetailConfig
48
+
49
+ describe('crudSuiteScreens', () => {
50
+ it('defaults to list + form, and adds a detail page when the entity has children', () => {
51
+ // `customers` is referenced by `orders`, so it earns a detail page.
52
+ expect(crudSuiteScreens(customers, [customers, orders]).map((s) => s.id))
53
+ .toEqual(['customers', 'customers-manage', 'customers-detail'])
54
+ // `orders` has no children pointing at it - list + form only.
55
+ expect(crudSuiteScreens(orders, [customers, orders]).map((s) => s.id))
56
+ .toEqual(['orders', 'orders-manage'])
57
+ })
58
+
59
+ it('honours an explicit screen selection', () => {
60
+ const screens = crudSuiteScreens(customers, [customers, orders], { screens: ['dashboard', 'list'] })
61
+ expect(screens.map((s) => s.id)).toEqual(['customers-overview', 'customers'])
62
+ })
63
+
64
+ it('puts status pills and a totals row on the list grid', () => {
65
+ const [list] = crudSuiteScreens(customers, [customers], { screens: ['list'] })
66
+ const grid = gridOf(list!)
67
+ expect(grid.formatRules?.map((r) => r.value)).toEqual(['free', 'pro'])
68
+ expect(grid.rowSummaries).toBe(true)
69
+ })
70
+
71
+ it('seeds a faceted filter panel, and drops it when asked', () => {
72
+ const [withFacets] = crudSuiteScreens(customers, [customers], { screens: ['list'] })
73
+ expect(withFacets!.blocks[0]!.config.kind).toBe('filter')
74
+ const [plain] = crudSuiteScreens(customers, [customers], { screens: ['list'], filter: false })
75
+ expect(plain!.blocks.every((b) => b.config.kind !== 'filter')).toBe(true)
76
+ })
77
+
78
+ describe('editing modes', () => {
79
+ it("'form' (the default) edits through a popup form", () => {
80
+ const [list] = crudSuiteScreens(customers, [customers], { screens: ['list'] })
81
+ expect(gridOf(list!).editing).toBe('form')
82
+ expect(gridOf(list!).rowLink).toBeUndefined()
83
+ })
84
+
85
+ it("'inline' edits in the grid", () => {
86
+ const [list] = crudSuiteScreens(customers, [customers], { screens: ['list'], editing: 'inline' })
87
+ expect(gridOf(list!).editing).toBe('inline')
88
+ })
89
+
90
+ it("'detail' makes the list read-only and drills into the detail screen", () => {
91
+ const screens = crudSuiteScreens(customers, [customers, orders], { editing: 'detail' })
92
+ const list = screens.find((s) => s.id === 'customers')!
93
+ const detail = screens.find((s) => s.id === 'customers-detail')!
94
+ expect(gridOf(list).editing).toBe('none')
95
+ expect(gridOf(list).rowLink).toEqual({ screen: detail.id, targetField: 'id' })
96
+ // A drill target is reached by clicking a row, so it stays out of the nav.
97
+ expect(detail.nav?.show).toBe(false)
98
+ })
99
+ })
100
+
101
+ it('builds related-record tabs from every relation pointing at the entity', () => {
102
+ const [detail] = crudSuiteScreens(customers, [customers, orders], { screens: ['detail'] })
103
+ expect(detailOf(detail!).related).toEqual([
104
+ { entity: 'orders', foreignKey: 'customer_id', label: 'Order', titleField: 'reference', statusField: 'status', dateField: 'placed' },
105
+ ])
106
+ })
107
+
108
+ it('treats a self-referential FK as a child collection (a hierarchy)', () => {
109
+ const folders: EntitySchema = {
110
+ name: 'folders',
111
+ idField: 'id',
112
+ fields: [
113
+ { field: 'id', type: 'text', primaryKey: true },
114
+ { field: 'name', type: 'text' },
115
+ { field: 'parent_id', type: 'relation', relation: { entity: 'folders', labelField: 'name' } },
116
+ ],
117
+ }
118
+ const [detail] = crudSuiteScreens(folders, [folders], { screens: ['detail'] })
119
+ expect(detailOf(detail!).related?.[0]).toMatchObject({ entity: 'folders', foreignKey: 'parent_id' })
120
+ })
121
+
122
+ it('survives an entity with nothing but a primary key', () => {
123
+ const bare: EntitySchema = { name: 'flags', idField: 'id', fields: [{ field: 'id', type: 'text', primaryKey: true }] }
124
+ const screens = crudSuiteScreens(bare, [bare])
125
+ expect(screens.map((s) => s.id)).toEqual(['flags', 'flags-manage'])
126
+ expect(gridOf(screens[0]!).rowSummaries).toBeFalsy()
127
+ })
128
+
129
+ it('picks non-colliding ids AND routes, keeping drill-through pointed at the real screen', () => {
130
+ const screens = crudSuiteScreens(customers, [customers, orders], {
131
+ editing: 'detail',
132
+ taken: { ids: new Set(['customers', 'customers-detail']), routes: new Set(['customers']) },
133
+ })
134
+ const ids = screens.map((s) => s.id)
135
+ expect(ids).toEqual(['customers-2', 'customers-manage', 'customers-detail-2'])
136
+ expect(screens.map((s) => s.route)).toEqual(['customers-2', 'customers-manage', 'customers-detail'])
137
+ // The rowLink must name the RENAMED detail screen, not the original id.
138
+ expect(gridOf(screens[0]!).rowLink?.screen).toBe('customers-detail-2')
139
+ })
140
+ })
141
+
142
+ describe('addCrudSuite', () => {
143
+ it('appends a suite without disturbing the existing screens', () => {
144
+ const project = createProject([customers, orders])
145
+ const next = addCrudSuite(project, 'orders')
146
+ expect(next.screens.slice(0, 2).map((s) => s.id)).toEqual(['customers', 'orders'])
147
+ // 'orders' is taken by the default screen, so the suite claims fresh ids.
148
+ expect(next.screens.slice(2).map((s) => s.id)).toEqual(['orders-2', 'orders-manage'])
149
+ expect(project.screens).toHaveLength(2) // immutable
150
+ })
151
+
152
+ it('ignores an unknown entity', () => {
153
+ const project = createProject([customers])
154
+ expect(addCrudSuite(project, 'nope')).toBe(project)
155
+ })
156
+ })
157
+
158
+ describe('crudAppFromSchemas', () => {
159
+ it('leads with an overview dashboard over the most-referenced entity', () => {
160
+ const app = crudAppFromSchemas([orders, customers])
161
+ const first = app.screens[0]!
162
+ expect(first.title).toBe('Overview')
163
+ expect(first.entity).toBe('customers') // orders point at customers
164
+ expect(first.nav?.order).toBe(0)
165
+ expect(first.blocks.some((b) => b.config.kind === 'kpi')).toBe(true)
166
+ })
167
+
168
+ it('can skip the dashboard', () => {
169
+ const app = crudAppFromSchemas([customers, orders], { overviewDashboard: false })
170
+ expect(app.screens[0]!.id).toBe('customers')
171
+ })
172
+
173
+ it('generates a suite per entity with unique ids, routes and ordered nav', () => {
174
+ const app = crudAppFromSchemas([customers, orders])
175
+ const ids = app.screens.map((s) => s.id)
176
+ const routes = app.screens.map((s) => s.route)
177
+ expect(new Set(ids).size).toBe(ids.length)
178
+ expect(new Set(routes).size).toBe(routes.length)
179
+ const orders2 = app.screens.map((s) => s.nav?.order).filter((n): n is number => n != null)
180
+ expect([...orders2].sort((a, b) => a - b)).toEqual(orders2)
181
+ })
182
+
183
+ it('binds sources per entity and merges seed rows into memory bindings', () => {
184
+ const app = crudAppFromSchemas([customers], { seed: { customers: [{ id: 'c1', name: 'Acme' }] } })
185
+ expect(app.dataSources?.customers).toEqual({ kind: 'memory', seed: [{ id: 'c1', name: 'Acme' }] })
186
+ })
187
+
188
+ it('carries seed into a PGlite binding and leaves SQL bindings alone', () => {
189
+ const app = crudAppFromSchemas([customers, orders], {
190
+ dataSource: 'sql',
191
+ sources: { customers: { kind: 'pglite', table: 'customers' }, orders: { kind: 'sql', table: 'orders', dialect: 'postgres' } },
192
+ seed: { customers: [{ id: 'c1' }], orders: [{ id: 'o1' }] },
193
+ })
194
+ expect(app.dataSources?.customers).toEqual({ kind: 'pglite', table: 'customers', seed: [{ id: 'c1' }] })
195
+ expect(app.dataSources?.orders).toEqual({ kind: 'sql', table: 'orders', dialect: 'postgres' })
196
+ expect(app.dataSource).toBe('sql')
197
+ })
198
+
199
+ it('applies per-entity overrides', () => {
200
+ const app = crudAppFromSchemas([customers, orders], {
201
+ overviewDashboard: false,
202
+ perEntity: { orders: { screens: ['list'] } },
203
+ })
204
+ expect(app.screens.filter((s) => s.entity === 'orders').map((s) => s.id)).toEqual(['orders'])
205
+ })
206
+
207
+ it('produces a valid project that round-trips through parse/serialize', () => {
208
+ const app = crudAppFromSchemas([customers, orders], { title: 'Sales desk' })
209
+ expect(validateProject(app).filter((i) => i.level === 'error')).toEqual([])
210
+ const round = parseProject(serializeProject(app))
211
+ expect(round).toEqual(app)
212
+ expect(round.title).toBe('Sales desk')
213
+ })
214
+
215
+ it('every rowLink names a screen that exists', () => {
216
+ const app = crudAppFromSchemas([customers, orders], { perEntity: { customers: { editing: 'detail' } } })
217
+ const ids = new Set(app.screens.map((s) => s.id))
218
+ for (const screen of app.screens) {
219
+ for (const block of screen.blocks) {
220
+ if (block.config.kind !== 'grid') continue
221
+ const link = (block.config as GridConfig).rowLink
222
+ if (link) expect(ids.has(link.screen), `dangling rowLink -> ${link.screen}`).toBe(true)
223
+ }
224
+ }
225
+ })
226
+ })
@@ -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
+ }