@svgrid/enterprise 1.2.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cdn/svgrid-enterprise.svelte-external.js +26039 -805
- package/dist/designer/assets/index-Dp44bTid.js +939 -0
- package/dist/designer/assets/index-RJp6x8tw.css +1 -0
- package/dist/designer/assets/jszip.min-CjMo-QGg.js +2 -0
- package/dist/designer/index.html +13 -0
- package/dist/node/studio.js +22554 -0
- package/package.json +10 -3
- package/src/SvAuthGate.svelte +115 -0
- package/src/SvBoard.dom.test.ts +67 -0
- package/src/SvBoard.svelte +192 -0
- package/src/SvExportMenu.svelte +553 -0
- package/src/SvFileInput.svelte +113 -0
- package/src/SvGridEditPanel.dom.test.ts +146 -0
- package/src/SvGridEditPanel.svelte +793 -0
- package/src/SvGridMasterDetail.svelte +95 -0
- package/src/SvImportDialog.svelte +1020 -0
- package/src/SvLookupInput.svelte +180 -0
- package/src/SvRecordDetail.dom.test.ts +137 -0
- package/src/SvRecordDetail.svelte +288 -0
- package/src/SvSchedule.dom.test.ts +57 -0
- package/src/SvSchedule.svelte +156 -0
- package/src/SvSchemaChart.svelte +233 -0
- package/src/SvSchemaDashboard.svelte +130 -0
- package/src/ai-export-pdf.test.ts +74 -0
- package/src/ai-export-xlsx.test.ts +87 -0
- package/src/ai-export.test.ts +110 -0
- package/src/ai.ts +1188 -782
- package/src/edit-panel.test.ts +213 -0
- package/src/edit-panel.ts +228 -0
- package/src/export-conditional.test.ts +60 -0
- package/src/export-conditional.ts +94 -0
- package/src/export-ooxml.test.ts +152 -0
- package/src/export-ooxml.ts +485 -0
- package/src/export-pdf.test.ts +138 -0
- package/src/export-pdf.ts +245 -0
- package/src/export-print.test.ts +66 -0
- package/src/export-print.ts +132 -0
- package/src/export-serialize.test.ts +56 -0
- package/src/export-serialize.ts +24 -0
- package/src/export-xls.ts +196 -0
- package/src/export-xlsx-roundtrip.test.ts +120 -0
- package/src/export-xlsx.test.ts +150 -0
- package/src/export.test.ts +349 -0
- package/src/export.ts +1452 -549
- package/src/import-automap.test.ts +131 -0
- package/src/import-hardening.test.ts +158 -0
- package/src/import.ts +1042 -648
- package/src/index.ts +282 -0
- package/src/install.ts +134 -114
- package/src/license-core.test.ts +23 -0
- package/src/license-core.ts +35 -0
- package/src/license.ts +103 -90
- package/src/master-detail.test.ts +61 -0
- package/src/master-detail.ts +42 -0
- package/src/print.ts +107 -127
- package/src/schema-designer.test.ts +121 -0
- package/src/schema-designer.ts +142 -0
- package/src/schema.test.ts +268 -0
- package/src/schema.ts +499 -0
- package/src/smart-shim.ts +107 -105
- package/src/sources/aggregate.test.ts +79 -0
- package/src/sources/aggregate.ts +102 -0
- package/src/sources/auth-supabase.test.ts +80 -0
- package/src/sources/auth-supabase.ts +87 -0
- package/src/sources/dashboard.kpi.test.ts +50 -0
- package/src/sources/dashboard.test.ts +61 -0
- package/src/sources/dashboard.ts +136 -0
- package/src/sources/field-inference.test.ts +60 -0
- package/src/sources/field-inference.ts +63 -0
- package/src/sources/filters.test.ts +58 -0
- package/src/sources/filters.ts +64 -0
- package/src/sources/index.ts +67 -0
- package/src/sources/introspect-supabase.test.ts +100 -0
- package/src/sources/introspect-supabase.ts +119 -0
- package/src/sources/realtime-supabase.test.ts +93 -0
- package/src/sources/realtime-supabase.ts +99 -0
- package/src/sources/relation-lookup.test.ts +99 -0
- package/src/sources/relation-lookup.ts +128 -0
- package/src/sources/rest-adapters.test.ts +95 -0
- package/src/sources/rest-adapters.ts +120 -0
- package/src/sources/rest.test.ts +104 -0
- package/src/sources/rest.ts +129 -0
- package/src/sources/schema-from-columns.test.ts +106 -0
- package/src/sources/schema-from-columns.ts +88 -0
- package/src/sources/supabase.test.ts +110 -0
- package/src/sources/supabase.ts +99 -0
- package/src/sources/with-entity-rules.test.ts +104 -0
- package/src/sources/with-entity-rules.ts +78 -0
- package/src/sources/with-relation-labels.test.ts +75 -0
- package/src/sources/with-relation-labels.ts +73 -0
- package/src/studio/bug-report.test.ts +101 -0
- package/src/studio/bug-report.ts +165 -0
- package/src/studio/cli.test.ts +187 -0
- package/src/studio/cli.ts +112 -0
- package/src/studio/csv.test.ts +90 -0
- package/src/studio/csv.ts +165 -0
- package/src/studio/db-connect-string.test.ts +94 -0
- package/src/studio/db-connect-string.ts +128 -0
- package/src/studio/emit-project.test.ts +923 -0
- package/src/studio/emit-project.ts +1273 -0
- package/src/studio/emit-schema.test.ts +94 -0
- package/src/studio/emit-schema.ts +920 -0
- package/src/studio/index.ts +195 -0
- package/src/studio/introspect-db.test.ts +186 -0
- package/src/studio/introspect-db.ts +312 -0
- package/src/studio/introspect-prisma.test.ts +99 -0
- package/src/studio/introspect-prisma.ts +175 -0
- package/src/studio/introspect.test.ts +172 -0
- package/src/studio/introspect.ts +310 -0
- package/src/studio/pipeline.test.ts +42 -0
- package/src/studio/project-robust.test.ts +157 -0
- package/src/studio/project.test.ts +473 -0
- package/src/studio/project.ts +916 -0
- package/src/studio/sample-data.test.ts +58 -0
- package/src/studio/sample-data.ts +200 -0
- package/src/studio/samples/ats.ts +202 -0
- package/src/studio/samples/clinic.ts +177 -0
- package/src/studio/samples/crm.ts +250 -0
- package/src/studio/samples/ecommerce.ts +187 -0
- package/src/studio/samples/events.ts +199 -0
- package/src/studio/samples/fleet.ts +184 -0
- package/src/studio/samples/gym.ts +213 -0
- package/src/studio/samples/hr.ts +193 -0
- package/src/studio/samples/index.ts +55 -0
- package/src/studio/samples/insurance.ts +195 -0
- package/src/studio/samples/inventory.ts +182 -0
- package/src/studio/samples/invoicing.ts +162 -0
- package/src/studio/samples/library.ts +180 -0
- package/src/studio/samples/live-data.test.ts +98 -0
- package/src/studio/samples/live-data.ts +308 -0
- package/src/studio/samples/projects.ts +190 -0
- package/src/studio/samples/realestate.ts +196 -0
- package/src/studio/samples/restaurant.ts +187 -0
- package/src/studio/samples/samples.test.ts +208 -0
- package/src/studio/samples/school.ts +197 -0
- package/src/studio/samples/seed-floor.test.ts +25 -0
- package/src/studio/samples/shared.ts +305 -0
- package/src/studio/samples/subscriptions.ts +183 -0
- package/src/studio/samples/support.ts +182 -0
- package/src/studio/scaffold-app.test.ts +91 -0
- package/src/studio/scaffold-app.ts +161 -0
- package/src/studio/scaffold.test.ts +178 -0
- package/src/studio/scaffold.ts +374 -0
- package/src/studio/themes.ts +172 -0
- package/src/studio/verify.test.ts +47 -0
- package/src/studio/verify.ts +79 -0
- package/src/sveltekit/in-memory.test.ts +104 -0
- package/src/sveltekit/in-memory.ts +129 -0
- package/src/sveltekit/index.ts +23 -0
- package/src/sveltekit/query-plan.test.ts +109 -0
- package/src/sveltekit/query-plan.ts +125 -0
- package/src/sveltekit/sql-source.test.ts +185 -0
- package/src/sveltekit/sql-source.ts +166 -0
- package/src/sveltekit/sql.test.ts +80 -0
- package/src/sveltekit/sql.ts +131 -0
- package/src/sveltekit/transport.test.ts +175 -0
- package/src/sveltekit/transport.ts +254 -0
- package/src/sveltekit/types.ts +11 -0
- package/src/upgrade-prompt.ts +149 -148
|
@@ -0,0 +1,916 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Studio project model - the declarative spine the visual designer edits and the
|
|
3
|
+
* codegen emits. A project is a multi-entity app: `entities` (the data model) +
|
|
4
|
+
* `screens` (one per entity by default), where a screen is an ordered list of
|
|
5
|
+
* data-bound `blocks` (grid, form, chart, dashboard, KPI, master-detail, lookup).
|
|
6
|
+
*
|
|
7
|
+
* Pure + node-safe (lives in the `./studio` subtree - `.js` imports, no Svelte,
|
|
8
|
+
* no `sources/`), mirroring the immutable-ops style of `schema-designer.ts`:
|
|
9
|
+
* every mutation returns a NEW project so Svelte reactivity + undo stay simple.
|
|
10
|
+
* Block-config unions are defined here (not imported from `sources/`) to keep the
|
|
11
|
+
* module resolvable under node16.
|
|
12
|
+
*/
|
|
13
|
+
import type { EntityField, EntityFieldType, EntitySchema } from '../schema.js'
|
|
14
|
+
import type { ChartType } from '@svgrid/grid'
|
|
15
|
+
|
|
16
|
+
export type Reduce = 'sum' | 'avg' | 'count' | 'min' | 'max'
|
|
17
|
+
export type DataSourceKind = 'memory' | 'sql' | 'supabase' | 'rest' | 'pglite'
|
|
18
|
+
export type Presentation = 'modal' | 'drawer' | 'inline'
|
|
19
|
+
|
|
20
|
+
// --- per-entity data-source binding ----------------------------------------
|
|
21
|
+
// Each entity can bind to its own backend (a REST endpoint, a SQL table, a
|
|
22
|
+
// Supabase table, or the seeded in-memory store). Connection config is a
|
|
23
|
+
// design-time concern, so it lives on the project (keyed by entity name), NOT on
|
|
24
|
+
// the pure EntitySchema. The codegen branches on the resolved source per entity.
|
|
25
|
+
|
|
26
|
+
export type RestMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
|
27
|
+
export type ParamLocation = 'path' | 'query' | 'header'
|
|
28
|
+
export type ParamType = 'string' | 'number' | 'boolean'
|
|
29
|
+
/** One request parameter in the REST builder (path `{id}`, query, or header). */
|
|
30
|
+
export type RequestParam = { name: string; location: ParamLocation; type: ParamType; value?: string }
|
|
31
|
+
export type SqlDialectKind = 'postgres' | 'mysql' | 'sqlite' | 'mssql' | 'supabase' | 'turso'
|
|
32
|
+
|
|
33
|
+
/** In-memory (seeded) source. `seed` carries curated rows (e.g. a sample app);
|
|
34
|
+
* when absent the codegen + preview synthesize realistic rows. */
|
|
35
|
+
export type MemorySource = { kind: 'memory'; seed?: Record<string, unknown>[] }
|
|
36
|
+
export type RestSource = {
|
|
37
|
+
kind: 'rest'
|
|
38
|
+
/** Origin + version prefix, e.g. `https://api.example.com/v1`. */
|
|
39
|
+
baseUrl: string
|
|
40
|
+
/** Collection path, e.g. `customers` or `albums/{id}/tracks`. */
|
|
41
|
+
path: string
|
|
42
|
+
method: RestMethod
|
|
43
|
+
params: RequestParam[]
|
|
44
|
+
idField?: string
|
|
45
|
+
/** Dotted path in the response body holding the rows, e.g. `data.items`. */
|
|
46
|
+
rowsPath?: string
|
|
47
|
+
/** Dotted path holding the total row count, e.g. `data.total`. */
|
|
48
|
+
totalPath?: string
|
|
49
|
+
}
|
|
50
|
+
export type SqlSource = { kind: 'sql'; table: string; dialect?: SqlDialectKind }
|
|
51
|
+
export type SupabaseSource = { kind: 'supabase'; table: string; url?: string; key?: string }
|
|
52
|
+
/** Embedded Postgres (PGlite) - a real, persistent database with ZERO backend
|
|
53
|
+
* setup. Runs in the browser, persisting to IndexedDB. Swap for a `sql` source
|
|
54
|
+
* pointed at hosted Postgres to go to production (same schema + SQL). */
|
|
55
|
+
export type PgliteSource = { kind: 'pglite'; table: string; seed?: Record<string, unknown>[] }
|
|
56
|
+
/** Where one entity's rows come from. */
|
|
57
|
+
export type EntityDataSource = MemorySource | RestSource | SqlSource | SupabaseSource | PgliteSource
|
|
58
|
+
|
|
59
|
+
/** The kinds of data-bound block a screen can hold. */
|
|
60
|
+
export type BlockKind = 'grid' | 'form' | 'chart' | 'dashboard' | 'kpi' | 'gauge' | 'tree' | 'tabs' | 'master-detail' | 'lookup' | 'pivot' | 'filter' | 'record' | 'board' | 'calendar' | 'detail'
|
|
61
|
+
|
|
62
|
+
export type GridAlign = 'left' | 'center' | 'right'
|
|
63
|
+
export type GridColumnConfig = { field: string; show: boolean; header?: string; width?: number; align?: GridAlign; pin?: 'left' | 'right' }
|
|
64
|
+
/** How a grid edits its rows: read-only, inline (Excel-style cells), or a popup form. */
|
|
65
|
+
export type GridEditing = 'none' | 'inline' | 'form'
|
|
66
|
+
/** Row height preset. */
|
|
67
|
+
export type GridDensity = 'compact' | 'normal' | 'comfortable'
|
|
68
|
+
/** Where the pagination footer sits. */
|
|
69
|
+
export type PagerPosition = 'top' | 'bottom' | 'both'
|
|
70
|
+
export type GridConfig = {
|
|
71
|
+
kind: 'grid'
|
|
72
|
+
columns: GridColumnConfig[]
|
|
73
|
+
pageSize: number
|
|
74
|
+
selectable: boolean
|
|
75
|
+
sortable: boolean
|
|
76
|
+
filterable: boolean
|
|
77
|
+
editing: GridEditing
|
|
78
|
+
/** Presentation of the edit form when `editing === 'form'`. */
|
|
79
|
+
formPresentation: Presentation
|
|
80
|
+
density: GridDensity
|
|
81
|
+
striped: boolean
|
|
82
|
+
/** Excel-style cell/range selection. */
|
|
83
|
+
cellSelection: boolean
|
|
84
|
+
/** Show a totals/summary footer row. */
|
|
85
|
+
rowSummaries: boolean
|
|
86
|
+
/** Paginate (false shows all rows, no pager). */
|
|
87
|
+
paginated: boolean
|
|
88
|
+
/** Where the pager sits when paginated. */
|
|
89
|
+
paginationPosition: PagerPosition
|
|
90
|
+
/** Page-size choices in the pager's selector. */
|
|
91
|
+
pageSizeOptions: number[]
|
|
92
|
+
/** Drill-through: clicking a row navigates to `screen`, filtered by the clicked
|
|
93
|
+
* row's `sourceField` value on the target's `targetField`. */
|
|
94
|
+
rowLink?: RowLink
|
|
95
|
+
/** Per-row action buttons (edit / delete / navigate) shown in an actions column. */
|
|
96
|
+
rowActions?: RowAction[]
|
|
97
|
+
/** No-code conditional formatting: color / bold a cell by its value. Compiled to
|
|
98
|
+
* the grid's `conditionalFormats` rule engine. */
|
|
99
|
+
formatRules?: FormatRule[]
|
|
100
|
+
}
|
|
101
|
+
/** A conditional-formatting comparison. */
|
|
102
|
+
export type FormatOp = 'eq' | 'ne' | 'lt' | 'lte' | 'gt' | 'gte' | 'contains' | 'empty' | 'notEmpty'
|
|
103
|
+
/** One no-code format rule: style a `field`'s cell when the comparison holds. */
|
|
104
|
+
export type FormatRule = { field: string; op: FormatOp; value?: string | number; color?: string; background?: string; bold?: boolean }
|
|
105
|
+
/** A row-click drill-through to another screen. */
|
|
106
|
+
export type RowLink = { screen: string; sourceField?: string; targetField: string }
|
|
107
|
+
/** One per-row action button. */
|
|
108
|
+
export type RowActionKind = 'edit' | 'delete' | 'navigate'
|
|
109
|
+
export type RowAction = {
|
|
110
|
+
kind: RowActionKind
|
|
111
|
+
label?: string
|
|
112
|
+
/** navigate: target screen id. */
|
|
113
|
+
screen?: string
|
|
114
|
+
/** navigate: field on this row whose value is passed (defaults to the id). */
|
|
115
|
+
sourceField?: string
|
|
116
|
+
/** navigate: field on the target entity to filter by. */
|
|
117
|
+
targetField?: string
|
|
118
|
+
}
|
|
119
|
+
/** Legacy standalone edit-form block. Editing is now a Grid property; kept so old
|
|
120
|
+
* `studio.config.json` files still parse. Not offered in the palette. */
|
|
121
|
+
export type FormConfig = { kind: 'form'; presentation: Presentation }
|
|
122
|
+
/** A chart, optionally drilling into `drillScreen` (filtered by the clicked category). */
|
|
123
|
+
export type ChartConfig = { kind: 'chart'; dimension: string; measure?: string; reduce: Reduce; type: ChartType; drillScreen?: string }
|
|
124
|
+
/** Number format for a KPI value. `auto` keeps the legacy behavior ($ when the
|
|
125
|
+
* measure's label carries `$`, else grouped number). */
|
|
126
|
+
export type KpiFormat = 'auto' | 'number' | 'currency' | 'percent' | 'compact'
|
|
127
|
+
export type KpiConfig = {
|
|
128
|
+
kind: 'kpi'
|
|
129
|
+
label: string
|
|
130
|
+
measure?: string
|
|
131
|
+
reduce: Reduce
|
|
132
|
+
/** Value formatting. Defaults to `auto`. */
|
|
133
|
+
format?: KpiFormat
|
|
134
|
+
/** Field to bucket the measure by for an inline sparkline (e.g. a date or stage). */
|
|
135
|
+
trendField?: string
|
|
136
|
+
/** Aggregate per sparkline bucket. Defaults to `reduce`. */
|
|
137
|
+
trendReduce?: Reduce
|
|
138
|
+
/** Target value: shows a "% of target" delta chip. */
|
|
139
|
+
target?: number
|
|
140
|
+
}
|
|
141
|
+
/** A radial gauge (SvGauge) of one aggregated measure within [min, max]. Like a
|
|
142
|
+
* KPI but rendered as an arc - good for utilization, progress, scores. */
|
|
143
|
+
export type GaugeConfig = { kind: 'gauge'; label: string; measure?: string; reduce: Reduce; min: number; max: number; unit?: string }
|
|
144
|
+
/** A hierarchical tree (SvTree) built from the entity's own rows: `labelField` is
|
|
145
|
+
* the node text, `parentField` is a self-referential FK (a row whose parent is
|
|
146
|
+
* empty / unknown is a root). Good for categories, folders, org charts. */
|
|
147
|
+
export type TreeConfig = { kind: 'tree'; labelField: string; parentField: string }
|
|
148
|
+
/** One tab of a Tabs container: a label + its own ordered child blocks. */
|
|
149
|
+
export type StudioTab = { label: string; blocks: Block[] }
|
|
150
|
+
/** A tabbed container (SvTabs) grouping display blocks into tabs. Children are the
|
|
151
|
+
* controller-free, `allRows`-driven blocks (see `TAB_CHILD_KINDS`). */
|
|
152
|
+
export type TabsConfig = { kind: 'tabs'; tabs: StudioTab[] }
|
|
153
|
+
export type DashboardConfig = { kind: 'dashboard' }
|
|
154
|
+
export type MasterDetailConfig = { kind: 'master-detail'; childEntity: string; foreignKey: string; linkScreen?: string }
|
|
155
|
+
export type LookupConfig = { kind: 'lookup'; field: string }
|
|
156
|
+
/** A pivot table (SvPivotDesigner): row/column dimensions + one aggregated measure.
|
|
157
|
+
* `aggregate` reuses the Reduce set (all valid pivot aggregators). */
|
|
158
|
+
export type PivotConfig = { kind: 'pivot'; rows: string[]; cols: string[]; measure?: string; aggregate: Reduce }
|
|
159
|
+
/** A faceted filter panel that drives the screen's grid. `fields` are the columns
|
|
160
|
+
* exposed as facets (enum/boolean -> select, text -> contains search). */
|
|
161
|
+
export type FilterPanelConfig = { kind: 'filter'; fields: string[]; title?: string }
|
|
162
|
+
/** A record detail panel: shows the row selected in the screen's grid via
|
|
163
|
+
* SvGridEditPanel. `fields` optionally narrows which fields show (empty = all). */
|
|
164
|
+
export type RecordConfig = { kind: 'record'; fields?: string[]; editable: boolean; presentation?: Presentation }
|
|
165
|
+
/** A Kanban board (SvBoard): columns are an `enum` field's options (`groupBy`),
|
|
166
|
+
* rows become draggable cards titled by `titleField`. Dragging a card changes its
|
|
167
|
+
* `groupBy` value. Optional `badgeField` (a chip) + `subtitleField` (secondary line). */
|
|
168
|
+
export type BoardConfig = { kind: 'board'; groupBy: string; titleField: string; badgeField?: string; subtitleField?: string; openScreen?: string }
|
|
169
|
+
/** A month event-calendar (SvSchedule): each row with a `dateField` value is an
|
|
170
|
+
* event on that day, labelled by `titleField` and (optionally) tinted by an enum
|
|
171
|
+
* `colorField`. Good for appointments / events / bookings / shifts. */
|
|
172
|
+
export type CalendarConfig = { kind: 'calendar'; dateField: string; titleField: string; colorField?: string; openScreen?: string }
|
|
173
|
+
/** A related child collection shown as a tab on a record detail page. */
|
|
174
|
+
export type DetailRelated = { entity: string; foreignKey: string; label?: string; titleField?: string; subtitleField?: string; dateField?: string; statusField?: string; parentField?: string }
|
|
175
|
+
/** A full record "detail page" (SvRecordDetail): a header (title + subtitle +
|
|
176
|
+
* colored `statusField` pill + `metricFields` tiles), a tabbed Overview of
|
|
177
|
+
* `sections` (field groups), and one tab per `related` child collection (a
|
|
178
|
+
* timeline of the children pointing back at the record). The universal signature
|
|
179
|
+
* view for relation-heavy entities where a board / calendar does not fit. */
|
|
180
|
+
export type DetailConfig = { kind: 'detail'; titleField: string; subtitleField?: string; statusField?: string; metricFields?: string[]; sections?: { label: string; fields: string[] }[]; related?: DetailRelated[] }
|
|
181
|
+
export type BlockConfig =
|
|
182
|
+
| GridConfig | FormConfig | ChartConfig | KpiConfig | GaugeConfig | TreeConfig | TabsConfig | DashboardConfig | MasterDetailConfig | LookupConfig
|
|
183
|
+
| PivotConfig | FilterPanelConfig | RecordConfig | BoardConfig | CalendarConfig | DetailConfig
|
|
184
|
+
|
|
185
|
+
/** Block kinds allowed inside a Tabs container: the controller-free, `allRows`-driven
|
|
186
|
+
* display blocks (no grid / form / master-detail, which need the screen controller). */
|
|
187
|
+
export const TAB_CHILD_KINDS: ReadonlyArray<BlockKind> = ['chart', 'kpi', 'gauge', 'dashboard', 'pivot', 'tree']
|
|
188
|
+
|
|
189
|
+
/** All blocks on a screen, flattened to include the children nested in Tabs
|
|
190
|
+
* containers - used to detect kinds for imports / data loading. */
|
|
191
|
+
export function flattenBlocks(blocks: ReadonlyArray<Block>): Block[] {
|
|
192
|
+
const out: Block[] = []
|
|
193
|
+
for (const b of blocks) {
|
|
194
|
+
out.push(b)
|
|
195
|
+
if (b.config.kind === 'tabs') for (const t of b.config.tabs) out.push(...flattenBlocks(t.blocks))
|
|
196
|
+
}
|
|
197
|
+
return out
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export type Block = {
|
|
201
|
+
id: string
|
|
202
|
+
/** Coarse width in a 3-col grid (legacy + quick buttons). `colSpan` overrides it. */
|
|
203
|
+
span: 1 | 2 | 3
|
|
204
|
+
/** Fine width in a 12-col grid (1-12), set by the horizontal drag / slider.
|
|
205
|
+
* Falls back to `span * 4` (so 1/2/3 -> 4/8/12) when unset. */
|
|
206
|
+
colSpan?: number
|
|
207
|
+
/** Canvas/preview region height in px. Undefined = the kind's natural default.
|
|
208
|
+
* Applies to height-driven blocks (grid, chart, master-detail). */
|
|
209
|
+
height?: number
|
|
210
|
+
config: BlockConfig
|
|
211
|
+
}
|
|
212
|
+
/** The number of columns (1-12) a block occupies in the 12-col layout. */
|
|
213
|
+
export const blockColumns = (b: Pick<Block, 'span' | 'colSpan'>): number =>
|
|
214
|
+
Math.max(1, Math.min(12, Math.round(b.colSpan ?? b.span * 4)))
|
|
215
|
+
/** Navigation placement for a screen (Manage Pages: show/hide + label + order). */
|
|
216
|
+
export type ScreenNav = { show?: boolean; label?: string; order?: number }
|
|
217
|
+
export type Screen = { id: string; entity: string; title: string; route: string; blocks: Block[]; nav?: ScreenNav }
|
|
218
|
+
|
|
219
|
+
/** The generated app's shell (master layout): sidebar vs top-nav, brand, footer. */
|
|
220
|
+
export type ShellStyle = 'sidebar' | 'top-nav'
|
|
221
|
+
export type ShellConfig = { style?: ShellStyle; brand?: string; footer?: string; navPosition?: 'left' | 'right'; logo?: string; toolbar?: boolean }
|
|
222
|
+
export type ProjectTheme = { accent?: string; preset?: string; mode?: 'light' | 'dark'; shell?: ShellConfig; customCss?: string }
|
|
223
|
+
|
|
224
|
+
/** A mutating CRUD action, gated by RBAC (reads are implied by screen access). */
|
|
225
|
+
export type CrudAction = 'create' | 'update' | 'delete'
|
|
226
|
+
export const CRUD_ACTIONS: readonly CrudAction[] = ['create', 'update', 'delete']
|
|
227
|
+
/** Access rules for one role. */
|
|
228
|
+
export type RoleAccess = {
|
|
229
|
+
role: string
|
|
230
|
+
/** Screen ids the role can open. `'*'` = every screen. */
|
|
231
|
+
screens: '*' | string[]
|
|
232
|
+
/** Write actions the role may perform. `'*'` = all; read is implied by screen access. */
|
|
233
|
+
actions: '*' | CrudAction[]
|
|
234
|
+
}
|
|
235
|
+
/** Role-based access control for the app. When `enabled`, the generator emits a
|
|
236
|
+
* `src/lib/access.ts` and gates nav / actions in the UI AND the server route. */
|
|
237
|
+
export type AccessControl = {
|
|
238
|
+
enabled: boolean
|
|
239
|
+
roles: RoleAccess[]
|
|
240
|
+
/** Fallback role when the app can't resolve one from the session. Default-denies. */
|
|
241
|
+
defaultRole?: string
|
|
242
|
+
}
|
|
243
|
+
/** Does a role's rules permit opening a screen? */
|
|
244
|
+
export const roleCanScreen = (r: RoleAccess, screenId: string): boolean =>
|
|
245
|
+
r.screens === '*' || r.screens.includes(screenId)
|
|
246
|
+
/** Does a role's rules permit a write action? */
|
|
247
|
+
export const roleCanAction = (r: RoleAccess, action: CrudAction): boolean =>
|
|
248
|
+
r.actions === '*' || r.actions.includes(action)
|
|
249
|
+
|
|
250
|
+
export type StudioProject = {
|
|
251
|
+
title: string
|
|
252
|
+
entities: EntitySchema[]
|
|
253
|
+
screens: Screen[]
|
|
254
|
+
/** Default source kind for new entities; per-entity overrides live in `dataSources`. */
|
|
255
|
+
dataSource: DataSourceKind
|
|
256
|
+
/** Per-entity data-source binding, keyed by entity name. */
|
|
257
|
+
dataSources?: Record<string, EntityDataSource>
|
|
258
|
+
theme?: ProjectTheme
|
|
259
|
+
/** Role-based access control (optional; off unless `access.enabled`). */
|
|
260
|
+
access?: AccessControl
|
|
261
|
+
/** Emit an audit trail: connected routes log create/update/delete + an /audit viewer. */
|
|
262
|
+
audit?: boolean
|
|
263
|
+
/** Localization: when enabled, emit a message catalog + locale switcher and route
|
|
264
|
+
* nav / titles / column headers through `t()`. */
|
|
265
|
+
i18n?: I18nConfig
|
|
266
|
+
/** Deploy target: picks the SvelteKit adapter + provider config the bundle emits.
|
|
267
|
+
* Defaults to `auto` (@sveltejs/adapter-auto, which detects Vercel/Netlify/Cloudflare). */
|
|
268
|
+
deploy?: DeployTarget
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Where the generated app deploys. Drives the emitted SvelteKit adapter + config. */
|
|
272
|
+
export type DeployTarget = 'auto' | 'vercel' | 'netlify' | 'cloudflare' | 'node'
|
|
273
|
+
|
|
274
|
+
/** Localization config: the locales the app ships and which is the default. */
|
|
275
|
+
export type I18nConfig = { enabled: boolean; locales: string[]; defaultLocale?: string }
|
|
276
|
+
|
|
277
|
+
export type ProjectIssueLevel = 'error' | 'warning'
|
|
278
|
+
export type ProjectIssue = { level: ProjectIssueLevel; message: string; screen?: string; block?: string }
|
|
279
|
+
|
|
280
|
+
/** One palette entry: a draggable block kind + what it needs to be useful. */
|
|
281
|
+
export type PaletteItem = { kind: BlockKind; label: string; needs?: 'measure' | 'child' }
|
|
282
|
+
|
|
283
|
+
/** The designer's block palette, in menu order. (Editing is a Grid property, so
|
|
284
|
+
* there's no standalone form block.) */
|
|
285
|
+
export const blockPalette: ReadonlyArray<PaletteItem> = [
|
|
286
|
+
{ kind: 'grid', label: 'Grid' },
|
|
287
|
+
{ kind: 'chart', label: 'Chart', needs: 'measure' },
|
|
288
|
+
{ kind: 'pivot', label: 'Pivot', needs: 'measure' },
|
|
289
|
+
{ kind: 'dashboard', label: 'Dashboard' },
|
|
290
|
+
{ kind: 'kpi', label: 'KPI tile', needs: 'measure' },
|
|
291
|
+
{ kind: 'gauge', label: 'Gauge', needs: 'measure' },
|
|
292
|
+
{ kind: 'tree', label: 'Tree' },
|
|
293
|
+
{ kind: 'tabs', label: 'Tabs' },
|
|
294
|
+
{ kind: 'master-detail', label: 'Master / detail', needs: 'child' },
|
|
295
|
+
{ kind: 'board', label: 'Board' },
|
|
296
|
+
{ kind: 'calendar', label: 'Calendar' },
|
|
297
|
+
{ kind: 'detail', label: 'Detail page' },
|
|
298
|
+
{ kind: 'filter', label: 'Filter panel' },
|
|
299
|
+
{ kind: 'record', label: 'Record panel' },
|
|
300
|
+
{ kind: 'lookup', label: 'Lookup' },
|
|
301
|
+
]
|
|
302
|
+
|
|
303
|
+
// --- helpers ---------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
/** A free `${prefix}${n}` id not already in `taken`. */
|
|
306
|
+
function uid(prefix: string, taken: ReadonlySet<string>): string {
|
|
307
|
+
let n = 1
|
|
308
|
+
while (taken.has(`${prefix}${n}`)) n++
|
|
309
|
+
return `${prefix}${n}`
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function entityOf(project: StudioProject, name: string): EntitySchema | undefined {
|
|
313
|
+
return project.entities.find((e) => e.name === name)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const gridHidden = (f: EntityField): boolean =>
|
|
317
|
+
f.hidden === true || (typeof f.hidden === 'object' && f.hidden.grid === true)
|
|
318
|
+
|
|
319
|
+
function pickDimension(entity: EntitySchema): string {
|
|
320
|
+
const nonKey = entity.fields.filter((f) => !f.primaryKey)
|
|
321
|
+
// Prefer a low-cardinality dimension: enum, then boolean, then text.
|
|
322
|
+
return (
|
|
323
|
+
nonKey.find((f) => f.type === 'enum')?.field ??
|
|
324
|
+
nonKey.find((f) => f.type === 'boolean')?.field ??
|
|
325
|
+
nonKey.find((f) => f.type === 'text')?.field ??
|
|
326
|
+
nonKey[0]?.field ??
|
|
327
|
+
entity.fields[0]?.field ??
|
|
328
|
+
''
|
|
329
|
+
)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function pickMeasure(entity: EntitySchema): string | undefined {
|
|
333
|
+
return entity.fields.find((f) => f.type === 'number' && !f.primaryKey)?.field
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Sensible grid columns for an entity: every non-grid-hidden field, shown. */
|
|
337
|
+
export function gridColumns(entity: EntitySchema): GridColumnConfig[] {
|
|
338
|
+
const pk = entity.idField ?? entity.fields.find((f) => f.primaryKey)?.field
|
|
339
|
+
return entity.fields
|
|
340
|
+
.filter((f) => !gridHidden(f))
|
|
341
|
+
// Hide the raw primary-key column by default (a "co1 / dl2" id column reads
|
|
342
|
+
// as unfinished); everything else is visible. Users can re-enable it.
|
|
343
|
+
.map((f) => ({ field: f.field, show: f.field !== pk, header: f.label }))
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** A default config for a freshly-added block of `kind`, sized for the entity. */
|
|
347
|
+
export function defaultBlockConfig(kind: BlockKind, entity: EntitySchema): BlockConfig {
|
|
348
|
+
switch (kind) {
|
|
349
|
+
case 'grid':
|
|
350
|
+
return { kind, columns: gridColumns(entity), pageSize: 10, selectable: true, sortable: true, filterable: false, editing: 'form', formPresentation: 'modal', density: 'normal', striped: false, cellSelection: false, rowSummaries: false, paginated: true, paginationPosition: 'bottom', pageSizeOptions: [10, 25, 50, 100] }
|
|
351
|
+
case 'form':
|
|
352
|
+
return { kind, presentation: 'modal' }
|
|
353
|
+
case 'chart': {
|
|
354
|
+
const measure = pickMeasure(entity)
|
|
355
|
+
return { kind, dimension: pickDimension(entity), measure, reduce: measure ? 'sum' : 'count', type: 'bar' }
|
|
356
|
+
}
|
|
357
|
+
case 'kpi': {
|
|
358
|
+
const measure = pickMeasure(entity)
|
|
359
|
+
return { kind, label: measure ? `Total ${measure}` : `Total ${entity.label ?? entity.name}`, measure, reduce: measure ? 'sum' : 'count' }
|
|
360
|
+
}
|
|
361
|
+
case 'gauge': {
|
|
362
|
+
const measure = pickMeasure(entity)
|
|
363
|
+
return { kind, label: measure ? `Avg ${measure}` : `${entity.label ?? entity.name} count`, measure, reduce: measure ? 'avg' : 'count', min: 0, max: 100 }
|
|
364
|
+
}
|
|
365
|
+
case 'tree': {
|
|
366
|
+
const label = entity.fields.find((f) => !f.primaryKey && f.type === 'text')?.field ?? entity.fields.find((f) => !f.primaryKey)?.field ?? entity.fields[0]?.field ?? ''
|
|
367
|
+
// A self-referential FK (relation back to this entity) is the natural parent link; else guess by name.
|
|
368
|
+
const parent = entity.fields.find((f) => f.type === 'relation' && f.relation?.entity === entity.name)?.field
|
|
369
|
+
?? entity.fields.find((f) => /parent/i.test(f.field))?.field ?? ''
|
|
370
|
+
return { kind, labelField: label, parentField: parent }
|
|
371
|
+
}
|
|
372
|
+
case 'tabs':
|
|
373
|
+
return { kind, tabs: [{ label: 'Overview', blocks: [] }, { label: 'Details', blocks: [] }] }
|
|
374
|
+
case 'dashboard':
|
|
375
|
+
return { kind }
|
|
376
|
+
case 'master-detail':
|
|
377
|
+
return { kind, childEntity: '', foreignKey: '' }
|
|
378
|
+
case 'lookup':
|
|
379
|
+
return { kind, field: entity.fields.find((f) => f.type === 'relation')?.field ?? entity.fields[0]?.field ?? '' }
|
|
380
|
+
case 'pivot': {
|
|
381
|
+
const measure = pickMeasure(entity)
|
|
382
|
+
const dim = pickDimension(entity)
|
|
383
|
+
return { kind, rows: dim ? [dim] : [], cols: [], measure, aggregate: measure ? 'sum' : 'count' }
|
|
384
|
+
}
|
|
385
|
+
case 'filter':
|
|
386
|
+
return { kind, fields: pickFacetFields(entity) }
|
|
387
|
+
case 'record':
|
|
388
|
+
return { kind, editable: false }
|
|
389
|
+
case 'board': {
|
|
390
|
+
const enumField = entity.fields.find((f) => f.type === 'enum' && !f.primaryKey)?.field ?? ''
|
|
391
|
+
const titleF = entity.fields.find((f) => f.type === 'text' && !f.primaryKey)?.field ?? entity.fields.find((f) => !f.primaryKey)?.field ?? ''
|
|
392
|
+
const badge = pickMeasure(entity)
|
|
393
|
+
return { kind, groupBy: enumField, titleField: titleF, ...(badge ? { badgeField: badge } : {}) }
|
|
394
|
+
}
|
|
395
|
+
case 'calendar': {
|
|
396
|
+
const dateF = entity.fields.find((f) => (f.type === 'datetime' || f.type === 'date' || f.type === 'dateString') && !f.primaryKey)?.field ?? ''
|
|
397
|
+
const titleF = entity.fields.find((f) => f.type === 'text' && !f.primaryKey)?.field ?? entity.fields.find((f) => !f.primaryKey)?.field ?? ''
|
|
398
|
+
const colorF = entity.fields.find((f) => f.type === 'enum' && !f.primaryKey)?.field
|
|
399
|
+
return { kind, dateField: dateF, titleField: titleF, ...(colorF ? { colorField: colorF } : {}) }
|
|
400
|
+
}
|
|
401
|
+
case 'detail': {
|
|
402
|
+
const nonKey = entity.fields.filter((f) => !f.primaryKey)
|
|
403
|
+
const titleF = nonKey.find((f) => f.type === 'text')?.field ?? nonKey[0]?.field ?? entity.fields[0]?.field ?? ''
|
|
404
|
+
const statusF = nonKey.find((f) => f.type === 'enum')?.field
|
|
405
|
+
const subF = nonKey.find((f) => (f.type === 'text' || f.type === 'relation') && f.field !== titleF)?.field
|
|
406
|
+
const metrics = nonKey.filter((f) => f.type === 'number').slice(0, 3).map((f) => f.field)
|
|
407
|
+
return { kind, titleField: titleF, ...(subF ? { subtitleField: subF } : {}), ...(statusF ? { statusField: statusF } : {}), ...(metrics.length ? { metricFields: metrics } : {}) }
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Low-cardinality, filter-friendly fields (enum, boolean, then text), non-key.
|
|
413
|
+
* Used to seed the faceted Filter panel. Caps at 4 so the panel stays compact. */
|
|
414
|
+
export function pickFacetFields(entity: EntitySchema): string[] {
|
|
415
|
+
return entity.fields
|
|
416
|
+
.filter((f) => !f.primaryKey && (f.type === 'enum' || f.type === 'boolean' || f.type === 'text'))
|
|
417
|
+
.sort((a, b) => facetRank(a.type) - facetRank(b.type))
|
|
418
|
+
.slice(0, 4)
|
|
419
|
+
.map((f) => f.field)
|
|
420
|
+
}
|
|
421
|
+
const facetRank = (t: EntityFieldType): number => (t === 'enum' ? 0 : t === 'boolean' ? 1 : 2)
|
|
422
|
+
|
|
423
|
+
const DEFAULT_SPAN: Record<BlockKind, 1 | 2 | 3> = {
|
|
424
|
+
grid: 3, form: 1, chart: 2, dashboard: 3, kpi: 1, gauge: 1, tree: 2, tabs: 3, 'master-detail': 3, lookup: 1, pivot: 3, filter: 1, record: 1, board: 3, calendar: 3, detail: 3,
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function makeBlock(kind: BlockKind, entity: EntitySchema, taken: ReadonlySet<string>): Block {
|
|
428
|
+
return { id: uid(`${kind}-`, taken), span: DEFAULT_SPAN[kind], config: defaultBlockConfig(kind, entity) }
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// --- Tabs container ops (pure; the designer builds a new config + updateBlock) ---
|
|
432
|
+
|
|
433
|
+
/** Append a tab to a Tabs container. */
|
|
434
|
+
export function addTab(cfg: TabsConfig, label?: string): TabsConfig {
|
|
435
|
+
return { ...cfg, tabs: [...cfg.tabs, { label: label ?? `Tab ${cfg.tabs.length + 1}`, blocks: [] }] }
|
|
436
|
+
}
|
|
437
|
+
/** Remove a tab by index (keeps at least one). */
|
|
438
|
+
export function removeTab(cfg: TabsConfig, index: number): TabsConfig {
|
|
439
|
+
if (cfg.tabs.length <= 1) return cfg
|
|
440
|
+
return { ...cfg, tabs: cfg.tabs.filter((_, i) => i !== index) }
|
|
441
|
+
}
|
|
442
|
+
/** Rename a tab by index. */
|
|
443
|
+
export function renameTab(cfg: TabsConfig, index: number, label: string): TabsConfig {
|
|
444
|
+
return { ...cfg, tabs: cfg.tabs.map((t, i) => (i === index ? { ...t, label } : t)) }
|
|
445
|
+
}
|
|
446
|
+
/** Add a child block (default config for `kind`) to a tab. Only `TAB_CHILD_KINDS` are allowed. */
|
|
447
|
+
export function addTabBlock(cfg: TabsConfig, index: number, kind: BlockKind, entity: EntitySchema): TabsConfig {
|
|
448
|
+
if (!TAB_CHILD_KINDS.includes(kind)) return cfg
|
|
449
|
+
const taken = new Set<string>()
|
|
450
|
+
cfg.tabs.forEach((t) => flattenBlocks(t.blocks).forEach((b) => taken.add(b.id)))
|
|
451
|
+
const block: Block = { id: uid(`${kind}-`, taken), span: DEFAULT_SPAN[kind], config: defaultBlockConfig(kind, entity) }
|
|
452
|
+
return { ...cfg, tabs: cfg.tabs.map((t, i) => (i === index ? { ...t, blocks: [...t.blocks, block] } : t)) }
|
|
453
|
+
}
|
|
454
|
+
/** Remove a child block from a tab by id. */
|
|
455
|
+
export function removeTabBlock(cfg: TabsConfig, index: number, blockId: string): TabsConfig {
|
|
456
|
+
return { ...cfg, tabs: cfg.tabs.map((t, i) => (i === index ? { ...t, blocks: t.blocks.filter((b) => b.id !== blockId) } : t)) }
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** A default screen for an entity: a grid (editing via a popup form by default). */
|
|
460
|
+
export function defaultScreenFor(entity: EntitySchema): Screen {
|
|
461
|
+
const grid = makeBlock('grid', entity, new Set<string>())
|
|
462
|
+
return { id: entity.name, entity: entity.name, title: entity.label ?? entity.name, route: entity.name, blocks: [grid] }
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** A new project from a set of entities: one default screen each, in-memory. */
|
|
466
|
+
export function createProject(entities: EntitySchema[], opts: { title?: string; dataSource?: DataSourceKind } = {}): StudioProject {
|
|
467
|
+
return {
|
|
468
|
+
title: opts.title ?? 'My Studio App',
|
|
469
|
+
entities: [...entities],
|
|
470
|
+
screens: entities.map(defaultScreenFor),
|
|
471
|
+
dataSource: opts.dataSource ?? 'memory',
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// --- immutable ops ---------------------------------------------------------
|
|
476
|
+
|
|
477
|
+
function mapScreen(project: StudioProject, screenId: string, fn: (s: Screen) => Screen): StudioProject {
|
|
478
|
+
return { ...project, screens: project.screens.map((s) => (s.id === screenId ? fn(s) : s)) }
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
export function addBlock(project: StudioProject, screenId: string, kind: BlockKind): StudioProject {
|
|
482
|
+
return mapScreen(project, screenId, (s) => {
|
|
483
|
+
const entity = entityOf(project, s.entity)
|
|
484
|
+
if (!entity) return s
|
|
485
|
+
const taken = new Set(s.blocks.map((b) => b.id))
|
|
486
|
+
return { ...s, blocks: [...s.blocks, makeBlock(kind, entity, taken)] }
|
|
487
|
+
})
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Insert a new block at `index` (drag-drop from the palette). Clamps to range. */
|
|
491
|
+
export function addBlockAt(project: StudioProject, screenId: string, kind: BlockKind, index: number): StudioProject {
|
|
492
|
+
return mapScreen(project, screenId, (s) => {
|
|
493
|
+
const entity = entityOf(project, s.entity)
|
|
494
|
+
if (!entity) return s
|
|
495
|
+
const taken = new Set(s.blocks.map((b) => b.id))
|
|
496
|
+
const block = makeBlock(kind, entity, taken)
|
|
497
|
+
const blocks = [...s.blocks]
|
|
498
|
+
blocks.splice(Math.max(0, Math.min(index, blocks.length)), 0, block)
|
|
499
|
+
return { ...s, blocks }
|
|
500
|
+
})
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** Apply `fn` to the block with `id` anywhere in the tree (top level or nested in a Tabs container). */
|
|
504
|
+
function mapBlockTree(blocks: Block[], id: string, fn: (b: Block) => Block): Block[] {
|
|
505
|
+
return blocks.map((b) => {
|
|
506
|
+
if (b.id === id) return fn(b)
|
|
507
|
+
if (b.config.kind === 'tabs') {
|
|
508
|
+
return { ...b, config: { ...b.config, tabs: b.config.tabs.map((t) => ({ ...t, blocks: mapBlockTree(t.blocks, id, fn) })) } }
|
|
509
|
+
}
|
|
510
|
+
return b
|
|
511
|
+
})
|
|
512
|
+
}
|
|
513
|
+
/** Remove the block with `id` anywhere in the tree (top level or nested in a Tabs container). */
|
|
514
|
+
function removeBlockTree(blocks: Block[], id: string): Block[] {
|
|
515
|
+
return blocks
|
|
516
|
+
.filter((b) => b.id !== id)
|
|
517
|
+
.map((b) => (b.config.kind === 'tabs' ? { ...b, config: { ...b.config, tabs: b.config.tabs.map((t) => ({ ...t, blocks: removeBlockTree(t.blocks, id) })) } } : b))
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export function removeBlock(project: StudioProject, screenId: string, blockId: string): StudioProject {
|
|
521
|
+
return mapScreen(project, screenId, (s) => ({ ...s, blocks: removeBlockTree(s.blocks, blockId) }))
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** Clone a block (config + width/height) with a fresh id, inserted right after it. */
|
|
525
|
+
export function duplicateBlock(project: StudioProject, screenId: string, blockId: string): StudioProject {
|
|
526
|
+
return mapScreen(project, screenId, (s) => {
|
|
527
|
+
const idx = s.blocks.findIndex((b) => b.id === blockId)
|
|
528
|
+
if (idx < 0) return s
|
|
529
|
+
const src = s.blocks[idx]!
|
|
530
|
+
// JSON clone (not structuredClone): the config is always JSON-safe, and this
|
|
531
|
+
// also unwraps any Svelte reactive $state proxy, which structuredClone rejects.
|
|
532
|
+
const clone: Block = { ...src, id: uid(`${src.config.kind}-`, new Set(s.blocks.map((b) => b.id))), config: JSON.parse(JSON.stringify(src.config)) as BlockConfig }
|
|
533
|
+
return { ...s, blocks: [...s.blocks.slice(0, idx + 1), clone, ...s.blocks.slice(idx + 1)] }
|
|
534
|
+
})
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** Move a block one slot up (`-1`) or down (`+1`). No-op at the ends. */
|
|
538
|
+
export function moveBlock(project: StudioProject, screenId: string, blockId: string, dir: -1 | 1): StudioProject {
|
|
539
|
+
return mapScreen(project, screenId, (s) => {
|
|
540
|
+
const i = s.blocks.findIndex((b) => b.id === blockId)
|
|
541
|
+
const j = i + dir
|
|
542
|
+
if (i < 0 || j < 0 || j >= s.blocks.length) return s
|
|
543
|
+
const blocks = [...s.blocks]
|
|
544
|
+
;[blocks[i], blocks[j]] = [blocks[j]!, blocks[i]!]
|
|
545
|
+
return { ...s, blocks }
|
|
546
|
+
})
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** Reorder a block to an explicit index (drag-drop). Clamps to range. */
|
|
550
|
+
export function reorderBlock(project: StudioProject, screenId: string, blockId: string, toIndex: number): StudioProject {
|
|
551
|
+
return mapScreen(project, screenId, (s) => {
|
|
552
|
+
const from = s.blocks.findIndex((b) => b.id === blockId)
|
|
553
|
+
if (from < 0) return s
|
|
554
|
+
const blocks = [...s.blocks]
|
|
555
|
+
const [moved] = blocks.splice(from, 1)
|
|
556
|
+
blocks.splice(Math.max(0, Math.min(toIndex, blocks.length)), 0, moved!)
|
|
557
|
+
return { ...s, blocks }
|
|
558
|
+
})
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** Patch a block's config (merged) and/or span. */
|
|
562
|
+
export function updateBlock(
|
|
563
|
+
project: StudioProject,
|
|
564
|
+
screenId: string,
|
|
565
|
+
blockId: string,
|
|
566
|
+
patch: { span?: 1 | 2 | 3; colSpan?: number; height?: number; config?: Partial<BlockConfig> },
|
|
567
|
+
): StudioProject {
|
|
568
|
+
return mapScreen(project, screenId, (s) => ({
|
|
569
|
+
...s,
|
|
570
|
+
blocks: mapBlockTree(s.blocks, blockId, (b) => ({
|
|
571
|
+
...b,
|
|
572
|
+
span: patch.span ?? b.span,
|
|
573
|
+
colSpan: patch.colSpan ?? b.colSpan,
|
|
574
|
+
height: patch.height ?? b.height,
|
|
575
|
+
config: patch.config ? ({ ...b.config, ...patch.config } as BlockConfig) : b.config,
|
|
576
|
+
})),
|
|
577
|
+
}))
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** Replace an entity's schema (e.g. after field edits), keeping its screens. */
|
|
581
|
+
export function updateEntity(project: StudioProject, name: string, schema: EntitySchema): StudioProject {
|
|
582
|
+
const entities = project.entities.map((e) => (e.name === name ? schema : e))
|
|
583
|
+
if (name === schema.name) return { ...project, entities }
|
|
584
|
+
// Renamed: retarget its screens AND any master-detail block pointing at it.
|
|
585
|
+
const screens = project.screens.map((s) => {
|
|
586
|
+
const blocks = s.blocks.map((b) =>
|
|
587
|
+
b.config.kind === 'master-detail' && b.config.childEntity === name
|
|
588
|
+
? { ...b, config: { ...b.config, childEntity: schema.name } }
|
|
589
|
+
: b,
|
|
590
|
+
)
|
|
591
|
+
return { ...s, entity: s.entity === name ? schema.name : s.entity, blocks }
|
|
592
|
+
})
|
|
593
|
+
return { ...project, entities, screens }
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** Add an entity + its default screen. */
|
|
597
|
+
export function addEntity(project: StudioProject, schema: EntitySchema): StudioProject {
|
|
598
|
+
if (entityOf(project, schema.name)) return project
|
|
599
|
+
return { ...project, entities: [...project.entities, schema], screens: [...project.screens, defaultScreenFor(schema)] }
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** Remove an entity and every screen bound to it. */
|
|
603
|
+
export function removeEntity(project: StudioProject, name: string): StudioProject {
|
|
604
|
+
return {
|
|
605
|
+
...project,
|
|
606
|
+
entities: project.entities.filter((e) => e.name !== name),
|
|
607
|
+
screens: project.screens.filter((s) => s.entity !== name),
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** Append a screen, making its id AND route unique against the existing set. */
|
|
612
|
+
function appendScreen(project: StudioProject, base: Screen, keyBase: string): StudioProject {
|
|
613
|
+
const ids = new Set(project.screens.map((s) => s.id))
|
|
614
|
+
const routes = new Set(project.screens.map((s) => s.route))
|
|
615
|
+
const id = ids.has(base.id) ? uid(`${keyBase}-`, ids) : base.id
|
|
616
|
+
const route = routes.has(base.route) ? uid(`${base.route}-`, routes) : base.route
|
|
617
|
+
return { ...project, screens: [...project.screens, { ...base, id, route }] }
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
export function addScreen(project: StudioProject, entity: string): StudioProject {
|
|
621
|
+
const schema = entityOf(project, entity)
|
|
622
|
+
if (!schema) return project
|
|
623
|
+
return appendScreen(project, defaultScreenFor(schema), entity)
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
export function removeScreen(project: StudioProject, screenId: string): StudioProject {
|
|
627
|
+
return { ...project, screens: project.screens.filter((s) => s.id !== screenId) }
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
export function updateScreen(project: StudioProject, screenId: string, patch: Partial<Pick<Screen, 'title' | 'route' | 'entity' | 'nav'>>): StudioProject {
|
|
631
|
+
return mapScreen(project, screenId, (s) => ({ ...s, ...patch }))
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** Deep-clone `block` with fresh ids (recursing into Tabs children). */
|
|
635
|
+
function freshBlockIds(blocks: ReadonlyArray<Block>, taken: Set<string>): Block[] {
|
|
636
|
+
return blocks.map((b) => {
|
|
637
|
+
const config = JSON.parse(JSON.stringify(b.config)) as BlockConfig
|
|
638
|
+
const id = uid(`${config.kind}-`, taken)
|
|
639
|
+
taken.add(id)
|
|
640
|
+
if (config.kind === 'tabs') config.tabs = config.tabs.map((t) => ({ ...t, blocks: freshBlockIds(t.blocks, taken) }))
|
|
641
|
+
return { ...b, id, config }
|
|
642
|
+
})
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** Insert a block (deep-cloned, fresh id) into a screen - the paste target. Appends by default. */
|
|
646
|
+
export function insertBlock(project: StudioProject, screenId: string, block: Block, index?: number): StudioProject {
|
|
647
|
+
return mapScreen(project, screenId, (s) => {
|
|
648
|
+
const taken = new Set(flattenBlocks(s.blocks).map((b) => b.id))
|
|
649
|
+
const [clone] = freshBlockIds([block], taken)
|
|
650
|
+
const blocks = [...s.blocks]
|
|
651
|
+
blocks.splice(index ?? blocks.length, 0, clone!)
|
|
652
|
+
return { ...s, blocks }
|
|
653
|
+
})
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** Duplicate a whole screen (fresh id / route / title + fresh block ids), inserted after it. */
|
|
657
|
+
export function duplicateScreen(project: StudioProject, screenId: string): StudioProject {
|
|
658
|
+
const idx = project.screens.findIndex((s) => s.id === screenId)
|
|
659
|
+
if (idx < 0) return project
|
|
660
|
+
const src = project.screens[idx]!
|
|
661
|
+
const ids = new Set(project.screens.map((s) => s.id))
|
|
662
|
+
const routes = new Set(project.screens.map((s) => s.route))
|
|
663
|
+
const id = uid(`${src.entity}-`, ids)
|
|
664
|
+
const route = uid(`${src.route}-`, routes)
|
|
665
|
+
// Seed the taken-ids set with the source's block ids so the clone's ids don't collide.
|
|
666
|
+
const clone: Screen = { ...src, id, route, title: `${src.title} copy`, blocks: freshBlockIds(src.blocks, new Set(flattenBlocks(src.blocks).map((b) => b.id))) }
|
|
667
|
+
const screens = [...project.screens]
|
|
668
|
+
screens.splice(idx + 1, 0, clone)
|
|
669
|
+
return { ...project, screens }
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/** Move a screen to an explicit index (drag-reorder the tab strip). Clamps to range. */
|
|
673
|
+
export function reorderScreen(project: StudioProject, screenId: string, toIndex: number): StudioProject {
|
|
674
|
+
const from = project.screens.findIndex((s) => s.id === screenId)
|
|
675
|
+
if (from < 0) return project
|
|
676
|
+
const screens = [...project.screens]
|
|
677
|
+
const [moved] = screens.splice(from, 1)
|
|
678
|
+
screens.splice(Math.max(0, Math.min(toIndex, screens.length)), 0, moved!)
|
|
679
|
+
return { ...project, screens }
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
export function setDataSource(project: StudioProject, dataSource: DataSourceKind): StudioProject {
|
|
683
|
+
return { ...project, dataSource }
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** Set the deploy target (SvelteKit adapter + provider config the bundle emits). */
|
|
687
|
+
export function setDeployTarget(project: StudioProject, deploy: DeployTarget): StudioProject {
|
|
688
|
+
if (deploy === 'auto') { const { deploy: _drop, ...rest } = project; return rest }
|
|
689
|
+
return { ...project, deploy }
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** A skeleton binding for a kind, seeded with the entity's name as its table/path. */
|
|
693
|
+
export function defaultEntitySource(kind: DataSourceKind, entityName: string): EntityDataSource {
|
|
694
|
+
switch (kind) {
|
|
695
|
+
case 'rest': return { kind: 'rest', baseUrl: '', path: entityName, method: 'GET', params: [] }
|
|
696
|
+
case 'sql': return { kind: 'sql', table: entityName }
|
|
697
|
+
case 'supabase': return { kind: 'supabase', table: entityName }
|
|
698
|
+
case 'pglite': return { kind: 'pglite', table: entityName }
|
|
699
|
+
default: return { kind: 'memory' }
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** Bind one entity to a data source (REST / SQL / Supabase / in-memory). */
|
|
704
|
+
export function setEntityDataSource(project: StudioProject, entityName: string, source: EntityDataSource): StudioProject {
|
|
705
|
+
return { ...project, dataSources: { ...project.dataSources, [entityName]: source } }
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/** The resolved source for an entity (its explicit binding, else in-memory). */
|
|
709
|
+
export function entityDataSource(project: StudioProject, entityName: string): EntityDataSource {
|
|
710
|
+
return project.dataSources?.[entityName] ?? { kind: 'memory' }
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
export function setTheme(project: StudioProject, theme: ProjectTheme): StudioProject {
|
|
714
|
+
return { ...project, theme: { ...project.theme, ...theme } }
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/** Apply a Studio theme preset: sets the preset id and clears any manual accent
|
|
718
|
+
* override so the preset's own accent takes effect (the user can re-tune it
|
|
719
|
+
* afterwards with the color picker). Pass `accent` to pin one explicitly. */
|
|
720
|
+
export function setThemePreset(project: StudioProject, preset: string, accent?: string): StudioProject {
|
|
721
|
+
const theme = { ...project.theme, preset }
|
|
722
|
+
if (accent) theme.accent = accent
|
|
723
|
+
else delete theme.accent
|
|
724
|
+
return { ...project, theme }
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/** Configure the generated app shell (layout style, brand, footer, nav position). */
|
|
728
|
+
export function setShell(project: StudioProject, shell: ShellConfig): StudioProject {
|
|
729
|
+
return { ...project, theme: { ...project.theme, shell: { ...project.theme?.shell, ...shell } } }
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// --- screen templates ------------------------------------------------------
|
|
733
|
+
|
|
734
|
+
export type ScreenTemplate = 'crud' | 'dashboard' | 'master-detail' | 'empty'
|
|
735
|
+
|
|
736
|
+
/** Build a screen for an entity from a template (a preset arrangement of blocks). */
|
|
737
|
+
export function screenFromTemplate(
|
|
738
|
+
entity: EntitySchema,
|
|
739
|
+
template: ScreenTemplate,
|
|
740
|
+
opts: { child?: EntitySchema; foreignKey?: string } = {},
|
|
741
|
+
): Screen {
|
|
742
|
+
const taken = new Set<string>()
|
|
743
|
+
const make = (kind: BlockKind, span?: 1 | 2 | 3, config?: BlockConfig): Block => {
|
|
744
|
+
const b = makeBlock(kind, entity, taken)
|
|
745
|
+
taken.add(b.id)
|
|
746
|
+
if (span) b.span = span
|
|
747
|
+
if (config) b.config = config
|
|
748
|
+
return b
|
|
749
|
+
}
|
|
750
|
+
const measure = pickMeasure(entity)
|
|
751
|
+
const label = entity.label ?? entity.name
|
|
752
|
+
|
|
753
|
+
let blocks: Block[]
|
|
754
|
+
if (template === 'empty') {
|
|
755
|
+
blocks = []
|
|
756
|
+
} else if (template === 'dashboard') {
|
|
757
|
+
const kpis: Block[] = [make('kpi', 1, { kind: 'kpi', label: `Total ${label}`, reduce: 'count' })]
|
|
758
|
+
if (measure) kpis.push(make('kpi', 1, { kind: 'kpi', label: `Total ${measure}`, measure, reduce: 'sum' }))
|
|
759
|
+
blocks = [...kpis, make('chart', 2), make('grid', 3)]
|
|
760
|
+
} else if (template === 'master-detail') {
|
|
761
|
+
const md = make('master-detail', 3, {
|
|
762
|
+
kind: 'master-detail',
|
|
763
|
+
childEntity: opts.child?.name ?? '',
|
|
764
|
+
foreignKey: opts.foreignKey ?? opts.child?.fields.find((f) => f.type === 'relation' && f.relation?.entity === entity.name)?.field ?? '',
|
|
765
|
+
})
|
|
766
|
+
blocks = [make('grid', 3), md]
|
|
767
|
+
} else {
|
|
768
|
+
blocks = [make('grid', 3)] // crud (the grid edits via a popup form)
|
|
769
|
+
}
|
|
770
|
+
return { id: entity.name, entity: entity.name, title: label, route: entity.name, blocks }
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/** Add a screen built from a template. `child` (for master-detail) is an entity name. */
|
|
774
|
+
export function addScreenFromTemplate(
|
|
775
|
+
project: StudioProject,
|
|
776
|
+
entityName: string,
|
|
777
|
+
template: ScreenTemplate,
|
|
778
|
+
opts: { child?: string; foreignKey?: string } = {},
|
|
779
|
+
): StudioProject {
|
|
780
|
+
const entity = entityOf(project, entityName)
|
|
781
|
+
if (!entity) return project
|
|
782
|
+
const base = screenFromTemplate(entity, template, {
|
|
783
|
+
child: opts.child ? entityOf(project, opts.child) : undefined,
|
|
784
|
+
foreignKey: opts.foreignKey,
|
|
785
|
+
})
|
|
786
|
+
return appendScreen(project, base, entityName)
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// --- persistence: round-trippable studio.config -----------------------------
|
|
790
|
+
|
|
791
|
+
/** `name`, or the first free `${name}_${n}` if taken. Records the result. */
|
|
792
|
+
function makeUnique(name: string, taken: Set<string>): string {
|
|
793
|
+
if (!taken.has(name)) { taken.add(name); return name }
|
|
794
|
+
let i = 2
|
|
795
|
+
while (taken.has(`${name}_${i}`)) i++
|
|
796
|
+
const u = `${name}_${i}`
|
|
797
|
+
taken.add(u)
|
|
798
|
+
return u
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Enforce the structural invariants the designer + codegen rely on: unique
|
|
803
|
+
* entity names, unique field names within an entity, unique screen ids + routes,
|
|
804
|
+
* and unique block ids within a screen. In-app ops already keep these, but a
|
|
805
|
+
* hand-edited / externally-produced `studio.config.json` may not - and a
|
|
806
|
+
* duplicate key would crash a keyed `{#each}` list at render time. Idempotent:
|
|
807
|
+
* a project that already satisfies the invariants is returned value-equal.
|
|
808
|
+
*/
|
|
809
|
+
export function sanitizeProject(project: StudioProject): StudioProject {
|
|
810
|
+
const entityNames = new Set<string>()
|
|
811
|
+
const entities: EntitySchema[] = []
|
|
812
|
+
for (const e of project.entities) {
|
|
813
|
+
if (entityNames.has(e.name)) continue // drop a duplicate-named entity; screens still resolve to the survivor
|
|
814
|
+
entityNames.add(e.name)
|
|
815
|
+
const fieldNames = new Set<string>()
|
|
816
|
+
entities.push({ ...e, fields: e.fields.map((f) => ({ ...f, field: makeUnique(f.field, fieldNames) })) })
|
|
817
|
+
}
|
|
818
|
+
const ids = new Set<string>()
|
|
819
|
+
const routes = new Set<string>()
|
|
820
|
+
const screens = project.screens.map((s) => {
|
|
821
|
+
const blockIds = new Set<string>()
|
|
822
|
+
return {
|
|
823
|
+
...s,
|
|
824
|
+
id: makeUnique(s.id, ids),
|
|
825
|
+
route: makeUnique(s.route, routes),
|
|
826
|
+
blocks: s.blocks.map((b) => ({ ...b, id: makeUnique(b.id, blockIds) })),
|
|
827
|
+
}
|
|
828
|
+
})
|
|
829
|
+
const result: StudioProject = { ...project, entities, screens }
|
|
830
|
+
// Drop per-entity source bindings for entities that no longer exist.
|
|
831
|
+
if (project.dataSources) {
|
|
832
|
+
const pruned = Object.fromEntries(Object.entries(project.dataSources).filter(([n]) => entityNames.has(n)))
|
|
833
|
+
if (Object.keys(pruned).length) result.dataSources = pruned
|
|
834
|
+
else delete result.dataSources
|
|
835
|
+
}
|
|
836
|
+
return result
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/** Serialize a project to a `studio.config.json` string (the persisted design). */
|
|
840
|
+
export function serializeProject(project: StudioProject): string {
|
|
841
|
+
return JSON.stringify(project, null, 2)
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* Parse a `studio.config.json` string back into a `StudioProject`, validating
|
|
846
|
+
* the shape. Note: only the data model round-trips - any runtime functions on an
|
|
847
|
+
* entity (`computed` / `validate` / `hooks`) are not part of the config.
|
|
848
|
+
*/
|
|
849
|
+
export function parseProject(json: string): StudioProject {
|
|
850
|
+
let raw: unknown
|
|
851
|
+
try {
|
|
852
|
+
raw = JSON.parse(json)
|
|
853
|
+
} catch {
|
|
854
|
+
throw new Error('parseProject: not valid JSON')
|
|
855
|
+
}
|
|
856
|
+
if (!raw || typeof raw !== 'object') throw new Error('parseProject: expected a project object')
|
|
857
|
+
const p = raw as Partial<StudioProject>
|
|
858
|
+
// An empty `entities` array is a valid work-in-progress (a "start from zero"
|
|
859
|
+
// project). Codegen is gated separately by validateProject / isProjectValid.
|
|
860
|
+
if (!Array.isArray(p.entities)) throw new Error('parseProject: missing "entities"')
|
|
861
|
+
if (!Array.isArray(p.screens)) throw new Error('parseProject: missing "screens"')
|
|
862
|
+
return sanitizeProject({
|
|
863
|
+
title: typeof p.title === 'string' ? p.title : 'My Studio App',
|
|
864
|
+
entities: p.entities as EntitySchema[],
|
|
865
|
+
screens: p.screens as Screen[],
|
|
866
|
+
dataSource: (p.dataSource ?? 'memory') as DataSourceKind,
|
|
867
|
+
...(p.dataSources && typeof p.dataSources === 'object' ? { dataSources: p.dataSources as Record<string, EntityDataSource> } : {}),
|
|
868
|
+
...(p.theme && typeof p.theme === 'object' ? { theme: p.theme as ProjectTheme } : {}),
|
|
869
|
+
...(p.access && typeof p.access === 'object' ? { access: p.access as AccessControl } : {}),
|
|
870
|
+
...(typeof p.audit === 'boolean' ? { audit: p.audit } : {}),
|
|
871
|
+
...(p.i18n && typeof p.i18n === 'object' ? { i18n: p.i18n as I18nConfig } : {}),
|
|
872
|
+
...(typeof p.deploy === 'string' && p.deploy !== 'auto' ? { deploy: p.deploy as DeployTarget } : {}),
|
|
873
|
+
})
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/** Validate a project. Errors block codegen; warnings are advisory. */
|
|
877
|
+
export function validateProject(project: StudioProject): ProjectIssue[] {
|
|
878
|
+
const issues: ProjectIssue[] = []
|
|
879
|
+
if (project.entities.length === 0) issues.push({ level: 'error', message: 'Add at least one entity.' })
|
|
880
|
+
if (project.screens.length === 0) issues.push({ level: 'warning', message: 'No screens yet.' })
|
|
881
|
+
|
|
882
|
+
const routes = new Set<string>()
|
|
883
|
+
for (const s of project.screens) {
|
|
884
|
+
if (!entityOf(project, s.entity)) {
|
|
885
|
+
issues.push({ level: 'error', message: `Screen "${s.title}" points at a missing entity "${s.entity}".`, screen: s.id })
|
|
886
|
+
}
|
|
887
|
+
if (routes.has(s.route)) issues.push({ level: 'error', message: `Duplicate route "/${s.route}".`, screen: s.id })
|
|
888
|
+
routes.add(s.route)
|
|
889
|
+
if (s.blocks.length === 0) issues.push({ level: 'warning', message: `Screen "${s.title}" has no blocks.`, screen: s.id })
|
|
890
|
+
for (const b of flattenBlocks(s.blocks)) {
|
|
891
|
+
const at = (message: string, level: ProjectIssueLevel = 'warning'): ProjectIssue => ({ level, message, screen: s.id, block: b.id })
|
|
892
|
+
const c = b.config
|
|
893
|
+
if (c.kind === 'master-detail') {
|
|
894
|
+
if (!c.childEntity || !c.foreignKey) issues.push(at('Master/detail needs a child entity + foreign key.'))
|
|
895
|
+
else if (!entityOf(project, c.childEntity)) issues.push(at(`Master/detail points at a missing child entity "${c.childEntity}".`))
|
|
896
|
+
} else if (c.kind === 'tree') {
|
|
897
|
+
if (!c.labelField || !c.parentField) issues.push(at('Tree needs a label field and a self-referential parent field.'))
|
|
898
|
+
} else if (c.kind === 'lookup') {
|
|
899
|
+
if (!c.field) issues.push(at('Lookup needs a relation field.'))
|
|
900
|
+
} else if (c.kind === 'filter') {
|
|
901
|
+
if (!c.fields.length) issues.push(at('Filter panel has no facets - pick fields to filter on.'))
|
|
902
|
+
} else if (c.kind === 'chart') {
|
|
903
|
+
if (!c.dimension) issues.push(at('Chart has no group-by dimension.'))
|
|
904
|
+
} else if (c.kind === 'pivot') {
|
|
905
|
+
if (!c.rows.length && !c.cols.length) issues.push(at('Pivot has no row or column dimensions.'))
|
|
906
|
+
} else if (c.kind === 'tabs') {
|
|
907
|
+
if (c.tabs.every((t) => t.blocks.length === 0)) issues.push(at('Tabs container has no blocks in any tab.'))
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
return issues
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
export function isProjectValid(project: StudioProject): boolean {
|
|
915
|
+
return !validateProject(project).some((i) => i.level === 'error')
|
|
916
|
+
}
|