@svgrid/enterprise 2.2.1 → 2.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -81
- package/dist/cdn/svgrid-enterprise.svelte-external.js +26642 -11252
- package/dist/designer/assets/GridMenus-BtWVk9Ab.js +7 -0
- package/dist/designer/assets/SvGridChartPanel-DWlBO2SD.js +10 -0
- package/dist/designer/assets/SvGridChartView-8c8Mb4j8.js +1 -0
- package/dist/designer/assets/SvGridChartView-DX9HfkBR.css +1 -0
- package/dist/designer/assets/index-DsDgp9Xq.js +78758 -0
- package/dist/designer/assets/index-tTY_Dx4P.css +1 -0
- package/dist/designer/assets/jszip.min-fkJdmAmj.js +2 -0
- package/dist/designer/assets/pdfmake-DeCsnyl9.js +242 -0
- package/dist/designer/assets/smart.export-BZlSCE8T.js +35 -0
- package/dist/designer/assets/vfs_fonts-eX2NpmfX.js +1 -0
- package/dist/designer/index.html +13 -0
- package/dist/node/studio.js +15304 -2708
- package/package.json +35 -11
- package/src/SvAlertRuleEditor.svelte +294 -0
- package/src/SvAlertsManager.svelte +210 -0
- package/src/SvAlertsPanel.svelte +129 -0
- package/src/SvBoard.svelte +3 -1
- package/src/SvExpressionEditor.svelte +341 -0
- package/src/SvGridAlerts.dom.test.ts +113 -0
- package/src/SvGridAlerts.svelte +265 -0
- package/src/SvGridBoard.svelte +2358 -0
- package/src/SvGridEditPanel.svelte +849 -794
- package/src/SvGridScheduler.svelte +5358 -4410
- package/src/SvPivotDesigner.svelte +3 -3
- package/src/SvRecordDetail.svelte +6 -2
- package/src/SvSchedule.svelte +3 -1
- package/src/{ai-export-pdf.test.ts → ai-export-pdf.dom.test.ts} +77 -74
- package/src/{ai-export-xlsx.test.ts → ai-export-xlsx.dom.test.ts} +90 -87
- package/src/{ai-export.test.ts → ai-export.dom.test.ts} +114 -110
- package/src/alerts/alert-engine-attach.ts +165 -0
- package/src/alerts/alert-engine.test.ts +135 -0
- package/src/alerts/alert-engine.ts +260 -0
- package/src/alerts/alert-formats.test.ts +87 -0
- package/src/alerts/alert-formats.ts +77 -0
- package/src/alerts/alert-observer.test.ts +189 -0
- package/src/alerts/alert-observer.ts +208 -0
- package/src/alerts/alert-scheduler.ts +96 -0
- package/src/alerts/alert-storage.test.ts +54 -0
- package/src/alerts/alert-storage.ts +116 -0
- package/src/alerts/alert-store.svelte.ts +80 -0
- package/src/alerts/alert-types.ts +94 -0
- package/src/alerts.ts +28 -0
- package/src/board.dom.test.ts +941 -0
- package/src/board.ts +36 -0
- package/src/export-ooxml.ts +4 -0
- package/src/export-xls.ts +3 -0
- package/src/export.ts +6 -0
- package/src/expressions/evaluate.test.ts +111 -0
- package/src/expressions/evaluate.ts +237 -0
- package/src/expressions/expression-columns.ts +133 -0
- package/src/expressions/expression-types.ts +84 -0
- package/src/expressions/parse.test.ts +106 -0
- package/src/expressions/parse.ts +610 -0
- package/src/import.test.ts +1 -1
- package/src/import.ts +3 -1
- package/src/index.ts +192 -46
- package/src/install.ts +12 -2
- package/src/pivot-enable.ts +44 -0
- package/src/pivot.test.ts +0 -1
- package/src/scheduler-assignments.test.ts +97 -0
- package/src/scheduler-assignments.ts +134 -0
- package/src/scheduler-axis.test.ts +108 -0
- package/src/scheduler-axis.ts +238 -0
- package/src/scheduler-booking.test.ts +57 -0
- package/src/scheduler-booking.ts +63 -0
- package/src/scheduler-config.ts +179 -0
- package/src/scheduler-dependencies.test.ts +155 -0
- package/src/scheduler-dependencies.ts +223 -0
- package/src/scheduler-freebusy.test.ts +41 -0
- package/src/scheduler-freebusy.ts +36 -0
- package/src/scheduler-heatmap.test.ts +39 -0
- package/src/scheduler-heatmap.ts +55 -0
- package/src/scheduler-resource-tree.test.ts +84 -0
- package/src/scheduler-resource-tree.ts +107 -0
- package/src/scheduler-slots.test.ts +53 -0
- package/src/scheduler-slots.ts +94 -0
- package/src/scheduler-summary.test.ts +47 -0
- package/src/scheduler-summary.ts +81 -0
- package/src/schema-designer.ts +1 -1
- package/src/sources/index.ts +1 -1
- package/src/sources/introspect-supabase.test.ts +13 -1
- package/src/sources/introspect-supabase.ts +20 -0
- package/src/studio/copilot-core.test.ts +45 -0
- package/src/studio/copilot-core.ts +65 -0
- package/src/studio/deploy-cli.test.ts +56 -0
- package/src/studio/deploy-cli.ts +100 -0
- package/src/studio/emit-project.test.ts +800 -24
- package/src/studio/emit-project.ts +1307 -135
- package/src/studio/emit-schema.test.ts +17 -0
- package/src/studio/emit-schema.ts +182 -41
- package/src/studio/index.ts +59 -1
- package/src/studio/init-flow.test.ts +239 -0
- package/src/studio/init-flow.ts +358 -0
- package/src/studio/introspect-openapi.test.ts +84 -0
- package/src/studio/introspect-openapi.ts +252 -0
- package/src/studio/project.test.ts +57 -1
- package/src/studio/project.ts +302 -8
- package/src/studio/samples/crm.ts +282 -258
- package/src/studio/samples/datasets.test.ts +84 -0
- package/src/studio/samples/datasets.ts +340 -0
- package/src/studio/samples/fleet.ts +213 -186
- package/src/studio/samples/insurance.ts +223 -195
- package/src/studio/samples/inventory.ts +213 -182
- package/src/studio/samples/live-data.test.ts +99 -98
- package/src/studio/samples/live-data.ts +8 -10
- package/src/studio/samples/projects.ts +212 -190
- package/src/studio/samples/samples.test.ts +268 -216
- package/src/studio/samples/shared.ts +22 -145
- package/src/studio/samples/starter.ts +251 -0
- package/src/studio/samples/support.ts +209 -184
- package/src/studio/screen-suites.test.ts +226 -0
- package/src/studio/screen-suites.ts +445 -0
- package/src/studio/ui-components-surface.test.ts +185 -0
- package/src/studio/ui-components.generated.ts +7154 -0
- package/src/studio/ui-components.ts +273 -3
- package/src/sveltekit/index.ts +1 -0
- package/src/sveltekit/sql-source.test.ts +13 -0
- package/src/sveltekit/sql-source.ts +12 -7
- package/src/sveltekit/transport-scope.test.ts +125 -0
- package/src/sveltekit/transport.ts +76 -2
- package/src/upgrade-prompt.ts +2 -2
- package/src/watermark.ts +2 -2
- package/src/ai.test.ts +0 -522
- package/src/ai.ts +0 -1388
|
@@ -11,12 +11,13 @@
|
|
|
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 {
|
|
16
|
-
import {
|
|
14
|
+
import type { ActionConfig, Block, ComponentBinding, ComponentConfig, EntityDataSource, FilterPanelConfig, GridColumnConfig, GridConfig, KpiConfig, OAuthProvider, PivotConfig, RecordConfig, RowAction, ScheduledJob, SchedulerViewConfig, Screen, StudioProject, SupabaseSource } from './project.js'
|
|
15
|
+
import { tenantField, isTenantScoped } from './project.js'
|
|
16
|
+
import { blockColumns, blockStyleCss, blockClassName, sanitizeClassName, componentHandleName, componentHasBindings, entityDataSource, flattenBlocks, serializeProject, seedUsers, compileHandlerSteps, rowSelectSlot, 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'
|
|
17
|
+
import { uiComponentSpec, gridApiSettableProps, STANDARD_UI_EVENTS } from './ui-components.js'
|
|
17
18
|
import { resolveThemeTokens, resolveThemeTokensFor, isDarkTheme } from './themes.js'
|
|
18
19
|
import type { EntityField, EntitySchema } from '../schema.js'
|
|
19
|
-
import { emitEntityModules, homeFile, layoutFile, lookupVar, namesFor, relationDisplayFields, type NavItem } from './emit-schema.js'
|
|
20
|
+
import { emitEntityModules, homeFile, layoutFile, lookupVar, namesFor, prepareEntities, relationDisplayFields, sqlDdlFiles, type NavItem } from './emit-schema.js'
|
|
20
21
|
|
|
21
22
|
const has = (blocks: Block[], kind: Block['config']['kind']) => blocks.some((b) => b.config.kind === kind)
|
|
22
23
|
|
|
@@ -148,7 +149,7 @@ function screenLayoutStyle(screen: Screen): string {
|
|
|
148
149
|
|
|
149
150
|
/** Markup for one block inside the screen grid. `ctx.hasRecord` tells a grid to
|
|
150
151
|
* 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 {
|
|
152
|
+
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
153
|
// A block's display label: localized via $t('block.<id>', 'literal') when i18n is on.
|
|
153
154
|
const tLabel = (label: string, key: string) => (ctx.i18n ? `{$t('block.${key}', ${JSON.stringify(label)})}` : label)
|
|
154
155
|
// In a dock pane the block fills its pane (the pane owns the size); in the 12-col grid it
|
|
@@ -187,6 +188,23 @@ function blockMarkup(entity: EntitySchema, schemaVar: string, typeName: string,
|
|
|
187
188
|
const tree = gridHasTree(cfg)
|
|
188
189
|
const grouped = !!cfg.grouping?.length && !tree
|
|
189
190
|
const dataExpr = tree ? `tree_${idSafe}.visible` : grouped ? 'allRows' : 'view.rows'
|
|
191
|
+
// Code-behind can subscribe to grid events (`ctx.grid.onCellClick = fn`): each
|
|
192
|
+
// event prop fires into the grid handle. Feature-owned props (sort/filter/edit/
|
|
193
|
+
// paginate/row-click) COMPOSE the fire onto their built-in body; the rest are
|
|
194
|
+
// added in one pass below. `sink` is the grid handle var, '' when code is off.
|
|
195
|
+
const sink = ctx.gridEventSink ?? ''
|
|
196
|
+
const firedProps = new Set<string>()
|
|
197
|
+
const fireInto = (key: string, args: string): string => {
|
|
198
|
+
if (!sink) return ''
|
|
199
|
+
firedProps.add(GRID_EVENTS.find((e) => e.key === key)!.prop)
|
|
200
|
+
return `; ${sink}.fire('${key}', ${args})`
|
|
201
|
+
}
|
|
202
|
+
// Compose a built-in event handler with the user's ctx.grid subscription. Stays
|
|
203
|
+
// terse (no braces, no fire) when code is off, so non-code screens are unchanged.
|
|
204
|
+
const withFire = (param: string, base: string, key: string, fireArgs: string): string => {
|
|
205
|
+
const fire = fireInto(key, fireArgs)
|
|
206
|
+
return fire ? `(${param}) => { ${base}${fire} }` : `(${param}) => ${base}`
|
|
207
|
+
}
|
|
190
208
|
const lines = [`data={${dataExpr}}`, `columns={${colVar}}`, `loading={${tree || grouped ? '!allRowsReady' : 'view.loading'}}`, `loadingOverlay`, `emptyMessage=${JSON.stringify(emptyMsg)}`, `fitColumns`]
|
|
191
209
|
lines.push(`enableRowSummaries={${cfg.rowSummaries ? 'true' : 'false'}}`)
|
|
192
210
|
if (cfg.striped) lines.push(`zebraRows`)
|
|
@@ -210,14 +228,19 @@ function blockMarkup(entity: EntitySchema, schemaVar: string, typeName: string,
|
|
|
210
228
|
if (cfg.sortable) lines.push(`sortable`)
|
|
211
229
|
if (cfg.filterable) lines.push(`filterable`, ...filterSurfaceProps(cfg))
|
|
212
230
|
} else {
|
|
213
|
-
if (cfg.sortable) lines.push(`sortable`, `externalSort`, `onSortingChange={(s
|
|
214
|
-
if (cfg.filterable) lines.push(`filterable`, ...filterSurfaceProps(cfg), `externalFilter`, `onFiltersChange={(f
|
|
231
|
+
if (cfg.sortable) lines.push(`sortable`, `externalSort`, `onSortingChange={${withFire('s', 'controller.setSort(s)', 'sortingChange', 's')}}`)
|
|
232
|
+
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
233
|
}
|
|
216
234
|
// RBAC: gate the edit affordances on the update permission (server also enforces).
|
|
217
235
|
const canUpdate = ctx.accessEnabled ? `can($currentRole, 'update')` : 'true'
|
|
218
|
-
if (cfg.editing === 'form')
|
|
236
|
+
if (cfg.editing === 'form') {
|
|
237
|
+
const fire = fireInto('rowDoubleClick', 'e')
|
|
238
|
+
lines.push(ctx.accessEnabled
|
|
239
|
+
? `onRowDoubleClick={(e) => { if (${canUpdate}) editing = e.row${fire} }}`
|
|
240
|
+
: fire ? `onRowDoubleClick={(e) => { (editing = e.row)${fire} }}` : `onRowDoubleClick={(e) => (editing = e.row)}`)
|
|
241
|
+
}
|
|
219
242
|
// 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}>) }}`)
|
|
243
|
+
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
244
|
// Row click: drill-through (highest precedence), a record-panel selection,
|
|
222
245
|
// and/or the user's "On row select" method steps (row-scoped ctx). When steps
|
|
223
246
|
// are present they merge with the built-in action into one async handler.
|
|
@@ -229,23 +252,30 @@ function blockMarkup(entity: EntitySchema, schemaVar: string, typeName: string,
|
|
|
229
252
|
if (ctx.rowSelectSteps) {
|
|
230
253
|
const pre = primaryStmt ? `\n ${primaryStmt}` : ''
|
|
231
254
|
const stepBody = ctx.rowSelectSteps.split('\n').map((l) => (l ? ' ' + l : l)).join('\n')
|
|
232
|
-
|
|
255
|
+
const fire = fireInto('rowClick', 'e')
|
|
256
|
+
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
257
|
} else if (rowLinkStmt) {
|
|
234
|
-
lines.push(`onRowClick={(e
|
|
258
|
+
lines.push(`onRowClick={${withFire('e', rowLinkStmt, 'rowClick', 'e')}}`)
|
|
235
259
|
} else if (ctx.hasRecord) {
|
|
236
|
-
lines.push(`onRowClick={(e
|
|
260
|
+
lines.push(`onRowClick={${withFire('e', '(selectedRecord = e.row)', 'rowClick', 'e')}}`)
|
|
237
261
|
}
|
|
238
262
|
if (cfg.paginated !== false && !tree) {
|
|
239
263
|
if (grouped) {
|
|
240
264
|
// Client pagination: the grid slices `data` itself.
|
|
241
265
|
lines.push(`showPagination`, `pageSize={${cfg.pageSize}}`)
|
|
242
266
|
} else {
|
|
243
|
-
lines.push(`showPagination`, `externalPagination`, `rowCount={view.total}`, `pageIndex={view.pageIndex}`, `pageSize={view.pageSize}`, `onPaginationChange={({ pageIndex, pageSize }
|
|
267
|
+
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
268
|
}
|
|
245
269
|
if (cfg.paginationPosition && cfg.paginationPosition !== 'bottom') lines.push(`paginationPosition="${cfg.paginationPosition}"`)
|
|
246
270
|
const opts = cfg.pageSizeOptions
|
|
247
271
|
if (opts && opts.length && (opts.length !== 4 || opts.join(',') !== '10,25,50,100')) lines.push(`pageSizeOptions={[${opts.join(', ')}]}`)
|
|
248
272
|
}
|
|
273
|
+
// Every remaining grid event -> fire into ctx.grid so code can subscribe
|
|
274
|
+
// (`ctx.grid.onCellClick = fn`). Feature-owned props already composed above.
|
|
275
|
+
if (sink) for (const ev of GRID_EVENTS) {
|
|
276
|
+
if (ev.dataEvent) continue // fired by the data controller, not a grid prop
|
|
277
|
+
if (!firedProps.has(ev.prop)) lines.push(`${ev.prop}={(...a) => ${sink}.fire('${ev.key}', ...a)}`)
|
|
278
|
+
}
|
|
249
279
|
// No-code conditional formatting -> the grid's rule engine.
|
|
250
280
|
const cf = conditionalFormatsExpr(cfg)
|
|
251
281
|
if (cf) lines.push(`conditionalFormats={${cf}}`)
|
|
@@ -256,6 +286,15 @@ function blockMarkup(entity: EntitySchema, schemaVar: string, typeName: string,
|
|
|
256
286
|
if (apiBody.length === 1 && ctx.captureApi && !grouped) lines.push(`onApiReady={(a) => (${ctx.captureApi} = a)}`)
|
|
257
287
|
else if (apiBody.length) lines.push(`onApiReady={(a) => { ${apiBody.join('; ')} }}`)
|
|
258
288
|
lines.push(`containerHeight=${ctx.pane ? '"100%"' : `{${block.height ?? 360}}`}`)
|
|
289
|
+
// Raw "All properties" overrides: pass through every grid prop the curated
|
|
290
|
+
// controls didn't already emit (deduped by prop name), so nothing is set twice.
|
|
291
|
+
if (cfg.props && Object.keys(cfg.props).length) {
|
|
292
|
+
const emitted = new Set(lines.map((l) => l.split(/[=\s]/)[0]))
|
|
293
|
+
for (const [k, v] of Object.entries(cfg.props)) {
|
|
294
|
+
if (v === undefined || emitted.has(k)) continue
|
|
295
|
+
lines.push(v === true ? k : `${k}={${JSON.stringify(v)}}`)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
259
298
|
// No-code export toolbar - buttons wired to the grid's own export API.
|
|
260
299
|
const exportBar = gridHasExport(cfg) && ctx.captureApi ? exportToolbarMarkup(cfg.export!, ctx.captureApi, entity.name) : ''
|
|
261
300
|
return ` <div ${span}${cls}>
|
|
@@ -296,10 +335,13 @@ ${exportBar} <SvGrid
|
|
|
296
335
|
if (cfg.trendField) {
|
|
297
336
|
const tReduce = cfg.trendReduce ?? cfg.reduce
|
|
298
337
|
const seriesExpr = `kpiSeries(${rowsExpr}, { trendField: '${cfg.trendField}', ${measurePart}reduce: '${tReduce}' })`
|
|
338
|
+
// The first-to-last delta chip - only when there's no target chip already
|
|
339
|
+
// (avoids a dead `&& false` branch that left `_d` null-unsafe under svelte-check).
|
|
340
|
+
const deltaChip = cfg.target == null
|
|
341
|
+
? `\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}`
|
|
342
|
+
: ''
|
|
299
343
|
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}
|
|
344
|
+
{@const _s = ${seriesExpr}}${deltaChip}
|
|
303
345
|
<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
346
|
{/if}`)
|
|
305
347
|
}
|
|
@@ -462,7 +504,7 @@ ${panels}
|
|
|
462
504
|
if (rels.length) props.push(`related={[${rels.join(', ')}]}`)
|
|
463
505
|
// Open the record named by the URL `?id=` (set by a grid / board / calendar
|
|
464
506
|
// drill-through); stays switchable via the header dropdown.
|
|
465
|
-
props.push(`selectedId={
|
|
507
|
+
props.push(`selectedId={page.url.searchParams.get('id') ?? undefined}`)
|
|
466
508
|
if (h) props.push(`height={${h}}`)
|
|
467
509
|
return ` <div ${wrapperStyle(block)}${cls}>
|
|
468
510
|
<SvRecordDetail ${props.join(' ')} />
|
|
@@ -505,13 +547,23 @@ function componentBlockMarkup(block: Block, cfg: ComponentConfig, handleName?: s
|
|
|
505
547
|
if (!spec) return ` <div ${span}${cls}><!-- unknown component "${cfg.component}" --></div>`
|
|
506
548
|
if (handleName) {
|
|
507
549
|
// Handle mode (code page): props + content come from the reactive handle, and
|
|
508
|
-
//
|
|
550
|
+
// events fire on it - so button1.setVariant(...) / button1.onclick = fn work.
|
|
509
551
|
const inner = spec.hasContent ? `>{${handleName}.text}</${spec.importName}>` : ' />'
|
|
510
|
-
//
|
|
511
|
-
//
|
|
552
|
+
// Events with a component callback prop are wired THROUGH that prop (exact
|
|
553
|
+
// semantics, works for non-bubbling callbacks like a picker's onChange); the
|
|
554
|
+
// wrapper's DOM listeners remain only as the catch-all for click/change on
|
|
555
|
+
// components without a matching callback prop - and are skipped when a prop
|
|
556
|
+
// covers the same key, so an event never fires twice.
|
|
557
|
+
const propWired = (spec.events ?? []).filter((e) => e.prop)
|
|
558
|
+
const eventAttrs = propWired.map((e) => ` ${e.prop}={(e) => ${handleName}.fire(${jsStr(e.key)}, e)}`).join('')
|
|
559
|
+
// The wrapper forwards the full standard DOM event surface to the handle (VS-style
|
|
560
|
+
// events), skipping any a component callback prop already covers (no double-fire).
|
|
561
|
+
const wrapper = STANDARD_UI_EVENTS.filter((se) => !propWired.some((e) => e.key === se.key))
|
|
562
|
+
.map((se) => ` on${se.dom}={(e) => ${handleName}.fire('${se.key}', e)}`)
|
|
563
|
+
.join('')
|
|
512
564
|
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}
|
|
565
|
+
<div id="${block.id}"${wrapper} ${span}${cls}>
|
|
566
|
+
<${spec.importName} {...${handleName}.props}${eventAttrs}${inner}
|
|
515
567
|
</div>`
|
|
516
568
|
}
|
|
517
569
|
// Data bindings: a bound prop's value is a reactive expression over the screen's
|
|
@@ -520,13 +572,13 @@ function componentBlockMarkup(block: Block, cfg: ComponentConfig, handleName?: s
|
|
|
520
572
|
const bound = (key: string): ComponentBinding | undefined => (rowsExpr ? bindings[key] : undefined)
|
|
521
573
|
const attrs: string[] = []
|
|
522
574
|
for (const p of spec.props) {
|
|
575
|
+
if (p.code) continue // code-only props (functions) are set via the handle, not literals
|
|
523
576
|
const b = bound(p.key)
|
|
524
577
|
if (b) { attrs.push(`${p.key}={${bindingExpr(b, rowsExpr!, p.type === 'number')}}`); continue }
|
|
525
578
|
const v = cfg.props[p.key] ?? p.default
|
|
526
579
|
if (v == null || v === '') continue
|
|
527
580
|
if (p.type === 'boolean') { if (v) attrs.push(p.key) }
|
|
528
|
-
else
|
|
529
|
-
else attrs.push(`${p.key}={${jsStr(String(v))}}`)
|
|
581
|
+
else attrs.push(`${p.key}={${propValueExpr(p, v)}}`)
|
|
530
582
|
}
|
|
531
583
|
// Baked-in array/object props (Timeline items, Sparkline data): emitted verbatim.
|
|
532
584
|
for (const f of spec.fixed ?? []) attrs.push(`${f.key}={${f.expr}}`)
|
|
@@ -542,14 +594,37 @@ function componentBlockMarkup(block: Block, cfg: ComponentConfig, handleName?: s
|
|
|
542
594
|
</div>`
|
|
543
595
|
}
|
|
544
596
|
|
|
597
|
+
/** A JS expression for a component prop's configured value, by panel type. */
|
|
598
|
+
function propValueExpr(p: { type: string }, v: unknown): string {
|
|
599
|
+
if (p.type === 'number') return String(Number(v))
|
|
600
|
+
if (p.type === 'boolean') return String(!!v)
|
|
601
|
+
if (p.type === 'json') return JSON.stringify(v)
|
|
602
|
+
if (p.type === 'date') return `new Date(${jsStr(String(v))})`
|
|
603
|
+
return jsStr(String(v))
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** Extra SvGridEditPanel props for a grid's optional form-layout depth. Returns a
|
|
607
|
+
* leading-space attribute string (or '' when nothing is configured). */
|
|
608
|
+
function editPanelLayoutProps(grid?: GridConfig): string {
|
|
609
|
+
if (!grid) return ''
|
|
610
|
+
const attrs: string[] = []
|
|
611
|
+
if (grid.formColumns && grid.formColumns !== 2) attrs.push(`columns={${grid.formColumns}}`)
|
|
612
|
+
if (grid.formFields?.length) attrs.push(`formFields={${JSON.stringify(grid.formFields)}}`)
|
|
613
|
+
if (grid.formSections?.length) attrs.push(`sections={${JSON.stringify(grid.formSections)}}`)
|
|
614
|
+
if (grid.formTitle) attrs.push(`title={${jsStr(grid.formTitle)}}`)
|
|
615
|
+
if (grid.formSize && grid.formSize !== 'md') attrs.push(`formSize=${jsStr(grid.formSize)}`)
|
|
616
|
+
return attrs.length ? ' ' + attrs.join(' ') : ''
|
|
617
|
+
}
|
|
618
|
+
|
|
545
619
|
/** The `{ props, text }` init literal for a component's reactive handle. */
|
|
546
620
|
function handleInit(cfg: ComponentConfig): string {
|
|
547
621
|
const spec = uiComponentSpec(cfg.component)
|
|
548
622
|
const props: string[] = []
|
|
549
623
|
for (const p of spec?.props ?? []) {
|
|
624
|
+
if (p.code) continue // code-only props are set via the handle in code, not seeded
|
|
550
625
|
const v = cfg.props[p.key] ?? p.default
|
|
551
626
|
if (v == null || v === '') continue
|
|
552
|
-
props.push(`${p.key}: ${
|
|
627
|
+
props.push(`${p.key}: ${propValueExpr(p, v)}`)
|
|
553
628
|
}
|
|
554
629
|
for (const f of spec?.fixed ?? []) props.push(`${f.key}: ${f.expr}`)
|
|
555
630
|
const parts = [`props: { ${props.join(', ')} }`]
|
|
@@ -636,14 +711,16 @@ function filterPanelState(entity: EntitySchema, block: Block, cfg: FilterPanelCo
|
|
|
636
711
|
const { state, apply } = facetNames(block)
|
|
637
712
|
const assigns = filterFieldsOf(entity, cfg).map((f) => {
|
|
638
713
|
const key = jsStr(f.field)
|
|
639
|
-
|
|
714
|
+
// The server filter model takes string values; the matcher string-coerces both
|
|
715
|
+
// sides, so a boolean column matches fine against 'true' / 'false'.
|
|
716
|
+
if (f.type === 'boolean') return ` if (v[${key}] === 'true' || v[${key}] === 'false') c[${key}] = { operator: 'equals', value: v[${key}] }`
|
|
640
717
|
if (f.type === 'enum') return ` if (v[${key}]) c[${key}] = { operator: 'equals', value: v[${key}] }`
|
|
641
718
|
return ` if (v[${key}]) c[${key}] = { operator: 'contains', value: v[${key}] }`
|
|
642
719
|
}).join('\n')
|
|
643
720
|
return `let ${state} = $state<Record<string, string>>({})
|
|
644
721
|
function ${apply}() {
|
|
645
722
|
const v = ${state}
|
|
646
|
-
const c: Record<string, { operator: 'equals' | 'contains'; value:
|
|
723
|
+
const c: Record<string, { operator: 'equals' | 'contains'; value: string }> = {}
|
|
647
724
|
${assigns}
|
|
648
725
|
controller.setFilter({ columns: c })
|
|
649
726
|
}`
|
|
@@ -717,7 +794,14 @@ const cellSnippetName = (idSafe: string, field: string): string => `cellRender_$
|
|
|
717
794
|
/** Does this grid want an export toolbar (any export affordance enabled)? */
|
|
718
795
|
function gridHasExport(cfg: GridConfig): boolean {
|
|
719
796
|
const e = cfg.export
|
|
720
|
-
return !!e && (!!e.csv || !!e.json || !!e.copy)
|
|
797
|
+
return !!e && (!!e.csv || !!e.json || !!e.copy || !!e.xlsx || !!e.pdf || !!e.print)
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** True when a grid's export bar needs `@svgrid/enterprise` (xlsx / pdf / print)
|
|
801
|
+
* rather than only the free grid API (csv / json / copy). */
|
|
802
|
+
function gridHasEnterpriseExport(cfg: GridConfig): boolean {
|
|
803
|
+
const e = cfg.export
|
|
804
|
+
return !!e && (!!e.xlsx || !!e.pdf || !!e.print)
|
|
721
805
|
}
|
|
722
806
|
|
|
723
807
|
/** Is this a tree-data grid (self-referential hierarchy)? */
|
|
@@ -812,6 +896,12 @@ function exportToolbarMarkup(e: NonNullable<GridConfig['export']>, apiVar: strin
|
|
|
812
896
|
const btns: string[] = []
|
|
813
897
|
if (e.csv) btns.push(`<button type="button" class="st-rowaction" onclick={() => void ${apiVar}?.exportCsv({ filename: ${fn} })}>Export CSV</button>`)
|
|
814
898
|
if (e.json) btns.push(`<button type="button" class="st-rowaction" onclick={() => void ${apiVar}?.exportJson({ filename: ${fn} })}>Export JSON</button>`)
|
|
899
|
+
// xlsx / pdf / print go through @svgrid/enterprise. `exportGrid` reads the
|
|
900
|
+
// grid's own visible columns and displayed rows, so the file matches what the
|
|
901
|
+
// user sees - no column list to keep in sync here.
|
|
902
|
+
if (e.xlsx) btns.push(`<button type="button" class="st-rowaction" onclick={() => void stExport(${apiVar}, 'xlsx', ${fn})}>Export Excel</button>`)
|
|
903
|
+
if (e.pdf) btns.push(`<button type="button" class="st-rowaction" onclick={() => void stExport(${apiVar}, 'pdf', ${fn})}>Export PDF</button>`)
|
|
904
|
+
if (e.print) btns.push(`<button type="button" class="st-rowaction" onclick={() => void stPrint(${apiVar}, ${fn})}>Print</button>`)
|
|
815
905
|
if (e.copy) btns.push(`<button type="button" class="st-rowaction" onclick={() => void ${apiVar}?.copyToClipboard()}>Copy</button>`)
|
|
816
906
|
return ` <div class="st-grid-toolbar">\n ${btns.join('\n ')}\n </div>\n`
|
|
817
907
|
}
|
|
@@ -827,6 +917,31 @@ const BADGE_VARIANT_HELPER = ` function stBadgeVariant(value: unknown): 'neutra
|
|
|
827
917
|
return 'neutral'
|
|
828
918
|
}`
|
|
829
919
|
|
|
920
|
+
/** Shared helper: run an `@svgrid/enterprise` export off a captured grid API.
|
|
921
|
+
* Emitted once per page when any grid has an xlsx / pdf button. Surfaces the
|
|
922
|
+
* failure instead of swallowing it - a missing optional peer dep (jszip for
|
|
923
|
+
* xlsx, pdfmake for pdf) is the usual cause and is worth seeing. */
|
|
924
|
+
const EXPORT_HELPER = ` async function stExport(api: SvGridApi<never, never> | undefined, format: 'xlsx' | 'pdf', filename: string) {
|
|
925
|
+
if (!api) return
|
|
926
|
+
try {
|
|
927
|
+
await exportGrid(api, { format, filename })
|
|
928
|
+
} catch (err) {
|
|
929
|
+
console.error('Export failed:', err)
|
|
930
|
+
alert('Export failed: ' + (err instanceof Error ? err.message : String(err)))
|
|
931
|
+
}
|
|
932
|
+
}`
|
|
933
|
+
|
|
934
|
+
/** Shared helper: paginated print off a captured grid API. */
|
|
935
|
+
const PRINT_HELPER = ` async function stPrint(api: SvGridApi<never, never> | undefined, title: string) {
|
|
936
|
+
if (!api) return
|
|
937
|
+
try {
|
|
938
|
+
await printGrid(api, { title })
|
|
939
|
+
} catch (err) {
|
|
940
|
+
console.error('Print failed:', err)
|
|
941
|
+
alert('Print failed: ' + (err instanceof Error ? err.message : String(err)))
|
|
942
|
+
}
|
|
943
|
+
}`
|
|
944
|
+
|
|
830
945
|
/** The `{#snippet}` body for one rich cell renderer (badge / progress / link). */
|
|
831
946
|
function cellRendererSnippet(idSafe: string, field: string, cellType: NonNullable<GridColumnConfig['cellType']>): string {
|
|
832
947
|
const name = cellSnippetName(idSafe, field)
|
|
@@ -1025,6 +1140,27 @@ function handleNameMap(screen: Screen): Map<string, string> {
|
|
|
1025
1140
|
return new Map(screenHandles(screen).map((h) => [h.blockId, h.name]))
|
|
1026
1141
|
}
|
|
1027
1142
|
|
|
1143
|
+
/** The `on<Event>` setter surface added to `ctx.grid` so `ctx.grid.onCellClick = fn`
|
|
1144
|
+
* type-checks. The `Row` placeholder in GRID_EVENTS params is the screen's row type. */
|
|
1145
|
+
function gridEventsType(rowType: string): string {
|
|
1146
|
+
return `{ ${GRID_EVENTS.map((e) => `${e.method}?: (${e.params.replace(/\bRow\b/g, rowType)}) => void`).join('; ')} }`
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/** The runtime-settable prop surface added to `ctx.grid` so `ctx.grid.sortable = true`
|
|
1150
|
+
* (and `if (ctx.grid.zebraRows)`) type-check + autocomplete. Assignments route to the
|
|
1151
|
+
* grid's `setOption` (a reactive $state override); reads route to `getOption`. */
|
|
1152
|
+
function gridPropsType(): string {
|
|
1153
|
+
const tsType = (p: { type: string; options?: string[] }): string =>
|
|
1154
|
+
p.type === 'boolean' ? 'boolean'
|
|
1155
|
+
: p.type === 'number' ? 'number'
|
|
1156
|
+
: p.type === 'select' && p.options?.length ? p.options.map((o) => JSON.stringify(o)).join(' | ')
|
|
1157
|
+
: p.type === 'json' ? 'unknown'
|
|
1158
|
+
: 'string'
|
|
1159
|
+
const props = gridApiSettableProps()
|
|
1160
|
+
if (!props.length) return '{}'
|
|
1161
|
+
return `{ ${props.map((p) => `${p.key}?: ${tsType(p)}`).join('; ')} }`
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1028
1164
|
/** Does this code-enabled screen expose a `ctx.data` dataset battery, and can code
|
|
1029
1165
|
* replace its rows? Freestanding data-grid pages own their rows (settable via
|
|
1030
1166
|
* setRows); an entity screen with a Grid exposes its current page + reload(). */
|
|
@@ -1088,7 +1224,7 @@ export function ctxCompletions(screen: Screen): string[] {
|
|
|
1088
1224
|
for (const h of screenHandles(screen)) {
|
|
1089
1225
|
const base = `ctx.${h.name}`
|
|
1090
1226
|
out.push(base)
|
|
1091
|
-
if (h.tier === 'grid') out.push(...GRID_API_MEMBERS.map((m) => `${base}.${m}()`))
|
|
1227
|
+
if (h.tier === 'grid') out.push(...GRID_API_MEMBERS.map((m) => `${base}.${m}()`), ...gridApiSettableProps().map((p) => `${base}.${p.key}`))
|
|
1092
1228
|
else if (h.tier === 'data') out.push(...DATA_HANDLE_MEMBERS.map((m) => `${base}.${m}`))
|
|
1093
1229
|
else if (h.component) out.push(...componentHandleMembers(h.component).map((m) => `${base}.${m}`))
|
|
1094
1230
|
}
|
|
@@ -1180,7 +1316,7 @@ export function ctxAmbientDts(screen: Screen, entity?: EntitySchema): string {
|
|
|
1180
1316
|
|
|
1181
1317
|
const members: string[] = []
|
|
1182
1318
|
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}
|
|
1319
|
+
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
1320
|
else if (h.tier === 'data') members.push(` /** The ${h.kind} - feed it rows with ${h.name}.setData(rows). */\n ${h.name}: DataHandle<${rowName}>`)
|
|
1185
1321
|
else members.push(` ${h.name}: ${componentHandleTypeName(h.component!)}`)
|
|
1186
1322
|
}
|
|
@@ -1227,7 +1363,7 @@ ${members.join('\n')}
|
|
|
1227
1363
|
function screenElementsManifest(screen: Screen): string {
|
|
1228
1364
|
const lines: string[] = []
|
|
1229
1365
|
const describe: Record<HandleTier, (h: BlockHandle) => string> = {
|
|
1230
|
-
grid: () => "the Grid's full SvGridApi - exportCsv(), selectCells(), startEditing(), addRow(), setFilter(),
|
|
1366
|
+
grid: () => "the Grid's full SvGridApi - exportCsv(), selectCells(), startEditing(), addRow(), setFilter(), ...; subscribe to events (grid.onRowClick = (e) => {}); set options live (grid.sortable = true).",
|
|
1231
1367
|
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
1368
|
component: (h) => `the ${h.component} - setText/set<Prop>(...), onclick = fn, onClick(fn).`,
|
|
1233
1369
|
}
|
|
@@ -1266,9 +1402,9 @@ export class ComponentHandle {
|
|
|
1266
1402
|
get(name: string): unknown { return this.props[name] }
|
|
1267
1403
|
setText(value: string): this { this.text = value; return this }
|
|
1268
1404
|
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 }
|
|
1405
|
+
onEvent(name: string, fn: (e: Event) => void): this { this.on = { ...this.on, [name.toLowerCase()]: fn }; return this }
|
|
1270
1406
|
onClick(fn: (e: Event) => void): this { return this.onEvent('click', fn) }
|
|
1271
|
-
fire(name: string, e: Event): void { this.on[name]?.(e) }
|
|
1407
|
+
fire(name: string, e: Event): void { this.on[name.toLowerCase()]?.(e) }
|
|
1272
1408
|
}
|
|
1273
1409
|
|
|
1274
1410
|
/** An imperative, reactive handle over a data-bound block (chart, KPI, gauge,
|
|
@@ -1290,6 +1426,42 @@ export class DataHandle<T = Record<string, unknown>> {
|
|
|
1290
1426
|
/** A DataHandle whose fallback is the screen dataset getter. */
|
|
1291
1427
|
export function dataHandle<T>(fallback: () => T[]): DataHandle<T> { return new DataHandle<T>(fallback) }
|
|
1292
1428
|
|
|
1429
|
+
/** Event subscriptions for the Grid (\`ctx.grid.onCellClick = (e) => {}\`). The keys
|
|
1430
|
+
* are lowercased on both store + dispatch so any on<Event> casing round-trips. */
|
|
1431
|
+
class GridEvents {
|
|
1432
|
+
on: Record<string, (...args: unknown[]) => void> = {}
|
|
1433
|
+
fire(name: string, ...args: unknown[]): void { this.on[name.toLowerCase()]?.(...args) }
|
|
1434
|
+
}
|
|
1435
|
+
/** Wrap the Grid's real SvGridApi so page code gets BOTH its methods
|
|
1436
|
+
* (\`ctx.grid.exportCsv()\`) and event subscription (\`ctx.grid.onRowClick = fn\`).
|
|
1437
|
+
* The api arrives asynchronously (onApiReady), so it is read lazily via \`getApi\`. */
|
|
1438
|
+
export function gridHandle<A extends object>(getApi: () => A | null): A & GridEvents {
|
|
1439
|
+
const ev = new GridEvents()
|
|
1440
|
+
return new Proxy(ev, {
|
|
1441
|
+
get(t, k) {
|
|
1442
|
+
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 }
|
|
1443
|
+
const api = getApi() as Record<string | symbol, unknown> | null
|
|
1444
|
+
if (!api) return undefined
|
|
1445
|
+
const v = api[k]
|
|
1446
|
+
if (typeof v === 'function') return (v as (...a: unknown[]) => unknown).bind(api)
|
|
1447
|
+
// A grid PROP read (ctx.grid.sortable) -> its effective value via getOption.
|
|
1448
|
+
if (v === undefined && typeof k === 'string' && !/^on[A-Z]/.test(k) && typeof api.getOption === 'function') return (api.getOption as (key: unknown) => unknown)(k)
|
|
1449
|
+
return v
|
|
1450
|
+
},
|
|
1451
|
+
set(t, k, val) {
|
|
1452
|
+
// onRowClick = fn / oncellclick = fn -> subscribe (any casing; keys lowercase).
|
|
1453
|
+
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 }
|
|
1454
|
+
const api = getApi() as Record<string | symbol, unknown> | null
|
|
1455
|
+
// ctx.grid.sortable = true -> the grid's reactive option channel (setOption writes
|
|
1456
|
+
// a $state override the grid merges over its props). Falls back to a direct set for
|
|
1457
|
+
// an older grid build without setOption.
|
|
1458
|
+
if (api && typeof api.setOption === 'function') { (api.setOption as (key: unknown, value: unknown) => void)(k, val); return true }
|
|
1459
|
+
if (api) api[k] = val
|
|
1460
|
+
return true
|
|
1461
|
+
},
|
|
1462
|
+
}) as A & GridEvents
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1293
1465
|
/** A handle plus dynamic setX / onX helpers and \`el.onclick = fn\` assignment. */
|
|
1294
1466
|
export type Handle = ComponentHandle & Record<string, any>
|
|
1295
1467
|
|
|
@@ -1299,11 +1471,13 @@ export function handle(init: { props?: Record<string, unknown>; text?: string })
|
|
|
1299
1471
|
get(t, k) {
|
|
1300
1472
|
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
1473
|
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
|
-
|
|
1474
|
+
// onEvent(fn) call form. onEvent lowercases, so onKeyDown maps to the 'keydown' the wrapper fires.
|
|
1475
|
+
if (typeof k === 'string' && /^on[A-Z]/.test(k)) { const e = k.slice(2); return (fn: (ev: Event) => void) => t.onEvent(e, fn) }
|
|
1303
1476
|
return typeof k === 'string' ? t.props[k] : undefined
|
|
1304
1477
|
},
|
|
1305
1478
|
set(t, k, v) {
|
|
1306
|
-
|
|
1479
|
+
// el.onKeyDown = fn / el.onclick = fn -> subscribe (onEvent lowercases the key).
|
|
1480
|
+
if (typeof k === 'string' && /^on[A-Za-z]/.test(k)) { t.onEvent(k.slice(2), v as (e: Event) => void); return true }
|
|
1307
1481
|
if (k === 'text') { t.text = v as string; return true } // content, not a prop
|
|
1308
1482
|
if (typeof k === 'string') t.set(k, v) // checkbox1.checked = true
|
|
1309
1483
|
return true
|
|
@@ -1361,14 +1535,16 @@ function componentHandleTypeDecl(componentKey: string): string | null {
|
|
|
1361
1535
|
* the PageContext type never drift. `rowType` is the entity's row type (entity
|
|
1362
1536
|
* screens) or `RowData` (freestanding); `datasetRowsVar` is the state var backing
|
|
1363
1537
|
* a settable `ctx.data`. */
|
|
1364
|
-
function codeWiring(screen: Screen, rowType: string, datasetRowsVar: string | undefined): { decls: string[]; ctxLiteral: string; usesHandle: boolean; usesDataHandle: boolean } {
|
|
1538
|
+
function codeWiring(screen: Screen, rowType: string, datasetRowsVar: string | undefined): { decls: string[]; ctxLiteral: string; usesHandle: boolean; usesDataHandle: boolean; usesGridHandle: boolean } {
|
|
1365
1539
|
const blockById = new Map(flattenBlocks(screen.blocks).map((b) => [b.id, b]))
|
|
1366
1540
|
const decls: string[] = []
|
|
1367
1541
|
const ctxParts: string[] = []
|
|
1368
1542
|
let usesHandle = false
|
|
1369
1543
|
let usesDataHandle = false
|
|
1544
|
+
let usesGridHandle = false
|
|
1370
1545
|
for (const h of screenHandles(screen)) {
|
|
1371
|
-
|
|
1546
|
+
// ctx.grid = the real SvGridApi PLUS event subscription (ctx.grid.onRowClick = fn).
|
|
1547
|
+
if (h.tier === 'grid') { usesGridHandle = true; decls.push('const gridCtx = gridHandle(() => gridApi)'); ctxParts.push('grid: gridCtx'); continue }
|
|
1372
1548
|
if (h.tier === 'data') {
|
|
1373
1549
|
usesDataHandle = true
|
|
1374
1550
|
decls.push(`const ${h.name} = dataHandle<${rowType}>(() => allRows)`)
|
|
@@ -1386,9 +1562,9 @@ function codeWiring(screen: Screen, rowType: string, datasetRowsVar: string | un
|
|
|
1386
1562
|
if (dataset === 'settable' && datasetRowsVar) ctxParts.push(`data: { get rows() { return ${datasetRowsVar} }, setRows: (r) => (${datasetRowsVar} = r) }`)
|
|
1387
1563
|
else if (dataset === 'reload') ctxParts.push('data: { get rows() { return view.rows }, reload: () => controller.refresh(), create: (v) => controller.createRow(v), update: (id, v) => controller.updateRow(id, v), delete: (id) => controller.deleteRow(id) }')
|
|
1388
1564
|
ctxParts.push('goto')
|
|
1389
|
-
ctxParts.push('params: Object.fromEntries(
|
|
1565
|
+
ctxParts.push('params: Object.fromEntries(page.url.searchParams)')
|
|
1390
1566
|
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 }
|
|
1567
|
+
return { decls, ctxLiteral: `{ ${ctxParts.join(', ')} }`, usesHandle, usesDataHandle, usesGridHandle }
|
|
1392
1568
|
}
|
|
1393
1569
|
|
|
1394
1570
|
/** Per-screen, regenerated PageContext type: the tiered handles (grid api / data
|
|
@@ -1404,7 +1580,7 @@ function screenContextFile(screen: Screen, rowType: string): GeneratedFile {
|
|
|
1404
1580
|
|
|
1405
1581
|
const members: string[] = []
|
|
1406
1582
|
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}
|
|
1583
|
+
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
1584
|
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
1585
|
else members.push(` ${h.name}: ${componentHandleTypeName(h.component!)}`)
|
|
1410
1586
|
}
|
|
@@ -1445,27 +1621,25 @@ function compiledMethodBodies(screen: Screen): { onLoadSteps?: string; clicks?:
|
|
|
1445
1621
|
if (!steps || !Object.keys(steps).length) return {}
|
|
1446
1622
|
const names = handleNameMap(screen)
|
|
1447
1623
|
const clicks: string[] = []
|
|
1624
|
+
// Legacy visual event steps compile to `ctx.<name>.on<event> = async () => { ... }`
|
|
1625
|
+
// inside onLoad. New event handlers are written as raw code directly in the onLoad
|
|
1626
|
+
// body (double-click an event -> Studio inserts `ctx.<name>.on<Event> = (e) => {}`).
|
|
1448
1627
|
for (const b of flattenBlocks(screen.blocks)) {
|
|
1449
1628
|
if (b.config.kind !== 'component') continue
|
|
1450
|
-
const clickSteps = steps[clickSlot(b.id)]
|
|
1451
|
-
if (!clickSteps?.length) continue
|
|
1452
|
-
const name = names.get(b.id)
|
|
1453
|
-
if (!name) continue
|
|
1454
|
-
clicks.push(`ctx.${name}.onclick = async () => {\n${indentBody(compileHandlerSteps(clickSteps))}\n}`)
|
|
1455
|
-
}
|
|
1456
|
-
// Component value-change wiring (`ctx.<name>.onchange = ...`), same shape as clicks.
|
|
1457
|
-
for (const b of flattenBlocks(screen.blocks)) {
|
|
1458
|
-
if (b.config.kind !== 'component') continue
|
|
1459
|
-
const changeSteps = steps[changeSlot(b.id)]
|
|
1460
|
-
if (!changeSteps?.length) continue
|
|
1461
1629
|
const name = names.get(b.id)
|
|
1462
1630
|
if (!name) continue
|
|
1463
|
-
|
|
1631
|
+
const spec = uiComponentSpec(b.config.component)
|
|
1632
|
+
const eventKeys = [...(spec?.events ?? []).map((e) => e.key), ...STANDARD_UI_EVENTS.map((e) => e.key)]
|
|
1633
|
+
for (const key of [...new Set(eventKeys)]) {
|
|
1634
|
+
const eventSteps = steps[eventSlot(key, b.id)]
|
|
1635
|
+
if (!eventSteps?.length) continue
|
|
1636
|
+
clicks.push(`ctx.${name}.on${key} = async () => {\n${indentBody(compileHandlerSteps(eventSteps))}\n}`)
|
|
1637
|
+
}
|
|
1464
1638
|
}
|
|
1465
1639
|
return {
|
|
1466
1640
|
// Legacy visual onLoad steps (the code view now edits onLoad directly).
|
|
1467
1641
|
onLoadSteps: steps[ON_LOAD]?.length ? compileHandlerSteps(steps[ON_LOAD]) : undefined,
|
|
1468
|
-
// Component on-
|
|
1642
|
+
// Component on-event wiring - MERGED with any hand-written onLoad body, never replacing it.
|
|
1469
1643
|
clicks: clicks.length ? clicks.join('\n\n') : undefined,
|
|
1470
1644
|
onDestroy: steps[ON_DESTROY]?.length ? compileHandlerSteps(steps[ON_DESTROY]) : undefined,
|
|
1471
1645
|
}
|
|
@@ -1546,12 +1720,12 @@ function freestandingScreenPage(screen: Screen, accessEnabled: boolean, i18nEnab
|
|
|
1546
1720
|
const gridImport = gridNames.length ? `import { ${gridNames.join(', ')} } from '@svgrid/grid'\n ` : ''
|
|
1547
1721
|
const gridTypes = [grid ? 'RowData' : '', grid ? 'SvGridApi' : ''].filter(Boolean)
|
|
1548
1722
|
const typeImport = hasCode && gridTypes.length ? `import type { ${gridTypes.join(', ')} } from '@svgrid/grid'\n ` : ''
|
|
1549
|
-
const handleSpecs = [wiring?.usesHandle ? 'handle' : '', wiring?.usesDataHandle ? 'dataHandle' : ''].filter(Boolean)
|
|
1723
|
+
const handleSpecs = [wiring?.usesHandle ? 'handle' : '', wiring?.usesDataHandle ? 'dataHandle' : '', wiring?.usesGridHandle ? 'gridHandle' : ''].filter(Boolean)
|
|
1550
1724
|
const handleImport = handleSpecs.length ? `import { ${handleSpecs.join(', ')} } from '$lib/handles.svelte'\n ` : ''
|
|
1551
1725
|
|
|
1552
1726
|
const codeImport = hasCode ? `import { onMount } from 'svelte'\n import * as handlers from './handlers'\n import type { PageContext } from './page-context'\n ` : ''
|
|
1553
1727
|
const gotoImport = hasCode ? `import { goto } from '$app/navigation'\n ` : ''
|
|
1554
|
-
const
|
|
1728
|
+
const pageStateImport = hasCode ? `import { page } from '$app/state'\n ` : ''
|
|
1555
1729
|
const handleDecls = (wiring?.decls ?? []).join('\n ')
|
|
1556
1730
|
// The Grid exposes its real SvGridApi (onApiReady) so code gets the full, typed
|
|
1557
1731
|
// grid API - ctx.grid.exportCsv(), selectCells(), startEditing(), ... - not a stub.
|
|
@@ -1559,7 +1733,7 @@ function freestandingScreenPage(screen: Screen, accessEnabled: boolean, i18nEnab
|
|
|
1559
1733
|
? `\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
1734
|
: ''
|
|
1561
1735
|
const codeScript = hasCode
|
|
1562
|
-
? `\n ${
|
|
1736
|
+
? `\n ${gridScript ? gridScript.trimStart() + '\n ' : ''}${handleDecls ? handleDecls + '\n ' : ''}onMount(() => {
|
|
1563
1737
|
// ctx is internal plumbing wired from this screen's blocks; the typed surface your
|
|
1564
1738
|
// code uses lives in handlers.ts (PageContext). Component handles are runtime proxies,
|
|
1565
1739
|
// so the cast bridges their dynamic shape to the typed context.
|
|
@@ -1568,7 +1742,9 @@ function freestandingScreenPage(screen: Screen, accessEnabled: boolean, i18nEnab
|
|
|
1568
1742
|
return () => handlers.${ON_DESTROY}(ctx)
|
|
1569
1743
|
})`
|
|
1570
1744
|
: ''
|
|
1571
|
-
|
|
1745
|
+
// Grid events fire into ctx.grid so freestanding page code can subscribe too.
|
|
1746
|
+
const gridEventAttrs = grid && wiring?.usesGridHandle ? GRID_EVENTS.filter((e) => e.prop).map((e) => ` ${e.prop}={(...a) => gridCtx.fire('${e.key}', ...a)}`).join('') : ''
|
|
1747
|
+
const gridMarkup = grid ? ` <SvGrid data={rows} columns={columns} features={features} onApiReady={(a) => (gridApi = a)}${gridEventAttrs} showRowNumbers />` : ''
|
|
1572
1748
|
|
|
1573
1749
|
const blockContent = screen.blocks.length
|
|
1574
1750
|
? screen.blocks.map((b) => (b.config.kind === 'component' ? componentBlockMarkup(b, b.config, hasCode ? handleNames.get(b.id) : undefined) : '')).filter(Boolean).join('\n')
|
|
@@ -1580,7 +1756,7 @@ function freestandingScreenPage(screen: Screen, accessEnabled: boolean, i18nEnab
|
|
|
1580
1756
|
path: `src/routes/${screen.route}/+page.svelte`,
|
|
1581
1757
|
description: `${screen.title} screen (freestanding, no bound entity).`,
|
|
1582
1758
|
contents: `<script lang="ts">
|
|
1583
|
-
${gridImport}${typeImport}${handleImport}${codeImport}${gotoImport}${
|
|
1759
|
+
${gridImport}${typeImport}${handleImport}${codeImport}${gotoImport}${pageStateImport}${accessImport}${i18nImport}${parts.join('\n\n ')}${codeScript}
|
|
1584
1760
|
</script>
|
|
1585
1761
|
|
|
1586
1762
|
<h1 class="st__title">${title}</h1>
|
|
@@ -1594,7 +1770,7 @@ ${content}
|
|
|
1594
1770
|
/** A self-contained screen page composing the screen's blocks. When `accessEnabled`
|
|
1595
1771
|
* the page gates create / update affordances by the current role (server still
|
|
1596
1772
|
* 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 {
|
|
1773
|
+
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
1774
|
const n = namesFor(schema)
|
|
1599
1775
|
const label = schema.label ?? n.label
|
|
1600
1776
|
const blocks = screen.blocks
|
|
@@ -1642,8 +1818,11 @@ function screenPage(schema: EntitySchema, rawSchema: EntitySchema, screen: Scree
|
|
|
1642
1818
|
const hasCellRenderers = cellRenderKinds.size > 0
|
|
1643
1819
|
const wantsForm = !!formGrid || hasForm || hasEditAction
|
|
1644
1820
|
// 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
|
|
1821
|
+
const gridPageSize = gridConfigs[0] ? (gridConfigs[0].paginated !== false ? (gridConfigs[0].pageSize ?? 10) : 1000) : 10
|
|
1646
1822
|
const formPres = formGrid?.formPresentation ?? 'modal'
|
|
1823
|
+
// Optional form-layout depth (columns / field selection+order / titled sections /
|
|
1824
|
+
// dialog title+size) -> extra SvGridEditPanel props. Emitted only when configured.
|
|
1825
|
+
const formLayoutProps = editPanelLayoutProps(formGrid)
|
|
1647
1826
|
const hasPivot = has(allBlocks, 'pivot')
|
|
1648
1827
|
const hasFilter = has(blocks, 'filter')
|
|
1649
1828
|
const hasRecord = has(blocks, 'record')
|
|
@@ -1651,27 +1830,18 @@ function screenPage(schema: EntitySchema, rawSchema: EntitySchema, screen: Scree
|
|
|
1651
1830
|
// Filter panels drive the grid's controller; record panels read the grid's
|
|
1652
1831
|
// selection - both need the controller even if the grid isn't editable.
|
|
1653
1832
|
const needsController = hasGrid || wantsForm || hasFilter || hasRecord
|
|
1833
|
+
// Supabase Realtime: when the screen's entity is Supabase-backed and opts into
|
|
1834
|
+
// live updates, subscribe to Postgres change streams and refresh() the paged
|
|
1835
|
+
// grid on any INSERT / UPDATE / DELETE (respects the active sort/filter/page).
|
|
1836
|
+
const rtSupabase = !ssrData && needsController && entSource?.kind === 'supabase' && entSource.realtime === true
|
|
1837
|
+
const rtTable = rtSupabase ? (entSource as SupabaseSource).table : ''
|
|
1654
1838
|
const hasAgg = has(allBlocks, 'chart') || has(allBlocks, 'dashboard') || has(allBlocks, 'kpi') || has(allBlocks, 'gauge') || has(allBlocks, 'tree')
|
|
1655
1839
|
const relationFields = schema.fields.filter((f) => f.type === 'relation' && f.relation)
|
|
1656
1840
|
|
|
1657
1841
|
// Distinct, resolvable child entities referenced by master-detail blocks, and by
|
|
1658
1842
|
// a detail page's related child collections (both load the child table into a
|
|
1659
1843
|
// `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()]
|
|
1844
|
+
const childList = screenChildEntities(blocks, resolve)
|
|
1675
1845
|
const hasMD = childList.length > 0
|
|
1676
1846
|
// Components with a data binding (aggregate / field) read the whole table too.
|
|
1677
1847
|
const boundComponents = allBlocks.filter((b): b is Block & { config: ComponentConfig } => b.config.kind === 'component' && componentHasBindings(b.config))
|
|
@@ -1733,8 +1903,16 @@ function screenPage(schema: EntitySchema, rawSchema: EntitySchema, screen: Scree
|
|
|
1733
1903
|
if (hasPivot) entImports.push('SvPivotDesigner')
|
|
1734
1904
|
// The scheduler renderer (grid scheduler-view + calendar block) is registered app-wide (idempotent).
|
|
1735
1905
|
if (usesScheduler) entImports.push('enableSchedulerView')
|
|
1906
|
+
// xlsx / pdf / print export buttons.
|
|
1907
|
+
const wantsEnterpriseExport = allBlocks.some((b) => b.config.kind === 'grid' && gridHasEnterpriseExport(b.config))
|
|
1908
|
+
const wantsPrint = allBlocks.some((b) => b.config.kind === 'grid' && !!b.config.export?.print)
|
|
1909
|
+
if (wantsEnterpriseExport) entImports.push('exportGrid')
|
|
1910
|
+
if (wantsPrint) entImports.push('printGrid')
|
|
1911
|
+
if (rtSupabase) entImports.push('createSupabaseRealtime', 'type SupabaseRealtimeClientLike')
|
|
1736
1912
|
// Dedupe: record + form both want SvGridEditPanel.
|
|
1737
1913
|
const entImport = entImports.length ? `import { ${[...new Set(entImports)].join(', ')} } from '@svgrid/enterprise'\n ` : ''
|
|
1914
|
+
// Realtime pulls the shared client from the generated connections module.
|
|
1915
|
+
const connImport = rtSupabase ? `import { supabaseClient } from '$lib/connections'\n ` : ''
|
|
1738
1916
|
const lookupVars = relationFields.map((f) => lookupVar(schema, f.field))
|
|
1739
1917
|
const childSchemaVars = childList.map((c) => namesFor(c).schemaVar)
|
|
1740
1918
|
const childTypes = childList.map((c) => namesFor(c).type)
|
|
@@ -1743,7 +1921,8 @@ function screenPage(schema: EntitySchema, rawSchema: EntitySchema, screen: Scree
|
|
|
1743
1921
|
// (childEntity === this entity) doesn't emit a duplicate import specifier.
|
|
1744
1922
|
const schemaVarImports = [...new Set([n.schemaVar, ...childSchemaVars])]
|
|
1745
1923
|
const typeImports = [...new Set([n.type, ...childTypes])]
|
|
1746
|
-
|
|
1924
|
+
// SSR read pages get every dataset from the server load - no client source use.
|
|
1925
|
+
const dataImports = ssrData ? [] : [...new Set([n.sourceVar, ...childSourceVars, ...(wantsForm ? [...lookupVars, 'nextId'] : [])])].filter(Boolean)
|
|
1747
1926
|
|
|
1748
1927
|
// Drill-through: this screen navigates out (goto) and/or is a drill target that
|
|
1749
1928
|
// reads URL query params matching its fields into an initial filter.
|
|
@@ -1769,16 +1948,25 @@ function screenPage(schema: EntitySchema, rawSchema: EntitySchema, screen: Scree
|
|
|
1769
1948
|
for (const a of screenActions) parts.push(actionHandlerScript(a))
|
|
1770
1949
|
if (needsController) {
|
|
1771
1950
|
const urlFilter = applyUrlFilters
|
|
1772
|
-
? `\n const sp =
|
|
1951
|
+
? `\n const sp = page.url.searchParams
|
|
1773
1952
|
const _cols: Record<string, { operator: 'equals'; value: string }> = {}
|
|
1774
1953
|
for (const _f of [${filterableFieldNames.map(jsStr).join(', ')}]) { const _v = sp.get(_f); if (_v != null) _cols[_f] = { operator: 'equals', value: _v } }
|
|
1775
1954
|
if (Object.keys(_cols).length) controller.setFilter({ columns: _cols })`
|
|
1776
1955
|
: ''
|
|
1956
|
+
const ctlBase = `createServerDataSource<${n.type}>(${n.sourceVar}, { pageSize: ${gridPageSize}, optimistic: true, getRowId: (r) => String((r as Record<string, unknown>)[idField]), onChange: (s) => (view = s) })`
|
|
1957
|
+
// In code mode, wrap CRUD so page code can react to data changes via ctx.grid
|
|
1958
|
+
// (grid.onRowAdded / onRowUpdated / onRowDeleted). Fires only after a write settles.
|
|
1959
|
+
const ctlDecl = codeGrid
|
|
1960
|
+
? `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) } }`
|
|
1961
|
+
: `const controller = ${ctlBase}`
|
|
1962
|
+
const rtWiring = rtSupabase
|
|
1963
|
+
? `\n const __rt = createSupabaseRealtime({ client: supabaseClient as unknown as SupabaseRealtimeClientLike, table: ${jsStr(rtTable)}, debounceMs: 250, onChange: () => controller.refresh() })
|
|
1964
|
+
return () => { __rt.unsubscribe(); controller.dispose() } })`
|
|
1965
|
+
: `\n controller.refresh(); return () => controller.dispose() })`
|
|
1777
1966
|
parts.push(`const idField = ${n.schemaVar}.idField ?? 'id'
|
|
1778
1967
|
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() })`)
|
|
1968
|
+
${ctlDecl}
|
|
1969
|
+
$effect(() => {${urlFilter}${rtSupabase ? '\n controller.refresh()' : ''}${rtWiring}`)
|
|
1782
1970
|
}
|
|
1783
1971
|
const actionSnippets: string[] = []
|
|
1784
1972
|
// Tree-grid scripts reference `allRows`; collected here, appended AFTER its declaration.
|
|
@@ -1787,6 +1975,14 @@ function screenPage(schema: EntitySchema, rawSchema: EntitySchema, screen: Scree
|
|
|
1787
1975
|
if (blocks.some((b) => b.config.kind === 'grid' && b.config.columns.some((c) => c.show && c.cellType?.kind === 'badge'))) {
|
|
1788
1976
|
parts.push(BADGE_VARIANT_HELPER)
|
|
1789
1977
|
}
|
|
1978
|
+
// Enterprise export/print helpers, emitted once per page beside the toolbar
|
|
1979
|
+
// buttons that call them.
|
|
1980
|
+
if (blocks.some((b) => b.config.kind === 'grid' && (!!b.config.export?.xlsx || !!b.config.export?.pdf))) {
|
|
1981
|
+
parts.push(EXPORT_HELPER)
|
|
1982
|
+
}
|
|
1983
|
+
if (blocks.some((b) => b.config.kind === 'grid' && !!b.config.export?.print)) {
|
|
1984
|
+
parts.push(PRINT_HELPER)
|
|
1985
|
+
}
|
|
1790
1986
|
for (const b of blocks) {
|
|
1791
1987
|
if (b.config.kind === 'grid') {
|
|
1792
1988
|
const idSafe = b.id.replace(/-/g, '_')
|
|
@@ -1813,10 +2009,17 @@ function screenPage(schema: EntitySchema, rawSchema: EntitySchema, screen: Scree
|
|
|
1813
2009
|
}
|
|
1814
2010
|
}
|
|
1815
2011
|
if (needsAllRows) {
|
|
1816
|
-
|
|
2012
|
+
// SSR read screens get their rows from the server load (real SSR HTML);
|
|
2013
|
+
// the SPA path fetches client-side after mount.
|
|
2014
|
+
parts.push(
|
|
2015
|
+
ssrData
|
|
2016
|
+
? `const allRows = $derived(data.rows as ${n.type}[])
|
|
2017
|
+
const allRowsReady = true`
|
|
2018
|
+
: `let allRows = $state<${n.type}[]>([])
|
|
1817
2019
|
let allRowsReady = $state(false)
|
|
1818
2020
|
async function loadAll() { allRows = [...(await ${n.sourceVar}.getRows({ startRow: 0, endRow: 1000, pageIndex: 0, pageSize: 1000, sortModel: [], filterModel: {} })).rows]; allRowsReady = true }
|
|
1819
|
-
loadAll()
|
|
2021
|
+
loadAll()`,
|
|
2022
|
+
)
|
|
1820
2023
|
}
|
|
1821
2024
|
// Tree-grid state/derivations - emitted after `allRows` so it's in scope.
|
|
1822
2025
|
for (const s of treeScripts) parts.push(s)
|
|
@@ -1840,6 +2043,10 @@ function screenPage(schema: EntitySchema, rawSchema: EntitySchema, screen: Scree
|
|
|
1840
2043
|
for (const c of childList) {
|
|
1841
2044
|
const cn = namesFor(c)
|
|
1842
2045
|
const v = mdChildVar(c.name)
|
|
2046
|
+
if (ssrData) {
|
|
2047
|
+
parts.push(`const ${v} = $derived(data.${v} as ${cn.type}[])`)
|
|
2048
|
+
continue
|
|
2049
|
+
}
|
|
1843
2050
|
parts.push(`let ${v} = $state<${cn.type}[]>([])
|
|
1844
2051
|
async function load_${v}() { ${v} = [...(await ${cn.sourceVar}.getRows({ startRow: 0, endRow: 1000, pageIndex: 0, pageSize: 1000, sortModel: [], filterModel: {} })).rows] }
|
|
1845
2052
|
load_${v}()`)
|
|
@@ -1925,7 +2132,7 @@ function screenPage(schema: EntitySchema, rawSchema: EntitySchema, screen: Scree
|
|
|
1925
2132
|
const steps = b.config.kind === 'grid' ? screen.handlerSteps?.[rowSelectSlot(b.id)] : undefined
|
|
1926
2133
|
return steps?.length ? compileHandlerSteps(steps) : undefined
|
|
1927
2134
|
}
|
|
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 })
|
|
2135
|
+
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
2136
|
const body = blocks.map((b) => blockMarkup(schema, n.schemaVar, n.type, b, resolve, blockCtx(b, false))).filter(Boolean).join('\n')
|
|
1930
2137
|
// Dock mode: each block becomes a pane rendered by id; a narrow viewport stacks the grid body.
|
|
1931
2138
|
const dockPanes = isDock
|
|
@@ -1961,7 +2168,7 @@ ${canvasBody}
|
|
|
1961
2168
|
${body}
|
|
1962
2169
|
</div>`
|
|
1963
2170
|
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}`
|
|
2171
|
+
? `\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
2172
|
: ''
|
|
1966
2173
|
// currentRole is needed for either a create/update UI gate or an action's
|
|
1967
2174
|
// screen-access gate; `can`/`canScreen` are pulled in only where actually used.
|
|
@@ -1969,13 +2176,13 @@ ${body}
|
|
|
1969
2176
|
const accessSpecs = [...(needsCurrentRole ? ['currentRole'] : []), ...(gatesUi ? ['can'] : []), ...(gatesActions ? ['canScreen'] : [])]
|
|
1970
2177
|
const accessImport = accessSpecs.length ? `import { ${accessSpecs.join(', ')} } from '$lib/access'\n ` : ''
|
|
1971
2178
|
const i18nImport = i18nEnabled ? `import { t, localizeCols } from '$lib/i18n'\n ` : ''
|
|
1972
|
-
// Code-behind needs goto (ctx.goto) +
|
|
2179
|
+
// Code-behind needs goto (ctx.goto) + page state (ctx.params) even when no
|
|
1973
2180
|
// block otherwise navigates; and the handle runtime for its data/component handles.
|
|
1974
2181
|
const gotoImport = usesGoto || codeEnabled ? `import { goto } from '$app/navigation'\n ` : ''
|
|
1975
2182
|
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)
|
|
2183
|
+
const handleSpecs = [codeWire?.usesHandle ? 'handle' : '', codeWire?.usesDataHandle ? 'dataHandle' : '', codeWire?.usesGridHandle ? 'gridHandle' : ''].filter(Boolean)
|
|
1977
2184
|
const handleImport = handleSpecs.length ? `import { ${handleSpecs.join(', ')} } from '$lib/handles.svelte'\n ` : ''
|
|
1978
|
-
const pageImport = applyUrlFilters || has(allBlocks, 'detail') || codeEnabled ? `import { page } from '$app/
|
|
2185
|
+
const pageImport = applyUrlFilters || has(allBlocks, 'detail') || codeEnabled ? `import { page } from '$app/state'\n ` : ''
|
|
1979
2186
|
const title = i18nEnabled ? `{$t('screen.${screen.id}', ${JSON.stringify(screen.title)})}` : screen.title
|
|
1980
2187
|
// Surface a failed data load (silent empty grid otherwise) with a retry.
|
|
1981
2188
|
const errorBanner = needsController
|
|
@@ -1991,10 +2198,9 @@ ${body}
|
|
|
1991
2198
|
path: `src/routes/${screen.route}/+page.svelte`,
|
|
1992
2199
|
description: `${screen.title} screen (${blocks.map((b) => b.config.kind).join(', ') || 'empty'}).`,
|
|
1993
2200
|
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 ')}
|
|
2201
|
+
${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'
|
|
2202
|
+
${dataImports.length ? ` import { ${dataImports.join(', ')} } from '$lib/data'\n` : ''}
|
|
2203
|
+
${ssrData ? 'let { data }: PageProps = $props()\n\n ' : ''}${parts.join('\n\n ')}
|
|
1998
2204
|
</script>
|
|
1999
2205
|
|
|
2000
2206
|
<h1 class="st__title">${title}</h1>
|
|
@@ -2019,6 +2225,376 @@ ${(() => {
|
|
|
2019
2225
|
}
|
|
2020
2226
|
}
|
|
2021
2227
|
|
|
2228
|
+
/** Distinct, resolvable child entities a screen's master-detail / detail blocks
|
|
2229
|
+
* reference - shared by the SPA loader, the SSR read `load`, and the page's
|
|
2230
|
+
* `md_<name>_rows` derivations so the three can never drift. */
|
|
2231
|
+
function screenChildEntities(blocks: Block[], resolve: (name: string) => EntitySchema | undefined): EntitySchema[] {
|
|
2232
|
+
const out = new Map<string, EntitySchema>()
|
|
2233
|
+
for (const b of blocks) {
|
|
2234
|
+
if (b.config.kind === 'master-detail' && b.config.childEntity && b.config.foreignKey) {
|
|
2235
|
+
const c = resolve(b.config.childEntity)
|
|
2236
|
+
if (c) out.set(c.name, c)
|
|
2237
|
+
}
|
|
2238
|
+
if (b.config.kind === 'detail') {
|
|
2239
|
+
for (const rel of b.config.related ?? []) {
|
|
2240
|
+
if (!rel.entity || !rel.foreignKey) continue
|
|
2241
|
+
const c = resolve(rel.entity)
|
|
2242
|
+
if (c) out.set(c.name, c)
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
}
|
|
2246
|
+
return [...out.values()]
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
/** `+page.server.ts` for a read-only SSR screen (data-viz / detail / master-
|
|
2250
|
+
* detail): a `load` that returns the full dataset (+ each child collection) -
|
|
2251
|
+
* the page renders real SSR HTML from `data.*`. No actions (read-only). */
|
|
2252
|
+
function ssrReadServerFile(schema: EntitySchema, screen: Screen, sourceKind: 'memory' | 'sql', accessEnabled: boolean, screenIds: string[], resolve: (name: string) => EntitySchema | undefined): GeneratedFile {
|
|
2253
|
+
const n = namesFor(schema)
|
|
2254
|
+
const isSql = sourceKind === 'sql'
|
|
2255
|
+
const children = screenChildEntities(screen.blocks, resolve)
|
|
2256
|
+
const rbac = accessEnabled && !isSql // sql: the /api route already enforces authz
|
|
2257
|
+
const srcExpr = (s: EntitySchema) =>
|
|
2258
|
+
isSql ? `createKitDataSource<Record<string, unknown>>({ endpoint: ${jsStr('/api/' + namesFor(s).route)}, fetch })` : namesFor(s).sourceVar
|
|
2259
|
+
const memImports = isSql ? [] : [...new Set([n.sourceVar, ...children.map((c) => namesFor(c).sourceVar)])]
|
|
2260
|
+
const childLoads = children.map((c) => ` const ${mdChildVar(c.name)} = (await ${srcExpr(c)}.getRows(PAGE)).rows`).join('\n')
|
|
2261
|
+
return {
|
|
2262
|
+
path: `src/routes/${screen.route}/+page.server.ts`,
|
|
2263
|
+
description: `${screen.title} - SSR load (full dataset${children.length ? ' + child collections' : ''}).`,
|
|
2264
|
+
contents: `import type { PageServerLoad } from './$types'
|
|
2265
|
+
import type { ServerRequest } from '@svgrid/grid'
|
|
2266
|
+
${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` : ''}
|
|
2267
|
+
// The app shell is a client SPA (+layout.ts ssr=false); this screen opts back
|
|
2268
|
+
// INTO server rendering - real SSR HTML, not just a server-side load.
|
|
2269
|
+
export const ssr = true
|
|
2270
|
+
${rbac ? `\nconst SCREEN_IDS = ${JSON.stringify(screenIds)}\n` : ''}
|
|
2271
|
+
const PAGE: ServerRequest = { startRow: 0, endRow: 1000, pageIndex: 0, pageSize: 1000, sortModel: [], filterModel: {} }
|
|
2272
|
+
|
|
2273
|
+
export const load: PageServerLoad = async (${isSql || rbac ? `{ ${[isSql ? 'fetch' : '', rbac ? 'locals' : ''].filter(Boolean).join(', ')} }` : ''}) => {
|
|
2274
|
+
${rbac ? ` if (!authorizeAction(getServerRole({ locals }), 'read', SCREEN_IDS)) throw error(403, 'Not allowed')\n` : ''} const rows = (await ${srcExpr(schema)}.getRows(PAGE)).rows
|
|
2275
|
+
${childLoads ? childLoads + '\n' : ''} return { rows${children.map((c) => `, ${mdChildVar(c.name)}`).join('')} }
|
|
2276
|
+
}
|
|
2277
|
+
`,
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
// ---------------------------------------------------------------------------
|
|
2282
|
+
// SSR-native output (opt-in per screen via `renderMode: 'ssr'`).
|
|
2283
|
+
//
|
|
2284
|
+
// Instead of the client data-source-controller page, an SSR screen emits idiomatic
|
|
2285
|
+
// SvelteKit: a `+page.server.ts` with a `load` (SSR first paint, sort/filter/page
|
|
2286
|
+
// read from the URL so it's shareable and works with no JS) and form `actions` for
|
|
2287
|
+
// create / update / delete (progressive enhancement via `use:enhance`), plus an
|
|
2288
|
+
// SSR `+page.svelte` that renders the server rows and drives the grid's external
|
|
2289
|
+
// sort/pagination back into the URL. Server-side validation runs in the actions.
|
|
2290
|
+
// ---------------------------------------------------------------------------
|
|
2291
|
+
|
|
2292
|
+
/** URL search params -> a data-source request. Shared by every SSR route's load. */
|
|
2293
|
+
const SSR_QUERY_HELPER = `// Turn a page URL's search params into a data-source request (sort / filter /
|
|
2294
|
+
// page / size). The grid drives these params via goto(), so the server re-runs
|
|
2295
|
+
// load() - which makes every list view bookmarkable and functional with no JS.
|
|
2296
|
+
import type { ServerRequest } from '@svgrid/grid'
|
|
2297
|
+
|
|
2298
|
+
export function planFromSearchParams(url: URL, defaultPageSize = 25): ServerRequest {
|
|
2299
|
+
const sp = url.searchParams
|
|
2300
|
+
const pageIndex = Math.max(0, Number.parseInt(sp.get('page') ?? '', 10) || 0)
|
|
2301
|
+
const pageSize = Math.min(200, Math.max(1, Number.parseInt(sp.get('size') ?? '', 10) || defaultPageSize))
|
|
2302
|
+
const sortModel = (sp.get('sort') ?? '')
|
|
2303
|
+
.split(',')
|
|
2304
|
+
.filter(Boolean)
|
|
2305
|
+
.map((token) => {
|
|
2306
|
+
const [id, dir] = token.split(':')
|
|
2307
|
+
return { id: id!, desc: dir === 'desc' }
|
|
2308
|
+
})
|
|
2309
|
+
const columns: Record<string, { operator: 'contains'; value: string }> = {}
|
|
2310
|
+
for (const [k, v] of sp) if (k.startsWith('f_') && v) columns[k.slice(2)] = { operator: 'contains', value: v }
|
|
2311
|
+
const global = sp.get('q') ?? ''
|
|
2312
|
+
return {
|
|
2313
|
+
startRow: pageIndex * pageSize,
|
|
2314
|
+
endRow: pageIndex * pageSize + pageSize,
|
|
2315
|
+
pageIndex,
|
|
2316
|
+
pageSize,
|
|
2317
|
+
sortModel,
|
|
2318
|
+
filterModel: { ...(global ? { global } : {}), columns },
|
|
2319
|
+
}
|
|
2320
|
+
}
|
|
2321
|
+
`
|
|
2322
|
+
|
|
2323
|
+
function ssrQueryHelperFile(): GeneratedFile {
|
|
2324
|
+
return { path: 'src/lib/server/query.ts', description: 'SSR: URL search params -> data-source request (sort/filter/page).', contents: SSR_QUERY_HELPER }
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
const htmlEsc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
2328
|
+
|
|
2329
|
+
/** The `+page.server.ts` + SSR `+page.svelte` for a single-grid CRUD screen. */
|
|
2330
|
+
function emitSsrGridScreen(schema: EntitySchema, screen: Screen, sourceKind: 'memory' | 'sql', accessEnabled: boolean, screenIds: string[], byName: Map<string, EntitySchema>): GeneratedFile[] {
|
|
2331
|
+
const n = namesFor(schema)
|
|
2332
|
+
const isSql = sourceKind === 'sql'
|
|
2333
|
+
const relSchemaOf = (f: EntityField) => byName.get(f.relation!.entity)!
|
|
2334
|
+
const relIdOf = (rs: EntitySchema) => rs.idField ?? rs.fields.find((x) => x.primaryKey)?.field ?? 'id'
|
|
2335
|
+
const grid = screen.blocks[0]!.config as GridConfig
|
|
2336
|
+
const pageSize = grid.pageSize && grid.pageSize > 0 ? grid.pageSize : 25
|
|
2337
|
+
const wantsFilter = grid.filterable !== false
|
|
2338
|
+
const idField = schema.idField ?? schema.fields.find((f) => f.primaryKey)?.field ?? 'id'
|
|
2339
|
+
const isFormHidden = (f: EntityField) => f.hidden === true || (typeof f.hidden === 'object' && !!f.hidden.form)
|
|
2340
|
+
const formFields = schema.fields.filter(
|
|
2341
|
+
(f) => f.field !== idField && !f.primaryKey && !f.readonly && !f.computed && !f.formula && !isFormHidden(f),
|
|
2342
|
+
)
|
|
2343
|
+
const normType = (t: string): 'text' | 'number' | 'boolean' => (t === 'number' ? 'number' : t === 'boolean' ? 'boolean' : 'text')
|
|
2344
|
+
const fieldTypesLit = `{ ${formFields.map((f) => `${jsStr(f.field)}: ${jsStr(normType(f.type))}`).join(', ')} }`
|
|
2345
|
+
|
|
2346
|
+
// Relation fields render as a native <select> whose options are prefetched in load
|
|
2347
|
+
// (memory path only for now; sql relation fields stay a text FK - follow-up).
|
|
2348
|
+
const isRel = (f: EntityField) => f.type === 'relation' && !!f.relation && byName.has(f.relation!.entity)
|
|
2349
|
+
const relFields = formFields.filter(isRel)
|
|
2350
|
+
// The $lib/data import only needs the related sources on the memory path; on sql
|
|
2351
|
+
// the related options come from the related entity's /api route (via event.fetch).
|
|
2352
|
+
const relSourceVars = isSql ? [] : [...new Set(relFields.map((f) => namesFor(relSchemaOf(f)).sourceVar))]
|
|
2353
|
+
const relPrefetch = relFields
|
|
2354
|
+
.map((f) => {
|
|
2355
|
+
const rs = relSchemaOf(f)
|
|
2356
|
+
const rel = namesFor(rs)
|
|
2357
|
+
const relId = relIdOf(rs)
|
|
2358
|
+
const labelF = f.relation!.labelField ?? relId
|
|
2359
|
+
const relSource = isSql
|
|
2360
|
+
? `createKitDataSource<Record<string, unknown>>({ endpoint: ${jsStr('/api/' + rel.route)}, fetch })`
|
|
2361
|
+
: rel.sourceVar
|
|
2362
|
+
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)}] ?? '') }))`
|
|
2363
|
+
})
|
|
2364
|
+
.join('\n')
|
|
2365
|
+
const relReturn = relFields.map((f) => `, ${f.field}Options`).join('')
|
|
2366
|
+
|
|
2367
|
+
// Source acquisition differs by kind:
|
|
2368
|
+
// - memory: import the in-process source from $lib/data and call it directly.
|
|
2369
|
+
// - sql: build a same-origin client over the connected /api/<entity> route with
|
|
2370
|
+
// SvelteKit's event.fetch, so validation / RBAC / triggers / audit stay enforced
|
|
2371
|
+
// once, in that route's createKitHandlers - not duplicated here.
|
|
2372
|
+
const src = isSql ? 'source(fetch)' : n.sourceVar
|
|
2373
|
+
const fetchArg = isSql ? ', fetch' : ''
|
|
2374
|
+
const enterpriseImports = isSql ? 'validateAll, createKitDataSource' : 'validateAll'
|
|
2375
|
+
const sourceImport = isSql ? '' : `import { ${[...new Set([n.sourceVar, ...relSourceVars]), 'nextId'].join(', ')} } from '$lib/data'\n`
|
|
2376
|
+
const schemaImport = `import { ${n.schemaVar}${isSql ? `, type ${n.type}` : ''} } from '$lib/schemas'`
|
|
2377
|
+
const idConst = isSql ? '' : `\nconst ID_FIELD = ${jsStr(idField)}`
|
|
2378
|
+
const srcHelper = isSql
|
|
2379
|
+
? `\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`
|
|
2380
|
+
: ''
|
|
2381
|
+
const createCall = isSql
|
|
2382
|
+
? `await ${src}.createRow(values)`
|
|
2383
|
+
: `await ${n.sourceVar}.createRow({ [ID_FIELD]: nextId(${jsStr(n.idPrefix)}), ...values })`
|
|
2384
|
+
|
|
2385
|
+
// RBAC only needs inline enforcement for the memory path; sql inherits it from the
|
|
2386
|
+
// connected /api route (createKitHandlers authorize), reached via event.fetch.
|
|
2387
|
+
const rbac = accessEnabled && !isSql
|
|
2388
|
+
const localsArg = rbac ? ', locals' : ''
|
|
2389
|
+
const readGuard = rbac ? ` if (!authorizeAction(getServerRole({ locals }), 'read', SCREEN_IDS)) throw error(403, 'Not allowed')\n` : ''
|
|
2390
|
+
const writeGuard = (action: string) => (rbac ? ` if (!authorizeAction(getServerRole({ locals }), '${action}', SCREEN_IDS)) return fail(403, { error: 'Not allowed' })\n` : '')
|
|
2391
|
+
|
|
2392
|
+
const server = `import type { Actions, PageServerLoad } from './$types'
|
|
2393
|
+
import { fail${rbac ? ', error' : ''} } from '@sveltejs/kit'
|
|
2394
|
+
import { ${enterpriseImports} } from '@svgrid/enterprise'
|
|
2395
|
+
${rbac ? "import { authorizeAction, getServerRole } from '$lib/access'\n" : ''}${sourceImport}${schemaImport}
|
|
2396
|
+
import { planFromSearchParams } from '$lib/server/query'
|
|
2397
|
+
|
|
2398
|
+
// The app shell is a client SPA (+layout.ts ssr=false); this screen opts back
|
|
2399
|
+
// INTO server rendering - real SSR HTML, not just a server-side load.
|
|
2400
|
+
export const ssr = true
|
|
2401
|
+
${idConst}${rbac ? `\nconst SCREEN_IDS = ${JSON.stringify(screenIds)}` : ''}
|
|
2402
|
+
const FIELD_TYPES: Record<string, 'text' | 'number' | 'boolean'> = ${fieldTypesLit}
|
|
2403
|
+
${srcHelper}
|
|
2404
|
+
/** Read a submitted form into a typed partial row. Booleans come from checkbox
|
|
2405
|
+
* presence; numbers are coerced; empty values are dropped so they don't clobber. */
|
|
2406
|
+
function formToValues(fd: FormData): Record<string, unknown> {
|
|
2407
|
+
const values: Record<string, unknown> = {}
|
|
2408
|
+
for (const [field, type] of Object.entries(FIELD_TYPES)) {
|
|
2409
|
+
if (type === 'boolean') { values[field] = fd.get(field) != null; continue }
|
|
2410
|
+
const raw = fd.get(field)
|
|
2411
|
+
if (raw == null || raw === '') continue
|
|
2412
|
+
values[field] = type === 'number' ? Number(raw) : String(raw)
|
|
2413
|
+
}
|
|
2414
|
+
return values
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2417
|
+
export const load: PageServerLoad = async ({ url${fetchArg}${localsArg} }) => {
|
|
2418
|
+
${readGuard} const plan = planFromSearchParams(url, ${pageSize})
|
|
2419
|
+
const { rows, rowCount } = await ${src}.getRows(plan)
|
|
2420
|
+
${relPrefetch ? relPrefetch + '\n' : ''} return { rows, total: rowCount, page: plan.pageIndex, size: plan.pageSize, sort: plan.sortModel${relReturn} }
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
export const actions: Actions = {
|
|
2424
|
+
create: async ({ request${fetchArg}${localsArg} }) => {
|
|
2425
|
+
${writeGuard('create')} const values = formToValues(await request.formData())
|
|
2426
|
+
const errors = await validateAll(${n.schemaVar}, values)
|
|
2427
|
+
if (Object.keys(errors).length) return fail(422, { errors, values })
|
|
2428
|
+
${createCall}
|
|
2429
|
+
return { ok: true }
|
|
2430
|
+
},
|
|
2431
|
+
update: async ({ request${fetchArg}${localsArg} }) => {
|
|
2432
|
+
const fd = await request.formData()
|
|
2433
|
+
${writeGuard('update')} const id = String(fd.get('__id') ?? '')
|
|
2434
|
+
const values = formToValues(fd)
|
|
2435
|
+
const errors = await validateAll(${n.schemaVar}, values)
|
|
2436
|
+
if (Object.keys(errors).length) return fail(422, { errors, values })
|
|
2437
|
+
await ${src}.updateRow(id, values)
|
|
2438
|
+
return { ok: true }
|
|
2439
|
+
},
|
|
2440
|
+
delete: async ({ request${fetchArg}${localsArg} }) => {
|
|
2441
|
+
${writeGuard('delete')} const fd = await request.formData()
|
|
2442
|
+
await ${src}.deleteRow(String(fd.get('__id') ?? ''))
|
|
2443
|
+
return { ok: true }
|
|
2444
|
+
},
|
|
2445
|
+
}
|
|
2446
|
+
`
|
|
2447
|
+
|
|
2448
|
+
const row = `(editing as Record<string, unknown>)`
|
|
2449
|
+
const fieldBlocks = formFields
|
|
2450
|
+
.map((f) => {
|
|
2451
|
+
const key = jsStr(f.field)
|
|
2452
|
+
const req = f.required ? ' required' : ''
|
|
2453
|
+
let input: string
|
|
2454
|
+
if (normType(f.type) === 'boolean') {
|
|
2455
|
+
input = `<input type="checkbox" name=${key} checked={!isCreate && !!${row}[${key}]} />`
|
|
2456
|
+
} else if (f.options && f.options.length) {
|
|
2457
|
+
const opts = f.options
|
|
2458
|
+
.map((o) => `<option value=${jsStr(String(o.value))} selected={!isCreate && ${row}[${key}] === ${jsStr(String(o.value))}}>${htmlEsc(o.label ?? String(o.value))}</option>`)
|
|
2459
|
+
.join('')
|
|
2460
|
+
input = `<select name=${key}${req}>${opts}</select>`
|
|
2461
|
+
} else if (isRel(f)) {
|
|
2462
|
+
// Relation: options prefetched in load (data.<field>Options), current FK selected.
|
|
2463
|
+
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>`
|
|
2464
|
+
} else {
|
|
2465
|
+
const t = normType(f.type) === 'number' ? 'number' : f.type === 'date' || f.type === 'dateString' ? 'date' : 'text'
|
|
2466
|
+
input = `<input type="${t}" name=${key} value={isCreate ? '' : (${row}[${key}] ?? '')}${req} />`
|
|
2467
|
+
}
|
|
2468
|
+
return ` <label class="sk-field">
|
|
2469
|
+
<span>${htmlEsc(f.label ?? f.field)}${f.required ? ' *' : ''}</span>
|
|
2470
|
+
${input}
|
|
2471
|
+
{#if form?.errors?.[${key}]}<em class="sk-err">{form.errors[${key}]}</em>{/if}
|
|
2472
|
+
</label>`
|
|
2473
|
+
})
|
|
2474
|
+
.join('\n')
|
|
2475
|
+
|
|
2476
|
+
const page = `<script lang="ts">
|
|
2477
|
+
import { SvGrid, renderSnippet, type ColumnDef, type CellContext } from '@svgrid/grid'
|
|
2478
|
+
import { schemaToColumns } from '@svgrid/enterprise'
|
|
2479
|
+
import { goto } from '$app/navigation'
|
|
2480
|
+
import { page } from '$app/state'
|
|
2481
|
+
import { enhance } from '$app/forms'
|
|
2482
|
+
import type { SubmitFunction } from '@sveltejs/kit'
|
|
2483
|
+
import { ${n.schemaVar}, type ${n.type} } from '$lib/schemas'
|
|
2484
|
+
import type { PageProps } from './$types'
|
|
2485
|
+
|
|
2486
|
+
let { data, form }: PageProps = $props()
|
|
2487
|
+
|
|
2488
|
+
const ID_FIELD = ${jsStr(idField)}
|
|
2489
|
+
const TITLE = ${jsStr(screen.title)}
|
|
2490
|
+
const NEW_LABEL = ${jsStr('New ' + n.label)}
|
|
2491
|
+
|
|
2492
|
+
// Grid columns from the schema + a row-actions column (Edit / Delete).
|
|
2493
|
+
const columns: ColumnDef<Record<string, never>, ${n.type}>[] = [
|
|
2494
|
+
...(schemaToColumns(${n.schemaVar}) as ColumnDef<Record<string, never>, ${n.type}>[]),
|
|
2495
|
+
{ id: '__actions', header: '', sortable: false, cell: (ctx: CellContext<${n.type}>) => renderSnippet(rowActions, { row: ctx.row.original }) },
|
|
2496
|
+
]
|
|
2497
|
+
|
|
2498
|
+
// null = no editor; 'create' = new row; a row = editing that row.
|
|
2499
|
+
let editing = $state<'create' | ${n.type} | null>(null)
|
|
2500
|
+
const isCreate = $derived(editing === 'create')
|
|
2501
|
+
|
|
2502
|
+
// Sort / paginate by writing to the URL - load() re-runs on the server.
|
|
2503
|
+
function setParams(patch: Record<string, string | null>) {
|
|
2504
|
+
const sp = new URLSearchParams(page.url.searchParams)
|
|
2505
|
+
for (const [k, v] of Object.entries(patch)) { if (v == null) sp.delete(k); else sp.set(k, v) }
|
|
2506
|
+
const q = sp.toString()
|
|
2507
|
+
void goto(q ? \`?\${q}\` : page.url.pathname, { keepFocus: true, noScroll: true })
|
|
2508
|
+
}
|
|
2509
|
+
${wantsFilter ? `
|
|
2510
|
+
// Filter via the URL (q = global search, f_<col> = per-column). The server's
|
|
2511
|
+
// planFromSearchParams reads these, so load() re-filters; reset to the first page.
|
|
2512
|
+
function applyFilters(f: { global: string; columns: Array<{ id: string; value: string }> }) {
|
|
2513
|
+
const sp = new URLSearchParams(page.url.searchParams)
|
|
2514
|
+
for (const k of [...sp.keys()]) if (k === 'q' || k.startsWith('f_')) sp.delete(k)
|
|
2515
|
+
if (f.global) sp.set('q', f.global)
|
|
2516
|
+
for (const c of f.columns) if (c.value) sp.set('f_' + c.id, c.value)
|
|
2517
|
+
sp.delete('page')
|
|
2518
|
+
const q = sp.toString()
|
|
2519
|
+
void goto(q ? \`?\${q}\` : page.url.pathname, { keepFocus: true, noScroll: true })
|
|
2520
|
+
}
|
|
2521
|
+
` : ''}
|
|
2522
|
+
// Progressive enhancement: post to the action, keep typed values on error,
|
|
2523
|
+
// close the editor on success (load re-runs automatically, refreshing the grid).
|
|
2524
|
+
const onSubmit: SubmitFunction = () => async ({ result, update }) => {
|
|
2525
|
+
await update({ reset: false })
|
|
2526
|
+
if (result.type === 'success') editing = null
|
|
2527
|
+
}
|
|
2528
|
+
</script>
|
|
2529
|
+
|
|
2530
|
+
{#snippet rowActions({ row }: { row: ${n.type} })}
|
|
2531
|
+
<div class="sk-rowact">
|
|
2532
|
+
<button type="button" class="sk-link" onclick={() => (editing = row)}>Edit</button>
|
|
2533
|
+
<form method="POST" action="?/delete" use:enhance={onSubmit} style="display:contents">
|
|
2534
|
+
<input type="hidden" name="__id" value={(row as Record<string, unknown>)[ID_FIELD] as string} />
|
|
2535
|
+
<button type="submit" class="sk-link sk-danger">Delete</button>
|
|
2536
|
+
</form>
|
|
2537
|
+
</div>
|
|
2538
|
+
{/snippet}
|
|
2539
|
+
|
|
2540
|
+
<header class="sk-head">
|
|
2541
|
+
<h1>{TITLE}</h1>
|
|
2542
|
+
<button type="button" class="sk-btn sk-btn--primary" onclick={() => (editing = 'create')}>{NEW_LABEL}</button>
|
|
2543
|
+
</header>
|
|
2544
|
+
|
|
2545
|
+
<SvGrid
|
|
2546
|
+
data={data.rows}
|
|
2547
|
+
{columns}
|
|
2548
|
+
externalSort
|
|
2549
|
+
initialSorting={data.sort}
|
|
2550
|
+
externalPagination${wantsFilter ? '\n filterable\n externalFilter\n onFiltersChange={applyFilters}' : ''}
|
|
2551
|
+
rowCount={data.total}
|
|
2552
|
+
pageIndex={data.page}
|
|
2553
|
+
pageSize={data.size}
|
|
2554
|
+
onSortingChange={(s) => setParams({ sort: s.map((x) => \`\${x.id}:\${x.desc ? 'desc' : 'asc'}\`).join(',') || null, page: null })}
|
|
2555
|
+
onPaginationChange={(p) => setParams({ page: String(p.pageIndex), size: String(p.pageSize) })}
|
|
2556
|
+
/>
|
|
2557
|
+
|
|
2558
|
+
{#if editing}
|
|
2559
|
+
<div class="sk-overlay">
|
|
2560
|
+
<form method="POST" action={isCreate ? '?/create' : '?/update'} class="sk-form" use:enhance={onSubmit}>
|
|
2561
|
+
<h2>{isCreate ? NEW_LABEL : 'Edit ${htmlEsc(n.label)}'}</h2>
|
|
2562
|
+
{#if !isCreate}<input type="hidden" name="__id" value={${row}[ID_FIELD] as string} />{/if}
|
|
2563
|
+
${fieldBlocks}
|
|
2564
|
+
<div class="sk-form__actions">
|
|
2565
|
+
<button type="button" class="sk-btn" onclick={() => (editing = null)}>Cancel</button>
|
|
2566
|
+
<button type="submit" class="sk-btn sk-btn--primary">Save</button>
|
|
2567
|
+
</div>
|
|
2568
|
+
</form>
|
|
2569
|
+
</div>
|
|
2570
|
+
{/if}
|
|
2571
|
+
|
|
2572
|
+
<style>
|
|
2573
|
+
.sk-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
|
|
2574
|
+
.sk-head h1 { margin: 0; font-size: 20px; }
|
|
2575
|
+
.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; }
|
|
2576
|
+
.sk-btn--primary { background: var(--sg-accent, #4f46e5); border-color: var(--sg-accent, #4f46e5); color: #fff; }
|
|
2577
|
+
.sk-rowact { display: flex; gap: 10px; }
|
|
2578
|
+
.sk-link { background: none; border: none; padding: 0; font: inherit; color: var(--sg-accent, #4f46e5); cursor: pointer; }
|
|
2579
|
+
.sk-danger { color: #dc2626; }
|
|
2580
|
+
.sk-overlay { position: fixed; inset: 0; background: rgba(15, 23, 42, 0.4); display: grid; place-items: center; z-index: 50; }
|
|
2581
|
+
.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); }
|
|
2582
|
+
.sk-form h2 { margin: 0 0 4px; font-size: 16px; }
|
|
2583
|
+
.sk-field { display: flex; flex-direction: column; gap: 4px; font-size: 13px; }
|
|
2584
|
+
.sk-field span { color: var(--sg-muted, #64748b); }
|
|
2585
|
+
.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); }
|
|
2586
|
+
.sk-field input[type='checkbox'] { align-self: flex-start; width: auto; }
|
|
2587
|
+
.sk-err { color: #dc2626; font-size: 12px; font-style: normal; }
|
|
2588
|
+
.sk-form__actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 6px; }
|
|
2589
|
+
</style>
|
|
2590
|
+
`
|
|
2591
|
+
|
|
2592
|
+
return [
|
|
2593
|
+
{ path: `src/routes/${screen.route}/+page.server.ts`, description: `SSR load + CRUD form actions for ${n.label}.`, contents: server },
|
|
2594
|
+
{ path: `src/routes/${screen.route}/+page.svelte`, description: `${n.label} screen (SSR + progressive enhancement).`, contents: page },
|
|
2595
|
+
]
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2022
2598
|
export function emitStudioProject(project: StudioProject): GeneratedFile[] {
|
|
2023
2599
|
if (project.entities.length === 0) throw new Error('emitStudioProject: no entities to emit')
|
|
2024
2600
|
if (project.screens.length === 0) throw new Error('emitStudioProject: no screens to emit')
|
|
@@ -2039,7 +2615,13 @@ export function emitStudioProject(project: StudioProject): GeneratedFile[] {
|
|
|
2039
2615
|
if (s.entity === undefined) continue // freestanding screen - gates no entity route
|
|
2040
2616
|
screensByEntity.set(s.entity, [...(screensByEntity.get(s.entity) ?? []), s.id])
|
|
2041
2617
|
}
|
|
2042
|
-
|
|
2618
|
+
// Multi-tenancy needs BOTH the session (to know the tenant) and the typed data
|
|
2619
|
+
// layer (so the column exists in the schema). Missing either, it degrades to
|
|
2620
|
+
// off rather than emitting a scope that reads a column nothing declares.
|
|
2621
|
+
const tenancyRequested = project.tenancy?.enabled === true
|
|
2622
|
+
const tenancyOn = tenancyRequested && authEnabled && project.dataLayer === 'drizzle' && Object.values(sources).some((s) => s.kind === 'sql')
|
|
2623
|
+
const tenantCol = tenancyOn ? tenantField(project) : undefined
|
|
2624
|
+
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', tenantField: tenantCol, tenantScoped: (name) => isTenantScoped(project, name) })
|
|
2043
2625
|
const byName = new Map(prepared.map((s) => [s.name, s]))
|
|
2044
2626
|
// Raw (unprepared) entities keep their original field set - needed to derive
|
|
2045
2627
|
// relation display-field names that match withRelationLabels (the prepared
|
|
@@ -2083,8 +2665,25 @@ export function emitStudioProject(project: StudioProject): GeneratedFile[] {
|
|
|
2083
2665
|
}
|
|
2084
2666
|
const schema = byName.get(screen.entity)
|
|
2085
2667
|
if (!schema) throw new Error(`emitStudioProject: screen "${screen.title}" references missing entity "${screen.entity}"`)
|
|
2086
|
-
|
|
2668
|
+
// SSR-native path (opt-in): a grid screen gets load + form actions + URL-
|
|
2669
|
+
// driven sort/filter/page; a read-only screen (data-viz / detail) gets a
|
|
2670
|
+
// load-only server file, and the SAME page markup renders from data.*.
|
|
2671
|
+
if (isSsrScreen(project, screen)) {
|
|
2672
|
+
const srcKind = sources[screen.entity]?.kind === 'sql' ? 'sql' : 'memory'
|
|
2673
|
+
if (ssrScreenShape(project, screen) === 'grid') {
|
|
2674
|
+
pages.push(...emitSsrGridScreen(schema, screen, srcKind, accessEnabled, screensByEntity.get(screen.entity) ?? [], byName))
|
|
2675
|
+
} else {
|
|
2676
|
+
pages.push(
|
|
2677
|
+
ssrReadServerFile(schema, screen, srcKind, accessEnabled, screensByEntity.get(screen.entity) ?? [], resolve),
|
|
2678
|
+
screenPage(schema, rawByName.get(screen.entity) ?? schema, screen, resolve, (name) => rawByName.get(name), accessEnabled, i18nEnabled, routeById, drillEnabled, true),
|
|
2679
|
+
)
|
|
2680
|
+
}
|
|
2681
|
+
continue
|
|
2682
|
+
}
|
|
2683
|
+
pages.push(screenPage(schema, rawByName.get(screen.entity) ?? schema, screen, resolve, (name) => rawByName.get(name), accessEnabled, i18nEnabled, routeById, drillEnabled, false, sources[screen.entity]))
|
|
2087
2684
|
}
|
|
2685
|
+
// Any SSR screen needs the shared URL-params -> query helper (server-only).
|
|
2686
|
+
const ssrHelpers = project.screens.some((s) => isSsrScreen(project, s)) ? [ssrQueryHelperFile()] : []
|
|
2088
2687
|
const actionRouteFiles = [...actionsById.values()].map(({ action, screenId }) => actionRouteFile(action, screenId, accessEnabled))
|
|
2089
2688
|
|
|
2090
2689
|
// Nav: only screens flagged into the menu, ordered, with an optional custom label.
|
|
@@ -2099,21 +2698,39 @@ export function emitStudioProject(project: StudioProject): GeneratedFile[] {
|
|
|
2099
2698
|
// auth requires that active layer, so its user store can live in the same schema.
|
|
2100
2699
|
const firstSqlSrc = sqlEntities.length ? sources[sqlEntities[0]!.name] : undefined
|
|
2101
2700
|
const dataLayerActive = project.dataLayer === 'drizzle' && sqlEntities.length > 0 && dzDialect(firstSqlSrc?.kind === 'sql' ? firstSqlSrc.dialect : undefined) !== null
|
|
2102
|
-
|
|
2701
|
+
// Supabase Auth: a client-side SvAuthGate over the shared Supabase client, in place
|
|
2702
|
+
// of the builtin cookie-session scaffold. Mutually exclusive with the builtin
|
|
2703
|
+
// provider, and it never uses the DB-backed user store.
|
|
2704
|
+
const supabaseAuth = authEnabled && project.auth?.provider === 'supabase'
|
|
2705
|
+
const dbBackedAuth = authEnabled && !supabaseAuth && dataLayerActive
|
|
2103
2706
|
const authRegister = dbBackedAuth && project.auth?.register === true
|
|
2104
2707
|
const authUserAdmin = dbBackedAuth && accessEnabled && project.auth?.userAdmin === true
|
|
2105
2708
|
const authTwoFactor = dbBackedAuth && project.auth?.twoFactor === true
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
const
|
|
2709
|
+
// The audit trail persists to a real table whenever the typed layer is active;
|
|
2710
|
+
// without it there is nowhere to put one, so the store stays in-memory.
|
|
2711
|
+
const auditPersisted = project.audit === true && dataLayerActive
|
|
2712
|
+
const dataLayerList = project.dataLayer === 'drizzle' && sqlEntities.length > 0 ? dataLayerFiles(project, sqlEntities, sources, dbBackedAuth, authTwoFactor, auditPersisted, tenantCol) : []
|
|
2713
|
+
// Without the Drizzle layer (or on MSSQL, which it can't cover), ship plain SQL the
|
|
2714
|
+
// user runs once against their database - otherwise the tables must pre-exist.
|
|
2715
|
+
const ddlFiles = sqlEntities.length > 0 && !dataLayerActive ? sqlDdlFiles(project.entities, sources) : []
|
|
2716
|
+
// The builtin cookie-session auth files (hooks.server, /login, session store, ...)
|
|
2717
|
+
// are skipped for the Supabase provider - SvAuthGate replaces them.
|
|
2718
|
+
const authFileList = authEnabled && !supabaseAuth ? authFiles(project, dbBackedAuth, accessEnabled, tenantCol) : []
|
|
2719
|
+
const auditFiles = auditEnabled ? [auditModule(auditPersisted), auditRouteFile(), auditViewerPage()] : []
|
|
2720
|
+
// Scheduled jobs. `emailReal` gates the email kind the same way the auth flows
|
|
2721
|
+
// gate theirs - an email job without the email layer emits a warning slot
|
|
2722
|
+
// rather than an import that would not resolve.
|
|
2723
|
+
const jobList = (project.jobs ?? []).filter((j) => j.id && j.cron)
|
|
2724
|
+
const jobFileList = jobList.length ? jobsFiles(project, jobList, project.auth?.email === true) : []
|
|
2725
|
+
const tenantFileList = tenantCol ? [tenantModule(tenantCol)] : []
|
|
2109
2726
|
// Routes that render bare (no shell) + skip the login guard.
|
|
2110
|
-
const publicAuthRoutes = authEnabled ? ['/login', ...(authTwoFactor ? ['/login/verify'] : []), ...(authRegister ? ['/register', '/forgot-password', '/reset-password'] : [])] : []
|
|
2727
|
+
const publicAuthRoutes = authEnabled && !supabaseAuth ? ['/login', ...(authTwoFactor ? ['/login/verify'] : []), ...(authRegister ? ['/register', '/forgot-password', '/reset-password'] : [])] : []
|
|
2111
2728
|
let navExtras = auditEnabled ? [...nav, { href: '/audit', label: 'Audit log', id: '__audit__' }] : nav
|
|
2112
2729
|
// The admin Users screen is nav-gated by canScreen('__users__') - only full-access ('*') roles see it.
|
|
2113
2730
|
if (authUserAdmin) navExtras = [...navExtras, { href: '/users', label: 'Users', id: '__users__' }]
|
|
2114
2731
|
const i18nFiles = i18nEnabled ? [i18nModule(project)] : []
|
|
2115
2732
|
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)]
|
|
2733
|
+
return [...files, ...accessFiles, ...authFileList, ...dataLayerList, ...ddlFiles, ...auditFiles, ...jobFileList, ...tenantFileList, ...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
2734
|
}
|
|
2118
2735
|
|
|
2119
2736
|
/** The default-locale (`en`) message catalog, keyed for nav, screen titles, the
|
|
@@ -2142,7 +2759,14 @@ function i18nModule(project: StudioProject): GeneratedFile {
|
|
|
2142
2759
|
const en = buildMessages(project)
|
|
2143
2760
|
const localeUnion = locales.map((l) => JSON.stringify(l)).join(' | ')
|
|
2144
2761
|
const seeded = JSON.stringify(en, null, 2).replace(/\n/g, '\n ')
|
|
2145
|
-
|
|
2762
|
+
// Every locale is seeded with the SAME keys, not just the default. An empty
|
|
2763
|
+
// `{}` left the translator to discover the key names by reading the codegen;
|
|
2764
|
+
// seeding means the work is "translate these values in place". Values start as
|
|
2765
|
+
// the default-locale copy, so an untranslated app reads correctly rather than
|
|
2766
|
+
// falling back to raw keys.
|
|
2767
|
+
const messagesEntries = locales
|
|
2768
|
+
.map((l) => ` ${JSON.stringify(l)}: ${seeded},${l === def ? '' : ' // TODO: translate'}`)
|
|
2769
|
+
.join('\n')
|
|
2146
2770
|
return {
|
|
2147
2771
|
path: 'src/lib/i18n.ts',
|
|
2148
2772
|
description: 'Localization: locales, the current-locale store, the message catalog, and t() / localizeCols helpers.',
|
|
@@ -2152,8 +2776,9 @@ export type Locale = ${localeUnion}
|
|
|
2152
2776
|
export const locales: Locale[] = ${JSON.stringify(locales)} as Locale[]
|
|
2153
2777
|
export const currentLocale = writable<Locale>(${JSON.stringify(def)})
|
|
2154
2778
|
|
|
2155
|
-
//
|
|
2156
|
-
//
|
|
2779
|
+
// Seeded from your schema + screen labels. Every locale starts with the same
|
|
2780
|
+
// keys and the default-locale text, so translating is editing values in place -
|
|
2781
|
+
// no key hunting. Anything you delete falls back to the default locale.
|
|
2157
2782
|
const messages: Record<Locale, Record<string, string>> = {
|
|
2158
2783
|
${messagesEntries}
|
|
2159
2784
|
}
|
|
@@ -2172,12 +2797,18 @@ export function localizeCols<T extends { field?: string | number; header?: strin
|
|
|
2172
2797
|
}
|
|
2173
2798
|
}
|
|
2174
2799
|
|
|
2175
|
-
/** The audit store:
|
|
2176
|
-
*
|
|
2177
|
-
|
|
2800
|
+
/** The audit store: the `AuditEntry` schema, a source, and the `recordAudit`
|
|
2801
|
+
* writer the API routes call.
|
|
2802
|
+
*
|
|
2803
|
+
* With the typed data layer active the trail is written to a real `audit_log`
|
|
2804
|
+
* table, so it survives a restart. Without one there is nowhere to put it and
|
|
2805
|
+
* it falls back to an in-memory source (fine for a demo, useless as an audit
|
|
2806
|
+
* trail - the emitted comment says so). */
|
|
2807
|
+
function auditModule(persisted = false): GeneratedFile {
|
|
2808
|
+
if (persisted) return auditModulePersisted()
|
|
2178
2809
|
return {
|
|
2179
2810
|
path: 'src/lib/audit.ts',
|
|
2180
|
-
description: 'Audit trail store: the AuditEntry schema, an in-memory source, and recordAudit().
|
|
2811
|
+
description: 'Audit trail store: the AuditEntry schema, an in-memory source, and recordAudit(). NOT persisted - enable the typed data layer to write to a real table.',
|
|
2181
2812
|
contents: `import { createInMemoryDataSource } from '@svgrid/enterprise'
|
|
2182
2813
|
import type { EntitySchema } from '@svgrid/enterprise'
|
|
2183
2814
|
|
|
@@ -2236,6 +2867,248 @@ export async function recordAudit(input: {
|
|
|
2236
2867
|
}
|
|
2237
2868
|
}
|
|
2238
2869
|
|
|
2870
|
+
/** The DB-backed audit store (typed data layer active): writes to `audit_log`
|
|
2871
|
+
* via Drizzle, so the trail survives a restart and records before/after values
|
|
2872
|
+
* rather than just which field names changed. */
|
|
2873
|
+
function auditModulePersisted(): GeneratedFile {
|
|
2874
|
+
return {
|
|
2875
|
+
path: 'src/lib/audit.ts',
|
|
2876
|
+
description: 'Audit trail: the AuditEntry schema, a Drizzle-backed source over audit_log, and recordAudit() with before/after snapshots.',
|
|
2877
|
+
contents: `import { count, desc } from 'drizzle-orm'
|
|
2878
|
+
import type { EntitySchema } from '@svgrid/enterprise'
|
|
2879
|
+
import { db } from '$lib/server/db'
|
|
2880
|
+
import { auditLog } from '$lib/server/db/schema'
|
|
2881
|
+
|
|
2882
|
+
export type AuditEntry = {
|
|
2883
|
+
id: string
|
|
2884
|
+
at: string
|
|
2885
|
+
actor: string
|
|
2886
|
+
entity: string
|
|
2887
|
+
action: 'create' | 'update' | 'delete'
|
|
2888
|
+
recordId: string
|
|
2889
|
+
summary: string
|
|
2890
|
+
/** JSON snapshot of the row before the change (update / delete). */
|
|
2891
|
+
before?: string | null
|
|
2892
|
+
/** JSON snapshot of the row after it (create / update). */
|
|
2893
|
+
after?: string | null
|
|
2894
|
+
}
|
|
2895
|
+
|
|
2896
|
+
export const auditSchema: EntitySchema<AuditEntry> = {
|
|
2897
|
+
name: 'audit',
|
|
2898
|
+
idField: 'id',
|
|
2899
|
+
fields: [
|
|
2900
|
+
{ field: 'id', type: 'text', primaryKey: true, readonly: true },
|
|
2901
|
+
{ field: 'at', type: 'datetime', label: 'When' },
|
|
2902
|
+
{ field: 'actor', type: 'text', label: 'Actor' },
|
|
2903
|
+
{ field: 'entity', type: 'text', label: 'Entity' },
|
|
2904
|
+
{ field: 'action', type: 'enum', label: 'Action', options: [{ value: 'create', label: 'Create' }, { value: 'update', label: 'Update' }, { value: 'delete', label: 'Delete' }] },
|
|
2905
|
+
{ field: 'recordId', type: 'text', label: 'Record' },
|
|
2906
|
+
{ field: 'summary', type: 'text', label: 'Summary' },
|
|
2907
|
+
{ field: 'before', type: 'json', label: 'Before', readonly: true },
|
|
2908
|
+
{ field: 'after', type: 'json', label: 'After', readonly: true },
|
|
2909
|
+
],
|
|
2910
|
+
}
|
|
2911
|
+
|
|
2912
|
+
const toEntry = (r: typeof auditLog.$inferSelect): AuditEntry => ({
|
|
2913
|
+
id: String(r.id),
|
|
2914
|
+
at: typeof r.at === 'string' ? r.at : new Date(r.at as unknown as Date).toISOString(),
|
|
2915
|
+
actor: r.actor,
|
|
2916
|
+
entity: r.entity,
|
|
2917
|
+
action: r.action as AuditEntry['action'],
|
|
2918
|
+
recordId: r.recordId,
|
|
2919
|
+
summary: r.summary,
|
|
2920
|
+
before: r.before ?? null,
|
|
2921
|
+
after: r.after ?? null,
|
|
2922
|
+
})
|
|
2923
|
+
|
|
2924
|
+
/** Read-only \\\`ServerDataSource\\\` for the /audit viewer. Newest first, paged in
|
|
2925
|
+
* the database so a long trail doesn't load in one go. No write methods: the
|
|
2926
|
+
* trail is append-only through \\\`recordAudit\\\`. */
|
|
2927
|
+
export const auditSource = {
|
|
2928
|
+
async getRows(request: { startRow?: number; endRow?: number }) {
|
|
2929
|
+
const start = request.startRow ?? 0
|
|
2930
|
+
const limit = Math.max(1, (request.endRow ?? start + 25) - start)
|
|
2931
|
+
const [rows, counted] = await Promise.all([
|
|
2932
|
+
db.select().from(auditLog).orderBy(desc(auditLog.at)).limit(limit).offset(start),
|
|
2933
|
+
db.select({ n: count() }).from(auditLog),
|
|
2934
|
+
])
|
|
2935
|
+
return { rows: rows.map(toEntry), rowCount: Number(counted.at(0)?.n ?? 0) }
|
|
2936
|
+
},
|
|
2937
|
+
}
|
|
2938
|
+
|
|
2939
|
+
/** Append one change record. Called by the API routes' \\\`audit\\\` hook. */
|
|
2940
|
+
export async function recordAudit(input: {
|
|
2941
|
+
entity: string
|
|
2942
|
+
action: 'create' | 'update' | 'delete'
|
|
2943
|
+
recordId: string | null
|
|
2944
|
+
values?: Record<string, unknown>
|
|
2945
|
+
before?: Record<string, unknown> | null
|
|
2946
|
+
actor?: string
|
|
2947
|
+
}): Promise<void> {
|
|
2948
|
+
const summary =
|
|
2949
|
+
input.action === 'delete'
|
|
2950
|
+
? \`Deleted \${input.entity} \${input.recordId ?? ''}\`.trim()
|
|
2951
|
+
: \`\${input.action === 'create' ? 'Created' : 'Updated'} \${input.entity}\${input.values ? ' (' + Object.keys(input.values).join(', ') + ')' : ''}\`
|
|
2952
|
+
await db.insert(auditLog).values({
|
|
2953
|
+
at: new Date().toISOString(),
|
|
2954
|
+
actor: input.actor ?? 'system',
|
|
2955
|
+
entity: input.entity,
|
|
2956
|
+
action: input.action,
|
|
2957
|
+
recordId: input.recordId ?? '',
|
|
2958
|
+
summary,
|
|
2959
|
+
before: input.before ? JSON.stringify(input.before) : null,
|
|
2960
|
+
after: input.values ? JSON.stringify(input.values) : null,
|
|
2961
|
+
} as typeof auditLog.$inferInsert)
|
|
2962
|
+
}
|
|
2963
|
+
`,
|
|
2964
|
+
}
|
|
2965
|
+
}
|
|
2966
|
+
|
|
2967
|
+
/**
|
|
2968
|
+
* The tenant resolver: reads the signed-in user's tenant off the session.
|
|
2969
|
+
*
|
|
2970
|
+
* `requireTenant` THROWS when there is no tenant, and the transport turns a
|
|
2971
|
+
* thrown scope resolver into a 403 - so an unauthenticated or tenant-less
|
|
2972
|
+
* request fails closed instead of silently querying every tenant's rows.
|
|
2973
|
+
*/
|
|
2974
|
+
function tenantModule(field: string): GeneratedFile {
|
|
2975
|
+
return {
|
|
2976
|
+
path: 'src/lib/server/tenant.ts',
|
|
2977
|
+
description: 'Resolve the caller\'s tenant from the session; used to scope every API route.',
|
|
2978
|
+
contents: `// Regenerated by SvGrid Studio. Multi-tenancy: which tenant is calling?
|
|
2979
|
+
//
|
|
2980
|
+
// The tenant is carried on the session (see hooks.server.ts / auth.ts) and
|
|
2981
|
+
// stamped onto \`event.locals\`. Every scoped API route calls requireTenant().
|
|
2982
|
+
|
|
2983
|
+
export type TenantEvent = { locals?: Record<string, unknown> }
|
|
2984
|
+
|
|
2985
|
+
/** The caller's tenant id, or null when there is none. */
|
|
2986
|
+
export function getTenant(event: TenantEvent): string | null {
|
|
2987
|
+
const t = event.locals?.${field}
|
|
2988
|
+
return t == null || t === '' ? null : String(t)
|
|
2989
|
+
}
|
|
2990
|
+
|
|
2991
|
+
/**
|
|
2992
|
+
* The caller's tenant id, or THROW.
|
|
2993
|
+
*
|
|
2994
|
+
* Throwing is the point: the route's \`scope\` turns it into a 403. Returning
|
|
2995
|
+
* null here would let the query run unscoped, which is the one failure mode
|
|
2996
|
+
* multi-tenancy cannot have.
|
|
2997
|
+
*/
|
|
2998
|
+
export function requireTenant(event: TenantEvent): string {
|
|
2999
|
+
const t = getTenant(event)
|
|
3000
|
+
if (!t) throw new Error('No tenant on the session - sign in again.')
|
|
3001
|
+
return t
|
|
3002
|
+
}
|
|
3003
|
+
`,
|
|
3004
|
+
}
|
|
3005
|
+
}
|
|
3006
|
+
|
|
3007
|
+
/**
|
|
3008
|
+
* Scheduled jobs: the handler registry + the guarded `/api/cron` route.
|
|
3009
|
+
*
|
|
3010
|
+
* Server-side, unlike `@svgrid/enterprise`'s `createScheduler`, which only ticks
|
|
3011
|
+
* while a browser tab is open. The platform's scheduler calls the route; the
|
|
3012
|
+
* route checks `CRON_SECRET` and runs the due handlers.
|
|
3013
|
+
*/
|
|
3014
|
+
function jobsFiles(project: StudioProject, jobs: ScheduledJob[], emailAvailable: boolean): GeneratedFile[] {
|
|
3015
|
+
const handler = (j: ScheduledJob): string => {
|
|
3016
|
+
const label = jsStr(j.name)
|
|
3017
|
+
if (j.kind === 'email') {
|
|
3018
|
+
const ent = project.entities.find((e) => e.name === j.entity)
|
|
3019
|
+
const to = jsStr(j.to ?? '')
|
|
3020
|
+
const subject = jsStr(j.subject ?? j.name)
|
|
3021
|
+
if (!ent || !emailAvailable || !j.to) {
|
|
3022
|
+
// Emit the slot anyway so the schedule is real and the gap is obvious,
|
|
3023
|
+
// rather than silently dropping the job.
|
|
3024
|
+
const why = !emailAvailable ? 'email is not enabled on this project' : !j.to ? 'no recipient set' : `unknown entity ${j.entity}`
|
|
3025
|
+
return ` ${JSON.stringify(j.id)}: async () => {\n // ${label}: cannot send - ${why}.\n console.warn('cron ${j.id}: skipped (${why})')\n },`
|
|
3026
|
+
}
|
|
3027
|
+
const n = namesFor(ent)
|
|
3028
|
+
return ` ${JSON.stringify(j.id)}: async () => {
|
|
3029
|
+
const { rows, rowCount } = await ${n.sourceVar}.getRows({ startRow: 0, endRow: 10, sortModel: [], filterModel: {} })
|
|
3030
|
+
const items = rows.map((r) => '<li>' + Object.values(r).slice(0, 3).map(String).join(' · ') + '</li>').join('')
|
|
3031
|
+
await sendEmail(${to}, ${subject}, '<p>' + rowCount + ' ${ent.name} total. Most recent:</p><ul>' + items + '</ul>')
|
|
3032
|
+
},`
|
|
3033
|
+
}
|
|
3034
|
+
const body = (j.code ?? '').trim() || `console.log('cron ${j.id}: no body yet')`
|
|
3035
|
+
return ` ${JSON.stringify(j.id)}: async () => {\n${body.split('\n').map((l) => ' ' + l).join('\n')}\n },`
|
|
3036
|
+
}
|
|
3037
|
+
|
|
3038
|
+
const entityImports = new Set<string>()
|
|
3039
|
+
for (const j of jobs) {
|
|
3040
|
+
if (j.kind !== 'email') continue
|
|
3041
|
+
const ent = project.entities.find((e) => e.name === j.entity)
|
|
3042
|
+
if (ent && emailAvailable && j.to) entityImports.add(namesFor(ent).sourceVar)
|
|
3043
|
+
}
|
|
3044
|
+
const imports = [
|
|
3045
|
+
...(entityImports.size ? [`import { ${[...entityImports].sort().join(', ')} } from '$lib/data'`] : []),
|
|
3046
|
+
...(jobs.some((j) => j.kind === 'email') && emailAvailable ? [`import { sendEmail } from '$lib/server/email'`] : []),
|
|
3047
|
+
]
|
|
3048
|
+
|
|
3049
|
+
const table = jobs.map((j) => ` * ${j.id.padEnd(20)} ${j.cron.padEnd(16)} ${j.name}`).join('\n')
|
|
3050
|
+
const jobsTs = `// Regenerated by SvGrid Studio. Scheduled job handlers.
|
|
3051
|
+
//
|
|
3052
|
+
// Schedule (UTC):
|
|
3053
|
+
${table}
|
|
3054
|
+
//
|
|
3055
|
+
// Runs on the server, triggered by /api/cron - NOT in the browser. Edit a
|
|
3056
|
+
// handler body freely; the registry keys are what /api/cron dispatches on.
|
|
3057
|
+
${imports.join('\n')}
|
|
3058
|
+
|
|
3059
|
+
export const jobs: Record<string, () => Promise<void>> = {
|
|
3060
|
+
${jobs.map(handler).join('\n')}
|
|
3061
|
+
}
|
|
3062
|
+
|
|
3063
|
+
/** Job ids that are switched on. /api/cron without ?job= runs exactly these. */
|
|
3064
|
+
export const enabledJobs: string[] = ${JSON.stringify(jobs.filter((j) => j.enabled !== false).map((j) => j.id))}
|
|
3065
|
+
`
|
|
3066
|
+
|
|
3067
|
+
const routeTs = `import { json, error } from '@sveltejs/kit'
|
|
3068
|
+
import { env } from '$env/dynamic/private'
|
|
3069
|
+
import type { RequestHandler } from './$types'
|
|
3070
|
+
import { jobs, enabledJobs } from '$lib/server/jobs'
|
|
3071
|
+
|
|
3072
|
+
/**
|
|
3073
|
+
* Scheduled-job endpoint. Your platform's scheduler calls this (Vercel Cron, a
|
|
3074
|
+
* GitHub Actions schedule, or a crontab running \`curl\`). See DEPLOY.md.
|
|
3075
|
+
*
|
|
3076
|
+
* Guarded by CRON_SECRET: send it as \`Authorization: Bearer <secret>\` or
|
|
3077
|
+
* \`?secret=<secret>\`. With no CRON_SECRET set the route refuses to run rather
|
|
3078
|
+
* than leaving a public "do work" URL open.
|
|
3079
|
+
*/
|
|
3080
|
+
const authorized = (request: Request, url: URL): boolean => {
|
|
3081
|
+
const secret = env.CRON_SECRET
|
|
3082
|
+
if (!secret) return false
|
|
3083
|
+
const header = request.headers.get('authorization')
|
|
3084
|
+
return header === 'Bearer ' + secret || url.searchParams.get('secret') === secret
|
|
3085
|
+
}
|
|
3086
|
+
|
|
3087
|
+
const run: RequestHandler = async ({ request, url }) => {
|
|
3088
|
+
if (!authorized(request, url)) throw error(401, 'cron: missing or invalid CRON_SECRET')
|
|
3089
|
+
const only = url.searchParams.get('job')
|
|
3090
|
+
const ids = only ? [only] : enabledJobs
|
|
3091
|
+
const results: Array<{ job: string; ok: boolean; error?: string }> = []
|
|
3092
|
+
for (const id of ids) {
|
|
3093
|
+
const fn = jobs[id]
|
|
3094
|
+
if (!fn) { results.push({ job: id, ok: false, error: 'unknown job' }); continue }
|
|
3095
|
+
// One failing job must not stop the rest of the run.
|
|
3096
|
+
try { await fn(); results.push({ job: id, ok: true }) }
|
|
3097
|
+
catch (err) { results.push({ job: id, ok: false, error: err instanceof Error ? err.message : String(err) }) }
|
|
3098
|
+
}
|
|
3099
|
+
return json({ ran: results.length, results })
|
|
3100
|
+
}
|
|
3101
|
+
|
|
3102
|
+
export const GET = run
|
|
3103
|
+
export const POST = run
|
|
3104
|
+
`
|
|
3105
|
+
|
|
3106
|
+
return [
|
|
3107
|
+
{ path: 'src/lib/server/jobs.ts', description: 'Scheduled job handlers, keyed by job id.', contents: jobsTs },
|
|
3108
|
+
{ path: 'src/routes/api/cron/+server.ts', description: 'Guarded cron endpoint that runs the scheduled jobs.', contents: routeTs },
|
|
3109
|
+
]
|
|
3110
|
+
}
|
|
3111
|
+
|
|
2239
3112
|
/** The read API for the audit trail (the /audit viewer reads it via the transport). */
|
|
2240
3113
|
function auditRouteFile(): GeneratedFile {
|
|
2241
3114
|
return {
|
|
@@ -2358,7 +3231,7 @@ export function authorizeAction(role: AppRole, action: 'read' | WriteAction, scr
|
|
|
2358
3231
|
* `hooks.server.ts` that resolves the caller into `event.locals.role`/`user` (the loop
|
|
2359
3232
|
* the RBAC layer already expects), a `/login` page + sign-out, and the `App.Locals`
|
|
2360
3233
|
* type augmentation. Works across every data source (no DB required for the demo). */
|
|
2361
|
-
function authFiles(project: StudioProject, dbBacked = false, accessEnabled = false): GeneratedFile[] {
|
|
3234
|
+
function authFiles(project: StudioProject, dbBacked = false, accessEnabled = false, tenantColumn?: string): GeneratedFile[] {
|
|
2362
3235
|
const users = seedUsers(project)
|
|
2363
3236
|
const demo = users[0]!
|
|
2364
3237
|
const protect = project.auth?.protect !== false
|
|
@@ -2590,15 +3463,24 @@ export async function setTwoFactor(email: string, on: boolean): Promise<void> {
|
|
|
2590
3463
|
}` : ''}
|
|
2591
3464
|
`
|
|
2592
3465
|
: `// 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
|
-
|
|
3466
|
+
// an external identity provider, ...). The seed passwords below are demo defaults
|
|
3467
|
+
// (printed on the login page); they are HASHED (PBKDF2) at startup and never compared
|
|
3468
|
+
// in plaintext. Change them for anything real. Tip: turn on the Drizzle data layer and
|
|
3469
|
+
// this becomes a real \`auth_users\` DB table.
|
|
3470
|
+
import { hashPassword } from './auth'
|
|
2597
3471
|
|
|
2598
|
-
export
|
|
3472
|
+
export type AppUser = { email: string; name: string; role: string; passwordHash: string }
|
|
3473
|
+
|
|
3474
|
+
const SEED: Array<{ email: string; name: string; role: string; password: string }> = [
|
|
2599
3475
|
${usersLiteral},
|
|
2600
3476
|
]
|
|
2601
3477
|
|
|
3478
|
+
// Hash the seed passwords once at module load (top-level await) so the store holds
|
|
3479
|
+
// only PBKDF2 hashes - login verifies with verifyPassword(), never a plaintext compare.
|
|
3480
|
+
export const USERS: AppUser[] = await Promise.all(
|
|
3481
|
+
SEED.map(async (u) => ({ email: u.email, name: u.name, role: u.role, passwordHash: await hashPassword(u.password) })),
|
|
3482
|
+
)
|
|
3483
|
+
|
|
2602
3484
|
export function findUser(email: string): AppUser | undefined {
|
|
2603
3485
|
const e = email.trim().toLowerCase()
|
|
2604
3486
|
return USERS.find((u) => u.email.toLowerCase() === e)
|
|
@@ -2613,7 +3495,10 @@ import { SESSION_COOKIE, readSession } from '$lib/server/auth'
|
|
|
2613
3495
|
export const handle: Handle = async ({ event, resolve }) => {
|
|
2614
3496
|
const user = await readSession(event.cookies.get(SESSION_COOKIE))
|
|
2615
3497
|
event.locals.user = user ?? undefined
|
|
2616
|
-
event.locals.role = user?.role
|
|
3498
|
+
event.locals.role = user?.role${tenantColumn ? `
|
|
3499
|
+
// Multi-tenancy: the tenant travels on the session, so every scoped API route
|
|
3500
|
+
// reads it from here rather than trusting anything the client sends.
|
|
3501
|
+
event.locals.${tenantColumn} = (user as { ${tenantColumn}?: string } | null | undefined)?.${tenantColumn}` : ''}
|
|
2617
3502
|
return resolve(event)
|
|
2618
3503
|
}
|
|
2619
3504
|
`
|
|
@@ -2637,7 +3522,7 @@ export {}
|
|
|
2637
3522
|
const layoutServerTs = `import type { LayoutServerLoad } from './$types'
|
|
2638
3523
|
${protect ? `import { redirect } from '@sveltejs/kit'\n\nconst PUBLIC = new Set(${JSON.stringify(publicRoutes)})\n` : ''}
|
|
2639
3524
|
// Expose the signed-in user + role to every page (read as \`data.user\` / \`data.role\`,
|
|
2640
|
-
// or
|
|
3525
|
+
// or \`page.data\`).${protect ? ' Unauthenticated visitors are sent to /login.' : ''}
|
|
2641
3526
|
export const load: LayoutServerLoad = async ({ locals${protect ? ', url' : ''} }) => {
|
|
2642
3527
|
${protect ? " if (!locals.user && !PUBLIC.has(url.pathname)) throw redirect(302, '/login?redirectTo=' + encodeURIComponent(url.pathname))\n" : ''} return { user: locals.user ?? null, role: locals.role ?? null }
|
|
2643
3528
|
}
|
|
@@ -2648,9 +3533,8 @@ ${protect ? " if (!locals.user && !PUBLIC.has(url.pathname)) throw redirect(302
|
|
|
2648
3533
|
// DB-backed store: verify against the stored PBKDF2 hash.
|
|
2649
3534
|
if (!user || !(await verifyPassword(password, user.passwordHash))) return fail(401, { email, error: 'Invalid email or password.' })`
|
|
2650
3535
|
: `const user = findUser(email)
|
|
2651
|
-
//
|
|
2652
|
-
|
|
2653
|
-
if (!user || user.password !== password) return fail(401, { email, error: 'Invalid email or password.' })`
|
|
3536
|
+
// Verify against the PBKDF2 hash of the seed (users store hashes it at startup).
|
|
3537
|
+
if (!user || !(await verifyPassword(password, user.passwordHash))) return fail(401, { email, error: 'Invalid email or password.' })`
|
|
2654
3538
|
// 2FA (email code): after the password check, email a code + park a pending token,
|
|
2655
3539
|
// then send the user to /login/verify instead of issuing the session immediately.
|
|
2656
3540
|
const twoFactorBranch = twoFactor ? `
|
|
@@ -2663,7 +3547,7 @@ ${protect ? " if (!locals.user && !PUBLIC.has(url.pathname)) throw redirect(302
|
|
|
2663
3547
|
}` : ''
|
|
2664
3548
|
const loginImports = twoFactor
|
|
2665
3549
|
? "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
|
|
3550
|
+
: `import { SESSION_COOKIE, SESSION_MAX_AGE, signSession, verifyPassword } from '$lib/server/auth'\nimport { findUser } from '$lib/server/users'`
|
|
2667
3551
|
const loginServerTs = `import { fail, redirect } from '@sveltejs/kit'
|
|
2668
3552
|
import type { Actions, PageServerLoad } from './$types'
|
|
2669
3553
|
${loginImports}
|
|
@@ -3376,8 +4260,44 @@ function dzColumn(dialect: DzDialect, field: EntityField, colName: string, isPk:
|
|
|
3376
4260
|
/** The Drizzle `auth_users` table (added when auth + data layer are both on): the
|
|
3377
4261
|
* DB-backed user store the login flow reads. Fixed shape: id, unique email, name,
|
|
3378
4262
|
* role, passwordHash. Returns the table block + the core imports it needs. */
|
|
3379
|
-
|
|
3380
|
-
|
|
4263
|
+
/** The tenant-scoping column, per dialect. Indexed-by-convention (callers filter
|
|
4264
|
+
* on it in every query), not null - a row with no tenant belongs to nobody and
|
|
4265
|
+
* would be invisible to every scoped read. */
|
|
4266
|
+
function dzTenantColumn(dialect: DzDialect, field: string): { expr: string; imports: string[] } {
|
|
4267
|
+
const col = field.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase()
|
|
4268
|
+
if (dialect === 'mysql') return { expr: `varchar(${JSON.stringify(col)}, { length: 128 }).notNull()`, imports: ['varchar'] }
|
|
4269
|
+
return { expr: `text(${JSON.stringify(col)}).notNull()`, imports: ['text'] }
|
|
4270
|
+
}
|
|
4271
|
+
|
|
4272
|
+
/** The audit-trail table, in the same schema as the entities so one migration
|
|
4273
|
+
* covers it. Shaped like `AuditEntry` in `$lib/audit`, plus `before` / `after`
|
|
4274
|
+
* JSON snapshots that the in-memory store never kept. */
|
|
4275
|
+
function dzAuditTable(dialect: DzDialect, tableFn: string): { block: string; imports: string[] } {
|
|
4276
|
+
const tail = '\n})\nexport type AuditRow = typeof auditLog.$inferSelect\nexport type AuditNew = typeof auditLog.$inferInsert'
|
|
4277
|
+
if (dialect === 'postgres') {
|
|
4278
|
+
return {
|
|
4279
|
+
imports: [tableFn, 'serial', 'text', 'timestamp'],
|
|
4280
|
+
block: `export const auditLog = ${tableFn}("audit_log", {\n "id": serial("id").primaryKey(),\n "at": timestamp("at").notNull().defaultNow(),\n "actor": text("actor").notNull(),\n "entity": text("entity").notNull(),\n "action": text("action").notNull(),\n "recordId": text("record_id").notNull(),\n "summary": text("summary").notNull(),\n "before": text("before"),\n "after": text("after"),${tail}`,
|
|
4281
|
+
}
|
|
4282
|
+
}
|
|
4283
|
+
if (dialect === 'mysql') {
|
|
4284
|
+
return {
|
|
4285
|
+
imports: [tableFn, 'int', 'varchar', 'text', 'timestamp'],
|
|
4286
|
+
block: `export const auditLog = ${tableFn}("audit_log", {\n "id": int("id").autoincrement().primaryKey(),\n "at": timestamp("at").notNull().defaultNow(),\n "actor": varchar("actor", { length: 255 }).notNull(),\n "entity": varchar("entity", { length: 128 }).notNull(),\n "action": varchar("action", { length: 16 }).notNull(),\n "recordId": varchar("record_id", { length: 128 }).notNull(),\n "summary": text("summary").notNull(),\n "before": text("before"),\n "after": text("after"),${tail}`,
|
|
4287
|
+
}
|
|
4288
|
+
}
|
|
4289
|
+
// sqlite / turso
|
|
4290
|
+
return {
|
|
4291
|
+
imports: [tableFn, 'integer', 'text'],
|
|
4292
|
+
block: `export const auditLog = ${tableFn}("audit_log", {\n "id": integer("id").primaryKey({ autoIncrement: true }),\n "at": text("at").notNull(),\n "actor": text("actor").notNull(),\n "entity": text("entity").notNull(),\n "action": text("action").notNull(),\n "recordId": text("record_id").notNull(),\n "summary": text("summary").notNull(),\n "before": text("before"),\n "after": text("after"),${tail}`,
|
|
4293
|
+
}
|
|
4294
|
+
}
|
|
4295
|
+
|
|
4296
|
+
function dzUsersTable(dialect: DzDialect, tableFn: string, with2fa = false, tenantColumn?: string): { block: string; imports: string[] } {
|
|
4297
|
+
// Multi-tenancy: the user's own tenant is where every scoped request gets its
|
|
4298
|
+
// tenant from, so the user store has to carry it.
|
|
4299
|
+
const ten = tenantColumn ? `\n ${JSON.stringify(tenantColumn)}: ${dzTenantColumn(dialect, tenantColumn).expr},` : ''
|
|
4300
|
+
const tail = `${ten}\n})\nexport type AuthUserRow = typeof authUsers.$inferSelect\nexport type AuthUserNew = typeof authUsers.$inferInsert`
|
|
3381
4301
|
if (dialect === 'postgres') {
|
|
3382
4302
|
const tfa = with2fa ? '\n "twoFactor": boolean("two_factor").notNull().default(false),' : ''
|
|
3383
4303
|
return {
|
|
@@ -3404,7 +4324,7 @@ function dzUsersTable(dialect: DzDialect, tableFn: string, with2fa = false): { b
|
|
|
3404
4324
|
* there's a SQL-bound entity on a supported dialect): a schema (the source of truth
|
|
3405
4325
|
* for drizzle-kit migrations), a client, a typed repository per entity, and the
|
|
3406
4326
|
* drizzle.config.ts. The connected `+server.ts` routes read the same tables. */
|
|
3407
|
-
function dataLayerFiles(project: StudioProject, sqlEntities: EntitySchema[], sources: Record<string, EntityDataSource>, includeUsers = false, usersTwoFactor = false): GeneratedFile[] {
|
|
4327
|
+
function dataLayerFiles(project: StudioProject, sqlEntities: EntitySchema[], sources: Record<string, EntityDataSource>, includeUsers = false, usersTwoFactor = false, includeAudit = false, tenantColumn?: string): GeneratedFile[] {
|
|
3408
4328
|
const firstSql = sources[sqlEntities[0]!.name]
|
|
3409
4329
|
const dialect = dzDialect(firstSql?.kind === 'sql' ? firstSql.dialect : undefined)
|
|
3410
4330
|
if (!dialect) return [] // MSSQL: raw route only (Drizzle has no SQL Server driver)
|
|
@@ -3427,15 +4347,29 @@ function dataLayerFiles(project: StudioProject, sqlEntities: EntitySchema[], sou
|
|
|
3427
4347
|
c.imports.forEach((i) => colImports.add(i))
|
|
3428
4348
|
return ` ${JSON.stringify(f.field)}: ${c.expr},`
|
|
3429
4349
|
})
|
|
4350
|
+
// Multi-tenancy: the scoping column the API route filters/stamps on. Added
|
|
4351
|
+
// here (not to the entity's field list) so it stays out of forms and grids -
|
|
4352
|
+
// it is infrastructure, not data the user edits.
|
|
4353
|
+
if (tenantColumn && isTenantScoped(project, e.name) && !e.fields.some((f) => f.field === tenantColumn)) {
|
|
4354
|
+
const t = dzTenantColumn(dialect, tenantColumn)
|
|
4355
|
+
t.imports.forEach((i) => colImports.add(i))
|
|
4356
|
+
cols.push(` ${JSON.stringify(tenantColumn)}: ${t.expr},`)
|
|
4357
|
+
}
|
|
3430
4358
|
tableBlocks.push(`export const ${tableVar} = ${cfg.table}(${JSON.stringify(table)}, {\n${cols.join('\n')}\n})\nexport type ${type}Row = typeof ${tableVar}.$inferSelect\nexport type ${type}New = typeof ${tableVar}.$inferInsert`)
|
|
3431
4359
|
meta.push({ e, tableVar, pkKey: pk, pkNumber })
|
|
3432
4360
|
}
|
|
3433
4361
|
// Auth: the DB-backed user store lives in the same schema (so one migration covers it).
|
|
3434
4362
|
if (includeUsers) {
|
|
3435
|
-
const u = dzUsersTable(dialect, cfg.table, usersTwoFactor)
|
|
4363
|
+
const u = dzUsersTable(dialect, cfg.table, usersTwoFactor, tenantColumn)
|
|
3436
4364
|
u.imports.forEach((i) => colImports.add(i))
|
|
3437
4365
|
tableBlocks.push(u.block)
|
|
3438
4366
|
}
|
|
4367
|
+
// Audit: same reasoning - one migration covers the trail alongside the data.
|
|
4368
|
+
if (includeAudit) {
|
|
4369
|
+
const a = dzAuditTable(dialect, cfg.table)
|
|
4370
|
+
a.imports.forEach((i) => colImports.add(i))
|
|
4371
|
+
tableBlocks.push(a.block)
|
|
4372
|
+
}
|
|
3439
4373
|
const schemaTs = `// Regenerated by SvGrid Studio. Typed database schema (Drizzle ORM) - the source of
|
|
3440
4374
|
// truth for migrations: edit here, then run \`npm run db:generate\` && \`npm run db:migrate\`.
|
|
3441
4375
|
import { ${[...colImports].sort().join(', ')} } from '${cfg.core}'
|
|
@@ -3512,6 +4446,60 @@ export default defineConfig({
|
|
|
3512
4446
|
dialect: '${cfg.kit}',
|
|
3513
4447
|
${cfg.creds},
|
|
3514
4448
|
})
|
|
4449
|
+
`
|
|
4450
|
+
|
|
4451
|
+
// db/seed.ts - inserts the sample rows into EMPTY tables after `db:migrate`, so a
|
|
4452
|
+
// freshly-migrated production database doesn't open onto blank grids. Lives outside
|
|
4453
|
+
// src/ (like drizzle.config.ts) and builds its own client from process.env, because
|
|
4454
|
+
// the app's client imports $env/dynamic/private which only exists under Vite.
|
|
4455
|
+
const { seed: seedMap } = prepareEntities(sqlEntities)
|
|
4456
|
+
const nameSet = new Set(sqlEntities.map((e) => e.name))
|
|
4457
|
+
// Parents before children so FK values resolve (simple repeated-pass ordering).
|
|
4458
|
+
const ordered: typeof meta = []
|
|
4459
|
+
const pending = [...meta]
|
|
4460
|
+
while (pending.length) {
|
|
4461
|
+
const next = pending.findIndex(({ e }) =>
|
|
4462
|
+
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)),
|
|
4463
|
+
)
|
|
4464
|
+
ordered.push(...pending.splice(next === -1 ? 0 : next, 1))
|
|
4465
|
+
}
|
|
4466
|
+
const pkNumberByName = new Map(meta.map((m) => [m.e.name, m.pkNumber]))
|
|
4467
|
+
const seedBlocks = ordered
|
|
4468
|
+
.map(({ e, tableVar, pkKey, pkNumber }) => {
|
|
4469
|
+
const rows = (seedMap.get(e.name) ?? []).map((row, i) => {
|
|
4470
|
+
const out: Record<string, unknown> = { ...row }
|
|
4471
|
+
if (pkNumber) out[pkKey] = i + 1
|
|
4472
|
+
for (const f of e.fields) {
|
|
4473
|
+
if (f.type === 'relation' && f.relation && pkNumberByName.get(f.relation.entity)) {
|
|
4474
|
+
const relCount = seedMap.get(f.relation.entity)?.length ?? 1
|
|
4475
|
+
out[f.field] = (i % Math.max(1, relCount)) + 1
|
|
4476
|
+
}
|
|
4477
|
+
}
|
|
4478
|
+
return out
|
|
4479
|
+
})
|
|
4480
|
+
if (!rows.length) return null
|
|
4481
|
+
return ` {
|
|
4482
|
+
const existing = await db.select().from(schema.${tableVar}).limit(1)
|
|
4483
|
+
if (existing.length === 0) {
|
|
4484
|
+
await db.insert(schema.${tableVar}).values(${JSON.stringify(rows)} as (typeof schema.${tableVar}.$inferInsert)[])
|
|
4485
|
+
console.log('seeded ${e.name} (${rows.length} rows)')
|
|
4486
|
+
} else console.log('${e.name} already has rows - skipped')
|
|
4487
|
+
}`
|
|
4488
|
+
})
|
|
4489
|
+
.filter((b): b is string => b !== null)
|
|
4490
|
+
const seedTs = `// Regenerated by SvGrid Studio. studio:db-seed
|
|
4491
|
+
// Inserts sample rows into EMPTY tables - a table that already has rows is skipped,
|
|
4492
|
+
// so this is safe to run repeatedly. Run AFTER migrations: npm run db:seed
|
|
4493
|
+
${cfg.client}
|
|
4494
|
+
import * as schema from '../src/lib/server/db/schema'
|
|
4495
|
+
|
|
4496
|
+
${cfg.setup.replaceAll('env.', 'process.env.').replace('export const db', 'const db')}
|
|
4497
|
+
|
|
4498
|
+
async function main() {
|
|
4499
|
+
${seedBlocks.join('\n')}
|
|
4500
|
+
}
|
|
4501
|
+
|
|
4502
|
+
main().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1) })
|
|
3515
4503
|
`
|
|
3516
4504
|
|
|
3517
4505
|
return [
|
|
@@ -3519,6 +4507,7 @@ export default defineConfig({
|
|
|
3519
4507
|
{ path: 'src/lib/server/db/index.ts', description: 'Drizzle client.', contents: indexTs },
|
|
3520
4508
|
...repoFiles,
|
|
3521
4509
|
{ path: 'drizzle.config.ts', description: 'drizzle-kit config (migrations).', contents: configTs },
|
|
4510
|
+
...(seedBlocks.length ? [{ path: 'db/seed.ts', description: 'Sample-row seeder (run after db:migrate: npm run db:seed).', contents: seedTs }] : []),
|
|
3522
4511
|
]
|
|
3523
4512
|
}
|
|
3524
4513
|
|
|
@@ -3529,7 +4518,10 @@ const appSlug = (title: string): string =>
|
|
|
3529
4518
|
|
|
3530
4519
|
/** The static SvelteKit + Vite scaffolding around the generated screens. */
|
|
3531
4520
|
const SCAFFOLD_STATIC: ReadonlyArray<GeneratedFile> = [
|
|
3532
|
-
|
|
4521
|
+
// watch.ignored: `studio dev` runs this app's Vite next to the designer, which
|
|
4522
|
+
// auto-saves studio.config.json + a .studio/ manifest into the same folder -
|
|
4523
|
+
// neither should trigger a reload of the app.
|
|
4524
|
+
{ 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
4525
|
{ 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
4526
|
{ 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
4527
|
{ path: 'src/app.d.ts', description: 'SvelteKit app types.', contents: `declare global {\n namespace App {}\n}\n\nexport {}\n` },
|
|
@@ -3770,12 +4762,15 @@ export function studioDeployInfo(project: StudioProject): { label: string; cli:
|
|
|
3770
4762
|
}
|
|
3771
4763
|
|
|
3772
4764
|
/** 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 {
|
|
4765
|
+
function deployDocs(project: StudioProject, plan: DeployPlan, envKeys: string[], hasMigrations: boolean, opts: { hasDdl?: boolean; hasSeedScript?: boolean } = {}): string {
|
|
3774
4766
|
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`
|
|
4767
|
+
? `## 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
4768
|
: ''
|
|
3777
4769
|
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`
|
|
4770
|
+
? `## 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`
|
|
4771
|
+
: ''
|
|
4772
|
+
const ddl = opts.hasDdl
|
|
4773
|
+
? `## 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
4774
|
: ''
|
|
3780
4775
|
const gitIntegration = plan.dashboard
|
|
3781
4776
|
? `## 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 +4782,7 @@ function deployDocs(project: StudioProject, plan: DeployPlan, envKeys: string[],
|
|
|
3787
4782
|
? `## 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
4783
|
: ''
|
|
3789
4784
|
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}`
|
|
4785
|
+
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
4786
|
}
|
|
3792
4787
|
|
|
3793
4788
|
/** The env-var keys the generated app reads (for DEPLOY.md). */
|
|
@@ -3795,10 +4790,37 @@ function envKeysUsed(allSource: string): string[] {
|
|
|
3795
4790
|
const keys: string[] = []
|
|
3796
4791
|
if (allSource.includes('env.DATABASE_URL')) keys.push('DATABASE_URL')
|
|
3797
4792
|
if (allSource.includes('env.DATABASE_AUTH_TOKEN')) keys.push('DATABASE_AUTH_TOKEN')
|
|
4793
|
+
if (allSource.includes('PUBLIC_SUPABASE_URL')) keys.push('PUBLIC_SUPABASE_URL', 'PUBLIC_SUPABASE_ANON_KEY')
|
|
3798
4794
|
if (allSource.includes('SESSION_SECRET')) keys.push('SESSION_SECRET')
|
|
4795
|
+
// Feature keys - same detection as envExample, so DEPLOY.md's checklist matches
|
|
4796
|
+
// .env.example instead of silently omitting the auth/email vars.
|
|
4797
|
+
if (allSource.includes("from '$lib/server/email'")) keys.push('EMAIL_FROM', 'RESEND_API_KEY or SMTP_HOST/PORT/USER/PASS')
|
|
4798
|
+
if (allSource.includes("from '$lib/server/oauth'")) {
|
|
4799
|
+
if (allSource.includes("'github'") || allSource.includes('"github"')) keys.push('GITHUB_CLIENT_ID', 'GITHUB_CLIENT_SECRET')
|
|
4800
|
+
if (allSource.includes("'google'") || allSource.includes('"google"')) keys.push('GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET')
|
|
4801
|
+
if (allSource.includes("'oidc'") || allSource.includes('"oidc"')) keys.push('OIDC_ISSUER', 'OIDC_CLIENT_ID', 'OIDC_CLIENT_SECRET')
|
|
4802
|
+
}
|
|
3799
4803
|
return keys
|
|
3800
4804
|
}
|
|
3801
4805
|
|
|
4806
|
+
/** A cryptographically-random hex secret. Portable across Node 19+ and browsers
|
|
4807
|
+
* (the visual designer runs codegen client-side), so every generated app gets a
|
|
4808
|
+
* unique real session secret instead of a shared hardcoded fallback. */
|
|
4809
|
+
function randomSecret(bytes = 32): string {
|
|
4810
|
+
const buf = new Uint8Array(bytes)
|
|
4811
|
+
globalThis.crypto.getRandomValues(buf)
|
|
4812
|
+
return Array.from(buf, (b) => b.toString(16).padStart(2, '0')).join('')
|
|
4813
|
+
}
|
|
4814
|
+
|
|
4815
|
+
/** A ready-to-run, git-ignored `.env`: the `.env.example` template with a real
|
|
4816
|
+
* random `SESSION_SECRET` filled in, so the app is secure out of the box (no
|
|
4817
|
+
* hardcoded dev secret). Only emitted when the app actually signs sessions. */
|
|
4818
|
+
function envDotFile(allSource: string): string | null {
|
|
4819
|
+
const example = envExample(allSource)
|
|
4820
|
+
if (!example || !allSource.includes('SESSION_SECRET')) return null
|
|
4821
|
+
return example.replace('SESSION_SECRET=change-me-to-a-long-random-string', `SESSION_SECRET=${randomSecret()}`)
|
|
4822
|
+
}
|
|
4823
|
+
|
|
3802
4824
|
/** A `.env.example` listing the env vars the generated code reads, when any. */
|
|
3803
4825
|
function envExample(allSource: string): string | null {
|
|
3804
4826
|
const lines: string[] = []
|
|
@@ -3810,6 +4832,12 @@ function envExample(allSource: string): string | null {
|
|
|
3810
4832
|
lines.push('# Turso / libSQL database auth token.')
|
|
3811
4833
|
lines.push('DATABASE_AUTH_TOKEN=')
|
|
3812
4834
|
}
|
|
4835
|
+
if (allSource.includes('PUBLIC_SUPABASE_URL')) {
|
|
4836
|
+
if (lines.length) lines.push('')
|
|
4837
|
+
lines.push('# Supabase project (Settings > API). The anon key is public / browser-safe.')
|
|
4838
|
+
lines.push('PUBLIC_SUPABASE_URL=')
|
|
4839
|
+
lines.push('PUBLIC_SUPABASE_ANON_KEY=')
|
|
4840
|
+
}
|
|
3813
4841
|
if (allSource.includes('SESSION_SECRET')) {
|
|
3814
4842
|
if (lines.length) lines.push('')
|
|
3815
4843
|
lines.push('# Session signing secret - set a long random value in production.')
|
|
@@ -3835,6 +4863,12 @@ function envExample(allSource: string): string | null {
|
|
|
3835
4863
|
if (allSource.includes("'google'") || allSource.includes('"google"')) { lines.push('# GOOGLE_CLIENT_ID='); lines.push('# GOOGLE_CLIENT_SECRET=') }
|
|
3836
4864
|
if (allSource.includes("'oidc'") || allSource.includes('"oidc"')) { lines.push('# OIDC_ISSUER= # e.g. https://login.microsoftonline.com/<tenant>/v2.0'); lines.push('# OIDC_CLIENT_ID='); lines.push('# OIDC_CLIENT_SECRET=') }
|
|
3837
4865
|
}
|
|
4866
|
+
if (allSource.includes('env.CRON_SECRET')) {
|
|
4867
|
+
lines.push('')
|
|
4868
|
+
lines.push('# Shared secret for /api/cron. The endpoint refuses to run without it,')
|
|
4869
|
+
lines.push('# so set the same value here and in your scheduler.')
|
|
4870
|
+
lines.push('CRON_SECRET=')
|
|
4871
|
+
}
|
|
3838
4872
|
if (lines.length === 0) return null
|
|
3839
4873
|
lines.push('')
|
|
3840
4874
|
lines.push('# Optional: your SvGrid license key removes the unlicensed watermark.')
|
|
@@ -3846,7 +4880,9 @@ function svelteConfig(plan: DeployPlan): string {
|
|
|
3846
4880
|
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
4881
|
}
|
|
3848
4882
|
|
|
3849
|
-
|
|
4883
|
+
/** The runtime npm deps the generated source imports, keyed by package -> range.
|
|
4884
|
+
* One source of truth for package.json AND the fragment-mode dependency report. */
|
|
4885
|
+
export function runtimeDeps(project: StudioProject, allSource: string): Record<string, string> {
|
|
3850
4886
|
const dependencies: Record<string, string> = { '@svgrid/grid': 'latest', '@svgrid/enterprise': 'latest' }
|
|
3851
4887
|
if (allSource.includes("from '@supabase/supabase-js'")) dependencies['@supabase/supabase-js'] = '^2.45.0'
|
|
3852
4888
|
if (allSource.includes("import pg from 'pg'")) dependencies['pg'] = '^8.11.0'
|
|
@@ -3860,18 +4896,33 @@ function packageJson(project: StudioProject, allSource: string): string {
|
|
|
3860
4896
|
if (/from ['"]hyperformula['"]/.test(allSource)) dependencies['hyperformula'] = '^3.3.0'
|
|
3861
4897
|
if (/from ['"]jszip['"]/.test(allSource)) dependencies['jszip'] = '^3.10.1'
|
|
3862
4898
|
if (/from ['"]pdfmake(?:\/[^'"]*)?['"]/.test(allSource)) dependencies['pdfmake'] = '^0.2.10'
|
|
4899
|
+
// xlsx / pdf export buttons reach these through @svgrid/enterprise, which
|
|
4900
|
+
// imports them lazily - so they never appear in the generated source and the
|
|
4901
|
+
// scans above cannot see them. Key off the export config instead, or the app
|
|
4902
|
+
// ships an Export Excel button that throws on the missing peer dep.
|
|
4903
|
+
for (const screen of project.screens ?? []) {
|
|
4904
|
+
for (const block of screen.blocks ?? []) {
|
|
4905
|
+
if (block.config.kind !== 'grid') continue
|
|
4906
|
+
if (block.config.export?.xlsx) dependencies['jszip'] = '^3.10.1'
|
|
4907
|
+
if (block.config.export?.pdf) dependencies['pdfmake'] = '^0.2.10'
|
|
4908
|
+
}
|
|
4909
|
+
}
|
|
3863
4910
|
// nodemailer is dynamically imported by the email layer only on the SMTP branch.
|
|
3864
|
-
|
|
3865
|
-
if (
|
|
3866
|
-
|
|
3867
|
-
|
|
3868
|
-
|
|
4911
|
+
if (/import\(['"]nodemailer['"]\)/.test(allSource)) dependencies['nodemailer'] = '^6.9.0'
|
|
4912
|
+
if (project.dataLayer === 'drizzle' && /from ['"]drizzle-orm(?:\/[^'"]*)?['"]/.test(allSource)) dependencies['drizzle-orm'] = '^0.44.0'
|
|
4913
|
+
return dependencies
|
|
4914
|
+
}
|
|
4915
|
+
|
|
4916
|
+
function packageJson(project: StudioProject, allSource: string): string {
|
|
4917
|
+
const dependencies = runtimeDeps(project, allSource)
|
|
4918
|
+
const drizzle = !!dependencies['drizzle-orm']
|
|
3869
4919
|
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
4920
|
if (drizzle) {
|
|
3871
4921
|
scripts['db:generate'] = 'drizzle-kit generate'
|
|
3872
4922
|
scripts['db:migrate'] = 'drizzle-kit migrate'
|
|
3873
4923
|
scripts['db:push'] = 'drizzle-kit push'
|
|
3874
4924
|
scripts['db:studio'] = 'drizzle-kit studio'
|
|
4925
|
+
if (allSource.includes('studio:db-seed')) scripts['db:seed'] = 'tsx db/seed.ts'
|
|
3875
4926
|
}
|
|
3876
4927
|
const pkg = {
|
|
3877
4928
|
name: appSlug(project.title),
|
|
@@ -3896,8 +4947,9 @@ function packageJson(project: StudioProject, allSource: string): string {
|
|
|
3896
4947
|
// Driver typings so the connected route + data layer type-check cleanly.
|
|
3897
4948
|
...(dependencies['pg'] ? { '@types/pg': '^8.11.0' } : {}),
|
|
3898
4949
|
...(dependencies['better-sqlite3'] ? { '@types/better-sqlite3': '^7.6.0' } : {}),
|
|
3899
|
-
...(
|
|
4950
|
+
...(dependencies['nodemailer'] ? { '@types/nodemailer': '^6.4.0' } : {}),
|
|
3900
4951
|
...(drizzle ? { 'drizzle-kit': '^0.31.0' } : {}),
|
|
4952
|
+
...(drizzle && allSource.includes('studio:db-seed') ? { tsx: '^4.19.0' } : {}),
|
|
3901
4953
|
},
|
|
3902
4954
|
}
|
|
3903
4955
|
return JSON.stringify(pkg, null, 2) + '\n'
|
|
@@ -3961,6 +5013,66 @@ export default defineConfig({
|
|
|
3961
5013
|
* Download it, `npm install`, `npm run dev`. This is what the designer's
|
|
3962
5014
|
* "Download .zip" produces.
|
|
3963
5015
|
*/
|
|
5016
|
+
/**
|
|
5017
|
+
* The platform-side half of scheduled jobs: something has to CALL `/api/cron`.
|
|
5018
|
+
*
|
|
5019
|
+
* Vercel has native cron, so its schedule goes in `vercel.json` and needs no
|
|
5020
|
+
* secrets. Every other target gets a GitHub Actions schedule that curls the
|
|
5021
|
+
* endpoint - it works anywhere the app is reachable, and stays inert until
|
|
5022
|
+
* `CRON_URL` / `CRON_SECRET` are set, so CI is green before you configure it.
|
|
5023
|
+
*/
|
|
5024
|
+
function cronScheduleFiles(project: StudioProject, plan: DeployPlan): GeneratedFile[] {
|
|
5025
|
+
const jobs = (project.jobs ?? []).filter((j) => j.id && j.cron && j.enabled !== false)
|
|
5026
|
+
if (!jobs.length) return []
|
|
5027
|
+
|
|
5028
|
+
if (plan.label === 'Vercel') {
|
|
5029
|
+
// Vercel Cron hits the path on its own schedule; one entry per job so each
|
|
5030
|
+
// keeps its own cron expression.
|
|
5031
|
+
const crons = jobs.map((j) => ({ path: `/api/cron?job=${encodeURIComponent(j.id)}`, schedule: j.cron }))
|
|
5032
|
+
return [{
|
|
5033
|
+
path: 'vercel.json',
|
|
5034
|
+
description: 'Vercel Cron schedule for the app\'s background jobs.',
|
|
5035
|
+
contents: JSON.stringify({ crons }, null, 2) + '\n',
|
|
5036
|
+
}]
|
|
5037
|
+
}
|
|
5038
|
+
|
|
5039
|
+
const steps = jobs.map((j) => ` - name: ${j.name} (${j.cron})
|
|
5040
|
+
if: \${{ github.event_name == 'workflow_dispatch' || github.event.schedule == '${j.cron}' }}
|
|
5041
|
+
run: |
|
|
5042
|
+
curl -fsS -X POST "$CRON_URL?job=${encodeURIComponent(j.id)}" \\
|
|
5043
|
+
-H "authorization: Bearer $CRON_SECRET"
|
|
5044
|
+
`).join('')
|
|
5045
|
+
const schedules = [...new Set(jobs.map((j) => j.cron))].map((c) => ` - cron: '${c}'`).join('\n')
|
|
5046
|
+
|
|
5047
|
+
return [{
|
|
5048
|
+
path: '.github/workflows/cron.yml',
|
|
5049
|
+
description: 'Scheduled job runner: calls /api/cron on the deployed app.',
|
|
5050
|
+
contents: `# Scheduled jobs for the generated app. GitHub runs this on the schedules
|
|
5051
|
+
# below and it calls the app's /api/cron endpoint.
|
|
5052
|
+
#
|
|
5053
|
+
# Set two repository secrets before it does anything:
|
|
5054
|
+
# CRON_URL https://<your-app>/api/cron
|
|
5055
|
+
# CRON_SECRET the same value as the app's CRON_SECRET env var
|
|
5056
|
+
# Until then the job short-circuits, so CI stays green.
|
|
5057
|
+
name: Scheduled jobs
|
|
5058
|
+
|
|
5059
|
+
on:
|
|
5060
|
+
schedule:
|
|
5061
|
+
${schedules}
|
|
5062
|
+
workflow_dispatch:
|
|
5063
|
+
|
|
5064
|
+
jobs:
|
|
5065
|
+
cron:
|
|
5066
|
+
runs-on: ubuntu-latest
|
|
5067
|
+
if: \${{ secrets.CRON_URL != '' && secrets.CRON_SECRET != '' }}
|
|
5068
|
+
env:
|
|
5069
|
+
CRON_URL: \${{ secrets.CRON_URL }}
|
|
5070
|
+
CRON_SECRET: \${{ secrets.CRON_SECRET }}
|
|
5071
|
+
steps:
|
|
5072
|
+
${steps}`,
|
|
5073
|
+
}]
|
|
5074
|
+
}
|
|
5075
|
+
|
|
3964
5076
|
export function emitStudioAppBundle(project: StudioProject): GeneratedFile[] {
|
|
3965
5077
|
const generated = emitStudioProject(project)
|
|
3966
5078
|
const allSource = generated.map((f) => f.contents).join('\n')
|
|
@@ -3975,8 +5087,10 @@ export function emitStudioAppBundle(project: StudioProject): GeneratedFile[] {
|
|
|
3975
5087
|
{ path: 'vitest.config.ts', description: 'Test runner config for the generated smoke tests (npm test).', contents: VITEST_CONFIG },
|
|
3976
5088
|
{ path: 'src/lib/schemas.test.ts', description: 'Smoke tests: every entity renders + round-trips through its data source.', contents: smokeTestFile(project) },
|
|
3977
5089
|
...plan.files,
|
|
5090
|
+
...cronScheduleFiles(project, plan),
|
|
3978
5091
|
...SCAFFOLD_STATIC,
|
|
3979
5092
|
...(envExample(allSource) ? [{ path: '.env.example', description: 'Environment variables the app reads (copy to .env and fill in).', contents: envExample(allSource)! }] : []),
|
|
5093
|
+
...(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
5094
|
{ path: 'src/app.css', description: 'App theme + page styles.', contents: APP_CSS },
|
|
3981
5095
|
// Your styles: a dedicated CSS file the layout imports AFTER app.css, so its rules
|
|
3982
5096
|
// win. Edited in the designer's Styles panel; regenerated from the model.
|
|
@@ -3989,7 +5103,65 @@ export function emitStudioAppBundle(project: StudioProject): GeneratedFile[] {
|
|
|
3989
5103
|
// has one, and a DEPLOY.md runbook.
|
|
3990
5104
|
{ path: '.github/workflows/ci.yml', description: 'CI: build + test on push / PR.', contents: CI_WORKFLOW },
|
|
3991
5105
|
...(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')) },
|
|
5106
|
+
{ 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
5107
|
]
|
|
3994
5108
|
return [...scaffold, ...generated]
|
|
3995
5109
|
}
|
|
5110
|
+
|
|
5111
|
+
/**
|
|
5112
|
+
* Emit ONLY the app content (`src/routes` + `src/lib` + `db`) to drop into an
|
|
5113
|
+
* EXISTING SvelteKit app - not a whole new app. Everything emitStudioProject
|
|
5114
|
+
* produces MINUS the nav shell (`+layout.svelte`) and the home redirect (home
|
|
5115
|
+
* `+page.svelte`), PLUS `src/app.css` (the `.st-*` page styles + `--sg-*` tokens,
|
|
5116
|
+
* which otherwise live only in the full bundle) and a FRAGMENT.md that lists the
|
|
5117
|
+
* deps to install and what the host app must provide.
|
|
5118
|
+
*/
|
|
5119
|
+
export function emitStudioFragment(project: StudioProject): GeneratedFile[] {
|
|
5120
|
+
const generated = emitStudioProject(project)
|
|
5121
|
+
const allSource = generated.map((f) => f.contents).join('\n')
|
|
5122
|
+
// Drop the two files that assume ownership of the whole app.
|
|
5123
|
+
const content = generated.filter((f) => f.path !== 'src/routes/+layout.svelte' && f.path !== 'src/routes/+page.svelte')
|
|
5124
|
+
|
|
5125
|
+
const deps = runtimeDeps(project, allSource)
|
|
5126
|
+
const depList = Object.entries(deps).map(([name, v]) => ` ${name}@${v}`).join('\n')
|
|
5127
|
+
const envKeys = envKeysUsed(allSource)
|
|
5128
|
+
const libFiles = content.filter((f) => f.path.startsWith('src/lib/')).map((f) => `- \`${f.path}\``)
|
|
5129
|
+
const fragmentMd = `# Studio fragment - drop into your SvelteKit app
|
|
5130
|
+
|
|
5131
|
+
This folder holds ONLY app content - routes under \`src/routes\` and modules under
|
|
5132
|
+
\`src/lib\` (plus \`db/\` if present). It has no package.json, config, or app shell,
|
|
5133
|
+
so it drops into your existing SvelteKit app.
|
|
5134
|
+
|
|
5135
|
+
## 1. Copy the files
|
|
5136
|
+
Merge \`src/routes/*\` and \`src/lib/*\` into your app's \`src/\`. \`$lib\` is a stock
|
|
5137
|
+
SvelteKit alias, so imports resolve with no config. Watch for filename collisions
|
|
5138
|
+
with your own \`src/lib\` (e.g. \`schemas.ts\`, \`data.ts\`) - rename if needed.
|
|
5139
|
+
|
|
5140
|
+
## 2. Install dependencies
|
|
5141
|
+
\`\`\`bash
|
|
5142
|
+
npm install ${Object.keys(deps).filter((d) => d !== '@svgrid/grid' && d !== '@svgrid/enterprise').join(' ') || '@svgrid/grid @svgrid/enterprise'}
|
|
5143
|
+
\`\`\`
|
|
5144
|
+
Full list this fragment imports:
|
|
5145
|
+
\`\`\`
|
|
5146
|
+
${depList}
|
|
5147
|
+
\`\`\`
|
|
5148
|
+
|
|
5149
|
+
## 3. Styles
|
|
5150
|
+
Import \`src/app.css\` once (e.g. in your root \`+layout.svelte\`) - it carries the
|
|
5151
|
+
\`.st-*\` page styles the screens use and the \`--sg-*\` design tokens.
|
|
5152
|
+
|
|
5153
|
+
## 4. What your app must provide
|
|
5154
|
+
The full-app nav shell, home redirect, auth guard, RBAC bootstrap, i18n provider,
|
|
5155
|
+
and theme toggle live in the bundle's \`+layout.svelte\` - NOT here. Wire nav to the
|
|
5156
|
+
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')}` : ''}
|
|
5157
|
+
|
|
5158
|
+
## Files included
|
|
5159
|
+
${libFiles.join('\n')}
|
|
5160
|
+
`
|
|
5161
|
+
|
|
5162
|
+
return [
|
|
5163
|
+
...content,
|
|
5164
|
+
{ path: 'src/app.css', description: 'Page styles (.st-*) + design tokens - import once in your layout.', contents: APP_CSS },
|
|
5165
|
+
{ path: 'FRAGMENT.md', description: 'How to drop this fragment into an existing SvelteKit app.', contents: fragmentMd },
|
|
5166
|
+
]
|
|
5167
|
+
}
|