Skip to content

feat(site): Add Admin Dropdown menu #885

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 27 commits into from
Apr 8, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Start porting components for Admin menu
  • Loading branch information
presleyp committed Apr 5, 2022
commit d6dacf96b2d3d9d19a56c13f067df911df74bf34
140 changes: 140 additions & 0 deletions site/src/components/BorderedMenu/BorderedMenuRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import ListItem from "@material-ui/core/ListItem"
import { makeStyles } from "@material-ui/core/styles"
import SvgIcon from "@material-ui/core/SvgIcon"
import CheckIcon from "@material-ui/icons/Check"
import React from "react"
import { NavLink } from "react-router-dom"
import { ellipsizeText } from "../../util/ellipsizeText"
import { Typography } from "../Typography"

type BorderedMenuRowVariant = "narrow" | "wide"

interface BorderedMenuRowProps {
/** `true` indicates this row is currently selected */
active?: boolean
/** Optional description that appears beneath the title */
description?: string
/** An SvgIcon that will be rendered to the left of the title */
Icon: typeof SvgIcon
/** URL path */
path?: string
/** Required title of this row */
title: string
/** Defaults to `"wide"` */
variant?: BorderedMenuRowVariant
/** Callback fired when this row is clicked */
onClick?: () => void
}

export const BorderedMenuRow: React.FC<BorderedMenuRowProps> = ({
active,
description,
Icon,
path,
title,
variant,
onClick,
}) => {
const styles = useStyles()

const Component = () => (
<ListItem
classes={{ gutters: styles.rootGutters }}
className={styles.root}
onClick={onClick}
data-status={active ? "active" : "inactive"}
>
<div className={styles.content} data-variant={variant}>
<div className={styles.contentTop}>
<Icon className={styles.icon} />
<Typography className={styles.title}>{title}</Typography>
{active && <CheckIcon className={styles.checkMark} />}
</div>

{description && (
<Typography className={styles.description} color="textSecondary" variant="caption">
{ellipsizeText(description)}
</Typography>
)}
</div>
</ListItem>
)

if (path) {
return (
<NavLink to={path}>
<Component />
</NavLink>
)
}

return <Component />
}

const iconSize = 20

const useStyles = makeStyles((theme) => ({
root: {
cursor: "pointer",
padding: `0 ${theme.spacing(1)}px`,

"&:hover": {
backgroundColor: "unset",
"& $content": {
backgroundColor: theme.palette.background.default,
},
},

"&[data-status='active']": {
color: theme.palette.primary.main,
"& .BorderedMenuRow-description": {
color: theme.palette.text.primary,
},
"& .BorderedMenuRow-icon": {
color: theme.palette.primary.main,
},
},
},
rootGutters: {
padding: `0 ${theme.spacing(1.5)}px`,
},
content: {
borderRadius: 7,
display: "flex",
flexDirection: "column",
padding: theme.spacing(2),
width: 320,

"&[data-variant='narrow']": {
width: 268,
},
},
contentTop: {
alignItems: "center",
display: "flex",
},
icon: {
color: theme.palette.text.secondary,
height: iconSize,
width: iconSize,

"& path": {
fill: theme.palette.text.secondary,
},
},
title: {
fontSize: 16,
fontWeight: 500,
lineHeight: 1.5,
marginLeft: theme.spacing(2),
},
checkMark: {
height: iconSize,
marginLeft: "auto",
width: iconSize,
},
description: {
marginLeft: theme.spacing(4.5),
marginTop: theme.spacing(0.5),
},
}))
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import Popover, { PopoverProps } from "@material-ui/core/Popover"
import { fade, makeStyles } from "@material-ui/core/styles"
import React from "react"

type BorderedMenuVariant = "manage-dropdown" | "user-dropdown"
type BorderedMenuVariant = "admin-dropdown" | "user-dropdown"

