diff --git a/src/components/config-generator/index.tsx b/src/components/config-generator/index.tsx new file mode 100644 index 0000000000..1c8f47e402 --- /dev/null +++ b/src/components/config-generator/index.tsx @@ -0,0 +1,844 @@ +import React, {useCallback, useEffect, useRef, useState} from "react" +import clsx from "clsx" + +// --------------------------------------------------------------------------- +// Types & helpers for the JSON-Schema-driven form +// --------------------------------------------------------------------------- + +const SCHEMA_URL = + "https://raw.githubusercontent.com/tailcallhq/tailcall/main/generated/.tailcallrc.schema.json" + +type JsonSchema = { + type?: string | string[] + description?: string + properties?: Record + items?: JsonSchema + $ref?: string + anyOf?: JsonSchema[] + allOf?: JsonSchema[] + enum?: (string | number | boolean | null)[] + required?: string[] + default?: unknown + minimum?: number + format?: string +} + +type SchemaRoot = { + definitions: Record + properties: Record +} + +function resolveRef(ref: string, root: SchemaRoot): JsonSchema { + const name = ref.replace("#/definitions/", "") + return root.definitions?.[name] ?? {} +} + +function effectiveSchema(schema: JsonSchema, root: SchemaRoot): JsonSchema { + if (schema.$ref) return resolveRef(schema.$ref, root) + if (schema.allOf?.length === 1) return effectiveSchema(schema.allOf[0], root) + if (schema.anyOf) { + const nonNull = schema.anyOf.find((s) => s.type !== "null" && !("type" in s && s.type === "null")) + if (nonNull) return effectiveSchema(nonNull, root) + } + return schema +} + +function isNullable(schema: JsonSchema): boolean { + const types = Array.isArray(schema.type) ? schema.type : [schema.type] + if (types.includes("null")) return true + if (schema.anyOf?.some((s) => s.type === "null")) return true + return false +} + +function baseType(schema: JsonSchema): string { + const types = Array.isArray(schema.type) ? schema.type : [schema.type] + return types.find((t) => t && t !== "null") ?? "string" +} + +// Strip nulls/undefined recursively for clean JSON output +function stripEmpty(val: unknown): unknown { + if (val === null || val === undefined || val === "") return undefined + if (Array.isArray(val)) { + const arr = val.map(stripEmpty).filter((v) => v !== undefined) + return arr.length ? arr : undefined + } + if (typeof val === "object") { + const obj: Record = {} + for (const [k, v] of Object.entries(val as Record)) { + const cleaned = stripEmpty(v) + if (cleaned !== undefined) obj[k] = cleaned + } + return Object.keys(obj).length ? obj : undefined + } + return val +} + +// --------------------------------------------------------------------------- +// Schema-driven field renderer +// --------------------------------------------------------------------------- + +type FieldProps = { + name: string + schema: JsonSchema + root: SchemaRoot + value: unknown + onChange: (val: unknown) => void + depth?: number +} + +const INDENT_PX = 16 + +function SchemaField({name, schema, root, value, onChange, depth = 0}: FieldProps): JSX.Element { + const eff = effectiveSchema(schema, root) + const nullable = isNullable(schema) || isNullable(eff) + const type = baseType(eff) + const indent = depth * INDENT_PX + + // Enum → searchable select + if (eff.enum) { + const options = eff.enum as string[] + return ( + + onChange(v === "" && nullable ? undefined : v)} + placeholder={nullable ? `— optional —` : `Select ${name}`} + nullable={nullable} + /> + + ) + } + + // Object → nested section + if (type === "object" && eff.properties) { + return ( + } + onChange={onChange} + depth={depth} + nullable={nullable} + /> + ) + } + + // Array of refs/objects → array manager + if (type === "array") { + return ( + + ) + } + + // Boolean + if (type === "boolean") { + const checked = value === true + const indeterminate = value === undefined || value === null + return ( + +
+ {nullable && ( + + )} + + {checked ? "true" : indeterminate ? "—" : "false"} +
+
+ ) + } + + // Number / integer + if (type === "number" || type === "integer") { + return ( + + { + const v = e.target.value + onChange(v === "" ? undefined : type === "integer" ? parseInt(v, 10) : parseFloat(v)) + }} + placeholder={nullable ? "optional" : ""} + className={inputCls} + /> + + ) + } + + // Default: string + return ( + + onChange(e.target.value === "" && nullable ? undefined : e.target.value)} + placeholder={nullable ? "optional" : ""} + className={inputCls} + /> + + ) +} + +// --------------------------------------------------------------------------- +// FieldWrapper +// --------------------------------------------------------------------------- +function FieldWrapper({ + label, + description, + indent, + children, +}: { + label: string + description?: string + indent: number + children: React.ReactNode +}) { + const [showDesc, setShowDesc] = useState(false) + return ( +
+
+ + {description && ( + + )} +
+ {showDesc && description && ( +

{description}

+ )} + {children} +
+ ) +} + +// --------------------------------------------------------------------------- +// ObjectSection +// --------------------------------------------------------------------------- +function ObjectSection({ + label, + description, + schema, + root, + value, + onChange, + depth, + nullable, +}: { + label: string + description?: string + schema: JsonSchema + root: SchemaRoot + value: Record | undefined | null + onChange: (v: unknown) => void + depth: number + nullable: boolean +}) { + const [open, setOpen] = useState(depth < 2) + const [enabled, setEnabled] = useState(value !== undefined && value !== null) + const indent = depth * INDENT_PX + + const handleEnable = (v: boolean) => { + setEnabled(v) + if (!v) onChange(undefined) + else onChange(value ?? {}) + } + + return ( +
+ + {open && ( +
+ {description &&

{description}

} + {nullable && !enabled ? ( +

Click "disabled" to enable this section.

+ ) : ( + schema.properties && + Object.entries(schema.properties).map(([key, propSchema]) => { + const resolved = effectiveSchema(propSchema, root) + return ( + )?.[key]} + onChange={(v) => { + const next = {...((value as Record) ?? {}), [key]: v} + onChange(next) + }} + depth={depth + 1} + /> + ) + }) + )} +
+ )} +
+ ) +} + +// --------------------------------------------------------------------------- +// ArrayField +// --------------------------------------------------------------------------- +function ArrayField({ + name, + schema, + root, + value, + onChange, + depth, + description, +}: { + name: string + schema: JsonSchema + root: SchemaRoot + value: unknown[] | undefined | null + onChange: (v: unknown) => void + depth: number + description?: string +}) { + const [open, setOpen] = useState(false) + const indent = depth * INDENT_PX + const items = schema.items ? effectiveSchema(schema.items, root) : undefined + const arr = (value as unknown[]) ?? [] + + const addItem = () => { + const newItem = items?.type === "object" ? {} : items?.enum ? items.enum[0] : "" + onChange([...arr, newItem]) + } + + const removeItem = (i: number) => { + const next = arr.filter((_, idx) => idx !== i) + onChange(next.length ? next : undefined) + } + + const updateItem = (i: number, v: unknown) => { + const next = [...arr] + next[i] = v + onChange(next) + } + + return ( +
+ + {open && ( +
+ {description &&

{description}

} + {arr.map((item, i) => ( +
+
+ {items ? ( + updateItem(i, v)} + depth={depth + 1} + /> + ) : ( + updateItem(i, e.target.value)} + className={inputCls} + /> + )} +
+ +
+ ))} + +
+ )} +
+ ) +} + +// --------------------------------------------------------------------------- +// SearchableSelect +// --------------------------------------------------------------------------- +function SearchableSelect({ + options, + value, + onChange, + placeholder, + nullable, +}: { + options: string[] + value: string + onChange: (v: string) => void + placeholder: string + nullable: boolean +}) { + const [query, setQuery] = useState("") + const [open, setOpen] = useState(false) + const ref = useRef(null) + const filtered = options.filter((o) => o.toLowerCase().includes(query.toLowerCase())) + + useEffect(() => { + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) + } + document.addEventListener("mousedown", handler) + return () => document.removeEventListener("mousedown", handler) + }, []) + + return ( +
+
setOpen((v) => !v)} + role="combobox" + tabIndex={0} + aria-expanded={open} + onKeyDown={(e) => e.key === "Enter" && setOpen((v) => !v)} + > + {value || placeholder} + +
+ {open && ( +
+
+ setQuery(e.target.value)} + placeholder="Search..." + className="w-full text-sm outline-none px-2 py-1 border border-solid border-tailCall-border-light-500 rounded" + onClick={(e) => e.stopPropagation()} + /> +
+ {nullable && ( + + )} + {filtered.map((opt) => ( + + ))} + {filtered.length === 0 &&

No matches

} +
+ )} +
+ ) +} + +// --------------------------------------------------------------------------- +// Format tabs +// --------------------------------------------------------------------------- +type Format = "json" | "yaml" | "graphql" + +function toYaml(obj: unknown, indent = 0): string { + if (obj === undefined || obj === null) return "null" + if (typeof obj === "boolean" || typeof obj === "number") return String(obj) + if (typeof obj === "string") { + // Quote strings that look like YAML keywords or contain special chars + if (/[:#\[\]{},|>&*!%@`]/.test(obj) || /^(true|false|null|yes|no|on|off)$/i.test(obj)) { + return `"${obj.replace(/"/g, '\\"')}"` + } + return obj + } + const pad = " ".repeat(indent) + if (Array.isArray(obj)) { + if (obj.length === 0) return "[]" + return "\n" + obj.map((item) => `${pad}- ${toYaml(item, indent + 1)}`).join("\n") + } + if (typeof obj === "object") { + const entries = Object.entries(obj as Record).filter(([, v]) => v !== undefined) + if (entries.length === 0) return "{}" + return "\n" + entries.map(([k, v]) => `${pad}${k}: ${toYaml(v, indent + 1)}`).join("\n") + } + return String(obj) +} + +function formatOutput(config: Record, format: Format): string { + const cleaned = stripEmpty(config) as Record + if (format === "graphql") return configToGraphql(cleaned ?? {}) + if (!cleaned || Object.keys(cleaned).length === 0) return "" + if (format === "json") return JSON.stringify(cleaned, null, 2) + // Simple YAML serialization + const entries = Object.entries(cleaned) + return entries.map(([k, v]) => `${k}:${toYaml(v, 1)}`).join("\n") + "\n" +} + +// --------------------------------------------------------------------------- +// GraphQL SDL serializer +// --------------------------------------------------------------------------- + +/** Render a single directive argument value as a GraphQL literal. */ +function gqlLiteral(val: unknown): string { + if (val === null || val === undefined) return "null" + if (typeof val === "boolean") return String(val) + if (typeof val === "number") return String(val) + if (typeof val === "string") return JSON.stringify(val) + if (Array.isArray(val)) { + return "[" + val.map(gqlLiteral).join(", ") + "]" + } + if (typeof val === "object") { + const entries = Object.entries(val as Record).filter(([, v]) => v !== undefined && v !== null && v !== "") + if (entries.length === 0) return "{}" + return "{" + entries.map(([k, v]) => `${k}: ${gqlLiteral(v)}`).join(", ") + "}" + } + return JSON.stringify(val) +} + +/** Render a directive call: @name(key: val, ...) */ +function gqlDirective(name: string, args: Record): string { + const entries = Object.entries(args).filter(([, v]) => v !== undefined && v !== null && v !== "") + if (entries.length === 0) return `@${name}` + const argStr = entries.map(([k, v]) => `${k}: ${gqlLiteral(v)}`).join(", ") + return `@${name}(${argStr})` +} + +/** + * Convert the cleaned config object to Tailcall GraphQL SDL. + * Only handles top-level schema directives (@server, @upstream) since the + * form is schema-driven from the JSON schema — type-level fields (@http etc.) + * are not part of the config schema and are not rendered here. + */ +function configToGraphql(config: Record): string { + if (!config || Object.keys(config).length === 0) { + return "# Configure fields on the left to generate SDL" + } + + const lines: string[] = [] + + // Schema definition with directives + const schemaDirectives: string[] = [] + + if (config.server && typeof config.server === "object") { + schemaDirectives.push(gqlDirective("server", config.server as Record)) + } + + if (config.upstream && typeof config.upstream === "object") { + schemaDirectives.push(gqlDirective("upstream", config.upstream as Record)) + } + + if (config.telemetry && typeof config.telemetry === "object") { + schemaDirectives.push(gqlDirective("telemetry", config.telemetry as Record)) + } + + // links directive — rendered as @link(src: "...", type: ...) per entry + if (Array.isArray(config.links) && config.links.length > 0) { + for (const link of config.links as Record[]) { + if (link && typeof link === "object") { + schemaDirectives.push(gqlDirective("link", link)) + } + } + } + + if (schemaDirectives.length > 0) { + lines.push("schema " + schemaDirectives.join(" ") + " {") + lines.push(" query: Query") + lines.push("}") + } else { + lines.push("# Configure fields on the left to generate SDL") + } + + return lines.join("\n") + "\n" +} + +// --------------------------------------------------------------------------- +// Main ConfigGenerator component +// --------------------------------------------------------------------------- + +const inputCls = + "border border-solid border-tailCall-border-light-500 rounded-lg font-space-grotesk text-sm h-9 w-full px-3 outline-none focus:border-tailCall-dark-200 text-tailCall-dark-400 bg-white" + +type ConfigState = Record + +export default function ConfigGenerator(): JSX.Element { + const [schema, setSchema] = useState(null) + const [schemaError, setSchemaError] = useState(null) + const [config, setConfig] = useState({}) + const [format, setFormat] = useState("json") + const [copied, setCopied] = useState(false) + + // Load schema on mount + useEffect(() => { + fetch(SCHEMA_URL) + .then((r) => { + if (!r.ok) throw new Error(`HTTP ${r.status}`) + return r.json() + }) + .then((data: SchemaRoot) => setSchema(data)) + .catch((err: Error) => setSchemaError(err.message)) + }, []) + + const updateField = useCallback((key: string, val: unknown) => { + setConfig((prev) => ({...prev, [key]: val})) + }, []) + + const output = schema ? formatOutput(config, format) : "" + + const handleDownload = () => { + const ext = format === "json" ? "json" : format === "graphql" ? "graphql" : "yaml" + const blob = new Blob([output], {type: "text/plain"}) + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = `tailcall-config.${ext}` + a.click() + URL.revokeObjectURL(url) + } + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(output) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch {} + } + + const handleReset = () => { + if (window.confirm("Reset all fields to defaults?")) setConfig({}) + } + + return ( +
+ {/* Header */} +
+
+

