chore: refactor frontend to use workspace status directly (#4361)

* Add/update copy

* Update mocks

* Handle disabled button labels separately

* Use workspace status directly, use i18n

* Update stories and tests

* Fix optimistic update in xservice to use status, pending

* Rename started to running in story

* Fix deletion banner conditional

* Send label to disabled button

* Refactor workspace actions
This commit is contained in:
Presley Pizzo
2022-10-05 16:20:29 -04:00
committed by GitHub
parent 2a66395fb7
commit 9a670b90df
18 changed files with 263 additions and 373 deletions
@@ -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<React.PropsWithChildren<WorkspaceAction>> = ({ handleAction }) => {
const styles = useStyles()
const { t } = useTranslation("workspacePage")
return (
<Button className={styles.actionButton} startIcon={<CloudQueueIcon />} onClick={handleAction}>
{Language.update}
{t("actionButton.update")}
</Button>
)
}
export const StartButton: FC<React.PropsWithChildren<WorkspaceAction>> = ({ handleAction }) => {
const styles = useStyles()
const { t } = useTranslation("workspacePage")
return (
<WorkspaceActionButton
className={styles.actionButton}
icon={<PlayCircleOutlineIcon />}
onClick={handleAction}
label={Language.start}
label={t("actionButton.start")}
/>
)
}
export const StopButton: FC<React.PropsWithChildren<WorkspaceAction>> = ({ handleAction }) => {
const styles = useStyles()
const { t } = useTranslation("workspacePage")
return (
<WorkspaceActionButton
className={styles.actionButton}
icon={<CropSquareIcon />}
onClick={handleAction}
label={Language.stop}
label={t("actionButton.stop")}
/>
)
}
export const DeleteButton: FC<React.PropsWithChildren<WorkspaceAction>> = ({ handleAction }) => {
const styles = useStyles()
const { t } = useTranslation("workspacePage")
return (
<WorkspaceActionButton
className={styles.actionButton}
icon={<DeleteOutlineIcon />}
onClick={handleAction}
label={Language.delete}
label={t("actionButton.delete")}
/>
)
}
@@ -92,15 +83,15 @@ export const CancelButton: FC<React.PropsWithChildren<WorkspaceAction>> = ({ han
}
interface DisabledProps {
workspaceState: WorkspaceStateEnum
label: string
}
export const DisabledButton: FC<React.PropsWithChildren<DisabledProps>> = ({ workspaceState }) => {
export const DisabledButton: FC<React.PropsWithChildren<DisabledProps>> = ({ label }) => {
const styles = useStyles()
return (
<Button disabled className={styles.actionButton}>
{workspaceState}
{label}
</Button>
)
}
@@ -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: <DisabledButton workspaceState={WorkspaceStateEnum.deleting} />,
primaryAction: <DisabledButton workspaceStatus="deleting" />,
secondaryActions: [],
canCancel: true,
handleCancel: action("cancel"),
@@ -13,8 +13,8 @@ export default {
const Template: Story<WorkspaceProps> = (args) => <Workspace {...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.",
+2 -1
View File
@@ -100,7 +100,8 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
canUpdateWorkspace={canUpdateWorkspace}
/>
<WorkspaceActions
workspace={workspace}
workspaceStatus={workspace.latest_build.status}
isOutdated={workspace.outdated}
handleStart={handleStart}
handleStop={handleStop}
handleDelete={handleDelete}
@@ -16,72 +16,74 @@ const defaultArgs = {
handleDelete: action("delete"),
handleUpdate: action("update"),
handleCancel: action("cancel"),
isOutdated: false,
isUpdating: false,
}
export const Starting = Template.bind({})
Starting.args = {
...defaultArgs,
workspace: Mocks.MockStartingWorkspace,
workspaceStatus: Mocks.MockStartingWorkspace.latest_build.status,
}
export const Started = Template.bind({})
Started.args = {
export const Running = Template.bind({})
Running.args = {
...defaultArgs,
workspace: Mocks.MockWorkspace,
workspaceStatus: Mocks.MockWorkspace.latest_build.status,
}
export const Stopping = Template.bind({})
Stopping.args = {
...defaultArgs,
workspace: Mocks.MockStoppingWorkspace,
workspaceStatus: Mocks.MockStoppingWorkspace.latest_build.status,
}
export const Stopped = Template.bind({})
Stopped.args = {
...defaultArgs,
workspace: Mocks.MockStoppedWorkspace,
workspaceStatus: Mocks.MockStoppedWorkspace.latest_build.status,
}
export const Canceling = Template.bind({})
Canceling.args = {
...defaultArgs,
workspace: Mocks.MockCancelingWorkspace,
workspaceStatus: Mocks.MockCancelingWorkspace.latest_build.status,
}
export const Canceled = Template.bind({})
Canceled.args = {
...defaultArgs,
workspace: Mocks.MockCanceledWorkspace,
workspaceStatus: Mocks.MockCanceledWorkspace.latest_build.status,
}
export const Deleting = Template.bind({})
Deleting.args = {
...defaultArgs,
workspace: Mocks.MockDeletingWorkspace,
workspaceStatus: Mocks.MockDeletingWorkspace.latest_build.status,
}
export const Deleted = Template.bind({})
Deleted.args = {
...defaultArgs,
workspace: Mocks.MockDeletedWorkspace,
workspaceStatus: Mocks.MockDeletedWorkspace.latest_build.status,
}
export const Outdated = Template.bind({})
Outdated.args = {
...defaultArgs,
workspace: Mocks.MockOutdatedWorkspace,
isOutdated: true,
workspaceStatus: Mocks.MockOutdatedWorkspace.latest_build.status,
}
export const Errored = Template.bind({})
Errored.args = {
export const Failed = Template.bind({})
Failed.args = {
...defaultArgs,
workspace: Mocks.MockFailedWorkspace,
workspaceStatus: Mocks.MockFailedWorkspace.latest_build.status,
}
export const Updating = Template.bind({})
Updating.args = {
...defaultArgs,
isUpdating: true,
workspace: Mocks.MockOutdatedWorkspace,
workspaceStatus: Mocks.MockOutdatedWorkspace.latest_build.status,
}
@@ -1,14 +1,16 @@
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<WorkspaceActionsProps> = {}) => {
render(
<WorkspaceActions
workspace={props.workspace ?? Mocks.MockWorkspace}
workspaceStatus={props.workspaceStatus ?? Mocks.MockWorkspace.latest_build.status}
isOutdated={props.isOutdated ?? false}
handleStart={jest.fn()}
handleStop={jest.fn()}
handleDelete={jest.fn()}
@@ -22,7 +24,8 @@ const renderComponent = async (props: Partial<WorkspaceActionsProps> = {}) => {
const renderAndClick = async (props: Partial<WorkspaceActionsProps> = {}) => {
render(
<WorkspaceActions
workspace={props.workspace ?? Mocks.MockWorkspace}
workspaceStatus={props.workspaceStatus ?? Mocks.MockWorkspace.latest_build.status}
isOutdated={props.isOutdated ?? false}
handleStart={jest.fn()}
handleStop={jest.fn()}
handleDelete={jest.fn()}
@@ -38,8 +41,10 @@ const renderAndClick = async (props: Partial<WorkspaceActionsProps> = {}) => {
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" }),
)
})
})
})
@@ -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<WorkspaceActionsProps> = ({
workspace,
workspaceStatus,
isOutdated,
handleStart,
handleStop,
handleDelete,
@@ -40,52 +34,42 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
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]: <UpdateButton handleAction={handleUpdate} />,
[ButtonTypesEnum.updating]: <ActionLoadingButton label={Language.updating} />,
[ButtonTypesEnum.updating]: <ActionLoadingButton label={t("actionButton.updating")} />,
[ButtonTypesEnum.start]: <StartButton handleAction={handleStart} />,
[ButtonTypesEnum.starting]: <ActionLoadingButton label={Language.starting} />,
[ButtonTypesEnum.starting]: <ActionLoadingButton label={t("actionButton.starting")} />,
[ButtonTypesEnum.stop]: <StopButton handleAction={handleStop} />,
[ButtonTypesEnum.stopping]: <ActionLoadingButton label={Language.stopping} />,
[ButtonTypesEnum.stopping]: <ActionLoadingButton label={t("actionButton.stopping")} />,
[ButtonTypesEnum.delete]: <DeleteButton handleAction={handleDelete} />,
[ButtonTypesEnum.deleting]: <ActionLoadingButton label={Language.deleting} />,
[ButtonTypesEnum.canceling]: <DisabledButton workspaceState={workspaceState} />,
[ButtonTypesEnum.disabled]: <DisabledButton workspaceState={workspaceState} />,
[ButtonTypesEnum.queued]: <DisabledButton workspaceState={workspaceState} />,
[ButtonTypesEnum.loading]: <DisabledButton workspaceState={workspaceState} />,
[ButtonTypesEnum.deleting]: <ActionLoadingButton label={t("actionButton.deleting")} />,
[ButtonTypesEnum.canceling]: <DisabledButton label={t("disabledButton.canceling")} />,
[ButtonTypesEnum.deleted]: <DisabledButton label={t("disabledButton.deleted")} />,
[ButtonTypesEnum.pending]: <DisabledButton label={t("disabledButton.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 (
<DropdownButton
primaryAction={
isUpdating ? buttonMapping[ButtonTypesEnum.updating] : buttonMapping[actions.primary]
}
canCancel={actions.canCancel}
primaryAction={buttonMapping[primaryAction]}
canCancel={canCancel}
handleCancel={handleCancel}
secondaryActions={actions.secondary.map((action) => ({
secondaryActions={secondaryActions.map((action) => ({
action,
button: buttonMapping[action],
}))}
@@ -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<WorkspaceStatus, WorkspaceAbilities> = {
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,
},
}
@@ -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<React.PropsWithChildren<WorkspaceDeleted
}) => {
const styles = useStyles()
if (!isWorkspaceDeleted(workspace)) {
return null
}
return (
<Alert
className={styles.root}
action={
<Button color="inherit" onClick={handleClick} size="small">
{Language.createWorkspaceCta}
</Button>
}
severity="warning"
>
<AlertTitle>{Language.bannerTitle}</AlertTitle>
</Alert>
<Maybe condition={workspace.latest_build.status === "deleted"}>
<Alert
className={styles.root}
action={
<Button color="inherit" onClick={handleClick} size="small">
{Language.createWorkspaceCta}
</Button>
}
severity="warning"
>
<AlertTitle>{Language.bannerTitle}</AlertTitle>
</Alert>
</Maybe>
)
}
@@ -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,
}
@@ -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 <CircularProgress size={10} style={{ color: "#FFF" }} />
@@ -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: <LoadingIcon />,
}
case "started":
case "running":
return {
type: "success",
text: t("workspaceStatus.started", { ns: "common" }),
text: t("workspaceStatus.running", { ns: "common" }),
icon: <PlayIcon />,
}
case "starting":
@@ -77,16 +75,16 @@ export const getStatus = (
text: t("workspaceStatus.canceled", { ns: "common" }),
icon: <ErrorIcon />,
}
case "error":
case "failed":
return {
type: "error",
text: t("workspaceStatus.failed", { ns: "common" }),
icon: <ErrorIcon />,
}
case "queued":
case "pending":
return {
type: "info",
text: t("workspaceStatus.queued", { ns: "common" }),
text: t("workspaceStatus.pending", { ns: "common" }),
icon: <LoadingIcon />,
}
}
+2 -2
View File
@@ -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}}",
+16
View File
@@ -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"
}
}
@@ -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" }))
+10 -6
View File
@@ -396,13 +396,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 = {
@@ -411,40 +412,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",
},
}
-39
View File
@@ -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]>([
[
+6 -88
View File
@@ -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<WorkspaceBuildTransition, WorkspaceStatus> = {
start: "starting",
stop: "stopping",
delete: "deleting",
}
const succeededToStatus: Record<WorkspaceBuildTransition, WorkspaceStatus> = {
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)
}
@@ -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,