Skip to content
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