feat: improve update button visibility (#3115)

* feat: give update button primary focus when applicable

resolves #3024

* added update tooltip

* cleanup

* prettier

* PR feedback
This commit is contained in:
Kira Pilot
2022-07-22 14:28:52 -04:00
committed by GitHub
parent 2dd98c7ec8
commit 471564df7d
16 changed files with 131 additions and 79 deletions
@@ -1,12 +1,9 @@
import { makeStyles } from "@material-ui/core/styles"
import dayjs from "dayjs"
import relativeTime from "dayjs/plugin/relativeTime"
import { FC } from "react"
import { createDayString } from "util/createDayString"
import { Template, TemplateVersion } from "../../api/typesGenerated"
import { CardRadius, MONOSPACE_FONT_FAMILY } from "../../theme/constants"
dayjs.extend(relativeTime)
const Language = {
usedByLabel: "Used by",
activeVersionLabel: "Active version",
@@ -45,7 +42,7 @@ export const TemplateStats: FC<TemplateStatsProps> = ({ template, activeVersion
<div className={styles.statItem}>
<span className={styles.statsLabel}>{Language.lastUpdateLabel}</span>
<span className={styles.statsValue} data-chromatic="ignore">
{dayjs().to(dayjs(template.updated_at))}
{createDayString(template.updated_at)}
</span>
</div>
<div className={styles.statsDivider} />
@@ -110,16 +110,17 @@ export const HelpTooltipLink: React.FC<{ href: string }> = ({ children, href })
)
}
export const HelpTooltipAction: React.FC<{ icon: Icon; onClick: () => void }> = ({
children,
icon: Icon,
onClick,
}) => {
export const HelpTooltipAction: React.FC<{
icon: Icon
onClick: () => void
ariaLabel?: string
}> = ({ children, icon: Icon, onClick, ariaLabel }) => {
const styles = useStyles()
const tooltip = useHelpTooltip()
return (
<button
aria-label={ariaLabel ?? ""}
className={styles.action}
onClick={(event) => {
event.stopPropagation()
@@ -8,7 +8,7 @@ import {
HelpTooltipTitle,
} from "./HelpTooltip"
const Language = {
export const Language = {
outdatedLabel: "Outdated",
versionTooltipText: "This workspace version is outdated and a newer version is available.",
updateVersionLabel: "Update version",
@@ -16,15 +16,16 @@ const Language = {
interface TooltipProps {
onUpdateVersion: () => void
ariaLabel?: string
}
export const OutdatedHelpTooltip: FC<TooltipProps> = ({ onUpdateVersion }) => {
export const OutdatedHelpTooltip: FC<TooltipProps> = ({ onUpdateVersion, ariaLabel }) => {
return (
<HelpTooltip size="small">
<HelpTooltipTitle>{Language.outdatedLabel}</HelpTooltipTitle>
<HelpTooltipText>{Language.versionTooltipText}</HelpTooltipText>
<HelpTooltipLinksGroup>
<HelpTooltipAction icon={RefreshIcon} onClick={onUpdateVersion}>
<HelpTooltipAction icon={RefreshIcon} onClick={onUpdateVersion} ariaLabel={ariaLabel}>
{Language.updateVersionLabel}
</HelpTooltipAction>
</HelpTooltipLinksGroup>
+1 -1
View File
@@ -94,7 +94,7 @@ export const Workspace: FC<WorkspaceProps> = ({
handleClick={() => navigate(`/templates`)}
/>
<WorkspaceStats workspace={workspace} />
<WorkspaceStats workspace={workspace} handleUpdate={handleUpdate} />
{!!resources && !!resources.length && (
<Resources
@@ -6,8 +6,6 @@ import DeleteOutlineIcon from "@material-ui/icons/DeleteOutline"
import HighlightOffIcon from "@material-ui/icons/HighlightOff"
import PlayCircleOutlineIcon from "@material-ui/icons/PlayCircleOutline"
import { FC } from "react"
import { Workspace } from "../../api/typesGenerated"
import { WorkspaceStatus } from "../../util/workspace"
import { WorkspaceActionButton } from "../WorkspaceActionButton/WorkspaceActionButton"
export const Language = {
@@ -22,6 +20,16 @@ interface WorkspaceAction {
handleAction: () => void
}
export const UpdateButton: FC<WorkspaceAction> = ({ handleAction }) => {
const styles = useStyles()
return (
<Button className={styles.actionButton} startIcon={<CloudQueueIcon />} onClick={handleAction}>
{Language.update}
</Button>
)
}
export const StartButton: FC<WorkspaceAction> = ({ handleAction }) => {
const styles = useStyles()
@@ -61,36 +69,6 @@ export const DeleteButton: FC<WorkspaceAction> = ({ handleAction }) => {
)
}
type UpdateAction = WorkspaceAction & {
workspace: Workspace
workspaceStatus: WorkspaceStatus
}
export const UpdateButton: FC<UpdateAction> = ({ handleAction, workspace, workspaceStatus }) => {
const styles = useStyles()
/**
* Jobs submitted while another job is in progress will be discarded,
* so check whether workspace job status has reached completion (whether successful or not).
*/
const canAcceptJobs = (workspaceStatus: WorkspaceStatus) =>
["started", "stopped", "deleted", "error", "canceled"].includes(workspaceStatus)
return (
<>
{workspace.outdated && canAcceptJobs(workspaceStatus) && (
<Button
className={styles.actionButton}
startIcon={<CloudQueueIcon />}
onClick={handleAction}
>
{Language.update}
</Button>
)}
</>
)
}
export const CancelButton: FC<WorkspaceAction> = ({ handleAction }) => {
const styles = useStyles()
@@ -79,11 +79,11 @@ describe("WorkspaceActions", () => {
})
})
describe("when the workspace is outdated", () => {
it("primary is start; secondary are delete, update", async () => {
it("primary is update; secondary are start, delete", async () => {
await renderAndClick({ workspace: Mocks.MockOutdatedWorkspace })
expect(screen.getByTestId("primary-cta")).toHaveTextContent(Language.start)
expect(screen.getByTestId("primary-cta")).toHaveTextContent(Language.update)
expect(screen.getByTestId("secondary-ctas")).toHaveTextContent(Language.start)
expect(screen.getByTestId("secondary-ctas")).toHaveTextContent(Language.delete)
expect(screen.getByTestId("secondary-ctas")).toHaveTextContent(Language.update)
})
})
})
@@ -1,13 +1,20 @@
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, useRef, useState } from "react"
import { FC, ReactNode, useEffect, useMemo, useRef, useState } from "react"
import { Workspace } from "../../api/typesGenerated"
import { getWorkspaceStatus } from "../../util/workspace"
import { getWorkspaceStatus, WorkspaceStatus } from "../../util/workspace"
import { CloseDropdown, OpenDropdown } from "../DropdownArrows/DropdownArrows"
import { CancelButton, DeleteButton, StartButton, StopButton, UpdateButton } from "./ActionCtas"
import { ButtonTypesEnum, WorkspaceStateActions, WorkspaceStateEnum } from "./constants"
/**
* Jobs submitted while another job is in progress will be discarded,
* so check whether workspace job status has reached completion (whether successful or not).
*/
const canAcceptJobs = (workspaceStatus: WorkspaceStatus) =>
["started", "stopped", "deleted", "error", "canceled"].includes(workspaceStatus)
export interface WorkspaceActionsProps {
workspace: Workspace
handleStart: () => void
@@ -34,7 +41,23 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
workspace.latest_build,
)
const workspaceState = WorkspaceStateEnum[workspaceStatus]
const actions = WorkspaceStateActions[workspaceState]
const canBeUpdated = workspace.outdated && canAcceptJobs(workspaceStatus)
// actions are the primary and secondary CTAs that appear in the workspace actions dropdown
const actions = useMemo(() => {
if (!canBeUpdated) {
return WorkspaceStateActions[workspaceState]
}
// if an update is available, we make the update button the primary CTA
// and move the former primary CTA to the secondary actions list
const updatedActions = { ...WorkspaceStateActions[workspaceState] }
updatedActions.secondary.unshift(updatedActions.primary)
updatedActions.primary = ButtonTypesEnum.update
return updatedActions
}, [canBeUpdated, workspaceState])
/**
* Ensures we close the popover before calling any action handler
@@ -58,16 +81,10 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
// A mapping of button type to the corresponding React component
const buttonMapping: ButtonMapping = {
[ButtonTypesEnum.update]: <UpdateButton handleAction={handleUpdate} />,
[ButtonTypesEnum.start]: <StartButton handleAction={handleStart} />,
[ButtonTypesEnum.stop]: <StopButton handleAction={handleStop} />,
[ButtonTypesEnum.delete]: <DeleteButton handleAction={handleDelete} />,
[ButtonTypesEnum.update]: (
<UpdateButton
handleAction={handleUpdate}
workspace={workspace}
workspaceStatus={workspaceStatus}
/>
),
[ButtonTypesEnum.cancel]: <CancelButton handleAction={handleCancel} />,
[ButtonTypesEnum.canceling]: disabledButton,
[ButtonTypesEnum.disabled]: disabledButton,
@@ -45,7 +45,7 @@ export const WorkspaceStateActions: StateActionsType = {
},
[WorkspaceStateEnum.started]: {
primary: ButtonTypesEnum.stop,
secondary: [ButtonTypesEnum.delete, ButtonTypesEnum.update],
secondary: [ButtonTypesEnum.delete],
},
[WorkspaceStateEnum.stopping]: {
primary: ButtonTypesEnum.cancel,
@@ -53,16 +53,16 @@ export const WorkspaceStateActions: StateActionsType = {
},
[WorkspaceStateEnum.stopped]: {
primary: ButtonTypesEnum.start,
secondary: [ButtonTypesEnum.delete, ButtonTypesEnum.update],
secondary: [ButtonTypesEnum.delete],
},
[WorkspaceStateEnum.canceled]: {
primary: ButtonTypesEnum.start,
secondary: [ButtonTypesEnum.stop, ButtonTypesEnum.delete, ButtonTypesEnum.update],
secondary: [ButtonTypesEnum.stop, ButtonTypesEnum.delete],
},
// in the case of an error
[WorkspaceStateEnum.error]: {
primary: ButtonTypesEnum.start, // give the user the ability to start a workspace again
secondary: [ButtonTypesEnum.delete, ButtonTypesEnum.update], // allows the user to delete or update
secondary: [ButtonTypesEnum.delete], // allows the user to delete
},
/**
* disabled states
@@ -0,0 +1,31 @@
import { fireEvent, screen } from "@testing-library/react"
import { Language } from "components/Tooltips/OutdatedHelpTooltip"
import { WorkspaceStats } from "components/WorkspaceStats/WorkspaceStats"
import { MockOutdatedWorkspace } from "testHelpers/entities"
import { renderWithAuth } from "testHelpers/renderHelpers"
import * as CreateDayString from "util/createDayString"
describe("WorkspaceStats", () => {
it("shows an outdated tooltip", async () => {
// Mocking the dayjs module within the createDayString file
const mock = jest.spyOn(CreateDayString, "createDayString")
mock.mockImplementation(() => "a minute ago")
const handleUpdateMock = jest.fn()
renderWithAuth(
<WorkspaceStats handleUpdate={handleUpdateMock} workspace={MockOutdatedWorkspace} />,
{
route: `/@${MockOutdatedWorkspace.owner_name}/${MockOutdatedWorkspace.name}`,
path: "/@:username/:workspace",
},
)
const tooltipButton = await screen.findByRole("button")
fireEvent.click(tooltipButton)
expect(await screen.findByText(Language.versionTooltipText)).toBeInTheDocument()
const updateButton = screen.getByRole("button", {
name: "update version",
})
fireEvent.click(updateButton)
expect(handleUpdateMock).toBeCalledTimes(1)
})
})
@@ -1,12 +1,13 @@
import Link from "@material-ui/core/Link"
import { makeStyles, useTheme } from "@material-ui/core/styles"
import dayjs from "dayjs"
import { OutdatedHelpTooltip } from "components/Tooltips"
import { FC } from "react"
import { Link as RouterLink } from "react-router-dom"
import { combineClasses } from "util/combineClasses"
import { createDayString } from "util/createDayString"
import { getDisplayStatus, getDisplayWorkspaceBuildInitiatedBy } from "util/workspace"
import { Workspace } from "../../api/typesGenerated"
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants"
import { combineClasses } from "../../util/combineClasses"
import { getDisplayStatus, getDisplayWorkspaceBuildInitiatedBy } from "../../util/workspace"
const Language = {
workspaceDetails: "Workspace Details",
@@ -21,9 +22,10 @@ const Language = {
export interface WorkspaceStatsProps {
workspace: Workspace
handleUpdate: () => void
}
export const WorkspaceStats: FC<WorkspaceStatsProps> = ({ workspace }) => {
export const WorkspaceStats: FC<WorkspaceStatsProps> = ({ workspace, handleUpdate }) => {
const styles = useStyles()
const theme = useTheme()
const status = getDisplayStatus(theme, workspace.latest_build)
@@ -46,7 +48,10 @@ export const WorkspaceStats: FC<WorkspaceStatsProps> = ({ workspace }) => {
<span className={styles.statsLabel}>{Language.versionLabel}</span>
<span className={styles.statsValue}>
{workspace.outdated ? (
<span style={{ color: theme.palette.error.main }}>{Language.outdated}</span>
<span className={styles.outdatedLabel}>
{Language.outdated}
<OutdatedHelpTooltip onUpdateVersion={handleUpdate} ariaLabel="update version" />
</span>
) : (
<span style={{ color: theme.palette.text.secondary }}>{Language.upToDate}</span>
)}
@@ -56,7 +61,7 @@ export const WorkspaceStats: FC<WorkspaceStatsProps> = ({ workspace }) => {
<div className={styles.statItem}>
<span className={styles.statsLabel}>{Language.lastBuiltLabel}</span>
<span className={styles.statsValue} data-chromatic="ignore">
{dayjs().to(dayjs(workspace.latest_build.created_at))}
{createDayString(workspace.latest_build.created_at)}
</span>
</div>
<div className={styles.statsDivider} />
@@ -133,4 +138,10 @@ const useStyles = makeStyles((theme) => ({
color: theme.palette.text.primary,
fontWeight: 600,
},
outdatedLabel: {
color: theme.palette.error.main,
display: "flex",
alignItems: "center",
gap: theme.spacing(0.5),
},
}))
@@ -3,10 +3,9 @@ import TableRow from "@material-ui/core/TableRow"
import KeyboardArrowRight from "@material-ui/icons/KeyboardArrowRight"
import useTheme from "@material-ui/styles/useTheme"
import { useActor } from "@xstate/react"
import dayjs from "dayjs"
import relativeTime from "dayjs/plugin/relativeTime"
import { FC } from "react"
import { useNavigate } from "react-router-dom"
import { createDayString } from "util/createDayString"
import { getDisplayStatus, getDisplayWorkspaceBuildInitiatedBy } from "../../util/workspace"
import { WorkspaceItemMachineRef } from "../../xServices/workspaces/workspacesXService"
import { AvatarData } from "../AvatarData/AvatarData"
@@ -18,8 +17,6 @@ import {
import { TableCellLink } from "../TableCellLink/TableCellLink"
import { OutdatedHelpTooltip } from "../Tooltips"
dayjs.extend(relativeTime)
const Language = {
upToDateLabel: "Up to date",
outdatedLabel: "Outdated",
@@ -58,7 +55,7 @@ export const WorkspacesRow: FC<{ workspaceRef: WorkspaceItemMachineRef }> = ({ w
<TableCellLink to={workspacePageLink}>
<AvatarData
title={initiatedBy}
subtitle={dayjs().to(dayjs(workspace.latest_build.created_at))}
subtitle={createDayString(workspace.latest_build.created_at)}
/>
</TableCellLink>
<TableCellLink to={workspacePageLink}>
@@ -1,4 +1,5 @@
import { screen } from "@testing-library/react"
import * as CreateDayString from "util/createDayString"
import {
MockTemplate,
MockTemplateVersion,
@@ -9,6 +10,10 @@ import { TemplatePage } from "./TemplatePage"
describe("TemplatePage", () => {
it("shows the template name, readme and resources", async () => {
// Mocking the dayjs module within the createDayString file
const mock = jest.spyOn(CreateDayString, "createDayString")
mock.mockImplementation(() => "a minute ago")
renderWithAuth(<TemplatePage />, {
route: `/templates/${MockTemplate.id}`,
path: "/templates/:template",
@@ -1,5 +1,6 @@
import { screen } from "@testing-library/react"
import { rest } from "msw"
import * as CreateDayString from "util/createDayString"
import { MockTemplate } from "../../testHelpers/entities"
import { history, render } from "../../testHelpers/renderHelpers"
import { server } from "../../testHelpers/server"
@@ -8,6 +9,9 @@ import { Language } from "./TemplatesPageView"
describe("TemplatesPage", () => {
beforeEach(() => {
// Mocking the dayjs module within the createDayString file
const mock = jest.spyOn(CreateDayString, "createDayString")
mock.mockImplementation(() => "a minute ago")
history.replace("/workspaces")
})
@@ -6,10 +6,9 @@ import TableCell from "@material-ui/core/TableCell"
import TableHead from "@material-ui/core/TableHead"
import TableRow from "@material-ui/core/TableRow"
import KeyboardArrowRight from "@material-ui/icons/KeyboardArrowRight"
import dayjs from "dayjs"
import relativeTime from "dayjs/plugin/relativeTime"
import { FC } from "react"
import { useNavigate } from "react-router-dom"
import { createDayString } from "util/createDayString"
import * as TypesGen from "../../api/typesGenerated"
import { AvatarData } from "../../components/AvatarData/AvatarData"
import { CodeExample } from "../../components/CodeExample/CodeExample"
@@ -31,8 +30,6 @@ import {
HelpTooltipTitle,
} from "../../components/Tooltips/HelpTooltip/HelpTooltip"
dayjs.extend(relativeTime)
export const Language = {
developerCount: (ownerCount: number): string => {
return `${ownerCount} developer${ownerCount !== 1 ? "s" : ""}`
@@ -151,7 +148,7 @@ export const TemplatesPageView: FC<TemplatesPageViewProps> = (props) => {
</TableCellLink>
<TableCellLink data-chromatic="ignore" to={templatePageLink}>
{dayjs().to(dayjs(template.updated_at))}
{createDayString(template.updated_at)}
</TableCellLink>
<TableCellLink to={templatePageLink}>{template.created_by_name}</TableCellLink>
<TableCellLink to={templatePageLink}>
@@ -1,5 +1,6 @@
import { screen } from "@testing-library/react"
import { rest } from "msw"
import * as CreateDayString from "util/createDayString"
import { Language as WorkspacesTableBodyLanguage } from "../../components/WorkspacesTable/WorkspacesTableBody"
import { MockWorkspace } from "../../testHelpers/entities"
import { history, render } from "../../testHelpers/renderHelpers"
@@ -9,6 +10,9 @@ import WorkspacesPage from "./WorkspacesPage"
describe("WorkspacesPage", () => {
beforeEach(() => {
history.replace("/workspaces")
// Mocking the dayjs module within the createDayString file
const mock = jest.spyOn(CreateDayString, "createDayString")
mock.mockImplementation(() => "a minute ago")
})
it("renders an empty workspaces page", async () => {
+9
View File
@@ -0,0 +1,9 @@
import dayjs from "dayjs"
/**
* Returns a human-readable string describing the passing of time
* Broken into its own module for testing purposes
*/
export function createDayString(time: string): string {
return dayjs().to(dayjs(time))
}