refactor(site): Refactor workspace actions (#7124)

This commit is contained in:
Bruno Quaresma
2023-04-14 15:28:52 +00:00
committed by GitHub
parent 9c0f59e3e5
commit 7bbbb91df5
14 changed files with 294 additions and 764 deletions
@@ -1,208 +0,0 @@
import Tooltip from "@material-ui/core/Tooltip"
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 SettingsOutlined from "@material-ui/icons/SettingsOutlined"
import HistoryOutlined from "@material-ui/icons/HistoryOutlined"
import CropSquareIcon from "@material-ui/icons/CropSquare"
import DeleteOutlineIcon from "@material-ui/icons/DeleteOutline"
import PlayCircleOutlineIcon from "@material-ui/icons/PlayCircleOutline"
import { LoadingButton } from "components/LoadingButton/LoadingButton"
import { FC } from "react"
import { useTranslation } from "react-i18next"
import { combineClasses } from "utils/combineClasses"
import { WorkspaceActionButton } from "../WorkspaceActionButton/WorkspaceActionButton"
interface WorkspaceAction {
handleAction: () => void
}
export const UpdateButton: FC<React.PropsWithChildren<WorkspaceAction>> = ({
handleAction,
}) => {
const styles = useStyles()
const { t } = useTranslation("workspacePage")
return (
<Button
variant="outlined"
className={styles.actionButton}
startIcon={<CloudQueueIcon />}
onClick={handleAction}
>
{t("actionButton.update")}
</Button>
)
}
export const SettingsButton: FC<React.PropsWithChildren<WorkspaceAction>> = ({
handleAction,
}) => {
const styles = useStyles()
const { t } = useTranslation("workspacePage")
return (
<Button
variant="outlined"
className={styles.actionButton}
startIcon={<SettingsOutlined />}
onClick={handleAction}
>
{t("actionButton.settings")}
</Button>
)
}
export const ChangeVersionButton: FC<
React.PropsWithChildren<WorkspaceAction>
> = ({ handleAction }) => {
const styles = useStyles()
return (
<Button
variant="outlined"
className={styles.actionButton}
startIcon={<HistoryOutlined />}
onClick={handleAction}
>
Change version
</Button>
)
}
export const StartButton: FC<React.PropsWithChildren<WorkspaceAction>> = ({
handleAction,
}) => {
const styles = useStyles()
const { t } = useTranslation("workspacePage")
return (
<WorkspaceActionButton
className={styles.actionButton}
icon={<PlayCircleOutlineIcon />}
onClick={handleAction}
label={t("actionButton.start")}
/>
)
}
export const StopButton: FC<React.PropsWithChildren<WorkspaceAction>> = ({
handleAction,
}) => {
const styles = useStyles()
const { t } = useTranslation("workspacePage")
return (
<WorkspaceActionButton
className={styles.actionButton}
icon={<CropSquareIcon />}
onClick={handleAction}
label={t("actionButton.stop")}
/>
)
}
export const DeleteButton: FC<React.PropsWithChildren<WorkspaceAction>> = ({
handleAction,
}) => {
const styles = useStyles()
const { t } = useTranslation("workspacePage")
return (
<WorkspaceActionButton
className={styles.actionButton}
icon={<DeleteOutlineIcon />}
onClick={handleAction}
label={t("actionButton.delete")}
/>
)
}
export const CancelButton: FC<React.PropsWithChildren<WorkspaceAction>> = ({
handleAction,
}) => {
const styles = useStyles()
// this is an icon button, so it's important to include an aria label
return (
<div>
<Tooltip title="Cancel action">
{/* We had to wrap the button to make it work with the tooltip. */}
<div>
<WorkspaceActionButton
icon={<BlockIcon />}
onClick={handleAction}
className={styles.cancelButton}
ariaLabel="cancel action"
/>
</div>
</Tooltip>
</div>
)
}
interface DisabledProps {
label: string
}
export const DisabledButton: FC<React.PropsWithChildren<DisabledProps>> = ({
label,
}) => {
const styles = useStyles()
return (
<Button variant="outlined" disabled className={styles.actionButton}>
{label}
</Button>
)
}
interface LoadingProps {
label: string
}
export const ActionLoadingButton: FC<React.PropsWithChildren<LoadingProps>> = ({
label,
}) => {
const styles = useStyles()
return (
<LoadingButton
loading
variant="outlined"
loadingLabel={label}
className={combineClasses([styles.loadingButton, styles.actionButton])}
/>
)
}
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(20),
borderRadius: `${theme.shape.borderRadius}px 0px 0px ${theme.shape.borderRadius}px`,
// This is used to show the hover effect
marginRight: -1,
position: "relative",
"&:hover": {
zIndex: 1,
},
},
cancelButton: {
"&.MuiButton-root": {
padding: "0px 0px !important",
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",
borderRadius: `${theme.shape.borderRadius} 0px 0px ${theme.shape.borderRadius}`,
},
}))
@@ -1,42 +0,0 @@
import { action } from "@storybook/addon-actions"
import { Story } from "@storybook/react"
import {
DeleteButton,
DisabledButton,
StartButton,
UpdateButton,
} from "./ActionCtas"
import { DropdownButton, DropdownButtonProps } from "./DropdownButton"
export default {
title: "Components/DropdownButton",
component: DropdownButton,
}
const Template: Story<DropdownButtonProps> = (args) => (
<DropdownButton {...args} />
)
export const WithDropdown = Template.bind({})
WithDropdown.args = {
primaryAction: <StartButton handleAction={action("start")} />,
secondaryActions: [
{
action: "update",
button: <UpdateButton handleAction={action("update")} />,
},
{
action: "delete",
button: <DeleteButton handleAction={action("delete")} />,
},
],
canCancel: false,
}
export const WithCancel = Template.bind({})
WithCancel.args = {
primaryAction: <DisabledButton label="deleting" />,
secondaryActions: [],
canCancel: true,
handleCancel: action("cancel"),
}
@@ -1,124 +0,0 @@
import Button from "@material-ui/core/Button"
import Popover from "@material-ui/core/Popover"
import { makeStyles, useTheme } from "@material-ui/core/styles"
import {
CloseDropdown,
OpenDropdown,
} from "components/DropdownArrows/DropdownArrows"
import { DropdownContent } from "components/DropdownButton/DropdownContent/DropdownContent"
import { FC, ReactNode, useRef, useState } from "react"
import { CancelButton } from "./ActionCtas"
export interface DropdownButtonProps {
primaryAction: ReactNode
secondaryActions: Array<{ action: string; button: ReactNode }>
canCancel: boolean
handleCancel?: () => void
}
export const DropdownButton: FC<DropdownButtonProps> = ({
primaryAction,
secondaryActions,
canCancel,
handleCancel,
}) => {
const styles = useStyles()
const theme = useTheme()
const anchorRef = useRef<HTMLButtonElement>(null)
const [isOpen, setIsOpen] = useState(false)
const id = isOpen ? "action-popover" : undefined
const canOpen = secondaryActions.length > 0
return (
<span className={styles.buttonContainer} data-testid="workspace-actions">
{/* primary workspace CTA */}
<span data-testid="primary-cta" className={styles.primaryCta}>
{primaryAction}
</span>
{canCancel && handleCancel ? (
<CancelButton handleAction={handleCancel} />
) : (
<>
{/* popover toggle button */}
<Button
variant="outlined"
data-testid="workspace-actions-button"
aria-controls="workspace-actions-menu"
aria-haspopup="true"
className={styles.dropdownButton}
ref={anchorRef}
disabled={!canOpen}
onClick={() => {
setIsOpen(true)
}}
>
{isOpen ? (
<CloseDropdown />
) : (
<OpenDropdown
color={canOpen ? undefined : theme.palette.action.disabled}
/>
)}
</Button>
<Popover
classes={{ paper: styles.popoverPaper }}
id={id}
open={isOpen}
anchorEl={anchorRef.current}
onClose={() => setIsOpen(false)}
onBlur={() => setIsOpen(false)}
anchorOrigin={{
vertical: "bottom",
horizontal: "right",
}}
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
>
{/* secondary workspace CTAs */}
<DropdownContent secondaryActions={secondaryActions} />
</Popover>
</>
)}
</span>
)
}
const useStyles = makeStyles((theme) => ({
buttonContainer: {
display: "inline-flex",
},
dropdownButton: {
borderRadius: `0px ${theme.shape.borderRadius}px ${theme.shape.borderRadius}px 0px`,
minWidth: "unset",
width: "64px", // matching cancel button so button grouping doesn't grow in size
"& .MuiButton-label": {
marginRight: "8px",
},
},
primaryCta: {
[theme.breakpoints.down("sm")]: {
width: "100%",
"& > *": {
width: "100%",
},
},
},
popoverPaper: {
padding: 0,
width: theme.spacing(28),
"& .MuiButton-root": {
padding: theme.spacing(1, 2),
borderRadius: 0,
width: "100%",
border: 0,
"&:hover": {
background: theme.palette.action.hover,
},
},
},
}))
@@ -1,33 +0,0 @@
import { makeStyles } from "@material-ui/core/styles"
import { FC, ReactNode } from "react"
export interface DropdownContentProps {
secondaryActions: Array<{ action: string; button: ReactNode }>
}
/* secondary workspace CTAs */
export const DropdownContent: FC<
React.PropsWithChildren<DropdownContentProps>
> = ({ secondaryActions }) => {
const styles = useStyles()
return (
<span data-testid="secondary-ctas">
{secondaryActions.map(({ action, button }) => (
<div key={action} className={styles.popoverActionButton}>
{button}
</div>
))}
</span>
)
}
const useStyles = makeStyles(() => ({
popoverActionButton: {
"& .MuiButtonBase-root": {
backgroundColor: "unset",
justifyContent: "start",
padding: "0px",
},
},
}))
@@ -15,8 +15,11 @@ import {
} from "api/api"
import { useQuery } from "@tanstack/react-query"
import { useDashboard } from "components/Dashboard/DashboardProvider"
import { AuthorizationRequest } from "api/typesGenerated"
const templatePermissions = (templateId: string) => ({
const templatePermissions = (
templateId: string,
): AuthorizationRequest["checks"] => ({
canUpdateTemplate: {
object: {
resource_type: "template",
@@ -14,22 +14,17 @@ import {
PageHeaderSubtitle,
} from "components/PageHeader/PageHeader"
import { Stack } from "components/Stack/Stack"
import { FC, useState } from "react"
import { Link as RouterLink } from "react-router-dom"
import { FC, useRef, useState } from "react"
import { Link as RouterLink, useNavigate } from "react-router-dom"
import { useDeleteTemplate } from "./deleteTemplate"
import { Margins } from "components/Margins/Margins"
import MoreVertOutlined from "@material-ui/icons/MoreVertOutlined"
import Menu from "@material-ui/core/Menu"
import MenuItem from "@material-ui/core/MenuItem"
const Language = {
variablesButton: "Variables",
settingsButton: "Settings",
createButton: "Create workspace",
deleteButton: "Delete",
editFilesButton: "Edit files",
duplicateButton: "Duplicate",
}
import SettingsOutlined from "@material-ui/icons/SettingsOutlined"
import DeleteOutlined from "@material-ui/icons/DeleteOutlined"
import EditOutlined from "@material-ui/icons/EditOutlined"
import FileCopyOutlined from "@material-ui/icons/FileCopyOutlined"
const TemplateMenu: FC<{
templateName: string
@@ -37,10 +32,15 @@ const TemplateMenu: FC<{
canEditFiles: boolean
onDelete: () => void
}> = ({ templateName, templateVersion, canEditFiles, onDelete }) => {
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null)
const menuTriggerRef = useRef<HTMLButtonElement>(null)
const [isMenuOpen, setIsMenuOpen] = useState(false)
const navigate = useNavigate()
const handleClose = () => {
setAnchorEl(null)
// Returns a function that will execute the action and close the menu
const onMenuItemClick = (actionFn: () => void) => () => {
setIsMenuOpen(false)
actionFn()
}
return (
@@ -49,50 +49,51 @@ const TemplateMenu: FC<{
variant="outlined"
aria-controls="template-options"
aria-haspopup="true"
onClick={(e) => setAnchorEl(e.currentTarget)}
onClick={() => setIsMenuOpen(true)}
ref={menuTriggerRef}
>
<MoreVertOutlined />
</Button>
<Menu
id="template-options"
anchorEl={anchorEl}
keepMounted
open={Boolean(anchorEl)}
onClose={handleClose}
anchorEl={menuTriggerRef.current}
open={isMenuOpen}
onClose={() => setIsMenuOpen(false)}
>
<MenuItem
onClick={handleClose}
component={RouterLink}
to={`/templates/${templateName}/settings`}
onClick={onMenuItemClick(() =>
navigate(`/templates/${templateName}/settings`),
)}
>
{Language.settingsButton}
<SettingsOutlined />
Settings
</MenuItem>
{canEditFiles && (
<MenuItem
onClick={handleClose}
component={RouterLink}
to={`/templates/new?fromTemplate=${templateName}`}
onClick={onMenuItemClick(() =>
navigate(`/templates/new?fromTemplate=${templateName}`),
)}
>
{Language.duplicateButton}
<FileCopyOutlined />
Duplicate
</MenuItem>
)}
{canEditFiles && (
<MenuItem
component={RouterLink}
to={`/templates/${templateName}/versions/${templateVersion}/edit`}
onClick={handleClose}
onClick={onMenuItemClick(() =>
navigate(
`/templates/${templateName}/versions/${templateVersion}/edit`,
),
)}
>
{Language.editFilesButton}
<EditOutlined />
Edit files
</MenuItem>
)}
<MenuItem
onClick={() => {
onDelete()
handleClose()
}}
>
{Language.deleteButton}
<MenuItem onClick={onMenuItemClick(onDelete)}>
<DeleteOutlined />
Delete
</MenuItem>
</Menu>
</div>
@@ -108,7 +109,7 @@ const CreateWorkspaceButton: FC<{
component={RouterLink}
to={`/templates/${templateName}/workspace`}
>
{Language.createButton}
Create workspace
</Button>
)
@@ -1,21 +0,0 @@
import PlayArrowRoundedIcon from "@material-ui/icons/PlayArrowRounded"
import { ComponentMeta, Story } from "@storybook/react"
import {
WorkspaceActionButton,
WorkspaceActionButtonProps,
} from "./WorkspaceActionButton"
export default {
title: "components/WorkspaceActionButton",
component: WorkspaceActionButton,
} as ComponentMeta<typeof WorkspaceActionButton>
const Template: Story<WorkspaceActionButtonProps> = (args) => (
<WorkspaceActionButton {...args} />
)
export const Example = Template.bind({})
Example.args = {
icon: <PlayArrowRoundedIcon />,
label: "Start workspace",
}
@@ -1,26 +0,0 @@
import Button from "@material-ui/core/Button"
import { FC } from "react"
export interface WorkspaceActionButtonProps {
label?: string
icon: JSX.Element
onClick: () => void
className?: string
ariaLabel?: string
}
export const WorkspaceActionButton: FC<
React.PropsWithChildren<WorkspaceActionButtonProps>
> = ({ label, icon, onClick, className, ariaLabel }) => {
return (
<Button
variant="outlined"
className={className}
startIcon={icon}
onClick={onClick}
aria-label={ariaLabel}
>
{Boolean(label) && label}
</Button>
)
}
@@ -0,0 +1,116 @@
import Button from "@material-ui/core/Button"
import BlockIcon from "@material-ui/icons/Block"
import CloudQueueIcon from "@material-ui/icons/CloudQueue"
import CropSquareIcon from "@material-ui/icons/CropSquare"
import PlayCircleOutlineIcon from "@material-ui/icons/PlayCircleOutline"
import { LoadingButton } from "components/LoadingButton/LoadingButton"
import { FC } from "react"
import { useTranslation } from "react-i18next"
import { makeStyles } from "@material-ui/core/styles"
interface WorkspaceAction {
handleAction: () => void
}
export const UpdateButton: FC<React.PropsWithChildren<WorkspaceAction>> = ({
handleAction,
}) => {
const { t } = useTranslation("workspacePage")
const styles = useStyles()
return (
<Button
variant="outlined"
startIcon={<CloudQueueIcon />}
onClick={handleAction}
className={styles.fixedWidth}
>
{t("actionButton.update")}
</Button>
)
}
export const StartButton: FC<React.PropsWithChildren<WorkspaceAction>> = ({
handleAction,
}) => {
const { t } = useTranslation("workspacePage")
const styles = useStyles()
return (
<Button
variant="outlined"
startIcon={<PlayCircleOutlineIcon />}
onClick={handleAction}
className={styles.fixedWidth}
>
{t("actionButton.start")}
</Button>
)
}
export const StopButton: FC<React.PropsWithChildren<WorkspaceAction>> = ({
handleAction,
}) => {
const { t } = useTranslation("workspacePage")
const styles = useStyles()
return (
<Button
variant="outlined"
startIcon={<CropSquareIcon />}
onClick={handleAction}
className={styles.fixedWidth}
>
{t("actionButton.stop")}
</Button>
)
}
export const CancelButton: FC<React.PropsWithChildren<WorkspaceAction>> = ({
handleAction,
}) => {
return (
<Button variant="outlined" startIcon={<BlockIcon />} onClick={handleAction}>
Cancel
</Button>
)
}
interface DisabledProps {
label: string
}
export const DisabledButton: FC<React.PropsWithChildren<DisabledProps>> = ({
label,
}) => {
return (
<Button variant="outlined" disabled>
{label}
</Button>
)
}
interface LoadingProps {
label: string
}
export const ActionLoadingButton: FC<React.PropsWithChildren<LoadingProps>> = ({
label,
}) => {
const styles = useStyles()
return (
<LoadingButton
loading
variant="outlined"
loadingLabel={label}
className={styles.fixedWidth}
/>
)
}
const useStyles = makeStyles((theme) => ({
fixedWidth: {
// Make it fixed so the loading changes will not "flick" the UI
width: theme.spacing(16),
},
}))
@@ -1,190 +0,0 @@
import { fireEvent, screen } from "@testing-library/react"
import i18next from "i18next"
import * as Mocks from "../../testHelpers/entities"
import { render } from "../../testHelpers/renderHelpers"
import { WorkspaceActions, WorkspaceActionsProps } from "./WorkspaceActions"
const { t } = i18next
const renderComponent = async (props: Partial<WorkspaceActionsProps> = {}) => {
render(
<WorkspaceActions
workspaceStatus={
props.workspaceStatus ?? Mocks.MockWorkspace.latest_build.status
}
isOutdated={props.isOutdated ?? false}
handleStart={jest.fn()}
handleStop={jest.fn()}
handleDelete={jest.fn()}
handleUpdate={jest.fn()}
handleCancel={jest.fn()}
handleSettings={jest.fn()}
isUpdating={false}
/>,
)
}
const renderAndClick = async (props: Partial<WorkspaceActionsProps> = {}) => {
render(
<WorkspaceActions
workspaceStatus={
props.workspaceStatus ?? Mocks.MockWorkspace.latest_build.status
}
isOutdated={props.isOutdated ?? false}
handleStart={jest.fn()}
handleStop={jest.fn()}
handleDelete={jest.fn()}
handleUpdate={jest.fn()}
handleCancel={jest.fn()}
handleSettings={jest.fn()}
isUpdating={false}
/>,
)
const trigger = await screen.findByTestId("workspace-actions-button")
fireEvent.click(trigger)
}
describe("WorkspaceActions", () => {
describe("when the workspace is starting", () => {
it("primary is starting; cancel is available; no secondary", async () => {
await renderComponent({
workspaceStatus: Mocks.MockStartingWorkspace.latest_build.status,
})
expect(screen.getByTestId("primary-cta")).toHaveTextContent(
t("actionButton.starting", { ns: "workspacePage" }),
)
expect(
screen.getByRole("button", {
name: "cancel action",
}),
).toBeInTheDocument()
expect(screen.queryByTestId("secondary-ctas")).toBeNull()
})
})
describe("when the workspace is started", () => {
it("primary is stop; secondary is delete", async () => {
await renderAndClick({
workspaceStatus: Mocks.MockWorkspace.latest_build.status,
})
expect(screen.getByTestId("primary-cta")).toHaveTextContent(
t("actionButton.stop", { ns: "workspacePage" }),
)
expect(screen.getByTestId("secondary-ctas")).toHaveTextContent(
t("actionButton.delete", { ns: "workspacePage" }),
)
})
})
describe("when the workspace is started", () => {
it("primary is stop; secondary is delete", async () => {
await renderAndClick({
workspaceStatus: Mocks.MockWorkspace.latest_build.status,
})
expect(screen.getByTestId("primary-cta")).toHaveTextContent(
t("actionButton.stop", { ns: "workspacePage" }),
)
expect(screen.getByTestId("secondary-ctas")).toHaveTextContent(
t("actionButton.delete", { ns: "workspacePage" }),
)
})
})
describe("when the workspace is stopping", () => {
it("primary is stopping; cancel is available; no secondary", async () => {
await renderComponent({
workspaceStatus: Mocks.MockStoppingWorkspace.latest_build.status,
})
expect(screen.getByTestId("primary-cta")).toHaveTextContent(
t("actionButton.stopping", { ns: "workspacePage" }),
)
expect(
screen.getByRole("button", {
name: "cancel action",
}),
).toBeInTheDocument()
expect(screen.queryByTestId("secondary-ctas")).toBeNull()
})
})
describe("when the workspace is canceling", () => {
it("primary is canceling; no secondary", async () => {
await renderAndClick({
workspaceStatus: Mocks.MockCancelingWorkspace.latest_build.status,
})
expect(screen.getByTestId("primary-cta")).toHaveTextContent(
t("disabledButton.canceling", { ns: "workspacePage" }),
)
expect(screen.queryByTestId("secondary-ctas")).toBeNull()
})
})
describe("when the workspace is canceled", () => {
it("primary is start; secondary are stop, delete", async () => {
await renderAndClick({
workspaceStatus: Mocks.MockCanceledWorkspace.latest_build.status,
})
expect(screen.getByTestId("primary-cta")).toHaveTextContent(
t("actionButton.start", { ns: "workspacePage" }),
)
expect(screen.getByTestId("secondary-ctas")).toHaveTextContent(
t("actionButton.stop", { ns: "workspacePage" }),
)
expect(screen.getByTestId("secondary-ctas")).toHaveTextContent(
t("actionButton.delete", { ns: "workspacePage" }),
)
})
})
describe("when the workspace is errored", () => {
it("primary is start; secondary is delete", async () => {
await renderAndClick({
workspaceStatus: Mocks.MockFailedWorkspace.latest_build.status,
})
expect(screen.getByTestId("primary-cta")).toHaveTextContent(
t("actionButton.start", { ns: "workspacePage" }),
)
expect(screen.getByTestId("secondary-ctas")).toHaveTextContent(
t("actionButton.delete", { ns: "workspacePage" }),
)
})
})
describe("when the workspace is deleting", () => {
it("primary is deleting; cancel is available; no secondary", async () => {
await renderComponent({
workspaceStatus: Mocks.MockDeletingWorkspace.latest_build.status,
})
expect(screen.getByTestId("primary-cta")).toHaveTextContent(
t("actionButton.deleting", { ns: "workspacePage" }),
)
expect(
screen.getByRole("button", {
name: "cancel action",
}),
).toBeInTheDocument()
expect(screen.queryByTestId("secondary-ctas")).toBeNull()
})
})
describe("when the workspace is deleted", () => {
it("primary is deleted; no secondary", async () => {
await renderAndClick({
workspaceStatus: Mocks.MockDeletedWorkspace.latest_build.status,
})
expect(screen.getByTestId("primary-cta")).toHaveTextContent(
t("disabledButton.deleted", { ns: "workspacePage" }),
)
expect(screen.queryByTestId("secondary-ctas")).toBeNull()
})
})
describe("when the workspace is outdated", () => {
it("primary is update; secondary are start, delete", async () => {
await renderAndClick({
isOutdated: true,
workspaceStatus: Mocks.MockOutdatedWorkspace.latest_build.status,
})
expect(screen.getByTestId("primary-cta")).toHaveTextContent(
t("actionButton.update", { ns: "workspacePage" }),
)
expect(screen.getByTestId("secondary-ctas")).toHaveTextContent(
t("actionButton.start", { ns: "workspacePage" }),
)
expect(screen.getByTestId("secondary-ctas")).toHaveTextContent(
t("actionButton.delete", { ns: "workspacePage" }),
)
})
})
})
@@ -1,18 +1,27 @@
import { DropdownButton } from "components/DropdownButton/DropdownButton"
import { FC, ReactNode, useMemo } from "react"
import MenuItem from "@material-ui/core/MenuItem"
import Button from "@material-ui/core/Button"
import Menu from "@material-ui/core/Menu"
import { makeStyles } from "@material-ui/core/styles"
import MoreVertOutlined from "@material-ui/icons/MoreVertOutlined"
import { FC, ReactNode, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { WorkspaceStatus } from "../../api/typesGenerated"
import {
ActionLoadingButton,
ChangeVersionButton,
DeleteButton,
CancelButton,
DisabledButton,
SettingsButton,
StartButton,
StopButton,
UpdateButton,
} from "../DropdownButton/ActionCtas"
import { ButtonMapping, ButtonTypesEnum, buttonAbilities } from "./constants"
} from "./Buttons"
import {
ButtonMapping,
ButtonTypesEnum,
actionsByWorkspaceStatus,
} from "./constants"
import SettingsOutlined from "@material-ui/icons/SettingsOutlined"
import HistoryOutlined from "@material-ui/icons/HistoryOutlined"
import DeleteOutlined from "@material-ui/icons/DeleteOutlined"
export interface WorkspaceActionsProps {
workspaceStatus: WorkspaceStatus
@@ -42,67 +51,128 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
isUpdating,
canChangeVersions,
}) => {
const styles = useStyles()
const { t } = useTranslation("workspacePage")
const { canCancel, canAcceptJobs, actions } = buttonAbilities(workspaceStatus)
const {
canCancel,
canAcceptJobs,
actions: actionsByStatus,
} = actionsByWorkspaceStatus(workspaceStatus)
const canBeUpdated = isOutdated && canAcceptJobs
const menuTriggerRef = useRef<HTMLButtonElement>(null)
const [isMenuOpen, setIsMenuOpen] = useState(false)
// A mapping of button type to the corresponding React component
const buttonMapping: ButtonMapping = {
[ButtonTypesEnum.update]: <UpdateButton handleAction={handleUpdate} />,
[ButtonTypesEnum.update]: (
<UpdateButton handleAction={handleUpdate} key={ButtonTypesEnum.update} />
),
[ButtonTypesEnum.updating]: (
<ActionLoadingButton label={t("actionButton.updating")} />
<ActionLoadingButton
label={t("actionButton.updating")}
key={ButtonTypesEnum.updating}
/>
),
[ButtonTypesEnum.settings]: (
<SettingsButton handleAction={handleSettings} />
[ButtonTypesEnum.start]: (
<StartButton handleAction={handleStart} key={ButtonTypesEnum.start} />
),
[ButtonTypesEnum.changeVersion]: canChangeVersions ? (
<ChangeVersionButton handleAction={handleChangeVersion} />
) : (
<></>
),
[ButtonTypesEnum.start]: <StartButton handleAction={handleStart} />,
[ButtonTypesEnum.starting]: (
<ActionLoadingButton label={t("actionButton.starting")} />
<ActionLoadingButton
label={t("actionButton.starting")}
key={ButtonTypesEnum.starting}
/>
),
[ButtonTypesEnum.stop]: (
<StopButton handleAction={handleStop} key={ButtonTypesEnum.stop} />
),
[ButtonTypesEnum.stop]: <StopButton handleAction={handleStop} />,
[ButtonTypesEnum.stopping]: (
<ActionLoadingButton label={t("actionButton.stopping")} />
<ActionLoadingButton
label={t("actionButton.stopping")}
key={ButtonTypesEnum.stopping}
/>
),
[ButtonTypesEnum.delete]: <DeleteButton handleAction={handleDelete} />,
[ButtonTypesEnum.deleting]: (
<ActionLoadingButton label={t("actionButton.deleting")} />
<ActionLoadingButton
label={t("actionButton.deleting")}
key={ButtonTypesEnum.deleting}
/>
),
[ButtonTypesEnum.canceling]: (
<DisabledButton label={t("disabledButton.canceling")} />
<DisabledButton
label={t("disabledButton.canceling")}
key={ButtonTypesEnum.canceling}
/>
),
[ButtonTypesEnum.deleted]: (
<DisabledButton label={t("disabledButton.deleted")} />
<DisabledButton
label={t("disabledButton.deleted")}
key={ButtonTypesEnum.deleted}
/>
),
[ButtonTypesEnum.pending]: (
<ActionLoadingButton label={t("disabledButton.pending")} />
<ActionLoadingButton
label={t("disabledButton.pending")}
key={ButtonTypesEnum.pending}
/>
),
}
// memoize so this isn't recalculated every time we fetch the workspace
const [primaryAction, ...secondaryActions] = useMemo(
() =>
isUpdating
? [ButtonTypesEnum.updating, ...actions]
: canBeUpdated
? [ButtonTypesEnum.update, ...actions]
: actions,
[actions, canBeUpdated, isUpdating],
)
// Returns a function that will execute the action and close the menu
const onMenuItemClick = (actionFn: () => void) => () => {
setIsMenuOpen(false)
actionFn()
}
return (
<DropdownButton
primaryAction={buttonMapping[primaryAction]}
canCancel={canCancel}
handleCancel={handleCancel}
secondaryActions={secondaryActions.map((action) => ({
action,
button: buttonMapping[action],
}))}
/>
<div className={styles.actions} data-testid="workspace-actions">
{canBeUpdated &&
(isUpdating
? buttonMapping[ButtonTypesEnum.updating]
: buttonMapping[ButtonTypesEnum.update])}
{actionsByStatus.map((action) => buttonMapping[action])}
{canCancel && <CancelButton handleAction={handleCancel} />}
<div>
<Button
data-testid="workspace-options-button"
aria-controls="workspace-options"
aria-haspopup="true"
variant="outlined"
disabled={!canAcceptJobs}
ref={menuTriggerRef}
onClick={() => setIsMenuOpen(true)}
>
<MoreVertOutlined />
</Button>
<Menu
id="workspace-options"
anchorEl={menuTriggerRef.current}
open={isMenuOpen}
onClose={() => setIsMenuOpen(false)}
>
<MenuItem onClick={onMenuItemClick(handleSettings)}>
<SettingsOutlined />
Settings
</MenuItem>
{canChangeVersions && (
<MenuItem onClick={onMenuItemClick(handleChangeVersion)}>
<HistoryOutlined />
Change version
</MenuItem>
)}
<MenuItem onClick={onMenuItemClick(handleDelete)}>
<DeleteOutlined />
Delete
</MenuItem>
</Menu>
</div>
</div>
)
}
const useStyles = makeStyles((theme) => ({
actions: {
display: "flex",
alignItems: "center",
gap: theme.spacing(2),
},
}))
@@ -7,12 +7,9 @@ export enum ButtonTypesEnum {
starting = "starting",
stop = "stop",
stopping = "stopping",
delete = "delete",
deleting = "deleting",
update = "update",
updating = "updating",
settings = "settings",
changeVersion = "changeVersion",
// disabled buttons
canceling = "canceling",
deleted = "deleted",
@@ -29,25 +26,20 @@ interface WorkspaceAbilities {
canAcceptJobs: boolean
}
export const buttonAbilities = (
export const actionsByWorkspaceStatus = (
status: WorkspaceStatus,
): WorkspaceAbilities => {
return statusToAbilities[status]
return statusToActions[status]
}
const statusToAbilities: Record<WorkspaceStatus, WorkspaceAbilities> = {
const statusToActions: Record<WorkspaceStatus, WorkspaceAbilities> = {
starting: {
actions: [ButtonTypesEnum.starting],
canCancel: true,
canAcceptJobs: false,
},
running: {
actions: [
ButtonTypesEnum.stop,
ButtonTypesEnum.settings,
ButtonTypesEnum.changeVersion,
ButtonTypesEnum.delete,
],
actions: [ButtonTypesEnum.stop],
canCancel: false,
canAcceptJobs: true,
},
@@ -57,35 +49,18 @@ const statusToAbilities: Record<WorkspaceStatus, WorkspaceAbilities> = {
canAcceptJobs: false,
},
stopped: {
actions: [
ButtonTypesEnum.start,
ButtonTypesEnum.settings,
ButtonTypesEnum.changeVersion,
ButtonTypesEnum.delete,
],
actions: [ButtonTypesEnum.start],
canCancel: false,
canAcceptJobs: true,
},
canceled: {
actions: [
ButtonTypesEnum.start,
ButtonTypesEnum.stop,
ButtonTypesEnum.settings,
ButtonTypesEnum.changeVersion,
ButtonTypesEnum.delete,
],
actions: [ButtonTypesEnum.start, ButtonTypesEnum.stop],
canCancel: false,
canAcceptJobs: true,
},
// in the case of an error
failed: {
actions: [
ButtonTypesEnum.start,
ButtonTypesEnum.stop,
ButtonTypesEnum.settings,
ButtonTypesEnum.changeVersion,
ButtonTypesEnum.delete,
],
actions: [ButtonTypesEnum.start, ButtonTypesEnum.stop],
canCancel: false,
canAcceptJobs: true,
},
@@ -105,7 +80,7 @@ const statusToAbilities: Record<WorkspaceStatus, WorkspaceAbilities> = {
deleted: {
actions: [ButtonTypesEnum.deleted],
canCancel: false,
canAcceptJobs: true,
canAcceptJobs: false,
},
pending: {
actions: [ButtonTypesEnum.pending],
@@ -98,7 +98,7 @@ describe("WorkspacePage", () => {
await renderWorkspacePage()
// open the workspace action popover so we have access to all available ctas
const trigger = screen.getByTestId("workspace-actions-button")
const trigger = screen.getByTestId("workspace-options-button")
await user.click(trigger)
const buttonText = t("actionButton.delete", { ns: "workspacePage" })
@@ -168,7 +168,7 @@ describe("WorkspacePage", () => {
const workspaceActions = screen.getByTestId("workspace-actions")
const cancelButton = within(workspaceActions).getByRole("button", {
name: "cancel action",
name: "Cancel",
})
await userEvent.setup().click(cancelButton)
+9
View File
@@ -242,6 +242,15 @@ export const getOverrides = ({
minWidth: 120,
},
},
MuiMenuItem: {
root: {
gap: 12,
"& .MuiSvgIcon-root": {
fontSize: 20,
},
},
},
MuiSnackbar: {
anchorOriginBottomRight: {
bottom: `${24 + 36}px !important`, // 36 is the bottom bar height