Skip to content

Commit b964cb0

Browse files
authored
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`
1 parent 69d88b4 commit b964cb0

File tree

13 files changed

+602
-1
lines changed

13 files changed

+602
-1
lines changed

site/api.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,17 @@ interface LoginResponse {
22
session_token: string
33
}
44

5+
// This must be kept in sync with the `Project` struct in the back-end
6+
export interface Project {
7+
id: string
8+
created_at: string
9+
updated_at: string
10+
organization_id: string
11+
name: string
12+
provisioner: string
13+
active_version_id: string
14+
}
15+
516
export const login = async (email: string, password: string): Promise<LoginResponse> => {
617
const response = await fetch("/api/v2/login", {
718
method: "POST",
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { render, screen } from "@testing-library/react"
2+
import React from "react"
3+
import { ErrorSummary } from "./index"
4+
5+
describe("ErrorSummary", () => {
6+
it("renders", async () => {
7+
// When
8+
const error = new Error("test error message")
9+
render(<ErrorSummary error={error} />)
10+
11+
// Then
12+
const element = await screen.findByText("test error message", { exact: false })
13+
expect(element).toBeDefined()
14+
})
15+
})
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import React from "react"
2+
3+
export interface ErrorSummaryProps {
4+
error: Error
5+
}
6+
7+
export const ErrorSummary: React.FC<ErrorSummaryProps> = ({ error }) => {
8+
// TODO: More interesting error page
9+
return <div>{error.toString()}</div>
10+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import Button from "@material-ui/core/Button"
2+
import { lighten, makeStyles } from "@material-ui/core/styles"
3+
import React from "react"
4+
5+
export interface HeaderButtonProps {
6+
readonly text: string
7+
readonly disabled?: boolean
8+
readonly onClick?: (event: MouseEvent) => void
9+
}
10+
11+
export const HeaderButton: React.FC<HeaderButtonProps> = (props) => {
12+
const styles = useStyles()
13+
14+
return (
15+
<Button
16+
className={styles.pageButton}
17+
variant="contained"
18+
onClick={(event: React.MouseEvent): void => {
19+
if (props.onClick) {
20+
props.onClick(event.nativeEvent)
21+
}
22+
}}
23+
disabled={props.disabled}
24+
component="button"
25+
>
26+
{props.text}
27+
</Button>
28+
)
29+
}
30+
31+
const useStyles = makeStyles((theme) => ({
32+
pageButton: {
33+
whiteSpace: "nowrap",
34+
backgroundColor: lighten(theme.palette.hero.main, 0.1),
35+
color: "#B5BFD2",
36+
},
37+
}))

site/components/Header/index.test.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { screen } from "@testing-library/react"
2+
import { render } from "./../../test_helpers"
3+
import React from "react"
4+
import { Header } from "./index"
5+
6+
describe("Header", () => {
7+
it("renders title and subtitle", async () => {
8+
// When
9+
render(<Header title="Title Test" subTitle="Subtitle Test" />)
10+
11+
// Then
12+
const titleElement = await screen.findByText("Title Test")
13+
expect(titleElement).toBeDefined()
14+
15+
const subTitleElement = await screen.findByText("Subtitle Test")
16+
expect(subTitleElement).toBeDefined()
17+
})
18+
19+
it("renders button if specified", async () => {
20+
// When
21+
render(<Header title="Title" action={{ text: "Button Test" }} />)
22+
23+
// Then
24+
const buttonElement = await screen.findByRole("button")
25+
expect(buttonElement).toBeDefined()
26+
expect(buttonElement.textContent).toEqual("Button Test")
27+
})
28+
})

site/components/Header/index.tsx

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import Box from "@material-ui/core/Box"
2+
import Typography from "@material-ui/core/Typography"
3+
import { makeStyles } from "@material-ui/core/styles"
4+
import React from "react"
5+
import { HeaderButton } from "./HeaderButton"
6+
7+
export interface HeaderAction {
8+
readonly text: string
9+
readonly onClick?: (event: MouseEvent) => void
10+
}
11+
12+
export interface HeaderProps {
13+
description?: string
14+
title: string
15+
subTitle?: string
16+
action?: HeaderAction
17+
}
18+
19+
export const Header: React.FC<HeaderProps> = ({ description, title, subTitle, action }) => {
20+
const styles = useStyles()
21+
22+
return (
23+
<div className={styles.root}>
24+
<div className={styles.top}>
25+
<div className={styles.topInner}>
26+
<Box display="flex" flexDirection="column" minWidth={0}>
27+
<div>
28+
<Box display="flex" alignItems="center">
29+
<Typography variant="h3" className={styles.title}>
30+
<Box component="span" maxWidth="100%" overflow="hidden" textOverflow="ellipsis">
31+
{title}
32+
</Box>
33+
</Typography>
34+
35+
{subTitle && (
36+
<div className={styles.subtitle}>
37+
<Typography style={{ fontSize: 16 }}>{subTitle}</Typography>
38+
</div>
39+
)}
40+
</Box>
41+
{description && (
42+
<Typography variant="caption" className={styles.description}>
43+
{description}
44+
</Typography>
45+
)}
46+
</div>
47+
</Box>
48+
49+
{action && (
50+
<>
51+
<div className={styles.actions}>
52+
<HeaderButton key={action.text} {...action} />
53+
</div>
54+
</>
55+
)}
56+
</div>
57+
</div>
58+
</div>
59+
)
60+
}
61+
62+
const secondaryText = "#B5BFD2"
63+
const useStyles = makeStyles((theme) => ({
64+
root: {},
65+
top: {
66+
position: "relative",
67+
display: "flex",
68+
alignItems: "center",
69+
height: 150,
70+
background: theme.palette.hero.main,
71+
boxShadow: theme.shadows[3],
72+
},
73+
topInner: {
74+
display: "flex",
75+
alignItems: "center",
76+
maxWidth: "1380px",
77+
margin: "0 auto",
78+
flex: 1,
79+
height: 68,
80+
minWidth: 0,
81+
},
82+
title: {
83+
display: "flex",
84+
alignItems: "center",
85+
fontWeight: "bold",
86+
whiteSpace: "nowrap",
87+
minWidth: 0,
88+
color: theme.palette.primary.contrastText,
89+
},
90+
description: {
91+
display: "block",
92+
marginTop: theme.spacing(1) / 2,
93+
marginBottom: -26,
94+
color: secondaryText,
95+
},
96+
subtitle: {
97+
position: "relative",
98+
top: 2,
99+
display: "flex",
100+
alignItems: "center",
101+
borderLeft: `1px solid ${theme.palette.divider}`,
102+
height: 28,
103+
marginLeft: 16,
104+
paddingLeft: 16,
105+
color: secondaryText,
106+
},
107+
actions: {
108+
paddingLeft: "50px",
109+
paddingRight: 0,
110+
flex: 1,
111+
display: "flex",
112+
flexDirection: "row",
113+
justifyContent: "flex-end",
114+
alignItems: "center",
115+
},
116+
}))

site/components/Table/Table.test.tsx

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { screen } from "@testing-library/react"
2+
import { render } from "./../../test_helpers"
3+
import React from "react"
4+
import { Table, Column } from "./Table"
5+
6+
interface TestData {
7+
name: string
8+
description: string
9+
}
10+
11+
const columns: Column<TestData>[] = [
12+
{
13+
name: "Name",
14+
key: "name",
15+
},
16+
{
17+
name: "Description",
18+
key: "description",
19+
// For description, we'll test out the custom renderer path
20+
renderer: (field) => <span>{"!!" + field + "!!"}</span>,
21+
},
22+
]
23+
24+
const data: TestData[] = [{ name: "AName", description: "ADescription" }]
25+
const emptyData: TestData[] = []
26+
27+
describe("Table", () => {
28+
it("renders empty state if empty", async () => {
29+
// Given
30+
const emptyState = <div>Empty Table!</div>
31+
const tableProps = {
32+
title: "TitleTest",
33+
data: emptyData,
34+
columns,
35+
emptyState,
36+
}
37+
38+
// When
39+
render(<Table {...tableProps} />)
40+
41+
// Then
42+
// Since there are no items, our empty state should've rendered
43+
const emptyTextElement = await screen.findByText("Empty Table!")
44+
expect(emptyTextElement).toBeDefined()
45+
})
46+
47+
it("renders title", async () => {
48+
// Given
49+
const tableProps = {
50+
title: "TitleTest",
51+
data: emptyData,
52+
columns,
53+
}
54+
55+
// When
56+
render(<Table {...tableProps} />)
57+
58+
// Then
59+
const titleElement = await screen.findByText("TitleTest")
60+
expect(titleElement).toBeDefined()
61+
})
62+
63+
it("renders data fields with default renderer if none provided", async () => {
64+
// Given
65+
const tableProps = {
66+
title: "TitleTest",
67+
data,
68+
columns,
69+
}
70+
71+
// When
72+
render(<Table {...tableProps} />)
73+
74+
// Then
75+
// Check that the 'name' was rendered, with the default renderer
76+
const nameElement = await screen.findByText("AName")
77+
expect(nameElement).toBeDefined()
78+
// ...and the description used our custom rendered
79+
const descriptionElement = await screen.findByText("!!ADescription!!")
80+
expect(descriptionElement).toBeDefined()
81+
})
82+
})

0 commit comments

Comments
 (0)