diff --git a/crates/app/src/lib/worker.ts b/crates/app/src/lib/worker.ts index 91e7a9caa..6b682774c 100644 --- a/crates/app/src/lib/worker.ts +++ b/crates/app/src/lib/worker.ts @@ -1,55 +1,55 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import {ComponentExportFunction, Field, Typ} from "@/types/component.ts"; +import { ComponentExportFunction, Field, Typ } from "@/types/component.ts"; function buildJsonSkeleton(field: Field): any { - const {type, fields} = field.typ; - switch (type) { - case "Str": - case "Chr": - return ""; - - case "Bool": - return false; - - case "F64": - case "F32": - case "U64": - case "S64": - case "U32": - case "S32": - case "U16": - case "S16": - case "U8": - case "S8": - return 0; - - case "Record": { - const obj: Record = {}; - fields?.forEach((subField: Field) => { - obj[subField.name] = buildJsonSkeleton(subField); - }); - return obj; - } - - case "Tuple": { - if (!fields) return []; - return fields.map((subField: Field) => buildJsonSkeleton(subField)); - } - - case "List": { - return []; - } - - case "Option": { - return null; - } - - case "Enum": { - return ""; - } - default: - return null; + const { type, fields } = field.typ; + switch (type) { + case "Str": + case "Chr": + return ""; + + case "Bool": + return false; + + case "F64": + case "F32": + case "U64": + case "S64": + case "U32": + case "S32": + case "U16": + case "S16": + case "U8": + case "S8": + return 0; + + case "Record": { + const obj: Record = {}; + fields?.forEach((subField: Field) => { + obj[subField.name] = buildJsonSkeleton(subField); + }); + return obj; + } + + case "Tuple": { + if (!fields) return []; + return fields.map((subField: Field) => buildJsonSkeleton(subField)); + } + + case "List": { + return []; + } + + case "Option": { + return null; } + + case "Enum": { + return ""; + } + default: + return null; + } } /** @@ -57,7 +57,7 @@ function buildJsonSkeleton(field: Field): any { * into a default JSON array for user editing. */ export function parseToJsonEditor(data: ComponentExportFunction) { - return data.parameters.map((param) => buildJsonSkeleton(param)); + return data.parameters.map((param) => buildJsonSkeleton(param)); } /** @@ -65,54 +65,127 @@ export function parseToJsonEditor(data: ComponentExportFunction) { * format expected by the server. */ export function parseToApiPayload( - input: any[], - actionDefinition: ComponentExportFunction + input: any[], + actionDefinition: ComponentExportFunction ) { - const payload = {params: [] as Array<{ value: any; typ: Typ }>}; - - const parseValue = (data: any, typeDef: Typ) => { - switch (typeDef.type) { - case "Str": - case "Chr": - case "Bool": - case "F64": - case "F32": - case "U64": - case "S64": - case "U32": - case "S32": - case "U16": - case "S16": - case "U8": - case "S8": - case "Tuple": - case "Record": - case "Enum": - return data; - case "List": - return Array.isArray(data) ? data : [data]; - default: - throw new Error(`Unsupported type: ${typeDef.type}`); - } - }; - - actionDefinition.parameters.forEach((param, index) => { - // Each param is presumably an item in input - const userValue = input[index]; - payload.params.push({ - value: parseValue(userValue, param.typ), - typ: param.typ, - }); + const payload = { params: [] as Array<{ value: any; typ: Typ }> }; + + const parseValue = (data: any, typeDef: Typ) => { + switch (typeDef.type) { + case "Str": + case "Chr": + case "Bool": + case "F64": + case "F32": + case "U64": + case "S64": + case "U32": + case "S32": + case "U16": + case "S16": + case "U8": + case "S8": + case "Tuple": + case "Record": + case "Enum": + return data; + case "List": + return Array.isArray(data) ? data : [data]; + default: + throw new Error(`Unsupported type: ${typeDef.type}`); + } + }; + + actionDefinition.parameters.forEach((param, index) => { + // Each param is presumably an item in input + const userValue = input[index]; + payload.params.push({ + value: parseValue(userValue, param.typ), + typ: param.typ, }); + }); - return payload; + return payload; } export function safeFormatJSON(input: string): string { - try { - const parsed = JSON.parse(input); - return JSON.stringify(parsed, null, 2); - } catch { - return input; // Return as-is if parse fails + try { + const parsed = JSON.parse(input); + return JSON.stringify(parsed, null, 2); + } catch { + return input; // Return as-is if parse fails + } +} + +export function getCaretCoordinates( + element: HTMLTextAreaElement, + position: number +) { + const div = document.createElement("div"); + const styles = getComputedStyle(element); + const properties = [ + "direction", + "boxSizing", + "width", + "height", + "overflowX", + "overflowY", + "borderTopWidth", + "borderRightWidth", + "borderBottomWidth", + "borderLeftWidth", + "borderStyle", + "paddingTop", + "paddingRight", + "paddingBottom", + "paddingLeft", + "fontStyle", + "fontVariant", + "fontWeight", + "fontStretch", + "fontSize", + "fontSizeAdjust", + "lineHeight", + "fontFamily", + "textAlign", + "textTransform", + "textIndent", + "textDecoration", + "letterSpacing", + "wordSpacing", + "tabSize", + "MozTabSize", + ]; + + div.id = "input-textarea-caret-position-mirror-div"; + document.body.appendChild(div); + + const style = div.style; + style.whiteSpace = "pre-wrap"; + style.wordWrap = "break-word"; + style.position = "absolute"; + style.visibility = "hidden"; + + properties.forEach((prop: string) => { + if ( + Object.prototype.hasOwnProperty.call(styles as Record, prop) + ) { + style.setProperty(prop, (styles as Record)[prop]); } + }); + + div.textContent = element.value.substring(0, position); + const span = document.createElement("span"); + span.textContent = element.value.substring(position) || "."; + div.appendChild(span); + + const coordinates = { + top: span.offsetTop + Number.parseInt(styles["borderTopWidth"]), + left: span.offsetLeft + Number.parseInt(styles["borderLeftWidth"]), + height: Number.parseInt(styles["lineHeight"]), + }; + + document.body.removeChild(div); + + return coordinates; } diff --git a/crates/app/src/pages/api/details/createRoute.tsx b/crates/app/src/pages/api/details/createRoute.tsx index d81cc80f5..ac75ddd2d 100644 --- a/crates/app/src/pages/api/details/createRoute.tsx +++ b/crates/app/src/pages/api/details/createRoute.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { ArrowLeft, Loader2 } from "lucide-react"; import { @@ -24,10 +24,24 @@ import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import * as z from "zod"; import { API } from "@/service"; -import { Api, HttpMethod } from "@/types/api"; -import { Component } from "@/types/component"; +import type { Api, HttpMethod } from "@/types/api"; +import type { ComponentList } from "@/types/component"; import ErrorBoundary from "@/components/errorBoundary"; import { toast } from "@/hooks/use-toast"; +import { Card } from "@/components/ui/card"; +import { getCaretCoordinates } from "@/lib/worker"; + +const extractDynamicParams = (path: string) => { + const regex = /{([^}]+)}/g; + const matches = []; + let match; + + while ((match = regex.exec(path)) !== null) { + matches.push(match[1]); + } + + return matches; +}; const HTTP_METHODS = [ "Get", @@ -68,7 +82,7 @@ const CreateRoute = () => { const [isLoading, setIsLoading] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [componentList, setComponentList] = useState<{ - [key: string]: Component; + [key: string]: ComponentList; }>({}); const [isEdit, setIsEdit] = useState(false); const [activeApiDetails, setActiveApiDetails] = useState(null); @@ -76,6 +90,24 @@ const CreateRoute = () => { const [queryParams] = useSearchParams(); const path = queryParams.get("path"); const method = queryParams.get("method"); + const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 }); + const [suggestions, setSuggestions] = useState([]); + const [showSuggestions, setShowSuggestions] = useState(false); + const textareaRef = useRef(null); + const [cursorPosition, setCursorPosition] = useState(0); + + const [responseSuggestions, setResponseSuggestions] = useState( + [] as string[] + ); + const [filteredResponseSuggestions, setFilteredResponseSuggestions] = + useState([]); + const [showResponseSuggestions, setShowResponseSuggestions] = useState(false); + const [responseMenuPosition, setResponseMenuPosition] = useState({ + top: 0, + left: 0, + }); + const responseTextareaRef = useRef(null); + const [responseCursorPosition, setResponseCursorPosition] = useState(0); const form = useForm({ resolver: zodResolver(routeSchema), @@ -145,7 +177,7 @@ const CreateRoute = () => { title: "API not found", description: "Please try again.", variant: "destructive", - duration: Infinity, + duration: Number.POSITIVE_INFINITY, }); return; } @@ -158,7 +190,7 @@ const CreateRoute = () => { binding: { componentId: { componentId: values.componentId, - version: parseInt(values.version), + version: Number.parseInt(values.version), }, workerName: values.workerName, response: values.response || "", @@ -184,6 +216,182 @@ const CreateRoute = () => { } }; + const handleSuggestionClick = (suggestion: string) => { + const currentValue = form.getValues("workerName"); + const textBeforeCursor = currentValue.slice(0, cursorPosition); + const pattern = "request.path."; + const startIndex = textBeforeCursor.lastIndexOf(pattern); + + let newValue: string; + let newCursorPosition: number; + + if (startIndex !== -1) { + // Replace any text after "request.path." with the suggestion + newValue = + currentValue.slice(0, startIndex) + + pattern + + suggestion + + currentValue.slice(cursorPosition); + newCursorPosition = startIndex + pattern.length + suggestion.length; + } else { + // If the pattern isn't found, insert it along with the suggestion at the cursor position + newValue = + currentValue.slice(0, cursorPosition) + + pattern + + suggestion + + currentValue.slice(cursorPosition); + newCursorPosition = cursorPosition + pattern.length + suggestion.length; + } + + form.setValue("workerName", newValue); + setShowSuggestions(false); + + if (textareaRef.current) { + textareaRef.current.focus(); + textareaRef.current.setSelectionRange( + newCursorPosition, + newCursorPosition + ); + setCursorPosition(newCursorPosition); + } + }; + + const onComponentChange = (componentId: string) => { + form.setValue("componentId", componentId); + }; + + const onVersionChange = (version: string) => { + form.setValue("version", version); + const componentId = form.getValues("componentId"); + const exportedFunctions = componentList?.[componentId]?.versions?.find( + (data) => data.versionedComponentId?.version?.toString() === version + ); + const data = exportedFunctions?.metadata?.exports || []; + const output = data.flatMap((item) => + item.functions.map((func) => `${item.name}.{${func.name}}`) + ); + setResponseSuggestions(output); + }; + + const handleWorkerNameChange = ( + e: React.ChangeEvent + ) => { + const value = e.target.value; + form.setValue("workerName", value); + const cursorPos = e.target.selectionStart || 0; + setCursorPosition(cursorPos); + + const textBeforeCursor = value.slice(0, cursorPos); + // Look for the last occurrence of "request.path." + const pattern = "request.path."; + const startIndex = textBeforeCursor.lastIndexOf(pattern); + + if (startIndex !== -1) { + // Extract the token typed after "request.path." + const token = textBeforeCursor.slice(startIndex + pattern.length); + // Retrieve the dynamic parameters (or suggestion candidates) + const dynamicParams = extractDynamicParams(form.getValues("path")); + + // If token is empty, show all dynamicParams; otherwise filter them + const filteredSuggestions = + token.trim().length > 0 + ? dynamicParams.filter((param) => + param.toLowerCase().startsWith(token.toLowerCase()) + ) + : dynamicParams; + + if (filteredSuggestions.length > 0) { + setSuggestions(filteredSuggestions); + updateMenuPosition(); + setShowSuggestions(true); + } else { + setShowSuggestions(false); + } + } else { + setShowSuggestions(false); + } + }; + + const updateMenuPosition = () => { + if (textareaRef.current) { + const { selectionStart } = textareaRef.current; + const coords = getCaretCoordinates(textareaRef.current, selectionStart); + setMenuPosition({ + top: coords.top + coords.height - textareaRef.current.scrollTop, + left: coords.left - textareaRef.current.scrollLeft, + }); + } + }; + + const handleResponseSuggestionClick = (suggestion: string) => { + const currentValue = form.getValues("response") ?? ""; + // Get text before the current cursor position. + const textBeforeCursor = currentValue.slice(0, responseCursorPosition); + // Find the last contiguous non-space token + const tokenMatch = textBeforeCursor.match(/(\S+)$/); + let tokenStart = responseCursorPosition; + if (tokenMatch) { + tokenStart = responseCursorPosition - tokenMatch[1].length; + } + // Replace the token with the suggestion. + const newValue = + currentValue.slice(0, tokenStart) + + suggestion + + currentValue.slice(responseCursorPosition); + form.setValue("response", newValue); + setShowResponseSuggestions(false); + + if (responseTextareaRef.current) { + responseTextareaRef.current.focus(); + const newCursorPosition = tokenStart + suggestion.length; + responseTextareaRef.current.setSelectionRange( + newCursorPosition, + newCursorPosition + ); + setResponseCursorPosition(newCursorPosition); + } + }; + + const handleResponseChange = (e: React.ChangeEvent) => { + const value = e.target.value; + form.setValue("response", value); + const cursorPos = e.target.selectionStart || 0; + setResponseCursorPosition(cursorPos); + + // Extract the last "word" (non-whitespace sequence) before the cursor. + const textBeforeCursor = value.slice(0, cursorPos); + const match = textBeforeCursor.match(/(\S+)$/); // captures last token + const token = match ? match[1] : ""; + + // Filter responseSuggestions to only those that match the token (case-insensitive) + const filtered = responseSuggestions.filter((item) => + item.toLowerCase().startsWith(token.toLowerCase()) + ); + + // If there are any matches and the token is not empty, show the dropdown. + if (filtered.length > 0 && token.length > 0) { + updateResponseMenuPosition(); + setFilteredResponseSuggestions(filtered); + setShowResponseSuggestions(true); + } else { + setShowResponseSuggestions(false); + } + }; + + const updateResponseMenuPosition = () => { + if (responseTextareaRef.current) { + const { selectionStart } = responseTextareaRef.current; + const coords = getCaretCoordinates( + responseTextareaRef.current, + selectionStart + ); + setResponseMenuPosition({ + top: coords.top + coords.height - responseTextareaRef.current.scrollTop, + left: coords.left - responseTextareaRef.current.scrollLeft, + }); + } + }; + if (fetchError) { return (
@@ -196,7 +404,6 @@ const CreateRoute = () => {
); } - return (
@@ -295,7 +502,7 @@ const CreateRoute = () => { Component @@ -344,7 +551,7 @@ const CreateRoute = () => { {form.watch("componentId") && componentList[ form.watch("componentId") - ]?.versionId?.map((v: number) => ( + ]?.versionList?.map((v: number) => ( v{v} @@ -364,13 +571,40 @@ const CreateRoute = () => { Worker Name -