Merge pull request #4216 from Infisical/dashboard-filter-improvements

improvement(frontend): improve dashboard filter behavior and design
This commit is contained in:
Scott Wilson
2025-07-24 08:33:24 -07:00
committed by GitHub
4 changed files with 147 additions and 123 deletions
@@ -270,11 +270,6 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
} }
} }
}); });
remainingLimit -= imports.length;
adjustedOffset = 0;
} else {
adjustedOffset = Math.max(0, adjustedOffset - totalImportCount);
} }
} }
@@ -317,7 +312,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
} }
} }
if (!includeDynamicSecrets && !includeSecrets) if (!includeDynamicSecrets && !includeSecrets && !includeSecretRotations)
return { return {
folders, folders,
totalFolderCount, totalFolderCount,
@@ -547,7 +542,6 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
(totalFolderCount ?? 0) + (totalFolderCount ?? 0) +
(totalDynamicSecretCount ?? 0) + (totalDynamicSecretCount ?? 0) +
(totalSecretCount ?? 0) + (totalSecretCount ?? 0) +
(totalImportCount ?? 0) +
(totalSecretRotationCount ?? 0) (totalSecretRotationCount ?? 0)
}; };
} }
@@ -10,13 +10,12 @@ import {
faArrowRight, faArrowRight,
faArrowRightToBracket, faArrowRightToBracket,
faArrowUp, faArrowUp,
faFileImport, faFilter,
faFingerprint, faFingerprint,
faFolder, faFolder,
faFolderBlank, faFolderBlank,
faFolderPlus, faFolderPlus,
faKey, faKey,
faList,
faPlus, faPlus,
faRotate faRotate
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
@@ -96,7 +95,12 @@ import { OrderByDirection } from "@app/hooks/api/generic/types";
import { useUpdateFolderBatch } from "@app/hooks/api/secretFolders/queries"; import { useUpdateFolderBatch } from "@app/hooks/api/secretFolders/queries";
import { TUpdateFolderBatchDTO } from "@app/hooks/api/secretFolders/types"; import { TUpdateFolderBatchDTO } from "@app/hooks/api/secretFolders/types";
import { TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2"; import { TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2";
import { SecretType, SecretV3RawSanitized, TSecretFolder } from "@app/hooks/api/types"; import {
SecretType,
SecretV3RawSanitized,
TSecretFolder,
WorkspaceEnv
} from "@app/hooks/api/types";
import { ProjectVersion } from "@app/hooks/api/workspace/types"; import { ProjectVersion } from "@app/hooks/api/workspace/types";
import { import {
useDynamicSecretOverview, useDynamicSecretOverview,
@@ -135,7 +139,6 @@ enum RowType {
Folder = "folder", Folder = "folder",
DynamicSecret = "dynamic", DynamicSecret = "dynamic",
Secret = "secret", Secret = "secret",
Import = "import",
SecretRotation = "rotation" SecretRotation = "rotation"
} }
@@ -144,11 +147,10 @@ type Filter = {
}; };
const DEFAULT_FILTER_STATE = { const DEFAULT_FILTER_STATE = {
[RowType.Folder]: true, [RowType.Folder]: false,
[RowType.DynamicSecret]: true, [RowType.DynamicSecret]: false,
[RowType.Secret]: true, [RowType.Secret]: false,
[RowType.Import]: true, [RowType.SecretRotation]: false
[RowType.SecretRotation]: true
}; };
const DEFAULT_COLLAPSED_HEADER_HEIGHT = 120; const DEFAULT_COLLAPSED_HEADER_HEIGHT = 120;
@@ -265,11 +267,8 @@ export const OverviewPage = () => {
) )
); );
const [visibleEnvs, setVisibleEnvs] = useState(userAvailableEnvs); const [filteredEnvs, setFilteredEnvs] = useState<WorkspaceEnv[]>([]);
const visibleEnvs = filteredEnvs.length ? filteredEnvs : userAvailableEnvs;
useEffect(() => {
setVisibleEnvs(userAvailableEnvs);
}, [userAvailableEnvs]);
const { const {
secretImports, secretImports,
@@ -282,6 +281,7 @@ export const OverviewPage = () => {
environments: (userAvailableEnvs || []).map(({ slug }) => slug) environments: (userAvailableEnvs || []).map(({ slug }) => slug)
}); });
const isFilteredByResources = Object.values(filter).some(Boolean);
const { isPending: isOverviewLoading, data: overview } = useGetProjectSecretsOverview( const { isPending: isOverviewLoading, data: overview } = useGetProjectSecretsOverview(
{ {
projectId: workspaceId, projectId: workspaceId,
@@ -289,11 +289,11 @@ export const OverviewPage = () => {
secretPath, secretPath,
orderDirection, orderDirection,
orderBy, orderBy,
includeFolders: filter.folder, includeFolders: isFilteredByResources ? filter.folder : true,
includeDynamicSecrets: filter.dynamic, includeDynamicSecrets: isFilteredByResources ? filter.dynamic : true,
includeSecrets: filter.secret, includeSecrets: isFilteredByResources ? filter.secret : true,
includeImports: filter.import, includeImports: true,
includeSecretRotations: filter.rotation, includeSecretRotations: isFilteredByResources ? filter.rotation : true,
search: debouncedSearchFilter, search: debouncedSearchFilter,
limit, limit,
offset offset
@@ -529,10 +529,10 @@ export const OverviewPage = () => {
}; };
const handleEnvSelect = (envId: string) => { const handleEnvSelect = (envId: string) => {
if (visibleEnvs.map((env) => env.id).includes(envId)) { if (filteredEnvs.map((env) => env.id).includes(envId)) {
setVisibleEnvs(visibleEnvs.filter((env) => env.id !== envId)); setFilteredEnvs(filteredEnvs.filter((env) => env.id !== envId));
} else { } else {
setVisibleEnvs(visibleEnvs.concat(userAvailableEnvs.filter((env) => env.id === envId))); setFilteredEnvs(filteredEnvs.concat(userAvailableEnvs.filter((env) => env.id === envId)));
} }
}; };
@@ -792,11 +792,11 @@ export const OverviewPage = () => {
envNames: string[], envNames: string[],
envs: { environment: string; importedBy: ProjectSecretsImportedBy[] }[] envs: { environment: string; importedBy: ProjectSecretsImportedBy[] }[]
): ProjectSecretsImportedBy[] => { ): ProjectSecretsImportedBy[] => {
const filteredEnvs = envs.filter((env) => envNames.includes(env.environment)); const environments = envs.filter((env) => envNames.includes(env.environment));
if (filteredEnvs.length === 0) return []; if (environments.length === 0) return [];
const allImportedBy = filteredEnvs.flatMap((env) => env.importedBy); const allImportedBy = environments.flatMap((env) => env.importedBy);
const groupedBySlug: Record<string, ProjectSecretsImportedBy[]> = {}; const groupedBySlug: Record<string, ProjectSecretsImportedBy[]> = {};
allImportedBy.forEach((item) => { allImportedBy.forEach((item) => {
@@ -902,9 +902,7 @@ export const OverviewPage = () => {
const isTableEmpty = totalCount === 0; const isTableEmpty = totalCount === 0;
const isTableFiltered = const isTableFiltered = isFilteredByResources || filteredEnvs.length > 0;
Boolean(Object.values(filter).filter((enabled) => !enabled).length) ||
userAvailableEnvs.length !== visibleEnvs.length;
if (!isProjectV3) if (!isProjectV3)
return ( return (
@@ -969,26 +967,42 @@ export const OverviewPage = () => {
<div className="mt-4 flex items-center justify-between"> <div className="mt-4 flex items-center justify-between">
<FolderBreadCrumbs secretPath={secretPath} onResetSearch={handleResetSearch} /> <FolderBreadCrumbs secretPath={secretPath} onResetSearch={handleResetSearch} />
<div className="flex flex-row items-center justify-center space-x-2"> <div className="flex flex-row items-center justify-center space-x-2">
{isTableFiltered && (
<Button
variant="plain"
colorSchema="secondary"
onClick={() => {
setFilteredEnvs([]);
setFilter(DEFAULT_FILTER_STATE);
}}
>
Clear Filters
</Button>
)}
{userAvailableEnvs.length > 0 && ( {userAvailableEnvs.length > 0 && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<IconButton <Button
ariaLabel="Environments"
variant="plain"
size="sm" size="sm"
variant="outline_bg"
className={twMerge( className={twMerge(
"flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10", "flex h-[2.5rem]",
isTableFiltered && "border-primary/50 text-primary" isTableFiltered && "border-primary/40 bg-primary/10"
)} )}
leftIcon={
<FontAwesomeIcon
icon={faFilter}
className={isTableFiltered ? "text-primary/80" : undefined}
/>
}
> >
<Tooltip content="Choose visible environments" className="mb-2"> Filters
<FontAwesomeIcon icon={faList} /> </Button>
</Tooltip>
</IconButton>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
className="thin-scrollbar max-h-[70vh] overflow-y-auto" className="thin-scrollbar max-h-[70vh] overflow-y-auto"
align="end" align="end"
sideOffset={2}
> >
{/* <DropdownMenuItem className="px-1.5" asChild> {/* <DropdownMenuItem className="px-1.5" asChild>
<Button <Button
@@ -1002,20 +1016,8 @@ export const OverviewPage = () => {
Create an environment Create an environment
</Button> </Button>
</DropdownMenuItem> */} </DropdownMenuItem> */}
<DropdownMenuLabel>Filter project resources</DropdownMenuLabel> <DropdownMenuLabel>Filter by Resource</DropdownMenuLabel>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
handleToggleRowType(RowType.Import);
}}
icon={filter[RowType.Import] && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faFileImport} className="text-green-700" />
<span>Imports</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
@@ -1070,11 +1072,11 @@ export const OverviewPage = () => {
<span>Secrets</span> <span>Secrets</span>
</div> </div>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuLabel>Choose visible environments</DropdownMenuLabel> <DropdownMenuLabel>Filter by Environment</DropdownMenuLabel>
{userAvailableEnvs.map((availableEnv) => { {userAvailableEnvs.map((availableEnv) => {
const { id: envId, name } = availableEnv; const { id: envId, name } = availableEnv;
const isEnvSelected = visibleEnvs.map((env) => env.id).includes(envId); const isEnvSelected = filteredEnvs.map((env) => env.id).includes(envId);
return ( return (
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => { onClick={(e) => {
@@ -1082,7 +1084,6 @@ export const OverviewPage = () => {
handleEnvSelect(envId); handleEnvSelect(envId);
}} }}
key={envId} key={envId}
disabled={visibleEnvs?.length === 1}
icon={isEnvSelected && <FontAwesomeIcon icon={faCheckCircle} />} icon={isEnvSelected && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right" iconPos="right"
> >
@@ -1405,13 +1406,6 @@ export const OverviewPage = () => {
className="bg-mineshaft-700" className="bg-mineshaft-700"
/> />
)} )}
{userAvailableEnvs.length > 0 && visibleEnvs.length === 0 && (
<Tr>
<Td colSpan={visibleEnvs.length + 1}>
<EmptyState title="You have no visible environments" iconSize="3x" />
</Td>
</Tr>
)}
{userAvailableEnvs.length === 0 && ( {userAvailableEnvs.length === 0 && (
<Tr> <Tr>
<Td colSpan={visibleEnvs.length + 1}> <Td colSpan={visibleEnvs.length + 1}>
@@ -1444,8 +1438,8 @@ export const OverviewPage = () => {
<Td colSpan={visibleEnvs.length + 1}> <Td colSpan={visibleEnvs.length + 1}>
<EmptyState <EmptyState
title={ title={
debouncedSearchFilter isTableFiltered || debouncedSearchFilter
? "No secret found for your search, add one now" ? "No secrets found for your search, add one now"
: "Let's add some secrets" : "Let's add some secrets"
} }
icon={faFolderBlank} icon={faFolderBlank}
@@ -212,11 +212,11 @@ const Page = () => {
searchFilter: (routerQueryParams.search as string) || "", searchFilter: (routerQueryParams.search as string) || "",
// these should always be on by default for the UI, they will be disabled for the query below based off permissions // these should always be on by default for the UI, they will be disabled for the query below based off permissions
include: { include: {
[RowType.Folder]: true, [RowType.Folder]: false,
[RowType.Import]: true, [RowType.Import]: false,
[RowType.DynamicSecret]: true, [RowType.DynamicSecret]: false,
[RowType.Secret]: true, [RowType.Secret]: false,
[RowType.SecretRotation]: true [RowType.SecretRotation]: false
} }
}; };
@@ -242,6 +242,7 @@ const Page = () => {
} }
}, [currentWorkspace, environment]); }, [currentWorkspace, environment]);
const isResourceTypeFiltered = Object.values(filter.include).some(Boolean);
const { const {
data, data,
isPending: isDetailsLoading, isPending: isDetailsLoading,
@@ -255,12 +256,14 @@ const Page = () => {
orderBy, orderBy,
search: debouncedSearchFilter, search: debouncedSearchFilter,
orderDirection, orderDirection,
includeImports: canReadSecretImports && filter.include.import, includeImports: canReadSecretImports && (isResourceTypeFiltered ? filter.include.import : true),
includeFolders: filter.include.folder, includeFolders: isResourceTypeFiltered ? filter.include.folder : true,
viewSecretValue: canReadSecretValue, viewSecretValue: canReadSecretValue,
includeDynamicSecrets: canReadDynamicSecret && filter.include.dynamic, includeDynamicSecrets:
includeSecrets: canReadSecret && filter.include.secret, canReadDynamicSecret && (isResourceTypeFiltered ? filter.include.dynamic : true),
includeSecretRotations: canReadSecretRotations && filter.include.rotation, includeSecrets: canReadSecret && (isResourceTypeFiltered ? filter.include.secret : true),
includeSecretRotations:
canReadSecretRotations && (isResourceTypeFiltered ? filter.include.rotation : true),
tags: filter.tags tags: filter.tags
}); });
@@ -769,6 +772,19 @@ const Page = () => {
isPITEnabled={isPITEnabled} isPITEnabled={isPITEnabled}
hasPathPolicies={hasPathPolicies} hasPathPolicies={hasPathPolicies}
onRequestAccess={(params) => handlePopUpOpen("requestAccess", params)} onRequestAccess={(params) => handlePopUpOpen("requestAccess", params)}
onClearFilters={() =>
setFilter((prev) => ({
...prev,
tags: {},
include: {
secret: false,
import: false,
dynamic: false,
rotation: false,
folder: false
}
}))
}
/> />
<div <div
className={twMerge( className={twMerge(
@@ -136,6 +136,7 @@ type Props = {
isPITEnabled: boolean; isPITEnabled: boolean;
onRequestAccess: (actions: ProjectPermissionActions[]) => void; onRequestAccess: (actions: ProjectPermissionActions[]) => void;
hasPathPolicies: boolean; hasPathPolicies: boolean;
onClearFilters: () => void;
}; };
export const ActionBar = ({ export const ActionBar = ({
@@ -159,7 +160,8 @@ export const ActionBar = ({
isPITEnabled = false, isPITEnabled = false,
usedBySecretSyncs, usedBySecretSyncs,
onRequestAccess, onRequestAccess,
hasPathPolicies hasPathPolicies,
onClearFilters
}: Props) => { }: Props) => {
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([ const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
"addFolder", "addFolder",
@@ -661,6 +663,9 @@ export const ActionBar = ({
} }
}; };
const isTableFiltered =
Object.values(filter.tags).some(Boolean) || Object.values(filter.include).some(Boolean);
return ( return (
<> <>
<div className="mt-4 flex items-center space-x-2"> <div className="mt-4 flex items-center space-x-2">
@@ -676,18 +681,22 @@ export const ActionBar = ({
<div> <div>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<IconButton <Button
size="sm"
variant="outline_bg" variant="outline_bg"
ariaLabel="Download"
className={twMerge( className={twMerge(
"transition-all", "flex h-[2.5rem]",
(Object.keys(filter.tags).length || isTableFiltered && "border-primary/40 bg-primary/10"
Object.values(filter.include).filter((include) => !include).length) &&
"border-primary/50 text-primary"
)} )}
leftIcon={
<FontAwesomeIcon
icon={faFilter}
className={isTableFiltered ? "text-primary/80" : undefined}
/>
}
> >
<FontAwesomeIcon icon={faFilter} /> Filters
</IconButton> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="p-0"> <DropdownMenuContent align="end" className="p-0">
<DropdownMenuGroup>Filter By</DropdownMenuGroup> <DropdownMenuGroup>Filter By</DropdownMenuGroup>
@@ -762,51 +771,62 @@ export const ActionBar = ({
<span>Secrets</span> <span>Secrets</span>
</div> </div>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownSubMenu> {Boolean(tags.length) && (
<DropdownSubMenuTrigger <DropdownSubMenu>
iconPos="right" <DropdownSubMenuTrigger
icon={<FontAwesomeIcon icon={faChevronRight} size="sm" />} iconPos="right"
> icon={<FontAwesomeIcon icon={faChevronRight} size="sm" />}
Tags >
</DropdownSubMenuTrigger> Tags
<DropdownSubMenuContent className="thin-scrollbar max-h-[20rem] overflow-y-auto rounded-l-none"> </DropdownSubMenuTrigger>
<DropdownMenuLabel className="sticky top-0 bg-mineshaft-900"> <DropdownSubMenuContent className="thin-scrollbar max-h-[20rem] overflow-y-auto rounded-l-none">
Apply Tags to Filter Secrets <DropdownMenuLabel className="sticky top-0 bg-mineshaft-900">
</DropdownMenuLabel> Apply Tags to Filter Secrets
{tags.map(({ id, slug, color }) => ( </DropdownMenuLabel>
<DropdownMenuItem {tags.map(({ id, slug, color }) => (
onClick={(evt) => { <DropdownMenuItem
evt.preventDefault(); onClick={(evt) => {
onToggleTagFilter(slug); evt.preventDefault();
}} onToggleTagFilter(slug);
key={id} }}
icon={filter?.tags[slug] && <FontAwesomeIcon icon={faCheckCircle} />} key={id}
iconPos="right" icon={filter?.tags[slug] && <FontAwesomeIcon icon={faCheckCircle} />}
> iconPos="right"
<div className="flex items-center"> >
<div <div className="flex items-center">
className="mr-2 h-2 w-2 rounded-full" <div
style={{ background: color || "#bec2c8" }} className="mr-2 h-2 w-2 rounded-full"
/> style={{ background: color || "#bec2c8" }}
{slug} />
</div> {slug}
</DropdownMenuItem> </div>
))} </DropdownMenuItem>
</DropdownSubMenuContent> ))}
</DropdownSubMenu> </DropdownSubMenuContent>
</DropdownSubMenu>
)}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
{isTableFiltered && (
<Button variant="plain" colorSchema="secondary" onClick={onClearFilters}>
Clear Filters
</Button>
)}
<div className="flex-grow" />
<div> <div>
{isProtectedBranch && ( {isProtectedBranch && (
<Tooltip content={`Protected by policy ${protectedBranchPolicyName}`}> <Tooltip content={`Protected by policy ${protectedBranchPolicyName}`}>
<IconButton variant="outline_bg" ariaLabel="protected"> <IconButton
variant="outline_bg"
className="border-primary/40 bg-primary/10"
ariaLabel="protected"
>
<FontAwesomeIcon icon={faLock} className="text-primary" /> <FontAwesomeIcon icon={faLock} className="text-primary" />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
)} )}
</div> </div>
<div className="flex-grow" />
<div> <div>
<IconButton variant="outline_bg" ariaLabel="Download" onClick={handleSecretDownload}> <IconButton variant="outline_bg" ariaLabel="Download" onClick={handleSecretDownload}>
<FontAwesomeIcon icon={faDownload} /> <FontAwesomeIcon icon={faDownload} />