diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 5912070e2e322..ffc3a073ae2dc 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -1473,7 +1473,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/codersdk.CreateTemplateVersionDryRunRequest" + "$ref": "#/definitions/codersdk.CreateTemplateVersionRequest" } } ], @@ -5808,6 +5808,66 @@ const docTemplate = `{ } } }, + "codersdk.CreateTemplateVersionRequest": { + "type": "object", + "required": [ + "provisioner", + "storage_method" + ], + "properties": { + "example_id": { + "type": "string" + }, + "file_id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "parameter_values": { + "description": "ParameterValues allows for additional parameters to be provided\nduring the dry-run provision stage.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.CreateParameterRequest" + } + }, + "provisioner": { + "type": "string", + "enum": [ + "terraform", + "echo" + ] + }, + "storage_method": { + "enum": [ + "file" + ], + "allOf": [ + { + "$ref": "#/definitions/codersdk.ProvisionerStorageMethod" + } + ] + }, + "tags": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "template_id": { + "description": "TemplateID optionally associates a version with a template.", + "type": "string", + "format": "uuid" + }, + "user_variable_values": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.VariableValue" + } + } + } + }, "codersdk.CreateTestAuditLogRequest": { "type": "object", "properties": { @@ -7295,6 +7355,15 @@ const docTemplate = `{ "ProvisionerJobFailed" ] }, + "codersdk.ProvisionerStorageMethod": { + "type": "string", + "enum": [ + "file" + ], + "x-enum-varnames": [ + "ProvisionerStorageMethodFile" + ] + }, "codersdk.PutExtendWorkspaceRequest": { "type": "object", "required": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index b21f7e6b86166..00109f36735f6 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1287,7 +1287,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/codersdk.CreateTemplateVersionDryRunRequest" + "$ref": "#/definitions/codersdk.CreateTemplateVersionRequest" } } ], @@ -5156,6 +5156,58 @@ } } }, + "codersdk.CreateTemplateVersionRequest": { + "type": "object", + "required": ["provisioner", "storage_method"], + "properties": { + "example_id": { + "type": "string" + }, + "file_id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "parameter_values": { + "description": "ParameterValues allows for additional parameters to be provided\nduring the dry-run provision stage.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.CreateParameterRequest" + } + }, + "provisioner": { + "type": "string", + "enum": ["terraform", "echo"] + }, + "storage_method": { + "enum": ["file"], + "allOf": [ + { + "$ref": "#/definitions/codersdk.ProvisionerStorageMethod" + } + ] + }, + "tags": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "template_id": { + "description": "TemplateID optionally associates a version with a template.", + "type": "string", + "format": "uuid" + }, + "user_variable_values": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.VariableValue" + } + } + } + }, "codersdk.CreateTestAuditLogRequest": { "type": "object", "properties": { @@ -6543,6 +6595,11 @@ "ProvisionerJobFailed" ] }, + "codersdk.ProvisionerStorageMethod": { + "type": "string", + "enum": ["file"], + "x-enum-varnames": ["ProvisionerStorageMethodFile"] + }, "codersdk.PutExtendWorkspaceRequest": { "type": "object", "required": ["deadline"], diff --git a/coderd/templateversions.go b/coderd/templateversions.go index 04d7e25070e1f..4500a15022894 100644 --- a/coderd/templateversions.go +++ b/coderd/templateversions.go @@ -1077,7 +1077,7 @@ func (api *API) patchActiveTemplateVersion(rw http.ResponseWriter, r *http.Reque // @Produce json // @Tags Templates // @Param organization path string true "Organization ID" format(uuid) -// @Param request body codersdk.CreateTemplateVersionDryRunRequest true "Create template version request" +// @Param request body codersdk.CreateTemplateVersionRequest true "Create template version request" // @Success 201 {object} codersdk.TemplateVersion // @Router /organizations/{organization}/templateversions [post] func (api *API) postTemplateVersionsByOrganization(rw http.ResponseWriter, r *http.Request) { diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 9ca068a81aacb..6404d3bfbc8c3 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -1027,6 +1027,61 @@ CreateParameterRequest is a structure used to create a new parameter value for a | `user_variable_values` | array of [codersdk.VariableValue](#codersdkvariablevalue) | false | | | | `workspace_name` | string | false | | | +## codersdk.CreateTemplateVersionRequest + +```json +{ + "example_id": "string", + "file_id": "8a0cfb4f-ddc9-436d-91bb-75133c583767", + "name": "string", + "parameter_values": [ + { + "copy_from_parameter": "000e07d6-021d-446c-be14-48a9c20bca0b", + "destination_scheme": "none", + "name": "string", + "source_scheme": "none", + "source_value": "string" + } + ], + "provisioner": "terraform", + "storage_method": "file", + "tags": { + "property1": "string", + "property2": "string" + }, + "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc", + "user_variable_values": [ + { + "name": "string", + "value": "string" + } + ] +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ---------------------- | --------------------------------------------------------------------------- | -------- | ------------ | ---------------------------------------------------------------------------------------------------- | +| `example_id` | string | false | | | +| `file_id` | string | false | | | +| `name` | string | false | | | +| `parameter_values` | array of [codersdk.CreateParameterRequest](#codersdkcreateparameterrequest) | false | | Parameter values allows for additional parameters to be provided during the dry-run provision stage. | +| `provisioner` | string | true | | | +| `storage_method` | [codersdk.ProvisionerStorageMethod](#codersdkprovisionerstoragemethod) | true | | | +| `tags` | object | false | | | +| ยป `[any property]` | string | false | | | +| `template_id` | string | false | | Template ID optionally associates a version with a template. | +| `user_variable_values` | array of [codersdk.VariableValue](#codersdkvariablevalue) | false | | | + +#### Enumerated Values + +| Property | Value | +| ---------------- | ----------- | +| `provisioner` | `terraform` | +| `provisioner` | `echo` | +| `storage_method` | `file` | + ## codersdk.CreateTestAuditLogRequest ```json @@ -4059,6 +4114,20 @@ Parameter represents a set value for the scope. | `canceled` | | `failed` | +## codersdk.ProvisionerStorageMethod + +```json +"file" +``` + +### Properties + +#### Enumerated Values + +| Value | +| ------ | +| `file` | + ## codersdk.PutExtendWorkspaceRequest ```json diff --git a/docs/api/templates.md b/docs/api/templates.md index b48b805d7c58e..a8bb6ce443908 100644 --- a/docs/api/templates.md +++ b/docs/api/templates.md @@ -545,6 +545,9 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/templa ```json { + "example_id": "string", + "file_id": "8a0cfb4f-ddc9-436d-91bb-75133c583767", + "name": "string", "parameter_values": [ { "copy_from_parameter": "000e07d6-021d-446c-be14-48a9c20bca0b", @@ -554,28 +557,28 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/templa "source_value": "string" } ], - "rich_parameter_values": [ - { - "name": "string", - "value": "string" - } - ], + "provisioner": "terraform", + "storage_method": "file", + "tags": { + "property1": "string", + "property2": "string" + }, + "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc", "user_variable_values": [ { "name": "string", "value": "string" } - ], - "workspace_name": "string" + ] } ``` ### Parameters -| Name | In | Type | Required | Description | -| -------------- | ---- | ---------------------------------------------------------------------------------------------------- | -------- | ------------------------------- | -| `organization` | path | string(uuid) | true | Organization ID | -| `body` | body | [codersdk.CreateTemplateVersionDryRunRequest](schemas.md#codersdkcreatetemplateversiondryrunrequest) | true | Create template version request | +| Name | In | Type | Required | Description | +| -------------- | ---- | ---------------------------------------------------------------------------------------- | -------- | ------------------------------- | +| `organization` | path | string(uuid) | true | Organization ID | +| `body` | body | [codersdk.CreateTemplateVersionRequest](schemas.md#codersdkcreatetemplateversionrequest) | true | Create template version request | ### Example responses diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index a1cd2ab296f0f..e2a184cc599cf 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -123,6 +123,9 @@ const StarterTemplatePage = lazy( const CreateTemplatePage = lazy( () => import("./pages/CreateTemplatePage/CreateTemplatePage"), ) +const TemplateVariablesPage = lazy( + () => import("./pages/TemplateVariablesPage/TemplateVariablesPage"), +) export const AppRouter: FC = () => { return ( @@ -160,6 +163,7 @@ export const AppRouter: FC = () => { } /> } /> + } /> } /> diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 303042f2ccf5e..75cb276c40cc6 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -235,6 +235,15 @@ export const getTemplateVersionResources = async ( return response.data } +export const getTemplateVersionVariables = async ( + versionId: string, +): Promise => { + const response = await axios.get( + `/api/v2/templateversions/${versionId}/variables`, + ) + return response.data +} + export const getTemplateVersions = async ( templateId: string, ): Promise => { diff --git a/site/src/components/TemplateLayout/TemplatePageHeader.tsx b/site/src/components/TemplateLayout/TemplatePageHeader.tsx index 69498e460fb5c..fdb43aa4875f3 100644 --- a/site/src/components/TemplateLayout/TemplatePageHeader.tsx +++ b/site/src/components/TemplateLayout/TemplatePageHeader.tsx @@ -2,6 +2,7 @@ import Button from "@material-ui/core/Button" import DeleteOutlined from "@material-ui/icons/DeleteOutlined" import AddCircleOutline from "@material-ui/icons/AddCircleOutline" import SettingsOutlined from "@material-ui/icons/SettingsOutlined" +import CodeOutlined from "@material-ui/icons/CodeOutlined" import { AuthorizationResponse, Template } from "api/typesGenerated" import { Avatar } from "components/Avatar/Avatar" import { Maybe } from "components/Conditionals/Maybe" @@ -19,6 +20,7 @@ import { Margins } from "components/Margins/Margins" const Language = { editButton: "Edit", + variablesButton: "Variables", settingsButton: "Settings", createButton: "Create workspace", deleteButton: "Delete", @@ -37,6 +39,19 @@ const TemplateSettingsButton: FC<{ templateName: string }> = ({ ) +const TemplateVariablesButton: FC<{ templateName: string }> = ({ + templateName, +}) => ( + +) + const CreateWorkspaceButton: FC<{ templateName: string className?: string @@ -80,6 +95,7 @@ export const TemplatePageHeader: FC = ({ onClick={deleteTemplate.openDeleteConfirmation} /> + diff --git a/site/src/components/TemplateVariableField/TemplateVariableField.tsx b/site/src/components/TemplateVariableField/TemplateVariableField.tsx new file mode 100644 index 0000000000000..3920896ea8353 --- /dev/null +++ b/site/src/components/TemplateVariableField/TemplateVariableField.tsx @@ -0,0 +1,84 @@ +import FormControlLabel from "@material-ui/core/FormControlLabel" +import Radio from "@material-ui/core/Radio" +import RadioGroup from "@material-ui/core/RadioGroup" +import TextField from "@material-ui/core/TextField" +import { TemplateVersionVariable } from "api/typesGenerated" +import { FC, useState } from "react" +import { useTranslation } from "react-i18next" + +export const SensitiveVariableHelperText = () => { + const { t } = useTranslation("templateVariablesPage") + return {t("sensitiveVariableHelperText")} +} + +export interface TemplateVariableFieldProps { + templateVersionVariable: TemplateVersionVariable + initialValue: string + disabled: boolean + onChange: (value: string) => void +} + +export const TemplateVariableField: FC = ({ + templateVersionVariable, + initialValue, + disabled, + onChange, + ...props +}) => { + const [variableValue, setVariableValue] = useState(initialValue) + if (isBoolean(templateVersionVariable)) { + return ( + { + onChange(event.target.value) + }} + > + } + label="True" + /> + } + label="False" + /> + + ) + } + + return ( + { + setVariableValue(event.target.value) + onChange(event.target.value) + }} + variant="outlined" + /> + ) +} + +const isBoolean = (variable: TemplateVersionVariable) => { + return variable.type === "bool" +} diff --git a/site/src/i18n/en/index.ts b/site/src/i18n/en/index.ts index e838753608850..58f6530cab049 100644 --- a/site/src/i18n/en/index.ts +++ b/site/src/i18n/en/index.ts @@ -9,6 +9,7 @@ import buildPage from "./buildPage.json" import workspacesPage from "./workspacesPage.json" import usersPage from "./usersPage.json" import templateSettingsPage from "./templateSettingsPage.json" +import templateVariablesPage from "./templateVariablesPage.json" import templateVersionPage from "./templateVersionPage.json" import loginPage from "./loginPage.json" import workspaceBuildParametersPage from "./workspaceBuildParametersPage.json" @@ -33,6 +34,7 @@ export const en = { workspacesPage, usersPage, templateSettingsPage, + templateVariablesPage, templateVersionPage, loginPage, workspaceBuildParametersPage, diff --git a/site/src/i18n/en/templateVariablesPage.json b/site/src/i18n/en/templateVariablesPage.json new file mode 100644 index 0000000000000..6a885a8bde64d --- /dev/null +++ b/site/src/i18n/en/templateVariablesPage.json @@ -0,0 +1,6 @@ +{ + "title": "Template variables", + "sensitiveVariableHelperText": "This variable is sensitive. The previous value will be used if empty.", + "validationRequiredVariable": "Variable is required.", + "unusedVariablesNotice": "This template does not use managed variables." +} diff --git a/site/src/pages/TemplateSettingsPage/TemplateSettingsPageView.stories.tsx b/site/src/pages/TemplateSettingsPage/TemplateSettingsPageView.stories.tsx index bd1baeca97622..0f17b2ff20bf6 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSettingsPageView.stories.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSettingsPageView.stories.tsx @@ -51,7 +51,7 @@ SaveTemplateSettingsError.args = { }), }, initialTouched: { - name: true, + allow_user_cancel_workspace_jobs: true, }, onSubmit: action("onSubmit"), onCancel: action("cancel"), diff --git a/site/src/pages/TemplateVariablesPage/TemplateVariablesForm.tsx b/site/src/pages/TemplateVariablesPage/TemplateVariablesForm.tsx new file mode 100644 index 0000000000000..cbea09ee748f5 --- /dev/null +++ b/site/src/pages/TemplateVariablesPage/TemplateVariablesForm.tsx @@ -0,0 +1,189 @@ +import { + CreateTemplateVersionRequest, + TemplateVersion, + TemplateVersionVariable, + VariableValue, +} from "api/typesGenerated" +import { FormikContextType, FormikTouched, useFormik } from "formik" +import { FC } from "react" +import { getFormHelpers } from "util/formUtils" +import * as Yup from "yup" +import { useTranslation } from "react-i18next" +import { + FormFields, + FormSection, + HorizontalForm, + FormFooter, +} from "components/HorizontalForm/HorizontalForm" +import { + SensitiveVariableHelperText, + TemplateVariableField, +} from "components/TemplateVariableField/TemplateVariableField" + +export const getValidationSchema = (): Yup.AnyObjectSchema => Yup.object() + +export interface TemplateVariablesForm { + templateVersion: TemplateVersion + templateVariables: TemplateVersionVariable[] + onSubmit: (data: CreateTemplateVersionRequest) => void + onCancel: () => void + isSubmitting: boolean + error?: unknown + // Helpful to show field errors on Storybook + initialTouched?: FormikTouched +} +export const TemplateVariablesForm: FC = ({ + templateVersion, + templateVariables, + onSubmit, + onCancel, + error, + isSubmitting, + initialTouched, +}) => { + const initialUserVariableValues = + selectInitialUserVariableValues(templateVariables) + const form: FormikContextType = + useFormik({ + initialValues: { + template_id: templateVersion.template_id, + provisioner: "terraform", + storage_method: "file", + tags: templateVersion.job.tags, + file_id: templateVersion.job.file_id, + user_variable_values: initialUserVariableValues, + }, + validationSchema: Yup.object({ + user_variable_values: ValidationSchemaForTemplateVariables( + "templateVariablesPage", + templateVariables, + ), + }), + onSubmit, + initialTouched, + }) + const getFieldHelpers = getFormHelpers( + form, + error, + ) + const { t } = useTranslation("templateVariablesPage") + + return ( + + {templateVariables.map((templateVariable, index) => { + let fieldHelpers + if (templateVariable.sensitive) { + fieldHelpers = getFieldHelpers( + "user_variable_values[" + index + "].value", + , + ) + } else { + fieldHelpers = getFieldHelpers( + "user_variable_values[" + index + "].value", + ) + } + + return ( + + + { + form.setFieldValue("user_variable_values." + index, { + name: templateVariable.name, + value: value, + }) + }} + /> + + + ) + })} + + + + ) +} + +export const selectInitialUserVariableValues = ( + templateVariables: TemplateVersionVariable[], +): VariableValue[] => { + const defaults: VariableValue[] = [] + templateVariables.forEach((templateVariable) => { + // Boolean variables must be always either "true" or "false" + if (templateVariable.type === "bool" && templateVariable.value === "") { + defaults.push({ + name: templateVariable.name, + value: templateVariable.default_value, + }) + return + } + + if (templateVariable.sensitive) { + defaults.push({ + name: templateVariable.name, + value: "", + }) + return + } + + if (templateVariable.required && templateVariable.value === "") { + defaults.push({ + name: templateVariable.name, + value: templateVariable.default_value, + }) + return + } + + defaults.push({ + name: templateVariable.name, + value: templateVariable.value, + }) + }) + return defaults +} + +const ValidationSchemaForTemplateVariables = ( + ns: string, + templateVariables: TemplateVersionVariable[], +): Yup.AnySchema => { + const { t } = useTranslation(ns) + + 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 templateVariable = templateVariables.find( + (variable) => variable.name === name, + ) + if (templateVariable && templateVariable.sensitive) { + // It's possible that the secret is already stored in database, + // so we can't properly verify the "required" condition. + return true + } + if (templateVariable && templateVariable.required) { + if (!val || val.length === 0) { + return ctx.createError({ + path: ctx.path, + message: t("validationRequiredVariable"), + }) + } + } + return true + }), + }), + ) + .required() +} diff --git a/site/src/pages/TemplateVariablesPage/TemplateVariablesPage.test.tsx b/site/src/pages/TemplateVariablesPage/TemplateVariablesPage.test.tsx new file mode 100644 index 0000000000000..240ef507a86d2 --- /dev/null +++ b/site/src/pages/TemplateVariablesPage/TemplateVariablesPage.test.tsx @@ -0,0 +1,192 @@ +import { screen, waitFor } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { + MockTemplate, + MockTemplateVersion2, + MockTemplateVersion, + MockTemplateVersionVariable1, + MockTemplateVersionVariable2, + renderWithAuth, + MockTemplateVersionVariable5, +} from "testHelpers/renderHelpers" +import * as API from "api/api" +import i18next from "i18next" +import TemplateVariablesPage from "./TemplateVariablesPage" +import { Language as FooterFormLanguage } from "components/FormFooter/FormFooter" +import { Route } from "react-router-dom" +import * as router from "react-router" + +const navigate = jest.fn() + +const { t } = i18next + +const validFormValues = { + first_variable: "Hello world", + second_variable: "123", +} + +const pageTitleText = t("title", { ns: "templateVariablesPage" }) + +const validationRequiredField = t("validationRequiredVariable", { + ns: "templateVariablesPage", +}) + +const renderTemplateVariablesPage = () => { + return renderWithAuth(, { + route: `/templates/${MockTemplate.name}/variables`, + path: `/templates/:template/variables`, + routes: ( + }> + ), + }) +} + +describe("TemplateVariablesPage", () => { + it("renders with variables", async () => { + jest.spyOn(API, "getTemplateByName").mockResolvedValueOnce(MockTemplate) + jest + .spyOn(API, "getTemplateVersion") + .mockResolvedValueOnce(MockTemplateVersion) + jest + .spyOn(API, "getTemplateVersionVariables") + .mockResolvedValueOnce([ + MockTemplateVersionVariable1, + MockTemplateVersionVariable2, + ]) + + renderTemplateVariablesPage() + + const element = await screen.findByText(pageTitleText) + expect(element).toBeDefined() + + const firstVariable = await screen.findByLabelText( + MockTemplateVersionVariable1.name, + ) + expect(firstVariable).toBeDefined() + + const secondVariable = await screen.findByLabelText( + MockTemplateVersionVariable2.name, + ) + expect(secondVariable).toBeDefined() + }) + + it("user submits the form successfully", async () => { + jest.spyOn(API, "getTemplateByName").mockResolvedValueOnce(MockTemplate) + jest + .spyOn(API, "getTemplateVersion") + .mockResolvedValueOnce(MockTemplateVersion) + jest + .spyOn(API, "getTemplateVersionVariables") + .mockResolvedValueOnce([ + MockTemplateVersionVariable1, + MockTemplateVersionVariable2, + ]) + jest + .spyOn(API, "createTemplateVersion") + .mockResolvedValueOnce(MockTemplateVersion2) + jest.spyOn(API, "updateActiveTemplateVersion").mockResolvedValueOnce({ + message: "done", + }) + jest.spyOn(router, "useNavigate").mockImplementation(() => navigate) + + renderTemplateVariablesPage() + + const element = await screen.findByText(pageTitleText) + expect(element).toBeDefined() + + const firstVariable = await screen.findByLabelText( + MockTemplateVersionVariable1.name, + ) + expect(firstVariable).toBeDefined() + + const secondVariable = await screen.findByLabelText( + MockTemplateVersionVariable2.name, + ) + expect(secondVariable).toBeDefined() + + // Fill the form + const firstVariableField = await screen.findByLabelText( + MockTemplateVersionVariable1.name, + ) + await userEvent.clear(firstVariableField) + await userEvent.type(firstVariableField, validFormValues.first_variable) + + const secondVariableField = await screen.findByLabelText( + MockTemplateVersionVariable2.name, + ) + await userEvent.clear(secondVariableField) + await userEvent.type(secondVariableField, validFormValues.second_variable) + + // Submit the form + const submitButton = await screen.findByText( + FooterFormLanguage.defaultSubmitLabel, + ) + await userEvent.click(submitButton) + + // Wait for redirect + await waitFor(() => + expect(navigate).toHaveBeenCalledWith(`/templates/${MockTemplate.name}`), + ) + }) + + it("user forgets to fill the required field", async () => { + jest.spyOn(API, "getTemplateByName").mockResolvedValueOnce(MockTemplate) + jest + .spyOn(API, "getTemplateVersion") + .mockResolvedValueOnce(MockTemplateVersion) + jest + .spyOn(API, "getTemplateVersionVariables") + .mockResolvedValueOnce([ + MockTemplateVersionVariable1, + MockTemplateVersionVariable5, + ]) + jest + .spyOn(API, "createTemplateVersion") + .mockResolvedValueOnce(MockTemplateVersion2) + jest.spyOn(API, "updateActiveTemplateVersion").mockResolvedValueOnce({ + message: "done", + }) + jest.spyOn(router, "useNavigate").mockImplementation(() => navigate) + + renderTemplateVariablesPage() + + const element = await screen.findByText(pageTitleText) + expect(element).toBeDefined() + + const firstVariable = await screen.findByLabelText( + MockTemplateVersionVariable1.name, + ) + expect(firstVariable).toBeDefined() + + const fifthVariable = await screen.findByLabelText( + MockTemplateVersionVariable5.name, + ) + expect(fifthVariable).toBeDefined() + + // Submit the form + const submitButton = await screen.findByText( + FooterFormLanguage.defaultSubmitLabel, + ) + await userEvent.click(submitButton) + + // Check validation error + const validationError = await screen.findByText(validationRequiredField) + expect(validationError).toBeDefined() + }) + + it("no managed variables", async () => { + jest.spyOn(API, "getTemplateByName").mockResolvedValueOnce(MockTemplate) + jest + .spyOn(API, "getTemplateVersion") + .mockResolvedValueOnce(MockTemplateVersion) + jest.spyOn(API, "getTemplateVersionVariables").mockResolvedValueOnce([]) + + renderTemplateVariablesPage() + + const element = await screen.findByText(pageTitleText) + expect(element).toBeDefined() + + const goBackButton = await screen.findByText("Go back") + expect(goBackButton).toBeDefined() + }) +}) diff --git a/site/src/pages/TemplateVariablesPage/TemplateVariablesPage.tsx b/site/src/pages/TemplateVariablesPage/TemplateVariablesPage.tsx new file mode 100644 index 0000000000000..5a3e94e0af978 --- /dev/null +++ b/site/src/pages/TemplateVariablesPage/TemplateVariablesPage.tsx @@ -0,0 +1,103 @@ +import { useMachine } from "@xstate/react" +import { + CreateTemplateVersionRequest, + TemplateVersionVariable, + VariableValue, +} from "api/typesGenerated" +import { useOrganizationId } from "hooks/useOrganizationId" +import { FC } from "react" +import { Helmet } from "react-helmet-async" +import { useTranslation } from "react-i18next" +import { useNavigate, useParams } from "react-router-dom" +import { templateVariablesMachine } from "xServices/template/templateVariablesXService" +import { pageTitle } from "../../util/page" +import { TemplateVariablesPageView } from "./TemplateVariablesPageView" + +export const TemplateVariablesPage: FC = () => { + const { template: templateName } = useParams() as { + organization: string + template: string + } + const organizationId = useOrganizationId() + const navigate = useNavigate() + const [state, send] = useMachine(templateVariablesMachine, { + context: { + organizationId, + templateName, + }, + actions: { + onUpdateTemplate: () => { + navigate(`/templates/${templateName}`) + }, + }, + }) + const { + activeTemplateVersion, + templateVariables, + getTemplateDataError, + updateTemplateError, + } = state.context + + const { t } = useTranslation("templateVariablesPage") + return ( + <> + + Codestin Search App + + + { + navigate(`/templates/${templateName}`) + }} + onSubmit={(formData) => { + const request = filterEmptySensitiveVariables( + formData, + templateVariables, + ) + send({ type: "UPDATE_TEMPLATE_EVENT", request: request }) + }} + /> + + ) +} + +const filterEmptySensitiveVariables = ( + request: CreateTemplateVersionRequest, + templateVariables?: TemplateVersionVariable[], +): CreateTemplateVersionRequest => { + const filtered: VariableValue[] = [] + + if (!templateVariables) { + return request + } + + if (request.user_variable_values) { + request.user_variable_values.forEach((variableValue) => { + const templateVariable = templateVariables.find( + (t) => t.name === variableValue.name, + ) + if ( + templateVariable && + templateVariable.sensitive && + variableValue.value === "" + ) { + return + } + filtered.push(variableValue) + }) + } + + return { + ...request, + user_variable_values: filtered, + } +} + +export default TemplateVariablesPage diff --git a/site/src/pages/TemplateVariablesPage/TemplateVariablesPageView.stories.tsx b/site/src/pages/TemplateVariablesPage/TemplateVariablesPageView.stories.tsx new file mode 100644 index 0000000000000..00fa2aff55d4c --- /dev/null +++ b/site/src/pages/TemplateVariablesPage/TemplateVariablesPageView.stories.tsx @@ -0,0 +1,78 @@ +import { action } from "@storybook/addon-actions" +import { Story } from "@storybook/react" +import { + makeMockApiError, + MockTemplateVersion, + MockTemplateVersionVariable1, + MockTemplateVersionVariable2, + MockTemplateVersionVariable3, + MockTemplateVersionVariable4, + MockTemplateVersionVariable5, +} from "testHelpers/entities" +import { + TemplateVariablesPageView, + TemplateVariablesPageViewProps, +} from "./TemplateVariablesPageView" + +export default { + title: "pages/TemplateVariablesPageView", + component: TemplateVariablesPageView, +} + +const TemplateVariables: Story = (args) => ( + +) + +export const Loading = TemplateVariables.bind({}) +Loading.args = { + onSubmit: action("onSubmit"), + onCancel: action("cancel"), +} + +export const Basic = TemplateVariables.bind({}) +Basic.args = { + templateVersion: MockTemplateVersion, + templateVariables: [ + MockTemplateVersionVariable1, + MockTemplateVersionVariable2, + MockTemplateVersionVariable3, + MockTemplateVersionVariable4, + ], + onSubmit: action("onSubmit"), + onCancel: action("cancel"), +} + +// This example isn't fully supported. As "user_variable_values" is an array, +// FormikTouched can't properly handle this. +// See: https://github.com/jaredpalmer/formik/issues/2022 +export const RequiredVariable = TemplateVariables.bind({}) +RequiredVariable.args = { + templateVersion: MockTemplateVersion, + templateVariables: [ + MockTemplateVersionVariable4, + MockTemplateVersionVariable5, + ], + onSubmit: action("onSubmit"), + onCancel: action("cancel"), + initialTouched: { + user_variable_values: true, + }, +} + +export const WithUpdateTemplateError = TemplateVariables.bind({}) +WithUpdateTemplateError.args = { + templateVersion: MockTemplateVersion, + templateVariables: [ + MockTemplateVersionVariable1, + MockTemplateVersionVariable2, + MockTemplateVersionVariable3, + MockTemplateVersionVariable4, + ], + errors: { + updateTemplateError: makeMockApiError({ + message: "Something went wrong.", + }), + }, + onSubmit: action("onSubmit"), + onCancel: action("cancel"), +} diff --git a/site/src/pages/TemplateVariablesPage/TemplateVariablesPageView.tsx b/site/src/pages/TemplateVariablesPage/TemplateVariablesPageView.tsx new file mode 100644 index 0000000000000..9048c6bdcfe69 --- /dev/null +++ b/site/src/pages/TemplateVariablesPage/TemplateVariablesPageView.tsx @@ -0,0 +1,93 @@ +import { + CreateTemplateVersionRequest, + TemplateVersion, + TemplateVersionVariable, +} from "api/typesGenerated" +import { AlertBanner } from "components/AlertBanner/AlertBanner" +import { Loader } from "components/Loader/Loader" +import { ComponentProps, FC } from "react" +import { TemplateVariablesForm } from "./TemplateVariablesForm" +import { Stack } from "components/Stack/Stack" +import { makeStyles } from "@material-ui/core/styles" +import { useTranslation } from "react-i18next" +import { FullPageHorizontalForm } from "components/FullPageForm/FullPageHorizontalForm" +import { GoBackButton } from "components/GoBackButton/GoBackButton" + +export interface TemplateVariablesPageViewProps { + templateVersion?: TemplateVersion + templateVariables?: TemplateVersionVariable[] + onSubmit: (data: CreateTemplateVersionRequest) => void + onCancel: () => void + isSubmitting: boolean + errors?: { + getTemplateDataError?: unknown + updateTemplateError?: unknown + } + initialTouched?: ComponentProps< + typeof TemplateVariablesForm + >["initialTouched"] +} + +export const TemplateVariablesPageView: FC = ({ + templateVersion, + templateVariables, + onCancel, + onSubmit, + isSubmitting, + errors = {}, + initialTouched, +}) => { + const classes = useStyles() + const isLoading = + !templateVersion && + !templateVariables && + !errors.getTemplateDataError && + !errors.updateTemplateError + const { t } = useTranslation("templateVariablesPage") + + return ( + + {Boolean(errors.getTemplateDataError) && ( + + + + )} + {Boolean(errors.updateTemplateError) && ( + + + + )} + {isLoading && } + {templateVersion && templateVariables && templateVariables.length > 0 && ( + + )} + {templateVariables && templateVariables.length === 0 && ( +
+ +
+ +
+
+ )} +
+ ) +} + +const useStyles = makeStyles((theme) => ({ + errorContainer: { + marginBottom: theme.spacing(2), + }, + goBackSection: { + display: "flex", + width: "100%", + marginTop: 32, + }, +})) diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 8881682f94d38..fe77ac7f79ee1 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -787,6 +787,56 @@ export const MockTemplateVersionParameter5: TypesGen.TemplateVersionParameter = validation_monotonic: "decreasing", } +export const MockTemplateVersionVariable1: TypesGen.TemplateVersionVariable = { + name: "first_variable", + description: "This is first variable.", + type: "string", + value: "", + default_value: "abc", + required: false, + sensitive: false, +} + +export const MockTemplateVersionVariable2: TypesGen.TemplateVersionVariable = { + name: "second_variable", + description: "This is second variable.", + type: "number", + value: "5", + default_value: "3", + required: false, + sensitive: false, +} + +export const MockTemplateVersionVariable3: TypesGen.TemplateVersionVariable = { + name: "third_variable", + description: "This is third variable.", + type: "bool", + value: "", + default_value: "false", + required: false, + sensitive: false, +} + +export const MockTemplateVersionVariable4: TypesGen.TemplateVersionVariable = { + name: "fourth_variable", + description: "This is fourth variable.", + type: "string", + value: "defghijk", + default_value: "", + required: true, + sensitive: true, +} + +export const MockTemplateVersionVariable5: TypesGen.TemplateVersionVariable = { + name: "fifth_variable", + description: "This is fifth variable.", + type: "string", + value: "", + default_value: "", + required: true, + sensitive: false, +} + // requests the MockWorkspace export const MockWorkspaceRequest: TypesGen.CreateWorkspaceRequest = { name: "test", diff --git a/site/src/xServices/template/templateVariablesXService.ts b/site/src/xServices/template/templateVariablesXService.ts new file mode 100644 index 0000000000000..898670c8bf5b5 --- /dev/null +++ b/site/src/xServices/template/templateVariablesXService.ts @@ -0,0 +1,263 @@ +import { + createTemplateVersion, + getTemplateByName, + getTemplateVersion, + getTemplateVersionVariables, + updateActiveTemplateVersion, +} from "api/api" +import { + CreateTemplateVersionRequest, + Template, + TemplateVersion, + TemplateVersionVariable, +} from "api/typesGenerated" +import { assign, createMachine } from "xstate" +import { delay } from "util/delay" +import { Message } from "api/types" + +type TemplateVariablesContext = { + organizationId: string + templateName: string + + template?: Template + activeTemplateVersion?: TemplateVersion + templateVariables?: TemplateVersionVariable[] + + createTemplateVersionRequest?: CreateTemplateVersionRequest + newTemplateVersion?: TemplateVersion + + getTemplateDataError?: Error | unknown + updateTemplateError?: Error | unknown +} + +type UpdateTemplateEvent = { + type: "UPDATE_TEMPLATE_EVENT" + request: CreateTemplateVersionRequest +} + +export const templateVariablesMachine = createMachine( + { + id: "templateVariablesState", + predictableActionArguments: true, + tsTypes: {} as import("./templateVariablesXService.typegen").Typegen0, + schema: { + context: {} as TemplateVariablesContext, + events: {} as UpdateTemplateEvent, + services: {} as { + getTemplate: { + data: Template + } + getActiveTemplateVersion: { + data: TemplateVersion + } + getTemplateVariables: { + data: TemplateVersionVariable[] + } + createNewTemplateVersion: { + data: TemplateVersion + } + waitForJobToBeCompleted: { + data: TemplateVersion + } + updateTemplate: { + data: Message + } + }, + }, + initial: "gettingTemplate", + states: { + gettingTemplate: { + entry: "clearGetTemplateDataError", + invoke: { + src: "getTemplate", + onDone: [ + { + actions: ["assignTemplate"], + target: "gettingActiveTemplateVersion", + }, + ], + onError: { + actions: ["assignGetTemplateDataError"], + target: "error", + }, + }, + }, + gettingActiveTemplateVersion: { + entry: "clearGetTemplateDataError", + invoke: { + src: "getActiveTemplateVersion", + onDone: [ + { + actions: ["assignActiveTemplateVersion"], + target: "gettingTemplateVariables", + }, + ], + onError: { + actions: ["assignGetTemplateDataError"], + target: "error", + }, + }, + }, + gettingTemplateVariables: { + entry: "clearGetTemplateDataError", + invoke: { + src: "getTemplateVariables", + onDone: [ + { + actions: ["assignTemplateVariables"], + target: "fillingParams", + }, + ], + onError: { + actions: ["assignGetTemplateDataError"], + target: "error", + }, + }, + }, + fillingParams: { + on: { + UPDATE_TEMPLATE_EVENT: { + actions: ["assignCreateTemplateVersionRequest"], + target: "creatingTemplateVersion", + }, + }, + }, + creatingTemplateVersion: { + entry: "clearUpdateTemplateError", + invoke: { + src: "createNewTemplateVersion", + onDone: { + actions: ["assignNewTemplateVersion"], + target: "waitingForJobToBeCompleted", + }, + onError: { + actions: ["assignGetTemplateDataError"], + target: "fillingParams", + }, + }, + tags: ["submitting"], + }, + waitingForJobToBeCompleted: { + invoke: { + src: "waitForJobToBeCompleted", + onDone: [ + { + actions: ["assignNewTemplateVersion"], + target: "updatingTemplate", + }, + ], + onError: { + actions: ["assignUpdateTemplateError"], + target: "fillingParams", + }, + }, + tags: ["submitting"], + }, + updatingTemplate: { + invoke: { + src: "updateTemplate", + onDone: { + target: "updated", + actions: ["onUpdateTemplate"], + }, + onError: { + actions: ["assignUpdateTemplateError"], + target: "fillingParams", + }, + }, + tags: ["submitting"], + }, + updated: { + entry: "onUpdateTemplate", + type: "final", + }, + error: {}, + }, + }, + { + services: { + getTemplate: ({ organizationId, templateName }) => { + return getTemplateByName(organizationId, templateName) + }, + getActiveTemplateVersion: ({ template }) => { + if (!template) { + throw new Error("No template selected") + } + return getTemplateVersion(template.active_version_id) + }, + getTemplateVariables: ({ template }) => { + if (!template) { + throw new Error("No template selected") + } + return getTemplateVersionVariables(template.active_version_id) + }, + createNewTemplateVersion: ({ + organizationId, + createTemplateVersionRequest, + }) => { + if (!createTemplateVersionRequest) { + throw new Error("Missing request body") + } + return createTemplateVersion( + organizationId, + createTemplateVersionRequest, + ) + }, + waitForJobToBeCompleted: async ({ newTemplateVersion }) => { + if (!newTemplateVersion) { + throw new Error("Template version is undefined") + } + + let status = newTemplateVersion.job.status + while (["pending", "running"].includes(status)) { + newTemplateVersion = await getTemplateVersion(newTemplateVersion.id) + status = newTemplateVersion.job.status + await delay(2_000) + } + return newTemplateVersion + }, + updateTemplate: ({ template, newTemplateVersion }) => { + if (!template) { + throw new Error("No template selected") + } + + if (!newTemplateVersion) { + throw new Error("New template version is undefined") + } + + return updateActiveTemplateVersion(template.id, { + id: newTemplateVersion.id, + }) + }, + }, + actions: { + assignTemplate: assign({ + template: (_, event) => event.data, + }), + assignActiveTemplateVersion: assign({ + activeTemplateVersion: (_, event) => event.data, + }), + assignTemplateVariables: assign({ + templateVariables: (_, event) => event.data, + }), + assignCreateTemplateVersionRequest: assign({ + createTemplateVersionRequest: (_, event) => event.request, + }), + assignNewTemplateVersion: assign({ + newTemplateVersion: (_, event) => event.data, + }), + assignGetTemplateDataError: assign({ + getTemplateDataError: (_, event) => event.data, + }), + clearGetTemplateDataError: assign({ + getTemplateDataError: (_) => undefined, + }), + assignUpdateTemplateError: assign({ + updateTemplateError: (_, event) => event.data, + }), + clearUpdateTemplateError: assign({ + updateTemplateError: (_) => undefined, + }), + }, + }, +)