@svgrid/enterprise 1.2.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cdn/svgrid-enterprise.svelte-external.js +26039 -805
- package/dist/designer/assets/index-Dp44bTid.js +939 -0
- package/dist/designer/assets/index-RJp6x8tw.css +1 -0
- package/dist/designer/assets/jszip.min-CjMo-QGg.js +2 -0
- package/dist/designer/index.html +13 -0
- package/dist/node/studio.js +22554 -0
- package/package.json +10 -3
- package/src/SvAuthGate.svelte +115 -0
- package/src/SvBoard.dom.test.ts +67 -0
- package/src/SvBoard.svelte +192 -0
- package/src/SvExportMenu.svelte +553 -0
- package/src/SvFileInput.svelte +113 -0
- package/src/SvGridEditPanel.dom.test.ts +146 -0
- package/src/SvGridEditPanel.svelte +793 -0
- package/src/SvGridMasterDetail.svelte +95 -0
- package/src/SvImportDialog.svelte +1020 -0
- package/src/SvLookupInput.svelte +180 -0
- package/src/SvRecordDetail.dom.test.ts +137 -0
- package/src/SvRecordDetail.svelte +288 -0
- package/src/SvSchedule.dom.test.ts +57 -0
- package/src/SvSchedule.svelte +156 -0
- package/src/SvSchemaChart.svelte +233 -0
- package/src/SvSchemaDashboard.svelte +130 -0
- package/src/ai-export-pdf.test.ts +74 -0
- package/src/ai-export-xlsx.test.ts +87 -0
- package/src/ai-export.test.ts +110 -0
- package/src/ai.ts +1188 -782
- package/src/edit-panel.test.ts +213 -0
- package/src/edit-panel.ts +228 -0
- package/src/export-conditional.test.ts +60 -0
- package/src/export-conditional.ts +94 -0
- package/src/export-ooxml.test.ts +152 -0
- package/src/export-ooxml.ts +485 -0
- package/src/export-pdf.test.ts +138 -0
- package/src/export-pdf.ts +245 -0
- package/src/export-print.test.ts +66 -0
- package/src/export-print.ts +132 -0
- package/src/export-serialize.test.ts +56 -0
- package/src/export-serialize.ts +24 -0
- package/src/export-xls.ts +196 -0
- package/src/export-xlsx-roundtrip.test.ts +120 -0
- package/src/export-xlsx.test.ts +150 -0
- package/src/export.test.ts +349 -0
- package/src/export.ts +1452 -549
- package/src/import-automap.test.ts +131 -0
- package/src/import-hardening.test.ts +158 -0
- package/src/import.ts +1042 -648
- package/src/index.ts +282 -0
- package/src/install.ts +134 -114
- package/src/license-core.test.ts +23 -0
- package/src/license-core.ts +35 -0
- package/src/license.ts +103 -90
- package/src/master-detail.test.ts +61 -0
- package/src/master-detail.ts +42 -0
- package/src/print.ts +107 -127
- package/src/schema-designer.test.ts +121 -0
- package/src/schema-designer.ts +142 -0
- package/src/schema.test.ts +268 -0
- package/src/schema.ts +499 -0
- package/src/smart-shim.ts +107 -105
- package/src/sources/aggregate.test.ts +79 -0
- package/src/sources/aggregate.ts +102 -0
- package/src/sources/auth-supabase.test.ts +80 -0
- package/src/sources/auth-supabase.ts +87 -0
- package/src/sources/dashboard.kpi.test.ts +50 -0
- package/src/sources/dashboard.test.ts +61 -0
- package/src/sources/dashboard.ts +136 -0
- package/src/sources/field-inference.test.ts +60 -0
- package/src/sources/field-inference.ts +63 -0
- package/src/sources/filters.test.ts +58 -0
- package/src/sources/filters.ts +64 -0
- package/src/sources/index.ts +67 -0
- package/src/sources/introspect-supabase.test.ts +100 -0
- package/src/sources/introspect-supabase.ts +119 -0
- package/src/sources/realtime-supabase.test.ts +93 -0
- package/src/sources/realtime-supabase.ts +99 -0
- package/src/sources/relation-lookup.test.ts +99 -0
- package/src/sources/relation-lookup.ts +128 -0
- package/src/sources/rest-adapters.test.ts +95 -0
- package/src/sources/rest-adapters.ts +120 -0
- package/src/sources/rest.test.ts +104 -0
- package/src/sources/rest.ts +129 -0
- package/src/sources/schema-from-columns.test.ts +106 -0
- package/src/sources/schema-from-columns.ts +88 -0
- package/src/sources/supabase.test.ts +110 -0
- package/src/sources/supabase.ts +99 -0
- package/src/sources/with-entity-rules.test.ts +104 -0
- package/src/sources/with-entity-rules.ts +78 -0
- package/src/sources/with-relation-labels.test.ts +75 -0
- package/src/sources/with-relation-labels.ts +73 -0
- package/src/studio/bug-report.test.ts +101 -0
- package/src/studio/bug-report.ts +165 -0
- package/src/studio/cli.test.ts +187 -0
- package/src/studio/cli.ts +112 -0
- package/src/studio/csv.test.ts +90 -0
- package/src/studio/csv.ts +165 -0
- package/src/studio/db-connect-string.test.ts +94 -0
- package/src/studio/db-connect-string.ts +128 -0
- package/src/studio/emit-project.test.ts +923 -0
- package/src/studio/emit-project.ts +1273 -0
- package/src/studio/emit-schema.test.ts +94 -0
- package/src/studio/emit-schema.ts +920 -0
- package/src/studio/index.ts +195 -0
- package/src/studio/introspect-db.test.ts +186 -0
- package/src/studio/introspect-db.ts +312 -0
- package/src/studio/introspect-prisma.test.ts +99 -0
- package/src/studio/introspect-prisma.ts +175 -0
- package/src/studio/introspect.test.ts +172 -0
- package/src/studio/introspect.ts +310 -0
- package/src/studio/pipeline.test.ts +42 -0
- package/src/studio/project-robust.test.ts +157 -0
- package/src/studio/project.test.ts +473 -0
- package/src/studio/project.ts +916 -0
- package/src/studio/sample-data.test.ts +58 -0
- package/src/studio/sample-data.ts +200 -0
- package/src/studio/samples/ats.ts +202 -0
- package/src/studio/samples/clinic.ts +177 -0
- package/src/studio/samples/crm.ts +250 -0
- package/src/studio/samples/ecommerce.ts +187 -0
- package/src/studio/samples/events.ts +199 -0
- package/src/studio/samples/fleet.ts +184 -0
- package/src/studio/samples/gym.ts +213 -0
- package/src/studio/samples/hr.ts +193 -0
- package/src/studio/samples/index.ts +55 -0
- package/src/studio/samples/insurance.ts +195 -0
- package/src/studio/samples/inventory.ts +182 -0
- package/src/studio/samples/invoicing.ts +162 -0
- package/src/studio/samples/library.ts +180 -0
- package/src/studio/samples/live-data.test.ts +98 -0
- package/src/studio/samples/live-data.ts +308 -0
- package/src/studio/samples/projects.ts +190 -0
- package/src/studio/samples/realestate.ts +196 -0
- package/src/studio/samples/restaurant.ts +187 -0
- package/src/studio/samples/samples.test.ts +208 -0
- package/src/studio/samples/school.ts +197 -0
- package/src/studio/samples/seed-floor.test.ts +25 -0
- package/src/studio/samples/shared.ts +305 -0
- package/src/studio/samples/subscriptions.ts +183 -0
- package/src/studio/samples/support.ts +182 -0
- package/src/studio/scaffold-app.test.ts +91 -0
- package/src/studio/scaffold-app.ts +161 -0
- package/src/studio/scaffold.test.ts +178 -0
- package/src/studio/scaffold.ts +374 -0
- package/src/studio/themes.ts +172 -0
- package/src/studio/verify.test.ts +47 -0
- package/src/studio/verify.ts +79 -0
- package/src/sveltekit/in-memory.test.ts +104 -0
- package/src/sveltekit/in-memory.ts +129 -0
- package/src/sveltekit/index.ts +23 -0
- package/src/sveltekit/query-plan.test.ts +109 -0
- package/src/sveltekit/query-plan.ts +125 -0
- package/src/sveltekit/sql-source.test.ts +185 -0
- package/src/sveltekit/sql-source.ts +166 -0
- package/src/sveltekit/sql.test.ts +80 -0
- package/src/sveltekit/sql.ts +131 -0
- package/src/sveltekit/transport.test.ts +175 -0
- package/src/sveltekit/transport.ts +254 -0
- package/src/sveltekit/types.ts +11 -0
- package/src/upgrade-prompt.ts +149 -148
|
@@ -0,0 +1,1273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* emitStudioProject - turn a `StudioProject` (the visual designer's model) into
|
|
3
|
+
* the source files of a runnable app: `schemas.ts` + `data.ts` from the
|
|
4
|
+
* entities, and one self-contained `+page.svelte` per SCREEN that composes the
|
|
5
|
+
* screen's BLOCKS (grid, edit form, chart, dashboard, KPI) with their config,
|
|
6
|
+
* plus the nav shell + home.
|
|
7
|
+
*
|
|
8
|
+
* Pure + node-safe (studio subtree), reusing the create-studio emit pipeline for
|
|
9
|
+
* schemas/data and building each screen page directly from `@svgrid/grid` +
|
|
10
|
+
* `@svgrid/enterprise` (no EntityScreen dependency, so the output is
|
|
11
|
+
* self-contained).
|
|
12
|
+
*/
|
|
13
|
+
import type { GeneratedFile } from './scaffold.js'
|
|
14
|
+
import type { Block, EntityDataSource, FilterPanelConfig, GridConfig, KpiConfig, PivotConfig, RecordConfig, RowAction, Screen, StudioProject } from './project.js'
|
|
15
|
+
import { blockColumns, entityDataSource, flattenBlocks, serializeProject } from './project.js'
|
|
16
|
+
import { resolveThemeTokens, isDarkTheme } from './themes.js'
|
|
17
|
+
import type { EntityField, EntitySchema } from '../schema.js'
|
|
18
|
+
import { emitEntityModules, homeFile, layoutFile, lookupVar, namesFor, relationDisplayFields, type NavItem } from './emit-schema.js'
|
|
19
|
+
|
|
20
|
+
const has = (blocks: Block[], kind: Block['config']['kind']) => blocks.some((b) => b.config.kind === kind)
|
|
21
|
+
|
|
22
|
+
/** Per-Tabs-block active-tab state var + a stable tab id. */
|
|
23
|
+
const tabsStateVar = (blockId: string) => `activeTab_${blockId.replace(/[^a-zA-Z0-9_$]/g, '_')}`
|
|
24
|
+
const tabId = (blockId: string, i: number) => `${blockId}-${i}`
|
|
25
|
+
|
|
26
|
+
/** A state var holding a master-detail block's child rows (loaded in full). */
|
|
27
|
+
const mdChildVar = (childName: string) => `md_${childName.replace(/[^a-zA-Z0-9]/g, '_')}_rows`
|
|
28
|
+
|
|
29
|
+
/** The visible grid columns: configured order, per-column header/width/align overrides, editability per mode. */
|
|
30
|
+
function gridColumnsExpr(schemaVar: string, block: Block): string {
|
|
31
|
+
if (block.config.kind !== 'grid') return `schemaToColumns(${schemaVar})`
|
|
32
|
+
const cfg = block.config
|
|
33
|
+
const visible = cfg.columns.filter((c) => c.show)
|
|
34
|
+
// Only inline editing keeps cells editable; form / read-only grids are not editable in place.
|
|
35
|
+
const editablePart = cfg.editing === 'inline' ? '' : ', editable: false'
|
|
36
|
+
if (visible.length === 0) {
|
|
37
|
+
return editablePart ? `schemaToColumns(${schemaVar}).map((c) => ({ ...c${editablePart} }))` : `schemaToColumns(${schemaVar})`
|
|
38
|
+
}
|
|
39
|
+
const order = visible.map((c) => `'${c.field}'`)
|
|
40
|
+
const ovEntries = visible
|
|
41
|
+
.map((c) => {
|
|
42
|
+
const parts: string[] = []
|
|
43
|
+
if (c.header) parts.push(`header: ${JSON.stringify(c.header)}`)
|
|
44
|
+
if (c.width != null) parts.push(`width: ${c.width}`)
|
|
45
|
+
if (c.align) parts.push(`align: '${c.align}'`)
|
|
46
|
+
return parts.length ? `'${c.field}': { ${parts.join(', ')} }` : null
|
|
47
|
+
})
|
|
48
|
+
.filter((x): x is string => !!x)
|
|
49
|
+
const ovDecl = ovEntries.length ? ` const ov: Record<string, Partial<(typeof all)[number]>> = { ${ovEntries.join(', ')} };` : ''
|
|
50
|
+
const ovPart = ovEntries.length ? ', ...(ov[String(c.field)] ?? {})' : ''
|
|
51
|
+
const inner = `[${order.join(', ')}].map((f) => all.find((c) => c.field === f)).filter((c): c is (typeof all)[number] => !!c)`
|
|
52
|
+
const mapped = editablePart || ovPart ? `${inner}.map((c) => ({ ...c${editablePart}${ovPart} }))` : inner
|
|
53
|
+
return `(() => { const all = schemaToColumns(${schemaVar});${ovDecl} return ${mapped} })()`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Markup for one block inside the screen grid. `ctx.hasRecord` tells a grid to
|
|
57
|
+
* publish its clicked row into `selectedRecord` for a sibling record panel. */
|
|
58
|
+
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 } = { hasRecord: false }): string {
|
|
59
|
+
// A block's display label: localized via $t('block.<id>', 'literal') when i18n is on.
|
|
60
|
+
const tLabel = (label: string, key: string) => (ctx.i18n ? `{$t('block.${key}', ${JSON.stringify(label)})}` : label)
|
|
61
|
+
const span = `style="grid-column: span ${blockColumns(block)}; min-width: 0"`
|
|
62
|
+
const cfg = block.config
|
|
63
|
+
switch (cfg.kind) {
|
|
64
|
+
case 'grid': {
|
|
65
|
+
const colVar = `columns_${block.id.replace(/-/g, '_')}`
|
|
66
|
+
const emptyMsg = `No ${(entity.label ?? entity.name).toLowerCase()} yet.`
|
|
67
|
+
const lines = [`data={view.rows}`, `columns={${colVar}}`, `loading={view.loading}`, `loadingOverlay`, `emptyMessage=${JSON.stringify(emptyMsg)}`, `fitColumns`]
|
|
68
|
+
lines.push(`enableRowSummaries={${cfg.rowSummaries ? 'true' : 'false'}}`)
|
|
69
|
+
if (cfg.striped) lines.push(`zebraRows`)
|
|
70
|
+
if (cfg.cellSelection) lines.push(`enableCellSelection`)
|
|
71
|
+
if (cfg.density !== 'normal') lines.push(`rowHeight={${cfg.density === 'compact' ? 28 : 46}}`)
|
|
72
|
+
const leftPins = cfg.columns.filter((c) => c.show && c.pin === 'left').map((c) => `'${c.field}'`)
|
|
73
|
+
const rightPins = cfg.columns.filter((c) => c.show && c.pin === 'right').map((c) => `'${c.field}'`)
|
|
74
|
+
if (leftPins.length || rightPins.length) {
|
|
75
|
+
const pins = [leftPins.length ? `left: [${leftPins.join(', ')}]` : '', rightPins.length ? `right: [${rightPins.join(', ')}]` : ''].filter(Boolean).join(', ')
|
|
76
|
+
lines.push(`initialColumnPinning={{ ${pins} }}`, `columnVirtualization={false}`)
|
|
77
|
+
}
|
|
78
|
+
if (cfg.selectable) lines.push(`showRowSelection`)
|
|
79
|
+
if (cfg.sortable) lines.push(`sortable`, `externalSort`, `onSortingChange={(s) => controller.setSort(s)}`)
|
|
80
|
+
if (cfg.filterable) lines.push(`filterable`, `showGlobalFilter`, `externalFilter`, `onFiltersChange={(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 }])) })}`)
|
|
81
|
+
// RBAC: gate the edit affordances on the update permission (server also enforces).
|
|
82
|
+
const canUpdate = ctx.accessEnabled ? `can($currentRole, 'update')` : 'true'
|
|
83
|
+
if (cfg.editing === 'form') lines.push(ctx.accessEnabled ? `onRowDoubleClick={(e) => { if (${canUpdate}) editing = e.row }}` : `onRowDoubleClick={(e) => (editing = e.row)}`)
|
|
84
|
+
if (cfg.editing === 'inline') lines.push(`onCellValueChange={(e) => { ${ctx.accessEnabled ? `if (!${canUpdate}) return; ` : ''}const row = view.rows[e.rowIndex]; if (row) controller.updateRow(String((row as Record<string, unknown>)[idField]), { [e.columnId]: e.newValue } as Partial<${typeName}>) }}`)
|
|
85
|
+
// Drill-through: a row click navigates to another screen, filtered by the
|
|
86
|
+
// clicked value. Takes precedence over a record-panel selection.
|
|
87
|
+
const linkRoute = cfg.rowLink && ctx.routeById?.get(cfg.rowLink.screen)
|
|
88
|
+
if (cfg.rowLink && linkRoute) {
|
|
89
|
+
const src = cfg.rowLink.sourceField ?? entity.idField ?? entity.fields.find((f) => f.primaryKey)?.field ?? 'id'
|
|
90
|
+
lines.push(`onRowClick={(e) => goto('/${linkRoute}?${cfg.rowLink.targetField}=' + encodeURIComponent(String((e.row as Record<string, unknown>)[${jsStr(src)}] ?? '')))}`)
|
|
91
|
+
} else if (ctx.hasRecord) {
|
|
92
|
+
lines.push(`onRowClick={(e) => (selectedRecord = e.row)}`)
|
|
93
|
+
}
|
|
94
|
+
if (cfg.paginated !== false) {
|
|
95
|
+
lines.push(`showPagination`, `externalPagination`, `rowCount={view.total}`, `pageIndex={view.pageIndex}`, `pageSize={view.pageSize}`, `onPaginationChange={({ pageIndex, pageSize }) => (pageSize !== view.pageSize ? controller.setPageSize(pageSize) : controller.setPage(pageIndex))}`)
|
|
96
|
+
if (cfg.paginationPosition && cfg.paginationPosition !== 'bottom') lines.push(`paginationPosition="${cfg.paginationPosition}"`)
|
|
97
|
+
const opts = cfg.pageSizeOptions
|
|
98
|
+
if (opts && opts.length && (opts.length !== 4 || opts.join(',') !== '10,25,50,100')) lines.push(`pageSizeOptions={[${opts.join(', ')}]}`)
|
|
99
|
+
}
|
|
100
|
+
// No-code conditional formatting -> the grid's rule engine.
|
|
101
|
+
const cf = conditionalFormatsExpr(cfg)
|
|
102
|
+
if (cf) lines.push(`conditionalFormats={${cf}}`)
|
|
103
|
+
lines.push(`containerHeight={${block.height ?? 360}}`)
|
|
104
|
+
return ` <div ${span}>
|
|
105
|
+
<SvGrid
|
|
106
|
+
${lines.join('\n ')}
|
|
107
|
+
/>
|
|
108
|
+
</div>`
|
|
109
|
+
}
|
|
110
|
+
case 'chart': {
|
|
111
|
+
const drillRoute = cfg.drillScreen && ctx.routeById?.get(cfg.drillScreen)
|
|
112
|
+
const onDrill = drillRoute ? ` onDrill={(cat) => goto('/${drillRoute}?${cfg.dimension}=' + encodeURIComponent(String(cat)))}` : ''
|
|
113
|
+
return ` <div ${span}>
|
|
114
|
+
<SvSchemaChart schema={${schemaVar}} rows={allRows} dimension="${cfg.dimension}"${cfg.measure ? ` measure="${cfg.measure}"` : ''} reduce="${cfg.reduce}" type="${cfg.type}"${block.height ? ` height={${block.height}}` : ''} controls={false} accent="var(--sg-accent)"${onDrill} />
|
|
115
|
+
</div>`
|
|
116
|
+
}
|
|
117
|
+
case 'dashboard':
|
|
118
|
+
return ` <div ${span}>
|
|
119
|
+
<SvSchemaDashboard schema={${schemaVar}} rows={allRows} />
|
|
120
|
+
</div>`
|
|
121
|
+
case 'kpi': {
|
|
122
|
+
const measurePart = cfg.measure ? `measure: '${cfg.measure}', ` : ''
|
|
123
|
+
const valueNum = `reduceValue(allRows, { ${measurePart}reduce: '${cfg.reduce}' })`
|
|
124
|
+
// Value formatting: explicit format wins, else auto ("$" for money measures).
|
|
125
|
+
const money = cfg.measure ? /\$/.test(entity.fields.find((f) => f.field === cfg.measure)?.label ?? '') : false
|
|
126
|
+
const fmt = cfg.format ?? 'auto'
|
|
127
|
+
const valueExpr = fmt === 'auto'
|
|
128
|
+
? `${money ? "'$' + " : ''}(${valueNum}).toLocaleString(undefined, { maximumFractionDigits: 1 })`
|
|
129
|
+
: `formatKpiValue(${valueNum}, '${fmt}')`
|
|
130
|
+
const rows: string[] = [
|
|
131
|
+
` <div class="kpi__head"><span class="kpi__label">${tLabel(cfg.label, block.id)}</span></div>`,
|
|
132
|
+
` <strong class="kpi__value">{${valueExpr}}</strong>`,
|
|
133
|
+
]
|
|
134
|
+
// Target: a "% of target" chip (green at/over target).
|
|
135
|
+
if (cfg.target != null && cfg.target !== 0) {
|
|
136
|
+
rows.push(` <span class="kpi__delta" class:is-up={${valueNum} >= ${cfg.target}}>{Math.round(${valueNum} / ${cfg.target} * 100)}% of target</span>`)
|
|
137
|
+
}
|
|
138
|
+
// Trend: an inline sparkline over `trendField`, with a first-to-last delta chip.
|
|
139
|
+
if (cfg.trendField) {
|
|
140
|
+
const tReduce = cfg.trendReduce ?? cfg.reduce
|
|
141
|
+
const seriesExpr = `kpiSeries(allRows, { trendField: '${cfg.trendField}', ${measurePart}reduce: '${tReduce}' })`
|
|
142
|
+
rows.push(` {#if ${seriesExpr}.length > 1}
|
|
143
|
+
{@const _s = ${seriesExpr}}
|
|
144
|
+
{@const _d = seriesDelta(_s)}
|
|
145
|
+
{#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}
|
|
146
|
+
<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>
|
|
147
|
+
{/if}`)
|
|
148
|
+
}
|
|
149
|
+
return ` <div ${span} class="kpi">\n${rows.join('\n')}\n </div>`
|
|
150
|
+
}
|
|
151
|
+
case 'gauge': {
|
|
152
|
+
const gexpr = `reduceValue(allRows, { ${cfg.measure ? `measure: '${cfg.measure}', ` : ''}reduce: '${cfg.reduce}' })`
|
|
153
|
+
const unit = cfg.unit ? ` unit=${JSON.stringify(cfg.unit)}` : ''
|
|
154
|
+
return ` <div ${span} class="gaugecard">
|
|
155
|
+
<span class="kpi__label">${tLabel(cfg.label, block.id)}</span>
|
|
156
|
+
<SvGauge value={${gexpr}} min={${cfg.min}} max={${cfg.max}}${unit} size={172} />
|
|
157
|
+
</div>`
|
|
158
|
+
}
|
|
159
|
+
case 'tree': {
|
|
160
|
+
if (!cfg.labelField || !cfg.parentField) {
|
|
161
|
+
return ` <div ${span}><!-- tree: set a label field + a self-referential parent field in the inspector --></div>`
|
|
162
|
+
}
|
|
163
|
+
const idExpr = `${schemaVar}.idField ?? 'id'`
|
|
164
|
+
return ` <div ${span} class="treecard">
|
|
165
|
+
<SvTree nodes={toTreeNodes(allRows as Record<string, unknown>[], ${idExpr}, ${JSON.stringify(cfg.labelField)}, ${JSON.stringify(cfg.parentField)})} />
|
|
166
|
+
</div>`
|
|
167
|
+
}
|
|
168
|
+
case 'tabs': {
|
|
169
|
+
const tabsVar = tabsStateVar(block.id)
|
|
170
|
+
// Localize tab labels via $t('tab.<id>', 'literal') when i18n is on (build the
|
|
171
|
+
// array as an expression so the labels can be function calls).
|
|
172
|
+
const items = ctx.i18n
|
|
173
|
+
? `[${cfg.tabs.map((t, i) => `{ id: ${JSON.stringify(tabId(block.id, i))}, label: $t('tab.${tabId(block.id, i)}', ${JSON.stringify(t.label)}) }`).join(', ')}]`
|
|
174
|
+
: JSON.stringify(cfg.tabs.map((t, i) => ({ id: tabId(block.id, i), label: t.label })))
|
|
175
|
+
const panels = cfg.tabs
|
|
176
|
+
.map((t, i) => {
|
|
177
|
+
const children = t.blocks.map((cb) => blockMarkup(entity, schemaVar, typeName, cb, resolve, ctx)).filter(Boolean).join('\n')
|
|
178
|
+
return ` {#if id === '${tabId(block.id, i)}'}
|
|
179
|
+
<div class="st-screen">
|
|
180
|
+
${children || ' <p style="color: var(--sg-muted, #94a3b8); font-size: 13px; padding: 10px;">This tab is empty.</p>'}
|
|
181
|
+
</div>
|
|
182
|
+
{/if}`
|
|
183
|
+
})
|
|
184
|
+
.join('\n')
|
|
185
|
+
return ` <div ${span}>
|
|
186
|
+
<SvTabs tabs={${items}} value={${tabsVar}} onChange={(id) => (${tabsVar} = id)}>
|
|
187
|
+
{#snippet panel(id)}
|
|
188
|
+
${panels}
|
|
189
|
+
{/snippet}
|
|
190
|
+
</SvTabs>
|
|
191
|
+
</div>`
|
|
192
|
+
}
|
|
193
|
+
case 'master-detail': {
|
|
194
|
+
const child = cfg.childEntity ? resolve(cfg.childEntity) : undefined
|
|
195
|
+
if (!child || !cfg.foreignKey) {
|
|
196
|
+
return ` <div ${span}><!-- master-detail: set a child entity + foreign key in the inspector --></div>`
|
|
197
|
+
}
|
|
198
|
+
const cn = namesFor(child)
|
|
199
|
+
const childRows = mdChildVar(child.name)
|
|
200
|
+
// Optionally, a parent row drills into a detail screen (linkScreen) instead
|
|
201
|
+
// of expanding inline - the detail page shows the same children as a timeline.
|
|
202
|
+
const mdRoute = cfg.linkScreen ? ctx.routeById?.get(cfg.linkScreen) : undefined
|
|
203
|
+
const onParent = mdRoute ? ` onParentClick={(id) => goto('/${mdRoute}?id=' + encodeURIComponent(id))}` : ''
|
|
204
|
+
return ` <div ${span}>
|
|
205
|
+
<SvGridMasterDetail schema={${schemaVar}} data={allRows} detailSchema={${cn.schemaVar}} getChildren={(p) => ${childRows}.filter((c) => String((c as Record<string, unknown>)['${cfg.foreignKey}']) === String((p as Record<string, unknown>)[${schemaVar}.idField ?? 'id']))}${onParent}${block.height ? ` containerHeight={${block.height}}` : ''} />
|
|
206
|
+
</div>`
|
|
207
|
+
}
|
|
208
|
+
case 'pivot': {
|
|
209
|
+
const h = block.height ?? 460
|
|
210
|
+
return ` <div style="grid-column: span ${blockColumns(block)}; min-width: 0; height: ${h}px">
|
|
211
|
+
<SvPivotDesigner data={allRows} fields={${pivotFieldsExpr(entity)}} layout={${pivotLayoutExpr(cfg)}} />
|
|
212
|
+
</div>`
|
|
213
|
+
}
|
|
214
|
+
case 'filter':
|
|
215
|
+
return filterPanelMarkup(entity, block, cfg)
|
|
216
|
+
case 'record':
|
|
217
|
+
return recordPanelMarkup(entity, schemaVar, block, cfg)
|
|
218
|
+
case 'board': {
|
|
219
|
+
const h = block.height ?? 480
|
|
220
|
+
// A relation title/subtitle renders as the raw FK id unless pointed at the
|
|
221
|
+
// denormalized display field that withRelationLabels fills in (e.g. company).
|
|
222
|
+
// Compute against the RAW entity (ctx.rawEntity): the prepared `entity` already
|
|
223
|
+
// has the display columns appended, which would false-collide the naming and
|
|
224
|
+
// diverge from what withRelationLabels actually put on the rows.
|
|
225
|
+
const disp = relationDisplayFields(ctx.rawEntity ?? entity, resolve)
|
|
226
|
+
const asText = (field: string) => disp.get(field) ?? field
|
|
227
|
+
const badge = cfg.badgeField ? ` badgeField=${JSON.stringify(cfg.badgeField)}` : ''
|
|
228
|
+
const sub = cfg.subtitleField ? ` subtitleField=${JSON.stringify(asText(cfg.subtitleField))}` : ''
|
|
229
|
+
// A card click drills into a detail screen (openScreen), filtered by ?id.
|
|
230
|
+
const openRoute = cfg.openScreen ? ctx.routeById?.get(cfg.openScreen) : undefined
|
|
231
|
+
const onOpen = openRoute ? ` onOpen={(id) => goto('/${openRoute}?id=' + encodeURIComponent(id))}` : ''
|
|
232
|
+
// Dragging a card updates its groupBy value in the local row state (optimistic).
|
|
233
|
+
return ` <div style="grid-column: span ${blockColumns(block)}; min-width: 0">
|
|
234
|
+
<SvBoard schema={${schemaVar}} rows={allRows} loading={!allRowsReady} groupBy=${JSON.stringify(cfg.groupBy)} titleField=${JSON.stringify(asText(cfg.titleField))}${badge}${sub}${onOpen} height={${h}} onMove={(id, value) => { allRows = allRows.map((r) => String((r as Record<string, unknown>)[idField]) === String(id) ? ({ ...r, ['${cfg.groupBy}']: value }) : r) }} />
|
|
235
|
+
</div>`
|
|
236
|
+
}
|
|
237
|
+
case 'calendar': {
|
|
238
|
+
const h = block.height ?? 560
|
|
239
|
+
// Compute against the RAW entity (ctx.rawEntity): the prepared `entity` already
|
|
240
|
+
// has the display columns appended, which would false-collide the naming and
|
|
241
|
+
// diverge from what withRelationLabels actually put on the rows.
|
|
242
|
+
const disp = relationDisplayFields(ctx.rawEntity ?? entity, resolve)
|
|
243
|
+
const asText = (field: string) => disp.get(field) ?? field
|
|
244
|
+
const color = cfg.colorField ? ` colorField=${JSON.stringify(cfg.colorField)}` : ''
|
|
245
|
+
// An event click drills into a detail screen (openScreen), filtered by ?id.
|
|
246
|
+
const calRoute = cfg.openScreen ? ctx.routeById?.get(cfg.openScreen) : undefined
|
|
247
|
+
const onSelect = calRoute ? ` onSelect={(id) => goto('/${calRoute}?id=' + encodeURIComponent(id))}` : ''
|
|
248
|
+
return ` <div style="grid-column: span ${blockColumns(block)}; min-width: 0">
|
|
249
|
+
<SvSchedule schema={${schemaVar}} rows={allRows} loading={!allRowsReady} dateField=${JSON.stringify(cfg.dateField)} titleField=${JSON.stringify(asText(cfg.titleField))}${color}${onSelect} height={${h}} />
|
|
250
|
+
</div>`
|
|
251
|
+
}
|
|
252
|
+
case 'detail': {
|
|
253
|
+
const h = block.height
|
|
254
|
+
// Parent relation fields (title/subtitle/section) -> their display columns.
|
|
255
|
+
const disp = relationDisplayFields(ctx.rawEntity ?? entity, resolve)
|
|
256
|
+
const asText = (field: string) => disp.get(field) ?? field
|
|
257
|
+
const props: string[] = [`schema={${schemaVar}}`, `rows={allRows}`, `loading={!allRowsReady}`, `titleField=${JSON.stringify(asText(cfg.titleField))}`]
|
|
258
|
+
if (cfg.subtitleField) props.push(`subtitleField=${JSON.stringify(asText(cfg.subtitleField))}`)
|
|
259
|
+
if (cfg.statusField) props.push(`statusField=${JSON.stringify(cfg.statusField)}`)
|
|
260
|
+
if (cfg.metricFields?.length) props.push(`metricFields={${JSON.stringify(cfg.metricFields)}}`)
|
|
261
|
+
if (cfg.sections?.length) {
|
|
262
|
+
const secs = cfg.sections.map((s) => `{ label: ${JSON.stringify(s.label)}, fields: ${JSON.stringify(s.fields.map(asText))} }`).join(', ')
|
|
263
|
+
props.push(`sections={[${secs}]}`)
|
|
264
|
+
}
|
|
265
|
+
// Related child collections load into `md_<name>_rows` and filter by the FK.
|
|
266
|
+
const rels = (cfg.related ?? []).map((rel) => {
|
|
267
|
+
const child = rel.entity ? resolve(rel.entity) : undefined
|
|
268
|
+
if (!child || !rel.foreignKey) return null
|
|
269
|
+
const cn = namesFor(child)
|
|
270
|
+
const rawChild = ctx.rawResolve?.(rel.entity) ?? child
|
|
271
|
+
const cdisp = relationDisplayFields(rawChild, resolve)
|
|
272
|
+
const cAs = (f: string) => cdisp.get(f) ?? f
|
|
273
|
+
const label = rel.label ?? child.label ?? child.name
|
|
274
|
+
const titleF = cAs(rel.titleField ?? child.fields.find((f) => f.type === 'text' && !f.primaryKey)?.field ?? child.fields[0]?.field ?? 'id')
|
|
275
|
+
const parts = [`label: ${JSON.stringify(label)}`, `schema: ${cn.schemaVar}`, `rows: ${mdChildVar(child.name)}`, `foreignKey: ${JSON.stringify(rel.foreignKey)}`, `titleField: ${JSON.stringify(titleF)}`]
|
|
276
|
+
if (rel.parentField) parts.push(`parentField: ${JSON.stringify(rel.parentField)}`)
|
|
277
|
+
if (rel.subtitleField) parts.push(`subtitleField: ${JSON.stringify(cAs(rel.subtitleField))}`)
|
|
278
|
+
if (rel.dateField) parts.push(`dateField: ${JSON.stringify(rel.dateField)}`)
|
|
279
|
+
if (rel.statusField) parts.push(`statusField: ${JSON.stringify(rel.statusField)}`)
|
|
280
|
+
return `{ ${parts.join(', ')} }`
|
|
281
|
+
}).filter(Boolean)
|
|
282
|
+
if (rels.length) props.push(`related={[${rels.join(', ')}]}`)
|
|
283
|
+
// Open the record named by the URL `?id=` (set by a grid / board / calendar
|
|
284
|
+
// drill-through); stays switchable via the header dropdown.
|
|
285
|
+
props.push(`selectedId={$page.url.searchParams.get('id') ?? undefined}`)
|
|
286
|
+
if (h) props.push(`height={${h}}`)
|
|
287
|
+
return ` <div style="grid-column: span ${blockColumns(block)}; min-width: 0">
|
|
288
|
+
<SvRecordDetail ${props.join(' ')} />
|
|
289
|
+
</div>`
|
|
290
|
+
}
|
|
291
|
+
case 'lookup':
|
|
292
|
+
return ` <div ${span}><!-- lookup (${cfg.field}): shown in the edit form --></div>`
|
|
293
|
+
case 'form':
|
|
294
|
+
default:
|
|
295
|
+
return '' // the form is the edit modal, rendered after the screen grid
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// --- new-block helpers ------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
const jsStr = (s: string) => `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
|
|
302
|
+
|
|
303
|
+
/** A JS predicate expression (over `value`) for a conditional-format rule. */
|
|
304
|
+
function formatPredicate(op: string, value?: string | number): string | null {
|
|
305
|
+
const num = Number(value)
|
|
306
|
+
switch (op) {
|
|
307
|
+
case 'eq': return `String(value) === ${JSON.stringify(String(value ?? ''))}`
|
|
308
|
+
case 'ne': return `String(value) !== ${JSON.stringify(String(value ?? ''))}`
|
|
309
|
+
case 'lt': return `Number(value) < ${num}`
|
|
310
|
+
case 'lte': return `Number(value) <= ${num}`
|
|
311
|
+
case 'gt': return `Number(value) > ${num}`
|
|
312
|
+
case 'gte': return `Number(value) >= ${num}`
|
|
313
|
+
case 'contains': return `String(value).toLowerCase().includes(${JSON.stringify(String(value ?? '').toLowerCase())})`
|
|
314
|
+
case 'empty': return `value == null || value === ''`
|
|
315
|
+
case 'notEmpty': return `value != null && value !== ''`
|
|
316
|
+
default: return null
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
/** Compile a grid's no-code format rules into a `conditionalFormats` array literal. */
|
|
320
|
+
function conditionalFormatsExpr(cfg: GridConfig): string | null {
|
|
321
|
+
const entries: string[] = []
|
|
322
|
+
for (const r of cfg.formatRules ?? []) {
|
|
323
|
+
if ((r.op === 'lt' || r.op === 'lte' || r.op === 'gt' || r.op === 'gte') && !Number.isFinite(Number(r.value))) continue
|
|
324
|
+
const pred = formatPredicate(r.op, r.value)
|
|
325
|
+
if (!pred) continue
|
|
326
|
+
const style = [
|
|
327
|
+
r.background ? `background: ${jsStr(r.background)}` : '',
|
|
328
|
+
r.color ? `color: ${jsStr(r.color)}` : '',
|
|
329
|
+
r.bold ? `fontWeight: 700` : '',
|
|
330
|
+
].filter(Boolean)
|
|
331
|
+
if (!style.length) continue
|
|
332
|
+
entries.push(`{ type: 'rule' as const, columns: [${jsStr(r.field)}], when: ({ value }: { value: unknown }) => ${pred}, ${style.join(', ')} }`)
|
|
333
|
+
}
|
|
334
|
+
return entries.length ? `[${entries.join(', ')}]` : null
|
|
335
|
+
}
|
|
336
|
+
const fieldLabel = (f: EntityField) => f.label ?? f.field
|
|
337
|
+
/** Stable per-block identifiers for a filter panel's state + apply function. */
|
|
338
|
+
const facetNames = (block: Block) => {
|
|
339
|
+
const s = block.id.replace(/-/g, '_')
|
|
340
|
+
return { state: `facet_${s}`, apply: `applyFacet_${s}` }
|
|
341
|
+
}
|
|
342
|
+
const filterFieldsOf = (entity: EntitySchema, cfg: FilterPanelConfig): EntityField[] =>
|
|
343
|
+
cfg.fields.map((name) => entity.fields.find((f) => f.field === name)).filter((f): f is EntityField => !!f)
|
|
344
|
+
/** Normalise a field's enum options to `{ value, label }`. */
|
|
345
|
+
function enumOpts(f: EntityField): { value: string; label: string }[] {
|
|
346
|
+
return (f.options ?? []).map((o) =>
|
|
347
|
+
o && typeof o === 'object'
|
|
348
|
+
? { value: String((o as { value: unknown }).value), label: String((o as { label?: unknown; value: unknown }).label ?? (o as { value: unknown }).value) }
|
|
349
|
+
: { value: String(o), label: String(o) },
|
|
350
|
+
)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Every non-key, aggregatable field as a PivotField literal (number -> measure). */
|
|
354
|
+
function pivotFieldsExpr(entity: EntitySchema): string {
|
|
355
|
+
const pk = entity.idField ?? entity.fields.find((f) => f.primaryKey)?.field
|
|
356
|
+
const items = entity.fields
|
|
357
|
+
.filter((f) => f.field !== pk && f.type !== 'json' && f.type !== 'relation')
|
|
358
|
+
.map((f) => {
|
|
359
|
+
const kind = f.type === 'number' ? 'measure' : 'dimension'
|
|
360
|
+
const agg = f.type === 'number' ? `, defaultAgg: 'sum'` : ''
|
|
361
|
+
return `{ field: ${jsStr(f.field)}, label: ${jsStr(fieldLabel(f))}, kind: '${kind}'${agg} }`
|
|
362
|
+
})
|
|
363
|
+
return `[${items.join(', ')}]`
|
|
364
|
+
}
|
|
365
|
+
/** The initial pivot layout literal from the block config. */
|
|
366
|
+
function pivotLayoutExpr(cfg: PivotConfig): string {
|
|
367
|
+
const rows = cfg.rows.map(jsStr).join(', ')
|
|
368
|
+
const cols = cfg.cols.map(jsStr).join(', ')
|
|
369
|
+
const values = cfg.measure ? `[{ field: ${jsStr(cfg.measure)}, agg: '${cfg.aggregate}' }]` : '[]'
|
|
370
|
+
return `{ rows: [${rows}], cols: [${cols}], values: ${values}, filters: [] }`
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** The `<script>` state + apply() for one filter panel. */
|
|
374
|
+
function filterPanelState(entity: EntitySchema, block: Block, cfg: FilterPanelConfig): string {
|
|
375
|
+
const { state, apply } = facetNames(block)
|
|
376
|
+
const assigns = filterFieldsOf(entity, cfg).map((f) => {
|
|
377
|
+
const key = jsStr(f.field)
|
|
378
|
+
if (f.type === 'boolean') return ` if (v[${key}] === 'true' || v[${key}] === 'false') c[${key}] = { operator: 'equals', value: v[${key}] === 'true' }`
|
|
379
|
+
if (f.type === 'enum') return ` if (v[${key}]) c[${key}] = { operator: 'equals', value: v[${key}] }`
|
|
380
|
+
return ` if (v[${key}]) c[${key}] = { operator: 'contains', value: v[${key}] }`
|
|
381
|
+
}).join('\n')
|
|
382
|
+
return `let ${state} = $state<Record<string, string>>({})
|
|
383
|
+
function ${apply}() {
|
|
384
|
+
const v = ${state}
|
|
385
|
+
const c: Record<string, { operator: 'equals' | 'contains'; value: unknown }> = {}
|
|
386
|
+
${assigns}
|
|
387
|
+
controller.setFilter({ columns: c })
|
|
388
|
+
}`
|
|
389
|
+
}
|
|
390
|
+
/** The faceted filter sidebar markup, wired to its facet state. */
|
|
391
|
+
function filterPanelMarkup(entity: EntitySchema, block: Block, cfg: FilterPanelConfig): string {
|
|
392
|
+
const { state, apply } = facetNames(block)
|
|
393
|
+
const controls = filterFieldsOf(entity, cfg).map((f) => {
|
|
394
|
+
const set = `${state}[${jsStr(f.field)}] = e.currentTarget.value; ${apply}()`
|
|
395
|
+
if (f.type === 'enum') {
|
|
396
|
+
const opts = enumOpts(f).map((o) => `<option value="${o.value}">${o.label}</option>`).join('')
|
|
397
|
+
return ` <label class="st-filter__row"><span>${fieldLabel(f)}</span>
|
|
398
|
+
<select onchange={(e) => { ${set} }}><option value="">Any</option>${opts}</select>
|
|
399
|
+
</label>`
|
|
400
|
+
}
|
|
401
|
+
if (f.type === 'boolean') {
|
|
402
|
+
return ` <label class="st-filter__row"><span>${fieldLabel(f)}</span>
|
|
403
|
+
<select onchange={(e) => { ${set} }}><option value="">Any</option><option value="true">Yes</option><option value="false">No</option></select>
|
|
404
|
+
</label>`
|
|
405
|
+
}
|
|
406
|
+
return ` <label class="st-filter__row"><span>${fieldLabel(f)}</span>
|
|
407
|
+
<input type="search" placeholder="Search…" oninput={(e) => { ${set} }} />
|
|
408
|
+
</label>`
|
|
409
|
+
}).join('\n')
|
|
410
|
+
return ` <aside style="grid-column: span ${blockColumns(block)}; min-width: 0" class="st-filter">
|
|
411
|
+
<div class="st-filter__title">${cfg.title ?? 'Filters'}</div>
|
|
412
|
+
${controls}
|
|
413
|
+
</aside>`
|
|
414
|
+
}
|
|
415
|
+
/** A `{#snippet}` rendering a grid row's action buttons (edit / delete / navigate).
|
|
416
|
+
* Buttons are unrolled statically; RBAC gates edit/delete when `gate` is set. */
|
|
417
|
+
function rowActionsSnippet(idSafe: string, typeName: string, entity: EntitySchema, actions: RowAction[], routeById: Map<string, string>, gate: boolean): string {
|
|
418
|
+
const idField = entity.idField ?? entity.fields.find((f) => f.primaryKey)?.field ?? 'id'
|
|
419
|
+
const rowId = `String((row as Record<string, unknown>)[${jsStr(idField)}] ?? '')`
|
|
420
|
+
const buttons = actions.map((a) => {
|
|
421
|
+
if (a.kind === 'edit') {
|
|
422
|
+
const btn = `<button type="button" class="st-rowaction" onclick={(e) => { e.stopPropagation(); editing = row }}>${a.label ?? 'Edit'}</button>`
|
|
423
|
+
return gate ? `{#if can($currentRole, 'update')}${btn}{/if}` : btn
|
|
424
|
+
}
|
|
425
|
+
if (a.kind === 'delete') {
|
|
426
|
+
const btn = `<button type="button" class="st-rowaction st-rowaction--danger" onclick={(e) => { e.stopPropagation(); controller.deleteRow(${rowId}) }}>${a.label ?? 'Delete'}</button>`
|
|
427
|
+
return gate ? `{#if can($currentRole, 'delete')}${btn}{/if}` : btn
|
|
428
|
+
}
|
|
429
|
+
const route = a.screen && routeById.get(a.screen)
|
|
430
|
+
if (!route) return ''
|
|
431
|
+
const src = a.sourceField ?? idField
|
|
432
|
+
const target = a.targetField ?? idField
|
|
433
|
+
return `<button type="button" class="st-rowaction" onclick={(e) => { e.stopPropagation(); goto('/${route}?${target}=' + encodeURIComponent(String((row as Record<string, unknown>)[${jsStr(src)}] ?? ''))) }}>${a.label ?? 'Open'}</button>`
|
|
434
|
+
}).filter(Boolean).join('\n ')
|
|
435
|
+
return `{#snippet rowActions_${idSafe}({ row }: { row: ${typeName} })}
|
|
436
|
+
<div class="st-rowactions">
|
|
437
|
+
${buttons}
|
|
438
|
+
</div>
|
|
439
|
+
{/snippet}`
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** The record detail panel markup: an inline edit form (editable) or a read-only
|
|
443
|
+
* field list, bound to `selectedRecord`. */
|
|
444
|
+
function recordPanelMarkup(entity: EntitySchema, schemaVar: string, block: Block, cfg: RecordConfig): string {
|
|
445
|
+
const span = `style="grid-column: span ${blockColumns(block)}; min-width: 0"`
|
|
446
|
+
let inner: string
|
|
447
|
+
if (cfg.editable) {
|
|
448
|
+
const pres = cfg.presentation ?? 'inline'
|
|
449
|
+
// Modal / drawer float over the page (shown only while a row is selected);
|
|
450
|
+
// inline lives in the block, with a prompt when nothing is selected.
|
|
451
|
+
inner = pres === 'inline'
|
|
452
|
+
? ` {#if selectedRecord}
|
|
453
|
+
<SvGridEditPanel schema={${schemaVar}} row={selectedRecord} presentation="inline" onSubmit={saveRecord} onCancel={() => (selectedRecord = null)} />
|
|
454
|
+
{:else}
|
|
455
|
+
<p class="st-hint">Select a row to see its details.</p>
|
|
456
|
+
{/if}`
|
|
457
|
+
: ` <p class="st-hint">Select a row to open its ${pres === 'drawer' ? 'editor drawer' : 'edit dialog'}.</p>
|
|
458
|
+
{#if selectedRecord}
|
|
459
|
+
<SvGridEditPanel schema={${schemaVar}} row={selectedRecord} presentation="${pres}" onSubmit={saveRecord} onCancel={() => (selectedRecord = null)} />
|
|
460
|
+
{/if}`
|
|
461
|
+
} else {
|
|
462
|
+
const pk = entity.idField ?? entity.fields.find((f) => f.primaryKey)?.field
|
|
463
|
+
const chosen = cfg.fields && cfg.fields.length
|
|
464
|
+
? entity.fields.filter((f) => cfg.fields!.includes(f.field))
|
|
465
|
+
: entity.fields.filter((f) => f.field !== pk)
|
|
466
|
+
const rows = chosen.map((f) => ` <div class="st-record__row"><dt>${fieldLabel(f)}</dt><dd>{String((selectedRecord as Record<string, unknown>)[${jsStr(f.field)}] ?? '')}</dd></div>`).join('\n')
|
|
467
|
+
inner = ` {#if selectedRecord}
|
|
468
|
+
<dl class="st-record">
|
|
469
|
+
${rows}
|
|
470
|
+
</dl>
|
|
471
|
+
{:else}
|
|
472
|
+
<p class="st-hint">Select a row to see its details.</p>
|
|
473
|
+
{/if}`
|
|
474
|
+
}
|
|
475
|
+
return ` <div ${span} class="st-record-card">
|
|
476
|
+
${inner}
|
|
477
|
+
</div>`
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** A self-contained screen page composing the screen's blocks. When `accessEnabled`
|
|
481
|
+
* the page gates create / update affordances by the current role (server still
|
|
482
|
+
* enforces via the route's `authorize`). */
|
|
483
|
+
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 {
|
|
484
|
+
const n = namesFor(schema)
|
|
485
|
+
const label = schema.label ?? n.label
|
|
486
|
+
const blocks = screen.blocks
|
|
487
|
+
// Display blocks (chart/kpi/gauge/pivot/tree/dashboard) can be nested inside Tabs;
|
|
488
|
+
// flatten so their imports + data loading are detected. Controller-bound kinds
|
|
489
|
+
// (grid/form/filter/record) only live at the top level.
|
|
490
|
+
const allBlocks = flattenBlocks(blocks)
|
|
491
|
+
const hasGrid = has(blocks, 'grid')
|
|
492
|
+
const hasForm = has(blocks, 'form') // legacy standalone form block
|
|
493
|
+
// Editing is a Grid property: a grid with editing 'form' opens the edit panel.
|
|
494
|
+
const gridConfigs = blocks.map((b) => b.config).filter((c): c is GridConfig => c.kind === 'grid')
|
|
495
|
+
const formGrid = gridConfigs.find((c) => c.editing === 'form')
|
|
496
|
+
// An "Edit" row-action needs the edit modal + state even on a non-form grid.
|
|
497
|
+
const hasEditAction = gridConfigs.some((c) => c.rowActions?.some((a) => a.kind === 'edit'))
|
|
498
|
+
const hasRowActions = gridConfigs.some((c) => (c.rowActions?.length ?? 0) > 0)
|
|
499
|
+
const wantsForm = !!formGrid || hasForm || hasEditAction
|
|
500
|
+
// An unpaginated grid loads everything (one big page); else its configured size.
|
|
501
|
+
const gridPageSize = gridConfigs[0] ? (gridConfigs[0].paginated !== false ? gridConfigs[0].pageSize : 1000) : 10
|
|
502
|
+
const formPres = formGrid?.formPresentation ?? 'modal'
|
|
503
|
+
const hasPivot = has(allBlocks, 'pivot')
|
|
504
|
+
const hasFilter = has(blocks, 'filter')
|
|
505
|
+
const hasRecord = has(blocks, 'record')
|
|
506
|
+
const recordEditable = blocks.some((b) => b.config.kind === 'record' && b.config.editable)
|
|
507
|
+
// Filter panels drive the grid's controller; record panels read the grid's
|
|
508
|
+
// selection - both need the controller even if the grid isn't editable.
|
|
509
|
+
const needsController = hasGrid || wantsForm || hasFilter || hasRecord
|
|
510
|
+
const hasAgg = has(allBlocks, 'chart') || has(allBlocks, 'dashboard') || has(allBlocks, 'kpi') || has(allBlocks, 'gauge') || has(allBlocks, 'tree')
|
|
511
|
+
const relationFields = schema.fields.filter((f) => f.type === 'relation' && f.relation)
|
|
512
|
+
|
|
513
|
+
// Distinct, resolvable child entities referenced by master-detail blocks, and by
|
|
514
|
+
// a detail page's related child collections (both load the child table into a
|
|
515
|
+
// `md_<name>_rows` state var + filter it by the foreign key at render time).
|
|
516
|
+
const mdChildren = new Map<string, EntitySchema>()
|
|
517
|
+
for (const b of blocks) {
|
|
518
|
+
if (b.config.kind === 'master-detail' && b.config.childEntity && b.config.foreignKey) {
|
|
519
|
+
const c = resolve(b.config.childEntity)
|
|
520
|
+
if (c) mdChildren.set(c.name, c)
|
|
521
|
+
}
|
|
522
|
+
if (b.config.kind === 'detail') {
|
|
523
|
+
for (const rel of b.config.related ?? []) {
|
|
524
|
+
if (!rel.entity || !rel.foreignKey) continue
|
|
525
|
+
const c = resolve(rel.entity)
|
|
526
|
+
if (c) mdChildren.set(c.name, c)
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
const childList = [...mdChildren.values()]
|
|
531
|
+
const hasMD = childList.length > 0
|
|
532
|
+
// The pivot reads the whole table (like charts / dashboards).
|
|
533
|
+
const needsAllRows = hasAgg || hasMD || hasPivot || has(allBlocks, 'board') || has(allBlocks, 'calendar') || has(allBlocks, 'detail')
|
|
534
|
+
|
|
535
|
+
// --- imports ---
|
|
536
|
+
const gridSpecs: string[] = []
|
|
537
|
+
if (needsController) gridSpecs.push('SvGrid', 'createServerDataSource', ...(hasRowActions ? ['renderSnippet'] : []), 'type ServerState')
|
|
538
|
+
if (has(allBlocks, 'gauge')) gridSpecs.push('SvGauge')
|
|
539
|
+
if (has(allBlocks, 'tree')) gridSpecs.push('SvTree')
|
|
540
|
+
if (has(blocks, 'tabs')) gridSpecs.push('SvTabs')
|
|
541
|
+
const gridImports = gridSpecs.length ? `import { ${gridSpecs.join(', ')} } from '@svgrid/grid'\n ` : ''
|
|
542
|
+
const entImports: string[] = []
|
|
543
|
+
if (hasGrid) entImports.push('schemaToColumns')
|
|
544
|
+
if (wantsForm || (hasRecord && recordEditable)) entImports.push('SvGridEditPanel')
|
|
545
|
+
if (has(allBlocks, 'chart')) entImports.push('SvSchemaChart')
|
|
546
|
+
if (has(allBlocks, 'dashboard')) entImports.push('SvSchemaDashboard')
|
|
547
|
+
if (has(allBlocks, 'board')) entImports.push('SvBoard')
|
|
548
|
+
if (has(allBlocks, 'calendar')) entImports.push('SvSchedule')
|
|
549
|
+
if (has(allBlocks, 'detail')) entImports.push('SvRecordDetail')
|
|
550
|
+
if (has(allBlocks, 'kpi') || has(allBlocks, 'gauge')) entImports.push('reduceValue')
|
|
551
|
+
const kpiCfgs = allBlocks.filter((b) => b.config.kind === 'kpi').map((b) => b.config as KpiConfig)
|
|
552
|
+
if (kpiCfgs.some((c) => c.format && c.format !== 'auto')) entImports.push('formatKpiValue')
|
|
553
|
+
if (kpiCfgs.some((c) => c.trendField)) entImports.push('kpiSeries', 'sparklinePoints', 'seriesDelta')
|
|
554
|
+
if (hasMD) entImports.push('SvGridMasterDetail')
|
|
555
|
+
if (hasPivot) entImports.push('SvPivotDesigner')
|
|
556
|
+
// Dedupe: record + form both want SvGridEditPanel.
|
|
557
|
+
const entImport = entImports.length ? `import { ${[...new Set(entImports)].join(', ')} } from '@svgrid/enterprise'\n ` : ''
|
|
558
|
+
const lookupVars = relationFields.map((f) => lookupVar(schema, f.field))
|
|
559
|
+
const childSchemaVars = childList.map((c) => namesFor(c).schemaVar)
|
|
560
|
+
const childTypes = childList.map((c) => namesFor(c).type)
|
|
561
|
+
const childSourceVars = childList.map((c) => namesFor(c).sourceVar)
|
|
562
|
+
// Dedupe against the parent's own names so a self-referential master-detail
|
|
563
|
+
// (childEntity === this entity) doesn't emit a duplicate import specifier.
|
|
564
|
+
const schemaVarImports = [...new Set([n.schemaVar, ...childSchemaVars])]
|
|
565
|
+
const typeImports = [...new Set([n.type, ...childTypes])]
|
|
566
|
+
const dataImports = [...new Set([n.sourceVar, ...childSourceVars, ...(wantsForm ? [...lookupVars, 'nextId'] : [])])].filter(Boolean)
|
|
567
|
+
|
|
568
|
+
// Drill-through: this screen navigates out (goto) and/or is a drill target that
|
|
569
|
+
// reads URL query params matching its fields into an initial filter.
|
|
570
|
+
const usesGoto = blocks.some((b) =>
|
|
571
|
+
(b.config.kind === 'grid' && b.config.rowLink && routeById.has(b.config.rowLink.screen)) ||
|
|
572
|
+
(b.config.kind === 'grid' && b.config.rowActions?.some((a) => a.kind === 'navigate' && a.screen && routeById.has(a.screen))) ||
|
|
573
|
+
(b.config.kind === 'chart' && b.config.drillScreen && routeById.has(b.config.drillScreen)) ||
|
|
574
|
+
((b.config.kind === 'board' || b.config.kind === 'calendar') && b.config.openScreen != null && routeById.has(b.config.openScreen)) ||
|
|
575
|
+
(b.config.kind === 'master-detail' && b.config.linkScreen != null && routeById.has(b.config.linkScreen)))
|
|
576
|
+
const applyUrlFilters = drillEnabled && needsController
|
|
577
|
+
const filterableFieldNames = schema.fields.filter((f) => !f.primaryKey).map((f) => f.field)
|
|
578
|
+
// RBAC gates the UI only where there's a create/update affordance to gate.
|
|
579
|
+
const gatesUi = accessEnabled && (wantsForm || gridConfigs.some((c) => c.editing === 'inline') || hasRowActions)
|
|
580
|
+
|
|
581
|
+
// --- script body ---
|
|
582
|
+
const parts: string[] = []
|
|
583
|
+
if (needsController) {
|
|
584
|
+
const urlFilter = applyUrlFilters
|
|
585
|
+
? `\n const sp = $page.url.searchParams
|
|
586
|
+
const _cols: Record<string, { operator: 'equals'; value: string }> = {}
|
|
587
|
+
for (const _f of [${filterableFieldNames.map(jsStr).join(', ')}]) { const _v = sp.get(_f); if (_v != null) _cols[_f] = { operator: 'equals', value: _v } }
|
|
588
|
+
if (Object.keys(_cols).length) controller.setFilter({ columns: _cols })`
|
|
589
|
+
: ''
|
|
590
|
+
parts.push(`const idField = ${n.schemaVar}.idField ?? 'id'
|
|
591
|
+
let view = $state<ServerState<${n.type}>>({ rows: [], total: 0, loading: false, saving: false, error: null, pageIndex: 0, pageSize: ${gridPageSize}, pageCount: 1, sortModel: [], filterModel: {} })
|
|
592
|
+
const controller = createServerDataSource<${n.type}>(${n.sourceVar}, { pageSize: ${gridPageSize}, optimistic: true, getRowId: (r) => String((r as Record<string, unknown>)[idField]), onChange: (s) => (view = s) })
|
|
593
|
+
$effect(() => {${urlFilter}
|
|
594
|
+
controller.refresh(); return () => controller.dispose() })`)
|
|
595
|
+
}
|
|
596
|
+
const actionSnippets: string[] = []
|
|
597
|
+
for (const b of blocks) {
|
|
598
|
+
if (b.config.kind === 'grid') {
|
|
599
|
+
const idSafe = b.id.replace(/-/g, '_')
|
|
600
|
+
const colExpr = gridColumnsExpr(n.schemaVar, b)
|
|
601
|
+
let colValue = i18nEnabled ? `localizeCols(${colExpr}, ${JSON.stringify(schema.name)}, $t)` : colExpr
|
|
602
|
+
// An "edit" action needs the form; drop it if this grid has no edit modal.
|
|
603
|
+
const actions = (b.config.rowActions ?? []).filter((a) => a.kind !== 'edit' || wantsForm)
|
|
604
|
+
if (actions.length) {
|
|
605
|
+
// A synthetic action column (id, no field, cell renderer) - type-clean.
|
|
606
|
+
colValue = `[...(${colValue}), { id: '__actions', header: 'Actions', sortable: false, cell: (ctx) => renderSnippet(rowActions_${idSafe}, { row: ctx.row.original }) }]`
|
|
607
|
+
actionSnippets.push(rowActionsSnippet(idSafe, n.type, schema, actions, routeById, gatesUi))
|
|
608
|
+
}
|
|
609
|
+
const reactive = i18nEnabled || actions.length > 0
|
|
610
|
+
parts.push(`const columns_${idSafe} = ${reactive ? `$derived(${colValue})` : colValue}`)
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
if (needsAllRows) {
|
|
614
|
+
parts.push(`let allRows = $state<${n.type}[]>([])
|
|
615
|
+
let allRowsReady = $state(false)
|
|
616
|
+
async function loadAll() { allRows = [...(await ${n.sourceVar}.getRows({ startRow: 0, endRow: 1000, pageIndex: 0, pageSize: 1000, sortModel: [], filterModel: {} })).rows]; allRowsReady = true }
|
|
617
|
+
loadAll()`)
|
|
618
|
+
}
|
|
619
|
+
for (const c of childList) {
|
|
620
|
+
const cn = namesFor(c)
|
|
621
|
+
const v = mdChildVar(c.name)
|
|
622
|
+
parts.push(`let ${v} = $state<${cn.type}[]>([])
|
|
623
|
+
async function load_${v}() { ${v} = [...(await ${cn.sourceVar}.getRows({ startRow: 0, endRow: 1000, pageIndex: 0, pageSize: 1000, sortModel: [], filterModel: {} })).rows] }
|
|
624
|
+
load_${v}()`)
|
|
625
|
+
}
|
|
626
|
+
if (wantsForm) {
|
|
627
|
+
const lookupsProp = relationFields.length
|
|
628
|
+
? `\n const lookups = { ${relationFields.map((f, i) => `${f.field}: ${lookupVars[i]}`).join(', ')} }`
|
|
629
|
+
: ''
|
|
630
|
+
parts.push(`let editing = $state<${n.type} | null | undefined>(undefined)${lookupsProp}
|
|
631
|
+
async function save({ mode, id, values }: { mode: 'create' | 'edit'; id: string | null; values: Partial<${n.type}> }) {
|
|
632
|
+
if (mode === 'create') { await controller.createRow({ [idField]: nextId('${n.idPrefix}'), ...values } as Partial<${n.type}>); controller.setPage(view.pageCount - 1) }
|
|
633
|
+
else if (id) { await controller.updateRow(id, values) }
|
|
634
|
+
editing = undefined${needsAllRows ? '\n await loadAll()' : ''}
|
|
635
|
+
}`)
|
|
636
|
+
}
|
|
637
|
+
// Record panel: the row selected in the grid, plus (when editable) a save hook.
|
|
638
|
+
if (hasRecord) {
|
|
639
|
+
parts.push(`let selectedRecord = $state<${n.type} | null>(null)${recordEditable ? `
|
|
640
|
+
async function saveRecord({ id, values }: { mode: 'create' | 'edit'; id: string | null; values: Partial<${n.type}> }) {
|
|
641
|
+
if (id) { await controller.updateRow(id, values) }
|
|
642
|
+
selectedRecord = null${needsAllRows ? '\n await loadAll()' : ''}
|
|
643
|
+
}` : ''}`)
|
|
644
|
+
}
|
|
645
|
+
// Filter panel(s): one facet-state object + an apply() that rebuilds the whole
|
|
646
|
+
// filterModel (setFilter replaces it) and pushes it to the controller.
|
|
647
|
+
for (const b of blocks) {
|
|
648
|
+
if (b.config.kind !== 'filter') continue
|
|
649
|
+
parts.push(filterPanelState(schema, b, b.config))
|
|
650
|
+
}
|
|
651
|
+
// Tabs container(s): one active-tab state var per block (first tab active).
|
|
652
|
+
for (const b of blocks) {
|
|
653
|
+
if (b.config.kind !== 'tabs') continue
|
|
654
|
+
parts.push(`let ${tabsStateVar(b.id)} = $state('${tabId(b.id, 0)}')`)
|
|
655
|
+
}
|
|
656
|
+
// Tree block: fold the flat rows into SvTree nodes by a self-referential parent.
|
|
657
|
+
if (has(allBlocks, 'tree')) {
|
|
658
|
+
parts.push(`type TreeNode = { id: string; label: string; children: TreeNode[] }
|
|
659
|
+
function toTreeNodes(rows: Record<string, unknown>[], idField: string, labelField: string, parentField: string): TreeNode[] {
|
|
660
|
+
const byId = new Map<string, TreeNode>(rows.map((r) => [String(r[idField]), { id: String(r[idField]), label: String(r[labelField] ?? r[idField]), children: [] }]))
|
|
661
|
+
const roots: TreeNode[] = []
|
|
662
|
+
for (const r of rows) {
|
|
663
|
+
const node = byId.get(String(r[idField]))!
|
|
664
|
+
const pid = r[parentField] != null && r[parentField] !== '' ? String(r[parentField]) : null
|
|
665
|
+
if (pid && pid !== node.id && byId.has(pid)) byId.get(pid)!.children.push(node)
|
|
666
|
+
else roots.push(node)
|
|
667
|
+
}
|
|
668
|
+
return roots
|
|
669
|
+
}`)
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// --- markup ---
|
|
673
|
+
const newLabel = i18nEnabled ? `{$t('new.${schema.name}', ${JSON.stringify('+ New ' + label)})}` : `+ New ${label}`
|
|
674
|
+
const newBtn = `<button class="st-btn st-btn--primary" onclick={() => (editing = null)}>${newLabel}</button>`
|
|
675
|
+
const toolbar = wantsForm
|
|
676
|
+
? `<div class="st__toolbar">\n ${gatesUi ? `{#if can($currentRole, 'create')}${newBtn}{/if}` : newBtn}\n</div>\n\n`
|
|
677
|
+
: ''
|
|
678
|
+
const body = blocks.map((b) => blockMarkup(schema, n.schemaVar, n.type, b, resolve, { hasRecord, accessEnabled: gatesUi, routeById, i18n: i18nEnabled, rawEntity: rawSchema, rawResolve })).filter(Boolean).join('\n')
|
|
679
|
+
const modal = wantsForm
|
|
680
|
+
? `\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}`
|
|
681
|
+
: ''
|
|
682
|
+
const accessImport = gatesUi ? `import { currentRole, can } from '$lib/access'\n ` : ''
|
|
683
|
+
const i18nImport = i18nEnabled ? `import { t, localizeCols } from '$lib/i18n'\n ` : ''
|
|
684
|
+
const gotoImport = usesGoto ? `import { goto } from '$app/navigation'\n ` : ''
|
|
685
|
+
const pageImport = applyUrlFilters || has(allBlocks, 'detail') ? `import { page } from '$app/stores'\n ` : ''
|
|
686
|
+
const title = i18nEnabled ? `{$t('screen.${screen.id}', ${JSON.stringify(screen.title)})}` : screen.title
|
|
687
|
+
// Surface a failed data load (silent empty grid otherwise) with a retry.
|
|
688
|
+
const errorBanner = needsController
|
|
689
|
+
? `\n{#if view.error}
|
|
690
|
+
<div class="st-error" role="alert">
|
|
691
|
+
<span>Couldn't load data. {view.error instanceof Error ? view.error.message : String(view.error)}</span>
|
|
692
|
+
<button type="button" class="st-btn" onclick={() => controller.refresh()}>Retry</button>
|
|
693
|
+
</div>
|
|
694
|
+
{/if}\n`
|
|
695
|
+
: ''
|
|
696
|
+
|
|
697
|
+
return {
|
|
698
|
+
path: `src/routes/${screen.route}/+page.svelte`,
|
|
699
|
+
description: `${screen.title} screen (${blocks.map((b) => b.config.kind).join(', ') || 'empty'}).`,
|
|
700
|
+
contents: `<script lang="ts">
|
|
701
|
+
${gridImports}${entImport}${accessImport}${i18nImport}${gotoImport}${pageImport}import { ${schemaVarImports.join(', ')}, ${typeImports.map((t) => `type ${t}`).join(', ')} } from '$lib/schemas'
|
|
702
|
+
import { ${dataImports.join(', ')} } from '$lib/data'
|
|
703
|
+
|
|
704
|
+
${parts.join('\n\n ')}
|
|
705
|
+
</script>
|
|
706
|
+
|
|
707
|
+
<h1 class="st__title">${title}</h1>
|
|
708
|
+
${errorBanner}
|
|
709
|
+
${toolbar}<div class="st-screen">
|
|
710
|
+
${body}
|
|
711
|
+
</div>${modal}${actionSnippets.length ? '\n\n' + actionSnippets.join('\n\n') : ''}
|
|
712
|
+
${has(blocks, 'kpi') || has(blocks, 'gauge') || has(blocks, 'tree') ? `
|
|
713
|
+
<style>
|
|
714
|
+
.kpi { position: relative; display: flex; flex-direction: column; gap: 6px; padding: 16px 18px; background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); overflow: hidden; }
|
|
715
|
+
.kpi__head { display: flex; align-items: center; justify-content: space-between; }
|
|
716
|
+
.kpi__label { font-size: 12.5px; font-weight: 600; color: var(--sg-muted, #64748b); text-transform: uppercase; letter-spacing: 0.03em; }
|
|
717
|
+
.kpi__value { font-size: 28px; font-weight: 750; line-height: 1.1; color: var(--sg-fg, #0f172a); }
|
|
718
|
+
.kpi__delta { align-self: flex-start; display: inline-flex; align-items: center; gap: 3px; padding: 2px 8px; border-radius: 999px; font-size: 11.5px; font-weight: 700; background: color-mix(in srgb, var(--sg-muted, #64748b) 14%, transparent); color: var(--sg-muted, #64748b); }
|
|
719
|
+
.kpi__delta.is-up { background: color-mix(in srgb, #16a34a 15%, transparent); color: #16a34a; }
|
|
720
|
+
.kpi__delta.is-down { background: color-mix(in srgb, #dc2626 15%, transparent); color: #dc2626; }
|
|
721
|
+
.kpi__spark { width: 100%; height: 30px; margin-top: 2px; color: var(--sg-accent, #4f46e5); opacity: 0.85; }
|
|
722
|
+
.gaugecard { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 16px; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; }
|
|
723
|
+
.gaugecard .kpi__label { align-self: flex-start; }
|
|
724
|
+
.treecard { padding: 12px; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; }
|
|
725
|
+
</style>
|
|
726
|
+
` : ''}`,
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
export function emitStudioProject(project: StudioProject): GeneratedFile[] {
|
|
731
|
+
if (project.entities.length === 0) throw new Error('emitStudioProject: no entities to emit')
|
|
732
|
+
if (project.screens.length === 0) throw new Error('emitStudioProject: no screens to emit')
|
|
733
|
+
|
|
734
|
+
const sources: Record<string, EntityDataSource> = Object.fromEntries(
|
|
735
|
+
project.entities.map((e) => [e.name, entityDataSource(project, e.name)]),
|
|
736
|
+
)
|
|
737
|
+
const accessEnabled = project.access?.enabled === true && (project.access?.roles.length ?? 0) > 0
|
|
738
|
+
// Audit only fires on server routes, so it needs at least one SQL-bound entity.
|
|
739
|
+
const auditEnabled = project.audit === true && Object.values(sources).some((s) => s.kind === 'sql')
|
|
740
|
+
const i18nEnabled = project.i18n?.enabled === true && (project.i18n?.locales.length ?? 0) > 0
|
|
741
|
+
const { files, prepared } = emitEntityModules(project.entities, { sources, accessEnabled, auditEnabled })
|
|
742
|
+
const byName = new Map(prepared.map((s) => [s.name, s]))
|
|
743
|
+
// Raw (unprepared) entities keep their original field set - needed to derive
|
|
744
|
+
// relation display-field names that match withRelationLabels (the prepared
|
|
745
|
+
// schemas already carry the appended display columns, which would false-collide).
|
|
746
|
+
const rawByName = new Map(project.entities.map((e) => [e.name, e]))
|
|
747
|
+
const resolve = (name: string) => byName.get(name)
|
|
748
|
+
|
|
749
|
+
// Drill-through wiring: map screen id -> route, and detect whether any block
|
|
750
|
+
// navigates (so target screens read URL params into an initial filter).
|
|
751
|
+
const routeById = new Map(project.screens.map((s) => [s.id, s.route]))
|
|
752
|
+
const drillEnabled = project.screens.some((s) => s.blocks.some((b) =>
|
|
753
|
+
(b.config.kind === 'grid' && !!b.config.rowLink) || (b.config.kind === 'chart' && !!b.config.drillScreen)))
|
|
754
|
+
|
|
755
|
+
const pages: GeneratedFile[] = []
|
|
756
|
+
const seenRoute = new Set<string>()
|
|
757
|
+
for (const screen of project.screens) {
|
|
758
|
+
const schema = byName.get(screen.entity)
|
|
759
|
+
if (!schema) throw new Error(`emitStudioProject: screen "${screen.title}" references missing entity "${screen.entity}"`)
|
|
760
|
+
if (seenRoute.has(screen.route)) throw new Error(`emitStudioProject: duplicate route "/${screen.route}"`)
|
|
761
|
+
seenRoute.add(screen.route)
|
|
762
|
+
pages.push(screenPage(schema, rawByName.get(screen.entity) ?? schema, screen, resolve, (name) => rawByName.get(name), accessEnabled, i18nEnabled, routeById, drillEnabled))
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Nav: only screens flagged into the menu, ordered, with an optional custom label.
|
|
766
|
+
// Carry the screen id so RBAC can hide links the current role can't open.
|
|
767
|
+
const nav: NavItem[] = [...project.screens]
|
|
768
|
+
.filter((s) => s.nav?.show !== false)
|
|
769
|
+
.sort((a, b) => (a.nav?.order ?? 0) - (b.nav?.order ?? 0))
|
|
770
|
+
.map((s) => ({ href: `/${s.route}`, label: s.nav?.label ?? s.title, id: s.id }))
|
|
771
|
+
const accessFiles = accessEnabled ? [accessModule(project)] : []
|
|
772
|
+
const auditFiles = auditEnabled ? [auditModule(), auditRouteFile(), auditViewerPage()] : []
|
|
773
|
+
const navWithAudit = auditEnabled ? [...nav, { href: '/audit', label: 'Audit log', id: '__audit__' }] : nav
|
|
774
|
+
const i18nFiles = i18nEnabled ? [i18nModule(project)] : []
|
|
775
|
+
return [...files, ...accessFiles, ...auditFiles, ...i18nFiles, ...pages, layoutFile(navWithAudit, { accent: project.theme?.accent, shell: project.theme?.shell, title: project.title, themeVars: resolveThemeTokens(project.theme), dark: isDarkTheme(project.theme), access: accessEnabled, i18n: i18nEnabled }), homeFile(navWithAudit)]
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/** The default-locale (`en`) message catalog, keyed for nav, screen titles, the
|
|
779
|
+
* New button, and grid column headers - the strings the app renders. */
|
|
780
|
+
function buildMessages(project: StudioProject): Record<string, string> {
|
|
781
|
+
const m: Record<string, string> = {}
|
|
782
|
+
for (const s of project.screens) {
|
|
783
|
+
m[`screen.${s.id}`] = s.title
|
|
784
|
+
if (s.nav?.show !== false) m[`nav.${s.id}`] = s.nav?.label ?? s.title
|
|
785
|
+
}
|
|
786
|
+
for (const e of project.entities) {
|
|
787
|
+
m[`new.${e.name}`] = `+ New ${e.label ?? e.name}`
|
|
788
|
+
for (const f of e.fields) m[`col.${e.name}.${f.field}`] = f.label ?? f.field
|
|
789
|
+
}
|
|
790
|
+
if (project.audit === true) m['nav.__audit__'] = 'Audit log'
|
|
791
|
+
return m
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/** The localization module: locales, the current-locale store, the message
|
|
795
|
+
* catalog (default locale seeded, others left for translators), a reactive
|
|
796
|
+
* `t()` translator, and a `localizeCols` helper for grid headers. */
|
|
797
|
+
function i18nModule(project: StudioProject): GeneratedFile {
|
|
798
|
+
const cfg = project.i18n!
|
|
799
|
+
const locales = cfg.locales
|
|
800
|
+
const def = cfg.defaultLocale && locales.includes(cfg.defaultLocale) ? cfg.defaultLocale : locales[0]!
|
|
801
|
+
const en = buildMessages(project)
|
|
802
|
+
const localeUnion = locales.map((l) => JSON.stringify(l)).join(' | ')
|
|
803
|
+
const seeded = JSON.stringify(en, null, 2).replace(/\n/g, '\n ')
|
|
804
|
+
const messagesEntries = locales.map((l) => ` ${JSON.stringify(l)}: ${l === def ? seeded : '{}'},`).join('\n')
|
|
805
|
+
return {
|
|
806
|
+
path: 'src/lib/i18n.ts',
|
|
807
|
+
description: 'Localization: locales, the current-locale store, the message catalog, and t() / localizeCols helpers.',
|
|
808
|
+
contents: `import { writable, derived } from 'svelte/store'
|
|
809
|
+
|
|
810
|
+
export type Locale = ${localeUnion}
|
|
811
|
+
export const locales: Locale[] = ${JSON.stringify(locales)} as Locale[]
|
|
812
|
+
export const currentLocale = writable<Locale>(${JSON.stringify(def)})
|
|
813
|
+
|
|
814
|
+
// The default locale is seeded from your schema + screen labels. Fill the other
|
|
815
|
+
// locales in with the same keys; missing keys fall back to the default.
|
|
816
|
+
const messages: Record<Locale, Record<string, string>> = {
|
|
817
|
+
${messagesEntries}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/** Reactive translator: \`$t('key', 'fallback')\`. Falls back to the default locale, then the fallback, then the key. */
|
|
821
|
+
export const t = derived(currentLocale, ($l) => (key: string, fallback?: string): string =>
|
|
822
|
+
messages[$l]?.[key] ?? messages[${JSON.stringify(def)}]?.[key] ?? fallback ?? key)
|
|
823
|
+
|
|
824
|
+
/** Localize a column list's headers via \`col.<entity>.<field>\` keys. */
|
|
825
|
+
export function localizeCols<T extends { field?: string | number; header?: string }>(
|
|
826
|
+
cols: T[], entity: string, translate: (k: string, fb?: string) => string,
|
|
827
|
+
): T[] {
|
|
828
|
+
return cols.map((c) => ({ ...c, header: translate('col.' + entity + '.' + String(c.field), c.header ?? String(c.field ?? '')) }))
|
|
829
|
+
}
|
|
830
|
+
`,
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/** The audit store: an in-memory `ServerDataSource` of change records + a
|
|
835
|
+
* `recordAudit` writer. Swap the source for SQL/Supabase to persist the trail. */
|
|
836
|
+
function auditModule(): GeneratedFile {
|
|
837
|
+
return {
|
|
838
|
+
path: 'src/lib/audit.ts',
|
|
839
|
+
description: 'Audit trail store: the AuditEntry schema, an in-memory source, and recordAudit(). Swap the source for a DB table to persist it.',
|
|
840
|
+
contents: `import { createInMemoryDataSource } from '@svgrid/enterprise'
|
|
841
|
+
import type { EntitySchema } from '@svgrid/enterprise'
|
|
842
|
+
|
|
843
|
+
export type AuditEntry = {
|
|
844
|
+
id: string
|
|
845
|
+
at: string
|
|
846
|
+
actor: string
|
|
847
|
+
entity: string
|
|
848
|
+
action: 'create' | 'update' | 'delete'
|
|
849
|
+
recordId: string
|
|
850
|
+
summary: string
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
export const auditSchema: EntitySchema<AuditEntry> = {
|
|
854
|
+
name: 'audit',
|
|
855
|
+
idField: 'id',
|
|
856
|
+
fields: [
|
|
857
|
+
{ field: 'id', type: 'text', primaryKey: true, readonly: true },
|
|
858
|
+
{ field: 'at', type: 'datetime', label: 'When' },
|
|
859
|
+
{ field: 'actor', type: 'text', label: 'Actor' },
|
|
860
|
+
{ field: 'entity', type: 'text', label: 'Entity' },
|
|
861
|
+
{ field: 'action', type: 'enum', label: 'Action', options: [{ value: 'create', label: 'Create' }, { value: 'update', label: 'Update' }, { value: 'delete', label: 'Delete' }] },
|
|
862
|
+
{ field: 'recordId', type: 'text', label: 'Record' },
|
|
863
|
+
{ field: 'summary', type: 'text', label: 'Summary' },
|
|
864
|
+
],
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// In-memory, server-side singleton. Replace with a SQL / Supabase source to
|
|
868
|
+
// persist the trail across restarts (recordAudit + the /audit viewer keep working).
|
|
869
|
+
export const auditSource = createInMemoryDataSource<AuditEntry>([], auditSchema)
|
|
870
|
+
let seq = 0
|
|
871
|
+
|
|
872
|
+
/** Append one change record. Called by the API routes' \`audit\` hook. */
|
|
873
|
+
export async function recordAudit(input: {
|
|
874
|
+
entity: string
|
|
875
|
+
action: 'create' | 'update' | 'delete'
|
|
876
|
+
recordId: string | null
|
|
877
|
+
values?: Record<string, unknown>
|
|
878
|
+
actor?: string
|
|
879
|
+
}): Promise<void> {
|
|
880
|
+
const summary =
|
|
881
|
+
input.action === 'delete'
|
|
882
|
+
? \`Deleted \${input.entity} \${input.recordId ?? ''}\`.trim()
|
|
883
|
+
: \`\${input.action === 'create' ? 'Created' : 'Updated'} \${input.entity}\${input.values ? ' (' + Object.keys(input.values).join(', ') + ')' : ''}\`
|
|
884
|
+
await auditSource.createRow?.({
|
|
885
|
+
id: String(++seq),
|
|
886
|
+
at: new Date().toISOString(),
|
|
887
|
+
actor: input.actor ?? 'system',
|
|
888
|
+
entity: input.entity,
|
|
889
|
+
action: input.action,
|
|
890
|
+
recordId: input.recordId ?? '',
|
|
891
|
+
summary,
|
|
892
|
+
})
|
|
893
|
+
}
|
|
894
|
+
`,
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/** The read API for the audit trail (the /audit viewer reads it via the transport). */
|
|
899
|
+
function auditRouteFile(): GeneratedFile {
|
|
900
|
+
return {
|
|
901
|
+
path: 'src/routes/api/audit/+server.ts',
|
|
902
|
+
description: 'API route for the audit trail (read-only viewer feed).',
|
|
903
|
+
contents: `import { createKitHandlers } from '@svgrid/enterprise'
|
|
904
|
+
import { auditSchema, auditSource } from '$lib/audit'
|
|
905
|
+
|
|
906
|
+
export const { POST } = createKitHandlers({ schema: auditSchema, source: auditSource })
|
|
907
|
+
`,
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/** The audit-log viewer screen: a read-only grid over the audit source. */
|
|
912
|
+
function auditViewerPage(): GeneratedFile {
|
|
913
|
+
return {
|
|
914
|
+
path: 'src/routes/audit/+page.svelte',
|
|
915
|
+
description: 'Audit log viewer (read-only grid of change records).',
|
|
916
|
+
contents: `<script lang="ts">
|
|
917
|
+
import { SvGrid, createServerDataSource, type ServerState } from '@svgrid/grid'
|
|
918
|
+
import { schemaToColumns, createKitDataSource } from '@svgrid/enterprise'
|
|
919
|
+
import { auditSchema, type AuditEntry } from '$lib/audit'
|
|
920
|
+
|
|
921
|
+
const source = createKitDataSource<AuditEntry>({ endpoint: '/api/audit' })
|
|
922
|
+
let view = $state<ServerState<AuditEntry>>({ rows: [], total: 0, loading: false, saving: false, error: null, pageIndex: 0, pageSize: 25, pageCount: 1, sortModel: [], filterModel: {} })
|
|
923
|
+
const controller = createServerDataSource<AuditEntry>(source, { pageSize: 25, onChange: (s) => (view = s) })
|
|
924
|
+
$effect(() => { controller.refresh(); return () => controller.dispose() })
|
|
925
|
+
const columns = schemaToColumns(auditSchema)
|
|
926
|
+
</script>
|
|
927
|
+
|
|
928
|
+
<h1 class="st__title">Audit log</h1>
|
|
929
|
+
<p class="st__sub">Every create, update, and delete recorded server-side.</p>
|
|
930
|
+
|
|
931
|
+
<div class="screen" style="margin-top: 16px">
|
|
932
|
+
<SvGrid
|
|
933
|
+
data={view.rows}
|
|
934
|
+
columns={columns}
|
|
935
|
+
loading={view.loading}
|
|
936
|
+
loadingOverlay
|
|
937
|
+
fitColumns
|
|
938
|
+
sortable
|
|
939
|
+
externalSort
|
|
940
|
+
onSortingChange={(s) => controller.setSort(s)}
|
|
941
|
+
showPagination
|
|
942
|
+
externalPagination
|
|
943
|
+
rowCount={view.total}
|
|
944
|
+
pageIndex={view.pageIndex}
|
|
945
|
+
pageSize={view.pageSize}
|
|
946
|
+
onPaginationChange={({ pageIndex, pageSize }) => (pageSize !== view.pageSize ? controller.setPageSize(pageSize) : controller.setPage(pageIndex))}
|
|
947
|
+
containerHeight={520}
|
|
948
|
+
/>
|
|
949
|
+
</div>
|
|
950
|
+
`,
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/** The shared RBAC policy module: roles, screen + action maps, the current-role
|
|
955
|
+
* store, and the server-side role/authorize helpers. */
|
|
956
|
+
function accessModule(project: StudioProject): GeneratedFile {
|
|
957
|
+
const access = project.access!
|
|
958
|
+
const roleNames = access.roles.map((r) => r.role)
|
|
959
|
+
const defaultRole = access.defaultRole && roleNames.includes(access.defaultRole) ? access.defaultRole : (roleNames[0] ?? 'viewer')
|
|
960
|
+
const roleUnion = roleNames.length ? roleNames.map((r) => JSON.stringify(r)).join(' | ') : "'viewer'"
|
|
961
|
+
const screensEntries = access.roles.map((r) => ` ${JSON.stringify(r.role)}: ${r.screens === '*' ? "'*'" : JSON.stringify(r.screens)},`).join('\n')
|
|
962
|
+
const actionsEntries = access.roles.map((r) => ` ${JSON.stringify(r.role)}: ${r.actions === '*' ? "'*'" : JSON.stringify(r.actions)},`).join('\n')
|
|
963
|
+
return {
|
|
964
|
+
path: 'src/lib/access.ts',
|
|
965
|
+
description: 'RBAC policy: roles, screen + action permissions, the current-role store, and server helpers. Shared by the UI and the API routes.',
|
|
966
|
+
contents: `import { writable } from 'svelte/store'
|
|
967
|
+
|
|
968
|
+
export type AppRole = ${roleUnion}
|
|
969
|
+
export type WriteAction = 'create' | 'update' | 'delete'
|
|
970
|
+
export const ROLES: AppRole[] = ${JSON.stringify(roleNames)} as AppRole[]
|
|
971
|
+
|
|
972
|
+
const SCREENS: Record<AppRole, '*' | string[]> = {
|
|
973
|
+
${screensEntries}
|
|
974
|
+
}
|
|
975
|
+
const ACTIONS: Record<AppRole, '*' | WriteAction[]> = {
|
|
976
|
+
${actionsEntries}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/** The signed-in user's role. Set it after login (e.g. from the session);
|
|
980
|
+
* defaults to the project's default role. Read it in components as \`$currentRole\`. */
|
|
981
|
+
export const currentRole = writable<AppRole>(${JSON.stringify(defaultRole)})
|
|
982
|
+
|
|
983
|
+
/** May this role open the given screen id? */
|
|
984
|
+
export function canScreen(role: AppRole, screenId: string): boolean {
|
|
985
|
+
const s = SCREENS[role]
|
|
986
|
+
return s === '*' || (Array.isArray(s) && s.includes(screenId))
|
|
987
|
+
}
|
|
988
|
+
/** May this role perform a write action? (Reads are implied by screen access.) */
|
|
989
|
+
export function can(role: AppRole, action: WriteAction): boolean {
|
|
990
|
+
const a = ACTIONS[role]
|
|
991
|
+
return a === '*' || (Array.isArray(a) && a.includes(action))
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// ---- server side ----------------------------------------------------------
|
|
995
|
+
/** Resolve the caller's role on the server. Wire this to YOUR auth: by default it
|
|
996
|
+
* reads \`event.locals.role\` - set it in \`hooks.server.ts\` from the session. */
|
|
997
|
+
export function getServerRole(event: { locals?: Record<string, unknown> }): AppRole {
|
|
998
|
+
const r = event?.locals?.role
|
|
999
|
+
return (typeof r === 'string' && (ROLES as string[]).includes(r) ? r : ${JSON.stringify(defaultRole)}) as AppRole
|
|
1000
|
+
}
|
|
1001
|
+
/** Authorize a CRUD action for a role - used by the API routes' \`authorize\` hook. */
|
|
1002
|
+
export function authorizeAction(role: AppRole, action: 'read' | WriteAction): boolean {
|
|
1003
|
+
return action === 'read' ? true : can(role, action)
|
|
1004
|
+
}
|
|
1005
|
+
`,
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// --- Full runnable app (download / npm-install-and-run) ---------------------
|
|
1010
|
+
|
|
1011
|
+
const appSlug = (title: string): string =>
|
|
1012
|
+
(title || 'studio-app').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'studio-app'
|
|
1013
|
+
|
|
1014
|
+
/** The static SvelteKit + Vite scaffolding around the generated screens. */
|
|
1015
|
+
const SCAFFOLD_STATIC: ReadonlyArray<GeneratedFile> = [
|
|
1016
|
+
{ path: 'vite.config.ts', description: 'Vite config.', contents: `import { sveltekit } from '@sveltejs/vite-plugin-svelte'\nimport { defineConfig } from 'vite'\n\nexport default defineConfig({ plugins: [sveltekit()] })\n` },
|
|
1017
|
+
{ 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` },
|
|
1018
|
+
{ 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` },
|
|
1019
|
+
{ path: 'src/app.d.ts', description: 'SvelteKit app types.', contents: `declare global {\n namespace App {}\n}\n\nexport {}\n` },
|
|
1020
|
+
{ path: 'src/routes/+layout.ts', description: 'Client SPA (in-memory sources persist across navigation).', contents: `// In-memory sources are module singletons, so render as a client SPA. Move an\n// entity to SQL / Supabase and its /api route still runs server-side.\nexport const ssr = false\nexport const prerender = false\n` },
|
|
1021
|
+
{ path: '.npmrc', description: 'npm config.', contents: `engine-strict=true\n` },
|
|
1022
|
+
{ path: '.gitignore', description: 'git ignore.', contents: `node_modules\n.svelte-kit\n/build\n.env\n.env.*\n!.env.example\n.DS_Store\n` },
|
|
1023
|
+
]
|
|
1024
|
+
|
|
1025
|
+
const APP_CSS = `:root { --sg-accent: #4f46e5; color-scheme: light dark; }
|
|
1026
|
+
* { box-sizing: border-box; }
|
|
1027
|
+
html, body { margin: 0; height: 100%; }
|
|
1028
|
+
body { font-family: var(--sg-font, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif); color: var(--sg-fg, #0f172a); background: var(--sg-bg, #fff); }
|
|
1029
|
+
.st__title { margin: 0; font-size: 22px; font-weight: 720; letter-spacing: -0.015em; }
|
|
1030
|
+
.st__sub { margin: 0; font-size: 14px; line-height: 1.6; color: var(--sg-muted, #64748b); max-width: 74ch; }
|
|
1031
|
+
.st__sub code { background: var(--sg-header-bg, #f1f5f9); padding: 1px 6px; border-radius: 5px; font-size: 0.9em; }
|
|
1032
|
+
.st__toolbar { display: flex; align-items: center; gap: 10px; }
|
|
1033
|
+
.st-hint { font-size: 12.5px; color: var(--sg-muted, #94a3b8); }
|
|
1034
|
+
.st-error { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin: 0 0 16px; padding: 11px 14px; border: 1px solid color-mix(in srgb, var(--sg-danger, #dc2626) 40%, var(--sg-border, #e6e8ec)); border-radius: 10px; background: color-mix(in srgb, var(--sg-danger, #dc2626) 8%, var(--sg-bg, #fff)); color: var(--sg-danger, #b3261e); font-size: 13.5px; }
|
|
1035
|
+
.st-btn { display: inline-flex; align-items: center; gap: 7px; padding: 8px 14px; font: inherit; font-size: 13.5px; font-weight: 560; line-height: 1; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 10px; background: var(--sg-bg, #fff); color: var(--sg-fg, inherit); cursor: pointer; box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05); }
|
|
1036
|
+
.st-btn:hover { background: color-mix(in srgb, var(--sg-fg, #0f172a) 5%, var(--sg-bg, #fff)); }
|
|
1037
|
+
.st-btn:disabled { opacity: 0.5; cursor: default; box-shadow: none; }
|
|
1038
|
+
.st-btn--primary { border-color: transparent; color: #fff; background: linear-gradient(180deg, color-mix(in srgb, var(--sg-accent) 88%, #fff), var(--sg-accent)); box-shadow: 0 1px 2px rgba(15, 23, 42, 0.14), 0 8px 18px -9px color-mix(in srgb, var(--sg-accent) 65%, transparent); }
|
|
1039
|
+
.st-btn--primary:hover { filter: brightness(1.06); }
|
|
1040
|
+
.home { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; margin-top: 6px; }
|
|
1041
|
+
.home__card { display: flex; flex-direction: column; gap: 6px; padding: 18px; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 14px; text-decoration: none; color: inherit; background: var(--sg-bg, #fff); box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05); }
|
|
1042
|
+
.home__card:hover { border-color: color-mix(in srgb, var(--sg-accent) 45%, var(--sg-border, #e6e8ec)); }
|
|
1043
|
+
.home__card strong { font-size: 15px; }
|
|
1044
|
+
.home__card span { font-size: 13px; color: var(--sg-muted, #64748b); line-height: 1.5; }
|
|
1045
|
+
.st-filter { display: flex; flex-direction: column; gap: 10px; padding: 14px; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; background: var(--sg-bg, #fff); align-self: start; }
|
|
1046
|
+
.st-filter__title { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--sg-muted, #64748b); }
|
|
1047
|
+
.st-filter__row { display: flex; flex-direction: column; gap: 4px; font-size: 12.5px; font-weight: 600; color: var(--sg-muted, #64748b); }
|
|
1048
|
+
.st-filter__row select, .st-filter__row input { padding: 7px 9px; font: inherit; font-size: 13px; font-weight: 400; color: var(--sg-fg, inherit); background: var(--sg-input-bg, var(--sg-bg, #fff)); border: 1px solid var(--sg-input-border, var(--sg-border, #e6e8ec)); border-radius: 8px; }
|
|
1049
|
+
.st-record-card { border: 1px solid var(--sg-border, #e6e8ec); border-radius: 12px; background: var(--sg-bg, #fff); padding: 14px; align-self: start; }
|
|
1050
|
+
.st-record { margin: 0; display: flex; flex-direction: column; gap: 8px; }
|
|
1051
|
+
.st-record__row { display: grid; grid-template-columns: 40% 1fr; gap: 10px; align-items: baseline; border-bottom: 1px solid var(--sg-border, #f1f5f9); padding-bottom: 6px; }
|
|
1052
|
+
.st-record__row dt { margin: 0; font-size: 12px; font-weight: 600; color: var(--sg-muted, #64748b); }
|
|
1053
|
+
.st-record__row dd { margin: 0; font-size: 13.5px; color: var(--sg-fg, inherit); overflow-wrap: anywhere; }
|
|
1054
|
+
.st-screen { display: grid; grid-template-columns: repeat(12, 1fr); gap: 16px; align-items: start; }
|
|
1055
|
+
/* Mobile: blocks stack full-width (a span-N block clamps to the single column). */
|
|
1056
|
+
@media (max-width: 720px) { .st-screen { grid-template-columns: 1fr; gap: 12px; } }
|
|
1057
|
+
@media (max-width: 640px) { .st__title { font-size: 19px; } }
|
|
1058
|
+
.st-rowactions { display: inline-flex; gap: 6px; }
|
|
1059
|
+
.st-rowaction { padding: 3px 9px; font: inherit; font-size: 12px; font-weight: 550; line-height: 1.4; border: 1px solid var(--sg-border, #e6e8ec); border-radius: 7px; background: var(--sg-bg, #fff); color: var(--sg-fg, inherit); cursor: pointer; }
|
|
1060
|
+
.st-rowaction:hover { background: color-mix(in srgb, var(--sg-fg, #0f172a) 5%, var(--sg-bg, #fff)); }
|
|
1061
|
+
.st-rowaction--danger { color: #dc2626; border-color: color-mix(in srgb, #dc2626 40%, var(--sg-border, #e6e8ec)); }
|
|
1062
|
+
.st-rowaction--danger:hover { background: color-mix(in srgb, #dc2626 8%, var(--sg-bg, #fff)); }
|
|
1063
|
+
`
|
|
1064
|
+
|
|
1065
|
+
/**
|
|
1066
|
+
* Deploy target -> the SvelteKit adapter + provider config the bundle ships, plus
|
|
1067
|
+
* the one-liner and dashboard link the designer's Deploy panel shows. `auto`
|
|
1068
|
+
* (the default) uses adapter-auto, which detects Vercel / Netlify / Cloudflare at
|
|
1069
|
+
* build time; picking a specific target pins the adapter and adds its config so a
|
|
1070
|
+
* `git push` (or the CLI one-liner) deploys with no further setup.
|
|
1071
|
+
*/
|
|
1072
|
+
type DeployPlan = {
|
|
1073
|
+
adapterModule: string
|
|
1074
|
+
adapterDep: [name: string, version: string]
|
|
1075
|
+
files: GeneratedFile[]
|
|
1076
|
+
/** Copy-paste command that deploys the app. */
|
|
1077
|
+
cli: string
|
|
1078
|
+
/** Provider "new project" dashboard link (for the import-from-Git path). */
|
|
1079
|
+
dashboard?: string
|
|
1080
|
+
label: string
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
function deployPlan(project: StudioProject): DeployPlan {
|
|
1084
|
+
const slug = appSlug(project.title)
|
|
1085
|
+
const target = project.deploy ?? 'auto'
|
|
1086
|
+
switch (target) {
|
|
1087
|
+
case 'vercel':
|
|
1088
|
+
return {
|
|
1089
|
+
adapterModule: '@sveltejs/adapter-vercel',
|
|
1090
|
+
adapterDep: ['@sveltejs/adapter-vercel', '^5.5.0'],
|
|
1091
|
+
files: [],
|
|
1092
|
+
cli: 'npx vercel --prod',
|
|
1093
|
+
dashboard: 'https://vercel.com/new',
|
|
1094
|
+
label: 'Vercel',
|
|
1095
|
+
}
|
|
1096
|
+
case 'netlify':
|
|
1097
|
+
return {
|
|
1098
|
+
adapterModule: '@sveltejs/adapter-netlify',
|
|
1099
|
+
adapterDep: ['@sveltejs/adapter-netlify', '^5.0.0'],
|
|
1100
|
+
files: [{ path: 'netlify.toml', description: 'Netlify build config.', contents: `[build]\n command = "npm run build"\n` }],
|
|
1101
|
+
cli: 'npx netlify deploy --build --prod',
|
|
1102
|
+
dashboard: 'https://app.netlify.com/start',
|
|
1103
|
+
label: 'Netlify',
|
|
1104
|
+
}
|
|
1105
|
+
case 'cloudflare':
|
|
1106
|
+
return {
|
|
1107
|
+
adapterModule: '@sveltejs/adapter-cloudflare',
|
|
1108
|
+
adapterDep: ['@sveltejs/adapter-cloudflare', '^7.0.0'],
|
|
1109
|
+
files: [{ path: 'wrangler.toml', description: 'Cloudflare Pages config.', contents: `name = "${slug}"\npages_build_output_dir = ".svelte-kit/cloudflare"\ncompatibility_date = "2024-11-01"\n` }],
|
|
1110
|
+
cli: 'npm run build && npx wrangler pages deploy .svelte-kit/cloudflare',
|
|
1111
|
+
dashboard: 'https://dash.cloudflare.com/?to=/:account/pages/new',
|
|
1112
|
+
label: 'Cloudflare Pages',
|
|
1113
|
+
}
|
|
1114
|
+
case 'node':
|
|
1115
|
+
return {
|
|
1116
|
+
adapterModule: '@sveltejs/adapter-node',
|
|
1117
|
+
adapterDep: ['@sveltejs/adapter-node', '^5.2.0'],
|
|
1118
|
+
files: [],
|
|
1119
|
+
cli: 'npm run build && node build',
|
|
1120
|
+
label: 'Node server',
|
|
1121
|
+
}
|
|
1122
|
+
default:
|
|
1123
|
+
return {
|
|
1124
|
+
adapterModule: '@sveltejs/adapter-auto',
|
|
1125
|
+
adapterDep: ['@sveltejs/adapter-auto', '^6.0.0'],
|
|
1126
|
+
files: [],
|
|
1127
|
+
cli: 'npx vercel --prod',
|
|
1128
|
+
dashboard: 'https://vercel.com/new',
|
|
1129
|
+
label: 'Auto (Vercel / Netlify / Cloudflare)',
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
/** The deploy facts the designer's Deploy panel shows (label, CLI one-liner, dashboard link). */
|
|
1135
|
+
export function studioDeployInfo(project: StudioProject): { label: string; cli: string; dashboard?: string; adapter: string } {
|
|
1136
|
+
const p = deployPlan(project)
|
|
1137
|
+
return { label: p.label, cli: p.cli, dashboard: p.dashboard, adapter: p.adapterModule }
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
/** A `.env.example` listing the env vars the generated code reads, when any. */
|
|
1141
|
+
function envExample(allSource: string): string | null {
|
|
1142
|
+
const lines: string[] = []
|
|
1143
|
+
if (allSource.includes('env.DATABASE_URL')) {
|
|
1144
|
+
lines.push('# Your database connection string (Neon / Supabase / Postgres / MySQL / SQLite path).')
|
|
1145
|
+
lines.push('DATABASE_URL=')
|
|
1146
|
+
}
|
|
1147
|
+
if (allSource.includes('env.DATABASE_AUTH_TOKEN')) {
|
|
1148
|
+
lines.push('# Turso / libSQL database auth token.')
|
|
1149
|
+
lines.push('DATABASE_AUTH_TOKEN=')
|
|
1150
|
+
}
|
|
1151
|
+
if (lines.length === 0) return null
|
|
1152
|
+
lines.push('')
|
|
1153
|
+
lines.push('# Optional: your SvGrid license key removes the unlicensed watermark.')
|
|
1154
|
+
lines.push('# VITE_SVPRO_KEY=')
|
|
1155
|
+
return lines.join('\n') + '\n'
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
function svelteConfig(plan: DeployPlan): string {
|
|
1159
|
+
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`
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
function packageJson(project: StudioProject, allSource: string): string {
|
|
1163
|
+
const dependencies: Record<string, string> = { '@svgrid/grid': 'latest', '@svgrid/enterprise': 'latest' }
|
|
1164
|
+
if (allSource.includes("from '@supabase/supabase-js'")) dependencies['@supabase/supabase-js'] = '^2.45.0'
|
|
1165
|
+
if (allSource.includes("import pg from 'pg'")) dependencies['pg'] = '^8.11.0'
|
|
1166
|
+
if (allSource.includes("from 'mysql2/promise'")) dependencies['mysql2'] = '^3.9.0'
|
|
1167
|
+
if (allSource.includes("import mssql from 'mssql'")) dependencies['mssql'] = '^10.0.0'
|
|
1168
|
+
if (allSource.includes("import Database from 'better-sqlite3'")) dependencies['better-sqlite3'] = '^11.0.0'
|
|
1169
|
+
if (allSource.includes("from '@libsql/client'")) dependencies['@libsql/client'] = '^0.14.0'
|
|
1170
|
+
if (allSource.includes("from '@electric-sql/pglite'")) dependencies['@electric-sql/pglite'] = '^0.5.0'
|
|
1171
|
+
const pkg = {
|
|
1172
|
+
name: appSlug(project.title),
|
|
1173
|
+
version: '0.0.1',
|
|
1174
|
+
private: true,
|
|
1175
|
+
type: 'module',
|
|
1176
|
+
scripts: { dev: 'vite dev', build: 'vite build', preview: 'vite preview', check: 'svelte-kit sync && svelte-check --tsconfig ./tsconfig.json', test: 'vitest run' },
|
|
1177
|
+
dependencies,
|
|
1178
|
+
devDependencies: {
|
|
1179
|
+
[deployPlan(project).adapterDep[0]]: deployPlan(project).adapterDep[1],
|
|
1180
|
+
'@sveltejs/kit': '^2.15.0',
|
|
1181
|
+
'@sveltejs/vite-plugin-svelte': '^7.0.0',
|
|
1182
|
+
svelte: '^5.55.5',
|
|
1183
|
+
'svelte-check': '^4.4.6',
|
|
1184
|
+
typescript: '^5.7.0',
|
|
1185
|
+
vite: '^8.0.10',
|
|
1186
|
+
vitest: '^4.1.5',
|
|
1187
|
+
},
|
|
1188
|
+
}
|
|
1189
|
+
return JSON.stringify(pkg, null, 2) + '\n'
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/**
|
|
1193
|
+
* A per-entity smoke test shipped with the generated app. It proves the two
|
|
1194
|
+
* things a screen depends on still hold: every schema yields grid columns +
|
|
1195
|
+
* form fields (so the page renders), and the schema round-trips through the
|
|
1196
|
+
* data-source layer (create -> read -> delete). It builds a fresh in-memory
|
|
1197
|
+
* source from the schema, so it runs offline regardless of the real backend
|
|
1198
|
+
* (SQL / Supabase / PGlite) - a fast regression guard on schema edits.
|
|
1199
|
+
*/
|
|
1200
|
+
function smokeTestFile(project: StudioProject): string {
|
|
1201
|
+
const imports = project.entities.map((e) => {
|
|
1202
|
+
const n = namesFor(e)
|
|
1203
|
+
return `${n.schemaVar}, type ${n.type}`
|
|
1204
|
+
})
|
|
1205
|
+
const blocks = project.entities.map((e) => {
|
|
1206
|
+
const n = namesFor(e)
|
|
1207
|
+
return `describe(${JSON.stringify(n.label)}, () => {
|
|
1208
|
+
it('exposes grid columns and form fields', () => {
|
|
1209
|
+
expect(schemaToColumns(${n.schemaVar}).length).toBeGreaterThan(0)
|
|
1210
|
+
expect(schemaToFormFields(${n.schemaVar}).length).toBeGreaterThan(0)
|
|
1211
|
+
})
|
|
1212
|
+
|
|
1213
|
+
it('round-trips create -> read -> delete through an in-memory source', async () => {
|
|
1214
|
+
const idField = ${n.schemaVar}.idField ?? ${n.schemaVar}.fields.find((f) => f.primaryKey)?.field ?? 'id'
|
|
1215
|
+
const source = createInMemoryDataSource<${n.type}>([], ${n.schemaVar})
|
|
1216
|
+
await source.createRow({ [idField]: 'smoke-1' } as unknown as Partial<${n.type}>)
|
|
1217
|
+
expect((await source.getRows({ startRow: 0, endRow: 100, pageIndex: 0, pageSize: 100, sortModel: [], filterModel: {} })).rowCount).toBe(1)
|
|
1218
|
+
await source.deleteRow('smoke-1')
|
|
1219
|
+
expect((await source.getRows({ startRow: 0, endRow: 100, pageIndex: 0, pageSize: 100, sortModel: [], filterModel: {} })).rowCount).toBe(0)
|
|
1220
|
+
})
|
|
1221
|
+
})`
|
|
1222
|
+
})
|
|
1223
|
+
return `// Smoke tests generated by SvGrid Studio. Run with \`npm test\`.
|
|
1224
|
+
// They prove every entity's schema still renders (grid columns + form fields)
|
|
1225
|
+
// and round-trips through the data-source layer, so a schema edit that would
|
|
1226
|
+
// break a screen fails here first. Regenerating the app refreshes this file.
|
|
1227
|
+
import { describe, it, expect } from 'vitest'
|
|
1228
|
+
import { schemaToColumns, schemaToFormFields, createInMemoryDataSource } from '@svgrid/enterprise'
|
|
1229
|
+
import { ${imports.join(', ')} } from './schemas'
|
|
1230
|
+
|
|
1231
|
+
${blocks.join('\n\n')}
|
|
1232
|
+
`
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
const VITEST_CONFIG = `import { defineConfig } from 'vitest/config'
|
|
1236
|
+
|
|
1237
|
+
// Node-only test runner for the generated smoke tests (no Svelte/DOM needed).
|
|
1238
|
+
// Kept separate from vite.config so the SvelteKit plugin doesn't load here.
|
|
1239
|
+
export default defineConfig({
|
|
1240
|
+
test: { environment: 'node', include: ['src/**/*.test.ts'] },
|
|
1241
|
+
})
|
|
1242
|
+
`
|
|
1243
|
+
|
|
1244
|
+
/**
|
|
1245
|
+
* Emit the COMPLETE runnable SvelteKit + Vite app: the generated screens/data
|
|
1246
|
+
* plus all scaffolding (package.json, vite/svelte/ts config, app shell, css).
|
|
1247
|
+
* Download it, `npm install`, `npm run dev`. This is what the designer's
|
|
1248
|
+
* "Download .zip" produces.
|
|
1249
|
+
*/
|
|
1250
|
+
export function emitStudioAppBundle(project: StudioProject): GeneratedFile[] {
|
|
1251
|
+
const generated = emitStudioProject(project)
|
|
1252
|
+
const allSource = generated.map((f) => f.contents).join('\n')
|
|
1253
|
+
const plan = deployPlan(project)
|
|
1254
|
+
const deploySteps = plan.dashboard
|
|
1255
|
+
? `1. Push this folder to a Git repo (GitHub / GitLab / Bitbucket).\n2. Import it at <${plan.dashboard}> - build settings are detected automatically.\n\nOr deploy straight from your machine with the CLI:\n\n\`\`\`bash\n${plan.cli}\n\`\`\``
|
|
1256
|
+
: `Build and run the server:\n\n\`\`\`bash\n${plan.cli}\n\`\`\``
|
|
1257
|
+
const readme = `# ${project.title}\n\nGenerated with SvGrid Studio.\n\n\`\`\`bash\nnpm install\nnpm run dev\n\`\`\`\n\nSQL / Supabase entities read their connection from \`.env\` (\`DATABASE_URL\`) or\n\`src/lib/connections.ts\`. Everything else runs on seeded in-memory data.\n\n## Deploy (${plan.label})\n\nThis app is configured for **${plan.label}** (SvelteKit \`${plan.adapterModule}\`).\n\n${deploySteps}\n\nTo target a different host, pick another **Deploy target** in the designer and\nre-generate, or swap the adapter in \`svelte.config.js\`. Entities on **Local\ndatabase** (PGlite) or **In-memory** need no server env; SQL / Supabase entities\nneed their connection set in the host's environment variables.\n\n## Round-tripping back into the designer\n\nThis app ships its own design model in \`studio.config.json\`. To keep editing\nvisually, open the SvGrid Studio designer and **Load** that file - your entities,\nscreens, blocks, theme, RBAC, i18n, etc. come back exactly as generated, and you\ncan re-generate from there.\n\nThe designer regenerates the files under \`src/routes\` and \`src/lib\` from the\nmodel, so **keep your own custom code in new files/modules and import it**, rather\nthan editing the generated screens in place - that way a re-generate never\nclobbers your work. (The CLI workflow, \`npx @svgrid/studio add\`, is the\nalternative: it wraps generated code in \`svgrid:managed\` markers and preserves\nanything you write outside them.)\n`
|
|
1258
|
+
const scaffold: GeneratedFile[] = [
|
|
1259
|
+
{ path: 'package.json', description: 'Dependencies + scripts (npm install, npm run dev).', contents: packageJson(project, allSource) },
|
|
1260
|
+
{ path: 'svelte.config.js', description: `SvelteKit config (${plan.adapterModule}).`, contents: svelteConfig(plan) },
|
|
1261
|
+
{ path: 'vitest.config.ts', description: 'Test runner config for the generated smoke tests (npm test).', contents: VITEST_CONFIG },
|
|
1262
|
+
{ path: 'src/lib/schemas.test.ts', description: 'Smoke tests: every entity renders + round-trips through its data source.', contents: smokeTestFile(project) },
|
|
1263
|
+
...plan.files,
|
|
1264
|
+
...SCAFFOLD_STATIC,
|
|
1265
|
+
...(envExample(allSource) ? [{ path: '.env.example', description: 'Environment variables the app reads (copy to .env and fill in).', contents: envExample(allSource)! }] : []),
|
|
1266
|
+
{ path: 'src/app.css', description: 'App theme + page styles.', contents: APP_CSS + (project.theme?.customCss ? `\n\n/* --- Custom CSS (from the designer) --- */\n${project.theme.customCss}\n` : '') },
|
|
1267
|
+
// The design model, shipped with the app so it can be re-imported (Load) into
|
|
1268
|
+
// the designer for further visual editing - the export/import round-trip.
|
|
1269
|
+
{ path: 'studio.config.json', description: 'The Studio project model - Load it back into the designer to keep editing visually.', contents: serializeProject(project) + '\n' },
|
|
1270
|
+
{ path: 'README.md', description: 'How to run the app.', contents: readme },
|
|
1271
|
+
]
|
|
1272
|
+
return [...scaffold, ...generated]
|
|
1273
|
+
}
|