diff --git a/site/src/components/LoadingButton/LoadingButton.tsx b/site/src/components/LoadingButton/LoadingButton.tsx index 9f8aaa3d592a7..0e4e51bfbc6a8 100644 --- a/site/src/components/LoadingButton/LoadingButton.tsx +++ b/site/src/components/LoadingButton/LoadingButton.tsx @@ -1,11 +1,14 @@ import Button, { ButtonProps } from "@material-ui/core/Button" import CircularProgress from "@material-ui/core/CircularProgress" import { makeStyles } from "@material-ui/core/styles" -import * as React from "react" +import { Theme } from "@material-ui/core/styles/createMuiTheme" +import { FC } from "react" export interface LoadingButtonProps extends ButtonProps { /** Whether or not to disable the button and show a spinner */ loading?: boolean + /** An optional label to display with the loading spinner */ + loadingLabel?: string } /** @@ -14,12 +17,13 @@ export interface LoadingButtonProps extends ButtonProps { * In Material-UI 5+ - this is built-in, but since we're on an earlier version, * we have to roll our own. */ -export const LoadingButton: React.FC = ({ +export const LoadingButton: FC = ({ loading = false, + loadingLabel, children, ...rest }) => { - const styles = useStyles() + const styles = useStyles({ hasLoadingLabel: !!loadingLabel }) const hidden = loading ? { opacity: 0 } : undefined return ( @@ -30,17 +34,35 @@ export const LoadingButton: React.FC = ({ )} + {!!loadingLabel && loadingLabel} ) } -const useStyles = makeStyles((theme) => ({ +interface StyleProps { + hasLoadingLabel?: boolean +} + +const useStyles = makeStyles((theme) => ({ loader: { - position: "absolute", + position: (props) => { + if (!props.hasLoadingLabel) { + return "absolute" + } + }, + transform: (props) => { + if (!props.hasLoadingLabel) { + return "translate(-50%, -50%)" + } + }, + marginRight: (props) => { + if (props.hasLoadingLabel) { + return "10px" + } + }, top: "50%", left: "50%", - transform: "translate(-50%, -50%)", - height: 18, + height: 22, // centering loading icon width: 18, }, spinner: { diff --git a/site/src/components/WorkspaceActionButton/WorkspaceActionButton.tsx b/site/src/components/WorkspaceActionButton/WorkspaceActionButton.tsx index 89fec93dccf99..212da090b7e52 100644 --- a/site/src/components/WorkspaceActionButton/WorkspaceActionButton.tsx +++ b/site/src/components/WorkspaceActionButton/WorkspaceActionButton.tsx @@ -2,10 +2,11 @@ import Button from "@material-ui/core/Button" import { FC } from "react" export interface WorkspaceActionButtonProps { - label: string + label?: string icon: JSX.Element onClick: () => void className?: string + ariaLabel?: string } export const WorkspaceActionButton: FC = ({ @@ -13,10 +14,11 @@ export const WorkspaceActionButton: FC = ({ icon, onClick, className, + ariaLabel, }) => { return ( - ) } diff --git a/site/src/components/WorkspaceActions/ActionCtas.tsx b/site/src/components/WorkspaceActions/ActionCtas.tsx index 1cf762ad42096..2240701c47db4 100644 --- a/site/src/components/WorkspaceActions/ActionCtas.tsx +++ b/site/src/components/WorkspaceActions/ActionCtas.tsx @@ -1,12 +1,15 @@ import Button from "@material-ui/core/Button" import { makeStyles } from "@material-ui/core/styles" +import BlockIcon from "@material-ui/icons/Block" 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 { LoadingButton } from "components/LoadingButton/LoadingButton" import { FC } from "react" +import { combineClasses } from "util/combineClasses" import { WorkspaceActionButton } from "../WorkspaceActionButton/WorkspaceActionButton" +import { WorkspaceStateEnum } from "./constants" export const Language = { start: "Start", @@ -14,6 +17,10 @@ export const Language = { delete: "Delete", cancel: "Cancel", update: "Update", + // these labels are used in WorkspaceActions.tsx + starting: "Starting...", + stopping: "Stopping...", + deleting: "Deleting...", } interface WorkspaceAction { @@ -72,12 +79,42 @@ export const DeleteButton: FC = ({ handleAction }) => { export const CancelButton: FC = ({ handleAction }) => { const styles = useStyles() + // this is an icon button, so it's important to include an aria label return ( } + icon={} onClick={handleAction} - label={Language.cancel} + className={styles.cancelButton} + ariaLabel="cancel action" + /> + ) +} + +interface DisabledProps { + workspaceState: WorkspaceStateEnum +} + +export const DisabledButton: FC = ({ workspaceState }) => { + const styles = useStyles() + + return ( + + ) +} + +interface LoadingProps { + label: string +} + +export const ActionLoadingButton: FC = ({ label }) => { + const styles = useStyles() + return ( + ) } @@ -86,8 +123,26 @@ 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), + width: theme.spacing(20), border: "none", borderRadius: `${theme.shape.borderRadius}px 0px 0px ${theme.shape.borderRadius}px`, }, + cancelButton: { + "&.MuiButton-root": { + padding: "0px 0px !important", + border: "none", + borderLeft: `1px solid ${theme.palette.divider}`, + borderRadius: `0px ${theme.shape.borderRadius}px ${theme.shape.borderRadius}px 0px`, + width: "63px", // matching dropdown button so button grouping doesn't grow in size + }, + "& .MuiButton-label": { + marginLeft: "10px", + }, + }, + // this is all custom to work with our button wrapper + loadingButton: { + border: "none", + borderLeft: "1px solid #333740", // MUI disabled button + borderRadius: "3px 0px 0px 3px", + }, })) diff --git a/site/src/components/WorkspaceActions/DropdownContent/DropdownContent.stories.tsx b/site/src/components/WorkspaceActions/DropdownContent/DropdownContent.stories.tsx new file mode 100644 index 0000000000000..edde3acabdb64 --- /dev/null +++ b/site/src/components/WorkspaceActions/DropdownContent/DropdownContent.stories.tsx @@ -0,0 +1,54 @@ +import { Story } from "@storybook/react" +import { DeleteButton, StartButton, StopButton } from "../ActionCtas" +import { + ButtonMapping, + ButtonTypesEnum, + WorkspaceStateActions, + WorkspaceStateEnum, +} from "../constants" +import { DropdownContent, DropdownContentProps } from "./DropdownContent" + +// These are the stories for the secondary actions (housed in the dropdown) +// in WorkspaceActions.tsx + +export default { + title: "WorkspaceActionsDropdown", + component: DropdownContent, +} + +const Template: Story = (args) => + +const buttonMappingMock: Partial = { + [ButtonTypesEnum.delete]: jest.fn()} />, + [ButtonTypesEnum.start]: jest.fn()} />, + [ButtonTypesEnum.stop]: jest.fn()} />, + [ButtonTypesEnum.delete]: jest.fn()} />, +} + +const defaultArgs = { + buttonMapping: buttonMappingMock, +} + +export const Started = Template.bind({}) +Started.args = { + ...defaultArgs, + secondaryActions: WorkspaceStateActions[WorkspaceStateEnum.started].secondary, +} + +export const Stopped = Template.bind({}) +Stopped.args = { + ...defaultArgs, + secondaryActions: WorkspaceStateActions[WorkspaceStateEnum.stopped].secondary, +} + +export const Canceled = Template.bind({}) +Canceled.args = { + ...defaultArgs, + secondaryActions: WorkspaceStateActions[WorkspaceStateEnum.canceled].secondary, +} + +export const Errored = Template.bind({}) +Errored.args = { + ...defaultArgs, + secondaryActions: WorkspaceStateActions[WorkspaceStateEnum.error].secondary, +} diff --git a/site/src/components/WorkspaceActions/DropdownContent/DropdownContent.tsx b/site/src/components/WorkspaceActions/DropdownContent/DropdownContent.tsx new file mode 100644 index 0000000000000..f4da8f13eb6ad --- /dev/null +++ b/site/src/components/WorkspaceActions/DropdownContent/DropdownContent.tsx @@ -0,0 +1,33 @@ +import { makeStyles } from "@material-ui/core/styles" +import { FC } from "react" +import { ButtonMapping, ButtonTypesEnum } from "../constants" + +export interface DropdownContentProps { + secondaryActions: ButtonTypesEnum[] + buttonMapping: Partial +} + +/* secondary workspace CTAs */ +export const DropdownContent: FC = ({ secondaryActions, buttonMapping }) => { + const styles = useStyles() + + return ( + + {secondaryActions.map((action) => ( +
+ {buttonMapping[action]} +
+ ))} +
+ ) +} + +const useStyles = makeStyles(() => ({ + popoverActionButton: { + "& .MuiButtonBase-root": { + backgroundColor: "unset", + justifyContent: "start", + padding: "0px", + }, + }, +})) diff --git a/site/src/components/WorkspaceActions/WorkspaceActions.test.tsx b/site/src/components/WorkspaceActions/WorkspaceActions.test.tsx index ca8b84d0e2d68..81d29c7222ec3 100644 --- a/site/src/components/WorkspaceActions/WorkspaceActions.test.tsx +++ b/site/src/components/WorkspaceActions/WorkspaceActions.test.tsx @@ -5,6 +5,19 @@ import { Language } from "./ActionCtas" import { WorkspaceStateEnum } from "./constants" import { WorkspaceActions, WorkspaceActionsProps } from "./WorkspaceActions" +const renderComponent = async (props: Partial = {}) => { + render( + , + ) +} + const renderAndClick = async (props: Partial = {}) => { render( = {}) => { 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) + it("primary is starting; cancel is available; no secondary", async () => { + await renderComponent({ workspace: Mocks.MockStartingWorkspace }) + expect(screen.getByTestId("primary-cta")).toHaveTextContent(Language.starting) + expect( + screen.getByRole("button", { + name: "cancel action", + }), + ).toBeInTheDocument() expect(screen.queryByTestId("secondary-ctas")).toBeNull() }) }) @@ -36,9 +54,14 @@ describe("WorkspaceActions", () => { }) }) 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) + it("primary is stopping; cancel is available; no secondary", async () => { + await renderComponent({ workspace: Mocks.MockStoppingWorkspace }) + expect(screen.getByTestId("primary-cta")).toHaveTextContent(Language.stopping) + expect( + screen.getByRole("button", { + name: "cancel action", + }), + ).toBeInTheDocument() expect(screen.queryByTestId("secondary-ctas")).toBeNull() }) }) @@ -65,9 +88,14 @@ describe("WorkspaceActions", () => { }) }) 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) + it("primary is deleting; cancel is available; no secondary", async () => { + await renderComponent({ workspace: Mocks.MockDeletingWorkspace }) + expect(screen.getByTestId("primary-cta")).toHaveTextContent(Language.deleting) + expect( + screen.getByRole("button", { + name: "cancel action", + }), + ).toBeInTheDocument() expect(screen.queryByTestId("secondary-ctas")).toBeNull() }) }) diff --git a/site/src/components/WorkspaceActions/WorkspaceActions.tsx b/site/src/components/WorkspaceActions/WorkspaceActions.tsx index cfb4c5be47d6f..e4c2339194f8e 100644 --- a/site/src/components/WorkspaceActions/WorkspaceActions.tsx +++ b/site/src/components/WorkspaceActions/WorkspaceActions.tsx @@ -1,12 +1,27 @@ import Button from "@material-ui/core/Button" import Popover from "@material-ui/core/Popover" import { makeStyles } from "@material-ui/core/styles" -import { FC, ReactNode, useEffect, useMemo, useRef, useState } from "react" +import { FC, useEffect, useMemo, useRef, useState } from "react" import { Workspace } from "../../api/typesGenerated" import { getWorkspaceStatus, WorkspaceStatus } from "../../util/workspace" import { CloseDropdown, OpenDropdown } from "../DropdownArrows/DropdownArrows" -import { CancelButton, DeleteButton, StartButton, StopButton, UpdateButton } from "./ActionCtas" -import { ButtonTypesEnum, WorkspaceStateActions, WorkspaceStateEnum } from "./constants" +import { + ActionLoadingButton, + CancelButton, + DeleteButton, + DisabledButton, + Language, + StartButton, + StopButton, + UpdateButton, +} from "./ActionCtas" +import { + ButtonMapping, + ButtonTypesEnum, + WorkspaceStateActions, + WorkspaceStateEnum, +} from "./constants" +import { DropdownContent } from "./DropdownContent/DropdownContent" /** * Jobs submitted while another job is in progress will be discarded, @@ -69,74 +84,66 @@ export const WorkspaceActions: FC = ({ } }, [workspaceStatus]) - const disabledButton = ( - - ) - - type ButtonMapping = { - [key in ButtonTypesEnum]: ReactNode - } - // A mapping of button type to the corresponding React component const buttonMapping: ButtonMapping = { [ButtonTypesEnum.update]: , [ButtonTypesEnum.start]: , + [ButtonTypesEnum.starting]: , [ButtonTypesEnum.stop]: , + [ButtonTypesEnum.stopping]: , [ButtonTypesEnum.delete]: , + [ButtonTypesEnum.deleting]: , [ButtonTypesEnum.cancel]: , - [ButtonTypesEnum.canceling]: disabledButton, - [ButtonTypesEnum.disabled]: disabledButton, - [ButtonTypesEnum.queued]: disabledButton, - [ButtonTypesEnum.error]: disabledButton, - [ButtonTypesEnum.loading]: disabledButton, + [ButtonTypesEnum.canceling]: , + [ButtonTypesEnum.disabled]: , + [ButtonTypesEnum.queued]: , + [ButtonTypesEnum.error]: , + [ButtonTypesEnum.loading]: , } return ( {/* 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]} -
- ))} -
-
+ {actions.canCancel ? ( + // cancel CTA + <>{buttonMapping[ButtonTypesEnum.cancel]} + ) : ( + <> + {/* popover toggle button */} + + setIsOpen(false)} + anchorOrigin={{ + vertical: "bottom", + horizontal: "right", + }} + transformOrigin={{ + vertical: "top", + horizontal: "right", + }} + > + {/* secondary workspace CTAs */} + + + + )}
) } @@ -152,25 +159,11 @@ const useStyles = makeStyles((theme) => ({ borderLeft: `1px solid ${theme.palette.divider}`, borderRadius: `0px ${theme.shape.borderRadius}px ${theme.shape.borderRadius}px 0px`, minWidth: "unset", - width: "35px", + width: "63px", // matching cancel button so button grouping doesn't grow in size "& .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", - }, - }, 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 index 38391b1ec6218..ecb7292a0d6ed 100644 --- a/site/src/components/WorkspaceActions/constants.ts +++ b/site/src/components/WorkspaceActions/constants.ts @@ -1,3 +1,5 @@ +import { ReactNode } from "react" + // all the possible states returned by the API export enum WorkspaceStateEnum { starting = "Starting", @@ -16,8 +18,11 @@ export enum WorkspaceStateEnum { // the button types we have export enum ButtonTypesEnum { start, + starting, stop, + stopping, delete, + deleting, update, cancel, error, @@ -28,10 +33,15 @@ export enum ButtonTypesEnum { loading, } +export type ButtonMapping = { + [key in ButtonTypesEnum]: ReactNode +} + type StateActionsType = { [key in WorkspaceStateEnum]: { primary: ButtonTypesEnum secondary: ButtonTypesEnum[] + canCancel: boolean } } @@ -40,29 +50,35 @@ type StateActionsType = { // 'Secondary' actions are ctas housed within the popover export const WorkspaceStateActions: StateActionsType = { [WorkspaceStateEnum.starting]: { - primary: ButtonTypesEnum.cancel, + primary: ButtonTypesEnum.starting, secondary: [], + canCancel: true, }, [WorkspaceStateEnum.started]: { primary: ButtonTypesEnum.stop, secondary: [ButtonTypesEnum.delete], + canCancel: false, }, [WorkspaceStateEnum.stopping]: { - primary: ButtonTypesEnum.cancel, + primary: ButtonTypesEnum.stopping, secondary: [], + canCancel: true, }, [WorkspaceStateEnum.stopped]: { primary: ButtonTypesEnum.start, secondary: [ButtonTypesEnum.delete], + canCancel: false, }, [WorkspaceStateEnum.canceled]: { primary: ButtonTypesEnum.start, secondary: [ButtonTypesEnum.stop, ButtonTypesEnum.delete], + canCancel: false, }, // in the case of an error [WorkspaceStateEnum.error]: { primary: ButtonTypesEnum.start, // give the user the ability to start a workspace again secondary: [ButtonTypesEnum.delete], // allows the user to delete + canCancel: false, }, /** * disabled states @@ -70,21 +86,26 @@ export const WorkspaceStateActions: StateActionsType = { [WorkspaceStateEnum.canceling]: { primary: ButtonTypesEnum.canceling, secondary: [], + canCancel: false, }, [WorkspaceStateEnum.deleting]: { - primary: ButtonTypesEnum.cancel, + primary: ButtonTypesEnum.deleting, secondary: [], + canCancel: true, }, [WorkspaceStateEnum.deleted]: { primary: ButtonTypesEnum.disabled, secondary: [], + canCancel: false, }, [WorkspaceStateEnum.queued]: { primary: ButtonTypesEnum.queued, secondary: [], + canCancel: false, }, [WorkspaceStateEnum.loading]: { primary: ButtonTypesEnum.loading, secondary: [], + canCancel: false, }, } diff --git a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx index 35b9d2243ac5e..25296de6001b3 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx @@ -43,9 +43,6 @@ 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 }) @@ -122,7 +119,15 @@ describe("Workspace Page", () => { const cancelWorkspaceMock = jest .spyOn(api, "cancelWorkspaceBuild") .mockImplementation(() => Promise.resolve({ message: "job canceled" })) - await testButton(Language.cancel, cancelWorkspaceMock) + + await renderWorkspacePage() + + const cancelButton = await screen.findByRole("button", { + name: "cancel action", + }) + await waitFor(() => fireEvent.click(cancelButton)) + + expect(cancelWorkspaceMock).toBeCalled() }) it("requests a template when the user presses Update", async () => { const getTemplateMock = jest.spyOn(api, "getTemplate").mockResolvedValueOnce(MockTemplate)