diff --git a/cypress/e2e/config_generator_spec.cy.ts b/cypress/e2e/config_generator_spec.cy.ts new file mode 100644 index 0000000000..bfb79dae5b --- /dev/null +++ b/cypress/e2e/config_generator_spec.cy.ts @@ -0,0 +1,39 @@ +// End-to-end coverage for the Tailcall Configuration Generator at /app/config. +// The schema request is intercepted with a fixture so the run is deterministic +// and independent of the network. + +describe("Configuration Generator", () => { + beforeEach(() => { + cy.intercept("GET", "**/.tailcallrc.schema.json", {fixture: "tailcallrc.schema.json"}).as("schema") + cy.visit("/app/config/") + }) + + it("loads the schema and renders a valid starter configuration", () => { + cy.contains("Schema loaded from tailcall").should("be.visible") + cy.get("[data-testid=validation]").should("contain.text", "No schema errors") + + // YAML is the default output and reflects the seeded runtime config. + cy.get("[data-testid=output]").should("contain.text", "port: 8000") + cy.get("[data-testid=output]").should("contain.text", "httpCache: 42") + }) + + it("serializes the same configuration to JSON and GraphQL", () => { + cy.contains("button", "JSON").click() + cy.get("[data-testid=output]").should("contain.text", '"port": 8000') + cy.get("[data-testid=output]").should("contain.text", '"httpCache": 42') + + cy.contains("button", "GraphQL").click() + cy.get("[data-testid=output]").should("contain.text", "schema @server(port: 8000") + cy.get("[data-testid=output]").should("contain.text", "@upstream(httpCache: 42") + }) + + it("regenerates the output when a field changes", () => { + cy.get("input[type=number]").first().clear().type("9000") + cy.get("[data-testid=output]").should("contain.text", "port: 9000") + }) + + it("exposes copy and download actions", () => { + cy.get("[data-testid=copy]").should("be.visible") + cy.contains("button", "Download").should("be.visible") + }) +}) diff --git a/cypress/fixtures/tailcallrc.schema.json b/cypress/fixtures/tailcallrc.schema.json new file mode 100644 index 0000000000..53a51b8a48 --- /dev/null +++ b/cypress/fixtures/tailcallrc.schema.json @@ -0,0 +1,77 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Config", + "type": "object", + "properties": { + "server": { + "description": "Dictates how the server behaves for all ingress requests.", + "allOf": [{"$ref": "#/definitions/Server"}] + }, + "upstream": { + "description": "Dictates how Tailcall handles upstream requests and responses.", + "allOf": [{"$ref": "#/definitions/Upstream"}] + }, + "telemetry": { + "description": "Enable OpenTelemetry support.", + "allOf": [{"$ref": "#/definitions/Telemetry"}] + }, + "links": { + "description": "A list of all links in the schema.", + "type": "array", + "items": {"$ref": "#/definitions/Link"} + } + }, + "definitions": { + "Server": { + "type": "object", + "properties": { + "port": {"type": ["integer", "null"], "description": "The port Tailcall listens on."}, + "hostname": {"type": ["string", "null"], "description": "The host address the server binds to."}, + "version": { + "description": "The HTTP version to use.", + "anyOf": [{"$ref": "#/definitions/HttpVersion"}, {"type": "null"}] + }, + "queryValidation": {"type": ["boolean", "null"], "description": "Validate incoming queries."} + } + }, + "Upstream": { + "type": "object", + "properties": { + "httpCache": {"type": ["integer", "null"], "description": "Number of responses to cache."}, + "timeout": {"type": ["integer", "null"], "description": "Upstream response timeout in seconds."}, + "batch": { + "description": "Group upstream requests.", + "anyOf": [{"$ref": "#/definitions/Batch"}, {"type": "null"}] + }, + "allowedHeaders": {"type": ["array", "null"], "items": {"type": "string"}} + } + }, + "Batch": { + "type": "object", + "properties": { + "maxSize": {"type": ["integer", "null"], "description": "Maximum requests per batch."}, + "delay": {"type": ["integer", "null"], "description": "Delay before dispatch, in ms."} + } + }, + "Telemetry": { + "type": "object", + "properties": { + "requestHeaders": {"type": ["array", "null"], "items": {"type": "string"}} + } + }, + "Link": { + "type": "object", + "required": ["src", "type"], + "properties": { + "id": {"type": ["string", "null"]}, + "src": {"type": "string", "description": "Path or URL of the linked resource."}, + "type": {"description": "The type of link.", "allOf": [{"$ref": "#/definitions/LinkType"}]} + } + }, + "HttpVersion": {"type": "string", "enum": ["HTTP1", "HTTP2"]}, + "LinkType": { + "type": "string", + "enum": ["Config", "Protobuf", "Script", "Cert", "Key", "Operation", "Htpasswd", "Jwks", "Grpc"] + } + } +} diff --git a/src/components/ConfigGenerator/SchemaForm.tsx b/src/components/ConfigGenerator/SchemaForm.tsx new file mode 100644 index 0000000000..ea4778e7cd --- /dev/null +++ b/src/components/ConfigGenerator/SchemaForm.tsx @@ -0,0 +1,373 @@ +import React, {useState} from "react" +import SearchableSelect, {type SelectOption} from "./SearchableSelect" +import {deref, typeOf, variantLabel, variantsOf, type JSONSchema} from "./jsonSchema" +import styles from "./styles.module.css" + +type FormProps = { + schema: JSONSchema + root: JSONSchema + value: unknown + onChange: (value: unknown) => void + // Extra options offered to string controls (e.g. known GraphQL type names). + suggestions?: SelectOption[] +} + +const isPlainObject = (v: unknown): v is Record => + typeof v === "object" && v !== null && !Array.isArray(v) + +// A sensible empty value for a freshly added property of the given schema. +function defaultFor(schema: JSONSchema, root: JSONSchema): unknown { + const node = deref(schema, root) + const type = typeOf(node) + if (type === "object") return {} + if (type === "array") return [] + if (type === "boolean") return false + return "" +} + +// -- primitive controls ------------------------------------------------------- + +const StringControl = ({value, onChange, suggestions, node}: FormProps & {node: JSONSchema}): JSX.Element => { + if (suggestions && suggestions.length > 0) { + return ( + + ) + } + return ( + onChange(e.target.value)} + /> + ) +} + +const NumberControl = ({value, onChange}: FormProps): JSX.Element => ( + { + const raw = e.target.value + onChange(raw === "" ? "" : Number(raw)) + }} + /> +) + +const BooleanControl = ({value, onChange}: FormProps): JSX.Element => ( + +) + +const EnumControl = ({value, onChange, node}: FormProps & {node: JSONSchema}): JSX.Element => { + const options: SelectOption[] = (node.enum ?? []).map((option) => ({value: String(option), label: String(option)})) + return ( + + ) +} + +// -- variant (anyOf / oneOf) -------------------------------------------------- + +const VariantForm = ({schema, root, value, onChange, suggestions}: FormProps): JSX.Element => { + const variants = variantsOf(schema) ?? [] + const [index, setIndex] = useState(0) + const options: SelectOption[] = variants.map((variant, i) => ({ + value: String(i), + label: variantLabel(variant, root, i), + })) + const active = variants[index] ?? {} + return ( +
+ setIndex(Number(v))} /> +
+ +
+
+ ) +} + +// -- arrays ------------------------------------------------------------------- + +const ArrayEditor = ({schema, root, value, onChange, suggestions}: FormProps): JSX.Element => { + const items = Array.isArray(value) ? value : [] + const itemSchema = schema.items ?? {type: "string"} + const update = (i: number, next: unknown) => onChange(items.map((item, j) => (j === i ? next : item))) + const remove = (i: number) => onChange(items.filter((_, j) => j !== i)) + const add = () => onChange([...items, defaultFor(itemSchema, root)]) + return ( +
+ {items.map((item, i) => ( +
+
+ update(i, v)} + suggestions={suggestions} + /> +
+ +
+ ))} + +
+ ) +} + +// -- object with fixed properties --------------------------------------------- + +const ObjectControl = ({schema, root, value, onChange, suggestions}: FormProps): JSX.Element => { + const obj = isPlainObject(value) ? value : {} + const properties = schema.properties ?? {} + const required = new Set(schema.required ?? []) + const propKeys = Object.keys(properties) + const mapSchema = isPlainObject(schema.additionalProperties) ? (schema.additionalProperties as JSONSchema) : undefined + + const setProp = (key: string, next: unknown) => onChange({...obj, [key]: next}) + const unsetProp = (key: string) => { + const {[key]: _removed, ...rest} = obj + onChange(rest) + } + + const visibleKeys = propKeys.filter((key) => required.has(key) || obj[key] !== undefined) + const hiddenKeys = propKeys.filter((key) => !required.has(key) && obj[key] === undefined) + const addOptions: SelectOption[] = hiddenKeys.map((key) => ({ + value: key, + label: key, + description: shortDescription(properties[key], root), + })) + + return ( +
+ {visibleKeys.map((key) => { + const childSchema = properties[key] + const child = deref(childSchema, root) + const isRequired = required.has(key) + const childIsObjectLike = + child.type === "object" || + Boolean(child.properties) || + isPlainObject(child.additionalProperties) || + Boolean(child.anyOf) || + Boolean(child.oneOf) + // Offer the type picker on `type` fields; otherwise only propagate the + // suggestion list through nested objects so deeper `type` fields see it. + const childSuggestions = key === "type" ? suggestions : childIsObjectLike ? suggestions : undefined + return ( +
+
+ + {!isRequired && ( + + )} +
+ {child.description &&

{child.description}

} + setProp(key, next)} + suggestions={childSuggestions} + /> +
+ ) + })} + + {mapSchema && ( + + )} + + {addOptions.length > 0 && ( +
+ setProp(key, defaultFor(properties[key], root))} + placeholder="+ Add field" + /> +
+ )} +
+ ) +} + +// -- object used as a map (additionalProperties) ------------------------------ + +type MapProps = FormProps & {propKeys: string[]} + +const MapEditor = ({schema, root, value, onChange, propKeys, suggestions}: MapProps): JSX.Element => { + const obj = isPlainObject(value) ? value : {} + const reserved = new Set(propKeys) + const entries = Object.keys(obj).filter((key) => !reserved.has(key)) + const [draft, setDraft] = useState("") + + const addEntry = () => { + const key = draft.trim() + if (!key || key in obj) return + onChange({...obj, [key]: defaultFor(schema, root)}) + setDraft("") + } + + return ( +
+ {entries.map((key) => ( + onChange({...obj, [key]: next})} + onRename={(nextName) => { + const nextName2 = nextName.trim() + if (!nextName2 || nextName2 === key || nextName2 in obj) return + const rebuilt: Record = {} + for (const [k, v] of Object.entries(obj)) rebuilt[k === key ? nextName2 : k] = v + onChange(rebuilt) + }} + onRemove={() => { + const {[key]: _removed, ...rest} = obj + onChange(rest) + }} + /> + ))} +
+ setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + addEntry() + } + }} + /> + +
+
+ ) +} + +type MapEntryProps = { + name: string + schema: JSONSchema + root: JSONSchema + value: unknown + existing: string[] + suggestions?: SelectOption[] + onChangeValue: (value: unknown) => void + onRename: (name: string) => void + onRemove: () => void +} + +const MapEntry = ({ + name, + schema, + root, + value, + suggestions, + onChangeValue, + onRename, + onRemove, +}: MapEntryProps): JSX.Element => { + const [localName, setLocalName] = useState(name) + return ( +
+
+ setLocalName(e.target.value)} + onBlur={() => onRename(localName)} + onKeyDown={(e) => { + if (e.key === "Enter") (e.target as HTMLInputElement).blur() + }} + /> + +
+
+ +
+
+ ) +} + +function shortDescription(schema: JSONSchema, root: JSONSchema): string | undefined { + const node = deref(schema, root) + if (!node.description) return undefined + return node.description.length > 80 ? `${node.description.slice(0, 77)}…` : node.description +} + +// -- dispatch ----------------------------------------------------------------- + +const SchemaForm = (props: FormProps): JSX.Element => { + const node = deref(props.schema, props.root) + + if (node.anyOf || node.oneOf) { + // schemars encodes Option as anyOf: [T, {type: "null"}]. Drop the null + // branch so an optional field renders as its real control, not a pointless + // two-way "Type / null" selector. + const variants = (variantsOf(node) ?? []).filter((v) => typeOf(deref(v, props.root)) !== "null") + // schemars documents each enum value as its own single-value branch (e.g. + // LinkType). Collapse those into one dropdown instead of a variant selector. + const derefed = variants.map((v) => deref(v, props.root)) + if (variants.length > 1 && derefed.every((d) => Array.isArray(d.enum) && d.enum.length > 0)) { + const enumValues = derefed.flatMap((d) => d.enum as Array) + return + } + if (variants.length <= 1) return + return + } + if (node.enum && node.enum.length > 0) return + + const type = typeOf(node) + if (type === "boolean") return + if (type === "integer" || type === "number") return + if (type === "array") return + if (type === "object" || node.properties || isPlainObject(node.additionalProperties)) { + return + } + return +} + +export default SchemaForm diff --git a/src/components/ConfigGenerator/SearchableSelect.tsx b/src/components/ConfigGenerator/SearchableSelect.tsx new file mode 100644 index 0000000000..3e2a295522 --- /dev/null +++ b/src/components/ConfigGenerator/SearchableSelect.tsx @@ -0,0 +1,134 @@ +import React, {useEffect, useMemo, useRef, useState} from "react" +import styles from "./styles.module.css" + +export type SelectOption = {value: string; label: string; description?: string} + +type Props = { + value: string + options: SelectOption[] + onChange: (value: string) => void + placeholder?: string + allowCustom?: boolean + ariaLabel?: string +} + +// A dependency-free searchable dropdown (combobox). Used everywhere a value is +// drawn from a bounded set — enum variants, HTTP methods, link kinds, and the +// GraphQL type picker — per the issue's "use searchable dropdowns wherever +// possible" requirement. +const SearchableSelect = ({value, options, onChange, placeholder, allowCustom, ariaLabel}: Props): JSX.Element => { + const [open, setOpen] = useState(false) + const [query, setQuery] = useState("") + const [active, setActive] = useState(0) + const containerRef = useRef(null) + const inputRef = useRef(null) + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase() + if (!q) return options + return options.filter((o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q)) + }, [options, query]) + + useEffect(() => { + if (!open) return + const onClick = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) setOpen(false) + } + document.addEventListener("mousedown", onClick) + return () => document.removeEventListener("mousedown", onClick) + }, [open]) + + useEffect(() => { + if (open) { + setActive(0) + inputRef.current?.focus() + } else { + setQuery("") + } + }, [open]) + + const commit = (next: string) => { + onChange(next) + setOpen(false) + } + + const onKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "ArrowDown") { + event.preventDefault() + setActive((i) => Math.min(i + 1, filtered.length - 1)) + } else if (event.key === "ArrowUp") { + event.preventDefault() + setActive((i) => Math.max(i - 1, 0)) + } else if (event.key === "Enter") { + event.preventDefault() + if (filtered[active]) commit(filtered[active].value) + else if (allowCustom && query.trim()) commit(query.trim()) + } else if (event.key === "Escape") { + setOpen(false) + } + } + + const selected = options.find((o) => o.value === value) + const label = selected?.label ?? value + + return ( +
+ + {open && ( +
+ { + setQuery(e.target.value) + setActive(0) + }} + onKeyDown={onKeyDown} + aria-label="Filter options" + /> +
    + {filtered.map((option, i) => ( +
  • setActive(i)} + onMouseDown={(e) => { + e.preventDefault() + commit(option.value) + }} + > + {option.label} + {option.description && {option.description}} +
  • + ))} + {filtered.length === 0 && ( +
  • + {allowCustom && query.trim() ? `Use "${query.trim()}"` : "No matches"} +
  • + )} +
