@svgrid/enterprise 2.0.4 → 2.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -6
- package/dist/cdn/svgrid-enterprise.svelte-external.js +14035 -6842
- package/dist/node/studio.js +7888 -2459
- package/package.json +9 -4
- package/src/SvGridMasterDetail.svelte +24 -3
- package/src/SvGridScheduler.svelte +4410 -0
- package/src/SvPivotDesigner.svelte +1990 -1045
- package/src/SvSchemaChart.svelte +10 -9
- package/src/ai.test.ts +522 -522
- package/src/ai.ts +202 -2
- package/src/export.ts +7 -1
- package/src/index.ts +409 -384
- package/src/install.ts +10 -0
- package/src/pivot-chart.test.ts +86 -0
- package/src/pivot-chart.ts +112 -0
- package/src/scheduler.ts +37 -0
- package/src/scheduling.test.ts +194 -0
- package/src/scheduling.ts +293 -0
- package/src/sources/filters.ts +6 -0
- package/src/studio/HANDLERS-DESIGN.md +142 -0
- package/src/studio/cli.ts +7 -2
- package/src/studio/emit-project.test.ts +1447 -13
- package/src/studio/emit-project.ts +3995 -1273
- package/src/studio/emit-schema.ts +146 -29
- package/src/studio/index.ts +320 -195
- package/src/studio/project.test.ts +370 -0
- package/src/studio/project.ts +1146 -26
- package/src/studio/sample-data.ts +4 -1
- package/src/studio/samples/ats.ts +2 -2
- package/src/studio/samples/clinic.ts +4 -2
- package/src/studio/samples/crm.ts +16 -8
- package/src/studio/samples/events.ts +4 -2
- package/src/studio/samples/fleet.ts +4 -2
- package/src/studio/samples/gym.ts +4 -2
- package/src/studio/samples/hr.ts +3 -1
- package/src/studio/samples/live-data.ts +308 -308
- package/src/studio/samples/projects.ts +2 -2
- package/src/studio/samples/restaurant.ts +4 -2
- package/src/studio/samples/samples.test.ts +13 -5
- package/src/studio/samples/shared.ts +346 -305
- package/src/studio/samples/support.ts +3 -1
- package/src/studio/scaffold.test.ts +15 -1
- package/src/studio/scaffold.ts +16 -0
- package/src/studio/themes.ts +7 -0
- package/src/studio/ui-components.ts +472 -0
- package/src/sveltekit/transport.test.ts +26 -0
- package/src/sveltekit/transport.ts +50 -5
- package/dist/designer/assets/index-Dp44bTid.js +0 -939
- package/dist/designer/assets/index-RJp6x8tw.css +0 -1
- package/dist/designer/assets/jszip.min-CjMo-QGg.js +0 -2
- package/dist/designer/index.html +0 -13
|
@@ -1,8 +1,34 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import { compile } from 'svelte/compiler'
|
|
3
3
|
import type { EntitySchema } from '../schema'
|
|
4
|
-
import { addBlock, addTabBlock, createProject, parseProject, setDeployTarget, setEntityDataSource, setShell, setTheme, setThemePreset, updateBlock, updateScreen, type GridConfig, type MasterDetailConfig, type TabsConfig, type StudioProject } from './project'
|
|
5
|
-
import
|
|
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
|
+
import ts from 'typescript'
|
|
6
|
+
import { emitStudioProject, emitStudioAppBundle, studioDeployInfo, ctxCompletions, ctxAmbientDts } from './emit-project'
|
|
7
|
+
import { UI_COMPONENT_REGISTRY } from './ui-components'
|
|
8
|
+
|
|
9
|
+
/** Type-check a handler BODY against a generated ambient ctx `.d.ts` using the real
|
|
10
|
+
* TypeScript compiler - the same surface the in-editor language service uses.
|
|
11
|
+
* Returns main.ts diagnostics (empty = clean). Mirrors the editor's function wrap. */
|
|
12
|
+
function typeCheckBody(dts: string, body: string): string[] {
|
|
13
|
+
const PRE = 'async function __run(ctx: PageContext): Promise<void> {\n'
|
|
14
|
+
const files: Record<string, string> = { 'env.d.ts': dts, 'main.ts': `${PRE}${body}\n}\n` }
|
|
15
|
+
const options: ts.CompilerOptions = {
|
|
16
|
+
target: ts.ScriptTarget.ES2021, lib: ['lib.es2021.d.ts', 'lib.dom.d.ts'],
|
|
17
|
+
module: ts.ModuleKind.ESNext, strict: false, noEmit: true, skipLibCheck: true, types: [],
|
|
18
|
+
}
|
|
19
|
+
const host = ts.createCompilerHost(options, true)
|
|
20
|
+
const origGet = host.getSourceFile.bind(host)
|
|
21
|
+
const origRead = host.readFile.bind(host)
|
|
22
|
+
const origExists = host.fileExists.bind(host)
|
|
23
|
+
host.getSourceFile = (name, langOrOpts, onErr, shouldCreate) =>
|
|
24
|
+
files[name] != null ? ts.createSourceFile(name, files[name], langOrOpts, true) : origGet(name, langOrOpts, onErr, shouldCreate)
|
|
25
|
+
host.readFile = (name) => files[name] ?? origRead(name)
|
|
26
|
+
host.fileExists = (name) => files[name] != null || origExists(name)
|
|
27
|
+
const program = ts.createProgram(['env.d.ts', 'main.ts'], options, host)
|
|
28
|
+
const main = program.getSourceFile('main.ts')
|
|
29
|
+
return [...program.getSemanticDiagnostics(main), ...program.getSyntacticDiagnostics(main)]
|
|
30
|
+
.map((d) => ts.flattenDiagnosticMessageText(d.messageText, '\n'))
|
|
31
|
+
}
|
|
6
32
|
|
|
7
33
|
const customers: EntitySchema = {
|
|
8
34
|
name: 'customers', label: 'Customer', idField: 'id',
|
|
@@ -134,6 +160,25 @@ describe('emitStudioProject (per-block screens)', () => {
|
|
|
134
160
|
expect(() => compile(page, { filename: 'customers/+page.svelte', generate: 'client' })).not.toThrow()
|
|
135
161
|
})
|
|
136
162
|
|
|
163
|
+
it('Accordion block: nests display blocks + components into SvAccordion sections', () => {
|
|
164
|
+
let p = createProject([customers])
|
|
165
|
+
const sid = p.screens[0]!.id
|
|
166
|
+
p = addBlock(p, sid, 'accordion')
|
|
167
|
+
const ab = p.screens.find((s) => s.id === sid)!.blocks.find((b) => b.config.kind === 'accordion')!
|
|
168
|
+
let cfg = addAccordionBlock(ab.config as AccordionConfig, 0, 'chart', customers) // display child
|
|
169
|
+
cfg = addAccordionComponent(cfg, 1, 'badge') // component child
|
|
170
|
+
p = updateBlock(p, sid, ab.id, { config: cfg })
|
|
171
|
+
const page = emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
|
|
172
|
+
expect(page).toMatch(/import \{[^}]*SvAccordion[^}]*\} from '@svgrid\/grid'/)
|
|
173
|
+
expect(page).toMatch(/import \{[^}]*SvBadge[^}]*\} from '@svgrid\/grid'/) // nested component imported
|
|
174
|
+
expect(page).toContain('{#snippet panel(item)}')
|
|
175
|
+
expect(page).toContain('expandMode="single"')
|
|
176
|
+
expect(page).toContain('<SvSchemaChart') // display child renders
|
|
177
|
+
expect(page).toContain('<SvBadge') // component child renders
|
|
178
|
+
expect(page).toMatch(/let accOpen_\w+ = \$state<string\[\]>\(\['/) // expanded-ids state var
|
|
179
|
+
expect(() => compile(page, { filename: 'customers/+page.svelte', generate: 'client' })).not.toThrow()
|
|
180
|
+
})
|
|
181
|
+
|
|
137
182
|
it('a chart-only screen has no controller/edit modal', () => {
|
|
138
183
|
let p = createProject([customers])
|
|
139
184
|
const sid = p.screens[0]!.id
|
|
@@ -282,7 +327,10 @@ describe('emitStudioProject (per-block screens)', () => {
|
|
|
282
327
|
|
|
283
328
|
it('RBAC: emits access.ts, gates the UI, guards the server route + nav, and compiles', () => {
|
|
284
329
|
let p = createProject([customers, orders])
|
|
285
|
-
|
|
330
|
+
// Both entities get a +server.ts, so we can prove each route's authorize call
|
|
331
|
+
// is bound to ITS OWN entity's screen id, not a shared/blanket value.
|
|
332
|
+
p = setEntityDataSource(p, 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres' })
|
|
333
|
+
p = setEntityDataSource(p, 'orders', { kind: 'sql', table: 'orders', dialect: 'postgres' })
|
|
286
334
|
p = { ...p, access: { enabled: true, defaultRole: 'viewer', roles: [
|
|
287
335
|
{ role: 'admin', screens: '*', actions: '*' },
|
|
288
336
|
{ role: 'viewer', screens: ['customers'], actions: [] },
|
|
@@ -294,14 +342,23 @@ describe('emitStudioProject (per-block screens)', () => {
|
|
|
294
342
|
expect(access).toContain('export const currentRole = writable<AppRole>("viewer")')
|
|
295
343
|
expect(access).toContain('export function authorizeAction')
|
|
296
344
|
expect(access).toContain('export function getServerRole')
|
|
345
|
+
// Reads are gated by screen access now (server-enforced), not blanket-allowed:
|
|
346
|
+
// an entity with no bound screen (e.g. a lookup-only relation target) stays
|
|
347
|
+
// open since there's nothing to gate it by.
|
|
348
|
+
expect(access).toContain("if (action !== 'read') return can(role, action)")
|
|
349
|
+
expect(access).toContain('if (screenIds.length === 0) return true')
|
|
350
|
+
expect(access).toContain('return screenIds.some((id) => canScreen(role, id))')
|
|
297
351
|
|
|
298
352
|
const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
|
|
299
353
|
expect(page).toContain("import { currentRole, can } from '$lib/access'")
|
|
300
354
|
expect(page).toContain("{#if can($currentRole, 'create')}") // New button gated
|
|
301
355
|
expect(page).toContain("if (can($currentRole, 'update'))") // edit gated
|
|
302
356
|
|
|
303
|
-
|
|
304
|
-
|
|
357
|
+
// Each entity's route carries its OWN screen id(s) - not a shared/static value.
|
|
358
|
+
const customersRoute = files.find((f) => f.path === 'src/routes/api/customers/+server.ts')!.contents
|
|
359
|
+
expect(customersRoute).toContain('authorize: ({ action, event }) => authorizeAction(getServerRole(event), action, ["customers"])')
|
|
360
|
+
const ordersRoute = files.find((f) => f.path === 'src/routes/api/orders/+server.ts')!.contents
|
|
361
|
+
expect(ordersRoute).toContain('authorize: ({ action, event }) => authorizeAction(getServerRole(event), action, ["orders"])')
|
|
305
362
|
|
|
306
363
|
const layout = files.find((f) => f.path === 'src/routes/+layout.svelte')!.contents
|
|
307
364
|
expect(layout).toContain('canScreen($currentRole, item.id)') // nav hides forbidden screens
|
|
@@ -319,6 +376,265 @@ describe('emitStudioProject (per-block screens)', () => {
|
|
|
319
376
|
expect(page).not.toContain('$currentRole')
|
|
320
377
|
})
|
|
321
378
|
|
|
379
|
+
it('Auth: scaffolds session + hooks + login + seed users, closes the RBAC loop, and compiles', () => {
|
|
380
|
+
let p = createProject([customers])
|
|
381
|
+
p = { ...p, access: { enabled: true, defaultRole: 'viewer', roles: [
|
|
382
|
+
{ role: 'admin', screens: '*', actions: '*' },
|
|
383
|
+
{ role: 'viewer', screens: ['customers'], actions: [] },
|
|
384
|
+
] } }
|
|
385
|
+
p = setAuth(p, { enabled: true })
|
|
386
|
+
const files = emitStudioProject(p)
|
|
387
|
+
const get = (path: string) => files.find((f) => f.path === path)?.contents
|
|
388
|
+
|
|
389
|
+
// The whole starter is emitted.
|
|
390
|
+
for (const path of ['src/lib/server/auth.ts', 'src/lib/server/users.ts', 'src/hooks.server.ts', 'src/auth.d.ts', 'src/routes/+layout.server.ts', 'src/routes/login/+page.svelte', 'src/routes/login/+page.server.ts', 'src/routes/logout/+page.server.ts']) {
|
|
391
|
+
expect(files.find((f) => f.path === path), path).toBeTruthy()
|
|
392
|
+
}
|
|
393
|
+
// SESSION_SECRET merges into the single shared .env.example (bundle level).
|
|
394
|
+
const envEx = emitStudioAppBundle(p).filter((f) => f.path === '.env.example')
|
|
395
|
+
expect(envEx).toHaveLength(1)
|
|
396
|
+
expect(envEx[0]!.contents).toContain('SESSION_SECRET')
|
|
397
|
+
// hooks resolves the session into event.locals.role - the value getServerRole reads.
|
|
398
|
+
expect(get('src/hooks.server.ts')).toContain('event.locals.role = user?.role')
|
|
399
|
+
// Dependency-free crypto (no external auth lib).
|
|
400
|
+
expect(get('src/lib/server/auth.ts')).toContain('crypto.subtle')
|
|
401
|
+
expect(get('src/lib/server/auth.ts')).toContain('export async function signSession')
|
|
402
|
+
// One demo user per RBAC role.
|
|
403
|
+
expect(get('src/lib/server/users.ts')).toContain('admin@example.com')
|
|
404
|
+
expect(get('src/lib/server/users.ts')).toContain('viewer@example.com')
|
|
405
|
+
// Protect-by-default guards the app in the root server load.
|
|
406
|
+
expect(get('src/routes/+layout.server.ts')).toContain("throw redirect(302, '/login?redirectTo='")
|
|
407
|
+
// Login form action signs + sets the session cookie.
|
|
408
|
+
expect(get('src/routes/login/+page.server.ts')).toContain('cookies.set(SESSION_COOKIE')
|
|
409
|
+
// The shell wires the session in: login bypass, real user, sign-out, role seed.
|
|
410
|
+
const layout = get('src/routes/+layout.svelte')!
|
|
411
|
+
expect(layout).toContain('["/login"].includes($page.url.pathname)') // login renders bare (no shell)
|
|
412
|
+
expect(layout).toContain('action="/logout"')
|
|
413
|
+
expect(layout).toContain('currentRole.set(data.role')
|
|
414
|
+
expect(layout).toContain('data?.user?.email')
|
|
415
|
+
|
|
416
|
+
for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
|
|
417
|
+
expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
|
|
418
|
+
}
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
// A SQL entity with a NUMBER primary key + a db-column alias, to exercise the
|
|
422
|
+
// serial / autoincrement + dbColumn mapping paths.
|
|
423
|
+
const products: EntitySchema = {
|
|
424
|
+
name: 'products', label: 'Product', idField: 'id',
|
|
425
|
+
fields: [
|
|
426
|
+
{ field: 'id', type: 'number', primaryKey: true },
|
|
427
|
+
{ field: 'title', type: 'text' },
|
|
428
|
+
{ field: 'price', type: 'number' },
|
|
429
|
+
{ field: 'inStock', type: 'boolean', dbColumn: 'in_stock' },
|
|
430
|
+
],
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
it('Data layer: Drizzle schema + typed repos + migrations config for SQL entities', () => {
|
|
434
|
+
let p = createProject([products])
|
|
435
|
+
p = setEntityDataSource(p, 'products', { kind: 'sql', table: 'products_tbl', dialect: 'postgres' })
|
|
436
|
+
p = setDataLayer(p, true)
|
|
437
|
+
const files = emitStudioProject(p)
|
|
438
|
+
const get = (path: string) => files.find((f) => f.path === path)?.contents
|
|
439
|
+
|
|
440
|
+
for (const path of ['src/lib/server/db/schema.ts', 'src/lib/server/db/index.ts', 'src/lib/server/db/products.ts', 'drizzle.config.ts']) {
|
|
441
|
+
expect(files.find((f) => f.path === path), path).toBeTruthy()
|
|
442
|
+
}
|
|
443
|
+
const schema = get('src/lib/server/db/schema.ts')!
|
|
444
|
+
expect(schema).toContain("from 'drizzle-orm/pg-core'")
|
|
445
|
+
expect(schema).toContain('pgTable("products_tbl"') // real DB table name
|
|
446
|
+
expect(schema).toContain('"id": serial("id").primaryKey()') // number PK -> serial
|
|
447
|
+
expect(schema).toContain('"inStock": boolean("in_stock")') // dbColumn alias
|
|
448
|
+
expect(schema).toContain('typeof products.$inferSelect')
|
|
449
|
+
// A typed repo with returning-based CRUD, id typed from the PK.
|
|
450
|
+
const repo = get('src/lib/server/db/products.ts')!
|
|
451
|
+
expect(repo).toContain('export const productsRepo')
|
|
452
|
+
expect(repo).toContain('.returning()).at(0)!')
|
|
453
|
+
expect(repo).toContain('get: async (id: number)')
|
|
454
|
+
// drizzle-kit migration config + client.
|
|
455
|
+
expect(get('drizzle.config.ts')).toContain("dialect: 'postgresql'")
|
|
456
|
+
expect(get('src/lib/server/db/index.ts')).toContain('drizzle-orm/node-postgres')
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
it('Data layer: package.json wires drizzle deps + migration scripts; SQLite maps a number id to autoincrement', () => {
|
|
460
|
+
let p = createProject([products])
|
|
461
|
+
p = setEntityDataSource(p, 'products', { kind: 'sql', table: 'products', dialect: 'sqlite' })
|
|
462
|
+
p = setDataLayer(p, true)
|
|
463
|
+
const files = emitStudioAppBundle(p)
|
|
464
|
+
const pkg = JSON.parse(files.find((f) => f.path === 'package.json')!.contents)
|
|
465
|
+
expect(pkg.dependencies['drizzle-orm']).toBeTruthy()
|
|
466
|
+
expect(pkg.devDependencies['drizzle-kit']).toBeTruthy()
|
|
467
|
+
expect(pkg.scripts['db:generate']).toBe('drizzle-kit generate')
|
|
468
|
+
expect(pkg.scripts['db:migrate']).toBe('drizzle-kit migrate')
|
|
469
|
+
const schema = files.find((f) => f.path === 'src/lib/server/db/schema.ts')!.contents
|
|
470
|
+
expect(schema).toContain("from 'drizzle-orm/sqlite-core'")
|
|
471
|
+
expect(schema).toContain('integer("id").primaryKey({ autoIncrement: true })')
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
it('no data layer without the toggle, without a SQL entity, or on an unsupported dialect', () => {
|
|
475
|
+
// Off by default.
|
|
476
|
+
let sqlOnly = setEntityDataSource(createProject([customers]), 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres' })
|
|
477
|
+
expect(emitStudioProject(sqlOnly).find((f) => f.path === 'src/lib/server/db/schema.ts')).toBeUndefined()
|
|
478
|
+
// Toggle on but no SQL entity (memory source) -> nothing.
|
|
479
|
+
expect(emitStudioProject(setDataLayer(createProject([customers]), true)).find((f) => f.path.startsWith('src/lib/server/db/'))).toBeUndefined()
|
|
480
|
+
// Toggle on + MSSQL (Drizzle has no SQL Server driver) -> raw route only, no Drizzle layer.
|
|
481
|
+
const mssql = setDataLayer(setEntityDataSource(createProject([customers]), 'customers', { kind: 'sql', table: 'customers', dialect: 'mssql' }), true)
|
|
482
|
+
expect(emitStudioProject(mssql).find((f) => f.path === 'src/lib/server/db/schema.ts')).toBeUndefined()
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
it('Data layer: MySQL emits a re-select repo (no RETURNING) + int autoincrement PK', () => {
|
|
486
|
+
let p = createProject([products])
|
|
487
|
+
p = setEntityDataSource(p, 'products', { kind: 'sql', table: 'products', dialect: 'mysql' })
|
|
488
|
+
p = setDataLayer(p, true)
|
|
489
|
+
const files = emitStudioProject(p)
|
|
490
|
+
const schema = files.find((f) => f.path === 'src/lib/server/db/schema.ts')!.contents
|
|
491
|
+
expect(schema).toContain("from 'drizzle-orm/mysql-core'")
|
|
492
|
+
expect(schema).toContain('int("id").autoincrement().primaryKey()')
|
|
493
|
+
const repo = files.find((f) => f.path === 'src/lib/server/db/products.ts')!.contents
|
|
494
|
+
expect(repo).toContain('async function getById') // re-select helper
|
|
495
|
+
expect(repo).toContain('.insertId') // uses the insert id
|
|
496
|
+
expect(repo).not.toContain('.returning()') // MySQL has none
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
it('DB-backed auth: auth + data layer moves the user store into an auth_users table (hashed)', () => {
|
|
500
|
+
let p = createProject([products])
|
|
501
|
+
p = setEntityDataSource(p, 'products', { kind: 'sql', table: 'products', dialect: 'postgres' })
|
|
502
|
+
p = setDataLayer(p, true)
|
|
503
|
+
p = setAuth(p, { enabled: true })
|
|
504
|
+
const files = emitStudioProject(p)
|
|
505
|
+
// The auth_users table lives in the same Drizzle schema (one migration covers it).
|
|
506
|
+
expect(files.find((f) => f.path === 'src/lib/server/db/schema.ts')!.contents).toContain('authUsers = pgTable("auth_users"')
|
|
507
|
+
// users.ts queries the DB + seeds once; login verifies the PBKDF2 hash.
|
|
508
|
+
const users = files.find((f) => f.path === 'src/lib/server/users.ts')!.contents
|
|
509
|
+
expect(users).toContain("from './db/schema'")
|
|
510
|
+
expect(users).toContain('function ensureSeeded')
|
|
511
|
+
expect(users).toContain('passwordHash: await hashPassword(u.password)')
|
|
512
|
+
const login = files.find((f) => f.path === 'src/routes/login/+page.server.ts')!.contents
|
|
513
|
+
expect(login).toContain('const user = await findUser(email)')
|
|
514
|
+
expect(login).toContain('await verifyPassword(password, user.passwordHash)')
|
|
515
|
+
// Without the data layer, auth stays on the in-code demo store.
|
|
516
|
+
const inCode = emitStudioProject(setAuth(createProject([products]), { enabled: true }))
|
|
517
|
+
expect(inCode.find((f) => f.path === 'src/lib/server/users.ts')!.contents).toContain('export const USERS')
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
it('Auth depth: register + password reset + change-password + admin user management (DB-backed)', () => {
|
|
521
|
+
let p = createProject([products])
|
|
522
|
+
p = { ...p, access: { enabled: true, defaultRole: 'viewer', roles: [
|
|
523
|
+
{ role: 'admin', screens: '*', actions: '*' },
|
|
524
|
+
{ role: 'viewer', screens: ['products'], actions: [] },
|
|
525
|
+
] } }
|
|
526
|
+
p = setEntityDataSource(p, 'products', { kind: 'sql', table: 'products', dialect: 'postgres' })
|
|
527
|
+
p = setDataLayer(p, true)
|
|
528
|
+
p = setAuth(p, { enabled: true, register: true, userAdmin: true })
|
|
529
|
+
const files = emitStudioProject(p)
|
|
530
|
+
const get = (path: string) => files.find((f) => f.path === path)?.contents
|
|
531
|
+
|
|
532
|
+
// Change-password (any signed-in user), self-service sign-up + recovery, admin screen.
|
|
533
|
+
for (const path of ['src/routes/account/+page.server.ts', 'src/routes/register/+page.server.ts', 'src/routes/forgot-password/+page.server.ts', 'src/routes/reset-password/+page.server.ts', 'src/routes/users/+page.server.ts', 'src/routes/users/+page.svelte']) {
|
|
534
|
+
expect(files.find((f) => f.path === path), path).toBeTruthy()
|
|
535
|
+
}
|
|
536
|
+
// Registration assigns the (least-privileged) default role.
|
|
537
|
+
expect(get('src/routes/register/+page.server.ts')).toContain('role: "viewer"')
|
|
538
|
+
// Password recovery uses signed reset tokens + an email stub (no extra table).
|
|
539
|
+
expect(get('src/lib/server/auth.ts')).toContain('export async function signReset')
|
|
540
|
+
expect(get('src/lib/server/auth.ts')).toContain('export async function sendResetEmail')
|
|
541
|
+
// User admin is role-gated server-side (only a full-access role can manage users).
|
|
542
|
+
expect(get('src/routes/users/+page.server.ts')).toContain("canScreen(getServerRole(event), '__users__')")
|
|
543
|
+
// The DB store gained full CRUD.
|
|
544
|
+
const users = get('src/lib/server/users.ts')!
|
|
545
|
+
for (const fn of ['createUser', 'updatePassword', 'setUserRole', 'deleteUser', 'listUsers']) expect(users).toContain(`export async function ${fn}`)
|
|
546
|
+
// Nav gains the admin Users screen (canScreen-gated); the auth pages render bare.
|
|
547
|
+
const layout = get('src/routes/+layout.svelte')!
|
|
548
|
+
expect(layout).toContain('"/users"')
|
|
549
|
+
expect(layout).toContain('"/register"') // bare-render set includes the sign-up flow
|
|
550
|
+
expect(get('src/routes/+layout.server.ts')).toContain('const PUBLIC = new Set(["/login","/register","/forgot-password","/reset-password"])')
|
|
551
|
+
expect(get('src/routes/login/+page.svelte')).toContain('href="/register"')
|
|
552
|
+
|
|
553
|
+
for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
|
|
554
|
+
expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
|
|
555
|
+
}
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
it('Auth extras: OAuth (github/google/oidc) + email 2FA + real email, all DB-backed', () => {
|
|
559
|
+
let p = createProject([products])
|
|
560
|
+
p = { ...p, access: { enabled: true, defaultRole: 'viewer', roles: [{ role: 'admin', screens: '*', actions: '*' }, { role: 'viewer', screens: ['products'], actions: [] }] } }
|
|
561
|
+
p = setEntityDataSource(p, 'products', { kind: 'sql', table: 'products', dialect: 'postgres' })
|
|
562
|
+
p = setDataLayer(p, true)
|
|
563
|
+
p = setAuth(p, { enabled: true, oauth: ['github', 'google', 'oidc'], twoFactor: true })
|
|
564
|
+
const files = emitStudioProject(p)
|
|
565
|
+
const bundle = emitStudioAppBundle(p)
|
|
566
|
+
const get = (path: string) => files.find((f) => f.path === path)?.contents
|
|
567
|
+
|
|
568
|
+
// OAuth: shared provider module + a [provider] start + callback endpoint.
|
|
569
|
+
for (const path of ['src/lib/server/oauth.ts', 'src/routes/auth/[provider]/+server.ts', 'src/routes/auth/[provider]/callback/+server.ts']) {
|
|
570
|
+
expect(files.find((f) => f.path === path), path).toBeTruthy()
|
|
571
|
+
}
|
|
572
|
+
expect(get('src/lib/server/oauth.ts')).toContain('export async function pkceChallenge') // PKCE
|
|
573
|
+
expect(get('src/lib/server/oauth.ts')).toContain('.well-known/openid-configuration') // OIDC discovery (Azure AD)
|
|
574
|
+
expect(get('src/routes/auth/[provider]/+server.ts')).toContain('code_challenge_method')
|
|
575
|
+
expect(get('src/routes/auth/[provider]/+server.ts')).toContain('new Set<Provider>(["github", "google", "oidc"])')
|
|
576
|
+
expect(get('src/routes/login/+page.svelte')).toContain('/auth/github')
|
|
577
|
+
|
|
578
|
+
// Email 2FA: schema column, challenge helpers, login branch, verify route, account toggle.
|
|
579
|
+
expect(get('src/lib/server/db/schema.ts')).toContain('"twoFactor": boolean("two_factor").notNull().default(false)')
|
|
580
|
+
expect(get('src/lib/server/auth.ts')).toContain('export async function signChallenge')
|
|
581
|
+
expect(get('src/routes/login/+page.server.ts')).toContain('if (user.twoFactor)')
|
|
582
|
+
expect(files.find((f) => f.path === 'src/routes/login/verify/+page.server.ts')).toBeTruthy()
|
|
583
|
+
expect(get('src/lib/server/users.ts')).toContain('export async function setTwoFactor')
|
|
584
|
+
|
|
585
|
+
// Real email layer (2FA implies it) + nodemailer wired only as an optional SMTP dep.
|
|
586
|
+
expect(get('src/lib/server/email.ts')).toContain('api.resend.com')
|
|
587
|
+
expect(get('src/lib/server/email.ts')).toContain("await import('nodemailer')")
|
|
588
|
+
const pkg = JSON.parse(bundle.find((f) => f.path === 'package.json')!.contents)
|
|
589
|
+
expect(pkg.dependencies.nodemailer).toBeTruthy()
|
|
590
|
+
expect(pkg.devDependencies['@types/nodemailer']).toBeTruthy()
|
|
591
|
+
// .env.example documents the OAuth + email vars.
|
|
592
|
+
const env = bundle.find((f) => f.path === '.env.example')!.contents
|
|
593
|
+
expect(env).toContain('GITHUB_CLIENT_ID')
|
|
594
|
+
expect(env).toContain('OIDC_ISSUER')
|
|
595
|
+
expect(env).toMatch(/RESEND_API_KEY|SMTP_HOST/)
|
|
596
|
+
|
|
597
|
+
for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
|
|
598
|
+
expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
|
|
599
|
+
}
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
it('Auth extras: OAuth / 2FA are inert without the DB-backed store', () => {
|
|
603
|
+
const p = setAuth(createProject([products]), { enabled: true, oauth: ['github'], twoFactor: true })
|
|
604
|
+
const files = emitStudioProject(p) // memory source -> no data layer
|
|
605
|
+
expect(files.find((f) => f.path === 'src/lib/server/oauth.ts')).toBeUndefined()
|
|
606
|
+
expect(files.find((f) => f.path === 'src/lib/server/email.ts')).toBeUndefined()
|
|
607
|
+
expect(files.find((f) => f.path === 'src/routes/login/verify/+page.server.ts')).toBeUndefined()
|
|
608
|
+
// Falls back to the in-code demo store + plain login.
|
|
609
|
+
expect(files.find((f) => f.path === 'src/routes/login/+page.server.ts')!.contents).not.toContain('user.twoFactor')
|
|
610
|
+
})
|
|
611
|
+
|
|
612
|
+
it('Auth depth: register/userAdmin are inert without the DB-backed store or RBAC', () => {
|
|
613
|
+
// register requested but no data layer -> no register routes (needs persistence).
|
|
614
|
+
const noDb = emitStudioProject(setAuth(createProject([products]), { enabled: true, register: true, userAdmin: true }))
|
|
615
|
+
expect(noDb.find((f) => f.path === 'src/routes/register/+page.server.ts')).toBeUndefined()
|
|
616
|
+
expect(noDb.find((f) => f.path === 'src/routes/users/+page.server.ts')).toBeUndefined()
|
|
617
|
+
// DB-backed but RBAC off -> register yes, userAdmin no (needs a role to gate on).
|
|
618
|
+
let p = setEntityDataSource(createProject([products]), 'products', { kind: 'sql', table: 'products', dialect: 'postgres' })
|
|
619
|
+
p = setAuth(setDataLayer(p, true), { enabled: true, register: true, userAdmin: true })
|
|
620
|
+
const files = emitStudioProject(p)
|
|
621
|
+
expect(files.find((f) => f.path === 'src/routes/register/+page.server.ts')).toBeTruthy()
|
|
622
|
+
expect(files.find((f) => f.path === 'src/routes/users/+page.server.ts')).toBeUndefined()
|
|
623
|
+
})
|
|
624
|
+
|
|
625
|
+
it('no auth files unless auth.enabled; protect:false drops the route guard', () => {
|
|
626
|
+
// Off by default.
|
|
627
|
+
expect(emitStudioProject(createProject([customers])).find((f) => f.path === 'src/hooks.server.ts')).toBeUndefined()
|
|
628
|
+
// protect:false still scaffolds login but does not force a redirect.
|
|
629
|
+
const p = setAuth(createProject([customers]), { enabled: true, protect: false })
|
|
630
|
+
const layoutServer = emitStudioProject(p).find((f) => f.path === 'src/routes/+layout.server.ts')!.contents
|
|
631
|
+
expect(layoutServer).not.toContain('throw redirect')
|
|
632
|
+
// Single admin seed user when RBAC is off.
|
|
633
|
+
const users = emitStudioProject(p).find((f) => f.path === 'src/lib/server/users.ts')!.contents
|
|
634
|
+
expect(users).toContain('admin@example.com')
|
|
635
|
+
expect(users).not.toContain('viewer@example.com')
|
|
636
|
+
})
|
|
637
|
+
|
|
322
638
|
it('Audit: emits the store + route + viewer, wires connected routes, and compiles', () => {
|
|
323
639
|
let p = createProject([customers, orders])
|
|
324
640
|
p = setEntityDataSource(p, 'orders', { kind: 'sql', table: 'orders', dialect: 'postgres' })
|
|
@@ -457,6 +773,44 @@ describe('emitStudioProject (per-block screens)', () => {
|
|
|
457
773
|
expect(parseProject(JSON.stringify(p)).deploy).toBeUndefined()
|
|
458
774
|
})
|
|
459
775
|
|
|
776
|
+
it('Deploy pipeline: CI workflow always, a push-to-deploy workflow + DEPLOY.md + deploy script per target', () => {
|
|
777
|
+
const bundle = emitStudioAppBundle(setDeployTarget(createProject([customers]), 'vercel'))
|
|
778
|
+
const get = (p: string) => bundle.find((f) => f.path === p)?.contents
|
|
779
|
+
// Universal CI (robust without a committed lockfile).
|
|
780
|
+
const ci = get('.github/workflows/ci.yml')!
|
|
781
|
+
expect(ci).toContain('npm install')
|
|
782
|
+
expect(ci).not.toContain('npm ci') // no lockfile is shipped
|
|
783
|
+
expect(ci).not.toContain('cache: npm')
|
|
784
|
+
expect(ci).toContain('npm run build')
|
|
785
|
+
// Push-to-deploy workflow, gated on the secret so an unconfigured repo stays green.
|
|
786
|
+
const deploy = get('.github/workflows/deploy.yml')!
|
|
787
|
+
expect(deploy).toContain("if: ${{ secrets.VERCEL_TOKEN != '' }}")
|
|
788
|
+
expect(deploy).toContain('vercel deploy --prebuilt --prod')
|
|
789
|
+
// Runbook + npm deploy script.
|
|
790
|
+
expect(get('DEPLOY.md')).toContain('VERCEL_TOKEN')
|
|
791
|
+
expect(JSON.parse(get('package.json')!).scripts.deploy).toBe('vercel deploy --prod')
|
|
792
|
+
// Designer panel exposes the required secrets.
|
|
793
|
+
expect(studioDeployInfo(setDeployTarget(createProject([customers]), 'vercel')).secrets).toEqual(['VERCEL_TOKEN', 'VERCEL_ORG_ID', 'VERCEL_PROJECT_ID'])
|
|
794
|
+
})
|
|
795
|
+
|
|
796
|
+
it('Deploy pipeline: node target ships a Dockerfile + .dockerignore and no deploy workflow', () => {
|
|
797
|
+
const bundle = emitStudioAppBundle(setDeployTarget(createProject([customers], { title: 'My App' }), 'node'))
|
|
798
|
+
expect(bundle.find((f) => f.path === 'Dockerfile')!.contents).toContain('CMD ["node", "build"]')
|
|
799
|
+
expect(bundle.find((f) => f.path === '.dockerignore')).toBeTruthy()
|
|
800
|
+
expect(bundle.find((f) => f.path === '.github/workflows/deploy.yml')).toBeUndefined() // self-hosted: no push-deploy
|
|
801
|
+
expect(bundle.find((f) => f.path === '.github/workflows/ci.yml')).toBeTruthy() // ...but still CI
|
|
802
|
+
expect(bundle.find((f) => f.path === 'DEPLOY.md')!.contents).toContain('docker build')
|
|
803
|
+
})
|
|
804
|
+
|
|
805
|
+
it('Deploy pipeline: a single .env.example merges DATABASE_URL + SESSION_SECRET (no duplicate)', () => {
|
|
806
|
+
let p = setEntityDataSource(createProject([customers]), 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres' })
|
|
807
|
+
p = setAuth(p, { enabled: true })
|
|
808
|
+
const envs = emitStudioAppBundle(p).filter((f) => f.path === '.env.example')
|
|
809
|
+
expect(envs).toHaveLength(1)
|
|
810
|
+
expect(envs[0]!.contents).toContain('DATABASE_URL')
|
|
811
|
+
expect(envs[0]!.contents).toContain('SESSION_SECRET')
|
|
812
|
+
})
|
|
813
|
+
|
|
460
814
|
it('Round-trip: the exported bundle ships studio.config.json that re-parses into the designer', () => {
|
|
461
815
|
const p = setShell(createProject([customers, orders]), { brand: 'Acme', logo: 'data:image/png;base64,AA' })
|
|
462
816
|
const bundle = emitStudioAppBundle(p)
|
|
@@ -590,6 +944,220 @@ describe('emitStudioProject (per-block screens)', () => {
|
|
|
590
944
|
})
|
|
591
945
|
})
|
|
592
946
|
|
|
947
|
+
describe('Custom actions', () => {
|
|
948
|
+
it('a toolbar action on an entity-bound screen emits a wired handler + button + stub route, and compiles', () => {
|
|
949
|
+
let p = createProject([customers])
|
|
950
|
+
const sid = p.screens[0]!.id
|
|
951
|
+
p = addScreenAction(p, sid, { label: 'Sync now', icon: '\u{1F504}', confirm: 'Sync now?' })
|
|
952
|
+
const actionId = p.screens[0]!.actions![0]!.id
|
|
953
|
+
const files = emitStudioProject(p)
|
|
954
|
+
|
|
955
|
+
const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
|
|
956
|
+
expect(page).toContain(`let actionBusy_${actionId.replace(/-/g, '_')} = $state(false)`)
|
|
957
|
+
expect(page).toContain(`fetch('/api/actions/${actionId}'`)
|
|
958
|
+
expect(page).toContain('Sync now')
|
|
959
|
+
expect(page).toContain(`confirm('Sync now?')`)
|
|
960
|
+
|
|
961
|
+
const route = files.find((f) => f.path === `src/routes/api/actions/${actionId}/+server.ts`)!.contents
|
|
962
|
+
expect(route).toContain('export async function POST')
|
|
963
|
+
expect(route).toContain('TODO: your business logic here')
|
|
964
|
+
|
|
965
|
+
for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
|
|
966
|
+
expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
|
|
967
|
+
}
|
|
968
|
+
})
|
|
969
|
+
|
|
970
|
+
it('a row-level custom action renders per row, wired to the same stub route, and compiles', () => {
|
|
971
|
+
let p = createProject([customers])
|
|
972
|
+
const sid = p.screens[0]!.id
|
|
973
|
+
const gridId = p.screens[0]!.blocks.find((b) => b.config.kind === 'grid')!.id
|
|
974
|
+
p = addScreenAction(p, sid, { label: 'Resend invoice' })
|
|
975
|
+
const actionId = p.screens[0]!.actions![0]!.id
|
|
976
|
+
p = updateScreen(p, sid, { actions: [] }) // the action now lives only on the row, not the toolbar
|
|
977
|
+
p = updateBlock(p, sid, gridId, { config: {
|
|
978
|
+
rowActions: [{ kind: 'custom', id: actionId, label: 'Resend invoice' }],
|
|
979
|
+
} as Partial<GridConfig> })
|
|
980
|
+
const files = emitStudioProject(p)
|
|
981
|
+
|
|
982
|
+
const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
|
|
983
|
+
expect(page).toContain("id: '__actions'") // synthetic action column
|
|
984
|
+
expect(page).toContain(`runAction_${actionId.replace(/-/g, '_')}({ id: `)
|
|
985
|
+
expect(page).toContain('Resend invoice')
|
|
986
|
+
|
|
987
|
+
expect(files.find((f) => f.path === `src/routes/api/actions/${actionId}/+server.ts`)).toBeTruthy()
|
|
988
|
+
|
|
989
|
+
for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
|
|
990
|
+
expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
|
|
991
|
+
}
|
|
992
|
+
})
|
|
993
|
+
|
|
994
|
+
it('a freestanding screen (no entity) renders a toolbar action and no grid/data plumbing, and compiles', () => {
|
|
995
|
+
let p = addFreestandingScreen(createProject([customers]), { title: 'Reports', route: 'reports' })
|
|
996
|
+
const sid = p.screens.find((s) => s.title === 'Reports')!.id
|
|
997
|
+
p = addScreenAction(p, sid, { label: 'Run report' })
|
|
998
|
+
const actionId = p.screens.find((s) => s.id === sid)!.actions![0]!.id
|
|
999
|
+
const files = emitStudioProject(p)
|
|
1000
|
+
|
|
1001
|
+
const page = files.find((f) => f.path === 'src/routes/reports/+page.svelte')!.contents
|
|
1002
|
+
expect(page).toContain('Run report')
|
|
1003
|
+
expect(page).toContain(`fetch('/api/actions/${actionId}'`)
|
|
1004
|
+
expect(page).not.toContain('createServerDataSource')
|
|
1005
|
+
expect(page).not.toContain('<SvGrid')
|
|
1006
|
+
|
|
1007
|
+
for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
|
|
1008
|
+
expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
|
|
1009
|
+
}
|
|
1010
|
+
})
|
|
1011
|
+
|
|
1012
|
+
it('RBAC-gated action: the toolbar button and the stub route both check canScreen', () => {
|
|
1013
|
+
let p = addFreestandingScreen(createProject([customers]), { title: 'Reports', route: 'reports' })
|
|
1014
|
+
const sid = p.screens.find((s) => s.title === 'Reports')!.id
|
|
1015
|
+
p = addScreenAction(p, sid, { label: 'Run report' })
|
|
1016
|
+
const actionId = p.screens.find((s) => s.id === sid)!.actions![0]!.id
|
|
1017
|
+
p = { ...p, access: { enabled: true, defaultRole: 'viewer', roles: [
|
|
1018
|
+
{ role: 'admin', screens: '*', actions: '*' },
|
|
1019
|
+
{ role: 'viewer', screens: [], actions: [] },
|
|
1020
|
+
] } }
|
|
1021
|
+
const files = emitStudioProject(p)
|
|
1022
|
+
|
|
1023
|
+
const page = files.find((f) => f.path === 'src/routes/reports/+page.svelte')!.contents
|
|
1024
|
+
expect(page).toContain(`{#if canScreen($currentRole, '${sid}')}`)
|
|
1025
|
+
|
|
1026
|
+
const route = files.find((f) => f.path === `src/routes/api/actions/${actionId}/+server.ts`)!.contents
|
|
1027
|
+
expect(route).toContain("import { getServerRole, canScreen } from '$lib/access'")
|
|
1028
|
+
expect(route).toContain(`if (!canScreen(getServerRole(event), '${sid}'))`)
|
|
1029
|
+
expect(route).toContain('status: 403')
|
|
1030
|
+
|
|
1031
|
+
for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
|
|
1032
|
+
expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
|
|
1033
|
+
}
|
|
1034
|
+
})
|
|
1035
|
+
})
|
|
1036
|
+
|
|
1037
|
+
describe('Component blocks', () => {
|
|
1038
|
+
it('a component block mixed onto an entity-bound screen emits the import + literal markup, and compiles', () => {
|
|
1039
|
+
let p = createProject([customers])
|
|
1040
|
+
const sid = p.screens[0]!.id
|
|
1041
|
+
p = addComponentBlock(p, sid, 'button', { variant: 'primary', block: true }, 0)
|
|
1042
|
+
const files = emitStudioProject(p)
|
|
1043
|
+
|
|
1044
|
+
const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
|
|
1045
|
+
const importLine = page.split('\n').find((l) => l.includes("from '@svgrid/grid'"))!
|
|
1046
|
+
expect(importLine).toContain('SvButton') // merged into the screen's one @svgrid/grid import
|
|
1047
|
+
expect(page).toContain("<SvButton variant={'primary'} size={'md'} block>{'Click me'}</SvButton>")
|
|
1048
|
+
|
|
1049
|
+
for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
|
|
1050
|
+
expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
|
|
1051
|
+
}
|
|
1052
|
+
})
|
|
1053
|
+
|
|
1054
|
+
it('a freestanding screen with component blocks emits deduped imports + markup for each, and compiles', () => {
|
|
1055
|
+
let p = addFreestandingScreen(createProject([customers]), { title: 'Reports', route: 'reports' })
|
|
1056
|
+
const sid = p.screens.find((s) => s.title === 'Reports')!.id
|
|
1057
|
+
p = addComponentBlock(p, sid, 'button', { variant: 'secondary' })
|
|
1058
|
+
p = addComponentBlock(p, sid, 'badge', { variant: 'success' })
|
|
1059
|
+
const files = emitStudioProject(p)
|
|
1060
|
+
|
|
1061
|
+
const page = files.find((f) => f.path === 'src/routes/reports/+page.svelte')!.contents
|
|
1062
|
+
expect(page).toContain("import { SvBadge, SvButton } from '@svgrid/grid'") // sorted, deduped
|
|
1063
|
+
expect(page).toContain("<SvButton variant={'secondary'} size={'md'}>{'Click me'}</SvButton>")
|
|
1064
|
+
expect(page).toContain("<SvBadge variant={'success'} size={'md'} pill>{'Badge'}</SvBadge>")
|
|
1065
|
+
expect(page).not.toContain('Add your own content here')
|
|
1066
|
+
|
|
1067
|
+
for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
|
|
1068
|
+
expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
|
|
1069
|
+
}
|
|
1070
|
+
})
|
|
1071
|
+
|
|
1072
|
+
it('an empty freestanding screen (no blocks) still emits the placeholder comment', () => {
|
|
1073
|
+
const p = addFreestandingScreen(createProject([customers]), { title: 'Reports', route: 'reports' })
|
|
1074
|
+
const page = emitStudioProject(p).find((f) => f.path === 'src/routes/reports/+page.svelte')!.contents
|
|
1075
|
+
expect(page).toContain('Add your own content here')
|
|
1076
|
+
})
|
|
1077
|
+
|
|
1078
|
+
it('a component with a free-typed label containing quotes/braces cannot break out of the markup, and compiles', () => {
|
|
1079
|
+
let p = createProject([customers])
|
|
1080
|
+
const sid = p.screens[0]!.id
|
|
1081
|
+
p = addComponentBlock(p, sid, 'button', { _content: `it's a "test" {value}` }, 0)
|
|
1082
|
+
const files = emitStudioProject(p)
|
|
1083
|
+
const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
|
|
1084
|
+
expect(() => compile(page, { filename: 'page.svelte', generate: 'client' })).not.toThrow()
|
|
1085
|
+
})
|
|
1086
|
+
|
|
1087
|
+
it('component props bound to data emit reactive expressions over allRows (aggregate / field / expr), get no code handle, and compile', () => {
|
|
1088
|
+
let p = createProject([customers])
|
|
1089
|
+
const sid = p.screens[0]!.id
|
|
1090
|
+
const flat = () => flattenBlocks(p.screens.find((s) => s.id === sid)!.blocks).filter((b) => b.config.kind === 'component')
|
|
1091
|
+
|
|
1092
|
+
p = addComponentBlock(p, sid, 'stat', { label: 'Total spend' })
|
|
1093
|
+
const stat = flat()[0]!
|
|
1094
|
+
p = setComponentBinding(p, sid, stat.id, 'value', { kind: 'aggregate', field: 'spend', reduce: 'sum' })
|
|
1095
|
+
|
|
1096
|
+
p = addComponentBlock(p, sid, 'badge', { _content: 'tier' })
|
|
1097
|
+
const badge = flat()[1]!
|
|
1098
|
+
p = setComponentBinding(p, sid, badge.id, '_content', { kind: 'field', field: 'tier' })
|
|
1099
|
+
|
|
1100
|
+
p = addComponentBlock(p, sid, 'progress', { value: 0, max: 100 })
|
|
1101
|
+
const prog = flat()[2]!
|
|
1102
|
+
p = setComponentBinding(p, sid, prog.id, 'value', { kind: 'expr', code: 'rows.filter((r) => r.spend > 100).length' })
|
|
1103
|
+
|
|
1104
|
+
const files = emitStudioProject(p)
|
|
1105
|
+
const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
|
|
1106
|
+
|
|
1107
|
+
// aggregate -> reduceValue over allRows; imported once.
|
|
1108
|
+
expect(page).toContain("value={(reduceValue(allRows, { measure: 'spend', reduce: 'sum' })).toLocaleString()}")
|
|
1109
|
+
expect(page).toMatch(/import \{[^}]*reduceValue[^}]*\} from '@svgrid\/enterprise'/)
|
|
1110
|
+
// field -> first row's value.
|
|
1111
|
+
expect(page).toContain("{String(allRows[0]?.['tier'] ?? '')}")
|
|
1112
|
+
// expr -> the user code wrapped with rows = allRows.
|
|
1113
|
+
expect(page).toContain('value={((rows) => (rows.filter((r) => r.spend > 100).length))(allRows)}')
|
|
1114
|
+
// the screen loads every row for the bindings.
|
|
1115
|
+
expect(page).toContain('async function loadAll()')
|
|
1116
|
+
// bound components are static reactive markup, not code handles.
|
|
1117
|
+
expect(page).not.toContain('= handle(')
|
|
1118
|
+
|
|
1119
|
+
for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
|
|
1120
|
+
expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
|
|
1121
|
+
}
|
|
1122
|
+
})
|
|
1123
|
+
|
|
1124
|
+
it('every registry component codegens to a page that compiles, from the full UI kit', () => {
|
|
1125
|
+
let p = addFreestandingScreen(createProject([customers]), { title: 'Sink', route: 'sink' })
|
|
1126
|
+
const sid = p.screens.find((s) => s.title === 'Sink')!.id
|
|
1127
|
+
for (const spec of UI_COMPONENT_REGISTRY) p = addComponentBlock(p, sid, spec.key)
|
|
1128
|
+
const files = emitStudioProject(p)
|
|
1129
|
+
const page = files.find((f) => f.path === 'src/routes/sink/+page.svelte')!.contents
|
|
1130
|
+
// The kit spans well beyond the original eight (inputs, nav, display, ...).
|
|
1131
|
+
expect(UI_COMPONENT_REGISTRY.length).toBeGreaterThanOrEqual(24)
|
|
1132
|
+
// Every importName is present in the single @svgrid/grid import.
|
|
1133
|
+
for (const spec of UI_COMPONENT_REGISTRY) expect(page).toContain(spec.importName)
|
|
1134
|
+
for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
|
|
1135
|
+
expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
|
|
1136
|
+
}
|
|
1137
|
+
})
|
|
1138
|
+
|
|
1139
|
+
it('array/object "fixed" props (Timeline items, Sparkline data) emit verbatim, not stringified', () => {
|
|
1140
|
+
let p = addFreestandingScreen(createProject([customers]), { title: 'Viz', route: 'viz' })
|
|
1141
|
+
const sid = p.screens.find((s) => s.title === 'Viz')!.id
|
|
1142
|
+
p = addComponentBlock(p, sid, 'timeline')
|
|
1143
|
+
p = addComponentBlock(p, sid, 'sparkline', { type: 'line' })
|
|
1144
|
+
const page = emitStudioProject(p).find((f) => f.path === 'src/routes/viz/+page.svelte')!.contents
|
|
1145
|
+
expect(page).toContain("items={[{ title: 'Order placed'") // real array expression
|
|
1146
|
+
expect(page).toContain('data={[4, 8, 5, 9') // real number array
|
|
1147
|
+
expect(page).not.toContain("items={'[") // never quoted as a string
|
|
1148
|
+
})
|
|
1149
|
+
|
|
1150
|
+
it('an unrecognized component key emits a harmless placeholder comment instead of throwing', () => {
|
|
1151
|
+
let p = addFreestandingScreen(createProject([customers]), { title: 'Reports', route: 'reports' })
|
|
1152
|
+
const sid = p.screens.find((s) => s.title === 'Reports')!.id
|
|
1153
|
+
p = addComponentBlock(p, sid, 'not-a-real-component', {})
|
|
1154
|
+
const files = emitStudioProject(p)
|
|
1155
|
+
const page = files.find((f) => f.path === 'src/routes/reports/+page.svelte')!.contents
|
|
1156
|
+
expect(page).toContain('unknown component "not-a-real-component"')
|
|
1157
|
+
expect(() => compile(page, { filename: 'page.svelte', generate: 'client' })).not.toThrow()
|
|
1158
|
+
})
|
|
1159
|
+
})
|
|
1160
|
+
|
|
593
1161
|
describe('data-source codegen', () => {
|
|
594
1162
|
const byPathOf = (files: ReturnType<typeof emitStudioProject>, path: string) => files.find((f) => f.path === path)
|
|
595
1163
|
|
|
@@ -745,6 +1313,22 @@ describe('grid editing (a Grid property) -> codegen', () => {
|
|
|
745
1313
|
expect(page).toContain('controller.setFilter')
|
|
746
1314
|
})
|
|
747
1315
|
|
|
1316
|
+
it('filterUi picks the filter surfaces (row / menu / global); default is the global search', () => {
|
|
1317
|
+
// Default (no filterUi) -> global search only.
|
|
1318
|
+
const dflt = pageFor(withGrid({ filterable: true }))
|
|
1319
|
+
expect(dflt).toContain('showGlobalFilter')
|
|
1320
|
+
expect(dflt).not.toContain('showFilterRow')
|
|
1321
|
+
expect(dflt).not.toContain('showFilterMenu')
|
|
1322
|
+
// Explicit row + menu (no global).
|
|
1323
|
+
const rm = pageFor(withGrid({ filterable: true, filterUi: { row: true, menu: true } }))
|
|
1324
|
+
expect(rm).toContain('showFilterRow')
|
|
1325
|
+
expect(rm).toContain('showFilterMenu')
|
|
1326
|
+
expect(rm).not.toContain('showGlobalFilter')
|
|
1327
|
+
// Column filters still flow to the server controller.
|
|
1328
|
+
expect(rm).toContain('externalFilter')
|
|
1329
|
+
expect(rm).toContain('onFiltersChange')
|
|
1330
|
+
})
|
|
1331
|
+
|
|
748
1332
|
it('form presentation is honored', () => {
|
|
749
1333
|
expect(pageFor(withGrid({ formPresentation: 'drawer' }))).toContain('presentation="drawer"')
|
|
750
1334
|
})
|
|
@@ -761,10 +1345,10 @@ describe('grid editing (a Grid property) -> codegen', () => {
|
|
|
761
1345
|
expect(page).toContain('rowHeight={28}')
|
|
762
1346
|
})
|
|
763
1347
|
|
|
764
|
-
it('normal density
|
|
1348
|
+
it('normal density emits the standard 30px row height (consistent with master-detail) + totals off', () => {
|
|
765
1349
|
const page = pageFor(createProject([customers]))
|
|
766
1350
|
expect(page).toContain('enableRowSummaries={false}')
|
|
767
|
-
expect(page).
|
|
1351
|
+
expect(page).toContain('rowHeight={30}')
|
|
768
1352
|
})
|
|
769
1353
|
|
|
770
1354
|
it('per-column header / width / align overrides flow into the columns', () => {
|
|
@@ -810,6 +1394,443 @@ describe('grid editing (a Grid property) -> codegen', () => {
|
|
|
810
1394
|
expect(page).toContain("initialColumnPinning={{ left: ['name'] }}")
|
|
811
1395
|
expect(page).toContain('columnVirtualization={false}')
|
|
812
1396
|
})
|
|
1397
|
+
|
|
1398
|
+
it('row grouping switches the grid to full-client mode + seeds setGroupBy + rolls up column aggregates', () => {
|
|
1399
|
+
let p = createProject([customers])
|
|
1400
|
+
const sid = p.screens[0]!.id
|
|
1401
|
+
const gid = p.screens[0]!.blocks[0]!.id
|
|
1402
|
+
const cfg = p.screens[0]!.blocks[0]!.config as GridConfig
|
|
1403
|
+
const columns = cfg.columns.map((c) => (c.field === 'mrr' ? { ...c, aggregate: 'sum' as const } : c))
|
|
1404
|
+
p = updateBlock(p, sid, gid, { config: { grouping: ['tier'], columns } as Partial<import('./project').BlockConfig> })
|
|
1405
|
+
const page = pageFor(p)
|
|
1406
|
+
// Full-client data + grouping controls + seeded grouping.
|
|
1407
|
+
expect(page).toContain('data={allRows}')
|
|
1408
|
+
expect(page).toContain('loading={!allRowsReady}')
|
|
1409
|
+
expect(page).toContain('groupable')
|
|
1410
|
+
expect(page).toContain("a.setGroupBy(['tier'])")
|
|
1411
|
+
// Per-column aggregate flows into the column override.
|
|
1412
|
+
expect(page).toContain("'mrr': { aggregate: 'sum' }")
|
|
1413
|
+
// Client-side sort/paginate, NOT the server controller wiring.
|
|
1414
|
+
expect(page).not.toContain('externalSort')
|
|
1415
|
+
expect(page).not.toContain('externalPagination')
|
|
1416
|
+
expect(page).not.toContain('onSortingChange')
|
|
1417
|
+
// Loads the whole dataset.
|
|
1418
|
+
expect(page).toContain('async function loadAll()')
|
|
1419
|
+
for (const f of emitStudioProject(p).filter((f) => f.path.endsWith('.svelte'))) {
|
|
1420
|
+
expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
|
|
1421
|
+
}
|
|
1422
|
+
})
|
|
1423
|
+
|
|
1424
|
+
it('per-column value formats compile to the grid format (CellFormatConfig)', () => {
|
|
1425
|
+
let p = createProject([customers])
|
|
1426
|
+
const sid = p.screens[0]!.id
|
|
1427
|
+
const gid = p.screens[0]!.blocks[0]!.id
|
|
1428
|
+
const cfg = p.screens[0]!.blocks[0]!.config as GridConfig
|
|
1429
|
+
const columns = cfg.columns.map((c) =>
|
|
1430
|
+
c.field === 'mrr' ? { ...c, format: { type: 'currency', currency: 'EUR' } as const } : c,
|
|
1431
|
+
)
|
|
1432
|
+
p = updateBlock(p, sid, gid, { config: { columns } as Partial<import('./project').BlockConfig> })
|
|
1433
|
+
const page = pageFor(p)
|
|
1434
|
+
expect(page).toContain("'mrr': { format: { type: 'currency', currency: 'EUR' } }")
|
|
1435
|
+
for (const f of emitStudioProject(p).filter((f) => f.path.endsWith('.svelte'))) {
|
|
1436
|
+
expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
|
|
1437
|
+
}
|
|
1438
|
+
})
|
|
1439
|
+
|
|
1440
|
+
it('percent + number formats emit decimals via Intl options', () => {
|
|
1441
|
+
let p = createProject([customers])
|
|
1442
|
+
const sid = p.screens[0]!.id
|
|
1443
|
+
const gid = p.screens[0]!.blocks[0]!.id
|
|
1444
|
+
const cfg = p.screens[0]!.blocks[0]!.config as GridConfig
|
|
1445
|
+
const columns = cfg.columns.map((c) =>
|
|
1446
|
+
c.field === 'mrr' ? { ...c, format: { type: 'percent', decimals: 1, valueIsPercentPoints: true } as const } : c,
|
|
1447
|
+
)
|
|
1448
|
+
p = updateBlock(p, sid, gid, { config: { columns } as Partial<import('./project').BlockConfig> })
|
|
1449
|
+
expect(pageFor(p)).toContain("format: { type: 'percent', valueIsPercentPoints: true, options: { minimumFractionDigits: 1, maximumFractionDigits: 1 } }")
|
|
1450
|
+
})
|
|
1451
|
+
|
|
1452
|
+
it('rich cell renderers emit per-column cell snippets + imports (badge / progress / link) and compile', () => {
|
|
1453
|
+
let p = createProject([customers])
|
|
1454
|
+
const sid = p.screens[0]!.id
|
|
1455
|
+
const gid = p.screens[0]!.blocks[0]!.id
|
|
1456
|
+
const cfg = p.screens[0]!.blocks[0]!.config as GridConfig
|
|
1457
|
+
const columns = cfg.columns.map((c) =>
|
|
1458
|
+
c.field === 'tier' ? { ...c, cellType: { kind: 'badge' } as const }
|
|
1459
|
+
: c.field === 'mrr' ? { ...c, cellType: { kind: 'progress', max: 500 } as const }
|
|
1460
|
+
: c.field === 'name' ? { ...c, cellType: { kind: 'link', as: 'email' } as const }
|
|
1461
|
+
: c,
|
|
1462
|
+
)
|
|
1463
|
+
p = updateBlock(p, sid, gid, { config: { columns } as Partial<import('./project').BlockConfig> })
|
|
1464
|
+
const page = pageFor(p)
|
|
1465
|
+
// Imports pulled in for the renderers.
|
|
1466
|
+
expect(page).toMatch(/import \{[^}]*renderSnippet[^}]*\} from '@svgrid\/grid'/)
|
|
1467
|
+
expect(page).toMatch(/import \{[^}]*SvBadge[^}]*\} from '@svgrid\/grid'/)
|
|
1468
|
+
expect(page).toMatch(/import \{[^}]*SvProgress[^}]*\} from '@svgrid\/grid'/)
|
|
1469
|
+
// Cell refs + snippets.
|
|
1470
|
+
expect(page).toContain('cell: (ctx: CellContext<Customers>) => renderSnippet(cellRender_')
|
|
1471
|
+
expect(page).toContain('<SvBadge variant={stBadgeVariant(value)}')
|
|
1472
|
+
expect(page).toContain('max={500}')
|
|
1473
|
+
expect(page).toContain("href={'mailto:' + String(value ?? '')}")
|
|
1474
|
+
// The shared badge-intent helper is emitted once.
|
|
1475
|
+
expect(page.match(/function stBadgeVariant\(/g)?.length).toBe(1)
|
|
1476
|
+
for (const f of emitStudioProject(p).filter((f) => f.path.endsWith('.svelte'))) {
|
|
1477
|
+
expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
|
|
1478
|
+
}
|
|
1479
|
+
})
|
|
1480
|
+
|
|
1481
|
+
it('an export toolbar captures the grid api + wires CSV / JSON / copy buttons, and compiles', () => {
|
|
1482
|
+
let p = createProject([customers])
|
|
1483
|
+
const sid = p.screens[0]!.id
|
|
1484
|
+
const gid = p.screens[0]!.blocks[0]!.id
|
|
1485
|
+
p = updateBlock(p, sid, gid, { config: { export: { csv: true, json: true, copy: true } } as Partial<import('./project').BlockConfig> })
|
|
1486
|
+
const page = pageFor(p)
|
|
1487
|
+
expect(page).toMatch(/import \{[^}]*type SvGridApi[^}]*\} from '@svgrid\/grid'/)
|
|
1488
|
+
expect(page).toContain('let gridApi_' + gid.replace(/-/g, '_') + ' = $state<SvGridApi<any, any> | null>(null)')
|
|
1489
|
+
expect(page).toContain('<div class="st-grid-toolbar">')
|
|
1490
|
+
expect(page).toContain('.exportCsv({ filename: \'customers\' })')
|
|
1491
|
+
expect(page).toContain('.exportJson({ filename: \'customers\' })')
|
|
1492
|
+
expect(page).toContain('.copyToClipboard()')
|
|
1493
|
+
for (const f of emitStudioProject(p).filter((f) => f.path.endsWith('.svelte'))) {
|
|
1494
|
+
expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
|
|
1495
|
+
}
|
|
1496
|
+
})
|
|
1497
|
+
|
|
1498
|
+
it('no export config emits no toolbar', () => {
|
|
1499
|
+
expect(pageFor(createProject([customers]))).not.toContain('st-grid-toolbar')
|
|
1500
|
+
})
|
|
1501
|
+
|
|
1502
|
+
it('tree data renders a client hierarchy: visible-row walk + tree cell on the label column, no flat sort/paginate', () => {
|
|
1503
|
+
let p = createProject([customers])
|
|
1504
|
+
const sid = p.screens[0]!.id
|
|
1505
|
+
const gid = p.screens[0]!.blocks[0]!.id
|
|
1506
|
+
p = updateBlock(p, sid, gid, { config: { treeData: { parentField: 'tier', labelField: 'name' } } as Partial<import('./project').BlockConfig> })
|
|
1507
|
+
const page = pageFor(p)
|
|
1508
|
+
// Full-client tree state + derivation.
|
|
1509
|
+
expect(page).toContain('let treeExpanded_' + gid.replace(/-/g, '_'))
|
|
1510
|
+
expect(page).toContain('function treeBuild_' + gid.replace(/-/g, '_'))
|
|
1511
|
+
expect(page).toContain('data={tree_' + gid.replace(/-/g, '_') + '.visible}')
|
|
1512
|
+
expect(page).toContain('loading={!allRowsReady}')
|
|
1513
|
+
// Tree cell on the label column; other columns untouched.
|
|
1514
|
+
expect(page).toContain('renderSnippet(treeCell_' + gid.replace(/-/g, '_'))
|
|
1515
|
+
expect(page).toContain('{#snippet treeCell_' + gid.replace(/-/g, '_'))
|
|
1516
|
+
expect(page).toContain('async function loadAll()')
|
|
1517
|
+
// No flat sort / paginate / grouping that would break the hierarchy.
|
|
1518
|
+
expect(page).not.toContain('externalSort')
|
|
1519
|
+
expect(page).not.toContain('showPagination')
|
|
1520
|
+
expect(page).not.toContain('groupable')
|
|
1521
|
+
for (const f of emitStudioProject(p).filter((f) => f.path.endsWith('.svelte'))) {
|
|
1522
|
+
expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
|
|
1523
|
+
}
|
|
1524
|
+
})
|
|
1525
|
+
|
|
1526
|
+
it('scheduler view renders the grid as a calendar: enableSchedulerView + scheduler prop + write-back, and compiles', () => {
|
|
1527
|
+
let p = createProject([customers])
|
|
1528
|
+
const sid = p.screens[0]!.id
|
|
1529
|
+
const gid = p.screens[0]!.blocks[0]!.id
|
|
1530
|
+
p = updateBlock(p, sid, gid, { config: { scheduler: { startField: 'mrr', titleField: 'name', colorField: 'tier', initialView: 'week', editable: true, drawer: true } } as Partial<import('./project').BlockConfig> })
|
|
1531
|
+
const page = pageFor(p)
|
|
1532
|
+
// Renderer registered once + imported.
|
|
1533
|
+
expect(page).toMatch(/import \{[^}]*enableSchedulerView[^}]*\} from '@svgrid\/enterprise'/)
|
|
1534
|
+
expect(page).toContain('enableSchedulerView()')
|
|
1535
|
+
// Full-client scheduler grid with the mapped config.
|
|
1536
|
+
expect(page).toContain('data={allRows}')
|
|
1537
|
+
expect(page).toContain("scheduler={{ startField: 'mrr'")
|
|
1538
|
+
expect(page).toContain("titleField: 'name'")
|
|
1539
|
+
expect(page).toContain("initialView: 'week'")
|
|
1540
|
+
expect(page).toContain('editable: true')
|
|
1541
|
+
// Optimistic write-back through the controller.
|
|
1542
|
+
expect(page).toContain('onEventMove:')
|
|
1543
|
+
expect(page).toContain('onEventCommit:')
|
|
1544
|
+
expect(page).toContain('controller.updateRow(')
|
|
1545
|
+
expect(page).toContain('async function loadAll()')
|
|
1546
|
+
for (const f of emitStudioProject(p).filter((f) => f.path.endsWith('.svelte'))) {
|
|
1547
|
+
expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
|
|
1548
|
+
}
|
|
1549
|
+
})
|
|
1550
|
+
|
|
1551
|
+
it('an ungrouped grid stays server-driven (no groupable, keeps the controller view)', () => {
|
|
1552
|
+
const page = pageFor(createProject([customers]))
|
|
1553
|
+
expect(page).not.toContain('groupable')
|
|
1554
|
+
expect(page).not.toContain('setGroupBy')
|
|
1555
|
+
expect(page).toContain('data={view.rows}')
|
|
1556
|
+
expect(page).toContain('externalSort')
|
|
1557
|
+
})
|
|
1558
|
+
})
|
|
1559
|
+
|
|
1560
|
+
describe('dock layout (screen.layout = dock)', () => {
|
|
1561
|
+
it('a dock screen renders SvDockManager with a pane per block + persistence + mobile stack, and compiles', () => {
|
|
1562
|
+
let p = createProject([customers])
|
|
1563
|
+
const sid = p.screens[0]!.id
|
|
1564
|
+
p = addBlock(p, sid, 'filter')
|
|
1565
|
+
p = addBlock(p, sid, 'kpi')
|
|
1566
|
+
p = setScreenLayout(p, sid, 'dock')
|
|
1567
|
+
const files = emitStudioProject(p)
|
|
1568
|
+
const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
|
|
1569
|
+
// Imports + workspace state + persistence.
|
|
1570
|
+
expect(page).toMatch(/import \{[^}]*SvDockManager[^}]*\} from '@svgrid\/grid'/)
|
|
1571
|
+
expect(page).toContain('let dockWorkspace = $state<DockManagerState>(')
|
|
1572
|
+
expect(page).toContain("localStorage.getItem('dock:customers')")
|
|
1573
|
+
expect(page).toContain('let dockNarrow = $state(false)')
|
|
1574
|
+
// The manager + a pane per block, wired to persist on change.
|
|
1575
|
+
expect(page).toContain('<SvDockManager bind:workspace={dockWorkspace} onChange={(w) => saveDock(w)}>')
|
|
1576
|
+
expect(page).toContain('{#snippet pane(p)}')
|
|
1577
|
+
for (const b of p.screens[0]!.blocks) expect(page).toContain(`{#if p.id === '${b.id}'}`)
|
|
1578
|
+
// Mobile fallback stacks the plain grid body.
|
|
1579
|
+
expect(page).toContain('{#if dockNarrow}')
|
|
1580
|
+
for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
|
|
1581
|
+
expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
|
|
1582
|
+
}
|
|
1583
|
+
})
|
|
1584
|
+
|
|
1585
|
+
it('a canvas-layout screen places blocks on a 12-col grid by cell coords and compiles', () => {
|
|
1586
|
+
let p = createProject([customers])
|
|
1587
|
+
const sid = p.screens[0]!.id
|
|
1588
|
+
p = addBlock(p, sid, 'kpi')
|
|
1589
|
+
p = setScreenLayout(p, sid, 'canvas')
|
|
1590
|
+
const files = emitStudioProject(p)
|
|
1591
|
+
const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
|
|
1592
|
+
expect(page).toContain('<div class="st-canvas">')
|
|
1593
|
+
expect(page).toContain('<div class="st-canvas__cell" style="grid-column:')
|
|
1594
|
+
expect(page).toMatch(/grid-column: \d+ \/ span \d+; grid-row: \d+ \/ span \d+;/)
|
|
1595
|
+
expect(page).not.toContain('SvDockManager')
|
|
1596
|
+
const css = emitStudioAppBundle(p).find((f) => f.path === 'src/app.css')!.contents
|
|
1597
|
+
expect(css).toContain('.st-canvas { display: grid; grid-template-columns: repeat(12, 1fr);')
|
|
1598
|
+
for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
|
|
1599
|
+
expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
|
|
1600
|
+
}
|
|
1601
|
+
})
|
|
1602
|
+
|
|
1603
|
+
it('layout settings flow into codegen (dock props, persist off, canvas cols, grid gap)', () => {
|
|
1604
|
+
// Dock with pop-out + bottom tabs + no persistence.
|
|
1605
|
+
let d = createProject([customers])
|
|
1606
|
+
const dsid = d.screens[0]!.id
|
|
1607
|
+
d = addBlock(d, dsid, 'kpi')
|
|
1608
|
+
d = setScreenLayout(d, dsid, 'dock')
|
|
1609
|
+
d = setLayoutOpts(d, dsid, 'dock', { allowPopout: true, headerPosition: 'bottom', persist: false })
|
|
1610
|
+
const dpage = emitStudioProject(d).find((f) => f.path.endsWith('/+page.svelte'))!.contents
|
|
1611
|
+
expect(dpage).toContain('allowPopout')
|
|
1612
|
+
expect(dpage).toContain('headerPosition="bottom"')
|
|
1613
|
+
expect(dpage).not.toContain('onChange={(w) => saveDock(w)}') // persistence off
|
|
1614
|
+
expect(dpage).not.toContain('function saveDock')
|
|
1615
|
+
|
|
1616
|
+
// Canvas with 24 columns + custom row height.
|
|
1617
|
+
let c = createProject([customers])
|
|
1618
|
+
const csid = c.screens[0]!.id
|
|
1619
|
+
c = setScreenLayout(c, csid, 'canvas')
|
|
1620
|
+
c = setLayoutOpts(c, csid, 'canvas', { cols: 24, rowHeight: 32 })
|
|
1621
|
+
const cpage = emitStudioProject(c).find((f) => f.path.endsWith('/+page.svelte'))!.contents
|
|
1622
|
+
expect(cpage).toContain('grid-template-columns: repeat(24, 1fr); grid-auto-rows: 32px;')
|
|
1623
|
+
|
|
1624
|
+
// Grid gap setting scoped into the page style.
|
|
1625
|
+
let g = createProject([customers])
|
|
1626
|
+
const gsid = g.screens[0]!.id
|
|
1627
|
+
g = setLayoutOpts(g, gsid, 'grid', { colGap: 24, rowGap: 8 })
|
|
1628
|
+
const gpage = emitStudioProject(g).find((f) => f.path.endsWith('/+page.svelte'))!.contents
|
|
1629
|
+
expect(gpage).toContain('.st-screen { grid-template-columns: repeat(12, 1fr); gap: 8px 24px;')
|
|
1630
|
+
|
|
1631
|
+
for (const files of [emitStudioProject(d), emitStudioProject(c), emitStudioProject(g)]) {
|
|
1632
|
+
for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
|
|
1633
|
+
expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
})
|
|
1637
|
+
|
|
1638
|
+
it('state variables + logic-core steps emit reactive state, ctx.state, and compiled code', () => {
|
|
1639
|
+
let p = createProject([customers])
|
|
1640
|
+
const sid = p.screens[0]!.id
|
|
1641
|
+
p = addStateVar(p, sid, { name: 'query', type: 'string', initial: 'hi' })
|
|
1642
|
+
p = addStateVar(p, sid, { name: 'count', type: 'number', initial: '0' })
|
|
1643
|
+
// A click handler that sets a var, then branches on it.
|
|
1644
|
+
p = addComponentBlock(p, sid, 'button', {})
|
|
1645
|
+
const btn = flattenBlocks(p.screens[0]!.blocks).find((b) => b.config.kind === 'component')!
|
|
1646
|
+
p = setHandlerSteps(p, sid, `click:${btn.id}`, [
|
|
1647
|
+
{ type: 'setVar', name: 'count', value: { kind: 'literal', value: '3' } },
|
|
1648
|
+
{ type: 'branch', condition: { left: { kind: 'state', name: 'count' }, op: 'gt', right: { kind: 'literal', value: '2' } }, then: [{ type: 'alert', message: 'big' }] },
|
|
1649
|
+
])
|
|
1650
|
+
const files = emitStudioProject(p)
|
|
1651
|
+
const page = files.find((f) => f.path.endsWith('/+page.svelte'))!.contents
|
|
1652
|
+
expect(page).toContain("let query = $state<string>('hi')")
|
|
1653
|
+
expect(page).toContain('let count = $state<number>(0)')
|
|
1654
|
+
expect(page).toContain('state: { get query() { return query }, set query(x) { query = x }, get count() { return count }, set count(x) { count = x } }')
|
|
1655
|
+
// The compiled step code lives in the handlers.ts companion (onLoad wires the onclick).
|
|
1656
|
+
const handlers = files.find((f) => f.path.endsWith('/handlers.ts'))!.contents
|
|
1657
|
+
expect(handlers).toContain('ctx.state.count = 3')
|
|
1658
|
+
expect(handlers).toContain('if (Number(ctx.state.count) > Number(2)) {')
|
|
1659
|
+
const pctx = files.find((f) => f.path.endsWith('/page-context.ts'))!.contents
|
|
1660
|
+
expect(pctx).toContain('state: { query: string; count: number }')
|
|
1661
|
+
for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
|
|
1662
|
+
expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
|
|
1663
|
+
}
|
|
1664
|
+
})
|
|
1665
|
+
|
|
1666
|
+
it('grid row-select event slot wires onRowClick to compiled steps (cross-block state)', () => {
|
|
1667
|
+
let p = createProject([customers])
|
|
1668
|
+
const sid = p.screens[0]!.id
|
|
1669
|
+
p = addStateVar(p, sid, { name: 'selName', type: 'string' })
|
|
1670
|
+
const gridBlock = flattenBlocks(p.screens[0]!.blocks).find((b) => b.config.kind === 'grid')!
|
|
1671
|
+
// On row select: copy the clicked row's name into state (drives sibling blocks).
|
|
1672
|
+
p = setHandlerSteps(p, sid, `rowSelect:${gridBlock.id}`, [
|
|
1673
|
+
{ type: 'setVar', name: 'selName', value: { kind: 'field', name: 'name' } },
|
|
1674
|
+
])
|
|
1675
|
+
const files = emitStudioProject(p)
|
|
1676
|
+
const page = files.find((f) => f.path.endsWith('/+page.svelte'))!.contents
|
|
1677
|
+
expect(page).toContain('onRowClick={async (e) => {')
|
|
1678
|
+
expect(page).toContain('const row = e.row')
|
|
1679
|
+
expect(page).toContain("ctx.state.selName = row?.['name']")
|
|
1680
|
+
for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
|
|
1681
|
+
expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
|
|
1682
|
+
}
|
|
1683
|
+
})
|
|
1684
|
+
|
|
1685
|
+
it('component on-change event slot wires ctx.<name>.onchange + the wrapper fires change', () => {
|
|
1686
|
+
let p = createProject([customers])
|
|
1687
|
+
const sid = p.screens[0]!.id
|
|
1688
|
+
p = addStateVar(p, sid, { name: 'touched', type: 'boolean' })
|
|
1689
|
+
p = addComponentBlock(p, sid, 'button', {})
|
|
1690
|
+
const cmp = flattenBlocks(p.screens[0]!.blocks).find((b) => b.config.kind === 'component')!
|
|
1691
|
+
p = setHandlerSteps(p, sid, `change:${cmp.id}`, [{ type: 'setVar', name: 'touched', value: { kind: 'literal', value: 'true' } }])
|
|
1692
|
+
const files = emitStudioProject(p)
|
|
1693
|
+
const page = files.find((f) => f.path.endsWith('/+page.svelte'))!.contents
|
|
1694
|
+
const handlers = files.find((f) => f.path.endsWith('/handlers.ts'))!.contents
|
|
1695
|
+
expect(page).toContain(".fire('change', e)")
|
|
1696
|
+
expect(handlers).toMatch(/ctx\.\w+\.onchange = async \(\) => \{/)
|
|
1697
|
+
expect(handlers).toContain('ctx.state.touched = true')
|
|
1698
|
+
for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
|
|
1699
|
+
expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
|
|
1700
|
+
}
|
|
1701
|
+
})
|
|
1702
|
+
|
|
1703
|
+
it('on-record-saved (formSubmit) steps run inside save() with the submitted row + ctx', () => {
|
|
1704
|
+
let p = createProject([customers])
|
|
1705
|
+
const sid = p.screens[0]!.id
|
|
1706
|
+
// Default CRUD screen edits via a popup form (editing = 'form').
|
|
1707
|
+
p = setHandlerSteps(p, sid, 'formSubmit', [
|
|
1708
|
+
{ type: 'navigate', to: '/customers' },
|
|
1709
|
+
])
|
|
1710
|
+
const page = emitStudioProject(p).find((f) => f.path.endsWith('/+page.svelte'))!.contents
|
|
1711
|
+
expect(page).toContain('async function save({ mode, id, values }')
|
|
1712
|
+
expect(page).toContain('const row = values')
|
|
1713
|
+
expect(page).toContain('const ctx = { grid: gridApi!,')
|
|
1714
|
+
expect(page).toContain("ctx.goto('/customers')")
|
|
1715
|
+
for (const f of emitStudioProject(p).filter((f) => f.path.endsWith('.svelte'))) {
|
|
1716
|
+
expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
|
|
1717
|
+
}
|
|
1718
|
+
})
|
|
1719
|
+
|
|
1720
|
+
it('entity triggers compile into the SQL route as createKitHandlers hooks (server-enforced)', () => {
|
|
1721
|
+
let p = setEntityDataSource(createProject([customers]), 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres' })
|
|
1722
|
+
p = setTrigger(p, 'customers', 'beforeCreate', [
|
|
1723
|
+
{ type: 'requireField', field: 'name', message: 'Name required' },
|
|
1724
|
+
{ type: 'setField', field: 'tier', value: { kind: 'literal', value: 'free' } },
|
|
1725
|
+
])
|
|
1726
|
+
p = setTrigger(p, 'customers', 'afterUpdate', [{ type: 'code', code: 'console.log("updated", row)' }])
|
|
1727
|
+
const route = emitStudioProject(p).find((f) => f.path === 'src/routes/api/customers/+server.ts')!.contents
|
|
1728
|
+
expect(route).toContain('hooks: {')
|
|
1729
|
+
expect(route).toContain('beforeCreate: async ({ values }) => {')
|
|
1730
|
+
expect(route).toContain('const v = values as Record<string, unknown>')
|
|
1731
|
+
expect(route).toContain("if (v['name'] == null || v['name'] === '') throw new Error('Name required')")
|
|
1732
|
+
expect(route).toContain("v['tier'] = 'free'")
|
|
1733
|
+
expect(route).toContain('afterUpdate: async ({ row }) => {')
|
|
1734
|
+
// The route uses createKitHandlers - hooks are a valid option on it.
|
|
1735
|
+
expect(route).toContain('createKitHandlers({')
|
|
1736
|
+
})
|
|
1737
|
+
|
|
1738
|
+
it('a grid-layout screen (default) emits no dock manager', () => {
|
|
1739
|
+
const page = emitStudioProject(createProject([customers])).find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
|
|
1740
|
+
expect(page).not.toContain('SvDockManager')
|
|
1741
|
+
expect(page).toContain('<div class="st-screen">')
|
|
1742
|
+
})
|
|
1743
|
+
|
|
1744
|
+
it('a split-layout screen renders a LOCKED SvDockManager (resize-only) and compiles', () => {
|
|
1745
|
+
let p = createProject([customers])
|
|
1746
|
+
const sid = p.screens[0]!.id
|
|
1747
|
+
p = addBlock(p, sid, 'filter')
|
|
1748
|
+
p = addBlock(p, sid, 'kpi')
|
|
1749
|
+
p = setScreenLayout(p, sid, 'split')
|
|
1750
|
+
const files = emitStudioProject(p)
|
|
1751
|
+
const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
|
|
1752
|
+
// Same SvDockManager plumbing as dock, but locked.
|
|
1753
|
+
expect(page).toMatch(/import \{[^}]*SvDockManager[^}]*\} from '@svgrid\/grid'/)
|
|
1754
|
+
expect(page).toContain('<SvDockManager bind:workspace={dockWorkspace} onChange={(w) => saveDock(w)} locked>')
|
|
1755
|
+
expect(page).toContain('{#snippet pane(p)}')
|
|
1756
|
+
for (const b of p.screens[0]!.blocks) expect(page).toContain(`{#if p.id === '${b.id}'}`)
|
|
1757
|
+
for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
|
|
1758
|
+
expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
|
|
1759
|
+
}
|
|
1760
|
+
})
|
|
1761
|
+
|
|
1762
|
+
it('a stack-layout screen wraps blocks in a single-column flow (.st-stack) and compiles', () => {
|
|
1763
|
+
let p = createProject([customers])
|
|
1764
|
+
const sid = p.screens[0]!.id
|
|
1765
|
+
p = addBlock(p, sid, 'kpi')
|
|
1766
|
+
p = setScreenLayout(p, sid, 'stack')
|
|
1767
|
+
const files = emitStudioProject(p)
|
|
1768
|
+
const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
|
|
1769
|
+
expect(page).toContain('<div class="st-stack">')
|
|
1770
|
+
expect(page).not.toContain('<div class="st-screen">')
|
|
1771
|
+
expect(page).not.toContain('SvDockManager')
|
|
1772
|
+
// Default stack min-height flows into the page-scoped style so blocks aren't tiny.
|
|
1773
|
+
expect(page).toContain('.st-stack > * { min-height: 160px; }')
|
|
1774
|
+
const css = emitStudioAppBundle(p).find((f) => f.path === 'src/app.css')!.contents
|
|
1775
|
+
expect(css).toContain('.st-stack { display: flex; flex-direction: column;')
|
|
1776
|
+
for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
|
|
1777
|
+
expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
|
|
1778
|
+
}
|
|
1779
|
+
})
|
|
1780
|
+
|
|
1781
|
+
it('syncDockPanes adds a pane for a new block + strips a removed one, preserving arrangement', () => {
|
|
1782
|
+
let p = createProject([customers])
|
|
1783
|
+
const sid = p.screens[0]!.id
|
|
1784
|
+
p = setScreenLayout(p, sid, 'dock')
|
|
1785
|
+
const before = p.screens[0]!.dock!
|
|
1786
|
+
const beforeIds = [...dockPaneIds(before)]
|
|
1787
|
+
// Add a block: sync appends its pane, keeps the existing ones + the main node id (arrangement).
|
|
1788
|
+
p = addBlock(p, sid, 'chart')
|
|
1789
|
+
p = { ...p, screens: p.screens.map((s) => (s.id === sid ? syncDockPanes(s) : s)) }
|
|
1790
|
+
const afterAdd = p.screens[0]!.dock!
|
|
1791
|
+
const addIds = dockPaneIds(afterAdd)
|
|
1792
|
+
for (const id of beforeIds) expect(addIds.has(id)).toBe(true) // existing panes preserved
|
|
1793
|
+
const chartId = p.screens[0]!.blocks.find((b) => b.config.kind === 'chart')!.id
|
|
1794
|
+
expect(addIds.has(chartId)).toBe(true) // new pane added
|
|
1795
|
+
expect(afterAdd.main?.id).toBe(before.main?.id) // main node reused (arrangement not rebuilt)
|
|
1796
|
+
// Remove the chart: its pane is stripped, the rest stay.
|
|
1797
|
+
p = removeBlock(p, sid, chartId)
|
|
1798
|
+
p = { ...p, screens: p.screens.map((s) => (s.id === sid ? syncDockPanes(s) : s)) }
|
|
1799
|
+
const afterRemove = dockPaneIds(p.screens[0]!.dock!)
|
|
1800
|
+
expect(afterRemove.has(chartId)).toBe(false)
|
|
1801
|
+
for (const id of beforeIds) expect(afterRemove.has(id)).toBe(true)
|
|
1802
|
+
})
|
|
1803
|
+
|
|
1804
|
+
it('setDockPaneTitle renames a pane (tab text) + dockPaneTitleOf reads it back', () => {
|
|
1805
|
+
let p = createProject([customers])
|
|
1806
|
+
const sid = p.screens[0]!.id
|
|
1807
|
+
p = setScreenLayout(p, sid, 'dock')
|
|
1808
|
+
const gridBlock = p.screens[0]!.blocks[0]!
|
|
1809
|
+
expect(dockPaneTitleOf(p.screens[0]!, gridBlock.id)).toBe('Grid') // auto title
|
|
1810
|
+
p = setDockPaneTitle(p, sid, gridBlock.id, 'Customers table')
|
|
1811
|
+
expect(dockPaneTitleOf(p.screens[0]!, gridBlock.id)).toBe('Customers table')
|
|
1812
|
+
// Custom title survives an incremental pane sync (add a block).
|
|
1813
|
+
p = addBlock(p, sid, 'chart')
|
|
1814
|
+
p = { ...p, screens: p.screens.map((s) => (s.id === sid ? syncDockPanes(s) : s)) }
|
|
1815
|
+
expect(dockPaneTitleOf(p.screens[0]!, gridBlock.id)).toBe('Customers table')
|
|
1816
|
+
})
|
|
1817
|
+
|
|
1818
|
+
it('setScreenLayout(dock) seeds a workspace by role + survives serialize/parse', () => {
|
|
1819
|
+
let p = createProject([customers])
|
|
1820
|
+
const sid = p.screens[0]!.id
|
|
1821
|
+
p = addBlock(p, sid, 'filter')
|
|
1822
|
+
p = setScreenLayout(p, sid, 'dock')
|
|
1823
|
+
const scr = p.screens[0]!
|
|
1824
|
+
expect(scr.layout).toBe('dock')
|
|
1825
|
+
expect(scr.dock).toBeTruthy()
|
|
1826
|
+
// Pane ids equal block ids.
|
|
1827
|
+
const paneIds = dockPaneIds(scr.dock!)
|
|
1828
|
+
for (const b of scr.blocks) expect(paneIds.has(b.id)).toBe(true)
|
|
1829
|
+
// Round-trips through the project (de)serializer.
|
|
1830
|
+
const round = parseProject(serializeProject(p))
|
|
1831
|
+
expect(round.screens[0]!.layout).toBe('dock')
|
|
1832
|
+
expect(round.screens[0]!.dock).toEqual(scr.dock)
|
|
1833
|
+
})
|
|
813
1834
|
})
|
|
814
1835
|
|
|
815
1836
|
describe('emitStudioAppBundle (full runnable app)', () => {
|
|
@@ -834,6 +1855,17 @@ describe('emitStudioAppBundle (full runnable app)', () => {
|
|
|
834
1855
|
expect(pkg.devDependencies['vite']).toBeTruthy()
|
|
835
1856
|
})
|
|
836
1857
|
|
|
1858
|
+
it('pins Vite 7 (not 8) so the app boots in StackBlitz WebContainer', () => {
|
|
1859
|
+
const bundle = emitStudioAppBundle(createProject([customers]))
|
|
1860
|
+
const pkg = JSON.parse(bundle.find((f) => f.path === 'package.json')!.contents)
|
|
1861
|
+
// Vite 8's Rolldown bundler crashes in the WebContainer; pin the Rollup-based stack.
|
|
1862
|
+
expect(pkg.devDependencies['vite']).toBe('^7.0.0')
|
|
1863
|
+
expect(pkg.devDependencies['@sveltejs/vite-plugin-svelte']).toBe('^6.0.0')
|
|
1864
|
+
expect(pkg.devDependencies['vitest']).toMatch(/^\^4\./) // vitest 4 supports vite 7
|
|
1865
|
+
// engine-strict must not hard-fail install in a sandbox whose Node may differ.
|
|
1866
|
+
expect(bundle.find((f) => f.path === '.npmrc')!.contents).toContain('engine-strict=false')
|
|
1867
|
+
})
|
|
1868
|
+
|
|
837
1869
|
it('ships a per-entity smoke test + vitest wiring', () => {
|
|
838
1870
|
const bundle = emitStudioAppBundle(createProject([customers, orders], { title: 'My Sales App' }))
|
|
839
1871
|
const pkg = JSON.parse(bundle.find((f) => f.path === 'package.json')!.contents)
|
|
@@ -883,6 +1915,21 @@ describe('pages (nav) + shell codegen', () => {
|
|
|
883
1915
|
expect(layout).toContain('(c) Acme')
|
|
884
1916
|
})
|
|
885
1917
|
|
|
1918
|
+
it('emits a bottom-nav shell (fixed bar, no sidebar drawer state, footer text dropped) that compiles', () => {
|
|
1919
|
+
const p = setShell(createProject([customers, orders]), { style: 'bottom-nav', brand: 'Acme', footer: '(c) Acme' })
|
|
1920
|
+
const layout = layoutOf(p)
|
|
1921
|
+
expect(layout).toContain('sv-app--bottom')
|
|
1922
|
+
expect(layout).toContain('sv-app__bar--bottom')
|
|
1923
|
+
expect(layout).toContain('Acme')
|
|
1924
|
+
// The bottom bar occupies that visual role; footer text is intentionally not rendered
|
|
1925
|
+
// in the markup (the `const footer = ...` script declaration is unconditional and stays,
|
|
1926
|
+
// but nothing in the bottom-nav body dereferences it).
|
|
1927
|
+
expect(layout).not.toContain('<footer class="sv-app__footbar">')
|
|
1928
|
+
// top-nav/sidebar-only collapse state must not leak into the bottom-nav shell.
|
|
1929
|
+
expect(layout).not.toContain('let collapsed = $state')
|
|
1930
|
+
expect(() => compile(layout, { filename: '+layout.svelte', generate: 'client' })).not.toThrow()
|
|
1931
|
+
})
|
|
1932
|
+
|
|
886
1933
|
it('defaults to sidebar; an empty footer omits the footer element', () => {
|
|
887
1934
|
const p = setShell(createProject([customers]), { footer: '' })
|
|
888
1935
|
const layout = layoutOf(p)
|
|
@@ -912,12 +1959,399 @@ describe('pages (nav) + shell codegen', () => {
|
|
|
912
1959
|
expect(layout).not.toContain('--sg-accent: #0969da')
|
|
913
1960
|
})
|
|
914
1961
|
|
|
915
|
-
it('custom CSS
|
|
1962
|
+
it('custom CSS goes to its own src/custom.css, imported after app.css by the layout', () => {
|
|
916
1963
|
const p = setTheme(createProject([customers]), { customCss: '.st__title { letter-spacing: -0.03em; }' })
|
|
917
|
-
const
|
|
918
|
-
|
|
919
|
-
expect(
|
|
920
|
-
//
|
|
921
|
-
expect(
|
|
1964
|
+
const files = emitStudioAppBundle(p)
|
|
1965
|
+
const customCss = files.find((f) => f.path === 'src/custom.css')!
|
|
1966
|
+
expect(customCss.contents).toContain('.st__title { letter-spacing: -0.03em; }')
|
|
1967
|
+
// app.css no longer carries the user's CSS.
|
|
1968
|
+
expect(files.find((f) => f.path === 'src/app.css')!.contents).not.toContain('.st__title { letter-spacing: -0.03em; }')
|
|
1969
|
+
// The layout imports both, custom.css after app.css so it overrides.
|
|
1970
|
+
const layout = files.find((f) => f.path === 'src/routes/+layout.svelte')!.contents
|
|
1971
|
+
expect(layout.indexOf("import '../custom.css'")).toBeGreaterThan(layout.indexOf("import '../app.css'"))
|
|
1972
|
+
// custom.css is always emitted (even empty) so the import never dangles.
|
|
1973
|
+
expect(emitStudioAppBundle(createProject([customers])).find((f) => f.path === 'src/custom.css')).toBeTruthy()
|
|
1974
|
+
})
|
|
1975
|
+
|
|
1976
|
+
it('no "Home" nav link; / redirects to the first screen', () => {
|
|
1977
|
+
const files = emitStudioProject(createProject([customers, orders]))
|
|
1978
|
+
const layout = files.find((f) => f.path === 'src/routes/+layout.svelte')!.contents
|
|
1979
|
+
// The auto "Home" link is gone (it duplicated the first screen's landing).
|
|
1980
|
+
expect(layout).not.toContain('>Home<')
|
|
1981
|
+
expect(layout).not.toContain("label: 'Home'")
|
|
1982
|
+
// `/` is a redirect to the first navigable screen, not a distinct page.
|
|
1983
|
+
const home = files.find((f) => f.path === 'src/routes/+page.svelte')!.contents
|
|
1984
|
+
expect(home).toContain('const home = "/customers"')
|
|
1985
|
+
expect(home).toContain('goto(home, { replaceState: true })')
|
|
1986
|
+
})
|
|
1987
|
+
|
|
1988
|
+
it('ships a light/dark switcher: both token sets scoped by [data-theme] + a toggle', () => {
|
|
1989
|
+
const p = setThemePreset(createProject([customers]), 'tailwind')
|
|
1990
|
+
const layout = layoutOf(p)
|
|
1991
|
+
// Both palettes emitted, keyed off <html data-theme>.
|
|
1992
|
+
expect(layout).toContain(':root[data-theme="light"]')
|
|
1993
|
+
expect(layout).toContain(':root[data-theme="dark"]')
|
|
1994
|
+
expect(layout).toContain('--sg-bg: #ffffff') // Tailwind light bg
|
|
1995
|
+
expect(layout).toContain('--sg-bg: #0f172a') // Tailwind dark bg
|
|
1996
|
+
// The toggle + its runtime are wired in and persist the choice.
|
|
1997
|
+
expect(layout).toContain('function toggleTheme()')
|
|
1998
|
+
expect(layout).toContain("document.documentElement.dataset.theme")
|
|
1999
|
+
expect(layout).toContain("localStorage.setItem('svapp:theme'")
|
|
2000
|
+
expect(layout).toContain('sv-app__theme')
|
|
2001
|
+
expect(() => compile(layout, { filename: '+layout.svelte', generate: 'client' })).not.toThrow()
|
|
2002
|
+
})
|
|
2003
|
+
})
|
|
2004
|
+
|
|
2005
|
+
describe('code companion (design + your own code)', () => {
|
|
2006
|
+
// A blank page with code enabled + a Grid fed by onLoad.
|
|
2007
|
+
const build = () => {
|
|
2008
|
+
let p = addFreestandingScreen(createProject([customers]), { title: 'Report', route: 'report' })
|
|
2009
|
+
const sid = p.screens.find((s) => s.route === 'report')!.id
|
|
2010
|
+
p = setScreenRenderGrid(p, sid, true) // implies code enabled
|
|
2011
|
+
return { p, sid }
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
it('emits a user-owned handlers.ts (onLoad) + a regenerated page-context.ts', () => {
|
|
2015
|
+
const { p } = build()
|
|
2016
|
+
const files = emitStudioProject(p)
|
|
2017
|
+
const companion = files.find((f) => f.path === 'src/routes/report/handlers.ts')
|
|
2018
|
+
expect(companion, 'companion emitted').toBeTruthy()
|
|
2019
|
+
expect(companion!.userOwned).toBe(true)
|
|
2020
|
+
expect(companion!.contents).toContain('export async function onLoad(ctx: PageContext): Promise<void>')
|
|
2021
|
+
expect(companion!.contents).toContain("import type { PageContext } from './page-context'")
|
|
2022
|
+
expect(companion!.contents).toContain('never overwrites')
|
|
2023
|
+
// The context type + shared handle runtime are generated (not user-owned).
|
|
2024
|
+
const ctx = files.find((f) => f.path === 'src/routes/report/page-context.ts')!
|
|
2025
|
+
expect(ctx.userOwned).toBeFalsy()
|
|
2026
|
+
expect(ctx.contents).toContain('export type PageContext')
|
|
2027
|
+
expect(files.find((f) => f.path === 'src/lib/handles.svelte.ts')).toBeTruthy()
|
|
2028
|
+
})
|
|
2029
|
+
|
|
2030
|
+
it('onLoad exists even with no Grid (a general mount hook, not grid-gated)', () => {
|
|
2031
|
+
let p = addFreestandingScreen(createProject([customers]), { title: 'Plain', route: 'plain' })
|
|
2032
|
+
const sid = p.screens.find((s) => s.route === 'plain')!.id
|
|
2033
|
+
p = enableScreenCode(p, sid) // code on, but renderGrid off
|
|
2034
|
+
const page = emitStudioProject(p).find((f) => f.path === 'src/routes/plain/+page.svelte')!
|
|
2035
|
+
expect(emitStudioProject(p).find((f) => f.path === 'src/routes/plain/handlers.ts')!.contents).toContain('export async function onLoad(ctx: PageContext)')
|
|
2036
|
+
expect(page.contents).not.toContain('<SvGrid')
|
|
2037
|
+
expect(page.contents).toContain('handlers.onLoad(') // still wired on mount
|
|
2038
|
+
})
|
|
2039
|
+
|
|
2040
|
+
it('the page runs onLoad in onMount, renders the Grid, and compiles', () => {
|
|
2041
|
+
const { p } = build()
|
|
2042
|
+
const page = emitStudioProject(p).find((f) => f.path === 'src/routes/report/+page.svelte')!
|
|
2043
|
+
expect(page.contents).toContain("import * as handlers from './handlers'")
|
|
2044
|
+
// onLoad runs on mount and onDestroy on unmount, both with the full ctx (grid +
|
|
2045
|
+
// the settable data battery, since this freestanding page owns its rows).
|
|
2046
|
+
expect(page.contents).toContain('const ctx = { grid: gridApi!, data: { get rows() { return rows }, setRows: (r) => (rows = r) }')
|
|
2047
|
+
expect(page.contents).toContain('as PageContext')
|
|
2048
|
+
expect(page.contents).toContain('handlers.onLoad(ctx)')
|
|
2049
|
+
expect(page.contents).toContain('return () => handlers.onDestroy(ctx)')
|
|
2050
|
+
expect(page.contents).toContain('<SvGrid data={rows} columns={columns} features={features}')
|
|
2051
|
+
expect(() => compile(page.contents, { filename: page.path, generate: 'client' })).not.toThrow()
|
|
2052
|
+
})
|
|
2053
|
+
|
|
2054
|
+
it('the Grid is exposed as its real SvGridApi (ctx.grid), typed to the row in page-context', () => {
|
|
2055
|
+
const { p } = build()
|
|
2056
|
+
const files = emitStudioProject(p)
|
|
2057
|
+
const page = files.find((f) => f.path === 'src/routes/report/+page.svelte')!
|
|
2058
|
+
const ctx = files.find((f) => f.path === 'src/routes/report/page-context.ts')!
|
|
2059
|
+
expect(page.contents).toContain('let gridApi = $state<SvGridApi<any, any> | null>(null)')
|
|
2060
|
+
expect(page.contents).toContain('onApiReady={(a) => (gridApi = a)}')
|
|
2061
|
+
expect(page.contents).toMatch(/import type \{[^}]*\bSvGridApi\b/)
|
|
2062
|
+
// Freestanding data-grid rows are RowData; the grid api is typed to it.
|
|
2063
|
+
expect(ctx.contents).toContain('grid: SvGridApi<any, RowData>')
|
|
2064
|
+
expect(ctx.contents).toContain("import type { RowData } from '@svgrid/grid'")
|
|
2065
|
+
expect(ctx.contents).toMatch(/import type \{[^}]*\bSvGridApi\b/)
|
|
2066
|
+
})
|
|
2067
|
+
|
|
2068
|
+
it('a dropped component becomes a named, typed handle in code + markup', () => {
|
|
2069
|
+
const { p: base, sid } = build()
|
|
2070
|
+
const p = addComponentBlock(base, sid, 'button', { variant: 'primary' })
|
|
2071
|
+
const files = emitStudioProject(p)
|
|
2072
|
+
const page = files.find((f) => f.path === 'src/routes/report/+page.svelte')!
|
|
2073
|
+
const ctx = files.find((f) => f.path === 'src/routes/report/page-context.ts')!
|
|
2074
|
+
const companion = files.find((f) => f.path === 'src/routes/report/handlers.ts')!
|
|
2075
|
+
// Named handle (button1) created + wired into the component markup.
|
|
2076
|
+
expect(page.contents).toContain('const button1 = handle(')
|
|
2077
|
+
expect(page.contents).toContain('<SvButton {...button1.props}>{button1.text}</SvButton>')
|
|
2078
|
+
expect(page.contents).toContain("import { handle } from '$lib/handles.svelte'")
|
|
2079
|
+
expect(page.contents).toContain('button1.fire(\'click\', e)')
|
|
2080
|
+
expect(page.contents).toContain('const ctx = { grid: gridApi!, button1, data:')
|
|
2081
|
+
expect(page.contents).toContain('handlers.onLoad(ctx)')
|
|
2082
|
+
// Typed handle: ButtonHandle intersects Handle with the button's real setters.
|
|
2083
|
+
expect(ctx.contents).toContain('button1: ButtonHandle')
|
|
2084
|
+
expect(ctx.contents).toContain('type ButtonHandle = Handle & {')
|
|
2085
|
+
expect(ctx.contents).toContain("setVariant(value: \"primary\" | \"secondary\" | \"outline\" | \"ghost\" | \"danger\"): void")
|
|
2086
|
+
// Each prop is ALSO a read/write property (button1.variant = 'danger'), not just a setter.
|
|
2087
|
+
expect(ctx.contents).toContain("variant: \"primary\" | \"secondary\" | \"outline\" | \"ghost\" | \"danger\"")
|
|
2088
|
+
expect(ctx.contents).toContain('disabled: boolean')
|
|
2089
|
+
expect(ctx.contents).toContain('text: string')
|
|
2090
|
+
expect(companion.contents).toContain('ctx.button1')
|
|
2091
|
+
expect(companion.contents).not.toContain('getElementById')
|
|
2092
|
+
expect(() => compile(page.contents, { filename: page.path, generate: 'client' })).not.toThrow()
|
|
2093
|
+
})
|
|
2094
|
+
|
|
2095
|
+
it('Methods panel: visual action steps compile to the ctx handler body + component onclick', () => {
|
|
2096
|
+
const { p: base, sid } = build()
|
|
2097
|
+
const withGrid = setScreenRenderGrid(base, sid, true) // gives ctx.grid
|
|
2098
|
+
let p = addComponentBlock(withGrid, sid, 'button', { _content: 'Export' })
|
|
2099
|
+
const btn = p.screens.find((s) => s.id === sid)!.blocks.find((b) => b.config.kind === 'component')!
|
|
2100
|
+
// onLoad steps + the button's on-click steps.
|
|
2101
|
+
p = setHandlerSteps(p, sid, 'onLoad', [
|
|
2102
|
+
{ type: 'gridSort', field: 'name', dir: 'desc' },
|
|
2103
|
+
{ type: 'setText', target: 'button1', value: 'Download' },
|
|
2104
|
+
])
|
|
2105
|
+
p = setHandlerSteps(p, sid, clickSlot(btn.id), [
|
|
2106
|
+
{ type: 'gridExport', format: 'csv' },
|
|
2107
|
+
{ type: 'navigate', to: '/other' },
|
|
2108
|
+
{ type: 'alert', message: 'Done' },
|
|
2109
|
+
])
|
|
2110
|
+
const files = emitStudioProject(p)
|
|
2111
|
+
const handlers = files.find((f) => f.path === 'src/routes/report/handlers.ts')!.contents
|
|
2112
|
+
// onLoad compiled from steps.
|
|
2113
|
+
expect(handlers).toContain("ctx.grid.setSort('name', 'desc')")
|
|
2114
|
+
expect(handlers).toContain("ctx.button1.text = 'Download'")
|
|
2115
|
+
// The button's click steps become an onclick assignment inside onLoad.
|
|
2116
|
+
expect(handlers).toContain('ctx.button1.onclick = async () => {')
|
|
2117
|
+
expect(handlers).toContain('await ctx.grid.exportCsv()')
|
|
2118
|
+
expect(handlers).toContain("ctx.goto('/other')")
|
|
2119
|
+
expect(handlers).toContain("alert('Done')")
|
|
2120
|
+
// The page runs it + compiles.
|
|
2121
|
+
const page = files.find((f) => f.path === 'src/routes/report/+page.svelte')!
|
|
2122
|
+
expect(page.contents).toContain('handlers.onLoad(ctx)')
|
|
2123
|
+
for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
|
|
2124
|
+
expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
|
|
2125
|
+
}
|
|
2126
|
+
})
|
|
2127
|
+
|
|
2128
|
+
it('Methods: stepsToCode drops the visual steps into the raw code editor (take over)', () => {
|
|
2129
|
+
const { p: base, sid } = build()
|
|
2130
|
+
let p = setHandlerSteps(base, sid, 'onLoad', [{ type: 'alert', message: 'hi' }, { type: 'navigate', to: '/x' }])
|
|
2131
|
+
p = stepsToCode(p, sid, 'onLoad')
|
|
2132
|
+
const screen = p.screens.find((s) => s.id === sid)!
|
|
2133
|
+
expect(screen.handlerSteps?.onLoad).toBeUndefined() // steps cleared
|
|
2134
|
+
expect(screen.handlerBodies?.onLoad).toBe("alert('hi')\nctx.goto('/x')") // compiled to raw body
|
|
2135
|
+
})
|
|
2136
|
+
|
|
2137
|
+
it('Methods: a hand-written onLoad body MERGES with component on-click wiring (neither replaces the other)', () => {
|
|
2138
|
+
const { p: base, sid } = build()
|
|
2139
|
+
const withGrid = setScreenRenderGrid(base, sid, true)
|
|
2140
|
+
let p = addComponentBlock(withGrid, sid, 'button', { _content: 'Go' })
|
|
2141
|
+
const btn = p.screens.find((s) => s.id === sid)!.blocks.find((b) => b.config.kind === 'component')!
|
|
2142
|
+
// Hand-written onLoad code AND a button with on-click steps.
|
|
2143
|
+
p = setHandlerBody(p, sid, 'onLoad', "console.log('mounted')")
|
|
2144
|
+
p = setHandlerSteps(p, sid, clickSlot(btn.id), [{ type: 'navigate', to: '/next' }])
|
|
2145
|
+
const handlers = emitStudioProject(p).find((f) => f.path === 'src/routes/report/handlers.ts')!.contents
|
|
2146
|
+
expect(handlers).toContain("console.log('mounted')") // the hand-written body survives
|
|
2147
|
+
expect(handlers).toContain('ctx.button1.onclick = async () => {') // + the onclick wiring is added
|
|
2148
|
+
expect(handlers).toContain("ctx.goto('/next')")
|
|
2149
|
+
})
|
|
2150
|
+
|
|
2151
|
+
it('handlersSource from the designer is emitted verbatim (advanced override)', () => {
|
|
2152
|
+
const { p: base, sid } = build()
|
|
2153
|
+
const src = "export async function onLoad() {\n await fetch('/api/report')\n}"
|
|
2154
|
+
const p = setScreenHandlersSource(base, sid, src)
|
|
2155
|
+
const companion = emitStudioProject(p).find((f) => f.path === 'src/routes/report/handlers.ts')!
|
|
2156
|
+
expect(companion.userOwned).toBe(true)
|
|
2157
|
+
expect(companion.contents).toContain("await fetch('/api/report')")
|
|
2158
|
+
expect(companion.contents).not.toContain('export async function onLoad(ctx') // structured shell replaced
|
|
2159
|
+
})
|
|
2160
|
+
|
|
2161
|
+
it('no companion and no wiring when the screen has no code', () => {
|
|
2162
|
+
const p = addFreestandingScreen(createProject([customers]), { title: 'Bare', route: 'bare' })
|
|
2163
|
+
const files = emitStudioProject(p)
|
|
2164
|
+
expect(files.find((f) => f.path === 'src/routes/bare/handlers.ts')).toBeUndefined()
|
|
2165
|
+
expect(files.find((f) => f.path === 'src/routes/bare/+page.svelte')!.contents).not.toContain("from './handlers'")
|
|
2166
|
+
})
|
|
2167
|
+
|
|
2168
|
+
it('entity screens wire onLoad/onDestroy + expose their grid as ctx.grid (reload, not setRows)', () => {
|
|
2169
|
+
let p = createProject([customers, orders])
|
|
2170
|
+
const sid = p.screens.find((s) => s.entity === 'customers')!.id
|
|
2171
|
+
p = setHandlerBody(p, sid, 'onLoad', 'ctx.grid.autosizeAllColumns()')
|
|
2172
|
+
const route = p.screens.find((s) => s.id === sid)!.route
|
|
2173
|
+
const files = emitStudioProject(p)
|
|
2174
|
+
const page = files.find((f) => f.path === `src/routes/${route}/+page.svelte`)!
|
|
2175
|
+
const ctx = files.find((f) => f.path === `src/routes/${route}/page-context.ts`)!
|
|
2176
|
+
expect(page.contents).toContain("import * as handlers from './handlers'")
|
|
2177
|
+
expect(page.contents).toContain('let gridApi = $state<SvGridApi<any, any> | null>(null)')
|
|
2178
|
+
expect(page.contents).toContain('onApiReady={(a) => (gridApi = a)}')
|
|
2179
|
+
// Full ctx on mount + cleanup on unmount; the grid exposes reload(), not setRows.
|
|
2180
|
+
expect(page.contents).toContain('const ctx = { grid: gridApi!, 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
|
+
expect(page.contents).toContain('handlers.onLoad(ctx)')
|
|
2182
|
+
expect(page.contents).toContain('return () => handlers.onDestroy(ctx)')
|
|
2183
|
+
// The grid api is typed to the entity's row (Customers), not any.
|
|
2184
|
+
expect(ctx.contents).toContain('grid: SvGridApi<any, Customers>')
|
|
2185
|
+
expect(ctx.contents).toContain("import type { Customers } from '$lib/schemas'")
|
|
2186
|
+
expect(ctx.contents).not.toContain('setRows: (rows') // the entity grid is controller-fed, no setRows member
|
|
2187
|
+
expect(() => compile(page.contents, { filename: page.path, generate: 'client' })).not.toThrow()
|
|
2188
|
+
})
|
|
2189
|
+
|
|
2190
|
+
it('data-viz blocks become setData handles; entity components + batteries are in ctx', () => {
|
|
2191
|
+
let p = createProject([customers, orders])
|
|
2192
|
+
const sid = p.screens.find((s) => s.entity === 'customers')!.id
|
|
2193
|
+
// A chart + a KPI + a button on the customers screen, with code enabled.
|
|
2194
|
+
p = addBlock(p, sid, 'chart')
|
|
2195
|
+
p = addBlock(p, sid, 'kpi')
|
|
2196
|
+
p = addComponentBlock(p, sid, 'button', { variant: 'primary' })
|
|
2197
|
+
p = enableScreenCode(p, sid)
|
|
2198
|
+
const route = p.screens.find((s) => s.id === sid)!.route
|
|
2199
|
+
const files = emitStudioProject(p)
|
|
2200
|
+
const page = files.find((f) => f.path === `src/routes/${route}/+page.svelte`)!
|
|
2201
|
+
const ctx = files.find((f) => f.path === `src/routes/${route}/page-context.ts`)!
|
|
2202
|
+
// Per-block DataHandles declared and fed to the chart/kpi markup.
|
|
2203
|
+
expect(page.contents).toContain('const chart1 = dataHandle<Customers>(() => allRows)')
|
|
2204
|
+
expect(page.contents).toContain('const kpi1 = dataHandle<Customers>(() => allRows)')
|
|
2205
|
+
expect(page.contents).toContain('rows={chart1.rows}')
|
|
2206
|
+
expect(page.contents).toContain("import { handle, dataHandle } from '$lib/handles.svelte'")
|
|
2207
|
+
// ctx carries the data handles + the entity component handle + batteries.
|
|
2208
|
+
expect(ctx.contents).toContain('chart1: DataHandle<Customers>')
|
|
2209
|
+
expect(ctx.contents).toContain('kpi1: DataHandle<Customers>')
|
|
2210
|
+
expect(ctx.contents).toContain('button1: ButtonHandle')
|
|
2211
|
+
expect(ctx.contents).toContain('goto: (path: string) => void')
|
|
2212
|
+
expect(ctx.contents).toContain('params: Record<string, string>')
|
|
2213
|
+
expect(() => compile(page.contents, { filename: page.path, generate: 'client' })).not.toThrow()
|
|
2214
|
+
})
|
|
2215
|
+
|
|
2216
|
+
it('emits the shared DataHandle runtime, and its private-$state pattern is valid Svelte', () => {
|
|
2217
|
+
let p = createProject([customers])
|
|
2218
|
+
const sid = p.screens.find((s) => s.entity === 'customers')!.id
|
|
2219
|
+
p = addBlock(p, sid, 'chart') // needs a DataHandle
|
|
2220
|
+
p = addComponentBlock(p, sid, 'button', {}) // needs a ComponentHandle
|
|
2221
|
+
p = enableScreenCode(p, sid)
|
|
2222
|
+
const mod = emitStudioProject(p).find((f) => f.path === 'src/lib/handles.svelte.ts')!
|
|
2223
|
+
expect(mod.contents).toContain('export class DataHandle')
|
|
2224
|
+
expect(mod.contents).toContain('setData(rows: T[]): void')
|
|
2225
|
+
// The generated .svelte.ts is stripped of types + compiled by the bundler's
|
|
2226
|
+
// svelte plugin; here we prove the reactive private-field pattern it relies on
|
|
2227
|
+
// (a `#field = $state()` read through a getter) is valid Svelte 5 via the
|
|
2228
|
+
// component compiler (which strips TS in `<script lang="ts">`).
|
|
2229
|
+
const probe = `<script lang="ts">
|
|
2230
|
+
class DataHandle<T> {
|
|
2231
|
+
#override = $state<T[] | null>(null)
|
|
2232
|
+
#fallback: () => T[]
|
|
2233
|
+
constructor(fallback: () => T[]) { this.#fallback = fallback }
|
|
2234
|
+
get rows(): T[] { return this.#override ?? this.#fallback() }
|
|
2235
|
+
setData(rows: T[]): void { this.#override = rows }
|
|
2236
|
+
clear(): void { this.#override = null }
|
|
2237
|
+
}
|
|
2238
|
+
let all = $state<number[]>([1, 2])
|
|
2239
|
+
const h = new DataHandle<number>(() => all)
|
|
2240
|
+
</script>
|
|
2241
|
+
<p>{h.rows.length}</p>`
|
|
2242
|
+
expect(() => compile(probe, { filename: 'probe.svelte', generate: 'client' })).not.toThrow()
|
|
2243
|
+
})
|
|
2244
|
+
|
|
2245
|
+
it('ctxCompletions exposes the FULL grid api + every handle member + batteries', () => {
|
|
2246
|
+
let p = createProject([customers])
|
|
2247
|
+
const sid = p.screens.find((s) => s.entity === 'customers')!.id
|
|
2248
|
+
p = addBlock(p, sid, 'chart')
|
|
2249
|
+
p = addComponentBlock(p, sid, 'button', {})
|
|
2250
|
+
p = enableScreenCode(p, sid)
|
|
2251
|
+
const screen = p.screens.find((s) => s.id === sid)!
|
|
2252
|
+
const comp = ctxCompletions(screen)
|
|
2253
|
+
// The whole grid surface, not a handful.
|
|
2254
|
+
expect(comp).toContain('ctx.grid.exportCsv()')
|
|
2255
|
+
expect(comp).toContain('ctx.grid.autosizeAllColumns()')
|
|
2256
|
+
expect(comp).toContain('ctx.grid.applyTransaction()')
|
|
2257
|
+
expect(comp.filter((c) => c.startsWith('ctx.grid.')).length).toBeGreaterThan(40)
|
|
2258
|
+
// Data handle + typed component setters + batteries.
|
|
2259
|
+
expect(comp).toContain('ctx.chart1.setData()')
|
|
2260
|
+
expect(comp).toContain('ctx.chart1.rows')
|
|
2261
|
+
expect(comp).toContain('ctx.button1.setVariant()')
|
|
2262
|
+
expect(comp).toContain('ctx.button1.onclick')
|
|
2263
|
+
expect(comp).toContain('ctx.data.reload()')
|
|
2264
|
+
expect(comp).toContain('ctx.goto()')
|
|
2265
|
+
expect(comp).toContain('ctx.params')
|
|
2266
|
+
})
|
|
2267
|
+
|
|
2268
|
+
it('per-block style overrides are emitted as inline CSS on the block wrapper', () => {
|
|
2269
|
+
let p = createProject([customers])
|
|
2270
|
+
const sid = p.screens[0]!.id
|
|
2271
|
+
// Give the grid block a border-off + padding + margin override.
|
|
2272
|
+
const bid = p.screens[0]!.blocks[0]!.id
|
|
2273
|
+
p = updateBlock(p, sid, bid, { style: { border: false, padding: 24, margin: 8, radius: 10, background: '#fafafa' } })
|
|
2274
|
+
const page = emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.svelte')!
|
|
2275
|
+
expect(page.contents).toContain('border: none')
|
|
2276
|
+
expect(page.contents).toContain('padding: 24px')
|
|
2277
|
+
expect(page.contents).toContain('margin: 8px')
|
|
2278
|
+
expect(page.contents).toContain('border-radius: 10px')
|
|
2279
|
+
expect(page.contents).toContain('background: #fafafa')
|
|
2280
|
+
expect(() => compile(page.contents, { filename: page.path, generate: 'client' })).not.toThrow()
|
|
2281
|
+
})
|
|
2282
|
+
|
|
2283
|
+
it('per-block className is emitted on the wrapper (merged with base classes) + sanitized', () => {
|
|
2284
|
+
let p = createProject([customers])
|
|
2285
|
+
const sid = p.screens[0]!.id
|
|
2286
|
+
p = addBlock(p, sid, 'kpi')
|
|
2287
|
+
const gridId = p.screens[0]!.blocks.find((b) => b.config.kind === 'grid')!.id
|
|
2288
|
+
const kpiId = p.screens[0]!.blocks.find((b) => b.config.kind === 'kpi')!.id
|
|
2289
|
+
p = updateBlock(p, sid, gridId, { className: 'my-grid highlight' })
|
|
2290
|
+
p = updateBlock(p, sid, kpiId, { className: 'evil"onload=x' }) // sanitized
|
|
2291
|
+
const page = emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.svelte')!
|
|
2292
|
+
expect(page.contents).toContain('class="my-grid highlight"')
|
|
2293
|
+
// kpi keeps its base class, appends the (sanitized) user class - no attribute break-out.
|
|
2294
|
+
expect(page.contents).toContain('class="kpi evilonloadx"')
|
|
2295
|
+
expect(page.contents).not.toContain('onload=x')
|
|
2296
|
+
expect(() => compile(page.contents, { filename: page.path, generate: 'client' })).not.toThrow()
|
|
2297
|
+
})
|
|
2298
|
+
|
|
2299
|
+
it('per-screen className lands on .st-screen; app className lands on the shell root', () => {
|
|
2300
|
+
let p = createProject([customers])
|
|
2301
|
+
const sid = p.screens[0]!.id
|
|
2302
|
+
p = updateScreen(p, sid, { className: 'crm-screen' })
|
|
2303
|
+
p = setTheme(p, { appClass: 'brand-app' })
|
|
2304
|
+
const files = emitStudioProject(p)
|
|
2305
|
+
const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!
|
|
2306
|
+
expect(page.contents).toContain('<div class="st-screen crm-screen">')
|
|
2307
|
+
const layout = files.find((f) => f.path === 'src/routes/+layout.svelte')!
|
|
2308
|
+
expect(layout.contents).toMatch(/class="sv-app sv-app--\w+ brand-app"/)
|
|
2309
|
+
expect(() => compile(page.contents, { filename: page.path, generate: 'client' })).not.toThrow()
|
|
2310
|
+
expect(() => compile(layout.contents, { filename: layout.path, generate: 'client' })).not.toThrow()
|
|
2311
|
+
})
|
|
2312
|
+
|
|
2313
|
+
it('ctxAmbientDts type-checks real ctx usage and catches grid/data typos (editor TS surface)', () => {
|
|
2314
|
+
let p = createProject([customers])
|
|
2315
|
+
const sid = p.screens.find((s) => s.entity === 'customers')!.id
|
|
2316
|
+
p = addBlock(p, sid, 'chart')
|
|
2317
|
+
p = addComponentBlock(p, sid, 'button', {})
|
|
2318
|
+
p = enableScreenCode(p, sid)
|
|
2319
|
+
const screen = p.screens.find((s) => s.id === sid)!
|
|
2320
|
+
const dts = ctxAmbientDts(screen, customers)
|
|
2321
|
+
// Valid, real code compiles clean: awaited grid export, feed a chart from the
|
|
2322
|
+
// dataset, type a component setter, navigate, read a param.
|
|
2323
|
+
expect(typeCheckBody(dts, [
|
|
2324
|
+
'const csv = await ctx.grid.exportCsv()',
|
|
2325
|
+
'console.log(csv.length)',
|
|
2326
|
+
'ctx.chart1.setData(ctx.data.rows)',
|
|
2327
|
+
"ctx.button1.setVariant('danger')",
|
|
2328
|
+
"ctx.button1.variant = 'ghost'", // prop assignment, not just the setter
|
|
2329
|
+
'ctx.button1.disabled = true',
|
|
2330
|
+
"ctx.button1.text = 'Save'",
|
|
2331
|
+
"ctx.goto('/orders')",
|
|
2332
|
+
'const id = ctx.params.id',
|
|
2333
|
+
'ctx.grid.setSort("mrr", "desc")',
|
|
2334
|
+
].join('\n'))).toEqual([])
|
|
2335
|
+
// Assigning the wrong type to a typed prop is caught.
|
|
2336
|
+
expect(typeCheckBody(dts, "ctx.button1.variant = 'nope'").length).toBeGreaterThan(0)
|
|
2337
|
+
expect(typeCheckBody(dts, 'ctx.button1.disabled = 5').length).toBeGreaterThan(0)
|
|
2338
|
+
// A misspelled grid method is a hard error (SvGridApi is precisely typed).
|
|
2339
|
+
const gridTypo = typeCheckBody(dts, 'ctx.grid.exprtCsv()')
|
|
2340
|
+
expect(gridTypo.length).toBeGreaterThan(0)
|
|
2341
|
+
expect(gridTypo.join(' ')).toMatch(/exprtCsv/)
|
|
2342
|
+
// Feeding a chart the wrong element type is caught (DataHandle<Customers>).
|
|
2343
|
+
expect(typeCheckBody(dts, 'ctx.chart1.setData([1, 2, 3])').length).toBeGreaterThan(0)
|
|
2344
|
+
// An unknown ctx member is caught.
|
|
2345
|
+
expect(typeCheckBody(dts, 'ctx.notAThing()').length).toBeGreaterThan(0)
|
|
2346
|
+
})
|
|
2347
|
+
|
|
2348
|
+
it('onDestroy is a first-class slot in handlers.ts + page-context manifest', () => {
|
|
2349
|
+
let p = createProject([customers])
|
|
2350
|
+
const sid = p.screens.find((s) => s.entity === 'customers')!.id
|
|
2351
|
+
p = setHandlerBody(p, sid, 'onDestroy', 'clearInterval(timer)')
|
|
2352
|
+
const route = p.screens.find((s) => s.id === sid)!.route
|
|
2353
|
+
const companion = emitStudioProject(p).find((f) => f.path === `src/routes/${route}/handlers.ts`)!
|
|
2354
|
+
expect(companion.contents).toContain('export function onDestroy(ctx: PageContext): void')
|
|
2355
|
+
expect(companion.contents).toContain('clearInterval(timer)')
|
|
922
2356
|
})
|
|
923
2357
|
})
|