From 1bcc7424667d63c71477d1eeac8eec0d65c20e5f Mon Sep 17 00:00:00 2001 From: Salman Date: Fri, 15 Mar 2024 02:22:09 +0530 Subject: [PATCH] feat: improve reference match, auto closing tag and reference select --- .../components/v2/SecretInput/SecretInput.tsx | 219 +++++++++++++----- 1 file changed, 164 insertions(+), 55 deletions(-) diff --git a/frontend/src/components/v2/SecretInput/SecretInput.tsx b/frontend/src/components/v2/SecretInput/SecretInput.tsx index 6ffb25f6f..d8febae80 100644 --- a/frontend/src/components/v2/SecretInput/SecretInput.tsx +++ b/frontend/src/components/v2/SecretInput/SecretInput.tsx @@ -1,5 +1,5 @@ /* eslint-disable react/no-danger */ -import React, { forwardRef, TextareaHTMLAttributes, useState } from "react"; +import React, { forwardRef, TextareaHTMLAttributes, useRef, useState } from "react"; import { faChevronRight, faFolder, faKey, faRecycle } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; @@ -32,7 +32,7 @@ const syntaxHighlight = (content?: string | null, isVisible?: boolean) => { skipNext = true; return ( - ${{el.slice(2, -1)} + ${{el.slice(2, -1)} } ); @@ -69,21 +69,33 @@ const commonClassName = "font-mono text-sm caret-white border-none outline-none export const SecretInput = forwardRef( ( - { value, isVisible, containerClassName, onBlur, isDisabled, isReadOnly, onFocus, ...props }, + { + value: propValue, + isVisible, + containerClassName, + onBlur, + isDisabled, + isReadOnly, + onFocus, + secretPath, + environment, + onChange, + ...props + }, ref ) => { const [isSecretFocused, setIsSecretFocused] = useToggle(); const [showReferencePopup, setShowReferencePopup] = useState(false); + const [value, setValue] = useState(propValue || ""); const { currentWorkspace } = useWorkspace(); const [listVariables, setListVariables] = useState([]); + const [lastSelectionIndex, setLastSelectionIndex] = useState(0); + const childRef = useRef(null); const workspaceId = currentWorkspace?.id || ""; const { data: decryptFileKey } = useGetUserWsKey(workspaceId); - const { environment, secretPath } = props; - - async function extractReference(refValue: string, refIndex: number) { - console.log({ refIndex }); + async function extractReference(refValue: string) { const isNested = refValue.includes("."); const currentListVariable: VariableType[] = []; @@ -101,7 +113,7 @@ export const SecretInput = forwardRef( return; } - console.log({ currentEnvironment, currentSecretPath }); + // Move to react query const [encryptSecrets, folders] = await Promise.all([ fetchProjectEncryptedSecrets({ workspaceId, @@ -122,63 +134,148 @@ export const SecretInput = forwardRef( currentListVariable.unshift({ name: secret.key, type: "secret" }); }); - // get list of secrets, folder name and envs - // On env select get list of secrets - // on env select show list of secrets and folder - // on env or folder select replace the text and update the caret? - // fetch secrets based on current base environment and the path - setListVariables(currentListVariable); + setShowReferencePopup(true); } - function handleVariablePopup(element: HTMLTextAreaElement) { - const { selectionStart, selectionEnd, value: elValue } = element; - if (selectionStart !== selectionEnd || selectionStart === 0) { - setShowReferencePopup(false); - return; - } - - let match = null; - for ( - let matches = REGEX_REFERENCE.exec(elValue); - matches !== null; - matches = REGEX_REFERENCE.exec(elValue) - ) { - if (matches.index <= selectionStart && REGEX_REFERENCE.lastIndex >= selectionStart) { - match = matches?.[2]; - extractReference(match, matches.index); + function findMatch(str: string, start: number) { + const matches = [...str.matchAll(REGEX_REFERENCE)]; + for (let i = 0; i < matches.length; i += 1) { + const match = matches[i]; + if ( + match && + typeof match.index !== "undefined" && + match.index <= start && + start < match.index + match[0].length + ) { + return match; } } - - setShowReferencePopup(Boolean(match)); + return null; } - function handleKeyDown(event: React.KeyboardEvent) { - // On Key up or down if the popup is open ignore it - if ((showReferencePopup && event.key === "ArrowUp") || event.key === "ArrowDown") { - event.preventDefault(); - // todo: point up or down in the variable popup - // return; + function setCaretPos(caretPos: number) { + if (childRef?.current) { + childRef.current.focus(); + setTimeout(() => { + if (!childRef?.current) return; + childRef.current.selectionStart = caretPos; + childRef.current.selectionEnd = caretPos; + }, 200); } } + function referencePopup(text: string, pos: number) { + const match = findMatch(text, pos); + if (match && typeof match.index !== "undefined") { + setLastSelectionIndex(pos); + extractReference(match?.[2]); + } + + setShowReferencePopup(!!match); + } + + function handleReferencePopup(element: HTMLTextAreaElement) { + const { selectionStart, selectionEnd, value: text } = element; + if (selectionStart !== selectionEnd || selectionStart === 0) { + return; + } + referencePopup(text, selectionStart); + } + function handleKeyUp(event: React.KeyboardEvent) { if (event.key === "Escape") { setShowReferencePopup(false); return; } + + if (event.key === "{") { + // auto close the tag + const currCaretPos = event.currentTarget.selectionEnd; + const isPrevDollar = value[currCaretPos - 2] === "$"; + if (!isPrevDollar) return; + + const newValue = `${value.slice(0, currCaretPos)}}${value.slice(currCaretPos)}`; + + setValue(newValue); + if (event.currentTarget) { + setCaretPos(currCaretPos); + setTimeout(() => { + // on next tick + referencePopup(newValue, currCaretPos); + }, 200); + + return; + } + } // On Key up or down if the popup is open ignore it if ((showReferencePopup && event.key === "ArrowUp") || event.key === "ArrowDown") { event.preventDefault(); - // todo: point up or down in the variable popup - // return; } - handleVariablePopup(event.currentTarget); + handleReferencePopup(event.currentTarget); } function handleMouseClick(event: React.MouseEvent) { - handleVariablePopup(event.currentTarget); + handleReferencePopup(event.currentTarget); + } + + + async function handleReferenceSelect({ + name, + type, + slug + }: { + name: string; + type: "folder" | "secret" | "environment"; + slug?: string; + }) { + setShowReferencePopup(false); + + // forward ref for parent component + if (typeof ref === "function") { + ref(childRef.current); + } else if (ref && "current" in ref) { + const refCopy = ref; + refCopy.current = childRef.current; + } + + let newValue = value || ""; + const match = findMatch(newValue, lastSelectionIndex); + const referenceStartIndex = match?.index || 0; + const referenceEndIndex = referenceStartIndex + (match?.[0]?.length || 0); + const [start, oldReference, end] = [ + value.slice(0, referenceStartIndex), + value.slice(referenceStartIndex, referenceEndIndex), + value.slice(referenceEndIndex) + ]; + + const oldReferenceStr = oldReference.slice(2, oldReference.length - 1); // remove template + let replaceReference = ""; + let offset = 3; + switch (type) { + case "folder": + replaceReference = `${oldReferenceStr}${name}.`; + offset -= 1; + break; + case "secret": + replaceReference = `${oldReferenceStr}${name}`; + break; + case "environment": + replaceReference = `${slug}.`; + offset -= 1; + break; + default: + } + newValue = `${start}$\{${replaceReference}}${end}`; + setValue(newValue); + setCaretPos(start.length + replaceReference.length + offset); + if (type !== "secret") extractReference(replaceReference); + } + + function handleChange(event: React.ChangeEvent) { + setValue(event.target.value); + return onChange; } return ( @@ -199,32 +296,38 @@ export const SecretInput = forwardRef(