feat: select all on page for secrets tables and fix multipage select behavior for actions

This commit is contained in:
Scott Wilson
2024-10-17 10:17:23 -07:00
parent f95957d534
commit d94b4b2a3c
8 changed files with 356 additions and 220 deletions
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import Link from "next/link";
import { useRouter } from "next/router";
@@ -25,6 +25,7 @@ import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
Checkbox,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
@@ -67,7 +68,7 @@ import { DashboardSecretsOrderBy } from "@app/hooks/api/dashboard/types";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { useUpdateFolderBatch } from "@app/hooks/api/secretFolders/queries";
import { TUpdateFolderBatchDTO } from "@app/hooks/api/secretFolders/types";
import { SecretType, TSecretFolder } from "@app/hooks/api/types";
import { SecretType, SecretV3RawSanitized, TSecretFolder } from "@app/hooks/api/types";
import { ProjectVersion } from "@app/hooks/api/workspace/types";
import { useDynamicSecretOverview, useFolderOverview, useSecretOverview } from "@app/hooks/utils";
import { SecretOverviewDynamicSecretRow } from "@app/views/SecretOverviewPage/components/SecretOverviewDynamicSecretRow";
@@ -133,8 +134,9 @@ export const SecretOverviewPage = () => {
>(new Map());
const [selectedEntries, setSelectedEntries] = useState<{
[EntryType.FOLDER]: Record<string, boolean>;
[EntryType.SECRET]: Record<string, boolean>;
// selectedEntries[name/key][envSlug][resource]
[EntryType.FOLDER]: Record<string, Record<string, TSecretFolder>>;
[EntryType.SECRET]: Record<string, Record<string, SecretV3RawSanitized>>;
}>({
[EntryType.FOLDER]: {},
[EntryType.SECRET]: {}
@@ -152,23 +154,6 @@ export const SecretOverviewPage = () => {
orderBy
} = usePagination<DashboardSecretsOrderBy>(DashboardSecretsOrderBy.Name);
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]: {},
@@ -551,6 +536,83 @@ export const SecretOverviewPage = () => {
[]
);
const allRowsSelectedOnPage = useMemo(() => {
if (
(!secrets?.length ||
secrets?.every((secret) => selectedEntries[EntryType.SECRET][secret.key])) &&
(!folders?.length ||
folders?.every((folder) => selectedEntries[EntryType.FOLDER][folder.name]))
)
return { isChecked: true, isIndeterminate: false };
if (
secrets?.some((secret) => selectedEntries[EntryType.SECRET][secret.key]) ||
folders?.some((folder) => selectedEntries[EntryType.FOLDER][folder.name])
)
return { isChecked: true, isIndeterminate: true };
return { isChecked: false, isIndeterminate: false };
}, [selectedEntries, secrets, folders]);
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] = {};
userAvailableEnvs.forEach((env) => {
const resource =
type === EntryType.SECRET
? getSecretByKey(env.slug, key)
: getFolderByNameAndEnv(key, env.slug);
if (resource) newChecks[type][key][env.slug] = resource;
});
}
setSelectedEntries(newChecks);
},
[selectedEntries, getFolderByNameAndEnv, getSecretByKey]
);
const toggleSelectAllRows = () => {
const newChecks = { ...selectedEntries };
userAvailableEnvs.forEach((env) => {
secrets?.forEach((secret) => {
if (allRowsSelectedOnPage.isChecked) {
delete newChecks[EntryType.SECRET][secret.key];
} else {
if (!newChecks[EntryType.SECRET][secret.key])
newChecks[EntryType.SECRET][secret.key] = {};
const resource = getSecretByKey(env.slug, secret.key);
if (resource) newChecks[EntryType.SECRET][secret.key][env.slug] = resource;
}
});
folders?.forEach((folder) => {
if (allRowsSelectedOnPage.isChecked) {
delete newChecks[EntryType.FOLDER][folder.name];
} else {
if (!newChecks[EntryType.FOLDER][folder.name])
newChecks[EntryType.FOLDER][folder.name] = {};
const resource = getFolderByNameAndEnv(folder.name, env.slug);
if (resource) newChecks[EntryType.FOLDER][folder.name][env.slug] = resource;
}
});
});
setSelectedEntries(newChecks);
};
if (isWorkspaceLoading || (isProjectV3 && isOverviewLoading)) {
return (
<div className="container mx-auto flex h-screen w-full items-center justify-center px-8 text-mineshaft-50 dark:[color-scheme:dark]">
@@ -799,8 +861,6 @@ export const SecretOverviewPage = () => {
</div>
<SelectionPanel
secretPath={secretPath}
getSecretByKey={getSecretByKey}
getFolderByNameAndEnv={getFolderByNameAndEnv}
selectedEntries={selectedEntries}
resetSelectedEntries={resetSelectedEntries}
/>
@@ -810,7 +870,28 @@ export const SecretOverviewPage = () => {
<THead>
<Tr className="sticky top-0 z-20 border-0">
<Th className="sticky left-0 z-20 min-w-[20rem] border-b-0 p-0">
<div className="flex items-center border-b border-r border-mineshaft-600 px-5 pt-3.5 pb-3">
<div className="flex items-center border-b border-r border-mineshaft-600 pr-5 pl-3 pt-3.5 pb-3">
<Tooltip
className="max-w-[20rem] whitespace-nowrap capitalize"
content={
totalCount > 0
? `${
!allRowsSelectedOnPage.isChecked ? "Select" : "Unselect"
} all folders and secrets on page`
: ""
}
>
<div>
<Checkbox
isDisabled={totalCount === 0}
id="checkbox-select-all-rows"
isChecked={allRowsSelectedOnPage.isChecked}
isIndeterminate={allRowsSelectedOnPage.isIndeterminate}
onCheckedChange={toggleSelectAllRows}
/>
</div>
</Tooltip>
<div className="mx-2 h-4 w-[1px] bg-mineshaft-400" />
Name
<IconButton
variant="plain"
@@ -938,7 +1019,7 @@ export const SecretOverviewPage = () => {
<SecretOverviewFolderRow
folderName={folderName}
isFolderPresentInEnv={isFolderPresentInEnv}
isSelected={selectedEntries.folder[folderName]}
isSelected={Boolean(selectedEntries.folder[folderName])}
onToggleFolderSelect={() =>
toggleSelectedEntry(EntryType.FOLDER, folderName)
}
@@ -960,7 +1041,7 @@ export const SecretOverviewPage = () => {
))}
{secKeys.map((key, index) => (
<SecretOverviewTableRow
isSelected={selectedEntries.secret[key]}
isSelected={Boolean(selectedEntries.secret[key])}
onToggleSecretSelect={() => toggleSelectedEntry(EntryType.SECRET, key)}
secretPath={secretPath}
getImportedSecretByKey={getImportedSecretByKey}
@@ -27,31 +27,23 @@ export enum EntryType {
type Props = {
secretPath: string;
getSecretByKey: (slug: string, key: string) => SecretV3RawSanitized | undefined;
getFolderByNameAndEnv: (name: string, env: string) => TSecretFolder | undefined;
resetSelectedEntries: () => void;
selectedEntries: {
[EntryType.FOLDER]: Record<string, boolean>;
[EntryType.SECRET]: Record<string, boolean>;
[EntryType.FOLDER]: Record<string, Record<string, TSecretFolder>>;
[EntryType.SECRET]: Record<string, Record<string, SecretV3RawSanitized>>;
};
};
export const SelectionPanel = ({
getFolderByNameAndEnv,
getSecretByKey,
secretPath,
resetSelectedEntries,
selectedEntries
}: Props) => {
export const SelectionPanel = ({ secretPath, resetSelectedEntries, selectedEntries }: Props) => {
const { permission } = useProjectPermission();
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
"bulkDeleteEntries"
] as const);
const selectedFolderCount = Object.keys(selectedEntries.folder).length
const selectedKeysCount = Object.keys(selectedEntries.secret).length
const selectedCount = selectedFolderCount + selectedKeysCount
const selectedFolderCount = Object.keys(selectedEntries.folder).length;
const selectedKeysCount = Object.keys(selectedEntries.secret).length;
const selectedCount = selectedFolderCount + selectedKeysCount;
const { currentWorkspace } = useWorkspace();
const workspaceId = currentWorkspace?.id || "";
@@ -77,7 +69,7 @@ export const SelectionPanel = ({
return "Do you want to delete the selected secrets across environments?";
}
return "Do you want to delete the selected folders across environments?";
}
};
const handleBulkDelete = async () => {
let processedEntries = 0;
@@ -94,8 +86,8 @@ export const SelectionPanel = ({
}
await Promise.all(
Object.keys(selectedEntries.folder).map(async (folderName) => {
const folder = getFolderByNameAndEnv(folderName, env.slug);
Object.values(selectedEntries.folder).map(async (folderRecord) => {
const folder = folderRecord[env.slug];
if (folder) {
processedEntries += 1;
await deleteFolder({
@@ -108,9 +100,9 @@ export const SelectionPanel = ({
})
);
const secretsToDelete = Object.keys(selectedEntries.secret).reduce(
(accum: TDeleteSecretBatchDTO["secrets"], secretName) => {
const entry = getSecretByKey(env.slug, secretName);
const secretsToDelete = Object.values(selectedEntries.secret).reduce(
(accum: TDeleteSecretBatchDTO["secrets"], secretRecord) => {
const entry = secretRecord[env.slug];
if (entry) {
return [
...accum,