From ac32b7f6cc4e781a2cc5b669861867bca4ec793a Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Fri, 22 Jul 2022 21:54:54 +0000 Subject: [PATCH 1/6] show progress indicator within workspace dropdown resolves #2020 --- .../LoadingButton/LoadingButton.tsx | 40 ++++-- .../WorkspaceActions/ActionCtas.tsx | 67 +++++++++- .../WorkspaceActions/WorkspaceActions.tsx | 124 +++++++++--------- .../components/WorkspaceActions/constants.ts | 23 +++- 4 files changed, 176 insertions(+), 78 deletions(-) diff --git a/site/src/components/LoadingButton/LoadingButton.tsx b/site/src/components/LoadingButton/LoadingButton.tsx index 9f8aaa3d592a7..500f146c97c82 100644 --- a/site/src/components/LoadingButton/LoadingButton.tsx +++ b/site/src/components/LoadingButton/LoadingButton.tsx @@ -1,11 +1,15 @@ 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 + classProp?: string } /** @@ -14,33 +18,53 @@ 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 = ({ +export const LoadingButton: FC = ({ loading = false, + loadingLabel, children, + classProp, ...rest }) => { - const styles = useStyles() + const styles = useStyles({ hasLoadingLabel: !!loadingLabel }) const hidden = loading ? { opacity: 0 } : undefined return ( - ) } -const useStyles = makeStyles((theme) => ({ +interface StyleProps { + hasLoadingLabel?: boolean +} + +const useStyles = makeStyles((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: { diff --git a/site/src/components/WorkspaceActions/ActionCtas.tsx b/site/src/components/WorkspaceActions/ActionCtas.tsx index 1cf762ad42096..84f9ec742621a 100644 --- a/site/src/components/WorkspaceActions/ActionCtas.tsx +++ b/site/src/components/WorkspaceActions/ActionCtas.tsx @@ -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 = ({ handleAction }) => { export const CancelButton: FC = ({ handleAction }) => { const styles = useStyles() + // this is an icon button, so it's important to include an aria label return ( - } + + ) +} + +interface LoadingProps { + label: string +} + +export const ActionLoadingButton: FC = ({ label }) => { + const styles = useStyles() + return ( + ) } @@ -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: "160px", 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", + }, })) diff --git a/site/src/components/WorkspaceActions/WorkspaceActions.tsx b/site/src/components/WorkspaceActions/WorkspaceActions.tsx index cfb4c5be47d6f..1d72fc9bffba4 100644 --- a/site/src/components/WorkspaceActions/WorkspaceActions.tsx +++ b/site/src/components/WorkspaceActions/WorkspaceActions.tsx @@ -5,7 +5,16 @@ import { FC, ReactNode, 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 { + ActionLoadingButton, + CancelButton, + DeleteButton, + DisabledButton, + Language, + StartButton, + StopButton, + UpdateButton, +} from "./ActionCtas" import { ButtonTypesEnum, WorkspaceStateActions, WorkspaceStateEnum } from "./constants" /** @@ -69,12 +78,6 @@ export const WorkspaceActions: FC = ({ } }, [workspaceStatus]) - const disabledButton = ( - - ) - type ButtonMapping = { [key in ButtonTypesEnum]: ReactNode } @@ -83,60 +86,68 @@ export const WorkspaceActions: FC = ({ const buttonMapping: ButtonMapping = { [ButtonTypesEnum.update]: , [ButtonTypesEnum.start]: , + [ButtonTypesEnum.starting]: , [ButtonTypesEnum.stop]: , + [ButtonTypesEnum.stopping]: , [ButtonTypesEnum.delete]: , + [ButtonTypesEnum.deleting]: , [ButtonTypesEnum.cancel]: , - [ButtonTypesEnum.canceling]: disabledButton, - [ButtonTypesEnum.disabled]: disabledButton, - [ButtonTypesEnum.queued]: disabledButton, - [ButtonTypesEnum.error]: disabledButton, - [ButtonTypesEnum.loading]: disabledButton, + [ButtonTypesEnum.canceling]: , + [ButtonTypesEnum.disabled]: , + [ButtonTypesEnum.queued]: , + [ButtonTypesEnum.error]: , + [ButtonTypesEnum.loading]: , } return ( {/* primary workspace CTA */} {buttonMapping[actions.primary]} - - {/* popover toggle button */} - - - setIsOpen(false)} - anchorOrigin={{ - vertical: "bottom", - horizontal: "right", - }} - transformOrigin={{ - vertical: "top", - horizontal: "right", - }} - > - {/* secondary workspace CTAs */} - - {actions.secondary.map((action) => ( -
- {buttonMapping[action]} -
- ))} -
-
+ {actions.canCancel ? ( + // cancel CTA + <>{buttonMapping[ButtonTypesEnum.cancel]} + ) : ( + <> + {/* popover toggle button */} + + setIsOpen(false)} + anchorOrigin={{ + vertical: "bottom", + horizontal: "right", + }} + transformOrigin={{ + vertical: "top", + horizontal: "right", + }} + > + {/* secondary workspace CTAs */} + + {actions.secondary.map((action) => ( +
+ {buttonMapping[action]} +
+ ))} +
+
+ + )}
) } @@ -152,18 +163,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", diff --git a/site/src/components/WorkspaceActions/constants.ts b/site/src/components/WorkspaceActions/constants.ts index 38391b1ec6218..da6002b394185 100644 --- a/site/src/components/WorkspaceActions/constants.ts +++ b/site/src/components/WorkspaceActions/constants.ts @@ -16,8 +16,11 @@ export enum WorkspaceStateEnum { // the button types we have export enum ButtonTypesEnum { start, + starting, stop, + stopping, delete, + deleting, update, cancel, error, @@ -32,6 +35,7 @@ type StateActionsType = { [key in WorkspaceStateEnum]: { primary: ButtonTypesEnum secondary: ButtonTypesEnum[] + canCancel: boolean } } @@ -40,29 +44,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 +80,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, }, } From 751281c1b7f655824e7de986443e3c5c3c15dc96 Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Fri, 22 Jul 2022 22:34:42 +0000 Subject: [PATCH 2/6] wrote tests --- .../WorkspaceActionButton.tsx | 6 ++- .../WorkspaceActions/ActionCtas.tsx | 7 +-- .../WorkspaceActions.test.tsx | 46 +++++++++++++++---- .../components/WorkspaceActions/constants.ts | 2 +- .../WorkspacePage/WorkspacePage.test.tsx | 13 ++++-- 5 files changed, 55 insertions(+), 19 deletions(-) diff --git a/site/src/components/WorkspaceActionButton/WorkspaceActionButton.tsx b/site/src/components/WorkspaceActionButton/WorkspaceActionButton.tsx index 89fec93dccf99..2cb11204ffb3b 100644 --- a/site/src/components/WorkspaceActionButton/WorkspaceActionButton.tsx +++ b/site/src/components/WorkspaceActionButton/WorkspaceActionButton.tsx @@ -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 = ({ @@ -13,9 +14,10 @@ export const WorkspaceActionButton: FC = ({ icon, onClick, className, + ariaLabel, }) => { return ( - ) diff --git a/site/src/components/WorkspaceActions/ActionCtas.tsx b/site/src/components/WorkspaceActions/ActionCtas.tsx index 84f9ec742621a..5418c3c43ad5d 100644 --- a/site/src/components/WorkspaceActions/ActionCtas.tsx +++ b/site/src/components/WorkspaceActions/ActionCtas.tsx @@ -81,11 +81,12 @@ export const CancelButton: FC = ({ handleAction }) => { // this is an icon button, so it's important to include an aria label return ( - ) } diff --git a/site/src/components/WorkspaceActions/ActionCtas.tsx b/site/src/components/WorkspaceActions/ActionCtas.tsx index d4ef929d66350..2240701c47db4 100644 --- a/site/src/components/WorkspaceActions/ActionCtas.tsx +++ b/site/src/components/WorkspaceActions/ActionCtas.tsx @@ -86,7 +86,6 @@ export const CancelButton: FC = ({ handleAction }) => { onClick={handleAction} className={styles.cancelButton} ariaLabel="cancel action" - label="" // MUI throws an error if you don't have a label /> ) } @@ -124,7 +123,7 @@ const useStyles = makeStyles((theme) => ({ actionButton: { // Set fixed width for the action buttons so they will not change the size // during the transitions - width: "160px", + width: theme.spacing(20), border: "none", borderRadius: `${theme.shape.borderRadius}px 0px 0px ${theme.shape.borderRadius}px`, }, From 922de30994e858088f995da8792f454f98d128e8 Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Mon, 25 Jul 2022 20:37:05 +0000 Subject: [PATCH 5/6] added stories for dropdown content --- .../DropdownContent.stories.tsx | 51 +++++++++++++++++++ .../DropdownContent/DropdownContent.tsx | 33 ++++++++++++ .../WorkspaceActions/WorkspaceActions.tsx | 29 ++++------- .../components/WorkspaceActions/constants.ts | 6 +++ 4 files changed, 99 insertions(+), 20 deletions(-) create mode 100644 site/src/components/WorkspaceActions/DropdownContent/DropdownContent.stories.tsx create mode 100644 site/src/components/WorkspaceActions/DropdownContent/DropdownContent.tsx diff --git a/site/src/components/WorkspaceActions/DropdownContent/DropdownContent.stories.tsx b/site/src/components/WorkspaceActions/DropdownContent/DropdownContent.stories.tsx new file mode 100644 index 0000000000000..f68410acb77ae --- /dev/null +++ b/site/src/components/WorkspaceActions/DropdownContent/DropdownContent.stories.tsx @@ -0,0 +1,51 @@ +import { Story } from "@storybook/react" +import { DeleteButton, StartButton, StopButton } from "../ActionCtas" +import { + ButtonMapping, + ButtonTypesEnum, + WorkspaceStateActions, + WorkspaceStateEnum, +} from "../constants" +import { DropdownContent, DropdownContentProps } from "./DropdownContent" + +export default { + title: "components/DropdownContent", + component: DropdownContent, +} + +const Template: Story = (args) => + +const buttonMappingMock: Partial = { + [ButtonTypesEnum.delete]: jest.fn()} />, + [ButtonTypesEnum.start]: jest.fn()} />, + [ButtonTypesEnum.stop]: jest.fn()} />, + [ButtonTypesEnum.delete]: 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, +} diff --git a/site/src/components/WorkspaceActions/DropdownContent/DropdownContent.tsx b/site/src/components/WorkspaceActions/DropdownContent/DropdownContent.tsx new file mode 100644 index 0000000000000..f4da8f13eb6ad --- /dev/null +++ b/site/src/components/WorkspaceActions/DropdownContent/DropdownContent.tsx @@ -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 +} + +/* secondary workspace CTAs */ +export const DropdownContent: FC = ({ secondaryActions, buttonMapping }) => { + const styles = useStyles() + + return ( + + {secondaryActions.map((action) => ( +
+ {buttonMapping[action]} +
+ ))} +
+ ) +} + +const useStyles = makeStyles(() => ({ + popoverActionButton: { + "& .MuiButtonBase-root": { + backgroundColor: "unset", + justifyContent: "start", + padding: "0px", + }, + }, +})) diff --git a/site/src/components/WorkspaceActions/WorkspaceActions.tsx b/site/src/components/WorkspaceActions/WorkspaceActions.tsx index 1d72fc9bffba4..e4c2339194f8e 100644 --- a/site/src/components/WorkspaceActions/WorkspaceActions.tsx +++ b/site/src/components/WorkspaceActions/WorkspaceActions.tsx @@ -1,7 +1,7 @@ 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" @@ -15,7 +15,13 @@ import { StopButton, UpdateButton, } from "./ActionCtas" -import { ButtonTypesEnum, WorkspaceStateActions, WorkspaceStateEnum } from "./constants" +import { + ButtonMapping, + ButtonTypesEnum, + WorkspaceStateActions, + WorkspaceStateEnum, +} from "./constants" +import { DropdownContent } from "./DropdownContent/DropdownContent" /** * Jobs submitted while another job is in progress will be discarded, @@ -78,10 +84,6 @@ export const WorkspaceActions: FC = ({ } }, [workspaceStatus]) - type ButtonMapping = { - [key in ButtonTypesEnum]: ReactNode - } - // A mapping of button type to the corresponding React component const buttonMapping: ButtonMapping = { [ButtonTypesEnum.update]: , @@ -138,13 +140,7 @@ export const WorkspaceActions: FC = ({ }} > {/* secondary workspace CTAs */} - - {actions.secondary.map((action) => ( -
- {buttonMapping[action]} -
- ))} -
+ )} @@ -168,13 +164,6 @@ const useStyles = makeStyles((theme) => ({ marginRight: "8px", }, }, - popoverActionButton: { - "& .MuiButtonBase-root": { - backgroundColor: "unset", - justifyContent: "start", - padding: "0px", - }, - }, popoverPaper: { padding: `${theme.spacing(2)}px ${theme.spacing(3)}px ${theme.spacing(3)}px`, }, diff --git a/site/src/components/WorkspaceActions/constants.ts b/site/src/components/WorkspaceActions/constants.ts index b63c7a57eeb31..ecb7292a0d6ed 100644 --- a/site/src/components/WorkspaceActions/constants.ts +++ b/site/src/components/WorkspaceActions/constants.ts @@ -1,3 +1,5 @@ +import { ReactNode } from "react" + // all the possible states returned by the API export enum WorkspaceStateEnum { starting = "Starting", @@ -31,6 +33,10 @@ export enum ButtonTypesEnum { loading, } +export type ButtonMapping = { + [key in ButtonTypesEnum]: ReactNode +} + type StateActionsType = { [key in WorkspaceStateEnum]: { primary: ButtonTypesEnum From 363e14d2300ec380e184792ea9514151991d049e Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Mon, 25 Jul 2022 21:59:58 +0000 Subject: [PATCH 6/6] PR feedbac --- .../DropdownContent/DropdownContent.stories.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/site/src/components/WorkspaceActions/DropdownContent/DropdownContent.stories.tsx b/site/src/components/WorkspaceActions/DropdownContent/DropdownContent.stories.tsx index f68410acb77ae..edde3acabdb64 100644 --- a/site/src/components/WorkspaceActions/DropdownContent/DropdownContent.stories.tsx +++ b/site/src/components/WorkspaceActions/DropdownContent/DropdownContent.stories.tsx @@ -8,8 +8,11 @@ import { } 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: "components/DropdownContent", + title: "WorkspaceActionsDropdown", component: DropdownContent, }