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

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
This commit is contained in:
Bryan 2022-01-25 16:36:26 -08:00 committed by GitHub
parent bbd8b8ffa8
commit c7fb16ebde
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 451 additions and 10 deletions

View File

@ -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
@ -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",

View File

@ -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,
},
})

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -0,0 +1,4 @@
export * from "./FormSection"
export * from "./FormDropdownField"
export * from "./FormTextField"
export * from "./FormTitle"

View File

@ -1 +0,0 @@
export * from "./FormTextField"

View File

@ -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()
})
})

View File

@ -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",
},
}))

View File

@ -0,0 +1,58 @@
import React from "react"
import { makeStyles } from "@material-ui/core/styles"
import { useRouter } from "next/router"
import useSWR from "swr"
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"
const CreateProjectPage: React.FC = () => {
const router = useRouter()
const styles = useStyles()
const { me } = useUser(true)
const { data: organizations, error } = useSWR("/api/v2/users/me/organizations")
if (error) {
return <ErrorSummary error={error} />
}
if (!me || !organizations) {
return <FullScreenLoader />
}
const onCancel = async () => {
await router.push("/projects")
}
const onSubmit = async (req: API.CreateProjectRequest) => {
const project = await API.Project.create(req)
await router.push("/projects")
return project
}
return (
<div className={styles.root}>
<CreateProjectForm
provisioners={API.provisioners}
organizations={organizations}
onSubmit={onSubmit}
onCancel={onCancel}
/>
</div>
)
}
const useStyles = makeStyles((theme) => ({
root: {
display: "flex",
flexDirection: "column",
alignItems: "center",
height: "100vh",
backgroundColor: theme.palette.background.paper,
},
}))
export default CreateProjectPage

View File

@ -12,4 +12,4 @@ export const render = (component: React.ReactElement): RenderResult => {
return wrappedRender(<WrapperComponent>{component}</WrapperComponent>)
}
export * from "./user"
export * from "./mocks"

View File

@ -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: "",
}

View File

@ -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: "",
}