"use client"; import { CustomDropdown } from "@/src/components/ui/custom-dropdown"; import { detectLocalMode } from "@/src/utils/detectLocalMode"; import { parseFieldValue } from "@/src/utils/parseFieldValue"; import React from "react"; import { wrapFieldsWithMeta } from "tinacms"; // ======================================== // API FUNCTIONS // ======================================== /** * Loads schemas from the API */ const loadSchemas = async () => { const response = await fetch("/api/list-api-schemas"); const result = await response.json(); if (!response.ok) { throw new Error(result.error || "Failed to load schemas"); } return result.schemas || []; }; /** * Loads tags for a specific schema */ const loadTagsForSchema = async (schemaFilename: string) => { const response = await fetch( `/api/get-tag-api-schema?filename=${encodeURIComponent(schemaFilename)}` ); const result = await response.json(); if (!response.ok) { throw new Error(result.error || "Failed to load schema"); } const apiSchema = result.apiSchema; if (apiSchema?.paths) { const tagSet = new Set(); // Extract tags for (const path in apiSchema.paths) { for (const method in apiSchema.paths[path]) { const op = apiSchema.paths[path][method]; if (op.tags) { for (const tag of op.tags) { tagSet.add(tag); } } } } return { tags: Array.from(tagSet), apiSchema: apiSchema, }; } return { tags: [], apiSchema: null }; }; /** * Validates if an API path follows the simple pattern like /something/api/{id} * and doesn't contain special characters */ const isValidApiPath = (input: string): boolean => { // Extract just the path if input contains an HTTP method const pathMatch = input.match( /^\s*(GET|POST|PUT|DELETE|PATCH|OPTIONS|HEAD)?\s*(\/[^\s]*)/i ); if (!pathMatch) return false; const path = pathMatch[2]; // e.g., "/api/projects/{id}/TogglePreferredNoiseLevelMeasurement" // Reject special characters not allowed in paths const specialCharRegex = /[^a-zA-Z0-9\/\{\}\-_]/; if (specialCharRegex.test(path)) { return false; } // Allow multiple segments including path params anywhere const validPathRegex = /^\/([a-zA-Z0-9\-_]+|\{[a-zA-Z0-9\-_]+\})(\/([a-zA-Z0-9\-_]+|\{[a-zA-Z0-9\-_]+\}))*$/; return validPathRegex.test(path); }; /** * Loads endpoints for a specific tag from API schema */ const loadEndpointsForTag = ( apiSchema: any, tag: string, setIsValidPath: (isValid: boolean) => void, hasTags = true ) => { const endpointsList: { id: string; label: string; method: string; path: string; summary: string; description: string; }[] = []; for (const path in apiSchema.paths) { if (!isValidApiPath(path)) { setIsValidPath(false); } for (const method in apiSchema.paths[path]) { const op = apiSchema.paths[path][method]; const endpoint = { id: `${method.toUpperCase()}:${path}`, label: `${method.toUpperCase()} ${path} - ${op.summary || ""}`, method: method.toUpperCase(), path, summary: op.summary || "", description: op.description || "", }; // If we don't have tags, we show all endpoints if (!hasTags) { endpointsList.push(endpoint); } else if (hasTags && op.tags?.includes(tag)) { endpointsList.push(endpoint); } } } return endpointsList; }; // ======================================== // MAIN COMPONENT // ======================================== export const ApiReferencesSelector = wrapFieldsWithMeta((props: any) => { const { input, meta } = props; const [schemas, setSchemas] = React.useState([]); const [tags, setTags] = React.useState([]); const [endpoints, setEndpoints] = React.useState< { id: string; label: string; method: string; path: string; summary: string; description: string; }[] >([]); const [loadingSchemas, setLoadingSchemas] = React.useState(true); const [loadingTags, setLoadingTags] = React.useState(false); const [loadingEndpoints, setLoadingEndpoints] = React.useState(false); const [selectedSchema, setSelectedSchema] = React.useState(""); const [selectedTag, setSelectedTag] = React.useState(""); const [hasTag, setHasTag] = React.useState(null); const [generatingFiles, setGeneratingFiles] = React.useState(false); const [lastSavedValue, setLastSavedValue] = React.useState(""); const [initialLoad, setInitialLoad] = React.useState(true); const [isValidPath, setIsValidPath] = React.useState(null); const isLocalMode = detectLocalMode(); const parsedValue = parseFieldValue(input.value); const selectedEndpoints = Array.isArray(parsedValue.endpoints) ? parsedValue.endpoints : []; // Load schemas from filesystem API React.useEffect(() => { const loadInitialData = async () => { setLoadingSchemas(true); try { const schemasList = await loadSchemas(); setSchemas(schemasList); setLoadingSchemas(false); // Set local state from parsed values const currentSchema = parsedValue.schema || ""; const currentTag = parsedValue.tag || ""; setSelectedSchema(currentSchema); setSelectedTag(currentTag); // If we have existing data, load tags and endpoints only once if (currentSchema && hasTag === null) { await loadTagsAndEndpoints(currentSchema, currentTag); } // Mark initial load as complete and set the last saved value setInitialLoad(false); setLastSavedValue(input.value || ""); } catch (error) { setSchemas([]); setLoadingSchemas(false); setInitialLoad(false); setLastSavedValue(input.value || ""); } }; loadInitialData(); }, [parsedValue.schema, parsedValue.tag, input.value, hasTag]); const loadTagsAndEndpoints = async ( schemaFilename: string, currentTag?: string ) => { setLoadingTags(true); setIsValidPath(null); try { const { tags: tagsList, apiSchema } = await loadTagsForSchema(schemaFilename); setTags(tagsList); setHasTag(tagsList.length > 0); setLoadingTags(false); // If we also have a selected tag, load endpoints if (apiSchema) { setLoadingEndpoints(true); const tag = currentTag ?? ""; const hasTag = !!currentTag; const endpointsList = loadEndpointsForTag( apiSchema, tag, setIsValidPath, hasTag ); setEndpoints(endpointsList); setLoadingEndpoints(false); } } catch (error) { setTags([]); setLoadingTags(false); } }; // Handle schema change const handleSchemaChange = async (schema: string) => { // Only update if schema actually changed to reduce form disruption if (schema === selectedSchema) return; setSelectedSchema(schema); setSelectedTag(""); setIsValidPath(null); setTags([]); setEndpoints([]); // Update form state with a slight delay to avoid dropdown disruption setTimeout(() => { input.onChange(JSON.stringify({ schema, tag: "", endpoints: [] })); }, 0); if (!schema) { setLoadingTags(false); return; } await loadTagsAndEndpoints(schema); }; // Handle tag change const handleTagChange = async (tag: string) => { // Only update if tag actually changed if (tag === selectedTag) return; setSelectedTag(tag); setEndpoints([]); // Update form state with a slight delay to avoid dropdown disruption setTimeout(() => { input.onChange( JSON.stringify({ schema: selectedSchema, tag, endpoints: [] }) ); }, 0); if (!tag || !selectedSchema) { setLoadingEndpoints(false); return; } try { const { apiSchema } = await loadTagsForSchema(selectedSchema); if (apiSchema) { setLoadingEndpoints(true); const endpointsList = loadEndpointsForTag( apiSchema, tag, setIsValidPath ); setEndpoints(endpointsList); setLoadingEndpoints(false); } } catch (error) { setEndpoints([]); setLoadingEndpoints(false); } }; const handleEndpointCheckbox = (id: string) => { let updated: typeof endpoints; const clickedEndpoint = endpoints.find((ep) => ep.id === id); if (!clickedEndpoint) return; if (selectedEndpoints.find((ep) => ep.id === id)) { updated = selectedEndpoints.filter((ep) => ep.id !== id); } else { updated = [...selectedEndpoints, clickedEndpoint]; } input.onChange( JSON.stringify({ schema: selectedSchema, tag: selectedTag, endpoints: updated, }) ); }; const handleSelectAll = () => { input.onChange( JSON.stringify({ schema: selectedSchema, tag: selectedTag, endpoints: endpoints, }) ); }; return (
({ value: schema.filename, label: schema.displayName, }))} value={selectedSchema} onChange={handleSchemaChange} disabled={loadingSchemas} placeholder={ loadingSchemas ? "Loading schemas..." : schemas.length === 0 ? "No schemas available" : "Select a schema" } className="w-full px-4 py-2 rounded-lg border border-slate-300 text-base bg-slate-50 mb-2 focus:outline-none focus:ring-2 focus:ring-blue-400" /> {!loadingSchemas && schemas.length === 0 && (
⚠️ No API schemas found. This might be due to:
• Missing TinaCMS client generation on staging
• Missing schema files deployment
• Environment configuration issues

Please ensure schema files are uploaded to the "API Schema" collection.
)}
{selectedSchema && (
{hasTag || hasTag === null ? ( <> ({ value: tag, label: tag, }))} value={selectedTag} onChange={handleTagChange} disabled={loadingTags} placeholder={loadingTags ? "Loading tags..." : "Select a tag"} className="w-full px-4 py-2 rounded-lg border border-slate-300 text-base bg-slate-50 mb-2 focus:outline-none focus:ring-2 focus:ring-blue-400" /> ) : (
⚠️ No tags found for this schema.
)}
)} {(selectedTag || hasTag === false) && (
{!loadingEndpoints && isValidPath === false && (
Unsupported Schema format detected. Please check the README for supported endpoint configurations{" "} Read more .
)} {loadingEndpoints ? (
Loading endpoints...
) : (
{endpoints.map((ep) => ( ))} {endpoints.length === 0 && (
No endpoints found for this tag.
)}
)} {/* Form Save Generation Status */} {selectedEndpoints.length > 0 && (
{generatingFiles ? (
{isLocalMode ? "Generating MDX files locally..." : "Creating files via TinaCMS..."}
) : (
💾 Ready for Save & Generate
{selectedEndpoints.length} endpoint {selectedEndpoints.length !== 1 ? "s" : ""} selected
Files will be generated using{" "} TinaCMS GraphQL when you hit save.
)}
)} {selectedEndpoints.length > 0 && (

Following are the endpoint(s) that will have their mdx files generated.

)}
{JSON.stringify( { schema: selectedSchema, tag: selectedTag, endpoints: selectedEndpoints, }, null, 2 )}
)}
); });