Improve UI no reference found message

This commit is contained in:
Carlos Monastyrski
2025-09-12 17:35:45 -03:00
parent 17cd03a00d
commit 1c5dd0c35f
2 changed files with 81 additions and 99 deletions
@@ -1,5 +1,5 @@
import { forwardRef, TextareaHTMLAttributes, useCallback, useMemo, useRef, useState } from "react"; import { forwardRef, TextareaHTMLAttributes, useCallback, useMemo, useRef, useState } from "react";
import { faFolder, faKey, faLayerGroup } from "@fortawesome/free-solid-svg-icons"; import { faFolder, faKey, faLayerGroup, faSearch } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import * as Popover from "@radix-ui/react-popover"; import * as Popover from "@radix-ui/react-popover";
@@ -9,15 +9,6 @@ import { useGetProjectFolders, useGetProjectSecrets } from "@app/hooks/api";
import { SecretInput } from "../SecretInput"; import { SecretInput } from "../SecretInput";
// Regex to find all secret references in the format ${reference}
const REFERENCE_REGEX = /\${([^}]+)}/g;
// Extract unique references from a value
const extractReferences = (value: string): string[] => {
const matches = Array.from(value.matchAll(REFERENCE_REGEX));
return [...new Set(matches.map((match) => match[1]))];
};
const getIndexOfUnclosedRefToTheLeft = (value: string, pos: number) => { const getIndexOfUnclosedRefToTheLeft = (value: string, pos: number) => {
// take substring up to pos in order to consider edits for closed references // take substring up to pos in order to consider edits for closed references
for (let i = pos; i >= 1; i -= 1) { for (let i = pos; i >= 1; i -= 1) {
@@ -92,7 +83,7 @@ export const InfisicalSecretInput = forwardRef<HTMLTextAreaElement, Props>(
const [highlightedIndex, setHighlightedIndex] = useState(-1); const [highlightedIndex, setHighlightedIndex] = useState(-1);
const inputRef = useRef<HTMLTextAreaElement | null>(null); const inputRef = useRef<HTMLTextAreaElement>(null);
const popoverContentRef = useRef<HTMLDivElement>(null); const popoverContentRef = useRef<HTMLDivElement>(null);
const [isFocused, setIsFocused] = useToggle(false); const [isFocused, setIsFocused] = useToggle(false);
const currentCursorPosition = inputRef.current?.selectionStart || 0; const currentCursorPosition = inputRef.current?.selectionStart || 0;
@@ -148,8 +139,6 @@ export const InfisicalSecretInput = forwardRef<HTMLTextAreaElement, Props>(
} }
}); });
const allReferences = useMemo(() => extractReferences(value), [value]);
const suggestions = useMemo(() => { const suggestions = useMemo(() => {
if (!isPopupOpen) return []; if (!isPopupOpen) return [];
// reset highlight whenever recomputation happens // reset highlight whenever recomputation happens
@@ -186,51 +175,22 @@ export const InfisicalSecretInput = forwardRef<HTMLTextAreaElement, Props>(
type: ReferenceType.SECRET type: ReferenceType.SECRET
}); });
}); });
return suggestionsArr;
}, [secrets, folders, currentWorkspace?.environments, isPopupOpen, suggestionSource.value]);
// Mark as invalid when editing and reference doesn't match any suggestion if (suggestionsArr.length === 0 && suggestionSource.predicate.trim()) {
const invalidReferences = useMemo(() => { suggestionsArr.push({
const invalid = new Set<string>(); label: "No matches found",
slug: "__no_match__",
if (!isPopupOpen) { type: ReferenceType.SECRET
return invalid; // No validation when not editing
}
const suggestionsHaveLoaded = Boolean(secrets || folders || currentWorkspace?.environments);
if (!suggestionsHaveLoaded) {
return invalid;
}
// If we have an active suggestion context but no suggestions, it means the query returned empty
const suggestionsAreEmpty = suggestions.length === 0;
allReferences.forEach((reference) => {
const matchesAnySuggestion = suggestions.some((suggestion) => {
if (!reference.includes(".")) {
return suggestion.slug === reference;
}
const parts = reference.split(".");
const finalPart = parts[parts.length - 1];
return suggestion.slug === finalPart;
}); });
}
// Mark as invalid only if: return suggestionsArr;
// 1. We have suggestions loaded AND none match, OR }, [secrets, folders, currentWorkspace?.environments, isPopupOpen, suggestionSource.predicate]);
// 2. The query returned empty results
if (!matchesAnySuggestion && (suggestions.length > 0 || suggestionsAreEmpty)) {
invalid.add(reference);
}
});
return invalid;
}, [isPopupOpen, allReferences, suggestions, secrets, folders, currentWorkspace?.environments]);
const handleSuggestionSelect = (selectIndex?: number) => { const handleSuggestionSelect = (selectIndex?: number) => {
const selectedSuggestion = const selectedSuggestion =
suggestions[typeof selectIndex !== "undefined" ? selectIndex : highlightedIndex]; suggestions[typeof selectIndex !== "undefined" ? selectIndex : highlightedIndex];
if (!selectedSuggestion) { if (!selectedSuggestion || selectedSuggestion.slug === "__no_match__") {
return; return;
} }
@@ -275,21 +235,40 @@ export const InfisicalSecretInput = forwardRef<HTMLTextAreaElement, Props>(
if (isPopupOpen) { if (isPopupOpen) {
if (e.key === "ArrowDown" || (e.key === "Tab" && !e.shiftKey)) { if (e.key === "ArrowDown" || (e.key === "Tab" && !e.shiftKey)) {
setHighlightedIndex((prevIndex) => { setHighlightedIndex((prevIndex) => {
const pos = mod(prevIndex + 1, suggestions.length); let nextIndex = mod(prevIndex + 1, suggestions.length);
popoverContentRef.current?.children?.[pos]?.scrollIntoView({ // Skip "no match" messages
while (
nextIndex < suggestions.length &&
suggestions[nextIndex].slug === "__no_match__"
) {
nextIndex = mod(nextIndex + 1, suggestions.length);
}
// If we only have no-match messages, don't highlight anything
if (suggestions[nextIndex]?.slug === "__no_match__") {
return -1;
}
popoverContentRef.current?.children?.[nextIndex]?.scrollIntoView({
block: "nearest", block: "nearest",
behavior: "smooth" behavior: "smooth"
}); });
return pos; return nextIndex;
}); });
} else if (e.key === "ArrowUp" || (e.key === "Tab" && e.shiftKey)) { } else if (e.key === "ArrowUp" || (e.key === "Tab" && e.shiftKey)) {
setHighlightedIndex((prevIndex) => { setHighlightedIndex((prevIndex) => {
const pos = mod(prevIndex - 1, suggestions.length); let prevIdx = mod(prevIndex - 1, suggestions.length);
popoverContentRef.current?.children?.[pos]?.scrollIntoView({ // Skip "no match" messages
while (prevIdx >= 0 && suggestions[prevIdx].slug === "__no_match__") {
prevIdx = mod(prevIdx - 1, suggestions.length);
}
// If we only have no-match messages, don't highlight anything
if (suggestions[prevIdx]?.slug === "__no_match__") {
return -1;
}
popoverContentRef.current?.children?.[prevIdx]?.scrollIntoView({
block: "nearest", block: "nearest",
behavior: "smooth" behavior: "smooth"
}); });
return pos; return prevIdx;
}); });
} else if (e.key === "Enter" && highlightedIndex >= 0) { } else if (e.key === "Enter" && highlightedIndex >= 0) {
e.preventDefault(); e.preventDefault();
@@ -306,20 +285,18 @@ export const InfisicalSecretInput = forwardRef<HTMLTextAreaElement, Props>(
}; };
// to handle multiple ref for single component // to handle multiple ref for single component
const handleRef = useCallback( const handleRef = useCallback((el: HTMLTextAreaElement) => {
(el: HTMLTextAreaElement) => { // @ts-expect-error this is for multiple ref single component
inputRef.current = el; inputRef.current = el;
if (ref) { if (ref) {
if (typeof ref === "function") { if (typeof ref === "function") {
ref(el); ref(el);
} else { } else {
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line
ref.current = el; ref.current = el;
}
} }
}, }
[ref] }, []);
);
return ( return (
<Popover.Root open={isPopupOpen} onOpenChange={handlePopUpOpen}> <Popover.Root open={isPopupOpen} onOpenChange={handlePopUpOpen}>
@@ -338,7 +315,6 @@ export const InfisicalSecretInput = forwardRef<HTMLTextAreaElement, Props>(
}} }}
onChange={(e) => onChange?.(e.target.value)} onChange={(e) => onChange?.(e.target.value)}
containerClassName={containerClassName} containerClassName={containerClassName}
invalidReferences={invalidReferences}
/> />
</Popover.Trigger> </Popover.Trigger>
<Popover.Content <Popover.Content
@@ -356,7 +332,12 @@ export const InfisicalSecretInput = forwardRef<HTMLTextAreaElement, Props>(
{suggestions.map((item, i) => { {suggestions.map((item, i) => {
let entryIcon; let entryIcon;
let subText; let subText;
if (item.type === ReferenceType.SECRET) { const isNoMatchMessage = item.slug === "__no_match__";
if (isNoMatchMessage) {
entryIcon = <FontAwesomeIcon icon={faSearch} className="text-gray-400" />;
subText = "No results";
} else if (item.type === ReferenceType.SECRET) {
entryIcon = <FontAwesomeIcon icon={faKey} className="text-bunker-300" />; entryIcon = <FontAwesomeIcon icon={faKey} className="text-bunker-300" />;
subText = "Secret"; subText = "Secret";
} else if (item.type === ReferenceType.ENVIRONMENT) { } else if (item.type === ReferenceType.ENVIRONMENT) {
@@ -367,10 +348,28 @@ export const InfisicalSecretInput = forwardRef<HTMLTextAreaElement, Props>(
subText = "Folder"; subText = "Folder";
} }
return ( return isNoMatchMessage ? (
<div <div
tabIndex={0} role="status"
role="button" aria-label="no-match-message"
className="flex w-full items-center justify-between border-mineshaft-600 text-left"
key={`secret-reference-secret-${i + 1}`}
>
<div className="text-md relative flex w-full cursor-default select-none items-center justify-between px-2 py-2 opacity-75 outline-none transition-all">
<div className="flex w-full items-start gap-2">
<div className="mt-1 flex items-center">{entryIcon}</div>
<div className="text-md w-10/12 truncate text-left">
<span className="text-gray-400">{item.label}</span>
<div className="mb-[0.1rem] text-xs leading-3 text-bunker-400">
{subText}
</div>
</div>
</div>
</div>
</div>
) : (
<button
type="button"
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") handleSuggestionSelect(i); if (e.key === "Enter") handleSuggestionSelect(i);
}} }}
@@ -382,8 +381,7 @@ export const InfisicalSecretInput = forwardRef<HTMLTextAreaElement, Props>(
handleSuggestionSelect(i); handleSuggestionSelect(i);
}} }}
onMouseEnter={() => setHighlightedIndex(i)} onMouseEnter={() => setHighlightedIndex(i)}
style={{ pointerEvents: "auto" }} className="flex w-full items-center justify-between border-none border-mineshaft-600 bg-transparent p-0 text-left"
className="flex w-full items-center justify-between border-mineshaft-600 text-left"
key={`secret-reference-secret-${i + 1}`} key={`secret-reference-secret-${i + 1}`}
> >
<div <div
@@ -394,14 +392,14 @@ export const InfisicalSecretInput = forwardRef<HTMLTextAreaElement, Props>(
<div className="flex w-full items-start gap-2"> <div className="flex w-full items-start gap-2">
<div className="mt-1 flex items-center">{entryIcon}</div> <div className="mt-1 flex items-center">{entryIcon}</div>
<div className="text-md w-10/12 truncate text-left"> <div className="text-md w-10/12 truncate text-left">
{item.label} <span>{item.label}</span>
<div className="mb-[0.1rem] text-xs leading-3 text-bunker-400"> <div className="mb-[0.1rem] text-xs leading-3 text-bunker-400">
{subText} {subText}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </button>
); );
})} })}
</div> </div>
@@ -7,12 +7,7 @@ import { HIDDEN_SECRET_VALUE } from "@app/pages/secret-manager/SecretDashboardPa
const REGEX = /(\${([a-zA-Z0-9-_.]+)})/g; const REGEX = /(\${([a-zA-Z0-9-_.]+)})/g;
const syntaxHighlight = ( const syntaxHighlight = (content?: string | null, isVisible?: boolean, isImport?: boolean) => {
content?: string | null,
isVisible?: boolean,
isImport?: boolean,
invalidReferences?: Set<string>
) => {
if (isImport && !content) return "IMPORTED"; if (isImport && !content) return "IMPORTED";
if (content === "") return "EMPTY"; if (content === "") return "EMPTY";
if (!content) return "EMPTY"; if (!content) return "EMPTY";
@@ -23,18 +18,10 @@ const syntaxHighlight = (
const isInterpolationSyntax = el.startsWith("${") && el.endsWith("}"); const isInterpolationSyntax = el.startsWith("${") && el.endsWith("}");
if (isInterpolationSyntax) { if (isInterpolationSyntax) {
skipNext = true; skipNext = true;
const referenceContent = el.slice(2, -1);
const isInvalid = invalidReferences?.has(referenceContent) ?? false;
return ( return (
<span <span className="ph-no-capture text-yellow" key={`secret-value-${i + 1}`}>
className={`ph-no-capture ${isInvalid ? "" : "text-yellow"}`}
key={`secret-value-${i + 1}`}
>
&#36;&#123; &#36;&#123;
<span className={`ph-no-capture ${isInvalid ? "" : "text-yellow-200/80"}`}> <span className="ph-no-capture text-yellow-200/80">{el.slice(2, -1)}</span>
{referenceContent}
</span>
&#125; &#125;
</span> </span>
); );
@@ -62,7 +49,6 @@ type Props = TextareaHTMLAttributes<HTMLTextAreaElement> & {
isDisabled?: boolean; isDisabled?: boolean;
containerClassName?: string; containerClassName?: string;
canEditButNotView?: boolean; canEditButNotView?: boolean;
invalidReferences?: Set<string>;
}; };
const commonClassName = "font-mono text-sm caret-white border-none outline-none w-full break-all"; const commonClassName = "font-mono text-sm caret-white border-none outline-none w-full break-all";
@@ -80,7 +66,6 @@ export const SecretInput = forwardRef<HTMLTextAreaElement, Props>(
isReadOnly, isReadOnly,
onFocus, onFocus,
canEditButNotView, canEditButNotView,
invalidReferences,
...props ...props
}, },
ref ref
@@ -99,8 +84,7 @@ export const SecretInput = forwardRef<HTMLTextAreaElement, Props>(
{syntaxHighlight( {syntaxHighlight(
value, value,
isVisible || (isSecretFocused && !valueAlwaysHidden), isVisible || (isSecretFocused && !valueAlwaysHidden),
isImport, isImport
invalidReferences
)} )}
</span> </span>
</code> </code>