Skip to content

Commit

Permalink
feat: Initial Project Create Form ('/projects/create') (#60)
Browse files Browse the repository at this point in the history
This implements a simple form for creating projects:

![2022-01-25 12 58 21](https://user-images.githubusercontent.com/88213859/151058767-be3672f6-e100-48c8-849e-cc6de94f3ebf.gif)

Fixes #65
  • Loading branch information
bryphe-coder committed Jan 26, 2022
1 parent bbd8b8f commit c7fb16e
Show file tree
Hide file tree
Showing 12 changed files with 451 additions and 10 deletions.
54 changes: 54 additions & 0 deletions site/api.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,35 @@
import { mutate } from "swr"

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",
},
]

// This must be kept in sync with the `Project` struct in the back-end
export interface Project {
id: string
Expand All @@ -13,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<Project> => {
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<LoginResponse> => {
const response = await fetch("/api/v2/login", {
method: "POST",
Expand Down
47 changes: 47 additions & 0 deletions site/components/Form/FormDropdownField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
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 { FormTextField, FormTextFieldProps } from "./FormTextField"

export interface DropdownItem {
value: string
name: string
description?: string
}

export interface FormDropdownFieldProps<T> extends FormTextFieldProps<T> {
items: DropdownItem[]
}

export const FormDropdownField = <T,>({ items, ...props }: FormDropdownFieldProps<T>): React.ReactElement => {
const styles = useStyles()
return (
<FormTextField select {...props}>
{items.map((item: DropdownItem) => (
<MenuItem key={item.value} value={item.value}>
<Box alignItems="center" display="flex">
<Box ml={1}>
<Typography>{item.name}</Typography>
</Box>
{item.description && (
<Box ml={1}>
<Typography className={styles.hintText} variant="caption">
{item.description}
</Typography>
</Box>
)}
</Box>
</MenuItem>
))}
</FormTextField>
)
}

const useStyles = makeStyles({
hintText: {
opacity: 0.75,
},
})
60 changes: 60 additions & 0 deletions site/components/Form/FormSection.tsx
Original file line number Diff line number Diff line change
@@ -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(4),
marginBottom: theme.spacing(4),
},
}))

export const FormSection: React.FC<FormSectionProps> = ({ title, description, children }) => {
const styles = useStyles()

return (
<div className={styles.root}>
<div className={styles.descriptionContainer}>
<Typography variant="h5" color="textPrimary">
{title}
</Typography>
{description && (
<Typography className={styles.descriptionText} variant="body2" color="textSecondary">
{description}
</Typography>
)}
</div>
<div className={styles.contents}>{children}</div>
</div>
)
}
31 changes: 31 additions & 0 deletions site/components/Form/FormTitle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { makeStyles } from "@material-ui/core/styles"
import Typography from "@material-ui/core/Typography"
import React from "react"

export interface FormTitleProps {
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 FormTitle: React.FC<FormTitleProps> = ({ title, detail }) => {
const styles = useStyles()

return (
<div className={styles.title}>
<Typography variant="h3">{title}</Typography>
{detail && <Typography variant="caption">{detail}</Typography>}
</div>
)
}
4 changes: 4 additions & 0 deletions site/components/Form/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from "./FormSection"
export * from "./FormDropdownField"
export * from "./FormTextField"
export * from "./FormTitle"
1 change: 0 additions & 1 deletion site/components/Form/index.tsx

This file was deleted.

29 changes: 29 additions & 0 deletions site/forms/CreateProjectForm.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<CreateProjectForm
provisioners={provisioners}
organizations={organizations}
onSubmit={onSubmit}
onCancel={onCancel}
/>,
)

// Then
// Simple smoke test to verify form renders
const element = await screen.findByText("Create Project")
expect(element).toBeDefined()
})
})
136 changes: 136 additions & 0 deletions site/forms/CreateProjectForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import Button from "@material-ui/core/Button"
import { makeStyles } from "@material-ui/core/styles"
import { FormikContextType, useFormik } from "formik"
import React from "react"
import * as Yup from "yup"

import { DropdownItem, FormDropdownField, FormTextField, FormTitle, FormSection } from "../components/Form"
import { LoadingButton } from "../components/Button"
import { Organization, Project, Provisioner, CreateProjectRequest } from "./../api"

export interface CreateProjectFormProps {
provisioners: Provisioner[]
organizations: Organization[]
onSubmit: (request: CreateProjectRequest) => Promise<Project>
onCancel: () => void
}

const validationSchema = Yup.object({
provisioner: Yup.string().required("Provisioner is required."),
organizationId: Yup.string().required("Organization is required."),
name: Yup.string().required("Name is required"),
})

export const CreateProjectForm: React.FC<CreateProjectFormProps> = ({
provisioners,
organizations,
onSubmit,
onCancel,
}) => {
const styles = useStyles()

const form: FormikContextType<CreateProjectRequest> = useFormik<CreateProjectRequest>({
initialValues: {
provisioner: provisioners[0].id,
organizationId: organizations[0].name,
name: "",
},
enableReinitialize: true,
validationSchema: validationSchema,
onSubmit: (req) => {
return onSubmit(req)
},
})

const organizationDropDownItems: DropdownItem[] = organizations.map((org) => {
return {
value: org.name,
name: org.name,
}
})

const provisionerDropDownItems: DropdownItem[] = provisioners.map((provisioner) => {
return {
value: provisioner.id,
name: provisioner.name,
}
})

return (
<div className={styles.root}>
<FormTitle title="Create Project" />

<FormSection title="Name">
<FormTextField
form={form}
formFieldName="name"
fullWidth
helperText="A unique name describing your project."
label="Project Name"
placeholder="my-project"
required
/>
</FormSection>

<FormSection title="Organization">
<FormDropdownField
form={form}
formFieldName="organizationId"
helperText="The organization owning this project."
items={organizationDropDownItems}
fullWidth
select
required
/>
</FormSection>

<FormSection title="Provider">
<FormDropdownField
form={form}
formFieldName="provisioner"
helperText="The backing provisioner for this project."
items={provisionerDropDownItems}
fullWidth
select
required
/>
</FormSection>

<div className={styles.footer}>
<Button className={styles.button} onClick={onCancel} variant="outlined">
Cancel
</Button>
<LoadingButton
loading={form.isSubmitting}
className={styles.button}
onClick={form.submitForm}
variant="contained"
color="primary"
type="submit"
>
Submit
</LoadingButton>
</div>
</div>
)
}

const useStyles = makeStyles(() => ({
root: {
maxWidth: "1380px",
width: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
},
footer: {
display: "flex",
flex: "0",
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
},
button: {
margin: "1em",
},
}))

0 comments on commit c7fb16e

Please sign in to comment.