feat(dashboard-v3): implemented new the dashboard with v3 support

This commit is contained in:
Akhil Mohan
2023-09-28 21:28:43 +05:30
parent fcf3f2837e
commit b54c29fc48
55 changed files with 4091 additions and 3599 deletions
@@ -0,0 +1,190 @@
import { useEffect } from "react";
import { subject } from "@casl/ability";
import { useSortable } from "@dnd-kit/sortable";
import {
faClose,
faFileImport,
faFolder,
faKey,
faUpDown
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ProjectPermissionCan } from "@app/components/permissions";
import { EmptyState, IconButton, SecretInput, TableContainer } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { useToggle } from "@app/hooks";
type Props = {
onDelete: (environment: string, secretPath: string) => void;
environment: string;
secretPath?: string;
importedEnv: string;
importedSecPath: string;
importedSecrets: { key: string; value: string; overriden: { env: string; secretPath: string } }[];
searchTerm: string;
};
// to show the environment and folder icon
export const EnvFolderIcon = ({ env, secretPath }: { env: string; secretPath: string }) => (
<div className="inline-flex items-center space-x-2">
<div style={{ minWidth: "96px" }}>{env || "-"}</div>
{secretPath && (
<div className="inline-flex items-center space-x-2 border-l border-mineshaft-600 pl-2">
<FontAwesomeIcon icon={faFolder} className="text-green-700 text-md" />
<span>{secretPath}</span>
</div>
)}
</div>
);
export const SecretImportItem = ({
importedEnv,
importedSecPath,
onDelete,
importedSecrets = [],
searchTerm = "",
secretPath,
environment
}: Props) => {
const [isExpanded, setIsExpanded] = useToggle();
const { attributes, listeners, transform, transition, setNodeRef, isDragging } = useSortable({
id: `${importedEnv}-${importedSecPath}`
});
const { currentWorkspace } = useWorkspace();
const rowEnv = currentWorkspace?.environments?.find(({ slug }) => slug === importedEnv);
useEffect(() => {
const filteredSecrets = importedSecrets.filter((secret) =>
secret.key.toUpperCase().includes(searchTerm.toUpperCase())
);
if (filteredSecrets.length > 0 && searchTerm) {
setIsExpanded.on();
} else {
setIsExpanded.off();
}
}, [searchTerm]);
useEffect(() => {
if (isDragging) {
setIsExpanded.off();
}
}, [isDragging]);
const style = {
transform: transform ? `translateY(${transform.y ? Math.round(transform.y) : 0}px)` : "",
transition
};
return (
<>
<div
className="flex group border-b border-mineshaft-600 hover:bg-mineshaft-700 cursor-pointer"
role="button"
ref={setNodeRef}
tabIndex={0}
style={style}
onClick={() => setIsExpanded.toggle()}
onKeyDown={() => setIsExpanded.toggle()}
>
<div className="w-12 px-4 py-2 flex items-center text-green-700">
<FontAwesomeIcon icon={faFileImport} />
</div>
<div className="flex-grow px-4 py-2 flex items-center">
<EnvFolderIcon env={rowEnv?.name || ""} secretPath={importedSecPath} />
</div>
<div className="px-4 py-2 flex items-center space-x-4 border-l border-mineshaft-600">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
renderTooltip
allowedLabel="Change order"
>
{(isAllowed) => (
<IconButton
size="md"
colorSchema="primary"
variant="plain"
ariaLabel="expand"
className="group-hover:opacity-100 opacity-0 p-0"
{...attributes}
{...listeners}
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faUpDown} />
</IconButton>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
renderTooltip
allowedLabel="Delete"
>
{(isAllowed) => (
<IconButton
size="md"
variant="plain"
colorSchema="danger"
ariaLabel="delete"
className="group-hover:opacity-100 opacity-0 p-0"
onClick={(evt) => {
evt.stopPropagation();
onDelete(importedEnv, importedSecPath);
}}
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faClose} size="lg" />
</IconButton>
)}
</ProjectPermissionCan>
</div>
</div>
{isExpanded && !isDragging && (
<td
colSpan={3}
className={`bg-bunker-800 ${isExpanded && "border-b-2 border-mineshaft-500"}`}
>
<div className="rounded-md bg-bunker-700 p-1">
<TableContainer>
<table className="secret-table">
<thead>
<tr>
<td style={{ padding: "0.25rem 1rem" }}>Key</td>
<td style={{ padding: "0.25rem 1rem" }}>Value</td>
<td style={{ padding: "0.25rem 1rem" }}>Override</td>
</tr>
</thead>
<tbody>
{importedSecrets?.length === 0 && (
<tr>
<td colSpan={3}>
<EmptyState title="No secrets found" icon={faKey} />
</td>
</tr>
)}
{importedSecrets
.filter((secret) => secret.key.toUpperCase().includes(searchTerm.toUpperCase()))
.map(({ key, value, overriden }, index) => (
<tr key={`${importedEnv}-${importedSecPath}-${key}-${index + 1}`}>
<td className="h-10" style={{ padding: "0.25rem 1rem" }}>
{key}
</td>
<td className="h-10" style={{ padding: "0.25rem 1rem" }}>
<SecretInput value={value} isReadOnly />
</td>
<td className="h-10" style={{ padding: "0.25rem 1rem" }}>
<EnvFolderIcon env={overriden?.env} secretPath={overriden?.secretPath} />
</td>
</tr>
))}
</tbody>
</table>
</TableContainer>
</div>
</td>
)}
</>
);
};
@@ -0,0 +1,218 @@
import { useEffect, useState } from "react";
import {
closestCenter,
DndContext,
DragEndEvent,
KeyboardSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors
} from "@dnd-kit/core";
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
import { arrayMove, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { DeleteActionModal } from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { usePopUp } from "@app/hooks";
import { useDeleteSecretImport, useUpdateSecretImport } from "@app/hooks/api";
import { TSecretImports } from "@app/hooks/api/secretImports/types";
import { DecryptedSecret } from "@app/hooks/api/types";
import { SecretImportItem } from "./SecretImportItem";
const SECRET_IN_DASHBOARD = "Present In Dashboard";
type TImportedSecrets = Array<{
environment: string;
secretPath: string;
folderId: string;
secrets: DecryptedSecret[];
}>;
export const computeImportedSecretRows = (
importedSecEnv: string,
importedSecPath: string,
importSecrets: TImportedSecrets = [],
secrets: DecryptedSecret[] = [],
environments: { name: string; slug: string }[] = []
) => {
const importedSecIndex = importSecrets.findIndex(
({ secretPath, environment }) =>
secretPath === importedSecPath && importedSecEnv === environment
);
if (importedSecIndex === -1) return [];
const importedSec = importSecrets[importedSecIndex];
const overridenSec: Record<string, { env: string; secretPath: string }> = {};
const envSlug2Name: Record<string, string> = {};
environments.forEach((el) => {
envSlug2Name[el.slug] = el.name;
});
for (let i = importedSecIndex + 1; i < importSecrets.length; i += 1) {
importSecrets[i].secrets.forEach((el) => {
overridenSec[el.key] = {
env: envSlug2Name?.[importSecrets[i].environment] || "unknown",
secretPath: importSecrets[i].secretPath
};
});
}
secrets.forEach((el) => {
overridenSec[el.key] = { env: SECRET_IN_DASHBOARD, secretPath: "" };
});
return importedSec.secrets.map(({ key, value }) => ({
key,
value,
overriden: overridenSec?.[key]
}));
};
type Props = {
environment: string;
workspaceId: string;
secretPath?: string;
secretImports?: TSecretImports;
isFetching?: boolean;
secrets?: DecryptedSecret[];
importedSecrets?: TImportedSecrets;
searchTerm: string;
};
type TDeleteSecretImport = { environment: string; secretPath: string };
export const SecretImportListView = ({
secretImports,
environment,
workspaceId,
secretPath,
importedSecrets,
secrets = [],
isFetching,
searchTerm
}: Props) => {
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"deleteSecretImport"
] as const);
const { currentWorkspace } = useWorkspace();
const { createNotification } = useNotificationContext();
const environments = currentWorkspace?.environments || [];
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
useSensor(KeyboardSensor, {})
);
const [items, setItems] = useState(
(secretImports?.imports || [])?.map((dto) => ({
id: `${dto.environment}-${dto.secretPath}`,
...dto
}))
);
useEffect(() => {
if (!isFetching) {
setItems(
(secretImports?.imports || [])?.map((dto) => ({
id: `${dto.environment}-${dto.secretPath}`,
...dto
}))
);
}
}, [isFetching]);
const { mutateAsync: deleteSecretImport } = useDeleteSecretImport();
const { mutate: updateSecretImport } = useUpdateSecretImport();
const handleSecretImportDelete = async () => {
const { environment: importEnv, secretPath: impSecPath } = popUp.deleteSecretImport
?.data as TDeleteSecretImport;
try {
if (secretImports?._id) {
await deleteSecretImport({
workspaceId,
environment,
directory: secretPath,
id: secretImports?._id,
secretImportEnv: importEnv,
secretImportPath: impSecPath
});
handlePopUpClose("deleteSecretImport");
createNotification({
type: "success",
text: "Successfully removed secret link"
});
}
} catch (err) {
console.error(err);
createNotification({
text: "Failed to remove secret link",
type: "error"
});
}
};
const handleSecretImportReorder = ({ over, active }: DragEndEvent) => {
if (active.id !== over?.id) {
const oldIndex = items.findIndex(({ id }) => id === active.id);
const newIndex = items.findIndex(({ id }) => id === over?.id);
const newImportOrder = arrayMove(items, oldIndex, newIndex);
setItems(newImportOrder);
updateSecretImport({
workspaceId,
environment,
directory: secretPath,
id: secretImports?._id || "",
secretImports: newImportOrder
});
}
};
return (
<>
<DndContext
onDragEnd={handleSecretImportReorder}
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
>
<SortableContext items={items} strategy={verticalListSortingStrategy}>
{items?.map(({ secretPath: importedSecPath, environment: importedEnv }) => (
<SecretImportItem
searchTerm={searchTerm}
key={`${importedEnv}-${importedSecPath}`}
importedEnv={importedEnv}
importedSecPath={importedSecPath}
importedSecrets={computeImportedSecretRows(
importedEnv,
importedSecPath,
importedSecrets,
secrets,
environments
)}
secretPath={secretPath}
environment={environment}
onDelete={(env, secPath) =>
handlePopUpOpen("deleteSecretImport", { environment: env, secretPath: secPath })
}
/>
))}
</SortableContext>
</DndContext>
<DeleteActionModal
isOpen={popUp.deleteSecretImport.isOpen}
deleteKey="unlink"
title="Do you want to remove this secret import?"
subTitle={`This will unlink secrets from environment ${
(popUp.deleteSecretImport?.data as TDeleteSecretImport)?.environment
} of path ${(popUp.deleteSecretImport?.data as TDeleteSecretImport)?.secretPath}?`}
onChange={(isOpen) => handlePopUpToggle("deleteSecretImport", isOpen)}
onDeleteApproved={handleSecretImportDelete}
/>
</>
);
};
@@ -0,0 +1 @@
export { SecretImportListView } from "./SecretImportListView";