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") pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy