feat: Initial Projects listing page (#58)

This implements a simple Project listing page at `/projects` - just a table for a list of projects:

![image](https://user-images.githubusercontent.com/88213859/150906058-bbc49cfc-cb42-4252-bade-b8d48a986280.png)

...and an empty state:

![image](https://user-images.githubusercontent.com/88213859/150906882-03b0ace5-77c6-4806-b530-008769948867.png)

There isn't too much data to show at the moment. It'll be nice in the future to show the following fields and improve the UI with it:
- An icon
- A list of users using the project
- A description

However, this brings in a lot of scaffolding to make it easier to build pages like this (`/organizations`, `/workspaces`, etc).

In particular, I brought over a few things from v1:
- The `Hero` / `Header` component at the top of pages + sub-components
- A `Table` component for help rendering table-like UI + sub-components
- Additional palette settings that the `Hero`
This commit is contained in:
Bryan 2022-01-25 07:41:59 -08:00 committed by GitHub
parent 69d88b4a6d
commit b964cb0380
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 602 additions and 1 deletions

View File

@ -2,6 +2,17 @@ interface LoginResponse {
session_token: string
}
// This must be kept in sync with the `Project` struct in the back-end
export interface Project {
id: string
created_at: string
updated_at: string
organization_id: string
name: string
provisioner: string
active_version_id: 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,15 @@
import { render, screen } from "@testing-library/react"
import React from "react"
import { ErrorSummary } from "./index"
describe("ErrorSummary", () => {
it("renders", async () => {
// When
const error = new Error("test error message")
render(<ErrorSummary error={error} />)
// Then
const element = await screen.findByText("test error message", { exact: false })
expect(element).toBeDefined()
})
})

View File

@ -0,0 +1,10 @@
import React from "react"
export interface ErrorSummaryProps {
error: Error
}
export const ErrorSummary: React.FC<ErrorSummaryProps> = ({ error }) => {
// TODO: More interesting error page
return <div>{error.toString()}</div>
}

View File

@ -0,0 +1,37 @@
import Button from "@material-ui/core/Button"
import { lighten, makeStyles } from "@material-ui/core/styles"
import React from "react"
export interface HeaderButtonProps {
readonly text: string
readonly disabled?: boolean
readonly onClick?: (event: MouseEvent) => void
}
export const HeaderButton: React.FC<HeaderButtonProps> = (props) => {
const styles = useStyles()
return (
<Button
className={styles.pageButton}
variant="contained"
onClick={(event: React.MouseEvent): void => {
if (props.onClick) {
props.onClick(event.nativeEvent)
}
}}
disabled={props.disabled}
component="button"
>
{props.text}
</Button>
)
}
const useStyles = makeStyles((theme) => ({
pageButton: {
whiteSpace: "nowrap",
backgroundColor: lighten(theme.palette.hero.main, 0.1),
color: "#B5BFD2",
},
}))

View File

@ -0,0 +1,28 @@
import { screen } from "@testing-library/react"
import { render } from "./../../test_helpers"
import React from "react"
import { Header } from "./index"
describe("Header", () => {
it("renders title and subtitle", async () => {
// When
render(<Header title="Title Test" subTitle="Subtitle Test" />)
// Then
const titleElement = await screen.findByText("Title Test")
expect(titleElement).toBeDefined()
const subTitleElement = await screen.findByText("Subtitle Test")
expect(subTitleElement).toBeDefined()
})
it("renders button if specified", async () => {
// When
render(<Header title="Title" action={{ text: "Button Test" }} />)
// Then
const buttonElement = await screen.findByRole("button")
expect(buttonElement).toBeDefined()
expect(buttonElement.textContent).toEqual("Button Test")
})
})

View File

@ -0,0 +1,116 @@
import Box from "@material-ui/core/Box"
import Typography from "@material-ui/core/Typography"
import { makeStyles } from "@material-ui/core/styles"
import React from "react"
import { HeaderButton } from "./HeaderButton"
export interface HeaderAction {
readonly text: string
readonly onClick?: (event: MouseEvent) => void
}
export interface HeaderProps {
description?: string
title: string
subTitle?: string
action?: HeaderAction
}
export const Header: React.FC<HeaderProps> = ({ description, title, subTitle, action }) => {
const styles = useStyles()
return (
<div className={styles.root}>
<div className={styles.top}>
<div className={styles.topInner}>
<Box display="flex" flexDirection="column" minWidth={0}>
<div>
<Box display="flex" alignItems="center">
<Typography variant="h3" className={styles.title}>
<Box component="span" maxWidth="100%" overflow="hidden" textOverflow="ellipsis">
{title}
</Box>
</Typography>
{subTitle && (
<div className={styles.subtitle}>
<Typography style={{ fontSize: 16 }}>{subTitle}</Typography>
</div>
)}
</Box>
{description && (
<Typography variant="caption" className={styles.description}>
{description}
</Typography>
)}
</div>
</Box>
{action && (
<>
<div className={styles.actions}>
<HeaderButton key={action.text} {...action} />
</div>
</>
)}
</div>
</div>
</div>
)
}
const secondaryText = "#B5BFD2"
const useStyles = makeStyles((theme) => ({
root: {},
top: {
position: "relative",
display: "flex",
alignItems: "center",
height: 150,
background: theme.palette.hero.main,
boxShadow: theme.shadows[3],
},
topInner: {
display: "flex",
alignItems: "center",
maxWidth: "1380px",
margin: "0 auto",
flex: 1,
height: 68,
minWidth: 0,
},
title: {
display: "flex",
alignItems: "center",
fontWeight: "bold",
whiteSpace: "nowrap",
minWidth: 0,
color: theme.palette.primary.contrastText,
},
description: {
display: "block",
marginTop: theme.spacing(1) / 2,
marginBottom: -26,
color: secondaryText,
},
subtitle: {
position: "relative",
top: 2,
display: "flex",
alignItems: "center",
borderLeft: `1px solid ${theme.palette.divider}`,
height: 28,
marginLeft: 16,
paddingLeft: 16,
color: secondaryText,
},
actions: {
paddingLeft: "50px",
paddingRight: 0,
flex: 1,
display: "flex",
flexDirection: "row",
justifyContent: "flex-end",
alignItems: "center",
},
}))

View File

@ -0,0 +1,82 @@
import { screen } from "@testing-library/react"
import { render } from "./../../test_helpers"
import React from "react"
import { Table, Column } from "./Table"
interface TestData {
name: string
description: string
}
const columns: Column<TestData>[] = [
{
name: "Name",
key: "name",
},
{
name: "Description",
key: "description",
// For description, we'll test out the custom renderer path
renderer: (field) => <span>{"!!" + field + "!!"}</span>,
},
]
const data: TestData[] = [{ name: "AName", description: "ADescription" }]
const emptyData: TestData[] = []
describe("Table", () => {
it("renders empty state if empty", async () => {
// Given
const emptyState = <div>Empty Table!</div>
const tableProps = {
title: "TitleTest",
data: emptyData,
columns,
emptyState,
}
// When
render(<Table {...tableProps} />)
// Then
// Since there are no items, our empty state should've rendered
const emptyTextElement = await screen.findByText("Empty Table!")
expect(emptyTextElement).toBeDefined()
})
it("renders title", async () => {
// Given
const tableProps = {
title: "TitleTest",
data: emptyData,
columns,
}
// When
render(<Table {...tableProps} />)
// Then
const titleElement = await screen.findByText("TitleTest")
expect(titleElement).toBeDefined()
})
it("renders data fields with default renderer if none provided", async () => {
// Given
const tableProps = {
title: "TitleTest",
data,
columns,
}
// When
render(<Table {...tableProps} />)
// Then
// Check that the 'name' was rendered, with the default renderer
const nameElement = await screen.findByText("AName")
expect(nameElement).toBeDefined()
// ...and the description used our custom rendered
const descriptionElement = await screen.findByText("!!ADescription!!")
expect(descriptionElement).toBeDefined()
})
})

View File

@ -0,0 +1,88 @@
import React from "react"
import Box from "@material-ui/core/Box"
import MuiTable from "@material-ui/core/Table"
import TableHead from "@material-ui/core/TableHead"
import TableRow from "@material-ui/core/TableRow"
import TableCell from "@material-ui/core/TableCell"
import { TableTitle } from "./TableTitle"
import { TableHeaders } from "./TableHeaders"
import TableBody from "@material-ui/core/TableBody"
export interface Column<T> {
/**
* The field of type T that this column is associated with
*/
key: keyof T
/**
* Friendly name of the field, shown in headers
*/
name: string
/**
* Custom render for the field inside the table
*/
renderer?: (field: T[keyof T], data: T) => React.ReactElement
}
export interface TableProps<T> {
/**
* Title of the table
*/
title?: string
/**
* A list of columns, including the name and the key
*/
columns: Column<T>[]
/**
* The actual data to show in the table
*/
data: T[]
/**
* Optional empty state UI when the data is empty
*/
emptyState?: React.ReactElement
}
export const Table = <T,>({ columns, data, emptyState, title }: TableProps<T>): React.ReactElement => {
const columnNames = columns.map(({ name }) => name)
const body = renderTableBody(data, columns, emptyState)
return (
<MuiTable>
<TableHead>
{title && <TableTitle title={title} />}
<TableHeaders columns={columnNames} />
</TableHead>
{body}
</MuiTable>
)
}
/**
* Helper function to render the table data, falling back to an empty state if available
*/
const renderTableBody = <T,>(data: T[], columns: Column<T>[], emptyState?: React.ReactElement) => {
if (data.length > 0) {
const rows = data.map((item: T, index) => {
const cells = columns.map((column) => {
if (column.renderer) {
return <TableCell key={String(column.key)}>{column.renderer(item[column.key], item)}</TableCell>
} else {
return <TableCell key={String(column.key)}>{String(item[column.key]).toString()}</TableCell>
}
})
return <TableRow key={index}>{cells}</TableRow>
})
return <TableBody>{rows}</TableBody>
} else {
return (
<TableBody>
<TableRow>
<TableCell colSpan={999}>
<Box p={4}>{emptyState}</Box>
</TableCell>
</TableRow>
</TableBody>
)
}
}

View File

@ -0,0 +1,35 @@
import React from "react"
import TableCell from "@material-ui/core/TableCell"
import TableRow from "@material-ui/core/TableRow"
import { makeStyles } from "@material-ui/core/styles"
export interface TableHeadersProps {
columns: string[]
}
export const TableHeaders: React.FC<TableHeadersProps> = ({ columns }) => {
const styles = useStyles()
return (
<TableRow className={styles.root}>
{columns.map((c, idx) => (
<TableCell key={idx} size="small">
{c}
</TableCell>
))}
</TableRow>
)
}
export const useStyles = makeStyles((theme) => ({
root: {
fontSize: 12,
fontWeight: 500,
lineHeight: "16px",
letterSpacing: 1.5,
textTransform: "uppercase",
paddingTop: theme.spacing(1),
paddingBottom: theme.spacing(1),
color: theme.palette.text.secondary,
backgroundColor: theme.palette.background.default,
},
}))

View File

@ -0,0 +1,65 @@
import Box from "@material-ui/core/Box"
import { makeStyles } from "@material-ui/core/styles"
import TableCell from "@material-ui/core/TableCell"
import TableRow from "@material-ui/core/TableRow"
import Typography from "@material-ui/core/Typography"
import * as React from "react"
export interface TableTitleProps {
/** A title to display */
readonly title?: React.ReactNode
/** Arbitrary node to display to the right of the title. */
readonly details?: React.ReactNode
}
/**
* Component that encapsulates all of the pieces that sit on the top of a table.
*/
export const TableTitle: React.FC<TableTitleProps> = ({ title, details }) => {
const styles = useStyles()
return (
<TableRow>
<TableCell colSpan={9999} className={styles.cell}>
<Box className={`${styles.container} ${details ? "-details" : ""}`}>
{title && (
<Typography variant="h6" className={styles.title}>
{title}
</Typography>
)}
{details && <div className={styles.details}>{details}</div>}
</Box>
</TableCell>
</TableRow>
)
}
const useStyles = makeStyles((theme) => ({
cell: {
background: "none",
paddingTop: theme.spacing(2),
paddingBottom: theme.spacing(2),
},
container: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
},
title: {
fontSize: theme.typography.h5.fontSize,
fontWeight: 500,
color: theme.palette.text.primary,
textTransform: "none",
letterSpacing: "normal",
},
details: {
alignItems: "center",
display: "flex",
justifyContent: "flex-end",
letterSpacing: "normal",
margin: `0 ${theme.spacing(2)}px`,
[theme.breakpoints.down("sm")]: {
margin: `${theme.spacing(1)}px 0 0 0`,
},
},
}))

View File

@ -0,0 +1 @@
export * from "./Table"

View File

@ -0,0 +1,94 @@
import React from "react"
import { makeStyles } from "@material-ui/core/styles"
import Paper from "@material-ui/core/Paper"
import { useRouter } from "next/router"
import Link from "next/link"
import { EmptyState } from "../../components"
import { ErrorSummary } from "../../components/ErrorSummary"
import { Navbar } from "../../components/Navbar"
import { Header } from "../../components/Header"
import { Footer } from "../../components/Page"
import { Column, Table } from "../../components/Table"
import { useUser } from "../../contexts/UserContext"
import { FullScreenLoader } from "../../components/Loader/FullScreenLoader"
import { 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")
// TODO: The API call is currently returning `null`, which isn't ideal
// - it breaks checking for data presence with SWR.
const projects = data || []
if (error) {
return <ErrorSummary error={error} />
}
if (!me || !projects) {
return <FullScreenLoader />
}
const createProject = () => {
void router.push("/projects/create")
}
const action = {
text: "Create Project",
onClick: createProject,
}
const columns: Column<Project>[] = [
{
key: "name",
name: "Name",
renderer: (nameField: string, data: Project) => {
return <Link href={`/projects/${data.organization_id}/${data.id}`}>{nameField}</Link>
},
},
]
const emptyState = (
<EmptyState
button={{
children: "Create Project",
onClick: createProject,
}}
message="No projects have been created yet"
description="Create a project to get started."
/>
)
const tableProps = {
title: "All Projects",
columns: columns,
emptyState: emptyState,
data: projects,
}
const subTitle = `${projects.length} total`
return (
<div className={styles.root}>
<Navbar user={me} onSignOut={signOut} />
<Header title="Projects" subTitle={subTitle} action={action} />
<Paper style={{ maxWidth: "1380px", margin: "1em auto", width: "100%" }}>
<Table {...tableProps} />
</Paper>
<Footer />
</div>
)
}
const useStyles = makeStyles(() => ({
root: {
display: "flex",
flexDirection: "column",
},
}))
export default ProjectsPage

View File

@ -7,12 +7,23 @@ declare module "@material-ui/core/styles/createPalette" {
navbar: {
main: string
}
// Styles for the 'hero' banner on several coder admin pages
hero: {
// Background color of the 'hero' banner
main: string
// Color for hero 'buttons'
button: string
}
}
interface PaletteOptions {
navbar: {
main: string
}
hero: {
main: string
button: string
}
}
}
/**
@ -21,7 +32,7 @@ declare module "@material-ui/core/styles/createPalette" {
*/
export type CustomPalette = Pick<
Palette,
"action" | "background" | "divider" | "error" | "info" | "navbar" | "primary" | "secondary" | "text" | "type"
"action" | "background" | "divider" | "error" | "hero" | "info" | "navbar" | "primary" | "secondary" | "text" | "type"
>
/**
@ -64,6 +75,10 @@ export const lightPalette: CustomPalette = {
dark: "#912F42",
contrastText: "#FFF",
},
hero: {
main: "#242424",
button: "#747474",
},
text: {
primary: "#000",
secondary: "#747474",
@ -93,6 +108,10 @@ export const darkPalette: CustomPalette = {
secondary: lightPalette.secondary,
info: lightPalette.info,
error: lightPalette.error,
hero: {
main: "#141414",
button: "#333333",
},
navbar: {
main: "rgb(8, 9, 10)",
},