diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index 715a4b080bb96..08b1973b999b6 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -7,6 +7,7 @@ import GroupsPage from "pages/GroupsPage/GroupsPage" import LoginPage from "pages/LoginPage/LoginPage" import { SetupPage } from "pages/SetupPage/SetupPage" import { TemplateSettingsPage } from "pages/TemplateSettingsPage/TemplateSettingsPage" +import { WorkspaceBuildParametersPage } from "pages/WorkspaceBuildParametersPage/WorkspaceBuildParametersPage" import TemplatesPage from "pages/TemplatesPage/TemplatesPage" import UsersPage from "pages/UsersPage/UsersPage" import WorkspacesPage from "pages/WorkspacesPage/WorkspacesPage" @@ -213,6 +214,10 @@ export const AppRouter: FC = () => { path="change-version" element={} /> + } + /> diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 2aee82ed96c85..dd651a54aeb1b 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1,7 +1,6 @@ import axios, { AxiosRequestHeaders } from "axios" import dayjs from "dayjs" import * as Types from "./types" -import { WorkspaceBuildTransition } from "./types" import * as TypesGen from "./typesGenerated" export const hardCodedCSRFCookie = (): string => { @@ -288,6 +287,15 @@ export const getTemplateVersionParameters = async ( return response.data } +export const getTemplateVersionRichParameters = async ( + versionId: string, +): Promise => { + const response = await axios.get( + `/api/v2/templateversions/${versionId}/rich-parameters`, + ) + return response.data +} + export const createTemplate = async ( organizationId: string, data: TypesGen.CreateTemplateRequest, @@ -390,26 +398,29 @@ export const getWorkspaceByOwnerAndName = async ( return response.data } -const postWorkspaceBuild = - (transition: WorkspaceBuildTransition) => - async ( - workspaceId: string, - template_version_id?: string, - ): Promise => { - const payload = { - transition, - template_version_id, - } - const response = await axios.post( - `/api/v2/workspaces/${workspaceId}/builds`, - payload, - ) - return response.data - } +export const postWorkspaceBuild = async ( + workspaceId: string, + data: TypesGen.CreateWorkspaceBuildRequest, +): Promise => { + const response = await axios.post( + `/api/v2/workspaces/${workspaceId}/builds`, + data, + ) + return response.data +} -export const startWorkspace = postWorkspaceBuild("start") -export const stopWorkspace = postWorkspaceBuild("stop") -export const deleteWorkspace = postWorkspaceBuild("delete") +export const startWorkspace = ( + workspaceId: string, + templateVersionID: string, +) => + postWorkspaceBuild(workspaceId, { + transition: "start", + template_version_id: templateVersionID, + }) +export const stopWorkspace = (workspaceId: string) => + postWorkspaceBuild(workspaceId, { transition: "stop" }) +export const deleteWorkspace = (workspaceId: string) => + postWorkspaceBuild(workspaceId, { transition: "delete" }) export const cancelWorkspaceBuild = async ( workspaceBuildId: TypesGen.WorkspaceBuild["id"], @@ -790,3 +801,12 @@ export const updateWorkspaceVersion = async ( const template = await getTemplate(workspace.template_id) return startWorkspace(workspace.id, template.active_version_id) } + +export const getWorkspaceBuildParameters = async ( + workspaceBuildId: TypesGen.WorkspaceBuild["id"], +): Promise => { + const response = await axios.get( + `/api/v2/workspacebuilds/${workspaceBuildId}/parameters`, + ) + return response.data +} diff --git a/site/src/components/DropdownButton/ActionCtas.tsx b/site/src/components/DropdownButton/ActionCtas.tsx index 1ccbb82ed2b72..8be23778a6a85 100644 --- a/site/src/components/DropdownButton/ActionCtas.tsx +++ b/site/src/components/DropdownButton/ActionCtas.tsx @@ -4,6 +4,7 @@ import { makeStyles } from "@material-ui/core/styles" import BlockIcon from "@material-ui/icons/Block" import CloudQueueIcon from "@material-ui/icons/CloudQueue" import UpdateOutlined from "@material-ui/icons/UpdateOutlined" +import SettingsOutlined from "@material-ui/icons/SettingsOutlined" import CropSquareIcon from "@material-ui/icons/CropSquare" import DeleteOutlineIcon from "@material-ui/icons/DeleteOutline" import PlayCircleOutlineIcon from "@material-ui/icons/PlayCircleOutline" @@ -51,6 +52,23 @@ export const ChangeVersionButton: FC< ) } +export const BuildParametersButton: FC< + React.PropsWithChildren +> = ({ handleAction }) => { + const styles = useStyles() + const { t } = useTranslation("workspacePage") + + return ( + + ) +} + export const StartButton: FC> = ({ handleAction, }) => { diff --git a/site/src/components/GoBackButton/GoBackButton.tsx b/site/src/components/GoBackButton/GoBackButton.tsx new file mode 100644 index 0000000000000..36818eb6458ae --- /dev/null +++ b/site/src/components/GoBackButton/GoBackButton.tsx @@ -0,0 +1,19 @@ +import Button from "@material-ui/core/Button" + +interface GoBackButtonProps { + onClick: () => void +} + +export const Language = { + ariaLabel: "Go back", +} + +export const GoBackButton: React.FC< + React.PropsWithChildren +> = ({ onClick }) => { + return ( + + ) +} diff --git a/site/src/components/RichParameterInput/RichParameterInput.stories.tsx b/site/src/components/RichParameterInput/RichParameterInput.stories.tsx new file mode 100644 index 0000000000000..65cbd160d7c69 --- /dev/null +++ b/site/src/components/RichParameterInput/RichParameterInput.stories.tsx @@ -0,0 +1,95 @@ +import { Story } from "@storybook/react" +import { TemplateVersionParameter } from "api/typesGenerated" +import { + RichParameterInput, + RichParameterInputProps, +} from "./RichParameterInput" + +export default { + title: "components/RichParameterInput", + component: RichParameterInput, +} + +const Template: Story = ( + args: RichParameterInputProps, +) => + +const createTemplateVersionParameter = ( + partial: Partial, +): TemplateVersionParameter => { + return { + name: "first_parameter", + description: "This is first parameter.", + type: "string", + mutable: false, + default_value: "default string", + icon: "/icon/folder.svg", + options: [], + validation_error: "", + validation_regex: "", + validation_min: 0, + validation_max: 0, + + ...partial, + } +} + +export const Basic = Template.bind({}) +Basic.args = { + initialValue: "initial-value", + parameter: createTemplateVersionParameter({ + name: "project_name", + description: + "Customize the name of a Google Cloud project that will be created!", + }), +} + +export const NumberType = Template.bind({}) +NumberType.args = { + initialValue: "4", + parameter: createTemplateVersionParameter({ + name: "number_parameter", + type: "number", + description: "Numeric parameter", + }), +} + +export const BooleanType = Template.bind({}) +BooleanType.args = { + initialValue: "false", + parameter: createTemplateVersionParameter({ + name: "bool_parameter", + type: "bool", + description: "Boolean parameter", + }), +} + +export const OptionsType = Template.bind({}) +OptionsType.args = { + initialValue: "first_option", + parameter: createTemplateVersionParameter({ + name: "options_parameter", + type: "string", + description: "Parameter with options", + options: [ + { + name: "First option", + value: "first_option", + description: "This is option 1", + icon: "", + }, + { + name: "Second option", + value: "second_option", + description: "This is option 2", + icon: "/icon/database.svg", + }, + { + name: "Third option", + value: "third_option", + description: "This is option 3", + icon: "/icon/aws.png", + }, + ], + }), +} diff --git a/site/src/components/RichParameterInput/RichParameterInput.tsx b/site/src/components/RichParameterInput/RichParameterInput.tsx new file mode 100644 index 0000000000000..b60c584220918 --- /dev/null +++ b/site/src/components/RichParameterInput/RichParameterInput.tsx @@ -0,0 +1,224 @@ +import FormControlLabel from "@material-ui/core/FormControlLabel" +import Radio from "@material-ui/core/Radio" +import RadioGroup from "@material-ui/core/RadioGroup" +import { makeStyles } from "@material-ui/core/styles" +import TextField from "@material-ui/core/TextField" +import { Stack } from "components/Stack/Stack" +import { FC, useState } from "react" +import { TemplateVersionParameter } from "../../api/typesGenerated" +import { colors } from "theme/colors" + +const isBoolean = (parameter: TemplateVersionParameter) => { + return parameter.type === "bool" +} + +export interface ParameterLabelProps { + index: number + parameter: TemplateVersionParameter +} + +const ParameterLabel: FC = ({ index, parameter }) => { + const styles = useStyles() + + return ( + + + {parameter.icon && ( + + Parameter icon + + )} + + + + + {parameter.description} + {!parameter.mutable && ( +
+ This parameter cannot be changed after creating workspace. +
+ )} +
+ ) +} + +export interface RichParameterInputProps { + index: number + disabled?: boolean + parameter: TemplateVersionParameter + onChange: (value: string) => void + initialValue?: string +} + +export const RichParameterInput: FC = ({ + index, + disabled, + onChange, + parameter, + initialValue, + ...props +}) => { + const styles = useStyles() + + return ( + + +
+ +
+
+ ) +} + +const RichParameterField: React.FC = ({ + disabled, + onChange, + parameter, + initialValue, + ...props +}) => { + const [parameterValue, setParameterValue] = useState(initialValue) + const styles = useStyles() + + if (isBoolean(parameter)) { + return ( + { + onChange(event.target.value) + }} + > + } + label="True" + /> + } + label="False" + /> + + ) + } + + if (parameter.options.length > 0) { + return ( + { + onChange(event.target.value) + }} + > + {parameter.options.map((option) => ( + } + label={ + + {option.icon && ( + Parameter icon + )} + {option.name} + + } + /> + ))} + + ) + } + + // A text field can technically handle all cases! + // As other cases become more prominent (like filtering for numbers), + // we should break this out into more finely scoped input fields. + return ( + { + setParameterValue(event.target.value) + onChange(event.target.value) + }} + /> + ) +} + +const iconSize = 20 +const optionIconSize = 24 + +const useStyles = makeStyles((theme) => ({ + labelName: { + fontSize: 14, + color: theme.palette.text.secondary, + display: "block", + marginBottom: theme.spacing(1.0), + }, + labelNameWithIcon: { + marginBottom: theme.spacing(0.5), + }, + labelDescription: { + fontSize: 16, + color: theme.palette.text.primary, + display: "block", + fontWeight: 600, + }, + labelImmutable: { + marginTop: theme.spacing(0.5), + marginBottom: theme.spacing(0.5), + color: colors.yellow[7], + }, + input: { + display: "flex", + flexDirection: "column", + }, + checkbox: { + display: "flex", + alignItems: "center", + gap: theme.spacing(1), + }, + iconWrapper: { + float: "left", + }, + icon: { + maxHeight: iconSize, + width: iconSize, + marginRight: theme.spacing(1.0), + }, + optionIcon: { + maxHeight: optionIconSize, + width: optionIconSize, + marginRight: theme.spacing(1.0), + float: "left", + }, +})) diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index 63d0d671efcf4..0b06fb693648d 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -45,6 +45,7 @@ export interface WorkspaceProps { handleUpdate: () => void handleCancel: () => void handleChangeVersion: () => void + handleBuildParameters: () => void isUpdating: boolean workspace: TypesGen.Workspace resources?: TypesGen.WorkspaceResource[] @@ -56,6 +57,7 @@ export interface WorkspaceProps { buildInfo?: TypesGen.BuildInfoResponse applicationsHost?: string template?: TypesGen.Template + templateParameters?: TypesGen.TemplateVersionParameter[] quota_budget?: number } @@ -70,6 +72,7 @@ export const Workspace: FC> = ({ handleUpdate, handleCancel, handleChangeVersion, + handleBuildParameters, workspace, isUpdating, resources, @@ -81,6 +84,7 @@ export const Workspace: FC> = ({ buildInfo, applicationsHost, template, + templateParameters, quota_budget, }) => { const { t } = useTranslation("workspacePage") @@ -122,7 +126,6 @@ export const Workspace: FC> = ({ if (template !== undefined) { transitionStats = ActiveTransition(template, workspace) } - return ( > = ({ /> 0 : false + } isOutdated={workspace.outdated} handleStart={handleStart} handleStop={handleStop} @@ -145,6 +151,7 @@ export const Workspace: FC> = ({ handleUpdate={handleUpdate} handleCancel={handleCancel} handleChangeVersion={handleChangeVersion} + handleBuildParameters={handleBuildParameters} isUpdating={isUpdating} /> diff --git a/site/src/components/WorkspaceActions/WorkspaceActions.test.tsx b/site/src/components/WorkspaceActions/WorkspaceActions.test.tsx index 27d59496fbf06..a83aa41cb1144 100644 --- a/site/src/components/WorkspaceActions/WorkspaceActions.test.tsx +++ b/site/src/components/WorkspaceActions/WorkspaceActions.test.tsx @@ -12,6 +12,7 @@ const renderComponent = async (props: Partial = {}) => { workspaceStatus={ props.workspaceStatus ?? Mocks.MockWorkspace.latest_build.status } + hasTemplateParameters={props.hasTemplateParameters ?? false} isOutdated={props.isOutdated ?? false} handleStart={jest.fn()} handleStop={jest.fn()} @@ -19,6 +20,7 @@ const renderComponent = async (props: Partial = {}) => { handleUpdate={jest.fn()} handleCancel={jest.fn()} handleChangeVersion={jest.fn()} + handleBuildParameters={jest.fn()} isUpdating={false} />, ) @@ -30,6 +32,7 @@ const renderAndClick = async (props: Partial = {}) => { workspaceStatus={ props.workspaceStatus ?? Mocks.MockWorkspace.latest_build.status } + hasTemplateParameters={props.hasTemplateParameters ?? false} isOutdated={props.isOutdated ?? false} handleStart={jest.fn()} handleStop={jest.fn()} @@ -37,6 +40,7 @@ const renderAndClick = async (props: Partial = {}) => { handleUpdate={jest.fn()} handleCancel={jest.fn()} handleChangeVersion={jest.fn()} + handleBuildParameters={jest.fn()} isUpdating={false} />, ) @@ -74,6 +78,33 @@ describe("WorkspaceActions", () => { ) }) }) + describe("when the workspace is started", () => { + it("primary is stop; secondary is delete", async () => { + await renderAndClick({ + workspaceStatus: Mocks.MockWorkspace.latest_build.status, + }) + expect(screen.getByTestId("primary-cta")).toHaveTextContent( + t("actionButton.stop", { ns: "workspacePage" }), + ) + expect(screen.getByTestId("secondary-ctas")).toHaveTextContent( + t("actionButton.delete", { ns: "workspacePage" }), + ) + }) + }) + describe("when the workspace with rich parameters is started", () => { + it("primary is stop; secondary is build parameters", async () => { + await renderAndClick({ + workspaceStatus: Mocks.MockWorkspace.latest_build.status, + hasTemplateParameters: true, + }) + expect(screen.getByTestId("primary-cta")).toHaveTextContent( + t("actionButton.stop", { ns: "workspacePage" }), + ) + expect(screen.getByTestId("secondary-ctas")).toHaveTextContent( + t("actionButton.buildParameters", { ns: "workspacePage" }), + ) + }) + }) describe("when the workspace is stopping", () => { it("primary is stopping; cancel is available; no secondary", async () => { await renderComponent({ diff --git a/site/src/components/WorkspaceActions/WorkspaceActions.tsx b/site/src/components/WorkspaceActions/WorkspaceActions.tsx index 0f5064fe82229..fad29ebc7a209 100644 --- a/site/src/components/WorkspaceActions/WorkspaceActions.tsx +++ b/site/src/components/WorkspaceActions/WorkspaceActions.tsx @@ -7,14 +7,16 @@ import { ChangeVersionButton, DeleteButton, DisabledButton, + BuildParametersButton, StartButton, StopButton, UpdateButton, } from "../DropdownButton/ActionCtas" -import { ButtonMapping, ButtonTypesEnum, statusToAbilities } from "./constants" +import { ButtonMapping, ButtonTypesEnum, buttonAbilities } from "./constants" export interface WorkspaceActionsProps { workspaceStatus: WorkspaceStatus + hasTemplateParameters: boolean isOutdated: boolean handleStart: () => void handleStop: () => void @@ -22,12 +24,14 @@ export interface WorkspaceActionsProps { handleUpdate: () => void handleCancel: () => void handleChangeVersion: () => void + handleBuildParameters: () => void isUpdating: boolean children?: ReactNode } export const WorkspaceActions: FC = ({ workspaceStatus, + hasTemplateParameters, isOutdated, handleStart, handleStop, @@ -35,11 +39,14 @@ export const WorkspaceActions: FC = ({ handleUpdate, handleCancel, handleChangeVersion, + handleBuildParameters, isUpdating, }) => { const { t } = useTranslation("workspacePage") - const { canCancel, canAcceptJobs, actions } = - statusToAbilities[workspaceStatus] + const { canCancel, canAcceptJobs, actions } = buttonAbilities( + workspaceStatus, + hasTemplateParameters, + ) const canBeUpdated = isOutdated && canAcceptJobs // A mapping of button type to the corresponding React component @@ -51,6 +58,9 @@ export const WorkspaceActions: FC = ({ [ButtonTypesEnum.changeVersion]: ( ), + [ButtonTypesEnum.buildParameters]: ( + + ), [ButtonTypesEnum.start]: , [ButtonTypesEnum.starting]: ( diff --git a/site/src/components/WorkspaceActions/constants.ts b/site/src/components/WorkspaceActions/constants.ts index 5d5c59434d48b..d019721f6a852 100644 --- a/site/src/components/WorkspaceActions/constants.ts +++ b/site/src/components/WorkspaceActions/constants.ts @@ -12,6 +12,7 @@ export enum ButtonTypesEnum { update = "update", updating = "updating", changeVersion = "changeVersion", + buildParameters = "buildParameters", // disabled buttons canceling = "canceling", deleted = "deleted", @@ -28,7 +29,24 @@ interface WorkspaceAbilities { canAcceptJobs: boolean } -export const statusToAbilities: Record = { +export const buttonAbilities = ( + status: WorkspaceStatus, + hasTemplateParameters: boolean, +): WorkspaceAbilities => { + if (hasTemplateParameters) { + return statusToAbilities[status] + } + + const all = statusToAbilities[status] + return { + ...all, + actions: all.actions.filter( + (action) => action !== ButtonTypesEnum.buildParameters, + ), + } +} + +const statusToAbilities: Record = { starting: { actions: [ButtonTypesEnum.starting], canCancel: true, @@ -37,6 +55,7 @@ export const statusToAbilities: Record = { running: { actions: [ ButtonTypesEnum.stop, + ButtonTypesEnum.buildParameters, ButtonTypesEnum.changeVersion, ButtonTypesEnum.delete, ], @@ -51,6 +70,7 @@ export const statusToAbilities: Record = { stopped: { actions: [ ButtonTypesEnum.start, + ButtonTypesEnum.buildParameters, ButtonTypesEnum.changeVersion, ButtonTypesEnum.delete, ], @@ -61,6 +81,7 @@ export const statusToAbilities: Record = { actions: [ ButtonTypesEnum.start, ButtonTypesEnum.stop, + ButtonTypesEnum.buildParameters, ButtonTypesEnum.changeVersion, ButtonTypesEnum.delete, ], @@ -71,6 +92,7 @@ export const statusToAbilities: Record = { failed: { actions: [ ButtonTypesEnum.start, + ButtonTypesEnum.buildParameters, ButtonTypesEnum.changeVersion, ButtonTypesEnum.delete, ], diff --git a/site/src/i18n/en/createWorkspacePage.json b/site/src/i18n/en/createWorkspacePage.json index 26f302d2f1f08..4c127c23a41b8 100644 --- a/site/src/i18n/en/createWorkspacePage.json +++ b/site/src/i18n/en/createWorkspacePage.json @@ -2,5 +2,8 @@ "templateLabel": "Template", "nameLabel": "Workspace Name", "ownerLabel": "Owner", - "createWorkspace": "Create workspace" + "createWorkspace": "Create workspace", + "validationRequiredParameter": "Parameter is required.", + "validationNumberNotInRange": "Value must be between {{min}} and {{max}}.", + "validationPatternNotMatched": "{{error}} (value does not match the pattern {{pattern}})." } diff --git a/site/src/i18n/en/index.ts b/site/src/i18n/en/index.ts index d44bb2e202e0d..8450a9050d271 100644 --- a/site/src/i18n/en/index.ts +++ b/site/src/i18n/en/index.ts @@ -11,6 +11,7 @@ import usersPage from "./usersPage.json" import templateSettingsPage from "./templateSettingsPage.json" import templateVersionPage from "./templateVersionPage.json" import loginPage from "./loginPage.json" +import workspaceBuildParametersPage from "./workspaceBuildParametersPage.json" import workspaceChangeVersionPage from "./workspaceChangeVersionPage.json" import workspaceSchedulePage from "./workspaceSchedulePage.json" import appearanceSettings from "./appearanceSettings.json" @@ -33,6 +34,7 @@ export const en = { templateSettingsPage, templateVersionPage, loginPage, + workspaceBuildParametersPage, workspaceChangeVersionPage, workspaceSchedulePage, appearanceSettings, diff --git a/site/src/i18n/en/workspaceBuildParametersPage.json b/site/src/i18n/en/workspaceBuildParametersPage.json new file mode 100644 index 0000000000000..473f55eae533d --- /dev/null +++ b/site/src/i18n/en/workspaceBuildParametersPage.json @@ -0,0 +1,9 @@ +{ + "title": "Workspace build parameters", + "detail": "Those values were provided by the workspace owner.", + "noParametersDefined": "This template does not use any rich parameters.", + "validationRequiredParameter": "Parameter is required.", + "validationNumberNotInRange": "Value must be between {{min}} and {{max}}.", + "validationPatternNotMatched": "{{error}} (value does not match the pattern {{pattern}}).", + "updateWorkspace": "Update workspace" +} diff --git a/site/src/i18n/en/workspacePage.json b/site/src/i18n/en/workspacePage.json index 17f7e2aba3941..bb69d7524b429 100644 --- a/site/src/i18n/en/workspacePage.json +++ b/site/src/i18n/en/workspacePage.json @@ -28,7 +28,8 @@ "starting": "Starting...", "stopping": "Stopping...", "deleting": "Deleting...", - "changeVersion": "Change version" + "changeVersion": "Change version", + "buildParameters": "Build parameters" }, "disabledButton": { "canceling": "Canceling", diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx index 48735f077bdb6..39bf44ea87ffa 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx @@ -9,6 +9,9 @@ import { MockWorkspace, MockWorkspaceQuota, MockWorkspaceRequest, + MockTemplateVersionParameter1, + MockTemplateVersionParameter2, + MockTemplateVersionParameter3, } from "testHelpers/entities" import { renderWithAuth } from "testHelpers/renderHelpers" import CreateWorkspacePage from "./CreateWorkspacePage" @@ -17,6 +20,16 @@ const { t } = i18next const nameLabelText = t("nameLabel", { ns: "createWorkspacePage" }) const createWorkspaceText = t("createWorkspace", { ns: "createWorkspacePage" }) +const validationNumberNotInRangeText = t("validationNumberNotInRange", { + ns: "createWorkspacePage", + min: "1", + max: "3", +}) +const validationPatternNotMatched = t("validationPatternNotMatched", { + ns: "createWorkspacePage", + error: MockTemplateVersionParameter3.validation_error, + pattern: "^[a-z]{3}$", +}) const renderCreateWorkspacePage = () => { return renderWithAuth(, { @@ -27,9 +40,27 @@ const renderCreateWorkspacePage = () => { describe("CreateWorkspacePage", () => { it("renders", async () => { + jest + .spyOn(API, "getTemplateVersionRichParameters") + .mockResolvedValueOnce([MockTemplateVersionParameter1]) renderCreateWorkspacePage() - const element = await screen.findByText("Create workspace") + + const element = await screen.findByText(createWorkspaceText) + expect(element).toBeDefined() + }) + + it("renders with rich parameter", async () => { + jest + .spyOn(API, "getTemplateVersionRichParameters") + .mockResolvedValueOnce([MockTemplateVersionParameter1]) + renderCreateWorkspacePage() + + const element = await screen.findByText(createWorkspaceText) expect(element).toBeDefined() + const firstParameter = await screen.findByText( + MockTemplateVersionParameter1.description, + ) + expect(firstParameter).toBeDefined() }) it("succeeds with default owner", async () => { @@ -40,6 +71,9 @@ describe("CreateWorkspacePage", () => { .spyOn(API, "getWorkspaceQuota") .mockResolvedValueOnce(MockWorkspaceQuota) jest.spyOn(API, "createWorkspace").mockResolvedValueOnce(MockWorkspace) + jest + .spyOn(API, "getTemplateVersionRichParameters") + .mockResolvedValueOnce([MockTemplateVersionParameter1]) renderCreateWorkspacePage() @@ -73,13 +107,106 @@ describe("CreateWorkspacePage", () => { default_source_value: "", }), ]) + jest + .spyOn(API, "getTemplateVersionRichParameters") + .mockResolvedValueOnce([MockTemplateVersionParameter1]) + renderWithAuth(, { route: "/templates/" + MockTemplate.name + `/workspace?param.${param}=${paramValue}`, path: "/templates/:template/workspace", - }) + }), + await screen.findByDisplayValue(paramValue) + }) + + it("uses default rich param values passed from the URL", async () => { + const param = "first_parameter" + const paramValue = "It works!" + jest.spyOn(API, "getTemplateVersionSchema").mockResolvedValueOnce([ + mockParameterSchema({ + name: param, + default_source_value: "", + }), + ]) + jest + .spyOn(API, "getTemplateVersionRichParameters") + .mockResolvedValueOnce([MockTemplateVersionParameter1]) + + await waitFor(() => + renderWithAuth(, { + route: + "/templates/" + + MockTemplate.name + + `/workspace?param.${param}=${paramValue}`, + path: "/templates/:template/workspace", + }), + ) + await screen.findByDisplayValue(paramValue) }) + + it("rich parameter: number validation fails", async () => { + jest + .spyOn(API, "getTemplateVersionRichParameters") + .mockResolvedValueOnce([ + MockTemplateVersionParameter1, + MockTemplateVersionParameter2, + ]) + + await waitFor(() => renderCreateWorkspacePage()) + + const element = await screen.findByText("Create workspace") + expect(element).toBeDefined() + const secondParameter = await screen.findByText( + MockTemplateVersionParameter2.description, + ) + expect(secondParameter).toBeDefined() + + const secondParameterField = await screen.findByLabelText( + MockTemplateVersionParameter2.name, + ) + expect(secondParameterField).toBeDefined() + + fireEvent.change(secondParameterField, { + target: { value: "4" }, + }) + fireEvent.submit(secondParameter) + + const validationError = await screen.findByText( + validationNumberNotInRangeText, + ) + expect(validationError).toBeDefined() + }) + + it("rich parameter: string validation fails", async () => { + jest + .spyOn(API, "getTemplateVersionRichParameters") + .mockResolvedValueOnce([ + MockTemplateVersionParameter1, + MockTemplateVersionParameter3, + ]) + + await waitFor(() => renderCreateWorkspacePage()) + + const element = await screen.findByText(createWorkspaceText) + expect(element).toBeDefined() + const thirdParameter = await screen.findByText( + MockTemplateVersionParameter3.description, + ) + expect(thirdParameter).toBeDefined() + + const thirdParameterField = await screen.findByLabelText( + MockTemplateVersionParameter3.name, + ) + expect(thirdParameterField).toBeDefined() + fireEvent.change(thirdParameterField, { + target: { value: "1234" }, + }) + fireEvent.submit(thirdParameterField) + + const validationError = await screen.findByText(validationPatternNotMatched) + expect(validationError).toBeInTheDocument() + }) }) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx index c0d01ebf7e5fe..30934d629735b 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx @@ -30,6 +30,7 @@ const CreateWorkspacePage: FC = () => { }) const { templates, + templateParameters, templateSchema, selectedTemplate, getTemplateSchemaError, @@ -57,6 +58,7 @@ const CreateWorkspacePage: FC = () => { templateName={templateName} templates={templates} selectedTemplate={selectedTemplate} + templateParameters={templateParameters} templateSchema={templateSchema} createWorkspaceErrors={{ [CreateWorkspaceErrors.GET_TEMPLATES_ERROR]: getTemplatesError, diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx index 9fc79b659dbac..e202ab9d60d8b 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx @@ -3,6 +3,9 @@ import { makeMockApiError, mockParameterSchema, MockTemplate, + MockTemplateVersionParameter1, + MockTemplateVersionParameter2, + MockTemplateVersionParameter3, } from "../../testHelpers/entities" import { CreateWorkspaceErrors, @@ -108,3 +111,15 @@ CreateWorkspaceError.args = { name: true, }, } + +export const RichParameters = Template.bind({}) +RichParameters.args = { + templates: [MockTemplate], + selectedTemplate: MockTemplate, + templateParameters: [ + MockTemplateVersionParameter1, + MockTemplateVersionParameter2, + MockTemplateVersionParameter3, + ], + createWorkspaceErrors: {}, +} diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx index 7cb9cb880f0f2..b2518fb3e743f 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx @@ -2,10 +2,10 @@ import TextField from "@material-ui/core/TextField" import * as TypesGen from "api/typesGenerated" import { FormFooter } from "components/FormFooter/FormFooter" import { ParameterInput } from "components/ParameterInput/ParameterInput" +import { RichParameterInput } from "components/RichParameterInput/RichParameterInput" import { Stack } from "components/Stack/Stack" import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete" import { FormikContextType, FormikTouched, useFormik } from "formik" -import { i18n } from "i18n" import { FC, useState } from "react" import { useTranslation } from "react-i18next" import { getFormHelpers, nameValidator, onChangeTrimmed } from "util/formUtils" @@ -30,6 +30,8 @@ export interface CreateWorkspacePageViewProps { templateName: string templates?: TypesGen.Template[] selectedTemplate?: TypesGen.Template + templateParameters?: TypesGen.TemplateVersionParameter[] + templateSchema?: TypesGen.ParameterSchema[] createWorkspaceErrors: Partial> canCreateForUser?: boolean @@ -42,30 +44,36 @@ export interface CreateWorkspacePageViewProps { defaultParameterValues?: Record } -const { t } = i18n - -export const validationSchema = Yup.object({ - name: nameValidator(t("nameLabel", { ns: "createWorkspacePage" })), -}) - export const CreateWorkspacePageView: FC< React.PropsWithChildren > = (props) => { - const { t } = useTranslation("createWorkspacePage") const styles = useStyles() const formFooterStyles = useFormFooterStyles() const [parameterValues, setParameterValues] = useState< Record >(props.defaultParameterValues ?? {}) + const initialRichParameterValues = selectInitialRichParametersValues( + props.templateParameters, + props.defaultParameterValues, + ) + + const { t } = useTranslation("createWorkspacePage") const form: FormikContextType = useFormik({ initialValues: { name: "", template_id: props.selectedTemplate ? props.selectedTemplate.id : "", + rich_parameter_values: initialRichParameterValues, }, + validationSchema: Yup.object({ + name: nameValidator(t("nameLabel", { ns: "createWorkspacePage" })), + rich_parameter_values: ValidationSchemaForRichParameters( + "createWorkspacePage", + props.templateParameters, + ), + }), enableReinitialize: true, - validationSchema, initialTouched: props.initialTouched, onSubmit: (request) => { if (!props.templateSchema) { @@ -249,6 +257,48 @@ export const CreateWorkspacePageView: FC< )} + {/* Rich parameters */} + {props.templateParameters && props.templateParameters.length > 0 && ( +
+
+

