diff --git a/site/src/modules/tasks/TaskDeleteDialog/TaskDeleteDialog.stories.tsx b/site/src/modules/tasks/TaskDeleteDialog/TaskDeleteDialog.stories.tsx new file mode 100644 index 0000000000..e595c26a78 --- /dev/null +++ b/site/src/modules/tasks/TaskDeleteDialog/TaskDeleteDialog.stories.tsx @@ -0,0 +1,50 @@ +import { MockTasks, MockWorkspace } from "testHelpers/entities"; +import { withGlobalSnackbar } from "testHelpers/storybook"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { API } from "api/api"; +import { expect, spyOn, userEvent, waitFor, within } from "storybook/test"; +import { TaskDeleteDialog } from "./TaskDeleteDialog"; + +const meta: Meta = { + title: "modules/tasks/TaskDeleteDialog", + component: TaskDeleteDialog, + decorators: [withGlobalSnackbar], +}; + +export default meta; +type Story = StoryObj; + +export const DeleteTaskSuccess: Story = { + decorators: [withGlobalSnackbar], + args: { + open: true, + task: { prompt: "My Task", workspace: MockWorkspace }, + onClose: () => {}, + }, + parameters: { + chromatic: { + disableSnapshot: false, + }, + }, + beforeEach: () => { + spyOn(API.experimental, "deleteTask").mockResolvedValue(); + }, + play: async ({ canvasElement, step }) => { + const body = within(canvasElement.ownerDocument.body); + + await step("Confirm delete", async () => { + const confirmButton = await body.findByRole("button", { + name: /delete/i, + }); + await userEvent.click(confirmButton); + await step("Confirm delete", async () => { + await waitFor(() => { + expect(API.experimental.deleteTask).toHaveBeenCalledWith( + MockTasks[0].workspace.owner_name, + MockTasks[0].workspace.id, + ); + }); + }); + }); + }, +}; diff --git a/site/src/modules/tasks/TaskDeleteDialog/TaskDeleteDialog.tsx b/site/src/modules/tasks/TaskDeleteDialog/TaskDeleteDialog.tsx new file mode 100644 index 0000000000..b5bac134a6 --- /dev/null +++ b/site/src/modules/tasks/TaskDeleteDialog/TaskDeleteDialog.tsx @@ -0,0 +1,58 @@ +import { API } from "api/api"; +import { getErrorDetail, getErrorMessage } from "api/errors"; +import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"; +import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; +import type { FC } from "react"; +import { QueryClient, useMutation } from "react-query"; +import type { Task } from "../tasks"; + +type TaskDeleteDialogProps = { + open: boolean; + task: Task; + onClose: () => void; + onSuccess?: () => void; +}; + +export const TaskDeleteDialog: FC = ({ + task, + onSuccess, + ...props +}) => { + const queryClient = new QueryClient(); + const deleteTaskMutation = useMutation({ + mutationFn: () => + API.experimental.deleteTask(task.workspace.owner_name, task.workspace.id), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["tasks"] }); + }, + }); + + return ( + { + try { + await deleteTaskMutation.mutateAsync(); + displaySuccess("Task deleted successfully"); + onSuccess?.(); + } catch (error) { + displayError( + getErrorMessage(error, "Failed to delete task"), + getErrorDetail(error), + ); + } finally { + props.onClose(); + } + }} + description={ +

+ This action is irreversible and removes all workspace resources and + data. +

