diff --git a/site/src/components/WorkspaceActions/ActionCtas.tsx b/site/src/components/WorkspaceActions/ActionCtas.tsx new file mode 100644 index 0000000000000..b99807d1050ae --- /dev/null +++ b/site/src/components/WorkspaceActions/ActionCtas.tsx @@ -0,0 +1,115 @@ +import Button from "@material-ui/core/Button" +import { makeStyles } from "@material-ui/core/styles" +import CloudQueueIcon from "@material-ui/icons/CloudQueue" +import CropSquareIcon from "@material-ui/icons/CropSquare" +import DeleteOutlineIcon from "@material-ui/icons/DeleteOutline" +import HighlightOffIcon from "@material-ui/icons/HighlightOff" +import PlayCircleOutlineIcon from "@material-ui/icons/PlayCircleOutline" +import { FC } from "react" +import { Workspace } from "../../api/typesGenerated" +import { WorkspaceStatus } from "../../util/workspace" +import { WorkspaceActionButton } from "../WorkspaceActionButton/WorkspaceActionButton" + +export const Language = { + start: "Start", + stop: "Stop", + delete: "Delete", + cancel: "Cancel", + update: "Update", +} + +interface WorkspaceAction { + handleAction: () => void +} + +export const StartButton: FC = ({ handleAction }) => { + const styles = useStyles() + + return ( + } + onClick={handleAction} + label={Language.start} + /> + ) +} + +export const StopButton: FC = ({ handleAction }) => { + const styles = useStyles() + + return ( + } + onClick={handleAction} + label={Language.stop} + /> + ) +} + +export const DeleteButton: FC = ({ handleAction }) => { + const styles = useStyles() + + return ( + } + onClick={handleAction} + label={Language.delete} + /> + ) +} + +type UpdateAction = WorkspaceAction & { + workspace: Workspace + workspaceStatus: WorkspaceStatus +} + +export const UpdateButton: FC = ({ handleAction, workspace, workspaceStatus }) => { + const styles = useStyles() + + /** + * Jobs submitted while another job is in progress will be discarded, + * so check whether workspace job status has reached completion (whether successful or not). + */ + const canAcceptJobs = (workspaceStatus: WorkspaceStatus) => + ["started", "stopped", "deleted", "error", "canceled"].includes(workspaceStatus) + + return ( + <> + {workspace.outdated && canAcceptJobs(workspaceStatus) && ( + + )} + + ) +} + +export const CancelButton: FC = ({ handleAction }) => { + const styles = useStyles() + + return ( + } + onClick={handleAction} + label={Language.cancel} + /> + ) +} + +const useStyles = makeStyles((theme) => ({ + actionButton: { + // Set fixed width for the action buttons so they will not change the size + // during the transitions + width: theme.spacing(16), + border: "none", + borderRadius: `${theme.shape.borderRadius}px 0px 0px ${theme.shape.borderRadius}px`, + }, +})) diff --git a/site/src/components/WorkspaceActions/WorkspaceActions.stories.tsx b/site/src/components/WorkspaceActions/WorkspaceActions.stories.tsx new file mode 100644 index 0000000000000..4b48a214ace08 --- /dev/null +++ b/site/src/components/WorkspaceActions/WorkspaceActions.stories.tsx @@ -0,0 +1,79 @@ +import { action } from "@storybook/addon-actions" +import { Story } from "@storybook/react" +import * as Mocks from "../../testHelpers/entities" +import { WorkspaceActions, WorkspaceActionsProps } from "./WorkspaceActions" + +export default { + title: "components/WorkspaceActions", + component: WorkspaceActions, +} + +const Template: Story = (args) => + +const defaultArgs = { + handleStart: action("start"), + handleStop: action("stop"), + handleDelete: action("delete"), + handleUpdate: action("update"), + handleCancel: action("cancel"), +} + +export const Starting = Template.bind({}) +Starting.args = { + ...defaultArgs, + workspace: Mocks.MockStartingWorkspace, +} + +export const Started = Template.bind({}) +Started.args = { + ...defaultArgs, + workspace: Mocks.MockWorkspace, +} + +export const Stopping = Template.bind({}) +Stopping.args = { + ...defaultArgs, + workspace: Mocks.MockStoppingWorkspace, +} + +export const Stopped = Template.bind({}) +Stopped.args = { + ...defaultArgs, + workspace: Mocks.MockStoppedWorkspace, +} + +export const Canceling = Template.bind({}) +Canceling.args = { + ...defaultArgs, + workspace: Mocks.MockCancelingWorkspace, +} + +export const Canceled = Template.bind({}) +Canceled.args = { + ...defaultArgs, + workspace: Mocks.MockCanceledWorkspace, +} + +export const Deleting = Template.bind({}) +Deleting.args = { + ...defaultArgs, + workspace: Mocks.MockDeletingWorkspace, +} + +export const Deleted = Template.bind({}) +Deleted.args = { + ...defaultArgs, + workspace: Mocks.MockDeletedWorkspace, +} + +export const Outdated = Template.bind({}) +Outdated.args = { + ...defaultArgs, + workspace: Mocks.MockOutdatedWorkspace, +} + +export const Errored = Template.bind({}) +Errored.args = { + ...defaultArgs, + workspace: Mocks.MockFailedWorkspace, +} diff --git a/site/src/components/WorkspaceActions/WorkspaceActions.test.tsx b/site/src/components/WorkspaceActions/WorkspaceActions.test.tsx new file mode 100644 index 0000000000000..d916393f70026 --- /dev/null +++ b/site/src/components/WorkspaceActions/WorkspaceActions.test.tsx @@ -0,0 +1,89 @@ +import { screen } from "@testing-library/react" +import * as Mocks from "../../testHelpers/entities" +import { render } from "../../testHelpers/renderHelpers" +import { Language } from "./ActionCtas" +import { WorkspaceStateEnum } from "./constants" +import { WorkspaceActions, WorkspaceActionsProps } from "./WorkspaceActions" + +const renderAndClick = async (props: Partial = {}) => { + render( + , + ) + const trigger = await screen.findByTestId("workspace-actions-button") + trigger.click() +} + +describe("WorkspaceActions", () => { + describe("when the workspace is starting", () => { + it("primary is cancel; no secondary", async () => { + await renderAndClick({ workspace: Mocks.MockStartingWorkspace }) + expect(screen.getByTestId("primary-cta")).toHaveTextContent(Language.cancel) + expect(screen.queryByTestId("secondary-ctas")).toBeNull() + }) + }) + describe("when the workspace is started", () => { + it("primary is stop; secondary is delete", async () => { + await renderAndClick({ workspace: Mocks.MockWorkspace }) + expect(screen.getByTestId("primary-cta")).toHaveTextContent(Language.stop) + expect(screen.getByTestId("secondary-ctas")).toHaveTextContent(Language.delete) + }) + }) + describe("when the workspace is stopping", () => { + it("primary is cancel; no secondary", async () => { + await renderAndClick({ workspace: Mocks.MockStoppingWorkspace }) + expect(screen.getByTestId("primary-cta")).toHaveTextContent(Language.cancel) + expect(screen.queryByTestId("secondary-ctas")).toBeNull() + }) + }) + describe("when the workspace is canceling", () => { + it("primary is canceling; no secondary", async () => { + await renderAndClick({ workspace: Mocks.MockCancelingWorkspace }) + expect(screen.getByTestId("primary-cta")).toHaveTextContent(WorkspaceStateEnum.canceling) + expect(screen.queryByTestId("secondary-ctas")).toBeNull() + }) + }) + describe("when the workspace is canceled", () => { + it("primary is start; secondary are stop, delete", async () => { + await renderAndClick({ workspace: Mocks.MockCanceledWorkspace }) + expect(screen.getByTestId("primary-cta")).toHaveTextContent(Language.start) + expect(screen.getByTestId("secondary-ctas")).toHaveTextContent(Language.stop) + expect(screen.getByTestId("secondary-ctas")).toHaveTextContent(Language.delete) + }) + }) + describe("when the workspace is errored", () => { + it("primary is start; secondary is delete", async () => { + await renderAndClick({ workspace: Mocks.MockFailedWorkspace }) + expect(screen.getByTestId("primary-cta")).toHaveTextContent(Language.start) + expect(screen.getByTestId("secondary-ctas")).toHaveTextContent(Language.delete) + }) + }) + describe("when the workspace is deleting", () => { + it("primary is cancel; no secondary", async () => { + await renderAndClick({ workspace: Mocks.MockDeletingWorkspace }) + expect(screen.getByTestId("primary-cta")).toHaveTextContent(Language.cancel) + expect(screen.queryByTestId("secondary-ctas")).toBeNull() + }) + }) + describe("when the workspace is deleted", () => { + it("primary is deleted; no secondary", async () => { + await renderAndClick({ workspace: Mocks.MockDeletedWorkspace }) + expect(screen.getByTestId("primary-cta")).toHaveTextContent(WorkspaceStateEnum.deleted) + expect(screen.queryByTestId("secondary-ctas")).toBeNull() + }) + }) + describe("when the workspace is outdated", () => { + it("primary is start; secondary are delete, update", async () => { + await renderAndClick({ workspace: Mocks.MockOutdatedWorkspace }) + expect(screen.getByTestId("primary-cta")).toHaveTextContent(Language.start) + expect(screen.getByTestId("secondary-ctas")).toHaveTextContent(Language.delete) + expect(screen.getByTestId("secondary-ctas")).toHaveTextContent(Language.update) + }) + }) +}) diff --git a/site/src/components/WorkspaceActions/WorkspaceActions.tsx b/site/src/components/WorkspaceActions/WorkspaceActions.tsx index 4a60c15c5ccef..10cc02a486270 100644 --- a/site/src/components/WorkspaceActions/WorkspaceActions.tsx +++ b/site/src/components/WorkspaceActions/WorkspaceActions.tsx @@ -1,50 +1,12 @@ import Button from "@material-ui/core/Button" +import Popover from "@material-ui/core/Popover" import { makeStyles } from "@material-ui/core/styles" -import CancelIcon from "@material-ui/icons/Cancel" -import CloudDownloadIcon from "@material-ui/icons/CloudDownload" -import DeleteIcon from "@material-ui/icons/Delete" -import PlayArrowRoundedIcon from "@material-ui/icons/PlayArrowRounded" -import StopIcon from "@material-ui/icons/Stop" -import { FC } from "react" +import { FC, ReactNode, useEffect, useRef, useState } from "react" import { Workspace } from "../../api/typesGenerated" -import { getWorkspaceStatus, WorkspaceStatus } from "../../util/workspace" -import { Stack } from "../Stack/Stack" -import { WorkspaceActionButton } from "../WorkspaceActionButton/WorkspaceActionButton" - -export const Language = { - stop: "Stop", - stopping: "Stopping", - start: "Start", - starting: "Starting", - delete: "Delete", - deleting: "Deleting", - cancel: "Cancel action", - update: "Update", -} - -/** - * Jobs submitted while another job is in progress will be discarded, - * so check whether workspace job status has reached completion (whether successful or not). - */ -const canAcceptJobs = (workspaceStatus: WorkspaceStatus) => - ["started", "stopped", "deleted", "error", "canceled"].includes(workspaceStatus) - -/** - * Jobs that are in progress (queued or pending) can be canceled. - * @param workspaceStatus WorkspaceStatus - * @returns boolean - */ -const canCancelJobs = (workspaceStatus: WorkspaceStatus) => - ["starting", "stopping", "deleting"].includes(workspaceStatus) - -const canStart = (workspaceStatus: WorkspaceStatus) => - ["stopped", "canceled", "error"].includes(workspaceStatus) - -const canStop = (workspaceStatus: WorkspaceStatus) => - ["started", "canceled", "error"].includes(workspaceStatus) - -const canDelete = (workspaceStatus: WorkspaceStatus) => - ["started", "stopped", "canceled", "error"].includes(workspaceStatus) +import { getWorkspaceStatus } from "../../util/workspace" +import { CloseDropdown, OpenDropdown } from "../DropdownArrows/DropdownArrows" +import { CancelButton, DeleteButton, StartButton, StopButton, UpdateButton } from "./ActionCtas" +import { ButtonTypesEnum, WorkspaceStateActions, WorkspaceStateEnum } from "./constants" export interface WorkspaceActionsProps { workspace: Workspace @@ -64,62 +26,135 @@ export const WorkspaceActions: FC = ({ handleCancel, }) => { const styles = useStyles() - const workspaceStatus = getWorkspaceStatus(workspace.latest_build) + const anchorRef = useRef(null) + const [isOpen, setIsOpen] = useState(false) + const id = isOpen ? "action-popover" : undefined + + const workspaceStatus: keyof typeof WorkspaceStateEnum = getWorkspaceStatus( + workspace.latest_build, + ) + const workspaceState = WorkspaceStateEnum[workspaceStatus] + const actions = WorkspaceStateActions[workspaceState] + + /** + * Ensures we close the popover before calling any action handler + */ + useEffect(() => { + setIsOpen(false) + return () => { + setIsOpen(false) + } + }, [workspaceStatus]) + + const disabledButton = ( + + ) + + type ButtonMapping = { + [key in ButtonTypesEnum]: ReactNode + } + + // A mapping of button type to the corresponding React component + const buttonMapping: ButtonMapping = { + [ButtonTypesEnum.start]: , + [ButtonTypesEnum.stop]: , + [ButtonTypesEnum.delete]: , + [ButtonTypesEnum.update]: ( + + ), + [ButtonTypesEnum.cancel]: , + [ButtonTypesEnum.canceling]: disabledButton, + [ButtonTypesEnum.disabled]: disabledButton, + [ButtonTypesEnum.queued]: disabledButton, + [ButtonTypesEnum.error]: disabledButton, + [ButtonTypesEnum.loading]: disabledButton, + } return ( - - {canStart(workspaceStatus) && ( - } - onClick={handleStart} - label={Language.start} - /> - )} - {canStop(workspaceStatus) && ( - } - onClick={handleStop} - label={Language.stop} - /> - )} - {canDelete(workspaceStatus) && ( - } - onClick={handleDelete} - label={Language.delete} - /> - )} - {canCancelJobs(workspaceStatus) && ( - } - onClick={handleCancel} - label={Language.cancel} - /> - )} - {workspace.outdated && canAcceptJobs(workspaceStatus) && ( - - )} - + + {/* primary workspace CTA */} + {buttonMapping[actions.primary]} + + {/* popover toggle button */} + + + setIsOpen(false)} + anchorOrigin={{ + vertical: "bottom", + horizontal: "right", + }} + transformOrigin={{ + vertical: "top", + horizontal: "right", + }} + > + {/* secondary workspace CTAs */} + + {actions.secondary.map((action) => ( +
+ {buttonMapping[action]} +
+ ))} +
+
+
) } const useStyles = makeStyles((theme) => ({ + buttonContainer: { + border: `1px solid ${theme.palette.divider}`, + borderRadius: `${theme.shape.borderRadius}px`, + display: "inline-block", + }, + dropdownButton: { + border: "none", + borderLeft: `1px solid ${theme.palette.divider}`, + borderRadius: `0px ${theme.shape.borderRadius}px ${theme.shape.borderRadius}px 0px`, + minWidth: "unset", + width: "35px", + "& .MuiButton-label": { + marginRight: "8px", + }, + }, actionButton: { // Set fixed width for the action buttons so they will not change the size // during the transitions width: theme.spacing(16), + border: "none", + borderRadius: `${theme.shape.borderRadius}px 0px 0px ${theme.shape.borderRadius}px`, + }, + popoverActionButton: { + "& .MuiButtonBase-root": { + backgroundColor: "unset", + justifyContent: "start", + padding: "0px", + }, }, - cancelActionButton: { - width: theme.spacing(27), + popoverPaper: { + padding: `${theme.spacing(2)}px ${theme.spacing(3)}px ${theme.spacing(3)}px`, }, })) diff --git a/site/src/components/WorkspaceActions/constants.ts b/site/src/components/WorkspaceActions/constants.ts new file mode 100644 index 0000000000000..88531fefedab6 --- /dev/null +++ b/site/src/components/WorkspaceActions/constants.ts @@ -0,0 +1,90 @@ +// all the possible states returned by the API +export enum WorkspaceStateEnum { + starting = "Starting", + started = "Started", + stopping = "Stopping", + stopped = "Stopped", + canceling = "Canceling", + canceled = "Canceled", + deleting = "Deleting", + deleted = "Deleted", + queued = "Queued", + error = "Error", + loading = "Loading", +} + +// the button types we have +export enum ButtonTypesEnum { + start, + stop, + delete, + update, + cancel, + error, + // disabled buttons + canceling, + disabled, + queued, + loading, +} + +type StateActionsType = { + [key in WorkspaceStateEnum]: { + primary: ButtonTypesEnum + secondary: ButtonTypesEnum[] + } +} + +// A mapping of workspace state to button type +// 'Primary' actions are the main ctas +// 'Secondary' actions are ctas housed within the popover +export const WorkspaceStateActions: StateActionsType = { + [WorkspaceStateEnum.starting]: { + primary: ButtonTypesEnum.cancel, + secondary: [], + }, + [WorkspaceStateEnum.started]: { + primary: ButtonTypesEnum.stop, + secondary: [ButtonTypesEnum.delete, ButtonTypesEnum.update], + }, + [WorkspaceStateEnum.stopping]: { + primary: ButtonTypesEnum.cancel, + secondary: [], + }, + [WorkspaceStateEnum.stopped]: { + primary: ButtonTypesEnum.start, + secondary: [ButtonTypesEnum.delete, ButtonTypesEnum.update], + }, + [WorkspaceStateEnum.canceled]: { + primary: ButtonTypesEnum.start, + secondary: [ButtonTypesEnum.stop, ButtonTypesEnum.delete, ButtonTypesEnum.update], + }, + // in the case of an error + [WorkspaceStateEnum.error]: { + primary: ButtonTypesEnum.start, // give the user the ability to start a workspace again + secondary: [ButtonTypesEnum.delete, ButtonTypesEnum.update], // allows the user to delete or update + }, + /** + * disabled states + */ + [WorkspaceStateEnum.canceling]: { + primary: ButtonTypesEnum.canceling, + secondary: [], + }, + [WorkspaceStateEnum.deleting]: { + primary: ButtonTypesEnum.cancel, + secondary: [], + }, + [WorkspaceStateEnum.deleted]: { + primary: ButtonTypesEnum.disabled, + secondary: [], + }, + [WorkspaceStateEnum.queued]: { + primary: ButtonTypesEnum.queued, + secondary: [], + }, + [WorkspaceStateEnum.loading]: { + primary: ButtonTypesEnum.loading, + secondary: [], + }, +} diff --git a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx index 88cb09c6591fa..35b9d2243ac5e 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx @@ -2,7 +2,7 @@ import { fireEvent, screen, waitFor, within } from "@testing-library/react" import { rest } from "msw" import * as api from "../../api/api" import { Workspace } from "../../api/typesGenerated" -import { Language } from "../../components/WorkspaceActions/WorkspaceActions" +import { Language } from "../../components/WorkspaceActions/ActionCtas" import { MockBuilds, MockCanceledWorkspace, @@ -43,6 +43,9 @@ const renderWorkspacePage = async () => { const testButton = async (label: string, actionMock: jest.SpyInstance) => { await renderWorkspacePage() + // open the workspace action popover so we have access to all available ctas + const trigger = await screen.findByTestId("workspace-actions-button") + trigger.click() // REMARK: exact here because the "Start" button and "START" label for // workspace schedule could otherwise conflict. const button = await screen.findByText(label, { exact: true }) @@ -87,6 +90,11 @@ describe("Workspace Page", () => { .spyOn(api, "deleteWorkspace") .mockResolvedValueOnce(MockWorkspaceBuild) await renderWorkspacePage() + + // open the workspace action popover so we have access to all available ctas + const trigger = await screen.findByTestId("workspace-actions-button") + trigger.click() + const button = await screen.findByText(Language.delete) await waitFor(() => fireEvent.click(button)) const confirmDialog = await screen.findByRole("dialog")