+
+ )} +
+ ) +} + +export default SearchableSelect diff --git a/src/components/ConfigGenerator/fallbackSchema.ts b/src/components/ConfigGenerator/fallbackSchema.ts new file mode 100644 index 0000000000..ed7617baad --- /dev/null +++ b/src/components/ConfigGenerator/fallbackSchema.ts @@ -0,0 +1,99 @@ +import type {JSONSchema} from "./jsonSchema" + +// A compact, offline snapshot of `.tailcallrc.schema.json`. The live schema is +// always fetched first; this is only used when the network request fails (for +// example while offline or in CI) so the generator stays usable. It mirrors the +// runtime configuration Tailcall's schema actually describes — server, upstream, +// telemetry and links — using the same draft-07 idioms schemars emits +// (`allOf` + `$ref`, nullable `type` arrays, `anyOf` with null, string enums). +export const fallbackSchema: JSONSchema = { + $schema: "http://json-schema.org/draft-07/schema#", + title: "Config", + type: "object", + properties: { + server: { + description: "Dictates how the server behaves and helps tune Tailcall for all ingress requests.", + allOf: [{$ref: "#/definitions/Server"}], + }, + upstream: { + description: "Dictates how Tailcall should handle upstream requests and responses.", + allOf: [{$ref: "#/definitions/Upstream"}], + }, + telemetry: { + description: "Enable OpenTelemetry support.", + allOf: [{$ref: "#/definitions/Telemetry"}], + }, + links: { + description: "A list of all links in the schema.", + type: "array", + items: {$ref: "#/definitions/Link"}, + }, + }, + definitions: { + Server: { + type: "object", + properties: { + port: {type: ["integer", "null"], description: "The port Tailcall listens on. Defaults to 8000."}, + hostname: {type: ["string", "null"], description: "The host address the server binds to."}, + version: { + description: "The HTTP version for the server.", + anyOf: [{$ref: "#/definitions/HttpVersion"}, {type: "null"}], + }, + workers: {type: ["integer", "null"], description: "The number of worker threads the server spawns."}, + queryValidation: {type: ["boolean", "null"], description: "Validate incoming queries against the schema."}, + introspection: {type: ["boolean", "null"], description: "Allow GraphQL introspection queries."}, + globalResponseTimeout: {type: ["integer", "null"], description: "Max time, in milliseconds, for a request."}, + }, + }, + Upstream: { + type: "object", + properties: { + httpCache: {type: ["integer", "null"], description: "Number of responses to keep in the HTTP cache."}, + timeout: {type: ["integer", "null"], description: "Max time, in seconds, to wait for an upstream response."}, + connectTimeout: {type: ["integer", "null"], description: "Max time, in seconds, to establish a connection."}, + http2Only: {type: ["boolean", "null"], description: "Use HTTP/2 for all upstream requests."}, + allowedHeaders: { + type: ["array", "null"], + description: "Headers forwarded to the upstream.", + items: {type: "string"}, + }, + batch: { + description: "Group upstream requests into batches.", + anyOf: [{$ref: "#/definitions/Batch"}, {type: "null"}], + }, + userAgent: {type: ["string", "null"], description: "The User-Agent header sent to the upstream."}, + }, + }, + Batch: { + type: "object", + properties: { + maxSize: {type: ["integer", "null"], description: "Maximum number of requests in a single batch."}, + delay: {type: ["integer", "null"], description: "Delay in milliseconds before a batch is dispatched."}, + }, + }, + Telemetry: { + type: "object", + properties: { + requestHeaders: { + type: ["array", "null"], + description: "Request headers to record on each span.", + items: {type: "string"}, + }, + }, + }, + Link: { + type: "object", + required: ["src", "type"], + properties: { + id: {type: ["string", "null"], description: "An id to refer to the link."}, + src: {type: "string", description: "The path or URL of the linked resource."}, + type: {description: "The type of the linked resource.", allOf: [{$ref: "#/definitions/LinkType"}]}, + }, + }, + HttpVersion: {type: "string", enum: ["HTTP1", "HTTP2"]}, + LinkType: { + type: "string", + enum: ["Config", "Protobuf", "Script", "Cert", "Key", "Operation", "Htpasswd", "Jwks", "Grpc"], + }, + }, +} diff --git a/src/components/ConfigGenerator/index.tsx b/src/components/ConfigGenerator/index.tsx new file mode 100644 index 0000000000..e3aeb31e46 --- /dev/null +++ b/src/components/ConfigGenerator/index.tsx @@ -0,0 +1,225 @@ +import React, {useEffect, useMemo, useState} from "react" +import Link from "@docusaurus/Link" +import {Check, Copy, Download, RotateCcw} from "lucide-react" +import SchemaForm from "./SchemaForm" +import {type SelectOption} from "./SearchableSelect" +import {prune, serialize, type OutputFormat} from "./serialize" +import {validateConfig, type ValidationError} from "./validate" +import {fetchSchema, SCHEMA_URL, type JSONSchema} from "./jsonSchema" +import {fallbackSchema} from "./fallbackSchema" +import styles from "./styles.module.css" + +// Versioned so a stale draft from an earlier (v1-shaped) build is not restored +// into the runtime-config form. +const STORAGE_KEY = "tailcall-config-generator-v2" + +const isPlainObject = (v: unknown): v is Record => + typeof v === "object" && v !== null && !Array.isArray(v) + +// A small starter configuration so the page is immediately useful and shows a +// valid, well-formed result on first load. +const STARTER_CONFIG: Record = { + server: {port: 8000, queryValidation: true, version: "HTTP2"}, + upstream: {httpCache: 42, batch: {maxSize: 100, delay: 10}}, +} + +const FORMATS: Array<{id: OutputFormat; label: string}> = [ + {id: "yaml", label: "YAML"}, + {id: "json", label: "JSON"}, + {id: "graphql", label: "GraphQL"}, +] + +const FILE_NAMES: Record = { + graphql: "config.graphql", + json: "config.json", + yaml: "config.yml", +} + +const SCALARS = ["String", "Int", "Float", "Boolean", "ID"] + +type SchemaSource = "loading" | "live" | "bundled" + +const ConfigGenerator = (): JSX.Element => { + const [schema, setSchema] = useState(fallbackSchema) + const [source, setSource] = useState("loading") + const [config, setConfig] = useState>(STARTER_CONFIG) + const [format, setFormat] = useState("yaml") + const [copied, setCopied] = useState(false) + + // Load the live schema on mount, falling back to the bundled snapshot. + useEffect(() => { + let cancelled = false + fetchSchema(SCHEMA_URL) + .then((live) => { + if (cancelled) return + setSchema(live) + setSource("live") + }) + .catch(() => { + if (cancelled) return + setSource("bundled") + }) + return () => { + cancelled = true + } + }, []) + + // Restore a previously saved draft, if any. + useEffect(() => { + try { + const saved = window.localStorage.getItem(STORAGE_KEY) + if (saved) { + const parsed = JSON.parse(saved) + if (isPlainObject(parsed)) setConfig(parsed) + } + } catch { + // Ignore malformed or unavailable storage. + } + }, []) + + // Persist the draft as it changes. + useEffect(() => { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(config)) + } catch { + // Ignore storage failures (private mode, quota). + } + }, [config]) + + const typeSuggestions = useMemo(() => { + const names = new Set(SCALARS) + for (const key of ["types", "unions", "enums"]) { + const group = config[key] + if (isPlainObject(group)) Object.keys(group).forEach((name) => names.add(name)) + } + const options: SelectOption[] = [] + for (const name of names) { + options.push({value: name, label: name}) + options.push({value: `[${name}]`, label: `[${name}] (list)`}) + } + return options + }, [config]) + + const output = useMemo(() => serialize(config, format), [config, format]) + // Validate the pruned config so cleared/empty fields (which are omitted from + // the output) do not raise spurious type errors. + const errors = useMemo(() => validateConfig(prune(config), schema), [config, schema]) + + const onCopy = async () => { + try { + await navigator.clipboard.writeText(output) + setCopied(true) + window.setTimeout(() => setCopied(false), 1500) + } catch { + // Clipboard may be unavailable; the output is still visible for manual copy. + } + } + + const onDownload = () => { + const blob = new Blob([output], {type: "text/plain;charset=utf-8"}) + const url = URL.createObjectURL(blob) + const anchor = document.createElement("a") + anchor.href = url + anchor.download = FILE_NAMES[format] + document.body.appendChild(anchor) + anchor.click() + document.body.removeChild(anchor) + URL.revokeObjectURL(url) + } + + const onReset = () => setConfig(STARTER_CONFIG) + + return ( +
+
+

