@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.
Files changed (73) hide show
  1. package/dist/cdn/{pdfmake-C6pyKjX0.js → pdfmake-DVbZqKeD.js} +2297 -2327
  2. package/dist/cdn/rolldown-runtime-DtPi1Y-2.js +13 -0
  3. package/dist/cdn/{smart.export-CfW2zpyq.js → smart.export-D9FF4wAB.js} +54 -51
  4. package/dist/cdn/svgrid-enterprise.svelte-external.js +11308 -7898
  5. package/dist/cdn/{vfs_fonts-BktEULEs.js → vfs_fonts-iHMTEkpG.js} +1 -1
  6. package/dist/designer/assets/GridMenus-CW-rnHS9.js +7 -0
  7. package/dist/designer/assets/SvDateTimePicker-Cygij-dr.css +1 -0
  8. package/dist/designer/assets/SvDateTimePicker-DOBYyLeN.js +1 -0
  9. package/dist/designer/assets/SvGridChart-BEDNTdc5.js +2 -0
  10. package/dist/designer/assets/SvGridChart-Vu4Nvjiv.css +1 -0
  11. package/dist/designer/assets/SvGridChartPanel-x3lzSxuf.js +10 -0
  12. package/dist/designer/assets/SvGridChartView-CAKhPELi.js +1 -0
  13. package/dist/designer/assets/SvGridChartView-DX9HfkBR.css +1 -0
  14. package/dist/designer/assets/SvGridDropdown-CAYG7Y-O.js +1 -0
  15. package/dist/designer/assets/SvGridDropdown-Crwb2P3Q.css +1 -0
  16. package/dist/designer/assets/SvListBox-BMSfFJJT.css +1 -0
  17. package/dist/designer/assets/SvListBox-BVzVfCG3.js +1 -0
  18. package/dist/designer/assets/chart-DHEgt4lN.js +1 -0
  19. package/dist/designer/assets/disclose-version-CIm4S9xS.js +2 -0
  20. package/dist/designer/assets/dismissable-2EZKoens.js +1 -0
  21. package/dist/designer/assets/dismissable-BF-9TJSd.css +1 -0
  22. package/dist/designer/assets/easing-zaypKrr0.js +3 -0
  23. package/dist/designer/assets/export-format-DwjuTfrQ.js +6 -0
  24. package/dist/designer/assets/index-DVrv9dH6.js +75797 -0
  25. package/dist/designer/assets/index-DjPp1mn7.css +1 -0
  26. package/dist/designer/assets/js-scroller.svelte-tHanKJx0.js +110 -0
  27. package/dist/designer/assets/jszip.min-CX2fPs3O.js +2 -0
  28. package/dist/designer/assets/pdfmake-BpIYFHoG.js +242 -0
  29. package/dist/designer/assets/popover-CUkLsvRX.js +1 -0
  30. package/dist/designer/assets/rolldown-runtime-Dd_uD5pT.js +1 -0
  31. package/dist/designer/assets/smart.export-CDCwbFH4.js +37 -0
  32. package/dist/designer/assets/src-BRKu53tV.js +2842 -0
  33. package/dist/designer/assets/src-DseB1VRN.css +1 -0
  34. package/dist/designer/assets/vfs_fonts-BV-Ul9v2.js +1 -0
  35. package/dist/designer/index.html +32 -0
  36. package/dist/node/studio.js +16778 -12672
  37. package/package.json +10 -7
  38. package/src/SvGridBoard.svelte +4 -1
  39. package/src/SvGridScheduler.svelte +114 -90
  40. package/src/ai-export-pdf.dom.test.ts +77 -77
  41. package/src/ai-export-xlsx.dom.test.ts +90 -90
  42. package/src/ai-export.dom.test.ts +114 -114
  43. package/src/export-ooxml.ts +4 -0
  44. package/src/export-xls.ts +3 -0
  45. package/src/expressions/evaluate.ts +9 -0
  46. package/src/import.test.ts +1 -1
  47. package/src/import.ts +1 -1
  48. package/src/index.ts +12 -0
  49. package/src/pivot.test.ts +0 -1
  50. package/src/schema-designer.ts +1 -1
  51. package/src/sources/introspect-supabase.ts +2 -2
  52. package/src/studio/emit-project.test.ts +298 -7
  53. package/src/studio/emit-project.ts +484 -30
  54. package/src/studio/emit-schema.ts +20 -10
  55. package/src/studio/index.ts +34 -0
  56. package/src/studio/init-flow.test.ts +364 -0
  57. package/src/studio/init-flow.ts +409 -0
  58. package/src/studio/project.test.ts +0 -1
  59. package/src/studio/project.ts +109 -2
  60. package/src/studio/samples/datasets.test.ts +84 -0
  61. package/src/studio/samples/datasets.ts +340 -0
  62. package/src/studio/samples/live-data.test.ts +1 -1
  63. package/src/studio/samples/samples.test.ts +268 -268
  64. package/src/studio/samples/shared.ts +7 -150
  65. package/src/studio/screen-suites.test.ts +226 -0
  66. package/src/studio/screen-suites.ts +445 -0
  67. package/src/studio/ui-components-surface.test.ts +2 -2
  68. package/src/studio/ui-components.generated.ts +1643 -106
  69. package/src/studio/ui-components.ts +742 -641
  70. package/src/sveltekit/index.ts +1 -0
  71. package/src/sveltekit/sql-source.ts +1 -1
  72. package/src/sveltekit/transport-scope.test.ts +125 -0
  73. package/src/sveltekit/transport.ts +76 -2
