feat: Change workspace version using the UI (#5158)

This commit is contained in:
Bruno Quaresma
2022-11-24 13:36:50 +00:00
committed by GitHub
parent eff99f78fa
commit 511bb469c4
18 changed files with 606 additions and 7 deletions
+13
View File
@@ -46,6 +46,9 @@ const WorkspaceBuildPage = lazy(
() => import("./pages/WorkspaceBuildPage/WorkspaceBuildPage"),
)
const WorkspacePage = lazy(() => import("./pages/WorkspacePage/WorkspacePage"))
const WorkspaceChangeVersionPage = lazy(
() => import("./pages/WorkspaceChangeVersionPage/WorkspaceChangeVersionPage"),
)
const WorkspaceSchedulePage = lazy(
() => import("./pages/WorkspaceSchedulePage/WorkspaceSchedulePage"),
)
@@ -360,6 +363,7 @@ export const AppRouter: FC = () => {
</AuthAndFrame>
}
/>
<Route
path="schedule"
element={
@@ -386,6 +390,15 @@ export const AppRouter: FC = () => {
</AuthAndFrame>
}
/>
<Route
path="change-version"
element={
<RequireAuth>
<WorkspaceChangeVersionPage />
</RequireAuth>
}
/>
</Route>
</Route>
@@ -3,6 +3,7 @@ import Button from "@material-ui/core/Button"
import { makeStyles } from "@material-ui/core/styles"
import BlockIcon from "@material-ui/icons/Block"
import CloudQueueIcon from "@material-ui/icons/CloudQueue"
import UpdateOutlined from "@material-ui/icons/UpdateOutlined"
import CropSquareIcon from "@material-ui/icons/CropSquare"
import DeleteOutlineIcon from "@material-ui/icons/DeleteOutline"
import PlayCircleOutlineIcon from "@material-ui/icons/PlayCircleOutline"
@@ -33,6 +34,23 @@ export const UpdateButton: FC<React.PropsWithChildren<WorkspaceAction>> = ({
)
}
export const ChangeVersionButton: FC<
React.PropsWithChildren<WorkspaceAction>
> = ({ handleAction }) => {
const styles = useStyles()
const { t } = useTranslation("workspacePage")
return (
<Button
className={styles.actionButton}
startIcon={<UpdateOutlined />}
onClick={handleAction}
>
{t("actionButton.changeVersion")}
</Button>
)
}
export const StartButton: FC<React.PropsWithChildren<WorkspaceAction>> = ({
handleAction,
}) => {
@@ -45,6 +45,7 @@ export interface WorkspaceProps {
handleDelete: () => void
handleUpdate: () => void
handleCancel: () => void
handleChangeVersion: () => void
isUpdating: boolean
workspace: TypesGen.Workspace
resources?: TypesGen.WorkspaceResource[]
@@ -68,6 +69,7 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
handleDelete,
handleUpdate,
handleCancel,
handleChangeVersion,
workspace,
isUpdating,
resources,
@@ -143,6 +145,7 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
handleDelete={handleDelete}
handleUpdate={handleUpdate}
handleCancel={handleCancel}
handleChangeVersion={handleChangeVersion}
isUpdating={isUpdating}
/>
</Stack>
@@ -18,6 +18,7 @@ const renderComponent = async (props: Partial<WorkspaceActionsProps> = {}) => {
handleDelete={jest.fn()}
handleUpdate={jest.fn()}
handleCancel={jest.fn()}
handleChangeVersion={jest.fn()}
isUpdating={false}
/>,
)
@@ -35,6 +36,7 @@ const renderAndClick = async (props: Partial<WorkspaceActionsProps> = {}) => {
handleDelete={jest.fn()}
handleUpdate={jest.fn()}
handleCancel={jest.fn()}
handleChangeVersion={jest.fn()}
isUpdating={false}
/>,
)
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next"
import { WorkspaceStatus } from "../../api/typesGenerated"
import {
ActionLoadingButton,
ChangeVersionButton,
DeleteButton,
DisabledButton,
StartButton,
@@ -20,6 +21,7 @@ export interface WorkspaceActionsProps {
handleDelete: () => void
handleUpdate: () => void
handleCancel: () => void
handleChangeVersion: () => void
isUpdating: boolean
children?: ReactNode
}
@@ -32,6 +34,7 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
handleDelete,
handleUpdate,
handleCancel,
handleChangeVersion,
isUpdating,
}) => {
const { t } = useTranslation("workspacePage")
@@ -45,6 +48,9 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
[ButtonTypesEnum.updating]: (
<ActionLoadingButton label={t("actionButton.updating")} />
),
[ButtonTypesEnum.changeVersion]: (
<ChangeVersionButton handleAction={handleChangeVersion} />
),
[ButtonTypesEnum.start]: <StartButton handleAction={handleStart} />,
[ButtonTypesEnum.starting]: (
<ActionLoadingButton label={t("actionButton.starting")} />
@@ -11,6 +11,7 @@ export enum ButtonTypesEnum {
deleting = "deleting",
update = "update",
updating = "updating",
changeVersion = "changeVersion",
// disabled buttons
canceling = "canceling",
deleted = "deleted",
@@ -34,7 +35,11 @@ export const statusToAbilities: Record<WorkspaceStatus, WorkspaceAbilities> = {
canAcceptJobs: false,
},
running: {
actions: [ButtonTypesEnum.stop, ButtonTypesEnum.delete],
actions: [
ButtonTypesEnum.stop,
ButtonTypesEnum.changeVersion,
ButtonTypesEnum.delete,
],
canCancel: false,
canAcceptJobs: true,
},
@@ -44,7 +49,11 @@ export const statusToAbilities: Record<WorkspaceStatus, WorkspaceAbilities> = {
canAcceptJobs: false,
},
stopped: {
actions: [ButtonTypesEnum.start, ButtonTypesEnum.delete],
actions: [
ButtonTypesEnum.start,
ButtonTypesEnum.changeVersion,
ButtonTypesEnum.delete,
],
canCancel: false,
canAcceptJobs: true,
},
@@ -52,6 +61,7 @@ export const statusToAbilities: Record<WorkspaceStatus, WorkspaceAbilities> = {
actions: [
ButtonTypesEnum.start,
ButtonTypesEnum.stop,
ButtonTypesEnum.changeVersion,
ButtonTypesEnum.delete,
],
canCancel: false,
@@ -59,7 +69,11 @@ export const statusToAbilities: Record<WorkspaceStatus, WorkspaceAbilities> = {
},
// in the case of an error
failed: {
actions: [ButtonTypesEnum.start, ButtonTypesEnum.delete],
actions: [
ButtonTypesEnum.start,
ButtonTypesEnum.changeVersion,
ButtonTypesEnum.delete,
],
canCancel: false,
canAcceptJobs: true,
},
+2
View File
@@ -10,6 +10,7 @@ import workspacesPage from "./workspacesPage.json"
import usersPage from "./usersPage.json"
import templateVersionPage from "./templateVersionPage.json"
import loginPage from "./loginPage.json"
import workspaceChangeVersionPage from "./workspaceChangeVersionPage.json"
export const en = {
common,
@@ -24,4 +25,5 @@ export const en = {
usersPage,
templateVersionPage,
loginPage,
workspaceChangeVersionPage,
}
@@ -0,0 +1,9 @@
{
"title": "",
"labels": {
"workspaceVersion": "Workspace version",
"submit": "Update version",
"createdBy": "Created by",
"active": "Active"
}
}
+2 -1
View File
@@ -27,7 +27,8 @@
"updating": "Updating",
"starting": "Starting...",
"stopping": "Stopping...",
"deleting": "Deleting..."
"deleting": "Deleting...",
"changeVersion": "Change version"
},
"disabledButton": {
"canceling": "Canceling",
@@ -0,0 +1,155 @@
import { makeStyles } from "@material-ui/core/styles"
import TextField from "@material-ui/core/TextField"
import Autocomplete from "@material-ui/lab/Autocomplete"
import { Template, TemplateVersion, Workspace } from "api/typesGenerated"
import { FormFooter } from "components/FormFooter/FormFooter"
import { Pill } from "components/Pill/Pill"
import { Stack } from "components/Stack/Stack"
import { useFormik } from "formik"
import { FC } from "react"
import { useTranslation } from "react-i18next"
import { createDayString } from "util/createDayString"
import * as Yup from "yup"
const validationSchema = Yup.object({
versionId: Yup.string().required(),
})
export const WorkspaceChangeVersionForm: FC<{
isLoading: boolean
workspace: Workspace
template: Template
versions: TemplateVersion[]
onSubmit: (versionId: string) => void
onCancel: () => void
}> = ({ isLoading, workspace, template, versions, onSubmit, onCancel }) => {
const styles = useStyles()
const { t } = useTranslation("workspaceChangeVersionPage")
const formik = useFormik({
initialValues: {
versionId: workspace.latest_build.template_version_id,
},
validationSchema,
onSubmit: ({ versionId }) => onSubmit(versionId),
})
const autocompleteValue = versions.find(
(version) => version.id === formik.values.versionId,
)
return (
<form onSubmit={formik.handleSubmit}>
<Stack direction="column" spacing={3}>
<Stack
direction="row"
spacing={2}
className={styles.workspace}
alignItems="center"
>
<div className={styles.workspaceIcon}>
<img src={workspace.template_icon} alt="" />
</div>
<Stack direction="column" spacing={0.5}>
<span className={styles.workspaceName}>{workspace.name}</span>
<span className={styles.workspaceDescription}>
{workspace.template_display_name.length > 0
? workspace.template_display_name
: workspace.template_name}
</span>
</Stack>
</Stack>
<Autocomplete
id="workspaceVersion"
disableClearable
options={versions.slice().reverse()}
value={autocompleteValue}
onChange={async (_event, value) => {
if (value) {
await formik.setFieldValue("versionId", value.id)
}
}}
renderInput={(params) => (
<TextField
{...params}
label={t("labels.workspaceVersion")}
variant="outlined"
fullWidth
/>
)}
getOptionLabel={(version: TemplateVersion) => version.name}
renderOption={(version: TemplateVersion) => (
<div className={styles.menuItem}>
<div>
<div>{version.name}</div>
<div className={styles.versionDescription}>
{t("labels.createdBy")} {version.created_by.username}{" "}
{createDayString(version.created_at)}
</div>
</div>
{template.active_version_id === version.id && (
<Pill
type="success"
text={t("labels.active")}
className={styles.activePill}
/>
)}
</div>
)}
/>
</Stack>
<FormFooter
onCancel={onCancel}
isLoading={isLoading}
submitLabel={t("labels.submit")}
/>
</form>
)
}
const useStyles = makeStyles((theme) => ({
workspace: {
padding: theme.spacing(2.5, 3),
borderRadius: theme.shape.borderRadius,
backgroundColor: theme.palette.background.paper,
border: `1px solid ${theme.palette.divider}`,
},
workspaceName: {
fontSize: theme.spacing(2),
},
workspaceDescription: {
fontSize: theme.spacing(1.75),
color: theme.palette.text.secondary,
},
workspaceIcon: {
width: theme.spacing(5),
lineHeight: 1,
"& img": {
width: "100%",
},
},
menuItem: {
paddingTop: theme.spacing(1),
paddingBottom: theme.spacing(1),
position: "relative",
width: "100%",
},
versionDescription: {
fontSize: theme.spacing(1.5),
color: theme.palette.text.secondary,
},
activePill: {
position: "absolute",
top: theme.spacing(2),
right: theme.spacing(2),
},
}))
@@ -0,0 +1,64 @@
import {
MockTemplateVersion2,
MockUser,
MockWorkspace,
renderWithAuth,
waitForLoaderToBeRemoved,
} from "testHelpers/renderHelpers"
import WorkspaceChangeVersionPage from "./WorkspaceChangeVersionPage"
import { screen, waitFor } from "@testing-library/react"
import * as API from "api/api"
import userEvent from "@testing-library/user-event"
import * as CreateDayString from "util/createDayString"
import i18next from "i18next"
const t = (path: string) => {
return i18next.t(path, { ns: "workspaceChangeVersionPage" })
}
const renderPage = async () => {
renderWithAuth(<WorkspaceChangeVersionPage />, {
path: "/@:username/:workspace/change-version",
route: `/@${MockUser.username}/${MockWorkspace.name}/change-version`,
})
await waitForLoaderToBeRemoved()
}
describe("WorkspaceChangeVersionPage", () => {
beforeEach(() => {
jest
.spyOn(CreateDayString, "createDayString")
.mockImplementation(() => "a minute ago")
})
it("sends the update request with the right version", async () => {
const user = userEvent.setup()
const updateSpy = jest.spyOn(API, "startWorkspace")
await renderPage()
// Type the version name and select it
const autocompleteInput = screen.getByLabelText(
t("labels.workspaceVersion"),
)
await user.clear(autocompleteInput)
await user.type(autocompleteInput, MockTemplateVersion2.name)
const newOption = screen.getByRole("option", {
// Using RegExp so we can match a substring
name: new RegExp(MockTemplateVersion2.name),
})
await user.click(newOption)
// Submit the form
const submitButton = screen.getByRole("button", {
name: t("labels.submit"),
})
await user.click(submitButton)
await waitFor(() => {
expect(updateSpy).toBeCalledWith(
MockWorkspace.id,
MockTemplateVersion2.id,
)
})
})
})
@@ -0,0 +1,48 @@
import { useMachine } from "@xstate/react"
import { FC } from "react"
import { Helmet } from "react-helmet-async"
import { useTranslation } from "react-i18next"
import { useNavigate, useParams } from "react-router-dom"
import { changeWorkspaceVersionMachine } from "xServices/workspace/changeWorkspaceVersionXService"
import { WorkspaceChangeVersionPageView } from "./WorkspaceChangeVersionPageView"
export const WorkspaceChangeVersionPage: FC = () => {
const navigate = useNavigate()
const { t } = useTranslation("workspaceChangeVersionPage")
const { username: owner, workspace: workspaceName } = useParams() as {
username: string
workspace: string
}
const [state, send] = useMachine(changeWorkspaceVersionMachine, {
context: {
owner,
workspaceName,
},
actions: {
onUpdateVersion: () => {
navigate(-1)
},
},
})
return (
<>
<Helmet>
<title>{`${t("title")} · ${workspaceName}`}</title>
</Helmet>
<WorkspaceChangeVersionPageView
isUpdating={state.matches("updatingVersion")}
context={state.context}
onSubmit={(versionId) => {
send({
type: "UPDATE_VERSION",
versionId,
})
}}
/>
</>
)
}
export default WorkspaceChangeVersionPage
@@ -0,0 +1,51 @@
import { action } from "@storybook/addon-actions"
import { ComponentMeta, Story } from "@storybook/react"
import {
makeMockApiError,
MockTemplate,
MockTemplateVersion,
MockTemplateVersion2,
MockUser,
MockWorkspace,
} from "testHelpers/entities"
import {
WorkspaceChangeVersionPageView,
WorkspaceChangeVersionPageViewProps,
} from "./WorkspaceChangeVersionPageView"
export default {
title: "pages/WorkspaceChangeVersionPageView",
component: WorkspaceChangeVersionPageView,
} as ComponentMeta<typeof WorkspaceChangeVersionPageView>
const Template: Story<WorkspaceChangeVersionPageViewProps> = (args) => (
<WorkspaceChangeVersionPageView {...args} />
)
export const Example = Template.bind({})
Example.args = {
isUpdating: false,
onSubmit: action("submit"),
context: {
error: undefined,
owner: MockUser.username,
workspaceName: MockWorkspace.name,
template: MockTemplate,
templateVersions: [MockTemplateVersion2, MockTemplateVersion],
workspace: MockWorkspace,
},
}
export const Error = Template.bind({})
Error.args = {
isUpdating: false,
onSubmit: action("submit"),
context: {
error: makeMockApiError({ message: "Error on updating the version." }),
owner: MockUser.username,
workspaceName: MockWorkspace.name,
template: MockTemplate,
templateVersions: [MockTemplateVersion2, MockTemplateVersion],
workspace: MockWorkspace,
},
}
@@ -0,0 +1,46 @@
import { AlertBanner } from "components/AlertBanner/AlertBanner"
import { Maybe } from "components/Conditionals/Maybe"
import { FullPageForm } from "components/FullPageForm/FullPageForm"
import { Loader } from "components/Loader/Loader"
import { Stack } from "components/Stack/Stack"
import { FC } from "react"
import { useNavigate } from "react-router-dom"
import { ChangeWorkspaceVersionContext } from "xServices/workspace/changeWorkspaceVersionXService"
import { WorkspaceChangeVersionForm } from "./WorkspaceChangeVersionForm"
export interface WorkspaceChangeVersionPageViewProps {
isUpdating: boolean
context: ChangeWorkspaceVersionContext
onSubmit: (versionId: string) => void
}
export const WorkspaceChangeVersionPageView: FC<
WorkspaceChangeVersionPageViewProps
> = ({ context, onSubmit, isUpdating }) => {
const navigate = useNavigate()
const { workspace, templateVersions, template, error } = context
return (
<FullPageForm title="Change version" onCancel={() => navigate(-1)}>
<Stack>
<Maybe condition={Boolean(error)}>
<AlertBanner severity="error" error={error} />
</Maybe>
{workspace && template && templateVersions ? (
<WorkspaceChangeVersionForm
isLoading={isUpdating}
versions={templateVersions}
workspace={workspace}
template={template}
onSubmit={onSubmit}
onCancel={() => {
navigate(-1)
}}
/>
) : (
<Loader />
)}
</Stack>
</FullPageForm>
)
}
@@ -4,6 +4,7 @@ import dayjs from "dayjs"
import { useContext } from "react"
import { Helmet } from "react-helmet-async"
import { useTranslation } from "react-i18next"
import { useNavigate } from "react-router-dom"
import {
getMaxDeadline,
getMaxDeadlineChange,
@@ -63,6 +64,7 @@ export const WorkspaceReadyPage = ({
const canUpdateWorkspace = Boolean(permissions?.updateWorkspace)
const { t } = useTranslation("workspacePage")
const favicon = getFaviconByStatus(workspace.latest_build)
const navigate = useNavigate()
return (
<>
@@ -114,6 +116,7 @@ export const WorkspaceReadyPage = ({
handleDelete={() => workspaceSend({ type: "ASK_DELETE" })}
handleUpdate={() => workspaceSend({ type: "UPDATE" })}
handleCancel={() => workspaceSend({ type: "CANCEL" })}
handleChangeVersion={() => navigate("change-version")}
resources={workspace.latest_build.resources}
builds={builds}
canUpdateWorkspace={canUpdateWorkspace}
+19 -2
View File
@@ -181,6 +181,23 @@ You can add instructions here
created_by: MockUser,
}
export const MockTemplateVersion2: TypesGen.TemplateVersion = {
id: "test-template-version-2",
created_at: "2022-05-17T17:39:01.382927298Z",
updated_at: "2022-05-17T17:39:01.382927298Z",
template_id: "test-template",
job: MockProvisionerJob,
name: "test-version-2",
readme: `---
name:Template test 2
---
## Instructions
You can add instructions here
[Some link info](https://coder.com)`,
created_by: MockUser,
}
export const MockTemplate: TypesGen.Template = {
id: "test-template",
created_at: "2022-05-17T17:39:01.382927298Z",
@@ -383,7 +400,7 @@ export const MockWorkspaceBuild: TypesGen.WorkspaceBuild = {
initiator_id: MockUser.id,
initiator_name: MockUser.username,
job: MockProvisionerJob,
template_version_id: "",
template_version_id: MockTemplateVersion.id,
transition: "start",
updated_at: "2022-05-17T17:39:01.382927298Z",
workspace_name: "test-workspace",
@@ -406,7 +423,7 @@ export const MockFailedWorkspaceBuild = (
initiator_id: MockUser.id,
initiator_name: MockUser.username,
job: MockFailedProvisionerJob,
template_version_id: "",
template_version_id: MockTemplateVersion.id,
transition: transition,
updated_at: "2022-05-17T17:39:01.382927298Z",
workspace_name: "test-workspace",
+4 -1
View File
@@ -37,7 +37,10 @@ export const handlers = [
return res(ctx.status(200), ctx.json(M.MockTemplate))
}),
rest.get("/api/v2/templates/:templateId/versions", async (req, res, ctx) => {
return res(ctx.status(200), ctx.json([M.MockTemplateVersion]))
return res(
ctx.status(200),
ctx.json([M.MockTemplateVersion2, M.MockTemplateVersion]),
)
}),
rest.patch("/api/v2/templates/:templateId", async (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockTemplate))
@@ -0,0 +1,144 @@
import {
getTemplate,
getTemplateVersions,
getWorkspaceByOwnerAndName,
startWorkspace,
} from "api/api"
import {
Template,
TemplateVersion,
Workspace,
WorkspaceBuild,
} from "api/typesGenerated"
import { assign, createMachine } from "xstate"
export interface ChangeWorkspaceVersionContext {
owner: string
workspaceName: string
workspace?: Workspace
template?: Template
templateVersions?: TemplateVersion[]
error?: unknown
}
interface ChangeWorkspaceVersionSchema {
context: ChangeWorkspaceVersionContext
services: {
getWorkspace: {
data: Workspace
}
getTemplateData: {
data: {
template: Template
versions: TemplateVersion[]
}
}
updateVersion: {
data: WorkspaceBuild
}
}
events: {
type: "UPDATE_VERSION"
versionId: string
}
}
export const changeWorkspaceVersionMachine = createMachine(
{
id: "changeWorkspaceVersion",
predictableActionArguments: true,
schema: {} as ChangeWorkspaceVersionSchema,
tsTypes: {} as import("./changeWorkspaceVersionXService.typegen").Typegen0,
initial: "loadingWorkspace",
states: {
loadingWorkspace: {
invoke: {
src: "getWorkspace",
onDone: {
target: "loadingTemplateData",
actions: "assignWorkspace",
},
onError: {
target: "idle",
actions: "assignError",
},
},
},
loadingTemplateData: {
invoke: {
src: "getTemplateData",
onDone: {
target: "idle",
actions: "assignTemplateData",
},
onError: {
target: "idle",
actions: "assignError",
},
},
},
idle: {
on: {
UPDATE_VERSION: "updatingVersion",
},
},
updatingVersion: {
invoke: {
src: "updateVersion",
onDone: {
target: "idle",
actions: "onUpdateVersion",
},
onError: {
target: "idle",
actions: "assignError",
},
},
},
},
},
{
services: {
getWorkspace: ({ owner, workspaceName }) =>
getWorkspaceByOwnerAndName(owner, workspaceName),
getTemplateData: async ({ workspace }) => {
if (!workspace) {
throw new Error("Workspace not defined.")
}
const [template, versions] = await Promise.all([
getTemplate(workspace.template_id),
getTemplateVersions(workspace.template_id),
])
return { template, versions }
},
updateVersion: ({ workspace }, { versionId }) => {
if (!workspace) {
throw new Error("Workspace not defined.")
}
return startWorkspace(workspace.id, versionId)
},
},
actions: {
assignError: assign({
error: (_, { data }) => data,
}),
assignWorkspace: assign({
workspace: (_, { data }) => data,
}),
assignTemplateData: assign({
template: (_, { data }) => data.template,
templateVersions: (_, { data }) => data.versions,
}),
},
},
)