@svgrid/create-studio 0.1.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/README.md +77 -0
- package/index.mjs +307 -0
- package/package.json +39 -0
- package/templates/default/README.md +51 -0
- package/templates/default/_gitignore +22 -0
- package/templates/default/_npmrc +1 -0
- package/templates/default/_package.json +25 -0
- package/templates/default/src/app.css +65 -0
- package/templates/default/src/app.d.ts +12 -0
- package/templates/default/src/app.html +12 -0
- package/templates/default/src/lib/EntityScreen.svelte +95 -0
- package/templates/default/src/lib/data.ts +55 -0
- package/templates/default/src/lib/schemas.ts +61 -0
- package/templates/default/src/routes/+layout.svelte +27 -0
- package/templates/default/src/routes/+layout.ts +5 -0
- package/templates/default/src/routes/+page.svelte +22 -0
- package/templates/default/src/routes/customers/+page.svelte +8 -0
- package/templates/default/src/routes/orders/+page.svelte +13 -0
- package/templates/default/svelte.config.js +12 -0
- package/templates/default/tsconfig.json +14 -0
- package/templates/default/vite.config.ts +6 -0
package/README.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# @svgrid/create-studio
|
|
2
|
+
|
|
3
|
+
Scaffold a runnable **SvGrid Studio** data app in one command.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm create @svgrid/studio@latest
|
|
7
|
+
# or into a named directory
|
|
8
|
+
npm create @svgrid/studio@latest my-data-app
|
|
9
|
+
# pnpm / yarn
|
|
10
|
+
pnpm create @svgrid/studio my-data-app
|
|
11
|
+
yarn create @svgrid/studio my-data-app
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Then:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
cd my-data-app
|
|
18
|
+
npm install
|
|
19
|
+
npm run dev
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## From your own schema
|
|
23
|
+
|
|
24
|
+
Point it at an existing **Drizzle** `schema.ts` or **Prisma** `schema.prisma`
|
|
25
|
+
(auto-detected) and it scaffolds a screen for every table/model instead of the
|
|
26
|
+
seeded example - foreign keys become searchable lookups:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npm create @svgrid/studio@latest my-app -- --from ./prisma/schema.prisma
|
|
30
|
+
npm create @svgrid/studio@latest my-app -- --from ./src/lib/db/schema.ts
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## From a designer project
|
|
34
|
+
|
|
35
|
+
Export a `studio.config.json` from the [visual app designer](https://www.svgrid.com/docs/enterprise/studio/app-designer)
|
|
36
|
+
and generate the exact screens + blocks you arranged:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npm create @svgrid/studio@latest my-app -- --project ./studio.config.json
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## What you get
|
|
43
|
+
|
|
44
|
+
A full [SvelteKit](https://svelte.dev/docs/kit) app - not a snippet - wired end to end:
|
|
45
|
+
|
|
46
|
+
- **Nav shell + modern theme** (plain CSS, light/dark aware, no Tailwind).
|
|
47
|
+
- **Two linked entities** - Customers and Orders - where an order references a
|
|
48
|
+
customer through a **searchable lookup** field.
|
|
49
|
+
- **Grid + modal CRUD** on every screen: sort, filter, global search, paging,
|
|
50
|
+
multi-select delete, and a draggable / resizable / pinnable edit modal - all
|
|
51
|
+
driven by a single `EntitySchema` per entity.
|
|
52
|
+
- **Seeded in-memory data**, so it runs with **no backend to set up**.
|
|
53
|
+
|
|
54
|
+
Everything is schema-driven. Open [`src/lib/schemas.ts`](templates/default/src/lib/schemas.ts),
|
|
55
|
+
add a field, and it appears in both the grid and the form.
|
|
56
|
+
|
|
57
|
+
## Going to a real database
|
|
58
|
+
|
|
59
|
+
The starter's data sources are the only thing tied to in-memory storage. Swap
|
|
60
|
+
`createInMemoryDataSource` in `src/lib/data.ts` for a real adapter - or generate
|
|
61
|
+
a connected entity from an existing table:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
npx @svgrid/studio add public.invoices --db postgres --url "$DATABASE_URL"
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The grid, form, sorting, filtering, and paging keep working unchanged.
|
|
68
|
+
|
|
69
|
+
## Requirements
|
|
70
|
+
|
|
71
|
+
- Node.js >= 18
|
|
72
|
+
|
|
73
|
+
## Links
|
|
74
|
+
|
|
75
|
+
- Docs: https://www.svgrid.com/docs/studio
|
|
76
|
+
- Grid: [`@svgrid/grid`](https://www.npmjs.com/package/@svgrid/grid)
|
|
77
|
+
- Studio / enterprise: [`@svgrid/enterprise`](https://www.npmjs.com/package/@svgrid/enterprise)
|
package/index.mjs
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @svgrid/create-studio - scaffold a runnable SvGrid Studio data app.
|
|
3
|
+
//
|
|
4
|
+
// npm create @svgrid/studio@latest # interactive
|
|
5
|
+
// pnpm create @svgrid/studio my-app # into ./my-app
|
|
6
|
+
// npm create @svgrid/studio@latest my-app -- --force
|
|
7
|
+
//
|
|
8
|
+
// One command -> a full SvelteKit app: nav shell, two linked entities
|
|
9
|
+
// (Customers + Orders with a searchable lookup), grid + modal CRUD, and a
|
|
10
|
+
// modern theme, running on seeded in-memory data with no backend to set up.
|
|
11
|
+
//
|
|
12
|
+
// Zero runtime dependencies - Node built-ins only. Copies the bundled template,
|
|
13
|
+
// renames `_`-prefixed dotfiles, and rewrites the project name.
|
|
14
|
+
|
|
15
|
+
import { cp, mkdir, readdir, rename, rm, stat, readFile, writeFile } from 'node:fs/promises'
|
|
16
|
+
import { existsSync } from 'node:fs'
|
|
17
|
+
import { dirname, join, basename, resolve, isAbsolute } from 'node:path'
|
|
18
|
+
import { fileURLToPath } from 'node:url'
|
|
19
|
+
import { createInterface } from 'node:readline/promises'
|
|
20
|
+
import { stdin, stdout } from 'node:process'
|
|
21
|
+
|
|
22
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
23
|
+
const TEMPLATE_DIR = join(__dirname, 'templates', 'default')
|
|
24
|
+
|
|
25
|
+
const RENAME_BACK = new Map([
|
|
26
|
+
['_gitignore', '.gitignore'],
|
|
27
|
+
['_npmrc', '.npmrc'],
|
|
28
|
+
['_env.example', '.env.example'],
|
|
29
|
+
['_package.json', 'package.json'],
|
|
30
|
+
])
|
|
31
|
+
|
|
32
|
+
const c = {
|
|
33
|
+
reset: '\x1b[0m',
|
|
34
|
+
bold: '\x1b[1m',
|
|
35
|
+
dim: '\x1b[2m',
|
|
36
|
+
green: '\x1b[32m',
|
|
37
|
+
cyan: '\x1b[36m',
|
|
38
|
+
red: '\x1b[31m',
|
|
39
|
+
yellow: '\x1b[33m',
|
|
40
|
+
}
|
|
41
|
+
const color = stdout.isTTY ? (k, s) => `${c[k]}${s}${c.reset}` : (_k, s) => s
|
|
42
|
+
|
|
43
|
+
function parseArgs(argv) {
|
|
44
|
+
const args = { _: [], force: false, help: false, from: null, project: null }
|
|
45
|
+
for (let i = 0; i < argv.length; i++) {
|
|
46
|
+
const a = argv[i]
|
|
47
|
+
if (a === '--help' || a === '-h') args.help = true
|
|
48
|
+
else if (a === '--force' || a === '-f') args.force = true
|
|
49
|
+
else if (a === '--from') args.from = argv[++i]
|
|
50
|
+
else if (a.startsWith('--from=')) args.from = a.slice('--from='.length)
|
|
51
|
+
else if (a === '--project') args.project = argv[++i]
|
|
52
|
+
else if (a.startsWith('--project=')) args.project = a.slice('--project='.length)
|
|
53
|
+
else if (!a.startsWith('-')) args._.push(a)
|
|
54
|
+
}
|
|
55
|
+
return args
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function printHelp() {
|
|
59
|
+
stdout.write(`
|
|
60
|
+
${color('bold', '@svgrid/create-studio')} - scaffold a SvGrid Studio data app
|
|
61
|
+
|
|
62
|
+
${color('bold', 'Usage')}
|
|
63
|
+
npm create @svgrid/studio@latest [dir] -- [--from <schema>] [--force]
|
|
64
|
+
|
|
65
|
+
${color('bold', 'What you get')}
|
|
66
|
+
A runnable SvelteKit app: nav shell, grid + modal CRUD per entity, and a
|
|
67
|
+
modern theme. Without --from you get seeded Customers + Orders; with --from
|
|
68
|
+
you get a screen for every table/model in your schema.
|
|
69
|
+
|
|
70
|
+
${color('bold', 'Options')}
|
|
71
|
+
--from <path> Generate the app from a Drizzle (.ts) or Prisma (.prisma)
|
|
72
|
+
schema instead of the seeded example (auto-detected).
|
|
73
|
+
--project <path> Generate the app from a studio.config.json exported by the
|
|
74
|
+
visual designer (screens + blocks, not just entities).
|
|
75
|
+
--force Scaffold into a non-empty directory.
|
|
76
|
+
|
|
77
|
+
${color('bold', 'Examples')}
|
|
78
|
+
npm create @svgrid/studio@latest
|
|
79
|
+
pnpm create @svgrid/studio my-data-app
|
|
80
|
+
npm create @svgrid/studio@latest my-app -- --from ./prisma/schema.prisma
|
|
81
|
+
npm create @svgrid/studio@latest my-app -- --project ./studio.config.json
|
|
82
|
+
`)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Generate the app from a studio.config.json (the visual designer's export):
|
|
87
|
+
* parse it, emit each screen's composed blocks, and replace the seeded example.
|
|
88
|
+
*/
|
|
89
|
+
async function applyProject(destDir, projectPath) {
|
|
90
|
+
const abs = resolve(process.cwd(), projectPath)
|
|
91
|
+
const json = await readFile(abs, 'utf8').catch(() => null)
|
|
92
|
+
if (json == null) throw new Error(`--project: config not found: ${projectPath}`)
|
|
93
|
+
|
|
94
|
+
let studio
|
|
95
|
+
try {
|
|
96
|
+
studio = await import('@svgrid/enterprise/studio')
|
|
97
|
+
} catch {
|
|
98
|
+
throw new Error('--project needs @svgrid/enterprise; reinstall and try again.')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const project = studio.parseProject(json)
|
|
102
|
+
const files = studio.emitStudioProject(project)
|
|
103
|
+
|
|
104
|
+
for (const ex of ['customers', 'orders']) {
|
|
105
|
+
await rm(join(destDir, 'src', 'routes', ex), { recursive: true, force: true })
|
|
106
|
+
}
|
|
107
|
+
for (const f of files) {
|
|
108
|
+
const full = join(destDir, f.path)
|
|
109
|
+
await mkdir(dirname(full), { recursive: true })
|
|
110
|
+
await writeFile(full, f.contents)
|
|
111
|
+
}
|
|
112
|
+
// Add the runtime deps the generated code imports (Supabase client / SQL
|
|
113
|
+
// drivers) so the app installs + runs turnkey.
|
|
114
|
+
const allSource = files.map((f) => f.contents).join('\n')
|
|
115
|
+
const DEPS = [
|
|
116
|
+
["from '@supabase/supabase-js'", '@supabase/supabase-js', '^2.45.0'],
|
|
117
|
+
["import pg from 'pg'", 'pg', '^8.11.0'],
|
|
118
|
+
["from 'mysql2/promise'", 'mysql2', '^3.9.0'],
|
|
119
|
+
["import mssql from 'mssql'", 'mssql', '^10.0.0'],
|
|
120
|
+
["import Database from 'better-sqlite3'", 'better-sqlite3', '^11.0.0'],
|
|
121
|
+
]
|
|
122
|
+
for (const [needle, dep, version] of DEPS) {
|
|
123
|
+
if (allSource.includes(needle)) await addDependency(destDir, dep, version)
|
|
124
|
+
}
|
|
125
|
+
return project.screens.map((s) => s.title)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Add a runtime dependency to the generated app's package.json (idempotent). */
|
|
129
|
+
async function addDependency(destDir, name, version) {
|
|
130
|
+
const pkgPath = join(destDir, 'package.json')
|
|
131
|
+
if (!existsSync(pkgPath)) return
|
|
132
|
+
try {
|
|
133
|
+
const pkg = JSON.parse(await readFile(pkgPath, 'utf8'))
|
|
134
|
+
pkg.dependencies = { ...pkg.dependencies, [name]: version }
|
|
135
|
+
await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
|
|
136
|
+
} catch {
|
|
137
|
+
// leave deps as-is if package.json isn't valid JSON
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Generate the app's entities from a Drizzle/Prisma schema file: parse it with
|
|
143
|
+
* the Studio core, emit EntityScreen-style files, and replace the seeded
|
|
144
|
+
* Customers/Orders. Runs after the base template is copied.
|
|
145
|
+
*/
|
|
146
|
+
async function applyFromSchema(destDir, fromPath) {
|
|
147
|
+
const abs = resolve(process.cwd(), fromPath)
|
|
148
|
+
const src = await readFile(abs, 'utf8').catch(() => null)
|
|
149
|
+
if (src == null) throw new Error(`--from: schema file not found: ${fromPath}`)
|
|
150
|
+
|
|
151
|
+
let studio
|
|
152
|
+
try {
|
|
153
|
+
studio = await import('@svgrid/enterprise/studio')
|
|
154
|
+
} catch {
|
|
155
|
+
throw new Error('--from needs @svgrid/enterprise; reinstall, or scaffold without --from and run `npx @svgrid/studio add --all --from ...` in the app.')
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const isPrisma = /\.prisma$/i.test(fromPath) || /\bmodel\s+\w+\s*\{/.test(src)
|
|
159
|
+
const schemas = isPrisma ? studio.introspectPrismaAll(src) : studio.introspectDrizzleAll(src)
|
|
160
|
+
if (!schemas.length) throw new Error(`--from: no ${isPrisma ? 'models' : 'tables'} found in ${fromPath}`)
|
|
161
|
+
|
|
162
|
+
const files = studio.emitStudioApp(schemas)
|
|
163
|
+
|
|
164
|
+
// Drop the seeded example screens (their schemas/data no longer exist).
|
|
165
|
+
for (const ex of ['customers', 'orders']) {
|
|
166
|
+
await rm(join(destDir, 'src', 'routes', ex), { recursive: true, force: true })
|
|
167
|
+
}
|
|
168
|
+
// Write generated files (overwrites schemas.ts / data.ts / +layout / +page).
|
|
169
|
+
for (const f of files) {
|
|
170
|
+
const full = join(destDir, f.path)
|
|
171
|
+
await mkdir(dirname(full), { recursive: true })
|
|
172
|
+
await writeFile(full, f.contents)
|
|
173
|
+
}
|
|
174
|
+
return schemas.map((s) => s.label ?? s.name)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function sanitizeName(name) {
|
|
178
|
+
return (
|
|
179
|
+
name
|
|
180
|
+
.trim()
|
|
181
|
+
.toLowerCase()
|
|
182
|
+
.replace(/[^a-z0-9._-]+/g, '-')
|
|
183
|
+
.replace(/^[-_.]+|[-_.]+$/g, '') || 'my-studio-app'
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function copyTemplate(srcDir, destDir) {
|
|
188
|
+
// Match only on the path RELATIVE to the template root - the absolute `src`
|
|
189
|
+
// includes the install location, which would match `node_modules` and skip
|
|
190
|
+
// the whole copy.
|
|
191
|
+
const skip = /(^|[\\/])(node_modules|\.svelte-kit|\.vercel|build|dist)([\\/]|$)/
|
|
192
|
+
await cp(srcDir, destDir, {
|
|
193
|
+
recursive: true,
|
|
194
|
+
filter: (src) => !skip.test(src.slice(srcDir.length)),
|
|
195
|
+
})
|
|
196
|
+
await renameBack(destDir)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function renameBack(dir) {
|
|
200
|
+
for (const entry of await readdir(dir)) {
|
|
201
|
+
const full = join(dir, entry)
|
|
202
|
+
if ((await stat(full)).isDirectory()) {
|
|
203
|
+
await renameBack(full)
|
|
204
|
+
} else if (RENAME_BACK.has(entry)) {
|
|
205
|
+
await rename(full, join(dir, RENAME_BACK.get(entry)))
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function setProjectName(destDir, name) {
|
|
211
|
+
const pkgPath = join(destDir, 'package.json')
|
|
212
|
+
if (!existsSync(pkgPath)) return
|
|
213
|
+
try {
|
|
214
|
+
const pkg = JSON.parse(await readFile(pkgPath, 'utf8'))
|
|
215
|
+
pkg.name = name
|
|
216
|
+
await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
|
|
217
|
+
} catch {
|
|
218
|
+
// leave the template's name if it isn't valid JSON for some reason
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function isEmptyDir(dir) {
|
|
223
|
+
if (!existsSync(dir)) return true
|
|
224
|
+
const entries = await readdir(dir)
|
|
225
|
+
return entries.filter((e) => e !== '.git').length === 0
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function main() {
|
|
229
|
+
const args = parseArgs(process.argv.slice(2))
|
|
230
|
+
if (args.help) return printHelp()
|
|
231
|
+
|
|
232
|
+
const interactive = stdin.isTTY && stdout.isTTY
|
|
233
|
+
let rl = null
|
|
234
|
+
const ask = async (q, def) => {
|
|
235
|
+
if (!interactive) return def
|
|
236
|
+
rl ??= createInterface({ input: stdin, output: stdout })
|
|
237
|
+
const a = (await rl.question(`${q} ${color('dim', `(${def})`)} `)).trim()
|
|
238
|
+
return a || def
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
stdout.write(`\n${color('bold', '◆ @svgrid/create-studio')} ${color('dim', 'a runnable data app')}\n\n`)
|
|
242
|
+
|
|
243
|
+
// 1. Target directory.
|
|
244
|
+
let target = args._[0]
|
|
245
|
+
if (!target) target = await ask('Project directory:', 'my-studio-app')
|
|
246
|
+
const destDir = resolve(process.cwd(), target)
|
|
247
|
+
const projectName = sanitizeName(basename(destDir))
|
|
248
|
+
|
|
249
|
+
// 2. Safety: don't clobber a non-empty directory.
|
|
250
|
+
if (!(await isEmptyDir(destDir)) && !args.force) {
|
|
251
|
+
const ok = await ask(
|
|
252
|
+
`\n${color('yellow', '!')} ${target} is not empty. Continue and overwrite files? (y/N)`,
|
|
253
|
+
'N',
|
|
254
|
+
)
|
|
255
|
+
if (!/^y(es)?$/i.test(ok)) {
|
|
256
|
+
if (rl) rl.close()
|
|
257
|
+
stdout.write(`${color('red', '✖')} Aborted.\n`)
|
|
258
|
+
process.exit(1)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (!existsSync(TEMPLATE_DIR)) {
|
|
263
|
+
if (rl) rl.close()
|
|
264
|
+
stdout.write(`\n${color('red', '✖')} Template is missing from this build.\n`)
|
|
265
|
+
process.exit(1)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// 3. Scaffold.
|
|
269
|
+
await mkdir(destDir, { recursive: true })
|
|
270
|
+
await copyTemplate(TEMPLATE_DIR, destDir)
|
|
271
|
+
await setProjectName(destDir, projectName)
|
|
272
|
+
if (rl) rl.close()
|
|
273
|
+
|
|
274
|
+
// 3b. Generate from a designer project (--project) or a schema file (--from).
|
|
275
|
+
let entities = null
|
|
276
|
+
let source = null
|
|
277
|
+
try {
|
|
278
|
+
if (args.project) {
|
|
279
|
+
entities = await applyProject(destDir, args.project)
|
|
280
|
+
source = args.project
|
|
281
|
+
} else if (args.from) {
|
|
282
|
+
entities = await applyFromSchema(destDir, args.from)
|
|
283
|
+
source = args.from
|
|
284
|
+
}
|
|
285
|
+
} catch (err) {
|
|
286
|
+
stdout.write(`\n${color('red', '✖')} ${err instanceof Error ? err.message : String(err)}\n`)
|
|
287
|
+
process.exit(1)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// 4. Next steps.
|
|
291
|
+
const rel = isAbsolute(target) || target.startsWith('.') ? target : `./${target}`
|
|
292
|
+
stdout.write(`\n${color('green', '✔')} Scaffolded ${color('bold', projectName)} into ${rel}\n`)
|
|
293
|
+
if (entities) {
|
|
294
|
+
stdout.write(` ${color('dim', 'from')} ${source} ${color('dim', '->')} ${entities.join(', ')}\n`)
|
|
295
|
+
}
|
|
296
|
+
stdout.write(`\n`)
|
|
297
|
+
stdout.write(`${color('bold', 'Next steps')}\n`)
|
|
298
|
+
stdout.write(` cd ${target}\n`)
|
|
299
|
+
stdout.write(` npm install\n`)
|
|
300
|
+
stdout.write(` npm run dev\n\n`)
|
|
301
|
+
stdout.write(`${color('dim', 'Docs:')} https://www.svgrid.com/docs/studio\n\n`)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
main().catch((err) => {
|
|
305
|
+
console.error(err)
|
|
306
|
+
process.exit(1)
|
|
307
|
+
})
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@svgrid/create-studio",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Scaffold a runnable SvGrid Studio data app in one command: npm create @svgrid/studio@latest",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "jQWidgets Ltd",
|
|
8
|
+
"homepage": "https://www.svgrid.com",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "https://github.com/sv-grid/sv-grid.git",
|
|
12
|
+
"directory": "packages/create-studio"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"svelte",
|
|
16
|
+
"sveltekit",
|
|
17
|
+
"data-grid",
|
|
18
|
+
"datagrid",
|
|
19
|
+
"data-app",
|
|
20
|
+
"crud",
|
|
21
|
+
"svgrid",
|
|
22
|
+
"studio",
|
|
23
|
+
"create",
|
|
24
|
+
"scaffold",
|
|
25
|
+
"starter",
|
|
26
|
+
"template"
|
|
27
|
+
],
|
|
28
|
+
"bin": "index.mjs",
|
|
29
|
+
"files": [
|
|
30
|
+
"index.mjs",
|
|
31
|
+
"templates"
|
|
32
|
+
],
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=18"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@svgrid/enterprise": "workspace:*"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# My Studio App
|
|
2
|
+
|
|
3
|
+
A data app built with [SvGrid Studio](https://www.svgrid.com/docs/studio).
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install
|
|
7
|
+
npm run dev # http://localhost:5173
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## How it works
|
|
11
|
+
|
|
12
|
+
Each screen is driven by one `EntitySchema`. A schema describes an entity's
|
|
13
|
+
fields once, and that single definition drives:
|
|
14
|
+
|
|
15
|
+
- the **grid** columns, sorting, filtering, and paging,
|
|
16
|
+
- the **edit form** (controls + validation), and
|
|
17
|
+
- the generated code when you scaffold from a real database.
|
|
18
|
+
|
|
19
|
+
### Where things live
|
|
20
|
+
|
|
21
|
+
| File | What it does |
|
|
22
|
+
| --- | --- |
|
|
23
|
+
| `src/lib/schemas.ts` | The entity definitions. Add a field here and it shows up in the grid and the form. |
|
|
24
|
+
| `src/lib/data.ts` | The data sources. Seeded in-memory by default; swap for a real adapter when ready. |
|
|
25
|
+
| `src/lib/EntityScreen.svelte` | The reusable grid + modal CRUD screen. |
|
|
26
|
+
| `src/routes/` | One route per entity, plus the nav shell (`+layout.svelte`) and home page. |
|
|
27
|
+
|
|
28
|
+
### Add a field
|
|
29
|
+
|
|
30
|
+
Open `src/lib/schemas.ts`, add an entry to a schema's `fields` array (and the
|
|
31
|
+
matching property on its TypeScript type), and it appears in both the grid and
|
|
32
|
+
the edit form on the next reload.
|
|
33
|
+
|
|
34
|
+
### Connect a real database
|
|
35
|
+
|
|
36
|
+
The in-memory sources in `src/lib/data.ts` are the only thing tied to fake data.
|
|
37
|
+
Replace `createInMemoryDataSource` with `createSqlDataSource` or
|
|
38
|
+
`createSupabaseDataSource`, or generate a connected entity:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
npx @svgrid/studio add public.invoices --db postgres --url "$DATABASE_URL"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Sorting, filtering, paging, and CRUD keep working unchanged.
|
|
45
|
+
|
|
46
|
+
## Scripts
|
|
47
|
+
|
|
48
|
+
- `npm run dev` - start the dev server
|
|
49
|
+
- `npm run build` - production build
|
|
50
|
+
- `npm run preview` - preview the production build
|
|
51
|
+
- `npm run check` - type-check with `svelte-check`
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
node_modules
|
|
2
|
+
|
|
3
|
+
# Output
|
|
4
|
+
.output
|
|
5
|
+
.vercel
|
|
6
|
+
.netlify
|
|
7
|
+
.wrangler
|
|
8
|
+
/.svelte-kit
|
|
9
|
+
/build
|
|
10
|
+
|
|
11
|
+
# OS / editor
|
|
12
|
+
.DS_Store
|
|
13
|
+
Thumbs.db
|
|
14
|
+
|
|
15
|
+
# Env
|
|
16
|
+
.env
|
|
17
|
+
.env.*
|
|
18
|
+
!.env.example
|
|
19
|
+
|
|
20
|
+
# Vite
|
|
21
|
+
vite.config.js.timestamp-*
|
|
22
|
+
vite.config.ts.timestamp-*
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
engine-strict=true
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "my-studio-app",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite dev",
|
|
8
|
+
"build": "vite build",
|
|
9
|
+
"preview": "vite preview",
|
|
10
|
+
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
|
|
11
|
+
},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"@sveltejs/adapter-auto": "^6.0.0",
|
|
14
|
+
"@sveltejs/kit": "^2.15.0",
|
|
15
|
+
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
|
16
|
+
"svelte": "^5.55.5",
|
|
17
|
+
"svelte-check": "^4.4.6",
|
|
18
|
+
"typescript": "^5.7.0",
|
|
19
|
+
"vite": "^8.0.10"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@svgrid/grid": "latest",
|
|
23
|
+
"@svgrid/enterprise": "latest"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/* App theme + chrome. The grid ships its own styles; these tokens drive the
|
|
2
|
+
* accent + surfaces (light / dark friendly), the nav shell, and the buttons. */
|
|
3
|
+
:root {
|
|
4
|
+
--sg-accent: #4f46e5;
|
|
5
|
+
color-scheme: light dark;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
* { box-sizing: border-box; }
|
|
9
|
+
html, body { margin: 0; height: 100%; }
|
|
10
|
+
body {
|
|
11
|
+
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
12
|
+
color: var(--sg-fg, #0f172a);
|
|
13
|
+
background: var(--sg-bg, #ffffff);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/* ---- App shell ---- */
|
|
17
|
+
.app { display: grid; grid-template-columns: 232px 1fr; min-height: 100vh; }
|
|
18
|
+
.app__nav {
|
|
19
|
+
display: flex; flex-direction: column; gap: 4px; padding: 18px 12px;
|
|
20
|
+
border-right: 1px solid var(--sg-border, #e6e8ec); background: var(--sg-header-bg, #f8fafc);
|
|
21
|
+
}
|
|
22
|
+
.app__brand { padding: 6px 10px 14px; font-weight: 750; font-size: 16px; color: inherit; text-decoration: none; letter-spacing: -0.01em; }
|
|
23
|
+
.app__links { display: flex; flex-direction: column; gap: 2px; }
|
|
24
|
+
.app__link { padding: 8px 11px; border-radius: 8px; color: var(--sg-muted, #475569); text-decoration: none; font-size: 14px; font-weight: 500; }
|
|
25
|
+
.app__link:hover { background: color-mix(in srgb, var(--sg-fg, #0f172a) 6%, transparent); color: var(--sg-fg, #0f172a); }
|
|
26
|
+
.app__link.is-active { background: color-mix(in srgb, var(--sg-accent) 14%, transparent); color: var(--sg-accent); font-weight: 600; }
|
|
27
|
+
.app__foot { margin-top: auto; padding: 10px; font-size: 11px; color: var(--sg-muted, #94a3b8); }
|
|
28
|
+
.app__main { padding: 24px 28px; min-width: 0; display: flex; flex-direction: column; gap: 14px; }
|
|
29
|
+
|
|
30
|
+
/* ---- Headings ---- */
|
|
31
|
+
.st__title { margin: 0; font-size: 22px; font-weight: 720; letter-spacing: -0.015em; }
|
|
32
|
+
.st__sub { margin: 0; font-size: 14px; line-height: 1.6; color: var(--sg-muted, #64748b); max-width: 74ch; }
|
|
33
|
+
.st__sub code { background: var(--sg-header-bg, #f1f5f9); padding: 1px 6px; border-radius: 5px; font-size: 0.9em; }
|
|
34
|
+
|
|
35
|
+
/* ---- Toolbar + buttons ---- */
|
|
36
|
+
.st__toolbar { display: flex; align-items: center; gap: 10px; }
|
|
37
|
+
.st-hint { font-size: 12.5px; color: var(--sg-muted, #94a3b8); }
|
|
38
|
+
.st-btn {
|
|
39
|
+
display: inline-flex; align-items: center; gap: 7px; padding: 8px 14px;
|
|
40
|
+
font: inherit; font-size: 13.5px; font-weight: 560; line-height: 1;
|
|
41
|
+
border: 1px solid var(--sg-border, #e6e8ec); border-radius: 10px;
|
|
42
|
+
background: var(--sg-bg, #fff); color: var(--sg-fg, inherit); cursor: pointer;
|
|
43
|
+
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
|
|
44
|
+
transition: background 0.15s, border-color 0.15s, filter 0.15s, transform 0.06s;
|
|
45
|
+
}
|
|
46
|
+
.st-btn:hover { background: color-mix(in srgb, var(--sg-fg, #0f172a) 5%, var(--sg-bg, #fff)); }
|
|
47
|
+
.st-btn:active { transform: translateY(0.5px); }
|
|
48
|
+
.st-btn:disabled { opacity: 0.5; cursor: default; box-shadow: none; }
|
|
49
|
+
.st-btn--primary {
|
|
50
|
+
border-color: transparent; color: #fff;
|
|
51
|
+
background: linear-gradient(180deg, color-mix(in srgb, var(--sg-accent) 88%, #fff), var(--sg-accent));
|
|
52
|
+
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.14), 0 8px 18px -9px color-mix(in srgb, var(--sg-accent) 65%, transparent);
|
|
53
|
+
}
|
|
54
|
+
.st-btn--primary:hover { filter: brightness(1.06); }
|
|
55
|
+
|
|
56
|
+
/* ---- Home cards ---- */
|
|
57
|
+
.home { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; margin-top: 6px; }
|
|
58
|
+
.home__card {
|
|
59
|
+
display: flex; flex-direction: column; gap: 6px; padding: 18px 18px 20px;
|
|
60
|
+
border: 1px solid var(--sg-border, #e6e8ec); border-radius: 14px; text-decoration: none; color: inherit;
|
|
61
|
+
background: var(--sg-bg, #fff); box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05); transition: border-color 0.15s, box-shadow 0.15s;
|
|
62
|
+
}
|
|
63
|
+
.home__card:hover { border-color: color-mix(in srgb, var(--sg-accent) 45%, var(--sg-border, #e6e8ec)); box-shadow: 0 8px 24px -14px rgba(15, 23, 42, 0.3); }
|
|
64
|
+
.home__card strong { font-size: 15px; }
|
|
65
|
+
.home__card span { font-size: 13px; color: var(--sg-muted, #64748b); line-height: 1.5; }
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
7
|
+
%sveltekit.head%
|
|
8
|
+
</head>
|
|
9
|
+
<body data-sveltekit-preload-data="hover">
|
|
10
|
+
<div style="display: contents">%sveltekit.body%</div>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
type Row = Record<string, unknown>
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<script lang="ts" generics="T extends Row">
|
|
6
|
+
/**
|
|
7
|
+
* A full CRUD screen for one entity: grid + create/edit modal + multi-select
|
|
8
|
+
* delete, over a ServerDataSource. Driven entirely by the EntitySchema.
|
|
9
|
+
*/
|
|
10
|
+
import { SvGrid, createServerDataSource, type ServerState, type ServerDataSource } from '@svgrid/grid'
|
|
11
|
+
import { SvGridEditPanel, schemaToColumns, type EntitySchema, type RelationLookup } from '@svgrid/enterprise'
|
|
12
|
+
|
|
13
|
+
let { schema, source, lookups, newId }: {
|
|
14
|
+
schema: EntitySchema<T>
|
|
15
|
+
source: ServerDataSource<T>
|
|
16
|
+
lookups?: Record<string, RelationLookup>
|
|
17
|
+
newId: () => string
|
|
18
|
+
} = $props()
|
|
19
|
+
|
|
20
|
+
const idField = schema.idField ?? 'id'
|
|
21
|
+
const columns = schemaToColumns(schema)
|
|
22
|
+
|
|
23
|
+
let view = $state<ServerState<T>>({
|
|
24
|
+
rows: [], total: 0, loading: false, saving: false, error: null,
|
|
25
|
+
pageIndex: 0, pageSize: 10, pageCount: 1, sortModel: [], filterModel: {},
|
|
26
|
+
})
|
|
27
|
+
const controller = createServerDataSource<T>(source, {
|
|
28
|
+
pageSize: 10, optimistic: true, getRowId: (r) => String(r[idField]), onChange: (s) => (view = s),
|
|
29
|
+
})
|
|
30
|
+
$effect(() => {
|
|
31
|
+
controller.refresh()
|
|
32
|
+
return () => controller.dispose()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
let editing = $state<T | null | undefined>(undefined)
|
|
36
|
+
let selected = $state<T[]>([])
|
|
37
|
+
|
|
38
|
+
async function save({ mode, id, values }: { mode: 'create' | 'edit'; id: string | null; values: Partial<T> }) {
|
|
39
|
+
if (mode === 'create') {
|
|
40
|
+
await controller.createRow({ [idField]: newId(), ...values } as Partial<T>)
|
|
41
|
+
controller.setPage(view.pageCount - 1)
|
|
42
|
+
} else if (id) {
|
|
43
|
+
await controller.updateRow(id, values)
|
|
44
|
+
}
|
|
45
|
+
editing = undefined
|
|
46
|
+
}
|
|
47
|
+
async function removeSelected() {
|
|
48
|
+
for (const row of selected) await controller.deleteRow(String(row[idField]))
|
|
49
|
+
selected = []
|
|
50
|
+
}
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
<div class="st__toolbar">
|
|
54
|
+
<button class="st-btn st-btn--primary" onclick={() => (editing = null)}>+ New {schema.label ?? schema.name}</button>
|
|
55
|
+
<button class="st-btn" disabled={selected.length === 0} onclick={removeSelected}>
|
|
56
|
+
Delete{selected.length ? ` (${selected.length})` : ''}
|
|
57
|
+
</button>
|
|
58
|
+
<span class="st-hint">Double-click a row to edit</span>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<SvGrid
|
|
62
|
+
data={view.rows}
|
|
63
|
+
{columns}
|
|
64
|
+
loading={view.loading}
|
|
65
|
+
fitColumns
|
|
66
|
+
enableRowSummaries={false}
|
|
67
|
+
showRowSelection
|
|
68
|
+
sortable
|
|
69
|
+
externalSort
|
|
70
|
+
onSortingChange={(s) => controller.setSort(s)}
|
|
71
|
+
filterable
|
|
72
|
+
filterMode="row"
|
|
73
|
+
showGlobalFilter
|
|
74
|
+
externalFilter
|
|
75
|
+
onFiltersChange={(f) =>
|
|
76
|
+
controller.setFilter({
|
|
77
|
+
global: f.global || undefined,
|
|
78
|
+
columns: Object.fromEntries(
|
|
79
|
+
f.columns.map((c) => [c.id, { operator: c.operator, value: c.value, valueTo: c.valueTo, selectedValues: c.selectedValues }]),
|
|
80
|
+
),
|
|
81
|
+
})}
|
|
82
|
+
onRowDoubleClick={(e) => (editing = e.row)}
|
|
83
|
+
onRowSelectionChange={(_sel, rows) => (selected = rows)}
|
|
84
|
+
showPagination
|
|
85
|
+
externalPagination
|
|
86
|
+
rowCount={view.total}
|
|
87
|
+
pageIndex={view.pageIndex}
|
|
88
|
+
pageSize={view.pageSize}
|
|
89
|
+
onPaginationChange={({ pageIndex, pageSize }) => (pageSize !== view.pageSize ? controller.setPageSize(pageSize) : controller.setPage(pageIndex))}
|
|
90
|
+
containerHeight={460}
|
|
91
|
+
/>
|
|
92
|
+
|
|
93
|
+
{#if editing !== undefined}
|
|
94
|
+
<SvGridEditPanel {schema} row={editing} presentation="modal" persistKey={schema.name} {lookups} onSubmit={save} onCancel={() => (editing = undefined)} />
|
|
95
|
+
{/if}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data sources. This starter runs on seeded in-memory sources, so it works with
|
|
3
|
+
* no backend. To point an entity at a real database, swap `createInMemoryDataSource`
|
|
4
|
+
* for `createSqlDataSource` / `createSupabaseDataSource` (or scaffold a connected
|
|
5
|
+
* one with `npx @svgrid/studio add <table> --db postgres --url ...`) - the grid,
|
|
6
|
+
* form, sorting, filtering, and paging keep working unchanged.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
createInMemoryDataSource,
|
|
10
|
+
createRelationLookup,
|
|
11
|
+
type ServerDataSource,
|
|
12
|
+
} from '@svgrid/enterprise'
|
|
13
|
+
import { customerSchema, orderSchema, type Customer, type Order } from './schemas'
|
|
14
|
+
|
|
15
|
+
const customerSeed: Customer[] = [
|
|
16
|
+
{ id: 'c1', name: 'Ada Lovelace', email: 'ada@analytic.io', tier: 'enterprise', mrr: 1200, active: true },
|
|
17
|
+
{ id: 'c2', name: 'Alan Turing', email: 'alan@bletchley.uk', tier: 'pro', mrr: 240, active: true },
|
|
18
|
+
{ id: 'c3', name: 'Grace Hopper', email: 'grace@navy.mil', tier: 'enterprise', mrr: 980, active: true },
|
|
19
|
+
{ id: 'c4', name: 'Linus Torvalds', email: 'linus@kernel.org', tier: 'pro', mrr: 360, active: true },
|
|
20
|
+
{ id: 'c5', name: 'Barbara Liskov', email: 'barbara@substitution.dev', tier: 'free', mrr: 0, active: false },
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
const orderSeed: Order[] = [
|
|
24
|
+
{ id: 'o1', ref: 'INV-1001', customerId: 'c1', amount: 1200, status: 'paid' },
|
|
25
|
+
{ id: 'o2', ref: 'INV-1002', customerId: 'c2', amount: 240, status: 'paid' },
|
|
26
|
+
{ id: 'o3', ref: 'INV-1003', customerId: 'c1', amount: 300, status: 'draft' },
|
|
27
|
+
{ id: 'o4', ref: 'INV-1004', customerId: 'c3', amount: 980, status: 'refunded' },
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
export const customersSource = createInMemoryDataSource(customerSeed, customerSchema)
|
|
31
|
+
|
|
32
|
+
// Orders reference a customer. Enrich every method's rows with the customer
|
|
33
|
+
// NAME so the grid shows it (the form stores the id via the lookup below).
|
|
34
|
+
const nameById = () => new Map((customersSource.rows() as Customer[]).map((c) => [c.id, c.name]))
|
|
35
|
+
const withCustomer = (r: Order): Order => ({ ...r, customer: nameById().get(r.customerId) ?? '' })
|
|
36
|
+
const rawOrders = createInMemoryDataSource(orderSeed, orderSchema)
|
|
37
|
+
export const ordersSource: ServerDataSource<Order> = {
|
|
38
|
+
...rawOrders,
|
|
39
|
+
async getRows(req) {
|
|
40
|
+
const res = await rawOrders.getRows(req)
|
|
41
|
+
return { ...res, rows: res.rows.map(withCustomer) }
|
|
42
|
+
},
|
|
43
|
+
createRow: (input) => rawOrders.createRow(input).then(withCustomer),
|
|
44
|
+
updateRow: (id, patch) => rawOrders.updateRow(id, patch).then(withCustomer),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// A searchable Customer picker for the order form.
|
|
48
|
+
export const customerLookup = createRelationLookup<Customer>({
|
|
49
|
+
source: customersSource,
|
|
50
|
+
schema: customerSchema,
|
|
51
|
+
labelField: 'name',
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
let seq = customerSeed.length + orderSeed.length
|
|
55
|
+
export const nextId = (prefix: string) => `${prefix}${++seq}`
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One EntitySchema per entity drives everything: the grid columns, the edit
|
|
3
|
+
* form (with validation), and - when you scaffold from a real database - the
|
|
4
|
+
* generated code. Add a field here and it shows up in the grid and the form.
|
|
5
|
+
*/
|
|
6
|
+
import type { EntitySchema } from '@svgrid/enterprise'
|
|
7
|
+
|
|
8
|
+
export type Customer = {
|
|
9
|
+
id: string
|
|
10
|
+
name: string
|
|
11
|
+
email: string
|
|
12
|
+
tier: string
|
|
13
|
+
mrr: number
|
|
14
|
+
active: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type Order = {
|
|
18
|
+
id: string
|
|
19
|
+
ref: string
|
|
20
|
+
customerId: string
|
|
21
|
+
customer?: string // resolved customer name, for the grid (see data.ts)
|
|
22
|
+
amount: number
|
|
23
|
+
status: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const customerSchema: EntitySchema<Customer> = {
|
|
27
|
+
name: 'customers',
|
|
28
|
+
label: 'Customer',
|
|
29
|
+
idField: 'id',
|
|
30
|
+
fields: [
|
|
31
|
+
{ field: 'id', type: 'text', primaryKey: true, readonly: true, hidden: { form: true } },
|
|
32
|
+
{ field: 'name', type: 'text', required: true, minLength: 2 },
|
|
33
|
+
{ field: 'email', type: 'text', label: 'Email', required: true, format: 'email' },
|
|
34
|
+
{ field: 'tier', type: 'enum', options: [
|
|
35
|
+
{ value: 'free', label: 'Free' },
|
|
36
|
+
{ value: 'pro', label: 'Pro' },
|
|
37
|
+
{ value: 'enterprise', label: 'Enterprise' },
|
|
38
|
+
] },
|
|
39
|
+
{ field: 'mrr', type: 'number', label: 'MRR ($)', min: 0 },
|
|
40
|
+
{ field: 'active', type: 'boolean' },
|
|
41
|
+
],
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const orderSchema: EntitySchema<Order> = {
|
|
45
|
+
name: 'orders',
|
|
46
|
+
label: 'Order',
|
|
47
|
+
idField: 'id',
|
|
48
|
+
fields: [
|
|
49
|
+
{ field: 'id', type: 'text', primaryKey: true, readonly: true, hidden: { form: true } },
|
|
50
|
+
{ field: 'ref', type: 'text', label: 'Reference', required: true },
|
|
51
|
+
// A foreign key: a searchable Customer picker in the form, the name in the grid.
|
|
52
|
+
{ field: 'customerId', type: 'relation', label: 'Customer', relation: { entity: 'customers', labelField: 'name' }, hidden: { grid: true } },
|
|
53
|
+
{ field: 'customer', type: 'text', label: 'Customer', readonly: true, hidden: { form: true } },
|
|
54
|
+
{ field: 'amount', type: 'number', label: 'Amount ($)', min: 0 },
|
|
55
|
+
{ field: 'status', type: 'enum', options: [
|
|
56
|
+
{ value: 'draft', label: 'Draft' },
|
|
57
|
+
{ value: 'paid', label: 'Paid' },
|
|
58
|
+
{ value: 'refunded', label: 'Refunded' },
|
|
59
|
+
] },
|
|
60
|
+
],
|
|
61
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import '../app.css'
|
|
3
|
+
import { page } from '$app/stores'
|
|
4
|
+
|
|
5
|
+
let { children } = $props()
|
|
6
|
+
|
|
7
|
+
const nav = [
|
|
8
|
+
{ href: '/', label: 'Home' },
|
|
9
|
+
{ href: '/customers', label: 'Customers' },
|
|
10
|
+
{ href: '/orders', label: 'Orders' },
|
|
11
|
+
]
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<div class="app">
|
|
15
|
+
<aside class="app__nav">
|
|
16
|
+
<a class="app__brand" href="/">My Studio App</a>
|
|
17
|
+
<nav class="app__links">
|
|
18
|
+
{#each nav as item (item.href)}
|
|
19
|
+
<a class="app__link" class:is-active={$page.url.pathname === item.href} href={item.href}>{item.label}</a>
|
|
20
|
+
{/each}
|
|
21
|
+
</nav>
|
|
22
|
+
<span class="app__foot">Built with SvGrid Studio</span>
|
|
23
|
+
</aside>
|
|
24
|
+
<main class="app__main">
|
|
25
|
+
{@render children()}
|
|
26
|
+
</main>
|
|
27
|
+
</div>
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// This starter runs on in-memory data, so render it as a client SPA (the seeded
|
|
2
|
+
// sources are module singletons that persist as you navigate). When you move an
|
|
3
|
+
// entity to a real database, you can drop this and use SSR / server routes.
|
|
4
|
+
export const ssr = false
|
|
5
|
+
export const prerender = false
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
const entities = [
|
|
3
|
+
{ href: '/customers', label: 'Customers', desc: 'Tiers, MRR, and status. Sort, filter, edit inline in a modal.' },
|
|
4
|
+
{ href: '/orders', label: 'Orders', desc: 'Linked to a customer via a searchable lookup field.' },
|
|
5
|
+
]
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<h1 class="st__title">Welcome to your data app</h1>
|
|
9
|
+
<p class="st__sub">
|
|
10
|
+
Built with SvGrid Studio. Each screen is driven by one <code>EntitySchema</code>.
|
|
11
|
+
It runs on seeded in-memory data - point an entity at a real database, or add one with
|
|
12
|
+
<code>npx @svgrid/studio add <table> --db postgres --url ...</code>
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
<div class="home">
|
|
16
|
+
{#each entities as e (e.href)}
|
|
17
|
+
<a class="home__card" href={e.href}>
|
|
18
|
+
<strong>{e.label}</strong>
|
|
19
|
+
<span>{e.desc}</span>
|
|
20
|
+
</a>
|
|
21
|
+
{/each}
|
|
22
|
+
</div>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import EntityScreen from '$lib/EntityScreen.svelte'
|
|
3
|
+
import { customerSchema } from '$lib/schemas'
|
|
4
|
+
import { customersSource, nextId } from '$lib/data'
|
|
5
|
+
</script>
|
|
6
|
+
|
|
7
|
+
<h1 class="st__title">Customers</h1>
|
|
8
|
+
<EntityScreen schema={customerSchema} source={customersSource} newId={() => nextId('c')} />
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import EntityScreen from '$lib/EntityScreen.svelte'
|
|
3
|
+
import { orderSchema } from '$lib/schemas'
|
|
4
|
+
import { ordersSource, customerLookup, nextId } from '$lib/data'
|
|
5
|
+
</script>
|
|
6
|
+
|
|
7
|
+
<h1 class="st__title">Orders</h1>
|
|
8
|
+
<EntityScreen
|
|
9
|
+
schema={orderSchema}
|
|
10
|
+
source={ordersSource}
|
|
11
|
+
lookups={{ customerId: customerLookup }}
|
|
12
|
+
newId={() => nextId('o')}
|
|
13
|
+
/>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import adapter from '@sveltejs/adapter-auto'
|
|
2
|
+
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
|
|
3
|
+
|
|
4
|
+
/** @type {import('@sveltejs/kit').Config} */
|
|
5
|
+
const config = {
|
|
6
|
+
preprocess: vitePreprocess(),
|
|
7
|
+
kit: {
|
|
8
|
+
adapter: adapter(),
|
|
9
|
+
},
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default config
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "./.svelte-kit/tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"allowJs": true,
|
|
5
|
+
"checkJs": true,
|
|
6
|
+
"esModuleInterop": true,
|
|
7
|
+
"forceConsistentCasingInFileNames": true,
|
|
8
|
+
"resolveJsonModule": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"sourceMap": true,
|
|
11
|
+
"strict": true,
|
|
12
|
+
"moduleResolution": "bundler"
|
|
13
|
+
}
|
|
14
|
+
}
|