Skip to content

feat: Initial Project Create Form ('/projects/create') #60

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 16 commits into from
Jan 26, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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",
},
}))
Loading