diff --git a/site/api.ts b/site/api.ts index f8c3ae330ebe8..e749e70c3dd7b 100644 --- a/site/api.ts +++ b/site/api.ts @@ -67,6 +67,16 @@ export namespace Project { } } +// Must be kept in sync with backend Workspace struct +export interface Workspace { + id: string + created_at: string + updated_at: string + owner_id: string + project_id: string + name: string +} + export const login = async (email: string, password: string): Promise => { const response = await fetch("/api/v2/login", { method: "POST", diff --git a/site/pages/projects/[organization]/[project]/index.tsx b/site/pages/projects/[organization]/[project]/index.tsx new file mode 100644 index 0000000000000..c011798f50ffd --- /dev/null +++ b/site/pages/projects/[organization]/[project]/index.tsx @@ -0,0 +1,114 @@ +import React from "react" +import { makeStyles } from "@material-ui/core/styles" +import Paper from "@material-ui/core/Paper" +import Link from "next/link" +import { useRouter } from "next/router" +import useSWR from "swr" + +import { Project, Workspace } from "../../../../api" +import { Header } from "../../../../components/Header" +import { FullScreenLoader } from "../../../../components/Loader/FullScreenLoader" +import { Navbar } from "../../../../components/Navbar" +import { Footer } from "../../../../components/Page" +import { Column, Table } from "../../../../components/Table" +import { useUser } from "../../../../contexts/UserContext" +import { ErrorSummary } from "../../../../components/ErrorSummary" +import { firstOrItem } from "../../../../util/array" +import { EmptyState } from "../../../../components/EmptyState" + +const ProjectPage: React.FC = () => { + const styles = useStyles() + const { me, signOut } = useUser(true) + + const router = useRouter() + const { project, organization } = router.query + + const { data: projectInfo, error: projectError } = useSWR( + () => `/api/v2/projects/${organization}/${project}`, + ) + const { data: workspaces, error: workspacesError } = useSWR( + () => `/api/v2/projects/${organization}/${project}/workspaces`, + ) + + if (projectError) { + return + } + + if (workspacesError) { + return + } + + if (!me || !projectInfo || !workspaces) { + return + } + + const createWorkspace = () => { + void router.push(`/projects/${organization}/${project}/create`) + } + + const emptyState = ( + + ) + + const columns: Column[] = [ + { + key: "name", + name: "Name", + renderer: (nameField: string, data: Workspace) => { + return {nameField} + }, + }, + ] + + const tableProps = { + title: "Workspaces", + columns, + data: workspaces, + emptyState: emptyState, + } + + return ( +
+ +
+ + + + +
+ + ) +} + +const useStyles = makeStyles((theme) => ({ + root: { + display: "flex", + flexDirection: "column", + }, + header: { + display: "flex", + flexDirection: "row-reverse", + justifyContent: "space-between", + margin: "1em auto", + maxWidth: "1380px", + padding: theme.spacing(2, 6.25, 0), + width: "100%", + }, +})) + +export default ProjectPage diff --git a/site/pages/projects/create.tsx b/site/pages/projects/create.tsx index 66adad4542ee1..ac49d29b6cecc 100644 --- a/site/pages/projects/create.tsx +++ b/site/pages/projects/create.tsx @@ -29,7 +29,7 @@ const CreateProjectPage: React.FC = () => { const onSubmit = async (req: API.CreateProjectRequest) => { const project = await API.Project.create(req) - await router.push("/projects") + await router.push(`/projects/${req.organizationId}/${project.name}`) return project } diff --git a/site/pages/projects/index.tsx b/site/pages/projects/index.tsx index 354e3530ad448..42df98f666ddf 100644 --- a/site/pages/projects/index.tsx +++ b/site/pages/projects/index.tsx @@ -12,7 +12,7 @@ import { Column, Table } from "../../components/Table" import { useUser } from "../../contexts/UserContext" import { FullScreenLoader } from "../../components/Loader/FullScreenLoader" -import { Project } from "./../../api" +import { Organization, Project } from "./../../api" import useSWR from "swr" const ProjectsPage: React.FC = () => { @@ -20,6 +20,7 @@ const ProjectsPage: React.FC = () => { const router = useRouter() const { me, signOut } = useUser(true) const { data, error } = useSWR("/api/v2/projects") + const { data: orgs, error: orgsError } = useSWR("/api/v2/users/me/organizations") // TODO: The API call is currently returning `null`, which isn't ideal // - it breaks checking for data presence with SWR. @@ -29,7 +30,11 @@ const ProjectsPage: React.FC = () => { return } - if (!me || !projects) { + if (orgsError) { + return + } + + if (!me || !projects || !orgs) { return } @@ -42,12 +47,21 @@ const ProjectsPage: React.FC = () => { onClick: createProject, } + // Create a dictionary of organization ID -> organization Name + // Needed to properly construct links to dive into a project + const orgDictionary = orgs.reduce((acc: Record, curr: Organization) => { + return { + ...acc, + [curr.id]: curr.name, + } + }, {}) + const columns: Column[] = [ { key: "name", name: "Name", renderer: (nameField: string, data: Project) => { - return {nameField} + return {nameField} }, }, ] diff --git a/site/test_helpers/mocks.ts b/site/test_helpers/mocks.ts index f939fd021ef01..081292b2d663e 100644 --- a/site/test_helpers/mocks.ts +++ b/site/test_helpers/mocks.ts @@ -1,5 +1,5 @@ import { User } from "../contexts/UserContext" -import { Provisioner, Organization, Project } from "../api" +import { Provisioner, Organization, Project, Workspace } from "../api" export const MockUser: User = { id: "test-user-id", @@ -29,3 +29,12 @@ export const MockOrganization: Organization = { created_at: "", updated_at: "", } + +export const MockWorkspace: Workspace = { + id: "test-workspace", + name: "Test-Workspace", + created_at: "", + updated_at: "", + project_id: "project-id", + owner_id: "test-user-id", +} diff --git a/site/util/array.test.ts b/site/util/array.test.ts new file mode 100644 index 0000000000000..e8e78074d1494 --- /dev/null +++ b/site/util/array.test.ts @@ -0,0 +1,17 @@ +import { firstOrItem } from "./array" + +describe("array", () => { + describe("firstOrItem", () => { + it("returns null if empty array", () => { + expect(firstOrItem([])).toBeNull() + }) + + it("returns first item if array with more one item", () => { + expect(firstOrItem(["a", "b"])).toEqual("a") + }) + + it("returns item if single item", () => { + expect(firstOrItem("c")).toEqual("c") + }) + }) +}) diff --git a/site/util/array.ts b/site/util/array.ts new file mode 100644 index 0000000000000..6be0442bc4ac4 --- /dev/null +++ b/site/util/array.ts @@ -0,0 +1,13 @@ +/** + * Helper function that, given an array or a single item: + * - If an array with no elements, returns null + * - If an array with 1 or more elements, returns the first element + * - If a single item, returns that item + */ +export const firstOrItem = (itemOrItems: T | T[]): T | null => { + if (Array.isArray(itemOrItems)) { + return itemOrItems.length > 0 ? itemOrItems[0] : null + } + + return itemOrItems +}