Skip to content

feat: Implement simple Project Summary page #71

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Jan 26, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions site/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
114 changes: 114 additions & 0 deletions site/pages/projects/[organization]/[project]/index.tsx
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion site/pages/projects/create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
20 changes: 17 additions & 3 deletions site/pages/projects/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@ 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 = () => {
const styles = useStyles()
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.
Expand All @@ -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 />
}

Expand All @@ -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>
},
},
]
Expand Down
11 changes: 10 additions & 1 deletion site/test_helpers/mocks.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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 changes: 17 additions & 0 deletions site/util/array.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 13 additions & 0 deletions site/util/array.ts
Original file line number Diff line number Diff line change
@@ -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
}