Files
webapp/app/composables/useTerminus.ts
2026-01-07 09:10:35 +07:00

295 lines
7.1 KiB
TypeScript

/**
* 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,
}
}