refactor: Refactor create workspace page (#4862)

This commit is contained in:
Bruno Quaresma
2022-11-02 17:44:41 -03:00
committed by GitHub
parent 86fc3e09a3
commit f76e7b1dbd
12 changed files with 384 additions and 137 deletions

View File

@ -47,6 +47,11 @@ const useStyles = makeStyles((theme) => ({
"&:hover": { "&:hover": {
opacity: 1, opacity: 1,
}, },
[theme.breakpoints.down("sm")]: {
top: theme.spacing(1),
right: theme.spacing(1),
},
}, },
label: { label: {
position: "absolute", position: "absolute",

View File

@ -1,5 +1,6 @@
import Button from "@material-ui/core/Button" import Button from "@material-ui/core/Button"
import { makeStyles } from "@material-ui/core/styles" import { makeStyles } from "@material-ui/core/styles"
import { ClassNameMap } from "@material-ui/core/styles/withStyles"
import { FC } from "react" import { FC } from "react"
import { LoadingButton } from "../LoadingButton/LoadingButton" import { LoadingButton } from "../LoadingButton/LoadingButton"
@ -8,36 +9,22 @@ export const Language = {
defaultSubmitLabel: "Submit", defaultSubmitLabel: "Submit",
} }
type FormFooterStyles = ClassNameMap<"footer" | "button">
export interface FormFooterProps { export interface FormFooterProps {
onCancel: () => void onCancel: () => void
isLoading: boolean isLoading: boolean
styles?: FormFooterStyles
submitLabel?: string submitLabel?: string
submitDisabled?: boolean submitDisabled?: boolean
} }
const useStyles = makeStyles((theme) => ({ export const FormFooter: FC<FormFooterProps> = ({
footer: {
display: "flex",
flex: "0",
// The first button is the submit so it is the first element to be focused
// on tab so we use row-reverse to display it on the right
flexDirection: "row-reverse",
gap: theme.spacing(1.5),
alignItems: "center",
marginTop: theme.spacing(3),
},
button: {
width: "100%",
},
}))
export const FormFooter: FC<React.PropsWithChildren<FormFooterProps>> = ({
onCancel, onCancel,
isLoading, isLoading,
submitLabel = Language.defaultSubmitLabel,
submitDisabled, submitDisabled,
submitLabel = Language.defaultSubmitLabel,
styles = defaultStyles(),
}) => { }) => {
const styles = useStyles()
return ( return (
<div className={styles.footer}> <div className={styles.footer}>
<LoadingButton <LoadingButton
@ -63,3 +50,19 @@ export const FormFooter: FC<React.PropsWithChildren<FormFooterProps>> = ({
</div> </div>
) )
} }
const defaultStyles = makeStyles((theme) => ({
footer: {
display: "flex",
flex: "0",
// The first button is the submit so it is the first element to be focused
// on tab so we use row-reverse to display it on the right
flexDirection: "row-reverse",
gap: theme.spacing(1.5),
alignItems: "center",
marginTop: theme.spacing(3),
},
button: {
width: "100%",
},
}))

View File

@ -0,0 +1,55 @@
import { FormCloseButton } from "../FormCloseButton/FormCloseButton"
import { makeStyles } from "@material-ui/core/styles"
import Typography from "@material-ui/core/Typography"
import { Margins } from "components/Margins/Margins"
import { FC, ReactNode } from "react"
export interface FormTitleProps {
title: string
detail?: ReactNode
}
export interface FullPageHorizontalFormProps {
title: string
detail?: ReactNode
onCancel: () => void
}
export const FullPageHorizontalForm: FC<
React.PropsWithChildren<FullPageHorizontalFormProps>
> = ({ title, detail, onCancel, children }) => {
const styles = useStyles()
return (
<>
<header className={styles.title}>
<Margins size="medium">
<Typography variant="h3">{title}</Typography>
{detail && <Typography variant="caption">{detail}</Typography>}
</Margins>
</header>
<FormCloseButton onClose={onCancel} />
<main className={styles.main}>
<Margins size="medium">{children}</Margins>
</main>
</>
)
}
const useStyles = makeStyles((theme) => ({
title: {
paddingTop: theme.spacing(6),
paddingBottom: theme.spacing(8),
[theme.breakpoints.down("sm")]: {
paddingTop: theme.spacing(4),
paddingBottom: theme.spacing(4),
},
},
main: {
paddingBottom: theme.spacing(10),
},
}))

View File

@ -1,12 +1,16 @@
import { makeStyles } from "@material-ui/core/styles" import { makeStyles } from "@material-ui/core/styles"
import { FC } from "react" import { FC } from "react"
import { containerWidth, sidePadding } from "../../theme/constants" import {
containerWidth,
containerWidthMedium,
sidePadding,
} from "../../theme/constants"
type Size = "regular" | "medium" | "small" type Size = "regular" | "medium" | "small"
const widthBySize: Record<Size, number> = { const widthBySize: Record<Size, number> = {
regular: containerWidth, regular: containerWidth,
medium: containerWidth / 2, medium: containerWidthMedium,
small: containerWidth / 3, small: containerWidth / 3,
} }

View File

@ -7,7 +7,6 @@ import TextField from "@material-ui/core/TextField"
import { Stack } from "components/Stack/Stack" import { Stack } from "components/Stack/Stack"
import { FC } from "react" import { FC } from "react"
import { ParameterSchema } from "../../api/typesGenerated" import { ParameterSchema } from "../../api/typesGenerated"
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants"
const isBoolean = (schema: ParameterSchema) => { const isBoolean = (schema: ParameterSchema) => {
return schema.validation_value_type === "bool" return schema.validation_value_type === "bool"
@ -16,12 +15,18 @@ const isBoolean = (schema: ParameterSchema) => {
const ParameterLabel: React.FC<{ schema: ParameterSchema }> = ({ schema }) => { const ParameterLabel: React.FC<{ schema: ParameterSchema }> = ({ schema }) => {
const styles = useStyles() const styles = useStyles()
if (schema.name && schema.description) {
return ( return (
<label className={styles.label} htmlFor={schema.name}> <label htmlFor={schema.name}>
<strong>var.{schema.name}</strong> <span className={styles.labelName}>var.{schema.name}</span>
{schema.description && (
<span className={styles.labelDescription}>{schema.description}</span> <span className={styles.labelDescription}>{schema.description}</span>
)} </label>
)
}
return (
<label htmlFor={schema.name}>
<span className={styles.labelDescription}>var.{schema.name}</span>
</label> </label>
) )
} }
@ -38,7 +43,7 @@ export const ParameterInput: FC<
const styles = useStyles() const styles = useStyles()
return ( return (
<Stack direction="column" className={styles.root}> <Stack direction="column" spacing={0.75}>
<ParameterLabel schema={schema} /> <ParameterLabel schema={schema} />
<div className={styles.input}> <div className={styles.input}>
<ParameterField <ParameterField
@ -119,19 +124,17 @@ const ParameterField: React.FC<
} }
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
root: { labelName: {
fontFamily: MONOSPACE_FONT_FAMILY, fontSize: 14,
paddingTop: theme.spacing(2), color: theme.palette.text.secondary,
paddingBottom: theme.spacing(2), display: "block",
}, marginBottom: theme.spacing(0.5),
label: {
display: "flex",
flexDirection: "column",
fontSize: 21,
}, },
labelDescription: { labelDescription: {
fontSize: 14, fontSize: 16,
marginTop: theme.spacing(1), color: theme.palette.text.primary,
display: "block",
fontWeight: 600,
}, },
input: { input: {
display: "flex", display: "flex",

View File

@ -6,7 +6,6 @@ import { AlertBanner } from "components/AlertBanner/AlertBanner"
import { Stack } from "components/Stack/Stack" import { Stack } from "components/Stack/Stack"
import { FC } from "react" import { FC } from "react"
import * as TypesGen from "../../api/typesGenerated" import * as TypesGen from "../../api/typesGenerated"
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants"
export const Language = { export const Language = {
of: "of", of: "of",
@ -27,7 +26,7 @@ export const WorkspaceQuota: FC<WorkspaceQuotaProps> = ({ quota, error }) => {
return ( return (
<Box> <Box>
<Stack spacing={1} className={styles.stack}> <Stack spacing={1} className={styles.stack}>
<span className={styles.title}>Workspace Quota</span> <span className={styles.title}>Usage Quota</span>
<AlertBanner severity="error" error={error} /> <AlertBanner severity="error" error={error} />
</Stack> </Stack>
</Box> </Box>
@ -39,7 +38,7 @@ export const WorkspaceQuota: FC<WorkspaceQuotaProps> = ({ quota, error }) => {
return ( return (
<Box> <Box>
<Stack spacing={1} className={styles.stack}> <Stack spacing={1} className={styles.stack}>
<span className={styles.title}>Workspace Quota</span> <span className={styles.title}>Usage quota</span>
<LinearProgress color="primary" /> <LinearProgress color="primary" />
<div className={styles.label}> <div className={styles.label}>
<Skeleton className={styles.skeleton} /> <Skeleton className={styles.skeleton} />
@ -64,8 +63,23 @@ export const WorkspaceQuota: FC<WorkspaceQuotaProps> = ({ quota, error }) => {
return ( return (
<Box> <Box>
<Stack spacing={1} className={styles.stack}> <Stack spacing={1.5} className={styles.stack}>
<span className={styles.title}>Workspace Quota</span> <Stack direction="row" justifyContent="space-between">
<span className={styles.title}>Usage Quota</span>
<div className={styles.label}>
<span className={styles.labelHighlight}>
{quota.user_workspace_count}
</span>{" "}
{Language.of}{" "}
<span className={styles.labelHighlight}>
{quota.user_workspace_limit}
</span>{" "}
{quota.user_workspace_limit === 1
? Language.workspace
: Language.workspaces}
{" used"}
</div>
</Stack>
<LinearProgress <LinearProgress
className={ className={
quota.user_workspace_count >= quota.user_workspace_limit quota.user_workspace_count >= quota.user_workspace_limit
@ -75,14 +89,6 @@ export const WorkspaceQuota: FC<WorkspaceQuotaProps> = ({ quota, error }) => {
value={value} value={value}
variant="determinate" variant="determinate"
/> />
<div className={styles.label}>
{quota.user_workspace_count} {Language.of}{" "}
{quota.user_workspace_limit}{" "}
{quota.user_workspace_limit === 1
? Language.workspace
: Language.workspaces}
{" used"}
</div>
</Stack> </Stack>
</Box> </Box>
) )
@ -101,18 +107,16 @@ const useStyles = makeStyles((theme) => ({
}, },
}, },
title: { title: {
fontFamily: MONOSPACE_FONT_FAMILY, fontSize: 16,
fontSize: 21,
paddingBottom: "8px",
}, },
label: { label: {
fontFamily: MONOSPACE_FONT_FAMILY, fontSize: 14,
fontSize: 12,
textTransform: "uppercase",
display: "block", display: "block",
fontWeight: 600,
color: theme.palette.text.secondary, color: theme.palette.text.secondary,
}, },
labelHighlight: {
color: theme.palette.text.primary,
},
skeleton: { skeleton: {
minWidth: "150px", minWidth: "150px",
}, },

View File

@ -1,5 +1,6 @@
{ {
"templateLabel": "Template", "templateLabel": "Template",
"nameLabel": "Name", "nameLabel": "Workspace Name",
"ownerLabel": "Workspace Owner" "ownerLabel": "Owner",
"createWorkspace": "Create workspace"
} }

View File

@ -2,7 +2,6 @@
import { fireEvent, screen, waitFor } from "@testing-library/react" import { fireEvent, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event" import userEvent from "@testing-library/user-event"
import * as API from "api/api" import * as API from "api/api"
import { Language as FooterLanguage } from "components/FormFooter/FormFooter"
import i18next from "i18next" import i18next from "i18next"
import { import {
MockTemplate, MockTemplate,
@ -17,6 +16,7 @@ import CreateWorkspacePage from "./CreateWorkspacePage"
const { t } = i18next const { t } = i18next
const nameLabelText = t("nameLabel", { ns: "createWorkspacePage" }) const nameLabelText = t("nameLabel", { ns: "createWorkspacePage" })
const createWorkspaceText = t("createWorkspace", { ns: "createWorkspacePage" })
const renderCreateWorkspacePage = () => { const renderCreateWorkspacePage = () => {
return renderWithAuth(<CreateWorkspacePage />, { return renderWithAuth(<CreateWorkspacePage />, {
@ -48,7 +48,7 @@ describe("CreateWorkspacePage", () => {
target: { value: "test" }, target: { value: "test" },
}) })
const submitButton = screen.getByText(FooterLanguage.defaultSubmitLabel) const submitButton = screen.getByText(createWorkspaceText)
userEvent.click(submitButton) userEvent.click(submitButton)
await waitFor(() => await waitFor(() =>

View File

@ -25,7 +25,6 @@ const CreateWorkspacePage: FC = () => {
shallowEqual, shallowEqual,
) )
const workspaceQuotaEnabled = featureVisibility[FeatureNames.WorkspaceQuota] const workspaceQuotaEnabled = featureVisibility[FeatureNames.WorkspaceQuota]
const [authState] = useActor(xServices.authXService) const [authState] = useActor(xServices.authXService)
const { me } = authState.context const { me } = authState.context
const [createWorkspaceState, send] = useMachine(createWorkspaceMachine, { const [createWorkspaceState, send] = useMachine(createWorkspaceMachine, {
@ -89,7 +88,8 @@ const CreateWorkspacePage: FC = () => {
}) })
}} }}
onCancel={() => { onCancel={() => {
navigate("/templates") // Go back
navigate(-1)
}} }}
onSubmit={(request) => { onSubmit={(request) => {
send({ send({

View File

@ -1,8 +1,6 @@
import TextField from "@material-ui/core/TextField" import TextField from "@material-ui/core/TextField"
import * as TypesGen from "api/typesGenerated" import * as TypesGen from "api/typesGenerated"
import { FormFooter } from "components/FormFooter/FormFooter" import { FormFooter } from "components/FormFooter/FormFooter"
import { FullPageForm } from "components/FullPageForm/FullPageForm"
import { Loader } from "components/Loader/Loader"
import { ParameterInput } from "components/ParameterInput/ParameterInput" import { ParameterInput } from "components/ParameterInput/ParameterInput"
import { Stack } from "components/Stack/Stack" import { Stack } from "components/Stack/Stack"
import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete" import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete"
@ -14,6 +12,9 @@ import { useTranslation } from "react-i18next"
import { getFormHelpers, nameValidator, onChangeTrimmed } from "util/formUtils" import { getFormHelpers, nameValidator, onChangeTrimmed } from "util/formUtils"
import * as Yup from "yup" import * as Yup from "yup"
import { AlertBanner } from "components/AlertBanner/AlertBanner" import { AlertBanner } from "components/AlertBanner/AlertBanner"
import { makeStyles } from "@material-ui/core/styles"
import { FullPageHorizontalForm } from "components/FullPageForm/FullPageHorizontalForm"
import { FullScreenLoader } from "components/Loader/FullScreenLoader"
export enum CreateWorkspaceErrors { export enum CreateWorkspaceErrors {
GET_TEMPLATES_ERROR = "getTemplatesError", GET_TEMPLATES_ERROR = "getTemplatesError",
@ -52,7 +53,8 @@ export const CreateWorkspacePageView: FC<
React.PropsWithChildren<CreateWorkspacePageViewProps> React.PropsWithChildren<CreateWorkspacePageViewProps>
> = (props) => { > = (props) => {
const { t } = useTranslation("createWorkspacePage") const { t } = useTranslation("createWorkspacePage")
const styles = useStyles()
const formFooterStyles = useFormFooterStyles()
const [parameterValues, setParameterValues] = useState< const [parameterValues, setParameterValues] = useState<
Record<string, string> Record<string, string>
>({}) >({})
@ -92,11 +94,23 @@ export const CreateWorkspacePageView: FC<
}, },
}) })
const canSubmit =
props.workspaceQuota && props.workspaceQuota.user_workspace_limit > 0
? props.workspaceQuota.user_workspace_count <
props.workspaceQuota.user_workspace_limit
: true
const isLoading = props.loadingTemplateSchema || props.loadingTemplates
const getFieldHelpers = getFormHelpers<TypesGen.CreateWorkspaceRequest>( const getFieldHelpers = getFormHelpers<TypesGen.CreateWorkspaceRequest>(
form, form,
props.createWorkspaceErrors[CreateWorkspaceErrors.CREATE_WORKSPACE_ERROR], props.createWorkspaceErrors[CreateWorkspaceErrors.CREATE_WORKSPACE_ERROR],
) )
if (isLoading) {
return <FullScreenLoader />
}
if (props.hasTemplateErrors) { if (props.hasTemplateErrors) {
return ( return (
<Stack> <Stack>
@ -132,21 +146,10 @@ export const CreateWorkspacePageView: FC<
) )
} }
const canSubmit = if (
props.workspaceQuota && props.workspaceQuota.user_workspace_limit > 0 props.createWorkspaceErrors[CreateWorkspaceErrors.CREATE_WORKSPACE_ERROR]
? props.workspaceQuota.user_workspace_count < ) {
props.workspaceQuota.user_workspace_limit
: true
return ( return (
<FullPageForm title="Create workspace" onCancel={props.onCancel}>
<form onSubmit={form.handleSubmit}>
<Stack>
{Boolean(
props.createWorkspaceErrors[
CreateWorkspaceErrors.CREATE_WORKSPACE_ERROR
],
) && (
<AlertBanner <AlertBanner
severity="error" severity="error"
error={ error={
@ -155,18 +158,50 @@ export const CreateWorkspacePageView: FC<
] ]
} }
/> />
)} )
<TextField }
disabled
fullWidth return (
label={t("templateLabel")} <FullPageHorizontalForm title="New workspace" onCancel={props.onCancel}>
value={props.selectedTemplate?.name || props.templateName} <form onSubmit={form.handleSubmit}>
variant="outlined" <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 && (
<Stack
direction="row"
spacing={2}
className={styles.template}
alignItems="center"
>
<div className={styles.templateIcon}>
<img src={props.selectedTemplate.icon} alt="" />
</div>
<Stack direction="column" spacing={0.5}>
<span className={styles.templateName}>
{props.selectedTemplate.name}
</span>
{props.selectedTemplate.description && (
<span className={styles.templateDescription}>
{props.selectedTemplate.description}
</span>
)}
</Stack>
</Stack>
)}
{props.loadingTemplateSchema && <Loader />}
{props.selectedTemplate && props.templateSchema && (
<>
<TextField <TextField
{...getFieldHelpers("name")} {...getFieldHelpers("name")}
disabled={form.isSubmitting} disabled={form.isSubmitting}
@ -176,8 +211,25 @@ export const CreateWorkspacePageView: FC<
label={t("nameLabel")} label={t("nameLabel")}
variant="outlined" variant="outlined"
/> />
</Stack>
</div>
{/* Workspace owner */}
{props.canCreateForUser && ( {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 <UserAutocomplete
value={props.owner} value={props.owner}
onChange={props.setOwner} onChange={props.setOwner}
@ -185,7 +237,6 @@ export const CreateWorkspacePageView: FC<
inputMargin="dense" inputMargin="dense"
showAvatar showAvatar
/> />
)}
{props.workspaceQuota && ( {props.workspaceQuota && (
<WorkspaceQuota <WorkspaceQuota
@ -197,9 +248,26 @@ export const CreateWorkspacePageView: FC<
} }
/> />
)} )}
</Stack>
</div>
)}
{props.templateSchema.length > 0 && ( {/* Template params */}
<Stack> {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) => ( {props.templateSchema.map((schema) => (
<ParameterInput <ParameterInput
disabled={form.isSubmitting} disabled={form.isSubmitting}
@ -214,17 +282,115 @@ export const CreateWorkspacePageView: FC<
/> />
))} ))}
</Stack> </Stack>
</div>
)} )}
<FormFooter <FormFooter
styles={formFooterStyles}
onCancel={props.onCancel} onCancel={props.onCancel}
isLoading={props.creatingWorkspace} isLoading={props.creatingWorkspace}
submitDisabled={!canSubmit} submitDisabled={!canSubmit}
submitLabel={t("createWorkspace")}
/> />
</>
)}
</Stack> </Stack>
</form> </form>
</FullPageForm> </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%",
},
template: {
padding: theme.spacing(2.5, 3),
borderRadius: theme.shape.borderRadius,
backgroundColor: theme.palette.background.paper,
border: `1px solid ${theme.palette.divider}`,
},
templateName: {
fontSize: 16,
},
templateDescription: {
fontSize: 14,
color: theme.palette.text.secondary,
},
templateIcon: {
width: theme.spacing(5),
lineHeight: 1,
"& img": {
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),
},
},
}))

View File

@ -9,6 +9,7 @@ export const lightButtonShadow = "0 2px 2px rgba(0, 23, 121, 0.08)"
export const emptyBoxShadow = "none" export const emptyBoxShadow = "none"
export const navHeight = 62 export const navHeight = 62
export const containerWidth = 1380 export const containerWidth = 1380
export const containerWidthMedium = 1080
export const sidePadding = 24 export const sidePadding = 24
export const TitleIconSize = 48 export const TitleIconSize = 48
export const CardRadius = 2 export const CardRadius = 2

View File

@ -203,5 +203,10 @@ export const getOverrides = ({
backgroundColor: palette.divider, backgroundColor: palette.divider,
}, },
}, },
MuiLinearProgress: {
root: {
borderRadius: 999,
},
},
} }
} }