feat(site): Ask for parameter values when updating a workspace (#6586)

This commit is contained in:
Bruno Quaresma
2023-03-14 13:20:49 -03:00
committed by GitHub
parent f91b3acf93
commit 3cf235c564
15 changed files with 690 additions and 279 deletions
+45 -9
View File
@@ -1,5 +1,11 @@
import axios from "axios"
import { getApiKey, getURLWithSearchParams, login, logout } from "./api"
import {
MockTemplate,
MockTemplateVersionParameter1,
MockWorkspace,
MockWorkspaceBuild,
} from "testHelpers/entities"
import * as api from "./api"
import * as TypesGen from "./typesGenerated"
describe("api.ts", () => {
@@ -12,7 +18,7 @@ describe("api.ts", () => {
jest.spyOn(axios, "post").mockResolvedValueOnce({ data: loginResponse })
// when
const result = await login("test", "123")
const result = await api.login("test", "123")
// then
expect(axios.post).toHaveBeenCalled()
@@ -33,7 +39,7 @@ describe("api.ts", () => {
axios.post = axiosMockPost
try {
await login("test", "123")
await api.login("test", "123")
} catch (error) {
expect(error).toStrictEqual(expectedError)
}
@@ -49,7 +55,7 @@ describe("api.ts", () => {
axios.post = axiosMockPost
// when
await logout()
await api.logout()
// then
expect(axiosMockPost).toHaveBeenCalled()
@@ -68,7 +74,7 @@ describe("api.ts", () => {
axios.post = axiosMockPost
try {
await logout()
await api.logout()
} catch (error) {
expect(error).toStrictEqual(expectedError)
}
@@ -87,7 +93,7 @@ describe("api.ts", () => {
axios.post = axiosMockPost
// when
const result = await getApiKey()
const result = await api.getApiKey()
// then
expect(axiosMockPost).toHaveBeenCalled()
@@ -107,7 +113,7 @@ describe("api.ts", () => {
axios.post = axiosMockPost
try {
await getApiKey()
await api.getApiKey()
} catch (error) {
expect(error).toStrictEqual(expectedError)
}
@@ -133,7 +139,7 @@ describe("api.ts", () => {
])(
`Workspaces - getURLWithSearchParams(%p, %p) returns %p`,
(basePath, filter, expected) => {
expect(getURLWithSearchParams(basePath, filter)).toBe(expected)
expect(api.getURLWithSearchParams(basePath, filter)).toBe(expected)
},
)
})
@@ -150,8 +156,38 @@ describe("api.ts", () => {
])(
`Users - getURLWithSearchParams(%p, %p) returns %p`,
(basePath, filter, expected) => {
expect(getURLWithSearchParams(basePath, filter)).toBe(expected)
expect(api.getURLWithSearchParams(basePath, filter)).toBe(expected)
},
)
})
describe("update", () => {
it("creates a build with start and the latest template", async () => {
jest
.spyOn(api, "postWorkspaceBuild")
.mockResolvedValueOnce(MockWorkspaceBuild)
jest.spyOn(api, "getTemplate").mockResolvedValueOnce(MockTemplate)
await api.updateWorkspace(MockWorkspace)
expect(api.postWorkspaceBuild).toHaveBeenCalledWith(MockWorkspace.id, {
transition: "start",
template_version_id: MockTemplate.active_version_id,
rich_parameter_values: [],
})
})
it("fails when having missing parameters", async () => {
jest
.spyOn(api, "postWorkspaceBuild")
.mockResolvedValueOnce(MockWorkspaceBuild)
jest.spyOn(api, "getTemplate").mockResolvedValueOnce(MockTemplate)
jest.spyOn(api, "getWorkspaceBuildParameters").mockResolvedValueOnce([])
jest
.spyOn(api, "getTemplateVersionRichParameters")
.mockResolvedValueOnce([MockTemplateVersionParameter1])
await expect(api.updateWorkspace(MockWorkspace)).rejects.toThrow(
api.MissingBuildParameters,
)
})
})
})
+79
View File
@@ -897,3 +897,82 @@ export const getWorkspaceBuildParameters = async (
)
return response.data
}
export class MissingBuildParameters extends Error {
parameters: TypesGen.TemplateVersionParameter[] = []
constructor(parameters: TypesGen.TemplateVersionParameter[]) {
super("Missing build parameters.")
this.parameters = parameters
}
}
/** Steps to update the workspace
* - Get the latest template to access the latest active version
* - Get the current build parameters
* - Get the template parameters
* - Update the build parameters and check if there are missed parameters for the newest version
* - If there are missing parameters raise an error
* - Create a build with the latest version and updated build parameters
*/
export const updateWorkspace = async (
workspace: TypesGen.Workspace,
newBuildParameters: TypesGen.WorkspaceBuildParameter[] = [],
): Promise<TypesGen.WorkspaceBuild> => {
const [template, oldBuildParameters] = await Promise.all([
getTemplate(workspace.template_id),
getWorkspaceBuildParameters(workspace.latest_build.id),
])
const activeVersionId = template.active_version_id
const templateParameters = await getTemplateVersionRichParameters(
activeVersionId,
)
const [updatedBuildParameters, missingParameters] = updateBuildParameters(
oldBuildParameters,
newBuildParameters,
templateParameters,
)
if (missingParameters.length > 0) {
throw new MissingBuildParameters(missingParameters)
}
return postWorkspaceBuild(workspace.id, {
transition: "start",
template_version_id: activeVersionId,
rich_parameter_values: updatedBuildParameters,
})
}
const updateBuildParameters = (
oldBuildParameters: TypesGen.WorkspaceBuildParameter[],
newBuildParameters: TypesGen.WorkspaceBuildParameter[],
templateParameters: TypesGen.TemplateVersionParameter[],
) => {
const missingParameters: TypesGen.TemplateVersionParameter[] = []
const updatedBuildParameters: TypesGen.WorkspaceBuildParameter[] = []
for (const parameter of templateParameters) {
// Check if there is a new value
let buildParameter = newBuildParameters.find(
(p) => p.name === parameter.name,
)
// If not, get the old one
if (!buildParameter) {
buildParameter = oldBuildParameters.find((p) => p.name === parameter.name)
}
// If there is a value from the new or old one, add it to the list
if (buildParameter) {
updatedBuildParameters.push(buildParameter)
continue
}
// If there is no value and it is required, add it to the list of missing parameters
if (parameter.required) {
missingParameters.push(parameter)
}
}
return [updatedBuildParameters, missingParameters] as const
}
@@ -4,36 +4,84 @@ import {
FormFooter as BaseFormFooter,
} from "components/FormFooter/FormFooter"
import { Stack } from "components/Stack/Stack"
import { FC, HTMLProps, PropsWithChildren } from "react"
import {
createContext,
FC,
HTMLProps,
PropsWithChildren,
useContext,
} from "react"
import { combineClasses } from "util/combineClasses"
export const HorizontalForm: FC<
PropsWithChildren & HTMLProps<HTMLFormElement>
> = ({ children, ...formProps }) => {
type FormContextValue = { direction?: "horizontal" | "vertical" }
const FormContext = createContext<FormContextValue>({
direction: "horizontal",
})
type FormProps = HTMLProps<HTMLFormElement> & {
direction?: FormContextValue["direction"]
}
export const Form: FC<FormProps> = ({ direction, className, ...formProps }) => {
const styles = useStyles()
return (
<form {...formProps}>
<Stack direction="column" spacing={10} className={styles.formSections}>
{children}
</Stack>
</form>
<FormContext.Provider value={{ direction }}>
<form
{...formProps}
className={combineClasses([styles.form, className])}
/>
</FormContext.Provider>
)
}
export const HorizontalForm: FC<HTMLProps<HTMLFormElement>> = ({
children,
...formProps
}) => {
return (
<Form direction="horizontal" {...formProps}>
{children}
</Form>
)
}
export const VerticalForm: FC<HTMLProps<HTMLFormElement>> = ({
children,
...formProps
}) => {
return (
<Form direction="vertical" {...formProps}>
{children}
</Form>
)
}
export const FormSection: FC<
PropsWithChildren & {
title: string
title: string | JSX.Element
description: string | JSX.Element
className?: string
classes?: {
root?: string
infoTitle?: string
}
}
> = ({ children, title, description, className }) => {
const styles = useStyles()
> = ({ children, title, description, classes = {} }) => {
const formContext = useContext(FormContext)
const styles = useStyles(formContext)
return (
<div className={combineClasses([styles.formSection, className])}>
<div className={combineClasses([styles.formSection, classes.root])}>
<div className={styles.formSectionInfo}>
<h2 className={styles.formSectionInfoTitle}>{title}</h2>
<h2
className={combineClasses([
styles.formSectionInfoTitle,
classes.infoTitle,
])}
>
{title}
</h2>
<div className={styles.formSectionInfoDescription}>{description}</div>
</div>
@@ -62,7 +110,12 @@ export const FormFooter: FC<BaseFormFooterProps> = (props) => {
}
const useStyles = makeStyles((theme) => ({
formSections: {
form: {
display: "flex",
flexDirection: "column",
gap: ({ direction }: FormContextValue = {}) =>
direction === "horizontal" ? theme.spacing(10) : theme.spacing(5),
[theme.breakpoints.down("sm")]: {
gap: theme.spacing(8),
},
@@ -71,7 +124,10 @@ const useStyles = makeStyles((theme) => ({
formSection: {
display: "flex",
alignItems: "flex-start",
gap: theme.spacing(15),
gap: ({ direction }: FormContextValue = {}) =>
direction === "horizontal" ? theme.spacing(15) : theme.spacing(3),
flexDirection: ({ direction }: FormContextValue = {}) =>
direction === "horizontal" ? "row" : "column",
[theme.breakpoints.down("sm")]: {
flexDirection: "column",
@@ -80,9 +136,11 @@ const useStyles = makeStyles((theme) => ({
},
formSectionInfo: {
width: 312,
maxWidth: ({ direction }: FormContextValue = {}) =>
direction === "horizontal" ? 312 : undefined,
flexShrink: 0,
position: "sticky",
position: ({ direction }: FormContextValue = {}) =>
direction === "horizontal" ? "sticky" : undefined,
top: theme.spacing(3),
[theme.breakpoints.down("sm")]: {
+3
View File
@@ -65,5 +65,8 @@
"agentVersionLabel": "Agent version",
"serverVersionLabel": "Server version",
"updateWorkspaceLabel": "Update workspace"
},
"askParametersDialog": {
"message": "It looks like the new version has new parameters that need to be filled in to update the workspace."
}
}
@@ -31,12 +31,12 @@ import { LazyIconField } from "components/IconField/LazyIconField"
import { Maybe } from "components/Conditionals/Maybe"
import i18next from "i18next"
import Link from "@material-ui/core/Link"
import { FormFooter } from "components/FormFooter/FormFooter"
import {
HorizontalForm,
FormSection,
FormFields,
} from "components/HorizontalForm/HorizontalForm"
FormFooter,
} from "components/Form/Form"
import camelCase from "lodash/camelCase"
import capitalize from "lodash/capitalize"
import { VariableInput } from "./VariableInput"
@@ -19,8 +19,12 @@ import {
FormSection,
FormFooter,
HorizontalForm,
} from "components/HorizontalForm/HorizontalForm"
} from "components/Form/Form"
import { makeStyles } from "@material-ui/core/styles"
import {
selectInitialRichParametersValues,
useValidationSchemaForRichParameters,
} from "util/richParameters"
export enum CreateWorkspaceErrors {
GET_TEMPLATES_ERROR = "getTemplatesError",
@@ -82,7 +86,7 @@ export const CreateWorkspacePageView: FC<
},
validationSchema: Yup.object({
name: nameValidator(t("nameLabel", { ns: "createWorkspacePage" })),
rich_parameter_values: ValidationSchemaForRichParameters(
rich_parameter_values: useValidationSchemaForRichParameters(
"createWorkspacePage",
props.templateParameters,
),
@@ -334,7 +338,7 @@ export const CreateWorkspacePageView: FC<
props.templateParameters.filter((p) => !p.mutable).length > 0 && (
<FormSection
title="Immutable parameters"
className={styles.warningSection}
classes={{ root: styles.warningSection }}
description={
<>
Those values are also parameters provided from your Terraform
@@ -398,44 +402,6 @@ const useStyles = makeStyles((theme) => ({
},
}))
const selectInitialRichParametersValues = (
templateParameters?: TypesGen.TemplateVersionParameter[],
defaultValuesFromQuery?: Record<string, string>,
): TypesGen.WorkspaceBuildParameter[] => {
const defaults: TypesGen.WorkspaceBuildParameter[] = []
if (!templateParameters) {
return defaults
}
templateParameters.forEach((parameter) => {
if (parameter.options.length > 0) {
let parameterValue = parameter.options[0].value
if (defaultValuesFromQuery && defaultValuesFromQuery[parameter.name]) {
parameterValue = defaultValuesFromQuery[parameter.name]
}
const buildParameter: TypesGen.WorkspaceBuildParameter = {
name: parameter.name,
value: parameterValue,
}
defaults.push(buildParameter)
return
}
let parameterValue = parameter.default_value
if (defaultValuesFromQuery && defaultValuesFromQuery[parameter.name]) {
parameterValue = defaultValuesFromQuery[parameter.name]
}
const buildParameter: TypesGen.WorkspaceBuildParameter = {
name: parameter.name,
value: parameterValue || "",
}
defaults.push(buildParameter)
})
return defaults
}
export const workspaceBuildParameterValue = (
workspaceBuildParameters: TypesGen.WorkspaceBuildParameter[],
parameter: TypesGen.TemplateVersionParameter,
@@ -445,107 +411,3 @@ export const workspaceBuildParameterValue = (
})
return (buildParameter && buildParameter.value) || ""
}
export const ValidationSchemaForRichParameters = (
ns: string,
templateParameters?: TypesGen.TemplateVersionParameter[],
lastBuildParameters?: TypesGen.WorkspaceBuildParameter[],
): Yup.AnySchema => {
const { t } = useTranslation(ns)
if (!templateParameters) {
return Yup.object()
}
return Yup.array()
.of(
Yup.object().shape({
name: Yup.string().required(),
value: Yup.string().test("verify with template", (val, ctx) => {
const name = ctx.parent.name
const templateParameter = templateParameters.find(
(parameter) => parameter.name === name,
)
if (templateParameter) {
switch (templateParameter.type) {
case "number":
if (
templateParameter.validation_min &&
templateParameter.validation_max
) {
if (
Number(val) < templateParameter.validation_min ||
templateParameter.validation_max < Number(val)
) {
return ctx.createError({
path: ctx.path,
message: t("validationNumberNotInRange", {
min: templateParameter.validation_min,
max: templateParameter.validation_max,
}),
})
}
}
if (
templateParameter.validation_monotonic &&
lastBuildParameters
) {
const lastBuildParameter = lastBuildParameters.find(
(last) => last.name === name,
)
if (lastBuildParameter) {
switch (templateParameter.validation_monotonic) {
case "increasing":
if (Number(lastBuildParameter.value) > Number(val)) {
return ctx.createError({
path: ctx.path,
message: t("validationNumberNotIncreasing", {
last: lastBuildParameter.value,
}),
})
}
break
case "decreasing":
if (Number(lastBuildParameter.value) < Number(val)) {
return ctx.createError({
path: ctx.path,
message: t("validationNumberNotDecreasing", {
last: lastBuildParameter.value,
}),
})
}
break
}
}
}
break
case "string":
{
if (
!templateParameter.validation_regex ||
templateParameter.validation_regex.length === 0
) {
return true
}
const regex = new RegExp(templateParameter.validation_regex)
if (val && !regex.test(val)) {
return ctx.createError({
path: ctx.path,
message: t("validationPatternNotMatched", {
error: templateParameter.validation_error,
pattern: templateParameter.validation_regex,
}),
})
}
}
break
}
}
return true
}),
}),
)
.required()
}
@@ -18,7 +18,7 @@ import {
FormSection,
HorizontalForm,
FormFooter,
} from "components/HorizontalForm/HorizontalForm"
} from "components/Form/Form"
import { Stack } from "components/Stack/Stack"
import Checkbox from "@material-ui/core/Checkbox"
import { HelpTooltip, HelpTooltipText } from "components/Tooltips/HelpTooltip"
@@ -14,7 +14,7 @@ import {
FormSection,
HorizontalForm,
FormFooter,
} from "components/HorizontalForm/HorizontalForm"
} from "components/Form/Form"
import {
SensitiveVariableHelperText,
TemplateVariableField,
@@ -8,14 +8,12 @@ import { makeStyles } from "@material-ui/core/styles"
import { getFormHelpers } from "util/formUtils"
import { FormikContextType, FormikTouched, useFormik } from "formik"
import { RichParameterInput } from "components/RichParameterInput/RichParameterInput"
import {
ValidationSchemaForRichParameters,
workspaceBuildParameterValue,
} from "pages/CreateWorkspacePage/CreateWorkspacePageView"
import { workspaceBuildParameterValue } from "pages/CreateWorkspacePage/CreateWorkspacePageView"
import { FormFooter } from "components/FormFooter/FormFooter"
import * as Yup from "yup"
import { Maybe } from "components/Conditionals/Maybe"
import { GoBackButton } from "components/GoBackButton/GoBackButton"
import { useValidationSchemaForRichParameters } from "util/richParameters"
export enum UpdateWorkspaceErrors {
GET_WORKSPACE_ERROR = "getWorkspaceError",
@@ -61,7 +59,7 @@ export const WorkspaceBuildParametersPageView: FC<
rich_parameter_values: initialRichParameterValues,
},
validationSchema: Yup.object({
rich_parameter_values: ValidationSchemaForRichParameters(
rich_parameter_values: useValidationSchemaForRichParameters(
"workspaceBuildParametersPage",
props.templateParameters,
initialRichParameterValues,
@@ -0,0 +1,168 @@
import { makeStyles } from "@material-ui/core/styles"
import Dialog from "@material-ui/core/Dialog"
import DialogContent from "@material-ui/core/DialogContent"
import DialogContentText from "@material-ui/core/DialogContentText"
import DialogTitle from "@material-ui/core/DialogTitle"
import { DialogProps } from "components/Dialogs/Dialog"
import { FC } from "react"
import { getFormHelpers } from "util/formUtils"
import { FormFields, VerticalForm } from "components/Form/Form"
import {
TemplateVersionParameter,
WorkspaceBuildParameter,
} from "api/typesGenerated"
import { RichParameterInput } from "components/RichParameterInput/RichParameterInput"
import { useFormik } from "formik"
import {
selectInitialRichParametersValues,
useValidationSchemaForRichParameters,
} from "util/richParameters"
import * as Yup from "yup"
import DialogActions from "@material-ui/core/DialogActions"
import Button from "@material-ui/core/Button"
import { useTranslation } from "react-i18next"
export type UpdateBuildParametersDialogProps = DialogProps & {
onClose: () => void
onUpdate: (buildParameters: WorkspaceBuildParameter[]) => void
parameters?: TemplateVersionParameter[]
}
export const UpdateBuildParametersDialog: FC<
UpdateBuildParametersDialogProps
> = ({ parameters, onUpdate, ...dialogProps }) => {
const styles = useStyles()
const form = useFormik({
initialValues: {
rich_parameter_values: selectInitialRichParametersValues(parameters),
},
validationSchema: Yup.object({
rich_parameter_values: useValidationSchemaForRichParameters(
"createWorkspacePage",
parameters,
),
}),
onSubmit: (values) => {
onUpdate(values.rich_parameter_values)
},
})
const getFieldHelpers = getFormHelpers(form)
const { t } = useTranslation("workspacePage")
return (
<Dialog
{...dialogProps}
scroll="body"
aria-labelledby="update-build-parameters-title"
maxWidth="xs"
data-testid="dialog"
>
<DialogTitle
id="update-build-parameters-title"
classes={{ root: styles.title }}
>
Workspace parameters
</DialogTitle>
<DialogContent className={styles.content}>
<DialogContentText className={styles.info}>
{t("askParametersDialog.message")}
</DialogContentText>
<VerticalForm
className={styles.form}
onSubmit={form.handleSubmit}
id="updateParameters"
>
{parameters && parameters.filter((p) => p.mutable).length > 0 && (
<FormFields>
{parameters.map((parameter, index) => {
if (!parameter.mutable) {
return <></>
}
return (
<RichParameterInput
{...getFieldHelpers(
"rich_parameter_values[" + index + "].value",
)}
key={parameter.name}
parameter={parameter}
initialValue=""
index={index}
onChange={async (value) => {
await form.setFieldValue(
"rich_parameter_values." + index,
{
name: parameter.name,
value: value,
},
)
}}
/>
)
})}
</FormFields>
)}
</VerticalForm>
</DialogContent>
<DialogActions disableSpacing className={styles.dialogActions}>
<Button
fullWidth
type="button"
variant="outlined"
onClick={dialogProps.onClose}
>
Cancel
</Button>
<Button color="primary" fullWidth type="submit" form="updateParameters">
Update
</Button>
</DialogActions>
</Dialog>
)
}
const useStyles = makeStyles((theme) => ({
title: {
padding: theme.spacing(3, 5),
"& h2": {
fontSize: theme.spacing(2.5),
fontWeight: 400,
},
},
content: {
padding: theme.spacing(0, 5, 0, 5),
},
info: {
margin: 0,
},
form: {
paddingTop: theme.spacing(4),
},
infoTitle: {
fontSize: theme.spacing(2),
fontWeight: 600,
display: "flex",
alignItems: "center",
gap: theme.spacing(1),
},
warningIcon: {
color: theme.palette.warning.light,
fontSize: theme.spacing(1.5),
},
formFooter: {
flexDirection: "column",
},
dialogActions: {
padding: theme.spacing(5),
flexDirection: "column",
gap: theme.spacing(1),
},
}))
@@ -17,6 +17,8 @@ import {
MockStoppedWorkspace,
MockStoppingWorkspace,
MockTemplate,
MockTemplateVersionParameter1,
MockTemplateVersionParameter2,
MockWorkspace,
MockWorkspaceBuild,
renderWithAuth,
@@ -171,52 +173,79 @@ describe("WorkspacePage", () => {
expect(cancelWorkspaceMock).toBeCalled()
})
it("requests a template when the user presses Update", async () => {
const getTemplateMock = jest
.spyOn(api, "getTemplate")
.mockResolvedValueOnce(MockTemplate)
server.use(
rest.get(
`/api/v2/users/:userId/workspace/:workspaceName`,
(req, res, ctx) => {
return res(ctx.status(200), ctx.json(MockOutdatedWorkspace))
},
),
it("requests an update when the user presses Update", async () => {
jest
.spyOn(api, "getWorkspaceByOwnerAndName")
.mockResolvedValueOnce(MockOutdatedWorkspace)
const updateWorkspaceMock = jest
.spyOn(api, "updateWorkspace")
.mockResolvedValueOnce(MockWorkspaceBuild)
await testButton(
t("actionButton.update", { ns: "workspacePage" }),
updateWorkspaceMock,
)
await renderWorkspacePage()
const buttonText = t("actionButton.update", { ns: "workspacePage" })
const button = await screen.findByText(buttonText, { exact: true })
await userEvent.setup().click(button)
// getTemplate is called twice: once when the machine starts, and once after the user requests to update
expect(getTemplateMock).toBeCalledTimes(2)
})
it("after an update postWorkspaceBuild is called with the latest template active version id", async () => {
jest.spyOn(api, "getTemplate").mockResolvedValueOnce(MockTemplate) // active_version_id = "test-template-version"
jest.spyOn(api, "startWorkspace").mockResolvedValueOnce({
...MockWorkspaceBuild,
it("updates the parameters when they are missing during update", async () => {
// Setup mocks
const user = userEvent.setup()
jest
.spyOn(api, "getWorkspaceByOwnerAndName")
.mockResolvedValueOnce(MockOutdatedWorkspace)
const updateWorkspaceSpy = jest
.spyOn(api, "updateWorkspace")
.mockRejectedValueOnce(
new api.MissingBuildParameters([
MockTemplateVersionParameter1,
MockTemplateVersionParameter2,
]),
)
// Render page and wait for it to be loaded
renderWithAuth(<WorkspacePage />, {
route: `/@${MockWorkspace.owner_name}/${MockWorkspace.name}`,
path: "/@:username/:workspace",
})
server.use(
rest.get(
`/api/v2/users/:userId/workspace/:workspaceName`,
(req, res, ctx) => {
return res(ctx.status(200), ctx.json(MockOutdatedWorkspace))
await waitForLoaderToBeRemoved()
// Click on the update button
const workspaceActions = screen.getByTestId("workspace-actions")
await user.click(
within(workspaceActions).getByRole("button", { name: "Update" }),
)
await waitFor(() => {
expect(api.updateWorkspace).toBeCalled()
// We want to clear this mock to use it later
updateWorkspaceSpy.mockClear()
})
// Fill the parameters and send the form
const dialog = await screen.findByTestId("dialog")
const firstParameterInput = within(dialog).getByLabelText(
MockTemplateVersionParameter1.name,
{ exact: false },
)
await user.clear(firstParameterInput)
await user.type(firstParameterInput, "some-value")
const secondParameterInput = within(dialog).getByLabelText(
MockTemplateVersionParameter2.name,
{ exact: false },
)
await user.clear(secondParameterInput)
await user.type(secondParameterInput, "2")
await user.click(within(dialog).getByRole("button", { name: "Update" }))
// Check if the update was called using the values from the form
await waitFor(() => {
expect(api.updateWorkspace).toBeCalledWith(MockOutdatedWorkspace, [
{
name: MockTemplateVersionParameter1.name,
value: "some-value",
},
),
)
await renderWorkspacePage()
const buttonText = t("actionButton.update", { ns: "workspacePage" })
const button = await screen.findByText(buttonText, { exact: true })
await userEvent.setup().click(button)
await waitFor(() =>
expect(api.startWorkspace).toBeCalledWith(
"test-outdated-workspace",
"test-template-version",
),
)
{
name: MockTemplateVersionParameter2.name,
value: "2",
},
])
})
})
it("shows the Stopping status when the workspace is stopping", async () => {
@@ -225,48 +254,56 @@ describe("WorkspacePage", () => {
t("workspaceStatus.stopping", { ns: "common" }),
)
})
it("shows the Stopped status when the workspace is stopped", async () => {
await testStatus(
MockStoppedWorkspace,
t("workspaceStatus.stopped", { ns: "common" }),
)
})
it("shows the Building status when the workspace is starting", async () => {
await testStatus(
MockStartingWorkspace,
t("workspaceStatus.starting", { ns: "common" }),
)
})
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,
t("workspaceStatus.failed", { ns: "common" }),
)
})
it("shows the Canceling status when the workspace is canceling", async () => {
await testStatus(
MockCancelingWorkspace,
t("workspaceStatus.canceling", { ns: "common" }),
)
})
it("shows the Canceled status when the workspace is canceling", async () => {
await testStatus(
MockCanceledWorkspace,
t("workspaceStatus.canceled", { ns: "common" }),
)
})
it("shows the Deleting status when the workspace is deleting", async () => {
await testStatus(
MockDeletingWorkspace,
t("workspaceStatus.deleting", { ns: "common" }),
)
})
it("shows the Deleted status when the workspace is deleted", async () => {
await testStatus(
MockDeletedWorkspace,
@@ -274,17 +311,15 @@ describe("WorkspacePage", () => {
)
})
describe("Timeline", () => {
it("shows the timeline build", async () => {
await renderWorkspacePage()
const table = await screen.findByTestId("builds-table")
it("shows the timeline build", async () => {
await renderWorkspacePage()
const table = await screen.findByTestId("builds-table")
// Wait for the results to be loaded
await waitFor(async () => {
const rows = table.querySelectorAll("tbody > tr")
// Added +1 because of the date row
expect(rows).toHaveLength(MockBuilds.length + 1)
})
// Wait for the results to be loaded
await waitFor(async () => {
const rows = table.querySelectorAll("tbody > tr")
// Added +1 because of the date row
expect(rows).toHaveLength(MockBuilds.length + 1)
})
})
})
@@ -25,6 +25,7 @@ import {
WorkspaceEvent,
workspaceMachine,
} from "../../xServices/workspace/workspaceXService"
import { UpdateBuildParametersDialog } from "./UpdateBuildParametersDialog"
interface WorkspaceReadyPageProps {
workspaceState: StateFrom<typeof workspaceMachine>
@@ -52,6 +53,7 @@ export const WorkspaceReadyPage = ({
cancellationError,
applicationsHost,
permissions,
missingParameters,
} = workspaceState.context
if (workspace === undefined) {
throw Error("Workspace is undefined")
@@ -103,7 +105,7 @@ export const WorkspaceReadyPage = ({
deadline,
),
}}
isUpdating={workspaceState.hasTag("updating")}
isUpdating={workspaceState.matches("ready.build.requestingUpdate")}
workspace={workspace}
handleStart={() => workspaceSend({ type: "START" })}
handleStop={() => workspaceSend({ type: "STOP" })}
@@ -140,6 +142,18 @@ export const WorkspaceReadyPage = ({
workspaceSend({ type: "DELETE" })
}}
/>
<UpdateBuildParametersDialog
parameters={missingParameters}
open={workspaceState.matches(
"ready.build.askingForMissedBuildParameters",
)}
onClose={() => {
workspaceSend({ type: "CANCEL" })
}}
onUpdate={(buildParameters) => {
workspaceSend({ type: "UPDATE", buildParameters })
}}
/>
</>
)
}
+7
View File
@@ -311,4 +311,11 @@ export const handlers = [
rest.get("/api/v2/deployment/stats", (_, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockDeploymentStats))
}),
rest.get(
"/api/v2/workspacebuilds/:workspaceBuildId/parameters",
(_, res, ctx) => {
return res(ctx.status(200), ctx.json([M.MockWorkspaceBuildParameter1]))
},
),
]
+148
View File
@@ -0,0 +1,148 @@
import {
TemplateVersionParameter,
WorkspaceBuildParameter,
} from "api/typesGenerated"
import { useTranslation } from "react-i18next"
import * as Yup from "yup"
export const selectInitialRichParametersValues = (
templateParameters?: TemplateVersionParameter[],
defaultValuesFromQuery?: Record<string, string>,
): WorkspaceBuildParameter[] => {
const defaults: WorkspaceBuildParameter[] = []
if (!templateParameters) {
return defaults
}
templateParameters.forEach((parameter) => {
if (parameter.options.length > 0) {
let parameterValue = parameter.options[0].value
if (defaultValuesFromQuery && defaultValuesFromQuery[parameter.name]) {
parameterValue = defaultValuesFromQuery[parameter.name]
}
const buildParameter: WorkspaceBuildParameter = {
name: parameter.name,
value: parameterValue,
}
defaults.push(buildParameter)
return
}
let parameterValue = parameter.default_value
if (defaultValuesFromQuery && defaultValuesFromQuery[parameter.name]) {
parameterValue = defaultValuesFromQuery[parameter.name]
}
const buildParameter: WorkspaceBuildParameter = {
name: parameter.name,
value: parameterValue || "",
}
defaults.push(buildParameter)
})
return defaults
}
export const useValidationSchemaForRichParameters = (
ns: string,
templateParameters?: TemplateVersionParameter[],
lastBuildParameters?: WorkspaceBuildParameter[],
): Yup.AnySchema => {
const { t } = useTranslation(ns)
if (!templateParameters) {
return Yup.object()
}
return Yup.array()
.of(
Yup.object().shape({
name: Yup.string().required(),
value: Yup.string().test("verify with template", (val, ctx) => {
const name = ctx.parent.name
const templateParameter = templateParameters.find(
(parameter) => parameter.name === name,
)
if (templateParameter) {
switch (templateParameter.type) {
case "number":
if (
templateParameter.validation_min &&
templateParameter.validation_max
) {
if (
Number(val) < templateParameter.validation_min ||
templateParameter.validation_max < Number(val)
) {
return ctx.createError({
path: ctx.path,
message: t("validationNumberNotInRange", {
min: templateParameter.validation_min,
max: templateParameter.validation_max,
}),
})
}
}
if (
templateParameter.validation_monotonic &&
lastBuildParameters
) {
const lastBuildParameter = lastBuildParameters.find(
(last) => last.name === name,
)
if (lastBuildParameter) {
switch (templateParameter.validation_monotonic) {
case "increasing":
if (Number(lastBuildParameter.value) > Number(val)) {
return ctx.createError({
path: ctx.path,
message: t("validationNumberNotIncreasing", {
last: lastBuildParameter.value,
}),
})
}
break
case "decreasing":
if (Number(lastBuildParameter.value) < Number(val)) {
return ctx.createError({
path: ctx.path,
message: t("validationNumberNotDecreasing", {
last: lastBuildParameter.value,
}),
})
}
break
}
}
}
break
case "string":
{
if (
!templateParameter.validation_regex ||
templateParameter.validation_regex.length === 0
) {
return true
}
const regex = new RegExp(templateParameter.validation_regex)
if (val && !regex.test(val)) {
return ctx.createError({
path: ctx.path,
message: t("validationPatternNotMatched", {
error: templateParameter.validation_error,
pattern: templateParameter.validation_regex,
}),
})
}
}
break
}
}
return true
}),
}),
)
.required()
}
@@ -63,6 +63,7 @@ export interface WorkspaceContext {
// Builds
builds?: TypesGen.WorkspaceBuild[]
getBuildsError?: Error | unknown
missingParameters?: TypesGen.TemplateVersionParameter[]
// error creating a new WorkspaceBuild
buildError?: Error | unknown
cancellationMessage?: Types.Message
@@ -82,7 +83,7 @@ export type WorkspaceEvent =
| { type: "ASK_DELETE" }
| { type: "DELETE" }
| { type: "CANCEL_DELETE" }
| { type: "UPDATE" }
| { type: "UPDATE"; buildParameters?: TypesGen.WorkspaceBuildParameter[] }
| { type: "CANCEL" }
| {
type: "REFRESH_TIMELINE"
@@ -135,7 +136,7 @@ export const workspaceMachine = createMachine(
getTemplateParameters: {
data: TypesGen.TemplateVersionParameter[]
}
startWorkspaceWithLatestTemplate: {
updateWorkspace: {
data: TypesGen.WorkspaceBuild
}
startWorkspace: {
@@ -301,7 +302,7 @@ export const workspaceMachine = createMachine(
START: "requestingStart",
STOP: "requestingStop",
ASK_DELETE: "askingDelete",
UPDATE: "updatingWorkspace",
UPDATE: "requestingUpdate",
CANCEL: "requestingCancel",
},
},
@@ -315,38 +316,31 @@ export const workspaceMachine = createMachine(
},
},
},
updatingWorkspace: {
tags: "updating",
initial: "refreshingTemplate",
states: {
refreshingTemplate: {
invoke: {
id: "refreshTemplate",
src: "getTemplate",
onDone: {
target: "startingWithLatestTemplate",
actions: ["assignTemplate"],
},
onError: {
target: "#workspaceState.ready.build.idle",
actions: ["assignGetTemplateWarning"],
},
},
requestingUpdate: {
entry: ["clearBuildError"],
invoke: {
src: "updateWorkspace",
onDone: {
target: "idle",
actions: ["assignBuild"],
},
startingWithLatestTemplate: {
invoke: {
id: "startWorkspaceWithLatestTemplate",
src: "startWorkspaceWithLatestTemplate",
onDone: {
target: "#workspaceState.ready.build.idle",
actions: ["assignBuild"],
},
onError: {
target: "#workspaceState.ready.build.idle",
actions: ["assignBuildError"],
},
onError: [
{
target: "askingForMissedBuildParameters",
cond: "isMissingBuildParameterError",
actions: ["assignMissingParameters"],
},
},
{
target: "idle",
actions: ["assignBuildError"],
},
],
},
},
askingForMissedBuildParameters: {
on: {
CANCEL: "idle",
UPDATE: "requestingUpdate",
},
},
requestingStart: {
@@ -641,9 +635,20 @@ export const workspaceMachine = createMachine(
}
},
}),
assignMissingParameters: assign({
missingParameters: (_, { data }) => {
if (!(data instanceof API.MissingBuildParameters)) {
throw new Error("data is not a MissingBuildParameters error")
}
return data.parameters
},
}),
},
guards: {
moreBuildsAvailable,
isMissingBuildParameterError: (_, { data }) => {
return data instanceof API.MissingBuildParameters
},
},
services: {
getWorkspace: async (_, event) => {
@@ -671,18 +676,16 @@ export const workspaceMachine = createMachine(
throw Error("Cannot get template parameters without workspace")
}
},
startWorkspaceWithLatestTemplate: (context) => async (send) => {
if (context.workspace && context.template) {
const startWorkspacePromise = await API.startWorkspace(
context.workspace.id,
context.template.active_version_id,
)
updateWorkspace:
({ workspace }, { buildParameters }) =>
async (send) => {
if (!workspace) {
throw new Error("Workspace is not set")
}
const build = await API.updateWorkspace(workspace, buildParameters)
send({ type: "REFRESH_TIMELINE" })
return startWorkspacePromise
} else {
throw Error("Cannot start workspace without workspace id")
}
},
return build
},
startWorkspace: (context) => async (send) => {
if (context.workspace) {
const startWorkspacePromise = await API.startWorkspace(