refactor(site): Highlight immutable parameters and do a few tweaks (#6490)

This commit is contained in:
Bruno Quaresma
2023-03-08 10:52:42 -03:00
committed by GitHub
parent fe10ba1157
commit 89008125c0
7 changed files with 346 additions and 351 deletions

View File

@@ -5,6 +5,7 @@ import {
} from "components/FormFooter/FormFooter"
import { Stack } from "components/Stack/Stack"
import { FC, HTMLProps, PropsWithChildren } from "react"
import { combineClasses } from "util/combineClasses"
export const HorizontalForm: FC<
PropsWithChildren & HTMLProps<HTMLFormElement>
@@ -21,12 +22,16 @@ export const HorizontalForm: FC<
}
export const FormSection: FC<
PropsWithChildren & { title: string; description: string | JSX.Element }
> = ({ children, title, description }) => {
PropsWithChildren & {
title: string
description: string | JSX.Element
className?: string
}
> = ({ children, title, description, className }) => {
const styles = useStyles()
return (
<div className={styles.formSection}>
<div className={combineClasses([styles.formSection, className])}>
<div className={styles.formSectionInfo}>
<h2 className={styles.formSectionInfoTitle}>{title}</h2>
<div className={styles.formSectionInfoDescription}>{description}</div>

View File

@@ -29,8 +29,8 @@ const createTemplateVersionParameter = (
validation_regex: "",
validation_min: 0,
validation_max: 0,
validation_monotonic: "",
validation_monotonic: "increasing",
description_plaintext: "",
...partial,
}
}
@@ -38,6 +38,7 @@ const createTemplateVersionParameter = (
export const Basic = Template.bind({})
Basic.args = {
initialValue: "initial-value",
id: "project_name",
parameter: createTemplateVersionParameter({
name: "project_name",
description:
@@ -48,6 +49,7 @@ Basic.args = {
export const NumberType = Template.bind({})
NumberType.args = {
initialValue: "4",
id: "number_parameter",
parameter: createTemplateVersionParameter({
name: "number_parameter",
type: "number",
@@ -58,6 +60,7 @@ NumberType.args = {
export const BooleanType = Template.bind({})
BooleanType.args = {
initialValue: "false",
id: "bool_parameter",
parameter: createTemplateVersionParameter({
name: "bool_parameter",
type: "bool",
@@ -68,6 +71,7 @@ BooleanType.args = {
export const OptionsType = Template.bind({})
OptionsType.args = {
initialValue: "first_option",
id: "options_parameter",
parameter: createTemplateVersionParameter({
name: "options_parameter",
type: "string",
@@ -94,3 +98,68 @@ OptionsType.args = {
],
}),
}
export const IconLabel = Template.bind({})
IconLabel.args = {
initialValue: "initial-value",
id: "project_name",
parameter: createTemplateVersionParameter({
name: "project_name",
description:
"Customize the name of a Google Cloud project that will be created!",
icon: "/emojis/1f30e.png",
}),
}
export const NoDescription = Template.bind({})
NoDescription.args = {
initialValue: "",
id: "region",
parameter: createTemplateVersionParameter({
name: "Region",
description: "",
description_plaintext: "",
type: "string",
mutable: false,
default_value: "",
icon: "/emojis/1f30e.png",
options: [
{
name: "Pittsburgh",
description: "",
value: "us-pittsburgh",
icon: "/emojis/1f1fa-1f1f8.png",
},
{
name: "Helsinki",
description: "",
value: "eu-helsinki",
icon: "/emojis/1f1eb-1f1ee.png",
},
{
name: "Sydney",
description: "",
value: "ap-sydney",
icon: "/emojis/1f1e6-1f1fa.png",
},
],
}),
}
export const DescriptionWithLinks = Template.bind({})
DescriptionWithLinks.args = {
initialValue: "",
id: "coder-repository-directory",
parameter: createTemplateVersionParameter({
name: "Coder Repository Directory",
description:
"The directory specified will be created and [coder/coder](https://github.com/coder/coder) will be automatically cloned into it 🪄.",
description_plaintext:
"The directory specified will be created and coder/coder (https://github.com/coder/coder) will be automatically cloned into it 🪄.",
type: "string",
mutable: true,
default_value: "~/coder",
icon: "",
options: [],
}),
}

View File

@@ -2,7 +2,7 @@ import FormControlLabel from "@material-ui/core/FormControlLabel"
import Radio from "@material-ui/core/Radio"
import RadioGroup from "@material-ui/core/RadioGroup"
import { makeStyles } from "@material-ui/core/styles"
import TextField from "@material-ui/core/TextField"
import TextField, { TextFieldProps } from "@material-ui/core/TextField"
import { Stack } from "components/Stack/Stack"
import { FC, useState } from "react"
import { TemplateVersionParameter } from "../../api/typesGenerated"
@@ -14,54 +14,48 @@ const isBoolean = (parameter: TemplateVersionParameter) => {
}
export interface ParameterLabelProps {
index: number
id: string
parameter: TemplateVersionParameter
}
const ParameterLabel: FC<ParameterLabelProps> = ({ index, parameter }) => {
const ParameterLabel: FC<ParameterLabelProps> = ({ id, parameter }) => {
const styles = useStyles()
const hasDescription = parameter.description && parameter.description !== ""
return (
<span>
<span className={styles.labelNameWithIcon}>
<label htmlFor={id}>
<Stack direction="row" alignItems="center">
{parameter.icon && (
<span className={styles.iconWrapper}>
<span className={styles.labelIconWrapper}>
<img
className={styles.icon}
className={styles.labelIcon}
alt="Parameter icon"
src={parameter.icon}
style={{
pointerEvents: "none",
}}
/>
</span>
)}
<span className={styles.labelName}>
<label htmlFor={`rich_parameter_values[${index}].value`}>
{parameter.name}
</label>
</span>
</span>
{parameter.description && (
<span className={styles.labelDescription}>
<MemoizedMarkdown>{parameter.description}</MemoizedMarkdown>
</span>
)}
{!parameter.mutable && (
<div className={styles.labelImmutable}>
This parameter cannot be changed after creating workspace.
</div>
)}
</span>
{hasDescription ? (
<Stack spacing={0.5}>
<span className={styles.labelCaption}>{parameter.name}</span>
<span className={styles.labelPrimary}>
<MemoizedMarkdown>{parameter.description}</MemoizedMarkdown>
</span>
</Stack>
) : (
<span className={styles.labelPrimary}>{parameter.name}</span>
)}
</Stack>
</label>
)
}
export interface RichParameterInputProps {
export type RichParameterInputProps = TextFieldProps & {
index: number
disabled?: boolean
parameter: TemplateVersionParameter
onChange: (value: string) => void
initialValue?: string
id: string
}
export const RichParameterInput: FC<RichParameterInputProps> = ({
@@ -70,16 +64,16 @@ export const RichParameterInput: FC<RichParameterInputProps> = ({
onChange,
parameter,
initialValue,
...props
...fieldProps
}) => {
const styles = useStyles()
return (
<Stack direction="column" spacing={0.75}>
<ParameterLabel index={index} parameter={parameter} />
<ParameterLabel id={fieldProps.id} parameter={parameter} />
<div className={styles.input}>
<RichParameterField
{...props}
{...fieldProps}
index={index}
disabled={disabled}
onChange={onChange}
@@ -140,7 +134,7 @@ const RichParameterField: React.FC<RichParameterInputProps> = ({
value={option.value}
control={<Radio color="primary" size="small" disableRipple />}
label={
<span>
<span className={styles.radioOption}>
{option.icon && (
<img
className={styles.optionIcon}
@@ -180,24 +174,25 @@ const RichParameterField: React.FC<RichParameterInputProps> = ({
)
}
const iconSize = 20
const optionIconSize = 24
const optionIconSize = 20
const useStyles = makeStyles((theme) => ({
labelName: {
fontSize: 14,
color: theme.palette.text.secondary,
display: "block",
marginBottom: theme.spacing(1.0),
},
labelNameWithIcon: {
label: {
marginBottom: theme.spacing(0.5),
},
labelDescription: {
labelCaption: {
fontSize: 14,
color: theme.palette.text.secondary,
},
labelPrimary: {
fontSize: 16,
color: theme.palette.text.primary,
display: "block",
fontWeight: 600,
"& p": {
margin: 0,
lineHeight: "20px", // Keep the same as ParameterInput
},
},
labelImmutable: {
marginTop: theme.spacing(0.5),
@@ -213,18 +208,23 @@ const useStyles = makeStyles((theme) => ({
alignItems: "center",
gap: theme.spacing(1),
},
iconWrapper: {
float: "left",
labelIconWrapper: {
width: theme.spacing(2.5),
height: theme.spacing(2.5),
display: "block",
},
icon: {
maxHeight: iconSize,
width: iconSize,
marginRight: theme.spacing(1.0),
labelIcon: {
width: "100%",
height: "100%",
objectFit: "contain",
},
radioOption: {
display: "flex",
alignItems: "center",
gap: theme.spacing(1.5),
},
optionIcon: {
maxHeight: optionIconSize,
width: optionIconSize,
marginRight: theme.spacing(1.0),
float: "left",
},
}))

View File

@@ -179,6 +179,7 @@ describe("CreateWorkspacePage", () => {
const secondParameterField = await screen.findByLabelText(
MockTemplateVersionParameter2.name,
{ exact: false },
)
expect(secondParameterField).toBeDefined()
@@ -212,6 +213,7 @@ describe("CreateWorkspacePage", () => {
const thirdParameterField = await screen.findByLabelText(
MockTemplateVersionParameter3.name,
{ exact: false },
)
expect(thirdParameterField).toBeDefined()
fireEvent.change(thirdParameterField, {

View File

@@ -133,6 +133,35 @@ RichParameters.args = {
MockTemplateVersionParameter1,
MockTemplateVersionParameter2,
MockTemplateVersionParameter3,
{
name: "Region",
description: "",
description_plaintext: "",
type: "string",
mutable: false,
default_value: "",
icon: "/emojis/1f30e.png",
options: [
{
name: "Pittsburgh",
description: "",
value: "us-pittsburgh",
icon: "/emojis/1f1fa-1f1f8.png",
},
{
name: "Helsinki",
description: "",
value: "eu-helsinki",
icon: "/emojis/1f1eb-1f1ee.png",
},
{
name: "Sydney",
description: "",
value: "ap-sydney",
icon: "/emojis/1f1e6-1f1fa.png",
},
],
},
],
createWorkspaceErrors: {},
}

View File

@@ -1,6 +1,5 @@
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"
@@ -11,11 +10,17 @@ 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"
import { GitAuth } from "components/GitAuth/GitAuth"
import {
FormFields,
FormSection,
FormFooter,
HorizontalForm,
} from "components/HorizontalForm/HorizontalForm"
import { makeStyles } from "@material-ui/core/styles"
export enum CreateWorkspaceErrors {
GET_TEMPLATES_ERROR = "getTemplatesError",
@@ -49,8 +54,6 @@ export interface CreateWorkspacePageViewProps {
export const CreateWorkspacePageView: FC<
React.PropsWithChildren<CreateWorkspacePageViewProps>
> = (props) => {
const styles = useStyles()
const formFooterStyles = useFormFooterStyles()
const [parameterValues, setParameterValues] = useState<
Record<string, string>
>(props.defaultParameterValues ?? {})
@@ -67,8 +70,8 @@ export const CreateWorkspacePageView: FC<
// to disappear.
setGitAuthErrors({})
}, [props.templateGitAuth])
const { t } = useTranslation("createWorkspacePage")
const styles = useStyles()
const form: FormikContextType<TypesGen.CreateWorkspaceRequest> =
useFormik<TypesGen.CreateWorkspaceRequest>({
@@ -203,313 +206,195 @@ export const CreateWorkspacePageView: FC<
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>
<HorizontalForm onSubmit={form.handleSubmit}>
{/* General info */}
<FormSection
title="General info"
description="The template and name of your new workspace."
>
<FormFields>
{props.selectedTemplate && (
<SelectedTemplate template={props.selectedTemplate} />
)}
<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"
/>
</FormFields>
</FormSection>
<TextField
{...getFieldHelpers("name")}
disabled={form.isSubmitting}
onChange={onChangeTrimmed(form)}
autoFocus
fullWidth
label={t("nameLabel")}
variant="outlined"
{/* Workspace owner */}
{props.canCreateForUser && (
<FormSection
title="Workspace owner"
description="The user that is going to own this workspace. If you are admin, you can create workspace for others."
>
<FormFields>
<UserAutocomplete
value={props.owner}
onChange={props.setOwner}
label={t("ownerLabel")}
/>
</Stack>
</div>
</FormFields>
</FormSection>
)}
{/* 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")}
{/* Template git auth */}
{props.templateGitAuth && props.templateGitAuth.length > 0 && (
<FormSection
title="Git Authentication"
description="This template requires authentication to automatically perform Git operations on create."
>
<FormFields>
{props.templateGitAuth.map((auth, index) => (
<GitAuth
key={index}
authenticateURL={auth.authenticate_url}
authenticated={auth.authenticated}
type={auth.type}
error={gitAuthErrors[auth.id]}
/>
</Stack>
</div>
)}
))}
</FormFields>
</FormSection>
)}
{/* Template git auth */}
{props.templateGitAuth && props.templateGitAuth.length > 0 && (
<div className={styles.formSection}>
<div className={styles.formSectionInfo}>
<h2 className={styles.formSectionInfoTitle}>
Git Authentication
</h2>
<p className={styles.formSectionInfoDescription}>
This template requires authentication to automatically perform
Git operations on create.
</p>
</div>
<Stack
direction="column"
spacing={2}
className={styles.formSectionFields}
>
{props.templateGitAuth.map((auth, index) => (
<GitAuth
key={index}
authenticateURL={auth.authenticate_url}
authenticated={auth.authenticated}
type={auth.type}
error={gitAuthErrors[auth.id]}
{/* Template params */}
{props.templateSchema && props.templateSchema.length > 0 && (
<FormSection
title="Template params"
description="Those values are provided by your template's Terraform configuration."
>
<FormFields>
{props.templateSchema
// We only want to show schema that have redisplay_value equals true
.filter((schema) => schema.redisplay_value)
.map((schema) => (
<ParameterInput
disabled={form.isSubmitting}
key={schema.id}
defaultValue={parameterValues[schema.name]}
onChange={(value) => {
setParameterValues({
...parameterValues,
[schema.name]: value,
})
}}
schema={schema}
/>
))}
</Stack>
</div>
</FormFields>
</FormSection>
)}
{/* Mutable rich parameters */}
{props.templateParameters &&
props.templateParameters.filter((p) => p.mutable).length > 0 && (
<FormSection
title="Parameters"
description="Those values are provided by your template's Terraform configuration. Values can be changed after creating the workspace."
>
<FormFields>
{props.templateParameters.map(
(parameter, index) =>
parameter.mutable && (
<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,
)}
/>
),
)}
</FormFields>
</FormSection>
)}
{/* 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
// We only want to show schema that have redisplay_value equals true
.filter((schema) => schema.redisplay_value)
.map((schema) => (
<ParameterInput
disabled={form.isSubmitting}
key={schema.id}
defaultValue={parameterValues[schema.name]}
onChange={(value) => {
setParameterValues({
...parameterValues,
[schema.name]: value,
})
}}
schema={schema}
/>
))}
</Stack>
</div>
{/* Immutable rich parameters */}
{props.templateParameters &&
props.templateParameters.filter((p) => !p.mutable).length > 0 && (
<FormSection
title="Immutable parameters"
className={styles.warningSection}
description={
<>
Those values are also parameters provided from your Terraform
configuration but they{" "}
<strong className={styles.warningText}>
cannot be changed after creating the workspace.
</strong>
</>
}
>
<FormFields>
{props.templateParameters.map(
(parameter, index) =>
!parameter.mutable && (
<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,
)}
/>
),
)}
</FormFields>
</FormSection>
)}
{/* Immutable rich parameters */}
{props.templateParameters &&
props.templateParameters.filter((p) => !p.mutable).length > 0 && (
<div className={styles.formSection}>
<div className={styles.formSectionInfo}>
<h2 className={styles.formSectionInfoTitle}>
Immutable parameters
</h2>
<p className={styles.formSectionInfoDescription}>
Those values are provided by your template&lsquo;s Terraform
configuration. Values cannot be changed after creating the
workspace.
</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) =>
!parameter.mutable && (
<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>
)}
{/* Mutable rich parameters */}
{props.templateParameters &&
props.templateParameters.filter((p) => p.mutable).length > 0 && (
<div className={styles.formSection}>
<div className={styles.formSectionInfo}>
<h2 className={styles.formSectionInfoTitle}>
Mutable parameters
</h2>
<p className={styles.formSectionInfoDescription}>
Those values are provided by your template&lsquo;s Terraform
configuration. Values can be changed after creating the
workspace.
</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) =>
parameter.mutable && (
<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>
<FormFooter
onCancel={props.onCancel}
isLoading={props.creatingWorkspace}
submitLabel={t("createWorkspace")}
/>
</HorizontalForm>
</FullPageHorizontalForm>
)
}
const useStyles = makeStyles((theme) => ({
formSections: {
[theme.breakpoints.down("sm")]: {
gap: theme.spacing(8),
},
warningText: {
color: theme.palette.warning.light,
},
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),
},
warningSection: {
border: `1px solid ${theme.palette.warning.light}`,
borderRadius: 8,
backgroundColor: theme.palette.background.paper,
padding: theme.spacing(10),
marginLeft: -theme.spacing(10),
marginRight: -theme.spacing(10),
},
}))

View File

@@ -80,11 +80,13 @@ describe("WorkspaceBuildParametersPage", () => {
const firstParameter = await screen.findByLabelText(
MockTemplateVersionParameter1.name,
{ exact: false },
)
expect(firstParameter).toBeDefined()
const secondParameter = await screen.findByLabelText(
MockTemplateVersionParameter2.name,
{ exact: false },
)
expect(secondParameter).toBeDefined()
})
@@ -113,6 +115,7 @@ describe("WorkspaceBuildParametersPage", () => {
const secondParameterField = await screen.findByLabelText(
MockTemplateVersionParameter2.name,
{ exact: false },
)
expect(secondParameterField).toBeDefined()
@@ -151,6 +154,7 @@ describe("WorkspaceBuildParametersPage", () => {
const secondParameterField = await screen.findByLabelText(
MockTemplateVersionParameter2.name,
{ exact: false },
)
expect(secondParameterField).toBeDefined()
@@ -189,6 +193,7 @@ describe("WorkspaceBuildParametersPage", () => {
const secondParameterField = await screen.findByLabelText(
MockTemplateVersionParameter5.name,
{ exact: false },
)
expect(secondParameterField).toBeDefined()