mirror of
https://github.com/coder/coder.git
synced 2025-07-12 00:14:10 +00:00
feat: Initial Projects listing page (#58)
This implements a simple Project listing page at `/projects` - just a table for a list of projects:  ...and an empty state:  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:
11
site/api.ts
11
site/api.ts
@ -2,6 +2,17 @@ interface LoginResponse {
|
|||||||
session_token: string
|
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> => {
|
export const login = async (email: string, password: string): Promise<LoginResponse> => {
|
||||||
const response = await fetch("/api/v2/login", {
|
const response = await fetch("/api/v2/login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
15
site/components/ErrorSummary/index.test.tsx
Normal file
15
site/components/ErrorSummary/index.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
10
site/components/ErrorSummary/index.tsx
Normal file
10
site/components/ErrorSummary/index.tsx
Normal 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>
|
||||||
|
}
|
37
site/components/Header/HeaderButton.tsx
Normal file
37
site/components/Header/HeaderButton.tsx
Normal 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",
|
||||||
|
},
|
||||||
|
}))
|
28
site/components/Header/index.test.tsx
Normal file
28
site/components/Header/index.test.tsx
Normal 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")
|
||||||
|
})
|
||||||
|
})
|
116
site/components/Header/index.tsx
Normal file
116
site/components/Header/index.tsx
Normal 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",
|
||||||
|
},
|
||||||
|
}))
|
82
site/components/Table/Table.test.tsx
Normal file
82
site/components/Table/Table.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
88
site/components/Table/Table.tsx
Normal file
88
site/components/Table/Table.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
35
site/components/Table/TableHeaders.tsx
Normal file
35
site/components/Table/TableHeaders.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
}))
|
65
site/components/Table/TableTitle.tsx
Normal file
65
site/components/Table/TableTitle.tsx
Normal 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`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
1
site/components/Table/index.tsx
Normal file
1
site/components/Table/index.tsx
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./Table"
|
94
site/pages/projects/index.tsx
Normal file
94
site/pages/projects/index.tsx
Normal 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
|
@ -7,12 +7,23 @@ declare module "@material-ui/core/styles/createPalette" {
|
|||||||
navbar: {
|
navbar: {
|
||||||
main: string
|
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 {
|
interface PaletteOptions {
|
||||||
navbar: {
|
navbar: {
|
||||||
main: string
|
main: string
|
||||||
}
|
}
|
||||||
|
hero: {
|
||||||
|
main: string
|
||||||
|
button: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
@ -21,7 +32,7 @@ declare module "@material-ui/core/styles/createPalette" {
|
|||||||
*/
|
*/
|
||||||
export type CustomPalette = Pick<
|
export type CustomPalette = Pick<
|
||||||
Palette,
|
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",
|
dark: "#912F42",
|
||||||
contrastText: "#FFF",
|
contrastText: "#FFF",
|
||||||
},
|
},
|
||||||
|
hero: {
|
||||||
|
main: "#242424",
|
||||||
|
button: "#747474",
|
||||||
|
},
|
||||||
text: {
|
text: {
|
||||||
primary: "#000",
|
primary: "#000",
|
||||||
secondary: "#747474",
|
secondary: "#747474",
|
||||||
@ -93,6 +108,10 @@ export const darkPalette: CustomPalette = {
|
|||||||
secondary: lightPalette.secondary,
|
secondary: lightPalette.secondary,
|
||||||
info: lightPalette.info,
|
info: lightPalette.info,
|
||||||
error: lightPalette.error,
|
error: lightPalette.error,
|
||||||
|
hero: {
|
||||||
|
main: "#141414",
|
||||||
|
button: "#333333",
|
||||||
|
},
|
||||||
navbar: {
|
navbar: {
|
||||||
main: "rgb(8, 9, 10)",
|
main: "rgb(8, 9, 10)",
|
||||||
},
|
},
|
||||||
|
Reference in New Issue
Block a user