@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.
- package/README.md +95 -81
- package/dist/cdn/svgrid-enterprise.svelte-external.js +26642 -11252
- package/dist/designer/assets/GridMenus-BtWVk9Ab.js +7 -0
- package/dist/designer/assets/SvGridChartPanel-DWlBO2SD.js +10 -0
- package/dist/designer/assets/SvGridChartView-8c8Mb4j8.js +1 -0
- package/dist/designer/assets/SvGridChartView-DX9HfkBR.css +1 -0
- package/dist/designer/assets/index-DsDgp9Xq.js +78758 -0
- package/dist/designer/assets/index-tTY_Dx4P.css +1 -0
- package/dist/designer/assets/jszip.min-fkJdmAmj.js +2 -0
- package/dist/designer/assets/pdfmake-DeCsnyl9.js +242 -0
- package/dist/designer/assets/smart.export-BZlSCE8T.js +35 -0
- package/dist/designer/assets/vfs_fonts-eX2NpmfX.js +1 -0
- package/dist/designer/index.html +13 -0
- package/dist/node/studio.js +15304 -2708
- package/package.json +35 -11
- package/src/SvAlertRuleEditor.svelte +294 -0
- package/src/SvAlertsManager.svelte +210 -0
- package/src/SvAlertsPanel.svelte +129 -0
- package/src/SvBoard.svelte +3 -1
- package/src/SvExpressionEditor.svelte +341 -0
- package/src/SvGridAlerts.dom.test.ts +113 -0
- package/src/SvGridAlerts.svelte +265 -0
- package/src/SvGridBoard.svelte +2358 -0
- package/src/SvGridEditPanel.svelte +849 -794
- package/src/SvGridScheduler.svelte +5358 -4410
- package/src/SvPivotDesigner.svelte +3 -3
- package/src/SvRecordDetail.svelte +6 -2
- package/src/SvSchedule.svelte +3 -1
- package/src/{ai-export-pdf.test.ts → ai-export-pdf.dom.test.ts} +77 -74
- package/src/{ai-export-xlsx.test.ts → ai-export-xlsx.dom.test.ts} +90 -87
- package/src/{ai-export.test.ts → ai-export.dom.test.ts} +114 -110
- package/src/alerts/alert-engine-attach.ts +165 -0
- package/src/alerts/alert-engine.test.ts +135 -0
- package/src/alerts/alert-engine.ts +260 -0
- package/src/alerts/alert-formats.test.ts +87 -0
- package/src/alerts/alert-formats.ts +77 -0
- package/src/alerts/alert-observer.test.ts +189 -0
- package/src/alerts/alert-observer.ts +208 -0
- package/src/alerts/alert-scheduler.ts +96 -0
- package/src/alerts/alert-storage.test.ts +54 -0
- package/src/alerts/alert-storage.ts +116 -0
- package/src/alerts/alert-store.svelte.ts +80 -0
- package/src/alerts/alert-types.ts +94 -0
- package/src/alerts.ts +28 -0
- package/src/board.dom.test.ts +941 -0
- package/src/board.ts +36 -0
- package/src/export-ooxml.ts +4 -0
- package/src/export-xls.ts +3 -0
- package/src/export.ts +6 -0
- package/src/expressions/evaluate.test.ts +111 -0
- package/src/expressions/evaluate.ts +237 -0
- package/src/expressions/expression-columns.ts +133 -0
- package/src/expressions/expression-types.ts +84 -0
- package/src/expressions/parse.test.ts +106 -0
- package/src/expressions/parse.ts +610 -0
- package/src/import.test.ts +1 -1
- package/src/import.ts +3 -1
- package/src/index.ts +192 -46
- package/src/install.ts +12 -2
- package/src/pivot-enable.ts +44 -0
- package/src/pivot.test.ts +0 -1
- package/src/scheduler-assignments.test.ts +97 -0
- package/src/scheduler-assignments.ts +134 -0
- package/src/scheduler-axis.test.ts +108 -0
- package/src/scheduler-axis.ts +238 -0
- package/src/scheduler-booking.test.ts +57 -0
- package/src/scheduler-booking.ts +63 -0
- package/src/scheduler-config.ts +179 -0
- package/src/scheduler-dependencies.test.ts +155 -0
- package/src/scheduler-dependencies.ts +223 -0
- package/src/scheduler-freebusy.test.ts +41 -0
- package/src/scheduler-freebusy.ts +36 -0
- package/src/scheduler-heatmap.test.ts +39 -0
- package/src/scheduler-heatmap.ts +55 -0
- package/src/scheduler-resource-tree.test.ts +84 -0
- package/src/scheduler-resource-tree.ts +107 -0
- package/src/scheduler-slots.test.ts +53 -0
- package/src/scheduler-slots.ts +94 -0
- package/src/scheduler-summary.test.ts +47 -0
- package/src/scheduler-summary.ts +81 -0
- package/src/schema-designer.ts +1 -1
- package/src/sources/index.ts +1 -1
- package/src/sources/introspect-supabase.test.ts +13 -1
- package/src/sources/introspect-supabase.ts +20 -0
- package/src/studio/copilot-core.test.ts +45 -0
- package/src/studio/copilot-core.ts +65 -0
- package/src/studio/deploy-cli.test.ts +56 -0
- package/src/studio/deploy-cli.ts +100 -0
- package/src/studio/emit-project.test.ts +800 -24
- package/src/studio/emit-project.ts +1307 -135
- package/src/studio/emit-schema.test.ts +17 -0
- package/src/studio/emit-schema.ts +182 -41
- package/src/studio/index.ts +59 -1
- package/src/studio/init-flow.test.ts +239 -0
- package/src/studio/init-flow.ts +358 -0
- package/src/studio/introspect-openapi.test.ts +84 -0
- package/src/studio/introspect-openapi.ts +252 -0
- package/src/studio/project.test.ts +57 -1
- package/src/studio/project.ts +302 -8
- package/src/studio/samples/crm.ts +282 -258
- package/src/studio/samples/datasets.test.ts +84 -0
- package/src/studio/samples/datasets.ts +340 -0
- package/src/studio/samples/fleet.ts +213 -186
- package/src/studio/samples/insurance.ts +223 -195
- package/src/studio/samples/inventory.ts +213 -182
- package/src/studio/samples/live-data.test.ts +99 -98
- package/src/studio/samples/live-data.ts +8 -10
- package/src/studio/samples/projects.ts +212 -190
- package/src/studio/samples/samples.test.ts +268 -216
- package/src/studio/samples/shared.ts +22 -145
- package/src/studio/samples/starter.ts +251 -0
- package/src/studio/samples/support.ts +209 -184
- package/src/studio/screen-suites.test.ts +226 -0
- package/src/studio/screen-suites.ts +445 -0
- package/src/studio/ui-components-surface.test.ts +185 -0
- package/src/studio/ui-components.generated.ts +7154 -0
- package/src/studio/ui-components.ts +273 -3
- package/src/sveltekit/index.ts +1 -0
- package/src/sveltekit/sql-source.test.ts +13 -0
- package/src/sveltekit/sql-source.ts +12 -7
- package/src/sveltekit/transport-scope.test.ts +125 -0
- package/src/sveltekit/transport.ts +76 -2
- package/src/upgrade-prompt.ts +2 -2
- package/src/watermark.ts +2 -2
- package/src/ai.test.ts +0 -522
- 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
|
+
}
|