diff --git a/site/src/api/api.test.ts b/site/src/api/api.test.ts index c50bfd3c846ad..3767a9ef81dd5 100644 --- a/site/src/api/api.test.ts +++ b/site/src/api/api.test.ts @@ -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, + ) + }) + }) }) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 3225311c31d20..b28d9e0379c12 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -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 => { + 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 +} diff --git a/site/src/components/HorizontalForm/HorizontalForm.tsx b/site/src/components/Form/Form.tsx similarity index 52% rename from site/src/components/HorizontalForm/HorizontalForm.tsx rename to site/src/components/Form/Form.tsx index 50ea8657b288b..1c7e06c25513e 100644 --- a/site/src/components/HorizontalForm/HorizontalForm.tsx +++ b/site/src/components/Form/Form.tsx @@ -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 -> = ({ children, ...formProps }) => { +type FormContextValue = { direction?: "horizontal" | "vertical" } + +const FormContext = createContext({ + direction: "horizontal", +}) + +type FormProps = HTMLProps & { + direction?: FormContextValue["direction"] +} + +export const Form: FC = ({ direction, className, ...formProps }) => { const styles = useStyles() return ( -
- - {children} - -
+ +
+ + ) +} + +export const HorizontalForm: FC> = ({ + children, + ...formProps +}) => { + return ( + + {children} + + ) +} + +export const VerticalForm: FC> = ({ + children, + ...formProps +}) => { + return ( +
+ {children} +
) } 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 ( -
+
-

{title}

+

+ {title} +

{description}
@@ -62,7 +110,12 @@ export const FormFooter: FC = (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")]: { diff --git a/site/src/i18n/en/workspacePage.json b/site/src/i18n/en/workspacePage.json index 114ff1fcf831e..02d40b8dff42b 100644 --- a/site/src/i18n/en/workspacePage.json +++ b/site/src/i18n/en/workspacePage.json @@ -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." } } diff --git a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx index f1af33a9d38cf..b1a0384240aa3 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx @@ -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" diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx index 638b4e8b3d6a9..f3e6766a48e46 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx @@ -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 && ( Those values are also parameters provided from your Terraform @@ -398,44 +402,6 @@ const useStyles = makeStyles((theme) => ({ }, })) -const selectInitialRichParametersValues = ( - templateParameters?: TypesGen.TemplateVersionParameter[], - defaultValuesFromQuery?: Record, -): 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() -} diff --git a/site/src/pages/TemplateSettingsPage/TemplateSettingsForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateSettingsForm.tsx index bc115967672a1..475a99d148344 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSettingsForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSettingsForm.tsx @@ -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" diff --git a/site/src/pages/TemplateVariablesPage/TemplateVariablesForm.tsx b/site/src/pages/TemplateVariablesPage/TemplateVariablesForm.tsx index cbea09ee748f5..3186bb633f794 100644 --- a/site/src/pages/TemplateVariablesPage/TemplateVariablesForm.tsx +++ b/site/src/pages/TemplateVariablesPage/TemplateVariablesForm.tsx @@ -14,7 +14,7 @@ import { FormSection, HorizontalForm, FormFooter, -} from "components/HorizontalForm/HorizontalForm" +} from "components/Form/Form" import { SensitiveVariableHelperText, TemplateVariableField, diff --git a/site/src/pages/WorkspaceBuildParametersPage/WorkspaceBuildParametersPageView.tsx b/site/src/pages/WorkspaceBuildParametersPage/WorkspaceBuildParametersPageView.tsx index fe896064aa1f2..8aeaaf52efea3 100644 --- a/site/src/pages/WorkspaceBuildParametersPage/WorkspaceBuildParametersPageView.tsx +++ b/site/src/pages/WorkspaceBuildParametersPage/WorkspaceBuildParametersPageView.tsx @@ -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, diff --git a/site/src/pages/WorkspacePage/UpdateBuildParametersDialog.tsx b/site/src/pages/WorkspacePage/UpdateBuildParametersDialog.tsx new file mode 100644 index 0000000000000..bfb9d0fbfd5e6 --- /dev/null +++ b/site/src/pages/WorkspacePage/UpdateBuildParametersDialog.tsx @@ -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 ( + + + Workspace parameters + + + + {t("askParametersDialog.message")} + + + {parameters && parameters.filter((p) => p.mutable).length > 0 && ( + + {parameters.map((parameter, index) => { + if (!parameter.mutable) { + return <> + } + + return ( + { + await form.setFieldValue( + "rich_parameter_values." + index, + { + name: parameter.name, + value: value, + }, + ) + }} + /> + ) + })} + + )} + + + + + + + + ) +} + +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), + }, +})) diff --git a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx index 7964c92a445e5..dbb2408be7cd6 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx @@ -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)) - }, - ), - ) - await renderWorkspacePage() - const buttonText = t("actionButton.update", { ns: "workspacePage" }) - const button = await screen.findByText(buttonText, { exact: true }) - await userEvent.setup().click(button) + it("requests an update when the user presses Update", async () => { + jest + .spyOn(api, "getWorkspaceByOwnerAndName") + .mockResolvedValueOnce(MockOutdatedWorkspace) + const updateWorkspaceMock = jest + .spyOn(api, "updateWorkspace") + .mockResolvedValueOnce(MockWorkspaceBuild) - // getTemplate is called twice: once when the machine starts, and once after the user requests to update - expect(getTemplateMock).toBeCalledTimes(2) + await testButton( + t("actionButton.update", { ns: "workspacePage" }), + updateWorkspaceMock, + ) }) - 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, - }) - server.use( - rest.get( - `/api/v2/users/:userId/workspace/:workspaceName`, - (req, res, ctx) => { - return res(ctx.status(200), ctx.json(MockOutdatedWorkspace)) - }, - ), + 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(, { + route: `/@${MockWorkspace.owner_name}/${MockWorkspace.name}`, + path: "/@:username/:workspace", + }) + await waitForLoaderToBeRemoved() + // Click on the update button + const workspaceActions = screen.getByTestId("workspace-actions") + await user.click( + within(workspaceActions).getByRole("button", { name: "Update" }), ) - 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", - ), + 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", + }, + { + 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") - - // 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) - }) + 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) }) }) }) diff --git a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx index b413b5be8cf6a..0174745194daf 100644 --- a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx @@ -25,6 +25,7 @@ import { WorkspaceEvent, workspaceMachine, } from "../../xServices/workspace/workspaceXService" +import { UpdateBuildParametersDialog } from "./UpdateBuildParametersDialog" interface WorkspaceReadyPageProps { workspaceState: StateFrom @@ -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" }) }} /> + { + workspaceSend({ type: "CANCEL" }) + }} + onUpdate={(buildParameters) => { + workspaceSend({ type: "UPDATE", buildParameters }) + }} + /> ) } diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index 4bf9a07d41a17..ff18cbf6a6f2c 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -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])) + }, + ), ] diff --git a/site/src/util/richParameters.ts b/site/src/util/richParameters.ts new file mode 100644 index 0000000000000..7bb036ebac337 --- /dev/null +++ b/site/src/util/richParameters.ts @@ -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, +): 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() +} diff --git a/site/src/xServices/workspace/workspaceXService.ts b/site/src/xServices/workspace/workspaceXService.ts index d22763a73a817..cdd7089076d17 100644 --- a/site/src/xServices/workspace/workspaceXService.ts +++ b/site/src/xServices/workspace/workspaceXService.ts @@ -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(