mirror of https://github.com/coder/coder.git
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:
parent
69d88b4a6d
commit
b964cb0380
11
site/api.ts
11
site/api.ts
|
@ -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",
|
||||
|
|
|
@ -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()
|
||||
})
|
||||
})
|
|
@ -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>
|
||||
}
|
|
@ -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",
|
||||
},
|
||||
}))
|
|
@ -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")
|
||||
})
|
||||
})
|
|
@ -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",
|
||||
},
|
||||
}))
|
|
@ -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()
|
||||
})
|
||||
})
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
}))
|
|
@ -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`,
|
||||
},
|
||||
},
|
||||
}))
|
|
@ -0,0 +1 @@
|
|||
export * from "./Table"
|
|
@ -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
|
|
@ -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)",
|
||||
},
|
||||
|
|
Loading…
Reference in New Issue