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

Skip to content

Commit 9bf5537

Browse files
authored
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
1 parent b0957f3 commit 9bf5537

File tree

9 files changed

+317
-104
lines changed

9 files changed

+317
-104
lines changed

site/src/components/LoadingButton/LoadingButton.tsx

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import Button, { ButtonProps } from "@material-ui/core/Button"
22
import CircularProgress from "@material-ui/core/CircularProgress"
33
import { makeStyles } from "@material-ui/core/styles"
4-
import * as React from "react"
4+
import { Theme } from "@material-ui/core/styles/createMuiTheme"
5+
import { FC } from "react"
56

67
export interface LoadingButtonProps extends ButtonProps {
78
/** Whether or not to disable the button and show a spinner */
89
loading?: boolean
10+
/** An optional label to display with the loading spinner */
11+
loadingLabel?: string
912
}
1013

1114
/**
@@ -14,12 +17,13 @@ export interface LoadingButtonProps extends ButtonProps {
1417
* In Material-UI 5+ - this is built-in, but since we're on an earlier version,
1518
* we have to roll our own.
1619
*/
17-
export const LoadingButton: React.FC<LoadingButtonProps> = ({
20+
export const LoadingButton: FC<LoadingButtonProps> = ({
1821
loading = false,
22+
loadingLabel,
1923
children,
2024
...rest
2125
}) => {
22-
const styles = useStyles()
26+
const styles = useStyles({ hasLoadingLabel: !!loadingLabel })
2327
const hidden = loading ? { opacity: 0 } : undefined
2428

2529
return (
@@ -30,17 +34,35 @@ export const LoadingButton: React.FC<LoadingButtonProps> = ({
3034
<CircularProgress size={18} className={styles.spinner} />
3135
</div>
3236
)}
37+
{!!loadingLabel && loadingLabel}
3338
</Button>
3439
)
3540
}
3641

37-
const useStyles = makeStyles((theme) => ({
42+
interface StyleProps {
43+
hasLoadingLabel?: boolean
44+
}
45+
46+
const useStyles = makeStyles<Theme, StyleProps>((theme) => ({
3847
loader: {
39-
position: "absolute",
48+
position: (props) => {
49+
if (!props.hasLoadingLabel) {
50+
return "absolute"
51+
}
52+
},
53+
transform: (props) => {
54+
if (!props.hasLoadingLabel) {
55+
return "translate(-50%, -50%)"
56+
}
57+
},
58+
marginRight: (props) => {
59+
if (props.hasLoadingLabel) {
60+
return "10px"
61+
}
62+
},
4063
top: "50%",
4164
left: "50%",
42-
transform: "translate(-50%, -50%)",
43-
height: 18,
65+
height: 22, // centering loading icon
4466
width: 18,
4567
},
4668
spinner: {

site/src/components/WorkspaceActionButton/WorkspaceActionButton.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,23 @@ import Button from "@material-ui/core/Button"
22
import { FC } from "react"
33

44
export interface WorkspaceActionButtonProps {
5-
label: string
5+
label?: string
66
icon: JSX.Element
77
onClick: () => void
88
className?: string
9+
ariaLabel?: string
910
}
1011

1112
export const WorkspaceActionButton: FC<WorkspaceActionButtonProps> = ({
1213
label,
1314
icon,
1415
onClick,
1516
className,
17+
ariaLabel,
1618
}) => {
1719
return (
18-
<Button className={className} startIcon={icon} onClick={onClick}>
19-
{label}
20+
<Button className={className} startIcon={icon} onClick={onClick} aria-label={ariaLabel}>
21+
{!!label && label}
2022
</Button>
2123
)
2224
}

site/src/components/WorkspaceActions/ActionCtas.tsx

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,26 @@
11
import Button from "@material-ui/core/Button"
22
import { makeStyles } from "@material-ui/core/styles"
3+
import BlockIcon from "@material-ui/icons/Block"
34
import CloudQueueIcon from "@material-ui/icons/CloudQueue"
45
import CropSquareIcon from "@material-ui/icons/CropSquare"
56
import DeleteOutlineIcon from "@material-ui/icons/DeleteOutline"
6-
import HighlightOffIcon from "@material-ui/icons/HighlightOff"
77
import PlayCircleOutlineIcon from "@material-ui/icons/PlayCircleOutline"
8+
import { LoadingButton } from "components/LoadingButton/LoadingButton"
89
import { FC } from "react"
10+
import { combineClasses } from "util/combineClasses"
911
import { WorkspaceActionButton } from "../WorkspaceActionButton/WorkspaceActionButton"
12+
import { WorkspaceStateEnum } from "./constants"
1013

1114
export const Language = {
1215
start: "Start",
1316
stop: "Stop",
1417
delete: "Delete",
1518
cancel: "Cancel",
1619
update: "Update",
20+
// these labels are used in WorkspaceActions.tsx
21+
starting: "Starting...",
22+
stopping: "Stopping...",
23+
deleting: "Deleting...",
1724
}
1825

1926
interface WorkspaceAction {
@@ -72,12 +79,42 @@ export const DeleteButton: FC<WorkspaceAction> = ({ handleAction }) => {
7279
export const CancelButton: FC<WorkspaceAction> = ({ handleAction }) => {
7380
const styles = useStyles()
7481

82+
// this is an icon button, so it's important to include an aria label
7583
return (
7684
<WorkspaceActionButton
77-
className={styles.actionButton}
78-
icon={<HighlightOffIcon />}
85+
icon={<BlockIcon />}
7986
onClick={handleAction}
80-
label={Language.cancel}
87+
className={styles.cancelButton}
88+
ariaLabel="cancel action"
89+
/>
90+
)
91+
}
92+
93+
interface DisabledProps {
94+
workspaceState: WorkspaceStateEnum
95+
}
96+
97+
export const DisabledButton: FC<DisabledProps> = ({ workspaceState }) => {
98+
const styles = useStyles()
99+
100+
return (
101+
<Button disabled className={styles.actionButton}>
102+
{workspaceState}
103+
</Button>
104+
)
105+
}
106+
107+
interface LoadingProps {
108+
label: string
109+
}
110+
111+
export const ActionLoadingButton: FC<LoadingProps> = ({ label }) => {
112+
const styles = useStyles()
113+
return (
114+
<LoadingButton
115+
loading
116+
loadingLabel={label}
117+
className={combineClasses([styles.loadingButton, styles.actionButton])}
81118
/>
82119
)
83120
}
@@ -86,8 +123,26 @@ const useStyles = makeStyles((theme) => ({
86123
actionButton: {
87124
// Set fixed width for the action buttons so they will not change the size
88125
// during the transitions
89-
width: theme.spacing(16),
126+
width: theme.spacing(20),
90127
border: "none",
91128
borderRadius: `${theme.shape.borderRadius}px 0px 0px ${theme.shape.borderRadius}px`,
92129
},
130+
cancelButton: {
131+
"&.MuiButton-root": {
132+
padding: "0px 0px !important",
133+
border: "none",
134+
borderLeft: `1px solid ${theme.palette.divider}`,
135+
borderRadius: `0px ${theme.shape.borderRadius}px ${theme.shape.borderRadius}px 0px`,
136+
width: "63px", // matching dropdown button so button grouping doesn't grow in size
137+
},
138+
"& .MuiButton-label": {
139+
marginLeft: "10px",
140+
},
141+
},
142+
// this is all custom to work with our button wrapper
143+
loadingButton: {
144+
border: "none",
145+
borderLeft: "1px solid #333740", // MUI disabled button
146+
borderRadius: "3px 0px 0px 3px",
147+
},
93148
}))
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { Story } from "@storybook/react"
2+
import { DeleteButton, StartButton, StopButton } from "../ActionCtas"
3+
import {
4+
ButtonMapping,
5+
ButtonTypesEnum,
6+
WorkspaceStateActions,
7+
WorkspaceStateEnum,
8+
} from "../constants"
9+
import { DropdownContent, DropdownContentProps } from "./DropdownContent"
10+
11+
// These are the stories for the secondary actions (housed in the dropdown)
12+
// in WorkspaceActions.tsx
13+
14+
export default {
15+
title: "WorkspaceActionsDropdown",
16+
component: DropdownContent,
17+
}
18+
19+
const Template: Story<DropdownContentProps> = (args) => <DropdownContent {...args} />
20+
21+
const buttonMappingMock: Partial<ButtonMapping> = {
22+
[ButtonTypesEnum.delete]: <DeleteButton handleAction={() => jest.fn()} />,
23+
[ButtonTypesEnum.start]: <StartButton handleAction={() => jest.fn()} />,
24+
[ButtonTypesEnum.stop]: <StopButton handleAction={() => jest.fn()} />,
25+
[ButtonTypesEnum.delete]: <DeleteButton handleAction={() => jest.fn()} />,
26+
}
27+
28+
const defaultArgs = {
29+
buttonMapping: buttonMappingMock,
30+
}
31+
32+
export const Started = Template.bind({})
33+
Started.args = {
34+
...defaultArgs,
35+
secondaryActions: WorkspaceStateActions[WorkspaceStateEnum.started].secondary,
36+
}
37+
38+
export const Stopped = Template.bind({})
39+
Stopped.args = {
40+
...defaultArgs,
41+
secondaryActions: WorkspaceStateActions[WorkspaceStateEnum.stopped].secondary,
42+
}
43+
44+
export const Canceled = Template.bind({})
45+
Canceled.args = {
46+
...defaultArgs,
47+
secondaryActions: WorkspaceStateActions[WorkspaceStateEnum.canceled].secondary,
48+
}
49+
50+
export const Errored = Template.bind({})
51+
Errored.args = {
52+
...defaultArgs,
53+
secondaryActions: WorkspaceStateActions[WorkspaceStateEnum.error].secondary,
54+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { makeStyles } from "@material-ui/core/styles"
2+
import { FC } from "react"
3+
import { ButtonMapping, ButtonTypesEnum } from "../constants"
4+
5+
export interface DropdownContentProps {
6+
secondaryActions: ButtonTypesEnum[]
7+
buttonMapping: Partial<ButtonMapping>
8+
}
9+
10+
/* secondary workspace CTAs */
11+
export const DropdownContent: FC<DropdownContentProps> = ({ secondaryActions, buttonMapping }) => {
12+
const styles = useStyles()
13+
14+
return (
15+
<span data-testid="secondary-ctas">
16+
{secondaryActions.map((action) => (
17+
<div key={action} className={styles.popoverActionButton}>
18+
{buttonMapping[action]}
19+
</div>
20+
))}
21+
</span>
22+
)
23+
}
24+
25+
const useStyles = makeStyles(() => ({
26+
popoverActionButton: {
27+
"& .MuiButtonBase-root": {
28+
backgroundColor: "unset",
29+
justifyContent: "start",
30+
padding: "0px",
31+
},
32+
},
33+
}))

site/src/components/WorkspaceActions/WorkspaceActions.test.tsx

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,19 @@ import { Language } from "./ActionCtas"
55
import { WorkspaceStateEnum } from "./constants"
66
import { WorkspaceActions, WorkspaceActionsProps } from "./WorkspaceActions"
77

8+
const renderComponent = async (props: Partial<WorkspaceActionsProps> = {}) => {
9+
render(
10+
<WorkspaceActions
11+
workspace={props.workspace ?? Mocks.MockWorkspace}
12+
handleStart={jest.fn()}
13+
handleStop={jest.fn()}
14+
handleDelete={jest.fn()}
15+
handleUpdate={jest.fn()}
16+
handleCancel={jest.fn()}
17+
/>,
18+
)
19+
}
20+
821
const renderAndClick = async (props: Partial<WorkspaceActionsProps> = {}) => {
922
render(
1023
<WorkspaceActions
@@ -22,9 +35,14 @@ const renderAndClick = async (props: Partial<WorkspaceActionsProps> = {}) => {
2235

2336
describe("WorkspaceActions", () => {
2437
describe("when the workspace is starting", () => {
25-
it("primary is cancel; no secondary", async () => {
26-
await renderAndClick({ workspace: Mocks.MockStartingWorkspace })
27-
expect(screen.getByTestId("primary-cta")).toHaveTextContent(Language.cancel)
38+
it("primary is starting; cancel is available; no secondary", async () => {
39+
await renderComponent({ workspace: Mocks.MockStartingWorkspace })
40+
expect(screen.getByTestId("primary-cta")).toHaveTextContent(Language.starting)
41+
expect(
42+
screen.getByRole("button", {
43+
name: "cancel action",
44+
}),
45+
).toBeInTheDocument()
2846
expect(screen.queryByTestId("secondary-ctas")).toBeNull()
2947
})
3048
})
@@ -36,9 +54,14 @@ describe("WorkspaceActions", () => {
3654
})
3755
})
3856
describe("when the workspace is stopping", () => {
39-
it("primary is cancel; no secondary", async () => {
40-
await renderAndClick({ workspace: Mocks.MockStoppingWorkspace })
41-
expect(screen.getByTestId("primary-cta")).toHaveTextContent(Language.cancel)
57+
it("primary is stopping; cancel is available; no secondary", async () => {
58+
await renderComponent({ workspace: Mocks.MockStoppingWorkspace })
59+
expect(screen.getByTestId("primary-cta")).toHaveTextContent(Language.stopping)
60+
expect(
61+
screen.getByRole("button", {
62+
name: "cancel action",
63+
}),
64+
).toBeInTheDocument()
4265
expect(screen.queryByTestId("secondary-ctas")).toBeNull()
4366
})
4467
})
@@ -65,9 +88,14 @@ describe("WorkspaceActions", () => {
6588
})
6689
})
6790
describe("when the workspace is deleting", () => {
68-
it("primary is cancel; no secondary", async () => {
69-
await renderAndClick({ workspace: Mocks.MockDeletingWorkspace })
70-
expect(screen.getByTestId("primary-cta")).toHaveTextContent(Language.cancel)
91+
it("primary is deleting; cancel is available; no secondary", async () => {
92+
await renderComponent({ workspace: Mocks.MockDeletingWorkspace })
93+
expect(screen.getByTestId("primary-cta")).toHaveTextContent(Language.deleting)
94+
expect(
95+
screen.getByRole("button", {
96+
name: "cancel action",
97+
}),
98+
).toBeInTheDocument()
7199
expect(screen.queryByTestId("secondary-ctas")).toBeNull()
72100
})
73101
})

0 commit comments

Comments
 (0)