feat: add warning message when trying to delete active template (#10142)

* refactor: clean up TemplatePageHeader

* chore: add react query configs for workspace lists

* feat: add delete-intercept functionality

* refactor: improve readability

* refactor: rename entities for readability/accuracy

* refactor: clean up variable names again

* refactor: remove redudant function calls

* fix: update logic check for safe deletions

* fix: update workspaces query logic

* fix: update call site for workspaces key
This commit is contained in:
Michael Smith
2023-10-10 08:04:54 -04:00
committed by GitHub
parent 78b9201b31
commit 5ae6cda89f
4 changed files with 183 additions and 95 deletions
+22
View File
@@ -0,0 +1,22 @@
import * as API from "api/api";
import { type QueryOptions } from "react-query";
import {
type WorkspacesResponse,
type WorkspacesRequest,
} from "api/typesGenerated";
export function workspacesKey(config: WorkspacesRequest = {}) {
const { q, limit } = config;
return ["workspaces", { q, limit }] as const;
}
export function workspaces(config: WorkspacesRequest = {}) {
// Duplicates some of the work from workspacesKey, but that felt better than
// letting invisible properties sneak into the query logic
const { q, limit } = config;
return {
queryKey: workspacesKey(config),
queryFn: () => API.getWorkspaces({ q, limit }),
} as const satisfies QueryOptions<WorkspacesResponse>;
}
@@ -1,114 +1,178 @@
import Button from "@mui/material/Button";
import AddIcon from "@mui/icons-material/AddOutlined";
import { type FC, useRef, useState } from "react";
import { Link as RouterLink, useNavigate } from "react-router-dom";
import { useDeletionDialogState } from "./useDeletionDialogState";
import { useQuery } from "react-query";
import { workspaces } from "api/queries/workspaces";
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
import {
AuthorizationResponse,
Template,
TemplateVersion,
} from "api/typesGenerated";
import { Avatar } from "components/Avatar/Avatar";
import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog";
import { Stack } from "components/Stack/Stack";
import { Margins } from "components/Margins/Margins";
import {
PageHeader,
PageHeaderTitle,
PageHeaderSubtitle,
} from "components/PageHeader/PageHeader";
import { Stack } from "components/Stack/Stack";
import { FC, useRef, useState } from "react";
import { Link as RouterLink, useNavigate } from "react-router-dom";
import { useDeleteTemplate } from "./deleteTemplate";
import { Margins } from "components/Margins/Margins";
import Button from "@mui/material/Button";
import MoreVertOutlined from "@mui/icons-material/MoreVertOutlined";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
import DeleteOutlined from "@mui/icons-material/DeleteOutlined";
import EditOutlined from "@mui/icons-material/EditOutlined";
import FileCopyOutlined from "@mui/icons-material/FileCopyOutlined";
import IconButton from "@mui/material/IconButton";
import AddIcon from "@mui/icons-material/AddOutlined";
import SettingsIcon from "@mui/icons-material/SettingsOutlined";
import DeleteIcon from "@mui/icons-material/DeleteOutlined";
import EditIcon from "@mui/icons-material/EditOutlined";
import CopyIcon from "@mui/icons-material/FileCopyOutlined";
const TemplateMenu: FC<{
type TemplateMenuProps = {
templateName: string;
templateVersion: string;
templateId: string;
onDelete: () => void;
}> = ({ templateName, templateVersion, onDelete }) => {
};
const TemplateMenu: FC<TemplateMenuProps> = ({
templateName,
templateVersion,
templateId,
onDelete,
}) => {
const dialogState = useDeletionDialogState(templateId, onDelete);
const menuTriggerRef = useRef<HTMLButtonElement>(null);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const navigate = useNavigate();
const queryText = `template:${templateName}`;
const workspaceCountQuery = useQuery({
...workspaces({ q: queryText }),
select: (res) => res.count,
});
// Returns a function that will execute the action and close the menu
const onMenuItemClick = (actionFn: () => void) => () => {
setIsMenuOpen(false);
actionFn();
};
return (
<div>
<IconButton
aria-controls="template-options"
aria-haspopup="true"
onClick={() => setIsMenuOpen(true)}
ref={menuTriggerRef}
arial-label="More options"
>
<MoreVertOutlined />
</IconButton>
const safeToDeleteTemplate = workspaceCountQuery.data === 0;
<Menu
id="template-options"
anchorEl={menuTriggerRef.current}
open={isMenuOpen}
onClose={() => setIsMenuOpen(false)}
>
<MenuItem
onClick={onMenuItemClick(() =>
navigate(`/templates/${templateName}/settings`),
)}
return (
<>
<div>
<IconButton
aria-controls="template-options"
aria-haspopup="true"
onClick={() => setIsMenuOpen(true)}
ref={menuTriggerRef}
arial-label="More options"
>
<SettingsOutlined />
Settings
</MenuItem>
<MenuItem
onClick={onMenuItemClick(() =>
navigate(
`/templates/${templateName}/versions/${templateVersion}/edit`,
),
)}
<MoreVertOutlined />
</IconButton>
<Menu
id="template-options"
anchorEl={menuTriggerRef.current}
open={isMenuOpen}
onClose={() => setIsMenuOpen(false)}
>
<EditOutlined />
Edit files
</MenuItem>
<MenuItem
onClick={onMenuItemClick(() =>
navigate(`/templates/new?fromTemplate=${templateName}`),
)}
>
<FileCopyOutlined />
Duplicate&hellip;
</MenuItem>
<MenuItem onClick={onMenuItemClick(onDelete)}>
<DeleteOutlined />
Delete&hellip;
</MenuItem>
</Menu>
</div>
<MenuItem
onClick={onMenuItemClick(() =>
navigate(`/templates/${templateName}/settings`),
)}
>
<SettingsIcon />
Settings
</MenuItem>
<MenuItem
onClick={onMenuItemClick(() =>
navigate(
`/templates/${templateName}/versions/${templateVersion}/edit`,
),
)}
>
<EditIcon />
Edit files
</MenuItem>
<MenuItem
onClick={onMenuItemClick(() =>
navigate(`/templates/new?fromTemplate=${templateName}`),
)}
>
<CopyIcon />
Duplicate&hellip;
</MenuItem>
<MenuItem
onClick={onMenuItemClick(dialogState.openDeleteConfirmation)}
>
<DeleteIcon />
Delete&hellip;
</MenuItem>
</Menu>
</div>
{safeToDeleteTemplate ? (
<DeleteDialog
isOpen={dialogState.isDeleteDialogOpen}
onConfirm={dialogState.confirmDelete}
onCancel={dialogState.cancelDeleteConfirmation}
entity="template"
name={templateName}
/>
) : (
<ConfirmDialog
type="info"
title="Unable to delete"
hideCancel={false}
open={dialogState.isDeleteDialogOpen}
onClose={dialogState.cancelDeleteConfirmation}
confirmText="See workspaces"
confirmLoading={workspaceCountQuery.status !== "success"}
onConfirm={() => {
navigate({
pathname: "/workspaces",
search: new URLSearchParams({ filter: queryText }).toString(),
});
}}
description={
<>
{workspaceCountQuery.isSuccess && (
<>
This template is used by{" "}
<strong>
{workspaceCountQuery.data} workspace
{workspaceCountQuery.data === 1 ? "" : "s"}
</strong>
. Please delete all related workspaces before deleting this
template.
</>
)}
{workspaceCountQuery.isLoading && (
<>Loading information about workspaces used by this template.</>
)}
{workspaceCountQuery.isError && (
<>Unable to determine workspaces used by this template.</>
)}
</>
}
/>
)}
</>
);
};
const CreateWorkspaceButton: FC<{
templateName: string;
className?: string;
}> = ({ templateName }) => (
<Button
variant="contained"
startIcon={<AddIcon />}
component={RouterLink}
to={`/templates/${templateName}/workspace`}
>
Create Workspace
</Button>
);
export type TemplatePageHeaderProps = {
template: Template;
activeVersion: TemplateVersion;
@@ -123,19 +187,27 @@ export const TemplatePageHeader: FC<TemplatePageHeaderProps> = ({
onDeleteTemplate,
}) => {
const hasIcon = template.icon && template.icon !== "";
const deleteTemplate = useDeleteTemplate(template, onDeleteTemplate);
return (
<Margins>
<PageHeader
actions={
<>
<CreateWorkspaceButton templateName={template.name} />
<Button
variant="contained"
startIcon={<AddIcon />}
component={RouterLink}
to={`/templates/${template.name}/workspace`}
>
Create Workspace
</Button>
{permissions.canUpdateTemplate && (
<TemplateMenu
templateVersion={activeVersion.name}
templateName={template.name}
onDelete={deleteTemplate.openDeleteConfirmation}
templateId={template.id}
onDelete={onDeleteTemplate}
/>
)}
</>
@@ -154,6 +226,7 @@ export const TemplatePageHeader: FC<TemplatePageHeaderProps> = ({
? template.display_name
: template.name}
</PageHeaderTitle>
{template.description !== "" && (
<PageHeaderSubtitle condensed>
{template.description}
@@ -162,15 +235,6 @@ export const TemplatePageHeader: FC<TemplatePageHeaderProps> = ({
</div>
</Stack>
</PageHeader>
<DeleteDialog
isOpen={deleteTemplate.isDeleteDialogOpen}
confirmLoading={deleteTemplate.state.status === "deleting"}
onConfirm={deleteTemplate.confirmDelete}
onCancel={deleteTemplate.cancelDeleteConfirmation}
entity="template"
name={template.name}
/>
</Margins>
);
};
@@ -1,11 +1,11 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { MockTemplate } from "testHelpers/entities";
import { useDeleteTemplate } from "./deleteTemplate";
import { useDeletionDialogState } from "./useDeletionDialogState";
import * as API from "api/api";
test("delete dialog starts closed", () => {
const { result } = renderHook(() =>
useDeleteTemplate(MockTemplate, jest.fn()),
useDeletionDialogState(MockTemplate.id, jest.fn()),
);
expect(result.current.isDeleteDialogOpen).toBeFalsy();
});
@@ -13,7 +13,7 @@ test("delete dialog starts closed", () => {
test("confirm template deletion", async () => {
const onDeleteTemplate = jest.fn();
const { result } = renderHook(() =>
useDeleteTemplate(MockTemplate, onDeleteTemplate),
useDeletionDialogState(MockTemplate.id, onDeleteTemplate),
);
//Open delete confirmation
@@ -31,7 +31,7 @@ test("confirm template deletion", async () => {
test("cancel template deletion", () => {
const { result } = renderHook(() =>
useDeleteTemplate(MockTemplate, jest.fn()),
useDeletionDialogState(MockTemplate.id, jest.fn()),
);
//Open delete confirmation
@@ -1,15 +1,17 @@
import { useState } from "react";
import { deleteTemplate } from "api/api";
import { getErrorMessage } from "api/errors";
import { Template } from "api/typesGenerated";
import { displayError } from "components/GlobalSnackbar/utils";
import { useState } from "react";
type DeleteTemplateState =
| { status: "idle" }
| { status: "confirming" }
| { status: "deleting" };
export const useDeleteTemplate = (template: Template, onDelete: () => void) => {
export const useDeletionDialogState = (
templateId: string,
onDelete: () => void,
) => {
const [state, setState] = useState<DeleteTemplateState>({ status: "idle" });
const isDeleteDialogOpen =
state.status === "confirming" || state.status === "deleting";
@@ -25,7 +27,7 @@ export const useDeleteTemplate = (template: Template, onDelete: () => void) => {
const confirmDelete = async () => {
try {
setState({ status: "deleting" });
await deleteTemplate(template.id);
await deleteTemplate(templateId);
onDelete();
} catch (e) {
setState({ status: "confirming" });