@svgrid/enterprise 2.0.4 → 2.2.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 +18 -6
- package/dist/cdn/svgrid-enterprise.svelte-external.js +14035 -6842
- package/dist/node/studio.js +7888 -2459
- package/package.json +9 -4
- package/src/SvGridMasterDetail.svelte +24 -3
- package/src/SvGridScheduler.svelte +4410 -0
- package/src/SvPivotDesigner.svelte +1990 -1045
- package/src/SvSchemaChart.svelte +10 -9
- package/src/ai.test.ts +522 -522
- package/src/ai.ts +202 -2
- package/src/export.ts +7 -1
- package/src/index.ts +409 -384
- package/src/install.ts +10 -0
- package/src/pivot-chart.test.ts +86 -0
- package/src/pivot-chart.ts +112 -0
- package/src/scheduler.ts +37 -0
- package/src/scheduling.test.ts +194 -0
- package/src/scheduling.ts +293 -0
- package/src/sources/filters.ts +6 -0
- package/src/studio/HANDLERS-DESIGN.md +142 -0
- package/src/studio/cli.ts +7 -2
- package/src/studio/emit-project.test.ts +1447 -13
- package/src/studio/emit-project.ts +3995 -1273
- package/src/studio/emit-schema.ts +146 -29
- package/src/studio/index.ts +320 -195
- package/src/studio/project.test.ts +370 -0
- package/src/studio/project.ts +1146 -26
- package/src/studio/sample-data.ts +4 -1
- package/src/studio/samples/ats.ts +2 -2
- package/src/studio/samples/clinic.ts +4 -2
- package/src/studio/samples/crm.ts +16 -8
- package/src/studio/samples/events.ts +4 -2
- package/src/studio/samples/fleet.ts +4 -2
- package/src/studio/samples/gym.ts +4 -2
- package/src/studio/samples/hr.ts +3 -1
- package/src/studio/samples/live-data.ts +308 -308
- package/src/studio/samples/projects.ts +2 -2
- package/src/studio/samples/restaurant.ts +4 -2
- package/src/studio/samples/samples.test.ts +13 -5
- package/src/studio/samples/shared.ts +346 -305
- package/src/studio/samples/support.ts +3 -1
- package/src/studio/scaffold.test.ts +15 -1
- package/src/studio/scaffold.ts +16 -0
- package/src/studio/themes.ts +7 -0
- package/src/studio/ui-components.ts +472 -0
- package/src/sveltekit/transport.test.ts +26 -0
- package/src/sveltekit/transport.ts +50 -5
- package/dist/designer/assets/index-Dp44bTid.js +0 -939
- package/dist/designer/assets/index-RJp6x8tw.css +0 -1
- package/dist/designer/assets/jszip.min-CjMo-QGg.js +0 -2
- package/dist/designer/index.html +0 -13
package/src/studio/project.ts
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* module resolvable under node16.
|
|
12
12
|
*/
|
|
13
13
|
import type { EntityField, EntityFieldType, EntitySchema } from '../schema.js'
|
|
14
|
-
import type { ChartType } from '@svgrid/grid'
|
|
14
|
+
import type { ChartType, DockManagerState, DockNode, DockPane, DockTabs } from '@svgrid/grid'
|
|
15
|
+
import { uiComponentSpec } from './ui-components.js'
|
|
15
16
|
|
|
16
17
|
export type Reduce = 'sum' | 'avg' | 'count' | 'min' | 'max'
|
|
17
18
|
export type DataSourceKind = 'memory' | 'sql' | 'supabase' | 'rest' | 'pglite'
|
|
@@ -56,11 +57,29 @@ export type PgliteSource = { kind: 'pglite'; table: string; seed?: Record<string
|
|
|
56
57
|
/** Where one entity's rows come from. */
|
|
57
58
|
export type EntityDataSource = MemorySource | RestSource | SqlSource | SupabaseSource | PgliteSource
|
|
58
59
|
|
|
59
|
-
/** The kinds of
|
|
60
|
-
|
|
60
|
+
/** The kinds of block a screen can hold: data-bound (entity-derived) or the
|
|
61
|
+
* entity-agnostic `'component'` (a UI-kit component from `UI_COMPONENT_REGISTRY`,
|
|
62
|
+
* usable on any screen, including a freestanding one with no entity). */
|
|
63
|
+
export type BlockKind = 'grid' | 'form' | 'chart' | 'dashboard' | 'kpi' | 'gauge' | 'tree' | 'tabs' | 'accordion' | 'master-detail' | 'lookup' | 'pivot' | 'filter' | 'record' | 'board' | 'calendar' | 'detail' | 'component'
|
|
61
64
|
|
|
62
65
|
export type GridAlign = 'left' | 'center' | 'right'
|
|
63
|
-
|
|
66
|
+
/** No-code per-column value formatting. Compiles to the grid's `format` (CellFormatConfig):
|
|
67
|
+
* a raw `1234.5` becomes `$1,234.50` / `42%` / `Jun 27, 2026` without a code renderer. */
|
|
68
|
+
export type ColumnFormat =
|
|
69
|
+
| { type: 'number'; decimals?: number }
|
|
70
|
+
| { type: 'currency'; currency?: string }
|
|
71
|
+
| { type: 'percent'; decimals?: number; /** cell values are 0-100 (42 -> 42%) not 0-1. */ valueIsPercentPoints?: boolean }
|
|
72
|
+
| { type: 'date'; pattern?: string }
|
|
73
|
+
| { type: 'datetime'; pattern?: string }
|
|
74
|
+
/** No-code rich cell renderer for a column. Unlike `ColumnFormat` (value formatting),
|
|
75
|
+
* these render a component/link: an auto-colored status pill, a progress bar, or a
|
|
76
|
+
* clickable link. Compiles to a `cell` snippet on the column. Mutually exclusive with
|
|
77
|
+
* `format` (a cell is either formatted text or a rich renderer). */
|
|
78
|
+
export type ColumnCellType =
|
|
79
|
+
| { kind: 'badge' }
|
|
80
|
+
| { kind: 'progress'; max?: number }
|
|
81
|
+
| { kind: 'link'; as?: 'url' | 'email' | 'tel' }
|
|
82
|
+
export type GridColumnConfig = { field: string; show: boolean; header?: string; width?: number; align?: GridAlign; pin?: 'left' | 'right'; /** Aggregate this column into the group summary row when the grid is grouped. */ aggregate?: Reduce; /** No-code display formatting (currency / percent / date / ...). */ format?: ColumnFormat; /** No-code rich cell renderer (badge / progress / link). */ cellType?: ColumnCellType }
|
|
64
83
|
/** How a grid edits its rows: read-only, inline (Excel-style cells), or a popup form. */
|
|
65
84
|
export type GridEditing = 'none' | 'inline' | 'form'
|
|
66
85
|
/** Row height preset. */
|
|
@@ -74,6 +93,10 @@ export type GridConfig = {
|
|
|
74
93
|
selectable: boolean
|
|
75
94
|
sortable: boolean
|
|
76
95
|
filterable: boolean
|
|
96
|
+
/** Which filtering surfaces show when `filterable` is on. Undefined = the global search
|
|
97
|
+
* box only (back-compat). Combine a global search, a per-column filter row, and/or the
|
|
98
|
+
* column header filter menu. */
|
|
99
|
+
filterUi?: GridFilterUi
|
|
77
100
|
editing: GridEditing
|
|
78
101
|
/** Presentation of the edit form when `editing === 'form'`. */
|
|
79
102
|
formPresentation: Presentation
|
|
@@ -97,7 +120,59 @@ export type GridConfig = {
|
|
|
97
120
|
/** No-code conditional formatting: color / bold a cell by its value. Compiled to
|
|
98
121
|
* the grid's `conditionalFormats` rule engine. */
|
|
99
122
|
formatRules?: FormatRule[]
|
|
123
|
+
/** No-code row grouping: fields to group rows by (outermost first). When set, the
|
|
124
|
+
* grid loads the full dataset and groups/sorts/paginates client-side (so groups
|
|
125
|
+
* span every row, not just a server page) and shows the grouping controls. Per-column
|
|
126
|
+
* `aggregate` values roll up into each group's summary row. */
|
|
127
|
+
grouping?: string[]
|
|
128
|
+
/** No-code export toolbar: a button bar above the grid wired to the grid's own
|
|
129
|
+
* export API (CSV / JSON / copy-to-clipboard - no extra dependencies). */
|
|
130
|
+
export?: GridExportConfig
|
|
131
|
+
/** No-code tree data: render the grid's own rows as an expand/collapse hierarchy
|
|
132
|
+
* built from a self-referential parent field. Mutually exclusive with `grouping`. */
|
|
133
|
+
treeData?: TreeDataConfig
|
|
134
|
+
/** Render the grid's rows as a calendar / scheduler (a view of the grid, like the
|
|
135
|
+
* Kanban board). Mutually exclusive with grouping / tree. Uses the enterprise
|
|
136
|
+
* scheduler renderer (`enableSchedulerView`). */
|
|
137
|
+
scheduler?: SchedulerViewConfig
|
|
100
138
|
}
|
|
139
|
+
/** Which calendar view the scheduler opens on / offers. */
|
|
140
|
+
export type SchedulerViewMode = 'month' | 'week' | 'day' | 'agenda' | 'timelineDay' | 'timelineWeek' | 'timelineMonth' | 'timelineYear'
|
|
141
|
+
/** No-code scheduler-view config: map the entity's fields onto calendar events. Only
|
|
142
|
+
* `startField` is required; the rest are optional refinements. Compiles to SvGrid's
|
|
143
|
+
* `scheduler` prop + write-back handlers. */
|
|
144
|
+
export type SchedulerViewConfig = {
|
|
145
|
+
/** Field holding the event start (Date / epoch / ISO string). Required. */
|
|
146
|
+
startField: string
|
|
147
|
+
/** Field holding the event end. Omit to use a default duration from the start. */
|
|
148
|
+
endField?: string
|
|
149
|
+
/** Field for the event title. Defaults to the first column. */
|
|
150
|
+
titleField?: string
|
|
151
|
+
/** Field holding a per-event accent color. */
|
|
152
|
+
colorField?: string
|
|
153
|
+
/** Field that groups events into per-resource columns (people / rooms / machines). */
|
|
154
|
+
resourceField?: string
|
|
155
|
+
/** Field holding a recurrence rule (one event per matching day). */
|
|
156
|
+
recurrenceField?: string
|
|
157
|
+
/** Boolean field marking an all-day event. */
|
|
158
|
+
allDayField?: string
|
|
159
|
+
/** The view shown first. Default 'month'. */
|
|
160
|
+
initialView?: SchedulerViewMode
|
|
161
|
+
/** Enable drag-to-move + edge-resize (writes back through the data source). */
|
|
162
|
+
editable?: boolean
|
|
163
|
+
/** Show the built-in event detail drawer. */
|
|
164
|
+
drawer?: boolean
|
|
165
|
+
}
|
|
166
|
+
/** Tree-data grid config: `parentField` is a self-referential FK (a row whose parent is
|
|
167
|
+
* empty / not in the set is a root); `labelField` is the column that shows the indented,
|
|
168
|
+
* expandable tree cell. */
|
|
169
|
+
export type TreeDataConfig = { parentField: string; labelField: string }
|
|
170
|
+
/** Which export affordances the grid toolbar shows. All go through the grid's built-in
|
|
171
|
+
* export API (dependency-free). Empty / all-false = no toolbar. */
|
|
172
|
+
export type GridExportConfig = { csv?: boolean; json?: boolean; copy?: boolean }
|
|
173
|
+
/** Which filtering surfaces the grid shows (when `filterable`). `global` = the search-all
|
|
174
|
+
* box, `row` = a filter input under each header, `menu` = the column header filter menu. */
|
|
175
|
+
export type GridFilterUi = { global?: boolean; row?: boolean; menu?: boolean }
|
|
101
176
|
/** A conditional-formatting comparison. */
|
|
102
177
|
export type FormatOp = 'eq' | 'ne' | 'lt' | 'lte' | 'gt' | 'gte' | 'contains' | 'empty' | 'notEmpty'
|
|
103
178
|
/** One no-code format rule: style a `field`'s cell when the comparison holds. */
|
|
@@ -105,7 +180,7 @@ export type FormatRule = { field: string; op: FormatOp; value?: string | number;
|
|
|
105
180
|
/** A row-click drill-through to another screen. */
|
|
106
181
|
export type RowLink = { screen: string; sourceField?: string; targetField: string }
|
|
107
182
|
/** One per-row action button. */
|
|
108
|
-
export type RowActionKind = 'edit' | 'delete' | 'navigate'
|
|
183
|
+
export type RowActionKind = 'edit' | 'delete' | 'navigate' | 'custom'
|
|
109
184
|
export type RowAction = {
|
|
110
185
|
kind: RowActionKind
|
|
111
186
|
label?: string
|
|
@@ -115,12 +190,27 @@ export type RowAction = {
|
|
|
115
190
|
sourceField?: string
|
|
116
191
|
/** navigate: field on the target entity to filter by. */
|
|
117
192
|
targetField?: string
|
|
193
|
+
/** custom: stable id - becomes the generated `/api/actions/<id>` route + handler
|
|
194
|
+
* name. Required when `kind` is `'custom'`. */
|
|
195
|
+
id?: string
|
|
196
|
+
/** custom: icon glyph (rendered next to the label). */
|
|
197
|
+
icon?: string
|
|
198
|
+
/** custom: confirm before running, e.g. "Approve this order?". */
|
|
199
|
+
confirm?: string
|
|
118
200
|
}
|
|
201
|
+
|
|
202
|
+
/** A custom action: a button wired to a generated stub API route + client
|
|
203
|
+
* handler - the plumbing (fetch, loading state, error handling, RBAC gate) is
|
|
204
|
+
* generated; the developer fills in the actual logic in the stub route.
|
|
205
|
+
* Screen-level actions render in the screen's toolbar and work on ANY screen,
|
|
206
|
+
* including one with no bound entity. Row-level actions (via `RowAction`'s
|
|
207
|
+
* `'custom'` kind) render per-row in a grid, alongside edit/delete/navigate. */
|
|
208
|
+
export type ActionConfig = { id: string; label: string; icon?: string; confirm?: string }
|
|
119
209
|
/** Legacy standalone edit-form block. Editing is now a Grid property; kept so old
|
|
120
210
|
* `studio.config.json` files still parse. Not offered in the palette. */
|
|
121
211
|
export type FormConfig = { kind: 'form'; presentation: Presentation }
|
|
122
212
|
/** 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 }
|
|
213
|
+
export type ChartConfig = { kind: 'chart'; dimension: string; measure?: string; reduce: Reduce; type: ChartType; drillScreen?: string; dataLabels?: boolean; color?: string }
|
|
124
214
|
/** Number format for a KPI value. `auto` keeps the legacy behavior ($ when the
|
|
125
215
|
* measure's label carries `$`, else grouped number). */
|
|
126
216
|
export type KpiFormat = 'auto' | 'number' | 'currency' | 'percent' | 'compact'
|
|
@@ -150,6 +240,12 @@ export type StudioTab = { label: string; blocks: Block[] }
|
|
|
150
240
|
/** A tabbed container (SvTabs) grouping display blocks into tabs. Children are the
|
|
151
241
|
* controller-free, `allRows`-driven blocks (see `TAB_CHILD_KINDS`). */
|
|
152
242
|
export type TabsConfig = { kind: 'tabs'; tabs: StudioTab[] }
|
|
243
|
+
/** One section of an Accordion container: a label + its own ordered child blocks. */
|
|
244
|
+
export type AccordionSection = { label: string; blocks: Block[] }
|
|
245
|
+
/** An accordion container (SvAccordion): collapsible sections, each hosting its own
|
|
246
|
+
* child blocks (same container-child set as Tabs). `multiple` lets several sections
|
|
247
|
+
* stay open at once (default: single-open). */
|
|
248
|
+
export type AccordionConfig = { kind: 'accordion'; sections: AccordionSection[]; multiple?: boolean }
|
|
153
249
|
export type DashboardConfig = { kind: 'dashboard' }
|
|
154
250
|
export type MasterDetailConfig = { kind: 'master-detail'; childEntity: string; foreignKey: string; linkScreen?: string }
|
|
155
251
|
export type LookupConfig = { kind: 'lookup'; field: string }
|
|
@@ -178,13 +274,33 @@ export type DetailRelated = { entity: string; foreignKey: string; label?: string
|
|
|
178
274
|
* timeline of the children pointing back at the record). The universal signature
|
|
179
275
|
* view for relation-heavy entities where a board / calendar does not fit. */
|
|
180
276
|
export type DetailConfig = { kind: 'detail'; titleField: string; subtitleField?: string; statusField?: string; metricFields?: string[]; sections?: { label: string; fields: string[] }[]; related?: DetailRelated[] }
|
|
277
|
+
/** A UI-kit component (from `UI_COMPONENT_REGISTRY`, keyed by `component`) dropped
|
|
278
|
+
* onto a screen. Entity-agnostic - works on a freestanding page or mixed onto an
|
|
279
|
+
* entity-bound screen alike. `props` holds its configured "chrome" values, keyed
|
|
280
|
+
* by the registry entry's prop `key`; a component with `hasContent` also stores
|
|
281
|
+
* its literal text content under the reserved `_content` key. */
|
|
282
|
+
/** A data binding for one component prop (or `_content`): its value is computed
|
|
283
|
+
* from the screen's rows instead of a static literal. `aggregate` reduces a field
|
|
284
|
+
* over all rows (a KPI-style number), `field` reads the first row's field, `expr`
|
|
285
|
+
* is a raw JS expression over `rows`. Needs an entity screen (rows to bind to). */
|
|
286
|
+
export type ComponentBinding =
|
|
287
|
+
| { kind: 'aggregate'; field?: string; reduce: Reduce }
|
|
288
|
+
| { kind: 'field'; field: string }
|
|
289
|
+
| { kind: 'expr'; code: string }
|
|
290
|
+
export type ComponentConfig = { kind: 'component'; component: string; props: Record<string, unknown>; name?: string; bindings?: Record<string, ComponentBinding> }
|
|
181
291
|
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,
|
|
186
|
-
* display blocks (no grid / form / master-detail, which need the
|
|
187
|
-
|
|
292
|
+
| GridConfig | FormConfig | ChartConfig | KpiConfig | GaugeConfig | TreeConfig | TabsConfig | AccordionConfig | DashboardConfig | MasterDetailConfig | LookupConfig
|
|
293
|
+
| PivotConfig | FilterPanelConfig | RecordConfig | BoardConfig | CalendarConfig | DetailConfig | ComponentConfig
|
|
294
|
+
|
|
295
|
+
/** Block kinds allowed inside a Tabs / Accordion container: the controller-free,
|
|
296
|
+
* `allRows`-driven display blocks (no grid / form / master-detail, which need the
|
|
297
|
+
* screen controller). `component` covers any UI-kit component added by key. */
|
|
298
|
+
export const TAB_CHILD_KINDS: ReadonlyArray<BlockKind> = ['chart', 'kpi', 'gauge', 'dashboard', 'pivot', 'tree', 'component']
|
|
299
|
+
/** Alias: the same set applies to Accordion sections. */
|
|
300
|
+
export const CONTAINER_CHILD_KINDS = TAB_CHILD_KINDS
|
|
301
|
+
/** Display-block child kinds (no `component`) - what the container "add block" menu offers;
|
|
302
|
+
* components are added separately, by registry key, via `addContainerComponent`. */
|
|
303
|
+
export const CONTAINER_DISPLAY_KINDS: ReadonlyArray<BlockKind> = ['chart', 'kpi', 'gauge', 'dashboard', 'pivot', 'tree']
|
|
188
304
|
|
|
189
305
|
/** All blocks on a screen, flattened to include the children nested in Tabs
|
|
190
306
|
* containers - used to detect kinds for imports / data loading. */
|
|
@@ -193,10 +309,29 @@ export function flattenBlocks(blocks: ReadonlyArray<Block>): Block[] {
|
|
|
193
309
|
for (const b of blocks) {
|
|
194
310
|
out.push(b)
|
|
195
311
|
if (b.config.kind === 'tabs') for (const t of b.config.tabs) out.push(...flattenBlocks(t.blocks))
|
|
312
|
+
if (b.config.kind === 'accordion') for (const s of b.config.sections) out.push(...flattenBlocks(s.blocks))
|
|
196
313
|
}
|
|
197
314
|
return out
|
|
198
315
|
}
|
|
199
316
|
|
|
317
|
+
/** User style overrides for a block's wrapper element (the card around the block).
|
|
318
|
+
* Each field is an OVERRIDE - `undefined` keeps the block kind's default look. Applied
|
|
319
|
+
* identically in the designer preview and the generated app (see `blockStyleCss`). */
|
|
320
|
+
export type BlockStyle = {
|
|
321
|
+
/** Force a border on (true) or off (false); undefined = the kind's default. */
|
|
322
|
+
border?: boolean
|
|
323
|
+
/** Force a drop shadow on (true) or off (false); undefined = default. */
|
|
324
|
+
shadow?: boolean
|
|
325
|
+
/** Inner padding, px. */
|
|
326
|
+
padding?: number
|
|
327
|
+
/** Outer margin, px. */
|
|
328
|
+
margin?: number
|
|
329
|
+
/** Background color (any CSS color). */
|
|
330
|
+
background?: string
|
|
331
|
+
/** Corner radius, px. */
|
|
332
|
+
radius?: number
|
|
333
|
+
}
|
|
334
|
+
|
|
200
335
|
export type Block = {
|
|
201
336
|
id: string
|
|
202
337
|
/** Coarse width in a 3-col grid (legacy + quick buttons). `colSpan` overrides it. */
|
|
@@ -207,19 +342,314 @@ export type Block = {
|
|
|
207
342
|
/** Canvas/preview region height in px. Undefined = the kind's natural default.
|
|
208
343
|
* Applies to height-driven blocks (grid, chart, master-detail). */
|
|
209
344
|
height?: number
|
|
345
|
+
/** Per-block appearance overrides (border / shadow / padding / margin / bg / radius). */
|
|
346
|
+
style?: BlockStyle
|
|
347
|
+
/** Extra CSS class(es) put on the block's wrapper, so custom.css can target it. */
|
|
348
|
+
className?: string
|
|
210
349
|
config: BlockConfig
|
|
211
350
|
}
|
|
351
|
+
|
|
352
|
+
/** Sanitize a user-typed class list to a safe value (letters/digits/_/- and spaces) so
|
|
353
|
+
* it can never break out of a `class="..."` attribute. Shared by block/screen/app. */
|
|
354
|
+
export function sanitizeClassName(raw: string | undefined): string {
|
|
355
|
+
return (raw ?? '').replace(/[^a-zA-Z0-9 _-]/g, '').trim().replace(/\s+/g, ' ').slice(0, 120)
|
|
356
|
+
}
|
|
357
|
+
/** The sanitized extra class(es) for a block's wrapper. */
|
|
358
|
+
export function blockClassName(block: Pick<Block, 'className'>): string {
|
|
359
|
+
return sanitizeClassName(block.className)
|
|
360
|
+
}
|
|
212
361
|
/** The number of columns (1-12) a block occupies in the 12-col layout. */
|
|
213
362
|
export const blockColumns = (b: Pick<Block, 'span' | 'colSpan'>): number =>
|
|
214
363
|
Math.max(1, Math.min(12, Math.round(b.colSpan ?? b.span * 4)))
|
|
364
|
+
|
|
365
|
+
/** Restrict a user-typed color to a safe subset so it can't break out of an inline
|
|
366
|
+
* `style="..."` attribute (hex, rgb()/hsl(), named colors, css vars). */
|
|
367
|
+
const safeColor = (c: string): string => c.replace(/[^#a-zA-Z0-9(),.%\s_-]/g, '').slice(0, 64)
|
|
368
|
+
|
|
369
|
+
/** The CSS declarations for a block's style overrides, for its wrapper element - shared
|
|
370
|
+
* by codegen and the designer so the preview matches the generated app. Empty when no
|
|
371
|
+
* overrides. Numeric fields are safe; the background color is sanitized. */
|
|
372
|
+
export function blockStyleCss(style: BlockStyle | undefined): string {
|
|
373
|
+
if (!style) return ''
|
|
374
|
+
const d: string[] = []
|
|
375
|
+
if (style.border === true) d.push('border: 1px solid var(--sg-border, #e6e8ec)')
|
|
376
|
+
else if (style.border === false) d.push('border: none')
|
|
377
|
+
if (style.shadow === true) d.push('box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06)')
|
|
378
|
+
else if (style.shadow === false) d.push('box-shadow: none')
|
|
379
|
+
if (typeof style.padding === 'number') d.push(`padding: ${style.padding}px`)
|
|
380
|
+
if (typeof style.margin === 'number') d.push(`margin: ${style.margin}px`)
|
|
381
|
+
if (style.background) d.push(`background: ${safeColor(style.background)}`)
|
|
382
|
+
if (typeof style.radius === 'number') d.push(`border-radius: ${style.radius}px`)
|
|
383
|
+
return d.join('; ')
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Merge a partial style patch over a block's style, dropping keys set to undefined;
|
|
387
|
+
* returns undefined when nothing is left (keeps the model + serialized config clean). */
|
|
388
|
+
export function mergeBlockStyle(base: BlockStyle | undefined, patch: Partial<BlockStyle>): BlockStyle | undefined {
|
|
389
|
+
const merged: Record<string, unknown> = { ...base, ...patch }
|
|
390
|
+
for (const k of Object.keys(merged)) if (merged[k] === undefined) delete merged[k]
|
|
391
|
+
return Object.keys(merged).length ? (merged as BlockStyle) : undefined
|
|
392
|
+
}
|
|
215
393
|
/** Navigation placement for a screen (Manage Pages: show/hide + label + order). */
|
|
216
394
|
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
395
|
|
|
219
|
-
/** The
|
|
220
|
-
|
|
396
|
+
/** The always-present page lifecycle handlers the Code view edits. Kept as named
|
|
397
|
+
* constants so codegen, the designer, and the manifest agree on the slot names.
|
|
398
|
+
* `onLoad` runs on mount (with the page context); `onDestroy` runs on unmount
|
|
399
|
+
* (cleanup: timers, subscriptions, aborts). */
|
|
400
|
+
export const ON_LOAD = 'onLoad'
|
|
401
|
+
export const ON_DESTROY = 'onDestroy'
|
|
402
|
+
/** Every lifecycle slot the Code view can edit, in display order. */
|
|
403
|
+
export const HANDLER_SLOTS: ReadonlyArray<string> = [ON_LOAD, ON_DESTROY]
|
|
404
|
+
/** The handler-steps key for a component block's click event. */
|
|
405
|
+
export const clickSlot = (blockId: string) => `click:${blockId}`
|
|
406
|
+
/** The handler-steps key for a grid block's row-select (row click) event. The
|
|
407
|
+
* compiled steps get the clicked `row` in scope (use row-field values). */
|
|
408
|
+
export const rowSelectSlot = (blockId: string) => `rowSelect:${blockId}`
|
|
409
|
+
/** The handler-steps key for a component block's change (value change) event. */
|
|
410
|
+
export const changeSlot = (blockId: string) => `change:${blockId}`
|
|
411
|
+
/** The handler-steps key for the screen's form-submit (record saved) event. The
|
|
412
|
+
* compiled steps get the submitted `row` (values) in scope. */
|
|
413
|
+
export const FORM_SUBMIT = 'formSubmit'
|
|
414
|
+
|
|
415
|
+
// --- logic core: screen state + a small expression engine --------------------
|
|
416
|
+
|
|
417
|
+
/** A screen-scoped reactive variable (`ctx.state.<name>`). Emitted as `$state`. */
|
|
418
|
+
export type StateVarType = 'string' | 'number' | 'boolean' | 'json'
|
|
419
|
+
export type StateVar = { name: string; type: StateVarType; initial?: string }
|
|
420
|
+
|
|
421
|
+
const identSafe = (s: string): string => (s || '').replace(/[^a-zA-Z0-9_$]/g, '_').replace(/^([0-9])/, '_$1') || 'v'
|
|
422
|
+
|
|
423
|
+
/** The `$state(...)` initializer for a variable's declared type. */
|
|
424
|
+
export function stateInitExpr(v: StateVar): string {
|
|
425
|
+
const raw = (v.initial ?? '').trim()
|
|
426
|
+
switch (v.type) {
|
|
427
|
+
case 'number': return raw === '' || Number.isNaN(Number(raw)) ? '0' : raw
|
|
428
|
+
case 'boolean': return raw === 'true' ? 'true' : 'false'
|
|
429
|
+
case 'json': return raw || 'null'
|
|
430
|
+
case 'string': return q(v.initial ?? '')
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
/** The TS type annotation for a variable. */
|
|
434
|
+
export function stateTsType(v: StateVar): string {
|
|
435
|
+
return v.type === 'number' ? 'number' : v.type === 'boolean' ? 'boolean' : v.type === 'json' ? 'unknown' : 'string'
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** A value expression usable in steps / conditions: a literal, a state var, a URL
|
|
439
|
+
* param, or the current row (for row-scoped handlers). Compiles to a `ctx` expr. */
|
|
440
|
+
export type LogicValue =
|
|
441
|
+
| { kind: 'literal'; value: string }
|
|
442
|
+
| { kind: 'state'; name: string }
|
|
443
|
+
| { kind: 'param'; name: string }
|
|
444
|
+
| { kind: 'field'; name: string } // the current row's field (row-scoped slots)
|
|
445
|
+
export function compileValue(v: LogicValue, rowExpr = 'row'): string {
|
|
446
|
+
switch (v.kind) {
|
|
447
|
+
case 'state': return `ctx.state.${identSafe(v.name)}`
|
|
448
|
+
case 'param': return `ctx.params[${q(v.name)}]`
|
|
449
|
+
case 'field': return `${rowExpr}?.[${q(v.name)}]`
|
|
450
|
+
case 'literal': return litValue(v.value)
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export type LogicOp = 'eq' | 'neq' | 'gt' | 'lt' | 'gte' | 'lte' | 'contains' | 'truthy' | 'falsy'
|
|
455
|
+
export type Condition = { left: LogicValue; op: LogicOp; right?: LogicValue }
|
|
456
|
+
export function compileCondition(c: Condition, rowExpr = 'row'): string {
|
|
457
|
+
const L = compileValue(c.left, rowExpr)
|
|
458
|
+
const R = c.right ? compileValue(c.right, rowExpr) : "''"
|
|
459
|
+
switch (c.op) {
|
|
460
|
+
case 'eq': return `${L} === ${R}`
|
|
461
|
+
case 'neq': return `${L} !== ${R}`
|
|
462
|
+
case 'gt': return `Number(${L}) > Number(${R})`
|
|
463
|
+
case 'lt': return `Number(${L}) < Number(${R})`
|
|
464
|
+
case 'gte': return `Number(${L}) >= Number(${R})`
|
|
465
|
+
case 'lte': return `Number(${L}) <= Number(${R})`
|
|
466
|
+
case 'contains': return `String(${L}).includes(String(${R}))`
|
|
467
|
+
case 'truthy': return `Boolean(${L})`
|
|
468
|
+
case 'falsy': return `!(${L})`
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** A field assignment used by create / update record steps. */
|
|
473
|
+
export type FieldValue = { field: string; value: LogicValue }
|
|
474
|
+
const compileFields = (fs: ReadonlyArray<FieldValue>): string => `{ ${fs.map((f) => `${identSafe(f.field)}: ${compileValue(f.value)}`).join(', ')} }`
|
|
475
|
+
|
|
476
|
+
/** One step in a visual "method" (the Methods panel, Radzen-style). Each compiles
|
|
477
|
+
* to a line of typed `ctx` code-behind - so the visual builder and the code editor
|
|
478
|
+
* produce identical output. `code` is the raw-TS escape hatch. */
|
|
479
|
+
export type ActionStep =
|
|
480
|
+
| { type: 'navigate'; to: string } // ctx.goto(to)
|
|
481
|
+
| { type: 'gridExport'; format?: 'csv' | 'tsv' | 'json' } // download the grid
|
|
482
|
+
| { type: 'gridCopy' } // ctx.grid.copyToClipboard()
|
|
483
|
+
| { type: 'gridClear'; what: 'filters' | 'sort' | 'selection' }
|
|
484
|
+
| { type: 'gridSort'; field: string; dir?: 'asc' | 'desc' }
|
|
485
|
+
| { type: 'gridFilter'; field: string; value: string }
|
|
486
|
+
| { type: 'reloadData' } // ctx.data.reload()
|
|
487
|
+
| { type: 'setProp'; target: string; prop: string; value: string } // ctx.<target>.<prop> = <value>
|
|
488
|
+
| { type: 'setText'; target: string; value: string } // ctx.<target>.text = <value>
|
|
489
|
+
| { type: 'alert'; message: string }
|
|
490
|
+
| { type: 'apiAction'; actionId: string } // POST /api/actions/<id>
|
|
491
|
+
| { type: 'setVar'; name: string; value: LogicValue } // ctx.state.<name> = <value>
|
|
492
|
+
| { type: 'createRecord'; values: FieldValue[] } // await ctx.data.create({...})
|
|
493
|
+
| { type: 'updateRecord'; id: LogicValue; values: FieldValue[] } // await ctx.data.update(id, {...})
|
|
494
|
+
| { type: 'deleteRecord'; id: LogicValue } // await ctx.data.delete(id)
|
|
495
|
+
| { type: 'branch'; condition: Condition; then: ActionStep[]; else?: ActionStep[] } // if / else
|
|
496
|
+
| { type: 'code'; code: string }
|
|
497
|
+
export type ActionStepType = ActionStep['type']
|
|
498
|
+
|
|
499
|
+
const q = (s: string) => `'${String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
|
|
500
|
+
/** Emit a value literal: true/false/number bare, else a quoted string. */
|
|
501
|
+
const litValue = (v: string): string => {
|
|
502
|
+
const t = v.trim()
|
|
503
|
+
if (t === 'true' || t === 'false') return t
|
|
504
|
+
if (t !== '' && !Number.isNaN(Number(t))) return t
|
|
505
|
+
return q(v)
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** Compile one step to a `ctx` statement (a single line, no trailing newline). */
|
|
509
|
+
export function compileStep(step: ActionStep): string {
|
|
510
|
+
switch (step.type) {
|
|
511
|
+
case 'navigate': return `ctx.goto(${q(step.to)})`
|
|
512
|
+
case 'gridExport': {
|
|
513
|
+
const fmt = step.format ?? 'csv'
|
|
514
|
+
const fn = fmt === 'tsv' ? 'exportTsv' : fmt === 'json' ? 'exportJson' : 'exportCsv'
|
|
515
|
+
const mime = fmt === 'json' ? 'application/json' : fmt === 'tsv' ? 'text/tab-separated-values' : 'text/csv'
|
|
516
|
+
// A scoped block so the locals don't collide when there are two export steps.
|
|
517
|
+
return [
|
|
518
|
+
'{',
|
|
519
|
+
` const data = await ctx.grid.${fn}()`,
|
|
520
|
+
" const link = document.createElement('a')",
|
|
521
|
+
` link.href = URL.createObjectURL(new Blob([data], { type: ${q(mime)} }))`,
|
|
522
|
+
` link.download = ${q('export.' + fmt)}`,
|
|
523
|
+
' link.click()',
|
|
524
|
+
' URL.revokeObjectURL(link.href)',
|
|
525
|
+
'}',
|
|
526
|
+
].join('\n')
|
|
527
|
+
}
|
|
528
|
+
case 'gridCopy': return `ctx.grid.copyToClipboard()`
|
|
529
|
+
case 'gridClear': return step.what === 'sort' ? `ctx.grid.clearSort()` : step.what === 'selection' ? `ctx.grid.clearRowSelection()` : `ctx.grid.clearAllFilters()`
|
|
530
|
+
case 'gridSort': return `ctx.grid.setSort(${q(step.field)}, ${q(step.dir ?? 'asc')})`
|
|
531
|
+
case 'gridFilter': return `ctx.grid.setFilter(${q(step.field)}, { operator: 'contains', value: ${q(step.value)} })`
|
|
532
|
+
case 'reloadData': return `await ctx.data.reload()`
|
|
533
|
+
case 'setProp': return `ctx.${step.target}.${step.prop} = ${litValue(step.value)}`
|
|
534
|
+
case 'setText': return `ctx.${step.target}.text = ${litValue(step.value)}`
|
|
535
|
+
case 'alert': return `alert(${q(step.message)})`
|
|
536
|
+
case 'apiAction': return `await fetch(${q('/api/actions/' + step.actionId)}, { method: 'POST' })`
|
|
537
|
+
case 'setVar': return `ctx.state.${identSafe(step.name)} = ${compileValue(step.value)}`
|
|
538
|
+
case 'createRecord': return `await ctx.data.create(${compileFields(step.values)})`
|
|
539
|
+
case 'updateRecord': return `await ctx.data.update(${compileValue(step.id)}, ${compileFields(step.values)})`
|
|
540
|
+
case 'deleteRecord': return `await ctx.data.delete(${compileValue(step.id)})`
|
|
541
|
+
case 'branch': {
|
|
542
|
+
const body = indentLines(compileHandlerSteps(step.then))
|
|
543
|
+
const elseB = step.else?.length ? ` else {\n${indentLines(compileHandlerSteps(step.else))}\n}` : ''
|
|
544
|
+
return `if (${compileCondition(step.condition)}) {\n${body}\n}${elseB}`
|
|
545
|
+
}
|
|
546
|
+
case 'code': return step.code
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
/** Indent every line by 2 spaces (nested branch bodies). */
|
|
550
|
+
const indentLines = (s: string): string => s.split('\n').map((l) => (l ? ' ' + l : l)).join('\n')
|
|
551
|
+
/** Compile a list of steps to a handler body (indented lines). */
|
|
552
|
+
export function compileHandlerSteps(steps: ReadonlyArray<ActionStep>): string {
|
|
553
|
+
return steps.map((s) => compileStep(s)).join('\n')
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// --- entity triggers: server-side business rules -----------------------------
|
|
557
|
+
// Compiled into the SQL route's createKitHandlers `hooks`, so they run + are
|
|
558
|
+
// ENFORCED on the server (a client that skips its checks still can't write bad
|
|
559
|
+
// data). The payload is a mutable record `v`; `field`-kind values read `v[...]`.
|
|
560
|
+
|
|
561
|
+
/** One step in an entity trigger (a server-side rule). */
|
|
562
|
+
export type TriggerStep =
|
|
563
|
+
| { type: 'setField'; field: string; value: LogicValue } // v[field] = value (transform)
|
|
564
|
+
| { type: 'requireField'; field: string; message?: string } // reject if empty
|
|
565
|
+
| { type: 'reject'; condition: Condition; message: string } // reject when the condition holds
|
|
566
|
+
| { type: 'branch'; condition: Condition; then: TriggerStep[]; else?: TriggerStep[] }
|
|
567
|
+
| { type: 'code'; code: string }
|
|
568
|
+
export type TriggerEvent = 'beforeCreate' | 'afterCreate' | 'beforeUpdate' | 'afterUpdate' | 'beforeDelete' | 'afterDelete'
|
|
569
|
+
/** All trigger events, in display order; before-hooks transform/validate, after-hooks are side effects. */
|
|
570
|
+
export const TRIGGER_EVENTS: ReadonlyArray<TriggerEvent> = ['beforeCreate', 'afterCreate', 'beforeUpdate', 'afterUpdate', 'beforeDelete', 'afterDelete']
|
|
571
|
+
export type EntityTriggers = Partial<Record<TriggerEvent, TriggerStep[]>>
|
|
572
|
+
|
|
573
|
+
/** Compile one trigger step against the payload variable `rowExpr` (default `v`). */
|
|
574
|
+
export function compileTriggerStep(step: TriggerStep, rowExpr = 'v'): string {
|
|
575
|
+
switch (step.type) {
|
|
576
|
+
case 'setField': return `${rowExpr}[${q(step.field)}] = ${compileValue(step.value, rowExpr)}`
|
|
577
|
+
case 'requireField': return `if (${rowExpr}[${q(step.field)}] == null || ${rowExpr}[${q(step.field)}] === '') throw new Error(${q(step.message || step.field + ' is required')})`
|
|
578
|
+
case 'reject': return `if (${compileCondition(step.condition, rowExpr)}) throw new Error(${q(step.message)})`
|
|
579
|
+
case 'branch': {
|
|
580
|
+
const body = indentLines(compileTriggerSteps(step.then, rowExpr))
|
|
581
|
+
const elseB = step.else?.length ? ` else {\n${indentLines(compileTriggerSteps(step.else, rowExpr))}\n}` : ''
|
|
582
|
+
return `if (${compileCondition(step.condition, rowExpr)}) {\n${body}\n}${elseB}`
|
|
583
|
+
}
|
|
584
|
+
case 'code': return step.code
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
export function compileTriggerSteps(steps: ReadonlyArray<TriggerStep>, rowExpr = 'v'): string {
|
|
588
|
+
return steps.map((s) => compileTriggerStep(s, rowExpr)).join('\n')
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** `entity` is optional: a screen with none is a freestanding page (no data
|
|
592
|
+
* binding, no blocks - every `BlockKind` is entity-bound) - just a title, an
|
|
593
|
+
* empty content area, and optionally a toolbar of custom `actions`. Wire up a
|
|
594
|
+
* "Run report" / "Sync now" button on a blank page without a table behind it.
|
|
595
|
+
*
|
|
596
|
+
* `code` opts the screen into a create-once `handlers.ts` companion (user-owned,
|
|
597
|
+
* never regenerated) whose `onLoad(ctx)` runs on mount; `renderGrid` adds a Grid
|
|
598
|
+
* fed via `ctx.setRows`. `handlerBodies` holds each handler's body (keyed by name). */
|
|
599
|
+
/** How a screen arranges its blocks: the default 12-column responsive grid, or a
|
|
600
|
+
* docking workspace (SvDockManager - drag-to-dock splits/tabs + floating / pinned panes,
|
|
601
|
+
* serialized as `dock`). */
|
|
602
|
+
export type ScreenLayout = 'grid' | 'stack' | 'split' | 'dock' | 'canvas'
|
|
603
|
+
|
|
604
|
+
/** Layouts that arrange blocks as dockable / split panes (both back onto a
|
|
605
|
+
* serialized `DockManagerState`; `split` renders it locked = resize-only). */
|
|
606
|
+
export const PANE_LAYOUTS: ReadonlyArray<ScreenLayout> = ['split', 'dock']
|
|
607
|
+
export function isPaneLayout(layout: ScreenLayout): boolean { return PANE_LAYOUTS.includes(layout) }
|
|
608
|
+
|
|
609
|
+
/** Free-form canvas: a block is placed on a 12-column grid by explicit cell
|
|
610
|
+
* coordinates (col/row) + spans, instead of flowing. `row`/`rowSpan` count
|
|
611
|
+
* fixed-height rows (see CANVAS_ROW_PX). All values are grid cells, 0-indexed. */
|
|
612
|
+
export type CanvasRect = { col: number; row: number; colSpan: number; rowSpan: number }
|
|
613
|
+
export const CANVAS_COLS = 12
|
|
614
|
+
/** Height of one canvas row in px (grid-auto-rows), shared by designer + codegen. */
|
|
615
|
+
export const CANVAS_ROW_PX = 40
|
|
616
|
+
/** Gap between canvas cells in px. */
|
|
617
|
+
export const CANVAS_GAP_PX = 8
|
|
618
|
+
// --- per-layout settings ----------------------------------------------------
|
|
619
|
+
// Each mode has its own knobs; stored per-mode so switching layouts keeps each
|
|
620
|
+
// mode's settings. All fields optional - absent means the default (below), so
|
|
621
|
+
// existing projects are unchanged.
|
|
622
|
+
export type GridLayoutOpts = { colGap?: number; rowGap?: number; maxWidth?: number; align?: 'start' | 'stretch'; mobileBreakpoint?: number }
|
|
623
|
+
export type StackLayoutOpts = { gap?: number; maxWidth?: number; align?: 'left' | 'center'; dividers?: boolean; minHeight?: number }
|
|
624
|
+
export type SplitLayoutOpts = { orientation?: 'auto' | 'row' | 'column'; minPaneSize?: number; persist?: boolean }
|
|
625
|
+
export type DockLayoutOpts = { allowPopout?: boolean; hideSingleTab?: boolean; headerPosition?: 'top' | 'bottom' | 'left' | 'right'; persist?: boolean }
|
|
626
|
+
export type CanvasLayoutOpts = { cols?: number; rowHeight?: number; gap?: number; showGrid?: boolean }
|
|
627
|
+
export type LayoutOpts = { grid?: GridLayoutOpts; stack?: StackLayoutOpts; split?: SplitLayoutOpts; dock?: DockLayoutOpts; canvas?: CanvasLayoutOpts }
|
|
628
|
+
|
|
629
|
+
/** `maxWidth: 0` (or absent) means full-bleed. */
|
|
630
|
+
export const GRID_OPT_DEFAULTS: Required<GridLayoutOpts> = { colGap: 16, rowGap: 16, maxWidth: 0, align: 'start', mobileBreakpoint: 720 }
|
|
631
|
+
export const STACK_OPT_DEFAULTS: Required<StackLayoutOpts> = { gap: 16, maxWidth: 0, align: 'left', dividers: false, minHeight: 160 }
|
|
632
|
+
export const SPLIT_OPT_DEFAULTS: Required<SplitLayoutOpts> = { orientation: 'auto', minPaneSize: 80, persist: true }
|
|
633
|
+
export const DOCK_OPT_DEFAULTS: Required<DockLayoutOpts> = { allowPopout: false, hideSingleTab: false, headerPosition: 'top', persist: true }
|
|
634
|
+
export const CANVAS_OPT_DEFAULTS: Required<CanvasLayoutOpts> = { cols: CANVAS_COLS, rowHeight: CANVAS_ROW_PX, gap: CANVAS_GAP_PX, showGrid: false }
|
|
635
|
+
|
|
636
|
+
export const gridOpts = (s: Screen): Required<GridLayoutOpts> => ({ ...GRID_OPT_DEFAULTS, ...s.layoutOpts?.grid })
|
|
637
|
+
export const stackOpts = (s: Screen): Required<StackLayoutOpts> => ({ ...STACK_OPT_DEFAULTS, ...s.layoutOpts?.stack })
|
|
638
|
+
export const splitOpts = (s: Screen): Required<SplitLayoutOpts> => ({ ...SPLIT_OPT_DEFAULTS, ...s.layoutOpts?.split })
|
|
639
|
+
export const dockOpts = (s: Screen): Required<DockLayoutOpts> => ({ ...DOCK_OPT_DEFAULTS, ...s.layoutOpts?.dock })
|
|
640
|
+
export const canvasOpts = (s: Screen): Required<CanvasLayoutOpts> => ({ ...CANVAS_OPT_DEFAULTS, ...s.layoutOpts?.canvas })
|
|
641
|
+
|
|
642
|
+
/** Patch one mode's settings (merged over any existing values). */
|
|
643
|
+
export function setLayoutOpts<K extends keyof LayoutOpts>(project: StudioProject, screenId: string, mode: K, patch: Partial<NonNullable<LayoutOpts[K]>>): StudioProject {
|
|
644
|
+
return mapScreen(project, screenId, (s) => ({ ...s, layoutOpts: { ...s.layoutOpts, [mode]: { ...s.layoutOpts?.[mode], ...patch } } }))
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
export type Screen = { id: string; entity?: string; title: string; route: string; blocks: Block[]; nav?: ScreenNav; actions?: ActionConfig[]; code?: boolean; renderGrid?: boolean; handlerBodies?: Record<string, string>; handlerSteps?: Record<string, ActionStep[]>; handlersSource?: string; className?: string; layout?: ScreenLayout; dock?: DockManagerState; canvas?: Record<string, CanvasRect>; layoutOpts?: LayoutOpts; state?: StateVar[] }
|
|
648
|
+
|
|
649
|
+
/** The generated app's shell (master layout): sidebar, top-nav, or bottom-nav; brand, footer. */
|
|
650
|
+
export type ShellStyle = 'sidebar' | 'top-nav' | 'bottom-nav'
|
|
221
651
|
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 }
|
|
652
|
+
export type ProjectTheme = { accent?: string; preset?: string; mode?: 'light' | 'dark'; shell?: ShellConfig; customCss?: string; appClass?: string }
|
|
223
653
|
|
|
224
654
|
/** A mutating CRUD action, gated by RBAC (reads are implied by screen access). */
|
|
225
655
|
export type CrudAction = 'create' | 'update' | 'delete'
|
|
@@ -240,6 +670,32 @@ export type AccessControl = {
|
|
|
240
670
|
/** Fallback role when the app can't resolve one from the session. Default-denies. */
|
|
241
671
|
defaultRole?: string
|
|
242
672
|
}
|
|
673
|
+
/** Authentication starter. When `enabled`, the generator scaffolds a real sign-in:
|
|
674
|
+
* a session cookie + `hooks.server.ts` that populates `event.locals.role`/`user`
|
|
675
|
+
* (closing the loop the RBAC layer expects), a `/login` page, sign-out, and demo
|
|
676
|
+
* seed users (one per RBAC role, or a single admin). Dependency-free: Web Crypto +
|
|
677
|
+
* stateless signed cookies. `protect` gates the whole app behind login (default). */
|
|
678
|
+
/** Social / enterprise sign-in providers. `oidc` is a generic OpenID Connect issuer
|
|
679
|
+
* (covers Azure AD / Entra ID, Okta, Auth0, Keycloak, ... via discovery). */
|
|
680
|
+
export type OAuthProvider = 'github' | 'google' | 'oidc'
|
|
681
|
+
export type AuthConfig = {
|
|
682
|
+
enabled: boolean
|
|
683
|
+
/** Redirect unauthenticated visitors to /login for every route. Default true. */
|
|
684
|
+
protect?: boolean
|
|
685
|
+
/** Self-service sign-up + password recovery (/register, /forgot-password,
|
|
686
|
+
* /reset-password). Needs the DB-backed user store (turn on the data layer). */
|
|
687
|
+
register?: boolean
|
|
688
|
+
/** An admin user-management screen (/users: list, invite, change role, remove).
|
|
689
|
+
* Needs the DB-backed store + RBAC (only a full-access role can open it). */
|
|
690
|
+
userAdmin?: boolean
|
|
691
|
+
/** OAuth / OpenID Connect sign-in buttons. Needs the DB-backed store. */
|
|
692
|
+
oauth?: OAuthProvider[]
|
|
693
|
+
/** Email one-time-code two-factor auth. Needs the DB-backed store + real email. */
|
|
694
|
+
twoFactor?: boolean
|
|
695
|
+
/** Real email delivery (Resend HTTP API or SMTP via nodemailer); dev console otherwise.
|
|
696
|
+
* Implied when `twoFactor` is on. */
|
|
697
|
+
email?: boolean
|
|
698
|
+
}
|
|
243
699
|
/** Does a role's rules permit opening a screen? */
|
|
244
700
|
export const roleCanScreen = (r: RoleAccess, screenId: string): boolean =>
|
|
245
701
|
r.screens === '*' || r.screens.includes(screenId)
|
|
@@ -258,6 +714,12 @@ export type StudioProject = {
|
|
|
258
714
|
theme?: ProjectTheme
|
|
259
715
|
/** Role-based access control (optional; off unless `access.enabled`). */
|
|
260
716
|
access?: AccessControl
|
|
717
|
+
/** Authentication starter (optional; off unless `auth.enabled`). Provides the
|
|
718
|
+
* session/login that populates the role RBAC consumes. */
|
|
719
|
+
auth?: AuthConfig
|
|
720
|
+
/** Typed data layer: when `'drizzle'` and there's a SQL-bound entity, emit a
|
|
721
|
+
* Drizzle schema + typed repositories + drizzle-kit migrations. */
|
|
722
|
+
dataLayer?: 'drizzle'
|
|
261
723
|
/** Emit an audit trail: connected routes log create/update/delete + an /audit viewer. */
|
|
262
724
|
audit?: boolean
|
|
263
725
|
/** Localization: when enabled, emit a message catalog + locale switcher and route
|
|
@@ -266,6 +728,25 @@ export type StudioProject = {
|
|
|
266
728
|
/** Deploy target: picks the SvelteKit adapter + provider config the bundle emits.
|
|
267
729
|
* Defaults to `auto` (@sveltejs/adapter-auto, which detects Vercel/Netlify/Cloudflare). */
|
|
268
730
|
deploy?: DeployTarget
|
|
731
|
+
/** Server-side business-rule triggers, keyed by entity name. Enforced on the
|
|
732
|
+
* SQL route (compiled into createKitHandlers `hooks`). */
|
|
733
|
+
triggers?: Record<string, EntityTriggers>
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** The triggers configured for an entity (or an empty object). */
|
|
737
|
+
export function triggersOf(project: StudioProject, entity: string): EntityTriggers {
|
|
738
|
+
return project.triggers?.[entity] ?? {}
|
|
739
|
+
}
|
|
740
|
+
/** Set (or clear, with `null`) an entity's steps for one trigger event. */
|
|
741
|
+
export function setTrigger(project: StudioProject, entity: string, event: TriggerEvent, steps: TriggerStep[] | null): StudioProject {
|
|
742
|
+
const cur = project.triggers?.[entity] ?? {}
|
|
743
|
+
const nextEnt: EntityTriggers = { ...cur }
|
|
744
|
+
if (steps && steps.length) nextEnt[event] = steps
|
|
745
|
+
else delete nextEnt[event]
|
|
746
|
+
const triggers = { ...project.triggers }
|
|
747
|
+
if (Object.keys(nextEnt).length) triggers[entity] = nextEnt
|
|
748
|
+
else delete triggers[entity]
|
|
749
|
+
return { ...project, triggers: Object.keys(triggers).length ? triggers : undefined }
|
|
269
750
|
}
|
|
270
751
|
|
|
271
752
|
/** Where the generated app deploys. Drives the emitted SvelteKit adapter + config. */
|
|
@@ -291,6 +772,7 @@ export const blockPalette: ReadonlyArray<PaletteItem> = [
|
|
|
291
772
|
{ kind: 'gauge', label: 'Gauge', needs: 'measure' },
|
|
292
773
|
{ kind: 'tree', label: 'Tree' },
|
|
293
774
|
{ kind: 'tabs', label: 'Tabs' },
|
|
775
|
+
{ kind: 'accordion', label: 'Accordion' },
|
|
294
776
|
{ kind: 'master-detail', label: 'Master / detail', needs: 'child' },
|
|
295
777
|
{ kind: 'board', label: 'Board' },
|
|
296
778
|
{ kind: 'calendar', label: 'Calendar' },
|
|
@@ -309,7 +791,8 @@ function uid(prefix: string, taken: ReadonlySet<string>): string {
|
|
|
309
791
|
return `${prefix}${n}`
|
|
310
792
|
}
|
|
311
793
|
|
|
312
|
-
export function entityOf(project: StudioProject, name: string): EntitySchema | undefined {
|
|
794
|
+
export function entityOf(project: StudioProject, name: string | undefined): EntitySchema | undefined {
|
|
795
|
+
if (name == null) return undefined
|
|
313
796
|
return project.entities.find((e) => e.name === name)
|
|
314
797
|
}
|
|
315
798
|
|
|
@@ -371,6 +854,8 @@ export function defaultBlockConfig(kind: BlockKind, entity: EntitySchema): Block
|
|
|
371
854
|
}
|
|
372
855
|
case 'tabs':
|
|
373
856
|
return { kind, tabs: [{ label: 'Overview', blocks: [] }, { label: 'Details', blocks: [] }] }
|
|
857
|
+
case 'accordion':
|
|
858
|
+
return { kind, sections: [{ label: 'Section 1', blocks: [] }, { label: 'Section 2', blocks: [] }], multiple: false }
|
|
374
859
|
case 'dashboard':
|
|
375
860
|
return { kind }
|
|
376
861
|
case 'master-detail':
|
|
@@ -406,6 +891,9 @@ export function defaultBlockConfig(kind: BlockKind, entity: EntitySchema): Block
|
|
|
406
891
|
const metrics = nonKey.filter((f) => f.type === 'number').slice(0, 3).map((f) => f.field)
|
|
407
892
|
return { kind, titleField: titleF, ...(subF ? { subtitleField: subF } : {}), ...(statusF ? { statusField: statusF } : {}), ...(metrics.length ? { metricFields: metrics } : {}) }
|
|
408
893
|
}
|
|
894
|
+
case 'component':
|
|
895
|
+
// Entity-agnostic - built directly by `addComponentBlock`, never through here.
|
|
896
|
+
return { kind, component: '', props: {} }
|
|
409
897
|
}
|
|
410
898
|
}
|
|
411
899
|
|
|
@@ -421,7 +909,7 @@ export function pickFacetFields(entity: EntitySchema): string[] {
|
|
|
421
909
|
const facetRank = (t: EntityFieldType): number => (t === 'enum' ? 0 : t === 'boolean' ? 1 : 2)
|
|
422
910
|
|
|
423
911
|
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,
|
|
912
|
+
grid: 3, form: 1, chart: 2, dashboard: 3, kpi: 1, gauge: 1, tree: 2, tabs: 3, accordion: 3, 'master-detail': 3, lookup: 1, pivot: 3, filter: 1, record: 1, board: 3, calendar: 3, detail: 3, component: 1,
|
|
425
913
|
}
|
|
426
914
|
|
|
427
915
|
function makeBlock(kind: BlockKind, entity: EntitySchema, taken: ReadonlySet<string>): Block {
|
|
@@ -456,6 +944,66 @@ export function removeTabBlock(cfg: TabsConfig, index: number, blockId: string):
|
|
|
456
944
|
return { ...cfg, tabs: cfg.tabs.map((t, i) => (i === index ? { ...t, blocks: t.blocks.filter((b) => b.id !== blockId) } : t)) }
|
|
457
945
|
}
|
|
458
946
|
|
|
947
|
+
/** Build a UI-kit component child block for a container section (Tabs / Accordion),
|
|
948
|
+
* seeded with the registry's default props + content. Returns null for an unknown
|
|
949
|
+
* component key. Entity-agnostic - components need no entity. */
|
|
950
|
+
function makeComponentChild(componentKey: string, taken: ReadonlySet<string>): Block | null {
|
|
951
|
+
const spec = uiComponentSpec(componentKey)
|
|
952
|
+
if (!spec) return null
|
|
953
|
+
const props: Record<string, unknown> = {}
|
|
954
|
+
for (const p of spec.props) if (p.default != null) props[p.key] = p.default
|
|
955
|
+
if (spec.hasContent) props._content = spec.contentDefault ?? spec.label
|
|
956
|
+
const set = new Set(taken)
|
|
957
|
+
return { id: uid('component-', set), span: DEFAULT_SPAN.component, config: { kind: 'component', component: componentKey, props } }
|
|
958
|
+
}
|
|
959
|
+
/** Add a specific UI-kit component (by registry key) to a tab. */
|
|
960
|
+
export function addTabComponent(cfg: TabsConfig, index: number, componentKey: string): TabsConfig {
|
|
961
|
+
const taken = new Set<string>()
|
|
962
|
+
cfg.tabs.forEach((t) => flattenBlocks(t.blocks).forEach((b) => taken.add(b.id)))
|
|
963
|
+
const block = makeComponentChild(componentKey, taken)
|
|
964
|
+
if (!block) return cfg
|
|
965
|
+
return { ...cfg, tabs: cfg.tabs.map((t, i) => (i === index ? { ...t, blocks: [...t.blocks, block] } : t)) }
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// --- Accordion container (mirrors the Tabs helpers) ------------------------
|
|
969
|
+
/** Append a section (collapsible panel) to an accordion. */
|
|
970
|
+
export function addAccordionSection(cfg: AccordionConfig, label?: string): AccordionConfig {
|
|
971
|
+
return { ...cfg, sections: [...cfg.sections, { label: label ?? `Section ${cfg.sections.length + 1}`, blocks: [] }] }
|
|
972
|
+
}
|
|
973
|
+
/** Remove a section by index (keeps at least one). */
|
|
974
|
+
export function removeAccordionSection(cfg: AccordionConfig, index: number): AccordionConfig {
|
|
975
|
+
if (cfg.sections.length <= 1) return cfg
|
|
976
|
+
return { ...cfg, sections: cfg.sections.filter((_, i) => i !== index) }
|
|
977
|
+
}
|
|
978
|
+
/** Rename a section by index. */
|
|
979
|
+
export function renameAccordionSection(cfg: AccordionConfig, index: number, label: string): AccordionConfig {
|
|
980
|
+
return { ...cfg, sections: cfg.sections.map((s, i) => (i === index ? { ...s, label } : s)) }
|
|
981
|
+
}
|
|
982
|
+
/** Toggle single- vs multiple-open behaviour. */
|
|
983
|
+
export function setAccordionMultiple(cfg: AccordionConfig, multiple: boolean): AccordionConfig {
|
|
984
|
+
return { ...cfg, multiple }
|
|
985
|
+
}
|
|
986
|
+
/** Add a display-block child (default config for `kind`) to a section. Only CONTAINER_CHILD_KINDS. */
|
|
987
|
+
export function addAccordionBlock(cfg: AccordionConfig, index: number, kind: BlockKind, entity: EntitySchema): AccordionConfig {
|
|
988
|
+
if (!CONTAINER_CHILD_KINDS.includes(kind)) return cfg
|
|
989
|
+
const taken = new Set<string>()
|
|
990
|
+
cfg.sections.forEach((s) => flattenBlocks(s.blocks).forEach((b) => taken.add(b.id)))
|
|
991
|
+
const block: Block = { id: uid(`${kind}-`, taken), span: DEFAULT_SPAN[kind], config: defaultBlockConfig(kind, entity) }
|
|
992
|
+
return { ...cfg, sections: cfg.sections.map((s, i) => (i === index ? { ...s, blocks: [...s.blocks, block] } : s)) }
|
|
993
|
+
}
|
|
994
|
+
/** Add a specific UI-kit component (by registry key) to a section. */
|
|
995
|
+
export function addAccordionComponent(cfg: AccordionConfig, index: number, componentKey: string): AccordionConfig {
|
|
996
|
+
const taken = new Set<string>()
|
|
997
|
+
cfg.sections.forEach((s) => flattenBlocks(s.blocks).forEach((b) => taken.add(b.id)))
|
|
998
|
+
const block = makeComponentChild(componentKey, taken)
|
|
999
|
+
if (!block) return cfg
|
|
1000
|
+
return { ...cfg, sections: cfg.sections.map((s, i) => (i === index ? { ...s, blocks: [...s.blocks, block] } : s)) }
|
|
1001
|
+
}
|
|
1002
|
+
/** Remove a child block from a section by id. */
|
|
1003
|
+
export function removeAccordionBlock(cfg: AccordionConfig, index: number, blockId: string): AccordionConfig {
|
|
1004
|
+
return { ...cfg, sections: cfg.sections.map((s, i) => (i === index ? { ...s, blocks: s.blocks.filter((b) => b.id !== blockId) } : s)) }
|
|
1005
|
+
}
|
|
1006
|
+
|
|
459
1007
|
/** A default screen for an entity: a grid (editing via a popup form by default). */
|
|
460
1008
|
export function defaultScreenFor(entity: EntitySchema): Screen {
|
|
461
1009
|
const grid = makeBlock('grid', entity, new Set<string>())
|
|
@@ -500,21 +1048,86 @@ export function addBlockAt(project: StudioProject, screenId: string, kind: Block
|
|
|
500
1048
|
})
|
|
501
1049
|
}
|
|
502
1050
|
|
|
503
|
-
/**
|
|
1051
|
+
/** Add a UI-kit component block (see `ui-components.ts`'s `UI_COMPONENT_REGISTRY`)
|
|
1052
|
+
* to a screen. Unlike `addBlock`/`addBlockAt`, this needs no entity - it works on
|
|
1053
|
+
* any screen, including a freestanding one, or mixed onto an entity-bound one. */
|
|
1054
|
+
export function addComponentBlock(project: StudioProject, screenId: string, componentKey: string, defaultProps: Record<string, unknown> = {}, index?: number): StudioProject {
|
|
1055
|
+
return mapScreen(project, screenId, (s) => {
|
|
1056
|
+
const taken = new Set(flattenBlocks(s.blocks).map((b) => b.id))
|
|
1057
|
+
const name = uniqueComponentName(componentKey, s)
|
|
1058
|
+
const block: Block = { id: uid('component-', taken), span: DEFAULT_SPAN.component, config: { kind: 'component', component: componentKey, props: { ...defaultProps }, name } }
|
|
1059
|
+
const blocks = [...s.blocks]
|
|
1060
|
+
blocks.splice(index != null ? Math.max(0, Math.min(index, blocks.length)) : blocks.length, 0, block)
|
|
1061
|
+
return { ...s, blocks }
|
|
1062
|
+
})
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
/** A valid, unique JS identifier for a component handle on a screen, e.g. `button1`.
|
|
1066
|
+
* Handles are the named objects code reaches (btn.setLabel(...), btn.onclick = ...). */
|
|
1067
|
+
export function componentHandleName(cfg: ComponentConfig): string {
|
|
1068
|
+
const raw = (cfg.name ?? cfg.component).trim()
|
|
1069
|
+
const id = raw.replace(/[^A-Za-z0-9_$]/g, '_').replace(/^([0-9])/, '_$1')
|
|
1070
|
+
return id || 'el'
|
|
1071
|
+
}
|
|
1072
|
+
function uniqueComponentName(componentKey: string, screen: Screen): string {
|
|
1073
|
+
const taken = new Set(
|
|
1074
|
+
flattenBlocks(screen.blocks)
|
|
1075
|
+
.filter((b) => b.config.kind === 'component')
|
|
1076
|
+
.map((b) => componentHandleName(b.config as ComponentConfig)),
|
|
1077
|
+
)
|
|
1078
|
+
let n = 1
|
|
1079
|
+
while (taken.has(`${componentKey}${n}`)) n++
|
|
1080
|
+
return `${componentKey}${n}`
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/** Rename a component's handle (the variable code uses to reach it). */
|
|
1084
|
+
export function setComponentName(project: StudioProject, screenId: string, blockId: string, name: string): StudioProject {
|
|
1085
|
+
return mapScreen(project, screenId, (s) => ({
|
|
1086
|
+
...s,
|
|
1087
|
+
blocks: mapBlockTree(s.blocks, blockId, (b) => (b.config.kind === 'component' ? { ...b, config: { ...b.config, name: name.trim() || undefined } } : b)),
|
|
1088
|
+
}))
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/** True when a component has at least one data-bound prop. */
|
|
1092
|
+
export function componentHasBindings(cfg: ComponentConfig): boolean {
|
|
1093
|
+
return !!cfg.bindings && Object.keys(cfg.bindings).length > 0
|
|
1094
|
+
}
|
|
1095
|
+
/** Bind (or, with `binding: null`, unbind) one component prop to the screen's data. */
|
|
1096
|
+
export function setComponentBinding(project: StudioProject, screenId: string, blockId: string, propKey: string, binding: ComponentBinding | null): StudioProject {
|
|
1097
|
+
return mapScreen(project, screenId, (s) => ({
|
|
1098
|
+
...s,
|
|
1099
|
+
blocks: mapBlockTree(s.blocks, blockId, (b) => {
|
|
1100
|
+
if (b.config.kind !== 'component') return b
|
|
1101
|
+
const bindings = { ...(b.config.bindings ?? {}) }
|
|
1102
|
+
if (binding) bindings[propKey] = binding
|
|
1103
|
+
else delete bindings[propKey]
|
|
1104
|
+
return { ...b, config: { ...b.config, bindings: Object.keys(bindings).length ? bindings : undefined } }
|
|
1105
|
+
}),
|
|
1106
|
+
}))
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/** Apply `fn` to the block with `id` anywhere in the tree (top level or nested in a Tabs / Accordion container). */
|
|
504
1110
|
function mapBlockTree(blocks: Block[], id: string, fn: (b: Block) => Block): Block[] {
|
|
505
1111
|
return blocks.map((b) => {
|
|
506
1112
|
if (b.id === id) return fn(b)
|
|
507
1113
|
if (b.config.kind === 'tabs') {
|
|
508
1114
|
return { ...b, config: { ...b.config, tabs: b.config.tabs.map((t) => ({ ...t, blocks: mapBlockTree(t.blocks, id, fn) })) } }
|
|
509
1115
|
}
|
|
1116
|
+
if (b.config.kind === 'accordion') {
|
|
1117
|
+
return { ...b, config: { ...b.config, sections: b.config.sections.map((s) => ({ ...s, blocks: mapBlockTree(s.blocks, id, fn) })) } }
|
|
1118
|
+
}
|
|
510
1119
|
return b
|
|
511
1120
|
})
|
|
512
1121
|
}
|
|
513
|
-
/** Remove the block with `id` anywhere in the tree (top level or nested in a Tabs container). */
|
|
1122
|
+
/** Remove the block with `id` anywhere in the tree (top level or nested in a Tabs / Accordion container). */
|
|
514
1123
|
function removeBlockTree(blocks: Block[], id: string): Block[] {
|
|
515
1124
|
return blocks
|
|
516
1125
|
.filter((b) => b.id !== id)
|
|
517
|
-
.map((b) =>
|
|
1126
|
+
.map((b) =>
|
|
1127
|
+
b.config.kind === 'tabs' ? { ...b, config: { ...b.config, tabs: b.config.tabs.map((t) => ({ ...t, blocks: removeBlockTree(t.blocks, id) })) } }
|
|
1128
|
+
: b.config.kind === 'accordion' ? { ...b, config: { ...b.config, sections: b.config.sections.map((s) => ({ ...s, blocks: removeBlockTree(s.blocks, id) })) } }
|
|
1129
|
+
: b,
|
|
1130
|
+
)
|
|
518
1131
|
}
|
|
519
1132
|
|
|
520
1133
|
export function removeBlock(project: StudioProject, screenId: string, blockId: string): StudioProject {
|
|
@@ -558,12 +1171,13 @@ export function reorderBlock(project: StudioProject, screenId: string, blockId:
|
|
|
558
1171
|
})
|
|
559
1172
|
}
|
|
560
1173
|
|
|
561
|
-
/** Patch a block's config (merged) and/or
|
|
1174
|
+
/** Patch a block's config (merged), span/height, and/or appearance `style` (merged;
|
|
1175
|
+
* keys set to undefined are cleared). */
|
|
562
1176
|
export function updateBlock(
|
|
563
1177
|
project: StudioProject,
|
|
564
1178
|
screenId: string,
|
|
565
1179
|
blockId: string,
|
|
566
|
-
patch: { span?: 1 | 2 | 3; colSpan?: number; height?: number; config?: Partial<BlockConfig
|
|
1180
|
+
patch: { span?: 1 | 2 | 3; colSpan?: number; height?: number; config?: Partial<BlockConfig>; style?: Partial<BlockStyle>; className?: string },
|
|
567
1181
|
): StudioProject {
|
|
568
1182
|
return mapScreen(project, screenId, (s) => ({
|
|
569
1183
|
...s,
|
|
@@ -572,6 +1186,8 @@ export function updateBlock(
|
|
|
572
1186
|
span: patch.span ?? b.span,
|
|
573
1187
|
colSpan: patch.colSpan ?? b.colSpan,
|
|
574
1188
|
height: patch.height ?? b.height,
|
|
1189
|
+
style: patch.style ? mergeBlockStyle(b.style, patch.style) : b.style,
|
|
1190
|
+
className: patch.className !== undefined ? (patch.className.trim() || undefined) : b.className,
|
|
575
1191
|
config: patch.config ? ({ ...b.config, ...patch.config } as BlockConfig) : b.config,
|
|
576
1192
|
})),
|
|
577
1193
|
}))
|
|
@@ -627,10 +1243,460 @@ export function removeScreen(project: StudioProject, screenId: string): StudioPr
|
|
|
627
1243
|
return { ...project, screens: project.screens.filter((s) => s.id !== screenId) }
|
|
628
1244
|
}
|
|
629
1245
|
|
|
630
|
-
export function updateScreen(project: StudioProject, screenId: string, patch: Partial<Pick<Screen, 'title' | 'route' | 'entity' | 'nav'>>): StudioProject {
|
|
1246
|
+
export function updateScreen(project: StudioProject, screenId: string, patch: Partial<Pick<Screen, 'title' | 'route' | 'entity' | 'nav' | 'actions' | 'className'>>): StudioProject {
|
|
631
1247
|
return mapScreen(project, screenId, (s) => ({ ...s, ...patch }))
|
|
632
1248
|
}
|
|
633
1249
|
|
|
1250
|
+
// ---- Screen layout: 12-column grid (default) or a docking workspace ----------
|
|
1251
|
+
|
|
1252
|
+
/** The screen's layout engine. Defaults to `'grid'` (the 12-column responsive grid). */
|
|
1253
|
+
export function screenLayoutOf(screen: Screen): ScreenLayout {
|
|
1254
|
+
return screen.layout ?? 'grid'
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
/** A short title for a block's dock pane. */
|
|
1258
|
+
function dockPaneTitle(block: Block): string {
|
|
1259
|
+
const c = block.config as { kind: string; label?: string; title?: string; name?: string; component?: string }
|
|
1260
|
+
const byKind: Record<string, string> = {
|
|
1261
|
+
grid: 'Grid', form: 'Form', chart: 'Chart', dashboard: 'Dashboard', tree: 'Tree', pivot: 'Pivot',
|
|
1262
|
+
filter: 'Filters', record: 'Record', detail: 'Detail', board: 'Board', calendar: 'Calendar',
|
|
1263
|
+
'master-detail': 'Master / detail', lookup: 'Lookup', tabs: 'Tabs', accordion: 'Accordion',
|
|
1264
|
+
kpi: 'KPI', gauge: 'Gauge', component: 'Component',
|
|
1265
|
+
}
|
|
1266
|
+
return c.label || c.title || c.name || c.component || byKind[c.kind] || 'Panel'
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
/** Every pane id present anywhere in a dock workspace (main tree + floating + auto-hidden). */
|
|
1270
|
+
export function dockPaneIds(state: DockManagerState): Set<string> {
|
|
1271
|
+
const ids = new Set<string>()
|
|
1272
|
+
const walk = (n: DockNode) => { if (n.type === 'tabs') for (const p of n.panes) ids.add(p.id); else for (const c of n.children) walk(c) }
|
|
1273
|
+
if (state.main) walk(state.main)
|
|
1274
|
+
for (const w of state.floating) for (const p of w.leaf.panes) ids.add(p.id)
|
|
1275
|
+
for (const e of state.autoHide) for (const p of e.leaf.panes) ids.add(p.id)
|
|
1276
|
+
return ids
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
/**
|
|
1280
|
+
* Smart auto-layout: seed a docking workspace from a screen's blocks by role -
|
|
1281
|
+
* filters dock left, record / detail panels dock right, KPIs / gauges strip across
|
|
1282
|
+
* the top, and the main content (grid / board / calendar / chart / ...) fills the
|
|
1283
|
+
* centre. Pane ids equal block ids. Runtime float / pin / pop-out is layered on later.
|
|
1284
|
+
*/
|
|
1285
|
+
export function buildDockLayout(screen: Screen): DockManagerState {
|
|
1286
|
+
const taken = new Set<string>(screen.blocks.map((b) => b.id))
|
|
1287
|
+
const genId = (p: string) => { const id = uid(p, taken); taken.add(id); return id }
|
|
1288
|
+
const paneFor = (b: Block): DockPane => ({ id: b.id, title: dockPaneTitle(b), closable: true })
|
|
1289
|
+
const roleOf = (b: Block): 'left' | 'center' | 'right' | 'top' => {
|
|
1290
|
+
switch (b.config.kind) {
|
|
1291
|
+
case 'filter': return 'left'
|
|
1292
|
+
case 'record': case 'detail': return 'right'
|
|
1293
|
+
case 'kpi': case 'gauge': return 'top'
|
|
1294
|
+
default: return 'center'
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
const groupOf = (direction: 'row' | 'column', children: DockNode[], sizes: number[]): DockNode => ({ type: 'group', id: genId('dg-'), direction, children, sizes })
|
|
1298
|
+
const norm = (ws: number[]): number[] => { const s = ws.reduce((a, b) => a + b, 0) || 1; return ws.map((w) => w / s) }
|
|
1299
|
+
// Each block gets its OWN leaf, so split view can size every block independently
|
|
1300
|
+
// (grouping them into one tabs leaf would trap them as tabs - unresizable when
|
|
1301
|
+
// the manager is locked). Multiple blocks in a bucket stack along the given axis.
|
|
1302
|
+
const oneLeaf = (b: Block): DockTabs => ({ type: 'tabs', id: genId('dt-'), panes: [paneFor(b)], active: 0 })
|
|
1303
|
+
const stackOf = (bs: Block[], direction: 'row' | 'column'): DockNode =>
|
|
1304
|
+
bs.length === 1 ? oneLeaf(bs[0]!) : groupOf(direction, bs.map(oneLeaf), norm(bs.map(() => 1)))
|
|
1305
|
+
// A forced split orientation (split-view setting) ignores roles and lays ALL
|
|
1306
|
+
// blocks out as equal panes in one row / column.
|
|
1307
|
+
const forced = splitOpts(screen).orientation
|
|
1308
|
+
if (forced !== 'auto' && screen.blocks.length) {
|
|
1309
|
+
return { main: stackOf(screen.blocks, forced), floating: [], autoHide: [] }
|
|
1310
|
+
}
|
|
1311
|
+
const buckets: Record<'left' | 'center' | 'right' | 'top', Block[]> = { left: [], center: [], right: [], top: [] }
|
|
1312
|
+
for (const b of screen.blocks) buckets[roleOf(b)].push(b)
|
|
1313
|
+
const rowChildren: DockNode[] = []
|
|
1314
|
+
const rowWeights: number[] = []
|
|
1315
|
+
if (buckets.left.length) { rowChildren.push(stackOf(buckets.left, 'column')); rowWeights.push(1) }
|
|
1316
|
+
if (buckets.center.length) { rowChildren.push(stackOf(buckets.center, 'column')); rowWeights.push(3) }
|
|
1317
|
+
if (buckets.right.length) { rowChildren.push(stackOf(buckets.right, 'column')); rowWeights.push(1) }
|
|
1318
|
+
const row: DockNode | null = rowChildren.length === 0 ? null : rowChildren.length === 1 ? rowChildren[0]! : groupOf('row', rowChildren, norm(rowWeights))
|
|
1319
|
+
let main: DockNode
|
|
1320
|
+
if (buckets.top.length && row) main = groupOf('column', [stackOf(buckets.top, 'row'), row], [0.28, 0.72])
|
|
1321
|
+
else if (buckets.top.length) main = stackOf(buckets.top, 'row')
|
|
1322
|
+
else if (row) main = row
|
|
1323
|
+
else main = { type: 'tabs', id: genId('dt-'), panes: [], active: 0 }
|
|
1324
|
+
return { main, floating: [], autoHide: [] }
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
/** Every group / tabs node id in a workspace (to keep freshly generated ids unique). */
|
|
1328
|
+
function collectDockNodeIds(state: DockManagerState): string[] {
|
|
1329
|
+
const ids: string[] = []
|
|
1330
|
+
const walk = (n: DockNode) => { ids.push(n.id); if (n.type === 'group') for (const c of n.children) walk(c) }
|
|
1331
|
+
if (state.main) walk(state.main)
|
|
1332
|
+
for (const w of state.floating) { ids.push(w.id); walk(w.leaf) }
|
|
1333
|
+
for (const e of state.autoHide) { ids.push(e.id); walk(e.leaf) }
|
|
1334
|
+
return ids
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
/** Append a pane to the main area's first tabs leaf (creating one if the area is empty). */
|
|
1338
|
+
function addDockPaneToMain(state: DockManagerState, p: DockPane, genId: () => string): DockManagerState {
|
|
1339
|
+
const main = state.main
|
|
1340
|
+
if (!main) return { ...state, main: { type: 'tabs', id: genId(), panes: [p], active: 0 } }
|
|
1341
|
+
let added = false
|
|
1342
|
+
const add = (n: DockNode): DockNode => {
|
|
1343
|
+
if (added) return n
|
|
1344
|
+
if (n.type === 'tabs') { added = true; return { ...n, panes: [...n.panes, p], active: n.panes.length } }
|
|
1345
|
+
return { ...n, children: n.children.map(add) }
|
|
1346
|
+
}
|
|
1347
|
+
const nextMain = add(main)
|
|
1348
|
+
if (added) return { ...state, main: nextMain }
|
|
1349
|
+
// No tabs leaf anywhere: put main + a new tabs side by side.
|
|
1350
|
+
return { ...state, main: { type: 'group', id: genId(), direction: 'row', children: [main, { type: 'tabs', id: genId(), panes: [p], active: 0 }], sizes: [0.7, 0.3] } }
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
/** Remove a pane anywhere in a workspace; collapse emptied tabs + single-child groups. */
|
|
1354
|
+
function stripDockPane(state: DockManagerState, paneId: string): DockManagerState {
|
|
1355
|
+
const strip = (n: DockNode): DockNode | null => {
|
|
1356
|
+
if (n.type === 'tabs') { const panes = n.panes.filter((p: DockPane) => p.id !== paneId); return panes.length ? { ...n, panes, active: Math.min(n.active, panes.length - 1) } : null }
|
|
1357
|
+
const children = n.children.map(strip).filter((c: DockNode | null): c is DockNode => c !== null)
|
|
1358
|
+
if (children.length === 0) return null
|
|
1359
|
+
if (children.length === 1) return children[0]!
|
|
1360
|
+
return { ...n, children, sizes: children.map(() => 1 / children.length) }
|
|
1361
|
+
}
|
|
1362
|
+
const main = state.main ? strip(state.main) : null
|
|
1363
|
+
const floating = state.floating.map((w: DockManagerState['floating'][number]) => ({ ...w, leaf: { ...w.leaf, panes: w.leaf.panes.filter((p: DockPane) => p.id !== paneId) } })).filter((w: DockManagerState['floating'][number]) => w.leaf.panes.length > 0)
|
|
1364
|
+
const autoHide = state.autoHide.map((e: DockManagerState['autoHide'][number]) => ({ ...e, leaf: { ...e.leaf, panes: e.leaf.panes.filter((p: DockPane) => p.id !== paneId) } })).filter((e: DockManagerState['autoHide'][number]) => e.leaf.panes.length > 0)
|
|
1365
|
+
return { ...state, main, floating, autoHide }
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
/** Incrementally reconcile a dock workspace to the screen's blocks - add a pane for each new
|
|
1369
|
+
* block, strip panes for removed blocks - WITHOUT rebuilding, so the user's arrangement
|
|
1370
|
+
* (splits / floats / pins) is preserved. Seeds a fresh workspace if none exists. */
|
|
1371
|
+
export function syncDockPanes(screen: Screen): Screen {
|
|
1372
|
+
if (!isPaneLayout(screenLayoutOf(screen))) return screen
|
|
1373
|
+
if (!screen.dock) return { ...screen, dock: buildDockLayout(screen) }
|
|
1374
|
+
const blockIds = new Set(screen.blocks.map((b) => b.id))
|
|
1375
|
+
const present = dockPaneIds(screen.dock)
|
|
1376
|
+
const missing = screen.blocks.filter((b) => !present.has(b.id))
|
|
1377
|
+
const extra = [...present].filter((id) => !blockIds.has(id))
|
|
1378
|
+
if (missing.length === 0 && extra.length === 0) return screen
|
|
1379
|
+
let dock = screen.dock
|
|
1380
|
+
for (const id of extra) dock = stripDockPane(dock, id)
|
|
1381
|
+
const taken = new Set<string>([...blockIds, ...collectDockNodeIds(dock)])
|
|
1382
|
+
const genId = () => { const id = uid('dt-', taken); taken.add(id); return id }
|
|
1383
|
+
for (const b of missing) dock = addDockPaneToMain(dock, { id: b.id, title: dockPaneTitle(b), closable: true }, genId)
|
|
1384
|
+
return { ...screen, dock }
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
/** Rebuild the dock workspace if its panes no longer match the screen's blocks (a safety
|
|
1388
|
+
* net after block add / delete while the visual editor hasn't reconciled incrementally). */
|
|
1389
|
+
export function reconcileDock(screen: Screen): Screen {
|
|
1390
|
+
if (!isPaneLayout(screenLayoutOf(screen))) return screen
|
|
1391
|
+
const blockIds = new Set(screen.blocks.map((b) => b.id))
|
|
1392
|
+
const paneIds = screen.dock ? dockPaneIds(screen.dock) : new Set<string>()
|
|
1393
|
+
const same = !!screen.dock && blockIds.size === paneIds.size && [...blockIds].every((id) => paneIds.has(id))
|
|
1394
|
+
return same ? screen : { ...screen, dock: buildDockLayout(screen) }
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
/** Switch a screen between the 12-column grid and a docking workspace. Switching to
|
|
1398
|
+
* `'dock'` seeds a workspace (smart auto-layout) if none exists yet. */
|
|
1399
|
+
export function setScreenLayout(project: StudioProject, screenId: string, layout: ScreenLayout): StudioProject {
|
|
1400
|
+
// Both pane layouts (split / dock) back onto a DockManagerState; canvas seeds
|
|
1401
|
+
// explicit cell coordinates. Switching split<->dock preserves the existing
|
|
1402
|
+
// arrangement, but coming FROM a non-pane layout (grid / stack / canvas) seeds a
|
|
1403
|
+
// fresh one - so a stale arrangement never carries over into a new workspace.
|
|
1404
|
+
return mapScreen(project, screenId, (s) => {
|
|
1405
|
+
if (isPaneLayout(layout)) {
|
|
1406
|
+
const dock = isPaneLayout(screenLayoutOf(s)) && s.dock ? s.dock : buildDockLayout(s)
|
|
1407
|
+
return { ...s, layout, dock }
|
|
1408
|
+
}
|
|
1409
|
+
if (layout === 'canvas') return { ...s, layout, canvas: s.canvas ?? buildCanvasLayout(s) }
|
|
1410
|
+
return { ...s, layout }
|
|
1411
|
+
})
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
// --- free-form canvas geometry ----------------------------------------------
|
|
1415
|
+
|
|
1416
|
+
const clampInt = (n: number, lo: number, hi: number): number => Math.max(lo, Math.min(hi, Math.round(n)))
|
|
1417
|
+
|
|
1418
|
+
/** A sensible default row height (in canvas rows) for a block kind - tall for
|
|
1419
|
+
* data views, short for a single stat. */
|
|
1420
|
+
function defaultCanvasRows(block: Block): number {
|
|
1421
|
+
const kind = block.config.kind
|
|
1422
|
+
if (kind === 'kpi' || kind === 'gauge') return 3
|
|
1423
|
+
if (kind === 'filter') return 4
|
|
1424
|
+
if (kind === 'chart' || kind === 'record' || kind === 'detail') return 6
|
|
1425
|
+
return 8 // grid / board / pivot / tree / dashboard / master-detail / form / etc.
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
/** Seed canvas rects for every block: flow them two-up (half-width each) down the
|
|
1429
|
+
* page, so switching to canvas gives a reasonable starting arrangement. */
|
|
1430
|
+
export function buildCanvasLayout(screen: Screen): Record<string, CanvasRect> {
|
|
1431
|
+
const cols = canvasOpts(screen).cols
|
|
1432
|
+
const out: Record<string, CanvasRect> = {}
|
|
1433
|
+
let row = 0
|
|
1434
|
+
let col = 0
|
|
1435
|
+
let rowMax = 0
|
|
1436
|
+
for (const b of screen.blocks) {
|
|
1437
|
+
const colSpan = Math.max(1, Math.round(cols / 2))
|
|
1438
|
+
const rowSpan = defaultCanvasRows(b)
|
|
1439
|
+
if (col + colSpan > cols) { col = 0; row += rowMax; rowMax = 0 }
|
|
1440
|
+
out[b.id] = { col, row, colSpan, rowSpan }
|
|
1441
|
+
col += colSpan
|
|
1442
|
+
rowMax = Math.max(rowMax, rowSpan)
|
|
1443
|
+
}
|
|
1444
|
+
return out
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
/** The stored rect for a block, or a computed default (so a block added after the
|
|
1448
|
+
* layout was seeded still lands somewhere sane). */
|
|
1449
|
+
export function canvasRectOf(screen: Screen, blockId: string): CanvasRect {
|
|
1450
|
+
const stored = screen.canvas?.[blockId]
|
|
1451
|
+
const cols = canvasOpts(screen).cols
|
|
1452
|
+
if (stored) {
|
|
1453
|
+
// Clamp a stored rect to the current column count (it may have been placed
|
|
1454
|
+
// when the canvas had more columns).
|
|
1455
|
+
const colSpan = Math.max(1, Math.min(stored.colSpan, cols))
|
|
1456
|
+
return { ...stored, colSpan, col: Math.max(0, Math.min(stored.col, cols - colSpan)) }
|
|
1457
|
+
}
|
|
1458
|
+
return buildCanvasLayout(screen)[blockId] ?? { col: 0, row: 0, colSpan: Math.max(1, Math.round(cols / 2)), rowSpan: 8 }
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
/** Move / resize a block on the canvas. Values are clamped to the screen's column
|
|
1462
|
+
* count (col in-grid, colSpan 1..cols; row >= 0, rowSpan >= 1). */
|
|
1463
|
+
export function setCanvasRect(project: StudioProject, screenId: string, blockId: string, rect: Partial<CanvasRect>): StudioProject {
|
|
1464
|
+
return mapScreen(project, screenId, (s) => {
|
|
1465
|
+
const cols = canvasOpts(s).cols
|
|
1466
|
+
const cur = canvasRectOf(s, blockId)
|
|
1467
|
+
const colSpan = clampInt(rect.colSpan ?? cur.colSpan, 1, cols)
|
|
1468
|
+
const col = clampInt(rect.col ?? cur.col, 0, cols - colSpan)
|
|
1469
|
+
const rowSpan = Math.max(1, clampInt(rect.rowSpan ?? cur.rowSpan, 1, 999))
|
|
1470
|
+
const row = Math.max(0, clampInt(rect.row ?? cur.row, 0, 999))
|
|
1471
|
+
return { ...s, canvas: { ...s.canvas, [blockId]: { col, row, colSpan, rowSpan } } }
|
|
1472
|
+
})
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
/** Named starting arrangements for the 12-column grid - a one-click way to set
|
|
1476
|
+
* every block's column span to a common pattern instead of picking each by hand. */
|
|
1477
|
+
export type GridPreset = 'full' | 'two-col' | 'three-col' | 'sidebar' | 'kpi-row'
|
|
1478
|
+
|
|
1479
|
+
const GRID_PRESET_LABELS: Record<GridPreset, string> = {
|
|
1480
|
+
full: 'Full width',
|
|
1481
|
+
'two-col': 'Two columns',
|
|
1482
|
+
'three-col': 'Three columns',
|
|
1483
|
+
sidebar: 'Sidebar + main',
|
|
1484
|
+
'kpi-row': 'KPI row + full',
|
|
1485
|
+
}
|
|
1486
|
+
export function gridPresetLabel(preset: GridPreset): string { return GRID_PRESET_LABELS[preset] }
|
|
1487
|
+
export const GRID_PRESETS: ReadonlyArray<GridPreset> = ['full', 'two-col', 'three-col', 'sidebar', 'kpi-row']
|
|
1488
|
+
|
|
1489
|
+
/** Small, wide KPI-like blocks that a "KPI row" preset lays out as a strip. */
|
|
1490
|
+
const KPI_ROW_KINDS: ReadonlyArray<BlockKind> = ['kpi', 'gauge']
|
|
1491
|
+
|
|
1492
|
+
/** Apply a grid preset: rewrite the top-level blocks' `colSpan` to the pattern.
|
|
1493
|
+
* Only touches the current screen's own blocks (nested container children keep
|
|
1494
|
+
* their spans). A no-op for a screen with no blocks. */
|
|
1495
|
+
export function applyGridPreset(project: StudioProject, screenId: string, preset: GridPreset): StudioProject {
|
|
1496
|
+
return mapScreen(project, screenId, (s) => {
|
|
1497
|
+
if (s.blocks.length === 0) return s
|
|
1498
|
+
const span = (i: number, b: Block): number => {
|
|
1499
|
+
switch (preset) {
|
|
1500
|
+
case 'full': return 12
|
|
1501
|
+
case 'two-col': return 6
|
|
1502
|
+
case 'three-col': return 4
|
|
1503
|
+
// Narrow first block (filters / nav) beside a wide content column.
|
|
1504
|
+
case 'sidebar': return i === 0 ? 4 : 8
|
|
1505
|
+
// KPI-ish blocks form a 4-across strip; everything else spans full.
|
|
1506
|
+
case 'kpi-row': return KPI_ROW_KINDS.includes(b.config.kind) ? 3 : 12
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
return { ...s, blocks: s.blocks.map((b, i) => ({ ...b, colSpan: span(i, b) })) }
|
|
1510
|
+
})
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
/** Persist a new dock workspace (user rearranged panes in the designer). */
|
|
1514
|
+
export function setScreenDock(project: StudioProject, screenId: string, dock: DockManagerState): StudioProject {
|
|
1515
|
+
return mapScreen(project, screenId, (s) => ({ ...s, dock }))
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
// --- screen state variables --------------------------------------------------
|
|
1519
|
+
|
|
1520
|
+
/** Add a state variable (unique name; `state_N` if the name is taken / empty). */
|
|
1521
|
+
export function addStateVar(project: StudioProject, screenId: string, v: Partial<StateVar> = {}): StudioProject {
|
|
1522
|
+
return mapScreen(project, screenId, (s) => {
|
|
1523
|
+
const taken = new Set((s.state ?? []).map((x) => x.name))
|
|
1524
|
+
let name = identSafe(v.name || 'value')
|
|
1525
|
+
if (taken.has(name)) { let i = 2; while (taken.has(`${name}${i}`)) i++; name = `${name}${i}` }
|
|
1526
|
+
const next: StateVar = { name, type: v.type ?? 'string', initial: v.initial }
|
|
1527
|
+
return { ...s, state: [...(s.state ?? []), next] }
|
|
1528
|
+
})
|
|
1529
|
+
}
|
|
1530
|
+
/** Patch a state variable by (old) name. Renames keep the identifier valid + unique. */
|
|
1531
|
+
export function updateStateVar(project: StudioProject, screenId: string, name: string, patch: Partial<StateVar>): StudioProject {
|
|
1532
|
+
return mapScreen(project, screenId, (s) => {
|
|
1533
|
+
const cur = s.state ?? []
|
|
1534
|
+
const taken = new Set(cur.filter((x) => x.name !== name).map((x) => x.name))
|
|
1535
|
+
return {
|
|
1536
|
+
...s,
|
|
1537
|
+
state: cur.map((x) => {
|
|
1538
|
+
if (x.name !== name) return x
|
|
1539
|
+
let nm = patch.name !== undefined ? identSafe(patch.name) : x.name
|
|
1540
|
+
if (nm !== x.name && taken.has(nm)) { let i = 2; while (taken.has(`${nm}${i}`)) i++; nm = `${nm}${i}` }
|
|
1541
|
+
return { ...x, ...patch, name: nm }
|
|
1542
|
+
}),
|
|
1543
|
+
}
|
|
1544
|
+
})
|
|
1545
|
+
}
|
|
1546
|
+
/** Remove a state variable by name. */
|
|
1547
|
+
export function removeStateVar(project: StudioProject, screenId: string, name: string): StudioProject {
|
|
1548
|
+
return mapScreen(project, screenId, (s) => ({ ...s, state: (s.state ?? []).filter((x) => x.name !== name) }))
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
/** Rebuild the pane arrangement from scratch (smart auto-layout, honoring the
|
|
1552
|
+
* current split orientation). Used when a setting invalidates the layout - e.g.
|
|
1553
|
+
* the user flips split orientation - or as a "reset arrangement" action. */
|
|
1554
|
+
export function reseedScreenDock(project: StudioProject, screenId: string): StudioProject {
|
|
1555
|
+
return mapScreen(project, screenId, (s) => ({ ...s, dock: buildDockLayout(s) }))
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
/** The current title (tab text) of a block's dock pane, if the screen is docked. */
|
|
1559
|
+
export function dockPaneTitleOf(screen: Screen, paneId: string): string | undefined {
|
|
1560
|
+
if (!screen.dock) return undefined
|
|
1561
|
+
let found: string | undefined
|
|
1562
|
+
const walk = (n: DockNode) => { if (n.type === 'tabs') { const p = n.panes.find((x: DockPane) => x.id === paneId); if (p) found = p.title } else for (const c of n.children) walk(c) }
|
|
1563
|
+
if (screen.dock.main) walk(screen.dock.main)
|
|
1564
|
+
for (const w of screen.dock.floating) { const p = w.leaf.panes.find((x: DockPane) => x.id === paneId); if (p) found = p.title }
|
|
1565
|
+
for (const e of screen.dock.autoHide) { const p = e.leaf.panes.find((x: DockPane) => x.id === paneId); if (p) found = p.title }
|
|
1566
|
+
return found
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
/** Rename a block's dock pane (the tab text) anywhere in the workspace. */
|
|
1570
|
+
export function setDockPaneTitle(project: StudioProject, screenId: string, paneId: string, title: string): StudioProject {
|
|
1571
|
+
return mapScreen(project, screenId, (s) => {
|
|
1572
|
+
if (!s.dock) return s
|
|
1573
|
+
const relabelPanes = (panes: DockPane[]): DockPane[] => panes.map((p) => (p.id === paneId ? { ...p, title } : p))
|
|
1574
|
+
const relabel = (n: DockNode): DockNode => (n.type === 'tabs' ? { ...n, panes: relabelPanes(n.panes) } : { ...n, children: n.children.map(relabel) })
|
|
1575
|
+
const main = s.dock.main ? relabel(s.dock.main) : null
|
|
1576
|
+
const floating = s.dock.floating.map((w: DockManagerState['floating'][number]) => ({ ...w, leaf: { ...w.leaf, panes: relabelPanes(w.leaf.panes) } }))
|
|
1577
|
+
const autoHide = s.dock.autoHide.map((e: DockManagerState['autoHide'][number]) => ({ ...e, leaf: { ...e.leaf, panes: relabelPanes(e.leaf.panes) } }))
|
|
1578
|
+
return { ...s, dock: { ...s.dock, main, floating, autoHide } }
|
|
1579
|
+
})
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
/** A freestanding screen: no bound entity, so no blocks/data binding - just a
|
|
1583
|
+
* title and (once actions are added) a toolbar. For a blank page the developer
|
|
1584
|
+
* builds on, or one that's purely a home for custom actions ("Run report"). */
|
|
1585
|
+
export function addFreestandingScreen(project: StudioProject, opts: { title: string; route?: string }): StudioProject {
|
|
1586
|
+
const id = 'screen'
|
|
1587
|
+
const route = opts.route ?? id
|
|
1588
|
+
return appendScreen(project, { id, title: opts.title, route, blocks: [] }, 'screen')
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
/** Every custom action id already used project-wide (screen toolbars + row
|
|
1592
|
+
* actions) - action ids become `/api/actions/<id>` routes, so they must be
|
|
1593
|
+
* unique across the whole project, not just within one screen. */
|
|
1594
|
+
function takenActionIds(project: StudioProject): Set<string> {
|
|
1595
|
+
const taken = new Set<string>()
|
|
1596
|
+
for (const s of project.screens) {
|
|
1597
|
+
for (const a of s.actions ?? []) taken.add(a.id)
|
|
1598
|
+
for (const b of flattenBlocks(s.blocks)) {
|
|
1599
|
+
if (b.config.kind !== 'grid') continue
|
|
1600
|
+
for (const a of b.config.rowActions ?? []) if (a.kind === 'custom' && a.id) taken.add(a.id)
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
return taken
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
/** Add a toolbar-level custom action to a screen (works on a freestanding screen
|
|
1607
|
+
* too). Generates a stable id from `taken` project-wide ids; the generated app
|
|
1608
|
+
* gets a wired-up button + a stub `/api/actions/<id>` route to fill in. */
|
|
1609
|
+
export function addScreenAction(project: StudioProject, screenId: string, action: { label: string; icon?: string; confirm?: string }): StudioProject {
|
|
1610
|
+
const id = uid('action-', takenActionIds(project))
|
|
1611
|
+
return mapScreen(project, screenId, (s) => ({ ...s, actions: [...(s.actions ?? []), { id, ...action }] }))
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
export function removeScreenAction(project: StudioProject, screenId: string, actionId: string): StudioProject {
|
|
1615
|
+
return mapScreen(project, screenId, (s) => ({ ...s, actions: (s.actions ?? []).filter((a) => a.id !== actionId) }))
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
/** Opt a screen into a user-owned `handlers.ts` companion (design + your own code).
|
|
1619
|
+
* The generator scaffolds the stub once and never rewrites it. */
|
|
1620
|
+
export function enableScreenCode(project: StudioProject, screenId: string): StudioProject {
|
|
1621
|
+
return mapScreen(project, screenId, (s) => ({ ...s, code: true }))
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
/** Drop the code companion binding (leaves any file the user already wrote on disk;
|
|
1625
|
+
* the generator simply stops emitting/importing it). */
|
|
1626
|
+
export function disableScreenCode(project: StudioProject, screenId: string): StudioProject {
|
|
1627
|
+
return mapScreen(project, screenId, (s) => ({ ...s, code: false, renderGrid: undefined }))
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
/** Toggle whether the page renders a Grid fed by `ctx.setRows` from `onLoad`.
|
|
1631
|
+
* Implies `code: true` (a Grid needs the handler to fill it). */
|
|
1632
|
+
export function setScreenRenderGrid(project: StudioProject, screenId: string, on: boolean): StudioProject {
|
|
1633
|
+
return mapScreen(project, screenId, (s) => ({ ...s, code: on ? true : s.code, renderGrid: on || undefined }))
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
/** Set the body of one event handler (e.g. `load`) - the code inside the generated
|
|
1637
|
+
* function. This is what the designer's Code view edits per slot (the "single
|
|
1638
|
+
* onLoad block"). Empty clears it back to the stub default. Implies `code: true`. */
|
|
1639
|
+
export function setHandlerBody(project: StudioProject, screenId: string, handler: string, body: string): StudioProject {
|
|
1640
|
+
return mapScreen(project, screenId, (s) => {
|
|
1641
|
+
const bodies = { ...(s.handlerBodies ?? {}) }
|
|
1642
|
+
if (body.trim()) bodies[handler] = body
|
|
1643
|
+
else delete bodies[handler]
|
|
1644
|
+
return { ...s, code: true, handlerBodies: Object.keys(bodies).length ? bodies : undefined }
|
|
1645
|
+
})
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
/** Set the full source the designer writes into the screen's `handlers.ts` companion.
|
|
1649
|
+
* An advanced escape hatch that overrides the structured per-event bodies: when
|
|
1650
|
+
* present it is emitted verbatim. Empty string clears it. Implies `code: true`. */
|
|
1651
|
+
export function setScreenHandlersSource(project: StudioProject, screenId: string, source: string): StudioProject {
|
|
1652
|
+
const trimmed = source.trim()
|
|
1653
|
+
return mapScreen(project, screenId, (s) => ({ ...s, code: true, handlersSource: trimmed || undefined }))
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
// --- visual methods (the Methods panel) ------------------------------------
|
|
1657
|
+
/** Replace the steps of one method slot (`onLoad`, `onDestroy`, or `click:<blockId>`).
|
|
1658
|
+
* An empty list clears the slot. Implies `code: true`. */
|
|
1659
|
+
export function setHandlerSteps(project: StudioProject, screenId: string, slot: string, steps: ActionStep[]): StudioProject {
|
|
1660
|
+
return mapScreen(project, screenId, (s) => {
|
|
1661
|
+
const all = { ...(s.handlerSteps ?? {}) }
|
|
1662
|
+
if (steps.length) all[slot] = steps
|
|
1663
|
+
else delete all[slot]
|
|
1664
|
+
return { ...s, code: true, handlerSteps: Object.keys(all).length ? all : undefined }
|
|
1665
|
+
})
|
|
1666
|
+
}
|
|
1667
|
+
/** Append a step to a method slot. */
|
|
1668
|
+
export function addHandlerStep(project: StudioProject, screenId: string, slot: string, step: ActionStep): StudioProject {
|
|
1669
|
+
const s = project.screens.find((x) => x.id === screenId)
|
|
1670
|
+
return setHandlerSteps(project, screenId, slot, [...(s?.handlerSteps?.[slot] ?? []), step])
|
|
1671
|
+
}
|
|
1672
|
+
/** Replace / remove a step at `index` (null removes it). */
|
|
1673
|
+
export function updateHandlerStep(project: StudioProject, screenId: string, slot: string, index: number, step: ActionStep | null): StudioProject {
|
|
1674
|
+
const s = project.screens.find((x) => x.id === screenId)
|
|
1675
|
+
const steps = [...(s?.handlerSteps?.[slot] ?? [])]
|
|
1676
|
+
if (index < 0 || index >= steps.length) return project
|
|
1677
|
+
if (step === null) steps.splice(index, 1)
|
|
1678
|
+
else steps[index] = step
|
|
1679
|
+
return setHandlerSteps(project, screenId, slot, steps)
|
|
1680
|
+
}
|
|
1681
|
+
/** Move a step within its slot (dir -1 up, +1 down). */
|
|
1682
|
+
export function moveHandlerStep(project: StudioProject, screenId: string, slot: string, index: number, dir: -1 | 1): StudioProject {
|
|
1683
|
+
const s = project.screens.find((x) => x.id === screenId)
|
|
1684
|
+
const steps = [...(s?.handlerSteps?.[slot] ?? [])]
|
|
1685
|
+
const j = index + dir
|
|
1686
|
+
if (index < 0 || index >= steps.length || j < 0 || j >= steps.length) return project
|
|
1687
|
+
;[steps[index], steps[j]] = [steps[j]!, steps[index]!]
|
|
1688
|
+
return setHandlerSteps(project, screenId, slot, steps)
|
|
1689
|
+
}
|
|
1690
|
+
/** Drop the visual steps for a slot into the raw code editor (compile once), so a
|
|
1691
|
+
* user can hand-edit from there. Moves `handlerSteps[slot]` -> `handlerBodies[slot]`. */
|
|
1692
|
+
export function stepsToCode(project: StudioProject, screenId: string, slot: string): StudioProject {
|
|
1693
|
+
const s = project.screens.find((x) => x.id === screenId)
|
|
1694
|
+
const steps = s?.handlerSteps?.[slot]
|
|
1695
|
+
if (!steps?.length) return project
|
|
1696
|
+
const withBody = setHandlerBody(project, screenId, slot, compileHandlerSteps(steps))
|
|
1697
|
+
return setHandlerSteps(withBody, screenId, slot, [])
|
|
1698
|
+
}
|
|
1699
|
+
|
|
634
1700
|
/** Deep-clone `block` with fresh ids (recursing into Tabs children). */
|
|
635
1701
|
function freshBlockIds(blocks: ReadonlyArray<Block>, taken: Set<string>): Block[] {
|
|
636
1702
|
return blocks.map((b) => {
|
|
@@ -638,6 +1704,7 @@ function freshBlockIds(blocks: ReadonlyArray<Block>, taken: Set<string>): Block[
|
|
|
638
1704
|
const id = uid(`${config.kind}-`, taken)
|
|
639
1705
|
taken.add(id)
|
|
640
1706
|
if (config.kind === 'tabs') config.tabs = config.tabs.map((t) => ({ ...t, blocks: freshBlockIds(t.blocks, taken) }))
|
|
1707
|
+
if (config.kind === 'accordion') config.sections = config.sections.map((s) => ({ ...s, blocks: freshBlockIds(s.blocks, taken) }))
|
|
641
1708
|
return { ...b, id, config }
|
|
642
1709
|
})
|
|
643
1710
|
}
|
|
@@ -689,6 +1756,46 @@ export function setDeployTarget(project: StudioProject, deploy: DeployTarget): S
|
|
|
689
1756
|
return { ...project, deploy }
|
|
690
1757
|
}
|
|
691
1758
|
|
|
1759
|
+
/** Enable / disable the authentication starter (login + session + hooks). */
|
|
1760
|
+
export function setAuth(project: StudioProject, patch: Partial<AuthConfig> & { enabled: boolean }): StudioProject {
|
|
1761
|
+
if (!patch.enabled) { const { auth: _drop, ...rest } = project; return rest }
|
|
1762
|
+
const prev = project.auth
|
|
1763
|
+
const oauth = patch.oauth ?? prev?.oauth
|
|
1764
|
+
return {
|
|
1765
|
+
...project,
|
|
1766
|
+
auth: {
|
|
1767
|
+
enabled: true,
|
|
1768
|
+
protect: patch.protect ?? prev?.protect ?? true,
|
|
1769
|
+
register: patch.register ?? prev?.register ?? false,
|
|
1770
|
+
userAdmin: patch.userAdmin ?? prev?.userAdmin ?? false,
|
|
1771
|
+
...(oauth && oauth.length ? { oauth } : {}),
|
|
1772
|
+
...((patch.twoFactor ?? prev?.twoFactor) ? { twoFactor: true } : {}),
|
|
1773
|
+
...((patch.email ?? prev?.email) ? { email: true } : {}),
|
|
1774
|
+
},
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
/** Enable / disable the typed Drizzle data layer (schema + repos + migrations). */
|
|
1779
|
+
export function setDataLayer(project: StudioProject, enabled: boolean): StudioProject {
|
|
1780
|
+
if (!enabled) { const { dataLayer: _drop, ...rest } = project; return rest }
|
|
1781
|
+
return { ...project, dataLayer: 'drizzle' }
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
/** A demo user seed for the auth starter: one user per RBAC role (so you can sign in
|
|
1785
|
+
* and see each role's access), or a single admin when RBAC is off. Passwords are
|
|
1786
|
+
* demo seeds (like sample rows) - the generated code flags them for replacement. */
|
|
1787
|
+
export type SeedUser = { email: string; name: string; role: string; password: string }
|
|
1788
|
+
export function seedUsers(project: StudioProject): SeedUser[] {
|
|
1789
|
+
const roles = project.access?.enabled ? project.access.roles.map((r) => r.role) : []
|
|
1790
|
+
if (!roles.length) return [{ email: 'admin@example.com', name: 'Admin', password: 'admin1234', role: 'admin' }]
|
|
1791
|
+
return roles.map((role) => ({
|
|
1792
|
+
email: `${role.replace(/[^a-z0-9]+/gi, '')}@example.com`.toLowerCase(),
|
|
1793
|
+
name: role.charAt(0).toUpperCase() + role.slice(1),
|
|
1794
|
+
role,
|
|
1795
|
+
password: `${role.replace(/[^a-z0-9]+/gi, '').toLowerCase()}1234`,
|
|
1796
|
+
}))
|
|
1797
|
+
}
|
|
1798
|
+
|
|
692
1799
|
/** A skeleton binding for a kind, seeded with the entity's name as its table/path. */
|
|
693
1800
|
export function defaultEntitySource(kind: DataSourceKind, entityName: string): EntityDataSource {
|
|
694
1801
|
switch (kind) {
|
|
@@ -867,9 +1974,12 @@ export function parseProject(json: string): StudioProject {
|
|
|
867
1974
|
...(p.dataSources && typeof p.dataSources === 'object' ? { dataSources: p.dataSources as Record<string, EntityDataSource> } : {}),
|
|
868
1975
|
...(p.theme && typeof p.theme === 'object' ? { theme: p.theme as ProjectTheme } : {}),
|
|
869
1976
|
...(p.access && typeof p.access === 'object' ? { access: p.access as AccessControl } : {}),
|
|
1977
|
+
...(p.auth && typeof p.auth === 'object' && (p.auth as AuthConfig).enabled ? { auth: p.auth as AuthConfig } : {}),
|
|
1978
|
+
...(p.dataLayer === 'drizzle' ? { dataLayer: 'drizzle' as const } : {}),
|
|
870
1979
|
...(typeof p.audit === 'boolean' ? { audit: p.audit } : {}),
|
|
871
1980
|
...(p.i18n && typeof p.i18n === 'object' ? { i18n: p.i18n as I18nConfig } : {}),
|
|
872
1981
|
...(typeof p.deploy === 'string' && p.deploy !== 'auto' ? { deploy: p.deploy as DeployTarget } : {}),
|
|
1982
|
+
...(p.triggers && typeof p.triggers === 'object' ? { triggers: p.triggers as Record<string, EntityTriggers> } : {}),
|
|
873
1983
|
})
|
|
874
1984
|
}
|
|
875
1985
|
|
|
@@ -881,12 +1991,18 @@ export function validateProject(project: StudioProject): ProjectIssue[] {
|
|
|
881
1991
|
|
|
882
1992
|
const routes = new Set<string>()
|
|
883
1993
|
for (const s of project.screens) {
|
|
884
|
-
|
|
1994
|
+
// `entity` is optional (a freestanding screen has none, by design) - only a
|
|
1995
|
+
// *dangling* reference (set but unresolvable) is an error.
|
|
1996
|
+
if (s.entity !== undefined && !entityOf(project, s.entity)) {
|
|
885
1997
|
issues.push({ level: 'error', message: `Screen "${s.title}" points at a missing entity "${s.entity}".`, screen: s.id })
|
|
886
1998
|
}
|
|
887
1999
|
if (routes.has(s.route)) issues.push({ level: 'error', message: `Duplicate route "/${s.route}".`, screen: s.id })
|
|
888
2000
|
routes.add(s.route)
|
|
889
|
-
if (s.blocks.length === 0)
|
|
2001
|
+
if (s.entity !== undefined && s.blocks.length === 0) {
|
|
2002
|
+
issues.push({ level: 'warning', message: `Screen "${s.title}" has no blocks.`, screen: s.id })
|
|
2003
|
+
} else if (s.entity === undefined && !s.actions?.length && s.blocks.length === 0) {
|
|
2004
|
+
issues.push({ level: 'warning', message: `Screen "${s.title}" is empty - add an action or some content.`, screen: s.id })
|
|
2005
|
+
}
|
|
890
2006
|
for (const b of flattenBlocks(s.blocks)) {
|
|
891
2007
|
const at = (message: string, level: ProjectIssueLevel = 'warning'): ProjectIssue => ({ level, message, screen: s.id, block: b.id })
|
|
892
2008
|
const c = b.config
|
|
@@ -905,6 +2021,10 @@ export function validateProject(project: StudioProject): ProjectIssue[] {
|
|
|
905
2021
|
if (!c.rows.length && !c.cols.length) issues.push(at('Pivot has no row or column dimensions.'))
|
|
906
2022
|
} else if (c.kind === 'tabs') {
|
|
907
2023
|
if (c.tabs.every((t) => t.blocks.length === 0)) issues.push(at('Tabs container has no blocks in any tab.'))
|
|
2024
|
+
} else if (c.kind === 'accordion') {
|
|
2025
|
+
if (c.sections.every((s) => s.blocks.length === 0)) issues.push(at('Accordion container has no blocks in any section.'))
|
|
2026
|
+
} else if (c.kind === 'component') {
|
|
2027
|
+
if (!c.component) issues.push(at('Component block has no component selected.'))
|
|
908
2028
|
}
|
|
909
2029
|
}
|
|
910
2030
|
}
|