mirror of
https://github.com/coder/coder.git
synced 2025-07-15 22:20:27 +00:00
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:
@ -1,12 +1,9 @@
|
|||||||
import { makeStyles } from "@material-ui/core/styles"
|
import { makeStyles } from "@material-ui/core/styles"
|
||||||
import dayjs from "dayjs"
|
|
||||||
import relativeTime from "dayjs/plugin/relativeTime"
|
|
||||||
import { FC } from "react"
|
import { FC } from "react"
|
||||||
|
import { createDayString } from "util/createDayString"
|
||||||
import { Template, TemplateVersion } from "../../api/typesGenerated"
|
import { Template, TemplateVersion } from "../../api/typesGenerated"
|
||||||
import { CardRadius, MONOSPACE_FONT_FAMILY } from "../../theme/constants"
|
import { CardRadius, MONOSPACE_FONT_FAMILY } from "../../theme/constants"
|
||||||
|
|
||||||
dayjs.extend(relativeTime)
|
|
||||||
|
|
||||||
const Language = {
|
const Language = {
|
||||||
usedByLabel: "Used by",
|
usedByLabel: "Used by",
|
||||||
activeVersionLabel: "Active version",
|
activeVersionLabel: "Active version",
|
||||||
@ -45,7 +42,7 @@ export const TemplateStats: FC<TemplateStatsProps> = ({ template, activeVersion
|
|||||||
<div className={styles.statItem}>
|
<div className={styles.statItem}>
|
||||||
<span className={styles.statsLabel}>{Language.lastUpdateLabel}</span>
|
<span className={styles.statsLabel}>{Language.lastUpdateLabel}</span>
|
||||||
<span className={styles.statsValue} data-chromatic="ignore">
|
<span className={styles.statsValue} data-chromatic="ignore">
|
||||||
{dayjs().to(dayjs(template.updated_at))}
|
{createDayString(template.updated_at)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.statsDivider} />
|
<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 }> = ({
|
export const HelpTooltipAction: React.FC<{
|
||||||
children,
|
icon: Icon
|
||||||
icon: Icon,
|
onClick: () => void
|
||||||
onClick,
|
ariaLabel?: string
|
||||||
}) => {
|
}> = ({ children, icon: Icon, onClick, ariaLabel }) => {
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
const tooltip = useHelpTooltip()
|
const tooltip = useHelpTooltip()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
aria-label={ariaLabel ?? ""}
|
||||||
className={styles.action}
|
className={styles.action}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
@ -8,7 +8,7 @@ import {
|
|||||||
HelpTooltipTitle,
|
HelpTooltipTitle,
|
||||||
} from "./HelpTooltip"
|
} from "./HelpTooltip"
|
||||||
|
|
||||||
const Language = {
|
export const Language = {
|
||||||
outdatedLabel: "Outdated",
|
outdatedLabel: "Outdated",
|
||||||
versionTooltipText: "This workspace version is outdated and a newer version is available.",
|
versionTooltipText: "This workspace version is outdated and a newer version is available.",
|
||||||
updateVersionLabel: "Update version",
|
updateVersionLabel: "Update version",
|
||||||
@ -16,15 +16,16 @@ const Language = {
|
|||||||
|
|
||||||
interface TooltipProps {
|
interface TooltipProps {
|
||||||
onUpdateVersion: () => void
|
onUpdateVersion: () => void
|
||||||
|
ariaLabel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OutdatedHelpTooltip: FC<TooltipProps> = ({ onUpdateVersion }) => {
|
export const OutdatedHelpTooltip: FC<TooltipProps> = ({ onUpdateVersion, ariaLabel }) => {
|
||||||
return (
|
return (
|
||||||
<HelpTooltip size="small">
|
<HelpTooltip size="small">
|
||||||
<HelpTooltipTitle>{Language.outdatedLabel}</HelpTooltipTitle>
|
<HelpTooltipTitle>{Language.outdatedLabel}</HelpTooltipTitle>
|
||||||
<HelpTooltipText>{Language.versionTooltipText}</HelpTooltipText>
|
<HelpTooltipText>{Language.versionTooltipText}</HelpTooltipText>
|
||||||
<HelpTooltipLinksGroup>
|
<HelpTooltipLinksGroup>
|
||||||
<HelpTooltipAction icon={RefreshIcon} onClick={onUpdateVersion}>
|
<HelpTooltipAction icon={RefreshIcon} onClick={onUpdateVersion} ariaLabel={ariaLabel}>
|
||||||
{Language.updateVersionLabel}
|
{Language.updateVersionLabel}
|
||||||
</HelpTooltipAction>
|
</HelpTooltipAction>
|
||||||
</HelpTooltipLinksGroup>
|
</HelpTooltipLinksGroup>
|
||||||
|
@ -94,7 +94,7 @@ export const Workspace: FC<WorkspaceProps> = ({
|
|||||||
handleClick={() => navigate(`/templates`)}
|
handleClick={() => navigate(`/templates`)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<WorkspaceStats workspace={workspace} />
|
<WorkspaceStats workspace={workspace} handleUpdate={handleUpdate} />
|
||||||
|
|
||||||
{!!resources && !!resources.length && (
|
{!!resources && !!resources.length && (
|
||||||
<Resources
|
<Resources
|
||||||
|
@ -6,8 +6,6 @@ import DeleteOutlineIcon from "@material-ui/icons/DeleteOutline"
|
|||||||
import HighlightOffIcon from "@material-ui/icons/HighlightOff"
|
import HighlightOffIcon from "@material-ui/icons/HighlightOff"
|
||||||
import PlayCircleOutlineIcon from "@material-ui/icons/PlayCircleOutline"
|
import PlayCircleOutlineIcon from "@material-ui/icons/PlayCircleOutline"
|
||||||
import { FC } from "react"
|
import { FC } from "react"
|
||||||
import { Workspace } from "../../api/typesGenerated"
|
|
||||||
import { WorkspaceStatus } from "../../util/workspace"
|
|
||||||
import { WorkspaceActionButton } from "../WorkspaceActionButton/WorkspaceActionButton"
|
import { WorkspaceActionButton } from "../WorkspaceActionButton/WorkspaceActionButton"
|
||||||
|
|
||||||
export const Language = {
|
export const Language = {
|
||||||
@ -22,6 +20,16 @@ interface WorkspaceAction {
|
|||||||
handleAction: () => void
|
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 }) => {
|
export const StartButton: FC<WorkspaceAction> = ({ handleAction }) => {
|
||||||
const styles = useStyles()
|
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 }) => {
|
export const CancelButton: FC<WorkspaceAction> = ({ handleAction }) => {
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
|
|
||||||
|
@ -79,11 +79,11 @@ describe("WorkspaceActions", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
describe("when the workspace is outdated", () => {
|
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 })
|
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.delete)
|
||||||
expect(screen.getByTestId("secondary-ctas")).toHaveTextContent(Language.update)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -1,13 +1,20 @@
|
|||||||
import Button from "@material-ui/core/Button"
|
import Button from "@material-ui/core/Button"
|
||||||
import Popover from "@material-ui/core/Popover"
|
import Popover from "@material-ui/core/Popover"
|
||||||
import { makeStyles } from "@material-ui/core/styles"
|
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 { Workspace } from "../../api/typesGenerated"
|
||||||
import { getWorkspaceStatus } from "../../util/workspace"
|
import { getWorkspaceStatus, WorkspaceStatus } from "../../util/workspace"
|
||||||
import { CloseDropdown, OpenDropdown } from "../DropdownArrows/DropdownArrows"
|
import { CloseDropdown, OpenDropdown } from "../DropdownArrows/DropdownArrows"
|
||||||
import { CancelButton, DeleteButton, StartButton, StopButton, UpdateButton } from "./ActionCtas"
|
import { CancelButton, DeleteButton, StartButton, StopButton, UpdateButton } from "./ActionCtas"
|
||||||
import { ButtonTypesEnum, WorkspaceStateActions, WorkspaceStateEnum } from "./constants"
|
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 {
|
export interface WorkspaceActionsProps {
|
||||||
workspace: Workspace
|
workspace: Workspace
|
||||||
handleStart: () => void
|
handleStart: () => void
|
||||||
@ -34,7 +41,23 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
|
|||||||
workspace.latest_build,
|
workspace.latest_build,
|
||||||
)
|
)
|
||||||
const workspaceState = WorkspaceStateEnum[workspaceStatus]
|
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
|
* 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
|
// A mapping of button type to the corresponding React component
|
||||||
const buttonMapping: ButtonMapping = {
|
const buttonMapping: ButtonMapping = {
|
||||||
|
[ButtonTypesEnum.update]: <UpdateButton handleAction={handleUpdate} />,
|
||||||
[ButtonTypesEnum.start]: <StartButton handleAction={handleStart} />,
|
[ButtonTypesEnum.start]: <StartButton handleAction={handleStart} />,
|
||||||
[ButtonTypesEnum.stop]: <StopButton handleAction={handleStop} />,
|
[ButtonTypesEnum.stop]: <StopButton handleAction={handleStop} />,
|
||||||
[ButtonTypesEnum.delete]: <DeleteButton handleAction={handleDelete} />,
|
[ButtonTypesEnum.delete]: <DeleteButton handleAction={handleDelete} />,
|
||||||
[ButtonTypesEnum.update]: (
|
|
||||||
<UpdateButton
|
|
||||||
handleAction={handleUpdate}
|
|
||||||
workspace={workspace}
|
|
||||||
workspaceStatus={workspaceStatus}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
[ButtonTypesEnum.cancel]: <CancelButton handleAction={handleCancel} />,
|
[ButtonTypesEnum.cancel]: <CancelButton handleAction={handleCancel} />,
|
||||||
[ButtonTypesEnum.canceling]: disabledButton,
|
[ButtonTypesEnum.canceling]: disabledButton,
|
||||||
[ButtonTypesEnum.disabled]: disabledButton,
|
[ButtonTypesEnum.disabled]: disabledButton,
|
||||||
|
@ -45,7 +45,7 @@ export const WorkspaceStateActions: StateActionsType = {
|
|||||||
},
|
},
|
||||||
[WorkspaceStateEnum.started]: {
|
[WorkspaceStateEnum.started]: {
|
||||||
primary: ButtonTypesEnum.stop,
|
primary: ButtonTypesEnum.stop,
|
||||||
secondary: [ButtonTypesEnum.delete, ButtonTypesEnum.update],
|
secondary: [ButtonTypesEnum.delete],
|
||||||
},
|
},
|
||||||
[WorkspaceStateEnum.stopping]: {
|
[WorkspaceStateEnum.stopping]: {
|
||||||
primary: ButtonTypesEnum.cancel,
|
primary: ButtonTypesEnum.cancel,
|
||||||
@ -53,16 +53,16 @@ export const WorkspaceStateActions: StateActionsType = {
|
|||||||
},
|
},
|
||||||
[WorkspaceStateEnum.stopped]: {
|
[WorkspaceStateEnum.stopped]: {
|
||||||
primary: ButtonTypesEnum.start,
|
primary: ButtonTypesEnum.start,
|
||||||
secondary: [ButtonTypesEnum.delete, ButtonTypesEnum.update],
|
secondary: [ButtonTypesEnum.delete],
|
||||||
},
|
},
|
||||||
[WorkspaceStateEnum.canceled]: {
|
[WorkspaceStateEnum.canceled]: {
|
||||||
primary: ButtonTypesEnum.start,
|
primary: ButtonTypesEnum.start,
|
||||||
secondary: [ButtonTypesEnum.stop, ButtonTypesEnum.delete, ButtonTypesEnum.update],
|
secondary: [ButtonTypesEnum.stop, ButtonTypesEnum.delete],
|
||||||
},
|
},
|
||||||
// in the case of an error
|
// in the case of an error
|
||||||
[WorkspaceStateEnum.error]: {
|
[WorkspaceStateEnum.error]: {
|
||||||
primary: ButtonTypesEnum.start, // give the user the ability to start a workspace again
|
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
|
* disabled states
|
||||||
|
31
site/src/components/WorkspaceStats/WorkspaceStats.test.tsx
Normal file
31
site/src/components/WorkspaceStats/WorkspaceStats.test.tsx
Normal file
@ -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 Link from "@material-ui/core/Link"
|
||||||
import { makeStyles, useTheme } from "@material-ui/core/styles"
|
import { makeStyles, useTheme } from "@material-ui/core/styles"
|
||||||
import dayjs from "dayjs"
|
import { OutdatedHelpTooltip } from "components/Tooltips"
|
||||||
import { FC } from "react"
|
import { FC } from "react"
|
||||||
import { Link as RouterLink } from "react-router-dom"
|
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 { Workspace } from "../../api/typesGenerated"
|
||||||
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants"
|
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants"
|
||||||
import { combineClasses } from "../../util/combineClasses"
|
|
||||||
import { getDisplayStatus, getDisplayWorkspaceBuildInitiatedBy } from "../../util/workspace"
|
|
||||||
|
|
||||||
const Language = {
|
const Language = {
|
||||||
workspaceDetails: "Workspace Details",
|
workspaceDetails: "Workspace Details",
|
||||||
@ -21,9 +22,10 @@ const Language = {
|
|||||||
|
|
||||||
export interface WorkspaceStatsProps {
|
export interface WorkspaceStatsProps {
|
||||||
workspace: Workspace
|
workspace: Workspace
|
||||||
|
handleUpdate: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WorkspaceStats: FC<WorkspaceStatsProps> = ({ workspace }) => {
|
export const WorkspaceStats: FC<WorkspaceStatsProps> = ({ workspace, handleUpdate }) => {
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const status = getDisplayStatus(theme, workspace.latest_build)
|
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.statsLabel}>{Language.versionLabel}</span>
|
||||||
<span className={styles.statsValue}>
|
<span className={styles.statsValue}>
|
||||||
{workspace.outdated ? (
|
{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>
|
<span style={{ color: theme.palette.text.secondary }}>{Language.upToDate}</span>
|
||||||
)}
|
)}
|
||||||
@ -56,7 +61,7 @@ export const WorkspaceStats: FC<WorkspaceStatsProps> = ({ workspace }) => {
|
|||||||
<div className={styles.statItem}>
|
<div className={styles.statItem}>
|
||||||
<span className={styles.statsLabel}>{Language.lastBuiltLabel}</span>
|
<span className={styles.statsLabel}>{Language.lastBuiltLabel}</span>
|
||||||
<span className={styles.statsValue} data-chromatic="ignore">
|
<span className={styles.statsValue} data-chromatic="ignore">
|
||||||
{dayjs().to(dayjs(workspace.latest_build.created_at))}
|
{createDayString(workspace.latest_build.created_at)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.statsDivider} />
|
<div className={styles.statsDivider} />
|
||||||
@ -133,4 +138,10 @@ const useStyles = makeStyles((theme) => ({
|
|||||||
color: theme.palette.text.primary,
|
color: theme.palette.text.primary,
|
||||||
fontWeight: 600,
|
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 KeyboardArrowRight from "@material-ui/icons/KeyboardArrowRight"
|
||||||
import useTheme from "@material-ui/styles/useTheme"
|
import useTheme from "@material-ui/styles/useTheme"
|
||||||
import { useActor } from "@xstate/react"
|
import { useActor } from "@xstate/react"
|
||||||
import dayjs from "dayjs"
|
|
||||||
import relativeTime from "dayjs/plugin/relativeTime"
|
|
||||||
import { FC } from "react"
|
import { FC } from "react"
|
||||||
import { useNavigate } from "react-router-dom"
|
import { useNavigate } from "react-router-dom"
|
||||||
|
import { createDayString } from "util/createDayString"
|
||||||
import { getDisplayStatus, getDisplayWorkspaceBuildInitiatedBy } from "../../util/workspace"
|
import { getDisplayStatus, getDisplayWorkspaceBuildInitiatedBy } from "../../util/workspace"
|
||||||
import { WorkspaceItemMachineRef } from "../../xServices/workspaces/workspacesXService"
|
import { WorkspaceItemMachineRef } from "../../xServices/workspaces/workspacesXService"
|
||||||
import { AvatarData } from "../AvatarData/AvatarData"
|
import { AvatarData } from "../AvatarData/AvatarData"
|
||||||
@ -18,8 +17,6 @@ import {
|
|||||||
import { TableCellLink } from "../TableCellLink/TableCellLink"
|
import { TableCellLink } from "../TableCellLink/TableCellLink"
|
||||||
import { OutdatedHelpTooltip } from "../Tooltips"
|
import { OutdatedHelpTooltip } from "../Tooltips"
|
||||||
|
|
||||||
dayjs.extend(relativeTime)
|
|
||||||
|
|
||||||
const Language = {
|
const Language = {
|
||||||
upToDateLabel: "Up to date",
|
upToDateLabel: "Up to date",
|
||||||
outdatedLabel: "Outdated",
|
outdatedLabel: "Outdated",
|
||||||
@ -58,7 +55,7 @@ export const WorkspacesRow: FC<{ workspaceRef: WorkspaceItemMachineRef }> = ({ w
|
|||||||
<TableCellLink to={workspacePageLink}>
|
<TableCellLink to={workspacePageLink}>
|
||||||
<AvatarData
|
<AvatarData
|
||||||
title={initiatedBy}
|
title={initiatedBy}
|
||||||
subtitle={dayjs().to(dayjs(workspace.latest_build.created_at))}
|
subtitle={createDayString(workspace.latest_build.created_at)}
|
||||||
/>
|
/>
|
||||||
</TableCellLink>
|
</TableCellLink>
|
||||||
<TableCellLink to={workspacePageLink}>
|
<TableCellLink to={workspacePageLink}>
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { screen } from "@testing-library/react"
|
import { screen } from "@testing-library/react"
|
||||||
|
import * as CreateDayString from "util/createDayString"
|
||||||
import {
|
import {
|
||||||
MockTemplate,
|
MockTemplate,
|
||||||
MockTemplateVersion,
|
MockTemplateVersion,
|
||||||
@ -9,6 +10,10 @@ import { TemplatePage } from "./TemplatePage"
|
|||||||
|
|
||||||
describe("TemplatePage", () => {
|
describe("TemplatePage", () => {
|
||||||
it("shows the template name, readme and resources", async () => {
|
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 />, {
|
renderWithAuth(<TemplatePage />, {
|
||||||
route: `/templates/${MockTemplate.id}`,
|
route: `/templates/${MockTemplate.id}`,
|
||||||
path: "/templates/:template",
|
path: "/templates/:template",
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { screen } from "@testing-library/react"
|
import { screen } from "@testing-library/react"
|
||||||
import { rest } from "msw"
|
import { rest } from "msw"
|
||||||
|
import * as CreateDayString from "util/createDayString"
|
||||||
import { MockTemplate } from "../../testHelpers/entities"
|
import { MockTemplate } from "../../testHelpers/entities"
|
||||||
import { history, render } from "../../testHelpers/renderHelpers"
|
import { history, render } from "../../testHelpers/renderHelpers"
|
||||||
import { server } from "../../testHelpers/server"
|
import { server } from "../../testHelpers/server"
|
||||||
@ -8,6 +9,9 @@ import { Language } from "./TemplatesPageView"
|
|||||||
|
|
||||||
describe("TemplatesPage", () => {
|
describe("TemplatesPage", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
// Mocking the dayjs module within the createDayString file
|
||||||
|
const mock = jest.spyOn(CreateDayString, "createDayString")
|
||||||
|
mock.mockImplementation(() => "a minute ago")
|
||||||
history.replace("/workspaces")
|
history.replace("/workspaces")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -6,10 +6,9 @@ import TableCell from "@material-ui/core/TableCell"
|
|||||||
import TableHead from "@material-ui/core/TableHead"
|
import TableHead from "@material-ui/core/TableHead"
|
||||||
import TableRow from "@material-ui/core/TableRow"
|
import TableRow from "@material-ui/core/TableRow"
|
||||||
import KeyboardArrowRight from "@material-ui/icons/KeyboardArrowRight"
|
import KeyboardArrowRight from "@material-ui/icons/KeyboardArrowRight"
|
||||||
import dayjs from "dayjs"
|
|
||||||
import relativeTime from "dayjs/plugin/relativeTime"
|
|
||||||
import { FC } from "react"
|
import { FC } from "react"
|
||||||
import { useNavigate } from "react-router-dom"
|
import { useNavigate } from "react-router-dom"
|
||||||
|
import { createDayString } from "util/createDayString"
|
||||||
import * as TypesGen from "../../api/typesGenerated"
|
import * as TypesGen from "../../api/typesGenerated"
|
||||||
import { AvatarData } from "../../components/AvatarData/AvatarData"
|
import { AvatarData } from "../../components/AvatarData/AvatarData"
|
||||||
import { CodeExample } from "../../components/CodeExample/CodeExample"
|
import { CodeExample } from "../../components/CodeExample/CodeExample"
|
||||||
@ -31,8 +30,6 @@ import {
|
|||||||
HelpTooltipTitle,
|
HelpTooltipTitle,
|
||||||
} from "../../components/Tooltips/HelpTooltip/HelpTooltip"
|
} from "../../components/Tooltips/HelpTooltip/HelpTooltip"
|
||||||
|
|
||||||
dayjs.extend(relativeTime)
|
|
||||||
|
|
||||||
export const Language = {
|
export const Language = {
|
||||||
developerCount: (ownerCount: number): string => {
|
developerCount: (ownerCount: number): string => {
|
||||||
return `${ownerCount} developer${ownerCount !== 1 ? "s" : ""}`
|
return `${ownerCount} developer${ownerCount !== 1 ? "s" : ""}`
|
||||||
@ -151,7 +148,7 @@ export const TemplatesPageView: FC<TemplatesPageViewProps> = (props) => {
|
|||||||
</TableCellLink>
|
</TableCellLink>
|
||||||
|
|
||||||
<TableCellLink data-chromatic="ignore" to={templatePageLink}>
|
<TableCellLink data-chromatic="ignore" to={templatePageLink}>
|
||||||
{dayjs().to(dayjs(template.updated_at))}
|
{createDayString(template.updated_at)}
|
||||||
</TableCellLink>
|
</TableCellLink>
|
||||||
<TableCellLink to={templatePageLink}>{template.created_by_name}</TableCellLink>
|
<TableCellLink to={templatePageLink}>{template.created_by_name}</TableCellLink>
|
||||||
<TableCellLink to={templatePageLink}>
|
<TableCellLink to={templatePageLink}>
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { screen } from "@testing-library/react"
|
import { screen } from "@testing-library/react"
|
||||||
import { rest } from "msw"
|
import { rest } from "msw"
|
||||||
|
import * as CreateDayString from "util/createDayString"
|
||||||
import { Language as WorkspacesTableBodyLanguage } from "../../components/WorkspacesTable/WorkspacesTableBody"
|
import { Language as WorkspacesTableBodyLanguage } from "../../components/WorkspacesTable/WorkspacesTableBody"
|
||||||
import { MockWorkspace } from "../../testHelpers/entities"
|
import { MockWorkspace } from "../../testHelpers/entities"
|
||||||
import { history, render } from "../../testHelpers/renderHelpers"
|
import { history, render } from "../../testHelpers/renderHelpers"
|
||||||
@ -9,6 +10,9 @@ import WorkspacesPage from "./WorkspacesPage"
|
|||||||
describe("WorkspacesPage", () => {
|
describe("WorkspacesPage", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
history.replace("/workspaces")
|
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 () => {
|
it("renders an empty workspaces page", async () => {
|
||||||
|
9
site/src/util/createDayString.ts
Normal file
9
site/src/util/createDayString.ts
Normal 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))
|
||||||
|
}
|
Reference in New Issue
Block a user