From b964cb038090ee82d65b2227e8adadc24c33db17 Mon Sep 17 00:00:00 2001 From: Bryan Date: Tue, 25 Jan 2022 07:41:59 -0800 Subject: [PATCH] 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` --- site/api.ts | 11 ++ site/components/ErrorSummary/index.test.tsx | 15 +++ site/components/ErrorSummary/index.tsx | 10 ++ site/components/Header/HeaderButton.tsx | 37 +++++++ site/components/Header/index.test.tsx | 28 +++++ site/components/Header/index.tsx | 116 ++++++++++++++++++++ site/components/Table/Table.test.tsx | 82 ++++++++++++++ site/components/Table/Table.tsx | 88 +++++++++++++++ site/components/Table/TableHeaders.tsx | 35 ++++++ site/components/Table/TableTitle.tsx | 65 +++++++++++ site/components/Table/index.tsx | 1 + site/pages/projects/index.tsx | 94 ++++++++++++++++ site/theme/palettes.ts | 21 +++- 13 files changed, 602 insertions(+), 1 deletion(-) create mode 100644 site/components/ErrorSummary/index.test.tsx create mode 100644 site/components/ErrorSummary/index.tsx create mode 100644 site/components/Header/HeaderButton.tsx create mode 100644 site/components/Header/index.test.tsx create mode 100644 site/components/Header/index.tsx create mode 100644 site/components/Table/Table.test.tsx create mode 100644 site/components/Table/Table.tsx create mode 100644 site/components/Table/TableHeaders.tsx create mode 100644 site/components/Table/TableTitle.tsx create mode 100644 site/components/Table/index.tsx create mode 100644 site/pages/projects/index.tsx diff --git a/site/api.ts b/site/api.ts index aece323b14..0c43e68d4b 100644 --- a/site/api.ts +++ b/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 => { const response = await fetch("/api/v2/login", { method: "POST", diff --git a/site/components/ErrorSummary/index.test.tsx b/site/components/ErrorSummary/index.test.tsx new file mode 100644 index 0000000000..9cb51484b7 --- /dev/null +++ b/site/components/ErrorSummary/index.test.tsx @@ -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() + + // Then + const element = await screen.findByText("test error message", { exact: false }) + expect(element).toBeDefined() + }) +}) diff --git a/site/components/ErrorSummary/index.tsx b/site/components/ErrorSummary/index.tsx new file mode 100644 index 0000000000..d2b72c90b3 --- /dev/null +++ b/site/components/ErrorSummary/index.tsx @@ -0,0 +1,10 @@ +import React from "react" + +export interface ErrorSummaryProps { + error: Error +} + +export const ErrorSummary: React.FC = ({ error }) => { + // TODO: More interesting error page + return
{error.toString()}
+} diff --git a/site/components/Header/HeaderButton.tsx b/site/components/Header/HeaderButton.tsx new file mode 100644 index 0000000000..7f7bdbaa77 --- /dev/null +++ b/site/components/Header/HeaderButton.tsx @@ -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 = (props) => { + const styles = useStyles() + + return ( + + ) +} + +const useStyles = makeStyles((theme) => ({ + pageButton: { + whiteSpace: "nowrap", + backgroundColor: lighten(theme.palette.hero.main, 0.1), + color: "#B5BFD2", + }, +})) diff --git a/site/components/Header/index.test.tsx b/site/components/Header/index.test.tsx new file mode 100644 index 0000000000..1c98f6466b --- /dev/null +++ b/site/components/Header/index.test.tsx @@ -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(
) + + // 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(
) + + // Then + const buttonElement = await screen.findByRole("button") + expect(buttonElement).toBeDefined() + expect(buttonElement.textContent).toEqual("Button Test") + }) +}) diff --git a/site/components/Header/index.tsx b/site/components/Header/index.tsx new file mode 100644 index 0000000000..4be56e65bf --- /dev/null +++ b/site/components/Header/index.tsx @@ -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 = ({ description, title, subTitle, action }) => { + const styles = useStyles() + + return ( +
+
+
+ +
+ + + + {title} + + + + {subTitle && ( +
+ {subTitle} +
+ )} +
+ {description && ( + + {description} + + )} +
+
+ + {action && ( + <> +
+ +
+ + )} +
+
+
+ ) +} + +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", + }, +})) diff --git a/site/components/Table/Table.test.tsx b/site/components/Table/Table.test.tsx new file mode 100644 index 0000000000..6de1f84a0e --- /dev/null +++ b/site/components/Table/Table.test.tsx @@ -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[] = [ + { + name: "Name", + key: "name", + }, + { + name: "Description", + key: "description", + // For description, we'll test out the custom renderer path + renderer: (field) => {"!!" + field + "!!"}, + }, +] + +const data: TestData[] = [{ name: "AName", description: "ADescription" }] +const emptyData: TestData[] = [] + +describe("Table", () => { + it("renders empty state if empty", async () => { + // Given + const emptyState =
Empty Table!
+ const tableProps = { + title: "TitleTest", + data: emptyData, + columns, + emptyState, + } + + // When + render() + + // 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(
) + + // 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(
) + + // 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() + }) +}) diff --git a/site/components/Table/Table.tsx b/site/components/Table/Table.tsx new file mode 100644 index 0000000000..786f019705 --- /dev/null +++ b/site/components/Table/Table.tsx @@ -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 { + /** + * 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 { + /** + * Title of the table + */ + title?: string + /** + * A list of columns, including the name and the key + */ + columns: Column[] + /** + * 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 = ({ columns, data, emptyState, title }: TableProps): React.ReactElement => { + const columnNames = columns.map(({ name }) => name) + const body = renderTableBody(data, columns, emptyState) + + return ( + + + {title && } + + + {body} + + ) +} + +/** + * Helper function to render the table data, falling back to an empty state if available + */ +const renderTableBody = (data: T[], columns: Column[], emptyState?: React.ReactElement) => { + if (data.length > 0) { + const rows = data.map((item: T, index) => { + const cells = columns.map((column) => { + if (column.renderer) { + return {column.renderer(item[column.key], item)} + } else { + return {String(item[column.key]).toString()} + } + }) + return {cells} + }) + return {rows} + } else { + return ( + + + + {emptyState} + + + + ) + } +} diff --git a/site/components/Table/TableHeaders.tsx b/site/components/Table/TableHeaders.tsx new file mode 100644 index 0000000000..63f0f2f13f --- /dev/null +++ b/site/components/Table/TableHeaders.tsx @@ -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 = ({ columns }) => { + const styles = useStyles() + return ( + + {columns.map((c, idx) => ( + + {c} + + ))} + + ) +} + +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, + }, +})) diff --git a/site/components/Table/TableTitle.tsx b/site/components/Table/TableTitle.tsx new file mode 100644 index 0000000000..d098919b9c --- /dev/null +++ b/site/components/Table/TableTitle.tsx @@ -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 = ({ title, details }) => { + const styles = useStyles() + return ( + + + + {title && ( + + {title} + + )} + {details &&
{details}
} +
+
+
+ ) +} + +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`, + }, + }, +})) diff --git a/site/components/Table/index.tsx b/site/components/Table/index.tsx new file mode 100644 index 0000000000..e7ae861eb7 --- /dev/null +++ b/site/components/Table/index.tsx @@ -0,0 +1 @@ +export * from "./Table" diff --git a/site/pages/projects/index.tsx b/site/pages/projects/index.tsx new file mode 100644 index 0000000000..354e3530ad --- /dev/null +++ b/site/pages/projects/index.tsx @@ -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("/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 + } + + if (!me || !projects) { + return + } + + const createProject = () => { + void router.push("/projects/create") + } + + const action = { + text: "Create Project", + onClick: createProject, + } + + const columns: Column[] = [ + { + key: "name", + name: "Name", + renderer: (nameField: string, data: Project) => { + return {nameField} + }, + }, + ] + + const emptyState = ( + + ) + + const tableProps = { + title: "All Projects", + columns: columns, + emptyState: emptyState, + data: projects, + } + + const subTitle = `${projects.length} total` + + return ( +
+ +
+ +
+ +
+ + ) +} + +const useStyles = makeStyles(() => ({ + root: { + display: "flex", + flexDirection: "column", + }, +})) + +export default ProjectsPage diff --git a/site/theme/palettes.ts b/site/theme/palettes.ts index 2dbb441c3d..93f1418f88 100644 --- a/site/theme/palettes.ts +++ b/site/theme/palettes.ts @@ -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)", },