mirror of https://github.com/coder/coder.git
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:
parent
c7fb16ebde
commit
3047f251a8
10
site/api.ts
10
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<LoginResponse> => {
|
||||
const response = await fetch("/api/v2/login", {
|
||||
method: "POST",
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
},
|
||||
},
|
||||
]
|
||||
|
|
|
@ -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",
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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
|
||||
}
|
Loading…
Reference in New Issue