diff --git a/site/src/components/DropdownButton/ActionCtas.tsx b/site/src/components/DropdownButton/ActionCtas.tsx index 5be231e9793c9..98b43e29c0875 100644 --- a/site/src/components/DropdownButton/ActionCtas.tsx +++ b/site/src/components/DropdownButton/ActionCtas.tsx @@ -7,72 +7,63 @@ 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 { WorkspaceStateEnum } from "util/workspace" import { WorkspaceActionButton } from "../WorkspaceActionButton/WorkspaceActionButton" -export const Language = { - start: "Start", - stop: "Stop", - delete: "Delete", - cancel: "Cancel", - update: "Update", - updating: "Updating", - // these labels are used in WorkspaceActions.tsx - starting: "Starting...", - stopping: "Stopping...", - deleting: "Deleting...", -} - interface WorkspaceAction { handleAction: () => void } export const UpdateButton: FC> = ({ handleAction }) => { const styles = useStyles() + const { t } = useTranslation("workspacePage") return ( ) } export const StartButton: FC> = ({ handleAction }) => { const styles = useStyles() + const { t } = useTranslation("workspacePage") return ( } onClick={handleAction} - label={Language.start} + label={t("actionButton.start")} /> ) } export const StopButton: FC> = ({ handleAction }) => { const styles = useStyles() + const { t } = useTranslation("workspacePage") return ( } onClick={handleAction} - label={Language.stop} + label={t("actionButton.stop")} /> ) } export const DeleteButton: FC> = ({ handleAction }) => { const styles = useStyles() + const { t } = useTranslation("workspacePage") return ( } onClick={handleAction} - label={Language.delete} + label={t("actionButton.delete")} /> ) } @@ -92,15 +83,15 @@ export const CancelButton: FC> = ({ han } interface DisabledProps { - workspaceState: WorkspaceStateEnum + label: string } -export const DisabledButton: FC> = ({ workspaceState }) => { +export const DisabledButton: FC> = ({ label }) => { const styles = useStyles() return ( ) } diff --git a/site/src/components/DropdownButton/DropdownButton.stories.tsx b/site/src/components/DropdownButton/DropdownButton.stories.tsx index 6b9f70d88589f..260f6988b71a0 100644 --- a/site/src/components/DropdownButton/DropdownButton.stories.tsx +++ b/site/src/components/DropdownButton/DropdownButton.stories.tsx @@ -1,6 +1,5 @@ import { action } from "@storybook/addon-actions" import { Story } from "@storybook/react" -import { WorkspaceStateEnum } from "util/workspace" import { DeleteButton, DisabledButton, StartButton, UpdateButton } from "./ActionCtas" import { DropdownButton, DropdownButtonProps } from "./DropdownButton" @@ -23,7 +22,7 @@ WithDropdown.args = { export const WithCancel = Template.bind({}) WithCancel.args = { - primaryAction: , + primaryAction: , secondaryActions: [], canCancel: true, handleCancel: action("cancel"), diff --git a/site/src/components/Workspace/Workspace.stories.tsx b/site/src/components/Workspace/Workspace.stories.tsx index 33f9d1d2c78b9..f8430ee947789 100644 --- a/site/src/components/Workspace/Workspace.stories.tsx +++ b/site/src/components/Workspace/Workspace.stories.tsx @@ -13,8 +13,8 @@ export default { const Template: Story = (args) => -export const Started = Template.bind({}) -Started.args = { +export const Running = Template.bind({}) +Running.args = { bannerProps: { isLoading: false, onExtend: action("extend"), @@ -53,42 +53,32 @@ Started.args = { export const WithoutUpdateAccess = Template.bind({}) WithoutUpdateAccess.args = { - ...Started.args, + ...Running.args, canUpdateWorkspace: false, } export const Starting = Template.bind({}) Starting.args = { - ...Started.args, + ...Running.args, workspace: Mocks.MockStartingWorkspace, } export const Stopped = Template.bind({}) Stopped.args = { - ...Started.args, + ...Running.args, workspace: Mocks.MockStoppedWorkspace, } export const Stopping = Template.bind({}) Stopping.args = { - ...Started.args, + ...Running.args, workspace: Mocks.MockStoppingWorkspace, } -export const Error = Template.bind({}) -Error.args = { - ...Started.args, - workspace: { - ...Mocks.MockFailedWorkspace, - latest_build: { - ...Mocks.MockWorkspaceBuild, - job: { - ...Mocks.MockProvisionerJob, - status: "failed", - }, - transition: "start", - }, - }, +export const Failed = Template.bind({}) +Failed.args = { + ...Running.args, + workspace: Mocks.MockFailedWorkspace, workspaceErrors: { [WorkspaceErrors.BUILD_ERROR]: Mocks.makeMockApiError({ message: "A workspace build is already active.", @@ -98,37 +88,37 @@ Error.args = { export const Deleting = Template.bind({}) Deleting.args = { - ...Started.args, + ...Running.args, workspace: Mocks.MockDeletingWorkspace, } export const Deleted = Template.bind({}) Deleted.args = { - ...Started.args, + ...Running.args, workspace: Mocks.MockDeletedWorkspace, } export const Canceling = Template.bind({}) Canceling.args = { - ...Started.args, + ...Running.args, workspace: Mocks.MockCancelingWorkspace, } export const Canceled = Template.bind({}) Canceled.args = { - ...Started.args, + ...Running.args, workspace: Mocks.MockCanceledWorkspace, } export const Outdated = Template.bind({}) Outdated.args = { - ...Started.args, + ...Running.args, workspace: Mocks.MockOutdatedWorkspace, } export const GetBuildsError = Template.bind({}) GetBuildsError.args = { - ...Started.args, + ...Running.args, workspaceErrors: { [WorkspaceErrors.GET_BUILDS_ERROR]: Mocks.makeMockApiError({ message: "There is a problem fetching builds.", @@ -138,7 +128,7 @@ GetBuildsError.args = { export const GetResourcesError = Template.bind({}) GetResourcesError.args = { - ...Started.args, + ...Running.args, workspaceErrors: { [WorkspaceErrors.GET_RESOURCES_ERROR]: Mocks.makeMockApiError({ message: "There is a problem fetching workspace resources.", @@ -148,7 +138,7 @@ GetResourcesError.args = { export const CancellationError = Template.bind({}) CancellationError.args = { - ...Error.args, + ...Failed.args, workspaceErrors: { [WorkspaceErrors.CANCELLATION_ERROR]: Mocks.makeMockApiError({ message: "Job could not be canceled.", diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index e8c1a82a41b76..959c7dddfb924 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -100,7 +100,8 @@ export const Workspace: FC> = ({ canUpdateWorkspace={canUpdateWorkspace} /> = {}) => { render( = {}) => { const renderAndClick = async (props: Partial = {}) => { render( = {}) => { describe("WorkspaceActions", () => { describe("when the workspace is starting", () => { it("primary is starting; cancel is available; no secondary", async () => { - await renderComponent({ workspace: Mocks.MockStartingWorkspace }) - expect(screen.getByTestId("primary-cta")).toHaveTextContent(Language.starting) + 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", @@ -50,15 +55,21 @@ describe("WorkspaceActions", () => { }) describe("when the workspace is started", () => { it("primary is stop; secondary is delete", async () => { - await renderAndClick({ workspace: Mocks.MockWorkspace }) - expect(screen.getByTestId("primary-cta")).toHaveTextContent(Language.stop) - expect(screen.getByTestId("secondary-ctas")).toHaveTextContent(Language.delete) + 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({ workspace: Mocks.MockStoppingWorkspace }) - expect(screen.getByTestId("primary-cta")).toHaveTextContent(Language.stopping) + 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", @@ -69,30 +80,44 @@ describe("WorkspaceActions", () => { }) describe("when the workspace is canceling", () => { it("primary is canceling; no secondary", async () => { - await renderAndClick({ workspace: Mocks.MockCancelingWorkspace }) - expect(screen.getByTestId("primary-cta")).toHaveTextContent(WorkspaceStateEnum.canceling) + 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({ workspace: Mocks.MockCanceledWorkspace }) - expect(screen.getByTestId("primary-cta")).toHaveTextContent(Language.start) - expect(screen.getByTestId("secondary-ctas")).toHaveTextContent(Language.stop) - expect(screen.getByTestId("secondary-ctas")).toHaveTextContent(Language.delete) + 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({ workspace: Mocks.MockFailedWorkspace }) - expect(screen.getByTestId("primary-cta")).toHaveTextContent(Language.start) - expect(screen.getByTestId("secondary-ctas")).toHaveTextContent(Language.delete) + 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({ workspace: Mocks.MockDeletingWorkspace }) - expect(screen.getByTestId("primary-cta")).toHaveTextContent(Language.deleting) + 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", @@ -103,17 +128,28 @@ describe("WorkspaceActions", () => { }) describe("when the workspace is deleted", () => { it("primary is deleted; no secondary", async () => { - await renderAndClick({ workspace: Mocks.MockDeletedWorkspace }) - expect(screen.getByTestId("primary-cta")).toHaveTextContent(WorkspaceStateEnum.deleted) + 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({ workspace: Mocks.MockOutdatedWorkspace }) - expect(screen.getByTestId("primary-cta")).toHaveTextContent(Language.update) - expect(screen.getByTestId("secondary-ctas")).toHaveTextContent(Language.start) - expect(screen.getByTestId("secondary-ctas")).toHaveTextContent(Language.delete) + 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" }), + ) }) }) }) diff --git a/site/src/components/WorkspaceActions/WorkspaceActions.tsx b/site/src/components/WorkspaceActions/WorkspaceActions.tsx index ca543e01a7e1b..65ed68c39846d 100644 --- a/site/src/components/WorkspaceActions/WorkspaceActions.tsx +++ b/site/src/components/WorkspaceActions/WorkspaceActions.tsx @@ -1,27 +1,20 @@ import { DropdownButton } from "components/DropdownButton/DropdownButton" import { FC, ReactNode, useMemo } from "react" -import { getWorkspaceStatus, WorkspaceStateEnum, WorkspaceStatus } from "util/workspace" -import { Workspace } from "../../api/typesGenerated" +import { useTranslation } from "react-i18next" +import { WorkspaceStatus } from "../../api/typesGenerated" import { ActionLoadingButton, DeleteButton, DisabledButton, - Language, StartButton, StopButton, UpdateButton, } from "../DropdownButton/ActionCtas" -import { ButtonMapping, ButtonTypesEnum, WorkspaceStateActions } 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) +import { ButtonMapping, ButtonTypesEnum, statusToAbilities } from "./constants" export interface WorkspaceActionsProps { - workspace: Workspace + workspaceStatus: WorkspaceStatus + isOutdated: boolean handleStart: () => void handleStop: () => void handleDelete: () => void @@ -32,7 +25,8 @@ export interface WorkspaceActionsProps { } export const WorkspaceActions: FC = ({ - workspace, + workspaceStatus, + isOutdated, handleStart, handleStop, handleDelete, @@ -40,52 +34,42 @@ export const WorkspaceActions: FC = ({ handleCancel, isUpdating, }) => { - const workspaceStatus: keyof typeof WorkspaceStateEnum = getWorkspaceStatus( - workspace.latest_build, - ) - const workspaceState = WorkspaceStateEnum[workspaceStatus] - - 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 = [updatedActions.primary, ...updatedActions.secondary] - updatedActions.primary = ButtonTypesEnum.update - - return updatedActions - }, [canBeUpdated, workspaceState]) + const { t } = useTranslation("workspacePage") + const { canCancel, canAcceptJobs, actions } = statusToAbilities[workspaceStatus] + const canBeUpdated = isOutdated && canAcceptJobs // A mapping of button type to the corresponding React component const buttonMapping: ButtonMapping = { [ButtonTypesEnum.update]: , - [ButtonTypesEnum.updating]: , + [ButtonTypesEnum.updating]: , [ButtonTypesEnum.start]: , - [ButtonTypesEnum.starting]: , + [ButtonTypesEnum.starting]: , [ButtonTypesEnum.stop]: , - [ButtonTypesEnum.stopping]: , + [ButtonTypesEnum.stopping]: , [ButtonTypesEnum.delete]: , - [ButtonTypesEnum.deleting]: , - [ButtonTypesEnum.canceling]: , - [ButtonTypesEnum.disabled]: , - [ButtonTypesEnum.queued]: , - [ButtonTypesEnum.loading]: , + [ButtonTypesEnum.deleting]: , + [ButtonTypesEnum.canceling]: , + [ButtonTypesEnum.deleted]: , + [ButtonTypesEnum.pending]: , } + // 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 ( ({ + secondaryActions={secondaryActions.map((action) => ({ action, button: buttonMapping[action], }))} diff --git a/site/src/components/WorkspaceActions/constants.ts b/site/src/components/WorkspaceActions/constants.ts index ad3d47731ef5a..45149ef74707b 100644 --- a/site/src/components/WorkspaceActions/constants.ts +++ b/site/src/components/WorkspaceActions/constants.ts @@ -1,5 +1,5 @@ +import { WorkspaceStatus } from "api/typesGenerated" import { ReactNode } from "react" -import { WorkspaceStateEnum } from "util/workspace" // the button types we have export enum ButtonTypesEnum { @@ -13,84 +13,73 @@ export enum ButtonTypesEnum { updating = "updating", // disabled buttons canceling = "canceling", - disabled = "disabled", - queued = "queued", - loading = "loading", + deleted = "deleted", + pending = "pending", } export type ButtonMapping = { [key in ButtonTypesEnum]: ReactNode } -type StateActionsType = { - [key in WorkspaceStateEnum]: { - primary: ButtonTypesEnum - secondary: ButtonTypesEnum[] - canCancel: boolean - } +interface WorkspaceAbilities { + actions: ButtonTypesEnum[] + canCancel: boolean + canAcceptJobs: boolean } -// A mapping of workspace state to button type -// 'Primary' actions are the main ctas -// 'Secondary' actions are ctas housed within the popover -export const WorkspaceStateActions: StateActionsType = { - [WorkspaceStateEnum.starting]: { - primary: ButtonTypesEnum.starting, - secondary: [], +export const statusToAbilities: Record = { + starting: { + actions: [ButtonTypesEnum.starting], canCancel: true, + canAcceptJobs: false, }, - [WorkspaceStateEnum.started]: { - primary: ButtonTypesEnum.stop, - secondary: [ButtonTypesEnum.delete], + running: { + actions: [ButtonTypesEnum.stop, ButtonTypesEnum.delete], canCancel: false, + canAcceptJobs: true, }, - [WorkspaceStateEnum.stopping]: { - primary: ButtonTypesEnum.stopping, - secondary: [], + stopping: { + actions: [ButtonTypesEnum.stopping], canCancel: true, + canAcceptJobs: false, }, - [WorkspaceStateEnum.stopped]: { - primary: ButtonTypesEnum.start, - secondary: [ButtonTypesEnum.delete], + stopped: { + actions: [ButtonTypesEnum.start, ButtonTypesEnum.delete], canCancel: false, + canAcceptJobs: true, }, - [WorkspaceStateEnum.canceled]: { - primary: ButtonTypesEnum.start, - secondary: [ButtonTypesEnum.stop, ButtonTypesEnum.delete], + canceled: { + actions: [ButtonTypesEnum.start, ButtonTypesEnum.stop, ButtonTypesEnum.delete], canCancel: false, + canAcceptJobs: true, }, // in the case of an error - [WorkspaceStateEnum.error]: { - primary: ButtonTypesEnum.start, // give the user the ability to start a workspace again - secondary: [ButtonTypesEnum.delete], // allows the user to delete + failed: { + actions: [ButtonTypesEnum.start, ButtonTypesEnum.delete], canCancel: false, + canAcceptJobs: true, }, /** * disabled states */ - [WorkspaceStateEnum.canceling]: { - primary: ButtonTypesEnum.canceling, - secondary: [], + canceling: { + actions: [ButtonTypesEnum.canceling], canCancel: false, + canAcceptJobs: false, }, - [WorkspaceStateEnum.deleting]: { - primary: ButtonTypesEnum.deleting, - secondary: [], + deleting: { + actions: [ButtonTypesEnum.deleting], canCancel: true, + canAcceptJobs: false, }, - [WorkspaceStateEnum.deleted]: { - primary: ButtonTypesEnum.disabled, - secondary: [], + deleted: { + actions: [ButtonTypesEnum.deleted], canCancel: false, + canAcceptJobs: true, }, - [WorkspaceStateEnum.queued]: { - primary: ButtonTypesEnum.queued, - secondary: [], - canCancel: false, - }, - [WorkspaceStateEnum.loading]: { - primary: ButtonTypesEnum.loading, - secondary: [], + pending: { + actions: [ButtonTypesEnum.pending], canCancel: false, + canAcceptJobs: false, }, } diff --git a/site/src/components/WorkspaceDeletedBanner/WorkspaceDeletedBanner.tsx b/site/src/components/WorkspaceDeletedBanner/WorkspaceDeletedBanner.tsx index 604de7a6d19de..c7e001b7c880f 100644 --- a/site/src/components/WorkspaceDeletedBanner/WorkspaceDeletedBanner.tsx +++ b/site/src/components/WorkspaceDeletedBanner/WorkspaceDeletedBanner.tsx @@ -2,9 +2,9 @@ import Button from "@material-ui/core/Button" import { makeStyles } from "@material-ui/core/styles" import Alert from "@material-ui/lab/Alert" import AlertTitle from "@material-ui/lab/AlertTitle" +import { Maybe } from "components/Conditionals/Maybe" import { FC } from "react" import * as TypesGen from "../../api/typesGenerated" -import { isWorkspaceDeleted } from "../../util/workspace" const Language = { bannerTitle: "This workspace has been deleted and cannot be edited.", @@ -22,22 +22,20 @@ export const WorkspaceDeletedBanner: FC { const styles = useStyles() - if (!isWorkspaceDeleted(workspace)) { - return null - } - return ( - - {Language.createWorkspaceCta} - - } - severity="warning" - > - {Language.bannerTitle} - + + + {Language.createWorkspaceCta} + + } + severity="warning" + > + {Language.bannerTitle} + + ) } diff --git a/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.stories.tsx b/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.stories.tsx index e91f1ca35b79a..0b0cfa5c5b7f2 100644 --- a/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.stories.tsx +++ b/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.stories.tsx @@ -5,7 +5,7 @@ import { MockDeletedWorkspace, MockDeletingWorkspace, MockFailedWorkspace, - MockQueuedWorkspace, + MockPendingWorkspace, MockStartingWorkspace, MockStoppedWorkspace, MockStoppingWorkspace, @@ -65,7 +65,7 @@ Failed.args = { build: MockFailedWorkspace.latest_build, } -export const Queued = Template.bind({}) -Queued.args = { - build: MockQueuedWorkspace.latest_build, +export const Pending = Template.bind({}) +Pending.args = { + build: MockPendingWorkspace.latest_build, } diff --git a/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx b/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx index 60d6ea35b7742..9a0276fd9fffb 100644 --- a/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx +++ b/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx @@ -7,7 +7,6 @@ import { Pill } from "components/Pill/Pill" import i18next from "i18next" import React from "react" import { PaletteIndex } from "theme/palettes" -import { getWorkspaceStatus } from "util/workspace" const LoadingIcon: React.FC = () => { return @@ -20,19 +19,18 @@ export const getStatus = ( text: string icon: React.ReactNode } => { - const status = getWorkspaceStatus(build) const { t } = i18next - switch (status) { + switch (build.status) { case undefined: return { text: t("workspaceStatus.loading", { ns: "common" }), icon: , } - case "started": + case "running": return { type: "success", - text: t("workspaceStatus.started", { ns: "common" }), + text: t("workspaceStatus.running", { ns: "common" }), icon: , } case "starting": @@ -77,16 +75,16 @@ export const getStatus = ( text: t("workspaceStatus.canceled", { ns: "common" }), icon: , } - case "error": + case "failed": return { type: "error", text: t("workspaceStatus.failed", { ns: "common" }), icon: , } - case "queued": + case "pending": return { type: "info", - text: t("workspaceStatus.queued", { ns: "common" }), + text: t("workspaceStatus.pending", { ns: "common" }), icon: , } } diff --git a/site/src/i18n/en/common.json b/site/src/i18n/en/common.json index 514205e9da8c2..66c66cb6fbee4 100644 --- a/site/src/i18n/en/common.json +++ b/site/src/i18n/en/common.json @@ -1,7 +1,7 @@ { "workspaceStatus": { "loading": "Loading", - "started": "Running", + "running": "Running", "starting": "Starting", "stopping": "Stopping", "stopped": "Stopped", @@ -10,7 +10,7 @@ "canceling": "Canceling action", "canceled": "Canceled action", "failed": "Failed", - "queued": "Queued" + "pending": "Pending" }, "deleteDialog": { "title": "Delete {{entity}}", diff --git a/site/src/i18n/en/workspacePage.json b/site/src/i18n/en/workspacePage.json index 9094ce9f7d183..ceebe7b5a0356 100644 --- a/site/src/i18n/en/workspacePage.json +++ b/site/src/i18n/en/workspacePage.json @@ -6,5 +6,21 @@ "schedule": "Schedule", "editDeadlineMinus": "Subtract one hour", "editDeadlinePlus": "Add one hour" + }, + "actionButton": { + "start": "Start", + "stop": "Stop", + "delete": "Delete", + "cancel": "Cancel", + "update": "Update", + "updating": "Updating", + "starting": "Starting...", + "stopping": "Stopping...", + "deleting": "Deleting..." + }, + "disabledButton": { + "canceling": "Canceling", + "deleted": "Deleted", + "pending": "Pending" } } diff --git a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx index 390a3950350a5..6512d5334587e 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx @@ -6,7 +6,6 @@ import i18next from "i18next" import { rest } from "msw" import * as api from "../../api/api" import { Workspace } from "../../api/typesGenerated" -import { Language } from "../../components/DropdownButton/ActionCtas" import { MockBuilds, MockCanceledWorkspace, @@ -28,7 +27,7 @@ import { renderWithAuth, } from "../../testHelpers/renderHelpers" import { server } from "../../testHelpers/server" -import { DisplayAgentStatusLanguage, DisplayStatusLanguage } from "../../util/workspace" +import { DisplayAgentStatusLanguage } from "../../util/workspace" import { WorkspacePage } from "./WorkspacePage" const { t } = i18next @@ -94,7 +93,7 @@ describe("WorkspacePage", () => { const stopWorkspaceMock = jest .spyOn(api, "stopWorkspace") .mockResolvedValueOnce(MockWorkspaceBuild) - testButton(Language.stop, stopWorkspaceMock) + testButton(t("actionButton.stop", { ns: "workspacePage" }), stopWorkspaceMock) }) it("requests a delete job when the user presses Delete and confirms", async () => { @@ -108,7 +107,8 @@ describe("WorkspacePage", () => { const trigger = await screen.findByTestId("workspace-actions-button") await user.click(trigger) - const button = await screen.findByText(Language.delete) + const buttonText = t("actionButton.delete", { ns: "workspacePage" }) + const button = await screen.findByText(buttonText) await user.click(button) const labelText = t("deleteDialog.confirmLabel", { ns: "common", entity: "workspace" }) @@ -128,7 +128,7 @@ describe("WorkspacePage", () => { const startWorkspaceMock = jest .spyOn(api, "startWorkspace") .mockImplementation(() => Promise.resolve(MockWorkspaceBuild)) - testButton(Language.start, startWorkspaceMock) + testButton(t("actionButton.start", { ns: "workspacePage" }), startWorkspaceMock) }) it("requests cancellation when the user presses Cancel", async () => { server.use( @@ -159,7 +159,8 @@ describe("WorkspacePage", () => { ) await renderWorkspacePage() - const button = await screen.findByText(Language.update, { exact: true }) + const buttonText = t("actionButton.update", { ns: "workspacePage" }) + const button = await screen.findByText(buttonText, { exact: true }) fireEvent.click(button) // getTemplate is called twice: once when the machine starts, and once after the user requests to update @@ -177,7 +178,8 @@ describe("WorkspacePage", () => { }), ) await renderWorkspacePage() - const button = await screen.findByText(Language.update, { exact: true }) + const buttonText = t("actionButton.update", { ns: "workspacePage" }) + const button = await screen.findByText(buttonText, { exact: true }) fireEvent.click(button) await waitFor(() => @@ -185,28 +187,28 @@ describe("WorkspacePage", () => { ) }) it("shows the Stopping status when the workspace is stopping", async () => { - await testStatus(MockStoppingWorkspace, DisplayStatusLanguage.stopping) + await testStatus(MockStoppingWorkspace, t("workspaceStatus.stopping", { ns: "common" })) }) it("shows the Stopped status when the workspace is stopped", async () => { - await testStatus(MockStoppedWorkspace, DisplayStatusLanguage.stopped) + await testStatus(MockStoppedWorkspace, t("workspaceStatus.stopped", { ns: "common" })) }) it("shows the Building status when the workspace is starting", async () => { - await testStatus(MockStartingWorkspace, DisplayStatusLanguage.starting) + await testStatus(MockStartingWorkspace, t("workspaceStatus.starting", { ns: "common" })) }) - it("shows the Running status when the workspace is started", async () => { - await testStatus(MockWorkspace, DisplayStatusLanguage.started) + it("shows the Running status when the workspace is running", async () => { + await testStatus(MockWorkspace, t("workspaceStatus.running", { ns: "common" })) }) it("shows the Failed status when the workspace is failed or canceled", async () => { - await testStatus(MockFailedWorkspace, DisplayStatusLanguage.failed) + await testStatus(MockFailedWorkspace, t("workspaceStatus.failed", { ns: "common" })) }) it("shows the Canceling status when the workspace is canceling", async () => { - await testStatus(MockCancelingWorkspace, DisplayStatusLanguage.canceling) + await testStatus(MockCancelingWorkspace, t("workspaceStatus.canceling", { ns: "common" })) }) it("shows the Canceled status when the workspace is canceling", async () => { - await testStatus(MockCanceledWorkspace, DisplayStatusLanguage.canceled) + await testStatus(MockCanceledWorkspace, t("workspaceStatus.canceled", { ns: "common" })) }) it("shows the Deleting status when the workspace is deleting", async () => { - await testStatus(MockDeletingWorkspace, DisplayStatusLanguage.deleting) + await testStatus(MockDeletingWorkspace, t("workspaceStatus.deleting", { ns: "common" })) }) it("shows the Deleted status when the workspace is deleted", async () => { await testStatus(MockDeletedWorkspace, t("workspaceStatus.deleted", { ns: "common" })) diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 9e085883682f3..c052f0f67f529 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -395,13 +395,14 @@ export const MockWorkspace: TypesGen.Workspace = { export const MockStoppedWorkspace: TypesGen.Workspace = { ...MockWorkspace, - latest_build: MockWorkspaceBuildStop, + latest_build: { ...MockWorkspaceBuildStop, status: "stopped" }, } export const MockStoppingWorkspace: TypesGen.Workspace = { ...MockWorkspace, latest_build: { ...MockWorkspaceBuildStop, job: MockRunningProvisionerJob, + status: "stopping", }, } export const MockStartingWorkspace: TypesGen.Workspace = { @@ -410,40 +411,43 @@ export const MockStartingWorkspace: TypesGen.Workspace = { ...MockWorkspaceBuild, job: MockRunningProvisionerJob, transition: "start", + status: "starting", }, } export const MockCancelingWorkspace: TypesGen.Workspace = { ...MockWorkspace, - latest_build: { ...MockWorkspaceBuild, job: MockCancelingProvisionerJob }, + latest_build: { ...MockWorkspaceBuild, job: MockCancelingProvisionerJob, status: "canceling" }, } export const MockCanceledWorkspace: TypesGen.Workspace = { ...MockWorkspace, - latest_build: { ...MockWorkspaceBuild, job: MockCanceledProvisionerJob }, + latest_build: { ...MockWorkspaceBuild, job: MockCanceledProvisionerJob, status: "canceled" }, } export const MockFailedWorkspace: TypesGen.Workspace = { ...MockWorkspace, latest_build: { ...MockWorkspaceBuild, job: MockFailedProvisionerJob, + status: "failed", }, } export const MockDeletingWorkspace: TypesGen.Workspace = { ...MockWorkspace, - latest_build: { ...MockWorkspaceBuildDelete, job: MockRunningProvisionerJob }, + latest_build: { ...MockWorkspaceBuildDelete, job: MockRunningProvisionerJob, status: "deleting" }, } export const MockDeletedWorkspace: TypesGen.Workspace = { ...MockWorkspace, - latest_build: MockWorkspaceBuildDelete, + latest_build: { ...MockWorkspaceBuildDelete, status: "deleted" }, } export const MockOutdatedWorkspace: TypesGen.Workspace = { ...MockFailedWorkspace, outdated: true } -export const MockQueuedWorkspace: TypesGen.Workspace = { +export const MockPendingWorkspace: TypesGen.Workspace = { ...MockWorkspace, latest_build: { ...MockWorkspaceBuild, job: MockPendingProvisionerJob, transition: "start", + status: "pending", }, } diff --git a/site/src/util/workspace.test.ts b/site/src/util/workspace.test.ts index 72479c88299b9..1bd170553ed95 100644 --- a/site/src/util/workspace.test.ts +++ b/site/src/util/workspace.test.ts @@ -5,7 +5,6 @@ import { defaultWorkspaceExtension, getDisplayVersionStatus, getDisplayWorkspaceBuildInitiatedBy, - isWorkspaceDeleted, isWorkspaceOn, } from "./workspace" @@ -48,44 +47,6 @@ describe("util > workspace", () => { }) }) - describe("isWorkspaceDeleted", () => { - it.each<[TypesGen.WorkspaceTransition, TypesGen.ProvisionerJobStatus, boolean]>([ - ["delete", "canceled", false], - ["delete", "canceling", false], - ["delete", "failed", false], - ["delete", "pending", false], - ["delete", "running", false], - ["delete", "succeeded", true], - - ["stop", "canceled", false], - ["stop", "canceling", false], - ["stop", "failed", false], - ["stop", "pending", false], - ["stop", "running", false], - ["stop", "succeeded", false], - - ["start", "canceled", false], - ["start", "canceling", false], - ["start", "failed", false], - ["start", "pending", false], - ["start", "running", false], - ["start", "succeeded", false], - ])(`transition=%p, status=%p, isWorkspaceDeleted=%p`, (transition, status, isDeleted) => { - const workspace: TypesGen.Workspace = { - ...Mocks.MockWorkspace, - latest_build: { - ...Mocks.MockWorkspaceBuild, - job: { - ...Mocks.MockProvisionerJob, - status, - }, - transition, - }, - } - expect(isWorkspaceDeleted(workspace)).toBe(isDeleted) - }) - }) - describe("defaultWorkspaceExtension", () => { it.each<[string, TypesGen.PutExtendWorkspaceRequest]>([ [ diff --git a/site/src/util/workspace.ts b/site/src/util/workspace.ts index 65b6b21ee1b8c..a5f61e84903d2 100644 --- a/site/src/util/workspace.ts +++ b/site/src/util/workspace.ts @@ -4,89 +4,12 @@ import duration from "dayjs/plugin/duration" import minMax from "dayjs/plugin/minMax" import utc from "dayjs/plugin/utc" import semver from "semver" -import { WorkspaceBuildTransition } from "../api/types" import * as TypesGen from "../api/typesGenerated" dayjs.extend(duration) dayjs.extend(utc) dayjs.extend(minMax) -// all the possible states returned by the API -export enum WorkspaceStateEnum { - starting = "Starting", - started = "Started", - stopping = "Stopping", - stopped = "Stopped", - canceling = "Canceling", - canceled = "Canceled", - deleting = "Deleting", - deleted = "Deleted", - queued = "Queued", - error = "Error", - loading = "Loading", -} - -export type WorkspaceStatus = - | "queued" - | "started" - | "starting" - | "stopped" - | "stopping" - | "error" - | "loading" - | "deleting" - | "deleted" - | "canceled" - | "canceling" - -const inProgressToStatus: Record = { - start: "starting", - stop: "stopping", - delete: "deleting", -} - -const succeededToStatus: Record = { - start: "started", - stop: "stopped", - delete: "deleted", -} - -// Converts a workspaces status to a human-readable form. -export const getWorkspaceStatus = (workspaceBuild?: TypesGen.WorkspaceBuild): WorkspaceStatus => { - const transition = workspaceBuild?.transition as WorkspaceBuildTransition - const jobStatus = workspaceBuild?.job.status - switch (jobStatus) { - case undefined: - return "loading" - case "succeeded": - return succeededToStatus[transition] - case "pending": - return "queued" - case "running": - return inProgressToStatus[transition] - case "canceling": - return "canceling" - case "canceled": - return "canceled" - case "failed": - return "error" - } -} - -export const DisplayStatusLanguage = { - loading: "Loading...", - started: "Running", - starting: "Starting", - stopping: "Stopping", - stopped: "Stopped", - deleting: "Deleting", - deleted: "Deleted", - canceling: "Canceling action", - canceled: "Canceled action", - failed: "Failed", - queued: "Queued", -} - export const DisplayWorkspaceBuildStatusLanguage = { succeeded: "Succeeded", pending: "Pending", @@ -181,6 +104,7 @@ export const displayWorkspaceBuildDuration = ( } export const DisplayAgentStatusLanguage = { + loading: "Loading...", connected: "⦿ Connected", connecting: "⦿ Connecting", disconnected: "◍ Disconnected", @@ -197,7 +121,7 @@ export const getDisplayAgentStatus = ( case undefined: return { color: theme.palette.text.secondary, - status: DisplayStatusLanguage.loading, + status: DisplayAgentStatusLanguage.loading, } case "connected": return { @@ -245,10 +169,6 @@ export const isWorkspaceOn = (workspace: TypesGen.Workspace): boolean => { return transition === "start" && status === "succeeded" } -export const isWorkspaceDeleted = (workspace: TypesGen.Workspace): boolean => { - return getWorkspaceStatus(workspace.latest_build) === succeededToStatus["delete"] -} - export const defaultWorkspaceExtension = ( __startDate?: dayjs.Dayjs, ): TypesGen.PutExtendWorkspaceRequest => { @@ -270,11 +190,10 @@ type FaviconType = | "favicon-running" export const getFaviconByStatus = (build: TypesGen.WorkspaceBuild): FaviconType => { - const status = getWorkspaceStatus(build) - switch (status) { + switch (build.status) { case undefined: return "favicon" - case "started": + case "running": return "favicon-success" case "starting": return "favicon-running" @@ -290,10 +209,9 @@ export const getFaviconByStatus = (build: TypesGen.WorkspaceBuild): FaviconType return "favicon-warning" case "canceled": return "favicon" - case "error": + case "failed": return "favicon-error" - case "queued": + case "pending": return "favicon" } - throw new Error("unknown status " + status) } diff --git a/site/src/xServices/workspaces/workspacesXService.ts b/site/src/xServices/workspaces/workspacesXService.ts index cc2a690cfa4e7..d2aed1adbb7d6 100644 --- a/site/src/xServices/workspaces/workspacesXService.ts +++ b/site/src/xServices/workspaces/workspacesXService.ts @@ -56,9 +56,9 @@ export const workspaceItemMachine = createMachine( UPDATE_VERSION: { target: "gettingUpdatedTemplate", // We can improve the UI by optimistically updating the workspace status - // to "Queued" so the UI can display the updated state right away and we + // to "Pending" so the UI can display the updated state right away and we // don't need to display an extra spinner. - actions: ["assignQueuedStatus", "displayUpdatingVersionMessage"], + actions: ["assignPendingStatus", "displayUpdatingVersionMessage"], }, UPDATE_DATA: { actions: "assignUpdatedData", @@ -153,12 +153,13 @@ export const workspaceItemMachine = createMachine( displayUpdatingVersionMessage: () => { displayMsg("Updating workspace...") }, - assignQueuedStatus: assign({ + assignPendingStatus: assign({ data: (ctx) => { return { ...ctx.data, latest_build: { ...ctx.data.latest_build, + status: "pending" as TypesGen.WorkspaceStatus, job: { ...ctx.data.latest_build.job, status: "pending" as TypesGen.ProvisionerJobStatus,