Skip to content

feat: improve update button visibility #3115

New issue

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

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

Already on GitHub? Sign in to your account

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions site/src/components/TemplateStats/TemplateStats.tsx
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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} />
Expand Down
11 changes: 6 additions & 5 deletions site/src/components/Tooltips/HelpTooltip/HelpTooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
7 changes: 4 additions & 3 deletions site/src/components/Tooltips/OutdatedHelpTooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,24 @@ 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",
}

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>
Expand Down
2 changes: 1 addition & 1 deletion site/src/components/Workspace/Workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ export const Workspace: FC<WorkspaceProps> = ({
handleClick={() => navigate(`/templates`)}
/>

<WorkspaceStats workspace={workspace} />
<WorkspaceStats workspace={workspace} handleUpdate={handleUpdate} />

{!!resources && !!resources.length && (
<Resources
Expand Down
42 changes: 10 additions & 32 deletions site/src/components/WorkspaceActions/ActionCtas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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()

Expand Down Expand Up @@ -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()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
})
37 changes: 27 additions & 10 deletions site/src/components/WorkspaceActions/WorkspaceActions.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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,
Expand Down
8 changes: 4 additions & 4 deletions site/src/components/WorkspaceActions/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,24 +45,24 @@ export const WorkspaceStateActions: StateActionsType = {
},
[WorkspaceStateEnum.started]: {
primary: ButtonTypesEnum.stop,
secondary: [ButtonTypesEnum.delete, ButtonTypesEnum.update],
secondary: [ButtonTypesEnum.delete],
},
[WorkspaceStateEnum.stopping]: {
primary: ButtonTypesEnum.cancel,
secondary: [],
},
[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
Expand Down
31 changes: 31 additions & 0 deletions site/src/components/WorkspaceStats/WorkspaceStats.test.tsx
Original file line number Diff line number Diff line change
@@ -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)
})
})
23 changes: 17 additions & 6 deletions site/src/components/WorkspaceStats/WorkspaceStats.tsx
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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)
Expand All @@ -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>
)}
Expand All @@ -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} />
Expand Down Expand Up @@ -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),
},
}))
7 changes: 2 additions & 5 deletions site/src/components/WorkspacesTable/WorkspacesRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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",
Expand Down Expand Up @@ -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}>
Expand Down
5 changes: 5 additions & 0 deletions site/src/pages/TemplatePage/TemplatePage.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { screen } from "@testing-library/react"
import * as CreateDayString from "util/createDayString"
import {
MockTemplate,
MockTemplateVersion,
Expand All @@ -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",
Expand Down
Loading