mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user