feature: clear filters when navigating down and restore filters when navigating up folders in secrets dashboard

This commit is contained in:
Scott Wilson
2024-10-01 09:26:25 -07:00
parent acde0867a0
commit fbf52850e8
13 changed files with 122 additions and 52 deletions

View File

@@ -77,7 +77,7 @@ export const InfisicalSecretInput = forwardRef<HTMLTextAreaElement, Props>(
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const workspaceId = currentWorkspace?.id || ""; const workspaceId = currentWorkspace?.id || "";
const debouncedValue = useDebounce(value, 500); const [debouncedValue] = useDebounce(value, 500);
const [highlightedIndex, setHighlightedIndex] = useState(-1); const [highlightedIndex, setHighlightedIndex] = useState(-1);

View File

@@ -33,7 +33,7 @@ export const SecretPathInput = ({
const [suggestions, setSuggestions] = useState<string[]>([]); const [suggestions, setSuggestions] = useState<string[]>([]);
const [isInputFocused, setIsInputFocus] = useState(false); const [isInputFocused, setIsInputFocus] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(-1); const [highlightedIndex, setHighlightedIndex] = useState(-1);
const debouncedInputValue = useDebounce(inputValue, 200); const [debouncedInputValue] = useDebounce(inputValue, 200);
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const workspaceId = currentWorkspace?.id || ""; const workspaceId = currentWorkspace?.id || "";

View File

@@ -1,7 +1,10 @@
import { useEffect, useState } from "react"; import { Dispatch, SetStateAction, useEffect, useState } from "react";
// Ref: https://usehooks.com/useDebounce/ // Ref: https://usehooks.com/useDebounce/
export const useDebounce = <T extends unknown>(value: T, delay = 500): T => { export const useDebounce = <T extends unknown>(
value: T,
delay = 500
): [T, Dispatch<SetStateAction<T>>] => {
// State and setters for debounced value // State and setters for debounced value
const [debouncedValue, setDebouncedValue] = useState(value); const [debouncedValue, setDebouncedValue] = useState(value);
@@ -22,5 +25,5 @@ export const useDebounce = <T extends unknown>(value: T, delay = 500): T => {
[value, delay] // Only re-call effect if value or delay changes [value, delay] // Only re-call effect if value or delay changes
); );
return debouncedValue; return [debouncedValue, setDebouncedValue];
}; };

View File

@@ -60,7 +60,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
const [orderDirection, setOrderDirection] = useState(OrderByDirection.ASC); const [orderDirection, setOrderDirection] = useState(OrderByDirection.ASC);
const [orderBy, setOrderBy] = useState(OrgIdentityOrderBy.Name); const [orderBy, setOrderBy] = useState(OrgIdentityOrderBy.Name);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search); const [debouncedSearch] = useDebounce(search);
const organizationId = currentOrg?.id || ""; const organizationId = currentOrg?.id || "";

View File

@@ -37,7 +37,7 @@ export const OrgAdminProjects = withPermission(
() => { () => {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search); const [debouncedSearch] = useDebounce(search);
const [perPage, setPerPage] = useState(25); const [perPage, setPerPage] = useState(25);
const router = useRouter(); const router = useRouter();
const orgAdminAccessProject = useOrgAdminAccessProject(); const orgAdminAccessProject = useOrgAdminAccessProject();

View File

@@ -72,7 +72,7 @@ export const IdentityTab = withProjectPermission(
const [orderDirection, setOrderDirection] = useState(OrderByDirection.ASC); const [orderDirection, setOrderDirection] = useState(OrderByDirection.ASC);
const [orderBy, setOrderBy] = useState(ProjectIdentityOrderBy.Name); const [orderBy, setOrderBy] = useState(ProjectIdentityOrderBy.Name);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search); const [debouncedSearch] = useDebounce(search);
const workspaceId = currentWorkspace?.id ?? ""; const workspaceId = currentWorkspace?.id ?? "";

View File

@@ -79,7 +79,7 @@ export const SecretMainPage = () => {
ProjectPermissionSub.SecretRollback ProjectPermissionSub.SecretRollback
); );
const [filter, setFilter] = useState<Filter>({ const defaultFilterState = {
tags: {}, tags: {},
searchFilter: (router.query.searchFilter as string) || "", searchFilter: (router.query.searchFilter as string) || "",
include: { include: {
@@ -88,8 +88,11 @@ export const SecretMainPage = () => {
[RowType.DynamicSecret]: canReadSecret, [RowType.DynamicSecret]: canReadSecret,
[RowType.Secret]: canReadSecret [RowType.Secret]: canReadSecret
} }
}); };
const debouncedSearchFilter = useDebounce(filter.searchFilter);
const [filter, setFilter] = useState<Filter>(defaultFilterState);
const [debouncedSearchFilter, setDebouncedSearchFilter] = useDebounce(filter.searchFilter);
const [filterHistory, setFilterHistory] = useState<Map<string, Filter>>(new Map());
// change filters if permissions change at different paths/env // change filters if permissions change at different paths/env
useEffect(() => { useEffect(() => {
@@ -255,10 +258,39 @@ export const SecretMainPage = () => {
if (totalCount < paginationOffset) setPage(1); if (totalCount < paginationOffset) setPage(1);
}, [totalCount]); }, [totalCount]);
useEffect(() => {
// restore filters for path if set
const restore = filterHistory.get(secretPath);
setFilter(restore ?? defaultFilterState);
setDebouncedSearchFilter(restore?.searchFilter ?? "");
const { searchFilter, ...query } = router.query;
// this is a temp work around until we fully transition state to query params,
// setting the initial search filter by query and then moving it to internal state
if (router.query.searchFilter) {
router.push({
pathname: router.pathname,
query
});
}
}, [secretPath]);
if (isDetailsLoading) { if (isDetailsLoading) {
return <ContentLoader text={LOADER_TEXT} />; return <ContentLoader text={LOADER_TEXT} />;
} }
const handleResetFilter = () => {
// store for breadcrumb nav to restore previously used filters
setFilterHistory((prev) => {
const curr = new Map(prev);
curr.set(secretPath, filter);
return curr;
});
setFilter(defaultFilterState);
setDebouncedSearchFilter("");
};
return ( return (
<StoreProvider> <StoreProvider>
<div className="container mx-auto flex flex-col px-6 text-mineshaft-50 dark:[color-scheme:dark]"> <div className="container mx-auto flex flex-col px-6 text-mineshaft-50 dark:[color-scheme:dark]">
@@ -339,6 +371,7 @@ export const SecretMainPage = () => {
environment={environment} environment={environment}
workspaceId={workspaceId} workspaceId={workspaceId}
secretPath={secretPath} secretPath={secretPath}
onNavigateToFolder={handleResetFilter}
/> />
)} )}
{canReadSecret && dynamicSecrets?.length && ( {canReadSecret && dynamicSecrets?.length && (

View File

@@ -1,4 +1,3 @@
import { useState } from "react";
import { TypeOptions } from "react-toastify"; import { TypeOptions } from "react-toastify";
import { subject } from "@casl/ability"; import { subject } from "@casl/ability";
import { import {
@@ -56,7 +55,6 @@ import { usePopUp } from "@app/hooks";
import { useCreateFolder, useDeleteSecretBatch, useMoveSecrets } from "@app/hooks/api"; import { useCreateFolder, useDeleteSecretBatch, useMoveSecrets } from "@app/hooks/api";
import { fetchProjectSecrets } from "@app/hooks/api/secrets/queries"; import { fetchProjectSecrets } from "@app/hooks/api/secrets/queries";
import { SecretType, SecretV3RawSanitized, WsTag } from "@app/hooks/api/types"; import { SecretType, SecretV3RawSanitized, WsTag } from "@app/hooks/api/types";
import { debounce } from "@app/lib/fn/debounce";
import { import {
PopUpNames, PopUpNames,
@@ -118,7 +116,6 @@ export const ActionBar = ({
] as const); ] as const);
const { subscription } = useSubscription(); const { subscription } = useSubscription();
const { openPopUp } = usePopUpAction(); const { openPopUp } = usePopUpAction();
const [search, setSearch] = useState(filter.searchFilter);
const { mutateAsync: createFolder } = useCreateFolder(); const { mutateAsync: createFolder } = useCreateFolder();
const { mutateAsync: deleteBatchSecretV3 } = useDeleteSecretBatch(); const { mutateAsync: deleteBatchSecretV3 } = useDeleteSecretBatch();
@@ -134,8 +131,6 @@ export const ActionBar = ({
(rule.subject as ProjectPermissionSub[]).includes(ProjectPermissionSub.SecretFolders) (rule.subject as ProjectPermissionSub[]).includes(ProjectPermissionSub.SecretFolders)
); );
const debouncedOnSearch = debounce(onSearchChange, 500);
const handleFolderCreate = async (folderName: string) => { const handleFolderCreate = async (folderName: string) => {
try { try {
await createFolder({ await createFolder({
@@ -286,11 +281,15 @@ export const ActionBar = ({
<Input <Input
className="bg-mineshaft-800 placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80" className="bg-mineshaft-800 placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80"
placeholder="Search by folder name, key name, comment..." placeholder="Search by folder name, key name, comment..."
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />} leftIcon={
value={search} <FontAwesomeIcon
className={filter.searchFilter ? "text-primary" : ""}
icon={faMagnifyingGlass}
/>
}
value={filter.searchFilter}
onChange={(evt) => { onChange={(evt) => {
setSearch(evt.target.value); onSearchChange(evt.target.value);
debouncedOnSearch(evt.target.value);
}} }}
/> />
</div> </div>

View File

@@ -18,13 +18,15 @@ type Props = {
environment: string; environment: string;
workspaceId: string; workspaceId: string;
secretPath?: string; secretPath?: string;
onNavigateToFolder: (path: string) => void;
}; };
export const FolderListView = ({ export const FolderListView = ({
folders = [], folders = [],
environment, environment,
workspaceId, workspaceId,
secretPath = "/" secretPath = "/",
onNavigateToFolder
}: Props) => { }: Props) => {
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([ const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([
"updateFolder", "updateFolder",
@@ -88,13 +90,16 @@ export const FolderListView = ({
}; };
const handleFolderClick = (name: string) => { const handleFolderClick = (name: string) => {
router.push({ const path = `${router.query?.secretPath || ""}/${name}`;
pathname: router.pathname, router
query: { .push({
...router.query, pathname: router.pathname,
secretPath: `${router.query?.secretPath || ""}/${name}` query: {
} ...router.query,
}); secretPath: path
}
})
.then(() => onNavigateToFolder(path));
}; };
return ( return (

View File

@@ -84,7 +84,7 @@ export const CopySecretsFromBoard = ({
const envCopySecPath = watch("secretPath"); const envCopySecPath = watch("secretPath");
const selectedEnvSlug = watch("environment"); const selectedEnvSlug = watch("environment");
const debouncedEnvCopySecretPath = useDebounce(envCopySecPath); const [debouncedEnvCopySecretPath] = useDebounce(envCopySecPath);
const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({ const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({
workspaceId, workspaceId,

View File

@@ -97,11 +97,16 @@ type Filter = {
}; };
const INIT_PER_PAGE = 20; const INIT_PER_PAGE = 20;
const DEFAULT_FILTER_STATE = {
[RowType.Folder]: true,
[RowType.DynamicSecret]: true,
[RowType.Secret]: true
};
export const SecretOverviewPage = () => { export const SecretOverviewPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
// this is to set expandable table width // this is to set expandable table width
// coz when overflow the table goes to the right // coz when overflow the table goes to the right
const parentTableRef = useRef<HTMLTableElement>(null); const parentTableRef = useRef<HTMLTableElement>(null);
@@ -121,14 +126,13 @@ export const SecretOverviewPage = () => {
const workspaceId = currentWorkspace?.id as string; const workspaceId = currentWorkspace?.id as string;
const projectSlug = currentWorkspace?.slug as string; const projectSlug = currentWorkspace?.slug as string;
const [searchFilter, setSearchFilter] = useState(""); const [searchFilter, setSearchFilter] = useState("");
const debouncedSearchFilter = useDebounce(searchFilter); const [debouncedSearchFilter, setDebouncedSearchFilter] = useDebounce(searchFilter);
const secretPath = (router.query?.secretPath as string) || "/"; const secretPath = (router.query?.secretPath as string) || "/";
const [filter, setFilter] = useState<Filter>({ const [filter, setFilter] = useState<Filter>(DEFAULT_FILTER_STATE);
[RowType.Folder]: true, const [filterHistory, setFilterHistory] = useState<
[RowType.DynamicSecret]: true, Map<string, { filter: Filter; searchFilter: string }>
[RowType.Secret]: true >(new Map());
});
const [selectedEntries, setSelectedEntries] = useState<{ const [selectedEntries, setSelectedEntries] = useState<{
[EntryType.FOLDER]: Record<string, boolean>; [EntryType.FOLDER]: Record<string, boolean>;
@@ -195,7 +199,7 @@ export const SecretOverviewPage = () => {
useEffect(() => { useEffect(() => {
setVisibleEnvs(userAvailableEnvs); setVisibleEnvs(userAvailableEnvs);
}, [userAvailableEnvs, secretPath]); }, [userAvailableEnvs]);
const { isImportedSecretPresentInEnv, getImportedSecretByKey, getEnvImportedSecretKeyCount } = const { isImportedSecretPresentInEnv, getImportedSecretByKey, getEnvImportedSecretKeyCount } =
useGetImportedSecretsAllEnvs({ useGetImportedSecretsAllEnvs({
@@ -456,16 +460,35 @@ export const SecretOverviewPage = () => {
} }
}; };
const handleResetSearch = () => setSearchFilter(""); const handleResetSearch = (path: string) => {
const restore = filterHistory.get(path);
setFilter(restore?.filter ?? DEFAULT_FILTER_STATE);
const search = restore?.searchFilter ?? "";
setSearchFilter(search);
setDebouncedSearchFilter(search);
};
const handleFolderClick = (path: string) => { const handleFolderClick = (path: string) => {
router.push({ // store for breadcrumb nav to restore previously used filters
pathname: router.pathname, setFilterHistory((prev) => {
query: { const curr = new Map(prev);
...router.query, curr.set(secretPath, { filter, searchFilter });
secretPath: `${router.query?.secretPath || ""}/${path}` return curr;
}
}); });
router
.push({
pathname: router.pathname,
query: {
...router.query,
secretPath: `${router.query?.secretPath || ""}/${path}`
}
})
.then(() => {
setFilter(DEFAULT_FILTER_STATE);
setSearchFilter("");
setDebouncedSearchFilter("");
});
}; };
const handleExploreEnvClick = async (slug: string) => { const handleExploreEnvClick = async (slug: string) => {
@@ -544,7 +567,9 @@ export const SecretOverviewPage = () => {
const isTableEmpty = totalCount === 0; const isTableEmpty = totalCount === 0;
const isTableFiltered = Boolean(Object.values(filter).filter((enabled) => !enabled).length); const isTableFiltered =
Boolean(Object.values(filter).filter((enabled) => !enabled).length) ||
userAvailableEnvs.length !== visibleEnvs.length;
if (!isProjectV3) if (!isProjectV3)
return ( return (
@@ -706,7 +731,12 @@ export const SecretOverviewPage = () => {
placeholder="Search by secret/folder name..." placeholder="Search by secret/folder name..."
value={searchFilter} value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)} onChange={(e) => setSearchFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />} leftIcon={
<FontAwesomeIcon
icon={faMagnifyingGlass}
className={searchFilter ? "text-primary" : ""}
/>
}
/> />
</div> </div>
{userAvailableEnvs.length > 0 && ( {userAvailableEnvs.length > 0 && (

View File

@@ -4,16 +4,16 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
type Props = { type Props = {
secretPath: string; secretPath: string;
onResetSearch: () => void; onResetSearch: (path: string) => void;
}; };
export const FolderBreadCrumbs = ({ secretPath = "/", onResetSearch }: Props) => { export const FolderBreadCrumbs = ({ secretPath = "/", onResetSearch }: Props) => {
const router = useRouter(); const router = useRouter();
const onFolderCrumbClick = (index: number) => { const onFolderCrumbClick = (index: number) => {
const newSecPath = secretPath.split("/").filter(Boolean).slice(0, index).join("/"); const newSecPath = `/${secretPath.split("/").filter(Boolean).slice(0, index).join("/")}`;
if (secretPath === `/${newSecPath}`) return; if (secretPath === newSecPath) return;
const query = { ...router.query, secretPath: `/${newSecPath}` } as Record<string, string>; const query = { ...router.query, secretPath: newSecPath } as Record<string, string>;
// root condition // root condition
if (index === 0) delete query.secretPath; if (index === 0) delete query.secretPath;
router router
@@ -21,7 +21,7 @@ export const FolderBreadCrumbs = ({ secretPath = "/", onResetSearch }: Props) =>
pathname: router.pathname, pathname: router.pathname,
query query
}) })
.then(() => onResetSearch()); .then(() => onResetSearch(newSecPath));
}; };
return ( return (

View File

@@ -38,7 +38,7 @@ const UserPanelTable = ({
const [searchUserFilter, setSearchUserFilter] = useState(""); const [searchUserFilter, setSearchUserFilter] = useState("");
const { user } = useUser(); const { user } = useUser();
const userId = user?.id || ""; const userId = user?.id || "";
const debounedSearchTerm = useDebounce(searchUserFilter, 500); const [debounedSearchTerm] = useDebounce(searchUserFilter, 500);
const { subscription } = useSubscription(); const { subscription } = useSubscription();
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } = useAdminGetUsers({ const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } = useAdminGetUsers({