@svgrid/enterprise 2.0.3 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +18 -6
  2. package/dist/cdn/svgrid-enterprise.svelte-external.js +14025 -6836
  3. package/dist/node/studio.js +7889 -2460
  4. package/package.json +9 -4
  5. package/src/SvGridMasterDetail.svelte +24 -3
  6. package/src/SvGridScheduler.svelte +4410 -0
  7. package/src/SvPivotDesigner.svelte +1990 -1045
  8. package/src/SvSchemaChart.svelte +10 -9
  9. package/src/ai.test.ts +522 -522
  10. package/src/ai.ts +202 -2
  11. package/src/index.ts +409 -384
  12. package/src/install.ts +10 -0
  13. package/src/pivot-chart.test.ts +86 -0
  14. package/src/pivot-chart.ts +112 -0
  15. package/src/scheduler.ts +37 -0
  16. package/src/scheduling.test.ts +194 -0
  17. package/src/scheduling.ts +293 -0
  18. package/src/sources/filters.ts +6 -0
  19. package/src/studio/HANDLERS-DESIGN.md +142 -0
  20. package/src/studio/cli.ts +7 -2
  21. package/src/studio/emit-project.test.ts +1447 -13
  22. package/src/studio/emit-project.ts +3995 -1273
  23. package/src/studio/emit-schema.ts +146 -29
  24. package/src/studio/index.ts +320 -195
  25. package/src/studio/project.test.ts +370 -0
  26. package/src/studio/project.ts +1146 -26
  27. package/src/studio/sample-data.ts +4 -1
  28. package/src/studio/samples/ats.ts +2 -2
  29. package/src/studio/samples/clinic.ts +4 -2
  30. package/src/studio/samples/crm.ts +16 -8
  31. package/src/studio/samples/events.ts +4 -2
  32. package/src/studio/samples/fleet.ts +4 -2
  33. package/src/studio/samples/gym.ts +4 -2
  34. package/src/studio/samples/hr.ts +3 -1
  35. package/src/studio/samples/live-data.ts +308 -308
  36. package/src/studio/samples/projects.ts +2 -2
  37. package/src/studio/samples/restaurant.ts +4 -2
  38. package/src/studio/samples/samples.test.ts +13 -5
  39. package/src/studio/samples/shared.ts +346 -305
  40. package/src/studio/samples/support.ts +3 -1
  41. package/src/studio/scaffold.test.ts +15 -1
  42. package/src/studio/scaffold.ts +16 -0
  43. package/src/studio/themes.ts +7 -0
  44. package/src/studio/ui-components.ts +472 -0
  45. package/src/sveltekit/transport.test.ts +26 -0
  46. package/src/sveltekit/transport.ts +50 -5
  47. package/dist/designer/assets/index-Dp44bTid.js +0 -939
  48. package/dist/designer/assets/index-RJp6x8tw.css +0 -1
  49. package/dist/designer/assets/jszip.min-CjMo-QGg.js +0 -2
  50. 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 { emitStudioProject, emitStudioAppBundle, studioDeployInfo } from './emit-project'
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
- p = setEntityDataSource(p, 'orders', { kind: 'sql', table: 'orders', dialect: 'postgres' }) // gets a +server.ts
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
- const route = files.find((f) => f.path === 'src/routes/api/orders/+server.ts')!.contents
304
- expect(route).toContain('authorize: ({ action, event }) => authorizeAction(getServerRole(event), action)')
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 + defaults emit no rowHeight and totals off', () => {
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).not.toContain('rowHeight=')
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 is appended to the generated app.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 appcss = emitStudioAppBundle(p).find((f) => f.path === 'src/app.css')!.contents
918
- expect(appcss).toContain('Custom CSS (from the designer)')
919
- expect(appcss).toContain('.st__title { letter-spacing: -0.03em; }')
920
- // No custom block when unset.
921
- expect(emitStudioAppBundle(createProject([customers])).find((f) => f.path === 'src/app.css')!.contents).not.toContain('Custom CSS (from the designer)')
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
  })