diff --git a/site/src/api/queries/workspaces.ts b/site/src/api/queries/workspaces.ts new file mode 100644 index 0000000000000..8e11409b49d62 --- /dev/null +++ b/site/src/api/queries/workspaces.ts @@ -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; +} diff --git a/site/src/pages/TemplatePage/TemplatePageHeader.tsx b/site/src/pages/TemplatePage/TemplatePageHeader.tsx index 3985306a97548..ec2bae83bd852 100644 --- a/site/src/pages/TemplatePage/TemplatePageHeader.tsx +++ b/site/src/pages/TemplatePage/TemplatePageHeader.tsx @@ -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 = ({ + templateName, + templateVersion, + templateId, + onDelete, +}) => { + const dialogState = useDeletionDialogState(templateId, onDelete); const menuTriggerRef = useRef(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(); }; + const safeToDeleteTemplate = workspaceCountQuery.data === 0; + return ( -
- setIsMenuOpen(true)} - ref={menuTriggerRef} - arial-label="More options" - > - - - - setIsMenuOpen(false)} - > - - navigate(`/templates/${templateName}/settings`), - )} + <> +
+ setIsMenuOpen(true)} + ref={menuTriggerRef} + arial-label="More options" > - - Settings - - - navigate( - `/templates/${templateName}/versions/${templateVersion}/edit`, - ), - )} - > - - Edit files - - - navigate(`/templates/new?fromTemplate=${templateName}`), - )} + + + + setIsMenuOpen(false)} > - - Duplicate… - - - - Delete… - - -
+ + navigate(`/templates/${templateName}/settings`), + )} + > + + Settings + + + + navigate( + `/templates/${templateName}/versions/${templateVersion}/edit`, + ), + )} + > + + Edit files + + + + navigate(`/templates/new?fromTemplate=${templateName}`), + )} + > + + Duplicate… + + + + + Delete… + +
+
+ + {safeToDeleteTemplate ? ( + + ) : ( + { + navigate({ + pathname: "/workspaces", + search: new URLSearchParams({ filter: queryText }).toString(), + }); + }} + description={ + <> + {workspaceCountQuery.isSuccess && ( + <> + This template is used by{" "} + + {workspaceCountQuery.data} workspace + {workspaceCountQuery.data === 1 ? "" : "s"} + + . 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 }) => ( - -); - export type TemplatePageHeaderProps = { template: Template; activeVersion: TemplateVersion; @@ -123,19 +187,27 @@ export const TemplatePageHeader: FC = ({ onDeleteTemplate, }) => { const hasIcon = template.icon && template.icon !== ""; - const deleteTemplate = useDeleteTemplate(template, onDeleteTemplate); return ( - + + {permissions.canUpdateTemplate && ( )} @@ -154,6 +226,7 @@ export const TemplatePageHeader: FC = ({ ? template.display_name : template.name} + {template.description !== "" && ( {template.description} @@ -162,15 +235,6 @@ export const TemplatePageHeader: FC = ({ - - ); }; diff --git a/site/src/pages/TemplatePage/deleteTemplate.test.ts b/site/src/pages/TemplatePage/useDeletionDialogState.test.ts similarity index 83% rename from site/src/pages/TemplatePage/deleteTemplate.test.ts rename to site/src/pages/TemplatePage/useDeletionDialogState.test.ts index fc4a4076d2852..1858520bff06f 100644 --- a/site/src/pages/TemplatePage/deleteTemplate.test.ts +++ b/site/src/pages/TemplatePage/useDeletionDialogState.test.ts @@ -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 diff --git a/site/src/pages/TemplatePage/deleteTemplate.ts b/site/src/pages/TemplatePage/useDeletionDialogState.ts similarity index 85% rename from site/src/pages/TemplatePage/deleteTemplate.ts rename to site/src/pages/TemplatePage/useDeletionDialogState.ts index e8a4f1e44b153..7b3b7bbfcac63 100644 --- a/site/src/pages/TemplatePage/deleteTemplate.ts +++ b/site/src/pages/TemplatePage/useDeletionDialogState.ts @@ -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({ 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" });