feat(site): Add Admin Dropdown menu (#885)

* Start porting components for Admin menu

* More porting, wip

* Add icons

* Extract arrow components, navHeight

* Add Admin Dropdown

* Format

* Delete types

* Fix styles

* Lint

* Add stub pages

* Use navHeight constant

* Move files

* Add and organize stories

* Storybook and organize text stories

* Add test

* Lint

* Lint

* Fix double navigation

* Lint

* Wrap new routes in AuthAndNav

* Undo unrelated storybook changes

* Refactor according to conventions
This commit is contained in:
Presley Pizzo
2022-04-08 18:25:53 -04:00
committed by GitHub
parent 4c1ef38280
commit 3f21ea472f
28 changed files with 674 additions and 45 deletions

View File

@ -7,13 +7,16 @@ import { NotFoundPage } from "./pages/404"
import { CliAuthenticationPage } from "./pages/cli-auth" import { CliAuthenticationPage } from "./pages/cli-auth"
import { HealthzPage } from "./pages/healthz" import { HealthzPage } from "./pages/healthz"
import { SignInPage } from "./pages/login" import { SignInPage } from "./pages/login"
import { OrganizationsPage } from "./pages/orgs"
import { PreferencesAccountPage } from "./pages/preferences/account" import { PreferencesAccountPage } from "./pages/preferences/account"
import { PreferencesLinkedAccountsPage } from "./pages/preferences/linked-accounts" import { PreferencesLinkedAccountsPage } from "./pages/preferences/linked-accounts"
import { PreferencesSecurityPage } from "./pages/preferences/security" import { PreferencesSecurityPage } from "./pages/preferences/security"
import { PreferencesSSHKeysPage } from "./pages/preferences/ssh-keys" import { PreferencesSSHKeysPage } from "./pages/preferences/ssh-keys"
import { SettingsPage } from "./pages/settings"
import { TemplatesPage } from "./pages/templates" import { TemplatesPage } from "./pages/templates"
import { TemplatePage } from "./pages/templates/[organization]/[template]" import { TemplatePage } from "./pages/templates/[organization]/[template]"
import { CreateWorkspacePage } from "./pages/templates/[organization]/[template]/create" import { CreateWorkspacePage } from "./pages/templates/[organization]/[template]/create"
import { UsersPage } from "./pages/users"
import { WorkspacePage } from "./pages/workspaces/[workspace]" import { WorkspacePage } from "./pages/workspaces/[workspace]"
export const AppRouter: React.FC = () => ( export const AppRouter: React.FC = () => (
@ -72,6 +75,31 @@ export const AppRouter: React.FC = () => (
/> />
</Route> </Route>
<Route
path="users"
element={
<AuthAndNav>
<UsersPage />
</AuthAndNav>
}
/>
<Route
path="orgs"
element={
<AuthAndNav>
<OrganizationsPage />
</AuthAndNav>
}
/>
<Route
path="settings"
element={
<AuthAndNav>
<SettingsPage />
</AuthAndNav>
}
/>
<Route path="preferences" element={<PreferencesLayout />}> <Route path="preferences" element={<PreferencesLayout />}>
<Route path="account" element={<PreferencesAccountPage />} /> <Route path="account" element={<PreferencesAccountPage />} />
<Route path="security" element={<PreferencesSecurityPage />} /> <Route path="security" element={<PreferencesSecurityPage />} />

View File

@ -0,0 +1,17 @@
import Box from "@material-ui/core/Box"
import { Story } from "@storybook/react"
import React from "react"
import { AdminDropdown } from "./AdminDropdown"
export default {
title: "components/AdminDropdown",
component: AdminDropdown,
}
const Template: Story = () => (
<Box style={{ backgroundColor: "#000", width: 100 }}>
<AdminDropdown />
</Box>
)
export const Example = Template.bind({})

View File

@ -0,0 +1,48 @@
import { screen } from "@testing-library/react"
import React from "react"
import { history, render } from "../../test_helpers"
import { AdminDropdown, Language } from "./AdminDropdown"
const renderAndClick = async () => {
render(<AdminDropdown />)
const trigger = await screen.findByText(Language.menuTitle)
trigger.click()
}
describe("AdminDropdown", () => {
describe("when the trigger is clicked", () => {
it("opens the menu", async () => {
await renderAndClick()
expect(screen.getByText(Language.usersLabel)).toBeDefined()
expect(screen.getByText(Language.orgsLabel)).toBeDefined()
expect(screen.getByText(Language.settingsLabel)).toBeDefined()
})
})
it("links to the users page", async () => {
await renderAndClick()
const usersLink = screen.getByText(Language.usersLabel).closest("a")
usersLink?.click()
expect(history.location.pathname).toEqual("/users")
})
it("links to the orgs page", async () => {
await renderAndClick()
const usersLink = screen.getByText(Language.orgsLabel).closest("a")
usersLink?.click()
expect(history.location.pathname).toEqual("/orgs")
})
it("links to the settings page", async () => {
await renderAndClick()
const usersLink = screen.getByText(Language.settingsLabel).closest("a")
usersLink?.click()
expect(history.location.pathname).toEqual("/settings")
})
})

View File

@ -0,0 +1,155 @@
import ListItem from "@material-ui/core/ListItem"
import ListItemText from "@material-ui/core/ListItemText"
import { fade, makeStyles, Theme } from "@material-ui/core/styles"
import AdminIcon from "@material-ui/icons/SettingsOutlined"
import React, { useState } from "react"
import { navHeight } from "../../theme/constants"
import { BorderedMenu } from "../BorderedMenu/BorderedMenu"
import { BorderedMenuRow } from "../BorderedMenuRow/BorderedMenuRow"
import { CloseDropdown, OpenDropdown } from "../DropdownArrows/DropdownArrows"
import { BuildingIcon } from "../Icons/BuildingIcon"
import { UsersOutlinedIcon } from "../Icons/UsersOutlinedIcon"
export const Language = {
menuTitle: "Admin",
usersLabel: "Users",
usersDescription: "Manage users, roles, and permissions.",
orgsLabel: "Organizations",
orgsDescription: "Manage organizations.",
settingsLabel: "Settings",
settingsDescription: "Configure authentication and more.",
}
const entries = [
{
label: Language.usersLabel,
description: Language.usersDescription,
path: "/users",
Icon: UsersOutlinedIcon,
},
{
label: Language.orgsLabel,
description: Language.orgsDescription,
path: "/orgs",
Icon: BuildingIcon,
},
{
label: Language.settingsLabel,
description: Language.settingsDescription,
path: "/settings",
Icon: AdminIcon,
},
]
export const AdminDropdown: React.FC = () => {
const styles = useStyles()
const [anchorEl, setAnchorEl] = useState<HTMLElement>()
const onClose = () => setAnchorEl(undefined)
const onOpenAdminMenu = (ev: React.MouseEvent<HTMLDivElement>) => setAnchorEl(ev.currentTarget)
return (
<>
<div className={styles.link}>
<ListItem selected={Boolean(anchorEl)} button onClick={onOpenAdminMenu}>
<ListItemText className="no-brace" color="primary" primary={Language.menuTitle} />
{anchorEl ? <CloseDropdown /> : <OpenDropdown />}
</ListItem>
</div>
<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) => (
<BorderedMenuRow
description={entry.description}
Icon={entry.Icon}
key={entry.label}
path={entry.path}
title={entry.label}
variant="narrow"
onClick={() => {
onClose()
}}
/>
))}
</BorderedMenu>
</>
)
}
const useStyles = makeStyles((theme: Theme) => ({
link: {
"&:focus": {
outline: "none",
"& .MuiListItem-button": {
background: fade(theme.palette.primary.light, 0.1),
},
},
"& .MuiListItemText-root": {
display: "flex",
flexDirection: "column",
alignItems: "center",
},
"& .feature-stage-chip": {
position: "absolute",
bottom: theme.spacing(1),
"& .MuiChip-labelSmall": {
fontSize: "10px",
},
},
whiteSpace: "nowrap",
"& .MuiListItem-button": {
height: navHeight,
color: "#A7A7A7",
padding: `0 ${theme.spacing(3)}px`,
"&.Mui-selected": {
background: "transparent",
"& .MuiListItemText-root": {
color: theme.palette.primary.contrastText,
"&:not(.no-brace) .MuiTypography-root": {
position: "relative",
"&::before": {
content: `"{"`,
left: -14,
position: "absolute",
},
"&::after": {
content: `"}"`,
position: "absolute",
right: -14,
},
},
},
},
"&.Mui-focusVisible, &:hover": {
background: "#333",
},
"& .MuiListItemText-primary": {
fontFamily: theme.typography.fontFamily,
fontSize: 16,
fontWeight: 500,
},
},
},
}))

View File

@ -0,0 +1,30 @@
import { Story } from "@storybook/react"
import React from "react"
import { BorderedMenuRow } from "../BorderedMenuRow/BorderedMenuRow"
import { BuildingIcon } from "../Icons/BuildingIcon"
import { UsersOutlinedIcon } from "../Icons/UsersOutlinedIcon"
import { BorderedMenu, BorderedMenuProps } from "./BorderedMenu"
export default {
title: "components/BorderedMenu",
component: BorderedMenu,
}
const Template: Story<BorderedMenuProps> = (args: BorderedMenuProps) => (
<BorderedMenu {...args}>
<BorderedMenuRow title="Item 1" description="Here's a description" Icon={BuildingIcon} />
<BorderedMenuRow active title="Item 2" description="This BorderedMenuRow is active" Icon={UsersOutlinedIcon} />
</BorderedMenu>
)
export const AdminVariant = Template.bind({})
AdminVariant.args = {
variant: "admin-dropdown",
open: true,
}
export const UserVariant = Template.bind({})
UserVariant.args = {
variant: "user-dropdown",
open: true,
}

View File

@ -2,9 +2,9 @@ import Popover, { PopoverProps } from "@material-ui/core/Popover"
import { fade, makeStyles } from "@material-ui/core/styles" import { fade, makeStyles } from "@material-ui/core/styles"
import React from "react" import React from "react"
type BorderedMenuVariant = "manage-dropdown" | "user-dropdown" type BorderedMenuVariant = "admin-dropdown" | "user-dropdown"
type BorderedMenuProps = Omit<PopoverProps, "variant"> & { export type BorderedMenuProps = Omit<PopoverProps, "variant"> & {
variant?: BorderedMenuVariant variant?: BorderedMenuVariant
} }
@ -20,7 +20,14 @@ export const BorderedMenu: React.FC<BorderedMenuProps> = ({ children, variant, .
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
root: { root: {
paddingBottom: theme.spacing(1), "&[data-variant='admin-dropdown'] $paperRoot": {
padding: `${theme.spacing(3)}px 0`,
},
"&[data-variant='user-dropdown'] $paperRoot": {
paddingBottom: theme.spacing(1),
width: 292,
},
}, },
paperRoot: { paperRoot: {
width: "292px", width: "292px",

View File

@ -0,0 +1,144 @@
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/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} className={styles.link}>
<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,
},
},
link: {
textDecoration: "none",
color: "inherit",
},
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),
},
}))

