From 330f2af513c3c6097b52d6a9406c459c205bf860 Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Tue, 4 Oct 2022 14:36:50 +0000 Subject: [PATCH 01/10] Add/update copy --- site/src/i18n/en/common.json | 4 ++-- site/src/i18n/en/workspacePage.json | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/site/src/i18n/en/common.json b/site/src/i18n/en/common.json index 514205e9da8c2..ea0f53241e068 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": "Queued" }, "deleteDialog": { "title": "Delete {{entity}}", diff --git a/site/src/i18n/en/workspacePage.json b/site/src/i18n/en/workspacePage.json index 9094ce9f7d183..a9b07354ce9cc 100644 --- a/site/src/i18n/en/workspacePage.json +++ b/site/src/i18n/en/workspacePage.json @@ -6,5 +6,16 @@ "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..." } } From c38352d833af133cf043d0264cdc3c15ba38fd0f Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Tue, 4 Oct 2022 14:37:05 +0000 Subject: [PATCH 02/10] Update mocks --- site/src/testHelpers/entities.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index e464fe9b227d0..c4d600996cc32 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -274,13 +274,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 = { @@ -289,40 +290,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", }, } From 7dc2ef2da316da332d4f1128b25a4ded96d1d46e Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Tue, 4 Oct 2022 15:22:45 +0000 Subject: [PATCH 03/10] Handle disabled button labels separately --- site/src/i18n/en/common.json | 2 +- site/src/i18n/en/workspacePage.json | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/site/src/i18n/en/common.json b/site/src/i18n/en/common.json index ea0f53241e068..66c66cb6fbee4 100644 --- a/site/src/i18n/en/common.json +++ b/site/src/i18n/en/common.json @@ -10,7 +10,7 @@ "canceling": "Canceling action", "canceled": "Canceled action", "failed": "Failed", - "pending": "Queued" + "pending": "Pending" }, "deleteDialog": { "title": "Delete {{entity}}", diff --git a/site/src/i18n/en/workspacePage.json b/site/src/i18n/en/workspacePage.json index a9b07354ce9cc..ceebe7b5a0356 100644 --- a/site/src/i18n/en/workspacePage.json +++ b/site/src/i18n/en/workspacePage.json @@ -17,5 +17,10 @@ "starting": "Starting...", "stopping": "Stopping...", "deleting": "Deleting..." + }, + "disabledButton": { + "canceling": "Canceling", + "deleted": "Deleted", + "pending": "Pending" } } From 56f927e11bbabafbe64516e5853b5cba9ce835f6 Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Tue, 4 Oct 2022 15:24:40 +0000 Subject: [PATCH 04/10] Use workspace status directly, use i18n --- .../components/DropdownButton/ActionCtas.tsx | 35 +++---- .../WorkspaceActions/WorkspaceActions.tsx | 34 +++---- .../components/WorkspaceActions/constants.ts | 39 ++++---- .../WorkspaceDeletedBanner.tsx | 3 +- .../WorkspaceStatusBadge.tsx | 14 ++- site/src/util/workspace.ts | 94 ++----------------- 6 files changed, 59 insertions(+), 160 deletions(-) diff --git a/site/src/components/DropdownButton/ActionCtas.tsx b/site/src/components/DropdownButton/ActionCtas.tsx index 5be231e9793c9..e885f878a6571 100644 --- a/site/src/components/DropdownButton/ActionCtas.tsx +++ b/site/src/components/DropdownButton/ActionCtas.tsx @@ -5,74 +5,66 @@ import CloudQueueIcon from "@material-ui/icons/CloudQueue" import CropSquareIcon from "@material-ui/icons/CropSquare" import DeleteOutlineIcon from "@material-ui/icons/DeleteOutline" import PlayCircleOutlineIcon from "@material-ui/icons/PlayCircleOutline" +import { WorkspaceStatus } from "api/typesGenerated" 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 +84,16 @@ export const CancelButton: FC> = ({ han } interface DisabledProps { - workspaceState: WorkspaceStateEnum + workspaceStatus: WorkspaceStatus } -export const DisabledButton: FC> = ({ workspaceState }) => { +export const DisabledButton: FC> = ({ workspaceStatus }) => { const styles = useStyles() + const { t } = useTranslation("workspacePage") return ( ) } diff --git a/site/src/components/WorkspaceActions/WorkspaceActions.tsx b/site/src/components/WorkspaceActions/WorkspaceActions.tsx index ca543e01a7e1b..473b6126b5ae4 100644 --- a/site/src/components/WorkspaceActions/WorkspaceActions.tsx +++ b/site/src/components/WorkspaceActions/WorkspaceActions.tsx @@ -1,12 +1,11 @@ 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 { Workspace, WorkspaceStatus } from "../../api/typesGenerated" import { ActionLoadingButton, DeleteButton, DisabledButton, - Language, StartButton, StopButton, UpdateButton, @@ -18,7 +17,7 @@ import { ButtonMapping, ButtonTypesEnum, WorkspaceStateActions } from "./constan * so check whether workspace job status has reached completion (whether successful or not). */ const canAcceptJobs = (workspaceStatus: WorkspaceStatus) => - ["started", "stopped", "deleted", "error", "canceled"].includes(workspaceStatus) + ["running", "stopped", "deleted", "failed", "canceled"].includes(workspaceStatus) export interface WorkspaceActionsProps { workspace: Workspace @@ -40,42 +39,39 @@ export const WorkspaceActions: FC = ({ handleCancel, isUpdating, }) => { - const workspaceStatus: keyof typeof WorkspaceStateEnum = getWorkspaceStatus( - workspace.latest_build, - ) - const workspaceState = WorkspaceStateEnum[workspaceStatus] + const { t } = useTranslation("workspacePage") + const workspaceStatus = workspace.latest_build.status 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] + return WorkspaceStateActions[workspaceStatus] } // 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] } + const updatedActions = { ...WorkspaceStateActions[workspaceStatus] } updatedActions.secondary = [updatedActions.primary, ...updatedActions.secondary] updatedActions.primary = ButtonTypesEnum.update return updatedActions - }, [canBeUpdated, workspaceState]) + }, [canBeUpdated, workspaceStatus]) // 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.disabled]: , + [ButtonTypesEnum.pending]: , } return ( diff --git a/site/src/components/WorkspaceActions/constants.ts b/site/src/components/WorkspaceActions/constants.ts index ad3d47731ef5a..bcb900be46e04 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 { @@ -14,53 +14,53 @@ export enum ButtonTypesEnum { // disabled buttons canceling = "canceling", disabled = "disabled", - queued = "queued", - loading = "loading", + pending = "pending", } export type ButtonMapping = { [key in ButtonTypesEnum]: ReactNode } -type StateActionsType = { - [key in WorkspaceStateEnum]: { +type StateActionsType = Record< + WorkspaceStatus, + { primary: ButtonTypesEnum secondary: ButtonTypesEnum[] canCancel: 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]: { + starting: { primary: ButtonTypesEnum.starting, secondary: [], canCancel: true, }, - [WorkspaceStateEnum.started]: { + running: { primary: ButtonTypesEnum.stop, secondary: [ButtonTypesEnum.delete], canCancel: false, }, - [WorkspaceStateEnum.stopping]: { + stopping: { primary: ButtonTypesEnum.stopping, secondary: [], canCancel: true, }, - [WorkspaceStateEnum.stopped]: { + stopped: { primary: ButtonTypesEnum.start, secondary: [ButtonTypesEnum.delete], canCancel: false, }, - [WorkspaceStateEnum.canceled]: { + canceled: { primary: ButtonTypesEnum.start, secondary: [ButtonTypesEnum.stop, ButtonTypesEnum.delete], canCancel: false, }, // in the case of an error - [WorkspaceStateEnum.error]: { + failed: { primary: ButtonTypesEnum.start, // give the user the ability to start a workspace again secondary: [ButtonTypesEnum.delete], // allows the user to delete canCancel: false, @@ -68,28 +68,23 @@ export const WorkspaceStateActions: StateActionsType = { /** * disabled states */ - [WorkspaceStateEnum.canceling]: { + canceling: { primary: ButtonTypesEnum.canceling, secondary: [], canCancel: false, }, - [WorkspaceStateEnum.deleting]: { + deleting: { primary: ButtonTypesEnum.deleting, secondary: [], canCancel: true, }, - [WorkspaceStateEnum.deleted]: { + deleted: { primary: ButtonTypesEnum.disabled, secondary: [], canCancel: false, }, - [WorkspaceStateEnum.queued]: { - primary: ButtonTypesEnum.queued, - secondary: [], - canCancel: false, - }, - [WorkspaceStateEnum.loading]: { - primary: ButtonTypesEnum.loading, + pending: { + primary: ButtonTypesEnum.pending, secondary: [], canCancel: false, }, diff --git a/site/src/components/WorkspaceDeletedBanner/WorkspaceDeletedBanner.tsx b/site/src/components/WorkspaceDeletedBanner/WorkspaceDeletedBanner.tsx index 604de7a6d19de..49830f5b04dc7 100644 --- a/site/src/components/WorkspaceDeletedBanner/WorkspaceDeletedBanner.tsx +++ b/site/src/components/WorkspaceDeletedBanner/WorkspaceDeletedBanner.tsx @@ -4,7 +4,6 @@ import Alert from "@material-ui/lab/Alert" import AlertTitle from "@material-ui/lab/AlertTitle" 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,7 +21,7 @@ export const WorkspaceDeletedBanner: FC { const styles = useStyles() - if (!isWorkspaceDeleted(workspace)) { + if (workspace.latest_build.status === "deleted") { return null } 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/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) } From a3635349d8a242bc85161db63cba95cc5d304615 Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Tue, 4 Oct 2022 15:24:46 +0000 Subject: [PATCH 05/10] Update stories and tests --- .../DropdownButton/DropdownButton.stories.tsx | 3 +- .../Workspace/Workspace.stories.tsx | 18 ++--- .../WorkspaceActions.test.tsx | 65 ++++++++++++++----- .../WorkspaceStatusBadge.stories.tsx | 8 +-- .../WorkspacePage/WorkspacePage.test.tsx | 34 +++++----- site/src/util/workspace.test.ts | 39 ----------- 6 files changed, 75 insertions(+), 92 deletions(-) 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..4fea426efebb6 100644 --- a/site/src/components/Workspace/Workspace.stories.tsx +++ b/site/src/components/Workspace/Workspace.stories.tsx @@ -75,20 +75,10 @@ Stopping.args = { workspace: Mocks.MockStoppingWorkspace, } -export const Error = Template.bind({}) -Error.args = { +export const Failed = Template.bind({}) +Failed.args = { ...Started.args, - workspace: { - ...Mocks.MockFailedWorkspace, - latest_build: { - ...Mocks.MockWorkspaceBuild, - job: { - ...Mocks.MockProvisionerJob, - status: "failed", - }, - transition: "start", - }, - }, + workspace: Mocks.MockFailedWorkspace, workspaceErrors: { [WorkspaceErrors.BUILD_ERROR]: Mocks.makeMockApiError({ message: "A workspace build is already active.", @@ -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/WorkspaceActions/WorkspaceActions.test.tsx b/site/src/components/WorkspaceActions/WorkspaceActions.test.tsx index b2b0194563c80..4e8a5d6466871 100644 --- a/site/src/components/WorkspaceActions/WorkspaceActions.test.tsx +++ b/site/src/components/WorkspaceActions/WorkspaceActions.test.tsx @@ -1,10 +1,11 @@ import { fireEvent, screen } from "@testing-library/react" -import { WorkspaceStateEnum } from "util/workspace" +import i18next from "i18next" import * as Mocks from "../../testHelpers/entities" import { render } from "../../testHelpers/renderHelpers" -import { Language } from "../DropdownButton/ActionCtas" import { WorkspaceActions, WorkspaceActionsProps } from "./WorkspaceActions" +const { t } = i18next + const renderComponent = async (props: Partial = {}) => { render( { 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) + expect(screen.getByTestId("primary-cta")).toHaveTextContent( + t("actionButton.starting", { ns: "workspacePage" }), + ) expect( screen.getByRole("button", { name: "cancel action", @@ -51,14 +54,20 @@ 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) + 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) + expect(screen.getByTestId("primary-cta")).toHaveTextContent( + t("actionButton.stopping", { ns: "workspacePage" }), + ) expect( screen.getByRole("button", { name: "cancel action", @@ -70,29 +79,43 @@ 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) + 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) + 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) + 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) + expect(screen.getByTestId("primary-cta")).toHaveTextContent( + t("actionButton.deleting", { ns: "workspacePage" }), + ) expect( screen.getByRole("button", { name: "cancel action", @@ -104,16 +127,24 @@ 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) + 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) + 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/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/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/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]>([ [ From 1992e083b06d705b280a27ec7ce756167c040e61 Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Tue, 4 Oct 2022 15:29:27 +0000 Subject: [PATCH 06/10] Fix optimistic update in xservice to use status, pending --- site/src/xServices/workspaces/workspacesXService.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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, From d06c432de5dc851b8b3113a1e5dc938d0d9cb236 Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Tue, 4 Oct 2022 16:02:19 +0000 Subject: [PATCH 07/10] Rename started to running in story --- .../Workspace/Workspace.stories.tsx | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/site/src/components/Workspace/Workspace.stories.tsx b/site/src/components/Workspace/Workspace.stories.tsx index 4fea426efebb6..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,31 +53,31 @@ 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 Failed = Template.bind({}) Failed.args = { - ...Started.args, + ...Running.args, workspace: Mocks.MockFailedWorkspace, workspaceErrors: { [WorkspaceErrors.BUILD_ERROR]: Mocks.makeMockApiError({ @@ -88,37 +88,37 @@ Failed.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.", @@ -128,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.", From b9829b573f9c33d5e755f533ed1f65f13ecdb359 Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Tue, 4 Oct 2022 16:24:15 +0000 Subject: [PATCH 08/10] Fix deletion banner conditional --- .../WorkspaceDeletedBanner.tsx | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/site/src/components/WorkspaceDeletedBanner/WorkspaceDeletedBanner.tsx b/site/src/components/WorkspaceDeletedBanner/WorkspaceDeletedBanner.tsx index 49830f5b04dc7..c7e001b7c880f 100644 --- a/site/src/components/WorkspaceDeletedBanner/WorkspaceDeletedBanner.tsx +++ b/site/src/components/WorkspaceDeletedBanner/WorkspaceDeletedBanner.tsx @@ -2,6 +2,7 @@ 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" @@ -21,22 +22,20 @@ export const WorkspaceDeletedBanner: FC { const styles = useStyles() - if (workspace.latest_build.status === "deleted") { - return null - } - return ( - - {Language.createWorkspaceCta} - - } - severity="warning" - > - {Language.bannerTitle} - + + + {Language.createWorkspaceCta} + + } + severity="warning" + > + {Language.bannerTitle} + + ) } From 23c607e8b2025c98c81a58cb36ee8cdf1b03123e Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Wed, 5 Oct 2022 16:07:18 +0000 Subject: [PATCH 09/10] Send label to disabled button --- site/src/components/DropdownButton/ActionCtas.tsx | 8 +++----- site/src/components/WorkspaceActions/WorkspaceActions.tsx | 6 +++--- site/src/components/WorkspaceActions/constants.ts | 4 ++-- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/site/src/components/DropdownButton/ActionCtas.tsx b/site/src/components/DropdownButton/ActionCtas.tsx index e885f878a6571..98b43e29c0875 100644 --- a/site/src/components/DropdownButton/ActionCtas.tsx +++ b/site/src/components/DropdownButton/ActionCtas.tsx @@ -5,7 +5,6 @@ import CloudQueueIcon from "@material-ui/icons/CloudQueue" import CropSquareIcon from "@material-ui/icons/CropSquare" import DeleteOutlineIcon from "@material-ui/icons/DeleteOutline" import PlayCircleOutlineIcon from "@material-ui/icons/PlayCircleOutline" -import { WorkspaceStatus } from "api/typesGenerated" import { LoadingButton } from "components/LoadingButton/LoadingButton" import { FC } from "react" import { useTranslation } from "react-i18next" @@ -84,16 +83,15 @@ export const CancelButton: FC> = ({ han } interface DisabledProps { - workspaceStatus: WorkspaceStatus + label: string } -export const DisabledButton: FC> = ({ workspaceStatus }) => { +export const DisabledButton: FC> = ({ label }) => { const styles = useStyles() - const { t } = useTranslation("workspacePage") return ( ) } diff --git a/site/src/components/WorkspaceActions/WorkspaceActions.tsx b/site/src/components/WorkspaceActions/WorkspaceActions.tsx index 473b6126b5ae4..689792f177036 100644 --- a/site/src/components/WorkspaceActions/WorkspaceActions.tsx +++ b/site/src/components/WorkspaceActions/WorkspaceActions.tsx @@ -69,9 +69,9 @@ export const WorkspaceActions: FC = ({ [ButtonTypesEnum.stopping]: , [ButtonTypesEnum.delete]: , [ButtonTypesEnum.deleting]: , - [ButtonTypesEnum.canceling]: , - [ButtonTypesEnum.disabled]: , - [ButtonTypesEnum.pending]: , + [ButtonTypesEnum.canceling]: , + [ButtonTypesEnum.deleted]: , + [ButtonTypesEnum.pending]: , } return ( diff --git a/site/src/components/WorkspaceActions/constants.ts b/site/src/components/WorkspaceActions/constants.ts index bcb900be46e04..57b3294316490 100644 --- a/site/src/components/WorkspaceActions/constants.ts +++ b/site/src/components/WorkspaceActions/constants.ts @@ -13,7 +13,7 @@ export enum ButtonTypesEnum { updating = "updating", // disabled buttons canceling = "canceling", - disabled = "disabled", + deleted = "deleted", pending = "pending", } @@ -79,7 +79,7 @@ export const WorkspaceStateActions: StateActionsType = { canCancel: true, }, deleted: { - primary: ButtonTypesEnum.disabled, + primary: ButtonTypesEnum.deleted, secondary: [], canCancel: false, }, From 5e10ddf511cef96922b2f350d7c91ef288c11f50 Mon Sep 17 00:00:00 2001 From: Presley Pizzo Date: Wed, 5 Oct 2022 18:33:59 +0000 Subject: [PATCH 10/10] Refactor workspace actions --- site/src/components/Workspace/Workspace.tsx | 3 +- .../WorkspaceActions.stories.tsx | 32 +++++----- .../WorkspaceActions.test.tsx | 27 +++++---- .../WorkspaceActions/WorkspaceActions.tsx | 56 +++++++----------- .../components/WorkspaceActions/constants.ts | 58 +++++++++---------- 5 files changed, 83 insertions(+), 93 deletions(-) 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 }) + await renderComponent({ workspaceStatus: Mocks.MockStartingWorkspace.latest_build.status }) expect(screen.getByTestId("primary-cta")).toHaveTextContent( t("actionButton.starting", { ns: "workspacePage" }), ) @@ -53,7 +55,7 @@ describe("WorkspaceActions", () => { }) describe("when the workspace is started", () => { it("primary is stop; secondary is delete", async () => { - await renderAndClick({ workspace: Mocks.MockWorkspace }) + await renderAndClick({ workspaceStatus: Mocks.MockWorkspace.latest_build.status }) expect(screen.getByTestId("primary-cta")).toHaveTextContent( t("actionButton.stop", { ns: "workspacePage" }), ) @@ -64,7 +66,7 @@ describe("WorkspaceActions", () => { }) describe("when the workspace is stopping", () => { it("primary is stopping; cancel is available; no secondary", async () => { - await renderComponent({ workspace: Mocks.MockStoppingWorkspace }) + await renderComponent({ workspaceStatus: Mocks.MockStoppingWorkspace.latest_build.status }) expect(screen.getByTestId("primary-cta")).toHaveTextContent( t("actionButton.stopping", { ns: "workspacePage" }), ) @@ -78,7 +80,7 @@ describe("WorkspaceActions", () => { }) describe("when the workspace is canceling", () => { it("primary is canceling; no secondary", async () => { - await renderAndClick({ workspace: Mocks.MockCancelingWorkspace }) + await renderAndClick({ workspaceStatus: Mocks.MockCancelingWorkspace.latest_build.status }) expect(screen.getByTestId("primary-cta")).toHaveTextContent( t("disabledButton.canceling", { ns: "workspacePage" }), ) @@ -87,7 +89,7 @@ describe("WorkspaceActions", () => { }) describe("when the workspace is canceled", () => { it("primary is start; secondary are stop, delete", async () => { - await renderAndClick({ workspace: Mocks.MockCanceledWorkspace }) + await renderAndClick({ workspaceStatus: Mocks.MockCanceledWorkspace.latest_build.status }) expect(screen.getByTestId("primary-cta")).toHaveTextContent( t("actionButton.start", { ns: "workspacePage" }), ) @@ -101,7 +103,7 @@ describe("WorkspaceActions", () => { }) describe("when the workspace is errored", () => { it("primary is start; secondary is delete", async () => { - await renderAndClick({ workspace: Mocks.MockFailedWorkspace }) + await renderAndClick({ workspaceStatus: Mocks.MockFailedWorkspace.latest_build.status }) expect(screen.getByTestId("primary-cta")).toHaveTextContent( t("actionButton.start", { ns: "workspacePage" }), ) @@ -112,7 +114,7 @@ describe("WorkspaceActions", () => { }) describe("when the workspace is deleting", () => { it("primary is deleting; cancel is available; no secondary", async () => { - await renderComponent({ workspace: Mocks.MockDeletingWorkspace }) + await renderComponent({ workspaceStatus: Mocks.MockDeletingWorkspace.latest_build.status }) expect(screen.getByTestId("primary-cta")).toHaveTextContent( t("actionButton.deleting", { ns: "workspacePage" }), ) @@ -126,7 +128,7 @@ describe("WorkspaceActions", () => { }) describe("when the workspace is deleted", () => { it("primary is deleted; no secondary", async () => { - await renderAndClick({ workspace: Mocks.MockDeletedWorkspace }) + await renderAndClick({ workspaceStatus: Mocks.MockDeletedWorkspace.latest_build.status }) expect(screen.getByTestId("primary-cta")).toHaveTextContent( t("disabledButton.deleted", { ns: "workspacePage" }), ) @@ -135,7 +137,10 @@ describe("WorkspaceActions", () => { }) describe("when the workspace is outdated", () => { it("primary is update; secondary are start, delete", async () => { - await renderAndClick({ workspace: Mocks.MockOutdatedWorkspace }) + await renderAndClick({ + isOutdated: true, + workspaceStatus: Mocks.MockOutdatedWorkspace.latest_build.status, + }) expect(screen.getByTestId("primary-cta")).toHaveTextContent( t("actionButton.update", { ns: "workspacePage" }), ) diff --git a/site/src/components/WorkspaceActions/WorkspaceActions.tsx b/site/src/components/WorkspaceActions/WorkspaceActions.tsx index 689792f177036..65ed68c39846d 100644 --- a/site/src/components/WorkspaceActions/WorkspaceActions.tsx +++ b/site/src/components/WorkspaceActions/WorkspaceActions.tsx @@ -1,7 +1,7 @@ import { DropdownButton } from "components/DropdownButton/DropdownButton" import { FC, ReactNode, useMemo } from "react" import { useTranslation } from "react-i18next" -import { Workspace, WorkspaceStatus } from "../../api/typesGenerated" +import { WorkspaceStatus } from "../../api/typesGenerated" import { ActionLoadingButton, DeleteButton, @@ -10,17 +10,11 @@ import { 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) => - ["running", "stopped", "deleted", "failed", "canceled"].includes(workspaceStatus) +import { ButtonMapping, ButtonTypesEnum, statusToAbilities } from "./constants" export interface WorkspaceActionsProps { - workspace: Workspace + workspaceStatus: WorkspaceStatus + isOutdated: boolean handleStart: () => void handleStop: () => void handleDelete: () => void @@ -31,7 +25,8 @@ export interface WorkspaceActionsProps { } export const WorkspaceActions: FC = ({ - workspace, + workspaceStatus, + isOutdated, handleStart, handleStop, handleDelete, @@ -40,24 +35,8 @@ export const WorkspaceActions: FC = ({ isUpdating, }) => { const { t } = useTranslation("workspacePage") - const workspaceStatus = workspace.latest_build.status - - 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[workspaceStatus] - } - - // 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[workspaceStatus] } - updatedActions.secondary = [updatedActions.primary, ...updatedActions.secondary] - updatedActions.primary = ButtonTypesEnum.update - - return updatedActions - }, [canBeUpdated, workspaceStatus]) + const { canCancel, canAcceptJobs, actions } = statusToAbilities[workspaceStatus] + const canBeUpdated = isOutdated && canAcceptJobs // A mapping of button type to the corresponding React component const buttonMapping: ButtonMapping = { @@ -74,14 +53,23 @@ export const WorkspaceActions: FC = ({ [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 57b3294316490..45149ef74707b 100644 --- a/site/src/components/WorkspaceActions/constants.ts +++ b/site/src/components/WorkspaceActions/constants.ts @@ -21,71 +21,65 @@ export type ButtonMapping = { [key in ButtonTypesEnum]: ReactNode } -type StateActionsType = Record< - WorkspaceStatus, - { - 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 = { +export const statusToAbilities: Record = { starting: { - primary: ButtonTypesEnum.starting, - secondary: [], + actions: [ButtonTypesEnum.starting], canCancel: true, + canAcceptJobs: false, }, running: { - primary: ButtonTypesEnum.stop, - secondary: [ButtonTypesEnum.delete], + actions: [ButtonTypesEnum.stop, ButtonTypesEnum.delete], canCancel: false, + canAcceptJobs: true, }, stopping: { - primary: ButtonTypesEnum.stopping, - secondary: [], + actions: [ButtonTypesEnum.stopping], canCancel: true, + canAcceptJobs: false, }, stopped: { - primary: ButtonTypesEnum.start, - secondary: [ButtonTypesEnum.delete], + actions: [ButtonTypesEnum.start, ButtonTypesEnum.delete], canCancel: false, + canAcceptJobs: true, }, canceled: { - primary: ButtonTypesEnum.start, - secondary: [ButtonTypesEnum.stop, ButtonTypesEnum.delete], + actions: [ButtonTypesEnum.start, ButtonTypesEnum.stop, ButtonTypesEnum.delete], canCancel: false, + canAcceptJobs: true, }, // in the case of an error failed: { - primary: ButtonTypesEnum.start, // give the user the ability to start a workspace again - secondary: [ButtonTypesEnum.delete], // allows the user to delete + actions: [ButtonTypesEnum.start, ButtonTypesEnum.delete], canCancel: false, + canAcceptJobs: true, }, /** * disabled states */ canceling: { - primary: ButtonTypesEnum.canceling, - secondary: [], + actions: [ButtonTypesEnum.canceling], canCancel: false, + canAcceptJobs: false, }, deleting: { - primary: ButtonTypesEnum.deleting, - secondary: [], + actions: [ButtonTypesEnum.deleting], canCancel: true, + canAcceptJobs: false, }, deleted: { - primary: ButtonTypesEnum.deleted, - secondary: [], + actions: [ButtonTypesEnum.deleted], canCancel: false, + canAcceptJobs: true, }, pending: { - primary: ButtonTypesEnum.pending, - secondary: [], + actions: [ButtonTypesEnum.pending], canCancel: false, + canAcceptJobs: false, }, }