@@ -1,268 +1,268 @@
1
- import { describe, it, expect } from 'vitest'
2
- import { compile } from 'svelte/compiler'
3
- import { sampleApps, getSampleApp } from './index.js'
4
- import { starterProject } from './starter.js'
5
- import { validateProject, parseProject, serializeProject } from '../project.js'
6
- import { emitStudioProject } from '../emit-project.js'
7
-
8
- describe('sample apps', () => {
9
- it('ships a non-empty gallery with unique ids', () => {
10
- expect(sampleApps.length).toBeGreaterThanOrEqual(4)
11
- const ids = sampleApps.map((s) => s.id)
12
- expect(new Set(ids).size).toBe(ids.length)
13
- for (const s of sampleApps) {
14
- expect(s.name).toBeTruthy()
15
- expect(s.description).toBeTruthy()
16
- expect(s.emoji).toBeTruthy()
17
- expect(s.accent).toMatch(/^#[0-9a-f]{6}$/i)
18
- }
19
- })
20
-
21
- it('getSampleApp resolves by id', () => {
22
- expect(getSampleApp('crm')?.name).toBe('CRM')
23
- expect(getSampleApp('nope')).toBeUndefined()
24
- })
25
-
26
- it('every app has a distinct visual identity (unique design-system preset)', () => {
27
- const presets = sampleApps.map((s) => s.build().theme?.preset)
28
- expect(presets.every(Boolean), 'an app is missing a theme preset').toBe(true)
29
- // Distinct presets => each app reads as its own product, not a recolored sibling.
30
- expect(new Set(presets).size, 'preset ids are not unique across apps').toBe(sampleApps.length)
31
- })
32
-
33
- it('ships a mix of dark-mode and top-nav apps for visual variety', () => {
34
- const themes = sampleApps.map((s) => s.build().theme)
35
- expect(themes.filter((t) => t?.mode === 'dark').length).toBeGreaterThanOrEqual(3)
36
- expect(themes.filter((t) => t?.shell?.style === 'top-nav').length).toBeGreaterThanOrEqual(3)
37
- })
38
-
39
- it('generated apps ship an app-chrome toolbar with a working quick screen-search', () => {
40
- const layout = emitStudioProject(getSampleApp('crm')!.build()).find((f) => f.path.endsWith('+layout.svelte'))!.contents
41
- expect(layout).toContain('sv-app__toolbar')
42
- expect(layout).toContain('sv-app__search-in')
43
- // The search is functional: a derived filter over the nav screens.
44
- expect(layout).toContain('const results = $derived')
45
- expect(layout).toContain('sv-app__avatar')
46
- })
47
-
48
- for (const sample of sampleApps) {
49
- describe(sample.id, () => {
50
- const project = sample.build()
51
-
52
- it('is a valid project (no errors)', () => {
53
- const errors = validateProject(project).filter((i) => i.level === 'error')
54
- expect(errors, JSON.stringify(errors)).toHaveLength(0)
55
- })
56
-
57
- it('has entities, screens and curated seed', () => {
58
- expect(project.entities.length).toBeGreaterThanOrEqual(2)
59
- expect(project.screens.length).toBeGreaterThanOrEqual(3)
60
- for (const e of project.entities) {
61
- const src = project.dataSources?.[e.name]
62
- expect(src?.kind).toBe('memory')
63
- expect((src as { seed?: unknown[] }).seed?.length).toBeGreaterThan(0)
64
- }
65
- })
66
-
67
- it('round-trips through parse/serialize', () => {
68
- const again = parseProject(serializeProject(project))
69
- expect(again.entities.map((e) => e.name)).toEqual(project.entities.map((e) => e.name))
70
- expect(again.dataSources).toEqual(project.dataSources)
71
- })
72
-
73
- it('generates compiling Svelte pages + seeded data.ts', () => {
74
- const files = emitStudioProject(project)
75
- const data = files.find((f) => f.path.endsWith('src/lib/data.ts'))!
76
- // Curated seed is baked in (a recognizable value from each app).
77
- expect(data.contents).toContain('createInMemoryDataSource')
78
- for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
79
- expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
80
- }
81
- })
82
- })
83
- }
84
-
85
- // Wave 1: these apps get the full editor treatment (rich form controls +
86
- // validation + computed fields). Guards against a regression that flattens a
87
- // sample back to plain text/number fields.
88
- // Every shipped sample now gets the full editor treatment. This guards against
89
- // any app being flattened back to plain text/number fields.
90
- const RICH_EDITOR_TYPES = new Set(['phone', 'country', 'mask', 'slider', 'rating', 'chips', 'color', 'datetime', 'password'])
91
- for (const id of sampleApps.map((s) => s.id)) {
92
- describe(`${id} (rich editors)`, () => {
93
- const project = getSampleApp(id)!.build()
94
- const allFields = project.entities.flatMap((e) => e.fields)
95
-
96
- it('uses several rich form editors', () => {
97
- const used = new Set(allFields.map((f) => f.input?.editorType).filter((t): t is string => !!t && RICH_EDITOR_TYPES.has(t)))
98
- expect(used.size, [...used].join(',')).toBeGreaterThanOrEqual(4)
99
- })
100
-
101
- it('adds real validation + a computed field', () => {
102
- expect(allFields.some((f) => f.required)).toBe(true)
103
- expect(allFields.some((f) => typeof f.min === 'number' || typeof f.max === 'number')).toBe(true)
104
- expect(allFields.some((f) => f.formula || f.computed)).toBe(true)
105
- })
106
-
107
- it('serializes editorType into the generated schema (so the edit panel renders them)', () => {
108
- const files = emitStudioProject(project)
109
- const schema = files.find((f) => f.path.endsWith('src/lib/schemas.ts'))!.contents
110
- expect(schema).toContain('editorType')
111
- // The computed formula compiles into a runtime `computed` assignment.
112
- expect(schema).toMatch(/\.computed = \(row\)/)
113
- })
114
- })
115
- }
116
-
117
- // Flagships reworked into enterprise layouts: they must use the full visual
118
- // palette (KPI cards + gauge + pivot + tabs) and a status-pill grid, not just
119
- // "3 KPIs + bar + pie + grid".
120
- const flatKinds = (p: ReturnType<(typeof sampleApps)[number]['build']>): Set<string> => {
121
- const kinds = new Set<string>()
122
- for (const s of p.screens) for (const b of s.blocks) kinds.add(b.config.kind)
123
- return kinds
124
- }
125
- // EVERY shipped app must now be an enterprise layout, not just the flagships.
126
- for (const id of sampleApps.map((s) => s.id)) {
127
- describe(`${id} (enterprise layout)`, () => {
128
- const project = getSampleApp(id)!.build()
129
- it('uses gauge + pivot + tabs + kpi + chart blocks', () => {
130
- const kinds = flatKinds(project)
131
- for (const k of ['kpi', 'chart', 'gauge', 'pivot', 'tabs']) expect(kinds.has(k), `${id}: missing ${k}`).toBe(true)
132
- })
133
- it('has a KPI card with a sparkline trend or target', () => {
134
- const kpis = project.screens.flatMap((s) => s.blocks).filter((b) => b.config.kind === 'kpi')
135
- expect(kpis.some((b) => (b.config as { trendField?: string }).trendField || (b.config as { target?: number }).target != null)).toBe(true)
136
- })
137
- it('has a grid with conditional-format status pills', () => {
138
- const grids = project.screens.flatMap((s) => s.blocks).filter((b) => b.config.kind === 'grid')
139
- expect(grids.some((b) => ((b.config as { formatRules?: unknown[] }).formatRules?.length ?? 0) > 0)).toBe(true)
140
- })
141
- it('generated app imports SvGauge, SvPivotDesigner, SvTabs + an enhanced KPI card', () => {
142
- const all = emitStudioProject(project).map((f) => f.contents).join('\n')
143
- for (const c of ['SvGauge', 'SvPivotDesigner', 'SvTabs']) expect(all.includes(c), `${id}: missing ${c}`).toBe(true)
144
- // A sparkline (trendField) or a "% of target" delta chip - either enhances the card.
145
- expect(all.includes('kpi__spark') || all.includes('kpi__delta'), `${id}: no enhanced KPI card`).toBe(true)
146
- })
147
- })
148
- }
149
-
150
- describe('signature views', () => {
151
- it('CRM Deal 360 emits SvRecordDetail with a resolved relation subtitle + a related timeline', () => {
152
- const project = getSampleApp('crm')!.build()
153
- const detail = project.screens.flatMap((s) => s.blocks).find((b) => b.config.kind === 'detail')
154
- expect(detail, 'crm: no detail block').toBeTruthy()
155
- const all = emitStudioProject(project).map((f) => f.contents).join('\n')
156
- expect(all.includes('SvRecordDetail')).toBe(true)
157
- const tag = all.match(/<SvRecordDetail[\s\S]*?\/>/)![0]
158
- // The companyId relation subtitle resolves to the denormalized display column.
159
- expect(tag).toContain('subtitleField="company"')
160
- // The Activities related tab loads the child table and filters by the FK.
161
- expect(tag).toContain('related={[')
162
- expect(tag).toContain('foreignKey: "dealId"')
163
- expect(all).toContain('load_md_activities_rows')
164
- })
165
-
166
- it('EVERY app has a detail page that emits SvRecordDetail reading ?id from the URL', () => {
167
- for (const s of sampleApps) {
168
- const project = s.build()
169
- const detail = project.screens.flatMap((sc) => sc.blocks).find((b) => b.config.kind === 'detail')
170
- expect(detail, `${s.id}: no detail block`).toBeTruthy()
171
- const all = emitStudioProject(project).map((f) => f.contents).join('\n')
172
- expect(all.includes('SvRecordDetail'), `${s.id}: no SvRecordDetail`).toBe(true)
173
- expect(all.includes("selectedId={$page.url.searchParams.get('id')"), `${s.id}: detail not URL-addressable`).toBe(true)
174
- }
175
- })
176
-
177
- it('EVERY app connects a view to its detail page (grid rowLink / board / calendar / master-detail drill)', () => {
178
- for (const s of sampleApps) {
179
- const detailIds = new Set(s.build().screens.filter((sc) => sc.blocks.some((b) => b.config.kind === 'detail')).map((sc) => sc.id))
180
- const drills = s.build().screens.flatMap((sc) => sc.blocks).some((b) =>
181
- (b.config.kind === 'grid' && b.config.rowLink && detailIds.has(b.config.rowLink.screen)) ||
182
- ((b.config.kind === 'board' || b.config.kind === 'calendar') && b.config.openScreen != null && detailIds.has(b.config.openScreen)) ||
183
- (b.config.kind === 'master-detail' && b.config.linkScreen != null && detailIds.has(b.config.linkScreen)))
184
- expect(drills, `${s.id}: nothing drills into its detail page`).toBe(true)
185
- // And the generated app actually navigates there.
186
- const all = emitStudioProject(s.build()).map((f) => f.contents).join('\n')
187
- expect(all.includes("import { goto }"), `${s.id}: no goto for drill-through`).toBe(true)
188
- }
189
- })
190
-
191
- it('child relation title fields on detail timelines resolve to display columns', () => {
192
- // clinic appointments titled by doctorId -> doctor; fleet trips by driverId -> driver.
193
- for (const [id, title] of Object.entries({ clinic: 'doctor', fleet: 'driver' })) {
194
- const all = emitStudioProject(getSampleApp(id)!.build()).map((f) => f.contents).join('\n')
195
- const rel = all.match(/related=\{\[[\s\S]*?\]\}/)![0]
196
- expect(rel, `${id}: child title not resolved`).toContain(`titleField: "${title}"`)
197
- }
198
- })
199
-
200
- it('scheduler apps resolve relation title + resource fields to their display columns', () => {
201
- // The scheduler grid view carries titleField + resourceField in the `scheduler={{ ... }}`
202
- // config; relation FKs resolve to their display columns (classId -> class, etc.).
203
- const cases: Record<string, { title: string; resource?: string }> = {
204
- clinic: { title: 'patient', resource: 'doctor' },
205
- gym: { title: 'member', resource: 'class' },
206
- restaurant: { title: 'name', resource: 'table' }, // title is a plain text field
207
- fleet: { title: 'driver', resource: 'vehicle' },
208
- }
209
- for (const [id, { title, resource }] of Object.entries(cases)) {
210
- const all = emitStudioProject(getSampleApp(id)!.build()).map((f) => f.contents).join('\n')
211
- const tag = all.match(/scheduler=\{\{[\s\S]*?\}\}/)![0]
212
- expect(tag, `${id}: schedule title not resolved`).toContain(`titleField: '${title}'`)
213
- if (resource) expect(tag, `${id}: resource not resolved`).toContain(`resourceField: '${resource}'`)
214
- }
215
- })
216
- })
217
- })
218
-
219
- describe('starter project (fresh `svgrid-studio designer` session)', () => {
220
- const project = starterProject()
221
-
222
- it('is a valid, multi-page CRM (dashboard + CRUD + board + calendar + forecast) with seeded rows', () => {
223
- expect(validateProject(project).filter((i) => i.level === 'error')).toHaveLength(0)
224
- expect(project.entities.map((e) => e.name)).toEqual(['customers', 'deals'])
225
- // Several pages, so the first-run app is a real app, not a lone table.
226
- const ids = project.screens.map((s) => s.id)
227
- expect(ids).toEqual(['overview', 'customers', 'pipeline', 'deals', 'calendar', 'forecast'])
228
- // A range of block kinds across the app (the "wow" surface).
229
- const kinds = new Set(project.screens.flatMap((s) => s.blocks.map((b) => b.config.kind)))
230
- for (const k of ['kpi', 'chart', 'pivot', 'grid', 'board', 'calendar']) expect(kinds.has(k as never), `missing ${k}`).toBe(true)
231
- for (const e of ['customers', 'deals']) {
232
- const seed = (project.dataSources?.[e] as { seed?: unknown[] } | undefined)?.seed
233
- expect(seed?.length ?? 0, `${e} seed`).toBeGreaterThanOrEqual(10)
234
- }
235
- })
236
-
237
- it('the Customers page turns on the enterprise-grade grid surface (filter + export + pills + row actions)', () => {
238
- const screen = project.screens.find((s) => s.id === 'customers')!
239
- const grid = screen.blocks.find((b) => b.config.kind === 'grid')!.config as Record<string, unknown>
240
- expect(screen.blocks.some((b) => b.config.kind === 'filter')).toBe(true)
241
- expect(grid.filterable).toBe(true)
242
- expect(grid.export).toEqual({ csv: true, json: true, copy: true })
243
- expect(grid.rowSummaries).toBe(true)
244
- expect((grid.rowActions as unknown[])?.length).toBeGreaterThan(0)
245
- // status / industry / plan render as colored chips (editorType 'list' on the enum fields).
246
- const cust = project.entities.find((e) => e.name === 'customers')!
247
- for (const f of ['status', 'industry', 'plan']) {
248
- expect(cust.fields.find((x) => x.field === f)?.input?.editorType, `${f} chip`).toBe('list')
249
- }
250
- })
251
-
252
- it('generates enterprise-wired, compiling code with a visible export toolbar', () => {
253
- const files = emitStudioProject(project)
254
- const customersPage = files.find((f) => f.path.endsWith('customers/+page.svelte'))!.contents
255
- expect(customersPage).toContain('@svgrid/enterprise')
256
- expect(customersPage).toContain('exportCsv') // export toolbar wired to the grid api
257
- // Every generated screen compiles (dashboard, board, calendar, forecast, ...).
258
- for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
259
- expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
260
- }
261
- })
262
-
263
- it('round-trips through parse/serialize', () => {
264
- const again = parseProject(serializeProject(project))
265
- expect(again.entities.map((e) => e.name)).toEqual(project.entities.map((e) => e.name))
266
- expect(again.dataSources).toEqual(project.dataSources)
267
- })
268
- })
1
+ import { describe, it, expect } from 'vitest'
2
+ import { compile } from 'svelte/compiler'
3
+ import { sampleApps, getSampleApp } from './index.js'
4
+ import { starterProject } from './starter.js'
5
+ import { validateProject, parseProject, serializeProject } from '../project.js'
6
+ import { emitStudioProject } from '../emit-project.js'
7
+
8
+ describe('sample apps', () => {
9
+ it('ships a non-empty gallery with unique ids', () => {
10
+ expect(sampleApps.length).toBeGreaterThanOrEqual(4)
11
+ const ids = sampleApps.map((s) => s.id)
12
+ expect(new Set(ids).size).toBe(ids.length)
13
+ for (const s of sampleApps) {
14
+ expect(s.name).toBeTruthy()
15
+ expect(s.description).toBeTruthy()
16
+ expect(s.emoji).toBeTruthy()
17
+ expect(s.accent).toMatch(/^#[0-9a-f]{6}$/i)
18
+ }
19
+ })
20
+
21
+ it('getSampleApp resolves by id', () => {
22
+ expect(getSampleApp('crm')?.name).toBe('CRM')
23
+ expect(getSampleApp('nope')).toBeUndefined()
24
+ })
25
+
26
+ it('every app has a distinct visual identity (unique design-system preset)', () => {
27
+ const presets = sampleApps.map((s) => s.build().theme?.preset)
28
+ expect(presets.every(Boolean), 'an app is missing a theme preset').toBe(true)
29
+ // Distinct presets => each app reads as its own product, not a recolored sibling.
30
+ expect(new Set(presets).size, 'preset ids are not unique across apps').toBe(sampleApps.length)
31
+ })
32
+
33
+ it('ships a mix of dark-mode and top-nav apps for visual variety', () => {
34
+ const themes = sampleApps.map((s) => s.build().theme)
35
+ expect(themes.filter((t) => t?.mode === 'dark').length).toBeGreaterThanOrEqual(3)
36
+ expect(themes.filter((t) => t?.shell?.style === 'top-nav').length).toBeGreaterThanOrEqual(3)
37
+ })
38
+
39
+ it('generated apps ship an app-chrome toolbar with a working quick screen-search', () => {
40
+ const layout = emitStudioProject(getSampleApp('crm')!.build()).find((f) => f.path.endsWith('+layout.svelte'))!.contents
41
+ expect(layout).toContain('sv-app__toolbar')
42
+ expect(layout).toContain('sv-app__search-in')
43
+ // The search is functional: a derived filter over the nav screens.
44
+ expect(layout).toContain('const results = $derived')
45
+ expect(layout).toContain('sv-app__avatar')
46
+ })
47
+
48
+ for (const sample of sampleApps) {
49
+ describe(sample.id, () => {
50
+ const project = sample.build()
51
+
52
+ it('is a valid project (no errors)', () => {
53
+ const errors = validateProject(project).filter((i) => i.level === 'error')
54
+ expect(errors, JSON.stringify(errors)).toHaveLength(0)
55
+ })
56
+
57
+ it('has entities, screens and curated seed', () => {
58
+ expect(project.entities.length).toBeGreaterThanOrEqual(2)
59
+ expect(project.screens.length).toBeGreaterThanOrEqual(3)
60
+ for (const e of project.entities) {
61
+ const src = project.dataSources?.[e.name]
62
+ expect(src?.kind).toBe('memory')
63
+ expect((src as { seed?: unknown[] }).seed?.length).toBeGreaterThan(0)
64
+ }
65
+ })
66
+
67
+ it('round-trips through parse/serialize', () => {
68
+ const again = parseProject(serializeProject(project))
69
+ expect(again.entities.map((e) => e.name)).toEqual(project.entities.map((e) => e.name))
70
+ expect(again.dataSources).toEqual(project.dataSources)
71
+ })
72
+
73
+ it('generates compiling Svelte pages + seeded data.ts', () => {
74
+ const files = emitStudioProject(project)
75
+ const data = files.find((f) => f.path.endsWith('src/lib/data.ts'))!
76
+ // Curated seed is baked in (a recognizable value from each app).
77
+ expect(data.contents).toContain('createInMemoryDataSource')
78
+ for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
79
+ expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
80
+ }
81
+ })
82
+ })
83
+ }
84
+
85
+ // Wave 1: these apps get the full editor treatment (rich form controls +
86
+ // validation + computed fields). Guards against a regression that flattens a
87
+ // sample back to plain text/number fields.
88
+ // Every shipped sample now gets the full editor treatment. This guards against
89
+ // any app being flattened back to plain text/number fields.
90
+ const RICH_EDITOR_TYPES = new Set(['phone', 'country', 'mask', 'slider', 'rating', 'chips', 'color', 'datetime', 'password'])
91
+ for (const id of sampleApps.map((s) => s.id)) {
92
+ describe(`${id} (rich editors)`, () => {
93
+ const project = getSampleApp(id)!.build()
94
+ const allFields = project.entities.flatMap((e) => e.fields)
95
+
96
+ it('uses several rich form editors', () => {
97
+ const used = new Set(allFields.map((f) => f.input?.editorType).filter((t): t is string => !!t && RICH_EDITOR_TYPES.has(t)))
98
+ expect(used.size, [...used].join(',')).toBeGreaterThanOrEqual(4)
99
+ })
100
+
101
+ it('adds real validation + a computed field', () => {
102
+ expect(allFields.some((f) => f.required)).toBe(true)
103
+ expect(allFields.some((f) => typeof f.min === 'number' || typeof f.max === 'number')).toBe(true)
104
+ expect(allFields.some((f) => f.formula || f.computed)).toBe(true)
105
+ })
106
+
107
+ it('serializes editorType into the generated schema (so the edit panel renders them)', () => {
108
+ const files = emitStudioProject(project)
109
+ const schema = files.find((f) => f.path.endsWith('src/lib/schemas.ts'))!.contents
110
+ expect(schema).toContain('editorType')
111
+ // The computed formula compiles into a runtime `computed` assignment.
112
+ expect(schema).toMatch(/\.computed = \(row\)/)
113
+ })
114
+ })
115
+ }
116
+
117
+ // Flagships reworked into enterprise layouts: they must use the full visual
118
+ // palette (KPI cards + gauge + pivot + tabs) and a status-pill grid, not just
119
+ // "3 KPIs + bar + pie + grid".
120
+ const flatKinds = (p: ReturnType<(typeof sampleApps)[number]['build']>): Set<string> => {
121
+ const kinds = new Set<string>()
122
+ for (const s of p.screens) for (const b of s.blocks) kinds.add(b.config.kind)
123
+ return kinds
124
+ }
125
+ // EVERY shipped app must now be an enterprise layout, not just the flagships.
126
+ for (const id of sampleApps.map((s) => s.id)) {
127
+ describe(`${id} (enterprise layout)`, () => {
128
+ const project = getSampleApp(id)!.build()
129
+ it('uses gauge + pivot + tabs + kpi + chart blocks', () => {
130
+ const kinds = flatKinds(project)
131
+ for (const k of ['kpi', 'chart', 'gauge', 'pivot', 'tabs']) expect(kinds.has(k), `${id}: missing ${k}`).toBe(true)
132
+ })
133
+ it('has a KPI card with a sparkline trend or target', () => {
134
+ const kpis = project.screens.flatMap((s) => s.blocks).filter((b) => b.config.kind === 'kpi')
135
+ expect(kpis.some((b) => (b.config as { trendField?: string }).trendField || (b.config as { target?: number }).target != null)).toBe(true)
136
+ })
137
+ it('has a grid with conditional-format status pills', () => {
138
+ const grids = project.screens.flatMap((s) => s.blocks).filter((b) => b.config.kind === 'grid')
139
+ expect(grids.some((b) => ((b.config as { formatRules?: unknown[] }).formatRules?.length ?? 0) > 0)).toBe(true)
140
+ })
141
+ it('generated app imports SvGauge, SvPivotDesigner, SvTabs + an enhanced KPI card', () => {
142
+ const all = emitStudioProject(project).map((f) => f.contents).join('\n')
143
+ for (const c of ['SvGauge', 'SvPivotDesigner', 'SvTabs']) expect(all.includes(c), `${id}: missing ${c}`).toBe(true)
144
+ // A sparkline (trendField) or a "% of target" delta chip - either enhances the card.
145
+ expect(all.includes('kpi__spark') || all.includes('kpi__delta'), `${id}: no enhanced KPI card`).toBe(true)
146
+ })
147
+ })
148
+ }
149
+
150
+ describe('signature views', () => {
151
+ it('CRM Deal 360 emits SvRecordDetail with a resolved relation subtitle + a related timeline', () => {
152
+ const project = getSampleApp('crm')!.build()
153
+ const detail = project.screens.flatMap((s) => s.blocks).find((b) => b.config.kind === 'detail')
154
+ expect(detail, 'crm: no detail block').toBeTruthy()
155
+ const all = emitStudioProject(project).map((f) => f.contents).join('\n')
156
+ expect(all.includes('SvRecordDetail')).toBe(true)
157
+ const tag = all.match(/<SvRecordDetail[\s\S]*?\/>/)![0]
158
+ // The companyId relation subtitle resolves to the denormalized display column.
159
+ expect(tag).toContain('subtitleField="company"')
160
+ // The Activities related tab loads the child table and filters by the FK.
161
+ expect(tag).toContain('related={[')
162
+ expect(tag).toContain('foreignKey: "dealId"')
163
+ expect(all).toContain('load_md_activities_rows')
164
+ })
165
+
166
+ it('EVERY app has a detail page that emits SvRecordDetail reading ?id from the URL', () => {
167
+ for (const s of sampleApps) {
168
+ const project = s.build()
169
+ const detail = project.screens.flatMap((sc) => sc.blocks).find((b) => b.config.kind === 'detail')
170
+ expect(detail, `${s.id}: no detail block`).toBeTruthy()
171
+ const all = emitStudioProject(project).map((f) => f.contents).join('\n')
172
+ expect(all.includes('SvRecordDetail'), `${s.id}: no SvRecordDetail`).toBe(true)
173
+ expect(all.includes("selectedId={page.url.searchParams.get('id')"), `${s.id}: detail not URL-addressable`).toBe(true)
174
+ }
175
+ })
176
+
177
+ it('EVERY app connects a view to its detail page (grid rowLink / board / calendar / master-detail drill)', () => {
178
+ for (const s of sampleApps) {
179
+ const detailIds = new Set(s.build().screens.filter((sc) => sc.blocks.some((b) => b.config.kind === 'detail')).map((sc) => sc.id))
180
+ const drills = s.build().screens.flatMap((sc) => sc.blocks).some((b) =>
181
+ (b.config.kind === 'grid' && b.config.rowLink && detailIds.has(b.config.rowLink.screen)) ||
182
+ ((b.config.kind === 'board' || b.config.kind === 'calendar') && b.config.openScreen != null && detailIds.has(b.config.openScreen)) ||
183
+ (b.config.kind === 'master-detail' && b.config.linkScreen != null && detailIds.has(b.config.linkScreen)))
184
+ expect(drills, `${s.id}: nothing drills into its detail page`).toBe(true)
185
+ // And the generated app actually navigates there.
186
+ const all = emitStudioProject(s.build()).map((f) => f.contents).join('\n')
187
+ expect(all.includes("import { goto }"), `${s.id}: no goto for drill-through`).toBe(true)
188
+ }
189
+ })
190
+
191
+ it('child relation title fields on detail timelines resolve to display columns', () => {
192
+ // clinic appointments titled by doctorId -> doctor; fleet trips by driverId -> driver.
193
+ for (const [id, title] of Object.entries({ clinic: 'doctor', fleet: 'driver' })) {
194
+ const all = emitStudioProject(getSampleApp(id)!.build()).map((f) => f.contents).join('\n')
195
+ const rel = all.match(/related=\{\[[\s\S]*?\]\}/)![0]
196
+ expect(rel, `${id}: child title not resolved`).toContain(`titleField: "${title}"`)
197
+ }
198
+ })
199
+
200
+ it('scheduler apps resolve relation title + resource fields to their display columns', () => {
201
+ // The scheduler grid view carries titleField + resourceField in the `scheduler={{ ... }}`
202
+ // config; relation FKs resolve to their display columns (classId -> class, etc.).
203
+ const cases: Record<string, { title: string; resource?: string }> = {
204
+ clinic: { title: 'patient', resource: 'doctor' },
205
+ gym: { title: 'member', resource: 'class' },
206
+ restaurant: { title: 'name', resource: 'table' }, // title is a plain text field
207
+ fleet: { title: 'driver', resource: 'vehicle' },
208
+ }
209
+ for (const [id, { title, resource }] of Object.entries(cases)) {
210
+ const all = emitStudioProject(getSampleApp(id)!.build()).map((f) => f.contents).join('\n')
211
+ const tag = all.match(/scheduler=\{\{[\s\S]*?\}\}/)![0]
212
+ expect(tag, `${id}: schedule title not resolved`).toContain(`titleField: '${title}'`)
213
+ if (resource) expect(tag, `${id}: resource not resolved`).toContain(`resourceField: '${resource}'`)
214
+ }
215
+ })
216
+ })
217
+ })
218
+
219
+ describe('starter project (fresh `svgrid-studio designer` session)', () => {
220
+ const project = starterProject()
221
+
222
+ it('is a valid, multi-page CRM (dashboard + CRUD + board + calendar + forecast) with seeded rows', () => {
223
+ expect(validateProject(project).filter((i) => i.level === 'error')).toHaveLength(0)
224
+ expect(project.entities.map((e) => e.name)).toEqual(['customers', 'deals'])
225
+ // Several pages, so the first-run app is a real app, not a lone table.
226
+ const ids = project.screens.map((s) => s.id)
227
+ expect(ids).toEqual(['overview', 'customers', 'pipeline', 'deals', 'calendar', 'forecast'])
228
+ // A range of block kinds across the app (the "wow" surface).
229
+ const kinds = new Set(project.screens.flatMap((s) => s.blocks.map((b) => b.config.kind)))
230
+ for (const k of ['kpi', 'chart', 'pivot', 'grid', 'board', 'calendar']) expect(kinds.has(k as never), `missing ${k}`).toBe(true)
231
+ for (const e of ['customers', 'deals']) {
232
+ const seed = (project.dataSources?.[e] as { seed?: unknown[] } | undefined)?.seed
233
+ expect(seed?.length ?? 0, `${e} seed`).toBeGreaterThanOrEqual(10)
234
+ }
235
+ })
236
+
237
+ it('the Customers page turns on the enterprise-grade grid surface (filter + export + pills + row actions)', () => {
238
+ const screen = project.screens.find((s) => s.id === 'customers')!
239
+ const grid = screen.blocks.find((b) => b.config.kind === 'grid')!.config as Record<string, unknown>
240
+ expect(screen.blocks.some((b) => b.config.kind === 'filter')).toBe(true)
241
+ expect(grid.filterable).toBe(true)
242
+ expect(grid.export).toEqual({ csv: true, json: true, copy: true })
243
+ expect(grid.rowSummaries).toBe(true)
244
+ expect((grid.rowActions as unknown[])?.length).toBeGreaterThan(0)
245
+ // status / industry / plan render as colored chips (editorType 'list' on the enum fields).
246
+ const cust = project.entities.find((e) => e.name === 'customers')!
247
+ for (const f of ['status', 'industry', 'plan']) {
248
+ expect(cust.fields.find((x) => x.field === f)?.input?.editorType, `${f} chip`).toBe('list')
249
+ }
250
+ })
251
+
252
+ it('generates enterprise-wired, compiling code with a visible export toolbar', () => {
253
+ const files = emitStudioProject(project)
254
+ const customersPage = files.find((f) => f.path.endsWith('customers/+page.svelte'))!.contents
255
+ expect(customersPage).toContain('@svgrid/enterprise')
256
+ expect(customersPage).toContain('exportCsv') // export toolbar wired to the grid api
257
+ // Every generated screen compiles (dashboard, board, calendar, forecast, ...).
258
+ for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
259
+ expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
260
+ }
261
+ })
262
+
263
+ it('round-trips through parse/serialize', () => {
264
+ const again = parseProject(serializeProject(project))
265
+ expect(again.entities.map((e) => e.name)).toEqual(project.entities.map((e) => e.name))
266
+ expect(again.dataSources).toEqual(project.dataSources)
267
+ })
268
+ })