feat: add segment click handling in SecretInput and InfisicalSecretInput components

This commit is contained in:
Victor Santos
2025-11-04 14:40:33 -03:00
parent 188eff144f
commit 1e857c04b7
4 changed files with 143 additions and 12 deletions

View File

@@ -3,6 +3,7 @@ import { faFolder, faKey, faLayerGroup, faSearch } from "@fortawesome/free-solid
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import * as Popover from "@radix-ui/react-popover";
import { ROUTE_PATHS } from "@app/const/routes";
import { useProject } from "@app/context";
import { useDebounce, useToggle } from "@app/hooks";
import { useGetProjectFolders, useGetProjectSecrets } from "@app/hooks/api";
@@ -307,6 +308,41 @@ export const InfisicalSecretInput = forwardRef<HTMLTextAreaElement, Props>(
}
}, []);
const handleClickSegment = useCallback(
(segment: string, allSegments: string[]) => {
// Single segment: search in current environment and path
if (allSegments.length === 1) {
const currentEnv = propEnvironment || "";
const currentPath = propSecretPath || "/";
const encodedPath = encodeURIComponent(currentPath);
const url = ROUTE_PATHS.SecretManager.SecretDashboardPage.path
.replace("$projectId", projectId)
.replace("$envSlug", currentEnv);
window.open(
`${url}?secretPath=${encodedPath}&search=${encodeURIComponent(segment)}&filterBy=secret`,
"_blank",
"noopener,noreferrer"
);
return;
}
// Multiple segments: first is env, last is secret, middle are folders
const environmentSlug = allSegments[0];
const secretName = allSegments[allSegments.length - 1];
const folderPath = allSegments.length > 2 ? `/${allSegments.slice(1, -1).join("/")}` : "/";
const encodedPath = encodeURIComponent(folderPath);
const url = ROUTE_PATHS.SecretManager.SecretDashboardPage.path
.replace("$projectId", projectId)
.replace("$envSlug", environmentSlug);
window.open(
`${url}?secretPath=${encodedPath}&search=${encodeURIComponent(secretName)}&filterBy=secret`,
"_blank",
"noopener,noreferrer"
);
},
[projectId, propEnvironment, propSecretPath]
);
return (
<Popover.Root open={isPopupOpen} onOpenChange={handlePopUpOpen}>
<Popover.Trigger asChild>
@@ -329,6 +365,7 @@ export const InfisicalSecretInput = forwardRef<HTMLTextAreaElement, Props>(
}}
onChange={(e) => onChange?.(e.target.value)}
containerClassName={containerClassName}
onClickSegment={handleClickSegment}
/>
</Popover.Trigger>
<Popover.Content

View File

