mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
feat: add a divider after Account menu item (#1927)
* add a divider after Account menu item * test: improve Storybook tests * add closed and open userdropdown tests * add default isOpen * extract UserDropdownContent into a single component * remove the isOpen prop * address nit comments * update test name
This commit is contained in:
@@ -17,8 +17,8 @@ const Template: Story<UserDropdownProps> = (args: UserDropdownProps) => (
|
||||
</Box>
|
||||
)
|
||||
|
||||
export const ExampleNoRoles = Template.bind({})
|
||||
ExampleNoRoles.args = {
|
||||
export const Example = Template.bind({})
|
||||
Example.args = {
|
||||
user: MockUser,
|
||||
onSignOut: () => {
|
||||
return Promise.resolve()
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { screen } from "@testing-library/react"
|
||||
import { MockAdminRole, MockUser } from "../../testHelpers/entities"
|
||||
import { MockUser } from "../../testHelpers/entities"
|
||||
import { render } from "../../testHelpers/renderHelpers"
|
||||
import { Language, UserDropdown, UserDropdownProps } from "./UsersDropdown"
|
||||
import { Language } from "../UserDropdownContent/UserDropdownContent"
|
||||
import { UserDropdown, UserDropdownProps } from "./UsersDropdown"
|
||||
|
||||
const renderAndClick = async (props: Partial<UserDropdownProps> = {}) => {
|
||||
render(<UserDropdown user={props.user ?? MockUser} onSignOut={props.onSignOut ?? jest.fn()} />)
|
||||
@@ -10,18 +11,6 @@ const renderAndClick = async (props: Partial<UserDropdownProps> = {}) => {
|
||||
}
|
||||
|
||||
describe("UserDropdown", () => {
|
||||
const env = process.env
|
||||
|
||||
// REMARK: copying process.env so we don't mutate that object or encounter conflicts between tests
|
||||
beforeEach(() => {
|
||||
process.env = { ...env }
|
||||
})
|
||||
|
||||
// REMARK: restoring process.env
|
||||
afterEach(() => {
|
||||
process.env = env
|
||||
})
|
||||
|
||||
describe("when the trigger is clicked", () => {
|
||||
it("opens the menu", async () => {
|
||||
await renderAndClick()
|
||||
@@ -30,44 +19,4 @@ describe("UserDropdown", () => {
|
||||
expect(screen.getByText(Language.signOutLabel)).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("when the menu is open", () => {
|
||||
it("displays the user's roles", async () => {
|
||||
await renderAndClick()
|
||||
|
||||
expect(screen.getByText(MockAdminRole.display_name)).toBeDefined()
|
||||
})
|
||||
|
||||
it("has the correct link for the documentation item", async () => {
|
||||
process.env.CODER_VERSION = "v0.5.4"
|
||||
await renderAndClick()
|
||||
|
||||
const link = screen.getByText(Language.docsLabel).closest("a")
|
||||
if (!link) {
|
||||
throw new Error("Anchor tag not found for the documentation menu item")
|
||||
}
|
||||
|
||||
expect(link.getAttribute("href")).toBe(`https://github.com/coder/coder/tree/${process.env.CODER_VERSION}/docs`)
|
||||
})
|
||||
|
||||
it("has the correct link for the account item", async () => {
|
||||
await renderAndClick()
|
||||
|
||||
const link = screen.getByText(Language.accountLabel).closest("a")
|
||||
if (!link) {
|
||||
throw new Error("Anchor tag not found for the account menu item")
|
||||
}
|
||||
|
||||
expect(link.getAttribute("href")).toBe("/settings/account")
|
||||
})
|
||||
|
||||
describe("and sign out is clicked", () => {
|
||||
it("calls the onSignOut function", async () => {
|
||||
const onSignOut = jest.fn()
|
||||
await renderAndClick({ onSignOut })
|
||||
screen.getByText(Language.signOutLabel).click()
|
||||
expect(onSignOut).toBeCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,26 +1,14 @@
|
||||
import Badge from "@material-ui/core/Badge"
|
||||
import Divider from "@material-ui/core/Divider"
|
||||
import ListItemIcon from "@material-ui/core/ListItemIcon"
|
||||
import ListItemText from "@material-ui/core/ListItemText"
|
||||
import MenuItem from "@material-ui/core/MenuItem"
|
||||
import { fade, makeStyles } from "@material-ui/core/styles"
|
||||
import AccountIcon from "@material-ui/icons/AccountCircleOutlined"
|
||||
import React, { useState } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import * as TypesGen from "../../api/typesGenerated"
|
||||
import { navHeight } from "../../theme/constants"
|
||||
import { BorderedMenu } from "../BorderedMenu/BorderedMenu"
|
||||
import { CloseDropdown, OpenDropdown } from "../DropdownArrows/DropdownArrows"
|
||||
import { DocsIcon } from "../Icons/DocsIcon"
|
||||
import { LogoutIcon } from "../Icons/LogoutIcon"
|
||||
import { UserAvatar } from "../UserAvatar/UserAvatar"
|
||||
import { UserProfileCard } from "../UserProfileCard/UserProfileCard"
|
||||
import { UserDropdownContent } from "../UserDropdownContent/UserDropdownContent"
|
||||
|
||||
export const Language = {
|
||||
accountLabel: "Account",
|
||||
docsLabel: "Documentation",
|
||||
signOutLabel: "Sign Out",
|
||||
}
|
||||
export interface UserDropdownProps {
|
||||
user: TypesGen.User
|
||||
onSignOut: () => void
|
||||
@@ -64,41 +52,7 @@ export const UserDropdown: React.FC<UserDropdownProps> = ({ user, onSignOut }: U
|
||||
variant="user-dropdown"
|
||||
onClose={onPopoverClose}
|
||||
>
|
||||
<div className={styles.userInfo}>
|
||||
<UserProfileCard user={user} />
|
||||
|
||||
<Divider />
|
||||
|
||||
<Link to="/settings/account" className={styles.link}>
|
||||
<MenuItem className={styles.menuItem} onClick={onPopoverClose}>
|
||||
<ListItemIcon className={styles.icon}>
|
||||
<AccountIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={Language.accountLabel} />
|
||||
</MenuItem>
|
||||
</Link>
|
||||
|
||||
<a
|
||||
href={`https://github.com/coder/coder/tree/${process.env.CODER_VERSION}/docs`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={styles.link}
|
||||
>
|
||||
<MenuItem className={styles.menuItem} onClick={onPopoverClose}>
|
||||
<ListItemIcon className={styles.icon}>
|
||||
<DocsIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={Language.docsLabel} />
|
||||
</MenuItem>
|
||||
</a>
|
||||
|
||||
<MenuItem className={styles.menuItem} onClick={onSignOut}>
|
||||
<ListItemIcon className={styles.icon}>
|
||||
<LogoutIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={Language.signOutLabel} />
|
||||
</MenuItem>
|
||||
</div>
|
||||
<UserDropdownContent user={user} onPopoverClose={onPopoverClose} onSignOut={onSignOut} />
|
||||
</BorderedMenu>
|
||||
</>
|
||||
)
|
||||
@@ -117,10 +71,6 @@ export const useStyles = makeStyles((theme) => ({
|
||||
maxWidth: 300,
|
||||
},
|
||||
|
||||
userInfo: {
|
||||
marginBottom: theme.spacing(1),
|
||||
},
|
||||
|
||||
menuItem: {
|
||||
height: navHeight,
|
||||
padding: `${theme.spacing(1.5)}px ${theme.spacing(2.75)}px`,
|
||||
@@ -130,13 +80,4 @@ export const useStyles = makeStyles((theme) => ({
|
||||
transition: "background-color 0.3s ease",
|
||||
},
|
||||
},
|
||||
|
||||
link: {
|
||||
textDecoration: "none",
|
||||
color: "inherit",
|
||||
},
|
||||
|
||||
icon: {
|
||||
color: theme.palette.text.secondary,
|
||||
},
|
||||
}))
|
||||
|
||||
+4
-7
@@ -1,17 +1,14 @@
|
||||
import { Story } from "@storybook/react"
|
||||
import React from "react"
|
||||
import { MockUser } from "../../testHelpers/entities"
|
||||
import { UserProfileCard, UserProfileCardProps } from "./UserProfileCard"
|
||||
import { UserDropdownContent, UserDropdownContentProps } from "./UserDropdownContent"
|
||||
|
||||
export default {
|
||||
title: "components/UserDropdown",
|
||||
component: UserProfileCard,
|
||||
argTypes: {
|
||||
onSignOut: { action: "Sign Out" },
|
||||
},
|
||||
title: "components/UserDropdownContent",
|
||||
component: UserDropdownContent,
|
||||
}
|
||||
|
||||
const Template: Story<UserProfileCardProps> = (args: UserProfileCardProps) => <UserProfileCard {...args} />
|
||||
const Template: Story<UserDropdownContentProps> = (args) => <UserDropdownContent {...args} />
|
||||
|
||||
export const ExampleNoRoles = Template.bind({})
|
||||
ExampleNoRoles.args = {
|
||||
@@ -0,0 +1,61 @@
|
||||
import { screen } from "@testing-library/react"
|
||||
import { MockAdminRole, MockUser } from "../../testHelpers/entities"
|
||||
import { render } from "../../testHelpers/renderHelpers"
|
||||
import { Language, UserDropdownContent } from "./UserDropdownContent"
|
||||
|
||||
describe("UserDropdownContent", () => {
|
||||
const env = process.env
|
||||
|
||||
// REMARK: copying process.env so we don't mutate that object or encounter conflicts between tests
|
||||
beforeEach(() => {
|
||||
process.env = { ...env }
|
||||
})
|
||||
|
||||
// REMARK: restoring process.env
|
||||
afterEach(() => {
|
||||
process.env = env
|
||||
})
|
||||
|
||||
it("displays the menu items", () => {
|
||||
render(<UserDropdownContent user={MockUser} onSignOut={jest.fn()} onPopoverClose={jest.fn()} />)
|
||||
expect(screen.getByText(Language.accountLabel)).toBeDefined()
|
||||
expect(screen.getByText(Language.docsLabel)).toBeDefined()
|
||||
expect(screen.getByText(Language.signOutLabel)).toBeDefined()
|
||||
})
|
||||
|
||||
it("displays the user's roles", () => {
|
||||
render(<UserDropdownContent user={MockUser} onSignOut={jest.fn()} onPopoverClose={jest.fn()} />)
|
||||
|
||||
expect(screen.getByText(MockAdminRole.display_name)).toBeDefined()
|
||||
})
|
||||
|
||||
it("has the correct link for the documentation item", () => {
|
||||
process.env.CODER_VERSION = "v0.5.4"
|
||||
render(<UserDropdownContent user={MockUser} onSignOut={jest.fn()} onPopoverClose={jest.fn()} />)
|
||||
|
||||
const link = screen.getByText(Language.docsLabel).closest("a")
|
||||
if (!link) {
|
||||
throw new Error("Anchor tag not found for the documentation menu item")
|
||||
}
|
||||
|
||||
expect(link.getAttribute("href")).toBe(`https://github.com/coder/coder/tree/${process.env.CODER_VERSION}/docs`)
|
||||
})
|
||||
|
||||
it("has the correct link for the account item", () => {
|
||||
render(<UserDropdownContent user={MockUser} onSignOut={jest.fn()} onPopoverClose={jest.fn()} />)
|
||||
|
||||
const link = screen.getByText(Language.accountLabel).closest("a")
|
||||
if (!link) {
|
||||
throw new Error("Anchor tag not found for the account menu item")
|
||||
}
|
||||
|
||||
expect(link.getAttribute("href")).toBe("/settings/account")
|
||||
})
|
||||
|
||||
it("calls the onSignOut function", () => {
|
||||
const onSignOut = jest.fn()
|
||||
render(<UserDropdownContent user={MockUser} onSignOut={onSignOut} onPopoverClose={jest.fn()} />)
|
||||
screen.getByText(Language.signOutLabel).click()
|
||||
expect(onSignOut).toBeCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,151 @@
|
||||
import Chip from "@material-ui/core/Chip"
|
||||
import Divider from "@material-ui/core/Divider"
|
||||
import ListItemIcon from "@material-ui/core/ListItemIcon"
|
||||
import ListItemText from "@material-ui/core/ListItemText"
|
||||
import MenuItem from "@material-ui/core/MenuItem"
|
||||
import { fade, makeStyles } from "@material-ui/core/styles"
|
||||
import Typography from "@material-ui/core/Typography"
|
||||
import AccountIcon from "@material-ui/icons/AccountCircleOutlined"
|
||||
import { FC } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import * as TypesGen from "../../api/typesGenerated"
|
||||
import { Role } from "../../api/typesGenerated"
|
||||
import { navHeight } from "../../theme/constants"
|
||||
import { DocsIcon } from "../Icons/DocsIcon"
|
||||
import { LogoutIcon } from "../Icons/LogoutIcon"
|
||||
import { UserAvatar } from "../UserAvatar/UserAvatar"
|
||||
|
||||
export const Language = {
|
||||
accountLabel: "Account",
|
||||
docsLabel: "Documentation",
|
||||
signOutLabel: "Sign Out",
|
||||
}
|
||||
|
||||
export interface UserDropdownContentProps {
|
||||
user: TypesGen.User
|
||||
onPopoverClose: () => void
|
||||
onSignOut: () => void
|
||||
}
|
||||
|
||||
export const UserDropdownContent: FC<UserDropdownContentProps> = ({ user, onPopoverClose, onSignOut }) => {
|
||||
const styles = useStyles()
|
||||
|
||||
return (
|
||||
<div className={styles.userInfo}>
|
||||
<div className={styles.root}>
|
||||
<div className={styles.avatarContainer}>
|
||||
<UserAvatar className={styles.avatar} username={user.username} />
|
||||
</div>
|
||||
<Typography className={styles.userName}>{user.username}</Typography>
|
||||
<Typography className={styles.userEmail}>{user.email}</Typography>
|
||||
<ul className={styles.chipContainer}>
|
||||
{user.roles.map((role: Role) => (
|
||||
<li key={role.name} className={styles.chipStyles}>
|
||||
<Chip classes={{ root: styles.chipRoot }} label={role.display_name} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Link to="/settings/account" className={styles.link}>
|
||||
<MenuItem className={styles.menuItem} onClick={onPopoverClose}>
|
||||
<ListItemIcon className={styles.icon}>
|
||||
<AccountIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={Language.accountLabel} />
|
||||
</MenuItem>
|
||||
</Link>
|
||||
|
||||
<Divider />
|
||||
|
||||
<a
|
||||
href={`https://github.com/coder/coder/tree/${process.env.CODER_VERSION}/docs`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={styles.link}
|
||||
>
|
||||
<MenuItem className={styles.menuItem} onClick={onPopoverClose}>
|
||||
<ListItemIcon className={styles.icon}>
|
||||
<DocsIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={Language.docsLabel} />
|
||||
</MenuItem>
|
||||
</a>
|
||||
|
||||
<MenuItem className={styles.menuItem} onClick={onSignOut}>
|
||||
<ListItemIcon className={styles.icon}>
|
||||
<LogoutIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={Language.signOutLabel} />
|
||||
</MenuItem>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
paddingTop: theme.spacing(3),
|
||||
textAlign: "center",
|
||||
},
|
||||
avatarContainer: {
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
avatar: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: "50%",
|
||||
marginBottom: theme.spacing(1),
|
||||
transition: `transform .2s`,
|
||||
|
||||
"&:hover": {
|
||||
transform: `scale(1.1)`,
|
||||
},
|
||||
},
|
||||
userName: {
|
||||
fontSize: 16,
|
||||
marginBottom: theme.spacing(0.5),
|
||||
},
|
||||
userEmail: {
|
||||
fontSize: 14,
|
||||
letterSpacing: 0.2,
|
||||
color: theme.palette.text.secondary,
|
||||
},
|
||||
chipContainer: {
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
flexWrap: "wrap",
|
||||
listStyle: "none",
|
||||
margin: "0",
|
||||
padding: `${theme.spacing(1.5)}px ${theme.spacing(2.75)}px`,
|
||||
},
|
||||
chipStyles: {
|
||||
margin: theme.spacing(0.5),
|
||||
},
|
||||
chipRoot: {
|
||||
backgroundColor: "#7057FF",
|
||||
},
|
||||
link: {
|
||||
textDecoration: "none",
|
||||
color: "inherit",
|
||||
},
|
||||
menuItem: {
|
||||
height: navHeight,
|
||||
padding: `${theme.spacing(1.5)}px ${theme.spacing(2.75)}px`,
|
||||
|
||||
"&:hover": {
|
||||
backgroundColor: fade(theme.palette.primary.light, 0.1),
|
||||
transition: "background-color 0.3s ease",
|
||||
},
|
||||
},
|
||||
userInfo: {
|
||||
marginBottom: theme.spacing(1),
|
||||
},
|
||||
icon: {
|
||||
color: theme.palette.text.secondary,
|
||||
},
|
||||
}))
|
||||
@@ -1,79 +0,0 @@
|
||||
import Chip from "@material-ui/core/Chip"
|
||||
import { makeStyles } from "@material-ui/core/styles"
|
||||
import Typography from "@material-ui/core/Typography"
|
||||
import { FC } from "react"
|
||||
import * as TypesGen from "../../api/typesGenerated"
|
||||
import { Role } from "../../api/typesGenerated"
|
||||
import { UserAvatar } from "../UserAvatar/UserAvatar"
|
||||
|
||||
export interface UserProfileCardProps {
|
||||
user: TypesGen.User
|
||||
}
|
||||
|
||||
export const UserProfileCard: FC<UserProfileCardProps> = ({ user }) => {
|
||||
const styles = useStyles()
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className={styles.avatarContainer}>
|
||||
<UserAvatar className={styles.avatar} username={user.username} />
|
||||
</div>
|
||||
<Typography className={styles.userName}>{user.username}</Typography>
|
||||
<Typography className={styles.userEmail}>{user.email}</Typography>
|
||||
<ul className={styles.chipContainer}>
|
||||
{user.roles.map((role: Role) => (
|
||||
<li key={role.name} className={styles.chipStyles}>
|
||||
<Chip classes={{ root: styles.chipRoot }} label={role.display_name} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
paddingTop: theme.spacing(3),
|
||||
textAlign: "center",
|
||||
},
|
||||
avatarContainer: {
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
avatar: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: "50%",
|
||||
marginBottom: theme.spacing(1),
|
||||
transition: `transform .2s`,
|
||||
|
||||
"&:hover": {
|
||||
transform: `scale(1.1)`,
|
||||
},
|
||||
},
|
||||
userName: {
|
||||
fontSize: 16,
|
||||
marginBottom: theme.spacing(0.5),
|
||||
},
|
||||
userEmail: {
|
||||
fontSize: 14,
|
||||
letterSpacing: 0.2,
|
||||
color: theme.palette.text.secondary,
|
||||
},
|
||||
chipContainer: {
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
flexWrap: "wrap",
|
||||
listStyle: "none",
|
||||
margin: "0",
|
||||
padding: `${theme.spacing(1.5)}px ${theme.spacing(2.75)}px`,
|
||||
},
|
||||
chipStyles: {
|
||||
margin: theme.spacing(0.5),
|
||||
},
|
||||
chipRoot: {
|
||||
backgroundColor: "#7057FF",
|
||||
},
|
||||
}))
|
||||
Reference in New Issue
Block a user