mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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…
|
||||
</MenuItem>
|
||||
<MenuItem onClick={onMenuItemClick(onDelete)}>
|
||||
<DeleteOutlined />
|
||||
Delete…
|
||||
</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…
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem
|
||||
onClick={onMenuItemClick(dialogState.openDeleteConfirmation)}
|
||||
>
|
||||
<DeleteIcon />
|
||||
Delete…
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
+4
-4
@@ -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
|
||||
+6
-4
@@ -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" });
|
||||
Reference in New Issue
Block a user