View File

@ -0,0 +1,26 @@
import { fade, makeStyles, Theme } from "@material-ui/core/styles"
import KeyboardArrowDown from "@material-ui/icons/KeyboardArrowDown"
import KeyboardArrowUp from "@material-ui/icons/KeyboardArrowUp"
import React from "react"
const useStyles = makeStyles((theme: Theme) => ({
arrowIcon: {
color: fade(theme.palette.primary.contrastText, 0.7),
marginLeft: theme.spacing(1),
width: 16,
height: 16,
},
arrowIconUp: {
color: theme.palette.primary.contrastText,
},
}))
export const OpenDropdown: React.FC = () => {
const styles = useStyles()
return <KeyboardArrowDown className={styles.arrowIcon} />
}
export const CloseDropdown: React.FC = () => {
const styles = useStyles()
return <KeyboardArrowUp className={`${styles.arrowIcon} ${styles.arrowIconUp}`} />
}

View File

@ -0,0 +1,13 @@
import SvgIcon, { SvgIconProps } from "@material-ui/core/SvgIcon"
import React from "react"
export const BuildingIcon = (props: SvgIconProps): JSX.Element => (
<SvgIcon {...props} viewBox="0 0 24 24">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2.96387 1.15222H17.3405V11.2377H21V22.8479L17.3405 22.8479L10.9364 22.8479H9.36801L2.96387 22.8479V1.15222ZM10.9364 21.2795V19.4498H9.36801V21.2795H4.53223V2.72058H15.7722V11.2377H15.7721V12.806H15.7722V21.2795H10.9364ZM17.3405 12.806V21.2795H19.4317V12.806H17.3405ZM9.10661 4.81173H6.62337V6.38009H9.10661V4.81173ZM11.1978 4.81173H13.681V6.38009H11.1978V4.81173ZM9.10661 8.47124H6.62337V10.0396H9.10661V8.47124ZM11.1978 8.47124H13.681V10.0396H11.1978V8.47124ZM9.10661 12.1307H6.62337V13.6991H9.10661V12.1307ZM11.1978 12.1307H13.681V13.6991H11.1978V12.1307ZM9.10661 15.7903H6.62337V17.3586H9.10661V15.7903ZM11.1978 15.7903H13.681V17.3586H11.1978V15.7903Z"
fill="currentColor"
/>
</SvgIcon>
)