Configuration Generator

+

+ Build a Tailcall configuration with a form driven directly by the official schema, then copy or download it as + GraphQL, JSON or YAML. +

+
+ + {source === "live" && "Schema loaded from tailcall"} + {source === "bundled" && "Using bundled schema (offline)"} + {source === "loading" && "Loading schema…"} + + + Open the GraphQL Playground → + +
+
+ +
+
+
+

Configuration

+ +
+
+ setConfig(isPlainObject(v) ? v : {})} + suggestions={typeSuggestions} + /> +
+
+ +
+
+
+ {FORMATS.map((f) => ( + + ))} +
+
+ + +
+
+ +
+            {output}
+          
+ +
0 ? styles.invalid : styles.valid} data-testid="validation"> + {errors.length === 0 ? ( + + + ) : ( +
    + {errors.slice(0, 6).map((error, i) => ( +
  • + {error.path || "(root)"} {error.message} +
  • + ))} +
+ )} +
+
+
+
+ ) +} + +export default ConfigGenerator diff --git a/src/components/ConfigGenerator/jsonSchema.ts b/src/components/ConfigGenerator/jsonSchema.ts new file mode 100644 index 0000000000..1072a9df64 --- /dev/null +++ b/src/components/ConfigGenerator/jsonSchema.ts @@ -0,0 +1,106 @@ +// Minimal JSON Schema (draft-07) model and helpers used to drive the +// Tailcall configuration form. The real schema is fetched at runtime from +// `.tailcallrc.schema.json`; these helpers stay deliberately small and only +// depend on the constructs schemars (Tailcall's generator) actually emits: +// `$ref`, `definitions`, `allOf`, `anyOf`/`oneOf`, `enum`, `properties`, +// `required`, `additionalProperties`, `items`. + +export type JSONSchema = { + $ref?: string + $schema?: string + title?: string + description?: string + type?: string | string[] + format?: string + properties?: Record + required?: string[] + items?: JSONSchema + enum?: Array + additionalProperties?: boolean | JSONSchema + allOf?: JSONSchema[] + anyOf?: JSONSchema[] + oneOf?: JSONSchema[] + default?: unknown + definitions?: Record + $defs?: Record + [key: string]: unknown +} + +export type ConfigValue = unknown + +// The canonical location of the Tailcall config schema. Loaded dynamically so +// the form always reflects the current shape of the configuration. +export const SCHEMA_URL = "https://raw.githubusercontent.com/tailcallhq/tailcall/main/generated/.tailcallrc.schema.json" + +// Resolve a local `$ref` ("#/definitions/Foo" or "#/$defs/Foo") against the +// root schema. Returns `undefined` for unknown or remote refs. +export function resolveRef(ref: string, root: JSONSchema): JSONSchema | undefined { + if (!ref.startsWith("#/")) return undefined + const parts = ref.slice(2).split("/") + let node: unknown = root + for (const part of parts) { + if (node && typeof node === "object" && part in (node as Record)) { + node = (node as Record)[part] + } else { + return undefined + } + } + return node as JSONSchema +} + +// Collapse a schema node into something directly renderable: follow a single +// `$ref` and merge `allOf` branches (properties/required union). Bounded depth +// guards against cyclic schemas. +export function deref(node: JSONSchema | undefined, root: JSONSchema, depth = 0): JSONSchema { + if (!node || depth > 32) return node ?? {} + if (node.$ref) { + const target = resolveRef(node.$ref, root) + return deref(target, root, depth + 1) + } + if (node.allOf && node.allOf.length > 0) { + const merged: JSONSchema = {type: "object", properties: {}, required: []} + for (const branch of node.allOf) { + const resolved = deref(branch, root, depth + 1) + merged.properties = {...merged.properties, ...resolved.properties} + merged.required = [...(merged.required ?? []), ...(resolved.required ?? [])] + if (resolved.type) merged.type = resolved.type + if (resolved.enum) merged.enum = resolved.enum + if (resolved.anyOf) merged.anyOf = resolved.anyOf + if (resolved.oneOf) merged.oneOf = resolved.oneOf + if (resolved.additionalProperties !== undefined) merged.additionalProperties = resolved.additionalProperties + } + // Preserve sibling keywords declared alongside allOf. + return {...node, ...merged, properties: merged.properties, required: merged.required} + } + return node +} + +// The list of variant schemas for a `anyOf`/`oneOf` node, if any. +export function variantsOf(node: JSONSchema): JSONSchema[] | undefined { + return node.anyOf ?? node.oneOf +} + +// A short human label for a schema variant, used in the variant selector. +export function variantLabel(node: JSONSchema, root: JSONSchema, index: number): string { + if (node.$ref) return node.$ref.split("/").pop() ?? `Option ${index + 1}` + const d = deref(node, root) + if (d.title) return d.title + if (typeof d.type === "string") return d.type + if (d.enum && d.enum.length > 0) return String(d.enum[0]) + return `Option ${index + 1}` +} + +// The effective `type` string of a node, if unambiguous. +export function typeOf(node: JSONSchema): string | undefined { + if (typeof node.type === "string") return node.type + if (Array.isArray(node.type)) return node.type.find((t) => t !== "null") + return undefined +} + +// Fetch the schema over the network. Callers are expected to fall back to the +// bundled snapshot when this rejects (offline, rate-limited, or CI). +export async function fetchSchema(url: string = SCHEMA_URL): Promise { + const res = await fetch(url, {headers: {Accept: "application/json"}}) + if (!res.ok) throw new Error(`Schema request failed with ${res.status}`) + return (await res.json()) as JSONSchema +} diff --git a/src/components/ConfigGenerator/serialize.ts b/src/components/ConfigGenerator/serialize.ts new file mode 100644 index 0000000000..8cc4484e01 --- /dev/null +++ b/src/components/ConfigGenerator/serialize.ts @@ -0,0 +1,235 @@ +// Serializers that turn the in-memory configuration model into the three +// formats Tailcall accepts: JSON, YAML and GraphQL SDL. Everything here is a +// pure function so it can be unit- or e2e-tested in isolation. + +type Dict = Record + +const isPlainObject = (v: unknown): v is Dict => typeof v === "object" && v !== null && !Array.isArray(v) + +// Drop empty/undefined leaves so the generated output stays clean: undefined, +// empty strings, empty objects and empty arrays are all treated as "unset". +export function prune(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(prune).filter((v) => v !== undefined) + } + if (isPlainObject(value)) { + const out: Dict = {} + for (const [key, raw] of Object.entries(value)) { + const pruned = prune(raw) + if (pruned === undefined) continue + if (pruned === "") continue + if (isPlainObject(pruned) && Object.keys(pruned).length === 0) continue + if (Array.isArray(pruned) && pruned.length === 0) continue + out[key] = pruned + } + return out + } + return value +} + +export function toJSON(model: unknown): string { + return JSON.stringify(prune(model), null, 2) +} + +// -- YAML --------------------------------------------------------------------- + +function needsQuote(s: string): boolean { + if (s === "") return true + if (/^\s|\s$/.test(s)) return true + if (/[\n\t\r]/.test(s)) return true + if (/[:#\[\]{}&*!|>'"%@`,]/.test(s)) return true + if (/^[-?]/.test(s)) return true + if (/^(true|false|null|~|yes|no|on|off)$/i.test(s)) return true + if (/^[-+]?(\d+\.?\d*|\.\d+)([eE][-+]?\d+)?$/.test(s)) return true + return false +} + +function scalarYaml(value: unknown): string { + if (value === null || value === undefined) return "null" + if (typeof value === "boolean") return value ? "true" : "false" + if (typeof value === "number") return String(value) + if (typeof value === "object") return JSON.stringify(value) + const s = String(value) + if (!needsQuote(s)) return s + const escaped = s + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"') + .replace(/\n/g, "\\n") + .replace(/\t/g, "\\t") + .replace(/\r/g, "\\r") + return `"${escaped}"` +} + +function yamlLines(value: unknown, indent: number): string[] { + const pad = " ".repeat(indent) + if (Array.isArray(value)) { + const lines: string[] = [] + for (const item of value) { + if (isPlainObject(item) && Object.keys(item).length > 0) { + const entries = Object.entries(item).filter(([, v]) => v !== undefined) + entries.forEach(([key, raw], i) => { + const prefix = i === 0 ? `${pad}- ` : `${pad} ` + appendEntry(lines, prefix, `${pad} `, key, raw) + }) + } else { + lines.push(`${pad}- ${scalarYaml(item)}`) + } + } + return lines + } + if (isPlainObject(value)) { + const lines: string[] = [] + for (const [key, raw] of Object.entries(value)) { + if (raw === undefined) continue + appendEntry(lines, pad, pad, key, raw) + } + return lines + } + return [`${pad}${scalarYaml(value)}`] +} + +// Emit a single `key: value` entry, recursing for containers. `firstPad` is the +// indentation for the key line; `childIndentPad` is used to compute the child +// indent level for nested blocks. +function appendEntry(lines: string[], firstPad: string, childIndentPad: string, key: string, raw: unknown): void { + const childIndent = childIndentPad.length / 2 + 1 + if (Array.isArray(raw)) { + if (raw.length === 0) { + lines.push(`${firstPad}${key}: []`) + return + } + lines.push(`${firstPad}${key}:`) + for (const line of yamlLines(raw, childIndent)) lines.push(line) + return + } + if (isPlainObject(raw)) { + if (Object.keys(raw).length === 0) { + lines.push(`${firstPad}${key}: {}`) + return + } + lines.push(`${firstPad}${key}:`) + for (const line of yamlLines(raw, childIndent)) lines.push(line) + return + } + lines.push(`${firstPad}${key}: ${scalarYaml(raw)}`) +} + +export function toYAML(model: unknown): string { + const pruned = prune(model) + if (!isPlainObject(pruned) || Object.keys(pruned).length === 0) return "{}\n" + return yamlLines(pruned, 0).join("\n") + "\n" +} + +// -- GraphQL SDL -------------------------------------------------------------- + +function gqlValue(value: unknown): string { + if (typeof value === "string") return JSON.stringify(value) + if (typeof value === "boolean" || typeof value === "number") return String(value) + if (Array.isArray(value)) return `[${value.map(gqlValue).join(", ")}]` + if (isPlainObject(value)) { + return `{${Object.entries(value) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => `${k}: ${gqlValue(v)}`) + .join(", ")}}` + } + return JSON.stringify(value) +} + +// Directive arguments whose values are GraphQL enums — emitted as bare +// identifiers (e.g. `method: POST`) rather than quoted strings, which is what +// valid SDL requires. +const ENUM_ARG_KEYS = new Set(["method", "type", "encoding", "format", "version"]) +const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/ + +function gqlArgs(obj: unknown): string { + if (!isPlainObject(obj)) return "" + return Object.entries(obj) + .filter(([, v]) => v !== undefined && v !== null && v !== "") + .map(([k, v]) => { + if (ENUM_ARG_KEYS.has(k) && typeof v === "string" && IDENTIFIER.test(v)) return `${k}: ${v}` + return `${k}: ${gqlValue(v)}` + }) + .join(", ") +} + +const RESERVED_FIELD_KEYS = new Set(["type", "required", "doc", "args", "name"]) + +function gqlField(name: string, field: unknown): string { + const f = isPlainObject(field) ? field : {} + let type = typeof f.type === "string" ? f.type : "JSON" + if (f.required === true && !type.endsWith("!")) type = `${type}!` + const directives: string[] = [] + for (const [key, value] of Object.entries(f)) { + if (RESERVED_FIELD_KEYS.has(key)) continue + if (isPlainObject(value)) { + const args = gqlArgs(value) + directives.push(args ? `@${key}(${args})` : `@${key}`) + } else if (value === true) { + directives.push(`@${key}`) + } + } + const suffix = directives.length > 0 ? ` ${directives.join(" ")}` : "" + return ` ${name}: ${type}${suffix}` +} + +export function toGraphQL(model: unknown): string { + const m = isPlainObject(model) ? model : {} + const blocks: string[] = [] + + const directives: string[] = [] + if (isPlainObject(m.server) && Object.keys(m.server).length > 0) directives.push(`@server(${gqlArgs(m.server)})`) + if (isPlainObject(m.upstream) && Object.keys(m.upstream).length > 0) { + directives.push(`@upstream(${gqlArgs(m.upstream)})`) + } + if (Array.isArray(m.links)) { + for (const link of m.links) { + const args = gqlArgs(link) + if (args) directives.push(`@link(${args})`) + } + } + const roots = isPlainObject(m.schema) ? m.schema : {} + const rootLines = ["query", "mutation", "subscription"] + .filter((k) => typeof roots[k] === "string" && roots[k] !== "") + .map((k) => ` ${k}: ${roots[k]}`) + if (directives.length > 0 || rootLines.length > 0) { + const dir = directives.length > 0 ? ` ${directives.join(" ")}` : "" + const body = rootLines.length > 0 ? ` {\n${rootLines.join("\n")}\n}` : "" + // A runtime config carries only directives with no operation roots. A bare + // `schema @dir` is not valid SDL, so emit a `SchemaExtension` — which is + // what a runtime config is: it augments a schema defined elsewhere. + const keyword = rootLines.length > 0 ? "schema" : "extend schema" + blocks.push(`${keyword}${dir}${body}`) + } + + if (isPlainObject(m.enums)) { + for (const [name, def] of Object.entries(m.enums)) { + const variants = isPlainObject(def) && Array.isArray(def.variants) ? def.variants : [] + blocks.push(`enum ${name} {\n${variants.map((v) => ` ${v}`).join("\n")}\n}`) + } + } + + if (isPlainObject(m.unions)) { + for (const [name, def] of Object.entries(m.unions)) { + const types = isPlainObject(def) && Array.isArray(def.types) ? def.types : [] + blocks.push(`union ${name} = ${types.join(" | ")}`) + } + } + + if (isPlainObject(m.types)) { + for (const [name, def] of Object.entries(m.types)) { + const fields = isPlainObject(def) && isPlainObject(def.fields) ? def.fields : {} + const lines = Object.entries(fields).map(([fieldName, field]) => gqlField(fieldName, field)) + blocks.push(`type ${name} {\n${lines.join("\n")}\n}`) + } + } + + return blocks.length > 0 ? `${blocks.join("\n\n")}\n` : "" +} + +export type OutputFormat = "graphql" | "json" | "yaml" + +export function serialize(model: unknown, format: OutputFormat): string { + if (format === "json") return toJSON(model) + if (format === "yaml") return toYAML(model) + return toGraphQL(model) +} diff --git a/src/components/ConfigGenerator/styles.module.css b/src/components/ConfigGenerator/styles.module.css new file mode 100644 index 0000000000..70a08f0b03 --- /dev/null +++ b/src/components/ConfigGenerator/styles.module.css @@ -0,0 +1,581 @@ +/* The form adapts to the site's light/dark theme via Infima variables; the + output panel is intentionally always-dark to read like Tailcall's code + blocks, with the brand yellow as the single accent. */ + +.page { + max-width: 82rem; + margin: 0 auto; + padding: 3rem 1.25rem 5rem; +} + +.header { + margin-bottom: 2.25rem; +} + +.title { + font-size: 2.5rem; + font-weight: 800; + letter-spacing: -0.02em; + line-height: 1.1; + margin: 0 0 0.6rem; +} + +.title::after { + content: ""; + display: block; + width: 3rem; + height: 4px; + margin-top: 0.9rem; + border-radius: 2px; + background: var(--ifm-color-brand); +} + +.subtitle { + color: var(--ifm-color-emphasis-700); + max-width: 44rem; + margin: 0 0 1.15rem; + line-height: 1.55; + font-size: 1.02rem; +} + +.headerMeta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.9rem; +} + +.badge { + display: inline-flex; + align-items: center; + gap: 0.45rem; + font-size: 0.78rem; + font-weight: 600; + padding: 0.32rem 0.75rem; + border-radius: 999px; + border: 1px solid var(--ifm-color-emphasis-300); + color: var(--ifm-color-emphasis-700); +} + +.badge::before { + content: ""; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--ifm-color-emphasis-400); +} + +.badge[data-source="live"] { + border-color: var(--ifm-color-brand); + color: var(--ifm-color-emphasis-900); +} + +.badge[data-source="live"]::before { + background: var(--ifm-color-brand); + box-shadow: 0 0 0 3px rgba(253, 234, 46, 0.25); +} + +.playgroundLink { + font-size: 0.86rem; + font-weight: 700; + color: var(--ifm-color-emphasis-700); +} + +.playgroundLink:hover { + text-decoration: none; + color: var(--ifm-font-color-base); +} + +.layout { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 1.5rem; + align-items: start; +} + +@media (max-width: 996px) { + .layout { + grid-template-columns: 1fr; + } +} + +/* -- panels ---------------------------------------------------------------- */ + +.formPanel { + border: 1px solid var(--ifm-color-emphasis-200); + border-radius: 16px; + background: var(--ifm-background-surface-color); +} + +.outputPanel { + border-radius: 16px; + background: #1b1c1e; + border: 1px solid #2c2d30; + overflow: hidden; + box-shadow: 0 24px 48px -24px rgba(0, 0, 0, 0.45); +} + +@media (min-width: 997px) { + .outputPanel { + position: sticky; + top: calc(var(--ifm-navbar-height, 4rem) + 1rem); + } +} + +.panelHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.85rem 1.1rem; + flex-wrap: wrap; +} + +.formPanel .panelHeader { + border-bottom: 1px solid var(--ifm-color-emphasis-200); +} + +.outputPanel .panelHeader { + border-bottom: 1px solid #2c2d30; + background: #161719; +} + +.panelTitle { + font-size: 0.95rem; + font-weight: 700; + margin: 0; + letter-spacing: -0.01em; +} + +.formScroll { + padding: 1.15rem; +} + +/* -- form controls --------------------------------------------------------- */ + +.objectBody { + display: flex; + flex-direction: column; + gap: 0.9rem; +} + +.field { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +/* An object-valued field renders as a titled section card. */ +.fieldSection { + gap: 0.6rem; + padding: 0.95rem 1rem 1.05rem; + border: 1px solid var(--ifm-color-emphasis-200); + border-radius: 12px; + background: var(--ifm-background-color); +} + +.fieldHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.fieldLabel { + font-family: var(--ifm-font-family-monospace); + font-size: 0.85rem; + font-weight: 600; + color: var(--ifm-font-color-base); +} + +.fieldSection > .fieldHeader .fieldLabel { + position: relative; + font-size: 0.9rem; + font-weight: 700; + padding-left: 0.7rem; +} + +.fieldSection > .fieldHeader .fieldLabel::before { + content: ""; + position: absolute; + left: 0; + top: 0.1em; + bottom: 0.1em; + width: 3px; + border-radius: 2px; + background: var(--ifm-color-brand); +} + +.requiredMark { + color: var(--ifm-color-danger); + margin-left: 0.15rem; +} + +.fieldDesc { + font-size: 0.78rem; + color: var(--ifm-color-emphasis-600); + margin: 0; + line-height: 1.45; +} + +.input { + width: 100%; + box-sizing: border-box; + padding: 0.55rem 0.7rem; + font-size: 0.9rem; + font-family: var(--ifm-font-family-monospace); + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: 9px; + background: var(--ifm-background-surface-color); + color: var(--ifm-font-color-base); + transition: + border-color 120ms ease, + box-shadow 120ms ease; +} + +.input:focus { + outline: none; + border-color: var(--ifm-color-brand); + box-shadow: 0 0 0 3px rgba(253, 234, 46, 0.28); +} + +.toggle { + display: inline-flex; + align-items: center; + gap: 0.55rem; + font-family: var(--ifm-font-family-monospace); + font-size: 0.85rem; + cursor: pointer; +} + +.group { + border-left: 2px solid var(--ifm-color-emphasis-200); + padding-left: 0.85rem; + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +.groupBody { + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +.arrayRow, +.addRow { + display: flex; + align-items: flex-start; + gap: 0.5rem; +} + +.arrayItem { + flex: 1; + min-width: 0; +} + +.mapEditor { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.mapEntry { + border: 1px solid var(--ifm-color-emphasis-200); + border-radius: 11px; + padding: 0.8rem; + background: var(--ifm-background-surface-color); +} + +.mapEntryHeader { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.65rem; +} + +.mapKey { + flex: 1; + min-width: 0; + padding: 0.45rem 0.6rem; + font-family: var(--ifm-font-family-monospace); + font-weight: 700; + font-size: 0.9rem; + border: 1px solid transparent; + border-radius: 8px; + background: var(--ifm-color-emphasis-100); + color: var(--ifm-font-color-base); +} + +.mapKey:focus { + outline: none; + border-color: var(--ifm-color-brand); + background: var(--ifm-background-color); +} + +.mapEntryBody { + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +/* -- buttons --------------------------------------------------------------- */ + +.ghostBtn { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.82rem; + font-weight: 600; + padding: 0.42rem 0.75rem; + border-radius: 9px; + border: 1px solid var(--ifm-color-emphasis-300); + background: var(--ifm-background-color); + color: var(--ifm-font-color-base); + cursor: pointer; + transition: + border-color 120ms ease, + background 120ms ease; +} + +.ghostBtn:hover { + border-color: var(--ifm-color-brand); +} + +/* The "+ Add field / + Add" affordance: a dashed add control, clearly distinct + from real inputs and scoped to its own section card. */ +.addBtn { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.82rem; + font-weight: 700; + padding: 0.5rem 0.85rem; + border-radius: 9px; + border: 1px dashed var(--ifm-color-emphasis-400); + background: transparent; + color: var(--ifm-color-emphasis-700); + cursor: pointer; + transition: + border-color 120ms ease, + color 120ms ease; +} + +.addBtn:hover { + border-color: var(--ifm-color-brand); + color: var(--ifm-font-color-base); +} + +.removeBtn, +.clearBtn { + border: none; + background: transparent; + color: var(--ifm-color-emphasis-500); + cursor: pointer; + font-size: 0.75rem; + font-weight: 600; + padding: 0.25rem 0.4rem; + border-radius: 6px; +} + +.removeBtn:hover, +.clearBtn:hover { + color: var(--ifm-color-danger); + background: var(--ifm-color-emphasis-100); +} + +/* -- searchable select ----------------------------------------------------- */ + +.select { + position: relative; + width: 100%; +} + +.selectButton { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.55rem 0.7rem; + font-size: 0.9rem; + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: 9px; + background: var(--ifm-background-surface-color); + color: var(--ifm-font-color-base); + cursor: pointer; + text-align: left; +} + +.selectButton:hover { + border-color: var(--ifm-color-emphasis-400); +} + +.selectValue { + font-family: var(--ifm-font-family-monospace); +} + +.selectPlaceholder { + color: var(--ifm-color-emphasis-500); +} + +.selectCaret { + color: var(--ifm-color-emphasis-600); + font-size: 0.7rem; +} + +.selectMenu { + position: absolute; + z-index: 20; + top: calc(100% + 5px); + left: 0; + right: 0; + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: 10px; + background: var(--ifm-background-surface-color); + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.22); + overflow: hidden; +} + +.selectSearch { + width: 100%; + box-sizing: border-box; + padding: 0.55rem 0.7rem; + font-size: 0.85rem; + border: none; + border-bottom: 1px solid var(--ifm-color-emphasis-200); + background: var(--ifm-background-color); + color: var(--ifm-font-color-base); + outline: none; +} + +.selectList { + list-style: none; + margin: 0; + padding: 0.3rem; + max-height: 15rem; + overflow-y: auto; +} + +.selectOption { + display: flex; + flex-direction: column; + gap: 0.1rem; + padding: 0.45rem 0.6rem; + border-radius: 7px; + cursor: pointer; +} + +.selectOptionActive { + background: var(--ifm-color-emphasis-100); +} + +.selectOptionLabel { + font-family: var(--ifm-font-family-monospace); + font-size: 0.85rem; +} + +.selectOptionDesc { + font-size: 0.72rem; + color: var(--ifm-color-emphasis-600); +} + +.selectEmpty { + padding: 0.5rem 0.6rem; + font-size: 0.82rem; + color: var(--ifm-color-emphasis-600); +} + +/* -- output (always dark) -------------------------------------------------- */ + +.tabs { + display: flex; + gap: 0.3rem; +} + +.tab { + padding: 0.35rem 0.8rem; + font-size: 0.83rem; + font-weight: 700; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: #8b8c8f; + cursor: pointer; + transition: + color 120ms ease, + background 120ms ease; +} + +.tab:hover { + color: #e7e7e7; +} + +.tabActive { + background: var(--ifm-color-brand); + color: #17181a; +} + +.tabActive:hover { + color: #17181a; +} + +.outputActions { + display: flex; + gap: 0.5rem; +} + +.outputActions .ghostBtn { + background: transparent; + border-color: #33353a; + color: #cfd0d2; +} + +.outputActions .ghostBtn:hover { + border-color: var(--ifm-color-brand); + color: #ffffff; +} + +.output { + margin: 0; + padding: 1.1rem 1.2rem; + max-height: 62vh; + overflow: auto; + font-family: var(--ifm-font-family-monospace); + font-size: 0.82rem; + line-height: 1.6; + background: #1b1c1e; + color: #e6e7e9; +} + +.valid, +.invalid { + padding: 0.65rem 1.1rem; + font-size: 0.8rem; + border-top: 1px solid #2c2d30; + display: flex; + align-items: center; + gap: 0.45rem; + background: #161719; +} + +.valid { + color: #7bd88f; +} + +.invalid { + color: #ff8a8a; +} + +.errorList { + margin: 0; + padding-left: 1rem; + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.errorList code { + color: #e6e7e9; + background: rgba(255, 255, 255, 0.06); +} diff --git a/src/components/ConfigGenerator/validate.ts b/src/components/ConfigGenerator/validate.ts new file mode 100644 index 0000000000..bf1eaa26ad --- /dev/null +++ b/src/components/ConfigGenerator/validate.ts @@ -0,0 +1,88 @@ +// A small, defensive JSON Schema validator. It intentionally covers only the +// checks that keep a Tailcall config "correct": required properties, primitive +// types, enum membership, array items and map (additionalProperties) values. +// Anything it cannot confidently evaluate (anyOf/oneOf variants) is accepted +// rather than reported, so the surface never produces false positives. + +import {deref, typeOf, type JSONSchema} from "./jsonSchema" + +export type ValidationError = {path: string; message: string} + +const isPlainObject = (v: unknown): v is Record => + typeof v === "object" && v !== null && !Array.isArray(v) + +function matchesType(value: unknown, type: string): boolean { + switch (type) { + case "object": + return isPlainObject(value) + case "array": + return Array.isArray(value) + case "string": + return typeof value === "string" + case "boolean": + return typeof value === "boolean" + case "integer": + return typeof value === "number" && Number.isInteger(value) + case "number": + return typeof value === "number" + case "null": + return value === null + default: + return true + } +} + +function walk(value: unknown, schema: JSONSchema, root: JSONSchema, path: string, out: ValidationError[]): void { + if (out.length > 200) return + if (value === undefined || value === null) return + const node = deref(schema, root) + + // Variants: accept if any branch is plausible; never report here. + if (node.anyOf || node.oneOf) return + + if (node.enum && node.enum.length > 0) { + if (!node.enum.some((option) => option === value)) { + out.push({path, message: `must be one of: ${node.enum.map((o) => String(o)).join(", ")}`}) + } + return + } + + const type = typeOf(node) + if (type && !matchesType(value, type)) { + out.push({path, message: `expected ${type}`}) + return + } + + if (isPlainObject(value)) { + for (const key of node.required ?? []) { + const present = value[key] !== undefined && value[key] !== null && value[key] !== "" + if (!present) out.push({path: path ? `${path}.${key}` : key, message: "is required"}) + } + const props = node.properties ?? {} + const extra = node.additionalProperties + for (const [key, child] of Object.entries(value)) { + const childPath = path ? `${path}.${key}` : key + if (props[key]) { + walk(child, props[key], root, childPath, out) + } else if (isPlainObject(extra)) { + walk(child, extra, root, childPath, out) + } else if (extra === false && Object.keys(props).length > 0) { + out.push({path: childPath, message: "unknown property"}) + } + } + } + + if (Array.isArray(value) && node.items) { + value.forEach((item, i) => walk(item, node.items as JSONSchema, root, `${path}[${i}]`, out)) + } +} + +export function validateConfig(value: unknown, schema: JSONSchema): ValidationError[] { + const out: ValidationError[] = [] + try { + walk(value, schema, schema, "", out) + } catch { + // Validation is advisory; a malformed schema must never break the UI. + } + return out +} diff --git a/src/components/playground/index.tsx b/src/components/playground/index.tsx index 846c15cf5d..4da4e6a798 100644 --- a/src/components/playground/index.tsx +++ b/src/components/playground/index.tsx @@ -1,10 +1,20 @@ import React from "react" +import Link from "@docusaurus/Link" import Playground from "./Playground" import Announcement from "@site/src/components/shared/Announcement" const PlaygroundPage = (): JSX.Element => { return ( <> +
+ Prefer to build a configuration with a form? + + Open the Config Generator → + +
) diff --git a/src/constants/titles.ts b/src/constants/titles.ts index 4fb87f136b..cccc4321cc 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: `Configuration 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 a Tailcall configuration from a schema-driven form and export it as GraphQL, JSON or YAML. The form is built from the Tailcall schema and updates automatically as it changes.", } diff --git a/src/pages/app/config.tsx b/src/pages/app/config.tsx new file mode 100644 index 0000000000..6d29ae281d --- /dev/null +++ b/src/pages/app/config.tsx @@ -0,0 +1,23 @@ +import React, {useEffect} from "react" +import ReactGA from "react-ga4" +import Layout from "@theme/Layout" +import BrowserOnly from "@docusaurus/BrowserOnly" +import {useLocation} from "@docusaurus/router" +import ConfigGenerator from "@site/src/components/ConfigGenerator" +import {PageDescription, PageTitle} from "../../constants/titles" + +const ConfigGeneratorPage = (): JSX.Element => { + const location = useLocation() + + useEffect(() => { + ReactGA.send({hitType: "pageview", page: location.pathname, title: "Config Generator Page"}) + }, []) + + return ( + + }>{() => } + + ) +} + +export default ConfigGeneratorPage