From ebf001122d6714f6883714a80167ac2cf9bec898 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Thu, 13 Apr 2023 14:07:05 +0000 Subject: [PATCH 1/6] Refactor primary buttons --- .../WorkspaceActions/WorkspaceActions.tsx | 54 +++++++++++------- .../components/WorkspaceActions/constants.ts | 55 ++++++++----------- 2 files changed, 55 insertions(+), 54 deletions(-) diff --git a/site/src/components/WorkspaceActions/WorkspaceActions.tsx b/site/src/components/WorkspaceActions/WorkspaceActions.tsx index 7a1c4e3aba14d..029c3c7cac88f 100644 --- a/site/src/components/WorkspaceActions/WorkspaceActions.tsx +++ b/site/src/components/WorkspaceActions/WorkspaceActions.tsx @@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next" import { WorkspaceStatus } from "../../api/typesGenerated" import { ActionLoadingButton, + CancelButton, ChangeVersionButton, DeleteButton, DisabledButton, @@ -43,7 +44,8 @@ export const WorkspaceActions: FC = ({ canChangeVersions, }) => { const { t } = useTranslation("workspacePage") - const { canCancel, canAcceptJobs, actions } = buttonAbilities(workspaceStatus) + const { canCancel, canAcceptJobs, primaryActions, secondaryActions } = + buttonAbilities(workspaceStatus) const canBeUpdated = isOutdated && canAcceptJobs // A mapping of button type to the corresponding React component @@ -83,26 +85,36 @@ export const WorkspaceActions: FC = ({ ), } - // 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], - ) - return ( - ({ - action, - button: buttonMapping[action], - }))} - /> +
+ {canBeUpdated && + (isUpdating + ? buttonMapping[ButtonTypesEnum.updating] + : buttonMapping[ButtonTypesEnum.update])} + {primaryActions.map((primaryAction) => buttonMapping[primaryAction])} + {canCancel && } +
) + // // 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], + // ) + + // return ( + // ({ + // action, + // button: buttonMapping[action], + // }))} + // /> + // ) } diff --git a/site/src/components/WorkspaceActions/constants.ts b/site/src/components/WorkspaceActions/constants.ts index ada352871f2c3..a4e265a775a51 100644 --- a/site/src/components/WorkspaceActions/constants.ts +++ b/site/src/components/WorkspaceActions/constants.ts @@ -24,7 +24,8 @@ export type ButtonMapping = { } interface WorkspaceAbilities { - actions: ButtonTypesEnum[] + primaryActions: ButtonTypesEnum[] + secondaryActions?: ButtonTypesEnum[] canCancel: boolean canAcceptJobs: boolean } @@ -35,57 +36,45 @@ export const buttonAbilities = ( return statusToAbilities[status] } +const defaultSecondaryActions = [ + ButtonTypesEnum.settings, + ButtonTypesEnum.changeVersion, + ButtonTypesEnum.delete, +] + const statusToAbilities: Record = { starting: { - actions: [ButtonTypesEnum.starting], + primaryActions: [ButtonTypesEnum.starting], canCancel: true, canAcceptJobs: false, }, running: { - actions: [ - ButtonTypesEnum.stop, - ButtonTypesEnum.settings, - ButtonTypesEnum.changeVersion, - ButtonTypesEnum.delete, - ], + primaryActions: [ButtonTypesEnum.stop], + secondaryActions: defaultSecondaryActions, canCancel: false, canAcceptJobs: true, }, stopping: { - actions: [ButtonTypesEnum.stopping], + primaryActions: [ButtonTypesEnum.stopping], canCancel: true, canAcceptJobs: false, }, stopped: { - actions: [ - ButtonTypesEnum.start, - ButtonTypesEnum.settings, - ButtonTypesEnum.changeVersion, - ButtonTypesEnum.delete, - ], + primaryActions: [ButtonTypesEnum.start], + secondaryActions: defaultSecondaryActions, canCancel: false, canAcceptJobs: true, }, canceled: { - actions: [ - ButtonTypesEnum.start, - ButtonTypesEnum.stop, - ButtonTypesEnum.settings, - ButtonTypesEnum.changeVersion, - ButtonTypesEnum.delete, - ], + primaryActions: [ButtonTypesEnum.start, ButtonTypesEnum.stop], + secondaryActions: defaultSecondaryActions, canCancel: false, canAcceptJobs: true, }, // in the case of an error failed: { - actions: [ - ButtonTypesEnum.start, - ButtonTypesEnum.stop, - ButtonTypesEnum.settings, - ButtonTypesEnum.changeVersion, - ButtonTypesEnum.delete, - ], + primaryActions: [ButtonTypesEnum.start, ButtonTypesEnum.stop], + secondaryActions: defaultSecondaryActions, canCancel: false, canAcceptJobs: true, }, @@ -93,22 +82,22 @@ const statusToAbilities: Record = { * disabled states */ canceling: { - actions: [ButtonTypesEnum.canceling], + primaryActions: [ButtonTypesEnum.canceling], canCancel: false, canAcceptJobs: false, }, deleting: { - actions: [ButtonTypesEnum.deleting], + primaryActions: [ButtonTypesEnum.deleting], canCancel: true, canAcceptJobs: false, }, deleted: { - actions: [ButtonTypesEnum.deleted], + primaryActions: [ButtonTypesEnum.deleted], canCancel: false, canAcceptJobs: true, }, pending: { - actions: [ButtonTypesEnum.pending], + primaryActions: [ButtonTypesEnum.pending], canCancel: false, canAcceptJobs: false, }, From 4e730893207f6f7f9c5ba6c42f42acb5cecb42ab Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Thu, 13 Apr 2023 16:31:56 +0000 Subject: [PATCH 2/6] refactor(site): Always show the main actions --- .../components/DropdownButton/ActionCtas.tsx | 208 ------------------ .../DropdownButton/DropdownButton.stories.tsx | 42 ---- .../DropdownButton/DropdownButton.tsx | 124 ----------- .../DropdownContent/DropdownContent.tsx | 33 --- .../TemplateLayout/TemplateLayout.tsx | 5 +- .../TemplateLayout/TemplatePageHeader.tsx | 79 +++---- .../WorkspaceActionButton.stories.tsx | 21 -- .../WorkspaceActionButton.tsx | 26 --- .../components/WorkspaceActions/Buttons.tsx | 116 ++++++++++ .../WorkspaceActions/WorkspaceActions.tsx | 114 ++++++---- .../components/WorkspaceActions/constants.ts | 44 ++-- site/src/theme/overrides.ts | 9 + 12 files changed, 256 insertions(+), 565 deletions(-) delete mode 100644 site/src/components/DropdownButton/ActionCtas.tsx delete mode 100644 site/src/components/DropdownButton/DropdownButton.stories.tsx delete mode 100644 site/src/components/DropdownButton/DropdownButton.tsx delete mode 100644 site/src/components/DropdownButton/DropdownContent/DropdownContent.tsx delete mode 100644 site/src/components/WorkspaceActionButton/WorkspaceActionButton.stories.tsx delete mode 100644 site/src/components/WorkspaceActionButton/WorkspaceActionButton.tsx create mode 100644 site/src/components/WorkspaceActions/Buttons.tsx diff --git a/site/src/components/DropdownButton/ActionCtas.tsx b/site/src/components/DropdownButton/ActionCtas.tsx deleted file mode 100644 index 85031e8256ab0..0000000000000 --- a/site/src/components/DropdownButton/ActionCtas.tsx +++ /dev/null @@ -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 "util/combineClasses" -import { WorkspaceActionButton } from "../WorkspaceActionButton/WorkspaceActionButton" - -interface WorkspaceAction { - handleAction: () => void -} - -export const UpdateButton: FC> = ({ - handleAction, -}) => { - const styles = useStyles() - const { t } = useTranslation("workspacePage") - - return ( - - ) -} - -export const SettingsButton: FC> = ({ - handleAction, -}) => { - const styles = useStyles() - const { t } = useTranslation("workspacePage") - - return ( - - ) -} - -export const ChangeVersionButton: FC< - React.PropsWithChildren -> = ({ handleAction }) => { - const styles = useStyles() - - return ( - - ) -} - -export const StartButton: FC> = ({ - handleAction, -}) => { - const styles = useStyles() - const { t } = useTranslation("workspacePage") - - return ( - } - onClick={handleAction} - label={t("actionButton.start")} - /> - ) -} - -export const StopButton: FC> = ({ - handleAction, -}) => { - const styles = useStyles() - const { t } = useTranslation("workspacePage") - - return ( - } - onClick={handleAction} - label={t("actionButton.stop")} - /> - ) -} - -export const DeleteButton: FC> = ({ - handleAction, -}) => { - const styles = useStyles() - const { t } = useTranslation("workspacePage") - - return ( - } - onClick={handleAction} - label={t("actionButton.delete")} - /> - ) -} - -export const CancelButton: FC> = ({ - handleAction, -}) => { - const styles = useStyles() - - // this is an icon button, so it's important to include an aria label - return ( -
- - {/* We had to wrap the button to make it work with the tooltip. */} -
- } - onClick={handleAction} - className={styles.cancelButton} - ariaLabel="cancel action" - /> -
-
-
- ) -} - -interface DisabledProps { - label: string -} - -export const DisabledButton: FC> = ({ - label, -}) => { - const styles = useStyles() - - return ( - - ) -} - -interface LoadingProps { - label: string -} - -export const ActionLoadingButton: FC> = ({ - label, -}) => { - const styles = useStyles() - return ( - - ) -} - -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}`, - }, -})) diff --git a/site/src/components/DropdownButton/DropdownButton.stories.tsx b/site/src/components/DropdownButton/DropdownButton.stories.tsx deleted file mode 100644 index ab2815a236dbc..0000000000000 --- a/site/src/components/DropdownButton/DropdownButton.stories.tsx +++ /dev/null @@ -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 = (args) => ( - -) - -export const WithDropdown = Template.bind({}) -WithDropdown.args = { - primaryAction: , - secondaryActions: [ - { - action: "update", - button: , - }, - { - action: "delete", - button: , - }, - ], - canCancel: false, -} - -export const WithCancel = Template.bind({}) -WithCancel.args = { - primaryAction: , - secondaryActions: [], - canCancel: true, - handleCancel: action("cancel"), -} diff --git a/site/src/components/DropdownButton/DropdownButton.tsx b/site/src/components/DropdownButton/DropdownButton.tsx deleted file mode 100644 index c7788941e6851..0000000000000 --- a/site/src/components/DropdownButton/DropdownButton.tsx +++ /dev/null @@ -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 = ({ - primaryAction, - secondaryActions, - canCancel, - handleCancel, -}) => { - const styles = useStyles() - const theme = useTheme() - const anchorRef = useRef(null) - const [isOpen, setIsOpen] = useState(false) - const id = isOpen ? "action-popover" : undefined - const canOpen = secondaryActions.length > 0 - - return ( - - {/* primary workspace CTA */} - - {primaryAction} - - {canCancel && handleCancel ? ( - - ) : ( - <> - {/* popover toggle button */} - - setIsOpen(false)} - onBlur={() => setIsOpen(false)} - anchorOrigin={{ - vertical: "bottom", - horizontal: "right", - }} - transformOrigin={{ - vertical: "top", - horizontal: "right", - }} - > - {/* secondary workspace CTAs */} - - - - )} - - ) -} - -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, - }, - }, - }, -})) diff --git a/site/src/components/DropdownButton/DropdownContent/DropdownContent.tsx b/site/src/components/DropdownButton/DropdownContent/DropdownContent.tsx deleted file mode 100644 index 3ed91531e795f..0000000000000 --- a/site/src/components/DropdownButton/DropdownContent/DropdownContent.tsx +++ /dev/null @@ -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 -> = ({ secondaryActions }) => { - const styles = useStyles() - - return ( - - {secondaryActions.map(({ action, button }) => ( -
- {button} -
- ))} -
- ) -} - -const useStyles = makeStyles(() => ({ - popoverActionButton: { - "& .MuiButtonBase-root": { - backgroundColor: "unset", - justifyContent: "start", - padding: "0px", - }, - }, -})) diff --git a/site/src/components/TemplateLayout/TemplateLayout.tsx b/site/src/components/TemplateLayout/TemplateLayout.tsx index 2dfd1569110a3..0954cddde4589 100644 --- a/site/src/components/TemplateLayout/TemplateLayout.tsx +++ b/site/src/components/TemplateLayout/TemplateLayout.tsx @@ -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", diff --git a/site/src/components/TemplateLayout/TemplatePageHeader.tsx b/site/src/components/TemplateLayout/TemplatePageHeader.tsx index 636d3dcb9db77..1776377339a95 100644 --- a/site/src/components/TemplateLayout/TemplatePageHeader.tsx +++ b/site/src/components/TemplateLayout/TemplatePageHeader.tsx @@ -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(null) + const menuTriggerRef = useRef(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} > setIsMenuOpen(false)} > + navigate(`/templates/${templateName}/settings`), + )} > - {Language.settingsButton} + + Settings {canEditFiles && ( + navigate(`/templates/new?fromTemplate=${templateName}`), + )} > - {Language.duplicateButton} + + Duplicate )} {canEditFiles && ( + navigate( + `/templates/${templateName}/versions/${templateVersion}/edit`, + ), + )} > - {Language.editFilesButton} + + Edit files )} - { - onDelete() - handleClose() - }} - > - {Language.deleteButton} + + + Delete @@ -108,7 +109,7 @@ const CreateWorkspaceButton: FC<{ component={RouterLink} to={`/templates/${templateName}/workspace`} > - {Language.createButton} + Create workspace ) diff --git a/site/src/components/WorkspaceActionButton/WorkspaceActionButton.stories.tsx b/site/src/components/WorkspaceActionButton/WorkspaceActionButton.stories.tsx deleted file mode 100644 index 6a8045d0efda9..0000000000000 --- a/site/src/components/WorkspaceActionButton/WorkspaceActionButton.stories.tsx +++ /dev/null @@ -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 - -const Template: Story = (args) => ( - -) - -export const Example = Template.bind({}) -Example.args = { - icon: , - label: "Start workspace", -} diff --git a/site/src/components/WorkspaceActionButton/WorkspaceActionButton.tsx b/site/src/components/WorkspaceActionButton/WorkspaceActionButton.tsx deleted file mode 100644 index 594cd23b75adf..0000000000000 --- a/site/src/components/WorkspaceActionButton/WorkspaceActionButton.tsx +++ /dev/null @@ -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 -> = ({ label, icon, onClick, className, ariaLabel }) => { - return ( - - ) -} diff --git a/site/src/components/WorkspaceActions/Buttons.tsx b/site/src/components/WorkspaceActions/Buttons.tsx new file mode 100644 index 0000000000000..b8c38469df68f --- /dev/null +++ b/site/src/components/WorkspaceActions/Buttons.tsx @@ -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> = ({ + handleAction, +}) => { + const { t } = useTranslation("workspacePage") + const styles = useStyles() + + return ( + + ) +} + +export const StartButton: FC> = ({ + handleAction, +}) => { + const { t } = useTranslation("workspacePage") + const styles = useStyles() + + return ( + + ) +} + +export const StopButton: FC> = ({ + handleAction, +}) => { + const { t } = useTranslation("workspacePage") + const styles = useStyles() + + return ( + + ) +} + +export const CancelButton: FC> = ({ + handleAction, +}) => { + return ( + + ) +} + +interface DisabledProps { + label: string +} + +export const DisabledButton: FC> = ({ + label, +}) => { + return ( + + ) +} + +interface LoadingProps { + label: string +} + +export const ActionLoadingButton: FC> = ({ + label, +}) => { + const styles = useStyles() + return ( + + ) +} + +const useStyles = makeStyles((theme) => ({ + fixedWidth: { + // Make it fixed so the loading changes will not "flick" the UI + width: theme.spacing(16), + }, +})) diff --git a/site/src/components/WorkspaceActions/WorkspaceActions.tsx b/site/src/components/WorkspaceActions/WorkspaceActions.tsx index 029c3c7cac88f..9e422d60f6085 100644 --- a/site/src/components/WorkspaceActions/WorkspaceActions.tsx +++ b/site/src/components/WorkspaceActions/WorkspaceActions.tsx @@ -1,19 +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, CancelButton, - ChangeVersionButton, - DeleteButton, 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 @@ -43,10 +51,16 @@ export const WorkspaceActions: FC = ({ isUpdating, canChangeVersions, }) => { + const styles = useStyles() const { t } = useTranslation("workspacePage") - const { canCancel, canAcceptJobs, primaryActions, secondaryActions } = - buttonAbilities(workspaceStatus) + const { + canCancel, + canAcceptJobs, + actions: actionsByStatus, + } = actionsByWorkspaceStatus(workspaceStatus) const canBeUpdated = isOutdated && canAcceptJobs + const menuTriggerRef = useRef(null) + const [isMenuOpen, setIsMenuOpen] = useState(false) // A mapping of button type to the corresponding React component const buttonMapping: ButtonMapping = { @@ -54,14 +68,6 @@ export const WorkspaceActions: FC = ({ [ButtonTypesEnum.updating]: ( ), - [ButtonTypesEnum.settings]: ( - - ), - [ButtonTypesEnum.changeVersion]: canChangeVersions ? ( - - ) : ( - <> - ), [ButtonTypesEnum.start]: , [ButtonTypesEnum.starting]: ( @@ -70,7 +76,6 @@ export const WorkspaceActions: FC = ({ [ButtonTypesEnum.stopping]: ( ), - [ButtonTypesEnum.delete]: , [ButtonTypesEnum.deleting]: ( ), @@ -85,36 +90,61 @@ export const WorkspaceActions: FC = ({ ), } + // Returns a function that will execute the action and close the menu + const onMenuItemClick = (actionFn: () => void) => () => { + setIsMenuOpen(false) + actionFn() + } + return ( -
+
{canBeUpdated && (isUpdating ? buttonMapping[ButtonTypesEnum.updating] : buttonMapping[ButtonTypesEnum.update])} - {primaryActions.map((primaryAction) => buttonMapping[primaryAction])} + {actionsByStatus.map((action) => buttonMapping[action])} {canCancel && } +
+ + setIsMenuOpen(false)} + > + + + Settings + + {canChangeVersions && ( + + + Change version + + )} + + + Delete + + +
) - // // 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], - // ) - - // return ( - // ({ - // action, - // button: buttonMapping[action], - // }))} - // /> - // ) } + +const useStyles = makeStyles((theme) => ({ + actions: { + display: "flex", + alignItems: "center", + gap: theme.spacing(2), + }, +})) diff --git a/site/src/components/WorkspaceActions/constants.ts b/site/src/components/WorkspaceActions/constants.ts index a4e265a775a51..18923c3f38295 100644 --- a/site/src/components/WorkspaceActions/constants.ts +++ b/site/src/components/WorkspaceActions/constants.ts @@ -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", @@ -24,57 +21,46 @@ export type ButtonMapping = { } interface WorkspaceAbilities { - primaryActions: ButtonTypesEnum[] - secondaryActions?: ButtonTypesEnum[] + actions: ButtonTypesEnum[] canCancel: boolean canAcceptJobs: boolean } -export const buttonAbilities = ( +export const actionsByWorkspaceStatus = ( status: WorkspaceStatus, ): WorkspaceAbilities => { - return statusToAbilities[status] + return statusToActions[status] } -const defaultSecondaryActions = [ - ButtonTypesEnum.settings, - ButtonTypesEnum.changeVersion, - ButtonTypesEnum.delete, -] - -const statusToAbilities: Record = { +const statusToActions: Record = { starting: { - primaryActions: [ButtonTypesEnum.starting], + actions: [ButtonTypesEnum.starting], canCancel: true, canAcceptJobs: false, }, running: { - primaryActions: [ButtonTypesEnum.stop], - secondaryActions: defaultSecondaryActions, + actions: [ButtonTypesEnum.stop], canCancel: false, canAcceptJobs: true, }, stopping: { - primaryActions: [ButtonTypesEnum.stopping], + actions: [ButtonTypesEnum.stopping], canCancel: true, canAcceptJobs: false, }, stopped: { - primaryActions: [ButtonTypesEnum.start], - secondaryActions: defaultSecondaryActions, + actions: [ButtonTypesEnum.start], canCancel: false, canAcceptJobs: true, }, canceled: { - primaryActions: [ButtonTypesEnum.start, ButtonTypesEnum.stop], - secondaryActions: defaultSecondaryActions, + actions: [ButtonTypesEnum.start, ButtonTypesEnum.stop], canCancel: false, canAcceptJobs: true, }, // in the case of an error failed: { - primaryActions: [ButtonTypesEnum.start, ButtonTypesEnum.stop], - secondaryActions: defaultSecondaryActions, + actions: [ButtonTypesEnum.start, ButtonTypesEnum.stop], canCancel: false, canAcceptJobs: true, }, @@ -82,22 +68,22 @@ const statusToAbilities: Record = { * disabled states */ canceling: { - primaryActions: [ButtonTypesEnum.canceling], + actions: [ButtonTypesEnum.canceling], canCancel: false, canAcceptJobs: false, }, deleting: { - primaryActions: [ButtonTypesEnum.deleting], + actions: [ButtonTypesEnum.deleting], canCancel: true, canAcceptJobs: false, }, deleted: { - primaryActions: [ButtonTypesEnum.deleted], + actions: [ButtonTypesEnum.deleted], canCancel: false, - canAcceptJobs: true, + canAcceptJobs: false, }, pending: { - primaryActions: [ButtonTypesEnum.pending], + actions: [ButtonTypesEnum.pending], canCancel: false, canAcceptJobs: false, }, diff --git a/site/src/theme/overrides.ts b/site/src/theme/overrides.ts index e9ded0bf46218..a6484d2d34371 100644 --- a/site/src/theme/overrides.ts +++ b/site/src/theme/overrides.ts @@ -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 From 4f0bc778a2e9715a477f6ee1bd311b86a2659150 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Fri, 14 Apr 2023 13:13:38 +0000 Subject: [PATCH 3/6] Remove tests that are testes on Storybook --- .../WorkspaceActions.test.tsx | 190 ------------------ 1 file changed, 190 deletions(-) delete mode 100644 site/src/components/WorkspaceActions/WorkspaceActions.test.tsx diff --git a/site/src/components/WorkspaceActions/WorkspaceActions.test.tsx b/site/src/components/WorkspaceActions/WorkspaceActions.test.tsx deleted file mode 100644 index 98c8c3e7fd1af..0000000000000 --- a/site/src/components/WorkspaceActions/WorkspaceActions.test.tsx +++ /dev/null @@ -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 = {}) => { - render( - , - ) -} - -const renderAndClick = async (props: Partial = {}) => { - render( - , - ) - 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" }), - ) - }) - }) -}) From a9bb484e3dc78ba9b57eadb794d28c4e3a072c01 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Fri, 14 Apr 2023 13:54:10 +0000 Subject: [PATCH 4/6] Fix tests --- site/src/components/WorkspaceActions/WorkspaceActions.tsx | 3 ++- site/src/pages/WorkspacePage/WorkspacePage.test.tsx | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/site/src/components/WorkspaceActions/WorkspaceActions.tsx b/site/src/components/WorkspaceActions/WorkspaceActions.tsx index 9e422d60f6085..ca1196079079b 100644 --- a/site/src/components/WorkspaceActions/WorkspaceActions.tsx +++ b/site/src/components/WorkspaceActions/WorkspaceActions.tsx @@ -97,7 +97,7 @@ export const WorkspaceActions: FC = ({ } return ( -
+
{canBeUpdated && (isUpdating ? buttonMapping[ButtonTypesEnum.updating] @@ -106,6 +106,7 @@ export const WorkspaceActions: FC = ({ {canCancel && }
+ ) +} + +export const CancelButton: FC> = ({ handleAction, }) => { return ( @@ -80,7 +99,7 @@ interface DisabledProps { label: string } -export const DisabledButton: FC> = ({ +export const DisabledButton: FC> = ({ label, }) => { return ( @@ -94,7 +113,7 @@ interface LoadingProps { label: string } -export const ActionLoadingButton: FC> = ({ +export const ActionLoadingButton: FC> = ({ label, }) => { const styles = useStyles() diff --git a/site/src/components/WorkspaceActions/WorkspaceActions.stories.tsx b/site/src/components/WorkspaceActions/WorkspaceActions.stories.tsx index 090e81cb2bb03..b2b2526811d0d 100644 --- a/site/src/components/WorkspaceActions/WorkspaceActions.stories.tsx +++ b/site/src/components/WorkspaceActions/WorkspaceActions.stories.tsx @@ -15,6 +15,7 @@ const Template: Story = (args) => ( const defaultArgs = { handleStart: action("start"), handleStop: action("stop"), + handleRestart: action("restart"), handleDelete: action("delete"), handleUpdate: action("update"), handleCancel: action("cancel"), diff --git a/site/src/components/WorkspaceActions/WorkspaceActions.test.tsx b/site/src/components/WorkspaceActions/WorkspaceActions.test.tsx index 98c8c3e7fd1af..cc4fa7a99f972 100644 --- a/site/src/components/WorkspaceActions/WorkspaceActions.test.tsx +++ b/site/src/components/WorkspaceActions/WorkspaceActions.test.tsx @@ -15,11 +15,14 @@ const renderComponent = async (props: Partial = {}) => { isOutdated={props.isOutdated ?? false} handleStart={jest.fn()} handleStop={jest.fn()} + handleRestart={jest.fn()} handleDelete={jest.fn()} handleUpdate={jest.fn()} handleCancel={jest.fn()} handleSettings={jest.fn()} + handleChangeVersion={jest.fn()} isUpdating={false} + canChangeVersions={false} />, ) } @@ -33,11 +36,14 @@ const renderAndClick = async (props: Partial = {}) => { isOutdated={props.isOutdated ?? false} handleStart={jest.fn()} handleStop={jest.fn()} + handleRestart={jest.fn()} handleDelete={jest.fn()} handleUpdate={jest.fn()} handleCancel={jest.fn()} handleSettings={jest.fn()} + handleChangeVersion={jest.fn()} isUpdating={false} + canChangeVersions={false} />, ) const trigger = await screen.findByTestId("workspace-actions-button") @@ -62,7 +68,7 @@ describe("WorkspaceActions", () => { }) }) describe("when the workspace is started", () => { - it("primary is stop; secondary is delete", async () => { + it("primary is stop; secondary are delete, restart", async () => { await renderAndClick({ workspaceStatus: Mocks.MockWorkspace.latest_build.status, }) @@ -72,6 +78,9 @@ describe("WorkspaceActions", () => { expect(screen.getByTestId("secondary-ctas")).toHaveTextContent( t("actionButton.delete", { ns: "workspacePage" }), ) + expect(screen.getByTestId("secondary-ctas")).toHaveTextContent( + t("actionButton.restart", { ns: "workspacePage" }), + ) }) }) describe("when the workspace is started", () => { diff --git a/site/src/components/WorkspaceActions/WorkspaceActions.tsx b/site/src/components/WorkspaceActions/WorkspaceActions.tsx index 9e422d60f6085..1f43b2958a6c3 100644 --- a/site/src/components/WorkspaceActions/WorkspaceActions.tsx +++ b/site/src/components/WorkspaceActions/WorkspaceActions.tsx @@ -12,6 +12,7 @@ import { DisabledButton, StartButton, StopButton, + RestartButton, UpdateButton, } from "./Buttons" import { @@ -28,6 +29,7 @@ export interface WorkspaceActionsProps { isOutdated: boolean handleStart: () => void handleStop: () => void + handleRestart: () => void handleDelete: () => void handleUpdate: () => void handleCancel: () => void @@ -43,6 +45,7 @@ export const WorkspaceActions: FC = ({ isOutdated, handleStart, handleStop, + handleRestart, handleDelete, handleUpdate, handleCancel, @@ -76,6 +79,7 @@ export const WorkspaceActions: FC = ({ [ButtonTypesEnum.stopping]: ( ), + [ButtonTypesEnum.restart]: , [ButtonTypesEnum.deleting]: ( ), diff --git a/site/src/components/WorkspaceActions/constants.ts b/site/src/components/WorkspaceActions/constants.ts index 18923c3f38295..373625ffebad5 100644 --- a/site/src/components/WorkspaceActions/constants.ts +++ b/site/src/components/WorkspaceActions/constants.ts @@ -7,6 +7,7 @@ export enum ButtonTypesEnum { starting = "starting", stop = "stop", stopping = "stopping", + restart = "restart", deleting = "deleting", update = "update", updating = "updating", @@ -39,7 +40,7 @@ const statusToActions: Record = { canAcceptJobs: false, }, running: { - actions: [ButtonTypesEnum.stop], + actions: [ButtonTypesEnum.stop, ButtonTypesEnum.restart], canCancel: false, canAcceptJobs: true, }, diff --git a/site/src/i18n/en/workspacePage.json b/site/src/i18n/en/workspacePage.json index fa5e9f8335021..fdcfc6ea31a65 100644 --- a/site/src/i18n/en/workspacePage.json +++ b/site/src/i18n/en/workspacePage.json @@ -21,6 +21,7 @@ "actionButton": { "start": "Start", "stop": "Stop", + "restart": "Restart", "delete": "Delete", "cancel": "Cancel", "update": "Update", diff --git a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx index c688735974787..b1fc97a738c45 100644 --- a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx @@ -124,6 +124,7 @@ export const WorkspaceReadyPage = ({ workspace={workspace} handleStart={() => workspaceSend({ type: "START" })} handleStop={() => workspaceSend({ type: "STOP" })} + handleRestart={() => workspaceSend({ type: "START" })} handleDelete={() => workspaceSend({ type: "ASK_DELETE" })} handleUpdate={() => workspaceSend({ type: "UPDATE" })} handleCancel={() => workspaceSend({ type: "CANCEL" })}