@svgrid/enterprise 2.2.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -81
- package/dist/cdn/svgrid-enterprise.svelte-external.js +22479 -10466
- package/dist/node/studio.js +11590 -2907
- package/package.json +31 -10
- package/src/SvAlertRuleEditor.svelte +294 -0
- package/src/SvAlertsManager.svelte +210 -0
- package/src/SvAlertsPanel.svelte +129 -0
- package/src/SvBoard.svelte +3 -1
- package/src/SvExpressionEditor.svelte +341 -0
- package/src/SvGridAlerts.dom.test.ts +113 -0
- package/src/SvGridAlerts.svelte +265 -0
- package/src/SvGridBoard.svelte +2355 -0
- package/src/SvGridEditPanel.svelte +849 -794
- package/src/SvGridScheduler.svelte +5334 -4410
- package/src/SvPivotDesigner.svelte +3 -3
- package/src/SvRecordDetail.svelte +6 -2
- package/src/SvSchedule.svelte +3 -1
- package/src/{ai-export-pdf.test.ts → ai-export-pdf.dom.test.ts} +4 -1
- package/src/{ai-export-xlsx.test.ts → ai-export-xlsx.dom.test.ts} +4 -1
- package/src/{ai-export.test.ts → ai-export.dom.test.ts} +5 -1
- package/src/alerts/alert-engine-attach.ts +165 -0
- package/src/alerts/alert-engine.test.ts +135 -0
- package/src/alerts/alert-engine.ts +260 -0
- package/src/alerts/alert-formats.test.ts +87 -0
- package/src/alerts/alert-formats.ts +77 -0
- package/src/alerts/alert-observer.test.ts +189 -0
- package/src/alerts/alert-observer.ts +208 -0
- package/src/alerts/alert-scheduler.ts +96 -0
- package/src/alerts/alert-storage.test.ts +54 -0
- package/src/alerts/alert-storage.ts +116 -0
- package/src/alerts/alert-store.svelte.ts +80 -0
- package/src/alerts/alert-types.ts +94 -0
- package/src/alerts.ts +28 -0
- package/src/board.dom.test.ts +941 -0
- package/src/board.ts +36 -0
- package/src/export.ts +13 -1
- package/src/expressions/evaluate.test.ts +111 -0
- package/src/expressions/evaluate.ts +228 -0
- package/src/expressions/expression-columns.ts +133 -0
- package/src/expressions/expression-types.ts +84 -0
- package/src/expressions/parse.test.ts +106 -0
- package/src/expressions/parse.ts +610 -0
- package/src/import.ts +2 -0
- package/src/index.ts +180 -46
- package/src/install.ts +12 -2
- package/src/pivot-enable.ts +44 -0
- package/src/scheduler-assignments.test.ts +97 -0
- package/src/scheduler-assignments.ts +134 -0
- package/src/scheduler-axis.test.ts +108 -0
- package/src/scheduler-axis.ts +238 -0
- package/src/scheduler-booking.test.ts +57 -0
- package/src/scheduler-booking.ts +63 -0
- package/src/scheduler-config.ts +179 -0
- package/src/scheduler-dependencies.test.ts +155 -0
- package/src/scheduler-dependencies.ts +223 -0
- package/src/scheduler-freebusy.test.ts +41 -0
- package/src/scheduler-freebusy.ts +36 -0
- package/src/scheduler-heatmap.test.ts +39 -0
- package/src/scheduler-heatmap.ts +55 -0
- package/src/scheduler-resource-tree.test.ts +84 -0
- package/src/scheduler-resource-tree.ts +107 -0
- package/src/scheduler-slots.test.ts +53 -0
- package/src/scheduler-slots.ts +94 -0
- package/src/scheduler-summary.test.ts +47 -0
- package/src/scheduler-summary.ts +81 -0
- package/src/sources/index.ts +1 -1
- package/src/sources/introspect-supabase.test.ts +13 -1
- package/src/sources/introspect-supabase.ts +20 -0
- package/src/studio/copilot-core.test.ts +45 -0
- package/src/studio/copilot-core.ts +65 -0
- package/src/studio/deploy-cli.test.ts +56 -0
- package/src/studio/deploy-cli.ts +100 -0
- package/src/studio/emit-project.test.ts +503 -18
- package/src/studio/emit-project.ts +829 -111
- package/src/studio/emit-schema.test.ts +17 -0
- package/src/studio/emit-schema.ts +166 -35
- package/src/studio/index.ts +25 -1
- package/src/studio/introspect-openapi.test.ts +84 -0
- package/src/studio/introspect-openapi.ts +252 -0
- package/src/studio/project.test.ts +57 -0
- package/src/studio/project.ts +193 -6
- package/src/studio/samples/crm.ts +282 -258
- package/src/studio/samples/fleet.ts +213 -186
- package/src/studio/samples/insurance.ts +223 -195
- package/src/studio/samples/inventory.ts +213 -182
- package/src/studio/samples/live-data.test.ts +99 -98
- package/src/studio/samples/live-data.ts +8 -10
- package/src/studio/samples/projects.ts +212 -190
- package/src/studio/samples/samples.test.ts +52 -0
- package/src/studio/samples/shared.ts +26 -6
- package/src/studio/samples/starter.ts +251 -0
- package/src/studio/samples/support.ts +209 -184
- package/src/studio/ui-components-surface.test.ts +185 -0
- package/src/studio/ui-components.generated.ts +5617 -0
- package/src/studio/ui-components.ts +641 -472
- package/src/sveltekit/sql-source.test.ts +13 -0
- package/src/sveltekit/sql-source.ts +11 -6
- package/src/upgrade-prompt.ts +2 -2
- package/src/watermark.ts +2 -2
- package/src/ai.test.ts +0 -522
- package/src/ai.ts +0 -1388
|
@@ -11,12 +11,12 @@
|
|
|
11
11
|
* self-contained).
|
|
12
12
|
*/
|
|
13
13
|
import type { GeneratedFile } from './scaffold.js'
|
|
14
|
-
import type { ActionConfig, Block, ComponentBinding, ComponentConfig, EntityDataSource, FilterPanelConfig, GridColumnConfig, GridConfig, KpiConfig, OAuthProvider, PivotConfig, RecordConfig, RowAction, SchedulerViewConfig, Screen, StudioProject } from './project.js'
|
|
15
|
-
import { blockColumns, blockStyleCss, blockClassName, sanitizeClassName, componentHandleName, componentHasBindings, entityDataSource, flattenBlocks, serializeProject, seedUsers, compileHandlerSteps, clickSlot, rowSelectSlot, changeSlot, FORM_SUBMIT, screenLayoutOf, isPaneLayout, canvasRectOf, CANVAS_ROW_PX, CANVAS_GAP_PX, gridOpts, stackOpts, splitOpts, dockOpts, canvasOpts, stateInitExpr, stateTsType, reconcileDock, ON_LOAD, ON_DESTROY } from './project.js'
|
|
16
|
-
import { uiComponentSpec } from './ui-components.js'
|
|
14
|
+
import type { ActionConfig, Block, ComponentBinding, ComponentConfig, EntityDataSource, FilterPanelConfig, GridColumnConfig, GridConfig, KpiConfig, OAuthProvider, PivotConfig, RecordConfig, RowAction, SchedulerViewConfig, Screen, StudioProject, SupabaseSource } from './project.js'
|
|
15
|
+
import { blockColumns, blockStyleCss, blockClassName, sanitizeClassName, componentHandleName, componentHasBindings, entityDataSource, flattenBlocks, serializeProject, seedUsers, compileHandlerSteps, clickSlot, rowSelectSlot, changeSlot, eventSlot, FORM_SUBMIT, GRID_EVENTS, screenLayoutOf, isPaneLayout, canvasRectOf, CANVAS_ROW_PX, CANVAS_GAP_PX, gridOpts, stackOpts, splitOpts, dockOpts, canvasOpts, stateInitExpr, stateTsType, reconcileDock, ON_LOAD, ON_DESTROY, isSsrScreen, ssrScreenShape } from './project.js'
|
|
16
|
+
import { uiComponentSpec, gridApiSettableProps, STANDARD_UI_EVENTS } from './ui-components.js'
|
|
17
17
|
import { resolveThemeTokens, resolveThemeTokensFor, isDarkTheme } from './themes.js'
|
|
18
18
|
import type { EntityField, EntitySchema } from '../schema.js'
|
|
19
|
-
import { emitEntityModules, homeFile, layoutFile, lookupVar, namesFor, relationDisplayFields, type NavItem } from './emit-schema.js'
|
|
19
|
+
import { emitEntityModules, homeFile, layoutFile, lookupVar, namesFor, prepareEntities, relationDisplayFields, sqlDdlFiles, type NavItem } from './emit-schema.js'
|
|
20
20
|
|
|
21
21
|
const has = (blocks: Block[], kind: Block['config']['kind']) => blocks.some((b) => b.config.kind === kind)
|
|
22
22
|
|
|
@@ -148,7 +148,7 @@ function screenLayoutStyle(screen: Screen): string {
|
|
|
148
148
|
|
|
149
149
|
/** Markup for one block inside the screen grid. `ctx.hasRecord` tells a grid to
|
|
150
150
|
* publish its clicked row into `selectedRecord` for a sibling record panel. */
|
|
151
|
-
function blockMarkup(entity: EntitySchema, schemaVar: string, typeName: string, block: Block, resolve: (name: string) => EntitySchema | undefined, ctx: { hasRecord: boolean; accessEnabled?: boolean; routeById?: Map<string, string>; i18n?: boolean; rawEntity?: EntitySchema; rawResolve?: (name: string) => EntitySchema | undefined; captureApi?: string; handleNames?: Map<string, string>; pane?: boolean; rowSelectSteps?: string; ctxLiteral?: string } = { hasRecord: false }): string {
|
|
151
|
+
function blockMarkup(entity: EntitySchema, schemaVar: string, typeName: string, block: Block, resolve: (name: string) => EntitySchema | undefined, ctx: { hasRecord: boolean; accessEnabled?: boolean; routeById?: Map<string, string>; i18n?: boolean; rawEntity?: EntitySchema; rawResolve?: (name: string) => EntitySchema | undefined; captureApi?: string; handleNames?: Map<string, string>; pane?: boolean; rowSelectSteps?: string; ctxLiteral?: string; gridEventSink?: string } = { hasRecord: false }): string {
|
|
152
152
|
// A block's display label: localized via $t('block.<id>', 'literal') when i18n is on.
|
|
153
153
|
const tLabel = (label: string, key: string) => (ctx.i18n ? `{$t('block.${key}', ${JSON.stringify(label)})}` : label)
|
|
154
154
|
// In a dock pane the block fills its pane (the pane owns the size); in the 12-col grid it
|
|
@@ -187,6 +187,23 @@ function blockMarkup(entity: EntitySchema, schemaVar: string, typeName: string,
|
|
|
187
187
|
const tree = gridHasTree(cfg)
|
|
188
188
|
const grouped = !!cfg.grouping?.length && !tree
|
|
189
189
|
const dataExpr = tree ? `tree_${idSafe}.visible` : grouped ? 'allRows' : 'view.rows'
|
|
190
|
+
// Code-behind can subscribe to grid events (`ctx.grid.onCellClick = fn`): each
|
|
191
|
+
// event prop fires into the grid handle. Feature-owned props (sort/filter/edit/
|
|
192
|
+
// paginate/row-click) COMPOSE the fire onto their built-in body; the rest are
|
|
193
|
+
// added in one pass below. `sink` is the grid handle var, '' when code is off.
|
|
194
|
+
const sink = ctx.gridEventSink ?? ''
|
|
195
|
+
const firedProps = new Set<string>()
|
|
196
|
+
const fireInto = (key: string, args: string): string => {
|
|
197
|
+
if (!sink) return ''
|
|
198
|
+
firedProps.add(GRID_EVENTS.find((e) => e.key === key)!.prop)
|
|
199
|
+
return `; ${sink}.fire('${key}', ${args})`
|
|
200
|
+
}
|
|
201
|
+
// Compose a built-in event handler with the user's ctx.grid subscription. Stays
|
|
202
|
+
// terse (no braces, no fire) when code is off, so non-code screens are unchanged.
|
|
203
|
+
const withFire = (param: string, base: string, key: string, fireArgs: string): string => {
|
|
204
|
+
const fire = fireInto(key, fireArgs)
|
|
205
|
+
return fire ? `(${param}) => { ${base}${fire} }` : `(${param}) => ${base}`
|
|
206
|
+
}
|
|
190
207
|
const lines = [`data={${dataExpr}}`, `columns={${colVar}}`, `loading={${tree || grouped ? '!allRowsReady' : 'view.loading'}}`, `loadingOverlay`, `emptyMessage=${JSON.stringify(emptyMsg)}`, `fitColumns`]
|
|
191
208
|
lines.push(`enableRowSummaries={${cfg.rowSummaries ? 'true' : 'false'}}`)
|
|
192
209
|
if (cfg.striped) lines.push(`zebraRows`)
|
|
@@ -210,14 +227,19 @@ function blockMarkup(entity: EntitySchema, schemaVar: string, typeName: string,
|
|
|
210
227
|
if (cfg.sortable) lines.push(`sortable`)
|
|
211
228
|
if (cfg.filterable) lines.push(`filterable`, ...filterSurfaceProps(cfg))
|
|
212
229
|
} else {
|
|
213
|
-
if (cfg.sortable) lines.push(`sortable`, `externalSort`, `onSortingChange={(s
|
|
214
|
-
if (cfg.filterable) lines.push(`filterable`, ...filterSurfaceProps(cfg), `externalFilter`, `onFiltersChange={(f
|
|
230
|
+
if (cfg.sortable) lines.push(`sortable`, `externalSort`, `onSortingChange={${withFire('s', 'controller.setSort(s)', 'sortingChange', 's')}}`)
|
|
231
|
+
if (cfg.filterable) lines.push(`filterable`, ...filterSurfaceProps(cfg), `externalFilter`, `onFiltersChange={${withFire('f', `controller.setFilter({ global: f.global || undefined, columns: Object.fromEntries(f.columns.map((c) => [c.id, { operator: c.operator, value: c.value, valueTo: c.valueTo, selectedValues: c.selectedValues }])) })`, 'filtersChange', 'f')}}`)
|
|
215
232
|
}
|
|
216
233
|
// RBAC: gate the edit affordances on the update permission (server also enforces).
|
|
217
234
|
const canUpdate = ctx.accessEnabled ? `can($currentRole, 'update')` : 'true'
|
|
218
|
-
if (cfg.editing === 'form')
|
|
235
|
+
if (cfg.editing === 'form') {
|
|
236
|
+
const fire = fireInto('rowDoubleClick', 'e')
|
|
237
|
+
lines.push(ctx.accessEnabled
|
|
238
|
+
? `onRowDoubleClick={(e) => { if (${canUpdate}) editing = e.row${fire} }}`
|
|
239
|
+
: fire ? `onRowDoubleClick={(e) => { (editing = e.row)${fire} }}` : `onRowDoubleClick={(e) => (editing = e.row)}`)
|
|
240
|
+
}
|
|
219
241
|
// Inline editing writes through the controller by row id (not offered for tree grids).
|
|
220
|
-
if (cfg.editing === 'inline' && !tree) lines.push(`onCellValueChange={(e) => { ${ctx.accessEnabled ? `if (!${canUpdate}) return; ` : ''}const row = ${grouped ? 'allRows' : 'view.rows'}[e.rowIndex]; if (row) controller.updateRow(String((row as Record<string, unknown>)[idField]), { [e.columnId]: e.newValue } as Partial<${typeName}>) }}`)
|
|
242
|
+
if (cfg.editing === 'inline' && !tree) lines.push(`onCellValueChange={(e) => { ${ctx.accessEnabled ? `if (!${canUpdate}) return; ` : ''}const row = ${grouped ? 'allRows' : 'view.rows'}[e.rowIndex]; if (row) controller.updateRow(String((row as Record<string, unknown>)[idField]), { [e.columnId]: e.newValue } as Partial<${typeName}>)${fireInto('cellValueChange', 'e')} }}`)
|
|
221
243
|
// Row click: drill-through (highest precedence), a record-panel selection,
|
|
222
244
|
// and/or the user's "On row select" method steps (row-scoped ctx). When steps
|
|
223
245
|
// are present they merge with the built-in action into one async handler.
|
|
@@ -229,23 +251,30 @@ function blockMarkup(entity: EntitySchema, schemaVar: string, typeName: string,
|
|
|
229
251
|
if (ctx.rowSelectSteps) {
|
|
230
252
|
const pre = primaryStmt ? `\n ${primaryStmt}` : ''
|
|
231
253
|
const stepBody = ctx.rowSelectSteps.split('\n').map((l) => (l ? ' ' + l : l)).join('\n')
|
|
232
|
-
|
|
254
|
+
const fire = fireInto('rowClick', 'e')
|
|
255
|
+
lines.push(`onRowClick={async (e) => {${pre}\n const row = e.row\n const ctx = ${ctx.ctxLiteral} as unknown as PageContext\n${stepBody}${fire ? '\n ' + fire.slice(2) : ''}\n }}`)
|
|
233
256
|
} else if (rowLinkStmt) {
|
|
234
|
-
lines.push(`onRowClick={(e
|
|
257
|
+
lines.push(`onRowClick={${withFire('e', rowLinkStmt, 'rowClick', 'e')}}`)
|
|
235
258
|
} else if (ctx.hasRecord) {
|
|
236
|
-
lines.push(`onRowClick={(e
|
|
259
|
+
lines.push(`onRowClick={${withFire('e', '(selectedRecord = e.row)', 'rowClick', 'e')}}`)
|
|
237
260
|
}
|
|
238
261
|
if (cfg.paginated !== false && !tree) {
|
|
239
262
|
if (grouped) {
|
|
240
263
|
// Client pagination: the grid slices `data` itself.
|
|
241
264
|
lines.push(`showPagination`, `pageSize={${cfg.pageSize}}`)
|
|
242
265
|
} else {
|
|
243
|
-
lines.push(`showPagination`, `externalPagination`, `rowCount={view.total}`, `pageIndex={view.pageIndex}`, `pageSize={view.pageSize}`, `onPaginationChange={({ pageIndex, pageSize }
|
|
266
|
+
lines.push(`showPagination`, `externalPagination`, `rowCount={view.total}`, `pageIndex={view.pageIndex}`, `pageSize={view.pageSize}`, `onPaginationChange={${withFire('{ pageIndex, pageSize }', '(pageSize !== view.pageSize ? controller.setPageSize(pageSize) : controller.setPage(pageIndex))', 'paginationChange', '{ pageIndex, pageSize }')}}`)
|
|
244
267
|
}
|
|
245
268
|
if (cfg.paginationPosition && cfg.paginationPosition !== 'bottom') lines.push(`paginationPosition="${cfg.paginationPosition}"`)
|
|
246
269
|
const opts = cfg.pageSizeOptions
|
|
247
270
|
if (opts && opts.length && (opts.length !== 4 || opts.join(',') !== '10,25,50,100')) lines.push(`pageSizeOptions={[${opts.join(', ')}]}`)
|
|
248
271
|
}
|
|
272
|
+
// Every remaining grid event -> fire into ctx.grid so code can subscribe
|
|
273
|
+
// (`ctx.grid.onCellClick = fn`). Feature-owned props already composed above.
|
|
274
|
+
if (sink) for (const ev of GRID_EVENTS) {
|
|
275
|
+
if (ev.dataEvent) continue // fired by the data controller, not a grid prop
|
|
276
|
+
if (!firedProps.has(ev.prop)) lines.push(`${ev.prop}={(...a) => ${sink}.fire('${ev.key}', ...a)}`)
|
|
277
|
+
}
|
|
249
278
|
// No-code conditional formatting -> the grid's rule engine.
|
|
250
279
|
const cf = conditionalFormatsExpr(cfg)
|
|
251
280
|
if (cf) lines.push(`conditionalFormats={${cf}}`)
|
|
@@ -256,6 +285,15 @@ function blockMarkup(entity: EntitySchema, schemaVar: string, typeName: string,
|
|
|
256
285
|
if (apiBody.length === 1 && ctx.captureApi && !grouped) lines.push(`onApiReady={(a) => (${ctx.captureApi} = a)}`)
|
|
257
286
|
else if (apiBody.length) lines.push(`onApiReady={(a) => { ${apiBody.join('; ')} }}`)
|
|
258
287
|
lines.push(`containerHeight=${ctx.pane ? '"100%"' : `{${block.height ?? 360}}`}`)
|
|
288
|
+
// Raw "All properties" overrides: pass through every grid prop the curated
|
|
289
|
+
// controls didn't already emit (deduped by prop name), so nothing is set twice.
|
|
290
|
+
if (cfg.props && Object.keys(cfg.props).length) {
|
|
291
|
+
const emitted = new Set(lines.map((l) => l.split(/[=\s]/)[0]))
|
|
292
|
+
for (const [k, v] of Object.entries(cfg.props)) {
|
|
293
|
+
if (v === undefined || emitted.has(k)) continue
|
|
294
|
+
lines.push(v === true ? k : `${k}={${JSON.stringify(v)}}`)
|
|
295
|
+
}
|
|
296
|
+
}
|
|
259
297
|
// No-code export toolbar - buttons wired to the grid's own export API.
|
|
260
298
|
const exportBar = gridHasExport(cfg) && ctx.captureApi ? exportToolbarMarkup(cfg.export!, ctx.captureApi, entity.name) : ''
|
|
261
299
|
return ` <div ${span}${cls}>
|
|
@@ -296,10 +334,13 @@ ${exportBar} <SvGrid
|
|
|
296
334
|
if (cfg.trendField) {
|
|
297
335
|
const tReduce = cfg.trendReduce ?? cfg.reduce
|
|
298
336
|
const seriesExpr = `kpiSeries(${rowsExpr}, { trendField: '${cfg.trendField}', ${measurePart}reduce: '${tReduce}' })`
|
|
337
|
+
// The first-to-last delta chip - only when there's no target chip already
|
|
338
|
+
// (avoids a dead `&& false` branch that left `_d` null-unsafe under svelte-check).
|
|
339
|
+
const deltaChip = cfg.target == null
|
|
340
|
+
? `\n {@const _d = seriesDelta(_s)}\n {#if _d != null}<span class="kpi__delta" class:is-up={_d >= 0} class:is-down={_d < 0}>{_d >= 0 ? '▲' : '▼'} {Math.abs(_d).toFixed(0)}%</span>{/if}`
|
|
341
|
+
: ''
|
|
299
342
|
rows.push(` {#if ${seriesExpr}.length > 1}
|
|
300
|
-
{@const _s = ${seriesExpr}}
|
|
301
|
-
{@const _d = seriesDelta(_s)}
|
|
302
|
-
{#if _d != null && ${cfg.target == null}}<span class="kpi__delta" class:is-up={_d >= 0} class:is-down={_d < 0}>{_d >= 0 ? '▲' : '▼'} {Math.abs(_d).toFixed(0)}%</span>{/if}
|
|
343
|
+
{@const _s = ${seriesExpr}}${deltaChip}
|
|
303
344
|
<svg class="kpi__spark" viewBox="0 0 120 30" preserveAspectRatio="none" aria-hidden="true"><polyline points={sparklinePoints(_s)} fill="none" stroke="currentColor" stroke-width="1.5" vector-effect="non-scaling-stroke" /></svg>
|
|
304
345
|
{/if}`)
|
|
305
346
|
}
|
|
@@ -505,13 +546,23 @@ function componentBlockMarkup(block: Block, cfg: ComponentConfig, handleName?: s
|
|
|
505
546
|
if (!spec) return ` <div ${span}${cls}><!-- unknown component "${cfg.component}" --></div>`
|
|
506
547
|
if (handleName) {
|
|
507
548
|
// Handle mode (code page): props + content come from the reactive handle, and
|
|
508
|
-
//
|
|
549
|
+
// events fire on it - so button1.setVariant(...) / button1.onclick = fn work.
|
|
509
550
|
const inner = spec.hasContent ? `>{${handleName}.text}</${spec.importName}>` : ' />'
|
|
510
|
-
//
|
|
511
|
-
//
|
|
551
|
+
// Events with a component callback prop are wired THROUGH that prop (exact
|
|
552
|
+
// semantics, works for non-bubbling callbacks like a picker's onChange); the
|
|
553
|
+
// wrapper's DOM listeners remain only as the catch-all for click/change on
|
|
554
|
+
// components without a matching callback prop - and are skipped when a prop
|
|
555
|
+
// covers the same key, so an event never fires twice.
|
|
556
|
+
const propWired = (spec.events ?? []).filter((e) => e.prop)
|
|
557
|
+
const eventAttrs = propWired.map((e) => ` ${e.prop}={(e) => ${handleName}.fire(${jsStr(e.key)}, e)}`).join('')
|
|
558
|
+
// The wrapper forwards the full standard DOM event surface to the handle (VS-style
|
|
559
|
+
// events), skipping any a component callback prop already covers (no double-fire).
|
|
560
|
+
const wrapper = STANDARD_UI_EVENTS.filter((se) => !propWired.some((e) => e.key === se.key))
|
|
561
|
+
.map((se) => ` on${se.dom}={(e) => ${handleName}.fire('${se.key}', e)}`)
|
|
562
|
+
.join('')
|
|
512
563
|
return ` <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
|
513
|
-
<div id="${block.id}"
|
|
514
|
-
<${spec.importName} {...${handleName}.props}${inner}
|
|
564
|
+
<div id="${block.id}"${wrapper} ${span}${cls}>
|
|
565
|
+
<${spec.importName} {...${handleName}.props}${eventAttrs}${inner}
|
|
515
566
|
</div>`
|
|
516
567
|
}
|
|
517
568
|
// Data bindings: a bound prop's value is a reactive expression over the screen's
|
|
@@ -520,13 +571,13 @@ function componentBlockMarkup(block: Block, cfg: ComponentConfig, handleName?: s
|
|
|
520
571
|
const bound = (key: string): ComponentBinding | undefined => (rowsExpr ? bindings[key] : undefined)
|
|
521
572
|
const attrs: string[] = []
|
|
522
573
|
for (const p of spec.props) {
|
|
574
|
+
if (p.code) continue // code-only props (functions) are set via the handle, not literals
|
|
523
575
|
const b = bound(p.key)
|
|
524
576
|
if (b) { attrs.push(`${p.key}={${bindingExpr(b, rowsExpr!, p.type === 'number')}}`); continue }
|
|
525
577
|
const v = cfg.props[p.key] ?? p.default
|
|
526
578
|
if (v == null || v === '') continue
|
|
527
579
|
if (p.type === 'boolean') { if (v) attrs.push(p.key) }
|
|
528
|
-
else
|
|
529
|
-
else attrs.push(`${p.key}={${jsStr(String(v))}}`)
|
|
580
|
+
else attrs.push(`${p.key}={${propValueExpr(p, v)}}`)
|
|
530
581
|
}
|
|
531
582
|
// Baked-in array/object props (Timeline items, Sparkline data): emitted verbatim.
|
|
532
583
|
for (const f of spec.fixed ?? []) attrs.push(`${f.key}={${f.expr}}`)
|
|
@@ -542,14 +593,37 @@ function componentBlockMarkup(block: Block, cfg: ComponentConfig, handleName?: s
|
|
|
542
593
|
</div>`
|
|
543
594
|
}
|
|
544
595
|
|
|
596
|
+
/** A JS expression for a component prop's configured value, by panel type. */
|
|
597
|
+
function propValueExpr(p: { type: string }, v: unknown): string {
|
|
598
|
+
if (p.type === 'number') return String(Number(v))
|
|
599
|
+
if (p.type === 'boolean') return String(!!v)
|
|
600
|
+
if (p.type === 'json') return JSON.stringify(v)
|
|
601
|
+
if (p.type === 'date') return `new Date(${jsStr(String(v))})`
|
|
602
|
+
return jsStr(String(v))
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** Extra SvGridEditPanel props for a grid's optional form-layout depth. Returns a
|
|
606
|
+
* leading-space attribute string (or '' when nothing is configured). */
|
|
607
|
+
function editPanelLayoutProps(grid?: GridConfig): string {
|
|
608
|
+
if (!grid) return ''
|
|
609
|
+
const attrs: string[] = []
|
|
610
|
+
if (grid.formColumns && grid.formColumns !== 2) attrs.push(`columns={${grid.formColumns}}`)
|
|
611
|
+
if (grid.formFields?.length) attrs.push(`formFields={${JSON.stringify(grid.formFields)}}`)
|
|
612
|
+
if (grid.formSections?.length) attrs.push(`sections={${JSON.stringify(grid.formSections)}}`)
|
|
613
|
+
if (grid.formTitle) attrs.push(`title={${jsStr(grid.formTitle)}}`)
|
|
614
|
+
if (grid.formSize && grid.formSize !== 'md') attrs.push(`formSize=${jsStr(grid.formSize)}`)
|
|
615
|
+
return attrs.length ? ' ' + attrs.join(' ') : ''
|
|
616
|
+
}
|
|
617
|
+
|
|
545
618
|
/** The `{ props, text }` init literal for a component's reactive handle. */
|
|
546
619
|
function handleInit(cfg: ComponentConfig): string {
|
|
547
620
|
const spec = uiComponentSpec(cfg.component)
|
|
548
621
|
const props: string[] = []
|
|
549
622
|
for (const p of spec?.props ?? []) {
|
|
623
|
+
if (p.code) continue // code-only props are set via the handle in code, not seeded
|
|
550
624
|
const v = cfg.props[p.key] ?? p.default
|
|
551
625
|
if (v == null || v === '') continue
|
|
552
|
-
props.push(`${p.key}: ${
|
|
626
|
+
props.push(`${p.key}: ${propValueExpr(p, v)}`)
|
|
553
627
|
}
|
|
554
628
|
for (const f of spec?.fixed ?? []) props.push(`${f.key}: ${f.expr}`)
|
|
555
629
|
const parts = [`props: { ${props.join(', ')} }`]
|
|
@@ -636,14 +710,16 @@ function filterPanelState(entity: EntitySchema, block: Block, cfg: FilterPanelCo
|
|
|
636
710
|
const { state, apply } = facetNames(block)
|
|
637
711
|
const assigns = filterFieldsOf(entity, cfg).map((f) => {
|
|
638
712
|
const key = jsStr(f.field)
|
|
639
|
-
|
|
713
|
+
// The server filter model takes string values; the matcher string-coerces both
|
|
714
|
+
// sides, so a boolean column matches fine against 'true' / 'false'.
|
|
715
|
+
if (f.type === 'boolean') return ` if (v[${key}] === 'true' || v[${key}] === 'false') c[${key}] = { operator: 'equals', value: v[${key}] }`
|
|
640
716
|
if (f.type === 'enum') return ` if (v[${key}]) c[${key}] = { operator: 'equals', value: v[${key}] }`
|
|
641
717
|
return ` if (v[${key}]) c[${key}] = { operator: 'contains', value: v[${key}] }`
|
|
642
718
|
}).join('\n')
|
|
643
719
|
return `let ${state} = $state<Record<string, string>>({})
|
|
644
720
|
function ${apply}() {
|
|
645
721
|
const v = ${state}
|
|
646
|
-
const c: Record<string, { operator: 'equals' | 'contains'; value:
|
|
722
|
+
const c: Record<string, { operator: 'equals' | 'contains'; value: string }> = {}
|
|
647
723
|
${assigns}
|
|
648
724
|
controller.setFilter({ columns: c })
|
|
649
725
|
}`
|
|
@@ -1025,6 +1101,27 @@ function handleNameMap(screen: Screen): Map<string, string> {
|
|
|
1025
1101
|
return new Map(screenHandles(screen).map((h) => [h.blockId, h.name]))
|
|
1026
1102
|
}
|
|
1027
1103
|
|
|
1104
|
+
/** The `on<Event>` setter surface added to `ctx.grid` so `ctx.grid.onCellClick = fn`
|
|
1105
|
+
* type-checks. The `Row` placeholder in GRID_EVENTS params is the screen's row type. */
|
|
1106
|
+
function gridEventsType(rowType: string): string {
|
|
1107
|
+
return `{ ${GRID_EVENTS.map((e) => `${e.method}?: (${e.params.replace(/\bRow\b/g, rowType)}) => void`).join('; ')} }`
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/** The runtime-settable prop surface added to `ctx.grid` so `ctx.grid.sortable = true`
|
|
1111
|
+
* (and `if (ctx.grid.zebraRows)`) type-check + autocomplete. Assignments route to the
|
|
1112
|
+
* grid's `setOption` (a reactive $state override); reads route to `getOption`. */
|
|
1113
|
+
function gridPropsType(): string {
|
|
1114
|
+
const tsType = (p: { type: string; options?: string[] }): string =>
|
|
1115
|
+
p.type === 'boolean' ? 'boolean'
|
|
1116
|
+
: p.type === 'number' ? 'number'
|
|
1117
|
+
: p.type === 'select' && p.options?.length ? p.options.map((o) => JSON.stringify(o)).join(' | ')
|
|
1118
|
+
: p.type === 'json' ? 'unknown'
|
|
1119
|
+
: 'string'
|
|
1120
|
+
const props = gridApiSettableProps()
|
|
1121
|
+
if (!props.length) return '{}'
|
|
1122
|
+
return `{ ${props.map((p) => `${p.key}?: ${tsType(p)}`).join('; ')} }`
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1028
1125
|
/** Does this code-enabled screen expose a `ctx.data` dataset battery, and can code
|
|
1029
1126
|
* replace its rows? Freestanding data-grid pages own their rows (settable via
|
|
1030
1127
|
* setRows); an entity screen with a Grid exposes its current page + reload(). */
|
|
@@ -1088,7 +1185,7 @@ export function ctxCompletions(screen: Screen): string[] {
|
|
|
1088
1185
|
for (const h of screenHandles(screen)) {
|
|
1089
1186
|
const base = `ctx.${h.name}`
|
|
1090
1187
|
out.push(base)
|
|
1091
|
-
if (h.tier === 'grid') out.push(...GRID_API_MEMBERS.map((m) => `${base}.${m}()`))
|
|
1188
|
+
if (h.tier === 'grid') out.push(...GRID_API_MEMBERS.map((m) => `${base}.${m}()`), ...gridApiSettableProps().map((p) => `${base}.${p.key}`))
|
|
1092
1189
|
else if (h.tier === 'data') out.push(...DATA_HANDLE_MEMBERS.map((m) => `${base}.${m}`))
|
|
1093
1190
|
else if (h.component) out.push(...componentHandleMembers(h.component).map((m) => `${base}.${m}`))
|
|
1094
1191
|
}
|
|
@@ -1180,7 +1277,7 @@ export function ctxAmbientDts(screen: Screen, entity?: EntitySchema): string {
|
|
|
1180
1277
|
|
|
1181
1278
|
const members: string[] = []
|
|
1182
1279
|
for (const h of handles) {
|
|
1183
|
-
if (h.tier === 'grid') members.push(` /** The Grid on this page - its full, real SvGridApi. */\n grid: SvGridApi<${rowName}
|
|
1280
|
+
if (h.tier === 'grid') members.push(` /** The Grid on this page - its full, real SvGridApi, plus event subscription (grid.onRowClick = (e) => {}) and runtime options (grid.sortable = true). */\n grid: SvGridApi<${rowName}> & ${gridEventsType(rowName)} & ${gridPropsType()}`)
|
|
1184
1281
|
else if (h.tier === 'data') members.push(` /** The ${h.kind} - feed it rows with ${h.name}.setData(rows). */\n ${h.name}: DataHandle<${rowName}>`)
|
|
1185
1282
|
else members.push(` ${h.name}: ${componentHandleTypeName(h.component!)}`)
|
|
1186
1283
|
}
|
|
@@ -1227,7 +1324,7 @@ ${members.join('\n')}
|
|
|
1227
1324
|
function screenElementsManifest(screen: Screen): string {
|
|
1228
1325
|
const lines: string[] = []
|
|
1229
1326
|
const describe: Record<HandleTier, (h: BlockHandle) => string> = {
|
|
1230
|
-
grid: () => "the Grid's full SvGridApi - exportCsv(), selectCells(), startEditing(), addRow(), setFilter(),
|
|
1327
|
+
grid: () => "the Grid's full SvGridApi - exportCsv(), selectCells(), startEditing(), addRow(), setFilter(), ...; subscribe to events (grid.onRowClick = (e) => {}); set options live (grid.sortable = true).",
|
|
1231
1328
|
data: (h) => `the ${h.kind} - setData(rows) to feed it your own rows, .rows to read them, clear() to follow the screen data again.`,
|
|
1232
1329
|
component: (h) => `the ${h.component} - setText/set<Prop>(...), onclick = fn, onClick(fn).`,
|
|
1233
1330
|
}
|
|
@@ -1266,9 +1363,9 @@ export class ComponentHandle {
|
|
|
1266
1363
|
get(name: string): unknown { return this.props[name] }
|
|
1267
1364
|
setText(value: string): this { this.text = value; return this }
|
|
1268
1365
|
setLabel(value: string): this { this.text = value; return this }
|
|
1269
|
-
onEvent(name: string, fn: (e: Event) => void): this { this.on = { ...this.on, [name]: fn }; return this }
|
|
1366
|
+
onEvent(name: string, fn: (e: Event) => void): this { this.on = { ...this.on, [name.toLowerCase()]: fn }; return this }
|
|
1270
1367
|
onClick(fn: (e: Event) => void): this { return this.onEvent('click', fn) }
|
|
1271
|
-
fire(name: string, e: Event): void { this.on[name]?.(e) }
|
|
1368
|
+
fire(name: string, e: Event): void { this.on[name.toLowerCase()]?.(e) }
|
|
1272
1369
|
}
|
|
1273
1370
|
|
|
1274
1371
|
/** An imperative, reactive handle over a data-bound block (chart, KPI, gauge,
|
|
@@ -1290,6 +1387,42 @@ export class DataHandle<T = Record<string, unknown>> {
|
|
|
1290
1387
|
/** A DataHandle whose fallback is the screen dataset getter. */
|
|
1291
1388
|
export function dataHandle<T>(fallback: () => T[]): DataHandle<T> { return new DataHandle<T>(fallback) }
|
|
1292
1389
|
|
|
1390
|
+
/** Event subscriptions for the Grid (\`ctx.grid.onCellClick = (e) => {}\`). The keys
|
|
1391
|
+
* are lowercased on both store + dispatch so any on<Event> casing round-trips. */
|
|
1392
|
+
class GridEvents {
|
|
1393
|
+
on: Record<string, (...args: unknown[]) => void> = {}
|
|
1394
|
+
fire(name: string, ...args: unknown[]): void { this.on[name.toLowerCase()]?.(...args) }
|
|
1395
|
+
}
|
|
1396
|
+
/** Wrap the Grid's real SvGridApi so page code gets BOTH its methods
|
|
1397
|
+
* (\`ctx.grid.exportCsv()\`) and event subscription (\`ctx.grid.onRowClick = fn\`).
|
|
1398
|
+
* The api arrives asynchronously (onApiReady), so it is read lazily via \`getApi\`. */
|
|
1399
|
+
export function gridHandle<A extends object>(getApi: () => A | null): A & GridEvents {
|
|
1400
|
+
const ev = new GridEvents()
|
|
1401
|
+
return new Proxy(ev, {
|
|
1402
|
+
get(t, k) {
|
|
1403
|
+
if (k in t) { const v = (t as Record<string | symbol, unknown>)[k]; return typeof v === 'function' ? (v as (...a: unknown[]) => unknown).bind(t) : v }
|
|
1404
|
+
const api = getApi() as Record<string | symbol, unknown> | null
|
|
1405
|
+
if (!api) return undefined
|
|
1406
|
+
const v = api[k]
|
|
1407
|
+
if (typeof v === 'function') return (v as (...a: unknown[]) => unknown).bind(api)
|
|
1408
|
+
// A grid PROP read (ctx.grid.sortable) -> its effective value via getOption.
|
|
1409
|
+
if (v === undefined && typeof k === 'string' && !/^on[A-Z]/.test(k) && typeof api.getOption === 'function') return (api.getOption as (key: unknown) => unknown)(k)
|
|
1410
|
+
return v
|
|
1411
|
+
},
|
|
1412
|
+
set(t, k, val) {
|
|
1413
|
+
// onRowClick = fn / oncellclick = fn -> subscribe (any casing; keys lowercase).
|
|
1414
|
+
if (typeof k === 'string' && /^on[A-Za-z]/.test(k)) { t.on = { ...t.on, [k.slice(2).toLowerCase()]: val as (...a: unknown[]) => void }; return true }
|
|
1415
|
+
const api = getApi() as Record<string | symbol, unknown> | null
|
|
1416
|
+
// ctx.grid.sortable = true -> the grid's reactive option channel (setOption writes
|
|
1417
|
+
// a $state override the grid merges over its props). Falls back to a direct set for
|
|
1418
|
+
// an older grid build without setOption.
|
|
1419
|
+
if (api && typeof api.setOption === 'function') { (api.setOption as (key: unknown, value: unknown) => void)(k, val); return true }
|
|
1420
|
+
if (api) api[k] = val
|
|
1421
|
+
return true
|
|
1422
|
+
},
|
|
1423
|
+
}) as A & GridEvents
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1293
1426
|
/** A handle plus dynamic setX / onX helpers and \`el.onclick = fn\` assignment. */
|
|
1294
1427
|
export type Handle = ComponentHandle & Record<string, any>
|
|
1295
1428
|
|
|
@@ -1299,11 +1432,13 @@ export function handle(init: { props?: Record<string, unknown>; text?: string })
|
|
|
1299
1432
|
get(t, k) {
|
|
1300
1433
|
if (Reflect.has(t, k)) { const v = (t as unknown as Record<string, unknown>)[k as string]; return typeof v === 'function' ? v.bind(t) : v }
|
|
1301
1434
|
if (typeof k === 'string' && /^set[A-Z]/.test(k)) { const p = k[3]!.toLowerCase() + k.slice(4); return (v: unknown) => t.set(p, v) }
|
|
1302
|
-
|
|
1435
|
+
// onEvent(fn) call form. onEvent lowercases, so onKeyDown maps to the 'keydown' the wrapper fires.
|
|
1436
|
+
if (typeof k === 'string' && /^on[A-Z]/.test(k)) { const e = k.slice(2); return (fn: (ev: Event) => void) => t.onEvent(e, fn) }
|
|
1303
1437
|
return typeof k === 'string' ? t.props[k] : undefined
|
|
1304
1438
|
},
|
|
1305
1439
|
set(t, k, v) {
|
|
1306
|
-
|
|
1440
|
+
// el.onKeyDown = fn / el.onclick = fn -> subscribe (onEvent lowercases the key).
|
|
1441
|
+
if (typeof k === 'string' && /^on[A-Za-z]/.test(k)) { t.onEvent(k.slice(2), v as (e: Event) => void); return true }
|
|
1307
1442
|
if (k === 'text') { t.text = v as string; return true } // content, not a prop
|
|
1308
1443
|
if (typeof k === 'string') t.set(k, v) // checkbox1.checked = true
|
|
1309
1444
|
return true
|
|
@@ -1361,14 +1496,16 @@ function componentHandleTypeDecl(componentKey: string): string | null {
|
|
|
1361
1496
|
* the PageContext type never drift. `rowType` is the entity's row type (entity
|
|
1362
1497
|
* screens) or `RowData` (freestanding); `datasetRowsVar` is the state var backing
|
|
1363
1498
|
* a settable `ctx.data`. */
|
|
1364
|
-
function codeWiring(screen: Screen, rowType: string, datasetRowsVar: string | undefined): { decls: string[]; ctxLiteral: string; usesHandle: boolean; usesDataHandle: boolean } {
|
|
1499
|
+
function codeWiring(screen: Screen, rowType: string, datasetRowsVar: string | undefined): { decls: string[]; ctxLiteral: string; usesHandle: boolean; usesDataHandle: boolean; usesGridHandle: boolean } {
|
|
1365
1500
|
const blockById = new Map(flattenBlocks(screen.blocks).map((b) => [b.id, b]))
|
|
1366
1501
|
const decls: string[] = []
|
|
1367
1502
|
const ctxParts: string[] = []
|
|
1368
1503
|
let usesHandle = false
|
|
1369
1504
|
let usesDataHandle = false
|
|
1505
|
+
let usesGridHandle = false
|
|
1370
1506
|
for (const h of screenHandles(screen)) {
|
|
1371
|
-
|
|
1507
|
+
// ctx.grid = the real SvGridApi PLUS event subscription (ctx.grid.onRowClick = fn).
|
|
1508
|
+
if (h.tier === 'grid') { usesGridHandle = true; decls.push('const gridCtx = gridHandle(() => gridApi)'); ctxParts.push('grid: gridCtx'); continue }
|
|
1372
1509
|
if (h.tier === 'data') {
|
|
1373
1510
|
usesDataHandle = true
|
|
1374
1511
|
decls.push(`const ${h.name} = dataHandle<${rowType}>(() => allRows)`)
|
|
@@ -1388,7 +1525,7 @@ function codeWiring(screen: Screen, rowType: string, datasetRowsVar: string | un
|
|
|
1388
1525
|
ctxParts.push('goto')
|
|
1389
1526
|
ctxParts.push('params: Object.fromEntries($page.url.searchParams)')
|
|
1390
1527
|
if (screen.state?.length) ctxParts.push(`state: { ${screen.state.map((v) => `get ${v.name}() { return ${v.name} }, set ${v.name}(x) { ${v.name} = x }`).join(', ')} }`)
|
|
1391
|
-
return { decls, ctxLiteral: `{ ${ctxParts.join(', ')} }`, usesHandle, usesDataHandle }
|
|
1528
|
+
return { decls, ctxLiteral: `{ ${ctxParts.join(', ')} }`, usesHandle, usesDataHandle, usesGridHandle }
|
|
1392
1529
|
}
|
|
1393
1530
|
|
|
1394
1531
|
/** Per-screen, regenerated PageContext type: the tiered handles (grid api / data
|
|
@@ -1404,7 +1541,7 @@ function screenContextFile(screen: Screen, rowType: string): GeneratedFile {
|
|
|
1404
1541
|
|
|
1405
1542
|
const members: string[] = []
|
|
1406
1543
|
for (const h of handles) {
|
|
1407
|
-
if (h.tier === 'grid') members.push(` /** The Grid on this page - its full, real SvGridApi. */\n grid: SvGridApi<any, ${rowType}
|
|
1544
|
+
if (h.tier === 'grid') members.push(` /** The Grid on this page - its full, real SvGridApi, plus event subscription (grid.onRowClick = (e) => {}) and runtime options (grid.sortable = true). */\n grid: SvGridApi<any, ${rowType}> & ${gridEventsType(rowType)} & ${gridPropsType()}`)
|
|
1408
1545
|
else if (h.tier === 'data') members.push(` /** The ${h.kind} - feed it rows with ${h.name}.setData(rows); ${h.name}.rows reads them. */\n ${h.name}: DataHandle<${rowType}>`)
|
|
1409
1546
|
else members.push(` ${h.name}: ${componentHandleTypeName(h.component!)}`)
|
|
1410
1547
|
}
|
|
@@ -1445,27 +1582,25 @@ function compiledMethodBodies(screen: Screen): { onLoadSteps?: string; clicks?:
|
|
|
1445
1582
|
if (!steps || !Object.keys(steps).length) return {}
|
|
1446
1583
|
const names = handleNameMap(screen)
|
|
1447
1584
|
const clicks: string[] = []
|
|
1585
|
+
// Legacy visual event steps compile to `ctx.<name>.on<event> = async () => { ... }`
|
|
1586
|
+
// inside onLoad. New event handlers are written as raw code directly in the onLoad
|
|
1587
|
+
// body (double-click an event -> Studio inserts `ctx.<name>.on<Event> = (e) => {}`).
|
|
1448
1588
|
for (const b of flattenBlocks(screen.blocks)) {
|
|
1449
1589
|
if (b.config.kind !== 'component') continue
|
|
1450
|
-
const clickSteps = steps[clickSlot(b.id)]
|
|
1451
|
-
if (!clickSteps?.length) continue
|
|
1452
1590
|
const name = names.get(b.id)
|
|
1453
1591
|
if (!name) continue
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
const name = names.get(b.id)
|
|
1462
|
-
if (!name) continue
|
|
1463
|
-
clicks.push(`ctx.${name}.onchange = async () => {\n${indentBody(compileHandlerSteps(changeSteps))}\n}`)
|
|
1592
|
+
const spec = uiComponentSpec(b.config.component)
|
|
1593
|
+
const eventKeys = [...(spec?.events ?? []).map((e) => e.key), ...STANDARD_UI_EVENTS.map((e) => e.key)]
|
|
1594
|
+
for (const key of [...new Set(eventKeys)]) {
|
|
1595
|
+
const eventSteps = steps[eventSlot(key, b.id)]
|
|
1596
|
+
if (!eventSteps?.length) continue
|
|
1597
|
+
clicks.push(`ctx.${name}.on${key} = async () => {\n${indentBody(compileHandlerSteps(eventSteps))}\n}`)
|
|
1598
|
+
}
|
|
1464
1599
|
}
|
|
1465
1600
|
return {
|
|
1466
1601
|
// Legacy visual onLoad steps (the code view now edits onLoad directly).
|
|
1467
1602
|
onLoadSteps: steps[ON_LOAD]?.length ? compileHandlerSteps(steps[ON_LOAD]) : undefined,
|
|
1468
|
-
// Component on-
|
|
1603
|
+
// Component on-event wiring - MERGED with any hand-written onLoad body, never replacing it.
|
|
1469
1604
|
clicks: clicks.length ? clicks.join('\n\n') : undefined,
|
|
1470
1605
|
onDestroy: steps[ON_DESTROY]?.length ? compileHandlerSteps(steps[ON_DESTROY]) : undefined,
|
|
1471
1606
|
}
|
|
@@ -1546,7 +1681,7 @@ function freestandingScreenPage(screen: Screen, accessEnabled: boolean, i18nEnab
|
|
|
1546
1681
|
const gridImport = gridNames.length ? `import { ${gridNames.join(', ')} } from '@svgrid/grid'\n ` : ''
|
|
1547
1682
|
const gridTypes = [grid ? 'RowData' : '', grid ? 'SvGridApi' : ''].filter(Boolean)
|
|
1548
1683
|
const typeImport = hasCode && gridTypes.length ? `import type { ${gridTypes.join(', ')} } from '@svgrid/grid'\n ` : ''
|
|
1549
|
-
const handleSpecs = [wiring?.usesHandle ? 'handle' : '', wiring?.usesDataHandle ? 'dataHandle' : ''].filter(Boolean)
|
|
1684
|
+
const handleSpecs = [wiring?.usesHandle ? 'handle' : '', wiring?.usesDataHandle ? 'dataHandle' : '', wiring?.usesGridHandle ? 'gridHandle' : ''].filter(Boolean)
|
|
1550
1685
|
const handleImport = handleSpecs.length ? `import { ${handleSpecs.join(', ')} } from '$lib/handles.svelte'\n ` : ''
|
|
1551
1686
|
|
|
1552
1687
|
const codeImport = hasCode ? `import { onMount } from 'svelte'\n import * as handlers from './handlers'\n import type { PageContext } from './page-context'\n ` : ''
|
|
@@ -1559,7 +1694,7 @@ function freestandingScreenPage(screen: Screen, accessEnabled: boolean, i18nEnab
|
|
|
1559
1694
|
? `\n let rows = $state<RowData[]>([])\n let gridApi = $state<SvGridApi<any, any> | null>(null)\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature, rowSelectionFeature })\n const columns = $derived(rows.length ? Object.keys(rows[0]).map((field) => ({ field, header: field })) : [])`
|
|
1560
1695
|
: ''
|
|
1561
1696
|
const codeScript = hasCode
|
|
1562
|
-
? `\n ${
|
|
1697
|
+
? `\n ${gridScript ? gridScript.trimStart() + '\n ' : ''}${handleDecls ? handleDecls + '\n ' : ''}onMount(() => {
|
|
1563
1698
|
// ctx is internal plumbing wired from this screen's blocks; the typed surface your
|
|
1564
1699
|
// code uses lives in handlers.ts (PageContext). Component handles are runtime proxies,
|
|
1565
1700
|
// so the cast bridges their dynamic shape to the typed context.
|
|
@@ -1568,7 +1703,9 @@ function freestandingScreenPage(screen: Screen, accessEnabled: boolean, i18nEnab
|
|
|
1568
1703
|
return () => handlers.${ON_DESTROY}(ctx)
|
|
1569
1704
|
})`
|
|
1570
1705
|
: ''
|
|
1571
|
-
|
|
1706
|
+
// Grid events fire into ctx.grid so freestanding page code can subscribe too.
|
|
1707
|
+
const gridEventAttrs = grid && wiring?.usesGridHandle ? GRID_EVENTS.filter((e) => e.prop).map((e) => ` ${e.prop}={(...a) => gridCtx.fire('${e.key}', ...a)}`).join('') : ''
|
|
1708
|
+
const gridMarkup = grid ? ` <SvGrid data={rows} columns={columns} features={features} onApiReady={(a) => (gridApi = a)}${gridEventAttrs} showRowNumbers />` : ''
|
|
1572
1709
|
|
|
1573
1710
|
const blockContent = screen.blocks.length
|
|
1574
1711
|
? screen.blocks.map((b) => (b.config.kind === 'component' ? componentBlockMarkup(b, b.config, hasCode ? handleNames.get(b.id) : undefined) : '')).filter(Boolean).join('\n')
|
|
@@ -1594,7 +1731,7 @@ ${content}
|
|
|
1594
1731
|
/** A self-contained screen page composing the screen's blocks. When `accessEnabled`
|
|
1595
1732
|
* the page gates create / update affordances by the current role (server still
|
|
1596
1733
|
* enforces via the route's `authorize`). */
|
|
1597
|
-
function screenPage(schema: EntitySchema, rawSchema: EntitySchema, screen: Screen, resolve: (name: string) => EntitySchema | undefined, rawResolve: (name: string) => EntitySchema | undefined, accessEnabled = false, i18nEnabled = false, routeById: Map<string, string> = new Map(), drillEnabled = false): GeneratedFile {
|
|
1734
|
+
function screenPage(schema: EntitySchema, rawSchema: EntitySchema, screen: Screen, resolve: (name: string) => EntitySchema | undefined, rawResolve: (name: string) => EntitySchema | undefined, accessEnabled = false, i18nEnabled = false, routeById: Map<string, string> = new Map(), drillEnabled = false, ssrData = false, entSource?: EntityDataSource): GeneratedFile {
|
|
1598
1735
|
const n = namesFor(schema)
|
|
1599
1736
|
const label = schema.label ?? n.label
|
|
1600
1737
|
const blocks = screen.blocks
|
|
@@ -1642,8 +1779,11 @@ function screenPage(schema: EntitySchema, rawSchema: EntitySchema, screen: Scree
|
|
|
1642
1779
|
const hasCellRenderers = cellRenderKinds.size > 0
|
|
1643
1780
|
const wantsForm = !!formGrid || hasForm || hasEditAction
|
|
1644
1781
|
// An unpaginated grid loads everything (one big page); else its configured size.
|
|
1645
|
-
const gridPageSize = gridConfigs[0] ? (gridConfigs[0].paginated !== false ? gridConfigs[0].pageSize : 1000) : 10
|
|
1782
|
+
const gridPageSize = gridConfigs[0] ? (gridConfigs[0].paginated !== false ? (gridConfigs[0].pageSize ?? 10) : 1000) : 10
|
|
1646
1783
|
const formPres = formGrid?.formPresentation ?? 'modal'
|
|
1784
|
+
// Optional form-layout depth (columns / field selection+order / titled sections /
|
|
1785
|
+
// dialog title+size) -> extra SvGridEditPanel props. Emitted only when configured.
|
|
1786
|
+
const formLayoutProps = editPanelLayoutProps(formGrid)
|
|
1647
1787
|
const hasPivot = has(allBlocks, 'pivot')
|
|
1648
1788
|
const hasFilter = has(blocks, 'filter')
|
|
1649
1789
|
const hasRecord = has(blocks, 'record')
|
|
@@ -1651,27 +1791,18 @@ function screenPage(schema: EntitySchema, rawSchema: EntitySchema, screen: Scree
|
|
|
1651
1791
|
// Filter panels drive the grid's controller; record panels read the grid's
|
|
1652
1792
|
// selection - both need the controller even if the grid isn't editable.
|
|
1653
1793
|
const needsController = hasGrid || wantsForm || hasFilter || hasRecord
|
|
1794
|
+
// Supabase Realtime: when the screen's entity is Supabase-backed and opts into
|
|
1795
|
+
// live updates, subscribe to Postgres change streams and refresh() the paged
|
|
1796
|
+
// grid on any INSERT / UPDATE / DELETE (respects the active sort/filter/page).
|
|
1797
|
+
const rtSupabase = !ssrData && needsController && entSource?.kind === 'supabase' && entSource.realtime === true
|
|
1798
|
+
const rtTable = rtSupabase ? (entSource as SupabaseSource).table : ''
|
|
1654
1799
|
const hasAgg = has(allBlocks, 'chart') || has(allBlocks, 'dashboard') || has(allBlocks, 'kpi') || has(allBlocks, 'gauge') || has(allBlocks, 'tree')
|
|
1655
1800
|
const relationFields = schema.fields.filter((f) => f.type === 'relation' && f.relation)
|
|
1656
1801
|
|
|
1657
1802
|
// Distinct, resolvable child entities referenced by master-detail blocks, and by
|
|
1658
1803
|
// a detail page's related child collections (both load the child table into a
|
|
1659
1804
|
// `md_<name>_rows` state var + filter it by the foreign key at render time).
|
|
1660
|
-
const
|
|
1661
|
-
for (const b of blocks) {
|
|
1662
|
-
if (b.config.kind === 'master-detail' && b.config.childEntity && b.config.foreignKey) {
|
|
1663
|
-
const c = resolve(b.config.childEntity)
|
|
1664
|
-
if (c) mdChildren.set(c.name, c)
|
|
1665
|
-
}
|
|
1666
|
-
if (b.config.kind === 'detail') {
|
|
1667
|
-
for (const rel of b.config.related ?? []) {
|
|
1668
|
-
if (!rel.entity || !rel.foreignKey) continue
|
|
1669
|
-
const c = resolve(rel.entity)
|
|
1670
|
-
if (c) mdChildren.set(c.name, c)
|
|
1671
|
-
}
|
|
1672
|
-
}
|
|
1673
|
-
}
|
|
1674
|
-
const childList = [...mdChildren.values()]
|
|
1805
|
+
const childList = screenChildEntities(blocks, resolve)
|
|
1675
1806
|
const hasMD = childList.length > 0
|
|
1676
1807
|
// Components with a data binding (aggregate / field) read the whole table too.
|
|
1677
1808
|
const boundComponents = allBlocks.filter((b): b is Block & { config: ComponentConfig } => b.config.kind === 'component' && componentHasBindings(b.config))
|
|
@@ -1733,8 +1864,11 @@ function screenPage(schema: EntitySchema, rawSchema: EntitySchema, screen: Scree
|
|
|
1733
1864
|
if (hasPivot) entImports.push('SvPivotDesigner')
|
|
1734
1865
|
// The scheduler renderer (grid scheduler-view + calendar block) is registered app-wide (idempotent).
|
|
1735
1866
|
if (usesScheduler) entImports.push('enableSchedulerView')
|
|
1867
|
+
if (rtSupabase) entImports.push('createSupabaseRealtime', 'type SupabaseRealtimeClientLike')
|
|
1736
1868
|
// Dedupe: record + form both want SvGridEditPanel.
|
|
1737
1869
|
const entImport = entImports.length ? `import { ${[...new Set(entImports)].join(', ')} } from '@svgrid/enterprise'\n ` : ''
|
|
1870
|
+
// Realtime pulls the shared client from the generated connections module.
|
|
1871
|
+
const connImport = rtSupabase ? `import { supabaseClient } from '$lib/connections'\n ` : ''
|
|
1738
1872
|
const lookupVars = relationFields.map((f) => lookupVar(schema, f.field))
|
|
1739
1873
|
const childSchemaVars = childList.map((c) => namesFor(c).schemaVar)
|
|
1740
1874
|
const childTypes = childList.map((c) => namesFor(c).type)
|
|
@@ -1743,7 +1877,8 @@ function screenPage(schema: EntitySchema, rawSchema: EntitySchema, screen: Scree
|
|
|
1743
1877
|
// (childEntity === this entity) doesn't emit a duplicate import specifier.
|
|
1744
1878
|
const schemaVarImports = [...new Set([n.schemaVar, ...childSchemaVars])]
|
|
1745
1879
|
const typeImports = [...new Set([n.type, ...childTypes])]
|
|
1746
|
-
|
|
1880
|
+
// SSR read pages get every dataset from the server load - no client source use.
|
|
1881
|
+
const dataImports = ssrData ? [] : [...new Set([n.sourceVar, ...childSourceVars, ...(wantsForm ? [...lookupVars, 'nextId'] : [])])].filter(Boolean)
|
|
1747
1882
|
|
|
1748
1883
|
// Drill-through: this screen navigates out (goto) and/or is a drill target that
|
|
1749
1884
|
// reads URL query params matching its fields into an initial filter.
|
|
@@ -1774,11 +1909,20 @@ function screenPage(schema: EntitySchema, rawSchema: EntitySchema, screen: Scree
|
|
|
1774
1909
|
for (const _f of [${filterableFieldNames.map(jsStr).join(', ')}]) { const _v = sp.get(_f); if (_v != null) _cols[_f] = { operator: 'equals', value: _v } }
|
|
1775
1910
|
if (Object.keys(_cols).length) controller.setFilter({ columns: _cols })`
|
|
1776
1911
|
: ''
|
|
1912
|
+
const ctlBase = `createServerDataSource<${n.type}>(${n.sourceVar}, { pageSize: ${gridPageSize}, optimistic: true, getRowId: (r) => String((r as Record<string, unknown>)[idField]), onChange: (s) => (view = s) })`
|
|
1913
|
+
// In code mode, wrap CRUD so page code can react to data changes via ctx.grid
|
|
1914
|
+
// (grid.onRowAdded / onRowUpdated / onRowDeleted). Fires only after a write settles.
|
|
1915
|
+
const ctlDecl = codeGrid
|
|
1916
|
+
? `const __ctl = ${ctlBase}\n const controller = { ...__ctl, createRow: async (i: Partial<${n.type}>) => { const r = await __ctl.createRow(i); gridCtx.fire('rowAdded', r); return r }, updateRow: async (id: string, p: Partial<${n.type}>) => { const r = await __ctl.updateRow(id, p); gridCtx.fire('rowUpdated', r); return r }, deleteRow: async (id: string) => { await __ctl.deleteRow(id); gridCtx.fire('rowDeleted', id) } }`
|
|
1917
|
+
: `const controller = ${ctlBase}`
|
|
1918
|
+
const rtWiring = rtSupabase
|
|
1919
|
+
? `\n const __rt = createSupabaseRealtime({ client: supabaseClient as unknown as SupabaseRealtimeClientLike, table: ${jsStr(rtTable)}, debounceMs: 250, onChange: () => controller.refresh() })
|
|
1920
|
+
return () => { __rt.unsubscribe(); controller.dispose() } })`
|
|
1921
|
+
: `\n controller.refresh(); return () => controller.dispose() })`
|
|
1777
1922
|
parts.push(`const idField = ${n.schemaVar}.idField ?? 'id'
|
|
1778
1923
|
let view = $state<ServerState<${n.type}>>({ rows: [], total: 0, loading: false, saving: false, error: null, pageIndex: 0, pageSize: ${gridPageSize}, pageCount: 1, sortModel: [], filterModel: {} })
|
|
1779
|
-
|
|
1780
|
-
$effect(() => {${urlFilter}
|
|
1781
|
-
controller.refresh(); return () => controller.dispose() })`)
|
|
1924
|
+
${ctlDecl}
|
|
1925
|
+
$effect(() => {${urlFilter}${rtSupabase ? '\n controller.refresh()' : ''}${rtWiring}`)
|
|
1782
1926
|
}
|
|
1783
1927
|
const actionSnippets: string[] = []
|
|
1784
1928
|
// Tree-grid scripts reference `allRows`; collected here, appended AFTER its declaration.
|
|
@@ -1813,10 +1957,17 @@ function screenPage(schema: EntitySchema, rawSchema: EntitySchema, screen: Scree
|
|
|
1813
1957
|
}
|
|
1814
1958
|
}
|
|
1815
1959
|
if (needsAllRows) {
|
|
1816
|
-
|
|
1960
|
+
// SSR read screens get their rows from the server load (real SSR HTML);
|
|
1961
|
+
// the SPA path fetches client-side after mount.
|
|
1962
|
+
parts.push(
|
|
1963
|
+
ssrData
|
|
1964
|
+
? `const allRows = $derived(data.rows as ${n.type}[])
|
|
1965
|
+
const allRowsReady = true`
|
|
1966
|
+
: `let allRows = $state<${n.type}[]>([])
|
|
1817
1967
|
let allRowsReady = $state(false)
|
|
1818
1968
|
async function loadAll() { allRows = [...(await ${n.sourceVar}.getRows({ startRow: 0, endRow: 1000, pageIndex: 0, pageSize: 1000, sortModel: [], filterModel: {} })).rows]; allRowsReady = true }
|
|
1819
|
-
loadAll()
|
|
1969
|
+
loadAll()`,
|
|
1970
|
+
)
|
|
1820
1971
|
}
|
|
1821
1972
|
// Tree-grid state/derivations - emitted after `allRows` so it's in scope.
|
|
1822
1973
|
for (const s of treeScripts) parts.push(s)
|
|
@@ -1840,6 +1991,10 @@ function screenPage(schema: EntitySchema, rawSchema: EntitySchema, screen: Scree
|
|
|
1840
1991
|
for (const c of childList) {
|
|
1841
1992
|
const cn = namesFor(c)
|
|
1842
1993
|
const v = mdChildVar(c.name)
|
|
1994
|
+
if (ssrData) {
|
|
1995
|
+
parts.push(`const ${v} = $derived(data.${v} as ${cn.type}[])`)
|
|
1996
|
+
continue
|
|
1997
|
+
}
|
|
1843
1998
|
parts.push(`let ${v} = $state<${cn.type}[]>([])
|
|
1844
1999
|
async function load_${v}() { ${v} = [...(await ${cn.sourceVar}.getRows({ startRow: 0, endRow: 1000, pageIndex: 0, pageSize: 1000, sortModel: [], filterModel: {} })).rows] }
|
|
1845
2000
|
load_${v}()`)
|
|
@@ -1925,7 +2080,7 @@ function screenPage(schema: EntitySchema, rawSchema: EntitySchema, screen: Scree
|
|
|
1925
2080
|
const steps = b.config.kind === 'grid' ? screen.handlerSteps?.[rowSelectSlot(b.id)] : undefined
|
|
1926
2081
|
return steps?.length ? compileHandlerSteps(steps) : undefined
|
|
1927
2082
|
}
|
|
1928
|
-
const blockCtx = (b: Block, pane: boolean) => ({ hasRecord, accessEnabled: gatesUi, routeById, i18n: i18nEnabled, rawEntity: rawSchema, rawResolve, captureApi: apiVarFor(b), handleNames: codeEnabled ? handleNames : undefined, pane, rowSelectSteps: rowSelectFor(b), ctxLiteral: codeWire?.ctxLiteral })
|
|
2083
|
+
const blockCtx = (b: Block, pane: boolean) => ({ hasRecord, accessEnabled: gatesUi, routeById, i18n: i18nEnabled, rawEntity: rawSchema, rawResolve, captureApi: apiVarFor(b), handleNames: codeEnabled ? handleNames : undefined, pane, rowSelectSteps: rowSelectFor(b), ctxLiteral: codeWire?.ctxLiteral, gridEventSink: codeGrid && b.id === codeGridBlockId ? 'gridCtx' : undefined })
|
|
1929
2084
|
const body = blocks.map((b) => blockMarkup(schema, n.schemaVar, n.type, b, resolve, blockCtx(b, false))).filter(Boolean).join('\n')
|
|
1930
2085
|
// Dock mode: each block becomes a pane rendered by id; a narrow viewport stacks the grid body.
|
|
1931
2086
|
const dockPanes = isDock
|
|
@@ -1961,7 +2116,7 @@ ${canvasBody}
|
|
|
1961
2116
|
${body}
|
|
1962
2117
|
</div>`
|
|
1963
2118
|
const modal = wantsForm
|
|
1964
|
-
? `\n\n{#if editing !== undefined}\n <SvGridEditPanel schema={${n.schemaVar}} row={editing}${relationFields.length ? ' {lookups}' : ''} presentation="${formPres}" persistKey="${screen.route}" onSubmit={save} onCancel={() => (editing = undefined)} />\n{/if}`
|
|
2119
|
+
? `\n\n{#if editing !== undefined}\n <SvGridEditPanel schema={${n.schemaVar}} row={editing}${relationFields.length ? ' {lookups}' : ''} presentation="${formPres}"${formLayoutProps} persistKey="${screen.route}" onSubmit={save} onCancel={() => (editing = undefined)} />\n{/if}`
|
|
1965
2120
|
: ''
|
|
1966
2121
|
// currentRole is needed for either a create/update UI gate or an action's
|
|
1967
2122
|
// screen-access gate; `can`/`canScreen` are pulled in only where actually used.
|
|
@@ -1973,7 +2128,7 @@ ${body}
|
|
|
1973
2128
|
// block otherwise navigates; and the handle runtime for its data/component handles.
|
|
1974
2129
|
const gotoImport = usesGoto || codeEnabled ? `import { goto } from '$app/navigation'\n ` : ''
|
|
1975
2130
|
const codeImport = codeEnabled ? `import { onMount } from 'svelte'\n import * as handlers from './handlers'\n import type { PageContext } from './page-context'\n ` : ''
|
|
1976
|
-
const handleSpecs = [codeWire?.usesHandle ? 'handle' : '', codeWire?.usesDataHandle ? 'dataHandle' : ''].filter(Boolean)
|
|
2131
|
+
const handleSpecs = [codeWire?.usesHandle ? 'handle' : '', codeWire?.usesDataHandle ? 'dataHandle' : '', codeWire?.usesGridHandle ? 'gridHandle' : ''].filter(Boolean)
|
|
1977
2132
|
const handleImport = handleSpecs.length ? `import { ${handleSpecs.join(', ')} } from '$lib/handles.svelte'\n ` : ''
|
|
1978
2133
|
const pageImport = applyUrlFilters || has(allBlocks, 'detail') || codeEnabled ? `import { page } from '$app/stores'\n ` : ''
|
|
1979
2134
|
const title = i18nEnabled ? `{$t('screen.${screen.id}', ${JSON.stringify(screen.title)})}` : screen.title
|
|
@@ -1991,10 +2146,9 @@ ${body}
|
|
|
1991
2146
|
path: `src/routes/${screen.route}/+page.svelte`,
|
|
1992
2147
|
description: `${screen.title} screen (${blocks.map((b) => b.config.kind).join(', ') || 'empty'}).`,
|
|
1993
2148
|
contents: `<script lang="ts">
|
|
1994
|
-
${gridImports}${entImport}${handleImport}${accessImport}${i18nImport}${gotoImport}${codeImport}${pageImport}import { ${schemaVarImports.join(', ')}, ${typeImports.map((t) => `type ${t}`).join(', ')} } from '$lib/schemas'
|
|
1995
|
-
import { ${dataImports.join(', ')} } from '$lib/data'
|
|
1996
|
-
|
|
1997
|
-
${parts.join('\n\n ')}
|
|
2149
|
+
${gridImports}${entImport}${connImport}${handleImport}${accessImport}${i18nImport}${gotoImport}${codeImport}${pageImport}${ssrData ? "import type { PageProps } from './$types'\n " : ''}import { ${schemaVarImports.join(', ')}, ${typeImports.map((t) => `type ${t}`).join(', ')} } from '$lib/schemas'
|
|
2150
|
+
${dataImports.length ? ` import { ${dataImports.join(', ')} } from '$lib/data'\n` : ''}
|
|
2151
|
+
${ssrData ? 'let { data }: PageProps = $props()\n\n ' : ''}${parts.join('\n\n ')}
|
|
1998
2152
|
</script>
|
|
1999
2153
|
|
|
2000
2154
|
<h1 class="st__title">${title}</h1>
|
|
@@ -2019,6 +2173,376 @@ ${(() => {
|
|
|
2019
2173
|
}
|
|
2020
2174
|
}
|
|
2021
2175
|
|
|
2176
|
+
/** Distinct, resolvable child entities a screen's master-detail / detail blocks
|
|
2177
|
+
* reference - shared by the SPA loader, the SSR read `load`, and the page's
|
|
2178
|
+
* `md_<name>_rows` derivations so the three can never drift. */
|
|
2179
|
+
function screenChildEntities(blocks: Block[], resolve: (name: string) => EntitySchema | undefined): EntitySchema[] {
|
|
2180
|
+
const out = new Map<string, EntitySchema>()
|
|
2181
|
+
for (const b of blocks) {
|
|
2182
|
+
if (b.config.kind === 'master-detail' && b.config.childEntity && b.config.foreignKey) {
|
|
2183
|
+
const c = resolve(b.config.childEntity)
|
|
2184
|
+
if (c) out.set(c.name, c)
|
|
2185
|
+
}
|
|
2186
|
+
if (b.config.kind === 'detail') {
|
|
2187
|
+
for (const rel of b.config.related ?? []) {
|
|
2188
|
+
if (!rel.entity || !rel.foreignKey) continue
|
|
2189
|
+
const c = resolve(rel.entity)
|
|
2190
|
+
if (c) out.set(c.name, c)
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
return [...out.values()]
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
/** `+page.server.ts` for a read-only SSR screen (data-viz / detail / master-
|
|
2198
|
+
* detail): a `load` that returns the full dataset (+ each child collection) -
|
|
2199
|
+
* the page renders real SSR HTML from `data.*`. No actions (read-only). */
|
|
2200
|
+
function ssrReadServerFile(schema: EntitySchema, screen: Screen, sourceKind: 'memory' | 'sql', accessEnabled: boolean, screenIds: string[], resolve: (name: string) => EntitySchema | undefined): GeneratedFile {
|
|
2201
|
+
const n = namesFor(schema)
|
|
2202
|
+
const isSql = sourceKind === 'sql'
|
|
2203
|
+
const children = screenChildEntities(screen.blocks, resolve)
|
|
2204
|
+
const rbac = accessEnabled && !isSql // sql: the /api route already enforces authz
|
|
2205
|
+
const srcExpr = (s: EntitySchema) =>
|
|
2206
|
+
isSql ? `createKitDataSource<Record<string, unknown>>({ endpoint: ${jsStr('/api/' + namesFor(s).route)}, fetch })` : namesFor(s).sourceVar
|
|
2207
|
+
const memImports = isSql ? [] : [...new Set([n.sourceVar, ...children.map((c) => namesFor(c).sourceVar)])]
|
|
2208
|
+
const childLoads = children.map((c) => ` const ${mdChildVar(c.name)} = (await ${srcExpr(c)}.getRows(PAGE)).rows`).join('\n')
|
|
2209
|
+
return {
|
|
2210
|
+
path: `src/routes/${screen.route}/+page.server.ts`,
|
|
2211
|
+
description: `${screen.title} - SSR load (full dataset${children.length ? ' + child collections' : ''}).`,
|
|
2212
|
+
contents: `import type { PageServerLoad } from './$types'
|
|
2213
|
+
import type { ServerRequest } from '@svgrid/grid'
|
|
2214
|
+
${isSql ? "import { createKitDataSource } from '@svgrid/enterprise'\n" : ''}${rbac ? "import { error } from '@sveltejs/kit'\nimport { authorizeAction, getServerRole } from '$lib/access'\n" : ''}${memImports.length ? `import { ${memImports.join(', ')} } from '$lib/data'\n` : ''}
|
|
2215
|
+
// The app shell is a client SPA (+layout.ts ssr=false); this screen opts back
|
|
2216
|
+
// INTO server rendering - real SSR HTML, not just a server-side load.
|
|
2217
|
+
export const ssr = true
|
|
2218
|
+
${rbac ? `\nconst SCREEN_IDS = ${JSON.stringify(screenIds)}\n` : ''}
|
|
2219
|
+
const PAGE: ServerRequest = { startRow: 0, endRow: 1000, pageIndex: 0, pageSize: 1000, sortModel: [], filterModel: {} }
|
|
2220
|
+
|
|
2221
|
+
export const load: PageServerLoad = async (${isSql || rbac ? `{ ${[isSql ? 'fetch' : '', rbac ? 'locals' : ''].filter(Boolean).join(', ')} }` : ''}) => {
|
|
2222
|
+
${rbac ? ` if (!authorizeAction(getServerRole({ locals }), 'read', SCREEN_IDS)) throw error(403, 'Not allowed')\n` : ''} const rows = (await ${srcExpr(schema)}.getRows(PAGE)).rows
|
|
2223
|
+
${childLoads ? childLoads + '\n' : ''} return { rows${children.map((c) => `, ${mdChildVar(c.name)}`).join('')} }
|
|
2224
|
+
}
|
|
2225
|
+
`,
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
// ---------------------------------------------------------------------------
|
|
2230
|
+
// SSR-native output (opt-in per screen via `renderMode: 'ssr'`).
|
|
2231
|
+
//
|
|
2232
|
+
// Instead of the client data-source-controller page, an SSR screen emits idiomatic
|
|
2233
|
+
// SvelteKit: a `+page.server.ts` with a `load` (SSR first paint, sort/filter/page
|
|
2234
|
+
// read from the URL so it's shareable and works with no JS) and form `actions` for
|
|
2235
|
+
// create / update / delete (progressive enhancement via `use:enhance`), plus an
|
|
2236
|
+
// SSR `+page.svelte` that renders the server rows and drives the grid's external
|
|
2237
|
+
// sort/pagination back into the URL. Server-side validation runs in the actions.
|
|
2238
|
+
// ---------------------------------------------------------------------------
|
|
2239
|
+
|
|
2240
|
+
/** URL search params -> a data-source request. Shared by every SSR route's load. */
|
|
2241
|
+
const SSR_QUERY_HELPER = `// Turn a page URL's search params into a data-source request (sort / filter /
|
|
2242
|
+
// page / size). The grid drives these params via goto(), so the server re-runs
|
|
2243
|
+
// load() - which makes every list view bookmarkable and functional with no JS.
|
|
2244
|
+
import type { ServerRequest } from '@svgrid/grid'
|
|
2245
|
+
|
|
2246
|
+
export function planFromSearchParams(url: URL, defaultPageSize = 25): ServerRequest {
|
|
2247
|
+
const sp = url.searchParams
|
|
2248
|
+
const pageIndex = Math.max(0, Number.parseInt(sp.get('page') ?? '', 10) || 0)
|
|
2249
|
+
const pageSize = Math.min(200, Math.max(1, Number.parseInt(sp.get('size') ?? '', 10) || defaultPageSize))
|
|
2250
|
+
const sortModel = (sp.get('sort') ?? '')
|
|
2251
|
+
.split(',')
|
|
2252
|
+
.filter(Boolean)
|
|
2253
|
+
.map((token) => {
|
|
2254
|
+
const [id, dir] = token.split(':')
|
|
2255
|
+
return { id: id!, desc: dir === 'desc' }
|
|
2256
|
+
})
|
|
2257
|
+
const columns: Record<string, { operator: 'contains'; value: string }> = {}
|
|
2258
|
+
for (const [k, v] of sp) if (k.startsWith('f_') && v) columns[k.slice(2)] = { operator: 'contains', value: v }
|
|
2259
|
+
const global = sp.get('q') ?? ''
|
|
2260
|
+
return {
|
|
2261
|
+
startRow: pageIndex * pageSize,
|
|
2262
|
+
endRow: pageIndex * pageSize + pageSize,
|
|
2263
|
+
pageIndex,
|
|
2264
|
+
pageSize,
|
|
2265
|
+
sortModel,
|
|
2266
|
+
filterModel: { ...(global ? { global } : {}), columns },
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
`
|
|
2270
|
+
|
|
2271
|
+
function ssrQueryHelperFile(): GeneratedFile {
|
|
2272
|
+
return { path: 'src/lib/server/query.ts', description: 'SSR: URL search params -> data-source request (sort/filter/page).', contents: SSR_QUERY_HELPER }
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
const htmlEsc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
2276
|
+
|
|
2277
|
+
/** The `+page.server.ts` + SSR `+page.svelte` for a single-grid CRUD screen. */
|
|
2278
|
+
function emitSsrGridScreen(schema: EntitySchema, screen: Screen, sourceKind: 'memory' | 'sql', accessEnabled: boolean, screenIds: string[], byName: Map<string, EntitySchema>): GeneratedFile[] {
|
|
2279
|
+
const n = namesFor(schema)
|
|
2280
|
+
const isSql = sourceKind === 'sql'
|
|
2281
|
+
const relSchemaOf = (f: EntityField) => byName.get(f.relation!.entity)!
|
|
2282
|
+
const relIdOf = (rs: EntitySchema) => rs.idField ?? rs.fields.find((x) => x.primaryKey)?.field ?? 'id'
|
|
2283
|
+
const grid = screen.blocks[0]!.config as GridConfig
|
|
2284
|
+
const pageSize = grid.pageSize && grid.pageSize > 0 ? grid.pageSize : 25
|
|
2285
|
+
const wantsFilter = grid.filterable !== false
|
|
2286
|
+
const idField = schema.idField ?? schema.fields.find((f) => f.primaryKey)?.field ?? 'id'
|
|
2287
|
+
const isFormHidden = (f: EntityField) => f.hidden === true || (typeof f.hidden === 'object' && !!f.hidden.form)
|
|
2288
|
+
const formFields = schema.fields.filter(
|
|
2289
|
+
(f) => f.field !== idField && !f.primaryKey && !f.readonly && !f.computed && !f.formula && !isFormHidden(f),
|
|
2290
|
+
)
|
|
2291
|
+
const normType = (t: string): 'text' | 'number' | 'boolean' => (t === 'number' ? 'number' : t === 'boolean' ? 'boolean' : 'text')
|
|
2292
|
+
const fieldTypesLit = `{ ${formFields.map((f) => `${jsStr(f.field)}: ${jsStr(normType(f.type))}`).join(', ')} }`
|
|
2293
|
+
|
|
2294
|
+
// Relation fields render as a native <select> whose options are prefetched in load
|
|
2295
|
+
// (memory path only for now; sql relation fields stay a text FK - follow-up).
|
|
2296
|
+
const isRel = (f: EntityField) => f.type === 'relation' && !!f.relation && byName.has(f.relation!.entity)
|
|
2297
|
+
const relFields = formFields.filter(isRel)
|
|
2298
|
+
// The $lib/data import only needs the related sources on the memory path; on sql
|
|
2299
|
+
// the related options come from the related entity's /api route (via event.fetch).
|
|
2300
|
+
const relSourceVars = isSql ? [] : [...new Set(relFields.map((f) => namesFor(relSchemaOf(f)).sourceVar))]
|
|
2301
|
+
const relPrefetch = relFields
|
|
2302
|
+
.map((f) => {
|
|
2303
|
+
const rs = relSchemaOf(f)
|
|
2304
|
+
const rel = namesFor(rs)
|
|
2305
|
+
const relId = relIdOf(rs)
|
|
2306
|
+
const labelF = f.relation!.labelField ?? relId
|
|
2307
|
+
const relSource = isSql
|
|
2308
|
+
? `createKitDataSource<Record<string, unknown>>({ endpoint: ${jsStr('/api/' + rel.route)}, fetch })`
|
|
2309
|
+
: rel.sourceVar
|
|
2310
|
+
return ` const ${f.field}Options = (await ${relSource}.getRows({ startRow: 0, endRow: 100, pageIndex: 0, pageSize: 100, sortModel: [], filterModel: {} })).rows.map((r: Record<string, unknown>) => ({ value: String(r[${jsStr(relId)}] ?? ''), label: String(r[${jsStr(labelF)}] ?? '') }))`
|
|
2311
|
+
})
|
|
2312
|
+
.join('\n')
|
|
2313
|
+
const relReturn = relFields.map((f) => `, ${f.field}Options`).join('')
|
|
2314
|
+
|
|
2315
|
+
// Source acquisition differs by kind:
|
|
2316
|
+
// - memory: import the in-process source from $lib/data and call it directly.
|
|
2317
|
+
// - sql: build a same-origin client over the connected /api/<entity> route with
|
|
2318
|
+
// SvelteKit's event.fetch, so validation / RBAC / triggers / audit stay enforced
|
|
2319
|
+
// once, in that route's createKitHandlers - not duplicated here.
|
|
2320
|
+
const src = isSql ? 'source(fetch)' : n.sourceVar
|
|
2321
|
+
const fetchArg = isSql ? ', fetch' : ''
|
|
2322
|
+
const enterpriseImports = isSql ? 'validateAll, createKitDataSource' : 'validateAll'
|
|
2323
|
+
const sourceImport = isSql ? '' : `import { ${[...new Set([n.sourceVar, ...relSourceVars]), 'nextId'].join(', ')} } from '$lib/data'\n`
|
|
2324
|
+
const schemaImport = `import { ${n.schemaVar}${isSql ? `, type ${n.type}` : ''} } from '$lib/schemas'`
|
|
2325
|
+
const idConst = isSql ? '' : `\nconst ID_FIELD = ${jsStr(idField)}`
|
|
2326
|
+
const srcHelper = isSql
|
|
2327
|
+
? `\n// Same-origin client over the connected /api/${n.route} route (that route runs\n// validation, RBAC, triggers + audit via createKitHandlers).\nconst source = (fetch: typeof globalThis.fetch) => createKitDataSource<${n.type}>({ endpoint: ${jsStr('/api/' + n.route)}, fetch })\n`
|
|
2328
|
+
: ''
|
|
2329
|
+
const createCall = isSql
|
|
2330
|
+
? `await ${src}.createRow(values)`
|
|
2331
|
+
: `await ${n.sourceVar}.createRow({ [ID_FIELD]: nextId(${jsStr(n.idPrefix)}), ...values })`
|
|
2332
|
+
|
|
2333
|
+
// RBAC only needs inline enforcement for the memory path; sql inherits it from the
|
|
2334
|
+
// connected /api route (createKitHandlers authorize), reached via event.fetch.
|
|
2335
|
+
const rbac = accessEnabled && !isSql
|
|
2336
|
+
const localsArg = rbac ? ', locals' : ''
|
|
2337
|
+
const readGuard = rbac ? ` if (!authorizeAction(getServerRole({ locals }), 'read', SCREEN_IDS)) throw error(403, 'Not allowed')\n` : ''
|
|
2338
|
+
const writeGuard = (action: string) => (rbac ? ` if (!authorizeAction(getServerRole({ locals }), '${action}', SCREEN_IDS)) return fail(403, { error: 'Not allowed' })\n` : '')
|
|
2339
|
+
|
|
2340
|
+
const server = `import type { Actions, PageServerLoad } from './$types'
|
|
2341
|
+
import { fail${rbac ? ', error' : ''} } from '@sveltejs/kit'
|
|
2342
|
+
import { ${enterpriseImports} } from '@svgrid/enterprise'
|
|
2343
|
+
${rbac ? "import { authorizeAction, getServerRole } from '$lib/access'\n" : ''}${sourceImport}${schemaImport}
|
|
2344
|
+
import { planFromSearchParams } from '$lib/server/query'
|
|
2345
|
+
|
|
2346
|
+
// The app shell is a client SPA (+layout.ts ssr=false); this screen opts back
|
|
2347
|
+
// INTO server rendering - real SSR HTML, not just a server-side load.
|
|
2348
|
+
export const ssr = true
|
|
2349
|
+
${idConst}${rbac ? `\nconst SCREEN_IDS = ${JSON.stringify(screenIds)}` : ''}
|
|
2350
|
+
const FIELD_TYPES: Record<string, 'text' | 'number' | 'boolean'> = ${fieldTypesLit}
|
|
2351
|
+
${srcHelper}
|
|
2352
|
+
/** Read a submitted form into a typed partial row. Booleans come from checkbox
|
|
2353
|
+
* presence; numbers are coerced; empty values are dropped so they don't clobber. */
|
|
2354
|
+
function formToValues(fd: FormData): Record<string, unknown> {
|
|
2355
|
+
const values: Record<string, unknown> = {}
|
|
2356
|
+
for (const [field, type] of Object.entries(FIELD_TYPES)) {
|
|
2357
|
+
if (type === 'boolean') { values[field] = fd.get(field) != null; continue }
|
|
2358
|
+
const raw = fd.get(field)
|
|
2359
|
+
if (raw == null || raw === '') continue
|
|
2360
|
+
values[field] = type === 'number' ? Number(raw) : String(raw)
|
|
2361
|
+
}
|
|
2362
|
+
return values
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
export const load: PageServerLoad = async ({ url${fetchArg}${localsArg} }) => {
|
|
2366
|
+
${readGuard} const plan = planFromSearchParams(url, ${pageSize})
|
|
2367
|
+
const { rows, rowCount } = await ${src}.getRows(plan)
|
|
2368
|
+
${relPrefetch ? relPrefetch + '\n' : ''} return { rows, total: rowCount, page: plan.pageIndex, size: plan.pageSize, sort: plan.sortModel${relReturn} }
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
export const actions: Actions = {
|
|
2372
|
+
create: async ({ request${fetchArg}${localsArg} }) => {
|
|
2373
|
+
${writeGuard('create')} const values = formToValues(await request.formData())
|
|
2374
|
+
const errors = await validateAll(${n.schemaVar}, values)
|
|
2375
|
+
if (Object.keys(errors).length) return fail(422, { errors, values })
|
|
2376
|
+
${createCall}
|
|
2377
|
+
return { ok: true }
|
|
2378
|
+
},
|
|
2379
|
+
update: async ({ request${fetchArg}${localsArg} }) => {
|
|
2380
|
+
const fd = await request.formData()
|
|
2381
|
+
${writeGuard('update')} const id = String(fd.get('__id') ?? '')
|
|
2382
|
+
const values = formToValues(fd)
|
|
2383
|
+
const errors = await validateAll(${n.schemaVar}, values)
|
|
2384
|
+
if (Object.keys(errors).length) return fail(422, { errors, values })
|
|
2385
|
+
await ${src}.updateRow(id, values)
|
|
2386
|
+
return { ok: true }
|
|
2387
|
+
},
|
|
2388
|
+
delete: async ({ request${fetchArg}${localsArg} }) => {
|
|
2389
|
+
${writeGuard('delete')} const fd = await request.formData()
|
|
2390
|
+
await ${src}.deleteRow(String(fd.get('__id') ?? ''))
|
|
2391
|
+
return { ok: true }
|
|
2392
|
+
},
|
|
2393
|
+
}
|
|
2394
|
+
`
|
|
2395
|
+
|
|
2396
|
+
const row = `(editing as Record<string, unknown>)`
|
|
2397
|
+
const fieldBlocks = formFields
|
|
2398
|
+
.map((f) => {
|
|
2399
|
+
const key = jsStr(f.field)
|
|
2400
|
+
const req = f.required ? ' required' : ''
|
|
2401
|
+
let input: string
|
|
2402
|
+
if (normType(f.type) === 'boolean') {
|
|
2403
|
+
input = `<input type="checkbox" name=${key} checked={!isCreate && !!${row}[${key}]} />`
|
|
2404
|
+
} else if (f.options && f.options.length) {
|
|
2405
|
+
const opts = f.options
|
|
2406
|
+
.map((o) => `<option value=${jsStr(String(o.value))} selected={!isCreate && ${row}[${key}] === ${jsStr(String(o.value))}}>${htmlEsc(o.label ?? String(o.value))}</option>`)
|
|
2407
|
+
.join('')
|
|
2408
|
+
input = `<select name=${key}${req}>${opts}</select>`
|
|
2409
|
+
} else if (isRel(f)) {
|
|
2410
|
+
// Relation: options prefetched in load (data.<field>Options), current FK selected.
|
|
2411
|
+
input = `<select name=${key}${req}>\n <option value="">-</option>\n {#each data.${f.field}Options as o (o.value)}<option value={o.value} selected={!isCreate && String(${row}[${key}] ?? '') === o.value}>{o.label}</option>{/each}\n </select>`
|
|
2412
|
+
} else {
|
|
2413
|
+
const t = normType(f.type) === 'number' ? 'number' : f.type === 'date' || f.type === 'dateString' ? 'date' : 'text'
|
|
2414
|
+
input = `<input type="${t}" name=${key} value={isCreate ? '' : (${row}[${key}] ?? '')}${req} />`
|
|
2415
|
+
}
|
|
2416
|
+
return ` <label class="sk-field">
|
|
2417
|
+
<span>${htmlEsc(f.label ?? f.field)}${f.required ? ' *' : ''}</span>
|
|
2418
|
+
${input}
|
|
2419
|
+
{#if form?.errors?.[${key}]}<em class="sk-err">{form.errors[${key}]}</em>{/if}
|
|
2420
|
+
</label>`
|
|
2421
|
+
})
|
|
2422
|
+
.join('\n')
|
|
2423
|
+
|
|
2424
|
+
const page = `<script lang="ts">
|
|
2425
|
+
import { SvGrid, renderSnippet, type ColumnDef, type CellContext } from '@svgrid/grid'
|
|
2426
|
+
import { schemaToColumns } from '@svgrid/enterprise'
|
|
2427
|
+
import { goto } from '$app/navigation'
|
|
2428
|
+
import { page } from '$app/state'
|
|
2429
|
+
import { enhance } from '$app/forms'
|
|
2430
|
+
import type { SubmitFunction } from '@sveltejs/kit'
|
|
2431
|
+
import { ${n.schemaVar}, type ${n.type} } from '$lib/schemas'
|
|
2432
|
+
import type { PageProps } from './$types'
|
|
2433
|
+
|
|
2434
|
+
let { data, form }: PageProps = $props()
|
|
2435
|
+
|
|
2436
|
+
const ID_FIELD = ${jsStr(idField)}
|
|
2437
|
+
const TITLE = ${jsStr(screen.title)}
|
|
2438
|
+
const NEW_LABEL = ${jsStr('New ' + n.label)}
|
|
2439
|
+
|
|
2440
|
+
// Grid columns from the schema + a row-actions column (Edit / Delete).
|
|
2441
|
+
const columns: ColumnDef<Record<string, never>, ${n.type}>[] = [
|
|
2442
|
+
...(schemaToColumns(${n.schemaVar}) as ColumnDef<Record<string, never>, ${n.type}>[]),
|
|
2443
|
+
{ id: '__actions', header: '', sortable: false, cell: (ctx: CellContext<${n.type}>) => renderSnippet(rowActions, { row: ctx.row.original }) },
|
|
2444
|
+
]
|
|
2445
|
+
|
|
2446
|
+
// null = no editor; 'create' = new row; a row = editing that row.
|
|
2447
|
+
let editing = $state<'create' | ${n.type} | null>(null)
|
|
2448
|
+
const isCreate = $derived(editing === 'create')
|
|
2449
|
+
|
|
2450
|
+
// Sort / paginate by writing to the URL - load() re-runs on the server.
|
|
2451
|
+
function setParams(patch: Record<string, string | null>) {
|
|
2452
|
+
const sp = new URLSearchParams(page.url.searchParams)
|
|
2453
|
+
for (const [k, v] of Object.entries(patch)) { if (v == null) sp.delete(k); else sp.set(k, v) }
|
|
2454
|
+
const q = sp.toString()
|
|
2455
|
+
void goto(q ? \`?\${q}\` : page.url.pathname, { keepFocus: true, noScroll: true })
|
|
2456
|
+
}
|
|
2457
|
+
${wantsFilter ? `
|
|
2458
|
+
// Filter via the URL (q = global search, f_<col> = per-column). The server's
|
|
2459
|
+
// planFromSearchParams reads these, so load() re-filters; reset to the first page.
|
|
2460
|
+
function applyFilters(f: { global: string; columns: Array<{ id: string; value: string }> }) {
|
|
2461
|
+
const sp = new URLSearchParams(page.url.searchParams)
|
|
2462
|
+
for (const k of [...sp.keys()]) if (k === 'q' || k.startsWith('f_')) sp.delete(k)
|
|
2463
|
+
if (f.global) sp.set('q', f.global)
|
|
2464
|
+
for (const c of f.columns) if (c.value) sp.set('f_' + c.id, c.value)
|
|
2465
|
+
sp.delete('page')
|
|
2466
|
+
const q = sp.toString()
|
|
2467
|
+
void goto(q ? \`?\${q}\` : page.url.pathname, { keepFocus: true, noScroll: true })
|
|
2468
|
+
}
|
|
2469
|
+
` : ''}
|
|
2470
|
+
// Progressive enhancement: post to the action, keep typed values on error,
|
|
2471
|
+
// close the editor on success (load re-runs automatically, refreshing the grid).
|
|
2472
|
+
const onSubmit: SubmitFunction = () => async ({ result, update }) => {
|
|
2473
|
+
await update({ reset: false })
|
|
2474
|
+
if (result.type === 'success') editing = null
|
|
2475
|
+
}
|
|
2476
|
+
</script>
|
|
2477
|
+
|
|
2478
|
+
{#snippet rowActions({ row }: { row: ${n.type} })}
|
|
2479
|
+
<div class="sk-rowact">
|
|
2480
|
+
<button type="button" class="sk-link" onclick={() => (editing = row)}>Edit</button>
|
|
2481
|
+
<form method="POST" action="?/delete" use:enhance={onSubmit} style="display:contents">
|
|
2482
|
+
<input type="hidden" name="__id" value={(row as Record<string, unknown>)[ID_FIELD] as string} />
|
|
2483
|
+
<button type="submit" class="sk-link sk-danger">Delete</button>
|
|
2484
|
+
</form>
|
|
2485
|
+
</div>
|
|
2486
|
+
{/snippet}
|
|
2487
|
+
|
|
2488
|
+
<header class="sk-head">
|
|
2489
|
+
<h1>{TITLE}</h1>
|
|
2490
|
+
<button type="button" class="sk-btn sk-btn--primary" onclick={() => (editing = 'create')}>{NEW_LABEL}</button>
|
|
2491
|
+
</header>
|
|
2492
|
+
|
|
2493
|
+
<SvGrid
|
|
2494
|
+
data={data.rows}
|
|
2495
|
+
{columns}
|
|
2496
|
+
externalSort
|
|
2497
|
+
initialSorting={data.sort}
|
|
2498
|
+
externalPagination${wantsFilter ? '\n filterable\n externalFilter\n onFiltersChange={applyFilters}' : ''}
|
|
2499
|
+
rowCount={data.total}
|
|
2500
|
+
pageIndex={data.page}
|
|
2501
|
+
pageSize={data.size}
|
|
2502
|
+
onSortingChange={(s) => setParams({ sort: s.map((x) => \`\${x.id}:\${x.desc ? 'desc' : 'asc'}\`).join(',') || null, page: null })}
|
|
2503
|
+
onPaginationChange={(p) => setParams({ page: String(p.pageIndex), size: String(p.pageSize) })}
|
|
2504
|
+
/>
|
|
2505
|
+
|
|
2506
|
+
{#if editing}
|
|
2507
|
+
<div class="sk-overlay">
|
|
2508
|
+
<form method="POST" action={isCreate ? '?/create' : '?/update'} class="sk-form" use:enhance={onSubmit}>
|
|
2509
|
+
<h2>{isCreate ? NEW_LABEL : 'Edit ${htmlEsc(n.label)}'}</h2>
|
|
2510
|
+
{#if !isCreate}<input type="hidden" name="__id" value={${row}[ID_FIELD] as string} />{/if}
|
|
2511
|
+
${fieldBlocks}
|
|
2512
|
+
<div class="sk-form__actions">
|
|
2513
|
+
<button type="button" class="sk-btn" onclick={() => (editing = null)}>Cancel</button>
|
|
2514
|
+
<button type="submit" class="sk-btn sk-btn--primary">Save</button>
|
|
2515
|
+
</div>
|
|
2516
|
+
</form>
|
|
2517
|
+
</div>
|
|
2518
|
+
{/if}
|
|
2519
|
+
|
|
2520
|
+
<style>
|
|
2521
|
+
.sk-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
|
|
2522
|
+
.sk-head h1 { margin: 0; font-size: 20px; }
|
|
2523
|
+
.sk-btn { font: inherit; padding: 7px 14px; border-radius: 8px; border: 1px solid var(--sg-border, #cbd5e1); background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a); cursor: pointer; }
|
|
2524
|
+
.sk-btn--primary { background: var(--sg-accent, #4f46e5); border-color: var(--sg-accent, #4f46e5); color: #fff; }
|
|
2525
|
+
.sk-rowact { display: flex; gap: 10px; }
|
|
2526
|
+
.sk-link { background: none; border: none; padding: 0; font: inherit; color: var(--sg-accent, #4f46e5); cursor: pointer; }
|
|
2527
|
+
.sk-danger { color: #dc2626; }
|
|
2528
|
+
.sk-overlay { position: fixed; inset: 0; background: rgba(15, 23, 42, 0.4); display: grid; place-items: center; z-index: 50; }
|
|
2529
|
+
.sk-form { width: min(480px, 92vw); max-height: 90vh; overflow: auto; background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a); border-radius: 12px; padding: 22px; display: flex; flex-direction: column; gap: 12px; box-shadow: 0 24px 60px -20px rgba(15, 23, 42, 0.5); }
|
|
2530
|
+
.sk-form h2 { margin: 0 0 4px; font-size: 16px; }
|
|
2531
|
+
.sk-field { display: flex; flex-direction: column; gap: 4px; font-size: 13px; }
|
|
2532
|
+
.sk-field span { color: var(--sg-muted, #64748b); }
|
|
2533
|
+
.sk-field input, .sk-field select { font: inherit; padding: 7px 9px; border-radius: 8px; border: 1px solid var(--sg-border, #cbd5e1); background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a); }
|
|
2534
|
+
.sk-field input[type='checkbox'] { align-self: flex-start; width: auto; }
|
|
2535
|
+
.sk-err { color: #dc2626; font-size: 12px; font-style: normal; }
|
|
2536
|
+
.sk-form__actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 6px; }
|
|
2537
|
+
</style>
|
|
2538
|
+
`
|
|
2539
|
+
|
|
2540
|
+
return [
|
|
2541
|
+
{ path: `src/routes/${screen.route}/+page.server.ts`, description: `SSR load + CRUD form actions for ${n.label}.`, contents: server },
|
|
2542
|
+
{ path: `src/routes/${screen.route}/+page.svelte`, description: `${n.label} screen (SSR + progressive enhancement).`, contents: page },
|
|
2543
|
+
]
|
|
2544
|
+
}
|
|
2545
|
+
|
|
2022
2546
|
export function emitStudioProject(project: StudioProject): GeneratedFile[] {
|
|
2023
2547
|
if (project.entities.length === 0) throw new Error('emitStudioProject: no entities to emit')
|
|
2024
2548
|
if (project.screens.length === 0) throw new Error('emitStudioProject: no screens to emit')
|
|
@@ -2039,7 +2563,7 @@ export function emitStudioProject(project: StudioProject): GeneratedFile[] {
|
|
|
2039
2563
|
if (s.entity === undefined) continue // freestanding screen - gates no entity route
|
|
2040
2564
|
screensByEntity.set(s.entity, [...(screensByEntity.get(s.entity) ?? []), s.id])
|
|
2041
2565
|
}
|
|
2042
|
-
const { files, prepared } = emitEntityModules(project.entities, { sources, accessEnabled, auditEnabled, screensByEntity, triggers: project.triggers })
|
|
2566
|
+
const { files, prepared } = emitEntityModules(project.entities, { sources, accessEnabled, auditEnabled, screensByEntity, triggers: project.triggers, supabaseConn: project.supabase, supabaseAuth: project.auth?.enabled === true && project.auth.provider === 'supabase' })
|
|
2043
2567
|
const byName = new Map(prepared.map((s) => [s.name, s]))
|
|
2044
2568
|
// Raw (unprepared) entities keep their original field set - needed to derive
|
|
2045
2569
|
// relation display-field names that match withRelationLabels (the prepared
|
|
@@ -2083,8 +2607,25 @@ export function emitStudioProject(project: StudioProject): GeneratedFile[] {
|
|
|
2083
2607
|
}
|
|
2084
2608
|
const schema = byName.get(screen.entity)
|
|
2085
2609
|
if (!schema) throw new Error(`emitStudioProject: screen "${screen.title}" references missing entity "${screen.entity}"`)
|
|
2086
|
-
|
|
2610
|
+
// SSR-native path (opt-in): a grid screen gets load + form actions + URL-
|
|
2611
|
+
// driven sort/filter/page; a read-only screen (data-viz / detail) gets a
|
|
2612
|
+
// load-only server file, and the SAME page markup renders from data.*.
|
|
2613
|
+
if (isSsrScreen(project, screen)) {
|
|
2614
|
+
const srcKind = sources[screen.entity]?.kind === 'sql' ? 'sql' : 'memory'
|
|
2615
|
+
if (ssrScreenShape(project, screen) === 'grid') {
|
|
2616
|
+
pages.push(...emitSsrGridScreen(schema, screen, srcKind, accessEnabled, screensByEntity.get(screen.entity) ?? [], byName))
|
|
2617
|
+
} else {
|
|
2618
|
+
pages.push(
|
|
2619
|
+
ssrReadServerFile(schema, screen, srcKind, accessEnabled, screensByEntity.get(screen.entity) ?? [], resolve),
|
|
2620
|
+
screenPage(schema, rawByName.get(screen.entity) ?? schema, screen, resolve, (name) => rawByName.get(name), accessEnabled, i18nEnabled, routeById, drillEnabled, true),
|
|
2621
|
+
)
|
|
2622
|
+
}
|
|
2623
|
+
continue
|
|
2624
|
+
}
|
|
2625
|
+
pages.push(screenPage(schema, rawByName.get(screen.entity) ?? schema, screen, resolve, (name) => rawByName.get(name), accessEnabled, i18nEnabled, routeById, drillEnabled, false, sources[screen.entity]))
|
|
2087
2626
|
}
|
|
2627
|
+
// Any SSR screen needs the shared URL-params -> query helper (server-only).
|
|
2628
|
+
const ssrHelpers = project.screens.some((s) => isSsrScreen(project, s)) ? [ssrQueryHelperFile()] : []
|
|
2088
2629
|
const actionRouteFiles = [...actionsById.values()].map(({ action, screenId }) => actionRouteFile(action, screenId, accessEnabled))
|
|
2089
2630
|
|
|
2090
2631
|
// Nav: only screens flagged into the menu, ordered, with an optional custom label.
|
|
@@ -2099,21 +2640,30 @@ export function emitStudioProject(project: StudioProject): GeneratedFile[] {
|
|
|
2099
2640
|
// auth requires that active layer, so its user store can live in the same schema.
|
|
2100
2641
|
const firstSqlSrc = sqlEntities.length ? sources[sqlEntities[0]!.name] : undefined
|
|
2101
2642
|
const dataLayerActive = project.dataLayer === 'drizzle' && sqlEntities.length > 0 && dzDialect(firstSqlSrc?.kind === 'sql' ? firstSqlSrc.dialect : undefined) !== null
|
|
2102
|
-
|
|
2643
|
+
// Supabase Auth: a client-side SvAuthGate over the shared Supabase client, in place
|
|
2644
|
+
// of the builtin cookie-session scaffold. Mutually exclusive with the builtin
|
|
2645
|
+
// provider, and it never uses the DB-backed user store.
|
|
2646
|
+
const supabaseAuth = authEnabled && project.auth?.provider === 'supabase'
|
|
2647
|
+
const dbBackedAuth = authEnabled && !supabaseAuth && dataLayerActive
|
|
2103
2648
|
const authRegister = dbBackedAuth && project.auth?.register === true
|
|
2104
2649
|
const authUserAdmin = dbBackedAuth && accessEnabled && project.auth?.userAdmin === true
|
|
2105
2650
|
const authTwoFactor = dbBackedAuth && project.auth?.twoFactor === true
|
|
2106
2651
|
const dataLayerList = project.dataLayer === 'drizzle' && sqlEntities.length > 0 ? dataLayerFiles(project, sqlEntities, sources, dbBackedAuth, authTwoFactor) : []
|
|
2107
|
-
|
|
2652
|
+
// Without the Drizzle layer (or on MSSQL, which it can't cover), ship plain SQL the
|
|
2653
|
+
// user runs once against their database - otherwise the tables must pre-exist.
|
|
2654
|
+
const ddlFiles = sqlEntities.length > 0 && !dataLayerActive ? sqlDdlFiles(project.entities, sources) : []
|
|
2655
|
+
// The builtin cookie-session auth files (hooks.server, /login, session store, ...)
|
|
2656
|
+
// are skipped for the Supabase provider - SvAuthGate replaces them.
|
|
2657
|
+
const authFileList = authEnabled && !supabaseAuth ? authFiles(project, dbBackedAuth, accessEnabled) : []
|
|
2108
2658
|
const auditFiles = auditEnabled ? [auditModule(), auditRouteFile(), auditViewerPage()] : []
|
|
2109
2659
|
// Routes that render bare (no shell) + skip the login guard.
|
|
2110
|
-
const publicAuthRoutes = authEnabled ? ['/login', ...(authTwoFactor ? ['/login/verify'] : []), ...(authRegister ? ['/register', '/forgot-password', '/reset-password'] : [])] : []
|
|
2660
|
+
const publicAuthRoutes = authEnabled && !supabaseAuth ? ['/login', ...(authTwoFactor ? ['/login/verify'] : []), ...(authRegister ? ['/register', '/forgot-password', '/reset-password'] : [])] : []
|
|
2111
2661
|
let navExtras = auditEnabled ? [...nav, { href: '/audit', label: 'Audit log', id: '__audit__' }] : nav
|
|
2112
2662
|
// The admin Users screen is nav-gated by canScreen('__users__') - only full-access ('*') roles see it.
|
|
2113
2663
|
if (authUserAdmin) navExtras = [...navExtras, { href: '/users', label: 'Users', id: '__users__' }]
|
|
2114
2664
|
const i18nFiles = i18nEnabled ? [i18nModule(project)] : []
|
|
2115
2665
|
const handleFiles = project.screens.some(screenHasCode) ? [handlesModuleFile()] : []
|
|
2116
|
-
return [...files, ...accessFiles, ...authFileList, ...dataLayerList, ...auditFiles, ...i18nFiles, ...actionRouteFiles, ...pages, ...companions, ...handleFiles, layoutFile(navExtras, { accent: project.theme?.accent, shell: project.theme?.shell, title: project.title, themeVars: resolveThemeTokens(project.theme), lightVars: resolveThemeTokensFor(project.theme, 'light'), darkVars: resolveThemeTokensFor(project.theme, 'dark'), dark: isDarkTheme(project.theme), access: accessEnabled, auth: authEnabled, authRoutes: publicAuthRoutes, authAccount: dbBackedAuth, i18n: i18nEnabled, appClass: project.theme?.appClass }), homeFile(navExtras)]
|
|
2666
|
+
return [...files, ...accessFiles, ...authFileList, ...dataLayerList, ...ddlFiles, ...auditFiles, ...i18nFiles, ...actionRouteFiles, ...ssrHelpers, ...pages, ...companions, ...handleFiles, layoutFile(navExtras, { accent: project.theme?.accent, shell: project.theme?.shell, title: project.title, themeVars: resolveThemeTokens(project.theme), lightVars: resolveThemeTokensFor(project.theme, 'light'), darkVars: resolveThemeTokensFor(project.theme, 'dark'), dark: isDarkTheme(project.theme), access: accessEnabled, auth: authEnabled && !supabaseAuth, supabaseAuth, authRoutes: publicAuthRoutes, authAccount: dbBackedAuth, i18n: i18nEnabled, appClass: project.theme?.appClass }), homeFile(navExtras)]
|
|
2117
2667
|
}
|
|
2118
2668
|
|
|
2119
2669
|
/** The default-locale (`en`) message catalog, keyed for nav, screen titles, the
|
|
@@ -2590,15 +3140,24 @@ export async function setTwoFactor(email: string, on: boolean): Promise<void> {
|
|
|
2590
3140
|
}` : ''}
|
|
2591
3141
|
`
|
|
2592
3142
|
: `// Regenerated by SvGrid Studio. DEMO user store - replace with your own (a DB table,
|
|
2593
|
-
// an external identity provider, ...).
|
|
2594
|
-
//
|
|
2595
|
-
// Tip: turn on the Drizzle data layer and
|
|
2596
|
-
|
|
3143
|
+
// an external identity provider, ...). The seed passwords below are demo defaults
|
|
3144
|
+
// (printed on the login page); they are HASHED (PBKDF2) at startup and never compared
|
|
3145
|
+
// in plaintext. Change them for anything real. Tip: turn on the Drizzle data layer and
|
|
3146
|
+
// this becomes a real \`auth_users\` DB table.
|
|
3147
|
+
import { hashPassword } from './auth'
|
|
3148
|
+
|
|
3149
|
+
export type AppUser = { email: string; name: string; role: string; passwordHash: string }
|
|
2597
3150
|
|
|
2598
|
-
|
|
3151
|
+
const SEED: Array<{ email: string; name: string; role: string; password: string }> = [
|
|
2599
3152
|
${usersLiteral},
|
|
2600
3153
|
]
|
|
2601
3154
|
|
|
3155
|
+
// Hash the seed passwords once at module load (top-level await) so the store holds
|
|
3156
|
+
// only PBKDF2 hashes - login verifies with verifyPassword(), never a plaintext compare.
|
|
3157
|
+
export const USERS: AppUser[] = await Promise.all(
|
|
3158
|
+
SEED.map(async (u) => ({ email: u.email, name: u.name, role: u.role, passwordHash: await hashPassword(u.password) })),
|
|
3159
|
+
)
|
|
3160
|
+
|
|
2602
3161
|
export function findUser(email: string): AppUser | undefined {
|
|
2603
3162
|
const e = email.trim().toLowerCase()
|
|
2604
3163
|
return USERS.find((u) => u.email.toLowerCase() === e)
|
|
@@ -2648,9 +3207,8 @@ ${protect ? " if (!locals.user && !PUBLIC.has(url.pathname)) throw redirect(302
|
|
|
2648
3207
|
// DB-backed store: verify against the stored PBKDF2 hash.
|
|
2649
3208
|
if (!user || !(await verifyPassword(password, user.passwordHash))) return fail(401, { email, error: 'Invalid email or password.' })`
|
|
2650
3209
|
: `const user = findUser(email)
|
|
2651
|
-
//
|
|
2652
|
-
|
|
2653
|
-
if (!user || user.password !== password) return fail(401, { email, error: 'Invalid email or password.' })`
|
|
3210
|
+
// Verify against the PBKDF2 hash of the seed (users store hashes it at startup).
|
|
3211
|
+
if (!user || !(await verifyPassword(password, user.passwordHash))) return fail(401, { email, error: 'Invalid email or password.' })`
|
|
2654
3212
|
// 2FA (email code): after the password check, email a code + park a pending token,
|
|
2655
3213
|
// then send the user to /login/verify instead of issuing the session immediately.
|
|
2656
3214
|
const twoFactorBranch = twoFactor ? `
|
|
@@ -2663,7 +3221,7 @@ ${protect ? " if (!locals.user && !PUBLIC.has(url.pathname)) throw redirect(302
|
|
|
2663
3221
|
}` : ''
|
|
2664
3222
|
const loginImports = twoFactor
|
|
2665
3223
|
? "import { SESSION_COOKIE, SESSION_MAX_AGE, TFA_COOKIE, signSession, verifyPassword, otpCode, signChallenge } from '$lib/server/auth'\nimport { findUser } from '$lib/server/users'\nimport { sendEmail } from '$lib/server/email'"
|
|
2666
|
-
: `import { SESSION_COOKIE, SESSION_MAX_AGE, signSession
|
|
3224
|
+
: `import { SESSION_COOKIE, SESSION_MAX_AGE, signSession, verifyPassword } from '$lib/server/auth'\nimport { findUser } from '$lib/server/users'`
|
|
2667
3225
|
const loginServerTs = `import { fail, redirect } from '@sveltejs/kit'
|
|
2668
3226
|
import type { Actions, PageServerLoad } from './$types'
|
|
2669
3227
|
${loginImports}
|
|
@@ -3512,6 +4070,60 @@ export default defineConfig({
|
|
|
3512
4070
|
dialect: '${cfg.kit}',
|
|
3513
4071
|
${cfg.creds},
|
|
3514
4072
|
})
|
|
4073
|
+
`
|
|
4074
|
+
|
|
4075
|
+
// db/seed.ts - inserts the sample rows into EMPTY tables after `db:migrate`, so a
|
|
4076
|
+
// freshly-migrated production database doesn't open onto blank grids. Lives outside
|
|
4077
|
+
// src/ (like drizzle.config.ts) and builds its own client from process.env, because
|
|
4078
|
+
// the app's client imports $env/dynamic/private which only exists under Vite.
|
|
4079
|
+
const { seed: seedMap } = prepareEntities(sqlEntities)
|
|
4080
|
+
const nameSet = new Set(sqlEntities.map((e) => e.name))
|
|
4081
|
+
// Parents before children so FK values resolve (simple repeated-pass ordering).
|
|
4082
|
+
const ordered: typeof meta = []
|
|
4083
|
+
const pending = [...meta]
|
|
4084
|
+
while (pending.length) {
|
|
4085
|
+
const next = pending.findIndex(({ e }) =>
|
|
4086
|
+
e.fields.every((f) => f.type !== 'relation' || !f.relation || !nameSet.has(f.relation.entity) || f.relation.entity === e.name || ordered.some((o) => o.e.name === f.relation!.entity)),
|
|
4087
|
+
)
|
|
4088
|
+
ordered.push(...pending.splice(next === -1 ? 0 : next, 1))
|
|
4089
|
+
}
|
|
4090
|
+
const pkNumberByName = new Map(meta.map((m) => [m.e.name, m.pkNumber]))
|
|
4091
|
+
const seedBlocks = ordered
|
|
4092
|
+
.map(({ e, tableVar, pkKey, pkNumber }) => {
|
|
4093
|
+
const rows = (seedMap.get(e.name) ?? []).map((row, i) => {
|
|
4094
|
+
const out: Record<string, unknown> = { ...row }
|
|
4095
|
+
if (pkNumber) out[pkKey] = i + 1
|
|
4096
|
+
for (const f of e.fields) {
|
|
4097
|
+
if (f.type === 'relation' && f.relation && pkNumberByName.get(f.relation.entity)) {
|
|
4098
|
+
const relCount = seedMap.get(f.relation.entity)?.length ?? 1
|
|
4099
|
+
out[f.field] = (i % Math.max(1, relCount)) + 1
|
|
4100
|
+
}
|
|
4101
|
+
}
|
|
4102
|
+
return out
|
|
4103
|
+
})
|
|
4104
|
+
if (!rows.length) return null
|
|
4105
|
+
return ` {
|
|
4106
|
+
const existing = await db.select().from(schema.${tableVar}).limit(1)
|
|
4107
|
+
if (existing.length === 0) {
|
|
4108
|
+
await db.insert(schema.${tableVar}).values(${JSON.stringify(rows)} as (typeof schema.${tableVar}.$inferInsert)[])
|
|
4109
|
+
console.log('seeded ${e.name} (${rows.length} rows)')
|
|
4110
|
+
} else console.log('${e.name} already has rows - skipped')
|
|
4111
|
+
}`
|
|
4112
|
+
})
|
|
4113
|
+
.filter((b): b is string => b !== null)
|
|
4114
|
+
const seedTs = `// Regenerated by SvGrid Studio. studio:db-seed
|
|
4115
|
+
// Inserts sample rows into EMPTY tables - a table that already has rows is skipped,
|
|
4116
|
+
// so this is safe to run repeatedly. Run AFTER migrations: npm run db:seed
|
|
4117
|
+
${cfg.client}
|
|
4118
|
+
import * as schema from '../src/lib/server/db/schema'
|
|
4119
|
+
|
|
4120
|
+
${cfg.setup.replaceAll('env.', 'process.env.').replace('export const db', 'const db')}
|
|
4121
|
+
|
|
4122
|
+
async function main() {
|
|
4123
|
+
${seedBlocks.join('\n')}
|
|
4124
|
+
}
|
|
4125
|
+
|
|
4126
|
+
main().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1) })
|
|
3515
4127
|
`
|
|
3516
4128
|
|
|
3517
4129
|
return [
|
|
@@ -3519,6 +4131,7 @@ export default defineConfig({
|
|
|
3519
4131
|
{ path: 'src/lib/server/db/index.ts', description: 'Drizzle client.', contents: indexTs },
|
|
3520
4132
|
...repoFiles,
|
|
3521
4133
|
{ path: 'drizzle.config.ts', description: 'drizzle-kit config (migrations).', contents: configTs },
|
|
4134
|
+
...(seedBlocks.length ? [{ path: 'db/seed.ts', description: 'Sample-row seeder (run after db:migrate: npm run db:seed).', contents: seedTs }] : []),
|
|
3522
4135
|
]
|
|
3523
4136
|
}
|
|
3524
4137
|
|
|
@@ -3529,7 +4142,10 @@ const appSlug = (title: string): string =>
|
|
|
3529
4142
|
|
|
3530
4143
|
/** The static SvelteKit + Vite scaffolding around the generated screens. */
|
|
3531
4144
|
const SCAFFOLD_STATIC: ReadonlyArray<GeneratedFile> = [
|
|
3532
|
-
|
|
4145
|
+
// watch.ignored: `studio dev` runs this app's Vite next to the designer, which
|
|
4146
|
+
// auto-saves studio.config.json + a .studio/ manifest into the same folder -
|
|
4147
|
+
// neither should trigger a reload of the app.
|
|
4148
|
+
{ path: 'vite.config.ts', description: 'Vite config.', contents: `import { sveltekit } from '@sveltejs/kit/vite'\nimport { defineConfig } from 'vite'\n\nexport default defineConfig({\n plugins: [sveltekit()],\n server: { watch: { ignored: ['**/.studio/**', '**/studio.config.json'] } },\n})\n` },
|
|
3533
4149
|
{ path: 'tsconfig.json', description: 'TypeScript config.', contents: `{\n "extends": "./.svelte-kit/tsconfig.json",\n "compilerOptions": {\n "allowJs": true,\n "checkJs": true,\n "esModuleInterop": true,\n "forceConsistentCasingInFileNames": true,\n "resolveJsonModule": true,\n "skipLibCheck": true,\n "sourceMap": true,\n "strict": true,\n "moduleResolution": "bundler"\n }\n}\n` },
|
|
3534
4150
|
{ path: 'src/app.html', description: 'HTML shell.', contents: `<!doctype html>\n<html lang="en">\n <head>\n <meta charset="utf-8" />\n <meta name="viewport" content="width=device-width, initial-scale=1" />\n %sveltekit.head%\n </head>\n <body data-sveltekit-preload-data="hover">\n <div style="display: contents">%sveltekit.body%</div>\n </body>\n</html>\n` },
|
|
3535
4151
|
{ path: 'src/app.d.ts', description: 'SvelteKit app types.', contents: `declare global {\n namespace App {}\n}\n\nexport {}\n` },
|
|
@@ -3770,12 +4386,15 @@ export function studioDeployInfo(project: StudioProject): { label: string; cli:
|
|
|
3770
4386
|
}
|
|
3771
4387
|
|
|
3772
4388
|
/** DEPLOY.md - the runbook: required env vars, then Git-integration / CI / CLI paths. */
|
|
3773
|
-
function deployDocs(project: StudioProject, plan: DeployPlan, envKeys: string[], hasMigrations: boolean): string {
|
|
4389
|
+
function deployDocs(project: StudioProject, plan: DeployPlan, envKeys: string[], hasMigrations: boolean, opts: { hasDdl?: boolean; hasSeedScript?: boolean } = {}): string {
|
|
3774
4390
|
const envBlock = envKeys.length
|
|
3775
|
-
? `## Environment variables\n\nSet these on your host (and locally in \`.env\` - copy \`.env.example\`):\n\n${envKeys.map((k) => `- \`${k}\``).join('\n')}\n\n`
|
|
4391
|
+
? `## Environment variables\n\nSet these on your host (and locally in \`.env\` - copy \`.env.example\`):\n\n${envKeys.map((k) => `- \`${k}\``).join('\n')}\n\nOptional: \`VITE_SVPRO_KEY\` (your SvGrid license key) removes the unlicensed watermark - call \`setLicenseKey(import.meta.env.VITE_SVPRO_KEY)\` once at startup (see the docs).\n\n`
|
|
3776
4392
|
: ''
|
|
3777
4393
|
const migrations = hasMigrations
|
|
3778
|
-
? `## Database migrations\n\nThis app has a typed Drizzle schema (\`src/lib/server/db/schema.ts\`). Create + apply the tables:\n\n\`\`\`bash\nnpm run db:generate # SQL migrations from the schema\nnpm run db:migrate # apply them to DATABASE_URL\n\`\`\`\n\n(Run these against your production database as part of your release step.)\n\n`
|
|
4394
|
+
? `## Database migrations\n\nThis app has a typed Drizzle schema (\`src/lib/server/db/schema.ts\`). Create + apply the tables:\n\n\`\`\`bash\nnpm run db:generate # SQL migrations from the schema\nnpm run db:migrate # apply them to DATABASE_URL\n${opts.hasSeedScript ? 'npm run db:seed # optional: insert the sample rows\n' : ''}\`\`\`\n\n(Run these against your production database as part of your release step.)\n\n`
|
|
4395
|
+
: ''
|
|
4396
|
+
const ddl = opts.hasDdl
|
|
4397
|
+
? `## Database tables\n\nThis app's SQL entities expect their tables to exist. Run \`db/schema.sql\` against the database your \`DATABASE_URL\` points at (psql / mysql / sqlite3 / sqlcmd, or your provider's SQL console) before first use - and optionally \`db/seed.sql\` for sample rows. Tip: enable the Drizzle data layer in Studio for real, versioned migrations instead.\n\n`
|
|
3779
4398
|
: ''
|
|
3780
4399
|
const gitIntegration = plan.dashboard
|
|
3781
4400
|
? `## Option A - Git integration (simplest, no secrets)\n\n1. Push this repo to GitHub.\n2. Import it at <${plan.dashboard}> - the SvelteKit build is auto-detected.\n3. Add the environment variables above in the host's dashboard.\n\nEvery push to \`main\` then redeploys automatically.\n\n`
|
|
@@ -3787,7 +4406,7 @@ function deployDocs(project: StudioProject, plan: DeployPlan, envKeys: string[],
|
|
|
3787
4406
|
? `## Container\n\nA \`Dockerfile\` is included:\n\n\`\`\`bash\ndocker build -t ${appSlug(project.title)} .\ndocker run -p 3000:3000 --env-file .env ${appSlug(project.title)}\n\`\`\`\n\nThe server listens on \`PORT\` (default 3000).\n\n`
|
|
3788
4407
|
: ''
|
|
3789
4408
|
const cli = `## Option C - Deploy from your machine\n\n\`\`\`bash\nnpm run deploy\n\`\`\`\n\n(runs \`${plan.deployScript}\`)\n`
|
|
3790
|
-
return `# Deploying ${project.title}\n\nConfigured for **${plan.label}** (SvelteKit \`${plan.adapterModule}\`). A CI workflow (\`.github/workflows/ci.yml\`) builds + tests every push.\n\n${envBlock}${migrations}${gitIntegration}${ci}${dockerNote}${cli}`
|
|
4409
|
+
return `# Deploying ${project.title}\n\nConfigured for **${plan.label}** (SvelteKit \`${plan.adapterModule}\`). A CI workflow (\`.github/workflows/ci.yml\`) builds + tests every push.\n\n${envBlock}${ddl}${migrations}${gitIntegration}${ci}${dockerNote}${cli}`
|
|
3791
4410
|
}
|
|
3792
4411
|
|
|
3793
4412
|
/** The env-var keys the generated app reads (for DEPLOY.md). */
|
|
@@ -3795,10 +4414,37 @@ function envKeysUsed(allSource: string): string[] {
|
|
|
3795
4414
|
const keys: string[] = []
|
|
3796
4415
|
if (allSource.includes('env.DATABASE_URL')) keys.push('DATABASE_URL')
|
|
3797
4416
|
if (allSource.includes('env.DATABASE_AUTH_TOKEN')) keys.push('DATABASE_AUTH_TOKEN')
|
|
4417
|
+
if (allSource.includes('PUBLIC_SUPABASE_URL')) keys.push('PUBLIC_SUPABASE_URL', 'PUBLIC_SUPABASE_ANON_KEY')
|
|
3798
4418
|
if (allSource.includes('SESSION_SECRET')) keys.push('SESSION_SECRET')
|
|
4419
|
+
// Feature keys - same detection as envExample, so DEPLOY.md's checklist matches
|
|
4420
|
+
// .env.example instead of silently omitting the auth/email vars.
|
|
4421
|
+
if (allSource.includes("from '$lib/server/email'")) keys.push('EMAIL_FROM', 'RESEND_API_KEY or SMTP_HOST/PORT/USER/PASS')
|
|
4422
|
+
if (allSource.includes("from '$lib/server/oauth'")) {
|
|
4423
|
+
if (allSource.includes("'github'") || allSource.includes('"github"')) keys.push('GITHUB_CLIENT_ID', 'GITHUB_CLIENT_SECRET')
|
|
4424
|
+
if (allSource.includes("'google'") || allSource.includes('"google"')) keys.push('GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET')
|
|
4425
|
+
if (allSource.includes("'oidc'") || allSource.includes('"oidc"')) keys.push('OIDC_ISSUER', 'OIDC_CLIENT_ID', 'OIDC_CLIENT_SECRET')
|
|
4426
|
+
}
|
|
3799
4427
|
return keys
|
|
3800
4428
|
}
|
|
3801
4429
|
|
|
4430
|
+
/** A cryptographically-random hex secret. Portable across Node 19+ and browsers
|
|
4431
|
+
* (the visual designer runs codegen client-side), so every generated app gets a
|
|
4432
|
+
* unique real session secret instead of a shared hardcoded fallback. */
|
|
4433
|
+
function randomSecret(bytes = 32): string {
|
|
4434
|
+
const buf = new Uint8Array(bytes)
|
|
4435
|
+
globalThis.crypto.getRandomValues(buf)
|
|
4436
|
+
return Array.from(buf, (b) => b.toString(16).padStart(2, '0')).join('')
|
|
4437
|
+
}
|
|
4438
|
+
|
|
4439
|
+
/** A ready-to-run, git-ignored `.env`: the `.env.example` template with a real
|
|
4440
|
+
* random `SESSION_SECRET` filled in, so the app is secure out of the box (no
|
|
4441
|
+
* hardcoded dev secret). Only emitted when the app actually signs sessions. */
|
|
4442
|
+
function envDotFile(allSource: string): string | null {
|
|
4443
|
+
const example = envExample(allSource)
|
|
4444
|
+
if (!example || !allSource.includes('SESSION_SECRET')) return null
|
|
4445
|
+
return example.replace('SESSION_SECRET=change-me-to-a-long-random-string', `SESSION_SECRET=${randomSecret()}`)
|
|
4446
|
+
}
|
|
4447
|
+
|
|
3802
4448
|
/** A `.env.example` listing the env vars the generated code reads, when any. */
|
|
3803
4449
|
function envExample(allSource: string): string | null {
|
|
3804
4450
|
const lines: string[] = []
|
|
@@ -3810,6 +4456,12 @@ function envExample(allSource: string): string | null {
|
|
|
3810
4456
|
lines.push('# Turso / libSQL database auth token.')
|
|
3811
4457
|
lines.push('DATABASE_AUTH_TOKEN=')
|
|
3812
4458
|
}
|
|
4459
|
+
if (allSource.includes('PUBLIC_SUPABASE_URL')) {
|
|
4460
|
+
if (lines.length) lines.push('')
|
|
4461
|
+
lines.push('# Supabase project (Settings > API). The anon key is public / browser-safe.')
|
|
4462
|
+
lines.push('PUBLIC_SUPABASE_URL=')
|
|
4463
|
+
lines.push('PUBLIC_SUPABASE_ANON_KEY=')
|
|
4464
|
+
}
|
|
3813
4465
|
if (allSource.includes('SESSION_SECRET')) {
|
|
3814
4466
|
if (lines.length) lines.push('')
|
|
3815
4467
|
lines.push('# Session signing secret - set a long random value in production.')
|
|
@@ -3846,7 +4498,9 @@ function svelteConfig(plan: DeployPlan): string {
|
|
|
3846
4498
|
return `import adapter from '${plan.adapterModule}'\nimport { vitePreprocess } from '@sveltejs/vite-plugin-svelte'\n\n/** @type {import('@sveltejs/kit').Config} */\nconst config = {\n preprocess: vitePreprocess(),\n kit: { adapter: adapter() },\n}\n\nexport default config\n`
|
|
3847
4499
|
}
|
|
3848
4500
|
|
|
3849
|
-
|
|
4501
|
+
/** The runtime npm deps the generated source imports, keyed by package -> range.
|
|
4502
|
+
* One source of truth for package.json AND the fragment-mode dependency report. */
|
|
4503
|
+
export function runtimeDeps(project: StudioProject, allSource: string): Record<string, string> {
|
|
3850
4504
|
const dependencies: Record<string, string> = { '@svgrid/grid': 'latest', '@svgrid/enterprise': 'latest' }
|
|
3851
4505
|
if (allSource.includes("from '@supabase/supabase-js'")) dependencies['@supabase/supabase-js'] = '^2.45.0'
|
|
3852
4506
|
if (allSource.includes("import pg from 'pg'")) dependencies['pg'] = '^8.11.0'
|
|
@@ -3861,17 +4515,21 @@ function packageJson(project: StudioProject, allSource: string): string {
|
|
|
3861
4515
|
if (/from ['"]jszip['"]/.test(allSource)) dependencies['jszip'] = '^3.10.1'
|
|
3862
4516
|
if (/from ['"]pdfmake(?:\/[^'"]*)?['"]/.test(allSource)) dependencies['pdfmake'] = '^0.2.10'
|
|
3863
4517
|
// nodemailer is dynamically imported by the email layer only on the SMTP branch.
|
|
3864
|
-
|
|
3865
|
-
if (
|
|
3866
|
-
|
|
3867
|
-
|
|
3868
|
-
|
|
4518
|
+
if (/import\(['"]nodemailer['"]\)/.test(allSource)) dependencies['nodemailer'] = '^6.9.0'
|
|
4519
|
+
if (project.dataLayer === 'drizzle' && /from ['"]drizzle-orm(?:\/[^'"]*)?['"]/.test(allSource)) dependencies['drizzle-orm'] = '^0.44.0'
|
|
4520
|
+
return dependencies
|
|
4521
|
+
}
|
|
4522
|
+
|
|
4523
|
+
function packageJson(project: StudioProject, allSource: string): string {
|
|
4524
|
+
const dependencies = runtimeDeps(project, allSource)
|
|
4525
|
+
const drizzle = !!dependencies['drizzle-orm']
|
|
3869
4526
|
const scripts: Record<string, string> = { dev: 'vite dev', build: 'vite build', preview: 'vite preview', check: 'svelte-kit sync && svelte-check --tsconfig ./tsconfig.json', test: 'vitest run', deploy: deployPlan(project).deployScript }
|
|
3870
4527
|
if (drizzle) {
|
|
3871
4528
|
scripts['db:generate'] = 'drizzle-kit generate'
|
|
3872
4529
|
scripts['db:migrate'] = 'drizzle-kit migrate'
|
|
3873
4530
|
scripts['db:push'] = 'drizzle-kit push'
|
|
3874
4531
|
scripts['db:studio'] = 'drizzle-kit studio'
|
|
4532
|
+
if (allSource.includes('studio:db-seed')) scripts['db:seed'] = 'tsx db/seed.ts'
|
|
3875
4533
|
}
|
|
3876
4534
|
const pkg = {
|
|
3877
4535
|
name: appSlug(project.title),
|
|
@@ -3896,8 +4554,9 @@ function packageJson(project: StudioProject, allSource: string): string {
|
|
|
3896
4554
|
// Driver typings so the connected route + data layer type-check cleanly.
|
|
3897
4555
|
...(dependencies['pg'] ? { '@types/pg': '^8.11.0' } : {}),
|
|
3898
4556
|
...(dependencies['better-sqlite3'] ? { '@types/better-sqlite3': '^7.6.0' } : {}),
|
|
3899
|
-
...(
|
|
4557
|
+
...(dependencies['nodemailer'] ? { '@types/nodemailer': '^6.4.0' } : {}),
|
|
3900
4558
|
...(drizzle ? { 'drizzle-kit': '^0.31.0' } : {}),
|
|
4559
|
+
...(drizzle && allSource.includes('studio:db-seed') ? { tsx: '^4.19.0' } : {}),
|
|
3901
4560
|
},
|
|
3902
4561
|
}
|
|
3903
4562
|
return JSON.stringify(pkg, null, 2) + '\n'
|
|
@@ -3977,6 +4636,7 @@ export function emitStudioAppBundle(project: StudioProject): GeneratedFile[] {
|
|
|
3977
4636
|
...plan.files,
|
|
3978
4637
|
...SCAFFOLD_STATIC,
|
|
3979
4638
|
...(envExample(allSource) ? [{ path: '.env.example', description: 'Environment variables the app reads (copy to .env and fill in).', contents: envExample(allSource)! }] : []),
|
|
4639
|
+
...(envDotFile(allSource) ? [{ path: '.env', description: 'Local env (git-ignored): a real random SESSION_SECRET is pre-filled so sessions are secure out of the box; fill in the rest.', contents: envDotFile(allSource)! }] : []),
|
|
3980
4640
|
{ path: 'src/app.css', description: 'App theme + page styles.', contents: APP_CSS },
|
|
3981
4641
|
// Your styles: a dedicated CSS file the layout imports AFTER app.css, so its rules
|
|
3982
4642
|
// win. Edited in the designer's Styles panel; regenerated from the model.
|
|
@@ -3989,7 +4649,65 @@ export function emitStudioAppBundle(project: StudioProject): GeneratedFile[] {
|
|
|
3989
4649
|
// has one, and a DEPLOY.md runbook.
|
|
3990
4650
|
{ path: '.github/workflows/ci.yml', description: 'CI: build + test on push / PR.', contents: CI_WORKFLOW },
|
|
3991
4651
|
...(plan.deployWorkflow ? [{ path: '.github/workflows/deploy.yml', description: `Deploy to ${plan.label} on push to main.`, contents: plan.deployWorkflow }] : []),
|
|
3992
|
-
{ path: 'DEPLOY.md', description: 'Deploy runbook (env vars, Git integration, CI, CLI).', contents: deployDocs(project, plan, envKeysUsed(allSource), generated.some((f) => f.path === 'drizzle.config.ts')) },
|
|
4652
|
+
{ path: 'DEPLOY.md', description: 'Deploy runbook (env vars, Git integration, CI, CLI).', contents: deployDocs(project, plan, envKeysUsed(allSource), generated.some((f) => f.path === 'drizzle.config.ts'), { hasDdl: generated.some((f) => f.path === 'db/schema.sql'), hasSeedScript: generated.some((f) => f.path === 'db/seed.ts') }) },
|
|
3993
4653
|
]
|
|
3994
4654
|
return [...scaffold, ...generated]
|
|
3995
4655
|
}
|
|
4656
|
+
|
|
4657
|
+
/**
|
|
4658
|
+
* Emit ONLY the app content (`src/routes` + `src/lib` + `db`) to drop into an
|
|
4659
|
+
* EXISTING SvelteKit app - not a whole new app. Everything emitStudioProject
|
|
4660
|
+
* produces MINUS the nav shell (`+layout.svelte`) and the home redirect (home
|
|
4661
|
+
* `+page.svelte`), PLUS `src/app.css` (the `.st-*` page styles + `--sg-*` tokens,
|
|
4662
|
+
* which otherwise live only in the full bundle) and a FRAGMENT.md that lists the
|
|
4663
|
+
* deps to install and what the host app must provide.
|
|
4664
|
+
*/
|
|
4665
|
+
export function emitStudioFragment(project: StudioProject): GeneratedFile[] {
|
|
4666
|
+
const generated = emitStudioProject(project)
|
|
4667
|
+
const allSource = generated.map((f) => f.contents).join('\n')
|
|
4668
|
+
// Drop the two files that assume ownership of the whole app.
|
|
4669
|
+
const content = generated.filter((f) => f.path !== 'src/routes/+layout.svelte' && f.path !== 'src/routes/+page.svelte')
|
|
4670
|
+
|
|
4671
|
+
const deps = runtimeDeps(project, allSource)
|
|
4672
|
+
const depList = Object.entries(deps).map(([name, v]) => ` ${name}@${v}`).join('\n')
|
|
4673
|
+
const envKeys = envKeysUsed(allSource)
|
|
4674
|
+
const libFiles = content.filter((f) => f.path.startsWith('src/lib/')).map((f) => `- \`${f.path}\``)
|
|
4675
|
+
const fragmentMd = `# Studio fragment - drop into your SvelteKit app
|
|
4676
|
+
|
|
4677
|
+
This folder holds ONLY app content - routes under \`src/routes\` and modules under
|
|
4678
|
+
\`src/lib\` (plus \`db/\` if present). It has no package.json, config, or app shell,
|
|
4679
|
+
so it drops into your existing SvelteKit app.
|
|
4680
|
+
|
|
4681
|
+
## 1. Copy the files
|
|
4682
|
+
Merge \`src/routes/*\` and \`src/lib/*\` into your app's \`src/\`. \`$lib\` is a stock
|
|
4683
|
+
SvelteKit alias, so imports resolve with no config. Watch for filename collisions
|
|
4684
|
+
with your own \`src/lib\` (e.g. \`schemas.ts\`, \`data.ts\`) - rename if needed.
|
|
4685
|
+
|
|
4686
|
+
## 2. Install dependencies
|
|
4687
|
+
\`\`\`bash
|
|
4688
|
+
npm install ${Object.keys(deps).filter((d) => d !== '@svgrid/grid' && d !== '@svgrid/enterprise').join(' ') || '@svgrid/grid @svgrid/enterprise'}
|
|
4689
|
+
\`\`\`
|
|
4690
|
+
Full list this fragment imports:
|
|
4691
|
+
\`\`\`
|
|
4692
|
+
${depList}
|
|
4693
|
+
\`\`\`
|
|
4694
|
+
|
|
4695
|
+
## 3. Styles
|
|
4696
|
+
Import \`src/app.css\` once (e.g. in your root \`+layout.svelte\`) - it carries the
|
|
4697
|
+
\`.st-*\` page styles the screens use and the \`--sg-*\` design tokens.
|
|
4698
|
+
|
|
4699
|
+
## 4. What your app must provide
|
|
4700
|
+
The full-app nav shell, home redirect, auth guard, RBAC bootstrap, i18n provider,
|
|
4701
|
+
and theme toggle live in the bundle's \`+layout.svelte\` - NOT here. Wire nav to the
|
|
4702
|
+
generated routes from your own layout.${envKeys.length ? `\n\n## Environment\nSet these where your app reads env:\n${envKeys.map((k) => `- \`${k}\``).join('\n')}` : ''}
|
|
4703
|
+
|
|
4704
|
+
## Files included
|
|
4705
|
+
${libFiles.join('\n')}
|
|
4706
|
+
`
|
|
4707
|
+
|
|
4708
|
+
return [
|
|
4709
|
+
...content,
|
|
4710
|
+
{ path: 'src/app.css', description: 'Page styles (.st-*) + design tokens - import once in your layout.', contents: APP_CSS },
|
|
4711
|
+
{ path: 'FRAGMENT.md', description: 'How to drop this fragment into an existing SvelteKit app.', contents: fragmentMd },
|
|
4712
|
+
]
|
|
4713
|
+
}
|