type BorderedMenuProps = Omit<PopoverProps, "variant"> & {
variant?: BorderedMenuVariant
Expand Down
11 changes: 11 additions & 0 deletions site/src/components/BorderedMenu/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* Shared types used by multiple components go here.
*/

import { RouteConfig } from "../../router"

export interface NavbarEntryProps extends Pick<RouteConfig, "description" | "featureFlag" | "Icon" | "label" | "path"> {
selected: boolean
className?: string
onClick?: () => void
}
51 changes: 51 additions & 0 deletions site/src/components/Navbar/Admin/Admin.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import React from "react"
import { useNavigate } from "react-router-dom"
import { BorderedMenu } from "../../BorderedMenu"
import { BorderedMenuRow } from "../../BorderedMenu/BorderedMenuRow"
import { NavbarEntryProps } from "../../BorderedMenu/types"

interface AdminDropdownProps {
anchorEl?: HTMLElement
entries: NavbarEntryProps[]
onClose: () => void
}

export const AdminDropdown: React.FC<AdminDropdownProps> = ({ anchorEl, entries, onClose }) => {
const navigate = useNavigate()

return (
<BorderedMenu
anchorEl={anchorEl}
getContentAnchorEl={null}
open={!!anchorEl}
anchorOrigin={{
vertical: "bottom",
horizontal: "center",
}}
transformOrigin={{
vertical: "top",
horizontal: "center",
}}
marginThreshold={0}
variant="admin-dropdown"
onClose={onClose}
>
{entries.map((entry) =>
entry.label && entry.Icon ? (
<BorderedMenuRow
description={entry.description}
Icon={entry.Icon}
key={entry.label}
path={entry.path}
title={entry.label}
variant="narrow"
onClick={() => {
onClose()
navigate(entry.path)
}}
/>
) : null,
)}
</BorderedMenu>
)
}
2 changes: 1 addition & 1 deletion site/src/components/Navbar/UserDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import KeyboardArrowDown from "@material-ui/icons/KeyboardArrowDown"
import KeyboardArrowUp from "@material-ui/icons/KeyboardArrowUp"
import React, { useState } from "react"
import { LogoutIcon } from "../Icons"
import { BorderedMenu } from "./BorderedMenu"
import { BorderedMenu } from "../BorderedMenu"
import { UserProfileCard } from "../User/UserProfileCard"

import { UserAvatar } from "../User"
Expand Down
42 changes: 42 additions & 0 deletions site/src/components/Typography/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* @fileoverview (TODO: Grey) This file is in a temporary state and is a
* verbatim port from `@coder/ui`.
*/

import { makeStyles } from "@material-ui/core/styles"
import MuiTypography, { TypographyProps as MuiTypographyProps } from "@material-ui/core/Typography"
import * as React from "react"
import { combineClasses, appendCSSString } from "../../util/combineClasses"

export interface TypographyProps extends MuiTypographyProps {
short?: boolean
}

/**
* Wrapper around Material UI's Typography component to allow for future
* custom typography types.
*
* See original component's Material UI documentation here: https://material-ui.com/components/typography/
*/
export const Typography: React.FC<TypographyProps> = ({ className, short, ...rest }) => {
const styles = useStyles()

let classes = combineClasses({ [styles.short]: short })
if (className) {
classes = appendCSSString(classes ?? "", className)
}

return <MuiTypography {...rest} className={classes} />
}

const useStyles = makeStyles({
short: {
"&.MuiTypography-body1": {
lineHeight: "21px",
},
"&.MuiTypography-body2": {
lineHeight: "18px",
letterSpacing: 0.2,
},
},
})
17 changes: 17 additions & 0 deletions site/src/util/ellipsizeText.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { ellipsizeText } from "./ellipsizeText"
import { Nullable } from "./nullable"

describe("ellipsizeText", () => {
it.each([
[undefined, 10, undefined],
[null, 10, undefined],
["", 10, ""],
["Hello World", "Hello World".length, "Hello World"],
["Hello World", "Hello...".length, "Hello..."],
])(
`ellipsizeText(%p, %p) returns %p`,
(str: Nullable<string>, maxLength: number | undefined, output: Nullable<string>) => {
expect(ellipsizeText(str, maxLength)).toBe(output)
},
)
})
9 changes: 9 additions & 0 deletions site/src/util/ellipsizeText.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Nullable } from "./nullable"

/** Truncates and ellipsizes text if it's longer than maxLength */
export const ellipsizeText = (text: Nullable<string>, maxLength = 80): string | undefined => {
if (typeof text !== "string") {
return
}
return text.length <= maxLength ? text : `${text.substr(0, maxLength - 3)}...`
}
5 changes: 5 additions & 0 deletions site/src/util/nullable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/**
* A Nullable may be its concrete type, `null` or `undefined`
* @remark Exact opposite of the native TS type NonNullable<T>
*/
export type Nullable<T> = null | undefined | T