@svgrid/enterprise 2.2.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -81
- package/dist/cdn/svgrid-enterprise.svelte-external.js +22479 -10466
- package/dist/node/studio.js +11590 -2907
- package/package.json +31 -10
- package/src/SvAlertRuleEditor.svelte +294 -0
- package/src/SvAlertsManager.svelte +210 -0
- package/src/SvAlertsPanel.svelte +129 -0
- package/src/SvBoard.svelte +3 -1
- package/src/SvExpressionEditor.svelte +341 -0
- package/src/SvGridAlerts.dom.test.ts +113 -0
- package/src/SvGridAlerts.svelte +265 -0
- package/src/SvGridBoard.svelte +2355 -0
- package/src/SvGridEditPanel.svelte +849 -794
- package/src/SvGridScheduler.svelte +5334 -4410
- package/src/SvPivotDesigner.svelte +3 -3
- package/src/SvRecordDetail.svelte +6 -2
- package/src/SvSchedule.svelte +3 -1
- package/src/{ai-export-pdf.test.ts → ai-export-pdf.dom.test.ts} +4 -1
- package/src/{ai-export-xlsx.test.ts → ai-export-xlsx.dom.test.ts} +4 -1
- package/src/{ai-export.test.ts → ai-export.dom.test.ts} +5 -1
- package/src/alerts/alert-engine-attach.ts +165 -0
- package/src/alerts/alert-engine.test.ts +135 -0
- package/src/alerts/alert-engine.ts +260 -0
- package/src/alerts/alert-formats.test.ts +87 -0
- package/src/alerts/alert-formats.ts +77 -0
- package/src/alerts/alert-observer.test.ts +189 -0
- package/src/alerts/alert-observer.ts +208 -0
- package/src/alerts/alert-scheduler.ts +96 -0
- package/src/alerts/alert-storage.test.ts +54 -0
- package/src/alerts/alert-storage.ts +116 -0
- package/src/alerts/alert-store.svelte.ts +80 -0
- package/src/alerts/alert-types.ts +94 -0
- package/src/alerts.ts +28 -0
- package/src/board.dom.test.ts +941 -0
- package/src/board.ts +36 -0
- package/src/export.ts +13 -1
- package/src/expressions/evaluate.test.ts +111 -0
- package/src/expressions/evaluate.ts +228 -0
- package/src/expressions/expression-columns.ts +133 -0
- package/src/expressions/expression-types.ts +84 -0
- package/src/expressions/parse.test.ts +106 -0
- package/src/expressions/parse.ts +610 -0
- package/src/import.ts +2 -0
- package/src/index.ts +180 -46
- package/src/install.ts +12 -2
- package/src/pivot-enable.ts +44 -0
- package/src/scheduler-assignments.test.ts +97 -0
- package/src/scheduler-assignments.ts +134 -0
- package/src/scheduler-axis.test.ts +108 -0
- package/src/scheduler-axis.ts +238 -0
- package/src/scheduler-booking.test.ts +57 -0
- package/src/scheduler-booking.ts +63 -0
- package/src/scheduler-config.ts +179 -0
- package/src/scheduler-dependencies.test.ts +155 -0
- package/src/scheduler-dependencies.ts +223 -0
- package/src/scheduler-freebusy.test.ts +41 -0
- package/src/scheduler-freebusy.ts +36 -0
- package/src/scheduler-heatmap.test.ts +39 -0
- package/src/scheduler-heatmap.ts +55 -0
- package/src/scheduler-resource-tree.test.ts +84 -0
- package/src/scheduler-resource-tree.ts +107 -0
- package/src/scheduler-slots.test.ts +53 -0
- package/src/scheduler-slots.ts +94 -0
- package/src/scheduler-summary.test.ts +47 -0
- package/src/scheduler-summary.ts +81 -0
- package/src/sources/index.ts +1 -1
- package/src/sources/introspect-supabase.test.ts +13 -1
- package/src/sources/introspect-supabase.ts +20 -0
- package/src/studio/copilot-core.test.ts +45 -0
- package/src/studio/copilot-core.ts +65 -0
- package/src/studio/deploy-cli.test.ts +56 -0
- package/src/studio/deploy-cli.ts +100 -0
- package/src/studio/emit-project.test.ts +503 -18
- package/src/studio/emit-project.ts +829 -111
- package/src/studio/emit-schema.test.ts +17 -0
- package/src/studio/emit-schema.ts +166 -35
- package/src/studio/index.ts +25 -1
- package/src/studio/introspect-openapi.test.ts +84 -0
- package/src/studio/introspect-openapi.ts +252 -0
- package/src/studio/project.test.ts +57 -0
- package/src/studio/project.ts +193 -6
- package/src/studio/samples/crm.ts +282 -258
- package/src/studio/samples/fleet.ts +213 -186
- package/src/studio/samples/insurance.ts +223 -195
- package/src/studio/samples/inventory.ts +213 -182
- package/src/studio/samples/live-data.test.ts +99 -98
- package/src/studio/samples/live-data.ts +8 -10
- package/src/studio/samples/projects.ts +212 -190
- package/src/studio/samples/samples.test.ts +52 -0
- package/src/studio/samples/shared.ts +26 -6
- package/src/studio/samples/starter.ts +251 -0
- package/src/studio/samples/support.ts +209 -184
- package/src/studio/ui-components-surface.test.ts +185 -0
- package/src/studio/ui-components.generated.ts +5617 -0
- package/src/studio/ui-components.ts +641 -472
- package/src/sveltekit/sql-source.test.ts +13 -0
- package/src/sveltekit/sql-source.ts +11 -6
- package/src/upgrade-prompt.ts +2 -2
- package/src/watermark.ts +2 -2
- package/src/ai.test.ts +0 -522
- package/src/ai.ts +0 -1388
|
@@ -3,7 +3,7 @@ import { compile } from 'svelte/compiler'
|
|
|
3
3
|
import type { EntitySchema } from '../schema'
|
|
4
4
|
import { addBlock, addComponentBlock, addFreestandingScreen, addScreenAction, addTabBlock, addAccordionBlock, addAccordionComponent, createProject, enableScreenCode, flattenBlocks, parseProject, serializeProject, addStateVar, setScreenLayout, setLayoutOpts, setScreenDock, setDockPaneTitle, dockPaneTitleOf, syncDockPanes, dockPaneIds, removeBlock, setAuth, setComponentBinding, setDataLayer, setDeployTarget, setEntityDataSource, setTrigger, setHandlerBody, setHandlerSteps, stepsToCode, clickSlot, setScreenHandlersSource, setScreenRenderGrid, setShell, setTheme, setThemePreset, updateBlock, updateScreen, type GridConfig, type MasterDetailConfig, type TabsConfig, type AccordionConfig, type StudioProject } from './project'
|
|
5
5
|
import ts from 'typescript'
|
|
6
|
-
import { emitStudioProject, emitStudioAppBundle, studioDeployInfo, ctxCompletions, ctxAmbientDts } from './emit-project'
|
|
6
|
+
import { emitStudioProject, emitStudioAppBundle, emitStudioFragment, studioDeployInfo, ctxCompletions, ctxAmbientDts } from './emit-project'
|
|
7
7
|
import { UI_COMPONENT_REGISTRY } from './ui-components'
|
|
8
8
|
|
|
9
9
|
/** Type-check a handler BODY against a generated ambient ctx `.d.ts` using the real
|
|
@@ -48,6 +48,320 @@ const orders: EntitySchema = {
|
|
|
48
48
|
],
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
describe('SSR-native screens (renderMode: ssr)', () => {
|
|
52
|
+
const ssrProject = (): StudioProject => {
|
|
53
|
+
const p = createProject([customers])
|
|
54
|
+
return { ...p, screens: p.screens.map((s) => ({ ...s, renderMode: 'ssr' as const })) }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
it('emits +page.server.ts with a load + CRUD form actions + server validation', () => {
|
|
58
|
+
const server = emitStudioProject(ssrProject()).find((f) => f.path === 'src/routes/customers/+page.server.ts')
|
|
59
|
+
expect(server, 'an SSR screen should emit a +page.server.ts').toBeTruthy()
|
|
60
|
+
const c = server!.contents
|
|
61
|
+
expect(c).toMatch(/export const ssr = true/) // opts back into SSR over the SPA layout's ssr=false
|
|
62
|
+
expect(c).toMatch(/export const load: PageServerLoad/)
|
|
63
|
+
expect(c).toMatch(/export const actions: Actions/)
|
|
64
|
+
expect(c).toMatch(/create:\s*async/)
|
|
65
|
+
expect(c).toMatch(/update:\s*async/)
|
|
66
|
+
expect(c).toMatch(/delete:\s*async/)
|
|
67
|
+
expect(c).toMatch(/await validateAll\(customersSchema, values\)/) // server-side validation
|
|
68
|
+
expect(c).toMatch(/return fail\(422/)
|
|
69
|
+
expect(c).toMatch(/planFromSearchParams\(url/)
|
|
70
|
+
expect(c).toMatch(/await customersSource\.getRows\(plan\)/)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('emits an SSR +page.svelte: server rows, URL-driven sort/page, form-action editing', () => {
|
|
74
|
+
const page = emitStudioProject(ssrProject()).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
|
|
75
|
+
expect(page).toMatch(/import type \{ PageProps \} from '\.\/\$types'/)
|
|
76
|
+
expect(page).toMatch(/data=\{data\.rows\}/) // renders SSR'd rows
|
|
77
|
+
expect(page).toMatch(/externalSort/)
|
|
78
|
+
expect(page).toMatch(/initialSorting=\{data\.sort\}/) // header indicator matches the URL sort on hard reload
|
|
79
|
+
expect(page).toMatch(/externalPagination/)
|
|
80
|
+
expect(page).toMatch(/onSortingChange=/)
|
|
81
|
+
expect(page).toMatch(/use:enhance=\{onSubmit\}/) // progressive enhancement
|
|
82
|
+
expect(page).toMatch(/action=\{isCreate \? '\?\/create' : '\?\/update'\}/)
|
|
83
|
+
// It must NOT fall back to the client data-source controller.
|
|
84
|
+
expect(page).not.toMatch(/createServerDataSource/)
|
|
85
|
+
// Valid Svelte 5.
|
|
86
|
+
expect(() => compile(page, { generate: 'client' })).not.toThrow()
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('SSR grid honors filterable: URL-driven filter (q + f_<col>) via applyFilters; off when not filterable', () => {
|
|
90
|
+
const p0 = createProject([customers])
|
|
91
|
+
const withFilter: StudioProject = {
|
|
92
|
+
...p0,
|
|
93
|
+
screens: p0.screens.map((s) => ({
|
|
94
|
+
...s,
|
|
95
|
+
renderMode: 'ssr' as const,
|
|
96
|
+
blocks: s.blocks.map((b) => (b.config.kind === 'grid' ? { ...b, config: { ...b.config, filterable: true } } : b)),
|
|
97
|
+
})),
|
|
98
|
+
}
|
|
99
|
+
const page = emitStudioProject(withFilter).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
|
|
100
|
+
expect(page).toMatch(/externalFilter/)
|
|
101
|
+
expect(page).toMatch(/onFiltersChange=\{applyFilters\}/)
|
|
102
|
+
expect(page).toMatch(/function applyFilters/)
|
|
103
|
+
expect(page).toMatch(/sp\.set\('f_' \+ c\.id, c\.value\)/) // per-column f_ params
|
|
104
|
+
// The default grid (filterable: false) omits the whole filter surface.
|
|
105
|
+
const off = emitStudioProject(ssrProject()).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
|
|
106
|
+
expect(off).not.toMatch(/externalFilter/)
|
|
107
|
+
expect(off).not.toMatch(/function applyFilters/)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('emits the shared server query helper once', () => {
|
|
111
|
+
const files = emitStudioProject(ssrProject())
|
|
112
|
+
const helpers = files.filter((f) => f.path === 'src/lib/server/query.ts')
|
|
113
|
+
expect(helpers).toHaveLength(1)
|
|
114
|
+
expect(helpers[0]!.contents).toMatch(/export function planFromSearchParams/)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('sql SSR screen: load/actions proxy to the connected /api route via createKitDataSource', () => {
|
|
118
|
+
const p0 = createProject([customers])
|
|
119
|
+
const p: StudioProject = {
|
|
120
|
+
...p0,
|
|
121
|
+
dataSource: 'sql',
|
|
122
|
+
dataSources: { customers: { kind: 'sql', dialect: 'postgres', table: 'customers' } },
|
|
123
|
+
screens: p0.screens.map((s) => ({ ...s, renderMode: 'ssr' as const })),
|
|
124
|
+
}
|
|
125
|
+
const files = emitStudioProject(p)
|
|
126
|
+
const server = files.find((f) => f.path === 'src/routes/customers/+page.server.ts')!.contents
|
|
127
|
+
expect(server).toMatch(/import \{ validateAll, createKitDataSource \} from '@svgrid\/enterprise'/)
|
|
128
|
+
expect(server).toMatch(/createKitDataSource<Customers>\(\{ endpoint: '\/api\/customers', fetch \}\)/)
|
|
129
|
+
expect(server).toMatch(/load: PageServerLoad = async \(\{ url, fetch \}\)/) // load gets event.fetch
|
|
130
|
+
expect(server).not.toMatch(/from '\$lib\/data'/) // no in-process source import for sql
|
|
131
|
+
// The connected /api route is still emitted - enforcement (validate/RBAC/triggers/audit) lives there.
|
|
132
|
+
expect(files.find((f) => f.path === 'src/routes/api/customers/+server.ts')).toBeTruthy()
|
|
133
|
+
// Page still compiles.
|
|
134
|
+
const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
|
|
135
|
+
expect(() => compile(page, { generate: 'client' })).not.toThrow()
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('read-only SSR screens (dashboard/kpi): load-only server file + page renders from data.*', () => {
|
|
139
|
+
let p = createProject([customers])
|
|
140
|
+
const sid = p.screens[0]!.id
|
|
141
|
+
p = { ...p, screens: p.screens.map((s) => (s.id === sid ? { ...s, blocks: [], renderMode: 'ssr' as const } : s)) }
|
|
142
|
+
p = addBlock(p, sid, 'kpi')
|
|
143
|
+
p = addBlock(p, sid, 'chart')
|
|
144
|
+
const files = emitStudioProject(p)
|
|
145
|
+
const server = files.find((f) => f.path === 'src/routes/customers/+page.server.ts')!.contents
|
|
146
|
+
expect(server).toMatch(/export const ssr = true/)
|
|
147
|
+
expect(server).toMatch(/export const load: PageServerLoad/)
|
|
148
|
+
expect(server).toMatch(/const rows = \(await customersSource\.getRows\(PAGE\)\)\.rows/)
|
|
149
|
+
expect(server).not.toMatch(/export const actions/) // read-only
|
|
150
|
+
const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
|
|
151
|
+
expect(page).toMatch(/let \{ data \}: PageProps = \$props\(\)/)
|
|
152
|
+
expect(page).toMatch(/const allRows = \$derived\(data\.rows as Customers\[\]\)/)
|
|
153
|
+
expect(page).not.toMatch(/loadAll\(\)/)
|
|
154
|
+
expect(page).not.toMatch(/from '\$lib\/data'/) // all data comes from the server load
|
|
155
|
+
expect(() => compile(page, { filename: 'p.svelte', generate: 'client' })).not.toThrow()
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('read-only SSR + master-detail: child collections load server-side too (sql via /api)', () => {
|
|
159
|
+
const p0 = createProject([customers, orders])
|
|
160
|
+
let p: StudioProject = {
|
|
161
|
+
...p0,
|
|
162
|
+
dataSource: 'sql',
|
|
163
|
+
dataSources: {
|
|
164
|
+
customers: { kind: 'sql', table: 'customers', dialect: 'postgres' },
|
|
165
|
+
orders: { kind: 'sql', table: 'orders', dialect: 'postgres' },
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
const sid = p.screens[0]!.id
|
|
169
|
+
p = { ...p, screens: p.screens.map((s) => (s.id === sid ? { ...s, blocks: [], renderMode: 'ssr' as const } : s)) }
|
|
170
|
+
p = addBlock(p, sid, 'master-detail')
|
|
171
|
+
const mdId = p.screens[0]!.blocks.at(-1)!.id
|
|
172
|
+
p = updateBlock(p, sid, mdId, { config: { childEntity: 'orders', foreignKey: 'customer_id' } as Partial<MasterDetailConfig> })
|
|
173
|
+
const files = emitStudioProject(p)
|
|
174
|
+
const server = files.find((f) => f.path === 'src/routes/customers/+page.server.ts')!.contents
|
|
175
|
+
expect(server).toMatch(/createKitDataSource<Record<string, unknown>>\(\{ endpoint: '\/api\/customers', fetch \}\)/)
|
|
176
|
+
expect(server).toMatch(/const md_orders_rows = \(await createKitDataSource.*'\/api\/orders'/)
|
|
177
|
+
expect(server).toMatch(/return \{ rows, md_orders_rows \}/)
|
|
178
|
+
const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
|
|
179
|
+
expect(page).toMatch(/const md_orders_rows = \$derived\(data\.md_orders_rows as Orders\[\]\)/)
|
|
180
|
+
expect(() => compile(page, { filename: 'p.svelte', generate: 'client' })).not.toThrow()
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('a board screen stays SPA even with renderMode ssr (client-only interaction)', () => {
|
|
184
|
+
let p = createProject([customers])
|
|
185
|
+
const sid = p.screens[0]!.id
|
|
186
|
+
p = { ...p, screens: p.screens.map((s) => (s.id === sid ? { ...s, blocks: [], renderMode: 'ssr' as const } : s)) }
|
|
187
|
+
p = addBlock(p, sid, 'board')
|
|
188
|
+
const files = emitStudioProject(p)
|
|
189
|
+
expect(files.find((f) => f.path === 'src/routes/customers/+page.server.ts')).toBeUndefined()
|
|
190
|
+
expect(files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents).toMatch(/loadAll\(\)/)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('sql SSR + relation field: prefetches options via the related /api route', () => {
|
|
194
|
+
const p0 = createProject([customers, orders])
|
|
195
|
+
const p: StudioProject = {
|
|
196
|
+
...p0,
|
|
197
|
+
dataSource: 'sql',
|
|
198
|
+
dataSources: { customers: { kind: 'sql', dialect: 'postgres', table: 'customers' }, orders: { kind: 'sql', dialect: 'postgres', table: 'orders' } },
|
|
199
|
+
screens: p0.screens.map((s) => (s.entity === 'orders' ? { ...s, renderMode: 'ssr' as const } : s)),
|
|
200
|
+
}
|
|
201
|
+
const server = emitStudioProject(p).find((f) => f.path === 'src/routes/orders/+page.server.ts')!.contents
|
|
202
|
+
expect(server).toMatch(/const customer_idOptions = \(await createKitDataSource<Record<string, unknown>>\(\{ endpoint: '\/api\/customers', fetch \}\)\.getRows\(/)
|
|
203
|
+
expect(server).toMatch(/, customer_idOptions \}/)
|
|
204
|
+
expect(server).not.toMatch(/from '\$lib\/data'/) // sql uses the /api route, not the in-process source
|
|
205
|
+
const page = emitStudioProject(p).find((f) => f.path === 'src/routes/orders/+page.svelte')!.contents
|
|
206
|
+
expect(page).toMatch(/<select name='customer_id'/)
|
|
207
|
+
expect(page).toMatch(/#each data\.customer_idOptions as o/)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('memory SSR + RBAC: load/actions enforce authz inline (sql inherits from /api)', () => {
|
|
211
|
+
const p0 = createProject([customers])
|
|
212
|
+
const p: StudioProject = {
|
|
213
|
+
...p0,
|
|
214
|
+
access: { enabled: true, defaultRole: 'viewer', roles: [{ role: 'admin', screens: '*', actions: '*' }, { role: 'viewer', screens: p0.screens.map((s) => s.id), actions: [] }] },
|
|
215
|
+
screens: p0.screens.map((s) => ({ ...s, renderMode: 'ssr' as const })),
|
|
216
|
+
}
|
|
217
|
+
const server = emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.server.ts')!.contents
|
|
218
|
+
expect(server).toMatch(/import \{ fail, error \} from '@sveltejs\/kit'/)
|
|
219
|
+
expect(server).toMatch(/import \{ authorizeAction, getServerRole \} from '\$lib\/access'/)
|
|
220
|
+
expect(server).toMatch(/authorizeAction\(getServerRole\(\{ locals \}\), 'read', SCREEN_IDS\)/)
|
|
221
|
+
expect(server).toMatch(/authorizeAction\(getServerRole\(\{ locals \}\), 'create', SCREEN_IDS\)/)
|
|
222
|
+
expect(server).toMatch(/authorizeAction\(getServerRole\(\{ locals \}\), 'delete', SCREEN_IDS\)/)
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('memory SSR + relation field: prefetches options in load and renders a <select>', () => {
|
|
226
|
+
const p0 = createProject([customers, orders])
|
|
227
|
+
const p: StudioProject = { ...p0, screens: p0.screens.map((s) => (s.entity === 'orders' ? { ...s, renderMode: 'ssr' as const } : s)) }
|
|
228
|
+
const files = emitStudioProject(p)
|
|
229
|
+
const server = files.find((f) => f.path === 'src/routes/orders/+page.server.ts')!.contents
|
|
230
|
+
expect(server).toMatch(/import \{ ordersSource, customersSource, nextId \} from '\$lib\/data'/)
|
|
231
|
+
expect(server).toMatch(/const customer_idOptions = \(await customersSource\.getRows\(/)
|
|
232
|
+
expect(server).toMatch(/label: String\(r\['name'\]/) // uses the relation's labelField
|
|
233
|
+
expect(server).toMatch(/, customer_idOptions \}/) // options returned from load
|
|
234
|
+
const page = files.find((f) => f.path === 'src/routes/orders/+page.svelte')!.contents
|
|
235
|
+
expect(page).toMatch(/<select name='customer_id'/)
|
|
236
|
+
expect(page).toMatch(/#each data\.customer_idOptions as o/)
|
|
237
|
+
expect(() => compile(page, { generate: 'client' })).not.toThrow()
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('leaves spa screens on the client-controller path', () => {
|
|
241
|
+
const p = createProject([customers]) // default renderMode is spa
|
|
242
|
+
const files = emitStudioProject(p)
|
|
243
|
+
expect(files.find((f) => f.path === 'src/routes/customers/+page.server.ts')).toBeUndefined()
|
|
244
|
+
expect(files.find((f) => f.path === 'src/lib/server/query.ts')).toBeUndefined()
|
|
245
|
+
const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
|
|
246
|
+
expect(page).toMatch(/createServerDataSource/)
|
|
247
|
+
})
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
describe('emitStudioFragment (drop into an existing app)', () => {
|
|
251
|
+
it('emits app content minus the shell, plus app.css + FRAGMENT.md', () => {
|
|
252
|
+
const files = emitStudioFragment(createProject([customers]))
|
|
253
|
+
const paths = files.map((f) => f.path)
|
|
254
|
+
// No whole-app scaffolding.
|
|
255
|
+
expect(paths).not.toContain('package.json')
|
|
256
|
+
expect(paths).not.toContain('svelte.config.js')
|
|
257
|
+
expect(paths).not.toContain('src/routes/+layout.svelte') // the nav shell
|
|
258
|
+
expect(paths).not.toContain('src/routes/+page.svelte') // the home redirect
|
|
259
|
+
// The screens + lib + the lifted styles + the readme ARE present.
|
|
260
|
+
expect(paths).toContain('src/routes/customers/+page.svelte')
|
|
261
|
+
expect(paths).toContain('src/lib/schemas.ts')
|
|
262
|
+
expect(paths).toContain('src/lib/data.ts')
|
|
263
|
+
expect(paths).toContain('src/app.css')
|
|
264
|
+
expect(paths).toContain('FRAGMENT.md')
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('FRAGMENT.md reports the runtime deps + host requirements', () => {
|
|
268
|
+
let p = createProject([customers])
|
|
269
|
+
p = setEntityDataSource(p, 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres' })
|
|
270
|
+
const md = emitStudioFragment(p).find((f) => f.path === 'FRAGMENT.md')!.contents
|
|
271
|
+
expect(md).toMatch(/npm install .*\bpg\b/) // the sql driver the fragment imports
|
|
272
|
+
expect(md).toMatch(/DATABASE_URL/) // env the host must set
|
|
273
|
+
expect(md).toMatch(/Import `src\/app\.css`/)
|
|
274
|
+
expect(md).toMatch(/nav shell.*NOT here/s)
|
|
275
|
+
})
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
describe('db/schema.sql + db/seed.sql + drizzle db:seed (real-DB last mile)', () => {
|
|
279
|
+
const sqlProject = (dialect: 'postgres' | 'mysql' | 'sqlite' | 'mssql' | 'turso'): StudioProject => {
|
|
280
|
+
let p = createProject([customers, orders])
|
|
281
|
+
p = setEntityDataSource(p, 'customers', { kind: 'sql', table: 'customers', dialect })
|
|
282
|
+
p = setEntityDataSource(p, 'orders', { kind: 'sql', table: 'orders', dialect })
|
|
283
|
+
return p
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
it('emits db/schema.sql + db/seed.sql for SQL entities without the Drizzle layer', () => {
|
|
287
|
+
const files = emitStudioProject(sqlProject('postgres'))
|
|
288
|
+
const ddl = files.find((f) => f.path === 'db/schema.sql')!.contents
|
|
289
|
+
expect(ddl).toMatch(/CREATE TABLE IF NOT EXISTS "customers" \(/)
|
|
290
|
+
expect(ddl).toMatch(/"id" text PRIMARY KEY/)
|
|
291
|
+
expect(ddl).toMatch(/"mrr" double precision/)
|
|
292
|
+
// Relation display fields are runtime labels, not columns.
|
|
293
|
+
const seedSql = files.find((f) => f.path === 'db/seed.sql')!.contents
|
|
294
|
+
expect(seedSql).toMatch(/INSERT INTO "customers" \(/)
|
|
295
|
+
expect(seedSql).toMatch(/INSERT INTO "orders" \(/)
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('mssql gets a guarded CREATE TABLE (no Drizzle possible) with N-literals', () => {
|
|
299
|
+
const files = emitStudioProject(sqlProject('mssql'))
|
|
300
|
+
const ddl = files.find((f) => f.path === 'db/schema.sql')!.contents
|
|
301
|
+
expect(ddl).toMatch(/IF OBJECT_ID\(N'customers', N'U'\) IS NULL/)
|
|
302
|
+
expect(ddl).toMatch(/"mrr" float/)
|
|
303
|
+
const seedSql = files.find((f) => f.path === 'db/seed.sql')!.contents
|
|
304
|
+
expect(seedSql).toMatch(/N'/)
|
|
305
|
+
// Even WITH the drizzle toggle, mssql still ships plain SQL (dzDialect is null).
|
|
306
|
+
const withLayer = emitStudioProject(setDataLayer(sqlProject('mssql'), true))
|
|
307
|
+
expect(withLayer.some((f) => f.path === 'db/schema.sql')).toBe(true)
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
it('mysql uses backticks; sqlite/turso use sqlite types', () => {
|
|
311
|
+
const my = emitStudioProject(sqlProject('mysql')).find((f) => f.path === 'db/schema.sql')!.contents
|
|
312
|
+
expect(my).toMatch(/CREATE TABLE IF NOT EXISTS `customers` \(/)
|
|
313
|
+
expect(my).toMatch(/`mrr` double/)
|
|
314
|
+
const tu = emitStudioProject(sqlProject('turso')).find((f) => f.path === 'db/schema.sql')!.contents
|
|
315
|
+
expect(tu).toMatch(/"mrr" real/)
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
it('the Drizzle layer replaces plain DDL with migrations + a db:seed script', () => {
|
|
319
|
+
const p = setDataLayer(sqlProject('postgres'), true)
|
|
320
|
+
const files = emitStudioAppBundle(p)
|
|
321
|
+
expect(files.some((f) => f.path === 'db/schema.sql')).toBe(false) // migrations own DDL
|
|
322
|
+
const seedTs = files.find((f) => f.path === 'db/seed.ts')!.contents
|
|
323
|
+
expect(seedTs).toMatch(/studio:db-seed/)
|
|
324
|
+
expect(seedTs).toMatch(/process\.env\.DATABASE_URL/) // own client - no \$env import
|
|
325
|
+
expect(seedTs).not.toMatch(/\$env\/dynamic\/private/)
|
|
326
|
+
expect(seedTs).toMatch(/db\.insert\(schema\.customers\)/)
|
|
327
|
+
expect(seedTs).toMatch(/existing\.length === 0/) // idempotent
|
|
328
|
+
const pkg = JSON.parse(files.find((f) => f.path === 'package.json')!.contents)
|
|
329
|
+
expect(pkg.scripts['db:seed']).toBe('tsx db/seed.ts')
|
|
330
|
+
expect(pkg.devDependencies.tsx).toBeTruthy()
|
|
331
|
+
const deploy = files.find((f) => f.path === 'DEPLOY.md')!.contents
|
|
332
|
+
expect(deploy).toMatch(/npm run db:seed/)
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
it('DEPLOY.md points at db/schema.sql on the non-Drizzle path', () => {
|
|
336
|
+
const deploy = emitStudioAppBundle(sqlProject('postgres')).find((f) => f.path === 'DEPLOY.md')!.contents
|
|
337
|
+
expect(deploy).toMatch(/db\/schema\.sql/)
|
|
338
|
+
expect(deploy).toMatch(/VITE_SVPRO_KEY/)
|
|
339
|
+
})
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
describe('secure-by-default auth', () => {
|
|
343
|
+
const authApp = () => emitStudioAppBundle(setAuth(createProject([customers]), { enabled: true, protect: true }))
|
|
344
|
+
|
|
345
|
+
it('ships a git-ignored .env with a real random SESSION_SECRET (not the hardcoded fallback)', () => {
|
|
346
|
+
const env = authApp().find((f) => f.path === '.env')
|
|
347
|
+
expect(env, 'an auth app should ship a .env with a real secret').toBeTruthy()
|
|
348
|
+
expect(env!.contents).toMatch(/SESSION_SECRET=[0-9a-f]{64}/)
|
|
349
|
+
expect(env!.contents).not.toMatch(/change-me-to-a-long-random-string/)
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
it('hashes demo users - no plaintext store or plaintext login compare', () => {
|
|
353
|
+
const files = authApp()
|
|
354
|
+
const users = files.find((f) => f.path === 'src/lib/server/users.ts')!.contents
|
|
355
|
+
expect(users).toMatch(/passwordHash: await hashPassword/)
|
|
356
|
+
// The stored user type carries only the hash (the SEED keeps plaintext defaults,
|
|
357
|
+
// which are hashed at module load).
|
|
358
|
+
expect(users).toMatch(/AppUser = \{ email: string; name: string; role: string; passwordHash: string \}/)
|
|
359
|
+
const login = files.find((f) => f.path === 'src/routes/login/+page.server.ts')!.contents
|
|
360
|
+
expect(login).toMatch(/await verifyPassword\(password, user\.passwordHash\)/)
|
|
361
|
+
expect(login).not.toMatch(/user\.password !== password/)
|
|
362
|
+
})
|
|
363
|
+
})
|
|
364
|
+
|
|
51
365
|
describe('emitStudioProject (per-block screens)', () => {
|
|
52
366
|
const project = createProject([customers, orders])
|
|
53
367
|
const files = emitStudioProject(project)
|
|
@@ -635,6 +949,38 @@ describe('emitStudioProject (per-block screens)', () => {
|
|
|
635
949
|
expect(users).not.toContain('viewer@example.com')
|
|
636
950
|
})
|
|
637
951
|
|
|
952
|
+
it('Supabase Auth: gates the app via SvAuthGate over the shared client, not the builtin session', () => {
|
|
953
|
+
let p = createProject([customers])
|
|
954
|
+
p = setEntityDataSource(p, 'customers', { kind: 'supabase', table: 'customers' })
|
|
955
|
+
p = setAuth(p, { enabled: true, provider: 'supabase' })
|
|
956
|
+
const files = emitStudioProject(p)
|
|
957
|
+
const layout = files.find((f) => f.path === 'src/routes/+layout.svelte')!.contents
|
|
958
|
+
expect(layout).toContain('<SvAuthGate')
|
|
959
|
+
expect(layout).toContain("import { SvAuthGate, type SupabaseAuthClientLike } from '@svgrid/enterprise'")
|
|
960
|
+
expect(layout).toContain("import { supabaseClient } from '$lib/connections'")
|
|
961
|
+
// Shared client emitted; NO builtin cookie-session scaffold.
|
|
962
|
+
expect(files.find((f) => f.path === 'src/lib/connections.ts')).toBeTruthy()
|
|
963
|
+
expect(files.find((f) => f.path === 'src/hooks.server.ts')).toBeUndefined()
|
|
964
|
+
expect(files.find((f) => f.path === 'src/lib/server/users.ts')).toBeUndefined()
|
|
965
|
+
expect(files.find((f) => f.path === 'src/routes/login/+page.svelte')).toBeUndefined()
|
|
966
|
+
// No cosmetic fake "Sign out" in the account menu - SvAuthGate's own bar owns it.
|
|
967
|
+
expect(layout).not.toMatch(/menuOpen = false\)}>Sign out/)
|
|
968
|
+
for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
|
|
969
|
+
expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
|
|
970
|
+
}
|
|
971
|
+
})
|
|
972
|
+
|
|
973
|
+
it('Supabase Auth emits the shared client even with no Supabase-bound entity', () => {
|
|
974
|
+
let p = createProject([customers]) // stays in-memory
|
|
975
|
+
p = { ...p, supabase: { url: 'https://proj.supabase.co', key: 'anon' } }
|
|
976
|
+
p = setAuth(p, { enabled: true, provider: 'supabase' })
|
|
977
|
+
const files = emitStudioProject(p)
|
|
978
|
+
expect(files.find((f) => f.path === 'src/lib/connections.ts')).toBeTruthy()
|
|
979
|
+
expect(files.find((f) => f.path === 'src/routes/+layout.svelte')!.contents).toContain('<SvAuthGate')
|
|
980
|
+
// data.ts does NOT import the client (no Supabase data entity uses it).
|
|
981
|
+
expect(files.find((f) => f.path === 'src/lib/data.ts')!.contents).not.toContain("from './connections'")
|
|
982
|
+
})
|
|
983
|
+
|
|
638
984
|
it('Audit: emits the store + route + viewer, wires connected routes, and compiles', () => {
|
|
639
985
|
let p = createProject([customers, orders])
|
|
640
986
|
p = setEntityDataSource(p, 'orders', { kind: 'sql', table: 'orders', dialect: 'postgres' })
|
|
@@ -1034,6 +1380,49 @@ describe('Custom actions', () => {
|
|
|
1034
1380
|
})
|
|
1035
1381
|
})
|
|
1036
1382
|
|
|
1383
|
+
describe('form/dialog layout depth', () => {
|
|
1384
|
+
const gridScreen = (extra: Partial<GridConfig>) => {
|
|
1385
|
+
let p = createProject([customers])
|
|
1386
|
+
const sid = p.screens[0]!.id
|
|
1387
|
+
const gid = p.screens[0]!.blocks[0]!.id
|
|
1388
|
+
p = updateBlock(p, sid, gid, { config: { editing: 'form', ...extra } as Partial<GridConfig> })
|
|
1389
|
+
return emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
const panelLine = (page: string) => page.split('\n').find((l) => l.includes('<SvGridEditPanel schema='))!
|
|
1393
|
+
|
|
1394
|
+
it('emits nothing extra for a default form', () => {
|
|
1395
|
+
const line = panelLine(gridScreen({}))
|
|
1396
|
+
expect(line).toMatch(/<SvGridEditPanel schema=\{customersSchema\} row=\{editing\}/)
|
|
1397
|
+
expect(line).not.toMatch(/columns=\{/)
|
|
1398
|
+
expect(line).not.toMatch(/sections=\{/)
|
|
1399
|
+
})
|
|
1400
|
+
|
|
1401
|
+
it('passes columns / field selection+order / sections / title / size to the panel', () => {
|
|
1402
|
+
const page = gridScreen({
|
|
1403
|
+
formColumns: 3,
|
|
1404
|
+
formFields: ['name', 'email'],
|
|
1405
|
+
formSections: [{ title: 'Contact', fields: ['name', 'email'] }],
|
|
1406
|
+
formTitle: 'Edit customer',
|
|
1407
|
+
formSize: 'lg',
|
|
1408
|
+
})
|
|
1409
|
+
const line = panelLine(page)
|
|
1410
|
+
expect(line).toMatch(/columns=\{3\}/)
|
|
1411
|
+
expect(line).toMatch(/formFields=\{\["name","email"\]\}/)
|
|
1412
|
+
expect(line).toMatch(/sections=\{\[\{"title":"Contact","fields":\["name","email"\]\}\]\}/)
|
|
1413
|
+
expect(line).toMatch(/title=\{'Edit customer'\}/)
|
|
1414
|
+
expect(line).toMatch(/formSize='lg'/)
|
|
1415
|
+
expect(() => compile(page, { filename: 'p.svelte', generate: 'client' })).not.toThrow()
|
|
1416
|
+
})
|
|
1417
|
+
|
|
1418
|
+
it('omits default-valued knobs (columns 2, size md)', () => {
|
|
1419
|
+
const line = panelLine(gridScreen({ formColumns: 2, formSize: 'md', formFields: ['name'] }))
|
|
1420
|
+
expect(line).not.toMatch(/columns=\{/)
|
|
1421
|
+
expect(line).not.toMatch(/formSize=/)
|
|
1422
|
+
expect(line).toMatch(/formFields=\{\["name"\]\}/)
|
|
1423
|
+
})
|
|
1424
|
+
})
|
|
1425
|
+
|
|
1037
1426
|
describe('Component blocks', () => {
|
|
1038
1427
|
it('a component block mixed onto an entity-bound screen emits the import + literal markup, and compiles', () => {
|
|
1039
1428
|
let p = createProject([customers])
|
|
@@ -1044,7 +1433,10 @@ describe('Component blocks', () => {
|
|
|
1044
1433
|
const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
|
|
1045
1434
|
const importLine = page.split('\n').find((l) => l.includes("from '@svgrid/grid'"))!
|
|
1046
1435
|
expect(importLine).toContain('SvButton') // merged into the screen's one @svgrid/grid import
|
|
1047
|
-
|
|
1436
|
+
// Full extracted surface: curated attrs first, then the rest of the component's
|
|
1437
|
+
// real props (e.g. type={'button'}); content children last.
|
|
1438
|
+
expect(page).toContain("<SvButton variant={'primary'} size={'md'} block")
|
|
1439
|
+
expect(page).toContain(">{'Click me'}</SvButton>")
|
|
1048
1440
|
|
|
1049
1441
|
for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
|
|
1050
1442
|
expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
|
|
@@ -1060,8 +1452,10 @@ describe('Component blocks', () => {
|
|
|
1060
1452
|
|
|
1061
1453
|
const page = files.find((f) => f.path === 'src/routes/reports/+page.svelte')!.contents
|
|
1062
1454
|
expect(page).toContain("import { SvBadge, SvButton } from '@svgrid/grid'") // sorted, deduped
|
|
1063
|
-
expect(page).toContain("<SvButton variant={'secondary'} size={'md'}
|
|
1064
|
-
expect(page).toContain("
|
|
1455
|
+
expect(page).toContain("<SvButton variant={'secondary'} size={'md'}")
|
|
1456
|
+
expect(page).toContain(">{'Click me'}</SvButton>")
|
|
1457
|
+
expect(page).toContain("<SvBadge variant={'success'} size={'md'} pill")
|
|
1458
|
+
expect(page).toContain(">{'Badge'}</SvBadge>")
|
|
1065
1459
|
expect(page).not.toContain('Add your own content here')
|
|
1066
1460
|
|
|
1067
1461
|
for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
|
|
@@ -1180,6 +1574,41 @@ describe('data-source codegen', () => {
|
|
|
1180
1574
|
expect(dataTs).not.toContain('createInMemoryDataSource') // customers is REST-bound
|
|
1181
1575
|
})
|
|
1182
1576
|
|
|
1577
|
+
it('REST adapter (dummyjson): emits real server paging via the adapter + imports it', () => {
|
|
1578
|
+
let p = createProject([customers])
|
|
1579
|
+
p = setEntityDataSource(p, 'customers', {
|
|
1580
|
+
kind: 'rest', baseUrl: 'https://dummyjson.com', path: 'users', method: 'GET', params: [],
|
|
1581
|
+
adapter: { kind: 'dummyjson', rowsKey: 'users' },
|
|
1582
|
+
})
|
|
1583
|
+
const dataTs = byPathOf(emitStudioProject(p), 'src/lib/data.ts')!.contents
|
|
1584
|
+
expect(dataTs).toContain('dummyJsonAdapter') // imported from @svgrid/enterprise
|
|
1585
|
+
expect(dataTs).toMatch(/\.\.\.dummyJsonAdapter<\w+>\('users'\)/)
|
|
1586
|
+
})
|
|
1587
|
+
|
|
1588
|
+
it('REST offsetLimit adapter emits only the configured params', () => {
|
|
1589
|
+
let p = createProject([customers])
|
|
1590
|
+
p = setEntityDataSource(p, 'customers', {
|
|
1591
|
+
kind: 'rest', baseUrl: 'https://api', path: 'c', method: 'GET', params: [],
|
|
1592
|
+
adapter: { kind: 'offsetLimit', offsetParam: '_start', limitParam: '_limit', sortByParam: 'sort' },
|
|
1593
|
+
})
|
|
1594
|
+
const dataTs = byPathOf(emitStudioProject(p), 'src/lib/data.ts')!.contents
|
|
1595
|
+
expect(dataTs).toContain("offsetParam: '_start'")
|
|
1596
|
+
expect(dataTs).toContain("limitParam: '_limit'")
|
|
1597
|
+
expect(dataTs).toContain("sortByParam: 'sort'")
|
|
1598
|
+
expect(dataTs).not.toContain('orderParam') // unset fields are omitted
|
|
1599
|
+
})
|
|
1600
|
+
|
|
1601
|
+
it('a REST adapter supersedes the manual rowsPath/totalPath parse', () => {
|
|
1602
|
+
let p = createProject([customers])
|
|
1603
|
+
p = setEntityDataSource(p, 'customers', {
|
|
1604
|
+
kind: 'rest', baseUrl: 'https://api', path: 'c', method: 'GET', params: [],
|
|
1605
|
+
adapter: { kind: 'jsonServer' }, rowsPath: 'data', totalPath: 'total',
|
|
1606
|
+
})
|
|
1607
|
+
const dataTs = byPathOf(emitStudioProject(p), 'src/lib/data.ts')!.contents
|
|
1608
|
+
expect(dataTs).toContain('jsonServerAdapter')
|
|
1609
|
+
expect(dataTs).not.toContain('parse: (body: any)') // adapter provides parse instead
|
|
1610
|
+
})
|
|
1611
|
+
|
|
1183
1612
|
it('substitutes static path params into the URL', () => {
|
|
1184
1613
|
let p = createProject([customers])
|
|
1185
1614
|
p = setEntityDataSource(p, 'customers', {
|
|
@@ -1245,20 +1674,30 @@ describe('data-source codegen', () => {
|
|
|
1245
1674
|
expect(emitStudioAppBundle(createProject([customers])).find((f) => f.path === '.env.example')).toBeUndefined()
|
|
1246
1675
|
})
|
|
1247
1676
|
|
|
1248
|
-
it('emits a
|
|
1677
|
+
it('emits a Supabase client that reads PUBLIC_SUPABASE_* env vars (never inlines the key)', () => {
|
|
1249
1678
|
let p = createProject([customers])
|
|
1250
1679
|
p = setEntityDataSource(p, 'customers', { kind: 'supabase', table: 'customers', url: 'https://x.supabase.co', key: 'anon123' })
|
|
1251
1680
|
const conn = byPathOf(emitStudioProject(p), 'src/lib/connections.ts')!.contents
|
|
1252
1681
|
expect(conn).toContain("import { createClient } from '@supabase/supabase-js'")
|
|
1253
|
-
expect(conn).toContain("
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1682
|
+
expect(conn).toContain("import { env } from '$env/dynamic/public'")
|
|
1683
|
+
expect(conn).toContain('createClient(')
|
|
1684
|
+
expect(conn).toContain('env.PUBLIC_SUPABASE_URL')
|
|
1685
|
+
expect(conn).toContain('env.PUBLIC_SUPABASE_ANON_KEY')
|
|
1686
|
+
// The anon key is NEVER baked into source (it goes to .env); the designer values
|
|
1687
|
+
// appear only as a paste-into-.env comment.
|
|
1688
|
+
expect(conn).not.toContain("createClient('https://x.supabase.co'")
|
|
1689
|
+
// .env.example lists the public Supabase keys.
|
|
1690
|
+
const envEx = emitStudioAppBundle(p).find((f) => f.path === '.env.example')!.contents
|
|
1691
|
+
expect(envEx).toContain('PUBLIC_SUPABASE_URL=')
|
|
1692
|
+
expect(envEx).toContain('PUBLIC_SUPABASE_ANON_KEY=')
|
|
1693
|
+
})
|
|
1694
|
+
|
|
1695
|
+
it('still emits an env-var Supabase client without designer url/key', () => {
|
|
1257
1696
|
let p = createProject([customers])
|
|
1258
1697
|
p = setEntityDataSource(p, 'customers', { kind: 'supabase', table: 'customers' })
|
|
1259
1698
|
const conn = byPathOf(emitStudioProject(p), 'src/lib/connections.ts')!.contents
|
|
1260
|
-
expect(conn).toContain('
|
|
1261
|
-
expect(conn).not.
|
|
1699
|
+
expect(conn).toContain('env.PUBLIC_SUPABASE_URL')
|
|
1700
|
+
expect(conn).not.toContain('null as unknown as SupabaseClientLike') // no stub - reads env
|
|
1262
1701
|
})
|
|
1263
1702
|
|
|
1264
1703
|
it('every emitted .svelte compiles with mixed REST + Supabase sources', () => {
|
|
@@ -1269,6 +1708,50 @@ describe('data-source codegen', () => {
|
|
|
1269
1708
|
expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
|
|
1270
1709
|
}
|
|
1271
1710
|
})
|
|
1711
|
+
|
|
1712
|
+
it('Supabase realtime: subscribes the grid page to live changes and refreshes on change', () => {
|
|
1713
|
+
let p = createProject([customers])
|
|
1714
|
+
p = setEntityDataSource(p, 'customers', { kind: 'supabase', table: 'customers', realtime: true })
|
|
1715
|
+
const page = byPathOf(emitStudioProject(p), 'src/routes/customers/+page.svelte')!.contents
|
|
1716
|
+
expect(page).toContain('createSupabaseRealtime')
|
|
1717
|
+
expect(page).toContain("import { supabaseClient } from '$lib/connections'")
|
|
1718
|
+
expect(page).toContain("table: 'customers'")
|
|
1719
|
+
expect(page).toContain('onChange: () => controller.refresh()')
|
|
1720
|
+
// Cleanup unsubscribes the channel alongside disposing the controller.
|
|
1721
|
+
expect(page).toContain('__rt.unsubscribe()')
|
|
1722
|
+
expect(page).toContain('controller.dispose()')
|
|
1723
|
+
// Compiles.
|
|
1724
|
+
expect(() => compile(page, { filename: 'p.svelte', generate: 'client' })).not.toThrow()
|
|
1725
|
+
})
|
|
1726
|
+
|
|
1727
|
+
it('Supabase without realtime does NOT emit a subscription', () => {
|
|
1728
|
+
let p = createProject([customers])
|
|
1729
|
+
p = setEntityDataSource(p, 'customers', { kind: 'supabase', table: 'customers' })
|
|
1730
|
+
const page = byPathOf(emitStudioProject(p), 'src/routes/customers/+page.svelte')!.contents
|
|
1731
|
+
expect(page).not.toContain('createSupabaseRealtime')
|
|
1732
|
+
})
|
|
1733
|
+
|
|
1734
|
+
it('project-level Supabase connection feeds the .env paste hint (entity carries only the table)', () => {
|
|
1735
|
+
let p = createProject([customers])
|
|
1736
|
+
p = setEntityDataSource(p, 'customers', { kind: 'supabase', table: 'customers' })
|
|
1737
|
+
p = { ...p, supabase: { url: 'https://proj.supabase.co', key: 'anonKEY' } }
|
|
1738
|
+
const conn = byPathOf(emitStudioProject(p), 'src/lib/connections.ts')!.contents
|
|
1739
|
+
expect(conn).toContain('PUBLIC_SUPABASE_URL=https://proj.supabase.co')
|
|
1740
|
+
expect(conn).toContain('PUBLIC_SUPABASE_ANON_KEY=anonKEY')
|
|
1741
|
+
// The client still reads env (project-level values only seed the paste comment).
|
|
1742
|
+
expect(conn).not.toContain("createClient('https://proj.supabase.co'")
|
|
1743
|
+
})
|
|
1744
|
+
|
|
1745
|
+
it('SQL schema selector qualifies the table in the generated route', () => {
|
|
1746
|
+
let p = createProject([customers])
|
|
1747
|
+
p = setEntityDataSource(p, 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres', schema: 'analytics' })
|
|
1748
|
+
const route = byPathOf(emitStudioProject(p), 'src/routes/api/customers/+server.ts')!.contents
|
|
1749
|
+
expect(route).toContain('dbSchema: "analytics"')
|
|
1750
|
+
// No schema set -> no dbSchema option (unqualified table).
|
|
1751
|
+
let q = createProject([customers])
|
|
1752
|
+
q = setEntityDataSource(q, 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres' })
|
|
1753
|
+
expect(byPathOf(emitStudioProject(q), 'src/routes/api/customers/+server.ts')!.contents).not.toContain('dbSchema:')
|
|
1754
|
+
})
|
|
1272
1755
|
})
|
|
1273
1756
|
|
|
1274
1757
|
describe('grid editing (a Grid property) -> codegen', () => {
|
|
@@ -1710,7 +2193,7 @@ describe('dock layout (screen.layout = dock)', () => {
|
|
|
1710
2193
|
const page = emitStudioProject(p).find((f) => f.path.endsWith('/+page.svelte'))!.contents
|
|
1711
2194
|
expect(page).toContain('async function save({ mode, id, values }')
|
|
1712
2195
|
expect(page).toContain('const row = values')
|
|
1713
|
-
expect(page).toContain('const ctx = { grid:
|
|
2196
|
+
expect(page).toContain('const ctx = { grid: gridCtx,')
|
|
1714
2197
|
expect(page).toContain("ctx.goto('/customers')")
|
|
1715
2198
|
for (const f of emitStudioProject(p).filter((f) => f.path.endsWith('.svelte'))) {
|
|
1716
2199
|
expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
|
|
@@ -2043,7 +2526,7 @@ describe('code companion (design + your own code)', () => {
|
|
|
2043
2526
|
expect(page.contents).toContain("import * as handlers from './handlers'")
|
|
2044
2527
|
// onLoad runs on mount and onDestroy on unmount, both with the full ctx (grid +
|
|
2045
2528
|
// the settable data battery, since this freestanding page owns its rows).
|
|
2046
|
-
expect(page.contents).toContain('const ctx = { grid:
|
|
2529
|
+
expect(page.contents).toContain('const ctx = { grid: gridCtx, data: { get rows() { return rows }, setRows: (r) => (rows = r) }')
|
|
2047
2530
|
expect(page.contents).toContain('as PageContext')
|
|
2048
2531
|
expect(page.contents).toContain('handlers.onLoad(ctx)')
|
|
2049
2532
|
expect(page.contents).toContain('return () => handlers.onDestroy(ctx)')
|
|
@@ -2074,10 +2557,12 @@ describe('code companion (design + your own code)', () => {
|
|
|
2074
2557
|
const companion = files.find((f) => f.path === 'src/routes/report/handlers.ts')!
|
|
2075
2558
|
// Named handle (button1) created + wired into the component markup.
|
|
2076
2559
|
expect(page.contents).toContain('const button1 = handle(')
|
|
2077
|
-
|
|
2078
|
-
|
|
2560
|
+
// Click is wired through the component's own onclick callback prop (exact
|
|
2561
|
+
// semantics), not a bubbling wrapper listener.
|
|
2562
|
+
expect(page.contents).toContain("<SvButton {...button1.props} onclick={(e) => button1.fire('click', e)}>{button1.text}</SvButton>")
|
|
2563
|
+
expect(page.contents).toContain("import { handle, gridHandle } from '$lib/handles.svelte'")
|
|
2079
2564
|
expect(page.contents).toContain('button1.fire(\'click\', e)')
|
|
2080
|
-
expect(page.contents).toContain('const ctx = { grid:
|
|
2565
|
+
expect(page.contents).toContain('const ctx = { grid: gridCtx, button1, data:')
|
|
2081
2566
|
expect(page.contents).toContain('handlers.onLoad(ctx)')
|
|
2082
2567
|
// Typed handle: ButtonHandle intersects Handle with the button's real setters.
|
|
2083
2568
|
expect(ctx.contents).toContain('button1: ButtonHandle')
|
|
@@ -2177,7 +2662,7 @@ describe('code companion (design + your own code)', () => {
|
|
|
2177
2662
|
expect(page.contents).toContain('let gridApi = $state<SvGridApi<any, any> | null>(null)')
|
|
2178
2663
|
expect(page.contents).toContain('onApiReady={(a) => (gridApi = a)}')
|
|
2179
2664
|
// Full ctx on mount + cleanup on unmount; the grid exposes reload(), not setRows.
|
|
2180
|
-
expect(page.contents).toContain('const ctx = { grid:
|
|
2665
|
+
expect(page.contents).toContain('const ctx = { grid: gridCtx, data: { get rows() { return view.rows }, reload: () => controller.refresh(), create: (v) => controller.createRow(v), update: (id, v) => controller.updateRow(id, v), delete: (id) => controller.deleteRow(id) }, goto, params: Object.fromEntries($page.url.searchParams) } as unknown as PageContext')
|
|
2181
2666
|
expect(page.contents).toContain('handlers.onLoad(ctx)')
|
|
2182
2667
|
expect(page.contents).toContain('return () => handlers.onDestroy(ctx)')
|
|
2183
2668
|
// The grid api is typed to the entity's row (Customers), not any.
|
|
@@ -2203,7 +2688,7 @@ describe('code companion (design + your own code)', () => {
|
|
|
2203
2688
|
expect(page.contents).toContain('const chart1 = dataHandle<Customers>(() => allRows)')
|
|
2204
2689
|
expect(page.contents).toContain('const kpi1 = dataHandle<Customers>(() => allRows)')
|
|
2205
2690
|
expect(page.contents).toContain('rows={chart1.rows}')
|
|
2206
|
-
expect(page.contents).toContain("import { handle, dataHandle } from '$lib/handles.svelte'")
|
|
2691
|
+
expect(page.contents).toContain("import { handle, dataHandle, gridHandle } from '$lib/handles.svelte'")
|
|
2207
2692
|
// ctx carries the data handles + the entity component handle + batteries.
|
|
2208
2693
|
expect(ctx.contents).toContain('chart1: DataHandle<Customers>')
|
|
2209
2694
|
expect(ctx.contents).toContain('kpi1: DataHandle<Customers>')
|