View File

@ -0,0 +1,25 @@
import SvgIcon from "@material-ui/core/SvgIcon"
import React from "react"
export const UsersOutlinedIcon: typeof SvgIcon = (props) => (
<SvgIcon {...props} viewBox="0 0 20 20">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M18.75 18.75H17.5V15.625V15.625C17.498 13.8999 16.1001 12.502 14.375 12.5V11.25C16.7901 11.2527 18.7473 13.2099 18.75 15.625L18.75 18.75Z"
fill="#677693"
/>
<path
d="M13.75 18.75H12.5V15.625V15.6251C12.498 13.9001 11.1002 12.5021 9.3751 12.5H5.625C3.89995 12.502 2.50203 13.9 2.5 15.625V18.75H1.25V15.625L1.25 15.6251C1.25277 13.21 3.20982 11.2529 5.62489 11.25H9.37489C11.79 11.2528 13.7471 13.2099 13.7499 15.625L13.75 18.75Z"
fill="#677693"
/>
<path
d="M12.5 1.25V2.5C14.2259 2.5 15.625 3.89911 15.625 5.625C15.625 7.35089 14.2259 8.75 12.5 8.75V10C14.9162 10 16.875 8.04124 16.875 5.625C16.875 3.20876 14.9162 1.25 12.5 1.25Z"
fill="#677693"
/>
<path
d="M7.5 2.5C9.22589 2.5 10.625 3.89911 10.625 5.625C10.625 7.35089 9.22589 8.75 7.5 8.75C5.77411 8.75 4.375 7.35089 4.375 5.625V5.625C4.375 3.89911 5.77411 2.5 7.5 2.5V2.5ZM7.5 1.25H7.5C5.08376 1.25 3.125 3.20876 3.125 5.625C3.125 8.04124 5.08376 10 7.5 10C9.91624 10 11.875 8.04124 11.875 5.625C11.875 3.20876 9.91624 1.25 7.5 1.25L7.5 1.25Z"
fill="#677693"
/>
</svg>
</SvgIcon>
)

