@svgrid/enterprise 2.3.0 → 2.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cdn/svgrid-enterprise.svelte-external.js +6885 -3504
- package/dist/designer/assets/GridMenus-BtWVk9Ab.js +7 -0
- package/dist/designer/assets/SvGridChartPanel-DWlBO2SD.js +10 -0
- package/dist/designer/assets/SvGridChartView-8c8Mb4j8.js +1 -0
- package/dist/designer/assets/SvGridChartView-DX9HfkBR.css +1 -0
- package/dist/designer/assets/index-DsDgp9Xq.js +78758 -0
- package/dist/designer/assets/index-tTY_Dx4P.css +1 -0
- package/dist/designer/assets/jszip.min-fkJdmAmj.js +2 -0
- package/dist/designer/assets/pdfmake-DeCsnyl9.js +242 -0
- package/dist/designer/assets/smart.export-BZlSCE8T.js +35 -0
- package/dist/designer/assets/vfs_fonts-eX2NpmfX.js +1 -0
- package/dist/designer/index.html +13 -0
- package/dist/node/studio.js +5660 -1747
- package/package.json +8 -5
- 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/studio/emit-project.test.ts +298 -7
- package/src/studio/emit-project.ts +483 -29
- package/src/studio/emit-schema.ts +20 -10
- package/src/studio/index.ts +34 -0
- package/src/studio/init-flow.test.ts +239 -0
- package/src/studio/init-flow.ts +358 -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
|
@@ -1,7 +1,7 @@
|
|
|
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, addComponentBlock, addFreestandingScreen, addScreenAction, addTabBlock, addAccordionBlock, addAccordionComponent, createProject, enableScreenCode, flattenBlocks, parseProject, serializeProject, addStateVar, setScreenLayout, setLayoutOpts,
|
|
4
|
+
import { addBlock, addComponentBlock, addFreestandingScreen, addScreenAction, addTabBlock, addAccordionBlock, addAccordionComponent, createProject, enableScreenCode, flattenBlocks, parseProject, serializeProject, addStateVar, setScreenLayout, setLayoutOpts, setDockPaneTitle, dockPaneTitleOf, syncDockPanes, dockPaneIds, removeBlock, setAuth, setComponentBinding, setDataLayer, setDeployTarget, setEntityDataSource, setJob, setTenancy, setTrigger, setHandlerBody, setHandlerSteps, stepsToCode, clickSlot, setScreenHandlersSource, setScreenRenderGrid, setShell, setTheme, setThemePreset, updateBlock, updateScreen, type GridConfig, type MasterDetailConfig, type TabsConfig, type AccordionConfig, type StudioProject } from './project'
|
|
5
5
|
import ts from 'typescript'
|
|
6
6
|
import { emitStudioProject, emitStudioAppBundle, emitStudioFragment, studioDeployInfo, ctxCompletions, ctxAmbientDts } from './emit-project'
|
|
7
7
|
import { UI_COMPONENT_REGISTRY } from './ui-components'
|
|
@@ -54,6 +54,26 @@ describe('SSR-native screens (renderMode: ssr)', () => {
|
|
|
54
54
|
return { ...p, screens: p.screens.map((s) => ({ ...s, renderMode: 'ssr' as const })) }
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
it('updateScreen can switch a screen to SSR (the CLI / MCP path, not just the designer)', () => {
|
|
58
|
+
// The other tests in this block set renderMode by spreading the screen object.
|
|
59
|
+
// Going through updateScreen is what the CLI and the studio_update_screen MCP
|
|
60
|
+
// tool do - and its patch type has to allow renderMode for that to be possible.
|
|
61
|
+
const p0 = createProject([customers])
|
|
62
|
+
const sid = p0.screens[0]!.id
|
|
63
|
+
expect(emitStudioProject(p0).find((f) => f.path === 'src/routes/customers/+page.server.ts')).toBeFalsy()
|
|
64
|
+
|
|
65
|
+
const p1 = updateScreen(p0, sid, { renderMode: 'ssr' })
|
|
66
|
+
expect(p1.screens[0]!.renderMode).toBe('ssr')
|
|
67
|
+
const server = emitStudioProject(p1).find((f) => f.path === 'src/routes/customers/+page.server.ts')
|
|
68
|
+
expect(server, 'updateScreen({ renderMode: "ssr" }) should reach the SSR emitter').toBeTruthy()
|
|
69
|
+
expect(server!.contents).toContain('export const load')
|
|
70
|
+
expect(server!.contents).toContain('export const actions')
|
|
71
|
+
|
|
72
|
+
// And back again - the SPA page returns, the server page goes away.
|
|
73
|
+
const p2 = updateScreen(p1, sid, { renderMode: 'spa' })
|
|
74
|
+
expect(emitStudioProject(p2).find((f) => f.path === 'src/routes/customers/+page.server.ts')).toBeFalsy()
|
|
75
|
+
})
|
|
76
|
+
|
|
57
77
|
it('emits +page.server.ts with a load + CRUD form actions + server validation', () => {
|
|
58
78
|
const server = emitStudioProject(ssrProject()).find((f) => f.path === 'src/routes/customers/+page.server.ts')
|
|
59
79
|
expect(server, 'an SSR screen should emit a +page.server.ts').toBeTruthy()
|
|
@@ -722,7 +742,7 @@ describe('emitStudioProject (per-block screens)', () => {
|
|
|
722
742
|
expect(get('src/routes/login/+page.server.ts')).toContain('cookies.set(SESSION_COOKIE')
|
|
723
743
|
// The shell wires the session in: login bypass, real user, sign-out, role seed.
|
|
724
744
|
const layout = get('src/routes/+layout.svelte')!
|
|
725
|
-
expect(layout).toContain('["/login"].includes(
|
|
745
|
+
expect(layout).toContain('["/login"].includes(page.url.pathname)') // login renders bare (no shell)
|
|
726
746
|
expect(layout).toContain('action="/logout"')
|
|
727
747
|
expect(layout).toContain('currentRole.set(data.role')
|
|
728
748
|
expect(layout).toContain('data?.user?.email')
|
|
@@ -787,7 +807,7 @@ describe('emitStudioProject (per-block screens)', () => {
|
|
|
787
807
|
|
|
788
808
|
it('no data layer without the toggle, without a SQL entity, or on an unsupported dialect', () => {
|
|
789
809
|
// Off by default.
|
|
790
|
-
|
|
810
|
+
const sqlOnly = setEntityDataSource(createProject([customers]), 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres' })
|
|
791
811
|
expect(emitStudioProject(sqlOnly).find((f) => f.path === 'src/lib/server/db/schema.ts')).toBeUndefined()
|
|
792
812
|
// Toggle on but no SQL entity (memory source) -> nothing.
|
|
793
813
|
expect(emitStudioProject(setDataLayer(createProject([customers]), true)).find((f) => f.path.startsWith('src/lib/server/db/'))).toBeUndefined()
|
|
@@ -1011,6 +1031,191 @@ describe('emitStudioProject (per-block screens)', () => {
|
|
|
1011
1031
|
expect(files.find((f) => f.path === 'src/routes/audit/+page.svelte')).toBeUndefined()
|
|
1012
1032
|
})
|
|
1013
1033
|
|
|
1034
|
+
it('every registered toolbox component generates code that compiles', () => {
|
|
1035
|
+
// The toolbox is registry-driven, so a new registration reaches codegen with
|
|
1036
|
+
// no per-component work - which is exactly why it needs a blanket check that
|
|
1037
|
+
// each one actually emits a valid import + usage.
|
|
1038
|
+
for (const spec of UI_COMPONENT_REGISTRY) {
|
|
1039
|
+
let p = createProject([customers])
|
|
1040
|
+
const sid = p.screens[0]!.id
|
|
1041
|
+
const defaults: Record<string, unknown> = {}
|
|
1042
|
+
for (const pr of spec.props) if (pr.default != null) defaults[pr.key] = pr.default
|
|
1043
|
+
if (spec.hasContent) defaults._content = spec.contentDefault ?? spec.label
|
|
1044
|
+
p = addComponentBlock(p, sid, spec.key, defaults)
|
|
1045
|
+
const page = emitStudioProject(p).find((f) => f.path === 'src/routes/customers/+page.svelte')!
|
|
1046
|
+
expect(page.contents, `${spec.key} did not import ${spec.importName}`).toContain(spec.importName)
|
|
1047
|
+
expect(() => compile(page.contents, { filename: page.path, generate: 'client' }), `${spec.key} emitted invalid Svelte`).not.toThrow()
|
|
1048
|
+
}
|
|
1049
|
+
})
|
|
1050
|
+
|
|
1051
|
+
describe('multi-tenancy', () => {
|
|
1052
|
+
/** Tenancy needs auth (to know the tenant) + the typed layer (for the column). */
|
|
1053
|
+
const tenantProject = () => {
|
|
1054
|
+
let p = createProject([customers, orders])
|
|
1055
|
+
p = setEntityDataSource(p, 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres' })
|
|
1056
|
+
p = setEntityDataSource(p, 'orders', { kind: 'sql', table: 'orders', dialect: 'postgres' })
|
|
1057
|
+
p = setDataLayer(p, true)
|
|
1058
|
+
p = setAuth(p, { enabled: true })
|
|
1059
|
+
return setTenancy(p, { enabled: true })
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
it('scopes every SQL route server-side', () => {
|
|
1063
|
+
const files = emitStudioProject(tenantProject())
|
|
1064
|
+
for (const entity of ['customers', 'orders']) {
|
|
1065
|
+
const route = files.find((f) => f.path === `src/routes/api/${entity}/+server.ts`)!.contents
|
|
1066
|
+
expect(route, entity).toContain("import { requireTenant } from '$lib/server/tenant'")
|
|
1067
|
+
expect(route, entity).toContain("scope: ({ event }) => ({ field: \"tenantId\", value: requireTenant(event) })")
|
|
1068
|
+
}
|
|
1069
|
+
})
|
|
1070
|
+
|
|
1071
|
+
it('resolves the tenant from the session and fails closed', () => {
|
|
1072
|
+
const files = emitStudioProject(tenantProject())
|
|
1073
|
+
const tenant = files.find((f) => f.path === 'src/lib/server/tenant.ts')!.contents
|
|
1074
|
+
// requireTenant THROWS rather than returning null - the transport turns that
|
|
1075
|
+
// into a 403. Returning null would run the query unscoped.
|
|
1076
|
+
expect(tenant).toContain('export function requireTenant')
|
|
1077
|
+
expect(tenant).toContain('throw new Error')
|
|
1078
|
+
// The tenant comes off the session in hooks, never from the client.
|
|
1079
|
+
const hooks = files.find((f) => f.path === 'src/hooks.server.ts')!.contents
|
|
1080
|
+
expect(hooks).toContain('event.locals.tenantId')
|
|
1081
|
+
})
|
|
1082
|
+
|
|
1083
|
+
it('adds the scoping column to every scoped table + the user store', () => {
|
|
1084
|
+
const schema = emitStudioProject(tenantProject()).find((f) => f.path === 'src/lib/server/db/schema.ts')!.contents
|
|
1085
|
+
// notNull: a row with no tenant belongs to nobody and no scoped read finds it.
|
|
1086
|
+
expect(schema).toContain('"tenantId": text("tenant_id").notNull()')
|
|
1087
|
+
// The user store carries it too - that is where the session's tenant comes from.
|
|
1088
|
+
expect(schema.slice(schema.indexOf('authUsers'))).toContain('"tenantId"')
|
|
1089
|
+
})
|
|
1090
|
+
|
|
1091
|
+
it('leaves shared entities global (no column, no scope)', () => {
|
|
1092
|
+
let p = tenantProject()
|
|
1093
|
+
p = setTenancy(p, { enabled: true, sharedEntities: ['orders'] })
|
|
1094
|
+
const files = emitStudioProject(p)
|
|
1095
|
+
expect(files.find((f) => f.path === 'src/routes/api/customers/+server.ts')!.contents).toContain('scope:')
|
|
1096
|
+
expect(files.find((f) => f.path === 'src/routes/api/orders/+server.ts')!.contents).not.toContain('scope:')
|
|
1097
|
+
})
|
|
1098
|
+
|
|
1099
|
+
it('honours a custom scoping column', () => {
|
|
1100
|
+
let p = tenantProject()
|
|
1101
|
+
p = setTenancy(p, { enabled: true, field: 'orgId' })
|
|
1102
|
+
const files = emitStudioProject(p)
|
|
1103
|
+
expect(files.find((f) => f.path === 'src/routes/api/customers/+server.ts')!.contents).toContain('field: "orgId"')
|
|
1104
|
+
expect(files.find((f) => f.path === 'src/lib/server/db/schema.ts')!.contents).toContain('"orgId": text("org_id").notNull()')
|
|
1105
|
+
})
|
|
1106
|
+
|
|
1107
|
+
it('degrades to OFF without auth or the typed data layer, rather than half-enforcing', () => {
|
|
1108
|
+
// No auth: nothing can tell us who the tenant is.
|
|
1109
|
+
let noAuth = createProject([customers])
|
|
1110
|
+
noAuth = setEntityDataSource(noAuth, 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres' })
|
|
1111
|
+
noAuth = setDataLayer(noAuth, true)
|
|
1112
|
+
noAuth = setTenancy(noAuth, { enabled: true })
|
|
1113
|
+
expect(emitStudioProject(noAuth).find((f) => f.path === 'src/lib/server/tenant.ts')).toBeUndefined()
|
|
1114
|
+
|
|
1115
|
+
// No typed layer: the scoping column would not exist in any schema.
|
|
1116
|
+
let noLayer = createProject([customers])
|
|
1117
|
+
noLayer = setEntityDataSource(noLayer, 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres' })
|
|
1118
|
+
noLayer = setAuth(noLayer, { enabled: true })
|
|
1119
|
+
noLayer = setTenancy(noLayer, { enabled: true })
|
|
1120
|
+
const files = emitStudioProject(noLayer)
|
|
1121
|
+
expect(files.find((f) => f.path === 'src/lib/server/tenant.ts')).toBeUndefined()
|
|
1122
|
+
expect(files.find((f) => f.path === 'src/routes/api/customers/+server.ts')!.contents).not.toContain('scope:')
|
|
1123
|
+
})
|
|
1124
|
+
|
|
1125
|
+
it('emits nothing tenancy-related when it is off', () => {
|
|
1126
|
+
const files = emitStudioProject(createProject([customers]))
|
|
1127
|
+
expect(files.find((f) => f.path === 'src/lib/server/tenant.ts')).toBeUndefined()
|
|
1128
|
+
})
|
|
1129
|
+
})
|
|
1130
|
+
|
|
1131
|
+
it('Scheduled jobs emit a guarded /api/cron + handler registry', () => {
|
|
1132
|
+
let p = createProject([customers, orders])
|
|
1133
|
+
p = setJob(p, 'nightly-digest', { name: 'Nightly digest', cron: '0 6 * * *', kind: 'code', code: "console.log('tick')" })
|
|
1134
|
+
const files = emitStudioProject(p)
|
|
1135
|
+
|
|
1136
|
+
const route = files.find((f) => f.path === 'src/routes/api/cron/+server.ts')!.contents
|
|
1137
|
+
// Secret-guarded, and refuses to run when no secret is configured - an
|
|
1138
|
+
// unguarded "do work" URL is the failure mode worth preventing.
|
|
1139
|
+
expect(route).toContain('env.CRON_SECRET')
|
|
1140
|
+
expect(route).toContain('if (!secret) return false')
|
|
1141
|
+
expect(route).toContain("throw error(401")
|
|
1142
|
+
// One failing job must not abort the rest of the run.
|
|
1143
|
+
expect(route).toContain('try { await fn()')
|
|
1144
|
+
|
|
1145
|
+
const jobs = files.find((f) => f.path === 'src/lib/server/jobs.ts')!.contents
|
|
1146
|
+
expect(jobs).toContain('"nightly-digest"')
|
|
1147
|
+
expect(jobs).toContain("console.log('tick')")
|
|
1148
|
+
expect(jobs).toContain('export const enabledJobs: string[] = ["nightly-digest"]')
|
|
1149
|
+
})
|
|
1150
|
+
|
|
1151
|
+
it('a disabled job keeps its handler but drops out of the scheduled run', () => {
|
|
1152
|
+
let p = createProject([customers])
|
|
1153
|
+
p = setJob(p, 'a', { name: 'A', cron: '0 1 * * *', kind: 'code', code: 'void 0' })
|
|
1154
|
+
p = setJob(p, 'b', { name: 'B', cron: '0 2 * * *', kind: 'code', code: 'void 0', enabled: false })
|
|
1155
|
+
const jobs = emitStudioProject(p).find((f) => f.path === 'src/lib/server/jobs.ts')!.contents
|
|
1156
|
+
expect(jobs).toContain('"a"')
|
|
1157
|
+
expect(jobs).toContain('"b"') // still callable via ?job=b
|
|
1158
|
+
expect(jobs).toContain('export const enabledJobs: string[] = ["a"]')
|
|
1159
|
+
})
|
|
1160
|
+
|
|
1161
|
+
it('an email job without the email layer emits a warning slot, not a broken import', () => {
|
|
1162
|
+
let p = createProject([customers])
|
|
1163
|
+
p = setJob(p, 'digest', { name: 'Digest', cron: '0 6 * * *', kind: 'email', entity: 'customers', to: 'ops@example.com' })
|
|
1164
|
+
const jobs = emitStudioProject(p).find((f) => f.path === 'src/lib/server/jobs.ts')!.contents
|
|
1165
|
+
expect(jobs).not.toContain("from '$lib/server/email'")
|
|
1166
|
+
expect(jobs).toContain('email is not enabled on this project')
|
|
1167
|
+
})
|
|
1168
|
+
|
|
1169
|
+
it('an email job with the email layer on sends a summary of its entity', () => {
|
|
1170
|
+
let p = createProject([customers, orders])
|
|
1171
|
+
p = setEntityDataSource(p, 'orders', { kind: 'sql', table: 'orders', dialect: 'postgres' })
|
|
1172
|
+
p = setDataLayer(p, true)
|
|
1173
|
+
p = setAuth(p, { enabled: true, email: true })
|
|
1174
|
+
p = setJob(p, 'digest', { name: 'Daily orders', cron: '0 6 * * *', kind: 'email', entity: 'orders', to: 'ops@example.com', subject: 'Orders today' })
|
|
1175
|
+
const jobs = emitStudioProject(p).find((f) => f.path === 'src/lib/server/jobs.ts')!.contents
|
|
1176
|
+
expect(jobs).toContain("import { sendEmail } from '$lib/server/email'")
|
|
1177
|
+
expect(jobs).toContain("sendEmail('ops@example.com', 'Orders today'")
|
|
1178
|
+
})
|
|
1179
|
+
|
|
1180
|
+
it('no jobs emits no cron route at all', () => {
|
|
1181
|
+
const files = emitStudioProject(createProject([customers]))
|
|
1182
|
+
expect(files.find((f) => f.path === 'src/routes/api/cron/+server.ts')).toBeUndefined()
|
|
1183
|
+
expect(files.find((f) => f.path === 'src/lib/server/jobs.ts')).toBeUndefined()
|
|
1184
|
+
})
|
|
1185
|
+
|
|
1186
|
+
it('Audit persists to an audit_log table when the typed data layer is on', () => {
|
|
1187
|
+
let p = createProject([customers, orders])
|
|
1188
|
+
p = setEntityDataSource(p, 'orders', { kind: 'sql', table: 'orders', dialect: 'postgres' })
|
|
1189
|
+
p = { ...p, audit: true, dataLayer: 'drizzle' }
|
|
1190
|
+
const files = emitStudioProject(p)
|
|
1191
|
+
|
|
1192
|
+
// The trail gets a real table in the same schema, so one migration covers it.
|
|
1193
|
+
const schema = files.find((f) => f.path === 'src/lib/server/db/schema.ts')!.contents
|
|
1194
|
+
expect(schema).toContain('export const auditLog =')
|
|
1195
|
+
expect(schema).toContain('"audit_log"')
|
|
1196
|
+
// before / after snapshots - the in-memory store only kept field names.
|
|
1197
|
+
expect(schema).toContain('"before"')
|
|
1198
|
+
expect(schema).toContain('"after"')
|
|
1199
|
+
|
|
1200
|
+
const audit = files.find((f) => f.path === 'src/lib/audit.ts')!.contents
|
|
1201
|
+
expect(audit).toContain("from '$lib/server/db'")
|
|
1202
|
+
expect(audit).toContain('db.insert(auditLog)')
|
|
1203
|
+
// Paged in the database, and read-only (append-only through recordAudit).
|
|
1204
|
+
expect(audit).toContain('async getRows(')
|
|
1205
|
+
expect(audit).toContain('.limit(limit).offset(start)')
|
|
1206
|
+
expect(audit).not.toContain('createInMemoryDataSource')
|
|
1207
|
+
})
|
|
1208
|
+
|
|
1209
|
+
it('Audit stays in-memory (and says so) without the typed data layer', () => {
|
|
1210
|
+
let p = createProject([customers, orders])
|
|
1211
|
+
p = setEntityDataSource(p, 'orders', { kind: 'sql', table: 'orders', dialect: 'postgres' })
|
|
1212
|
+
p = { ...p, audit: true }
|
|
1213
|
+
const files = emitStudioProject(p)
|
|
1214
|
+
const audit = files.find((f) => f.path === 'src/lib/audit.ts')!.contents
|
|
1215
|
+
expect(audit).toContain('createInMemoryDataSource')
|
|
1216
|
+
expect(files.find((f) => f.path === 'src/lib/server/db/schema.ts')).toBeUndefined()
|
|
1217
|
+
})
|
|
1218
|
+
|
|
1014
1219
|
it('Layout: 12-col grid, colSpan gives finer widths (legacy span still works)', () => {
|
|
1015
1220
|
let p = createProject([customers, orders])
|
|
1016
1221
|
const cid = p.screens.find((s) => s.entity === 'customers')!.id
|
|
@@ -1233,7 +1438,7 @@ describe('emitStudioProject (per-block screens)', () => {
|
|
|
1233
1438
|
expect(cust).toContain('controller.deleteRow(') // delete button (fills the missing affordance)
|
|
1234
1439
|
|
|
1235
1440
|
const ord = files.find((f) => f.path === 'src/routes/orders/+page.svelte')!.contents
|
|
1236
|
-
expect(ord).toContain("import { page } from '$app/
|
|
1441
|
+
expect(ord).toContain("import { page } from '$app/state'") // drill TARGET reads URL params
|
|
1237
1442
|
expect(ord).toContain('sp.get(_f)')
|
|
1238
1443
|
|
|
1239
1444
|
for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
|
|
@@ -1251,7 +1456,15 @@ describe('emitStudioProject (per-block screens)', () => {
|
|
|
1251
1456
|
expect(cat).toContain('export function localizeCols')
|
|
1252
1457
|
expect(cat).toContain('"col.customers.name"') // seeded from the schema
|
|
1253
1458
|
expect(cat).toContain('"nav.customers"')
|
|
1254
|
-
|
|
1459
|
+
// EVERY locale is seeded with the same keys, not just the default - an empty
|
|
1460
|
+
// `{}` made the translator hunt for key names in the codegen.
|
|
1461
|
+
expect(cat).not.toContain('"es": {}')
|
|
1462
|
+
expect(cat).toMatch(/"es": \{[\s\S]*"col\.customers\.name"/)
|
|
1463
|
+
expect(cat).toContain('// TODO: translate')
|
|
1464
|
+
// Both locales carry the full key set.
|
|
1465
|
+
const keyCount = (block: string) => (block.match(/"col\.customers\./g) ?? []).length
|
|
1466
|
+
const [, enBlock = '', esBlock = ''] = cat.split(/"(?:en|es)": /)
|
|
1467
|
+
expect(keyCount(enBlock)).toBe(keyCount(esBlock))
|
|
1255
1468
|
|
|
1256
1469
|
const page = files.find((f) => f.path === 'src/routes/customers/+page.svelte')!.contents
|
|
1257
1470
|
expect(page).toContain("import { t, localizeCols } from '$lib/i18n'")
|
|
@@ -1982,6 +2195,50 @@ describe('grid editing (a Grid property) -> codegen', () => {
|
|
|
1982
2195
|
expect(pageFor(createProject([customers]))).not.toContain('st-grid-toolbar')
|
|
1983
2196
|
})
|
|
1984
2197
|
|
|
2198
|
+
it('xlsx / pdf / print buttons go through @svgrid/enterprise and compile', () => {
|
|
2199
|
+
let p = createProject([customers])
|
|
2200
|
+
const sid = p.screens[0]!.id
|
|
2201
|
+
const gid = p.screens[0]!.blocks[0]!.id
|
|
2202
|
+
p = updateBlock(p, sid, gid, { config: { export: { xlsx: true, pdf: true, print: true } } as Partial<import('./project').BlockConfig> })
|
|
2203
|
+
const page = pageFor(p)
|
|
2204
|
+
// Imported from the enterprise pack, not the free grid api.
|
|
2205
|
+
expect(page).toMatch(/import \{[^}]*exportGrid[^}]*\} from '@svgrid\/enterprise'/)
|
|
2206
|
+
expect(page).toMatch(/import \{[^}]*printGrid[^}]*\} from '@svgrid\/enterprise'/)
|
|
2207
|
+
expect(page).toContain("stExport(gridApi_" + gid.replace(/-/g, '_') + ", 'xlsx', 'customers')")
|
|
2208
|
+
expect(page).toContain("stExport(gridApi_" + gid.replace(/-/g, '_') + ", 'pdf', 'customers')")
|
|
2209
|
+
expect(page).toContain('stPrint(gridApi_' + gid.replace(/-/g, '_') + ", 'customers')")
|
|
2210
|
+
// Helpers emitted exactly once each, beside the buttons that call them.
|
|
2211
|
+
expect(page.match(/async function stExport\(/g)?.length).toBe(1)
|
|
2212
|
+
expect(page.match(/async function stPrint\(/g)?.length).toBe(1)
|
|
2213
|
+
for (const f of emitStudioProject(p).filter((f) => f.path.endsWith('.svelte'))) {
|
|
2214
|
+
expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
|
|
2215
|
+
}
|
|
2216
|
+
})
|
|
2217
|
+
|
|
2218
|
+
it('declares the optional peer deps the enterprise exporters need', () => {
|
|
2219
|
+
const base = createProject([customers])
|
|
2220
|
+
const sid = base.screens[0]!.id
|
|
2221
|
+
const gid = base.screens[0]!.blocks[0]!.id
|
|
2222
|
+
const pkgOf = (proj: typeof base) => {
|
|
2223
|
+
const f = emitStudioAppBundle(proj).find((f) => f.path === 'package.json')!
|
|
2224
|
+
return JSON.parse(f.contents).dependencies as Record<string, string>
|
|
2225
|
+
}
|
|
2226
|
+
// jszip (xlsx) and pdfmake (pdf) are imported lazily INSIDE @svgrid/enterprise,
|
|
2227
|
+
// so they never appear in the generated source - they must come from the config.
|
|
2228
|
+
const xlsxDeps = pkgOf(updateBlock(base, sid, gid, { config: { export: { xlsx: true } } as Partial<import('./project').BlockConfig> }))
|
|
2229
|
+
expect(xlsxDeps).toHaveProperty('jszip')
|
|
2230
|
+
expect(xlsxDeps).not.toHaveProperty('pdfmake')
|
|
2231
|
+
|
|
2232
|
+
const pdfDeps = pkgOf(updateBlock(base, sid, gid, { config: { export: { pdf: true } } as Partial<import('./project').BlockConfig> }))
|
|
2233
|
+
expect(pdfDeps).toHaveProperty('pdfmake')
|
|
2234
|
+
expect(pdfDeps).not.toHaveProperty('jszip')
|
|
2235
|
+
|
|
2236
|
+
// A csv-only app drags in neither.
|
|
2237
|
+
const csvDeps = pkgOf(updateBlock(base, sid, gid, { config: { export: { csv: true } } as Partial<import('./project').BlockConfig> }))
|
|
2238
|
+
expect(csvDeps).not.toHaveProperty('jszip')
|
|
2239
|
+
expect(csvDeps).not.toHaveProperty('pdfmake')
|
|
2240
|
+
})
|
|
2241
|
+
|
|
1985
2242
|
it('tree data renders a client hierarchy: visible-row walk + tree cell on the label column, no flat sort/paginate', () => {
|
|
1986
2243
|
let p = createProject([customers])
|
|
1987
2244
|
const sid = p.screens[0]!.id
|
|
@@ -2338,6 +2595,35 @@ describe('emitStudioAppBundle (full runnable app)', () => {
|
|
|
2338
2595
|
expect(pkg.devDependencies['vite']).toBeTruthy()
|
|
2339
2596
|
})
|
|
2340
2597
|
|
|
2598
|
+
it('schedules jobs with the platform: vercel.json crons on Vercel, a workflow elsewhere', () => {
|
|
2599
|
+
let base = createProject([customers])
|
|
2600
|
+
base = setJob(base, 'digest', { name: 'Digest', cron: '0 6 * * *', kind: 'code', code: 'void 0' })
|
|
2601
|
+
|
|
2602
|
+
const vercel = emitStudioAppBundle(setDeployTarget(base, 'vercel'))
|
|
2603
|
+
const vj = vercel.find((f) => f.path === 'vercel.json')!
|
|
2604
|
+
expect(JSON.parse(vj.contents).crons).toEqual([{ path: '/api/cron?job=digest', schedule: '0 6 * * *' }])
|
|
2605
|
+
// Vercel Cron calls the route itself - no Actions workflow needed.
|
|
2606
|
+
expect(vercel.find((f) => f.path === '.github/workflows/cron.yml')).toBeUndefined()
|
|
2607
|
+
|
|
2608
|
+
const node = emitStudioAppBundle(setDeployTarget(base, 'node'))
|
|
2609
|
+
const wf = node.find((f) => f.path === '.github/workflows/cron.yml')!.contents
|
|
2610
|
+
expect(wf).toContain("- cron: '0 6 * * *'")
|
|
2611
|
+
// The endpoint lives in the CRON_URL secret; the workflow appends the job.
|
|
2612
|
+
expect(wf).toContain('"$CRON_URL?job=digest"')
|
|
2613
|
+
// Inert until the secrets exist, so CI is green before it is configured.
|
|
2614
|
+
expect(wf).toContain("secrets.CRON_URL != ''")
|
|
2615
|
+
expect(node.find((f) => f.path === 'vercel.json')).toBeUndefined()
|
|
2616
|
+
|
|
2617
|
+
// The secret the route demands is documented in .env.example.
|
|
2618
|
+
expect(node.find((f) => f.path === '.env.example')!.contents).toContain('CRON_SECRET=')
|
|
2619
|
+
})
|
|
2620
|
+
|
|
2621
|
+
it('emits no schedule config when there are no jobs', () => {
|
|
2622
|
+
const files = emitStudioAppBundle(setDeployTarget(createProject([customers]), 'vercel'))
|
|
2623
|
+
expect(files.find((f) => f.path === 'vercel.json')).toBeUndefined()
|
|
2624
|
+
expect(files.find((f) => f.path === '.github/workflows/cron.yml')).toBeUndefined()
|
|
2625
|
+
})
|
|
2626
|
+
|
|
2341
2627
|
it('pins Vite 7 (not 8) so the app boots in StackBlitz WebContainer', () => {
|
|
2342
2628
|
const bundle = emitStudioAppBundle(createProject([customers]))
|
|
2343
2629
|
const pkg = JSON.parse(bundle.find((f) => f.path === 'package.json')!.contents)
|
|
@@ -2662,7 +2948,7 @@ describe('code companion (design + your own code)', () => {
|
|
|
2662
2948
|
expect(page.contents).toContain('let gridApi = $state<SvGridApi<any, any> | null>(null)')
|
|
2663
2949
|
expect(page.contents).toContain('onApiReady={(a) => (gridApi = a)}')
|
|
2664
2950
|
// Full ctx on mount + cleanup on unmount; the grid exposes reload(), not setRows.
|
|
2665
|
-
expect(page.contents).toContain('const ctx = { grid: gridCtx, data: { get rows() { return view.rows }, reload: () => controller.refresh(), create: (v) => controller.createRow(v), update: (id, v) => controller.updateRow(id, v), delete: (id) => controller.deleteRow(id) }, goto, params: Object.fromEntries(
|
|
2951
|
+
expect(page.contents).toContain('const ctx = { grid: gridCtx, data: { get rows() { return view.rows }, reload: () => controller.refresh(), create: (v) => controller.createRow(v), update: (id, v) => controller.updateRow(id, v), delete: (id) => controller.deleteRow(id) }, goto, params: Object.fromEntries(page.url.searchParams) } as unknown as PageContext')
|
|
2666
2952
|
expect(page.contents).toContain('handlers.onLoad(ctx)')
|
|
2667
2953
|
expect(page.contents).toContain('return () => handlers.onDestroy(ctx)')
|
|
2668
2954
|
// The grid api is typed to the entity's row (Customers), not any.
|
|
@@ -2828,7 +3114,12 @@ describe('code companion (design + your own code)', () => {
|
|
|
2828
3114
|
expect(typeCheckBody(dts, 'ctx.chart1.setData([1, 2, 3])').length).toBeGreaterThan(0)
|
|
2829
3115
|
// An unknown ctx member is caught.
|
|
2830
3116
|
expect(typeCheckBody(dts, 'ctx.notAThing()').length).toBeGreaterThan(0)
|
|
2831
|
-
|
|
3117
|
+
// This test runs REAL TypeScript programs over the generated code against
|
|
3118
|
+
// the grid's .d.ts, so it scales with the public type surface and is far
|
|
3119
|
+
// slower than a normal unit test. It passes comfortably in isolation but was
|
|
3120
|
+
// brushing the default 5s budget under full-suite parallel load; every grid
|
|
3121
|
+
// API added since made that worse. Give the compile room.
|
|
3122
|
+
}, 30_000)
|
|
2832
3123
|
|
|
2833
3124
|
it('onDestroy is a first-class slot in handlers.ts + page-context manifest', () => {
|
|
2834
3125
|
let p = createProject([customers])
|