From b4aca39adb50b74e0b8129973568ce71be999bba Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Tue, 25 Jan 2022 05:19:01 +0000 Subject: [PATCH 01/12] Bring over form components --- site/components/Form/FormRow.tsx | 14 ++++++ site/components/Form/FormSection.tsx | 60 ++++++++++++++++++++++++++ site/components/Form/FormTextField.tsx | 58 ++++++++++++------------- site/components/Form/FormTitle.tsx | 31 +++++++++++++ site/components/Form/index.ts | 4 ++ site/components/Form/types.ts | 16 +++++++ 6 files changed, 154 insertions(+), 29 deletions(-) create mode 100644 site/components/Form/FormRow.tsx create mode 100644 site/components/Form/FormSection.tsx create mode 100644 site/components/Form/FormTitle.tsx create mode 100644 site/components/Form/index.ts create mode 100644 site/components/Form/types.ts diff --git a/site/components/Form/FormRow.tsx b/site/components/Form/FormRow.tsx new file mode 100644 index 0000000000000..84ae37d381a2c --- /dev/null +++ b/site/components/Form/FormRow.tsx @@ -0,0 +1,14 @@ +import { makeStyles } from "@material-ui/core/styles" +import React from "react" + +const useStyles = makeStyles((theme) => ({ + row: { + marginTop: theme.spacing(2), + marginBottom: theme.spacing(2), + }, +})) + +export const FormRow: React.FC = ({ children }) => { + const styles = useStyles() + return
{children}
+} diff --git a/site/components/Form/FormSection.tsx b/site/components/Form/FormSection.tsx new file mode 100644 index 0000000000000..ff82819163974 --- /dev/null +++ b/site/components/Form/FormSection.tsx @@ -0,0 +1,60 @@ +import { makeStyles } from "@material-ui/core/styles" +import Typography from "@material-ui/core/Typography" +import React from "react" + +export interface FormSectionProps { + title: string + description?: string +} + +export const useStyles = makeStyles((theme) => ({ + root: { + display: "flex", + flexDirection: "row", + // Borrowed from PaperForm styles + maxWidth: "852px", + width: "100%", + borderBottom: `1px solid ${theme.palette.divider}`, + }, + descriptionContainer: { + maxWidth: "200px", + flex: "0 0 200px", + display: "flex", + flexDirection: "column", + justifyContent: "flex-start", + alignItems: "flex-start", + marginTop: theme.spacing(5), + marginBottom: theme.spacing(2), + }, + descriptionText: { + fontSize: "0.9em", + lineHeight: "1em", + color: theme.palette.text.secondary, + marginTop: theme.spacing(1), + }, + contents: { + flex: 1, + marginTop: theme.spacing(2), + marginBottom: theme.spacing(2), + }, +})) + +export const FormSection: React.FC = ({ title, description, children }) => { + const styles = useStyles() + + return ( +
+
+ + {title} + + {description && ( + + {description} + + )} +
+
{children}
+
+ ) +} diff --git a/site/components/Form/FormTextField.tsx b/site/components/Form/FormTextField.tsx index db76173846a67..f80308f0b001c 100644 --- a/site/components/Form/FormTextField.tsx +++ b/site/components/Form/FormTextField.tsx @@ -1,7 +1,7 @@ import TextField, { TextFieldProps } from "@material-ui/core/TextField" +import { FormikLike } from "../../util/formik" import React from "react" import { PasswordField } from "./PasswordField" -import { FormikContextType } from "formik" /** * FormFieldProps are required props for creating form fields using a factory. @@ -11,7 +11,7 @@ export interface FormFieldProps { * form is a reference to a form or subform and is used to compute common * states such as error and helper text */ - form: FormikContextType + form: FormikLike /** * formFieldName is a field name associated with the form schema. */ @@ -26,31 +26,31 @@ export interface FormFieldProps { */ export interface FormTextFieldProps extends Pick< - TextFieldProps, - | "autoComplete" - | "autoFocus" - | "children" - | "className" - | "disabled" - | "fullWidth" - | "helperText" - | "id" - | "InputLabelProps" - | "InputProps" - | "inputProps" - | "label" - | "margin" - | "multiline" - | "onChange" - | "placeholder" - | "required" - | "rows" - | "select" - | "SelectProps" - | "style" - | "type" - >, - FormFieldProps { + TextFieldProps, + | "autoComplete" + | "autoFocus" + | "children" + | "className" + | "disabled" + | "fullWidth" + | "helperText" + | "id" + | "InputLabelProps" + | "InputProps" + | "inputProps" + | "label" + | "margin" + | "multiline" + | "onChange" + | "placeholder" + | "required" + | "rows" + | "select" + | "SelectProps" + | "style" + | "type" + >, + FormFieldProps { /** * eventTransform is an optional transformer on the event data before it is * processed by formik. @@ -124,7 +124,7 @@ export const formTextFieldFactory = (): React.FC> => { // Conversion to a string primitive is necessary as formFieldName is an in // indexable type such as a string, number or enum. - const fieldId = String(formFieldName) + const fieldId = FormikLike.getFieldId(form, String(formFieldName)) const Component = isPassword ? PasswordField : TextField const inputType = isPassword ? undefined : type @@ -167,4 +167,4 @@ export const formTextFieldFactory = (): React.FC> => { // Required when using an anonymous factory function component.displayName = "FormTextField" return component -} +} \ No newline at end of file diff --git a/site/components/Form/FormTitle.tsx b/site/components/Form/FormTitle.tsx new file mode 100644 index 0000000000000..8e6275853719c --- /dev/null +++ b/site/components/Form/FormTitle.tsx @@ -0,0 +1,31 @@ +import { makeStyles } from "@material-ui/core/styles" +import Typography from "@material-ui/core/Typography" +import React from "react" + +export interface TitleProps { + title: string + detail: React.ReactNode +} + +const useStyles = makeStyles((theme) => ({ + title: { + textAlign: "center", + marginTop: theme.spacing(5), + marginBottom: theme.spacing(5), + + "& h3": { + marginBottom: theme.spacing(1), + }, + }, +})) + +export const Title: React.FC = ({ title, detail }) => { + const styles = useStyles() + + return ( +
+ {title} + {detail} +
+ ) +} diff --git a/site/components/Form/index.ts b/site/components/Form/index.ts new file mode 100644 index 0000000000000..f9b0464de03ce --- /dev/null +++ b/site/components/Form/index.ts @@ -0,0 +1,4 @@ +export * from "./FormRow" +export * from "./FormSection" +export * from "./FormTextField" +export * from "./FormTitle" \ No newline at end of file diff --git a/site/components/Form/types.ts b/site/components/Form/types.ts new file mode 100644 index 0000000000000..bc30b42d424e7 --- /dev/null +++ b/site/components/Form/types.ts @@ -0,0 +1,16 @@ +import { FormikLike } from "../../util/formik" + +/** + * FormFieldProps are required props for creating form fields using a factory. + */ +export interface FormFieldProps { + /** + * form is a reference to a form or subform and is used to compute common + * states such as error and helper text + */ + form: FormikLike + /** + * formFieldName is a field name associated with the form schema. + */ + formFieldName: keyof T +} From 362bb5345ad46a8b0c7e287bd8276b24be180fd0 Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Tue, 25 Jan 2022 05:19:20 +0000 Subject: [PATCH 02/12] Add project create page --- site/pages/projects/create.tsx | 63 ++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 site/pages/projects/create.tsx diff --git a/site/pages/projects/create.tsx b/site/pages/projects/create.tsx new file mode 100644 index 0000000000000..dc2d9bcd8be26 --- /dev/null +++ b/site/pages/projects/create.tsx @@ -0,0 +1,63 @@ +import React from "react" +import Box from "@material-ui/core/Box" +import { makeStyles } from "@material-ui/core/styles" +import Paper from "@material-ui/core/Paper" +import AddWorkspaceIcon from "@material-ui/icons/AddToQueue" + +import { useUser } from "../../contexts/UserContext" +import { FullScreenLoader } from "../../components/Loader/FullScreenLoader" + +const CreateProjectPage: React.FC = () => { + const styles = useStyles() + const { me, signOut } = useUser(true) + + if (!me) { + return + } + + const createWorkspace = () => { + alert("create") + } + + const button = { + children: "New Workspace", + onClick: createWorkspace, + } + + return ( +
+
+ + color="primary" + onClick={createWorkspace} + options={[ + { + label: "New workspace", + value: "custom", + }, + { + label: "New workspace from template", + value: "template", + }, + ]} + startIcon={} + textTransform="none" + /> +
+ + + + + +
+ ) +} + +const useStyles = makeStyles((theme) => ({ + root: { + display: "flex", + flexDirection: "column", + }, +})) + +export default CreateProjectPage From 396b3c336cf5566d9b4b801cdf0a6289cc2e69cd Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Tue, 25 Jan 2022 06:22:17 +0000 Subject: [PATCH 03/12] Add dropdown field; /projects/create route; initial form --- site/api.ts | 26 ++++ site/components/Form/FormDropdownField.tsx | 85 +++++++++++++ site/components/Form/FormRow.tsx | 14 -- site/components/Form/FormSection.tsx | 4 +- site/components/Form/FormTextField.tsx | 58 ++++----- site/components/Form/FormTitle.tsx | 8 +- site/components/Form/index.ts | 4 +- site/components/Form/index.tsx | 1 - site/forms/CreateProjectForm.tsx | 141 +++++++++++++++++++++ site/pages/projects/create.tsx | 56 +++----- 10 files changed, 309 insertions(+), 88 deletions(-) create mode 100644 site/components/Form/FormDropdownField.tsx delete mode 100644 site/components/Form/FormRow.tsx delete mode 100644 site/components/Form/index.tsx create mode 100644 site/forms/CreateProjectForm.tsx diff --git a/site/api.ts b/site/api.ts index aece323b14aaf..7bc08cc423b05 100644 --- a/site/api.ts +++ b/site/api.ts @@ -2,6 +2,32 @@ interface LoginResponse { session_token: string } +/** + * `Organization` must be kept in sync with the go struct in organizations.go + */ +export interface Organization { + id: string + name: string + created_at: string + updated_at: string +} + +export interface Provisioner { + id: string + name: string +} + +export const provisioners: Provisioner[] = [ + { + id: "terraform", + name: "Terraform", + }, + { + id: "cdr-basic", + name: "Basic", + }, +] + export const login = async (email: string, password: string): Promise => { const response = await fetch("/api/v2/login", { method: "POST", diff --git a/site/components/Form/FormDropdownField.tsx b/site/components/Form/FormDropdownField.tsx new file mode 100644 index 0000000000000..9271aac415838 --- /dev/null +++ b/site/components/Form/FormDropdownField.tsx @@ -0,0 +1,85 @@ +import Box from "@material-ui/core/Box" +import MenuItem from "@material-ui/core/MenuItem" +import { makeStyles } from "@material-ui/core/styles" +import Typography from "@material-ui/core/Typography" +import React from "react" + +import { formTextFieldFactory, FormTextFieldProps } from "./FormTextField" + +export interface DropdownItem { + value: string + name: string + description?: string +} + +export interface FormDropdownFieldProps extends FormTextFieldProps { + items: DropdownItem[] +} + +export const formDropdownFieldFactory = (): React.FC> => { + const FormTextField = formTextFieldFactory() + + const component: React.FC> = ({ items, ...props }) => { + const styles = useStyles() + return ( + + {items.map((item: DropdownItem) => ( + + + + {item.name} + + {item.description && ( + + + {item.description} + + + )} + + + ))} + + ) + } + + // Required when using an anonymous factory function + component.displayName = "FormDropdownField" + return component +} + +const useStyles = makeStyles({ + hintText: { + opacity: 0.75, + }, +}) + +/* + handleSelectPool(ev.target.value)} + value={selectedPool ? selectedPool.name : ""} + disabled={fieldIsDisabled} + required + label="Workspace provider" + select +> + {poolsSorted.map((pool: UIResourcePoolWithRegion) => ( + + + + + {pool.name} + + {pool.region !== null && ( + + + {pool.region} + + + )} + + + ))} +*/ diff --git a/site/components/Form/FormRow.tsx b/site/components/Form/FormRow.tsx deleted file mode 100644 index 84ae37d381a2c..0000000000000 --- a/site/components/Form/FormRow.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { makeStyles } from "@material-ui/core/styles" -import React from "react" - -const useStyles = makeStyles((theme) => ({ - row: { - marginTop: theme.spacing(2), - marginBottom: theme.spacing(2), - }, -})) - -export const FormRow: React.FC = ({ children }) => { - const styles = useStyles() - return
{children}
-} diff --git a/site/components/Form/FormSection.tsx b/site/components/Form/FormSection.tsx index ff82819163974..5621c9858e53b 100644 --- a/site/components/Form/FormSection.tsx +++ b/site/components/Form/FormSection.tsx @@ -34,8 +34,8 @@ export const useStyles = makeStyles((theme) => ({ }, contents: { flex: 1, - marginTop: theme.spacing(2), - marginBottom: theme.spacing(2), + marginTop: theme.spacing(4), + marginBottom: theme.spacing(4), }, })) diff --git a/site/components/Form/FormTextField.tsx b/site/components/Form/FormTextField.tsx index f80308f0b001c..db76173846a67 100644 --- a/site/components/Form/FormTextField.tsx +++ b/site/components/Form/FormTextField.tsx @@ -1,7 +1,7 @@ import TextField, { TextFieldProps } from "@material-ui/core/TextField" -import { FormikLike } from "../../util/formik" import React from "react" import { PasswordField } from "./PasswordField" +import { FormikContextType } from "formik" /** * FormFieldProps are required props for creating form fields using a factory. @@ -11,7 +11,7 @@ export interface FormFieldProps { * form is a reference to a form or subform and is used to compute common * states such as error and helper text */ - form: FormikLike + form: FormikContextType /** * formFieldName is a field name associated with the form schema. */ @@ -26,31 +26,31 @@ export interface FormFieldProps { */ export interface FormTextFieldProps extends Pick< - TextFieldProps, - | "autoComplete" - | "autoFocus" - | "children" - | "className" - | "disabled" - | "fullWidth" - | "helperText" - | "id" - | "InputLabelProps" - | "InputProps" - | "inputProps" - | "label" - | "margin" - | "multiline" - | "onChange" - | "placeholder" - | "required" - | "rows" - | "select" - | "SelectProps" - | "style" - | "type" - >, - FormFieldProps { + TextFieldProps, + | "autoComplete" + | "autoFocus" + | "children" + | "className" + | "disabled" + | "fullWidth" + | "helperText" + | "id" + | "InputLabelProps" + | "InputProps" + | "inputProps" + | "label" + | "margin" + | "multiline" + | "onChange" + | "placeholder" + | "required" + | "rows" + | "select" + | "SelectProps" + | "style" + | "type" + >, + FormFieldProps { /** * eventTransform is an optional transformer on the event data before it is * processed by formik. @@ -124,7 +124,7 @@ export const formTextFieldFactory = (): React.FC> => { // Conversion to a string primitive is necessary as formFieldName is an in // indexable type such as a string, number or enum. - const fieldId = FormikLike.getFieldId(form, String(formFieldName)) + const fieldId = String(formFieldName) const Component = isPassword ? PasswordField : TextField const inputType = isPassword ? undefined : type @@ -167,4 +167,4 @@ export const formTextFieldFactory = (): React.FC> => { // Required when using an anonymous factory function component.displayName = "FormTextField" return component -} \ No newline at end of file +} diff --git a/site/components/Form/FormTitle.tsx b/site/components/Form/FormTitle.tsx index 8e6275853719c..59b9ef7e7beea 100644 --- a/site/components/Form/FormTitle.tsx +++ b/site/components/Form/FormTitle.tsx @@ -2,9 +2,9 @@ import { makeStyles } from "@material-ui/core/styles" import Typography from "@material-ui/core/Typography" import React from "react" -export interface TitleProps { +export interface FormTitleProps { title: string - detail: React.ReactNode + detail?: React.ReactNode } const useStyles = makeStyles((theme) => ({ @@ -19,13 +19,13 @@ const useStyles = makeStyles((theme) => ({ }, })) -export const Title: React.FC = ({ title, detail }) => { +export const FormTitle: React.FC = ({ title, detail }) => { const styles = useStyles() return (
{title} - {detail} + {detail && {detail}}
) } diff --git a/site/components/Form/index.ts b/site/components/Form/index.ts index f9b0464de03ce..80ddbbac74b3b 100644 --- a/site/components/Form/index.ts +++ b/site/components/Form/index.ts @@ -1,4 +1,4 @@ -export * from "./FormRow" export * from "./FormSection" +export * from "./FormDropdownField" export * from "./FormTextField" -export * from "./FormTitle" \ No newline at end of file +export * from "./FormTitle" diff --git a/site/components/Form/index.tsx b/site/components/Form/index.tsx deleted file mode 100644 index 4e916cdae4d1d..0000000000000 --- a/site/components/Form/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export * from "./FormTextField" diff --git a/site/forms/CreateProjectForm.tsx b/site/forms/CreateProjectForm.tsx new file mode 100644 index 0000000000000..33e8f99860859 --- /dev/null +++ b/site/forms/CreateProjectForm.tsx @@ -0,0 +1,141 @@ +import Button from "@material-ui/core/Button" +import { makeStyles } from "@material-ui/core/styles" +import { useFormik } from "formik" +import React from "react" +import * as Yup from "yup" + +import { + FormTitle, + FormSection, + formTextFieldFactory, + formDropdownFieldFactory, + DropdownItem, +} from "../components/Form" + +import { Organization, Provisioner } from "./../api" + +export interface CreateProjectRequest { + provisionerId: string + organizationId: string + name: string +} + +export interface CreateProjectFormProps { + provisioners: Provisioner[] + organizations: Organization[] + onSubmit: (request: CreateProjectRequest) => Promise + onCancel: () => void +} + +const validationSchema = Yup.object({ + provisionerId: Yup.string().required("Email is required."), + organizationId: Yup.string().required("Organization is required."), + name: Yup.string().required("Name is required"), +}) + +const FormTextField = formTextFieldFactory() +const FormDropdownField = formDropdownFieldFactory() + +export const CreateProjectForm: React.FC = ({ + provisioners, + organizations, + onSubmit, + onCancel, +}) => { + const styles = useStyles() + + const form = useFormik({ + initialValues: { + provisionerId: provisioners[0].id, + organizationId: organizations[0].id, + name: "", + }, + enableReinitialize: true, + validationSchema: validationSchema, + onSubmit: onSubmit, + }) + + const organizationDropDownItems: DropdownItem[] = organizations.map((org) => { + return { + value: org.id, + name: org.name, + } + }) + + const provisionerDropDownItems: DropdownItem[] = provisioners.map((provisioner) => { + return { + value: provisioner.id, + name: provisioner.name, + } + }) + + return ( +
+ + + + + + + + + + + + + + +
+ + +
+
+ ) +} + +const useStyles = makeStyles(() => ({ + root: { + maxWidth: "1380px", + display: "flex", + flexDirection: "column", + alignItems: "center", + }, + footer: { + display: "flex", + flex: "0", + flexDirection: "row", + justifyContent: "center", + alignItems: "center", + }, + button: { + margin: "1em", + }, +})) diff --git a/site/pages/projects/create.tsx b/site/pages/projects/create.tsx index dc2d9bcd8be26..9f158a04d403a 100644 --- a/site/pages/projects/create.tsx +++ b/site/pages/projects/create.tsx @@ -1,54 +1,36 @@ import React from "react" -import Box from "@material-ui/core/Box" import { makeStyles } from "@material-ui/core/styles" -import Paper from "@material-ui/core/Paper" -import AddWorkspaceIcon from "@material-ui/icons/AddToQueue" +import useSWR from "swr" +import { provisioners } from "../../api" import { useUser } from "../../contexts/UserContext" import { FullScreenLoader } from "../../components/Loader/FullScreenLoader" +import { CreateProjectForm } from "../../forms/CreateProjectForm" const CreateProjectPage: React.FC = () => { const styles = useStyles() - const { me, signOut } = useUser(true) + const { me } = useUser(true) + const { data: organizations, error } = useSWR("/api/v2/users/me/organizations") - if (!me) { - return - } - - const createWorkspace = () => { - alert("create") + if (error) { + // TODO: Merge with error component in other PR + return
{"Error"}
} - const button = { - children: "New Workspace", - onClick: createWorkspace, + if (!me || !organizations) { + return } return (
-
- - color="primary" - onClick={createWorkspace} - options={[ - { - label: "New workspace", - value: "custom", - }, - { - label: "New workspace from template", - value: "template", - }, - ]} - startIcon={} - textTransform="none" - /> -
- - - - - +
{JSON.stringify(organizations)}
+ + alert(JSON.stringify(request))} + onCancel={() => alert("Cancelled")} + />
) } @@ -57,6 +39,8 @@ const useStyles = makeStyles((theme) => ({ root: { display: "flex", flexDirection: "column", + height: "100vh", + backgroundColor: theme.palette.background.paper, }, })) From d48818d4801fea77e490628b432836fe9024037c Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Tue, 25 Jan 2022 06:24:14 +0000 Subject: [PATCH 04/12] Clean up --- site/pages/projects/create.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/site/pages/projects/create.tsx b/site/pages/projects/create.tsx index 9f158a04d403a..5424e3eda9148 100644 --- a/site/pages/projects/create.tsx +++ b/site/pages/projects/create.tsx @@ -23,8 +23,6 @@ const CreateProjectPage: React.FC = () => { return (
-
{JSON.stringify(organizations)}
- Date: Tue, 25 Jan 2022 06:25:52 +0000 Subject: [PATCH 05/12] Remove extra code --- site/components/Form/FormDropdownField.tsx | 32 +--------------------- 1 file changed, 1 insertion(+), 31 deletions(-) diff --git a/site/components/Form/FormDropdownField.tsx b/site/components/Form/FormDropdownField.tsx index 9271aac415838..3d3ccf87bcf3f 100644 --- a/site/components/Form/FormDropdownField.tsx +++ b/site/components/Form/FormDropdownField.tsx @@ -52,34 +52,4 @@ const useStyles = makeStyles({ hintText: { opacity: 0.75, }, -}) - -/* - handleSelectPool(ev.target.value)} - value={selectedPool ? selectedPool.name : ""} - disabled={fieldIsDisabled} - required - label="Workspace provider" - select -> - {poolsSorted.map((pool: UIResourcePoolWithRegion) => ( - - - - - {pool.name} - - {pool.region !== null && ( - - - {pool.region} - - - )} - - - ))} -*/ +}) \ No newline at end of file From 35ddcab9564656a924bb87a4af98fd291e1241bc Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Tue, 25 Jan 2022 19:27:19 +0000 Subject: [PATCH 06/12] Hook up cancel button --- site/api.ts | 1 - site/components/Form/FormDropdownField.tsx | 2 +- site/pages/projects/create.tsx | 8 +++++++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/site/api.ts b/site/api.ts index bb21a5ff6c3e7..3c9bb4390ee52 100644 --- a/site/api.ts +++ b/site/api.ts @@ -39,7 +39,6 @@ export interface Project { active_version_id: string } ->>>>>>> main export const login = async (email: string, password: string): Promise => { const response = await fetch("/api/v2/login", { method: "POST", diff --git a/site/components/Form/FormDropdownField.tsx b/site/components/Form/FormDropdownField.tsx index 3d3ccf87bcf3f..83d9404afa668 100644 --- a/site/components/Form/FormDropdownField.tsx +++ b/site/components/Form/FormDropdownField.tsx @@ -52,4 +52,4 @@ const useStyles = makeStyles({ hintText: { opacity: 0.75, }, -}) \ No newline at end of file +}) diff --git a/site/pages/projects/create.tsx b/site/pages/projects/create.tsx index 5424e3eda9148..b37442ccb8d70 100644 --- a/site/pages/projects/create.tsx +++ b/site/pages/projects/create.tsx @@ -1,5 +1,6 @@ import React from "react" import { makeStyles } from "@material-ui/core/styles" +import { useRouter } from "next/router" import useSWR from "swr" import { provisioners } from "../../api" @@ -8,6 +9,7 @@ import { FullScreenLoader } from "../../components/Loader/FullScreenLoader" import { CreateProjectForm } from "../../forms/CreateProjectForm" const CreateProjectPage: React.FC = () => { + const router = useRouter() const styles = useStyles() const { me } = useUser(true) const { data: organizations, error } = useSWR("/api/v2/users/me/organizations") @@ -21,13 +23,17 @@ const CreateProjectPage: React.FC = () => { return } + const onCancel = async () => { + await router.push("/projects") + } + return (
alert(JSON.stringify(request))} - onCancel={() => alert("Cancelled")} + onCancel={onCancel} />
) From 502a034ee3c75561df5d8d3ce529db229ba2c0ec Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Tue, 25 Jan 2022 19:45:50 +0000 Subject: [PATCH 07/12] Wire up more to the create form --- site/api.ts | 28 +++++++++++++++++++++++++ site/forms/CreateProjectForm.tsx | 35 ++++++++++++++++++-------------- site/pages/projects/create.tsx | 16 ++++++++++----- 3 files changed, 59 insertions(+), 20 deletions(-) diff --git a/site/api.ts b/site/api.ts index 3c9bb4390ee52..f8c3ae330ebe8 100644 --- a/site/api.ts +++ b/site/api.ts @@ -1,3 +1,5 @@ +import { mutate } from "swr" + interface LoginResponse { session_token: string } @@ -39,6 +41,32 @@ export interface Project { active_version_id: string } +export interface CreateProjectRequest { + name: string + organizationId: string + provisioner: string +} + +export namespace Project { + export const create = async (request: CreateProjectRequest): Promise => { + const response = await fetch(`/api/v2/projects/${request.organizationId}/`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(request), + }) + + const body = await response.json() + await mutate("/api/v2/projects") + if (!response.ok) { + throw new Error(body.message) + } + + return body + } +} + export const login = async (email: string, password: string): Promise => { const response = await fetch("/api/v2/login", { method: "POST", diff --git a/site/forms/CreateProjectForm.tsx b/site/forms/CreateProjectForm.tsx index 33e8f99860859..b661c58c637fb 100644 --- a/site/forms/CreateProjectForm.tsx +++ b/site/forms/CreateProjectForm.tsx @@ -12,23 +12,17 @@ import { DropdownItem, } from "../components/Form" -import { Organization, Provisioner } from "./../api" - -export interface CreateProjectRequest { - provisionerId: string - organizationId: string - name: string -} +import { Organization, Project, Provisioner, CreateProjectRequest } from "./../api" export interface CreateProjectFormProps { provisioners: Provisioner[] organizations: Organization[] - onSubmit: (request: CreateProjectRequest) => Promise + onSubmit: (request: CreateProjectRequest) => Promise onCancel: () => void } const validationSchema = Yup.object({ - provisionerId: Yup.string().required("Email is required."), + provisioner: Yup.string().required("Provisioner is required."), organizationId: Yup.string().required("Organization is required."), name: Yup.string().required("Name is required"), }) @@ -46,18 +40,20 @@ export const CreateProjectForm: React.FC = ({ const form = useFormik({ initialValues: { - provisionerId: provisioners[0].id, - organizationId: organizations[0].id, + provisioner: provisioners[0].id, + organizationId: organizations[0].name, name: "", }, enableReinitialize: true, validationSchema: validationSchema, - onSubmit: onSubmit, + onSubmit: (req) => { + return onSubmit(req) + }, }) const organizationDropDownItems: DropdownItem[] = organizations.map((org) => { return { - value: org.id, + value: org.name, name: org.name, } }) @@ -100,7 +96,7 @@ export const CreateProjectForm: React.FC = ({ = ({ -
diff --git a/site/pages/projects/create.tsx b/site/pages/projects/create.tsx index b37442ccb8d70..99aea8eac40cd 100644 --- a/site/pages/projects/create.tsx +++ b/site/pages/projects/create.tsx @@ -3,8 +3,9 @@ import { makeStyles } from "@material-ui/core/styles" import { useRouter } from "next/router" import useSWR from "swr" -import { provisioners } from "../../api" +import * as API from "../../api" import { useUser } from "../../contexts/UserContext" +import { ErrorSummary } from "../../components/ErrorSummary" import { FullScreenLoader } from "../../components/Loader/FullScreenLoader" import { CreateProjectForm } from "../../forms/CreateProjectForm" @@ -15,8 +16,7 @@ const CreateProjectPage: React.FC = () => { const { data: organizations, error } = useSWR("/api/v2/users/me/organizations") if (error) { - // TODO: Merge with error component in other PR - return
{"Error"}
+ return } if (!me || !organizations) { @@ -27,12 +27,18 @@ const CreateProjectPage: React.FC = () => { await router.push("/projects") } + const onSubmit = async (req: API.CreateProjectRequest) => { + const project = await API.Project.create(req) + await router.push("/projects") + return project + } + return (
alert(JSON.stringify(request))} + onSubmit={onSubmit} onCancel={onCancel} />
From 538bfba67b66fff71d48efb4123ff24b429b03df Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Tue, 25 Jan 2022 20:36:21 +0000 Subject: [PATCH 08/12] Use loading spinner for create --- site/forms/CreateProjectForm.tsx | 8 +++++--- site/pages/projects/create.tsx | 1 + 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/site/forms/CreateProjectForm.tsx b/site/forms/CreateProjectForm.tsx index b661c58c637fb..7aa12504585c9 100644 --- a/site/forms/CreateProjectForm.tsx +++ b/site/forms/CreateProjectForm.tsx @@ -11,7 +11,7 @@ import { formDropdownFieldFactory, DropdownItem, } from "../components/Form" - +import { LoadingButton } from "../components/Button" import { Organization, Project, Provisioner, CreateProjectRequest } from "./../api" export interface CreateProjectFormProps { @@ -109,7 +109,8 @@ export const CreateProjectForm: React.FC = ({ - + ) @@ -129,6 +130,7 @@ export const CreateProjectForm: React.FC = ({ const useStyles = makeStyles(() => ({ root: { maxWidth: "1380px", + width: "100%", display: "flex", flexDirection: "column", alignItems: "center", diff --git a/site/pages/projects/create.tsx b/site/pages/projects/create.tsx index 99aea8eac40cd..66adad4542ee1 100644 --- a/site/pages/projects/create.tsx +++ b/site/pages/projects/create.tsx @@ -49,6 +49,7 @@ const useStyles = makeStyles((theme) => ({ root: { display: "flex", flexDirection: "column", + alignItems: "center", height: "100vh", backgroundColor: theme.palette.background.paper, }, From e872b9eabf429c323ad5c91544ceccb51a08b8b6 Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Tue, 25 Jan 2022 20:40:22 +0000 Subject: [PATCH 09/12] Clean up lint issues --- site/components/Form/FormDropdownField.tsx | 6 +++--- site/components/Form/types.ts | 16 ---------------- site/forms/CreateProjectForm.tsx | 11 ++++------- 3 files changed, 7 insertions(+), 26 deletions(-) delete mode 100644 site/components/Form/types.ts diff --git a/site/components/Form/FormDropdownField.tsx b/site/components/Form/FormDropdownField.tsx index 83d9404afa668..6111100f50836 100644 --- a/site/components/Form/FormDropdownField.tsx +++ b/site/components/Form/FormDropdownField.tsx @@ -19,7 +19,7 @@ export interface FormDropdownFieldProps extends FormTextFieldProps { export const formDropdownFieldFactory = (): React.FC> => { const FormTextField = formTextFieldFactory() - const component: React.FC> = ({ items, ...props }) => { + const Component: React.FC> = ({ items, ...props }) => { const styles = useStyles() return ( @@ -44,8 +44,8 @@ export const formDropdownFieldFactory = (): React.FC { - /** - * form is a reference to a form or subform and is used to compute common - * states such as error and helper text - */ - form: FormikLike - /** - * formFieldName is a field name associated with the form schema. - */ - formFieldName: keyof T -} diff --git a/site/forms/CreateProjectForm.tsx b/site/forms/CreateProjectForm.tsx index 7aa12504585c9..9715ea1a3d18f 100644 --- a/site/forms/CreateProjectForm.tsx +++ b/site/forms/CreateProjectForm.tsx @@ -76,7 +76,7 @@ export const CreateProjectForm: React.FC = ({ fullWidth helperText="A unique name describing your project." label="Project Name" - placeholder={"my-project"} + placeholder="my-project" required /> @@ -84,7 +84,7 @@ export const CreateProjectForm: React.FC = ({ = ({ = ({ { - console.log("submit clicked: " + JSON.stringify(form.values)) - form.submitForm() - }} + onClick={form.submitForm} variant="contained" color="primary" type="submit" From 9036e600440186a0cca5ea49ae554bd48af63c37 Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Tue, 25 Jan 2022 20:54:04 +0000 Subject: [PATCH 10/12] Add smoke test for CreateProjectForm --- site/forms/CreateProjectForm.test.tsx | 29 +++++++++++++++++++++++++ site/test_helpers/index.tsx | 2 +- site/test_helpers/mocks.ts | 31 +++++++++++++++++++++++++++ site/test_helpers/user.ts | 8 ------- 4 files changed, 61 insertions(+), 9 deletions(-) create mode 100644 site/forms/CreateProjectForm.test.tsx create mode 100644 site/test_helpers/mocks.ts delete mode 100644 site/test_helpers/user.ts diff --git a/site/forms/CreateProjectForm.test.tsx b/site/forms/CreateProjectForm.test.tsx new file mode 100644 index 0000000000000..a3eb0c0ce7246 --- /dev/null +++ b/site/forms/CreateProjectForm.test.tsx @@ -0,0 +1,29 @@ +import { render, screen } from "@testing-library/react" +import React from "react" +import { CreateProjectForm } from "./CreateProjectForm" +import { MockProvisioner, MockOrganization, MockProject } from "./../test_helpers" + +describe("CreateProjectForm", () => { + it("renders", async () => { + // Given + const provisioners = [MockProvisioner] + const organizations = [MockOrganization] + const onSubmit = () => Promise.resolve(MockProject) + const onCancel = () => Promise.resolve() + + // When + render( + , + ) + + // Then + // Simple smoke test to verify form renders + const element = await screen.findByText("Create Project") + expect(element).toBeDefined() + }) +}) diff --git a/site/test_helpers/index.tsx b/site/test_helpers/index.tsx index 8242832ee7d50..6cd4183fb1db1 100644 --- a/site/test_helpers/index.tsx +++ b/site/test_helpers/index.tsx @@ -12,4 +12,4 @@ export const render = (component: React.ReactElement): RenderResult => { return wrappedRender({component}) } -export * from "./user" +export * from "./mocks" diff --git a/site/test_helpers/mocks.ts b/site/test_helpers/mocks.ts new file mode 100644 index 0000000000000..f939fd021ef01 --- /dev/null +++ b/site/test_helpers/mocks.ts @@ -0,0 +1,31 @@ +import { User } from "../contexts/UserContext" +import { Provisioner, Organization, Project } from "../api" + +export const MockUser: User = { + id: "test-user-id", + username: "TestUser", + email: "test@coder.com", + created_at: "", +} + +export const MockProject: Project = { + id: "project-id", + created_at: "", + updated_at: "", + organization_id: "test-org", + name: "Test Project", + provisioner: "test-provisioner", + active_version_id: "", +} + +export const MockProvisioner: Provisioner = { + id: "test-provisioner", + name: "Test Provisioner", +} + +export const MockOrganization: Organization = { + id: "test-org", + name: "Test Organization", + created_at: "", + updated_at: "", +} diff --git a/site/test_helpers/user.ts b/site/test_helpers/user.ts deleted file mode 100644 index 652538ef19583..0000000000000 --- a/site/test_helpers/user.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { User } from "../contexts/UserContext" - -export const MockUser: User = { - id: "test-user-id", - username: "TestUser", - email: "test@coder.com", - created_at: "", -} From e1b450fce8ffd4c39b768de0fedb8d1e022111ff Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Tue, 25 Jan 2022 22:04:17 +0000 Subject: [PATCH 11/12] Refactor form to use FormTextField w/o HoC --- site/components/Form/FormDropdownField.tsx | 4 +--- site/forms/CreateProjectForm.tsx | 13 +++---------- 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/site/components/Form/FormDropdownField.tsx b/site/components/Form/FormDropdownField.tsx index 6111100f50836..9ad3988b4db1c 100644 --- a/site/components/Form/FormDropdownField.tsx +++ b/site/components/Form/FormDropdownField.tsx @@ -4,7 +4,7 @@ import { makeStyles } from "@material-ui/core/styles" import Typography from "@material-ui/core/Typography" import React from "react" -import { formTextFieldFactory, FormTextFieldProps } from "./FormTextField" +import { FormTextField, FormTextFieldProps } from "./FormTextField" export interface DropdownItem { value: string @@ -17,8 +17,6 @@ export interface FormDropdownFieldProps extends FormTextFieldProps { } export const formDropdownFieldFactory = (): React.FC> => { - const FormTextField = formTextFieldFactory() - const Component: React.FC> = ({ items, ...props }) => { const styles = useStyles() return ( diff --git a/site/forms/CreateProjectForm.tsx b/site/forms/CreateProjectForm.tsx index 9715ea1a3d18f..406b622ba4f3c 100644 --- a/site/forms/CreateProjectForm.tsx +++ b/site/forms/CreateProjectForm.tsx @@ -1,16 +1,10 @@ import Button from "@material-ui/core/Button" import { makeStyles } from "@material-ui/core/styles" -import { useFormik } from "formik" +import { FormikContextType, useFormik } from "formik" import React from "react" import * as Yup from "yup" -import { - FormTitle, - FormSection, - formTextFieldFactory, - formDropdownFieldFactory, - DropdownItem, -} from "../components/Form" +import { FormTextField, FormTitle, FormSection, formDropdownFieldFactory, DropdownItem } from "../components/Form" import { LoadingButton } from "../components/Button" import { Organization, Project, Provisioner, CreateProjectRequest } from "./../api" @@ -27,7 +21,6 @@ const validationSchema = Yup.object({ name: Yup.string().required("Name is required"), }) -const FormTextField = formTextFieldFactory() const FormDropdownField = formDropdownFieldFactory() export const CreateProjectForm: React.FC = ({ @@ -38,7 +31,7 @@ export const CreateProjectForm: React.FC = ({ }) => { const styles = useStyles() - const form = useFormik({ + const form: FormikContextType = useFormik({ initialValues: { provisioner: provisioners[0].id, organizationId: organizations[0].name, From 458703edee22c79310cc16a57b148b04d0b55253 Mon Sep 17 00:00:00 2001 From: Bryan Phelps Date: Tue, 25 Jan 2022 22:06:43 +0000 Subject: [PATCH 12/12] Now, FormDropdownField doesn't need a HoC --- site/components/Form/FormDropdownField.tsx | 46 ++++++++++------------ site/forms/CreateProjectForm.tsx | 4 +- 2 files changed, 21 insertions(+), 29 deletions(-) diff --git a/site/components/Form/FormDropdownField.tsx b/site/components/Form/FormDropdownField.tsx index 9ad3988b4db1c..c4d8993da7df3 100644 --- a/site/components/Form/FormDropdownField.tsx +++ b/site/components/Form/FormDropdownField.tsx @@ -16,34 +16,28 @@ export interface FormDropdownFieldProps extends FormTextFieldProps { items: DropdownItem[] } -export const formDropdownFieldFactory = (): React.FC> => { - const Component: React.FC> = ({ items, ...props }) => { - const styles = useStyles() - return ( - - {items.map((item: DropdownItem) => ( - - +export const FormDropdownField = ({ items, ...props }: FormDropdownFieldProps): React.ReactElement => { + const styles = useStyles() + return ( + + {items.map((item: DropdownItem) => ( + + + + {item.name} + + {item.description && ( - {item.name} + + {item.description} + - {item.description && ( - - - {item.description} - - - )} - - - ))} - - ) - } - - // Required when using an anonymous factory function - Component.displayName = "FormDropdownField" - return Component + )} + + + ))} + + ) } const useStyles = makeStyles({ diff --git a/site/forms/CreateProjectForm.tsx b/site/forms/CreateProjectForm.tsx index 406b622ba4f3c..1a4558e9b2ef8 100644 --- a/site/forms/CreateProjectForm.tsx +++ b/site/forms/CreateProjectForm.tsx @@ -4,7 +4,7 @@ import { FormikContextType, useFormik } from "formik" import React from "react" import * as Yup from "yup" -import { FormTextField, FormTitle, FormSection, formDropdownFieldFactory, DropdownItem } from "../components/Form" +import { DropdownItem, FormDropdownField, FormTextField, FormTitle, FormSection } from "../components/Form" import { LoadingButton } from "../components/Button" import { Organization, Project, Provisioner, CreateProjectRequest } from "./../api" @@ -21,8 +21,6 @@ const validationSchema = Yup.object({ name: Yup.string().required("Name is required"), }) -const FormDropdownField = formDropdownFieldFactory() - export const CreateProjectForm: React.FC = ({ provisioners, organizations,