mirror of https://github.com/coder/coder.git
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:
parent
bbd8b8ffa8
commit
c7fb16ebde
54
site/api.ts
54
site/api.ts
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
export * from "./FormSection"
|
||||
export * from "./FormDropdownField"
|
||||
export * from "./FormTextField"
|
||||
export * from "./FormTitle"
|
|
@ -1 +0,0 @@
|
|||
export * from "./FormTextField"
|
|
@ -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()
|
||||
})
|
||||
})
|
|
@ -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,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
|
|
@ -12,4 +12,4 @@ export const render = (component: React.ReactElement): RenderResult => {
|
|||
return wrappedRender(<WrapperComponent>{component}</WrapperComponent>)
|
||||
}
|
||||
|
||||
export * from "./user"
|
||||
export * from "./mocks"
|
||||
|
|
|
@ -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: "",
|
||||
}
|
|
@ -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: "",
|
||||
}
|
Loading…
Reference in New Issue