@@ -1,18 +1,22 @@
/* eslint-disable react/no-danger */
import { forwardRef, TextareaHTMLAttributes } from "react";
import { forwardRef, TextareaHTMLAttributes, useEffect, useState } from "react";
import { twMerge } from "tailwind-merge";
import { useToggle } from "@app/hooks";
import { HIDDEN_SECRET_VALUE } from "@app/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretItem";
const REGEX = /(\${([a-zA-Z0-9-_.]+)})/g;
const REGEX = /(\${([a-zA-Z0-9-_. ]+)})/g;
const syntaxHighlight = (
content?: string | null,
isVisible?: boolean,
isImport?: boolean,
isLoadingValue?: boolean,
isErrorLoadingValue?: boolean
isErrorLoadingValue?: boolean,
onHoverPart?: (part: string) => void,
hoveredPart?: string,
isCmdOrCtrlPressed?: boolean,
onClickSegment?: (segment: string, allSegments: string[]) => void
) => {
if (isLoadingValue) return HIDDEN_SECRET_VALUE;
if (isErrorLoadingValue)
@@ -27,10 +31,51 @@ const syntaxHighlight = (
const isInterpolationSyntax = el.startsWith("${") && el.endsWith("}");
if (isInterpolationSyntax) {
skipNext = true;
const part = el;
const innerContent = el.slice(2, -1); // Remove ${ and }
const parts = innerContent.split(".");
return (
<span className="ph-no-capture text-yellow" key={`secret-value-${i + 1}`}>
<span className="ph-no-capture relative z-10 text-yellow" key={`secret-value-${i + 1}`}>
&#36;&#123;
<span className="ph-no-capture text-yellow-200/80">{el.slice(2, -1)}</span>
{parts.map((segment, segmentIndex) => {
const segmentKey = `${part}-segment-${segmentIndex}`;
const isHovered = hoveredPart === segmentKey;
const shouldShowHoverStyle = isHovered && isCmdOrCtrlPressed;
return (
<span key={segmentKey}>
<span
role="button"
tabIndex={isCmdOrCtrlPressed ? 0 : -1}
className={`ph-no-capture text-yellow-200/80 ${
isCmdOrCtrlPressed ? "pointer-events-auto" : "pointer-events-none"
} ${shouldShowHoverStyle ? "cursor-pointer underline decoration-yellow-400" : ""}`}
onMouseEnter={() => onHoverPart?.(segmentKey)}
onMouseLeave={() => onHoverPart?.("")}
onClick={(e) => {
if (isCmdOrCtrlPressed) {
e.preventDefault();
e.stopPropagation();
onClickSegment?.(segment, parts);
}
}}
onKeyDown={(e) => {
if (isCmdOrCtrlPressed && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
e.stopPropagation();
onClickSegment?.(segment, parts);
}
}}
>
{segment}
</span>
{segmentIndex < parts.length - 1 && (
<span className="ph-no-capture pointer-events-none text-yellow-200/80">.</span>
)}
</span>
);
})}
&#125;
</span>
);
@@ -60,6 +105,7 @@ type Props = TextareaHTMLAttributes<HTMLTextAreaElement> & {
canEditButNotView?: boolean;
isLoadingValue?: boolean;
isErrorLoadingValue?: boolean;
onClickSegment?: (segment: string, allSegments: string[]) => void;
};
const commonClassName = "font-mono text-sm caret-white border-none outline-hidden w-full break-all";
@@ -79,11 +125,36 @@ export const SecretInput = forwardRef<HTMLTextAreaElement, Props>(
canEditButNotView,
isLoadingValue,
isErrorLoadingValue,
onClickSegment,
...props
},
ref
) => {
const [isSecretFocused, setIsSecretFocused] = useToggle();
const [hoveredPart, setHoveredPart] = useState<string | undefined>();
const [isCmdOrCtrlPressed, setIsCmdOrCtrlPressed] = useState(false);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.metaKey || e.ctrlKey) {
setIsCmdOrCtrlPressed(true);
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (!e.metaKey && !e.ctrlKey) {
setIsCmdOrCtrlPressed(false);
}
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
};
}, []);
return (
<div
@@ -91,7 +162,7 @@ export const SecretInput = forwardRef<HTMLTextAreaElement, Props>(
style={{ maxHeight: `${21 * 7}px` }}
>
<div className="relative overflow-hidden">
<pre aria-hidden className="m-0">
<pre aria-hidden className="pointer-events-none relative z-10 m-0">
<code className={`inline-block w-full ${commonClassName}`}>
<span style={{ whiteSpace: "break-spaces" }}>
{syntaxHighlight(
@@ -99,7 +170,13 @@ export const SecretInput = forwardRef<HTMLTextAreaElement, Props>(
isVisible || (isSecretFocused && !valueAlwaysHidden),
isImport,
isLoadingValue,
isErrorLoadingValue
isErrorLoadingValue,
(part) => {
setHoveredPart(part);
},
hoveredPart,
isCmdOrCtrlPressed,
onClickSegment
)}
</span>
</code>
@@ -128,6 +205,9 @@ export const SecretInput = forwardRef<HTMLTextAreaElement, Props>(
onBlur?.(evt);
setIsSecretFocused.off();
}}
onMouseLeave={() => {
setHoveredPart(undefined);
}}
value={value || ""}
{...props}
readOnly={isReadOnly || isLoadingValue || isErrorLoadingValue}

View File

@@ -534,7 +534,7 @@ const Page = () => {
}, [secretPath]);
useEffect(() => {
if (!routerQueryParams.search && !routerQueryParams.tags) return;
if (!routerQueryParams.search && !routerQueryParams.tags && !routerQueryParams.filterBy) return;
const queryTags = routerQueryParams.tags
? (routerQueryParams.tags as string).split(",").filter((tag) => Boolean(tag.trim()))
@@ -544,21 +544,34 @@ const Page = () => {
updatedTags[tag] = true;
});
const filterBy = routerQueryParams.filterBy
? (routerQueryParams.filterBy as string).split(",").filter(Boolean)
: [];
const includeFilter: Record<RowType, boolean> = {
[RowType.Folder]: filterBy.includes("folder"),
[RowType.Import]: filterBy.includes("import"),
[RowType.DynamicSecret]: filterBy.includes("dynamic"),
[RowType.Secret]: filterBy.includes("secret"),
[RowType.SecretRotation]: filterBy.includes("rotation")
};
setFilter((prev) => ({
...prev,
...defaultFilterState,
searchFilter: (routerQueryParams.search as string) ?? "",
tags: updatedTags
tags: updatedTags,
include: includeFilter
}));
setDebouncedSearchFilter(routerQueryParams.search as string);
// this is a temp workaround until we fully transition state to query params,
navigate({
search: (state) => {
const { search, tags: qTags, ...query } = state;
const { search, tags: qTags, filterBy: qFilterBy, ...query } = state;
return query;
}
});
}, [routerQueryParams.search, routerQueryParams.tags]);
}, [routerQueryParams.search, routerQueryParams.tags, routerQueryParams.filterBy]);
const selectedSecrets = useSelectedSecrets();
const selectedSecretActions = useSelectedSecretActions();

View File

@@ -11,6 +11,7 @@ const SecretDashboardPageQueryParamsSchema = z.object({
secretPath: z.string().catch("/"),
search: z.string().catch(""),
tags: z.string().catch(""),
filterBy: z.string().catch(""),
connectionId: z.string().optional(),
connectionName: z.string().optional()
});
@@ -20,7 +21,7 @@ export const Route = createFileRoute(
component: SecretDashboardPage,
validateSearch: zodValidator(SecretDashboardPageQueryParamsSchema),
search: {
middlewares: [stripSearchParams({ secretPath: "/", search: "", tags: "" })]
middlewares: [stripSearchParams({ secretPath: "/", search: "", tags: "", filterBy: "" })]
},
beforeLoad: ({ context, params, search }) => {
const secretPathSegments = search.secretPath.split("/").filter(Boolean);