@svgrid/enterprise 2.3.0 → 2.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cdn/{pdfmake-C6pyKjX0.js → pdfmake-DVbZqKeD.js} +2297 -2327
- package/dist/cdn/rolldown-runtime-DtPi1Y-2.js +13 -0
- package/dist/cdn/{smart.export-CfW2zpyq.js → smart.export-D9FF4wAB.js} +54 -51
- package/dist/cdn/svgrid-enterprise.svelte-external.js +11308 -7898
- package/dist/cdn/{vfs_fonts-BktEULEs.js → vfs_fonts-iHMTEkpG.js} +1 -1
- package/dist/designer/assets/GridMenus-CW-rnHS9.js +7 -0
- package/dist/designer/assets/SvDateTimePicker-Cygij-dr.css +1 -0
- package/dist/designer/assets/SvDateTimePicker-DOBYyLeN.js +1 -0
- package/dist/designer/assets/SvGridChart-BEDNTdc5.js +2 -0
- package/dist/designer/assets/SvGridChart-Vu4Nvjiv.css +1 -0
- package/dist/designer/assets/SvGridChartPanel-x3lzSxuf.js +10 -0
- package/dist/designer/assets/SvGridChartView-CAKhPELi.js +1 -0
- package/dist/designer/assets/SvGridChartView-DX9HfkBR.css +1 -0
- package/dist/designer/assets/SvGridDropdown-CAYG7Y-O.js +1 -0
- package/dist/designer/assets/SvGridDropdown-Crwb2P3Q.css +1 -0
- package/dist/designer/assets/SvListBox-BMSfFJJT.css +1 -0
- package/dist/designer/assets/SvListBox-BVzVfCG3.js +1 -0
- package/dist/designer/assets/chart-DHEgt4lN.js +1 -0
- package/dist/designer/assets/disclose-version-CIm4S9xS.js +2 -0
- package/dist/designer/assets/dismissable-2EZKoens.js +1 -0
- package/dist/designer/assets/dismissable-BF-9TJSd.css +1 -0
- package/dist/designer/assets/easing-zaypKrr0.js +3 -0
- package/dist/designer/assets/export-format-DwjuTfrQ.js +6 -0
- package/dist/designer/assets/index-DVrv9dH6.js +75797 -0
- package/dist/designer/assets/index-DjPp1mn7.css +1 -0
- package/dist/designer/assets/js-scroller.svelte-tHanKJx0.js +110 -0
- package/dist/designer/assets/jszip.min-CX2fPs3O.js +2 -0
- package/dist/designer/assets/pdfmake-BpIYFHoG.js +242 -0
- package/dist/designer/assets/popover-CUkLsvRX.js +1 -0
- package/dist/designer/assets/rolldown-runtime-Dd_uD5pT.js +1 -0
- package/dist/designer/assets/smart.export-CDCwbFH4.js +37 -0
- package/dist/designer/assets/src-BRKu53tV.js +2842 -0
- package/dist/designer/assets/src-DseB1VRN.css +1 -0
- package/dist/designer/assets/vfs_fonts-BV-Ul9v2.js +1 -0
- package/dist/designer/index.html +32 -0
- package/dist/node/studio.js +16778 -12672
- package/package.json +10 -7
- package/src/SvGridBoard.svelte +4 -1
- package/src/SvGridScheduler.svelte +114 -90
- package/src/ai-export-pdf.dom.test.ts +77 -77
- package/src/ai-export-xlsx.dom.test.ts +90 -90
- package/src/ai-export.dom.test.ts +114 -114
- package/src/export-ooxml.ts +4 -0
- package/src/export-xls.ts +3 -0
- package/src/expressions/evaluate.ts +9 -0
- package/src/import.test.ts +1 -1
- package/src/import.ts +1 -1
- package/src/index.ts +12 -0
- package/src/pivot.test.ts +0 -1
- package/src/schema-designer.ts +1 -1
- package/src/sources/introspect-supabase.ts +2 -2
- package/src/studio/emit-project.test.ts +298 -7
- package/src/studio/emit-project.ts +484 -30
- package/src/studio/emit-schema.ts +20 -10
- package/src/studio/index.ts +34 -0
- package/src/studio/init-flow.test.ts +364 -0
- package/src/studio/init-flow.ts +409 -0
- package/src/studio/project.test.ts +0 -1
- package/src/studio/project.ts +109 -2
- package/src/studio/samples/datasets.test.ts +84 -0
- package/src/studio/samples/datasets.ts +340 -0
- package/src/studio/samples/live-data.test.ts +1 -1
- package/src/studio/samples/samples.test.ts +268 -268
- package/src/studio/samples/shared.ts +7 -150
- package/src/studio/screen-suites.test.ts +226 -0
- package/src/studio/screen-suites.ts +445 -0
- package/src/studio/ui-components-surface.test.ts +2 -2
- package/src/studio/ui-components.generated.ts +1643 -106
- package/src/studio/ui-components.ts +742 -641
- package/src/sveltekit/index.ts +1 -0
- package/src/sveltekit/sql-source.ts +1 -1
- package/src/sveltekit/transport-scope.test.ts +125 -0
- package/src/sveltekit/transport.ts +76 -2
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* build. It targets the template's conventions: `$lib/EntityScreen.svelte`,
|
|
11
11
|
* `$lib/schemas`, `$lib/data`, and the `st-*` / `.home` CSS classes.
|
|
12
12
|
*/
|
|
13
|
-
import { resolveIdField, titleCase, type EntityField, type
|
|
13
|
+
import { resolveIdField, titleCase, type EntityField, type EntitySchema, type ValidationRuleSpec } from '../schema.js'
|
|
14
14
|
import type { GeneratedFile } from './scaffold.js'
|
|
15
15
|
import type { EntityDataSource, RestAdapterConfig, RestSource, ShellConfig, ShellStyle, SqlDialectKind, SqlSource, EntityTriggers, TriggerEvent } from './project.js'
|
|
16
16
|
import { sanitizeClassName, compileTriggerSteps, TRIGGER_EVENTS } from './project.js'
|
|
@@ -362,17 +362,19 @@ const SQL_DRIVERS: Record<'postgres' | 'mysql' | 'mssql' | 'sqlite' | 'turso', {
|
|
|
362
362
|
* the route imports the shared access policy and rejects unauthorized writes -
|
|
363
363
|
* server-enforced, so a tampered client can't bypass it. When audit is on, every
|
|
364
364
|
* successful write is recorded. */
|
|
365
|
-
function sqlRouteFile(schema: EntitySchema, table: string, dialect?: SqlDialectKind, feat: { access?: boolean; audit?: boolean; screenIds?: string[]; triggers?: EntityTriggers; dbSchema?: string } = {}): GeneratedFile {
|
|
365
|
+
function sqlRouteFile(schema: EntitySchema, table: string, dialect?: SqlDialectKind, feat: { access?: boolean; audit?: boolean; screenIds?: string[]; triggers?: EntityTriggers; dbSchema?: string; tenantField?: string } = {}): GeneratedFile {
|
|
366
366
|
const n = namesFor(schema)
|
|
367
367
|
const key = (dialect === 'supabase' ? 'postgres' : (dialect ?? 'postgres')) as 'postgres' | 'mysql' | 'mssql' | 'sqlite' | 'turso'
|
|
368
368
|
const driver = SQL_DRIVERS[key]
|
|
369
369
|
const dialectLiteral = sqlDialectExpr(dialect)
|
|
370
370
|
const accessImport = feat.access ? `\nimport { authorizeAction, getServerRole } from '$lib/access'` : ''
|
|
371
371
|
const auditImport = feat.audit ? `\nimport { recordAudit } from '$lib/audit'` : ''
|
|
372
|
+
const tenantImport = feat.tenantField ? `\nimport { requireTenant } from '$lib/server/tenant'` : ''
|
|
372
373
|
// Every connected route validates writes against the schema server-side, and
|
|
373
374
|
// (when enabled) authorizes them by role + records an audit entry.
|
|
374
375
|
const opts = [`schema: ${n.schemaVar}`, `source`, `validate: true`]
|
|
375
376
|
if (feat.access) opts.push(`// Server-enforced RBAC: the caller's role comes from the session (event.locals). Reads\n // are allowed only if the role can open one of this entity's own screens.\n authorize: ({ action, event }) => authorizeAction(getServerRole(event), action, ${JSON.stringify(feat.screenIds ?? [])})`)
|
|
377
|
+
if (feat.tenantField) opts.push(`// Multi-tenancy, enforced server-side: reads are filtered to the caller's tenant,\n // creates are stamped with it, and update/delete re-read the target under the\n // scope first - so a guessed id cannot reach another tenant's row.\n scope: ({ event }) => ({ field: ${JSON.stringify(feat.tenantField)}, value: requireTenant(event) })`)
|
|
376
378
|
if (feat.audit) opts.push(`// Record every successful write to the audit trail.\n audit: (e) => recordAudit({ entity: ${JSON.stringify(schema.name)}, action: e.action, recordId: e.id, values: e.values as Record<string, unknown> | undefined, actor: String(e.event.locals?.role ?? e.event.locals?.user ?? 'system') })`)
|
|
377
379
|
// Server-enforced business rules: the entity's triggers compiled to lifecycle hooks.
|
|
378
380
|
const hooks = triggerHooksOpt(feat.triggers)
|
|
@@ -384,7 +386,7 @@ function sqlRouteFile(schema: EntitySchema, table: string, dialect?: SqlDialectK
|
|
|
384
386
|
contents: `${driver.imports}
|
|
385
387
|
import { env } from '$env/dynamic/private'
|
|
386
388
|
import { createKitHandlers, createSqlDataSource } from '@svgrid/enterprise'
|
|
387
|
-
import { ${n.schemaVar}, type ${n.type} } from '$lib/schemas'${accessImport}${auditImport}
|
|
389
|
+
import { ${n.schemaVar}, type ${n.type} } from '$lib/schemas'${accessImport}${auditImport}${tenantImport}
|
|
388
390
|
|
|
389
391
|
${driver.setup}
|
|
390
392
|
|
|
@@ -737,7 +739,7 @@ export function layoutFile(nav: NavItem[], opts: { accent?: string; shell?: Shel
|
|
|
737
739
|
|
|
738
740
|
// i18n: translate nav labels via `nav.<id>` keys (Home has no id -> literal).
|
|
739
741
|
const navLabel = opts.i18n ? `{item.id ? $t('nav.' + item.id, item.label) : item.label}` : '{item.label}'
|
|
740
|
-
const anchor = `<a class="sv-app__link" class:is-active={
|
|
742
|
+
const anchor = `<a class="sv-app__link" class:is-active={page.url.pathname === item.href} href={item.href}>${navLabel}</a>`
|
|
741
743
|
// When RBAC is on, hide nav links the current role can't open.
|
|
742
744
|
const linkGate = opts.access
|
|
743
745
|
? `{#if !item.id || canScreen($currentRole, item.id)}${anchor}{/if}`
|
|
@@ -968,14 +970,14 @@ export function layoutFile(nav: NavItem[], opts: { accent?: string; shell?: Shel
|
|
|
968
970
|
contents: `<script lang="ts">
|
|
969
971
|
import '../app.css'
|
|
970
972
|
import '../custom.css'
|
|
971
|
-
import { page } from '$app/
|
|
973
|
+
import { page } from '$app/state'${opts.access ? `\n import { currentRole, canScreen } from '$lib/access'` : ''}${opts.i18n ? `\n import { t, currentLocale, locales } from '$lib/i18n'` : ''}${opts.auth ? `\n import type { LayoutData } from './$types'` : ''}${opts.supabaseAuth ? `\n import { SvAuthGate, type SupabaseAuthClientLike } from '@svgrid/enterprise'\n import { supabaseClient } from '$lib/connections'` : ''}
|
|
972
974
|
|
|
973
975
|
let { children${opts.auth ? ', data' : ''} }${opts.auth ? ": { children: import('svelte').Snippet; data: LayoutData }" : ''} = $props()
|
|
974
976
|
const nav = ${JSON.stringify(links)}
|
|
975
977
|
const brand = ${JSON.stringify(brand)}${footConst}${logoConst}
|
|
976
978
|
let navOpen = $state(false)${opts.auth && opts.access ? `\n // Seed the client role store from the signed-in session (server-resolved).\n $effect(() => { if (data?.role) currentRole.set(data.role as never) })` : ''}
|
|
977
979
|
// Close the mobile drawer whenever the route changes.
|
|
978
|
-
$effect(() => { void
|
|
980
|
+
$effect(() => { void page.url.pathname; navOpen = false })${hasSwitch ? `
|
|
979
981
|
// Light/dark switcher: defaults to the mode picked in Studio, then honours the
|
|
980
982
|
// visitor's saved choice. Applies via [data-theme] on <html> (see the token
|
|
981
983
|
// sets in <svelte:head>), and persists per browser.
|
|
@@ -1014,11 +1016,11 @@ export function layoutFile(nav: NavItem[], opts: { accent?: string; shell?: Shel
|
|
|
1014
1016
|
return () => window.removeEventListener('keydown', onKey)
|
|
1015
1017
|
})
|
|
1016
1018
|
// Close both menus on route change.
|
|
1017
|
-
$effect(() => { void
|
|
1019
|
+
$effect(() => { void page.url.pathname; bellOpen = false; menuOpen = false })` : ''}
|
|
1018
1020
|
</script>
|
|
1019
1021
|
${themeHead}
|
|
1020
1022
|
|
|
1021
|
-
${opts.auth ? `{#if ${JSON.stringify(opts.authRoutes ?? ['/login'])}.includes(
|
|
1023
|
+
${opts.auth ? `{#if ${JSON.stringify(opts.authRoutes ?? ['/login'])}.includes(page.url.pathname)}
|
|
1022
1024
|
{@render children()}
|
|
1023
1025
|
{:else}
|
|
1024
1026
|
${body}
|
|
@@ -1137,7 +1139,7 @@ export function prepareEntities(schemas: EntitySchema[]): { entries: Prepared[];
|
|
|
1137
1139
|
/** Emit the shared entity modules: `src/lib/schemas.ts` + `src/lib/data.ts` (+ `connections.ts`). */
|
|
1138
1140
|
export function emitEntityModules(
|
|
1139
1141
|
schemas: EntitySchema[],
|
|
1140
|
-
opts: { sources?: Record<string, EntityDataSource>; accessEnabled?: boolean; auditEnabled?: boolean; screensByEntity?: Map<string, string[]>; triggers?: Record<string, EntityTriggers>; supabaseConn?: { url?: string; key?: string }; supabaseAuth?: boolean } = {},
|
|
1142
|
+
opts: { sources?: Record<string, EntityDataSource>; accessEnabled?: boolean; auditEnabled?: boolean; screensByEntity?: Map<string, string[]>; triggers?: Record<string, EntityTriggers>; supabaseConn?: { url?: string; key?: string }; supabaseAuth?: boolean; tenantField?: string; tenantScoped?: (entity: string) => boolean } = {},
|
|
1141
1143
|
): { files: GeneratedFile[]; prepared: EntitySchema[] } {
|
|
1142
1144
|
const { entries, seed } = prepareEntities(schemas)
|
|
1143
1145
|
const { file: data, needs } = dataModule(entries, seed, opts.sources, opts.supabaseConn)
|
|
@@ -1148,7 +1150,15 @@ export function emitEntityModules(
|
|
|
1148
1150
|
needs.supabaseUrl = opts.supabaseConn.url; needs.supabaseKey = opts.supabaseConn.key
|
|
1149
1151
|
}
|
|
1150
1152
|
if (needs.supabase || opts.supabaseAuth) files.push(connectionsModule(needs))
|
|
1151
|
-
for (const r of needs.sqlRoutes) files.push(sqlRouteFile(r.schema, r.table, r.dialect, {
|
|
1153
|
+
for (const r of needs.sqlRoutes) files.push(sqlRouteFile(r.schema, r.table, r.dialect, {
|
|
1154
|
+
access: opts.accessEnabled,
|
|
1155
|
+
audit: opts.auditEnabled,
|
|
1156
|
+
screenIds: opts.screensByEntity?.get(r.schema.name) ?? [],
|
|
1157
|
+
triggers: opts.triggers?.[r.schema.name],
|
|
1158
|
+
dbSchema: r.dbSchema,
|
|
1159
|
+
// A shared (reference-data) entity stays global - no scope on its route.
|
|
1160
|
+
tenantField: opts.tenantField && opts.tenantScoped?.(r.schema.name) !== false ? opts.tenantField : undefined,
|
|
1161
|
+
}))
|
|
1152
1162
|
return { files, prepared: entries.map((e) => e.schema) }
|
|
1153
1163
|
}
|
|
1154
1164
|
|
package/src/studio/index.ts
CHANGED
|
@@ -44,6 +44,33 @@ export { emitStudioProject, emitStudioAppBundle, emitStudioFragment, runtimeDeps
|
|
|
44
44
|
export { sampleApps, getSampleApp, type SampleApp } from './samples/index.js'
|
|
45
45
|
export { starterProject } from './samples/starter.js'
|
|
46
46
|
export { liveDataSamples, getLiveDataSample } from './samples/live-data.js'
|
|
47
|
+
export { starterDatasets, getStarterDataset, type StarterDataset } from './samples/datasets.js'
|
|
48
|
+
export {
|
|
49
|
+
runStudioInit,
|
|
50
|
+
parseSelection,
|
|
51
|
+
type PromptIO,
|
|
52
|
+
type DbGateway,
|
|
53
|
+
type FetchText,
|
|
54
|
+
type InitFlags,
|
|
55
|
+
type InitResult,
|
|
56
|
+
} from './init-flow.js'
|
|
57
|
+
export {
|
|
58
|
+
crudSuiteScreens,
|
|
59
|
+
addCrudSuite,
|
|
60
|
+
crudAppFromSchemas,
|
|
61
|
+
listScreen,
|
|
62
|
+
formScreen,
|
|
63
|
+
detailScreen,
|
|
64
|
+
dashScreen,
|
|
65
|
+
statusPills,
|
|
66
|
+
gridConfig,
|
|
67
|
+
type CrudScreenKind,
|
|
68
|
+
type CrudEditingMode,
|
|
69
|
+
type CrudSuiteOptions,
|
|
70
|
+
type CrudAppOptions,
|
|
71
|
+
type GridOpts,
|
|
72
|
+
type Tile,
|
|
73
|
+
} from './screen-suites.js'
|
|
47
74
|
export {
|
|
48
75
|
sanitizeStudioProject,
|
|
49
76
|
buildStudioBugReport,
|
|
@@ -134,6 +161,13 @@ export {
|
|
|
134
161
|
type TriggerStep,
|
|
135
162
|
type TriggerEvent,
|
|
136
163
|
type EntityTriggers,
|
|
164
|
+
setJob,
|
|
165
|
+
type ScheduledJob,
|
|
166
|
+
type ScheduledJobKind,
|
|
167
|
+
setTenancy,
|
|
168
|
+
tenantField,
|
|
169
|
+
isTenantScoped,
|
|
170
|
+
type TenancyConfig,
|
|
137
171
|
clickSlot,
|
|
138
172
|
ssrScreenShape,
|
|
139
173
|
ssrEligible,
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
2
|
+
import { runStudioInit, parseSelection, type DbGateway, type PromptIO } from './init-flow.js'
|
|
3
|
+
import type { StudioIO } from './cli.js'
|
|
4
|
+
import { parseProject, validateProject, type GridConfig } from './project.js'
|
|
5
|
+
|
|
6
|
+
/** A scripted terminal: answers are consumed in order, defaults fill the rest. */
|
|
7
|
+
function scriptedPrompts(answers: string[]) {
|
|
8
|
+
const asked: string[] = []
|
|
9
|
+
const said: string[] = []
|
|
10
|
+
const queue = [...answers]
|
|
11
|
+
const io: PromptIO = {
|
|
12
|
+
ask: async (question, def) => {
|
|
13
|
+
asked.push(question)
|
|
14
|
+
const next = queue.shift()
|
|
15
|
+
return next === undefined ? (def ?? '') : next
|
|
16
|
+
},
|
|
17
|
+
say: (line) => said.push(line),
|
|
18
|
+
}
|
|
19
|
+
return { io, asked, said, get remaining() { return queue.length } }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** An in-memory filesystem. */
|
|
23
|
+
function memoryIo() {
|
|
24
|
+
const files = new Map<string, string>()
|
|
25
|
+
const io: StudioIO = {
|
|
26
|
+
readFile: async (path) => files.get(path) ?? null,
|
|
27
|
+
writeFile: async (path, contents) => { files.set(path, contents) },
|
|
28
|
+
}
|
|
29
|
+
return { io, files }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A fake Postgres holding two tables, answering the real catalog queries
|
|
33
|
+
* (which alias their columns to name / type / nullable / pk). */
|
|
34
|
+
function fakeDb(): DbGateway {
|
|
35
|
+
const columns: Record<string, { name: string; type: string; nullable: number; pk: number }[]> = {
|
|
36
|
+
customers: [
|
|
37
|
+
{ name: 'id', type: 'integer', nullable: 0, pk: 1 },
|
|
38
|
+
{ name: 'name', type: 'text', nullable: 0, pk: 0 },
|
|
39
|
+
{ name: 'city', type: 'text', nullable: 1, pk: 0 },
|
|
40
|
+
],
|
|
41
|
+
orders: [
|
|
42
|
+
{ name: 'id', type: 'integer', nullable: 0, pk: 1 },
|
|
43
|
+
{ name: 'total', type: 'numeric', nullable: 1, pk: 0 },
|
|
44
|
+
],
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
ensureDriver: async () => ({ ok: true }),
|
|
48
|
+
connect: async () => async (sql: string, params: unknown[]) => {
|
|
49
|
+
if (/count\(\*\)/i.test(sql)) return [{ n: 7 }]
|
|
50
|
+
if (/information_schema\.tables/i.test(sql)) return Object.keys(columns).map((name) => ({ name }))
|
|
51
|
+
if (/information_schema\.columns/i.test(sql)) return columns[String(params[0] ?? '')] ?? []
|
|
52
|
+
return [] // no foreign keys
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Stand in for a Supabase project: PostgREST serves its OpenAPI doc at /rest/v1/. */
|
|
58
|
+
function stubSupabase(definitions: Record<string, unknown> | null) {
|
|
59
|
+
vi.stubGlobal('fetch', async (input: string | URL) => {
|
|
60
|
+
const href = String(input)
|
|
61
|
+
if (href.endsWith('/rest/v1/')) {
|
|
62
|
+
if (!definitions) return new Response('nope', { status: 401 })
|
|
63
|
+
return new Response(JSON.stringify({ definitions }), { status: 200, headers: { 'content-type': 'application/json' } })
|
|
64
|
+
}
|
|
65
|
+
// Row-sample and CSV fallbacks: answer empty so only the OpenAPI path counts.
|
|
66
|
+
return new Response('[]', { status: 200, headers: { 'content-type': 'application/json' } })
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const SUPABASE_DEFS = {
|
|
71
|
+
customers: {
|
|
72
|
+
required: ['id', 'name'],
|
|
73
|
+
properties: {
|
|
74
|
+
id: { type: 'integer', description: 'Note:\nThis is a Primary Key.<pk/>' },
|
|
75
|
+
name: { type: 'string' },
|
|
76
|
+
tier: { type: 'string', enum: ['free', 'pro'] },
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
orders: {
|
|
80
|
+
required: ['id'],
|
|
81
|
+
properties: {
|
|
82
|
+
id: { type: 'integer', description: '<pk/>' },
|
|
83
|
+
total: { type: 'number' },
|
|
84
|
+
customer_id: { type: 'integer', description: "<fk table='customers' column='id'/>" },
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
describe('parseSelection', () => {
|
|
90
|
+
const items = ['customers', 'orders', 'invoices']
|
|
91
|
+
|
|
92
|
+
it('takes everything by default', () => {
|
|
93
|
+
expect(parseSelection('', items)).toEqual(items)
|
|
94
|
+
expect(parseSelection('all', items)).toEqual(items)
|
|
95
|
+
expect(parseSelection('*', items)).toEqual(items)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('takes nothing for "none"', () => {
|
|
99
|
+
expect(parseSelection('none', items)).toEqual([])
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('accepts names, numbers, and a mix - without duplicates', () => {
|
|
103
|
+
expect(parseSelection('orders', items)).toEqual(['orders'])
|
|
104
|
+
expect(parseSelection('1, 3', items)).toEqual(['customers', 'invoices'])
|
|
105
|
+
expect(parseSelection('2, invoices', items)).toEqual(['orders', 'invoices'])
|
|
106
|
+
expect(parseSelection('orders, 2', items)).toEqual(['orders'])
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('ignores names and numbers that do not exist', () => {
|
|
110
|
+
expect(parseSelection('nope, 9, orders', items)).toEqual(['orders'])
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
describe('runStudioInit', () => {
|
|
115
|
+
it('builds a runnable app from a starter dataset with all defaults', async () => {
|
|
116
|
+
const prompts = scriptedPrompts([])
|
|
117
|
+
const fs = memoryIo()
|
|
118
|
+
const result = await runStudioInit({ yes: true }, prompts.io, null, fs.io)
|
|
119
|
+
|
|
120
|
+
expect(result.project.entities.length).toBeGreaterThanOrEqual(2)
|
|
121
|
+
expect(validateProject(result.project).filter((i) => i.level === 'error')).toEqual([])
|
|
122
|
+
expect(result.written).toContain('studio.config.json')
|
|
123
|
+
expect(result.written.some((p) => p.endsWith('package.json'))).toBe(true)
|
|
124
|
+
expect(result.written.some((p) => p.includes('+page.svelte'))).toBe(true)
|
|
125
|
+
// --yes asks nothing.
|
|
126
|
+
expect(prompts.asked).toEqual([])
|
|
127
|
+
// The written config re-parses into the same project.
|
|
128
|
+
expect(parseProject(fs.files.get('studio.config.json')!)).toEqual(result.project)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('asks the questions in order when driven interactively', async () => {
|
|
132
|
+
const prompts = scriptedPrompts([
|
|
133
|
+
'1', // source: sample data
|
|
134
|
+
'2', // dataset: products & categories
|
|
135
|
+
'y', // full CRUD suite
|
|
136
|
+
'1', // editing: popup form
|
|
137
|
+
'1', // theme
|
|
138
|
+
'n', // dark mode
|
|
139
|
+
'Catalog', // app name
|
|
140
|
+
'.', // out dir
|
|
141
|
+
])
|
|
142
|
+
const fs = memoryIo()
|
|
143
|
+
const result = await runStudioInit({}, prompts.io, null, fs.io)
|
|
144
|
+
|
|
145
|
+
expect(prompts.asked).toEqual([
|
|
146
|
+
'Pick a number:', // source
|
|
147
|
+
'Pick a number:', // dataset
|
|
148
|
+
'\nGenerate a list, an edit form and a record page for each table? (Y/n)',
|
|
149
|
+
'Pick a number:', // editing mode
|
|
150
|
+
'Pick a number:', // theme
|
|
151
|
+
'Dark mode? (y/N)',
|
|
152
|
+
'\nApp name:',
|
|
153
|
+
'Write it where?',
|
|
154
|
+
])
|
|
155
|
+
expect(result.project.title).toBe('Catalog')
|
|
156
|
+
expect(result.project.entities.map((e) => e.name)).toEqual(['categories', 'products'])
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('honours the out directory', async () => {
|
|
160
|
+
const prompts = scriptedPrompts([])
|
|
161
|
+
const fs = memoryIo()
|
|
162
|
+
const result = await runStudioInit({ yes: true, out: 'apps/shop' }, prompts.io, null, fs.io)
|
|
163
|
+
|
|
164
|
+
expect(result.written.every((p) => p.startsWith('apps/shop/'))).toBe(true)
|
|
165
|
+
expect(result.nextSteps[0]).toBe('cd apps/shop')
|
|
166
|
+
expect(fs.files.has('apps/shop/studio.config.json')).toBe(true)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('stores a dataset in PGlite when that source is picked', async () => {
|
|
170
|
+
// Source menu: 1 sample, 2 database, 3 supabase, 4 pglite, 5 rest.
|
|
171
|
+
const prompts = scriptedPrompts(['4', '1', 'y', '1', '1', 'n', 'Shop', '.'])
|
|
172
|
+
const fs = memoryIo()
|
|
173
|
+
const result = await runStudioInit({}, prompts.io, null, fs.io)
|
|
174
|
+
|
|
175
|
+
expect(result.project.dataSource).toBe('pglite')
|
|
176
|
+
const first = result.project.entities[0]!.name
|
|
177
|
+
expect(result.project.dataSources?.[first]).toMatchObject({ kind: 'pglite', table: first })
|
|
178
|
+
// The seed rides along, so the generated app boots with data.
|
|
179
|
+
expect((result.project.dataSources?.[first] as { seed?: unknown[] }).seed?.length).toBeGreaterThan(0)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('reads tables from a live database', async () => {
|
|
183
|
+
const prompts = scriptedPrompts(['all', 'y', '1', '1', 'n', 'Ops', '.'])
|
|
184
|
+
const fs = memoryIo()
|
|
185
|
+
const result = await runStudioInit(
|
|
186
|
+
{ db: 'postgres', url: 'postgres://localhost/app' },
|
|
187
|
+
prompts.io,
|
|
188
|
+
fakeDb(),
|
|
189
|
+
fs.io,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
expect(result.project.entities.map((e) => e.name)).toEqual(['customers', 'orders'])
|
|
193
|
+
expect(result.project.dataSource).toBe('sql')
|
|
194
|
+
expect(result.project.dataSources?.customers).toEqual({ kind: 'sql', table: 'customers', dialect: 'postgres' })
|
|
195
|
+
// --db + --url skip the connection questions entirely.
|
|
196
|
+
expect(prompts.asked[0]).toBe('Which tables? (all, or a comma list of names/numbers)')
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('imports only the picked tables', async () => {
|
|
200
|
+
const prompts = scriptedPrompts(['orders', 'y', '1', '1', 'n', 'Ops', '.'])
|
|
201
|
+
const fs = memoryIo()
|
|
202
|
+
const result = await runStudioInit({ db: 'postgres', url: 'postgres://x' }, prompts.io, fakeDb(), fs.io)
|
|
203
|
+
expect(result.project.entities.map((e) => e.name)).toEqual(['orders'])
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('fails clearly when the driver cannot be installed', async () => {
|
|
207
|
+
const prompts = scriptedPrompts(['all'])
|
|
208
|
+
const fs = memoryIo()
|
|
209
|
+
const db: DbGateway = { ...fakeDb(), ensureDriver: async () => ({ ok: false, message: 'pg is not installed' }) }
|
|
210
|
+
await expect(runStudioInit({ db: 'postgres', url: 'postgres://x' }, prompts.io, db, fs.io))
|
|
211
|
+
.rejects.toThrow('pg is not installed')
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('fails clearly when no table is picked', async () => {
|
|
215
|
+
const prompts = scriptedPrompts(['none'])
|
|
216
|
+
const fs = memoryIo()
|
|
217
|
+
await expect(runStudioInit({ db: 'postgres', url: 'postgres://x' }, prompts.io, fakeDb(), fs.io))
|
|
218
|
+
.rejects.toThrow('No tables picked')
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('refuses a database when no gateway is available', async () => {
|
|
222
|
+
const prompts = scriptedPrompts([])
|
|
223
|
+
const fs = memoryIo()
|
|
224
|
+
await expect(runStudioInit({ db: 'postgres', url: 'postgres://x' }, prompts.io, null, fs.io))
|
|
225
|
+
.rejects.toThrow('not available here')
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('rejects an unknown dataset id by name', async () => {
|
|
229
|
+
const prompts = scriptedPrompts([])
|
|
230
|
+
const fs = memoryIo()
|
|
231
|
+
await expect(runStudioInit({ yes: true, dataset: 'nope' }, prompts.io, null, fs.io))
|
|
232
|
+
.rejects.toThrow(/Unknown dataset "nope"/)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('builds an app from a REST endpoint', async () => {
|
|
236
|
+
const prompts = scriptedPrompts(['5', 'https://api.example.com/widgets', 'widgets', 'y', '1', '1', 'n', 'API app', '.'])
|
|
237
|
+
const fs = memoryIo()
|
|
238
|
+
const rows = JSON.stringify({ data: [{ id: 1, name: 'Widget', price: 9.5 }] })
|
|
239
|
+
const result = await runStudioInit({}, prompts.io, null, fs.io, async () => rows)
|
|
240
|
+
|
|
241
|
+
expect(result.project.entities.map((e) => e.name)).toEqual(['widgets'])
|
|
242
|
+
expect(result.project.dataSources?.widgets).toMatchObject({ kind: 'rest', baseUrl: 'https://api.example.com', path: 'widgets' })
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('applies the picked editing mode to the generated grids', async () => {
|
|
246
|
+
const prompts = scriptedPrompts(['1', '1', 'y', '2', '1', 'n', 'Inline', '.'])
|
|
247
|
+
const fs = memoryIo()
|
|
248
|
+
const result = await runStudioInit({}, prompts.io, null, fs.io)
|
|
249
|
+
|
|
250
|
+
const listScreen = result.project.screens.find((s) => s.id === result.project.entities[0]!.name)!
|
|
251
|
+
const grid = listScreen.blocks.find((b) => b.config.kind === 'grid')!.config as GridConfig
|
|
252
|
+
expect(grid.editing).toBe('inline')
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
describe('supabase', () => {
|
|
256
|
+
afterEach(() => vi.unstubAllGlobals())
|
|
257
|
+
|
|
258
|
+
it('reads a project over the REST API and binds each table to it', async () => {
|
|
259
|
+
stubSupabase(SUPABASE_DEFS)
|
|
260
|
+
const prompts = scriptedPrompts(['all', 'y', '1', '1', 'n', 'Shop', '.'])
|
|
261
|
+
const fs = memoryIo()
|
|
262
|
+
const result = await runStudioInit(
|
|
263
|
+
{ supabaseUrl: 'https://abc.supabase.co', supabaseKey: 'anon-key' },
|
|
264
|
+
prompts.io,
|
|
265
|
+
null, // no DbGateway: the point is that Supabase needs no driver
|
|
266
|
+
fs.io,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
expect(result.project.entities.map((e) => e.name)).toEqual(['customers', 'orders'])
|
|
270
|
+
expect(result.project.dataSource).toBe('supabase')
|
|
271
|
+
expect(result.project.dataSources?.customers).toEqual({
|
|
272
|
+
kind: 'supabase', table: 'customers', url: 'https://abc.supabase.co', key: 'anon-key',
|
|
273
|
+
})
|
|
274
|
+
expect(validateProject(result.project).filter((i) => i.level === 'error')).toEqual([])
|
|
275
|
+
// The URL + key flags skip straight to the table question.
|
|
276
|
+
expect(prompts.asked[0]).toBe('Which tables? (all, or a comma list of names/numbers)')
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it('picks up the primary key and the foreign key from the API doc', async () => {
|
|
280
|
+
stubSupabase(SUPABASE_DEFS)
|
|
281
|
+
const prompts = scriptedPrompts(['all', 'y', '1', '1', 'n', 'Shop', '.'])
|
|
282
|
+
const fs = memoryIo()
|
|
283
|
+
const { project } = await runStudioInit(
|
|
284
|
+
{ supabaseUrl: 'https://abc.supabase.co', supabaseKey: 'k' }, prompts.io, null, fs.io,
|
|
285
|
+
)
|
|
286
|
+
const orders = project.entities.find((e) => e.name === 'orders')!
|
|
287
|
+
expect(orders.fields.find((f) => f.field === 'id')?.primaryKey).toBe(true)
|
|
288
|
+
expect(orders.fields.find((f) => f.field === 'customer_id')?.relation?.entity).toBe('customers')
|
|
289
|
+
// A relation means the customers detail page gets an orders tab.
|
|
290
|
+
expect(project.screens.some((s) => s.id === 'customers-detail')).toBe(true)
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it('imports only the picked tables', async () => {
|
|
294
|
+
stubSupabase(SUPABASE_DEFS)
|
|
295
|
+
const prompts = scriptedPrompts(['orders', 'y', '1', '1', 'n', 'Shop', '.'])
|
|
296
|
+
const fs = memoryIo()
|
|
297
|
+
const { project } = await runStudioInit(
|
|
298
|
+
{ supabaseUrl: 'https://abc.supabase.co', supabaseKey: 'k' }, prompts.io, null, fs.io,
|
|
299
|
+
)
|
|
300
|
+
expect(project.entities.map((e) => e.name)).toEqual(['orders'])
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('falls back to typed table names when the API doc is restricted', async () => {
|
|
304
|
+
// 401 on the doc, but each table still introspects from a sample row.
|
|
305
|
+
let calls = 0
|
|
306
|
+
vi.stubGlobal('fetch', async (input: string | URL) => {
|
|
307
|
+
const href = String(input)
|
|
308
|
+
calls++
|
|
309
|
+
if (href.endsWith('/rest/v1/')) return new Response('no', { status: 401 })
|
|
310
|
+
return new Response(JSON.stringify([{ id: 1, name: 'Acme' }]), {
|
|
311
|
+
status: 200, headers: { 'content-type': 'application/json' },
|
|
312
|
+
})
|
|
313
|
+
})
|
|
314
|
+
const prompts = scriptedPrompts(['customers', 'y', '1', '1', 'n', 'Shop', '.'])
|
|
315
|
+
const fs = memoryIo()
|
|
316
|
+
const { project } = await runStudioInit(
|
|
317
|
+
{ supabaseUrl: 'https://abc.supabase.co', supabaseKey: 'k' }, prompts.io, null, fs.io,
|
|
318
|
+
)
|
|
319
|
+
expect(calls).toBeGreaterThan(0)
|
|
320
|
+
expect(project.entities.map((e) => e.name)).toEqual(['customers'])
|
|
321
|
+
expect(project.entities[0]!.fields.map((f) => f.field)).toContain('name')
|
|
322
|
+
expect(prompts.asked).toContain('Table names (comma separated):')
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
it('requires both the URL and the key', async () => {
|
|
326
|
+
const prompts = scriptedPrompts(['', ''])
|
|
327
|
+
const fs = memoryIo()
|
|
328
|
+
await expect(runStudioInit({ supabaseUrl: 'https://abc.supabase.co' }, prompts.io, null, fs.io))
|
|
329
|
+
.rejects.toThrow('project URL and the anon key are required')
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
it('fails clearly when no picked table can be read', async () => {
|
|
333
|
+
// Doc lists nothing AND every per-table fallback 404s.
|
|
334
|
+
vi.stubGlobal('fetch', async (input: string | URL) =>
|
|
335
|
+
String(input).endsWith('/rest/v1/')
|
|
336
|
+
? new Response(JSON.stringify({ definitions: {} }), { status: 200, headers: { 'content-type': 'application/json' } })
|
|
337
|
+
: new Response('missing', { status: 404 }),
|
|
338
|
+
)
|
|
339
|
+
const prompts = scriptedPrompts(['ghosts'])
|
|
340
|
+
const fs = memoryIo()
|
|
341
|
+
await expect(runStudioInit(
|
|
342
|
+
{ supabaseUrl: 'https://abc.supabase.co', supabaseKey: 'k' }, prompts.io, null, fs.io,
|
|
343
|
+
)).rejects.toThrow('None of the picked tables could be read')
|
|
344
|
+
})
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('applies the chosen theme', async () => {
|
|
348
|
+
const prompts = scriptedPrompts([])
|
|
349
|
+
const fs = memoryIo()
|
|
350
|
+
const result = await runStudioInit({ yes: true, theme: 'material', dark: true }, prompts.io, null, fs.io)
|
|
351
|
+
expect(result.project.theme?.preset).toBe('material')
|
|
352
|
+
expect(result.project.theme?.mode).toBe('dark')
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
it('keeps user-owned files that already exist', async () => {
|
|
356
|
+
const fs = memoryIo()
|
|
357
|
+
await runStudioInit({ yes: true }, scriptedPrompts([]).io, null, fs.io)
|
|
358
|
+
const handlers = [...fs.files.keys()].find((p) => p.endsWith('handlers.ts'))
|
|
359
|
+
if (!handlers) return // no user-owned companion in this project shape
|
|
360
|
+
fs.files.set(handlers, '// my own code')
|
|
361
|
+
await runStudioInit({ yes: true }, scriptedPrompts([]).io, null, fs.io)
|
|
362
|
+
expect(fs.files.get(handlers)).toBe('// my own code')
|
|
363
|
+
})
|
|
364
|
+
})
|