diff --git a/site/src/components/TemplateStats/TemplateStats.tsx b/site/src/components/TemplateStats/TemplateStats.tsx index a0b7a0722cc45..b02730b329d99 100644 --- a/site/src/components/TemplateStats/TemplateStats.tsx +++ b/site/src/components/TemplateStats/TemplateStats.tsx @@ -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 = ({ template, activeVersion
{Language.lastUpdateLabel} - {dayjs().to(dayjs(template.updated_at))} + {createDayString(template.updated_at)}
diff --git a/site/src/components/Tooltips/HelpTooltip/HelpTooltip.tsx b/site/src/components/Tooltips/HelpTooltip/HelpTooltip.tsx index d8b4e460d26af..7c70d9146743a 100644 --- a/site/src/components/Tooltips/HelpTooltip/HelpTooltip.tsx +++ b/site/src/components/Tooltips/HelpTooltip/HelpTooltip.tsx @@ -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 ( + ) +} + export const StartButton: FC = ({ handleAction }) => { const styles = useStyles() @@ -61,36 +69,6 @@ export const DeleteButton: FC = ({ handleAction }) => { ) } -type UpdateAction = WorkspaceAction & { - workspace: Workspace - workspaceStatus: WorkspaceStatus -} - -export const UpdateButton: FC = ({ 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) && ( - - )} - - ) -} - export const CancelButton: FC = ({ handleAction }) => { const styles = useStyles() diff --git a/site/src/components/WorkspaceActions/WorkspaceActions.test.tsx b/site/src/components/WorkspaceActions/WorkspaceActions.test.tsx index d916393f70026..ca8b84d0e2d68 100644 --- a/site/src/components/WorkspaceActions/WorkspaceActions.test.tsx +++ b/site/src/components/WorkspaceActions/WorkspaceActions.test.tsx @@ -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) }) }) }) diff --git a/site/src/components/WorkspaceActions/WorkspaceActions.tsx b/site/src/components/WorkspaceActions/WorkspaceActions.tsx index 10cc02a486270..cfb4c5be47d6f 100644 --- a/site/src/components/WorkspaceActions/WorkspaceActions.tsx +++ b/site/src/components/WorkspaceActions/WorkspaceActions.tsx @@ -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 = ({ 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 = ({ // A mapping of button type to the corresponding React component const buttonMapping: ButtonMapping = { + [ButtonTypesEnum.update]: , [ButtonTypesEnum.start]: , [ButtonTypesEnum.stop]: , [ButtonTypesEnum.delete]: , - [ButtonTypesEnum.update]: ( - - ), [ButtonTypesEnum.cancel]: , [ButtonTypesEnum.canceling]: disabledButton, [ButtonTypesEnum.disabled]: disabledButton, diff --git a/site/src/components/WorkspaceActions/constants.ts b/site/src/components/WorkspaceActions/constants.ts index 88531fefedab6..38391b1ec6218 100644 --- a/site/src/components/WorkspaceActions/constants.ts +++ b/site/src/components/WorkspaceActions/constants.ts @@ -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 diff --git a/site/src/components/WorkspaceStats/WorkspaceStats.test.tsx b/site/src/components/WorkspaceStats/WorkspaceStats.test.tsx new file mode 100644 index 0000000000000..d7d8fc9fef3b1 --- /dev/null +++ b/site/src/components/WorkspaceStats/WorkspaceStats.test.tsx @@ -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( + , + { + 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) + }) +}) diff --git a/site/src/components/WorkspaceStats/WorkspaceStats.tsx b/site/src/components/WorkspaceStats/WorkspaceStats.tsx index bde20f8226637..094fe50c3c1fb 100644 --- a/site/src/components/WorkspaceStats/WorkspaceStats.tsx +++ b/site/src/components/WorkspaceStats/WorkspaceStats.tsx @@ -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 = ({ workspace }) => { +export const WorkspaceStats: FC = ({ workspace, handleUpdate }) => { const styles = useStyles() const theme = useTheme() const status = getDisplayStatus(theme, workspace.latest_build) @@ -46,7 +48,10 @@ export const WorkspaceStats: FC = ({ workspace }) => { {Language.versionLabel} {workspace.outdated ? ( - {Language.outdated} + + {Language.outdated} + + ) : ( {Language.upToDate} )} @@ -56,7 +61,7 @@ export const WorkspaceStats: FC = ({ workspace }) => {
{Language.lastBuiltLabel} - {dayjs().to(dayjs(workspace.latest_build.created_at))} + {createDayString(workspace.latest_build.created_at)}
@@ -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), + }, })) diff --git a/site/src/components/WorkspacesTable/WorkspacesRow.tsx b/site/src/components/WorkspacesTable/WorkspacesRow.tsx index bfe9b2e99e42e..24a2c534b7ebe 100644 --- a/site/src/components/WorkspacesTable/WorkspacesRow.tsx +++ b/site/src/components/WorkspacesTable/WorkspacesRow.tsx @@ -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 diff --git a/site/src/pages/TemplatePage/TemplatePage.test.tsx b/site/src/pages/TemplatePage/TemplatePage.test.tsx index d1d86d5b9b74a..9dac040cbab08 100644 --- a/site/src/pages/TemplatePage/TemplatePage.test.tsx +++ b/site/src/pages/TemplatePage/TemplatePage.test.tsx @@ -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(, { route: `/templates/${MockTemplate.id}`, path: "/templates/:template", diff --git a/site/src/pages/TemplatesPage/TemplatesPage.test.tsx b/site/src/pages/TemplatesPage/TemplatesPage.test.tsx index c2400ba1a70b3..608f9d3a95790 100644 --- a/site/src/pages/TemplatesPage/TemplatesPage.test.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPage.test.tsx @@ -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") }) diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.tsx index 79d1ec294ccc1..ce822af401b24 100644 --- a/site/src/pages/TemplatesPage/TemplatesPageView.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPageView.tsx @@ -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 = (props) => { - {dayjs().to(dayjs(template.updated_at))} + {createDayString(template.updated_at)} {template.created_by_name} diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx index 34199cb5b59de..46b5283a96a59 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx @@ -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 () => { diff --git a/site/src/util/createDayString.ts b/site/src/util/createDayString.ts new file mode 100644 index 0000000000000..5aea9856452ce --- /dev/null +++ b/site/src/util/createDayString.ts @@ -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)) +}