+ } + /> + ); +}; diff --git a/site/src/modules/tasks/TasksSidebar/TasksSidebar.stories.tsx b/site/src/modules/tasks/TasksSidebar/TasksSidebar.stories.tsx index 333b3db13c..11fb2c54bb 100644 --- a/site/src/modules/tasks/TasksSidebar/TasksSidebar.stories.tsx +++ b/site/src/modules/tasks/TasksSidebar/TasksSidebar.stories.tsx @@ -1,9 +1,9 @@ import { MockTasks, MockUserOwner, mockApiError } from "testHelpers/entities"; -import { withAuthProvider, withGlobalSnackbar } from "testHelpers/storybook"; +import { withAuthProvider } from "testHelpers/storybook"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { API } from "api/api"; import { MockUsers } from "pages/UsersPage/storybookData/users"; -import { expect, spyOn, userEvent, waitFor, within } from "storybook/test"; +import { spyOn, userEvent, within } from "storybook/test"; import { reactRouterParameters } from "storybook-addon-remix-react-router"; import { TasksSidebar } from "./TasksSidebar"; @@ -93,7 +93,7 @@ export const OpenOptionsMenu: Story = { }, }; -export const DeleteTaskDialog: Story = { +export const OpenDeleteDialog: Story = { beforeEach: () => { spyOn(API.experimental, "getTasks").mockResolvedValue(MockTasks); }, @@ -114,49 +114,3 @@ export const DeleteTaskDialog: Story = { }); }, }; - -export const DeleteTaskSuccess: Story = { - decorators: [withGlobalSnackbar], - parameters: { - chromatic: { - disableSnapshot: false, - }, - }, - beforeEach: () => { - spyOn(API.experimental, "getTasks").mockResolvedValue(MockTasks); - spyOn(API.experimental, "deleteTask").mockResolvedValue(); - }, - play: async ({ canvasElement, step }) => { - const body = within(canvasElement.ownerDocument.body); - const canvas = within(canvasElement); - - await step("Open menu", async () => { - const optionButtons = await canvas.findAllByRole("button", { - name: /task options/i, - }); - await userEvent.click(optionButtons[0]); - }); - - await step("Open delete dialog", async () => { - const deleteButton = await body.findByRole("menuitem", { - name: /delete/i, - }); - await userEvent.click(deleteButton); - }); - - await step("Confirm delete", async () => { - const confirmButton = await body.findByRole("button", { - name: /delete/i, - }); - await userEvent.click(confirmButton); - await step("Confirm delete", async () => { - await waitFor(() => { - expect(API.experimental.deleteTask).toHaveBeenCalledWith( - MockTasks[0].workspace.owner_name, - MockTasks[0].workspace.id, - ); - }); - }); - }); - }, -}; diff --git a/site/src/modules/tasks/TasksSidebar/TasksSidebar.tsx b/site/src/modules/tasks/TasksSidebar/TasksSidebar.tsx index 0c440e6432..b63366e1b9 100644 --- a/site/src/modules/tasks/TasksSidebar/TasksSidebar.tsx +++ b/site/src/modules/tasks/TasksSidebar/TasksSidebar.tsx @@ -1,8 +1,7 @@ import { API } from "api/api"; -import { getErrorDetail, getErrorMessage } from "api/errors"; +import { getErrorMessage } from "api/errors"; import { cva } from "class-variance-authority"; import { Button } from "components/Button/Button"; -import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"; import { DropdownMenu, DropdownMenuContent, @@ -10,7 +9,6 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "components/DropdownMenu/DropdownMenu"; -import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; import { CoderIcon } from "components/Icons/CoderIcon"; import { ScrollArea } from "components/ScrollArea/ScrollArea"; import { Skeleton } from "components/Skeleton/Skeleton"; @@ -25,9 +23,10 @@ import { useSearchParamsKey } from "hooks/useSearchParamsKey"; import { EditIcon, EllipsisIcon, PanelLeftIcon, TrashIcon } from "lucide-react"; import type { Task } from "modules/tasks/tasks"; import { type FC, useState } from "react"; -import { QueryClient, useMutation, useQuery } from "react-query"; +import { useQuery } from "react-query"; import { Link as RouterLink, useNavigate, useParams } from "react-router"; import { cn } from "utils/cn"; +import { TaskDeleteDialog } from "../TaskDeleteDialog/TaskDeleteDialog"; import { UserCombobox } from "./UserCombobox"; export const TasksSidebar: FC = () => { @@ -180,14 +179,6 @@ const TaskSidebarMenuItem: FC = ({ task }) => { const isActive = task.workspace.name === workspace; const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const navigate = useNavigate(); - const queryClient = new QueryClient(); - const deleteTaskMutation = useMutation({ - mutationFn: () => - API.experimental.deleteTask(task.workspace.owner_name, task.workspace.id), - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: ["tasks"] }); - }, - }); return ( <> @@ -249,36 +240,17 @@ const TaskSidebarMenuItem: FC = ({ task }) => { - { + task={task} + onClose={() => { setIsDeleteDialogOpen(false); }} - onConfirm={async () => { - try { - await deleteTaskMutation.mutateAsync(); - displaySuccess("Task deleted successfully"); - if (isActive) { - navigate("/tasks"); - } - } catch (error) { - displayError( - getErrorMessage(error, "Failed to delete task"), - getErrorDetail(error), - ); - } finally { - setIsDeleteDialogOpen(false); + onSuccess={() => { + if (isActive) { + navigate("/tasks"); } }} - description={ -

