Files
coder/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx
T
Marcin Tojek f9ae105a26 feat: Adjust forms to include Rich Parameters (#5856)
* XService: GetTemplateParameters

* Rich parameter input shows up

* Render option icons

* Icons

* WIP

* For testing purposes: template

* Fix: useState

* WIP: dynamic validation

* Yup validation

* Translations

* Remove temporary template

* make fmt

* WIP

* Fix: tests

* Fix: fmt

* URL param

* Refactor

* Test: rich param value

* Storybook

* Fix

* Refactor for testing purposes

* Typo

* test: string validation

* Button: build parameters

* Full screen page

* Fix: navigate

* XState done

* refactor: postWorkspaceBuild

* RichParameterInput rendered

* Fix: bad initial value

* Validation works

* Maybe

* Fix

* Go back button

* GoBack button

* Form

* Fix

* Storybook

* Fix: CreateWorkspacePage

* fmt

* Test

* ns

* fmt

* All tests

* feat: WorkspaceActions depend on template parameters

* Fix
2023-02-01 18:13:11 +01:00

504 lines
16 KiB
TypeScript

import TextField from "@material-ui/core/TextField"
import * as TypesGen from "api/typesGenerated"
import { FormFooter } from "components/FormFooter/FormFooter"
import { ParameterInput } from "components/ParameterInput/ParameterInput"
import { RichParameterInput } from "components/RichParameterInput/RichParameterInput"
import { Stack } from "components/Stack/Stack"
import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete"
import { FormikContextType, FormikTouched, useFormik } from "formik"
import { FC, useState } from "react"
import { useTranslation } from "react-i18next"
import { getFormHelpers, nameValidator, onChangeTrimmed } from "util/formUtils"
import * as Yup from "yup"
import { AlertBanner } from "components/AlertBanner/AlertBanner"
import { makeStyles } from "@material-ui/core/styles"
import { FullPageHorizontalForm } from "components/FullPageForm/FullPageHorizontalForm"
import { SelectedTemplate } from "./SelectedTemplate"
import { Loader } from "components/Loader/Loader"
export enum CreateWorkspaceErrors {
GET_TEMPLATES_ERROR = "getTemplatesError",
GET_TEMPLATE_SCHEMA_ERROR = "getTemplateSchemaError",
CREATE_WORKSPACE_ERROR = "createWorkspaceError",
}
export interface CreateWorkspacePageViewProps {
loadingTemplates: boolean
loadingTemplateSchema: boolean
creatingWorkspace: boolean
hasTemplateErrors: boolean
templateName: string
templates?: TypesGen.Template[]
selectedTemplate?: TypesGen.Template
templateParameters?: TypesGen.TemplateVersionParameter[]
templateSchema?: TypesGen.ParameterSchema[]
createWorkspaceErrors: Partial<Record<CreateWorkspaceErrors, Error | unknown>>
canCreateForUser?: boolean
owner: TypesGen.User | null
setOwner: (arg0: TypesGen.User | null) => void
onCancel: () => void
onSubmit: (req: TypesGen.CreateWorkspaceRequest) => void
// initialTouched is only used for testing the error state of the form.
initialTouched?: FormikTouched<TypesGen.CreateWorkspaceRequest>
defaultParameterValues?: Record<string, string>
}
export const CreateWorkspacePageView: FC<
React.PropsWithChildren<CreateWorkspacePageViewProps>
> = (props) => {
const styles = useStyles()
const formFooterStyles = useFormFooterStyles()
const [parameterValues, setParameterValues] = useState<
Record<string, string>
>(props.defaultParameterValues ?? {})
const initialRichParameterValues = selectInitialRichParametersValues(
props.templateParameters,
props.defaultParameterValues,
)
const { t } = useTranslation("createWorkspacePage")
const form: FormikContextType<TypesGen.CreateWorkspaceRequest> =
useFormik<TypesGen.CreateWorkspaceRequest>({
initialValues: {
name: "",
template_id: props.selectedTemplate ? props.selectedTemplate.id : "",
rich_parameter_values: initialRichParameterValues,
},
validationSchema: Yup.object({
name: nameValidator(t("nameLabel", { ns: "createWorkspacePage" })),
rich_parameter_values: ValidationSchemaForRichParameters(
"createWorkspacePage",
props.templateParameters,
),
}),
enableReinitialize: true,
initialTouched: props.initialTouched,
onSubmit: (request) => {
if (!props.templateSchema) {
throw new Error("No template schema loaded")
}
const createRequests: TypesGen.CreateParameterRequest[] = []
props.templateSchema.forEach((schema) => {
let value = schema.default_source_value
if (schema.name in parameterValues) {
value = parameterValues[schema.name]
}
createRequests.push({
name: schema.name,
destination_scheme: schema.default_destination_scheme,
source_scheme: "data",
source_value: value,
})
})
props.onSubmit({
...request,
parameter_values: createRequests,
})
form.setSubmitting(false)
},
})
const isLoading = props.loadingTemplateSchema || props.loadingTemplates
const getFieldHelpers = getFormHelpers<TypesGen.CreateWorkspaceRequest>(
form,
props.createWorkspaceErrors[CreateWorkspaceErrors.CREATE_WORKSPACE_ERROR],
)
if (isLoading) {
return <Loader />
}
if (props.hasTemplateErrors) {
return (
<Stack>
{Boolean(
props.createWorkspaceErrors[
CreateWorkspaceErrors.GET_TEMPLATES_ERROR
],
) && (
<AlertBanner
severity="error"
error={
props.createWorkspaceErrors[
CreateWorkspaceErrors.GET_TEMPLATES_ERROR
]
}
/>
)}
{Boolean(
props.createWorkspaceErrors[
CreateWorkspaceErrors.GET_TEMPLATE_SCHEMA_ERROR
],
) && (
<AlertBanner
severity="error"
error={
props.createWorkspaceErrors[
CreateWorkspaceErrors.GET_TEMPLATE_SCHEMA_ERROR
]
}
/>
)}
</Stack>
)
}
if (
props.createWorkspaceErrors[CreateWorkspaceErrors.CREATE_WORKSPACE_ERROR]
) {
return (
<AlertBanner
severity="error"
error={
props.createWorkspaceErrors[
CreateWorkspaceErrors.CREATE_WORKSPACE_ERROR
]
}
/>
)
}
return (
<FullPageHorizontalForm title="New workspace" onCancel={props.onCancel}>
<form onSubmit={form.handleSubmit}>
<Stack direction="column" spacing={10} className={styles.formSections}>
{/* General info */}
<div className={styles.formSection}>
<div className={styles.formSectionInfo}>
<h2 className={styles.formSectionInfoTitle}>General info</h2>
<p className={styles.formSectionInfoDescription}>
The template and name of your new workspace.
</p>
</div>
<Stack
direction="column"
spacing={1}
className={styles.formSectionFields}
>
{props.selectedTemplate && (
<SelectedTemplate template={props.selectedTemplate} />
)}
<TextField
{...getFieldHelpers("name")}
disabled={form.isSubmitting}
onChange={onChangeTrimmed(form)}
autoFocus
fullWidth
label={t("nameLabel")}
variant="outlined"
/>
</Stack>
</div>
{/* Workspace owner */}
{props.canCreateForUser && (
<div className={styles.formSection}>
<div className={styles.formSectionInfo}>
<h2 className={styles.formSectionInfoTitle}>Workspace owner</h2>
<p className={styles.formSectionInfoDescription}>
The user that is going to own this workspace. If you are
admin, you can create workspace for others.
</p>
</div>
<Stack
direction="column"
spacing={1}
className={styles.formSectionFields}
>
<UserAutocomplete
value={props.owner}
onChange={props.setOwner}
label={t("ownerLabel")}
inputMargin="dense"
showAvatar
/>
</Stack>
</div>
)}
{/* Template params */}
{props.templateSchema && props.templateSchema.length > 0 && (
<div className={styles.formSection}>
<div className={styles.formSectionInfo}>
<h2 className={styles.formSectionInfoTitle}>Template params</h2>
<p className={styles.formSectionInfoDescription}>
Those values are provided by your template&lsquo;s Terraform
configuration.
</p>
</div>
<Stack
direction="column"
spacing={4} // Spacing here is diff because the fields here don't have the MUI floating label spacing
className={styles.formSectionFields}
>
{props.templateSchema.map((schema) => (
<ParameterInput
disabled={form.isSubmitting}
key={schema.id}
defaultValue={parameterValues[schema.name]}
onChange={(value) => {
setParameterValues({
...parameterValues,
[schema.name]: value,
})
}}
schema={schema}
/>
))}
</Stack>
</div>
)}
{/* Rich parameters */}
{props.templateParameters && props.templateParameters.length > 0 && (
<div className={styles.formSection}>
<div className={styles.formSectionInfo}>
<h2 className={styles.formSectionInfoTitle}>
Rich template params
</h2>
<p className={styles.formSectionInfoDescription}>
Those values are provided by your template&lsquo;s Terraform
configuration.
</p>
</div>
<Stack
direction="column"
spacing={4} // Spacing here is diff because the fields here don't have the MUI floating label spacing
className={styles.formSectionFields}
>
{props.templateParameters.map((parameter, index) => (
<RichParameterInput
{...getFieldHelpers(
"rich_parameter_values[" + index + "].value",
)}
disabled={form.isSubmitting}
index={index}
key={parameter.name}
onChange={(value) => {
form.setFieldValue("rich_parameter_values." + index, {
name: parameter.name,
value: value,
})
}}
parameter={parameter}
initialValue={workspaceBuildParameterValue(
initialRichParameterValues,
parameter,
)}
/>
))}
</Stack>
</div>
)}
<FormFooter
styles={formFooterStyles}
onCancel={props.onCancel}
isLoading={props.creatingWorkspace}
submitLabel={t("createWorkspace")}
/>
</Stack>
</form>
</FullPageHorizontalForm>
)
}
const useStyles = makeStyles((theme) => ({
formSections: {
[theme.breakpoints.down("sm")]: {
gap: theme.spacing(8),
},
},
formSection: {
display: "flex",
alignItems: "flex-start",
gap: theme.spacing(15),
[theme.breakpoints.down("sm")]: {
flexDirection: "column",
gap: theme.spacing(2),
},
},
formSectionInfo: {
width: 312,
flexShrink: 0,
position: "sticky",
top: theme.spacing(3),
[theme.breakpoints.down("sm")]: {
width: "100%",
position: "initial",
},
},
formSectionInfoTitle: {
fontSize: 20,
color: theme.palette.text.primary,
fontWeight: 400,
margin: 0,
marginBottom: theme.spacing(1),
},
formSectionInfoDescription: {
fontSize: 14,
color: theme.palette.text.secondary,
lineHeight: "160%",
margin: 0,
},
formSectionFields: {
width: "100%",
},
}))
const useFormFooterStyles = makeStyles((theme) => ({
button: {
minWidth: theme.spacing(23),
[theme.breakpoints.down("sm")]: {
width: "100%",
},
},
footer: {
display: "flex",
alignItems: "center",
justifyContent: "flex-start",
flexDirection: "row-reverse",
gap: theme.spacing(2),
[theme.breakpoints.down("sm")]: {
flexDirection: "column",
gap: theme.spacing(1),
},
},
}))
const selectInitialRichParametersValues = (
templateParameters?: TypesGen.TemplateVersionParameter[],
defaultValuesFromQuery?: Record<string, string>,
): TypesGen.WorkspaceBuildParameter[] => {
const defaults: TypesGen.WorkspaceBuildParameter[] = []
if (!templateParameters) {
return defaults
}
templateParameters.forEach((parameter) => {
if (parameter.options.length > 0) {
let parameterValue = parameter.options[0].value
if (defaultValuesFromQuery && defaultValuesFromQuery[parameter.name]) {
parameterValue = defaultValuesFromQuery[parameter.name]
}
const buildParameter: TypesGen.WorkspaceBuildParameter = {
name: parameter.name,
value: parameterValue,
}
defaults.push(buildParameter)
return
}
let parameterValue = parameter.default_value
if (defaultValuesFromQuery && defaultValuesFromQuery[parameter.name]) {
parameterValue = defaultValuesFromQuery[parameter.name]
}
const buildParameter: TypesGen.WorkspaceBuildParameter = {
name: parameter.name,
value: parameterValue || "",
}
defaults.push(buildParameter)
})
return defaults
}
export const workspaceBuildParameterValue = (
workspaceBuildParameters: TypesGen.WorkspaceBuildParameter[],
parameter: TypesGen.TemplateVersionParameter,
): string => {
const buildParameter = workspaceBuildParameters.find((buildParameter) => {
return buildParameter.name === parameter.name
})
return (buildParameter && buildParameter.value) || ""
}
export const ValidationSchemaForRichParameters = (
ns: string,
templateParameters?: TypesGen.TemplateVersionParameter[],
): Yup.AnySchema => {
const { t } = useTranslation(ns)
if (!templateParameters) {
return Yup.object()
}
return Yup.array()
.of(
Yup.object().shape({
name: Yup.string().required(),
value: Yup.string()
.required(t("validationRequiredParameter"))
.test("verify with template", (val, ctx) => {
const name = ctx.parent.name
const templateParameter = templateParameters.find(
(parameter) => parameter.name === name,
)
if (templateParameter) {
switch (templateParameter.type) {
case "number":
if (
templateParameter.validation_min === 0 &&
templateParameter.validation_max === 0
) {
return true
}
if (
Number(val) < templateParameter.validation_min ||
templateParameter.validation_max < Number(val)
) {
return ctx.createError({
path: ctx.path,
message: t("validationNumberNotInRange", {
min: templateParameter.validation_min,
max: templateParameter.validation_max,
}),
})
}
break
case "string":
{
if (templateParameter.validation_regex.length === 0) {
return true
}
const regex = new RegExp(templateParameter.validation_regex)
if (val && !regex.test(val)) {
return ctx.createError({
path: ctx.path,
message: t("validationPatternNotMatched", {
error: templateParameter.validation_error,
pattern: templateParameter.validation_regex,
}),
})
}
}
break
}
}
return true
}),
}),
)
.required()
}