View File

@ -1,9 +1,9 @@
import { Story } from "@storybook/react" import { Story } from "@storybook/react"
import React from "react" import React from "react"
import { NavbarView, NavbarViewProps } from "./NavbarView" import { NavbarView, NavbarViewProps } from "."
export default { export default {
title: "Page/NavbarView", title: "components/NavbarView",
component: NavbarView, component: NavbarView,
argTypes: { argTypes: {
onSignOut: { action: "Sign Out" }, onSignOut: { action: "Sign Out" },
@ -12,8 +12,16 @@ export default {
const Template: Story<NavbarViewProps> = (args: NavbarViewProps) => <NavbarView {...args} /> const Template: Story<NavbarViewProps> = (args: NavbarViewProps) => <NavbarView {...args} />
export const Primary = Template.bind({}) export const ForAdmin = Template.bind({})
Primary.args = { ForAdmin.args = {
user: { id: "1", username: "Administrator", email: "admin@coder.com", created_at: "dawn" },
onSignOut: () => {
return Promise.resolve()
},
}
export const ForMember = Template.bind({})
ForMember.args = {
user: { id: "1", username: "CathyCoder", email: "cathy@coder.com", created_at: "dawn" }, user: { id: "1", username: "CathyCoder", email: "cathy@coder.com", created_at: "dawn" },
onSignOut: () => { onSignOut: () => {
return Promise.resolve() return Promise.resolve()

View File

@ -1,8 +1,8 @@
import { screen } from "@testing-library/react" import { screen } from "@testing-library/react"
import React from "react" import React from "react"
import { render } from "../../test_helpers" import { NavbarView } from "."
import { MockUser } from "../../test_helpers/entities" import { render } from "../../../test_helpers"
import { NavbarView } from "./NavbarView" import { MockUser } from "../../../test_helpers/entities"
describe("NavbarView", () => { describe("NavbarView", () => {
const noop = () => { const noop = () => {

View File

@ -3,9 +3,11 @@ import ListItem from "@material-ui/core/ListItem"
import { fade, makeStyles } from "@material-ui/core/styles" import { fade, makeStyles } from "@material-ui/core/styles"
import React from "react" import React from "react"
import { NavLink } from "react-router-dom" import { NavLink } from "react-router-dom"
import { UserResponse } from "../../api/types" import { UserResponse } from "../../../api/types"
import { Logo } from "../Icons" import { navHeight } from "../../../theme/constants"
import { UserDropdown } from "./UserDropdown" import { AdminDropdown } from "../../AdminDropdown/AdminDropdown"
import { Logo } from "../../Icons"
import { UserDropdown } from "../UserDropdown"
export interface NavbarViewProps { export interface NavbarViewProps {
user?: UserResponse user?: UserResponse
@ -29,6 +31,7 @@ export const NavbarView: React.FC<NavbarViewProps> = ({ user, onSignOut }) => {
</ListItem> </ListItem>
</List> </List>
<div className={styles.fullWidth} /> <div className={styles.fullWidth} />
{user && user.email === "admin@coder.com" && <AdminDropdown />}
<div className={styles.fixed}>{user && <UserDropdown user={user} onSignOut={onSignOut} />}</div> <div className={styles.fixed}>{user && <UserDropdown user={user} onSignOut={onSignOut} />}</div>
</nav> </nav>
) )
@ -42,7 +45,7 @@ const useStyles = makeStyles((theme) => ({
flexDirection: "row", flexDirection: "row",
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
height: 56, height: navHeight,
background: theme.palette.navbar.main, background: theme.palette.navbar.main,
marginTop: 0, marginTop: 0,
transition: "margin 150ms ease", transition: "margin 150ms ease",
@ -62,7 +65,7 @@ const useStyles = makeStyles((theme) => ({
logo: { logo: {
alignItems: "center", alignItems: "center",
display: "flex", display: "flex",
height: 56, height: navHeight,
paddingLeft: theme.spacing(4), paddingLeft: theme.spacing(4),
paddingRight: theme.spacing(2), paddingRight: theme.spacing(2),
"& svg": { "& svg": {
@ -81,7 +84,7 @@ const useStyles = makeStyles((theme) => ({
color: "#A7A7A7", color: "#A7A7A7",
display: "flex", display: "flex",
fontSize: 16, fontSize: 16,
height: 56, height: navHeight,
padding: `0 ${theme.spacing(3)}px`, padding: `0 ${theme.spacing(3)}px`,
textDecoration: "none", textDecoration: "none",
transition: "background-color 0.3s ease", transition: "background-color 0.3s ease",

View File

@ -1,7 +1,7 @@
import Box from "@material-ui/core/Box" import Box from "@material-ui/core/Box"
import { Story } from "@storybook/react" import { Story } from "@storybook/react"
import React from "react" import React from "react"
import { UserDropdown, UserDropdownProps } from "./UserDropdown" import { UserDropdown, UserDropdownProps } from "."
export default { export default {
title: "Page/UserDropdown", title: "Page/UserDropdown",

View File

@ -1,8 +1,8 @@
import { screen } from "@testing-library/react" import { screen } from "@testing-library/react"
import React from "react" import React from "react"
import { render } from "../../test_helpers" import { Language, UserDropdown, UserDropdownProps } from "."
import { MockUser } from "../../test_helpers/entities" import { render } from "../../../test_helpers"
import { Language, UserDropdown, UserDropdownProps } from "./UserDropdown" import { MockUser } from "../../../test_helpers/entities"
const renderAndClick = async (props: Partial<UserDropdownProps> = {}) => { const renderAndClick = async (props: Partial<UserDropdownProps> = {}) => {
render(<UserDropdown user={props.user ?? MockUser} onSignOut={props.onSignOut ?? jest.fn()} />) render(<UserDropdown user={props.user ?? MockUser} onSignOut={props.onSignOut ?? jest.fn()} />)

View File

@ -5,16 +5,15 @@ import ListItemText from "@material-ui/core/ListItemText"
import MenuItem from "@material-ui/core/MenuItem" import MenuItem from "@material-ui/core/MenuItem"
import { fade, makeStyles } from "@material-ui/core/styles" import { fade, makeStyles } from "@material-ui/core/styles"
import AccountIcon from "@material-ui/icons/AccountCircleOutlined" import AccountIcon from "@material-ui/icons/AccountCircleOutlined"
import KeyboardArrowDown from "@material-ui/icons/KeyboardArrowDown"
import KeyboardArrowUp from "@material-ui/icons/KeyboardArrowUp"
import React, { useState } from "react" import React, { useState } from "react"
import { Link } from "react-router-dom" import { Link } from "react-router-dom"
import { UserResponse } from "../../api/types" import { UserResponse } from "../../../api/types"
import { LogoutIcon } from "../Icons" import { BorderedMenu } from "../../BorderedMenu/BorderedMenu"
import { DocsIcon } from "../Icons/DocsIcon" import { CloseDropdown, OpenDropdown } from "../../DropdownArrows/DropdownArrows"
import { UserAvatar } from "../User" import { LogoutIcon } from "../../Icons"
import { UserProfileCard } from "../User/UserProfileCard" import { DocsIcon } from "../../Icons/DocsIcon"
import { BorderedMenu } from "./BorderedMenu" import { UserAvatar } from "../../User"
import { UserProfileCard } from "../../User/UserProfileCard"
export const Language = { export const Language = {
accountLabel: "Account", accountLabel: "Account",
@ -44,11 +43,7 @@ export const UserDropdown: React.FC<UserDropdownProps> = ({ user, onSignOut }: U
<Badge overlap="circle"> <Badge overlap="circle">
<UserAvatar username={user.username} /> <UserAvatar username={user.username} />
</Badge> </Badge>
{anchorEl ? ( {anchorEl ? <CloseDropdown /> : <OpenDropdown />}
<KeyboardArrowUp className={`${styles.arrowIcon} ${styles.arrowIconUp}`} />
) : (
<KeyboardArrowDown className={styles.arrowIcon} />
)}
</div> </div>
</MenuItem> </MenuItem>
@ -120,17 +115,6 @@ export const useStyles = makeStyles((theme) => ({
marginBottom: theme.spacing(1), marginBottom: theme.spacing(1),
}, },
arrowIcon: {
color: fade(theme.palette.primary.contrastText, 0.7),
marginLeft: theme.spacing(1),
width: 16,
height: 16,
},
arrowIconUp: {
color: theme.palette.primary.contrastText,
},
menuItem: { menuItem: {
height: 44, height: 44,
padding: `${theme.spacing(1.5)}px ${theme.spacing(2.75)}px`, padding: `${theme.spacing(1.5)}px ${theme.spacing(2.75)}px`,

View File

@ -3,7 +3,7 @@ import ListItem from "@material-ui/core/ListItem"
import { makeStyles } from "@material-ui/core/styles" import { makeStyles } from "@material-ui/core/styles"
import React from "react" import React from "react"
import { NavLink } from "react-router-dom" import { NavLink } from "react-router-dom"
import { combineClasses } from "../../util/combine-classes" import { combineClasses } from "../../util/combineClasses"
export interface TabSidebarItem { export interface TabSidebarItem {
path: string path: string

View File

@ -0,0 +1,24 @@
import { Story } from "@storybook/react"
import React from "react"
import { Typography, TypographyProps } from "./Typography"
export default {
title: "components/Typography",
component: Typography,
}
const Template: Story<TypographyProps> = (args: TypographyProps) => (
<>
<Typography {...args}>Colorless green ideas sleep furiously</Typography>
<Typography {...args}>More people have been to France than I have</Typography>
</>
)
export const Short = Template.bind({})
Short.args = {
short: true,
}
export const Tall = Template.bind({})
Tall.args = {
short: false,
}

View File

@ -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 { appendCSSString, combineClasses } 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,
},
},
})

View File

@ -0,0 +1,5 @@
import React from "react"
export const OrganizationsPage: React.FC = () => {
return <div>Coming soon!</div>
}

View File

@ -0,0 +1,5 @@
import React from "react"
export const SettingsPage: React.FC = () => {
return <div>Coming soon!</div>
}

5
site/src/pages/users.tsx Normal file
View File

@ -0,0 +1,5 @@
import React from "react"
export const UsersPage: React.FC = () => {
return <div>Coming soon!</div>
}

View File

@ -6,3 +6,4 @@ export const MONOSPACE_FONT_FAMILY =
export const BODY_FONT_FAMILY = `"Inter", sans-serif` export const BODY_FONT_FAMILY = `"Inter", sans-serif`
export const lightButtonShadow = "0 2px 2px rgba(0, 23, 121, 0.08)" export const lightButtonShadow = "0 2px 2px rgba(0, 23, 121, 0.08)"
export const emptyBoxShadow = "none" export const emptyBoxShadow = "none"
export const navHeight = 56

View File

@ -0,0 +1,28 @@
import { combineClasses } from "./combineClasses"
const staticStyles = {
text: "MuiText",
success: "MuiText-Green",
warning: "MuiText-Red",
}
describe("combineClasses", () => {
it.each([
// Falsy
[undefined, undefined],
[{ [staticStyles.text]: false }, undefined],
[{ [staticStyles.text]: undefined }, undefined],
[[], undefined],
// Truthy
[{ [staticStyles.text]: true }, "MuiText"],
[{ [staticStyles.text]: true, [staticStyles.warning]: true }, "MuiText MuiText-Red"],
[[staticStyles.text], "MuiText"],
// Mixed
[{ [staticStyles.text]: true, [staticStyles.success]: false }, "MuiText"],
[[staticStyles.text, staticStyles.success], "MuiText MuiText-Green"],
])(`classNames(%p) returns %p`, (staticClasses, result) => {
expect(combineClasses(staticClasses)).toBe(result)
})
})

View File

@ -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)
},
)
})

View File

@ -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)}...`
}

View File

@ -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