diff --git a/site/api.ts b/site/api.ts index 0c43e68d4bd6e..f8c3ae330ebe8 100644 --- a/site/api.ts +++ b/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 => { + 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 => { const response = await fetch("/api/v2/login", { method: "POST", diff --git a/site/components/Form/FormDropdownField.tsx b/site/components/Form/FormDropdownField.tsx new file mode 100644 index 0000000000000..c4d8993da7df3 --- /dev/null +++ b/site/components/Form/FormDropdownField.tsx @@ -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 extends FormTextFieldProps { + items: DropdownItem[] +} + +export const FormDropdownField = ({ items, ...props }: FormDropdownFieldProps): React.ReactElement => { + const styles = useStyles() + return ( + + {items.map((item: DropdownItem) => ( + + + + {item.name} + + {item.description && ( + + + {item.description} + + + )} + + + ))} + + ) +} + +const useStyles = makeStyles({ + hintText: { + opacity: 0.75, + }, +}) diff --git a/site/components/Form/FormSection.tsx b/site/components/Form/FormSection.tsx new file mode 100644 index 0000000000000..5621c9858e53b --- /dev/null +++ b/site/components/Form/FormSection.tsx @@ -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 = ({ title, description, children }) => { + const styles = useStyles() + + return ( +
+
+ + {title} + + {description && ( + + {description} + + )} +
+
{children}
+
+ ) +} diff --git a/site/components/Form/FormTitle.tsx b/site/components/Form/FormTitle.tsx new file mode 100644 index 0000000000000..59b9ef7e7beea --- /dev/null +++ b/site/components/Form/FormTitle.tsx @@ -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 = ({ title, detail }) => { + const styles = useStyles() + + return ( +
+ {title} + {detail && {detail}} +
+ ) +} diff --git a/site/components/Form/index.ts b/site/components/Form/index.ts new file mode 100644 index 0000000000000..80ddbbac74b3b --- /dev/null +++ b/site/components/Form/index.ts @@ -0,0 +1,4 @@ +export * from "./FormSection" +export * from "./FormDropdownField" +export * from "./FormTextField" +export * from "./FormTitle" diff --git a/site/components/Form/index.tsx b/site/components/Form/index.tsx deleted file mode 100644 index 4e916cdae4d1d..0000000000000 --- a/site/components/Form/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export * from "./FormTextField" diff --git a/site/forms/CreateProjectForm.test.tsx b/site/forms/CreateProjectForm.test.tsx new file mode 100644 index 0000000000000..a3eb0c0ce7246 --- /dev/null +++ b/site/forms/CreateProjectForm.test.tsx @@ -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( + , + ) + + // Then + // Simple smoke test to verify form renders + const element = await screen.findByText("Create Project") + expect(element).toBeDefined() + }) +}) diff --git a/site/forms/CreateProjectForm.tsx b/site/forms/CreateProjectForm.tsx new file mode 100644 index 0000000000000..1a4558e9b2ef8 --- /dev/null +++ b/site/forms/CreateProjectForm.tsx @@ -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 + 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 = ({ + provisioners, + organizations, + onSubmit, + onCancel, +}) => { + const styles = useStyles() + + const form: FormikContextType = useFormik({ + 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 ( +
+ + + + + + + + + + + + + + +
+ + + Submit + +
+
+ ) +} + +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", + }, +})) diff --git a/site/pages/projects/create.tsx b/site/pages/projects/create.tsx new file mode 100644 index 0000000000000..66adad4542ee1 --- /dev/null +++ b/site/pages/projects/create.tsx @@ -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 + } + + if (!me || !organizations) { + return + } + + 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 ( +
+ +
+ ) +} + +const useStyles = makeStyles((theme) => ({ + root: { + display: "flex", + flexDirection: "column", + alignItems: "center", + height: "100vh", + backgroundColor: theme.palette.background.paper, + }, +})) + +export default CreateProjectPage diff --git a/site/test_helpers/index.tsx b/site/test_helpers/index.tsx index 8242832ee7d50..6cd4183fb1db1 100644 --- a/site/test_helpers/index.tsx +++ b/site/test_helpers/index.tsx @@ -12,4 +12,4 @@ export const render = (component: React.ReactElement): RenderResult => { return wrappedRender({component}) } -export * from "./user" +export * from "./mocks" diff --git a/site/test_helpers/mocks.ts b/site/test_helpers/mocks.ts new file mode 100644 index 0000000000000..f939fd021ef01 --- /dev/null +++ b/site/test_helpers/mocks.ts @@ -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: "", +} diff --git a/site/test_helpers/user.ts b/site/test_helpers/user.ts deleted file mode 100644 index 652538ef19583..0000000000000 --- a/site/test_helpers/user.ts +++ /dev/null @@ -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: "", -}