+ Rich template params +

+

+ Those values are provided by your template‘s Terraform + configuration. +

+
+ + + {props.templateParameters.map((parameter, index) => ( + { + form.setFieldValue("rich_parameter_values." + index, { + name: parameter.name, + value: value, + }) + }} + parameter={parameter} + initialValue={workspaceBuildParameterValue( + initialRichParameterValues, + parameter, + )} + /> + ))} + +
+ )} ({ }, }, })) + +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, +): string => { + const buildParameter = workspaceBuildParameters.find((buildParameter) => { + return buildParameter.name === parameter.name + }) + return (buildParameter && buildParameter.value) || "" +} + +export const ValidationSchemaForRichParameters = ( + ns: string, + templateParameters?: TypesGen.TemplateVersionParameter[], +): 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() + .required(t("validationRequiredParameter")) + .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 === 0 && + templateParameter.validation_max === 0 + ) { + return true + } + + 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, + }), + }) + } + break + case "string": + { + if (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/WorkspaceBuildParametersPage.test.tsx b/site/src/pages/WorkspaceBuildParametersPage/WorkspaceBuildParametersPage.test.tsx new file mode 100644 index 0000000000000..3727ebf29c7b4 --- /dev/null +++ b/site/src/pages/WorkspaceBuildParametersPage/WorkspaceBuildParametersPage.test.tsx @@ -0,0 +1,119 @@ +import { fireEvent, screen } from "@testing-library/react" +import { + MockTemplateVersionParameter1, + MockTemplateVersionParameter2, + MockWorkspace, + MockWorkspaceBuildParameter1, + MockWorkspaceBuildParameter2, + renderWithAuth, +} from "testHelpers/renderHelpers" +import * as API from "api/api" +import i18next from "i18next" +import { WorkspaceBuildParametersPage } from "./WorkspaceBuildParametersPage" + +const { t } = i18next + +const pageTitleText = t("title", { ns: "workspaceBuildParametersPage" }) +const validationNumberNotInRangeText = t("validationNumberNotInRange", { + ns: "workspaceBuildParametersPage", + min: "1", + max: "3", +}) + +const renderWorkspaceBuildParametersPage = () => { + return renderWithAuth(, { + route: `/@${MockWorkspace.owner_name}/${MockWorkspace.name}/build-parameters`, + path: `/@:ownerName/:workspaceName/build-parameters`, + }) +} + +describe("WorkspaceBuildParametersPage", () => { + it("renders without rich parameters", async () => { + jest.spyOn(API, "getWorkspace").mockResolvedValueOnce(MockWorkspace) + jest + .spyOn(API, "getTemplateVersionRichParameters") + .mockResolvedValueOnce([]) + jest + .spyOn(API, "getWorkspaceBuildParameters") + .mockResolvedValueOnce([ + MockWorkspaceBuildParameter1, + MockWorkspaceBuildParameter2, + ]) + renderWorkspaceBuildParametersPage() + + const element = await screen.findByText(pageTitleText) + expect(element).toBeDefined() + + const goBackButton = await screen.findByText("Go back") + expect(goBackButton).toBeDefined() + }) + + it("renders with rich parameter", async () => { + jest.spyOn(API, "getWorkspace").mockResolvedValueOnce(MockWorkspace) + jest + .spyOn(API, "getTemplateVersionRichParameters") + .mockResolvedValueOnce([ + MockTemplateVersionParameter1, + MockTemplateVersionParameter2, + ]) + jest + .spyOn(API, "getWorkspaceBuildParameters") + .mockResolvedValueOnce([ + MockWorkspaceBuildParameter1, + MockWorkspaceBuildParameter2, + ]) + + renderWorkspaceBuildParametersPage() + + const element = await screen.findByText(pageTitleText) + expect(element).toBeDefined() + + const firstParameter = await screen.findByLabelText( + MockTemplateVersionParameter1.name, + ) + expect(firstParameter).toBeDefined() + + const secondParameter = await screen.findByLabelText( + MockTemplateVersionParameter2.name, + ) + expect(secondParameter).toBeDefined() + }) + + it("rich parameter: number validation fails", async () => { + jest + .spyOn(API, "getTemplateVersionRichParameters") + .mockResolvedValueOnce([ + MockTemplateVersionParameter1, + MockTemplateVersionParameter2, + ]) + jest + .spyOn(API, "getWorkspaceBuildParameters") + .mockResolvedValueOnce([ + MockWorkspaceBuildParameter1, + MockWorkspaceBuildParameter2, + ]) + renderWorkspaceBuildParametersPage() + + const element = await screen.findByText(pageTitleText) + expect(element).toBeDefined() + const secondParameter = await screen.findByText( + MockTemplateVersionParameter2.description, + ) + expect(secondParameter).toBeDefined() + + const secondParameterField = await screen.findByLabelText( + MockTemplateVersionParameter2.name, + ) + expect(secondParameterField).toBeDefined() + + fireEvent.change(secondParameterField, { + target: { value: "4" }, + }) + fireEvent.submit(secondParameter) + + const validationError = await screen.findByText( + validationNumberNotInRangeText, + ) + expect(validationError).toBeDefined() + }) +}) diff --git a/site/src/pages/WorkspaceBuildParametersPage/WorkspaceBuildParametersPage.tsx b/site/src/pages/WorkspaceBuildParametersPage/WorkspaceBuildParametersPage.tsx new file mode 100644 index 0000000000000..44d60e1217e88 --- /dev/null +++ b/site/src/pages/WorkspaceBuildParametersPage/WorkspaceBuildParametersPage.tsx @@ -0,0 +1,76 @@ +import { FC } from "react" +import { Helmet } from "react-helmet-async" +import { useTranslation } from "react-i18next" +import { pageTitle } from "util/page" +import { useMachine } from "@xstate/react" +import { useNavigate, useParams } from "react-router-dom" +import { workspaceBuildParametersMachine } from "xServices/workspace/workspaceBuildParametersXService" +import { + UpdateWorkspaceErrors, + WorkspaceBuildParametersPageView, +} from "./WorkspaceBuildParametersPageView" + +export const WorkspaceBuildParametersPage: FC = () => { + const { t } = useTranslation("workspaceBuildParametersPage") + + const navigate = useNavigate() + const { owner: workspaceOwner, workspace: workspaceName } = useParams() as { + owner: string + workspace: string + } + const [state, send] = useMachine(workspaceBuildParametersMachine, { + context: { + workspaceOwner, + workspaceName, + }, + actions: { + onUpdateWorkspace: (_, event) => { + navigate( + `/@${event.data.workspace_owner_name}/${event.data.workspace_name}`, + ) + }, + }, + }) + const { + selectedWorkspace, + templateParameters, + workspaceBuildParameters, + getWorkspaceError, + getTemplateParametersError, + getWorkspaceBuildParametersError, + updateWorkspaceError, + } = state.context + + return ( + <> + + {pageTitle(t("title"))} + + { + // Go back + navigate(-1) + }} + onSubmit={(request) => { + send({ + type: "UPDATE_WORKSPACE", + request, + }) + }} + /> + + ) +} diff --git a/site/src/pages/WorkspaceBuildParametersPage/WorkspaceBuildParametersPageView.stories.tsx b/site/src/pages/WorkspaceBuildParametersPage/WorkspaceBuildParametersPageView.stories.tsx new file mode 100644 index 0000000000000..aec6fc73a1f08 --- /dev/null +++ b/site/src/pages/WorkspaceBuildParametersPage/WorkspaceBuildParametersPageView.stories.tsx @@ -0,0 +1,48 @@ +import { ComponentMeta, Story } from "@storybook/react" +import { + MockTemplateVersionParameter1, + MockTemplateVersionParameter2, + MockTemplateVersionParameter3, + MockTemplateVersionParameter4, + MockWorkspace, +} from "testHelpers/entities" +import { + WorkspaceBuildParametersPageView, + WorkspaceBuildParametersPageViewProps, +} from "./WorkspaceBuildParametersPageView" + +export default { + title: "pages/WorkspaceBuildParametersPageView", + component: WorkspaceBuildParametersPageView, +} as ComponentMeta + +const Template: Story = (args) => ( + +) + +export const NoRichParametersDefined = Template.bind({}) +NoRichParametersDefined.args = { + workspace: MockWorkspace, + templateParameters: [], + workspaceBuildParameters: [], + updateWorkspaceErrors: {}, + initialTouched: { + name: true, + }, +} + +export const RichParametersDefined = Template.bind({}) +RichParametersDefined.args = { + workspace: MockWorkspace, + templateParameters: [ + MockTemplateVersionParameter1, + MockTemplateVersionParameter2, + MockTemplateVersionParameter3, + MockTemplateVersionParameter4, + ], + workspaceBuildParameters: [], + updateWorkspaceErrors: {}, + initialTouched: { + name: true, + }, +} diff --git a/site/src/pages/WorkspaceBuildParametersPage/WorkspaceBuildParametersPageView.tsx b/site/src/pages/WorkspaceBuildParametersPage/WorkspaceBuildParametersPageView.tsx new file mode 100644 index 0000000000000..b8b603bc09cd2 --- /dev/null +++ b/site/src/pages/WorkspaceBuildParametersPage/WorkspaceBuildParametersPageView.tsx @@ -0,0 +1,317 @@ +import { FC } from "react" +import { FullPageForm } from "components/FullPageForm/FullPageForm" +import { useTranslation } from "react-i18next" +import * as TypesGen from "api/typesGenerated" +import { AlertBanner } from "components/AlertBanner/AlertBanner" +import { Stack } from "components/Stack/Stack" +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 { FormFooter } from "components/FormFooter/FormFooter" +import * as Yup from "yup" +import { Maybe } from "components/Conditionals/Maybe" +import { GoBackButton } from "components/GoBackButton/GoBackButton" + +export enum UpdateWorkspaceErrors { + GET_WORKSPACE_ERROR = "getWorkspaceError", + GET_TEMPLATE_PARAMETERS_ERROR = "getTemplateParametersError", + GET_WORKSPACE_BUILD_PARAMETERS_ERROR = "getWorkspaceBuildParametersError", + UPDATE_WORKSPACE_ERROR = "updateWorkspaceError", +} + +export interface WorkspaceBuildParametersPageViewProps { + workspace?: TypesGen.Workspace + templateParameters?: TypesGen.TemplateVersionParameter[] + workspaceBuildParameters?: TypesGen.WorkspaceBuildParameter[] + + initialTouched?: FormikTouched + updatingWorkspace: boolean + onCancel: () => void + onSubmit: (req: TypesGen.CreateWorkspaceBuildRequest) => void + + hasErrors: boolean + updateWorkspaceErrors: Partial> +} + +export const WorkspaceBuildParametersPageView: FC< + React.PropsWithChildren +> = (props) => { + const { t } = useTranslation("workspaceBuildParametersPage") + const styles = useStyles() + const formFooterStyles = useFormFooterStyles() + + const initialRichParameterValues = selectInitialRichParametersValues( + props.templateParameters, + props.workspaceBuildParameters, + ) + + const form: FormikContextType = + useFormik({ + initialValues: { + template_version_id: props.workspace + ? props.workspace.latest_build.template_version_id + : "", + transition: "start", + rich_parameter_values: initialRichParameterValues, + }, + validationSchema: Yup.object({ + rich_parameter_values: ValidationSchemaForRichParameters( + "workspaceBuildParametersPage", + props.templateParameters, + ), + }), + enableReinitialize: true, + initialTouched: props.initialTouched, + onSubmit: (request) => { + props.onSubmit( + stripImmutableParameters(request, props.templateParameters), + ) + form.setSubmitting(false) + }, + }) + + const getFieldHelpers = getFormHelpers( + form, + props.updateWorkspaceErrors[UpdateWorkspaceErrors.UPDATE_WORKSPACE_ERROR], + ) + + { + props.hasErrors && ( + + {Boolean( + props.updateWorkspaceErrors[ + UpdateWorkspaceErrors.GET_WORKSPACE_ERROR + ], + ) && ( + + )} + {Boolean( + props.updateWorkspaceErrors[ + UpdateWorkspaceErrors.GET_TEMPLATE_PARAMETERS_ERROR + ], + ) && ( + + )} + {Boolean( + props.updateWorkspaceErrors[ + UpdateWorkspaceErrors.GET_WORKSPACE_BUILD_PARAMETERS_ERROR + ], + ) && ( + + )} + + ) + } + + return ( + + + + + + +
+ +
+ +
+
+
+ + {props.templateParameters && + props.templateParameters.length > 0 && + props.workspaceBuildParameters && ( +
+
+ + {props.templateParameters.map((parameter, index) => ( + { + form.setFieldValue("rich_parameter_values." + index, { + name: parameter.name, + value: value, + }) + }} + parameter={parameter} + initialValue={workspaceBuildParameterValue( + initialRichParameterValues, + parameter, + )} + /> + ))} + + +
+
+ )} +
+ ) +} + +const selectInitialRichParametersValues = ( + templateParameters?: TypesGen.TemplateVersionParameter[], + workspaceBuildParameters?: TypesGen.WorkspaceBuildParameter[], +): 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 (workspaceBuildParameters) { + const foundBuildParameter = workspaceBuildParameters.find( + (buildParameter) => { + return buildParameter.name === parameter.name + }, + ) + if (foundBuildParameter) { + parameterValue = foundBuildParameter.value + } + } + + const buildParameter: TypesGen.WorkspaceBuildParameter = { + name: parameter.name, + value: parameterValue, + } + defaults.push(buildParameter) + return + } + + let parameterValue = parameter.default_value + if (workspaceBuildParameters) { + const foundBuildParameter = workspaceBuildParameters.find( + (buildParameter) => { + return buildParameter.name === parameter.name + }, + ) + if (foundBuildParameter) { + parameterValue = foundBuildParameter.value + } + } + + const buildParameter: TypesGen.WorkspaceBuildParameter = { + name: parameter.name, + value: parameterValue || "", + } + defaults.push(buildParameter) + }) + return defaults +} + +const stripImmutableParameters = ( + request: TypesGen.CreateWorkspaceBuildRequest, + templateParameters?: TypesGen.TemplateVersionParameter[], +): TypesGen.CreateWorkspaceBuildRequest => { + if (!templateParameters || !request.rich_parameter_values) { + return request + } + + const mutableBuildParameters = request.rich_parameter_values.filter( + (buildParameter) => + templateParameters.find( + (templateParameter) => templateParameter.name === buildParameter.name, + )?.mutable, + ) + + return { + ...request, + rich_parameter_values: mutableBuildParameters, + } +} + +const useStyles = makeStyles(() => ({ + goBackSection: { + display: "flex", + width: "100%", + marginTop: 32, + }, + formSection: { + marginTop: 20, + }, + + formSectionFields: { + width: "100%", + }, +})) + +const useFormFooterStyles = makeStyles((theme) => ({ + button: { + minWidth: theme.spacing(23), + + [theme.breakpoints.down("sm")]: { + width: "100%", + }, + }, + footer: { + display: "flex", + alignItems: "center", + justifyContent: "flex-start", + flexDirection: "row-reverse", + gap: theme.spacing(2), + + [theme.breakpoints.down("sm")]: { + 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 2110e27ada732..c793863b16d9d 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx @@ -30,6 +30,7 @@ const { t } = i18next // It renders the workspace page and waits for it be loaded const renderWorkspacePage = async () => { jest.spyOn(api, "getTemplate").mockResolvedValueOnce(MockTemplate) + jest.spyOn(api, "getTemplateVersionRichParameters").mockResolvedValueOnce([]) renderWithAuth(, { route: `/@${MockWorkspace.owner_name}/${MockWorkspace.name}`, path: "/@:username/:workspace", diff --git a/site/src/pages/WorkspacePage/WorkspacePage.tsx b/site/src/pages/WorkspacePage/WorkspacePage.tsx index a1882c4422c5e..1fae46c62c795 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.tsx @@ -20,6 +20,7 @@ export const WorkspacePage: FC = () => { workspace, getWorkspaceError, getTemplateWarning, + getTemplateParametersWarning, checkPermissionsError, } = workspaceState.context const [quotaState, quotaSend] = useMachine(quotaMachine) @@ -50,6 +51,12 @@ export const WorkspacePage: FC = () => { {Boolean(getTemplateWarning) && ( )} + {Boolean(getTemplateParametersWarning) && ( + + )} {Boolean(checkPermissionsError) && ( )} diff --git a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx index ecec679938913..d346b48caa3b9 100644 --- a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx @@ -45,6 +45,7 @@ export const WorkspaceReadyPage = ({ const { workspace, template, + templateParameters, refreshWorkspaceWarning, builds, getBuildsError, @@ -111,6 +112,7 @@ export const WorkspaceReadyPage = ({ handleUpdate={() => workspaceSend({ type: "UPDATE" })} handleCancel={() => workspaceSend({ type: "CANCEL" })} handleChangeVersion={() => navigate("change-version")} + handleBuildParameters={() => navigate("build-parameters")} resources={workspace.latest_build.resources} builds={builds} canUpdateWorkspace={canUpdateWorkspace} @@ -125,6 +127,7 @@ export const WorkspaceReadyPage = ({ buildInfo={buildInfo} applicationsHost={applicationsHost} template={template} + templateParameters={templateParameters} quota_budget={quotaState.context.quota?.budget} /> , ): TypesGen.ParameterSchema => { diff --git a/site/src/util/formUtils.ts b/site/src/util/formUtils.ts index c0dc1aa53bac9..029a40727f087 100644 --- a/site/src/util/formUtils.ts +++ b/site/src/util/formUtils.ts @@ -36,7 +36,7 @@ interface FormHelpers { export const getFormHelpers = (form: FormikContextType, error?: Error | unknown) => ( - name: keyof T, + name: string, HelperText: ReactNode = "", backendErrorName?: string, ): FormHelpers => { diff --git a/site/src/xServices/createWorkspace/createWorkspaceXService.ts b/site/src/xServices/createWorkspace/createWorkspaceXService.ts index 80b9ce6b6f42f..433c341b3c41c 100644 --- a/site/src/xServices/createWorkspace/createWorkspaceXService.ts +++ b/site/src/xServices/createWorkspace/createWorkspaceXService.ts @@ -2,12 +2,14 @@ import { checkAuthorization, createWorkspace, getTemplates, + getTemplateVersionRichParameters, getTemplateVersionSchema, } from "api/api" import { CreateWorkspaceRequest, ParameterSchema, Template, + TemplateVersionParameter, User, Workspace, } from "api/typesGenerated" @@ -19,11 +21,13 @@ type CreateWorkspaceContext = { templateName: string templates?: Template[] selectedTemplate?: Template + templateParameters?: TemplateVersionParameter[] templateSchema?: ParameterSchema[] createWorkspaceRequest?: CreateWorkspaceRequest createdWorkspace?: Workspace createWorkspaceError?: Error | unknown getTemplatesError?: Error | unknown + getTemplateParametersError?: Error | unknown getTemplateSchemaError?: Error | unknown permissions?: Record checkPermissionsError?: Error | unknown @@ -52,6 +56,9 @@ export const createWorkspaceMachine = createMachine( getTemplates: { data: Template[] } + getTemplateParameters: { + data: TemplateVersionParameter[] + } getTemplateSchema: { data: ParameterSchema[] } @@ -88,7 +95,7 @@ export const createWorkspaceMachine = createMachine( src: "getTemplateSchema", onDone: { actions: ["assignTemplateSchema"], - target: "checkingPermissions", + target: "gettingTemplateParameters", }, onError: { actions: ["assignGetTemplateSchemaError"], @@ -96,6 +103,20 @@ export const createWorkspaceMachine = createMachine( }, }, }, + gettingTemplateParameters: { + entry: "clearGetTemplateParametersError", + invoke: { + src: "getTemplateParameters", + onDone: { + actions: ["assignTemplateParameters"], + target: "checkingPermissions", + }, + onError: { + actions: ["assignGetTemplateParametersError"], + target: "error", + }, + }, + }, checkingPermissions: { entry: "clearCheckPermissionsError", invoke: { @@ -145,6 +166,17 @@ export const createWorkspaceMachine = createMachine( { services: { getTemplates: (context) => getTemplates(context.organizationId), + getTemplateParameters: (context) => { + const { selectedTemplate } = context + + if (!selectedTemplate) { + throw new Error("No selected template") + } + + return getTemplateVersionRichParameters( + selectedTemplate.active_version_id, + ) + }, getTemplateSchema: (context) => { const { selectedTemplate } = context @@ -206,11 +238,13 @@ export const createWorkspaceMachine = createMachine( return templates.length > 0 ? templates[0] : undefined }, }), + assignTemplateParameters: assign({ + templateParameters: (_, event) => event.data, + }), assignTemplateSchema: assign({ // Only show parameters that are allowed to be overridden. // CLI code: https://github.com/coder/coder/blob/main/cli/create.go#L152-L155 - templateSchema: (_, event) => - event.data.filter((param) => param.allow_override_source), + templateSchema: (_, event) => event.data, }), assignPermissions: assign({ permissions: (_, event) => event.data as Record, @@ -239,6 +273,12 @@ export const createWorkspaceMachine = createMachine( clearGetTemplatesError: assign({ getTemplatesError: (_) => undefined, }), + assignGetTemplateParametersError: assign({ + getTemplateParametersError: (_, event) => event.data, + }), + clearGetTemplateParametersError: assign({ + getTemplateParametersError: (_) => undefined, + }), assignGetTemplateSchemaError: assign({ getTemplateSchemaError: (_, event) => event.data, }), diff --git a/site/src/xServices/workspace/workspaceBuildParametersXService.ts b/site/src/xServices/workspace/workspaceBuildParametersXService.ts new file mode 100644 index 0000000000000..b4b2e7fad42f1 --- /dev/null +++ b/site/src/xServices/workspace/workspaceBuildParametersXService.ts @@ -0,0 +1,223 @@ +import { + getTemplateVersionRichParameters, + getWorkspaceByOwnerAndName, + getWorkspaceBuildParameters, + postWorkspaceBuild, +} from "api/api" +import { + CreateWorkspaceBuildRequest, + Template, + TemplateVersionParameter, + Workspace, + WorkspaceBuild, + WorkspaceBuildParameter, +} from "api/typesGenerated" +import { assign, createMachine } from "xstate" + +type WorkspaceBuildParametersContext = { + workspaceOwner: string + workspaceName: string + + selectedWorkspace?: Workspace + selectedTemplate?: Template + templateParameters?: TemplateVersionParameter[] + workspaceBuildParameters?: WorkspaceBuildParameter[] + + createWorkspaceBuildRequest?: CreateWorkspaceBuildRequest + + getWorkspaceError?: Error | unknown + getTemplateParametersError?: Error | unknown + getWorkspaceBuildParametersError?: Error | unknown + updateWorkspaceError?: Error | unknown +} + +type UpdateWorkspaceEvent = { + type: "UPDATE_WORKSPACE" + request: CreateWorkspaceBuildRequest +} + +export const workspaceBuildParametersMachine = createMachine( + { + id: "workspaceBuildParametersState", + predictableActionArguments: true, + tsTypes: + {} as import("./workspaceBuildParametersXService.typegen").Typegen0, + schema: { + context: {} as WorkspaceBuildParametersContext, + events: {} as UpdateWorkspaceEvent, + services: {} as { + getWorkspace: { + data: Workspace + } + getTemplateParameters: { + data: TemplateVersionParameter[] + } + getWorkspaceBuildParameters: { + data: WorkspaceBuildParameter[] + } + updateWorkspace: { + data: WorkspaceBuild + } + }, + }, + initial: "gettingWorkspace", + states: { + gettingWorkspace: { + entry: "clearGetWorkspaceError", + invoke: { + src: "getWorkspace", + onDone: [ + { + actions: ["assignWorkspace"], + target: "gettingTemplateParameters", + }, + ], + onError: { + actions: ["assignGetWorkspaceError"], + target: "error", + }, + }, + }, + gettingTemplateParameters: { + entry: "clearGetTemplateParametersError", + invoke: { + src: "getTemplateParameters", + onDone: [ + { + actions: ["assignTemplateParameters"], + target: "gettingWorkspaceBuildParameters", + }, + ], + onError: { + actions: ["assignGetTemplateParametersError"], + target: "error", + }, + }, + }, + gettingWorkspaceBuildParameters: { + entry: "clearGetWorkspaceBuildParametersError", + invoke: { + src: "getWorkspaceBuildParameters", + onDone: { + actions: ["assignWorkspaceBuildParameters"], + target: "fillingParams", + }, + onError: { + actions: ["assignGetWorkspaceBuildParametersError"], + target: "error", + }, + }, + }, + fillingParams: { + on: { + UPDATE_WORKSPACE: { + actions: ["assignCreateWorkspaceBuildRequest"], + target: "updatingWorkspace", + }, + }, + }, + updatingWorkspace: { + entry: "clearUpdateWorkspaceError", + invoke: { + src: "updateWorkspace", + onDone: { + actions: ["onUpdateWorkspace"], + target: "updated", + }, + onError: { + actions: ["assignUpdateWorkspaceError"], + target: "fillingParams", + }, + }, + }, + updated: { + entry: "onUpdateWorkspace", + type: "final", + }, + error: {}, + }, + }, + { + services: { + getWorkspace: (context) => { + const { workspaceOwner, workspaceName } = context + return getWorkspaceByOwnerAndName(workspaceOwner, workspaceName) + }, + getTemplateParameters: (context) => { + const { selectedWorkspace } = context + + if (!selectedWorkspace) { + throw new Error("No workspace selected") + } + + return getTemplateVersionRichParameters( + selectedWorkspace.latest_build.template_version_id, + ) + }, + getWorkspaceBuildParameters: (context) => { + const { selectedWorkspace } = context + + if (!selectedWorkspace) { + throw new Error("No workspace selected") + } + + return getWorkspaceBuildParameters(selectedWorkspace.latest_build.id) + }, + updateWorkspace: (context) => { + const { selectedWorkspace, createWorkspaceBuildRequest } = context + + if (!selectedWorkspace) { + throw new Error("No workspace selected") + } + + if (!createWorkspaceBuildRequest) { + throw new Error("No workspace build request") + } + + return postWorkspaceBuild( + selectedWorkspace.id, + createWorkspaceBuildRequest, + ) + }, + }, + actions: { + assignWorkspace: assign({ + selectedWorkspace: (_, event) => event.data, + }), + assignTemplateParameters: assign({ + templateParameters: (_, event) => event.data, + }), + assignWorkspaceBuildParameters: assign({ + workspaceBuildParameters: (_, event) => event.data, + }), + + assignCreateWorkspaceBuildRequest: assign({ + createWorkspaceBuildRequest: (_, event) => event.request, + }), + assignGetWorkspaceError: assign({ + getWorkspaceError: (_, event) => event.data, + }), + clearGetWorkspaceError: assign({ + getWorkspaceError: (_) => undefined, + }), + assignGetTemplateParametersError: assign({ + getTemplateParametersError: (_, event) => event.data, + }), + clearGetTemplateParametersError: assign({ + getTemplateParametersError: (_) => undefined, + }), + clearGetWorkspaceBuildParametersError: assign({ + getWorkspaceBuildParametersError: (_) => undefined, + }), + assignGetWorkspaceBuildParametersError: assign({ + getWorkspaceBuildParametersError: (_, event) => event.data, + }), + clearUpdateWorkspaceError: assign({ + updateWorkspaceError: (_) => undefined, + }), + assignUpdateWorkspaceError: assign({ + updateWorkspaceError: (_, event) => event.data, + }), + }, + }, +) diff --git a/site/src/xServices/workspace/workspaceXService.ts b/site/src/xServices/workspace/workspaceXService.ts index bcb450d83d6b1..1c51e36a5fbf6 100644 --- a/site/src/xServices/workspace/workspaceXService.ts +++ b/site/src/xServices/workspace/workspaceXService.ts @@ -43,6 +43,8 @@ const moreBuildsAvailable = ( const Language = { getTemplateWarning: "Error updating workspace: latest template could not be fetched.", + getTemplateParametersWarning: + "Error updating workspace: template parameters could not be fetched.", buildError: "Workspace action failed.", } @@ -53,11 +55,13 @@ export interface WorkspaceContext { eventSource?: EventSource workspace?: TypesGen.Workspace template?: TypesGen.Template + templateParameters?: TypesGen.TemplateVersionParameter[] build?: TypesGen.WorkspaceBuild getWorkspaceError?: Error | unknown // these are labeled as warnings because they don't make the page unusable refreshWorkspaceWarning?: Error | unknown getTemplateWarning: Error | unknown + getTemplateParametersWarning: Error | unknown // Builds builds?: TypesGen.WorkspaceBuild[] getBuildsError?: Error | unknown @@ -130,6 +134,9 @@ export const workspaceMachine = createMachine( getTemplate: { data: TypesGen.Template } + getTemplateParameters: { + data: TypesGen.TemplateVersionParameter[] + } startWorkspaceWithLatestTemplate: { data: TypesGen.WorkspaceBuild } @@ -191,14 +198,14 @@ export const workspaceMachine = createMachine( tags: "loading", }, gettingTemplate: { - entry: "clearGettingTemplateWarning", + entry: "clearGetTemplateWarning", invoke: { src: "getTemplate", id: "getTemplate", onDone: [ { actions: "assignTemplate", - target: "gettingPermissions", + target: "gettingTemplateParameters", }, ], onError: [ @@ -213,6 +220,29 @@ export const workspaceMachine = createMachine( }, tags: "loading", }, + gettingTemplateParameters: { + entry: "clearGetTemplateParametersWarning", + invoke: { + src: "getTemplateParameters", + id: "getTemplateParameters", + onDone: [ + { + actions: "assignTemplateParameters", + target: "gettingPermissions", + }, + ], + onError: [ + { + actions: [ + "assignGetTemplateParametersWarning", + "displayGetTemplateParametersWarning", + ], + target: "error", + }, + ], + }, + tags: "loading", + }, gettingPermissions: { entry: "clearGetPermissionsError", invoke: { @@ -506,6 +536,9 @@ export const workspaceMachine = createMachine( assignTemplate: assign({ template: (_, event) => event.data, }), + assignTemplateParameters: assign({ + templateParameters: (_, event) => event.data, + }), assignPermissions: assign({ // Setting event.data as Permissions to be more stricted. So we know // what permissions we asked for. @@ -566,9 +599,18 @@ export const workspaceMachine = createMachine( displayGetTemplateWarning: () => { displayError(Language.getTemplateWarning) }, - clearGettingTemplateWarning: assign({ + clearGetTemplateWarning: assign({ getTemplateWarning: (_) => undefined, }), + assignGetTemplateParametersWarning: assign({ + getTemplateParametersWarning: (_, event) => event.data, + }), + displayGetTemplateParametersWarning: () => { + displayError(Language.getTemplateParametersWarning) + }, + clearGetTemplateParametersWarning: assign({ + getTemplateParametersWarning: (_) => undefined, + }), // Timeline assignBuilds: assign({ builds: (_, event) => event.data, @@ -629,6 +671,15 @@ export const workspaceMachine = createMachine( throw Error("Cannot get template without workspace") } }, + getTemplateParameters: async (context) => { + if (context.workspace) { + return await API.getTemplateVersionRichParameters( + context.workspace.latest_build.template_version_id, + ) + } else { + throw Error("Cannot get template parameters without workspace") + } + }, startWorkspaceWithLatestTemplate: (context) => async (send) => { if (context.workspace && context.template) { const startWorkspacePromise = await API.startWorkspace(