From 5142e6e5f68529a9045d92849fee6877f9d2549b Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Fri, 26 Apr 2024 16:07:27 +0800 Subject: [PATCH] feature: created secret path input component with autocomplete support --- .../v2/SecretPathInput/SecretPathInput.tsx | 175 ++++++++++++++++++ .../components/v2/SecretPathInput/index.tsx | 1 + 2 files changed, 176 insertions(+) create mode 100644 frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx create mode 100644 frontend/src/components/v2/SecretPathInput/index.tsx diff --git a/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx b/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx new file mode 100644 index 000000000..115ff488c --- /dev/null +++ b/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx @@ -0,0 +1,175 @@ +import { InputHTMLAttributes, useEffect, useRef, useState } from "react"; +import { faFolder } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import * as Popover from "@radix-ui/react-popover"; +import { twMerge } from "tailwind-merge"; + +import { useWorkspace } from "@app/context"; +import { useDebounce } from "@app/hooks"; +import { useGetFoldersByEnv } from "@app/hooks/api"; + +import { Input } from "../Input"; + +type Props = Omit, "size"> & { + value?: string | null; + isImport?: boolean; + isVisible?: boolean; + isReadOnly?: boolean; + isDisabled?: boolean; + environment?: string; + containerClassName?: string; +}; + +export const SecretPathInput = ({ + containerClassName, + onChange, + environment, + value: propValue, + ...props +}: Props) => { + const [inputValue, setInputValue] = useState(propValue ?? ""); + const [secretPath, setSecretPath] = useState("/"); + const [suggestions, setSuggestions] = useState([]); + const [highlightedIndex, setHighlightedIndex] = useState(-1); + const inputRef = useRef(null); + const debouncedInputValue = useDebounce(inputValue, 200); + + const { currentWorkspace } = useWorkspace(); + const workspaceId = currentWorkspace?.id || ""; + const { folderNames: folders } = useGetFoldersByEnv({ + path: secretPath, + environments: [environment || currentWorkspace?.environments?.[0].slug!], + projectId: workspaceId + }); + + useEffect(() => { + setInputValue(propValue ?? ""); + }, [propValue]); + + useEffect(() => { + setInputValue("/"); + setSecretPath("/"); + }, [environment]); + + useEffect(() => { + // update secret path if input is valid + if ( + debouncedInputValue.length > 0 && + debouncedInputValue[debouncedInputValue.length - 1] === "/" + ) { + setSecretPath(debouncedInputValue); + } + + // filter suggestions based on matching + const searchFragment = debouncedInputValue.split("/").pop() || ""; + + const filteredSuggestions = folders + .filter((suggestionEntry) => + suggestionEntry.toUpperCase().startsWith(searchFragment.toUpperCase()) + ) + .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); + + setSuggestions(filteredSuggestions); + }, [debouncedInputValue]); + + const handleSuggestionSelect = (selectedIndex: number) => { + if (!suggestions[selectedIndex]) { + return; + } + + const validPaths = inputValue.split("/"); + validPaths.pop(); + + const newValue = `${validPaths.join("/")}/${suggestions[selectedIndex]}`; + onChange?.({ target: { value: newValue } } as any); + setInputValue(newValue); + setSecretPath(newValue); + setHighlightedIndex(-1); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + const mod = (n: number, m: number) => ((n % m) + m) % m; + if (e.key === "ArrowDown") { + setHighlightedIndex((prevIndex) => mod(prevIndex + 1, folders.length)); + } else if (e.key === "ArrowUp") { + setHighlightedIndex((prevIndex) => mod(prevIndex - 1, folders.length)); + } else if (e.key === "Enter" && highlightedIndex >= 0) { + handleSuggestionSelect(highlightedIndex); + } + if (["ArrowDown", "ArrowUp", "Enter"].includes(e.key)) { + e.preventDefault(); + } + }; + + const handleInputChange = (e: any) => { + // propagate event to react-hook-form onChange + if (onChange) { + onChange(e); + } + + setInputValue(e.target.value); + }; + + return ( + 0 && inputValue.length > 1} + onOpenChange={() => { + setHighlightedIndex(-1); + }} + > + + + + e.preventDefault()} + className={twMerge( + "relative top-2 z-[100] overflow-hidden rounded-md border border-mineshaft-600 bg-mineshaft-900 font-inter text-bunker-100 shadow-md" + )} + style={{ + width: "var(--radix-popover-trigger-width)", + maxHeight: "var(--radix-select-content-available-height)" + }} + > +
+ {suggestions.map((suggestion, i) => ( +
{ + e.preventDefault(); + setHighlightedIndex(i); + handleSuggestionSelect(i); + }} + style={{ pointerEvents: "auto" }} + className="flex items-center justify-between border-mineshaft-600 text-left" + key={`secret-reference-secret-${i + 1}`} + > +
+
+
+ +
+
{suggestion}
+
+
+
+ ))} +
+
+
+ ); +}; diff --git a/frontend/src/components/v2/SecretPathInput/index.tsx b/frontend/src/components/v2/SecretPathInput/index.tsx new file mode 100644 index 000000000..5d8595891 --- /dev/null +++ b/frontend/src/components/v2/SecretPathInput/index.tsx @@ -0,0 +1 @@ +export { SecretPathInput } from "./SecretPathInput";