feat: Add form to modify managed Template variables (#6257)

This commit is contained in:
Marcin Tojek
2023-03-02 14:11:16 +01:00
committed by GitHub
parent 8cf292f50a
commit 67db36bf81
19 changed files with 1303 additions and 16 deletions
+70 -1
View File
@@ -1473,7 +1473,7 @@ const docTemplate = `{
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.CreateTemplateVersionDryRunRequest"
"$ref": "#/definitions/codersdk.CreateTemplateVersionRequest"
}
}
],
@@ -5846,6 +5846,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": {
@@ -7348,6 +7408,15 @@ const docTemplate = `{
"ProvisionerJobFailed"
]
},
"codersdk.ProvisionerStorageMethod": {
"type": "string",
"enum": [
"file"
],
"x-enum-varnames": [
"ProvisionerStorageMethodFile"
]
},
"codersdk.PutExtendWorkspaceRequest": {
"type": "object",
"required": [
+58 -1
View File
@@ -1287,7 +1287,7 @@
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.CreateTemplateVersionDryRunRequest"
"$ref": "#/definitions/codersdk.CreateTemplateVersionRequest"
}
}
],
@@ -5190,6 +5190,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": {
@@ -6587,6 +6639,11 @@
"ProvisionerJobFailed"
]
},
"codersdk.ProvisionerStorageMethod": {
"type": "string",
"enum": ["file"],
"x-enum-varnames": ["ProvisionerStorageMethodFile"]
},
"codersdk.PutExtendWorkspaceRequest": {
"type": "object",
"required": ["deadline"],
+1 -1
View File
@@ -1179,7 +1179,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) {
+69
View File
@@ -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
@@ -4076,6 +4131,20 @@ Parameter represents a set value for the scope.
| `canceled` |
| `failed` |
## codersdk.ProvisionerStorageMethod
```json
"file"
```
### Properties
#### Enumerated Values
| Value |
| ------ |
| `file` |
## codersdk.PutExtendWorkspaceRequest
```json
+15 -12
View File
@@ -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
+4
View File
@@ -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 = () => {
<Route path="workspace" element={<CreateWorkspacePage />} />
<Route path="settings" element={<TemplateSettingsPage />} />
<Route path="variables" element={<TemplateVariablesPage />} />
<Route path="versions">
<Route path=":version">
<Route index element={<TemplateVersionPage />} />
+9
View File
@@ -235,6 +235,15 @@ export const getTemplateVersionResources = async (
return response.data
}
export const getTemplateVersionVariables = async (
versionId: string,
): Promise<TypesGen.TemplateVersionVariable[]> => {
const response = await axios.get<TypesGen.TemplateVersionVariable[]>(
`/api/v2/templateversions/${versionId}/variables`,
)
return response.data
}
export const getTemplateVersions = async (
templateId: string,
): Promise<TypesGen.TemplateVersion[]> => {
@@ -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 }> = ({
</Button>
)
const TemplateVariablesButton: FC<{ templateName: string }> = ({
templateName,
}) => (
<Button
variant="outlined"
component={RouterLink}
to={`/templates/${templateName}/variables`}
startIcon={<CodeOutlined />}
>
{Language.variablesButton}
</Button>
)
const CreateWorkspaceButton: FC<{
templateName: string
className?: string
@@ -80,6 +95,7 @@ export const TemplatePageHeader: FC<TemplatePageHeaderProps> = ({
onClick={deleteTemplate.openDeleteConfirmation}
/>
<TemplateSettingsButton templateName={template.name} />
<TemplateVariablesButton templateName={template.name} />
</Maybe>
<CreateWorkspaceButton templateName={template.name} />
</>
@@ -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 <span>{t("sensitiveVariableHelperText")}</span>
}
export interface TemplateVariableFieldProps {
templateVersionVariable: TemplateVersionVariable
initialValue: string
disabled: boolean
onChange: (value: string) => void
}
export const TemplateVariableField: FC<TemplateVariableFieldProps> = ({
templateVersionVariable,
initialValue,
disabled,
onChange,
...props
}) => {
const [variableValue, setVariableValue] = useState(initialValue)
if (isBoolean(templateVersionVariable)) {
return (
<RadioGroup
defaultValue={variableValue}
onChange={(event) => {
onChange(event.target.value)
}}
>
<FormControlLabel
disabled={disabled}
value="true"
control={<Radio color="primary" size="small" disableRipple />}
label="True"
/>
<FormControlLabel
disabled={disabled}
value="false"
control={<Radio color="primary" size="small" disableRipple />}
label="False"
/>
</RadioGroup>
)
}
return (
<TextField
{...props}
type={
templateVersionVariable.type === "number"
? "number"
: templateVersionVariable.sensitive
? "password"
: "string"
}
disabled={disabled}
autoFocus
fullWidth
label={templateVersionVariable.name}
value={variableValue}
placeholder={
templateVersionVariable.sensitive
? ""
: templateVersionVariable.default_value
}
onChange={(event) => {
setVariableValue(event.target.value)
onChange(event.target.value)
}}
variant="outlined"
/>
)
}
const isBoolean = (variable: TemplateVersionVariable) => {
return variable.type === "bool"
}
+2
View File
@@ -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,
@@ -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."
}
@@ -51,7 +51,7 @@ SaveTemplateSettingsError.args = {
}),
},
initialTouched: {
name: true,
allow_user_cancel_workspace_jobs: true,
},
onSubmit: action("onSubmit"),
onCancel: action("cancel"),
@@ -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<CreateTemplateVersionRequest>
}
export const TemplateVariablesForm: FC<TemplateVariablesForm> = ({
templateVersion,
templateVariables,
onSubmit,
onCancel,
error,
isSubmitting,
initialTouched,
}) => {
const initialUserVariableValues =
selectInitialUserVariableValues(templateVariables)
const form: FormikContextType<CreateTemplateVersionRequest> =
useFormik<CreateTemplateVersionRequest>({
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<CreateTemplateVersionRequest>(
form,
error,
)
const { t } = useTranslation("templateVariablesPage")
return (
<HorizontalForm
onSubmit={form.handleSubmit}
aria-label={t("formAriaLabel")}
>
{templateVariables.map((templateVariable, index) => {
let fieldHelpers
if (templateVariable.sensitive) {
fieldHelpers = getFieldHelpers(
"user_variable_values[" + index + "].value",
<SensitiveVariableHelperText />,
)
} else {
fieldHelpers = getFieldHelpers(
"user_variable_values[" + index + "].value",
)
}
return (
<FormSection
key={templateVariable.name}
title={templateVariable.name}
description={templateVariable.description}
>
<FormFields>
<TemplateVariableField
{...fieldHelpers}
templateVersionVariable={templateVariable}
initialValue={initialUserVariableValues[index].value}
disabled={isSubmitting}
onChange={(value) => {
form.setFieldValue("user_variable_values." + index, {
name: templateVariable.name,
value: value,
})
}}
/>
</FormFields>
</FormSection>
)
})}
<FormFooter onCancel={onCancel} isLoading={isSubmitting} />
</HorizontalForm>
)
}
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()
}
@@ -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(<TemplateVariablesPage />, {
route: `/templates/${MockTemplate.name}/variables`,
path: `/templates/:template/variables`,
routes: (
<Route path={`/templates/${MockTemplate.name}`} element={<></>}></Route>
),
})
}
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()
})
})
@@ -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 (
<>
<Helmet>
<title>{pageTitle(t("title"))}</title>
</Helmet>
<TemplateVariablesPageView
isSubmitting={state.hasTag("submitting")}
templateVersion={activeTemplateVersion}
templateVariables={templateVariables}
errors={{
getTemplateDataError,
updateTemplateError,
}}
onCancel={() => {
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
@@ -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<TemplateVariablesPageViewProps> = (args) => (
<TemplateVariablesPageView {...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"),
}
@@ -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<TemplateVariablesPageViewProps> = ({
templateVersion,
templateVariables,
onCancel,
onSubmit,
isSubmitting,
errors = {},
initialTouched,
}) => {
const classes = useStyles()
const isLoading =
!templateVersion &&
!templateVariables &&
!errors.getTemplateDataError &&
!errors.updateTemplateError
const { t } = useTranslation("templateVariablesPage")
return (
<FullPageHorizontalForm title={t("title")} onCancel={onCancel}>
{Boolean(errors.getTemplateDataError) && (
<Stack className={classes.errorContainer}>
<AlertBanner severity="error" error={errors.getTemplateDataError} />
</Stack>
)}
{Boolean(errors.updateTemplateError) && (
<Stack className={classes.errorContainer}>
<AlertBanner severity="error" error={errors.updateTemplateError} />
</Stack>
)}
{isLoading && <Loader />}
{templateVersion && templateVariables && templateVariables.length > 0 && (
<TemplateVariablesForm
initialTouched={initialTouched}
isSubmitting={isSubmitting}
templateVersion={templateVersion}
templateVariables={templateVariables}
onSubmit={onSubmit}
onCancel={onCancel}
error={errors.updateTemplateError}
/>
)}
{templateVariables && templateVariables.length === 0 && (
<div>
<AlertBanner severity="info" text={t("unusedVariablesNotice")} />
<div className={classes.goBackSection}>
<GoBackButton onClick={onCancel} />
</div>
</div>
)}
</FullPageHorizontalForm>
)
}
const useStyles = makeStyles((theme) => ({
errorContainer: {
marginBottom: theme.spacing(2),
},
goBackSection: {
display: "flex",
width: "100%",
marginTop: 32,
},
}))
+50
View File
@@ -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",
@@ -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,
}),
},
},
)