Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Oct 10, 2023
22 changes: 22 additions & 0 deletions site/src/api/queries/workspaces.ts
Original file line number Diff line number Diff line change
@@ -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>;
}
238 changes: 151 additions & 87 deletions site/src/pages/TemplatePage/TemplatePageHeader.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => {
};
Comment on lines +35 to +40
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

my hero!


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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TIL!

});

// Returns a function that will execute the action and close the menu
const onMenuItemClick = (actionFn: () => void) => () => {
setIsMenuOpen(false);

actionFn();
};

const safeToDeleteTemplate = workspaceCountQuery.data === 0;

return (
<div>
<IconButton
aria-controls="template-options"
aria-haspopup="true"
onClick={() => setIsMenuOpen(true)}
ref={menuTriggerRef}
arial-label="More options"
>
<MoreVertOutlined />
</IconButton>

<Menu
id="template-options"
anchorEl={menuTriggerRef.current}
open={isMenuOpen}
onClose={() => setIsMenuOpen(false)}
>
<MenuItem
onClick={onMenuItemClick(() =>
navigate(`/templates/${templateName}/settings`),
)}
<>
<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`,
),
)}
>
<EditOutlined />
Edit files
</MenuItem>
<MenuItem
onClick={onMenuItemClick(() =>
navigate(`/templates/new?fromTemplate=${templateName}`),
)}
<MoreVertOutlined />
</IconButton>

<Menu
id="template-options"
anchorEl={menuTriggerRef.current}
open={isMenuOpen}
onClose={() => setIsMenuOpen(false)}
>
<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;
Expand All @@ -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}
/>
)}
</>
Expand All @@ -154,6 +226,7 @@ export const TemplatePageHeader: FC<TemplatePageHeaderProps> = ({
? template.display_name
: template.name}
</PageHeaderTitle>

{template.description !== "" && (
<PageHeaderSubtitle condensed>
{template.description}
Expand All @@ -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>
);
};
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
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();
});

test("confirm template deletion", async () => {
const onDeleteTemplate = jest.fn();
const { result } = renderHook(() =>
useDeleteTemplate(MockTemplate, onDeleteTemplate),
useDeletionDialogState(MockTemplate.id, onDeleteTemplate),
);

//Open delete confirmation
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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" });
Expand Down