Initial commit from monorepo
This commit is contained in:
294
app/composables/useTerminus.ts
Normal file
294
app/composables/useTerminus.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* Composable for TerminusDB:
|
||||
* - Load schema by ID via GraphQL introspection
|
||||
* - Convert schema to FormKit format
|
||||
*/
|
||||
|
||||
import type { FormKitSchemaNode } from '@formkit/core'
|
||||
|
||||
// Types for TerminusDB GraphQL introspection
|
||||
interface TerminusField {
|
||||
name: string
|
||||
type: {
|
||||
name: string | null
|
||||
kind: string
|
||||
ofType?: {
|
||||
name: string | null
|
||||
kind: string
|
||||
enumValues?: Array<{ name: string }>
|
||||
}
|
||||
enumValues?: Array<{ name: string }>
|
||||
}
|
||||
}
|
||||
|
||||
interface TerminusType {
|
||||
name: string
|
||||
kind: string
|
||||
description: string | null
|
||||
fields: TerminusField[] | null
|
||||
enumValues: Array<{ name: string }> | null
|
||||
}
|
||||
|
||||
// Map GraphQL scalar types to FormKit types
|
||||
const graphqlToFormKit: Record<string, string> = {
|
||||
'String': 'text',
|
||||
'Int': 'number',
|
||||
'Float': 'number',
|
||||
'Boolean': 'checkbox',
|
||||
'DateTime': 'datetime-local',
|
||||
'Date': 'date',
|
||||
'BigInt': 'number',
|
||||
'Decimal': 'number',
|
||||
}
|
||||
|
||||
export function useTerminus() {
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
// TerminusDB GraphQL endpoint
|
||||
const getEndpoint = (): string => {
|
||||
return (config.public.terminusGraphql as string) || 'https://terminus.optovia.ru/api/graphql/admin/optovia'
|
||||
}
|
||||
|
||||
// Basic auth header
|
||||
const getAuthHeader = () => {
|
||||
// admin:optovia_admin_2024 in base64
|
||||
return 'Basic ' + btoa('admin:optovia_admin_2024')
|
||||
}
|
||||
|
||||
/**
|
||||
* Load schema from TerminusDB via GraphQL introspection
|
||||
*/
|
||||
async function getSchema(schemaId: string): Promise<TerminusType | null> {
|
||||
try {
|
||||
const endpoint = getEndpoint()
|
||||
|
||||
// GraphQL introspection query for a specific type
|
||||
const query = `
|
||||
query IntrospectionQuery {
|
||||
__type(name: "${schemaId}") {
|
||||
name
|
||||
kind
|
||||
description
|
||||
fields {
|
||||
name
|
||||
type {
|
||||
name
|
||||
kind
|
||||
ofType {
|
||||
name
|
||||
kind
|
||||
enumValues {
|
||||
name
|
||||
}
|
||||
}
|
||||
enumValues {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': getAuthHeader(),
|
||||
},
|
||||
body: JSON.stringify({ query }),
|
||||
})
|
||||
|
||||
const result = await response.json() as { data: { __type: TerminusType | null } }
|
||||
return result.data?.__type || null
|
||||
} catch (error) {
|
||||
console.error('Failed to load schema from TerminusDB:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all enums from schema via introspection
|
||||
*/
|
||||
async function getEnums(): Promise<Record<string, string[]>> {
|
||||
try {
|
||||
const endpoint = getEndpoint()
|
||||
|
||||
// Get all types and filter enums
|
||||
const query = `
|
||||
query IntrospectionQuery {
|
||||
__schema {
|
||||
types {
|
||||
name
|
||||
kind
|
||||
enumValues {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': getAuthHeader(),
|
||||
},
|
||||
body: JSON.stringify({ query }),
|
||||
})
|
||||
|
||||
const result = await response.json() as { data: { __schema: { types: TerminusType[] } } }
|
||||
|
||||
const enums: Record<string, string[]> = {}
|
||||
|
||||
for (const type of result.data?.__schema?.types || []) {
|
||||
// Keep only our enums (exclude system __)
|
||||
if (type.kind === 'ENUM' && !type.name.startsWith('__') && type.enumValues) {
|
||||
enums[type.name] = type.enumValues.map(e => e.name)
|
||||
}
|
||||
}
|
||||
|
||||
return enums
|
||||
} catch (error) {
|
||||
console.error('Failed to load enums from TerminusDB:', error)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert TerminusDB type to FormKit schema
|
||||
*/
|
||||
function schemaToFormKit(
|
||||
terminusType: TerminusType,
|
||||
enums: Record<string, string[]>
|
||||
): FormKitSchemaNode[] {
|
||||
const formkitSchema: FormKitSchemaNode[] = []
|
||||
|
||||
if (!terminusType.fields) return formkitSchema
|
||||
|
||||
// System fields to skip (TerminusDB internal fields start with _)
|
||||
const skipFields = ['id', '_id', '_type', '_json']
|
||||
// Also skip path fields like _path_to_Cocoa, _path_to_Coffee, etc.
|
||||
const isPathField = (name: string) => name.startsWith('_path_to_')
|
||||
|
||||
for (const field of terminusType.fields) {
|
||||
if (skipFields.includes(field.name) || isPathField(field.name)) continue
|
||||
|
||||
const formkitField = parseFieldType(field, enums)
|
||||
if (formkitField) {
|
||||
formkitSchema.push(formkitField)
|
||||
}
|
||||
}
|
||||
|
||||
return formkitSchema
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse field type and build FormKit node
|
||||
*/
|
||||
function parseFieldType(
|
||||
field: TerminusField,
|
||||
enums: Record<string, string[]>
|
||||
): FormKitSchemaNode | null {
|
||||
const { name, type } = field
|
||||
|
||||
// Determine actual type (may be wrapped in NON_NULL or LIST)
|
||||
let actualType = type
|
||||
let required = false
|
||||
|
||||
if (type.kind === 'NON_NULL') {
|
||||
required = true
|
||||
actualType = type.ofType as typeof type
|
||||
}
|
||||
|
||||
// Enum?
|
||||
if (actualType.kind === 'ENUM' && actualType.name) {
|
||||
const enumValues = actualType.enumValues?.map(e => e.name) || enums[actualType.name] || []
|
||||
return createSelectField(name, enumValues, required)
|
||||
}
|
||||
|
||||
// Scalar type
|
||||
if (actualType.kind === 'SCALAR' && actualType.name) {
|
||||
return createInputField(name, actualType.name, required)
|
||||
}
|
||||
|
||||
// LIST -> textarea
|
||||
if (actualType.kind === 'LIST') {
|
||||
return {
|
||||
$formkit: 'textarea',
|
||||
name,
|
||||
label: formatLabel(name),
|
||||
help: 'Enter values separated by comma',
|
||||
validation: required ? 'required' : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Create input field
|
||||
*/
|
||||
function createInputField(
|
||||
fieldName: string,
|
||||
graphqlType: string,
|
||||
required: boolean
|
||||
): FormKitSchemaNode {
|
||||
const formkitType = graphqlToFormKit[graphqlType] || 'text'
|
||||
|
||||
const field: FormKitSchemaNode = {
|
||||
$formkit: formkitType,
|
||||
name: fieldName,
|
||||
label: formatLabel(fieldName),
|
||||
validation: required ? 'required' : undefined,
|
||||
}
|
||||
|
||||
// For Float/Decimal add step
|
||||
if (graphqlType === 'Float' || graphqlType === 'Decimal') {
|
||||
(field as Record<string, unknown>).step = '0.01'
|
||||
}
|
||||
|
||||
return field
|
||||
}
|
||||
|
||||
/**
|
||||
* Create select field from enum
|
||||
*/
|
||||
function createSelectField(
|
||||
fieldName: string,
|
||||
options: string[],
|
||||
required: boolean
|
||||
): FormKitSchemaNode {
|
||||
return {
|
||||
$formkit: 'select',
|
||||
name: fieldName,
|
||||
label: formatLabel(fieldName),
|
||||
validation: required ? 'required' : undefined,
|
||||
placeholder: 'Select...',
|
||||
options: options.map(opt => ({
|
||||
value: opt,
|
||||
label: formatLabel(opt),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format field name to human-readable label
|
||||
*/
|
||||
function formatLabel(fieldName: string): string {
|
||||
return fieldName
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/([A-Z])/g, ' $1')
|
||||
.replace(/^\w/, c => c.toUpperCase())
|
||||
.trim()
|
||||
}
|
||||
|
||||
return {
|
||||
getSchema,
|
||||
getEnums,
|
||||
schemaToFormKit,
|
||||
formatLabel,
|
||||
getEndpoint,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user