@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,182 @@
|
|
|
1
|
+
import type { EntitySchema } from '../../schema.js'
|
|
2
|
+
import { screen, formScreen, boardScreen, detailScreen, project, dashScreen, statusPills, pad, ids, type SampleApp } from './shared.js'
|
|
3
|
+
|
|
4
|
+
const customers: EntitySchema = {
|
|
5
|
+
name: 'customers',
|
|
6
|
+
label: 'Customer',
|
|
7
|
+
idField: 'id',
|
|
8
|
+
fields: [
|
|
9
|
+
{ field: 'id', type: 'text', primaryKey: true, readonly: true },
|
|
10
|
+
{ field: 'name', type: 'text', required: true, minLength: 2 },
|
|
11
|
+
{ field: 'email', type: 'text', format: 'email', required: true, input: { help: 'Primary contact address' } },
|
|
12
|
+
{ field: 'phone', type: 'text', input: { editorType: 'phone', help: 'Support callback number' } },
|
|
13
|
+
{ field: 'country', type: 'text', label: 'Country', input: { editorType: 'country', help: 'Billing country' } },
|
|
14
|
+
{ field: 'accountNo', type: 'text', label: 'Account #', input: { editorType: 'mask', mask: '####-####', help: 'Internal billing account number' } },
|
|
15
|
+
{ field: 'plan', type: 'enum', required: true, defaultValue: 'free', options: [
|
|
16
|
+
{ value: 'free', label: 'Free', color: '#94a3b8' },
|
|
17
|
+
{ value: 'pro', label: 'Pro', color: '#6366f1' },
|
|
18
|
+
{ value: 'business', label: 'Business', color: '#f59e0b' },
|
|
19
|
+
] },
|
|
20
|
+
{ field: 'signupAt', type: 'datetime', label: 'Signed up', input: { editorType: 'datetime', help: 'When the account was created' } },
|
|
21
|
+
{ field: 'health', type: 'number', label: 'Account health', min: 1, max: 5, defaultValue: 3, input: { editorType: 'rating', help: '1-5 relationship score' } },
|
|
22
|
+
{ field: 'active', type: 'boolean', defaultValue: true },
|
|
23
|
+
],
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const agents: EntitySchema = {
|
|
27
|
+
name: 'agents',
|
|
28
|
+
label: 'Agent',
|
|
29
|
+
idField: 'id',
|
|
30
|
+
fields: [
|
|
31
|
+
{ field: 'id', type: 'text', primaryKey: true, readonly: true },
|
|
32
|
+
{ field: 'name', type: 'text', required: true, minLength: 2 },
|
|
33
|
+
{ field: 'email', type: 'text', format: 'email', required: true, input: { help: 'Work address' } },
|
|
34
|
+
{ field: 'team', type: 'enum', required: true, defaultValue: 'technical', options: [
|
|
35
|
+
{ value: 'billing', label: 'Billing', color: '#10b981' },
|
|
36
|
+
{ value: 'technical', label: 'Technical', color: '#6366f1' },
|
|
37
|
+
{ value: 'onboarding', label: 'Onboarding', color: '#f59e0b' },
|
|
38
|
+
] },
|
|
39
|
+
{ field: 'phone', type: 'text', input: { editorType: 'phone', help: 'Desk extension' } },
|
|
40
|
+
{ field: 'skills', type: 'text', label: 'Skills', input: { editorType: 'chips', help: 'Tools and specialities' } },
|
|
41
|
+
{ field: 'capacity', type: 'number', label: 'Capacity (%)', min: 0, max: 100, defaultValue: 80, input: { editorType: 'slider', help: 'Share of a full ticket load' } },
|
|
42
|
+
{ field: 'csat', type: 'number', label: 'CSAT', min: 1, max: 5, defaultValue: 3, input: { editorType: 'rating', help: 'Rolling satisfaction score' } },
|
|
43
|
+
{ field: 'calendarColor', type: 'text', label: 'Calendar color', defaultValue: '#6366f1', input: { editorType: 'color', help: 'Shown on the shift calendar' } },
|
|
44
|
+
{ field: 'active', type: 'boolean', defaultValue: true },
|
|
45
|
+
],
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const tickets: EntitySchema = {
|
|
49
|
+
name: 'tickets',
|
|
50
|
+
label: 'Ticket',
|
|
51
|
+
idField: 'id',
|
|
52
|
+
fields: [
|
|
53
|
+
{ field: 'id', type: 'text', primaryKey: true, readonly: true },
|
|
54
|
+
{ field: 'subject', type: 'text', required: true, minLength: 3 },
|
|
55
|
+
{ field: 'customerId', type: 'relation', label: 'Customer', relation: { entity: 'customers', foreignKey: 'customerId', labelField: 'name' } },
|
|
56
|
+
{ field: 'agentId', type: 'relation', label: 'Agent', relation: { entity: 'agents', foreignKey: 'agentId', labelField: 'name' } },
|
|
57
|
+
{ field: 'priority', type: 'enum', required: true, defaultValue: 'normal', options: [
|
|
58
|
+
{ value: 'low', label: 'Low', color: '#94a3b8' },
|
|
59
|
+
{ value: 'normal', label: 'Normal', color: '#6366f1' },
|
|
60
|
+
{ value: 'high', label: 'High', color: '#f59e0b' },
|
|
61
|
+
{ value: 'urgent', label: 'Urgent', color: '#ef4444' },
|
|
62
|
+
] },
|
|
63
|
+
{ field: 'status', type: 'enum', required: true, defaultValue: 'open', options: [
|
|
64
|
+
{ value: 'open', label: 'Open', color: '#6366f1' },
|
|
65
|
+
{ value: 'pending', label: 'Pending', color: '#f59e0b' },
|
|
66
|
+
{ value: 'resolved', label: 'Resolved', color: '#10b981' },
|
|
67
|
+
{ value: 'closed', label: 'Closed', color: '#94a3b8' },
|
|
68
|
+
] },
|
|
69
|
+
{ field: 'channel', type: 'enum', options: [
|
|
70
|
+
{ value: 'email', label: 'Email', color: '#6366f1' },
|
|
71
|
+
{ value: 'chat', label: 'Chat', color: '#10b981' },
|
|
72
|
+
{ value: 'phone', label: 'Phone', color: '#f59e0b' },
|
|
73
|
+
] },
|
|
74
|
+
{ field: 'tags', type: 'text', label: 'Tags', input: { editorType: 'chips', help: 'Topic labels for routing' } },
|
|
75
|
+
{ field: 'createdAt', type: 'datetime', label: 'Created', input: { editorType: 'datetime', help: 'When the ticket was opened' } },
|
|
76
|
+
{ field: 'firstResponseMins', type: 'number', label: 'First response (min)', min: 0, input: { help: 'Time to the first agent reply' } },
|
|
77
|
+
{ field: 'satisfaction', type: 'number', label: 'CSAT', min: 1, max: 5, input: { editorType: 'rating', help: 'Customer rating on close' } },
|
|
78
|
+
{ field: 'estimateHrs', type: 'number', label: 'Estimate (hrs)', min: 0, defaultValue: 1, input: { help: 'Estimated effort to resolve' } },
|
|
79
|
+
{ field: 'hourlyRate', type: 'number', label: 'Rate ($/hr)', min: 0, defaultValue: 90, input: { help: 'Billed support rate' } },
|
|
80
|
+
// Derived: estimated support cost for the ticket (display-only, never seeded).
|
|
81
|
+
{ field: 'cost', type: 'number', label: 'Cost ($)', readonly: true, formula: 'estimateHrs * hourlyRate' },
|
|
82
|
+
],
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const seed = {
|
|
86
|
+
customers: [
|
|
87
|
+
{ id: 'c1', name: 'Ada Lovelace', email: 'ada@analytic.io', phone: '+1 (415) 555-0101', country: 'United States', accountNo: '4021-7788', plan: 'business', signupAt: '2025-09-12T09:15:00Z', health: 5, active: true },
|
|
88
|
+
{ id: 'c2', name: 'Alan Turing', email: 'alan@bletchley.uk', phone: '+44 20 7946 0102', country: 'United Kingdom', accountNo: '5590-2143', plan: 'pro', signupAt: '2025-11-03T14:20:00Z', health: 4, active: true },
|
|
89
|
+
{ id: 'c3', name: 'Grace Hopper', email: 'grace@navy.mil', phone: '+1 (202) 555-0103', country: 'United States', accountNo: '7781-6620', plan: 'business', signupAt: '2026-01-21T10:05:00Z', health: 5, active: true },
|
|
90
|
+
{ id: 'c4', name: 'Linus Torvalds', email: 'linus@kernel.org', phone: '+1 (503) 555-0104', country: 'United States', accountNo: '3312-9087', plan: 'pro', signupAt: '2026-03-08T08:45:00Z', health: 3, active: true },
|
|
91
|
+
{ id: 'c5', name: 'Barbara Liskov', email: 'barbara@sub.dev', phone: '+1 (617) 555-0105', country: 'United States', accountNo: '9001-4455', plan: 'free', signupAt: '2026-05-30T16:30:00Z', health: 2, active: false },
|
|
92
|
+
],
|
|
93
|
+
agents: [
|
|
94
|
+
{ id: 'a1', name: 'Sam Rivera', email: 'sam@support.io', team: 'technical', phone: '+1 (415) 555-0201', skills: ['TypeScript', 'AWS', 'GraphQL'], capacity: 85, csat: 5, calendarColor: '#6366f1', active: true },
|
|
95
|
+
{ id: 'a2', name: 'Jamie Chen', email: 'jamie@support.io', team: 'billing', phone: '+1 (312) 555-0202', skills: ['SQL', 'Leadership'], capacity: 70, csat: 4, calendarColor: '#10b981', active: true },
|
|
96
|
+
{ id: 'a3', name: 'Priya Patel', email: 'priya@support.io', team: 'onboarding', phone: '+1 (206) 555-0203', skills: ['Product', 'UX', 'Figma'], capacity: 60, csat: 4, calendarColor: '#f59e0b', active: true },
|
|
97
|
+
],
|
|
98
|
+
tickets: [
|
|
99
|
+
{ id: 'tk1', subject: 'Cannot reset password', customerId: 'c1', agentId: 'a1', priority: 'high', status: 'open', channel: 'email', tags: ['auth', 'login'], createdAt: '2026-07-10T08:30:00Z', firstResponseMins: 12, satisfaction: 4, estimateHrs: 2, hourlyRate: 90 },
|
|
100
|
+
{ id: 'tk2', subject: 'Invoice discrepancy', customerId: 'c2', agentId: 'a2', priority: 'normal', status: 'pending', channel: 'email', tags: ['billing'], createdAt: '2026-07-08T11:05:00Z', firstResponseMins: 45, satisfaction: 3, estimateHrs: 1, hourlyRate: 75 },
|
|
101
|
+
{ id: 'tk3', subject: 'API returns 500', customerId: 'c3', agentId: 'a1', priority: 'urgent', status: 'open', channel: 'chat', tags: ['api', 'outage'], createdAt: '2026-07-12T15:40:00Z', firstResponseMins: 4, satisfaction: 5, estimateHrs: 4, hourlyRate: 120 },
|
|
102
|
+
{ id: 'tk4', subject: 'How do I export data?', customerId: 'c4', agentId: 'a3', priority: 'low', status: 'resolved', channel: 'chat', tags: ['export'], createdAt: '2026-07-02T09:20:00Z', firstResponseMins: 20, satisfaction: 5, estimateHrs: 1, hourlyRate: 60 },
|
|
103
|
+
{ id: 'tk5', subject: 'Upgrade to Business', customerId: 'c5', agentId: 'a3', priority: 'normal', status: 'closed', channel: 'phone', tags: ['upgrade', 'billing'], createdAt: '2026-06-28T13:10:00Z', firstResponseMins: 8, satisfaction: 4, estimateHrs: 1, hourlyRate: 75 },
|
|
104
|
+
{ id: 'tk6', subject: 'SSO configuration', customerId: 'c1', agentId: 'a1', priority: 'high', status: 'pending', channel: 'email', tags: ['sso', 'security'], createdAt: '2026-07-11T10:00:00Z', firstResponseMins: 60, satisfaction: 2, estimateHrs: 3, hourlyRate: 120 },
|
|
105
|
+
{ id: 'tk7', subject: 'Double charge refund', customerId: 'c2', agentId: 'a2', priority: 'urgent', status: 'resolved', channel: 'phone', tags: ['refund', 'billing'], createdAt: '2026-07-05T16:25:00Z', firstResponseMins: 15, satisfaction: 4, estimateHrs: 2, hourlyRate: 90 },
|
|
106
|
+
{ id: 'tk8', subject: 'Feature request: dark mode', customerId: 'c3', agentId: 'a3', priority: 'low', status: 'open', channel: 'chat', tags: ['feature'], createdAt: '2026-07-13T12:50:00Z', firstResponseMins: 90, satisfaction: 3, estimateHrs: 5, hourlyRate: 100 },
|
|
107
|
+
],
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const support: SampleApp = {
|
|
111
|
+
id: 'support',
|
|
112
|
+
name: 'Support desk',
|
|
113
|
+
description: 'Tickets, customers and agents - triage + SLA dashboards (CSAT sparkline + gauge, pivot, tabbed breakdown), a team-capacity dashboard, status-pill grids, master/detail, and rich edit forms.',
|
|
114
|
+
emoji: '\u{1F3AF}',
|
|
115
|
+
accent: '#0ea5e9',
|
|
116
|
+
build: () => {
|
|
117
|
+
const customerRows = pad(customers, seed.customers, 16)
|
|
118
|
+
const agentRows = pad(agents, seed.agents, 6)
|
|
119
|
+
const ticketRows = pad(tickets, seed.tickets, 36, { customerId: ids(customerRows), agentId: ids(agentRows) })
|
|
120
|
+
return project({
|
|
121
|
+
title: 'Support Desk',
|
|
122
|
+
brand: 'Support Desk',
|
|
123
|
+
accent: '#0ea5e9',
|
|
124
|
+
preset: 'atlassian',
|
|
125
|
+
footer: '',
|
|
126
|
+
entities: [customers, agents, tickets],
|
|
127
|
+
seed: { customers: customerRows, agents: agentRows, tickets: ticketRows },
|
|
128
|
+
screens: [
|
|
129
|
+
// Triage dashboard: volume + CSAT KPIs (CSAT card trends over created date),
|
|
130
|
+
// a CSAT gauge, status/priority/channel breakdowns, a tabbed view, and a
|
|
131
|
+
// ticket grid with status AND priority pills.
|
|
132
|
+
dashScreen(tickets, { id: 'overview', title: 'Overview', order: 0 }, [
|
|
133
|
+
{ kpi: 'Open tickets', reduce: 'count', span: 1 },
|
|
134
|
+
{ kpi: 'Avg first response', measure: 'firstResponseMins', reduce: 'avg', span: 1 },
|
|
135
|
+
{ kpi: 'Avg CSAT', measure: 'satisfaction', reduce: 'avg', trendField: 'createdAt', trendReduce: 'avg', span: 1 },
|
|
136
|
+
{ gauge: 'Avg CSAT', measure: 'satisfaction', reduce: 'avg', min: 0, max: 5, span: 1 },
|
|
137
|
+
{ chart: 'status', reduce: 'count', type: 'bar', span: 2 },
|
|
138
|
+
{ chart: 'priority', reduce: 'count', type: 'pie', span: 1 },
|
|
139
|
+
{ chart: 'channel', reduce: 'count', type: 'bar', span: 2 },
|
|
140
|
+
{ tabs: [
|
|
141
|
+
{ label: 'By status', tiles: [{ chart: 'status', reduce: 'count', type: 'bar' }] },
|
|
142
|
+
{ label: 'By priority', tiles: [{ chart: 'priority', reduce: 'count', type: 'bar' }] },
|
|
143
|
+
{ label: 'By channel', tiles: [{ chart: 'channel', reduce: 'count', type: 'bar' }] },
|
|
144
|
+
], span: 3 },
|
|
145
|
+
{ grid: true, format: [...statusPills(tickets, 'status'), ...statusPills(tickets, 'priority')], summaries: true, span: 3 },
|
|
146
|
+
]),
|
|
147
|
+
// SLA / quality: response + CSAT KPIs, a CSAT gauge, and a pivot of CSAT by
|
|
148
|
+
// priority (rows) x status (columns).
|
|
149
|
+
// Ticket board: tickets as cards in status columns (drag to change status).
|
|
150
|
+
boardScreen(tickets, { id: 'board', title: 'Board', order: 1 }, { groupBy: 'status', titleField: 'subject', subtitleField: 'priority', filter: ['priority', 'channel', 'agentId'] }),
|
|
151
|
+
// Customer 360: account header (plan pill + health tile) with a timeline of
|
|
152
|
+
// the customer's tickets (newest first, colored by status).
|
|
153
|
+
detailScreen(customers, { id: 'customer-360', title: 'Customer 360', order: 2 }, {
|
|
154
|
+
titleField: 'name', subtitleField: 'email', statusField: 'plan', metricFields: ['health'],
|
|
155
|
+
related: [{ entity: 'tickets', foreignKey: 'customerId', label: 'Tickets', titleField: 'subject', subtitleField: 'priority', dateField: 'createdAt', statusField: 'status' }],
|
|
156
|
+
}),
|
|
157
|
+
dashScreen(tickets, { id: 'performance', title: 'Performance', order: 3 }, [
|
|
158
|
+
{ kpi: 'Avg first response', measure: 'firstResponseMins', reduce: 'avg', span: 1 },
|
|
159
|
+
{ kpi: 'Avg CSAT', measure: 'satisfaction', reduce: 'avg', span: 1 },
|
|
160
|
+
{ kpi: 'Est. hours', measure: 'estimateHrs', reduce: 'sum', format: 'number', span: 1 },
|
|
161
|
+
{ gauge: 'Avg CSAT', measure: 'satisfaction', reduce: 'avg', min: 0, max: 5, span: 1 },
|
|
162
|
+
{ chart: 'channel', measure: 'firstResponseMins', reduce: 'avg', type: 'bar', span: 2 },
|
|
163
|
+
{ chart: 'priority', measure: 'satisfaction', reduce: 'avg', type: 'bar', span: 1 },
|
|
164
|
+
{ pivot: { rows: ['priority'], cols: ['status'], measure: 'satisfaction', aggregate: 'avg' }, span: 3 },
|
|
165
|
+
]),
|
|
166
|
+
// Team load: capacity gauge + a per-agent capacity leaderboard.
|
|
167
|
+
dashScreen(agents, { id: 'team', title: 'Team', order: 4 }, [
|
|
168
|
+
{ kpi: 'Agents', reduce: 'count', span: 1 },
|
|
169
|
+
{ kpi: 'Avg capacity', measure: 'capacity', reduce: 'avg', format: 'percent', span: 1 },
|
|
170
|
+
{ kpi: 'Avg CSAT', measure: 'csat', reduce: 'avg', span: 1 },
|
|
171
|
+
{ gauge: 'Avg team capacity', measure: 'capacity', reduce: 'avg', min: 0, max: 100, unit: '%', span: 1 },
|
|
172
|
+
{ chart: 'name', measure: 'capacity', reduce: 'avg', type: 'bar', span: 2 },
|
|
173
|
+
{ chart: 'team', reduce: 'count', type: 'pie', span: 1 },
|
|
174
|
+
{ grid: true, format: statusPills(agents, 'team'), summaries: true, span: 3 },
|
|
175
|
+
]),
|
|
176
|
+
formScreen(tickets, { id: 'tickets', title: 'Tickets', order: 5 }, undefined, { format: [...statusPills(tickets, 'status'), ...statusPills(tickets, 'priority')], summaries: true }),
|
|
177
|
+
screen(customers, 'master-detail', { id: 'customers', title: 'Customers', order: 6, child: tickets, foreignKey: 'customerId', linkScreen: 'customer-360' }),
|
|
178
|
+
formScreen(agents, { id: 'agents', title: 'Agents', order: 7 }),
|
|
179
|
+
],
|
|
180
|
+
})
|
|
181
|
+
},
|
|
182
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { compile } from 'svelte/compiler'
|
|
3
|
+
import type { EntitySchema } from '../schema'
|
|
4
|
+
import { MANAGED_START, MANAGED_END } from './scaffold'
|
|
5
|
+
import { scaffoldApp } from './scaffold-app'
|
|
6
|
+
|
|
7
|
+
const companies: EntitySchema = {
|
|
8
|
+
name: 'companies', label: 'Companies', idField: 'id',
|
|
9
|
+
fields: [
|
|
10
|
+
{ field: 'id', type: 'number', primaryKey: true, readonly: true },
|
|
11
|
+
{ field: 'name', type: 'text', required: true },
|
|
12
|
+
],
|
|
13
|
+
}
|
|
14
|
+
const contacts: EntitySchema = {
|
|
15
|
+
name: 'contacts', label: 'Contacts', idField: 'id',
|
|
16
|
+
fields: [
|
|
17
|
+
{ field: 'id', type: 'number', primaryKey: true, readonly: true },
|
|
18
|
+
{ field: 'full_name', type: 'text', required: true },
|
|
19
|
+
// FK with a placeholder labelField - scaffoldApp should resolve it to 'name'.
|
|
20
|
+
{ field: 'company_id', type: 'relation', relation: { entity: 'companies', foreignKey: 'company_id', labelField: 'xxx' } },
|
|
21
|
+
],
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('scaffoldApp', () => {
|
|
25
|
+
const { files } = scaffoldApp([companies, contacts], { appTitle: 'CRM' })
|
|
26
|
+
const byPath = Object.fromEntries(files.map((f) => [f.path, f]))
|
|
27
|
+
|
|
28
|
+
it('emits every entity screen plus a layout and home page', () => {
|
|
29
|
+
for (const p of [
|
|
30
|
+
'src/lib/companies.schema.ts', 'src/routes/companies/+page.svelte', 'src/routes/api/companies/+server.ts',
|
|
31
|
+
'src/lib/contacts.schema.ts', 'src/routes/contacts/+page.svelte', 'src/routes/api/contacts/+server.ts',
|
|
32
|
+
'src/routes/+layout.svelte', 'src/routes/+page.svelte',
|
|
33
|
+
]) {
|
|
34
|
+
expect(byPath[p], p).toBeDefined()
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('the layout links every entity and renders the routed page', () => {
|
|
39
|
+
const layout = byPath['src/routes/+layout.svelte']!.contents
|
|
40
|
+
expect(layout).toContain('"href":"/companies"')
|
|
41
|
+
expect(layout).toContain('"href":"/contacts"')
|
|
42
|
+
expect(layout).toContain('{@render children?.()}')
|
|
43
|
+
expect(layout).toContain('CRM')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('the sidebar is collapsible and starts collapsed on tablet / phone', () => {
|
|
47
|
+
const layout = byPath['src/routes/+layout.svelte']!.contents
|
|
48
|
+
// Off-canvas drawer driven by a collapse toggle + a <= 1024px media query.
|
|
49
|
+
expect(layout).toContain('matchMedia')
|
|
50
|
+
expect(layout).toContain('max-width: 1024px')
|
|
51
|
+
expect(layout).toContain('function toggleNav')
|
|
52
|
+
expect(layout).toContain('class:is-drawer={drawer}')
|
|
53
|
+
expect(layout).toContain('app-shell__burger')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('the home page lists the entities', () => {
|
|
57
|
+
const home = byPath['src/routes/+page.svelte']!.contents
|
|
58
|
+
expect(home).toContain('"label":"Companies"')
|
|
59
|
+
expect(home).toContain('"label":"Contacts"')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('resolves relation labelFields across the app (linkRelationLabels)', () => {
|
|
63
|
+
const schema = byPath['src/lib/contacts.schema.ts']!.contents
|
|
64
|
+
// The placeholder 'xxx' is replaced by the related companies schema's 'name'.
|
|
65
|
+
expect(schema).toContain('"labelField":"name"')
|
|
66
|
+
expect(schema).not.toContain('"labelField":"xxx"')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('the contacts page wires a lookup to the companies route', () => {
|
|
70
|
+
const page = byPath['src/routes/contacts/+page.svelte']!.contents
|
|
71
|
+
expect(page).toContain('createRelationLookup')
|
|
72
|
+
expect(page).toContain('endpoint: "/api/companies"')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('layout and home are valid Svelte (compile)', () => {
|
|
76
|
+
for (const p of ['src/routes/+layout.svelte', 'src/routes/+page.svelte']) {
|
|
77
|
+
expect(() => compile(byPath[p]!.contents, { filename: p, generate: 'client' }), p).not.toThrow()
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('wraps shell files in managed markers', () => {
|
|
82
|
+
for (const p of ['src/routes/+layout.svelte', 'src/routes/+page.svelte']) {
|
|
83
|
+
expect(byPath[p]!.contents).toContain(MANAGED_START)
|
|
84
|
+
expect(byPath[p]!.contents).toContain(MANAGED_END)
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('throws with no schemas', () => {
|
|
89
|
+
expect(() => scaffoldApp([])).toThrow(/no schemas/)
|
|
90
|
+
})
|
|
91
|
+
})
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scaffoldApp - scaffold a whole data app, not a single page. Given several
|
|
3
|
+
* `EntitySchema`s (e.g. every table from `introspectDatabase` + `--all`), it
|
|
4
|
+
* emits each entity's screen (via `scaffold`) plus a shared **navigation
|
|
5
|
+
* layout** and a **home page** linking them. It also runs `linkRelationLabels`
|
|
6
|
+
* across the set, so foreign-key lookups resolve their display field from the
|
|
7
|
+
* actual related schema and every relation's `/api/<entity>` route exists.
|
|
8
|
+
*
|
|
9
|
+
* Pure string building, like `scaffold`. Each generated shell file wraps its
|
|
10
|
+
* body in `svgrid:managed` markers so re-running is safe.
|
|
11
|
+
*/
|
|
12
|
+
import { linkRelationLabels, titleCase, type EntitySchema } from '../schema.js'
|
|
13
|
+
import { scaffold, MANAGED_START, MANAGED_END, type GeneratedFile, type ScaffoldOptions } from './scaffold.js'
|
|
14
|
+
|
|
15
|
+
export type ScaffoldAppOptions = {
|
|
16
|
+
/** Backend for every entity's API route (see `ScaffoldOptions.dataSource`). */
|
|
17
|
+
dataSource?: ScaffoldOptions['dataSource']
|
|
18
|
+
/** App title shown in the nav + home. Default `'Data App'`. */
|
|
19
|
+
appTitle?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type NavItem = { href: string; label: string }
|
|
23
|
+
|
|
24
|
+
const MANAGED_NOTE = '// Regenerated by SvGrid Studio. Edits inside these markers are overwritten.'
|
|
25
|
+
const managed = (body: string) => `${MANAGED_START}\n${MANAGED_NOTE}\n${body}\n${MANAGED_END}`
|
|
26
|
+
|
|
27
|
+
function layoutFile(nav: NavItem[], appTitle: string): GeneratedFile {
|
|
28
|
+
return {
|
|
29
|
+
path: 'src/routes/+layout.svelte',
|
|
30
|
+
description: 'App shell: a collapsible nav sidebar linking every entity screen (an off-canvas drawer on tablet/phone), with the routed page in the main area.',
|
|
31
|
+
contents: `<script lang="ts">
|
|
32
|
+
${managed(` let { children } = $props()
|
|
33
|
+
const appTitle = ${JSON.stringify(appTitle)}
|
|
34
|
+
const nav = ${JSON.stringify(nav)}
|
|
35
|
+
|
|
36
|
+
// The sidebar collapses to an off-canvas drawer either on user request or on
|
|
37
|
+
// tablet / phone (<= 1024px), where it also starts collapsed. The desktop
|
|
38
|
+
// collapse choice persists across visits.
|
|
39
|
+
let navOpen = $state(false)
|
|
40
|
+
let collapsed = $state(false)
|
|
41
|
+
let narrow = $state(false)
|
|
42
|
+
const drawer = $derived(collapsed || narrow)
|
|
43
|
+
function toggleNav() { collapsed = !collapsed; try { localStorage.setItem('svapp:nav', collapsed ? 'c' : 'e') } catch (_) { /* storage blocked */ } }
|
|
44
|
+
$effect(() => {
|
|
45
|
+
const mq = window.matchMedia('(max-width: 1024px)')
|
|
46
|
+
const sync = () => (narrow = mq.matches)
|
|
47
|
+
sync()
|
|
48
|
+
try { const s = localStorage.getItem('svapp:nav'); if (s) collapsed = s === 'c' } catch (_) { /* storage blocked */ }
|
|
49
|
+
mq.addEventListener('change', sync)
|
|
50
|
+
return () => mq.removeEventListener('change', sync)
|
|
51
|
+
})`)}
|
|
52
|
+
</script>
|
|
53
|
+
|
|
54
|
+
<div class="app-shell" class:is-drawer={drawer} class:is-navopen={navOpen}>
|
|
55
|
+
<header class="app-shell__mobilebar">
|
|
56
|
+
<button type="button" class="app-shell__burger" aria-label="Toggle navigation" aria-expanded={navOpen} onclick={() => (navOpen = !navOpen)}>
|
|
57
|
+
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
|
|
58
|
+
</button>
|
|
59
|
+
<a class="app-shell__brand" href="/">{appTitle}</a>
|
|
60
|
+
{#if !narrow}<button type="button" class="app-shell__dock" aria-label="Dock sidebar" title="Dock sidebar" onclick={toggleNav}>
|
|
61
|
+
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6l6 6-6 6"/></svg>
|
|
62
|
+
</button>{/if}
|
|
63
|
+
</header>
|
|
64
|
+
{#if navOpen}<button type="button" class="app-shell__backdrop" aria-label="Close navigation" onclick={() => (navOpen = false)}></button>{/if}
|
|
65
|
+
<aside class="app-shell__nav">
|
|
66
|
+
<div class="app-shell__navhead">
|
|
67
|
+
<a class="app-shell__brand" href="/">{appTitle}</a>
|
|
68
|
+
<button type="button" class="app-shell__collapse" aria-label="Collapse sidebar" title="Collapse sidebar" onclick={toggleNav}>
|
|
69
|
+
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 6l-6 6 6 6"/></svg>
|
|
70
|
+
</button>
|
|
71
|
+
</div>
|
|
72
|
+
<nav onclick={() => (navOpen = false)}>
|
|
73
|
+
{#each nav as item (item.href)}
|
|
74
|
+
<a class="app-shell__link" href={item.href}>{item.label}</a>
|
|
75
|
+
{/each}
|
|
76
|
+
</nav>
|
|
77
|
+
</aside>
|
|
78
|
+
<main class="app-shell__main">
|
|
79
|
+
{@render children?.()}
|
|
80
|
+
</main>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<style>
|
|
84
|
+
.app-shell { display: grid; grid-template-columns: 220px 1fr; min-height: 100vh; }
|
|
85
|
+
.app-shell__nav { display: flex; flex-direction: column; gap: 4px; padding: 16px 12px; border-right: 1px solid #e5e7eb; background: #f8fafc; }
|
|
86
|
+
.app-shell__navhead { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
|
87
|
+
.app-shell__brand { font-weight: 700; font-size: 16px; padding: 6px 10px 12px; color: inherit; text-decoration: none; }
|
|
88
|
+
.app-shell__navhead .app-shell__brand { padding-bottom: 6px; }
|
|
89
|
+
.app-shell__collapse, .app-shell__dock, .app-shell__burger { display: inline-flex; align-items: center; justify-content: center; padding: 6px; border: 0; border-radius: 8px; background: transparent; color: #64748b; cursor: pointer; }
|
|
90
|
+
.app-shell__collapse:hover, .app-shell__dock:hover, .app-shell__burger:hover { background: #e2e8f0; color: #0f172a; }
|
|
91
|
+
.app-shell__link { padding: 8px 10px; border-radius: 6px; color: #334155; text-decoration: none; }
|
|
92
|
+
.app-shell__link:hover { background: #e2e8f0; }
|
|
93
|
+
.app-shell nav { display: flex; flex-direction: column; gap: 4px; }
|
|
94
|
+
.app-shell__main { padding: 20px 24px; min-width: 0; }
|
|
95
|
+
.app-shell__mobilebar { display: none; align-items: center; gap: 12px; padding: 10px 14px; border-bottom: 1px solid #e5e7eb; background: #f8fafc; position: sticky; top: 0; z-index: 50; }
|
|
96
|
+
.app-shell__mobilebar .app-shell__dock { margin-left: auto; }
|
|
97
|
+
.app-shell__backdrop { display: none; position: fixed; inset: 0; z-index: 55; border: 0; padding: 0; background: rgba(15, 23, 42, 0.42); cursor: pointer; }
|
|
98
|
+
/* Drawer mode: tablet / phone (<= 1024px) or a user-collapsed desktop sidebar.
|
|
99
|
+
The sidebar becomes a fixed off-canvas panel that slides in when opened. */
|
|
100
|
+
.app-shell.is-drawer { display: block; }
|
|
101
|
+
.app-shell.is-drawer .app-shell__mobilebar { display: flex; }
|
|
102
|
+
.app-shell.is-drawer .app-shell__backdrop { display: block; }
|
|
103
|
+
.app-shell.is-drawer .app-shell__nav {
|
|
104
|
+
position: fixed; top: 0; bottom: 0; left: 0; z-index: 60; width: 264px; max-width: 82vw;
|
|
105
|
+
transform: translateX(-100%); transition: transform 0.24s ease; box-shadow: 0 0 40px rgba(15, 23, 42, 0.28);
|
|
106
|
+
}
|
|
107
|
+
.app-shell.is-drawer.is-navopen .app-shell__nav { transform: translateX(0); }
|
|
108
|
+
.app-shell.is-drawer .app-shell__navhead .app-shell__brand { display: none; } /* brand already in the top bar */
|
|
109
|
+
.app-shell.is-drawer .app-shell__collapse { display: none; } /* dock lives in the top bar when drawered */
|
|
110
|
+
</style>
|
|
111
|
+
`,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function homeFile(nav: NavItem[], appTitle: string): GeneratedFile {
|
|
116
|
+
return {
|
|
117
|
+
path: 'src/routes/+page.svelte',
|
|
118
|
+
description: 'Home page: cards linking to each entity screen.',
|
|
119
|
+
contents: `<script lang="ts">
|
|
120
|
+
${managed(` const appTitle = ${JSON.stringify(appTitle)}
|
|
121
|
+
const entities = ${JSON.stringify(nav)}`)}
|
|
122
|
+
</script>
|
|
123
|
+
|
|
124
|
+
<section class="home">
|
|
125
|
+
<h2 class="home__title">{appTitle}</h2>
|
|
126
|
+
<div class="home__grid">
|
|
127
|
+
{#each entities as item (item.href)}
|
|
128
|
+
<a class="home__card" href={item.href}>{item.label}</a>
|
|
129
|
+
{/each}
|
|
130
|
+
</div>
|
|
131
|
+
</section>
|
|
132
|
+
|
|
133
|
+
<style>
|
|
134
|
+
.home__title { margin: 0 0 16px; font-size: 20px; font-weight: 600; }
|
|
135
|
+
.home__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px; }
|
|
136
|
+
.home__card { display: block; padding: 18px 16px; border: 1px solid #e5e7eb; border-radius: 10px; color: inherit; text-decoration: none; font-weight: 600; }
|
|
137
|
+
.home__card:hover { border-color: #94a3b8; background: #f8fafc; }
|
|
138
|
+
</style>
|
|
139
|
+
`,
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function scaffoldApp(
|
|
144
|
+
schemas: EntitySchema[],
|
|
145
|
+
options: ScaffoldAppOptions = {},
|
|
146
|
+
): { files: GeneratedFile[] } {
|
|
147
|
+
if (schemas.length === 0) throw new Error('scaffoldApp: no schemas to scaffold')
|
|
148
|
+
const appTitle = options.appTitle ?? 'Data App'
|
|
149
|
+
|
|
150
|
+
// Resolve each relation's display field against the related schema in the set.
|
|
151
|
+
const linked = linkRelationLabels(schemas)
|
|
152
|
+
|
|
153
|
+
const files: GeneratedFile[] = []
|
|
154
|
+
for (const schema of linked) {
|
|
155
|
+
files.push(...scaffold(schema, { dataSource: options.dataSource }).files)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const nav: NavItem[] = linked.map((s) => ({ href: `/${s.name}`, label: s.label ?? titleCase(s.name) }))
|
|
159
|
+
files.push(layoutFile(nav, appTitle), homeFile(nav, appTitle))
|
|
160
|
+
return { files }
|
|
161
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { compile } from 'svelte/compiler'
|
|
3
|
+
import type { EntitySchema } from '../schema'
|
|
4
|
+
import { MANAGED_END, MANAGED_START, mergeManaged, scaffold } from './scaffold'
|
|
5
|
+
|
|
6
|
+
const schema: EntitySchema = {
|
|
7
|
+
name: 'customers',
|
|
8
|
+
label: 'Customer',
|
|
9
|
+
idField: 'id',
|
|
10
|
+
fields: [
|
|
11
|
+
{ field: 'id', type: 'text', primaryKey: true, readonly: true },
|
|
12
|
+
{ field: 'firstName', type: 'text', required: true },
|
|
13
|
+
{ field: 'age', type: 'number' },
|
|
14
|
+
{ field: 'tier', type: 'enum', options: [{ value: 'free', label: 'Free' }] },
|
|
15
|
+
],
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('scaffold', () => {
|
|
19
|
+
const { files } = scaffold(schema)
|
|
20
|
+
const byPath = Object.fromEntries(files.map((f) => [f.path, f]))
|
|
21
|
+
|
|
22
|
+
it('emits schema, server route, and page files at SvelteKit paths', () => {
|
|
23
|
+
expect(Object.keys(byPath).sort()).toEqual([
|
|
24
|
+
'src/lib/customers.schema.ts',
|
|
25
|
+
'src/routes/api/customers/+server.ts',
|
|
26
|
+
'src/routes/customers/+page.svelte',
|
|
27
|
+
])
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('the schema file exports a typed EntitySchema literal and Row type', () => {
|
|
31
|
+
const c = byPath['src/lib/customers.schema.ts']!.contents
|
|
32
|
+
expect(c).toContain('export const customersSchema: EntitySchema<CustomersRow>')
|
|
33
|
+
expect(c).toContain('export type CustomersRow = {')
|
|
34
|
+
expect(c).toContain('age: number')
|
|
35
|
+
expect(c).toContain('firstName: string')
|
|
36
|
+
expect(c).toContain(`field: "tier", type: "enum"`)
|
|
37
|
+
expect(c).toContain(`options: [{"value":"free","label":"Free"}]`)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('the server route wires createKitHandlers', () => {
|
|
41
|
+
const c = byPath['src/routes/api/customers/+server.ts']!.contents
|
|
42
|
+
expect(c).toContain('createKitHandlers({ schema: customersSchema, source })')
|
|
43
|
+
expect(c).toContain('export const { POST }')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('the page wires the grid + edit panel over the transport', () => {
|
|
47
|
+
const c = byPath['src/routes/customers/+page.svelte']!.contents
|
|
48
|
+
expect(c).toContain("createKitDataSource<CustomersRow>({ endpoint: '/api/customers' })")
|
|
49
|
+
expect(c).toContain('schemaToColumns(customersSchema)')
|
|
50
|
+
expect(c).toContain('<SvGrid')
|
|
51
|
+
expect(c).toContain('<SvGridEditPanel')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('the page is a full data screen: sort, filter, paging, delete, optimistic', () => {
|
|
55
|
+
const c = byPath['src/routes/customers/+page.svelte']!.contents
|
|
56
|
+
expect(c).toContain('sortable')
|
|
57
|
+
expect(c).toContain('externalSort')
|
|
58
|
+
expect(c).toContain('controller.setSort(s)')
|
|
59
|
+
expect(c).toContain('filterable')
|
|
60
|
+
expect(c).toContain('showGlobalFilter')
|
|
61
|
+
expect(c).toContain('controller.setFilter(') // server filtering
|
|
62
|
+
expect(c).toContain('fitColumns') // columns fill the width
|
|
63
|
+
expect(c).toContain('enableRowSummaries={false}') // no default summary
|
|
64
|
+
expect(c).toContain('externalPagination') // native server pagination footer
|
|
65
|
+
expect(c).toContain('onPaginationChange')
|
|
66
|
+
expect(c).toContain('controller.setPage(') // pagination
|
|
67
|
+
expect(c).toContain('removeSelected') // multi-select delete
|
|
68
|
+
expect(c).toContain('optimistic: true')
|
|
69
|
+
expect(c).toContain("getRowId: (r) => String(r.id)") // uses the primary key
|
|
70
|
+
expect(c).toContain('role="alert"') // error state
|
|
71
|
+
expect(c).toContain('emptyMessage=') // empty state
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('the generated page is syntactically valid Svelte (compiles)', () => {
|
|
75
|
+
// Compile with Svelte's own compiler - validates markup, runes, bindings,
|
|
76
|
+
// and snippets. (It does not resolve imports; that's the bundler's job.)
|
|
77
|
+
const page = byPath['src/routes/customers/+page.svelte']!.contents
|
|
78
|
+
expect(() => compile(page, { filename: '+page.svelte', generate: 'client' })).not.toThrow()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('wraps every file body in managed markers', () => {
|
|
82
|
+
for (const f of files) {
|
|
83
|
+
expect(f.contents).toContain(MANAGED_START)
|
|
84
|
+
expect(f.contents).toContain(MANAGED_END)
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('emits a connected +server.ts for a database dialect', () => {
|
|
89
|
+
const pg = scaffold(schema, { dataSource: 'postgres' }).files.find(
|
|
90
|
+
(f) => f.path === 'src/routes/api/customers/+server.ts',
|
|
91
|
+
)!.contents
|
|
92
|
+
expect(pg).toContain("import pg from 'pg'")
|
|
93
|
+
expect(pg).toContain('new pg.Pool({ connectionString: process.env.DATABASE_URL })')
|
|
94
|
+
expect(pg).toContain('createSqlDataSource')
|
|
95
|
+
expect(pg).toContain("placeholders: '$'")
|
|
96
|
+
expect(pg).toContain('await pool.query(text, params)')
|
|
97
|
+
|
|
98
|
+
const sqlite = scaffold(schema, { dataSource: 'sqlite' }).files.find((f) =>
|
|
99
|
+
f.path.endsWith('+server.ts'),
|
|
100
|
+
)!.contents
|
|
101
|
+
expect(sqlite).toContain("import Database from 'better-sqlite3'")
|
|
102
|
+
expect(sqlite).toContain('db.prepare(text).all(...params)')
|
|
103
|
+
|
|
104
|
+
const mssql = scaffold(schema, { dataSource: 'mssql' }).files.find((f) =>
|
|
105
|
+
f.path.endsWith('+server.ts'),
|
|
106
|
+
)!.contents
|
|
107
|
+
expect(mssql).toContain("import mssql from 'mssql'")
|
|
108
|
+
expect(mssql).toContain("placeholders: '@'")
|
|
109
|
+
|
|
110
|
+
// supabase reuses the postgres driver
|
|
111
|
+
const supa = scaffold(schema, { dataSource: 'supabase' }).files.find((f) =>
|
|
112
|
+
f.path.endsWith('+server.ts'),
|
|
113
|
+
)!.contents
|
|
114
|
+
expect(supa).toContain("import pg from 'pg'")
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('honors route/apiRoute overrides', () => {
|
|
118
|
+
const { files: f2 } = scaffold(schema, { route: 'clients', apiRoute: '/data/clients' })
|
|
119
|
+
const paths = f2.map((x) => x.path)
|
|
120
|
+
expect(paths).toContain('src/routes/clients/+page.svelte')
|
|
121
|
+
expect(paths).toContain('src/routes/data/clients/+server.ts')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('the base page (no relations) has no lookup wiring', () => {
|
|
125
|
+
const c = byPath['src/routes/customers/+page.svelte']!.contents
|
|
126
|
+
expect(c).not.toContain('createRelationLookup')
|
|
127
|
+
expect(c).not.toContain('lookups={lookups}')
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
describe('scaffold with relation fields', () => {
|
|
132
|
+
const relSchema: EntitySchema = {
|
|
133
|
+
name: 'contacts',
|
|
134
|
+
label: 'Contact',
|
|
135
|
+
idField: 'id',
|
|
136
|
+
fields: [
|
|
137
|
+
{ field: 'id', type: 'number', primaryKey: true, readonly: true },
|
|
138
|
+
{ field: 'name', type: 'text', required: true },
|
|
139
|
+
{ field: 'companyId', type: 'relation', label: 'Company', relation: { entity: 'companies', foreignKey: 'companyId', labelField: 'name' } },
|
|
140
|
+
],
|
|
141
|
+
}
|
|
142
|
+
const page = scaffold(relSchema).files.find((f) => f.path === 'src/routes/contacts/+page.svelte')!.contents
|
|
143
|
+
|
|
144
|
+
it('generates a lookup over the related API route and wires it into the edit panel', () => {
|
|
145
|
+
expect(page).toContain('createRelationLookup')
|
|
146
|
+
expect(page).toContain('createKitDataSource({ endpoint: "/api/companies" })')
|
|
147
|
+
expect(page).toContain('labelField: "name"')
|
|
148
|
+
expect(page).toContain('const lookups = { "companyId": companyIdLookup }')
|
|
149
|
+
expect(page).toContain('lookups={lookups}')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('the page with lookups is valid Svelte (compiles)', () => {
|
|
153
|
+
expect(() => compile(page, { filename: '+page.svelte', generate: 'client' })).not.toThrow()
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
describe('mergeManaged', () => {
|
|
158
|
+
it('uses the generated file whole when there is no existing content', () => {
|
|
159
|
+
const gen = `head\n${MANAGED_START}\nX\n${MANAGED_END}\ntail`
|
|
160
|
+
expect(mergeManaged(null, gen)).toBe(gen)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('replaces only the managed region, preserving user edits outside', () => {
|
|
164
|
+
const existing = `import x\n${MANAGED_START}\nOLD\n${MANAGED_END}\n// my custom helper\nconst keep = 1`
|
|
165
|
+
const generated = `import y\n${MANAGED_START}\nNEW\n${MANAGED_END}\nignored tail`
|
|
166
|
+
const merged = mergeManaged(existing, generated)
|
|
167
|
+
expect(merged).toContain('import x') // user's import kept
|
|
168
|
+
expect(merged).toContain('// my custom helper') // user's code kept
|
|
169
|
+
expect(merged).toContain('const keep = 1')
|
|
170
|
+
expect(merged).toContain('NEW') // managed region updated
|
|
171
|
+
expect(merged).not.toContain('OLD')
|
|
172
|
+
expect(merged).not.toContain('ignored tail')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('falls back to generated when existing has no markers', () => {
|
|
176
|
+
expect(mergeManaged('no markers here', 'GEN')).toBe('GEN')
|
|
177
|
+
})
|
|
178
|
+
})
|