feat: consolidate workspace buttons/kira pilot (#2996)

* added workspace cta dropdown

resolves #2748

* added tests

* fixed failing tests

* clean up snapshots
This commit is contained in:
Kira Pilot
2022-07-14 16:47:10 -04:00
committed by GitHub
parent aea3b3b83e
commit eebf0dd736
6 changed files with 507 additions and 91 deletions
@@ -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<WorkspaceAction> = ({ handleAction }) => {
const styles = useStyles()
return (
<WorkspaceActionButton
className={styles.actionButton}
icon={<PlayCircleOutlineIcon />}
onClick={handleAction}
label={Language.start}
/>
)
}
export const StopButton: FC<WorkspaceAction> = ({ handleAction }) => {
const styles = useStyles()
return (
<WorkspaceActionButton
className={styles.actionButton}
icon={<CropSquareIcon />}
onClick={handleAction}
label={Language.stop}
/>
)
}
export const DeleteButton: FC<WorkspaceAction> = ({ handleAction }) => {
const styles = useStyles()
return (
<WorkspaceActionButton
className={styles.actionButton}
icon={<DeleteOutlineIcon />}
onClick={handleAction}
label={Language.delete}
/>
)
}
type UpdateAction = WorkspaceAction & {
workspace: Workspace
workspaceStatus: WorkspaceStatus
}
export const UpdateButton: FC<UpdateAction> = ({ 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) && (
<Button
className={styles.actionButton}
startIcon={<CloudQueueIcon />}
onClick={handleAction}
>
{Language.update}
</Button>
)}
</>
)
}
export const CancelButton: FC<WorkspaceAction> = ({ handleAction }) => {
const styles = useStyles()
return (
<WorkspaceActionButton
className={styles.actionButton}
icon={<HighlightOffIcon />}
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`,
},
}))
@@ -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<WorkspaceActionsProps> = (args) => <WorkspaceActions {...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,
}
@@ -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<WorkspaceActionsProps> = {}) => {
render(
<WorkspaceActions
workspace={props.workspace ?? Mocks.MockWorkspace}
handleStart={jest.fn()}
handleStop={jest.fn()}
handleDelete={jest.fn()}
handleUpdate={jest.fn()}
handleCancel={jest.fn()}
/>,
)
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)
})
})
})
@@ -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<WorkspaceActionsProps> = ({
handleCancel,
}) => {
const styles = useStyles()
const workspaceStatus = getWorkspaceStatus(workspace.latest_build)
const anchorRef = useRef<HTMLButtonElement>(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 = (
<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.start]: <StartButton handleAction={handleStart} />,
[ButtonTypesEnum.stop]: <StopButton handleAction={handleStop} />,
[ButtonTypesEnum.delete]: <DeleteButton handleAction={handleDelete} />,
[ButtonTypesEnum.update]: (
<UpdateButton
handleAction={handleUpdate}
workspace={workspace}
workspaceStatus={workspaceStatus}
/>
),
[ButtonTypesEnum.cancel]: <CancelButton handleAction={handleCancel} />,
[ButtonTypesEnum.canceling]: disabledButton,
[ButtonTypesEnum.disabled]: disabledButton,
[ButtonTypesEnum.queued]: disabledButton,
[ButtonTypesEnum.error]: disabledButton,
[ButtonTypesEnum.loading]: disabledButton,
}
return (
<Stack direction="row" spacing={1}>
{canStart(workspaceStatus) && (
<WorkspaceActionButton
className={styles.actionButton}
icon={<PlayArrowRoundedIcon />}
onClick={handleStart}
label={Language.start}
/>
)}
{canStop(workspaceStatus) && (
<WorkspaceActionButton
className={styles.actionButton}
icon={<StopIcon />}
onClick={handleStop}
label={Language.stop}
/>
)}
{canDelete(workspaceStatus) && (
<WorkspaceActionButton
className={styles.actionButton}
icon={<DeleteIcon />}
onClick={handleDelete}
label={Language.delete}
/>
)}
{canCancelJobs(workspaceStatus) && (
<WorkspaceActionButton
className={styles.cancelActionButton}
icon={<CancelIcon />}
onClick={handleCancel}
label={Language.cancel}
/>
)}
{workspace.outdated && canAcceptJobs(workspaceStatus) && (
<Button
className={styles.actionButton}
startIcon={<CloudDownloadIcon />}
onClick={handleUpdate}
>
{Language.update}
</Button>
)}
</Stack>
<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>
</span>
)
}
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`,
},
cancelActionButton: {
width: theme.spacing(27),
popoverActionButton: {
"& .MuiButtonBase-root": {
backgroundColor: "unset",
justifyContent: "start",
padding: "0px",
},
},
popoverPaper: {
padding: `${theme.spacing(2)}px ${theme.spacing(3)}px ${theme.spacing(3)}px`,
},
}))
@@ -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: [],
},
}
@@ -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")