Thanks to visit codestin.com
Credit goes to github.com

Skip to content

feat: showcase workspace state in actions dropdown #3133

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Jul 25, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 29 additions & 7 deletions site/src/components/LoadingButton/LoadingButton.tsx
Original file line number Diff line number Diff line change
@@ -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
}

/**
Expand All @@ -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 (
Expand All @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,23 @@ 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> = ({
label,
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>
)
}
65 changes: 60 additions & 5 deletions site/src/components/WorkspaceActions/ActionCtas.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,26 @@
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",
stop: "Stop",
delete: "Delete",
cancel: "Cancel",
update: "Update",
// these labels are used in WorkspaceActions.tsx
starting: "Starting...",
stopping: "Stopping...",
deleting: "Deleting...",
}

interface WorkspaceAction {
Expand Down Expand Up @@ -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])}
/>
)
}
Expand All @@ -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",
},
}))
Original file line number Diff line number Diff line change
@@ -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,
}
Original file line number Diff line number Diff line change
@@ -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",
},
},
}))
46 changes: 37 additions & 9 deletions site/src/components/WorkspaceActions/WorkspaceActions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
})
})
Expand All @@ -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()
})
})
Expand All @@ -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()
})
})
Expand Down
Loading