From 4a61c5571502711d20e004829cc7a3f899d8806b Mon Sep 17 00:00:00 2001 From: Mikers Date: Thu, 5 Feb 2026 11:27:42 -1000 Subject: [PATCH] feat(studio): switch to form-only schema builder UI --- packages/cli/src/index.ts | 491 ++++++++++++++++--- test/integration/testStudioCliIntegration.js | 59 ++- 2 files changed, 444 insertions(+), 106 deletions(-) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 51e8670..46239cb 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -70,32 +70,32 @@ function formatIssues(issues: Issue[]): string { .join('\n'); } -function defaultStudioSchemaText(): string { - return JSON.stringify( - { - thsVersion: '2025-12', - schemaVersion: '0.0.1', - app: { - name: 'My App', - slug: 'my-app', - features: { uploads: false, onChainIndexing: true } - }, - collections: [ - { - name: 'Item', - fields: [{ name: 'title', type: 'string', required: true }], - createRules: { required: ['title'], access: 'public' }, - visibilityRules: { gets: ['title'], access: 'public' }, - updateRules: { mutable: ['title'], access: 'owner' }, - deleteRules: { softDelete: true, access: 'owner' }, - transferRules: { access: 'owner' }, - indexes: { unique: [], index: [] } - } - ] +function defaultStudioFormState(): ThsSchema { + return { + thsVersion: '2025-12', + schemaVersion: '0.0.1', + app: { + name: 'My App', + slug: 'my-app', + description: '', + features: { uploads: false, onChainIndexing: true, indexer: false, delegation: false } }, - null, - 2 - ); + collections: [ + { + name: 'Item', + plural: 'Items', + fields: [{ name: 'title', type: 'string', required: true }], + createRules: { required: ['title'], access: 'public', auto: {} }, + visibilityRules: { gets: ['title'], access: 'public' }, + updateRules: { mutable: ['title'], access: 'owner', optimisticConcurrency: false }, + deleteRules: { softDelete: true, access: 'owner' }, + transferRules: { access: 'owner' }, + indexes: { unique: [], index: [] }, + relations: [] + } + ], + metadata: {} + }; } function buildStudioPreview(schema: ThsSchema): { @@ -139,37 +139,122 @@ function buildStudioPreview(schema: ThsSchema): { }; } -function validateStudioSchemaText(schemaText: string): { +function normalizeStudioFormState(input: any): ThsSchema { + const state = input && typeof input === 'object' ? input : {}; + const appIn = state.app && typeof state.app === 'object' ? state.app : {}; + const collectionsIn = Array.isArray(state.collections) ? state.collections : []; + const metadata = state.metadata && typeof state.metadata === 'object' ? state.metadata : {}; + + const out: ThsSchema = { + thsVersion: String(state.thsVersion ?? '2025-12'), + schemaVersion: String(state.schemaVersion ?? '0.0.1'), + app: { + name: String(appIn.name ?? 'My App'), + slug: String(appIn.slug ?? 'my-app'), + description: appIn.description == null ? undefined : String(appIn.description), + theme: appIn.theme && typeof appIn.theme === 'object' ? appIn.theme : undefined, + features: { + uploads: Boolean(appIn.features?.uploads), + onChainIndexing: Boolean(appIn.features?.onChainIndexing), + indexer: Boolean(appIn.features?.indexer), + delegation: Boolean(appIn.features?.delegation) + } + }, + collections: collectionsIn.map((c: any) => { + const fields = Array.isArray(c?.fields) ? c.fields : []; + const createRules = c?.createRules && typeof c.createRules === 'object' ? c.createRules : {}; + const visibilityRules = c?.visibilityRules && typeof c.visibilityRules === 'object' ? c.visibilityRules : {}; + const updateRules = c?.updateRules && typeof c.updateRules === 'object' ? c.updateRules : {}; + const deleteRules = c?.deleteRules && typeof c.deleteRules === 'object' ? c.deleteRules : {}; + const transferRules = c?.transferRules && typeof c.transferRules === 'object' ? c.transferRules : null; + const indexes = c?.indexes && typeof c.indexes === 'object' ? c.indexes : {}; + const relations = Array.isArray(c?.relations) ? c.relations : []; + + return { + name: String(c?.name ?? ''), + plural: c?.plural == null ? undefined : String(c.plural), + fields: fields.map((f: any) => ({ + name: String(f?.name ?? ''), + type: String(f?.type ?? 'string') as any, + required: Boolean(f?.required), + decimals: f?.decimals == null || f?.decimals === '' ? undefined : Number(f.decimals), + default: f?.default, + validation: f?.validation && typeof f.validation === 'object' ? f.validation : undefined, + ui: f?.ui && typeof f.ui === 'object' ? f.ui : undefined + })), + createRules: { + required: Array.isArray(createRules.required) ? createRules.required.map((x: any) => String(x)) : [], + auto: createRules.auto && typeof createRules.auto === 'object' ? createRules.auto : undefined, + payment: + createRules.payment && typeof createRules.payment === 'object' + ? { + asset: String(createRules.payment.asset ?? 'native') as 'native', + amountWei: String(createRules.payment.amountWei ?? '0') + } + : undefined, + access: String(createRules.access ?? 'public') as any + }, + visibilityRules: { + gets: Array.isArray(visibilityRules.gets) ? visibilityRules.gets.map((x: any) => String(x)) : [], + access: String(visibilityRules.access ?? 'public') as any + }, + updateRules: { + mutable: Array.isArray(updateRules.mutable) ? updateRules.mutable.map((x: any) => String(x)) : [], + access: String(updateRules.access ?? 'owner') as any, + optimisticConcurrency: Boolean(updateRules.optimisticConcurrency) + }, + deleteRules: { + softDelete: Boolean(deleteRules.softDelete), + access: String(deleteRules.access ?? 'owner') as any + }, + transferRules: transferRules + ? { + access: String(transferRules.access ?? 'owner') as any + } + : undefined, + indexes: { + unique: Array.isArray(indexes.unique) + ? indexes.unique.map((u: any) => ({ + field: String(u?.field ?? ''), + scope: u?.scope == null ? undefined : String(u.scope) as any + })) + : [], + index: Array.isArray(indexes.index) + ? indexes.index.map((idx: any) => ({ + field: String(idx?.field ?? '') + })) + : [] + }, + relations: relations.map((r: any) => ({ + field: String(r?.field ?? ''), + to: String(r?.to ?? ''), + enforce: Boolean(r?.enforce), + reverseIndex: Boolean(r?.reverseIndex) + })), + ui: c?.ui && typeof c.ui === 'object' ? c.ui : undefined + }; + }), + metadata + }; + + return out; +} + +function validateStudioFormState(formState: any): { ok: boolean; issues: Issue[]; schemaHash: string | null; + schema: ThsSchema | null; preview: ReturnType | null; } { - let parsed: unknown; - try { - parsed = JSON.parse(schemaText); - } catch (e: any) { - return { - ok: false, - issues: [ - { - code: 'json.parse', - message: String(e?.message ?? e), - path: '$', - severity: 'error' - } - ], - schemaHash: null, - preview: null - }; - } - - const structural = validateThsStructural(parsed); + const normalized = normalizeStudioFormState(formState); + const structural = validateThsStructural(normalized); if (!structural.ok) { return { ok: false, issues: structural.issues, schemaHash: null, + schema: null, preview: null }; } @@ -182,6 +267,7 @@ function validateStudioSchemaText(schemaText: string): { ok: !hasErrors, issues, schemaHash: computeSchemaHash(schema), + schema, preview: buildStudioPreview(schema) }; } @@ -199,12 +285,18 @@ function renderStudioHtml(): string { * { box-sizing: border-box; } body { margin:0; font-family: ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif; background: radial-gradient(circle at 10% 10%, #1f3559, #0b1220 55%); color: var(--text); } .wrap { max-width: 1400px; margin: 0 auto; padding: 20px; } - .row { display:grid; grid-template-columns: 1.4fr 1fr; gap: 14px; } + .row { display:grid; grid-template-columns: 1.6fr 1fr; gap: 14px; } .panel { background: linear-gradient(180deg, rgba(255,255,255,0.03), rgba(0,0,0,0.12)); border:1px solid rgba(255,255,255,0.12); border-radius: 14px; padding: 14px; } .title { margin:0 0 10px 0; font-size: 18px; font-weight: 700; } .muted { color: var(--muted); font-size: 13px; } - textarea { width:100%; min-height: 70vh; border-radius: 10px; border:1px solid rgba(255,255,255,0.18); background: #0a1426; color: #eaf2ff; padding: 10px; font-family: ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace; font-size: 13px; line-height: 1.35; } - input[type=text] { width: 100%; border-radius: 8px; border:1px solid rgba(255,255,255,0.18); background:#0a1426; color:#eaf2ff; padding: 8px; } + textarea { width:100%; min-height: 120px; border-radius: 10px; border:1px solid rgba(255,255,255,0.18); background: #0a1426; color: #eaf2ff; padding: 10px; font-family: ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace; font-size: 13px; line-height: 1.35; } + input[type=text], input[type=number], select { width: 100%; border-radius: 8px; border:1px solid rgba(255,255,255,0.18); background:#0a1426; color:#eaf2ff; padding: 8px; } + label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 4px; } + .grid2 { display:grid; grid-template-columns: 1fr 1fr; gap:8px; } + .grid3 { display:grid; grid-template-columns: 1fr 1fr 1fr; gap:8px; } + .card { border:1px solid rgba(255,255,255,0.12); border-radius: 10px; padding: 8px; margin-top: 8px; background: rgba(0,0,0,0.2); } + .sectionTitle { font-size: 14px; font-weight: 700; margin-top: 10px; } + .stack { display:flex; flex-direction:column; gap:8px; } .toolbar { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:10px; } button { border:1px solid rgba(255,255,255,0.2); color:#fff; background:#1e3357; border-radius:8px; padding:8px 10px; cursor:pointer; } button:hover { filter: brightness(1.08); } @@ -223,16 +315,16 @@ function renderStudioHtml(): string {
Edit THS JSON, validate/lint in real-time, save/load files, and preview routes + contract surface.
-

Schema JSON

+

Schema Builder

+
-
- +

Validation + Preview

@@ -250,12 +342,16 @@ function renderStudioHtml(): string {