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,
+}) => (
+ }
+ >
+ {Language.variablesButton}
+
+)
+
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,
+ }),
+ },
+ },
+)