mirror of
https://github.com/coder/coder.git
synced 2025-07-12 00:14:10 +00:00
feat: Initial Project Create Form ('/projects/create') (#60)
This implements a simple form for creating projects:  Fixes #65
This commit is contained in:
54
site/api.ts
54
site/api.ts
@ -1,7 +1,35 @@
|
|||||||
|
import { mutate } from "swr"
|
||||||
|
|
||||||
interface LoginResponse {
|
interface LoginResponse {
|
||||||
session_token: string
|
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
|
// This must be kept in sync with the `Project` struct in the back-end
|
||||||
export interface Project {
|
export interface Project {
|
||||||
id: string
|
id: string
|
||||||
@ -13,6 +41,32 @@ export interface Project {
|
|||||||
active_version_id: string
|
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> => {
|
export const login = async (email: string, password: string): Promise<LoginResponse> => {
|
||||||
const response = await fetch("/api/v2/login", {
|
const response = await fetch("/api/v2/login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
47
site/components/Form/FormDropdownField.tsx
Normal file
47
site/components/Form/FormDropdownField.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
})
|
60
site/components/Form/FormSection.tsx
Normal file
60
site/components/Form/FormSection.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
31
site/components/Form/FormTitle.tsx
Normal file
31
site/components/Form/FormTitle.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
4
site/components/Form/index.ts
Normal file
4
site/components/Form/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export * from "./FormSection"
|
||||||
|
export * from "./FormDropdownField"
|
||||||
|
export * from "./FormTextField"
|
||||||
|
export * from "./FormTitle"
|
@ -1 +0,0 @@
|
|||||||
export * from "./FormTextField"
|
|
29
site/forms/CreateProjectForm.test.tsx
Normal file
29
site/forms/CreateProjectForm.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
136
site/forms/CreateProjectForm.tsx
Normal file
136
site/forms/CreateProjectForm.tsx
Normal 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",
|
||||||
|
},
|
||||||
|
}))
|
58
site/pages/projects/create.tsx
Normal file
58
site/pages/projects/create.tsx
Normal 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
|
@ -12,4 +12,4 @@ export const render = (component: React.ReactElement): RenderResult => {
|
|||||||
return wrappedRender(<WrapperComponent>{component}</WrapperComponent>)
|
return wrappedRender(<WrapperComponent>{component}</WrapperComponent>)
|
||||||
}
|
}
|
||||||
|
|
||||||
export * from "./user"
|
export * from "./mocks"
|
||||||
|
31
site/test_helpers/mocks.ts
Normal file
31
site/test_helpers/mocks.ts
Normal 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: "",
|
||||||
|
}
|
@ -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: "",
|
|
||||||
}
|
|
Reference in New Issue
Block a user