@svgrid/enterprise 1.2.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cdn/svgrid-enterprise.svelte-external.js +26039 -805
- package/dist/designer/assets/index-Dp44bTid.js +939 -0
- package/dist/designer/assets/index-RJp6x8tw.css +1 -0
- package/dist/designer/assets/jszip.min-CjMo-QGg.js +2 -0
- package/dist/designer/index.html +13 -0
- package/dist/node/studio.js +22554 -0
- package/package.json +10 -3
- package/src/SvAuthGate.svelte +115 -0
- package/src/SvBoard.dom.test.ts +67 -0
- package/src/SvBoard.svelte +192 -0
- package/src/SvExportMenu.svelte +553 -0
- package/src/SvFileInput.svelte +113 -0
- package/src/SvGridEditPanel.dom.test.ts +146 -0
- package/src/SvGridEditPanel.svelte +793 -0
- package/src/SvGridMasterDetail.svelte +95 -0
- package/src/SvImportDialog.svelte +1020 -0
- package/src/SvLookupInput.svelte +180 -0
- package/src/SvRecordDetail.dom.test.ts +137 -0
- package/src/SvRecordDetail.svelte +288 -0
- package/src/SvSchedule.dom.test.ts +57 -0
- package/src/SvSchedule.svelte +156 -0
- package/src/SvSchemaChart.svelte +233 -0
- package/src/SvSchemaDashboard.svelte +130 -0
- package/src/ai-export-pdf.test.ts +74 -0
- package/src/ai-export-xlsx.test.ts +87 -0
- package/src/ai-export.test.ts +110 -0
- package/src/ai.ts +1188 -782
- package/src/edit-panel.test.ts +213 -0
- package/src/edit-panel.ts +228 -0
- package/src/export-conditional.test.ts +60 -0
- package/src/export-conditional.ts +94 -0
- package/src/export-ooxml.test.ts +152 -0
- package/src/export-ooxml.ts +485 -0
- package/src/export-pdf.test.ts +138 -0
- package/src/export-pdf.ts +245 -0
- package/src/export-print.test.ts +66 -0
- package/src/export-print.ts +132 -0
- package/src/export-serialize.test.ts +56 -0
- package/src/export-serialize.ts +24 -0
- package/src/export-xls.ts +196 -0
- package/src/export-xlsx-roundtrip.test.ts +120 -0
- package/src/export-xlsx.test.ts +150 -0
- package/src/export.test.ts +349 -0
- package/src/export.ts +1452 -549
- package/src/import-automap.test.ts +131 -0
- package/src/import-hardening.test.ts +158 -0
- package/src/import.ts +1042 -648
- package/src/index.ts +282 -0
- package/src/install.ts +134 -114
- package/src/license-core.test.ts +23 -0
- package/src/license-core.ts +35 -0
- package/src/license.ts +103 -90
- package/src/master-detail.test.ts +61 -0
- package/src/master-detail.ts +42 -0
- package/src/print.ts +107 -127
- package/src/schema-designer.test.ts +121 -0
- package/src/schema-designer.ts +142 -0
- package/src/schema.test.ts +268 -0
- package/src/schema.ts +499 -0
- package/src/smart-shim.ts +107 -105
- package/src/sources/aggregate.test.ts +79 -0
- package/src/sources/aggregate.ts +102 -0
- package/src/sources/auth-supabase.test.ts +80 -0
- package/src/sources/auth-supabase.ts +87 -0
- package/src/sources/dashboard.kpi.test.ts +50 -0
- package/src/sources/dashboard.test.ts +61 -0
- package/src/sources/dashboard.ts +136 -0
- package/src/sources/field-inference.test.ts +60 -0
- package/src/sources/field-inference.ts +63 -0
- package/src/sources/filters.test.ts +58 -0
- package/src/sources/filters.ts +64 -0
- package/src/sources/index.ts +67 -0
- package/src/sources/introspect-supabase.test.ts +100 -0
- package/src/sources/introspect-supabase.ts +119 -0
- package/src/sources/realtime-supabase.test.ts +93 -0
- package/src/sources/realtime-supabase.ts +99 -0
- package/src/sources/relation-lookup.test.ts +99 -0
- package/src/sources/relation-lookup.ts +128 -0
- package/src/sources/rest-adapters.test.ts +95 -0
- package/src/sources/rest-adapters.ts +120 -0
- package/src/sources/rest.test.ts +104 -0
- package/src/sources/rest.ts +129 -0
- package/src/sources/schema-from-columns.test.ts +106 -0
- package/src/sources/schema-from-columns.ts +88 -0
- package/src/sources/supabase.test.ts +110 -0
- package/src/sources/supabase.ts +99 -0
- package/src/sources/with-entity-rules.test.ts +104 -0
- package/src/sources/with-entity-rules.ts +78 -0
- package/src/sources/with-relation-labels.test.ts +75 -0
- package/src/sources/with-relation-labels.ts +73 -0
- package/src/studio/bug-report.test.ts +101 -0
- package/src/studio/bug-report.ts +165 -0
- package/src/studio/cli.test.ts +187 -0
- package/src/studio/cli.ts +112 -0
- package/src/studio/csv.test.ts +90 -0
- package/src/studio/csv.ts +165 -0
- package/src/studio/db-connect-string.test.ts +94 -0
- package/src/studio/db-connect-string.ts +128 -0
- package/src/studio/emit-project.test.ts +923 -0
- package/src/studio/emit-project.ts +1273 -0
- package/src/studio/emit-schema.test.ts +94 -0
- package/src/studio/emit-schema.ts +920 -0
- package/src/studio/index.ts +195 -0
- package/src/studio/introspect-db.test.ts +186 -0
- package/src/studio/introspect-db.ts +312 -0
- package/src/studio/introspect-prisma.test.ts +99 -0
- package/src/studio/introspect-prisma.ts +175 -0
- package/src/studio/introspect.test.ts +172 -0
- package/src/studio/introspect.ts +310 -0
- package/src/studio/pipeline.test.ts +42 -0
- package/src/studio/project-robust.test.ts +157 -0
- package/src/studio/project.test.ts +473 -0
- package/src/studio/project.ts +916 -0
- package/src/studio/sample-data.test.ts +58 -0
- package/src/studio/sample-data.ts +200 -0
- package/src/studio/samples/ats.ts +202 -0
- package/src/studio/samples/clinic.ts +177 -0
- package/src/studio/samples/crm.ts +250 -0
- package/src/studio/samples/ecommerce.ts +187 -0
- package/src/studio/samples/events.ts +199 -0
- package/src/studio/samples/fleet.ts +184 -0
- package/src/studio/samples/gym.ts +213 -0
- package/src/studio/samples/hr.ts +193 -0
- package/src/studio/samples/index.ts +55 -0
- package/src/studio/samples/insurance.ts +195 -0
- package/src/studio/samples/inventory.ts +182 -0
- package/src/studio/samples/invoicing.ts +162 -0
- package/src/studio/samples/library.ts +180 -0
- package/src/studio/samples/live-data.test.ts +98 -0
- package/src/studio/samples/live-data.ts +308 -0
- package/src/studio/samples/projects.ts +190 -0
- package/src/studio/samples/realestate.ts +196 -0
- package/src/studio/samples/restaurant.ts +187 -0
- package/src/studio/samples/samples.test.ts +208 -0
- package/src/studio/samples/school.ts +197 -0
- package/src/studio/samples/seed-floor.test.ts +25 -0
- package/src/studio/samples/shared.ts +305 -0
- package/src/studio/samples/subscriptions.ts +183 -0
- package/src/studio/samples/support.ts +182 -0
- package/src/studio/scaffold-app.test.ts +91 -0
- package/src/studio/scaffold-app.ts +161 -0
- package/src/studio/scaffold.test.ts +178 -0
- package/src/studio/scaffold.ts +374 -0
- package/src/studio/themes.ts +172 -0
- package/src/studio/verify.test.ts +47 -0
- package/src/studio/verify.ts +79 -0
- package/src/sveltekit/in-memory.test.ts +104 -0
- package/src/sveltekit/in-memory.ts +129 -0
- package/src/sveltekit/index.ts +23 -0
- package/src/sveltekit/query-plan.test.ts +109 -0
- package/src/sveltekit/query-plan.ts +125 -0
- package/src/sveltekit/sql-source.test.ts +185 -0
- package/src/sveltekit/sql-source.ts +166 -0
- package/src/sveltekit/sql.test.ts +80 -0
- package/src/sveltekit/sql.ts +131 -0
- package/src/sveltekit/transport.test.ts +175 -0
- package/src/sveltekit/transport.ts +254 -0
- package/src/sveltekit/types.ts +11 -0
- package/src/upgrade-prompt.ts +149 -148
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { compile } from 'svelte/compiler'
|
|
3
|
+
import { sampleApps, getSampleApp } from './index.js'
|
|
4
|
+
import { validateProject, parseProject, serializeProject } from '../project.js'
|
|
5
|
+
import { emitStudioProject } from '../emit-project.js'
|
|
6
|
+
|
|
7
|
+
describe('sample apps', () => {
|
|
8
|
+
it('ships a non-empty gallery with unique ids', () => {
|
|
9
|
+
expect(sampleApps.length).toBeGreaterThanOrEqual(4)
|
|
10
|
+
const ids = sampleApps.map((s) => s.id)
|
|
11
|
+
expect(new Set(ids).size).toBe(ids.length)
|
|
12
|
+
for (const s of sampleApps) {
|
|
13
|
+
expect(s.name).toBeTruthy()
|
|
14
|
+
expect(s.description).toBeTruthy()
|
|
15
|
+
expect(s.emoji).toBeTruthy()
|
|
16
|
+
expect(s.accent).toMatch(/^#[0-9a-f]{6}$/i)
|
|
17
|
+
}
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('getSampleApp resolves by id', () => {
|
|
21
|
+
expect(getSampleApp('crm')?.name).toBe('CRM')
|
|
22
|
+
expect(getSampleApp('nope')).toBeUndefined()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('every app has a distinct visual identity (unique design-system preset)', () => {
|
|
26
|
+
const presets = sampleApps.map((s) => s.build().theme?.preset)
|
|
27
|
+
expect(presets.every(Boolean), 'an app is missing a theme preset').toBe(true)
|
|
28
|
+
// Distinct presets => each app reads as its own product, not a recolored sibling.
|
|
29
|
+
expect(new Set(presets).size, 'preset ids are not unique across apps').toBe(sampleApps.length)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('ships a mix of dark-mode and top-nav apps for visual variety', () => {
|
|
33
|
+
const themes = sampleApps.map((s) => s.build().theme)
|
|
34
|
+
expect(themes.filter((t) => t?.mode === 'dark').length).toBeGreaterThanOrEqual(3)
|
|
35
|
+
expect(themes.filter((t) => t?.shell?.style === 'top-nav').length).toBeGreaterThanOrEqual(3)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('generated apps ship an app-chrome toolbar with a working quick screen-search', () => {
|
|
39
|
+
const layout = emitStudioProject(getSampleApp('crm')!.build()).find((f) => f.path.endsWith('+layout.svelte'))!.contents
|
|
40
|
+
expect(layout).toContain('sv-app__toolbar')
|
|
41
|
+
expect(layout).toContain('sv-app__search-in')
|
|
42
|
+
// The search is functional: a derived filter over the nav screens.
|
|
43
|
+
expect(layout).toContain('const results = $derived')
|
|
44
|
+
expect(layout).toContain('sv-app__avatar')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
for (const sample of sampleApps) {
|
|
48
|
+
describe(sample.id, () => {
|
|
49
|
+
const project = sample.build()
|
|
50
|
+
|
|
51
|
+
it('is a valid project (no errors)', () => {
|
|
52
|
+
const errors = validateProject(project).filter((i) => i.level === 'error')
|
|
53
|
+
expect(errors, JSON.stringify(errors)).toHaveLength(0)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('has entities, screens and curated seed', () => {
|
|
57
|
+
expect(project.entities.length).toBeGreaterThanOrEqual(2)
|
|
58
|
+
expect(project.screens.length).toBeGreaterThanOrEqual(3)
|
|
59
|
+
for (const e of project.entities) {
|
|
60
|
+
const src = project.dataSources?.[e.name]
|
|
61
|
+
expect(src?.kind).toBe('memory')
|
|
62
|
+
expect((src as { seed?: unknown[] }).seed?.length).toBeGreaterThan(0)
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('round-trips through parse/serialize', () => {
|
|
67
|
+
const again = parseProject(serializeProject(project))
|
|
68
|
+
expect(again.entities.map((e) => e.name)).toEqual(project.entities.map((e) => e.name))
|
|
69
|
+
expect(again.dataSources).toEqual(project.dataSources)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('generates compiling Svelte pages + seeded data.ts', () => {
|
|
73
|
+
const files = emitStudioProject(project)
|
|
74
|
+
const data = files.find((f) => f.path.endsWith('src/lib/data.ts'))!
|
|
75
|
+
// Curated seed is baked in (a recognizable value from each app).
|
|
76
|
+
expect(data.contents).toContain('createInMemoryDataSource')
|
|
77
|
+
for (const f of files.filter((f) => f.path.endsWith('.svelte'))) {
|
|
78
|
+
expect(() => compile(f.contents, { filename: f.path, generate: 'client' }), f.path).not.toThrow()
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Wave 1: these apps get the full editor treatment (rich form controls +
|
|
85
|
+
// validation + computed fields). Guards against a regression that flattens a
|
|
86
|
+
// sample back to plain text/number fields.
|
|
87
|
+
// Every shipped sample now gets the full editor treatment. This guards against
|
|
88
|
+
// any app being flattened back to plain text/number fields.
|
|
89
|
+
const RICH_EDITOR_TYPES = new Set(['phone', 'country', 'mask', 'slider', 'rating', 'chips', 'color', 'datetime', 'password'])
|
|
90
|
+
for (const id of sampleApps.map((s) => s.id)) {
|
|
91
|
+
describe(`${id} (rich editors)`, () => {
|
|
92
|
+
const project = getSampleApp(id)!.build()
|
|
93
|
+
const allFields = project.entities.flatMap((e) => e.fields)
|
|
94
|
+
|
|
95
|
+
it('uses several rich form editors', () => {
|
|
96
|
+
const used = new Set(allFields.map((f) => f.input?.editorType).filter((t): t is string => !!t && RICH_EDITOR_TYPES.has(t)))
|
|
97
|
+
expect(used.size, [...used].join(',')).toBeGreaterThanOrEqual(4)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('adds real validation + a computed field', () => {
|
|
101
|
+
expect(allFields.some((f) => f.required)).toBe(true)
|
|
102
|
+
expect(allFields.some((f) => typeof f.min === 'number' || typeof f.max === 'number')).toBe(true)
|
|
103
|
+
expect(allFields.some((f) => f.formula || f.computed)).toBe(true)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('serializes editorType into the generated schema (so the edit panel renders them)', () => {
|
|
107
|
+
const files = emitStudioProject(project)
|
|
108
|
+
const schema = files.find((f) => f.path.endsWith('src/lib/schemas.ts'))!.contents
|
|
109
|
+
expect(schema).toContain('editorType')
|
|
110
|
+
// The computed formula compiles into a runtime `computed` assignment.
|
|
111
|
+
expect(schema).toMatch(/\.computed = \(row\)/)
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Flagships reworked into enterprise layouts: they must use the full visual
|
|
117
|
+
// palette (KPI cards + gauge + pivot + tabs) and a status-pill grid, not just
|
|
118
|
+
// "3 KPIs + bar + pie + grid".
|
|
119
|
+
const flatKinds = (p: ReturnType<(typeof sampleApps)[number]['build']>): Set<string> => {
|
|
120
|
+
const kinds = new Set<string>()
|
|
121
|
+
for (const s of p.screens) for (const b of s.blocks) kinds.add(b.config.kind)
|
|
122
|
+
return kinds
|
|
123
|
+
}
|
|
124
|
+
// EVERY shipped app must now be an enterprise layout, not just the flagships.
|
|
125
|
+
for (const id of sampleApps.map((s) => s.id)) {
|
|
126
|
+
describe(`${id} (enterprise layout)`, () => {
|
|
127
|
+
const project = getSampleApp(id)!.build()
|
|
128
|
+
it('uses gauge + pivot + tabs + kpi + chart blocks', () => {
|
|
129
|
+
const kinds = flatKinds(project)
|
|
130
|
+
for (const k of ['kpi', 'chart', 'gauge', 'pivot', 'tabs']) expect(kinds.has(k), `${id}: missing ${k}`).toBe(true)
|
|
131
|
+
})
|
|
132
|
+
it('has a KPI card with a sparkline trend or target', () => {
|
|
133
|
+
const kpis = project.screens.flatMap((s) => s.blocks).filter((b) => b.config.kind === 'kpi')
|
|
134
|
+
expect(kpis.some((b) => (b.config as { trendField?: string }).trendField || (b.config as { target?: number }).target != null)).toBe(true)
|
|
135
|
+
})
|
|
136
|
+
it('has a grid with conditional-format status pills', () => {
|
|
137
|
+
const grids = project.screens.flatMap((s) => s.blocks).filter((b) => b.config.kind === 'grid')
|
|
138
|
+
expect(grids.some((b) => ((b.config as { formatRules?: unknown[] }).formatRules?.length ?? 0) > 0)).toBe(true)
|
|
139
|
+
})
|
|
140
|
+
it('generated app imports SvGauge, SvPivotDesigner, SvTabs + an enhanced KPI card', () => {
|
|
141
|
+
const all = emitStudioProject(project).map((f) => f.contents).join('\n')
|
|
142
|
+
for (const c of ['SvGauge', 'SvPivotDesigner', 'SvTabs']) expect(all.includes(c), `${id}: missing ${c}`).toBe(true)
|
|
143
|
+
// A sparkline (trendField) or a "% of target" delta chip - either enhances the card.
|
|
144
|
+
expect(all.includes('kpi__spark') || all.includes('kpi__delta'), `${id}: no enhanced KPI card`).toBe(true)
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
describe('signature views', () => {
|
|
150
|
+
it('CRM Deal 360 emits SvRecordDetail with a resolved relation subtitle + a related timeline', () => {
|
|
151
|
+
const project = getSampleApp('crm')!.build()
|
|
152
|
+
const detail = project.screens.flatMap((s) => s.blocks).find((b) => b.config.kind === 'detail')
|
|
153
|
+
expect(detail, 'crm: no detail block').toBeTruthy()
|
|
154
|
+
const all = emitStudioProject(project).map((f) => f.contents).join('\n')
|
|
155
|
+
expect(all.includes('SvRecordDetail')).toBe(true)
|
|
156
|
+
const tag = all.match(/<SvRecordDetail[\s\S]*?\/>/)![0]
|
|
157
|
+
// The companyId relation subtitle resolves to the denormalized display column.
|
|
158
|
+
expect(tag).toContain('subtitleField="company"')
|
|
159
|
+
// The Activities related tab loads the child table and filters by the FK.
|
|
160
|
+
expect(tag).toContain('related={[')
|
|
161
|
+
expect(tag).toContain('foreignKey: "dealId"')
|
|
162
|
+
expect(all).toContain('load_md_activities_rows')
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('EVERY app has a detail page that emits SvRecordDetail reading ?id from the URL', () => {
|
|
166
|
+
for (const s of sampleApps) {
|
|
167
|
+
const project = s.build()
|
|
168
|
+
const detail = project.screens.flatMap((sc) => sc.blocks).find((b) => b.config.kind === 'detail')
|
|
169
|
+
expect(detail, `${s.id}: no detail block`).toBeTruthy()
|
|
170
|
+
const all = emitStudioProject(project).map((f) => f.contents).join('\n')
|
|
171
|
+
expect(all.includes('SvRecordDetail'), `${s.id}: no SvRecordDetail`).toBe(true)
|
|
172
|
+
expect(all.includes("selectedId={$page.url.searchParams.get('id')"), `${s.id}: detail not URL-addressable`).toBe(true)
|
|
173
|
+
}
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('EVERY app connects a view to its detail page (grid rowLink / board / calendar / master-detail drill)', () => {
|
|
177
|
+
for (const s of sampleApps) {
|
|
178
|
+
const detailIds = new Set(s.build().screens.filter((sc) => sc.blocks.some((b) => b.config.kind === 'detail')).map((sc) => sc.id))
|
|
179
|
+
const drills = s.build().screens.flatMap((sc) => sc.blocks).some((b) =>
|
|
180
|
+
(b.config.kind === 'grid' && b.config.rowLink && detailIds.has(b.config.rowLink.screen)) ||
|
|
181
|
+
((b.config.kind === 'board' || b.config.kind === 'calendar') && b.config.openScreen != null && detailIds.has(b.config.openScreen)) ||
|
|
182
|
+
(b.config.kind === 'master-detail' && b.config.linkScreen != null && detailIds.has(b.config.linkScreen)))
|
|
183
|
+
expect(drills, `${s.id}: nothing drills into its detail page`).toBe(true)
|
|
184
|
+
// And the generated app actually navigates there.
|
|
185
|
+
const all = emitStudioProject(s.build()).map((f) => f.contents).join('\n')
|
|
186
|
+
expect(all.includes("import { goto }"), `${s.id}: no goto for drill-through`).toBe(true)
|
|
187
|
+
}
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('child relation title fields on detail timelines resolve to display columns', () => {
|
|
191
|
+
// clinic appointments titled by doctorId -> doctor; fleet trips by driverId -> driver.
|
|
192
|
+
for (const [id, title] of Object.entries({ clinic: 'doctor', fleet: 'driver' })) {
|
|
193
|
+
const all = emitStudioProject(getSampleApp(id)!.build()).map((f) => f.contents).join('\n')
|
|
194
|
+
const rel = all.match(/related=\{\[[\s\S]*?\]\}/)![0]
|
|
195
|
+
expect(rel, `${id}: child title not resolved`).toContain(`titleField: "${title}"`)
|
|
196
|
+
}
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('calendar apps resolve relation title fields to their display columns', () => {
|
|
200
|
+
const cases: Record<string, string> = { clinic: 'patient', gym: 'class', restaurant: 'table', fleet: 'driver' }
|
|
201
|
+
for (const [id, title] of Object.entries(cases)) {
|
|
202
|
+
const all = emitStudioProject(getSampleApp(id)!.build()).map((f) => f.contents).join('\n')
|
|
203
|
+
const tag = all.match(/<SvSchedule[\s\S]*?\/>/)![0]
|
|
204
|
+
expect(tag, `${id}: schedule title not resolved`).toContain(`titleField="${title}"`)
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
})
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import type { EntitySchema } from '../../schema.js'
|
|
2
|
+
import type { FormatRule } from '../project.js'
|
|
3
|
+
import { screen, formScreen, project, dashScreen, detailScreen, statusPills, pad, ids, type SampleApp } from './shared.js'
|
|
4
|
+
|
|
5
|
+
const courses: EntitySchema = {
|
|
6
|
+
name: 'courses',
|
|
7
|
+
label: 'Course',
|
|
8
|
+
idField: 'id',
|
|
9
|
+
fields: [
|
|
10
|
+
{ field: 'id', type: 'text', primaryKey: true, readonly: true },
|
|
11
|
+
{ field: 'name', type: 'text', required: true, minLength: 2 },
|
|
12
|
+
{ field: 'department', type: 'enum', label: 'Department', required: true, options: [
|
|
13
|
+
{ value: 'cs', label: 'Computer Science', color: '#6366f1' },
|
|
14
|
+
{ value: 'math', label: 'Mathematics', color: '#0ea5e9' },
|
|
15
|
+
{ value: 'biology', label: 'Biology', color: '#10b981' },
|
|
16
|
+
{ value: 'arts', label: 'Arts', color: '#ec4899' },
|
|
17
|
+
] },
|
|
18
|
+
{ field: 'credits', type: 'number', label: 'Credits', required: true, min: 0, max: 6, defaultValue: 3, input: { help: 'Credit hours awarded on completion' } },
|
|
19
|
+
{ field: 'capacity', type: 'number', label: 'Capacity', required: true, min: 1, input: { help: 'Maximum seats per term' } },
|
|
20
|
+
{ field: 'fee', type: 'number', label: 'Fee ($)', min: 0, defaultValue: 0, input: { prefix: '$', help: 'Tuition charged per seat' } },
|
|
21
|
+
{ field: 'topics', type: 'text', label: 'Topics', input: { editorType: 'chips', help: 'Syllabus themes, e.g. Workshop / Hands-on' } },
|
|
22
|
+
{ field: 'rating', type: 'number', label: 'Rating', min: 0, max: 5, defaultValue: 0, input: { editorType: 'rating', help: 'Average student rating' } },
|
|
23
|
+
{ field: 'color', type: 'text', label: 'Catalog color', defaultValue: '#6366f1', input: { editorType: 'color', help: 'Swatch shown in the course catalog' } },
|
|
24
|
+
// Derived: projected tuition if every seat is filled (display-only, never stored).
|
|
25
|
+
{ field: 'revenue', type: 'number', label: 'Revenue ($)', readonly: true, formula: 'fee * capacity' },
|
|
26
|
+
{ field: 'active', type: 'boolean', label: 'Active', defaultValue: true },
|
|
27
|
+
],
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const students: EntitySchema = {
|
|
31
|
+
name: 'students',
|
|
32
|
+
label: 'Student',
|
|
33
|
+
idField: 'id',
|
|
34
|
+
fields: [
|
|
35
|
+
{ field: 'id', type: 'text', primaryKey: true, readonly: true },
|
|
36
|
+
{ field: 'name', type: 'text', required: true, minLength: 2 },
|
|
37
|
+
{ field: 'email', type: 'text', format: 'email', required: true },
|
|
38
|
+
{ field: 'phone', type: 'text', label: 'Phone', input: { editorType: 'phone', help: 'Primary contact number' } },
|
|
39
|
+
{ field: 'country', type: 'text', label: 'Country', input: { editorType: 'country', help: 'Country of residence' } },
|
|
40
|
+
{ field: 'sisId', type: 'text', label: 'SIS number', input: { editorType: 'mask', mask: 'SIS-######', help: 'Student information system id' } },
|
|
41
|
+
{ field: 'gpa', type: 'number', label: 'GPA', min: 0, max: 4, defaultValue: 0, input: { step: 0.1, precision: 2, help: 'Grade point average, 0 to 4' } },
|
|
42
|
+
{ field: 'year', type: 'enum', label: 'Year', required: true, options: [
|
|
43
|
+
{ value: 'freshman', label: 'Freshman', color: '#0ea5e9' },
|
|
44
|
+
{ value: 'sophomore', label: 'Sophomore', color: '#6366f1' },
|
|
45
|
+
{ value: 'junior', label: 'Junior', color: '#f59e0b' },
|
|
46
|
+
{ value: 'senior', label: 'Senior', color: '#10b981' },
|
|
47
|
+
] },
|
|
48
|
+
{ field: 'enrolledAt', type: 'datetime', label: 'Enrolled at', input: { help: 'First matriculation date' } },
|
|
49
|
+
{ field: 'active', type: 'boolean', label: 'Active', defaultValue: true },
|
|
50
|
+
],
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const enrollments: EntitySchema = {
|
|
54
|
+
name: 'enrollments',
|
|
55
|
+
label: 'Enrollment',
|
|
56
|
+
idField: 'id',
|
|
57
|
+
fields: [
|
|
58
|
+
{ field: 'id', type: 'text', primaryKey: true, readonly: true },
|
|
59
|
+
{ field: 'studentId', type: 'relation', label: 'Student', relation: { entity: 'students', foreignKey: 'studentId', labelField: 'name' } },
|
|
60
|
+
{ field: 'courseId', type: 'relation', label: 'Course', relation: { entity: 'courses', foreignKey: 'courseId', labelField: 'name' } },
|
|
61
|
+
{ field: 'term', type: 'enum', label: 'Term', required: true, options: [
|
|
62
|
+
{ value: 'fall2025', label: 'Fall 2025', color: '#6366f1' },
|
|
63
|
+
{ value: 'spring2026', label: 'Spring 2026', color: '#10b981' },
|
|
64
|
+
{ value: 'summer2026', label: 'Summer 2026', color: '#f59e0b' },
|
|
65
|
+
] },
|
|
66
|
+
{ field: 'status', type: 'enum', label: 'Status', required: true, options: [
|
|
67
|
+
{ value: 'enrolled', label: 'Enrolled', color: '#6366f1' },
|
|
68
|
+
{ value: 'completed', label: 'Completed', color: '#10b981' },
|
|
69
|
+
{ value: 'dropped', label: 'Dropped', color: '#ef4444' },
|
|
70
|
+
] },
|
|
71
|
+
{ field: 'grade', type: 'number', label: 'Grade', min: 0, max: 100, defaultValue: 0, input: { help: 'Final score, 0 to 100' } },
|
|
72
|
+
{ field: 'attendance', type: 'number', label: 'Attendance (%)', min: 0, max: 100, defaultValue: 100, input: { editorType: 'slider', suffix: '%', help: 'Share of sessions attended' } },
|
|
73
|
+
// Derived: attendance-weighted final score (display-only, never stored).
|
|
74
|
+
{ field: 'finalScore', type: 'number', label: 'Final score', readonly: true, formula: 'grade * attendance / 100' },
|
|
75
|
+
{ field: 'enrolledOn', type: 'date', label: 'Enrolled on' },
|
|
76
|
+
],
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Enrollment grid formatting: term + status pills, plus attendance/grade
|
|
80
|
+
// thresholds (chronic absentees red, near-perfect attendance green + bold,
|
|
81
|
+
// top grades bold) - data-driven color, not just status pills.
|
|
82
|
+
const enrollmentFormats: FormatRule[] = [
|
|
83
|
+
...statusPills(enrollments, 'status'),
|
|
84
|
+
...statusPills(enrollments, 'term'),
|
|
85
|
+
{ field: 'attendance', op: 'lt', value: 50, color: '#dc2626' },
|
|
86
|
+
{ field: 'attendance', op: 'gte', value: 90, color: '#16a34a', bold: true },
|
|
87
|
+
{ field: 'grade', op: 'gte', value: 90, bold: true },
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
// Course catalog formatting: department pills plus rating / capacity thresholds
|
|
91
|
+
// (five-star courses green + bold, thin sections red).
|
|
92
|
+
const courseFormats: FormatRule[] = [
|
|
93
|
+
...statusPills(courses, 'department'),
|
|
94
|
+
{ field: 'rating', op: 'gte', value: 5, color: '#16a34a', bold: true },
|
|
95
|
+
{ field: 'capacity', op: 'lt', value: 40, color: '#dc2626' },
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
const seed = {
|
|
99
|
+
courses: [
|
|
100
|
+
{ id: 'cr1', name: 'Intro to Algorithms', department: 'cs', credits: 4, capacity: 120, fee: 1200, topics: ['Beginner', 'Hands-on'], rating: 5, color: '#6366f1', active: true },
|
|
101
|
+
{ id: 'cr2', name: 'Linear Algebra', department: 'math', credits: 3, capacity: 90, fee: 950, topics: ['Advanced'], rating: 4, color: '#0ea5e9', active: true },
|
|
102
|
+
{ id: 'cr3', name: 'Molecular Biology', department: 'biology', credits: 4, capacity: 60, fee: 1400, topics: ['Workshop', 'Live'], rating: 4, color: '#10b981', active: true },
|
|
103
|
+
{ id: 'cr4', name: 'Renaissance Painting', department: 'arts', credits: 2, capacity: 40, fee: 700, topics: ['Beginner'], rating: 3, color: '#ec4899', active: true },
|
|
104
|
+
{ id: 'cr5', name: 'Data Structures', department: 'cs', credits: 4, capacity: 110, fee: 1250, topics: ['Advanced', 'Hands-on'], rating: 5, color: '#6366f1', active: true },
|
|
105
|
+
{ id: 'cr6', name: 'Calculus III', department: 'math', credits: 3, capacity: 85, fee: 980, topics: ['Advanced'], rating: 4, color: '#0ea5e9', active: true },
|
|
106
|
+
{ id: 'cr7', name: 'Genetics', department: 'biology', credits: 3, capacity: 70, fee: 1100, topics: ['Workshop'], rating: 4, color: '#10b981', active: true },
|
|
107
|
+
{ id: 'cr8', name: 'Sculpture Studio', department: 'arts', credits: 2, capacity: 30, fee: 650, topics: ['Hands-on', 'Beginner'], rating: 3, color: '#ec4899', active: false },
|
|
108
|
+
],
|
|
109
|
+
students: [
|
|
110
|
+
{ id: 'st1', name: 'Liam Carter', email: 'liam.carter@campus.edu', phone: '+1 (415) 555-0101', country: 'United States', sisId: 'SIS-100234', gpa: 3.6, year: 'freshman', enrolledAt: '2025-09-01T09:00:00Z', active: true },
|
|
111
|
+
{ id: 'st2', name: 'Emma Nguyen', email: 'emma.nguyen@campus.edu', phone: '+1 (416) 555-0102', country: 'Canada', sisId: 'SIS-100235', gpa: 3.9, year: 'sophomore', enrolledAt: '2024-09-02T09:00:00Z', active: true },
|
|
112
|
+
{ id: 'st3', name: 'Noah Silva', email: 'noah.silva@campus.edu', phone: '+44 20 7946 0103', country: 'United Kingdom', sisId: 'SIS-100236', gpa: 3.4, year: 'junior', enrolledAt: '2023-09-01T09:00:00Z', active: true },
|
|
113
|
+
{ id: 'st4', name: 'Ava Johansson', email: 'ava.johansson@campus.edu', phone: '+46 8 555 0104', country: 'Sweden', sisId: 'SIS-100237', gpa: 3.8, year: 'senior', enrolledAt: '2022-09-05T09:00:00Z', active: true },
|
|
114
|
+
{ id: 'st5', name: 'Mateo Rossi', email: 'mateo.rossi@campus.edu', phone: '+39 06 5550105', country: 'Italy', sisId: 'SIS-100238', gpa: 3.1, year: 'sophomore', enrolledAt: '2024-09-03T09:00:00Z', active: true },
|
|
115
|
+
{ id: 'st6', name: 'Isla Murphy', email: 'isla.murphy@campus.edu', phone: '+353 1 555 0106', country: 'Ireland', sisId: 'SIS-100239', gpa: 3.7, year: 'freshman', enrolledAt: '2025-09-02T09:00:00Z', active: true },
|
|
116
|
+
{ id: 'st7', name: 'Kenji Watanabe', email: 'kenji.watanabe@campus.edu', phone: '+81 3 5550107', country: 'Japan', sisId: 'SIS-100240', gpa: 3.5, year: 'junior', enrolledAt: '2023-09-04T09:00:00Z', active: true },
|
|
117
|
+
{ id: 'st8', name: 'Sofia Garcia', email: 'sofia.garcia@campus.edu', phone: '+34 91 555 0108', country: 'Spain', sisId: 'SIS-100241', gpa: 2.9, year: 'senior', enrolledAt: '2022-09-06T09:00:00Z', active: false },
|
|
118
|
+
],
|
|
119
|
+
enrollments: [
|
|
120
|
+
{ id: 'en1', studentId: 'st1', courseId: 'cr1', term: 'fall2025', status: 'enrolled', grade: 0, attendance: 92, enrolledOn: '2025-09-01' },
|
|
121
|
+
{ id: 'en2', studentId: 'st2', courseId: 'cr2', term: 'spring2026', status: 'completed', grade: 88, attendance: 96, enrolledOn: '2026-01-15' },
|
|
122
|
+
{ id: 'en3', studentId: 'st3', courseId: 'cr3', term: 'fall2025', status: 'completed', grade: 92, attendance: 90, enrolledOn: '2025-09-03' },
|
|
123
|
+
{ id: 'en4', studentId: 'st4', courseId: 'cr4', term: 'fall2025', status: 'dropped', grade: 0, attendance: 40, enrolledOn: '2025-09-04' },
|
|
124
|
+
{ id: 'en5', studentId: 'st5', courseId: 'cr1', term: 'spring2026', status: 'enrolled', grade: 0, attendance: 85, enrolledOn: '2026-01-16' },
|
|
125
|
+
{ id: 'en6', studentId: 'st6', courseId: 'cr5', term: 'fall2025', status: 'completed', grade: 79, attendance: 88, enrolledOn: '2025-09-02' },
|
|
126
|
+
{ id: 'en7', studentId: 'st3', courseId: 'cr6', term: 'spring2026', status: 'completed', grade: 84, attendance: 91, enrolledOn: '2026-01-14' },
|
|
127
|
+
{ id: 'en8', studentId: 'st2', courseId: 'cr5', term: 'summer2026', status: 'enrolled', grade: 0, attendance: 78, enrolledOn: '2026-05-20' },
|
|
128
|
+
{ id: 'en9', studentId: 'st7', courseId: 'cr7', term: 'fall2025', status: 'completed', grade: 81, attendance: 87, enrolledOn: '2025-09-05' },
|
|
129
|
+
{ id: 'en10', studentId: 'st8', courseId: 'cr8', term: 'spring2026', status: 'dropped', grade: 0, attendance: 35, enrolledOn: '2026-01-18' },
|
|
130
|
+
],
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export const school: SampleApp = {
|
|
134
|
+
id: 'school',
|
|
135
|
+
name: 'School',
|
|
136
|
+
description: 'Students, courses and enrollments - enrollment + catalog dashboards (attendance sparkline + gauge, term/status breakdowns, a term x status pivot, tabbed views, faceted filters), status-pill enrollment grids with attendance/grade thresholds and row actions, courses master/detail, and rich edit forms (topic chips, course rating, catalog color, phone, country, SIS mask, GPA, enrollment datetime, attendance slider).',
|
|
137
|
+
emoji: '\u{1F393}',
|
|
138
|
+
accent: '#6366f1',
|
|
139
|
+
build: () => {
|
|
140
|
+
const courseRows = pad(courses, seed.courses, 12)
|
|
141
|
+
const studentRows = pad(students, seed.students, 22)
|
|
142
|
+
const enrollmentRows = pad(enrollments, seed.enrollments, 34, { studentId: ids(studentRows), courseId: ids(courseRows) })
|
|
143
|
+
return project({
|
|
144
|
+
title: 'Campus',
|
|
145
|
+
brand: 'Campus',
|
|
146
|
+
accent: '#6366f1',
|
|
147
|
+
preset: 'github',
|
|
148
|
+
navStyle: 'top-nav',
|
|
149
|
+
footer: '',
|
|
150
|
+
entities: [courses, students, enrollments],
|
|
151
|
+
seed: { courses: courseRows, students: studentRows, enrollments: enrollmentRows },
|
|
152
|
+
screens: [
|
|
153
|
+
// Enrollment dashboard: volume + quality KPIs (an attendance card that trends
|
|
154
|
+
// over enrollment dates), an attendance gauge, term/status breakdowns, a grade
|
|
155
|
+
// trend area, a tabbed breakdown, a term x status pivot, a filter, and an
|
|
156
|
+
// enrollment grid with term + status pills and attendance/grade thresholds.
|
|
157
|
+
dashScreen(enrollments, { id: 'enrollment', title: 'Enrollment', order: 0 }, [
|
|
158
|
+
{ kpi: 'Enrollments', reduce: 'count', span: 1 },
|
|
159
|
+
{ kpi: 'Avg grade', measure: 'grade', reduce: 'avg', span: 1 },
|
|
160
|
+
{ kpi: 'Avg attendance', measure: 'attendance', reduce: 'avg', format: 'percent', trendField: 'enrolledOn', trendReduce: 'avg', span: 1 },
|
|
161
|
+
{ gauge: 'Avg attendance', measure: 'attendance', reduce: 'avg', min: 0, max: 100, unit: '%', span: 1 },
|
|
162
|
+
{ chart: 'term', reduce: 'count', type: 'bar', span: 2 },
|
|
163
|
+
{ chart: 'status', reduce: 'count', type: 'pie', span: 1 },
|
|
164
|
+
{ chart: 'enrolledOn', measure: 'grade', reduce: 'avg', type: 'area', span: 3 },
|
|
165
|
+
{ tabs: [
|
|
166
|
+
{ label: 'By term', tiles: [{ chart: 'term', reduce: 'count', type: 'bar' }] },
|
|
167
|
+
{ label: 'By status', tiles: [{ chart: 'status', reduce: 'count', type: 'bar' }] },
|
|
168
|
+
], span: 3 },
|
|
169
|
+
{ pivot: { rows: ['term'], cols: ['status'], measure: 'grade', aggregate: 'avg' }, span: 3 },
|
|
170
|
+
{ filter: ['term', 'status'], span: 3 },
|
|
171
|
+
{ grid: true, format: enrollmentFormats, summaries: true, rowActions: [{ kind: 'edit' }], span: 3 },
|
|
172
|
+
]),
|
|
173
|
+
// Catalog dashboard: course counts + capacity KPIs (a capacity card with a
|
|
174
|
+
// 1,000-seat target), an average-rating gauge, department breakdowns, a
|
|
175
|
+
// filter, and a course grid with department pills + rating/capacity thresholds.
|
|
176
|
+
dashScreen(courses, { id: 'catalog', title: 'Catalog', order: 1 }, [
|
|
177
|
+
{ kpi: 'Courses', reduce: 'count', span: 1 },
|
|
178
|
+
{ kpi: 'Total capacity', measure: 'capacity', reduce: 'sum', target: 1000, span: 1 },
|
|
179
|
+
{ kpi: 'Avg credits', measure: 'credits', reduce: 'avg', span: 1 },
|
|
180
|
+
{ gauge: 'Avg rating', measure: 'rating', reduce: 'avg', min: 0, max: 5, span: 1 },
|
|
181
|
+
{ chart: 'department', measure: 'capacity', reduce: 'sum', type: 'bar', span: 2 },
|
|
182
|
+
{ chart: 'department', reduce: 'count', type: 'pie', span: 1 },
|
|
183
|
+
{ chart: 'department', measure: 'fee', reduce: 'avg', type: 'bar', span: 2 },
|
|
184
|
+
{ filter: ['department', 'active'], span: 1 },
|
|
185
|
+
{ grid: true, format: courseFormats, summaries: true, rowActions: [{ kind: 'edit' }], span: 3 },
|
|
186
|
+
]),
|
|
187
|
+
detailScreen(students, { id: 'student-360', title: 'Student 360', order: 2 }, {
|
|
188
|
+
titleField: 'name', subtitleField: 'email', statusField: 'year', metricFields: ['gpa'],
|
|
189
|
+
related: [{ entity: 'enrollments', foreignKey: 'studentId', label: 'Enrollments', titleField: 'courseId', subtitleField: 'grade', dateField: 'enrolledOn', statusField: 'status' }],
|
|
190
|
+
}),
|
|
191
|
+
screen(courses, 'master-detail', { id: 'courses', title: 'Courses', order: 3, child: enrollments, foreignKey: 'courseId' }),
|
|
192
|
+
formScreen(students, { id: 'students', title: 'Students', order: 4 }, undefined, { rowLink: { screen: 'student-360', sourceField: 'id', targetField: 'id' } }, ['year', 'country', 'active']),
|
|
193
|
+
formScreen(enrollments, { id: 'enrollments', title: 'Enrollments', order: 5 }, undefined, { format: enrollmentFormats, summaries: true, rowActions: [{ kind: 'edit' }] }, ['term', 'status', 'studentId', 'courseId']),
|
|
194
|
+
],
|
|
195
|
+
})
|
|
196
|
+
},
|
|
197
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { sampleApps } from './index.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A seed-density floor: every sample app must ship enough rows that its grids
|
|
6
|
+
* scroll and its charts / KPIs look substantial (a dashboard with 6 rows reads
|
|
7
|
+
* as unfinished). Guards against a sample regressing to a sparse curated seed.
|
|
8
|
+
*/
|
|
9
|
+
describe('sample seed density', () => {
|
|
10
|
+
const FLOOR = 40 // total curated/padded rows across all entities
|
|
11
|
+
const PRIMARY_FLOOR = 30 // the largest entity (the "fact" table) should scroll
|
|
12
|
+
|
|
13
|
+
for (const app of sampleApps) {
|
|
14
|
+
it(`${app.id} ships >= ${FLOOR} rows (largest entity >= ${PRIMARY_FLOOR})`, () => {
|
|
15
|
+
const p = app.build()
|
|
16
|
+
const counts = p.entities.map((e) => {
|
|
17
|
+
const src = p.dataSources?.[e.name] as { seed?: unknown[] } | undefined
|
|
18
|
+
return src?.seed?.length ?? 0
|
|
19
|
+
})
|
|
20
|
+
const total = counts.reduce((a, b) => a + b, 0)
|
|
21
|
+
expect(total, `${app.id}: only ${total} rows`).toBeGreaterThanOrEqual(FLOOR)
|
|
22
|
+
expect(Math.max(...counts), `${app.id}: largest entity too small`).toBeGreaterThanOrEqual(PRIMARY_FLOOR)
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
})
|