- This action is irreversible and removes all workspace resources and - data. -

- } /> ); diff --git a/site/src/pages/TasksPage/TasksPage.stories.tsx b/site/src/pages/TasksPage/TasksPage.stories.tsx index b0510bb6f4..7f4818bf00 100644 --- a/site/src/pages/TasksPage/TasksPage.stories.tsx +++ b/site/src/pages/TasksPage/TasksPage.stories.tsx @@ -424,3 +424,17 @@ export const NonAdmin: Story = { }); }, }; + +export const OpenDeleteDialog: Story = { + beforeEach: () => { + spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]); + spyOn(API.experimental, "getTasks").mockResolvedValue(MockTasks); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const deleteButtons = await canvas.findAllByRole("button", { + name: /delete task/i, + }); + await userEvent.click(deleteButtons[0]); + }, +}; diff --git a/site/src/pages/TasksPage/TasksTable.tsx b/site/src/pages/TasksPage/TasksTable.tsx index 883f3dd84c..c1cddb75a8 100644 --- a/site/src/pages/TasksPage/TasksTable.tsx +++ b/site/src/pages/TasksPage/TasksTable.tsx @@ -16,10 +16,17 @@ import { TableLoaderSkeleton, TableRowSkeleton, } from "components/TableLoader/TableLoader"; -import { RotateCcwIcon } from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "components/Tooltip/Tooltip"; +import { RotateCcwIcon, TrashIcon } from "lucide-react"; +import { TaskDeleteDialog } from "modules/tasks/TaskDeleteDialog/TaskDeleteDialog"; import type { Task } from "modules/tasks/tasks"; import { WorkspaceAppStatus } from "modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus"; -import type { FC, ReactNode } from "react"; +import { type FC, type ReactNode, useState } from "react"; import { Link as RouterLink } from "react-router"; import { relativeTime } from "utils/time"; @@ -39,7 +46,7 @@ export const TasksTable: FC = ({ tasks, error, onRetry }) => { } else if (tasks.length === 0) { body = ; } else { - body = ; + body = tasks.map((task) => ); } return ( @@ -49,6 +56,7 @@ export const TasksTable: FC = ({ tasks, error, onRetry }) => { Task Status Created by + {body} @@ -103,24 +111,25 @@ const TasksEmpty: FC = () => { ); }; -type TasksProps = { tasks: Task[] }; +type TaskRowProps = { task: Task }; -const Tasks: FC = ({ tasks }) => { - return tasks.map(({ workspace, prompt }) => { - const templateDisplayName = - workspace.template_display_name ?? workspace.template_name; +const TaskRow: FC = ({ task }) => { + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const templateDisplayName = + task.workspace.template_display_name ?? task.workspace.template_name; - return ( - + return ( + <> + - {prompt} + {task.prompt} Access task @@ -132,7 +141,7 @@ const Tasks: FC = ({ tasks }) => { } @@ -140,24 +149,50 @@ const Tasks: FC = ({ tasks }) => { - {relativeTime(new Date(workspace.created_at))} + {relativeTime(new Date(task.workspace.created_at))} } - src={workspace.owner_avatar_url} + src={task.workspace.owner_avatar_url} /> + + + + + + + Delete task + + + - ); - }); + + { + setIsDeleteDialogOpen(false); + }} + /> + + ); }; const TasksSkeleton: FC = () => {