diff --git a/frontend/src/pages/project/[id]/secrets/overview.tsx b/frontend/src/pages/project/[id]/secrets/overview.tsx index 69e2bc196..eaf915120 100644 --- a/frontend/src/pages/project/[id]/secrets/overview.tsx +++ b/frontend/src/pages/project/[id]/secrets/overview.tsx @@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next"; import Head from "next/head"; import { SecretOverviewPage } from "@app/views/SecretOverviewPage"; +import { StoreProvider } from "@app/views/SecretOverviewPage/SecretOverviewPage.store"; const Dashboard = () => { const { t } = useTranslation(); @@ -16,7 +17,9 @@ const Dashboard = () => {
- + + +
); diff --git a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.store.tsx b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.store.tsx new file mode 100644 index 000000000..df239d6ae --- /dev/null +++ b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.store.tsx @@ -0,0 +1,78 @@ +import { createContext, ReactNode, useContext, useEffect, useRef } from "react"; +import { useRouter } from "next/router"; +import { createStore, StateCreator, StoreApi, useStore } from "zustand"; + +export enum EntryType { + FOLDER = "folder", + SECRET = "secret" +} + +type SelectedEntriesState = { + selectedEntries: { + [EntryType.FOLDER]: Record; + [EntryType.SECRET]: Record; + }; + action: { + toggle: (type: EntryType, key: string) => void; + reset: () => void; + }; +}; + +const createSelectedSecretStore: StateCreator = (set) => ({ + selectedEntries: { + [EntryType.FOLDER]: {}, + [EntryType.SECRET]: {} + }, + action: { + toggle: (type: EntryType, key: string) => + set((state) => { + const isChecked = Boolean(state.selectedEntries[type]?.[key]); + const newChecks = { ...state.selectedEntries }; + // remove selection if its present else add it + if (isChecked) delete newChecks[type][key]; + else newChecks[type][key] = true; + return { selectedEntries: newChecks }; + }), + reset: () => + set({ + selectedEntries: { + [EntryType.FOLDER]: {}, + [EntryType.SECRET]: {} + } + }) + } +}); + +const StoreContext = createContext | null>(null); +export const StoreProvider = ({ children }: { children: ReactNode }) => { + const storeRef = useRef>(); + const router = useRouter(); + if (!storeRef.current) { + storeRef.current = createStore((...a) => ({ + ...createSelectedSecretStore(...a) + })); + } + + useEffect(() => { + const onRouteChangeStart = () => { + const state = storeRef.current?.getState(); + state?.action.reset(); + }; + + router.events.on("routeChangeStart", onRouteChangeStart); + return () => { + router.events.off("routeChangeStart", onRouteChangeStart); + }; + }, []); + + return {children}; +}; + +const useStoreContext = (selector: (state: SelectedEntriesState) => T): T => { + const ctx = useContext(StoreContext); + if (!ctx) throw new Error("Missing "); + return useStore(ctx, selector); +}; + +export const useSelectedEntries = () => useStoreContext((state) => state.selectedEntries); +export const useSelectedEntryActions = () => useStoreContext((state) => state.action); diff --git a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx index 692bdcbe3..c6d97361a 100644 --- a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx +++ b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx @@ -70,6 +70,8 @@ import { ProjectIndexSecretsSection } from "./components/ProjectIndexSecretsSect import { SecretOverviewDynamicSecretRow } from "./components/SecretOverviewDynamicSecretRow"; import { SecretOverviewFolderRow } from "./components/SecretOverviewFolderRow"; import { SecretOverviewTableRow } from "./components/SecretOverviewTableRow"; +import { SelectionPanel } from "./components/SelectionPanel/SelectionPanel"; +import { EntryType, useSelectedEntries, useSelectedEntryActions } from "./SecretOverviewPage.store"; export const SecretOverviewPage = () => { const { t } = useTranslation(); @@ -105,6 +107,9 @@ export const SecretOverviewPage = () => { const [searchFilter, setSearchFilter] = useState(""); const secretPath = (router.query?.secretPath as string) || "/"; + const selectedEntries = useSelectedEntries(); + const { toggle: toggleSelectedEntry } = useSelectedEntryActions(); + useEffect(() => { if (!isWorkspaceLoading && !workspaceId && router.isReady) { router.push(`/org/${currentOrg?.id}/overview`); @@ -543,6 +548,7 @@ export const SecretOverviewPage = () => { +
@@ -666,6 +672,8 @@ export const SecretOverviewPage = () => { toggleSelectedEntry(EntryType.FOLDER, folderName)} environments={visibleEnvs} key={`overview-${folderName}-${index + 1}`} onClick={handleFolderClick} @@ -684,6 +692,8 @@ export const SecretOverviewPage = () => { visibleEnvs?.length > 0 && filteredSecretNames.map((key, index) => ( toggleSelectedEntry(EntryType.SECRET, key)} secretPath={secretPath} isImportedSecretPresentInEnv={isImportedSecretPresentInEnv} onSecretCreate={handleSecretCreate} diff --git a/frontend/src/views/SecretOverviewPage/components/SecretOverviewFolderRow/SecretOverviewFolderRow.tsx b/frontend/src/views/SecretOverviewPage/components/SecretOverviewFolderRow/SecretOverviewFolderRow.tsx index 597541fe7..c60aff912 100644 --- a/frontend/src/views/SecretOverviewPage/components/SecretOverviewFolderRow/SecretOverviewFolderRow.tsx +++ b/frontend/src/views/SecretOverviewPage/components/SecretOverviewFolderRow/SecretOverviewFolderRow.tsx @@ -2,20 +2,23 @@ import { faCheck, faFolder, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; -import { Td, Tr } from "@app/components/v2"; +import { Checkbox, Td, Tr } from "@app/components/v2"; type Props = { folderName: string; environments: { name: string; slug: string }[]; isFolderPresentInEnv: (name: string, env: string) => boolean; onClick: (path: string) => void; + isSelected: boolean; + onToggleFolderSelect: (folderName: string) => void; }; export const SecretOverviewFolderRow = ({ folderName, environments = [], isFolderPresentInEnv, - + isSelected, + onToggleFolderSelect, onClick }: Props) => { return ( @@ -23,7 +26,21 @@ export const SecretOverviewFolderRow = ({
- + { + onToggleFolderSelect(folderName); + }} + onClick={(e) => { + e.stopPropagation(); + }} + className={twMerge("hidden group-hover:flex", isSelected && "flex")} + /> +
{folderName}
diff --git a/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx b/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx index 076ff22c8..f1476c167 100644 --- a/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx +++ b/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx @@ -11,7 +11,7 @@ import { import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; -import { Button, TableContainer, Td, Tooltip, Tr } from "@app/components/v2"; +import { Button, Checkbox, TableContainer, Td, Tooltip, Tr } from "@app/components/v2"; import { useToggle } from "@app/hooks"; import { DecryptedSecret } from "@app/hooks/api/secrets/types"; @@ -23,6 +23,8 @@ type Props = { secretPath: string; environments: { name: string; slug: string }[]; expandableColWidth: number; + isSelected: boolean; + onToggleSecretSelect: (key: string) => void; getSecretByKey: (slug: string, key: string) => DecryptedSecret | undefined; onSecretCreate: (env: string, key: string, value: string) => Promise; onSecretUpdate: (env: string, key: string, value: string, secretId?: string) => Promise; @@ -39,7 +41,9 @@ export const SecretOverviewTableRow = ({ onSecretCreate, onSecretDelete, isImportedSecretPresentInEnv, - expandableColWidth + expandableColWidth, + onToggleSecretSelect, + isSelected }: Props) => { const [isFormExpanded, setIsFormExpanded] = useToggle(); const totalCols = environments.length + 1; // secret key row @@ -56,7 +60,21 @@ export const SecretOverviewTableRow = ({
- + { + onToggleSecretSelect(secretKey); + }} + onClick={(e) => { + e.stopPropagation(); + }} + className={twMerge("hidden group-hover:flex", isSelected && "flex")} + /> +
{secretKey}
diff --git a/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx b/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx new file mode 100644 index 000000000..6b95ffccf --- /dev/null +++ b/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx @@ -0,0 +1,78 @@ +import { subject } from "@casl/ability"; +import { faMinusSquare, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { Button, DeleteActionModal, IconButton, Tooltip } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context"; +import { usePopUp } from "@app/hooks"; + +import { useSelectedEntries, useSelectedEntryActions } from "../../SecretOverviewPage.store"; + +type Props = { + secretPath: string; +}; + +export const SelectionPanel = ({ secretPath }: Props) => { + const { permission } = useProjectPermission(); + + const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([ + "bulkDeleteEntries" + ] as const); + + const selectedEntries = useSelectedEntries(); + const { reset: resetSelectedEntries } = useSelectedEntryActions(); + const selectedCount = + Object.keys(selectedEntries.folder).length + Object.keys(selectedEntries.secret).length; + + const isMultiSelectActive = selectedCount > 0; + + // TODO: REVISIT RBAC + const shouldShowDelete = permission.can( + ProjectPermissionActions.Delete, + subject(ProjectPermissionSub.Secrets, { environment: "", secretPath }) + ); + + const handleBulkDelete = async () => { + handlePopUpClose("bulkDeleteEntries"); + }; + + return ( + <> +
+
+ + + + + +
{selectedCount} Selected
+ {shouldShowDelete && ( + + )} +
+
+ handlePopUpToggle("bulkDeleteEntries", isOpen)} + onDeleteApproved={handleBulkDelete} + /> + + ); +};