diff --git a/skyvern-frontend/src/components/Orgwalled.tsx b/skyvern-frontend/src/components/Orgwalled.tsx index f70b9126..53249245 100644 --- a/skyvern-frontend/src/components/Orgwalled.tsx +++ b/skyvern-frontend/src/components/Orgwalled.tsx @@ -28,7 +28,7 @@ function OrgWalled({
diff --git a/skyvern-frontend/src/components/icons/BrowserIcon.tsx b/skyvern-frontend/src/components/icons/BrowserIcon.tsx index f390ca0a..b027a9d5 100644 --- a/skyvern-frontend/src/components/icons/BrowserIcon.tsx +++ b/skyvern-frontend/src/components/icons/BrowserIcon.tsx @@ -15,9 +15,9 @@ function BrowserIcon({ className }: Props) { diff --git a/skyvern-frontend/src/routes/workflows/components/BlockCodeEditor.tsx b/skyvern-frontend/src/routes/workflows/components/BlockCodeEditor.tsx index 03f664da..094139ee 100644 --- a/skyvern-frontend/src/routes/workflows/components/BlockCodeEditor.tsx +++ b/skyvern-frontend/src/routes/workflows/components/BlockCodeEditor.tsx @@ -1,18 +1,16 @@ import { ExitIcon } from "@radix-ui/react-icons"; -import { useParams } from "react-router-dom"; +import { useSearchParams } from "react-router-dom"; import { Handle } from "@xyflow/react"; import { Position } from "@xyflow/react"; import { KeyIcon } from "@/components/icons/KeyIcon"; import { WorkflowBlockType } from "@/routes/workflows/types/workflowTypes"; import { useToggleScriptForNodeCallback } from "@/routes/workflows/hooks/useToggleScriptForNodeCallback"; -import { useWorkflowQuery } from "@/routes/workflows/hooks/useWorkflowQuery"; import { cn } from "@/util/utils"; import { CodeEditor } from "./CodeEditor"; import { workflowBlockTitle } from "../editor/nodes/types"; import { WorkflowBlockIcon } from "../editor/nodes/WorkflowBlockIcon"; -import { constructCacheKeyValue } from "../editor/utils"; function BlockCodeEditor({ blockLabel, @@ -25,19 +23,11 @@ function BlockCodeEditor({ script: string | undefined; onClick?: (e: React.MouseEvent) => void; }) { - const { workflowPermanentId } = useParams(); + const [searchParams] = useSearchParams(); const blockTitle = workflowBlockTitle[blockType]; const toggleScriptForNodeCallback = useToggleScriptForNodeCallback(); - const { data: workflow } = useWorkflowQuery({ - workflowPermanentId, - }); - const cacheKey = workflow?.cache_key ?? ""; - const cacheKeyValue = workflow - ? cacheKey === "" - ? "" - : constructCacheKeyValue(cacheKey, workflow) - : ""; + const cacheKeyValue = searchParams.get("cache-key-value"); return (
@@ -81,7 +71,9 @@ function BlockCodeEditor({
- {cacheKeyValue === "" ? "(none)" : cacheKeyValue} + {cacheKeyValue === "" || !cacheKeyValue + ? "(none)" + : cacheKeyValue}
diff --git a/skyvern-frontend/src/routes/workflows/editor/WorkflowHeader.tsx b/skyvern-frontend/src/routes/workflows/editor/WorkflowHeader.tsx index d2e90e73..5ceb67ce 100644 --- a/skyvern-frontend/src/routes/workflows/editor/WorkflowHeader.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/WorkflowHeader.tsx @@ -1,3 +1,13 @@ +import { + CheckIcon, + ChevronDownIcon, + ChevronUpIcon, + CopyIcon, + PlayIcon, + ReloadIcon, +} from "@radix-ui/react-icons"; +import { useEffect, useRef, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; import { SaveIcon } from "@/components/icons/SaveIcon"; import { BrowserIcon } from "@/components/icons/BrowserIcon"; import { Button } from "@/components/ui/button"; @@ -7,14 +17,7 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; -import { - ChevronDownIcon, - ChevronUpIcon, - CopyIcon, - PlayIcon, - ReloadIcon, -} from "@radix-ui/react-icons"; -import { useNavigate, useParams } from "react-router-dom"; +import { Input } from "@/components/ui/input"; import { useUser } from "@/hooks/useUser"; import { statusIsRunningOrQueued } from "@/routes/tasks/types"; import { useGlobalWorkflowsQuery } from "../hooks/useGlobalWorkflowsQuery"; @@ -26,21 +29,65 @@ import { useDebugStore } from "@/store/useDebugStore"; import { useWorkflowTitleStore } from "@/store/WorkflowTitleStore"; import { useWorkflowHasChangesStore } from "@/store/WorkflowHasChangesStore"; import { cn } from "@/util/utils"; +import { WorkflowApiResponse } from "../types/workflowTypes"; +import { CacheKeyValuesResponse } from "@/routes/workflows/types/scriptTypes"; +import { OrgWalled } from "@/components/Orgwalled"; + +function CopyButton({ value }: { value: string }) { + const [copied, setCopied] = useState(false); + + const handleCopy = () => { + if (copied) { + return; + } + window.navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }; + + return ( + + ); +} + +interface Dom { + input: React.MutableRefObject; +} type Props = { + cacheKeyValue: string | null; + cacheKeyValues: CacheKeyValuesResponse | undefined; + cacheKeyValuesPanelOpen: boolean; parametersPanelOpen: boolean; + saving: boolean; + workflow: WorkflowApiResponse; + onCacheKeyValueAccept: (cacheKeyValue: string | null) => void; + onCacheKeyValuesBlurred: (cacheKeyValue: string | null) => void; + onCacheKeyValuesFilter: (cacheKeyValue: string) => void; + onCacheKeyValuesKeydown: (e: React.KeyboardEvent) => void; onParametersClick: () => void; + onCacheKeyValuesClick: () => void; onSave: () => void; onRun?: () => void; - saving: boolean; }; function WorkflowHeader({ + cacheKeyValue, + cacheKeyValues, + cacheKeyValuesPanelOpen, parametersPanelOpen, + saving, + workflow, + onCacheKeyValueAccept, + onCacheKeyValuesBlurred, + onCacheKeyValuesFilter, + onCacheKeyValuesKeydown, onParametersClick, + onCacheKeyValuesClick, onSave, onRun, - saving, }: Props) { const { title, setTitle } = useWorkflowTitleStore(); const workflowChangesStore = useWorkflowHasChangesStore(); @@ -53,6 +100,22 @@ function WorkflowHeader({ const workflowRunIsRunningOrQueued = workflowRun && statusIsRunningOrQueued(workflowRun); const user = useUser().get(); + const [chosenCacheKeyValue, setChosenCacheKeyValue] = useState( + cacheKeyValue ?? null, + ); + + const dom: Dom = { + input: useRef(null), + }; + + useEffect(() => { + if (cacheKeyValue === chosenCacheKeyValue) { + return; + } + + setChosenCacheKeyValue(cacheKeyValue ?? null); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cacheKeyValue]); if (!globalWorkflows) { return null; // this should be loaded already by some other components @@ -81,6 +144,68 @@ function WorkflowHeader({ />
+ {user && workflow.generate_script && ( + // (cacheKeyValues?.total_count ?? 0) > 0 && ( + +
+ { + setChosenCacheKeyValue(e.target.value); + onCacheKeyValuesFilter(e.target.value); + }} + onMouseDown={() => { + if (!cacheKeyValuesPanelOpen) { + onCacheKeyValuesClick(); + } + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + const numFiltered = cacheKeyValues?.values?.length ?? 0; + + if (numFiltered === 1) { + const first = cacheKeyValues?.values?.[0]; + if (first) { + setChosenCacheKeyValue(first); + onCacheKeyValueAccept(first); + } + return; + } + + setChosenCacheKeyValue(chosenCacheKeyValue); + onCacheKeyValueAccept(chosenCacheKeyValue); + } + onCacheKeyValuesKeydown(e); + }} + placeholder="Script Key" + value={chosenCacheKeyValue ?? undefined} + onBlur={(e) => { + onCacheKeyValuesBlurred(e.target.value); + setChosenCacheKeyValue(e.target.value); + }} + /> + {cacheKeyValuesPanelOpen ? ( + + ) : ( + { + dom.input.current?.focus(); + onCacheKeyValuesClick(); + }} + /> + )} + +
+
+ )} {isGlobalWorkflow ? ( + + )} + + + + + {/* header panel */}
{ + setCacheKeyValue(v ?? ""); + setCacheKeyValueFilter(""); + closeWorkflowPanel(); + }} + onCacheKeyValuesBlurred={(v) => { + setCacheKeyValue(v ?? ""); + }} + onCacheKeyValuesKeydown={(e) => { + if (e.key === "Enter") { + toggleCacheKeyValuesPanel(); + return; + } + + if (e.key !== "Tab") { + openCacheKeyValuesPanel(); + } + }} + onCacheKeyValuesFilter={(v) => { + setCacheKeyValueFilter(v); + }} + onCacheKeyValuesClick={() => { + toggleCacheKeyValuesPanel(); + }} onParametersClick={() => { if ( workflowPanelState.active && @@ -443,6 +648,12 @@ function Workspace({ return; } await saveWorkflow.mutateAsync(); + + queryClient.invalidateQueries({ + queryKey: ["cache-key-values", workflowPermanentId, cacheKey], + }); + + setCacheKeyValueFilter(""); }} onRun={() => { closeWorkflowPanel(); @@ -466,6 +677,28 @@ function Workspace({ promote("dropdown"); }} > + {workflowPanelState.content === "cacheKeyValues" && ( + "} + onDelete={(cacheKeyValue) => { + setToDeleteCacheKeyValue(cacheKeyValue); + setOpenConfirmCacheKeyValueDeleteDialogue(true); + }} + onMouseDownCapture={() => { + promote("dropdown"); + }} + onPaginate={(page) => { + setPage(page); + }} + onSelect={(cacheKeyValue) => { + setCacheKeyValue(cacheKeyValue); + setCacheKeyValueFilter(""); + closeWorkflowPanel(); + }} + /> + )} {workflowPanelState.content === "parameters" && ( { @@ -541,7 +774,6 @@ function Workspace({ onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} initialTitle={initialTitle} - // initialParameters={initialParameters} workflow={workflow} onMouseDownCapture={() => promote("infiniteCanvas")} zIndex={rankedItems.infiniteCanvas} diff --git a/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowCacheKeyValuesPanel.tsx b/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowCacheKeyValuesPanel.tsx new file mode 100644 index 00000000..0a960ed5 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowCacheKeyValuesPanel.tsx @@ -0,0 +1,165 @@ +import { CrossCircledIcon, ReloadIcon } from "@radix-ui/react-icons"; +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationNext, + PaginationPrevious, +} from "@/components/ui/pagination"; +import { Button } from "@/components/ui/button"; +import { CacheKeyValuesResponse } from "@/routes/workflows/types/scriptTypes"; +import { cn } from "@/util/utils"; + +interface Props { + cacheKeyValues: CacheKeyValuesResponse | undefined; + pending: boolean; + scriptKey: string; + onDelete: (cacheKeyValue: string) => void; + onMouseDownCapture?: () => void; + onPaginate: (page: number) => void; + onSelect: (cacheKeyValue: string) => void; +} + +function WorkflowCacheKeyValuesPanel({ + cacheKeyValues, + pending, + scriptKey, + onDelete, + onMouseDownCapture, + onPaginate, + onSelect, +}: Props) { + const values = cacheKeyValues?.values ?? []; + const page = cacheKeyValues?.page ?? 0; + const pageSize = cacheKeyValues?.page_size ?? 0; + const filteredCount = cacheKeyValues?.filtered_count ?? 0; + const totalCount = cacheKeyValues?.total_count ?? 0; + const totalPages = Math.ceil(filteredCount / pageSize); + const displayPage = totalPages === 0 ? 0 : page; + + return ( +
onMouseDownCapture?.()} + > +
+
+

Cached Scripts

+ + Given your script key,{" "} + + {scriptKey} + + , search for a cached value to see scripts for. For this script key + there {totalCount === 1 ? "is" : "are"}{" "} + {totalCount}{" "} + cached {totalCount === 1 ? "value" : "values"} + {filteredCount !== totalCount && ( + <> + {" "} + ( + + {filteredCount} + {" "} + filtered) + + )} + . + +
+
+ {values.length ? ( +
+ {values.map((cacheKeyValue, i) => ( +
{ + onSelect(cacheKeyValue); + }} + > +
+ {i + 1 + (page - 1) * pageSize} +
+
+
+ {cacheKeyValue} +
+
+ +
+ ))} +
+ ) : ( +
+ No cached scripts found +
+ )} +
+
+ {pending && } + + + + { + if (page <= 1) { + return; + } + onPaginate(page - 1); + }} + /> + + +
+ {displayPage} of {totalPages} +
+
+ + { + onPaginate(page + 1); + }} + /> + +
+
+
+
+
+ ); +} + +export { WorkflowCacheKeyValuesPanel }; diff --git a/skyvern-frontend/src/routes/workflows/hooks/useCacheKeyValuesQuery.ts b/skyvern-frontend/src/routes/workflows/hooks/useCacheKeyValuesQuery.ts new file mode 100644 index 00000000..c9b28139 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/hooks/useCacheKeyValuesQuery.ts @@ -0,0 +1,65 @@ +import { getClient } from "@/api/AxiosClient"; +import { useCredentialGetter } from "@/hooks/useCredentialGetter"; +import { useQuery } from "@tanstack/react-query"; +import { keepPreviousData } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; +import { CacheKeyValuesResponse } from "../types/scriptTypes"; + +type Props = { + cacheKey?: string; + filter?: string; + page: number; + workflowPermanentId?: string; + debounceMs?: number; +}; + +function useCacheKeyValuesQuery({ + cacheKey, + filter, + page, + workflowPermanentId, + debounceMs = 300, +}: Props) { + const credentialGetter = useCredentialGetter(); + + // Debounce the filter parameter + const [debouncedFilter, setDebouncedFilter] = useState(filter); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedFilter(filter); + }, debounceMs); + + return () => clearTimeout(timer); + }, [filter, debounceMs]); + + return useQuery({ + queryKey: [ + "cache-key-values", + workflowPermanentId, + cacheKey, + page, + debouncedFilter, + ], + queryFn: async () => { + const client = await getClient(credentialGetter, "sans-api-v1"); + const cacheKeyEncoded = encodeURIComponent(cacheKey ?? ""); + let url = `/scripts/${workflowPermanentId}/${cacheKeyEncoded}/values?page=${page}&page_size=25`; + + if (debouncedFilter) { + url += `&filter=${encodeURIComponent(debouncedFilter)}`; + } + + const result = await client + .get(url) + .then((response) => response.data); + + return result; + }, + enabled: !!workflowPermanentId, + placeholderData: keepPreviousData, + staleTime: 5 * 60 * 1000, + }); +} + +export { useCacheKeyValuesQuery }; diff --git a/skyvern-frontend/src/routes/workflows/types/scriptTypes.ts b/skyvern-frontend/src/routes/workflows/types/scriptTypes.ts index 11e145e0..aa17d284 100644 --- a/skyvern-frontend/src/routes/workflows/types/scriptTypes.ts +++ b/skyvern-frontend/src/routes/workflows/types/scriptTypes.ts @@ -1,3 +1,11 @@ export type ScriptBlocksResponse = { blocks: { [blockName: string]: string }; }; + +export type CacheKeyValuesResponse = { + filtered_count: number; + page: number; + page_size: number; + total_count: number; + values: string[]; +}; diff --git a/skyvern-frontend/src/store/WorkflowPanelStore.ts b/skyvern-frontend/src/store/WorkflowPanelStore.ts index e4127e9b..87c3e5cd 100644 --- a/skyvern-frontend/src/store/WorkflowPanelStore.ts +++ b/skyvern-frontend/src/store/WorkflowPanelStore.ts @@ -2,7 +2,7 @@ import { create } from "zustand"; type WorkflowPanelState = { active: boolean; - content: "parameters" | "nodeLibrary"; + content: "cacheKeyValues" | "parameters" | "nodeLibrary"; data?: { previous?: string | null; next?: string | null;