From 3085418024421f40718dfd9d7fc83ad04b435a09 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Wed, 8 Mar 2023 16:35:00 +0000 Subject: [PATCH 1/8] wip --- site/src/api/api.ts | 80 +++++++++++++++++++ .../xServices/workspace/workspaceXService.ts | 74 +++++++---------- 2 files changed, 111 insertions(+), 43 deletions(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 76c0cefc32676..e8d2dd7c05a32 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -891,3 +891,83 @@ 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/xServices/workspace/workspaceXService.ts b/site/src/xServices/workspace/workspaceXService.ts index 588d6e9d266fe..d09850b951013 100644 --- a/site/src/xServices/workspace/workspaceXService.ts +++ b/site/src/xServices/workspace/workspaceXService.ts @@ -137,7 +137,7 @@ export const workspaceMachine = createMachine( getTemplateParameters: { data: TypesGen.TemplateVersionParameter[] } - startWorkspaceWithLatestTemplate: { + updateWorkspace: { data: TypesGen.WorkspaceBuild } startWorkspace: { @@ -306,7 +306,7 @@ export const workspaceMachine = createMachine( START: "requestingStart", STOP: "requestingStop", ASK_DELETE: "askingDelete", - UPDATE: "updatingWorkspace", + UPDATE: "requestingUpdate", CANCEL: "requestingCancel", }, }, @@ -320,40 +320,27 @@ 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", "updateStatusToPending"], + 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", }, - }, + { + target: "idle", + actions: ["assignBuildError"], + }, + ], }, }, + askingForMissedBuildParameters: {}, requestingStart: { entry: ["clearBuildError", "updateStatusToPending"], invoke: { @@ -652,6 +639,9 @@ export const workspaceMachine = createMachine( }, guards: { moreBuildsAvailable, + isMissingBuildParameterError: (_, { data }) => { + return data instanceof API.MissingBuildParameters + }, }, services: { getWorkspace: async (_, event) => { @@ -679,18 +669,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 }) => + async (send) => { + if (!workspace) { + throw new Error("Workspace is not set") + } + const build = await API.updateWorkspace(workspace) 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( From 0998ae3bd6d16e14a04c8385dd1e205c4826238d Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Wed, 8 Mar 2023 19:20:36 +0000 Subject: [PATCH 2/8] Bind dialog --- .../UpdateBuildParametersDialog.tsx | 36 +++++++++++++++++++ .../WorkspacePage/WorkspaceReadyPage.tsx | 8 ++++- .../xServices/workspace/workspaceXService.ts | 10 ++++++ 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 site/src/pages/WorkspacePage/UpdateBuildParametersDialog.tsx diff --git a/site/src/pages/WorkspacePage/UpdateBuildParametersDialog.tsx b/site/src/pages/WorkspacePage/UpdateBuildParametersDialog.tsx new file mode 100644 index 0000000000000..723cb4e680f3e --- /dev/null +++ b/site/src/pages/WorkspacePage/UpdateBuildParametersDialog.tsx @@ -0,0 +1,36 @@ +import Button from "@material-ui/core/Button" +import Dialog from "@material-ui/core/Dialog" +import DialogActions from "@material-ui/core/DialogActions" +import DialogContent from "@material-ui/core/DialogContent" +import DialogContentText from "@material-ui/core/DialogContentText" +import DialogTitle from "@material-ui/core/DialogTitle" +import TextField from "@material-ui/core/TextField" +import { DialogProps } from "components/Dialogs/Dialog" +import { FC } from "react" + +export const UpdateBuildParametersDialog: FC = (props) => { + return ( + + Subscribe + + + To subscribe to this website, please enter your email address here. We + will send updates occasionally. + + + + + + + + ) +} diff --git a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx index d346b48caa3b9..83a63212488b2 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 @@ -104,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" })} @@ -142,6 +143,11 @@ export const WorkspaceReadyPage = ({ workspaceSend({ type: "DELETE" }) }} /> + ) } diff --git a/site/src/xServices/workspace/workspaceXService.ts b/site/src/xServices/workspace/workspaceXService.ts index d09850b951013..c89e79b710db0 100644 --- a/site/src/xServices/workspace/workspaceXService.ts +++ b/site/src/xServices/workspace/workspaceXService.ts @@ -65,6 +65,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 @@ -332,6 +333,7 @@ export const workspaceMachine = createMachine( { target: "askingForMissedBuildParameters", cond: "isMissingBuildParameterError", + actions: ["assignMissingParameters"], }, { target: "idle", @@ -636,6 +638,14 @@ 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, From ba975a9e0a0257792831a5f92e76ab4291c0d3d7 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Thu, 9 Mar 2023 14:14:54 +0000 Subject: [PATCH 3/8] Add basic flow for missing parameters on update --- .../CreateWorkspacePageView.tsx | 146 +---------------- .../WorkspaceBuildParametersPageView.tsx | 6 +- .../UpdateBuildParametersDialog.tsx | 132 +++++++++++++--- .../WorkspacePage/WorkspaceReadyPage.tsx | 8 + site/src/util/richParameters.ts | 148 ++++++++++++++++++ .../xServices/workspace/workspaceXService.ts | 15 +- 6 files changed, 281 insertions(+), 174 deletions(-) create mode 100644 site/src/util/richParameters.ts diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx index 638b4e8b3d6a9..aefcc6648fc42 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx @@ -21,6 +21,10 @@ import { HorizontalForm, } from "components/HorizontalForm/HorizontalForm" import { makeStyles } from "@material-ui/core/styles" +import { + selectInitialRichParametersValues, + ValidationSchemaForRichParameters, +} from "util/richParameters" export enum CreateWorkspaceErrors { GET_TEMPLATES_ERROR = "getTemplatesError", @@ -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/WorkspaceBuildParametersPage/WorkspaceBuildParametersPageView.tsx b/site/src/pages/WorkspaceBuildParametersPage/WorkspaceBuildParametersPageView.tsx index fe896064aa1f2..8a6dd68217de7 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 { ValidationSchemaForRichParameters } from "util/richParameters" export enum UpdateWorkspaceErrors { GET_WORKSPACE_ERROR = "getWorkspaceError", diff --git a/site/src/pages/WorkspacePage/UpdateBuildParametersDialog.tsx b/site/src/pages/WorkspacePage/UpdateBuildParametersDialog.tsx index 723cb4e680f3e..925ae9e9461b9 100644 --- a/site/src/pages/WorkspacePage/UpdateBuildParametersDialog.tsx +++ b/site/src/pages/WorkspacePage/UpdateBuildParametersDialog.tsx @@ -1,36 +1,122 @@ -import Button from "@material-ui/core/Button" +import { makeStyles } from "@material-ui/core/styles" import Dialog from "@material-ui/core/Dialog" -import DialogActions from "@material-ui/core/DialogActions" import DialogContent from "@material-ui/core/DialogContent" import DialogContentText from "@material-ui/core/DialogContentText" import DialogTitle from "@material-ui/core/DialogTitle" -import TextField from "@material-ui/core/TextField" import { DialogProps } from "components/Dialogs/Dialog" import { FC } from "react" +import { getFormHelpers } from "util/formUtils" +import { + FormFields, + FormFooter, +} from "components/HorizontalForm/HorizontalForm" +import { + TemplateVersionParameter, + WorkspaceBuildParameter, +} from "api/typesGenerated" +import { RichParameterInput } from "components/RichParameterInput/RichParameterInput" +import { Stack } from "components/Stack/Stack" +import { useFormik } from "formik" +import { + selectInitialRichParametersValues, + ValidationSchemaForRichParameters, +} from "util/richParameters" +import * as Yup from "yup" + +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: ValidationSchemaForRichParameters( + "createWorkspacePage", + parameters, + ), + }), + onSubmit: (values) => { + onUpdate(values.rich_parameter_values) + }, + }) + const getFieldHelpers = getFormHelpers(form) -export const UpdateBuildParametersDialog: FC = (props) => { return ( - - Subscribe - - - To subscribe to this website, please enter your email address here. We - will send updates occasionally. + + + Missing workspace parameters + + + + It looks like the new version has some mandatory parameters that need + to be filled in to update the workspace. - +
+ + + {parameters && + parameters.map((parameter, index) => { + return ( + { + await form.setFieldValue( + "rich_parameter_values." + index, + { + name: parameter.name, + value: value, + }, + ) + }} + /> + ) + })} + + + +
- - -
) } + +const useStyles = makeStyles((theme) => ({ + title: { + padding: theme.spacing(5, 5, 2, 5), + + "& h2": { + fontSize: theme.spacing(2.5), + fontWeight: 400, + }, + }, + + content: { + padding: theme.spacing(0, 5, 5, 5), + }, + + contentText: { + fontSize: theme.spacing(2), + lineHeight: "160%", + }, + + form: { + marginTop: theme.spacing(4), + }, +})) diff --git a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx index 83a63212488b2..9f7eec1b9e5d6 100644 --- a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx @@ -54,6 +54,7 @@ export const WorkspaceReadyPage = ({ cancellationError, applicationsHost, permissions, + missingParameters, } = workspaceState.context if (workspace === undefined) { throw Error("Workspace is undefined") @@ -144,9 +145,16 @@ export const WorkspaceReadyPage = ({ }} /> { + workspaceSend({ type: "CANCEL" }) + }} + onUpdate={(buildParameters) => { + workspaceSend({ type: "UPDATE", buildParameters }) + }} /> ) diff --git a/site/src/util/richParameters.ts b/site/src/util/richParameters.ts new file mode 100644 index 0000000000000..81b288dacf143 --- /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 ValidationSchemaForRichParameters = ( + 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 c89e79b710db0..e6636041364db 100644 --- a/site/src/xServices/workspace/workspaceXService.ts +++ b/site/src/xServices/workspace/workspaceXService.ts @@ -85,7 +85,7 @@ export type WorkspaceEvent = | { type: "ASK_DELETE" } | { type: "DELETE" } | { type: "CANCEL_DELETE" } - | { type: "UPDATE" } + | { type: "UPDATE"; buildParameters?: TypesGen.WorkspaceBuildParameter[] } | { type: "CANCEL" } | { type: "REFRESH_TIMELINE" @@ -322,7 +322,7 @@ export const workspaceMachine = createMachine( }, }, requestingUpdate: { - entry: ["clearBuildError", "updateStatusToPending"], + entry: ["clearBuildError"], invoke: { src: "updateWorkspace", onDone: { @@ -342,7 +342,12 @@ export const workspaceMachine = createMachine( ], }, }, - askingForMissedBuildParameters: {}, + askingForMissedBuildParameters: { + on: { + CANCEL: "idle", + UPDATE: "requestingUpdate", + }, + }, requestingStart: { entry: ["clearBuildError", "updateStatusToPending"], invoke: { @@ -680,12 +685,12 @@ export const workspaceMachine = createMachine( } }, updateWorkspace: - ({ workspace }) => + ({ workspace }, { buildParameters }) => async (send) => { if (!workspace) { throw new Error("Workspace is not set") } - const build = await API.updateWorkspace(workspace) + const build = await API.updateWorkspace(workspace, buildParameters) send({ type: "REFRESH_TIMELINE" }) return build }, From 022e400026e4189c5c43678605acc98ddbe36077 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Thu, 9 Mar 2023 17:47:06 +0000 Subject: [PATCH 4/8] Finish design --- .../HorizontalForm/HorizontalForm.tsx | 96 ++++++++++--- .../CreateWorkspacePageView.tsx | 2 +- .../UpdateBuildParametersDialog.tsx | 134 +++++++++++++++--- 3 files changed, 194 insertions(+), 38 deletions(-) diff --git a/site/src/components/HorizontalForm/HorizontalForm.tsx b/site/src/components/HorizontalForm/HorizontalForm.tsx index 50ea8657b288b..1c7e06c25513e 100644 --- a/site/src/components/HorizontalForm/HorizontalForm.tsx +++ b/site/src/components/HorizontalForm/HorizontalForm.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/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx index aefcc6648fc42..ed18e8f3ca138 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx @@ -338,7 +338,7 @@ export const CreateWorkspacePageView: FC< props.templateParameters.filter((p) => !p.mutable).length > 0 && ( Those values are also parameters provided from your Terraform diff --git a/site/src/pages/WorkspacePage/UpdateBuildParametersDialog.tsx b/site/src/pages/WorkspacePage/UpdateBuildParametersDialog.tsx index 925ae9e9461b9..fab76715a213e 100644 --- a/site/src/pages/WorkspacePage/UpdateBuildParametersDialog.tsx +++ b/site/src/pages/WorkspacePage/UpdateBuildParametersDialog.tsx @@ -8,20 +8,23 @@ import { FC } from "react" import { getFormHelpers } from "util/formUtils" import { FormFields, - FormFooter, + FormSection, + VerticalForm, } from "components/HorizontalForm/HorizontalForm" import { TemplateVersionParameter, WorkspaceBuildParameter, } from "api/typesGenerated" import { RichParameterInput } from "components/RichParameterInput/RichParameterInput" -import { Stack } from "components/Stack/Stack" import { useFormik } from "formik" import { selectInitialRichParametersValues, ValidationSchemaForRichParameters, } from "util/richParameters" import * as Yup from "yup" +import DialogActions from "@material-ui/core/DialogActions" +import Button from "@material-ui/core/Button" +import InfoOutlined from "@material-ui/icons/InfoOutlined" export type UpdateBuildParametersDialogProps = DialogProps & { onClose: () => void @@ -50,23 +53,75 @@ export const UpdateBuildParametersDialog: FC< const getFieldHelpers = getFormHelpers(form) return ( - + - Missing workspace parameters + Workspace parameters - + It looks like the new version has some mandatory parameters that need to be filled in to update the workspace. -
- + + {parameters && parameters.filter((p) => p.mutable).length > 0 && ( - {parameters && - parameters.map((parameter, index) => { + {parameters.map((parameter, index) => { + if (!parameter.mutable) { + return <> + } + + return ( + { + await form.setFieldValue( + "rich_parameter_values." + index, + { + name: parameter.name, + value: value, + }, + ) + }} + /> + ) + })} + + )} + {parameters && parameters.filter((p) => !p.mutable).length > 0 && ( + + + Immutable parameters + + } + classes={{ infoTitle: styles.infoTitle }} + description="These parameters values are immutable and cannot be changed after the update." + > + + {parameters.map((parameter, index) => { + if (parameter.mutable) { + return <> + } + return ( ) })} - - - -
+ +
+ )} + + + + +
) } const useStyles = makeStyles((theme) => ({ title: { - padding: theme.spacing(5, 5, 2, 5), + padding: theme.spacing(3, 5), "& h2": { fontSize: theme.spacing(2.5), @@ -108,15 +176,45 @@ const useStyles = makeStyles((theme) => ({ }, content: { - padding: theme.spacing(0, 5, 5, 5), + padding: theme.spacing(0, 5, 0, 5), }, - contentText: { - fontSize: theme.spacing(2), + info: { + fontSize: theme.spacing(1.75), lineHeight: "160%", + backgroundColor: theme.palette.info.dark, + color: theme.palette.text.primary, + border: `1px solid ${theme.palette.info.light}`, + borderRight: 0, + borderLeft: 0, + padding: theme.spacing(3, 5), + margin: theme.spacing(0, -5), }, form: { - marginTop: theme.spacing(4), + paddingTop: theme.spacing(5), + }, + + 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), }, })) From 5ff98ce568c9911470da1f1a9eafdf5eb760b4fe Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Mon, 13 Mar 2023 17:17:06 +0000 Subject: [PATCH 5/8] Do not handle immutable parameters --- .../UpdateBuildParametersDialog.tsx | 43 ------------------- 1 file changed, 43 deletions(-) diff --git a/site/src/pages/WorkspacePage/UpdateBuildParametersDialog.tsx b/site/src/pages/WorkspacePage/UpdateBuildParametersDialog.tsx index fab76715a213e..d8639b36d9b6b 100644 --- a/site/src/pages/WorkspacePage/UpdateBuildParametersDialog.tsx +++ b/site/src/pages/WorkspacePage/UpdateBuildParametersDialog.tsx @@ -8,7 +8,6 @@ import { FC } from "react" import { getFormHelpers } from "util/formUtils" import { FormFields, - FormSection, VerticalForm, } from "components/HorizontalForm/HorizontalForm" import { @@ -24,7 +23,6 @@ import { import * as Yup from "yup" import DialogActions from "@material-ui/core/DialogActions" import Button from "@material-ui/core/Button" -import InfoOutlined from "@material-ui/icons/InfoOutlined" export type UpdateBuildParametersDialogProps = DialogProps & { onClose: () => void @@ -105,47 +103,6 @@ export const UpdateBuildParametersDialog: FC< })} )} - {parameters && parameters.filter((p) => !p.mutable).length > 0 && ( - - - Immutable parameters - - } - classes={{ infoTitle: styles.infoTitle }} - description="These parameters values are immutable and cannot be changed after the update." - > - - {parameters.map((parameter, index) => { - if (parameter.mutable) { - return <> - } - - return ( - { - await form.setFieldValue( - "rich_parameter_values." + index, - { - name: parameter.name, - value: value, - }, - ) - }} - /> - ) - })} - - - )} From 60bf64fb59b586c9a187bd3bd9f3f2b1a15a4d94 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Mon, 13 Mar 2023 17:52:47 +0000 Subject: [PATCH 6/8] Update a few things --- .../HorizontalForm.tsx => Form/Form.tsx} | 0 .../CreateTemplatePage/CreateTemplateForm.tsx | 4 +- .../CreateWorkspacePageView.tsx | 2 +- .../TemplateSettingsForm.tsx | 2 +- .../TemplateVariablesForm.tsx | 2 +- .../UpdateBuildParametersDialog.tsx | 18 ++---- .../WorkspacePage/WorkspacePage.test.tsx | 62 +++++++++++++++++++ site/src/testHelpers/handlers.ts | 7 +++ 8 files changed, 78 insertions(+), 19 deletions(-) rename site/src/components/{HorizontalForm/HorizontalForm.tsx => Form/Form.tsx} (100%) diff --git a/site/src/components/HorizontalForm/HorizontalForm.tsx b/site/src/components/Form/Form.tsx similarity index 100% rename from site/src/components/HorizontalForm/HorizontalForm.tsx rename to site/src/components/Form/Form.tsx 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 ed18e8f3ca138..d11aeeb2a56a1 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx @@ -19,7 +19,7 @@ import { FormSection, FormFooter, HorizontalForm, -} from "components/HorizontalForm/HorizontalForm" +} from "components/Form/Form" import { makeStyles } from "@material-ui/core/styles" import { selectInitialRichParametersValues, 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/WorkspacePage/UpdateBuildParametersDialog.tsx b/site/src/pages/WorkspacePage/UpdateBuildParametersDialog.tsx index d8639b36d9b6b..93546e4829a90 100644 --- a/site/src/pages/WorkspacePage/UpdateBuildParametersDialog.tsx +++ b/site/src/pages/WorkspacePage/UpdateBuildParametersDialog.tsx @@ -6,10 +6,7 @@ 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/HorizontalForm/HorizontalForm" +import { FormFields, VerticalForm } from "components/Form/Form" import { TemplateVersionParameter, WorkspaceBuildParameter, @@ -56,6 +53,7 @@ export const UpdateBuildParametersDialog: FC< scroll="body" aria-labelledby="update-build-parameters-title" maxWidth="xs" + data-testid="dialog" > ({ }, info: { - fontSize: theme.spacing(1.75), - lineHeight: "160%", - backgroundColor: theme.palette.info.dark, - color: theme.palette.text.primary, - border: `1px solid ${theme.palette.info.light}`, - borderRight: 0, - borderLeft: 0, - padding: theme.spacing(3, 5), - margin: theme.spacing(0, -5), + margin: 0, }, form: { - paddingTop: theme.spacing(5), + paddingTop: theme.spacing(4), }, infoTitle: { diff --git a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx index 7964c92a445e5..3eabc3ca03abb 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, @@ -287,4 +289,64 @@ describe("WorkspacePage", () => { }) }) }) + + it("Workspace update when having new parameters on the template version", 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 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", + }, + ]) + }) + }) }) diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index 4bf9a07d41a17..ec3e7af9f4497 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.MockTemplateVersionParameter1])) + }, + ), ] From 5e5ea5fd63cf3834ad5ca6c97e92a92eb0105571 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Tue, 14 Mar 2023 14:06:42 +0000 Subject: [PATCH 7/8] Assign PR comments --- site/src/i18n/en/workspacePage.json | 3 +++ .../CreateWorkspacePage/CreateWorkspacePageView.tsx | 4 ++-- .../WorkspaceBuildParametersPageView.tsx | 4 ++-- .../pages/WorkspacePage/UpdateBuildParametersDialog.tsx | 9 +++++---- site/src/testHelpers/handlers.ts | 2 +- site/src/util/richParameters.ts | 2 +- 6 files changed, 14 insertions(+), 10 deletions(-) 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/CreateWorkspacePage/CreateWorkspacePageView.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx index d11aeeb2a56a1..f3e6766a48e46 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx @@ -23,7 +23,7 @@ import { import { makeStyles } from "@material-ui/core/styles" import { selectInitialRichParametersValues, - ValidationSchemaForRichParameters, + useValidationSchemaForRichParameters, } from "util/richParameters" export enum CreateWorkspaceErrors { @@ -86,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, ), diff --git a/site/src/pages/WorkspaceBuildParametersPage/WorkspaceBuildParametersPageView.tsx b/site/src/pages/WorkspaceBuildParametersPage/WorkspaceBuildParametersPageView.tsx index 8a6dd68217de7..8aeaaf52efea3 100644 --- a/site/src/pages/WorkspaceBuildParametersPage/WorkspaceBuildParametersPageView.tsx +++ b/site/src/pages/WorkspaceBuildParametersPage/WorkspaceBuildParametersPageView.tsx @@ -13,7 +13,7 @@ import { FormFooter } from "components/FormFooter/FormFooter" import * as Yup from "yup" import { Maybe } from "components/Conditionals/Maybe" import { GoBackButton } from "components/GoBackButton/GoBackButton" -import { ValidationSchemaForRichParameters } from "util/richParameters" +import { useValidationSchemaForRichParameters } from "util/richParameters" export enum UpdateWorkspaceErrors { GET_WORKSPACE_ERROR = "getWorkspaceError", @@ -59,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 index 93546e4829a90..bfb9d0fbfd5e6 100644 --- a/site/src/pages/WorkspacePage/UpdateBuildParametersDialog.tsx +++ b/site/src/pages/WorkspacePage/UpdateBuildParametersDialog.tsx @@ -15,11 +15,12 @@ import { RichParameterInput } from "components/RichParameterInput/RichParameterI import { useFormik } from "formik" import { selectInitialRichParametersValues, - ValidationSchemaForRichParameters, + 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 @@ -36,7 +37,7 @@ export const UpdateBuildParametersDialog: FC< rich_parameter_values: selectInitialRichParametersValues(parameters), }, validationSchema: Yup.object({ - rich_parameter_values: ValidationSchemaForRichParameters( + rich_parameter_values: useValidationSchemaForRichParameters( "createWorkspacePage", parameters, ), @@ -46,6 +47,7 @@ export const UpdateBuildParametersDialog: FC< }, }) const getFieldHelpers = getFormHelpers(form) + const { t } = useTranslation("workspacePage") return ( - It looks like the new version has some mandatory parameters that need - to be filled in to update the workspace. + {t("askParametersDialog.message")} { - return res(ctx.status(200), ctx.json([M.MockTemplateVersionParameter1])) + return res(ctx.status(200), ctx.json([M.MockWorkspaceBuildParameter1])) }, ), ] diff --git a/site/src/util/richParameters.ts b/site/src/util/richParameters.ts index 81b288dacf143..7bb036ebac337 100644 --- a/site/src/util/richParameters.ts +++ b/site/src/util/richParameters.ts @@ -43,7 +43,7 @@ export const selectInitialRichParametersValues = ( return defaults } -export const ValidationSchemaForRichParameters = ( +export const useValidationSchemaForRichParameters = ( ns: string, templateParameters?: TemplateVersionParameter[], lastBuildParameters?: WorkspaceBuildParameter[], From c3351484f186fa57cdc0557973a34c4a5ea43c11 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Tue, 14 Mar 2023 16:10:05 +0000 Subject: [PATCH 8/8] Fix tests --- site/src/api/api.test.ts | 54 ++++- site/src/api/api.ts | 1 - .../WorkspacePage/WorkspacePage.test.tsx | 193 ++++++++---------- 3 files changed, 128 insertions(+), 120 deletions(-) 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 fa60758b01618..b28d9e0379c12 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -932,7 +932,6 @@ export const updateWorkspace = async ( newBuildParameters, templateParameters, ) - if (missingParameters.length > 0) { throw new MissingBuildParameters(missingParameters) } diff --git a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx index 3eabc3ca03abb..dbb2408be7cd6 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx @@ -173,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 () => { @@ -227,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, @@ -276,77 +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") - it("Workspace update when having new parameters on the template version", 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 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", - }, - ]) + // 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) }) }) })