feat: showcase workspace state in actions dropdown (#3133)

* show progress indicator within workspace dropdown

resolves #2020

* wrote tests

* fix loading button

* PR feedback

* added stories for dropdown content

* PR feedbac
This commit is contained in:
Kira Pilot
2022-07-25 18:12:59 -04:00
committed by GitHub
parent b0957f32e3
commit 9bf5537b0f
9 changed files with 317 additions and 104 deletions
@@ -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<LoadingButtonProps> = ({
export const LoadingButton: FC<LoadingButtonProps> = ({
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<LoadingButtonProps> = ({
<CircularProgress size={18} className={styles.spinner} />
</div>
)}
{!!loadingLabel && loadingLabel}
</Button>
)
}
const useStyles = makeStyles((theme) => ({
interface StyleProps {
hasLoadingLabel?: boolean
}
const useStyles = makeStyles<Theme, StyleProps>((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: {
@@ -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<WorkspaceActionButtonProps> = ({
@@ -13,10 +14,11 @@ export const WorkspaceActionButton: FC<WorkspaceActionButtonProps> = ({
icon,
onClick,
className,
ariaLabel,
}) => {
return (
<Button className={className} startIcon={icon} onClick={onClick}>
{label}
<Button className={className} startIcon={icon} onClick={onClick} aria-label={ariaLabel}>
{!!label && label}
</Button>
)
}
@@ -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<WorkspaceAction> = ({ handleAction }) => {
export const CancelButton: FC<WorkspaceAction> = ({ handleAction }) => {
const styles = useStyles()
// this is an icon button, so it's important to include an aria label
return (
<WorkspaceActionButton
className={styles.actionButton}
icon={<HighlightOffIcon />}
icon={<BlockIcon />}
onClick={handleAction}
label={Language.cancel}
className={styles.cancelButton}
ariaLabel="cancel action"
/>
)
}
interface DisabledProps {
workspaceState: WorkspaceStateEnum
}
export const DisabledButton: FC<DisabledProps> = ({ workspaceState }) => {
const styles = useStyles()
return (
<Button disabled className={styles.actionButton}>
{workspaceState}
</Button>
)
}
interface LoadingProps {
label: string
}
export const ActionLoadingButton: FC<LoadingProps> = ({ label }) => {
const styles = useStyles()
return (
<LoadingButton
loading
loadingLabel={label}
className={combineClasses([styles.loadingButton, styles.actionButton])}
/>
)
}
@@ -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",
},
}))
@@ -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<DropdownContentProps> = (args) => <DropdownContent {...args} />
const buttonMappingMock: Partial<ButtonMapping> = {
[ButtonTypesEnum.delete]: <DeleteButton handleAction={() => jest.fn()} />,
[ButtonTypesEnum.start]: <StartButton handleAction={() => jest.fn()} />,
[ButtonTypesEnum.stop]: <StopButton handleAction={() => jest.fn()} />,
[ButtonTypesEnum.delete]: <DeleteButton handleAction={() => 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,
}
@@ -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<ButtonMapping>
}
/* secondary workspace CTAs */
export const DropdownContent: FC<DropdownContentProps> = ({ secondaryActions, buttonMapping }) => {
const styles = useStyles()
return (
<span data-testid="secondary-ctas">
{secondaryActions.map((action) => (
<div key={action} className={styles.popoverActionButton}>
{buttonMapping[action]}
</div>
))}
</span>
)
}
const useStyles = makeStyles(() => ({
popoverActionButton: {
"& .MuiButtonBase-root": {
backgroundColor: "unset",
justifyContent: "start",
padding: "0px",
},
},
}))
@@ -5,6 +5,19 @@ import { Language } from "./ActionCtas"
import { WorkspaceStateEnum } from "./constants"
import { WorkspaceActions, WorkspaceActionsProps } from "./WorkspaceActions"
const renderComponent = async (props: Partial<WorkspaceActionsProps> = {}) => {
render(
<WorkspaceActions
workspace={props.workspace ?? Mocks.MockWorkspace}
handleStart={jest.fn()}
handleStop={jest.fn()}
handleDelete={jest.fn()}
handleUpdate={jest.fn()}
handleCancel={jest.fn()}
/>,
)
}
const renderAndClick = async (props: Partial<WorkspaceActionsProps> = {}) => {
render(
<WorkspaceActions
@@ -22,9 +35,14 @@ const renderAndClick = async (props: Partial<WorkspaceActionsProps> = {}) => {
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()
})
})
@@ -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<WorkspaceActionsProps> = ({
}
}, [workspaceStatus])
const disabledButton = (
<Button disabled className={styles.actionButton}>
{workspaceState}
</Button>
)
type ButtonMapping = {
[key in ButtonTypesEnum]: ReactNode
}
// A mapping of button type to the corresponding React component
const buttonMapping: ButtonMapping = {
[ButtonTypesEnum.update]: <UpdateButton handleAction={handleUpdate} />,
[ButtonTypesEnum.start]: <StartButton handleAction={handleStart} />,
[ButtonTypesEnum.starting]: <ActionLoadingButton label={Language.starting} />,
[ButtonTypesEnum.stop]: <StopButton handleAction={handleStop} />,
[ButtonTypesEnum.stopping]: <ActionLoadingButton label={Language.stopping} />,
[ButtonTypesEnum.delete]: <DeleteButton handleAction={handleDelete} />,
[ButtonTypesEnum.deleting]: <ActionLoadingButton label={Language.deleting} />,
[ButtonTypesEnum.cancel]: <CancelButton handleAction={handleCancel} />,
[ButtonTypesEnum.canceling]: disabledButton,
[ButtonTypesEnum.disabled]: disabledButton,
[ButtonTypesEnum.queued]: disabledButton,
[ButtonTypesEnum.error]: disabledButton,
[ButtonTypesEnum.loading]: disabledButton,
[ButtonTypesEnum.canceling]: <DisabledButton workspaceState={workspaceState} />,
[ButtonTypesEnum.disabled]: <DisabledButton workspaceState={workspaceState} />,
[ButtonTypesEnum.queued]: <DisabledButton workspaceState={workspaceState} />,
[ButtonTypesEnum.error]: <DisabledButton workspaceState={workspaceState} />,
[ButtonTypesEnum.loading]: <DisabledButton workspaceState={workspaceState} />,
}
return (
<span className={styles.buttonContainer}>
{/* primary workspace CTA */}
<span data-testid="primary-cta">{buttonMapping[actions.primary]}</span>
{/* popover toggle button */}
<Button
data-testid="workspace-actions-button"
aria-controls="workspace-actions-menu"
aria-haspopup="true"
className={styles.dropdownButton}
ref={anchorRef}
disabled={!actions.secondary.length}
onClick={() => {
setIsOpen(true)
}}
>
{isOpen ? <CloseDropdown /> : <OpenDropdown />}
</Button>
<Popover
classes={{ paper: styles.popoverPaper }}
id={id}
open={isOpen}
anchorEl={anchorRef.current}
onClose={() => setIsOpen(false)}
anchorOrigin={{
vertical: "bottom",
horizontal: "right",
}}
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
>
{/* secondary workspace CTAs */}
<span data-testid="secondary-ctas">
{actions.secondary.map((action) => (
<div key={action} className={styles.popoverActionButton}>
{buttonMapping[action]}
</div>
))}
</span>
</Popover>
{actions.canCancel ? (
// cancel CTA
<>{buttonMapping[ButtonTypesEnum.cancel]}</>
) : (
<>
{/* popover toggle button */}
<Button
data-testid="workspace-actions-button"
aria-controls="workspace-actions-menu"
aria-haspopup="true"
className={styles.dropdownButton}
ref={anchorRef}
disabled={!actions.secondary.length}
onClick={() => {
setIsOpen(true)
}}
>
{isOpen ? <CloseDropdown /> : <OpenDropdown />}
</Button>
<Popover
classes={{ paper: styles.popoverPaper }}
id={id}
open={isOpen}
anchorEl={anchorRef.current}
onClose={() => setIsOpen(false)}
anchorOrigin={{
vertical: "bottom",
horizontal: "right",
}}
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
>
{/* secondary workspace CTAs */}
<DropdownContent secondaryActions={actions.secondary} buttonMapping={buttonMapping} />
</Popover>
</>
)}
</span>
)
}
@@ -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`,
},
@@ -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,
},
}
@@ -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)