feat: Implement simple Project Summary page (#71)

This implements a very simple Project Summary page (which lists workspaces):

![image](https://user-images.githubusercontent.com/88213859/151085991-bf5b101a-eadd-445b-9b42-1e98591e8343.png)

...which also has an empty state:

![image](https://user-images.githubusercontent.com/88213859/151086084-90d526a9-7661-46f0-b205-976518f978c1.png)

Fixes #66
This commit is contained in:
Bryan 2022-01-25 19:50:31 -08:00 committed by GitHub
parent c7fb16ebde
commit 3047f251a8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 182 additions and 5 deletions

View File

@ -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<LoginResponse> => {
const response = await fetch("/api/v2/login", {
method: "POST",

View File

@ -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<Project, Error>(
() => `/api/v2/projects/${organization}/${project}`,
)
const { data: workspaces, error: workspacesError } = useSWR<Workspace[], Error>(
() => `/api/v2/projects/${organization}/${project}/workspaces`,
)
if (projectError) {
return <ErrorSummary error={projectError} />
}
if (workspacesError) {
return <ErrorSummary error={workspacesError} />
}
if (!me || !projectInfo || !workspaces) {
return <FullScreenLoader />
}
const createWorkspace = () => {
void router.push(`/projects/${organization}/${project}/create`)
}
const emptyState = (
<EmptyState
button={{
children: "Create Workspace",
onClick: createWorkspace,
}}
message="No workspaces have been created yet"
description="Create a workspace to get started"
/>
)
const columns: Column<Workspace>[] = [
{
key: "name",
name: "Name",
renderer: (nameField: string, data: Workspace) => {
return <Link href={`/projects/${organization}/${project}/${data.id}`}>{nameField}</Link>
},
},
]
const tableProps = {
title: "Workspaces",
columns,
data: workspaces,
emptyState: emptyState,
}
return (
<div className={styles.root}>
<Navbar user={me} onSignOut={signOut} />
<Header
title={firstOrItem(project)}
description={firstOrItem(organization)}
subTitle={`${workspaces.length} workspaces`}
action={{
text: "Create Workspace",
onClick: createWorkspace,
}}
/>
<Paper style={{ maxWidth: "1380px", margin: "1em auto", width: "100%" }}>
<Table {...tableProps} />
</Paper>
<Footer />
</div>
)
}
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

View File

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

View File

@ -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<Project[] | null, Error>("/api/v2/projects")
const { data: orgs, error: orgsError } = useSWR<Organization[], Error>("/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 <ErrorSummary error={error} />
}
if (!me || !projects) {
if (orgsError) {
return <ErrorSummary error={error} />
}
if (!me || !projects || !orgs) {
return <FullScreenLoader />
}
@ -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<string, string>, curr: Organization) => {
return {
...acc,
[curr.id]: curr.name,
}
}, {})
const columns: Column<Project>[] = [
{
key: "name",
name: "Name",
renderer: (nameField: string, data: Project) => {
return <Link href={`/projects/${data.organization_id}/${data.id}`}>{nameField}</Link>
return <Link href={`/projects/${orgDictionary[data.organization_id]}/${nameField}`}>{nameField}</Link>
},
},
]

View File

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

17
site/util/array.test.ts Normal file
View File

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

13
site/util/array.ts Normal file
View File

@ -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 = <T>(itemOrItems: T | T[]): T | null => {
if (Array.isArray(itemOrItems)) {
return itemOrItems.length > 0 ? itemOrItems[0] : null
}
return itemOrItems
}