@svgrid/enterprise 2.2.1 → 2.3.1
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 +95 -81
- package/dist/cdn/svgrid-enterprise.svelte-external.js +26642 -11252
- package/dist/designer/assets/GridMenus-BtWVk9Ab.js +7 -0
- package/dist/designer/assets/SvGridChartPanel-DWlBO2SD.js +10 -0
- package/dist/designer/assets/SvGridChartView-8c8Mb4j8.js +1 -0
- package/dist/designer/assets/SvGridChartView-DX9HfkBR.css +1 -0
- package/dist/designer/assets/index-DsDgp9Xq.js +78758 -0
- package/dist/designer/assets/index-tTY_Dx4P.css +1 -0
- package/dist/designer/assets/jszip.min-fkJdmAmj.js +2 -0
- package/dist/designer/assets/pdfmake-DeCsnyl9.js +242 -0
- package/dist/designer/assets/smart.export-BZlSCE8T.js +35 -0
- package/dist/designer/assets/vfs_fonts-eX2NpmfX.js +1 -0
- package/dist/designer/index.html +13 -0
- package/dist/node/studio.js +15304 -2708
- package/package.json +35 -11
- package/src/SvAlertRuleEditor.svelte +294 -0
- package/src/SvAlertsManager.svelte +210 -0
- package/src/SvAlertsPanel.svelte +129 -0
- package/src/SvBoard.svelte +3 -1
- package/src/SvExpressionEditor.svelte +341 -0
- package/src/SvGridAlerts.dom.test.ts +113 -0
- package/src/SvGridAlerts.svelte +265 -0
- package/src/SvGridBoard.svelte +2358 -0
- package/src/SvGridEditPanel.svelte +849 -794
- package/src/SvGridScheduler.svelte +5358 -4410
- package/src/SvPivotDesigner.svelte +3 -3
- package/src/SvRecordDetail.svelte +6 -2
- package/src/SvSchedule.svelte +3 -1
- package/src/{ai-export-pdf.test.ts → ai-export-pdf.dom.test.ts} +77 -74
- package/src/{ai-export-xlsx.test.ts → ai-export-xlsx.dom.test.ts} +90 -87
- package/src/{ai-export.test.ts → ai-export.dom.test.ts} +114 -110
- package/src/alerts/alert-engine-attach.ts +165 -0
- package/src/alerts/alert-engine.test.ts +135 -0
- package/src/alerts/alert-engine.ts +260 -0
- package/src/alerts/alert-formats.test.ts +87 -0
- package/src/alerts/alert-formats.ts +77 -0
- package/src/alerts/alert-observer.test.ts +189 -0
- package/src/alerts/alert-observer.ts +208 -0
- package/src/alerts/alert-scheduler.ts +96 -0
- package/src/alerts/alert-storage.test.ts +54 -0
- package/src/alerts/alert-storage.ts +116 -0
- package/src/alerts/alert-store.svelte.ts +80 -0
- package/src/alerts/alert-types.ts +94 -0
- package/src/alerts.ts +28 -0
- package/src/board.dom.test.ts +941 -0
- package/src/board.ts +36 -0
- package/src/export-ooxml.ts +4 -0
- package/src/export-xls.ts +3 -0
- package/src/export.ts +6 -0
- package/src/expressions/evaluate.test.ts +111 -0
- package/src/expressions/evaluate.ts +237 -0
- package/src/expressions/expression-columns.ts +133 -0
- package/src/expressions/expression-types.ts +84 -0
- package/src/expressions/parse.test.ts +106 -0
- package/src/expressions/parse.ts +610 -0
- package/src/import.test.ts +1 -1
- package/src/import.ts +3 -1
- package/src/index.ts +192 -46
- package/src/install.ts +12 -2
- package/src/pivot-enable.ts +44 -0
- package/src/pivot.test.ts +0 -1
- package/src/scheduler-assignments.test.ts +97 -0
- package/src/scheduler-assignments.ts +134 -0
- package/src/scheduler-axis.test.ts +108 -0
- package/src/scheduler-axis.ts +238 -0
- package/src/scheduler-booking.test.ts +57 -0
- package/src/scheduler-booking.ts +63 -0
- package/src/scheduler-config.ts +179 -0
- package/src/scheduler-dependencies.test.ts +155 -0
- package/src/scheduler-dependencies.ts +223 -0
- package/src/scheduler-freebusy.test.ts +41 -0
- package/src/scheduler-freebusy.ts +36 -0
- package/src/scheduler-heatmap.test.ts +39 -0
- package/src/scheduler-heatmap.ts +55 -0
- package/src/scheduler-resource-tree.test.ts +84 -0
- package/src/scheduler-resource-tree.ts +107 -0
- package/src/scheduler-slots.test.ts +53 -0
- package/src/scheduler-slots.ts +94 -0
- package/src/scheduler-summary.test.ts +47 -0
- package/src/scheduler-summary.ts +81 -0
- package/src/schema-designer.ts +1 -1
- package/src/sources/index.ts +1 -1
- package/src/sources/introspect-supabase.test.ts +13 -1
- package/src/sources/introspect-supabase.ts +20 -0
- package/src/studio/copilot-core.test.ts +45 -0
- package/src/studio/copilot-core.ts +65 -0
- package/src/studio/deploy-cli.test.ts +56 -0
- package/src/studio/deploy-cli.ts +100 -0
- package/src/studio/emit-project.test.ts +800 -24
- package/src/studio/emit-project.ts +1307 -135
- package/src/studio/emit-schema.test.ts +17 -0
- package/src/studio/emit-schema.ts +182 -41
- package/src/studio/index.ts +59 -1
- package/src/studio/init-flow.test.ts +239 -0
- package/src/studio/init-flow.ts +358 -0
- package/src/studio/introspect-openapi.test.ts +84 -0
- package/src/studio/introspect-openapi.ts +252 -0
- package/src/studio/project.test.ts +57 -1
- package/src/studio/project.ts +302 -8
- package/src/studio/samples/crm.ts +282 -258
- package/src/studio/samples/datasets.test.ts +84 -0
- package/src/studio/samples/datasets.ts +340 -0
- package/src/studio/samples/fleet.ts +213 -186
- package/src/studio/samples/insurance.ts +223 -195
- package/src/studio/samples/inventory.ts +213 -182
- package/src/studio/samples/live-data.test.ts +99 -98
- package/src/studio/samples/live-data.ts +8 -10
- package/src/studio/samples/projects.ts +212 -190
- package/src/studio/samples/samples.test.ts +268 -216
- package/src/studio/samples/shared.ts +22 -145
- package/src/studio/samples/starter.ts +251 -0
- package/src/studio/samples/support.ts +209 -184
- package/src/studio/screen-suites.test.ts +226 -0
- package/src/studio/screen-suites.ts +445 -0
- package/src/studio/ui-components-surface.test.ts +185 -0
- package/src/studio/ui-components.generated.ts +7154 -0
- package/src/studio/ui-components.ts +273 -3
- package/src/sveltekit/index.ts +1 -0
- package/src/sveltekit/sql-source.test.ts +13 -0
- package/src/sveltekit/sql-source.ts +12 -7
- package/src/sveltekit/transport-scope.test.ts +125 -0
- package/src/sveltekit/transport.ts +76 -2
- package/src/upgrade-prompt.ts +2 -2
- package/src/watermark.ts +2 -2
- package/src/ai.test.ts +0 -522
- package/src/ai.ts +0 -1388
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { introspectOpenApi } from './introspect-openapi'
|
|
3
|
+
|
|
4
|
+
// A petstore-ish spec: bare-array list, $ref item schema, an enum, a nested $ref
|
|
5
|
+
// relation (pet.category -> Category), and a wrapped-list resource (orders).
|
|
6
|
+
const spec = {
|
|
7
|
+
openapi: '3.0.3',
|
|
8
|
+
servers: [{ url: 'https://api.example.com/v1/' }],
|
|
9
|
+
paths: {
|
|
10
|
+
'/pets': {
|
|
11
|
+
get: { responses: { '200': { content: { 'application/json': { schema: { type: 'array', items: { $ref: '#/components/schemas/Pet' } } } } } }, parameters: [{ name: 'status', in: 'query', schema: { type: 'string' } }] },
|
|
12
|
+
post: { requestBody: { content: { 'application/json': { schema: { $ref: '#/components/schemas/Pet' } } } } },
|
|
13
|
+
},
|
|
14
|
+
'/pets/{petId}': {
|
|
15
|
+
get: { responses: { '200': { content: { 'application/json': { schema: { $ref: '#/components/schemas/Pet' } } } } } },
|
|
16
|
+
put: { responses: { '200': {} } },
|
|
17
|
+
delete: { responses: { '204': {} } },
|
|
18
|
+
},
|
|
19
|
+
'/categories': { get: { responses: { '200': { content: { 'application/json': { schema: { type: 'array', items: { $ref: '#/components/schemas/Category' } } } } } } } },
|
|
20
|
+
'/orders': { get: { responses: { '200': { content: { 'application/json': { schema: { $ref: '#/components/schemas/OrderList' } } } } } } },
|
|
21
|
+
},
|
|
22
|
+
components: {
|
|
23
|
+
schemas: {
|
|
24
|
+
Pet: {
|
|
25
|
+
type: 'object',
|
|
26
|
+
required: ['name'],
|
|
27
|
+
properties: {
|
|
28
|
+
id: { type: 'integer' },
|
|
29
|
+
name: { type: 'string' },
|
|
30
|
+
status: { type: 'string', enum: ['available', 'pending', 'sold'] },
|
|
31
|
+
bornOn: { type: 'string', format: 'date' },
|
|
32
|
+
category: { $ref: '#/components/schemas/Category' },
|
|
33
|
+
tags: { type: 'array', items: { type: 'string' } },
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
Category: { type: 'object', properties: { id: { type: 'integer' }, name: { type: 'string' } } },
|
|
37
|
+
Order: { type: 'object', properties: { id: { type: 'integer' }, total: { type: 'number' }, placedAt: { type: 'string', format: 'date-time' } } },
|
|
38
|
+
OrderList: { type: 'object', properties: { data: { type: 'array', items: { $ref: '#/components/schemas/Order' } }, total: { type: 'integer' } } },
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('introspectOpenApi', () => {
|
|
44
|
+
const out = introspectOpenApi(JSON.stringify(spec))
|
|
45
|
+
|
|
46
|
+
it('groups paths into one entity + one REST source per resource', () => {
|
|
47
|
+
expect(out.entities.map((e) => e.name).sort()).toEqual(['category', 'order', 'pet'])
|
|
48
|
+
const pet = out.sources.pet
|
|
49
|
+
expect(pet).toMatchObject({ kind: 'rest', baseUrl: 'https://api.example.com/v1', path: 'pets', method: 'GET', idField: 'id' })
|
|
50
|
+
// query parameters carried onto the source
|
|
51
|
+
if (pet?.kind === 'rest') expect(pet.params).toEqual([{ name: 'status', location: 'query', type: 'string' }])
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('maps JSON-Schema types + formats + enums + pk', () => {
|
|
55
|
+
const pet = out.entities.find((e) => e.name === 'pet')!
|
|
56
|
+
const byName = new Map(pet.fields.map((f) => [f.field, f]))
|
|
57
|
+
expect(byName.get('id')).toMatchObject({ type: 'number', primaryKey: true })
|
|
58
|
+
expect(byName.get('name')).toMatchObject({ type: 'text', required: true })
|
|
59
|
+
expect(byName.get('status')).toMatchObject({ type: 'enum' })
|
|
60
|
+
expect(byName.get('status')!.options!.map((o) => o.value)).toEqual(['available', 'pending', 'sold'])
|
|
61
|
+
expect(byName.get('bornOn')!.type).toBe('dateString')
|
|
62
|
+
expect(byName.get('tags')!.type).toBe('json')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('turns a $ref property into a relation to the referenced entity', () => {
|
|
66
|
+
const pet = out.entities.find((e) => e.name === 'pet')!
|
|
67
|
+
const category = pet.fields.find((f) => f.field === 'category')!
|
|
68
|
+
expect(category).toMatchObject({ type: 'relation', relation: { entity: 'category', foreignKey: 'category' } })
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('detects a wrapped list response (rowsPath = data) + item schema behind it', () => {
|
|
72
|
+
const src = out.sources.order
|
|
73
|
+
expect(src?.kind === 'rest' ? src.rowsPath : null).toBe('data')
|
|
74
|
+
const order = out.entities.find((e) => e.name === 'order')!
|
|
75
|
+
expect(order.fields.map((f) => f.field).sort()).toEqual(['id', 'placedAt', 'total'])
|
|
76
|
+
expect(order.fields.find((f) => f.field === 'placedAt')!.type).toBe('datetime')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('rejects non-JSON and non-OpenAPI input with clear errors', () => {
|
|
80
|
+
expect(() => introspectOpenApi('name: swagger')).toThrow(/JSON only/)
|
|
81
|
+
expect(() => introspectOpenApi('{"foo":1}')).toThrow(/no "paths"/)
|
|
82
|
+
expect(() => introspectOpenApi('{"paths":{}}')).toThrow(/No REST resources/)
|
|
83
|
+
})
|
|
84
|
+
})
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Import an OpenAPI 3.x document (JSON) into Studio entities + REST data sources.
|
|
3
|
+
* Point at a spec and get a modelled app: each REST resource ( `GET /pets` +
|
|
4
|
+
* `GET /pets/{id}` + POST/PUT/DELETE ) becomes one EntitySchema and one
|
|
5
|
+
* `RestSource`, with fields mapped from the resource's JSON Schema.
|
|
6
|
+
*
|
|
7
|
+
* v1 scope: OpenAPI 3.x JSON only (YAML deferred), single-file `$ref` into
|
|
8
|
+
* `components/schemas`. Pure + Svelte-free like the rest of the studio core.
|
|
9
|
+
*/
|
|
10
|
+
import type { EntityField, EntityFieldType, EntitySchema } from '../schema.js'
|
|
11
|
+
import type { EntityDataSource, RequestParam, RestSource } from './project.js'
|
|
12
|
+
|
|
13
|
+
type Json = Record<string, unknown>
|
|
14
|
+
type OpenApiDoc = {
|
|
15
|
+
openapi?: string
|
|
16
|
+
servers?: Array<{ url?: string }>
|
|
17
|
+
paths?: Record<string, Json>
|
|
18
|
+
components?: { schemas?: Record<string, Json> }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type OpenApiImport = {
|
|
22
|
+
entities: EntitySchema[]
|
|
23
|
+
sources: Record<string, EntityDataSource>
|
|
24
|
+
/** Non-fatal notes (skipped paths, unresolved refs) to surface in the UI. */
|
|
25
|
+
warnings: string[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete'] as const
|
|
29
|
+
|
|
30
|
+
/** Resolve a local `#/components/schemas/Foo` ref (one level). */
|
|
31
|
+
function resolveRef(doc: OpenApiDoc, ref: string): Json | null {
|
|
32
|
+
const m = ref.match(/^#\/components\/schemas\/(.+)$/)
|
|
33
|
+
if (!m) return null
|
|
34
|
+
return (doc.components?.schemas?.[m[1]!] as Json) ?? null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Follow a `$ref` to its target schema (guards against a self/cyclic ref). */
|
|
38
|
+
function deref(doc: OpenApiDoc, node: Json | undefined, seen = new Set<string>()): Json | null {
|
|
39
|
+
if (!node) return null
|
|
40
|
+
const ref = node['$ref']
|
|
41
|
+
if (typeof ref === 'string') {
|
|
42
|
+
if (seen.has(ref)) return null
|
|
43
|
+
seen.add(ref)
|
|
44
|
+
return deref(doc, resolveRef(doc, ref) ?? undefined, seen)
|
|
45
|
+
}
|
|
46
|
+
return node
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The component-schema name a `$ref` points at, or null. */
|
|
50
|
+
function refName(node: Json | undefined): string | null {
|
|
51
|
+
const ref = node?.['$ref']
|
|
52
|
+
const m = typeof ref === 'string' ? ref.match(/^#\/components\/schemas\/(.+)$/) : null
|
|
53
|
+
return m ? m[1]! : null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const titleCase = (s: string) => s.replace(/[-_]+/g, ' ').replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase()).trim()
|
|
57
|
+
const singular = (s: string) => (s.endsWith('ies') ? s.slice(0, -3) + 'y' : s.endsWith('ses') ? s.slice(0, -2) : s.endsWith('s') && !s.endsWith('ss') ? s.slice(0, -1) : s)
|
|
58
|
+
|
|
59
|
+
/** Map one JSON-Schema property to an EntityField type (+ enum options). */
|
|
60
|
+
function fieldType(doc: OpenApiDoc, prop: Json, entityByRef: Map<string, string>): { type: EntityFieldType; options?: Array<{ value: string; label: string }>; relation?: string } {
|
|
61
|
+
// A `$ref` to a schema that became one of our entities -> a relation.
|
|
62
|
+
const rn = refName(prop)
|
|
63
|
+
if (rn && entityByRef.has(rn)) return { type: 'relation', relation: entityByRef.get(rn)! }
|
|
64
|
+
const schema = deref(doc, prop) ?? prop
|
|
65
|
+
const enumVals = schema['enum']
|
|
66
|
+
if (Array.isArray(enumVals) && enumVals.every((v) => typeof v === 'string' || typeof v === 'number')) {
|
|
67
|
+
return { type: 'enum', options: enumVals.map((v) => ({ value: String(v), label: titleCase(String(v)) })) }
|
|
68
|
+
}
|
|
69
|
+
const t = schema['type']
|
|
70
|
+
const fmt = schema['format']
|
|
71
|
+
if (t === 'integer' || t === 'number') return { type: 'number' }
|
|
72
|
+
if (t === 'boolean') return { type: 'boolean' }
|
|
73
|
+
if (t === 'array' || t === 'object') return { type: 'json' }
|
|
74
|
+
if (t === 'string') {
|
|
75
|
+
if (fmt === 'date') return { type: 'dateString' }
|
|
76
|
+
if (fmt === 'date-time') return { type: 'datetime' }
|
|
77
|
+
return { type: 'text' }
|
|
78
|
+
}
|
|
79
|
+
return { type: 'text' }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Object schema -> EntityField[]. `id`/pk detection + required + relations. */
|
|
83
|
+
function schemaToFields(doc: OpenApiDoc, objectSchema: Json, entityByRef: Map<string, string>): EntityField[] {
|
|
84
|
+
const props = (deref(doc, objectSchema)?.['properties'] as Record<string, Json> | undefined) ?? {}
|
|
85
|
+
const required = new Set((deref(doc, objectSchema)?.['required'] as string[] | undefined) ?? [])
|
|
86
|
+
const fields: EntityField[] = []
|
|
87
|
+
for (const [name, rawProp] of Object.entries(props)) {
|
|
88
|
+
const ft = fieldType(doc, rawProp, entityByRef)
|
|
89
|
+
const isPk = name === 'id' || name === '_id'
|
|
90
|
+
const field: EntityField = {
|
|
91
|
+
field: name,
|
|
92
|
+
label: titleCase(name),
|
|
93
|
+
type: ft.type,
|
|
94
|
+
...(isPk ? { primaryKey: true } : {}),
|
|
95
|
+
...(required.has(name) && !isPk ? { required: true } : {}),
|
|
96
|
+
...(ft.options ? { options: ft.options } : {}),
|
|
97
|
+
...(ft.relation ? { relation: { entity: ft.relation, labelField: 'name', foreignKey: name } } : {}),
|
|
98
|
+
}
|
|
99
|
+
fields.push(field)
|
|
100
|
+
}
|
|
101
|
+
return fields
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The item schema for a resource: the 200 response of GET-by-id, else the array
|
|
105
|
+
* item of the list response, else the POST request body. */
|
|
106
|
+
function itemSchemaFor(doc: OpenApiDoc, ops: Record<string, Json>): Json | null {
|
|
107
|
+
const bodySchema = (op: Json | undefined, kind: 'response' | 'request'): Json | null => {
|
|
108
|
+
if (!op) return null
|
|
109
|
+
const content =
|
|
110
|
+
kind === 'response'
|
|
111
|
+
? ((op['responses'] as Json | undefined)?.['200'] as Json | undefined)?.['content']
|
|
112
|
+
: (op['requestBody'] as Json | undefined)?.['content']
|
|
113
|
+
const json = (content as Json | undefined)?.['application/json'] as Json | undefined
|
|
114
|
+
return (json?.['schema'] as Json | undefined) ?? null
|
|
115
|
+
}
|
|
116
|
+
const byId = bodySchema(ops.getOne, 'response')
|
|
117
|
+
if (byId) return deref(doc, byId)
|
|
118
|
+
const list = bodySchema(ops.list, 'response')
|
|
119
|
+
if (list) {
|
|
120
|
+
const s = deref(doc, list)
|
|
121
|
+
if (s?.['type'] === 'array') return deref(doc, s['items'] as Json)
|
|
122
|
+
// wrapped: { data: [ item ] } / { items: [...] } / { results: [...] }
|
|
123
|
+
for (const key of ['data', 'items', 'results', 'rows']) {
|
|
124
|
+
const wrapped = deref(doc, (s?.['properties'] as Record<string, Json> | undefined)?.[key])
|
|
125
|
+
if (wrapped?.['type'] === 'array') return deref(doc, wrapped['items'] as Json)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const created = bodySchema(ops.create, 'request')
|
|
129
|
+
return created ? deref(doc, created) : null
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The `application/json` schema node of an operation's 200 response, else null. */
|
|
133
|
+
function responseSchema(op: Json | undefined): Json | undefined {
|
|
134
|
+
const content = ((op?.['responses'] as Json | undefined)?.['200'] as Json | undefined)?.['content'] as Json | undefined
|
|
135
|
+
return (content?.['application/json'] as Json | undefined)?.['schema'] as Json | undefined
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The dotted rows path for a list response ('data'/'items'/...), or undefined
|
|
139
|
+
* when the response is a bare array. */
|
|
140
|
+
function rowsPathFor(doc: OpenApiDoc, listOp: Json | undefined): string | undefined {
|
|
141
|
+
const s = deref(doc, responseSchema(listOp))
|
|
142
|
+
if (!s || s['type'] === 'array') return undefined
|
|
143
|
+
for (const key of ['data', 'items', 'results', 'rows']) {
|
|
144
|
+
if (deref(doc, (s['properties'] as Record<string, Json> | undefined)?.[key])?.['type'] === 'array') return key
|
|
145
|
+
}
|
|
146
|
+
return undefined
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function paramsFor(doc: OpenApiDoc, op: Json | undefined): RequestParam[] {
|
|
150
|
+
const raw = (op?.['parameters'] as Json[] | undefined) ?? []
|
|
151
|
+
const out: RequestParam[] = []
|
|
152
|
+
for (const p of raw) {
|
|
153
|
+
const param = deref(doc, p) ?? p
|
|
154
|
+
const loc = param['in']
|
|
155
|
+
const name = param['name']
|
|
156
|
+
if (typeof name !== 'string' || (loc !== 'query' && loc !== 'path' && loc !== 'header')) continue
|
|
157
|
+
out.push({ name, location: loc, type: 'string' })
|
|
158
|
+
}
|
|
159
|
+
return out
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Split "/pets/{petId}" -> { collection: "/pets", isItem: true }. A collection
|
|
163
|
+
* path ends in a static segment; an item path ends in a `{param}`. */
|
|
164
|
+
function classifyPath(path: string): { collection: string; isItem: boolean } {
|
|
165
|
+
const segs = path.split('/').filter(Boolean)
|
|
166
|
+
if (segs.length && /^\{.+\}$/.test(segs.at(-1)!)) return { collection: '/' + segs.slice(0, -1).join('/'), isItem: true }
|
|
167
|
+
return { collection: path, isItem: false }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Parse + introspect an OpenAPI 3.x JSON document into entities + REST sources. */
|
|
171
|
+
export function introspectOpenApi(specText: string): OpenApiImport {
|
|
172
|
+
let doc: OpenApiDoc
|
|
173
|
+
try {
|
|
174
|
+
doc = JSON.parse(specText) as OpenApiDoc
|
|
175
|
+
} catch {
|
|
176
|
+
throw new Error('Could not parse the OpenAPI document. v1 supports JSON only - convert a YAML spec to JSON first (e.g. https://editor.swagger.io export).')
|
|
177
|
+
}
|
|
178
|
+
if (!doc || typeof doc !== 'object' || !doc.paths) throw new Error('Not an OpenAPI document: no "paths" object found.')
|
|
179
|
+
|
|
180
|
+
const warnings: string[] = []
|
|
181
|
+
const baseUrl = (doc.servers?.[0]?.url ?? '').replace(/\/+$/, '')
|
|
182
|
+
|
|
183
|
+
// Group operations by resource (collection path).
|
|
184
|
+
type Res = { name: string; collection: string; ops: Record<string, Json> }
|
|
185
|
+
const byCollection = new Map<string, Res>()
|
|
186
|
+
for (const [path, item] of Object.entries(doc.paths)) {
|
|
187
|
+
const { collection, isItem } = classifyPath(path)
|
|
188
|
+
const segs = collection.split('/').filter(Boolean)
|
|
189
|
+
const last = segs.at(-1)
|
|
190
|
+
if (!last || /^\{.+\}$/.test(last)) { warnings.push(`Skipped path "${path}" (no clear resource name).`); continue }
|
|
191
|
+
const name = singular(last).replace(/[^a-zA-Z0-9]+/g, '_').toLowerCase()
|
|
192
|
+
const res = byCollection.get(collection) ?? { name, collection, ops: {} }
|
|
193
|
+
for (const method of HTTP_METHODS) {
|
|
194
|
+
const op = item[method] as Json | undefined
|
|
195
|
+
if (!op) continue
|
|
196
|
+
if (method === 'get') res.ops[isItem ? 'getOne' : 'list'] = op
|
|
197
|
+
else if (method === 'post' && !isItem) res.ops.create = op
|
|
198
|
+
else if ((method === 'put' || method === 'patch') && isItem) res.ops.update = op
|
|
199
|
+
else if (method === 'delete' && isItem) res.ops.delete = op
|
|
200
|
+
}
|
|
201
|
+
byCollection.set(collection, res)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Two passes: map each resource's component schema NAME -> entity name first,
|
|
205
|
+
// so a property that `$ref`s another resource's schema becomes a relation.
|
|
206
|
+
const resources = [...byCollection.values()].filter((r) => r.ops.list || r.ops.getOne || r.ops.create)
|
|
207
|
+
const entityByRef = new Map<string, string>()
|
|
208
|
+
for (const r of resources) {
|
|
209
|
+
for (const opKey of ['getOne', 'list', 'create'] as const) {
|
|
210
|
+
const op = r.ops[opKey]
|
|
211
|
+
const schema = responseSchema(op) ?? ((((op?.['requestBody'] as Json | undefined)?.['content'] as Json | undefined)?.['application/json'] as Json | undefined)?.['schema'] as Json | undefined)
|
|
212
|
+
const direct = refName(schema)
|
|
213
|
+
if (direct) entityByRef.set(direct, r.name)
|
|
214
|
+
const arrItem = refName(deref(doc, schema)?.['items'] as Json | undefined)
|
|
215
|
+
if (arrItem) entityByRef.set(arrItem, r.name)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const entities: EntitySchema[] = []
|
|
220
|
+
const sources: Record<string, EntityDataSource> = {}
|
|
221
|
+
const seenNames = new Set<string>()
|
|
222
|
+
for (const r of resources) {
|
|
223
|
+
let name = r.name
|
|
224
|
+
while (seenNames.has(name)) name = name + '_'
|
|
225
|
+
seenNames.add(name)
|
|
226
|
+
const item = itemSchemaFor(doc, r.ops)
|
|
227
|
+
if (!item) { warnings.push(`Resource "${r.collection}" has no readable schema - skipped.`); continue }
|
|
228
|
+
const fields = schemaToFields(doc, item, entityByRef)
|
|
229
|
+
if (!fields.length) { warnings.push(`Resource "${r.collection}" resolved no fields - skipped.`); continue }
|
|
230
|
+
if (!fields.some((f) => f.primaryKey)) {
|
|
231
|
+
const idish = fields.find((f) => /id$/i.test(f.field))
|
|
232
|
+
if (idish) idish.primaryKey = true
|
|
233
|
+
else fields.unshift({ field: 'id', label: 'Id', type: 'text', primaryKey: true })
|
|
234
|
+
}
|
|
235
|
+
const idField = fields.find((f) => f.primaryKey)!.field
|
|
236
|
+
entities.push({ name, label: titleCase(name), idField, fields })
|
|
237
|
+
const path = r.collection.replace(/^\/+/, '')
|
|
238
|
+
const source: RestSource = {
|
|
239
|
+
kind: 'rest',
|
|
240
|
+
baseUrl,
|
|
241
|
+
path,
|
|
242
|
+
method: 'GET',
|
|
243
|
+
params: paramsFor(doc, r.ops.list),
|
|
244
|
+
idField,
|
|
245
|
+
...(rowsPathFor(doc, r.ops.list) ? { rowsPath: rowsPathFor(doc, r.ops.list) } : {}),
|
|
246
|
+
}
|
|
247
|
+
sources[name] = source
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (!entities.length) throw new Error('No REST resources with a readable schema were found in the document.')
|
|
251
|
+
return { entities, sources, warnings }
|
|
252
|
+
}
|
|
@@ -4,6 +4,8 @@ import {
|
|
|
4
4
|
addBlock,
|
|
5
5
|
applyGridPreset,
|
|
6
6
|
addStateVar,
|
|
7
|
+
setDataLayer,
|
|
8
|
+
setDeployTarget,
|
|
7
9
|
updateStateVar,
|
|
8
10
|
removeStateVar,
|
|
9
11
|
stateInitExpr,
|
|
@@ -37,7 +39,6 @@ import {
|
|
|
37
39
|
blockPalette,
|
|
38
40
|
createProject,
|
|
39
41
|
defaultBlockConfig,
|
|
40
|
-
defaultScreenFor,
|
|
41
42
|
gridColumns,
|
|
42
43
|
moveBlock,
|
|
43
44
|
removeBlock,
|
|
@@ -55,6 +56,7 @@ import {
|
|
|
55
56
|
reorderScreen,
|
|
56
57
|
insertBlock,
|
|
57
58
|
validateProject,
|
|
59
|
+
setAuth,
|
|
58
60
|
addTab,
|
|
59
61
|
removeTab,
|
|
60
62
|
renameTab,
|
|
@@ -599,6 +601,22 @@ describe('component blocks', () => {
|
|
|
599
601
|
p = addComponentBlock(p, sid, 'button', {})
|
|
600
602
|
expect(validateProject(p).some((i) => /is empty/.test(i.message))).toBe(false)
|
|
601
603
|
})
|
|
604
|
+
|
|
605
|
+
it('Supabase Auth warns without a Supabase connection, and clears once one is set', () => {
|
|
606
|
+
let p = setAuth(createProject([customers]), { enabled: true, provider: 'supabase' })
|
|
607
|
+
const needsConn = (pr: typeof p) => validateProject(pr).some((i) => /Supabase Auth needs a Supabase connection/.test(i.message))
|
|
608
|
+
expect(needsConn(p)).toBe(true)
|
|
609
|
+
// Shared project connection satisfies it.
|
|
610
|
+
p = { ...p, supabase: { url: 'https://x.supabase.co', key: 'anon' } }
|
|
611
|
+
expect(needsConn(p)).toBe(false)
|
|
612
|
+
})
|
|
613
|
+
|
|
614
|
+
it('Supabase Auth + RBAC warns that the client session does not populate the server role', () => {
|
|
615
|
+
let p = createProject([customers])
|
|
616
|
+
p = { ...p, supabase: { url: 'https://x.supabase.co', key: 'anon' }, access: { enabled: true, roles: [] } as never }
|
|
617
|
+
p = setAuth(p, { enabled: true, provider: 'supabase' })
|
|
618
|
+
expect(validateProject(p).some((i) => /Row Level Security/.test(i.message))).toBe(true)
|
|
619
|
+
})
|
|
602
620
|
})
|
|
603
621
|
|
|
604
622
|
describe('validateProject', () => {
|
|
@@ -618,6 +636,33 @@ describe('validateProject', () => {
|
|
|
618
636
|
expect(validateProject(broken).some((i) => /missing entity/.test(i.message))).toBe(true)
|
|
619
637
|
})
|
|
620
638
|
|
|
639
|
+
it('warns when SQL entities have no Drizzle data layer (tables must pre-exist)', () => {
|
|
640
|
+
let p = createProject([customers])
|
|
641
|
+
p = setEntityDataSource(p, 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres' })
|
|
642
|
+
const warn = validateProject(p).find((i) => /tables must already exist/.test(i.message))
|
|
643
|
+
expect(warn?.level).toBe('warning')
|
|
644
|
+
// Enabling the Drizzle layer clears it.
|
|
645
|
+
expect(validateProject(setDataLayer(p, true)).some((i) => /tables must already exist/.test(i.message))).toBe(false)
|
|
646
|
+
})
|
|
647
|
+
|
|
648
|
+
it('warns on cloudflare deploy + a socket Postgres driver', () => {
|
|
649
|
+
let p = createProject([customers])
|
|
650
|
+
p = setEntityDataSource(p, 'customers', { kind: 'sql', table: 'customers', dialect: 'postgres' })
|
|
651
|
+
p = setDeployTarget(p, 'cloudflare')
|
|
652
|
+
expect(validateProject(p).some((i) => /Cloudflare Workers cannot run the socket/.test(i.message))).toBe(true)
|
|
653
|
+
// sqlite on cloudflare doesn't trip the pg warning.
|
|
654
|
+
p = setEntityDataSource(p, 'customers', { kind: 'sql', table: 'customers', dialect: 'sqlite' })
|
|
655
|
+
expect(validateProject(p).some((i) => /Cloudflare Workers cannot run the socket/.test(i.message))).toBe(false)
|
|
656
|
+
})
|
|
657
|
+
|
|
658
|
+
it('warns when a supabase source has no url/key (stub client)', () => {
|
|
659
|
+
let p = createProject([customers])
|
|
660
|
+
p = setEntityDataSource(p, 'customers', { kind: 'supabase', table: 'customers' })
|
|
661
|
+
expect(validateProject(p).some((i) => /has no URL\/key/.test(i.message))).toBe(true)
|
|
662
|
+
p = setEntityDataSource(p, 'customers', { kind: 'supabase', table: 'customers', url: 'https://x.supabase.co', key: 'anon' })
|
|
663
|
+
expect(validateProject(p).some((i) => /has no URL\/key/.test(i.message))).toBe(false)
|
|
664
|
+
})
|
|
665
|
+
|
|
621
666
|
it('the palette lists every block kind', () => {
|
|
622
667
|
expect(blockPalette.map((p) => p.kind)).toContain('grid')
|
|
623
668
|
expect(blockPalette.map((p) => p.kind)).toContain('dashboard')
|
|
@@ -761,6 +806,17 @@ describe('per-entity data sources', () => {
|
|
|
761
806
|
expect(p1.dataSources).toMatchObject({ customers: { kind: 'rest' } })
|
|
762
807
|
})
|
|
763
808
|
|
|
809
|
+
it('an unbound entity inherits the project default source (not always memory)', () => {
|
|
810
|
+
const p0 = createProject([customers]) // dataSource: 'memory'
|
|
811
|
+
expect(entityDataSource(p0, 'customers')).toEqual({ kind: 'memory' })
|
|
812
|
+
// Flip the project default to SQL: unbound entities now resolve to the SQL skeleton.
|
|
813
|
+
const pSql = { ...p0, dataSource: 'sql' as const }
|
|
814
|
+
expect(entityDataSource(pSql, 'customers')).toEqual({ kind: 'sql', table: 'customers' })
|
|
815
|
+
// An explicit per-entity binding still wins over the default.
|
|
816
|
+
const pOverride = setEntityDataSource(pSql, 'customers', { kind: 'memory' })
|
|
817
|
+
expect(entityDataSource(pOverride, 'customers')).toEqual({ kind: 'memory' })
|
|
818
|
+
})
|
|
819
|
+
|
|
764
820
|
it('defaultEntitySource seeds a skeleton per kind', () => {
|
|
765
821
|
expect(defaultEntitySource('rest', 'customers')).toMatchObject({ kind: 'rest', path: 'customers', method: 'GET', params: [] })
|
|
766
822
|
expect(defaultEntitySource('sql', 'orders')).toEqual({ kind: 'sql', table: 'orders' })
|