Skip to content

Commit 3047f25

Browse files
authored
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
1 parent c7fb16e commit 3047f25

File tree

7 files changed

+182
-5
lines changed

7 files changed

+182
-5
lines changed

site/api.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,16 @@ export namespace Project {
6767
}
6868
}
6969

70+
// Must be kept in sync with backend Workspace struct
71+
export interface Workspace {
72+
id: string
73+
created_at: string
74+
updated_at: string
75+
owner_id: string
76+
project_id: string
77+
name: string
78+
}
79+
7080
export const login = async (email: string, password: string): Promise<LoginResponse> => {
7181
const response = await fetch("/api/v2/login", {
7282
method: "POST",
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import React from "react"
2+
import { makeStyles } from "@material-ui/core/styles"
3+
import Paper from "@material-ui/core/Paper"
4+
import Link from "next/link"
5+
import { useRouter } from "next/router"
6+
import useSWR from "swr"
7+
8+
import { Project, Workspace } from "../../../../api"
9+
import { Header } from "../../../../components/Header"
10+
import { FullScreenLoader } from "../../../../components/Loader/FullScreenLoader"
11+
import { Navbar } from "../../../../components/Navbar"
12+
import { Footer } from "../../../../components/Page"
13+
import { Column, Table } from "../../../../components/Table"
14+
import { useUser } from "../../../../contexts/UserContext"
15+
import { ErrorSummary } from "../../../../components/ErrorSummary"
16+
import { firstOrItem } from "../../../../util/array"
17+
import { EmptyState } from "../../../../components/EmptyState"
18+
19+
const ProjectPage: React.FC = () => {
20+
const styles = useStyles()
21+
const { me, signOut } = useUser(true)
22+
23+
const router = useRouter()
24+
const { project, organization } = router.query
25+
26+
const { data: projectInfo, error: projectError } = useSWR<Project, Error>(
27+
() => `/api/v2/projects/${organization}/${project}`,
28+
)
29+
const { data: workspaces, error: workspacesError } = useSWR<Workspace[], Error>(
30+
() => `/api/v2/projects/${organization}/${project}/workspaces`,
31+
)
32+
33+
if (projectError) {
34+
return <ErrorSummary error={projectError} />
35+
}
36+
37+
if (workspacesError) {
38+
return <ErrorSummary error={workspacesError} />
39+
}
40+
41+
if (!me || !projectInfo || !workspaces) {
42+
return <FullScreenLoader />
43+
}
44+
45+
const createWorkspace = () => {
46+
void router.push(`/projects/${organization}/${project}/create`)
47+
}
48+
49+
const emptyState = (
50+
<EmptyState
51+
button={{
52+
children: "Create Workspace",
53+
onClick: createWorkspace,
54+
}}
55+
message="No workspaces have been created yet"
56+
description="Create a workspace to get started"
57+
/>
58+
)
59+
60+
const columns: Column<Workspace>[] = [
61+
{
62+
key: "name",
63+
name: "Name",
64+
renderer: (nameField: string, data: Workspace) => {
65+
return <Link href={`/projects/${organization}/${project}/${data.id}`}>{nameField}</Link>
66+
},
67+
},
68+
]
69+
70+
const tableProps = {
71+
title: "Workspaces",
72+
columns,
73+
data: workspaces,
74+
emptyState: emptyState,
75+
}
76+
77+
return (
78+
<div className={styles.root}>
79+
<Navbar user={me} onSignOut={signOut} />
80+
<Header
81+
title={firstOrItem(project)}
82+
description={firstOrItem(organization)}
83+
subTitle={`${workspaces.length} workspaces`}
84+
action={{
85+
text: "Create Workspace",
86+
onClick: createWorkspace,
87+
}}
88+
/>
89+
90+
<Paper style={{ maxWidth: "1380px", margin: "1em auto", width: "100%" }}>
91+
<Table {...tableProps} />
92+
</Paper>
93+
<Footer />
94+
</div>
95+
)
96+
}
97+
98+
const useStyles = makeStyles((theme) => ({
99+
root: {
100+
display: "flex",
101+
flexDirection: "column",
102+
},
103+
header: {
104+
display: "flex",
105+
flexDirection: "row-reverse",
106+
justifyContent: "space-between",
107+
margin: "1em auto",
108+
maxWidth: "1380px",
109+
padding: theme.spacing(2, 6.25, 0),
110+
width: "100%",
111+
},
112+
}))
113+
114+
export default ProjectPage

site/pages/projects/create.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ const CreateProjectPage: React.FC = () => {
2929

3030
const onSubmit = async (req: API.CreateProjectRequest) => {
3131
const project = await API.Project.create(req)
32-
await router.push("/projects")
32+
await router.push(`/projects/${req.organizationId}/${project.name}`)
3333
return project
3434
}
3535

site/pages/projects/index.tsx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,15 @@ import { Column, Table } from "../../components/Table"
1212
import { useUser } from "../../contexts/UserContext"
1313
import { FullScreenLoader } from "../../components/Loader/FullScreenLoader"
1414

15-
import { Project } from "./../../api"
15+
import { Organization, Project } from "./../../api"
1616
import useSWR from "swr"
1717

1818
const ProjectsPage: React.FC = () => {
1919
const styles = useStyles()
2020
const router = useRouter()
2121
const { me, signOut } = useUser(true)
2222
const { data, error } = useSWR<Project[] | null, Error>("/api/v2/projects")
23+
const { data: orgs, error: orgsError } = useSWR<Organization[], Error>("/api/v2/users/me/organizations")
2324

2425
// TODO: The API call is currently returning `null`, which isn't ideal
2526
// - it breaks checking for data presence with SWR.
@@ -29,7 +30,11 @@ const ProjectsPage: React.FC = () => {
2930
return <ErrorSummary error={error} />
3031
}
3132

32-
if (!me || !projects) {
33+
if (orgsError) {
34+
return <ErrorSummary error={error} />
35+
}
36+
37+
if (!me || !projects || !orgs) {
3338
return <FullScreenLoader />
3439
}
3540

@@ -42,12 +47,21 @@ const ProjectsPage: React.FC = () => {
4247
onClick: createProject,
4348
}
4449

50+
// Create a dictionary of organization ID -> organization Name
51+
// Needed to properly construct links to dive into a project
52+
const orgDictionary = orgs.reduce((acc: Record<string, string>, curr: Organization) => {
53+
return {
54+
...acc,
55+
[curr.id]: curr.name,
56+
}
57+
}, {})
58+
4559
const columns: Column<Project>[] = [
4660
{
4761
key: "name",
4862
name: "Name",
4963
renderer: (nameField: string, data: Project) => {
50-
return <Link href={`/projects/${data.organization_id}/${data.id}`}>{nameField}</Link>
64+
return <Link href={`/projects/${orgDictionary[data.organization_id]}/${nameField}`}>{nameField}</Link>
5165
},
5266
},
5367
]

site/test_helpers/mocks.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { User } from "../contexts/UserContext"
2-
import { Provisioner, Organization, Project } from "../api"
2+
import { Provisioner, Organization, Project, Workspace } from "../api"
33

44
export const MockUser: User = {
55
id: "test-user-id",
@@ -29,3 +29,12 @@ export const MockOrganization: Organization = {
2929
created_at: "",
3030
updated_at: "",
3131
}
32+
33+
export const MockWorkspace: Workspace = {
34+
id: "test-workspace",
35+
name: "Test-Workspace",
36+
created_at: "",
37+
updated_at: "",
38+
project_id: "project-id",
39+
owner_id: "test-user-id",
40+
}

site/util/array.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { firstOrItem } from "./array"
2+
3+
describe("array", () => {
4+
describe("firstOrItem", () => {
5+
it("returns null if empty array", () => {
6+
expect(firstOrItem([])).toBeNull()
7+
})
8+
9+
it("returns first item if array with more one item", () => {
10+
expect(firstOrItem(["a", "b"])).toEqual("a")
11+
})
12+
13+
it("returns item if single item", () => {
14+
expect(firstOrItem("c")).toEqual("c")
15+
})
16+
})
17+
})

site/util/array.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/**
2+
* Helper function that, given an array or a single item:
3+
* - If an array with no elements, returns null
4+
* - If an array with 1 or more elements, returns the first element
5+
* - If a single item, returns that item
6+
*/
7+
export const firstOrItem = <T>(itemOrItems: T | T[]): T | null => {
8+
if (Array.isArray(itemOrItems)) {
9+
return itemOrItems.length > 0 ? itemOrItems[0] : null
10+
}
11+
12+
return itemOrItems
13+
}

0 commit comments

Comments
 (0)