Config Generator

+

+ Build a Tailcall configuration visually. Fields are driven directly from the{" "} + + official schema + {" "} + and stay up-to-date automatically. +

+
+
+ + {/* Body */} +
+ {schemaError && ( +
+ Failed to load schema: {schemaError}. Please refresh or try again later. +
+ )} + + {!schema && !schemaError && ( +
+ Loading schema… +
+ )} + + {schema && ( +
+ {/* ---- Left: form ---- */} +
+
+
+

Configuration Fields

+ +
+
+ {Object.entries(schema.properties ?? {}).map(([key, propSchema]) => ( + updateField(key, v)} + depth={0} + /> + ))} +
+
+
+ + {/* ---- Right: output ---- */} +
+
+
+ {/* Format tabs + actions */} +
+ {(["json", "yaml", "graphql"] as Format[]).map((f) => ( + + ))} +
+ + +
+
+ + {/* Code preview */} +
+                    {output || (
+                      
+                        Fill in fields on the left to generate your configuration…
+                      
+                    )}
+                  
+
+ + {/* Tips */} +
+

Tips

+
    +
  • Click section headers to expand/collapse nested config.
  • +
  • Optional fields are marked with an "disabled" badge — toggle to enable.
  • +
  • Use the format buttons to switch between JSON, YAML, and GraphQL SDL output.
  • +
  • Download saves the config file ready for use with Tailcall CLI.
  • +
+
+
+
+
+ )} +
+
+ ) +} diff --git a/src/constants/titles.ts b/src/constants/titles.ts index 4fb87f136b..05bc690e65 100644 --- a/src/constants/titles.ts +++ b/src/constants/titles.ts @@ -6,6 +6,7 @@ export const PageTitle = { ENTERPRISE: `Enterprise | ${tagline}`, CONTACT: `Contact | ${tagline}`, PLAYGROUND: `Playground | ${tagline}`, + CONFIG: `Config Generator | ${tagline}`, } export const PageDescription = { @@ -15,4 +16,6 @@ export const PageDescription = { CONTACT: "Get in touch with us for any queries, feedback, or support. We are here to help you.", PLAYGROUND: "Play around with Tailcall's GraphQL playground to see how you can build and deploy GraphQL APIs in minutes.", + CONFIG: + "Generate valid Tailcall configurations visually. Build, customise, and download JSON or YAML config files directly from the official schema.", } diff --git a/src/pages/app/config.tsx b/src/pages/app/config.tsx new file mode 100644 index 0000000000..1d989feb5f --- /dev/null +++ b/src/pages/app/config.tsx @@ -0,0 +1,22 @@ +import React, {useEffect} from "react" +import ReactGA from "react-ga4" +import Layout from "@theme/Layout" +import {useLocation} from "@docusaurus/router" +import ConfigGenerator from "../../components/config-generator" +import {PageDescription, PageTitle} from "../../constants/titles" + +const ConfigPage = () => { + const location = useLocation() + + useEffect(() => { + ReactGA.send({hitType: "pageview", page: location.pathname, title: "Config Generator"}) + }, []) + + return ( + + + + ) +} + +export default ConfigPage