From a3552d00d1e11d93918ad151fb3310a78c294083 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 7 May 2024 13:52:42 +0800 Subject: [PATCH 1/7] feat: add multi-select in secret overview --- .../pages/project/[id]/secrets/overview.tsx | 5 +- .../SecretOverviewPage.store.tsx | 78 +++++++++++++++++++ .../SecretOverviewPage/SecretOverviewPage.tsx | 10 +++ .../SecretOverviewFolderRow.tsx | 23 +++++- .../SecretOverviewTableRow.tsx | 24 +++++- .../SelectionPanel/SelectionPanel.tsx | 78 +++++++++++++++++++ 6 files changed, 211 insertions(+), 7 deletions(-) create mode 100644 frontend/src/views/SecretOverviewPage/SecretOverviewPage.store.tsx create mode 100644 frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx 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} + /> + + ); +}; From a9b72b2da391003c18cdd2db230a02e49bc41b8b Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 7 May 2024 15:16:37 +0800 Subject: [PATCH 2/7] feat: added handling of folder/secret deletion --- .../src/hooks/api/secretFolders/queries.tsx | 16 +++- .../SecretOverviewPage/SecretOverviewPage.tsx | 9 ++- .../SelectionPanel/SelectionPanel.tsx | 78 ++++++++++++++++++- 3 files changed, 97 insertions(+), 6 deletions(-) diff --git a/frontend/src/hooks/api/secretFolders/queries.tsx b/frontend/src/hooks/api/secretFolders/queries.tsx index bcda2b0a4..71c63f3eb 100644 --- a/frontend/src/hooks/api/secretFolders/queries.tsx +++ b/frontend/src/hooks/api/secretFolders/queries.tsx @@ -94,7 +94,21 @@ export const useGetFoldersByEnv = ({ [(folders || []).map((folder) => folder.data)] ); - return { folders, folderNames, isFolderPresentInEnv }; + const getFolderByNameAndEnv = useCallback( + (name: string, env: string) => { + const selectedEnvIndex = environments.indexOf(env); + if (selectedEnvIndex !== -1) { + return folders?.[selectedEnvIndex]?.data?.find( + ({ name: folderName }) => folderName === name + ); + } + + return undefined; + }, + [(folders || []).map((folder) => folder.data)] + ); + + return { folders, folderNames, isFolderPresentInEnv, getFolderByNameAndEnv }; }; export const useCreateFolder = () => { diff --git a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx index c6d97361a..cd00af883 100644 --- a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx +++ b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx @@ -134,7 +134,8 @@ export const SecretOverviewPage = () => { secretPath, decryptFileKey: latestFileKey! }); - const { folders, folderNames, isFolderPresentInEnv } = useGetFoldersByEnv({ + + const { folders, folderNames, isFolderPresentInEnv, getFolderByNameAndEnv } = useGetFoldersByEnv({ projectId: workspaceId, path: secretPath, environments: userAvailableEnvs.map(({ slug }) => slug) @@ -548,7 +549,11 @@ export const SecretOverviewPage = () => {
- +
diff --git a/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx b/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx index 6b95ffccf..f68e804bf 100644 --- a/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx +++ b/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx @@ -3,17 +3,27 @@ import { faMinusSquare, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; +import { createNotification } from "@app/components/notifications"; import { Button, DeleteActionModal, IconButton, Tooltip } from "@app/components/v2"; -import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useProjectPermission, + useWorkspace +} from "@app/context"; import { usePopUp } from "@app/hooks"; +import { useDeleteFolder, useDeleteSecretBatch } from "@app/hooks/api"; +import { DecryptedSecret, TDeleteSecretBatchDTO, TSecretFolder } from "@app/hooks/api/types"; import { useSelectedEntries, useSelectedEntryActions } from "../../SecretOverviewPage.store"; type Props = { secretPath: string; + getSecretByKey: (slug: string, key: string) => DecryptedSecret | undefined; + getFolderByNameAndEnv: (name: string, env: string) => TSecretFolder | undefined; }; -export const SelectionPanel = ({ secretPath }: Props) => { +export const SelectionPanel = ({ getFolderByNameAndEnv, getSecretByKey, secretPath }: Props) => { const { permission } = useProjectPermission(); const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([ @@ -25,6 +35,12 @@ export const SelectionPanel = ({ secretPath }: Props) => { const selectedCount = Object.keys(selectedEntries.folder).length + Object.keys(selectedEntries.secret).length; + const { currentWorkspace } = useWorkspace(); + const workspaceId = currentWorkspace?.id || ""; + const userAvailableEnvs = currentWorkspace?.environments || []; + const { mutateAsync: deleteBatchSecretV3 } = useDeleteSecretBatch(); + const { mutateAsync: deleteFolder } = useDeleteFolder(); + const isMultiSelectActive = selectedCount > 0; // TODO: REVISIT RBAC @@ -34,7 +50,63 @@ export const SelectionPanel = ({ secretPath }: Props) => { ); const handleBulkDelete = async () => { - handlePopUpClose("bulkDeleteEntries"); + const promises = userAvailableEnvs.map(async (env) => { + await Promise.all( + Object.keys(selectedEntries.folder).map(async (folderName) => { + const folder = getFolderByNameAndEnv(folderName, env.slug); + if (folder) { + await deleteFolder({ + folderId: folder?.id, + path: secretPath, + environment: env.slug, + projectId: workspaceId + }); + } + }) + ); + + const secretsToDelete = Object.keys(selectedEntries.secret).reduce( + (accum: TDeleteSecretBatchDTO["secrets"], secretName) => { + const entry = getSecretByKey(env.slug, secretName); + if (entry) { + return [ + ...accum, + { + secretName: entry.key, + type: "shared" as "shared" + } + ]; + } + return accum; + }, + [] + ); + + if (secretsToDelete.length > 0) { + await deleteBatchSecretV3({ + secretPath, + workspaceId, + environment: env.slug, + secrets: secretsToDelete + }); + } + }); + + const results = await Promise.allSettled(promises); + const areEntriesDeleted = results.some((result) => result.status === "fulfilled"); + if (areEntriesDeleted) { + handlePopUpClose("bulkDeleteEntries"); + resetSelectedEntries(); + createNotification({ + type: "success", + text: "Successfully deleted selected secrets and folders" + }); + } else { + createNotification({ + type: "error", + text: "Failed to delete selected secrets and folders" + }); + } }; return ( From 536f51f6bacba9808e4e8e401bff6f2ffe0dce3d Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 7 May 2024 15:21:17 +0800 Subject: [PATCH 3/7] misc: added descriptive error message --- .../src/views/SecretOverviewPage/SecretOverviewPage.store.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.store.tsx b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.store.tsx index df239d6ae..db544c7e8 100644 --- a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.store.tsx +++ b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.store.tsx @@ -70,7 +70,7 @@ export const StoreProvider = ({ children }: { children: ReactNode }) => { const useStoreContext = (selector: (state: SelectedEntriesState) => T): T => { const ctx = useContext(StoreContext); - if (!ctx) throw new Error("Missing "); + if (!ctx) throw new Error("Missing context provider"); return useStore(ctx, selector); }; From b6a73459a8cfd1ecd74c639f9da28d2837f20d48 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 7 May 2024 16:37:10 +0800 Subject: [PATCH 4/7] misc: addressed rbac for bulk delete in overview --- .../SelectionPanel/SelectionPanel.tsx | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx b/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx index f68e804bf..03d2d44be 100644 --- a/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx +++ b/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx @@ -43,14 +43,26 @@ export const SelectionPanel = ({ getFolderByNameAndEnv, getSecretByKey, secretPa const isMultiSelectActive = selectedCount > 0; - // TODO: REVISIT RBAC - const shouldShowDelete = permission.can( - ProjectPermissionActions.Delete, - subject(ProjectPermissionSub.Secrets, { environment: "", secretPath }) + // user should have the ability to delete secrets/folders in at least one of the envs + const shouldShowDelete = userAvailableEnvs.some((env) => + permission.can( + ProjectPermissionActions.Delete, + subject(ProjectPermissionSub.Secrets, { environment: env.slug, secretPath }) + ) ); const handleBulkDelete = async () => { const promises = userAvailableEnvs.map(async (env) => { + // additional check: ensure that bulk delete is only executed on envs that user has access to + if ( + permission.cannot( + ProjectPermissionActions.Delete, + subject(ProjectPermissionSub.Secrets, { environment: env.slug, secretPath }) + ) + ) { + return; + } + await Promise.all( Object.keys(selectedEntries.folder).map(async (folderName) => { const folder = getFolderByNameAndEnv(folderName, env.slug); From 06c103c10af0ec6a150ae29303695d5a53e26d49 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 7 May 2024 22:19:20 +0800 Subject: [PATCH 5/7] misc: added handling for no changes made --- .../components/SelectionPanel/SelectionPanel.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx b/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx index 03d2d44be..4f331e989 100644 --- a/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx +++ b/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx @@ -52,6 +52,8 @@ export const SelectionPanel = ({ getFolderByNameAndEnv, getSecretByKey, secretPa ); const handleBulkDelete = async () => { + let processedEntries = 0; + const promises = userAvailableEnvs.map(async (env) => { // additional check: ensure that bulk delete is only executed on envs that user has access to if ( @@ -67,6 +69,7 @@ export const SelectionPanel = ({ getFolderByNameAndEnv, getSecretByKey, secretPa Object.keys(selectedEntries.folder).map(async (folderName) => { const folder = getFolderByNameAndEnv(folderName, env.slug); if (folder) { + processedEntries += 1; await deleteFolder({ folderId: folder?.id, path: secretPath, @@ -95,6 +98,7 @@ export const SelectionPanel = ({ getFolderByNameAndEnv, getSecretByKey, secretPa ); if (secretsToDelete.length > 0) { + processedEntries += secretsToDelete.length; await deleteBatchSecretV3({ secretPath, workspaceId, @@ -106,7 +110,13 @@ export const SelectionPanel = ({ getFolderByNameAndEnv, getSecretByKey, secretPa const results = await Promise.allSettled(promises); const areEntriesDeleted = results.some((result) => result.status === "fulfilled"); - if (areEntriesDeleted) { + if (processedEntries === 0) { + handlePopUpClose("bulkDeleteEntries"); + createNotification({ + type: "info", + text: "No changes have been made. Ensure that you have sufficient access." + }); + } else if (areEntriesDeleted) { handlePopUpClose("bulkDeleteEntries"); resetSelectedEntries(); createNotification({ From c08fcc6f5e5dc35ee4acb8ca68855b16a1175719 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 8 May 2024 00:12:55 +0800 Subject: [PATCH 6/7] adjustment: finalized notification text --- .../components/SelectionPanel/SelectionPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx b/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx index 4f331e989..89fcef58c 100644 --- a/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx +++ b/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx @@ -114,7 +114,7 @@ export const SelectionPanel = ({ getFolderByNameAndEnv, getSecretByKey, secretPa handlePopUpClose("bulkDeleteEntries"); createNotification({ type: "info", - text: "No changes have been made. Ensure that you have sufficient access." + text: "You don't have access to delete selected items" }); } else if (areEntriesDeleted) { handlePopUpClose("bulkDeleteEntries"); From f957b9d970024896a41791269848e40edc5fe6b4 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 8 May 2024 01:03:41 +0800 Subject: [PATCH 7/7] misc: migrated to react-state --- .../pages/project/[id]/secrets/overview.tsx | 5 +- .../SecretOverviewPage.store.tsx | 78 ------------------- .../SecretOverviewPage/SecretOverviewPage.tsx | 70 +++++++++++++---- .../SelectionPanel/SelectionPanel.tsx | 20 ++++- 4 files changed, 74 insertions(+), 99 deletions(-) delete mode 100644 frontend/src/views/SecretOverviewPage/SecretOverviewPage.store.tsx diff --git a/frontend/src/pages/project/[id]/secrets/overview.tsx b/frontend/src/pages/project/[id]/secrets/overview.tsx index eaf915120..69e2bc196 100644 --- a/frontend/src/pages/project/[id]/secrets/overview.tsx +++ b/frontend/src/pages/project/[id]/secrets/overview.tsx @@ -2,7 +2,6 @@ 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(); @@ -17,9 +16,7 @@ const Dashboard = () => {
- - - +
); diff --git a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.store.tsx b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.store.tsx deleted file mode 100644 index db544c7e8..000000000 --- a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.store.tsx +++ /dev/null @@ -1,78 +0,0 @@ -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 context provider"); - 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 cd00af883..fab7ebecf 100644 --- a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx +++ b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import Link from "next/link"; import { useRouter } from "next/router"; @@ -71,7 +71,11 @@ import { SecretOverviewDynamicSecretRow } from "./components/SecretOverviewDynam import { SecretOverviewFolderRow } from "./components/SecretOverviewFolderRow"; import { SecretOverviewTableRow } from "./components/SecretOverviewTableRow"; import { SelectionPanel } from "./components/SelectionPanel/SelectionPanel"; -import { EntryType, useSelectedEntries, useSelectedEntryActions } from "./SecretOverviewPage.store"; + +export enum EntryType { + FOLDER = "folder", + SECRET = "secret" +} export const SecretOverviewPage = () => { const { t } = useTranslation(); @@ -84,15 +88,6 @@ export const SecretOverviewPage = () => { const [expandableTableWidth, setExpandableTableWidth] = useState(0); const [sortDir, setSortDir] = useState<"asc" | "desc">("asc"); - useEffect(() => { - const handleParentTableWidthResize = () => { - setExpandableTableWidth(parentTableRef.current?.clientWidth || 0); - }; - - window.addEventListener("resize", handleParentTableWidthResize); - return () => window.removeEventListener("resize", handleParentTableWidthResize); - }, []); - useEffect(() => { if (parentTableRef.current) { setExpandableTableWidth(parentTableRef.current.clientWidth); @@ -107,8 +102,55 @@ export const SecretOverviewPage = () => { const [searchFilter, setSearchFilter] = useState(""); const secretPath = (router.query?.secretPath as string) || "/"; - const selectedEntries = useSelectedEntries(); - const { toggle: toggleSelectedEntry } = useSelectedEntryActions(); + const [selectedEntries, setSelectedEntries] = useState<{ + [EntryType.FOLDER]: Record; + [EntryType.SECRET]: Record; + }>({ + [EntryType.FOLDER]: {}, + [EntryType.SECRET]: {} + }); + + const toggleSelectedEntry = useCallback( + (type: EntryType, key: string) => { + const isChecked = Boolean(selectedEntries[type]?.[key]); + const newChecks = { ...selectedEntries }; + + // remove selection if its present else add it + if (isChecked) { + delete newChecks[type][key]; + } else { + newChecks[type][key] = true; + } + + setSelectedEntries(newChecks); + }, + [selectedEntries] + ); + + const resetSelectedEntries = useCallback(() => { + setSelectedEntries({ + [EntryType.FOLDER]: {}, + [EntryType.SECRET]: {} + }); + }, []); + + useEffect(() => { + const handleParentTableWidthResize = () => { + setExpandableTableWidth(parentTableRef.current?.clientWidth || 0); + }; + + const onRouteChangeStart = () => { + resetSelectedEntries(); + }; + + router.events.on("routeChangeStart", onRouteChangeStart); + + window.addEventListener("resize", handleParentTableWidthResize); + return () => { + window.removeEventListener("resize", handleParentTableWidthResize); + router.events.off("routeChangeStart", onRouteChangeStart); + }; + }, []); useEffect(() => { if (!isWorkspaceLoading && !workspaceId && router.isReady) { @@ -553,6 +595,8 @@ export const SecretOverviewPage = () => { secretPath={secretPath} getSecretByKey={getSecretByKey} getFolderByNameAndEnv={getFolderByNameAndEnv} + selectedEntries={selectedEntries} + resetSelectedEntries={resetSelectedEntries} />
diff --git a/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx b/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx index 89fcef58c..6a2fe9bf6 100644 --- a/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx +++ b/frontend/src/views/SecretOverviewPage/components/SelectionPanel/SelectionPanel.tsx @@ -15,23 +15,35 @@ import { usePopUp } from "@app/hooks"; import { useDeleteFolder, useDeleteSecretBatch } from "@app/hooks/api"; import { DecryptedSecret, TDeleteSecretBatchDTO, TSecretFolder } from "@app/hooks/api/types"; -import { useSelectedEntries, useSelectedEntryActions } from "../../SecretOverviewPage.store"; +export enum EntryType { + FOLDER = "folder", + SECRET = "secret" +} type Props = { secretPath: string; getSecretByKey: (slug: string, key: string) => DecryptedSecret | undefined; getFolderByNameAndEnv: (name: string, env: string) => TSecretFolder | undefined; + resetSelectedEntries: () => void; + selectedEntries: { + [EntryType.FOLDER]: Record; + [EntryType.SECRET]: Record; + }; }; -export const SelectionPanel = ({ getFolderByNameAndEnv, getSecretByKey, secretPath }: Props) => { +export const SelectionPanel = ({ + getFolderByNameAndEnv, + getSecretByKey, + secretPath, + resetSelectedEntries, + selectedEntries +}: 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;