Skip to content

Commit 90388a3

Browse files
feat: Add user menu (#887)
1 parent 2ca7253 commit 90388a3

File tree

6 files changed

+158
-3
lines changed

6 files changed

+158
-3
lines changed

site/src/AppRouter.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { NotFoundPage } from "./pages/404"
66
import { CliAuthenticationPage } from "./pages/cli-auth"
77
import { HealthzPage } from "./pages/healthz"
88
import { SignInPage } from "./pages/login"
9+
import { PreferencesPage } from "./pages/preferences"
910
import { TemplatesPage } from "./pages/templates"
1011
import { TemplatePage } from "./pages/templates/[organization]/[template]"
1112
import { CreateWorkspacePage } from "./pages/templates/[organization]/[template]/create"
@@ -67,6 +68,17 @@ export const AppRouter: React.FC = () => (
6768
/>
6869
</Route>
6970

71+
<Route path="preferences">
72+
<Route
73+
index
74+
element={
75+
<AuthAndNav>
76+
<PreferencesPage />
77+
</AuthAndNav>
78+
}
79+
/>
80+
</Route>
81+
7082
{/* Using path="*"" means "match anything", so this route
7183
acts like a catch-all for URLs that we don't have explicit
7284
routes for. */}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import SvgIcon, { SvgIconProps } from "@material-ui/core/SvgIcon"
2+
import React from "react"
3+
4+
export const DocsIcon = (props: SvgIconProps): JSX.Element => (
5+
<SvgIcon {...props} viewBox="0 0 24 24">
6+
<path
7+
fillRule="evenodd"
8+
clipRule="evenodd"
9+
d="M6.53846 3.75C4.67698 3.75 2.86058 4.50721 2.86058 4.50721L2.5 4.66947V16.4423H9.00841C9.20898 16.7871 9.57407 17.0192 10 17.0192C10.4259 17.0192 10.791 16.7871 10.9916 16.4423H17.5V4.66947L17.1394 4.50721C17.1394 4.50721 15.323 3.75 13.4615 3.75C11.7781 3.75 10.2997 4.31566 10 4.4351C9.70027 4.31566 8.22191 3.75 6.53846 3.75ZM6.53846 4.90385C7.654 4.90385 8.84615 5.26442 9.42308 5.46274V14.7656C8.7808 14.5538 7.72611 14.2608 6.53846 14.2608C5.32602 14.2608 4.33894 14.5403 3.65385 14.7656V5.46274C4.09781 5.30273 5.26968 4.90385 6.53846 4.90385ZM13.4615 4.90385C14.7303 4.90385 15.9022 5.30273 16.3462 5.46274V14.7656C15.6611 14.5403 14.674 14.2608 13.4615 14.2608C12.2739 14.2608 11.2192 14.5538 10.5769 14.7656V5.46274C11.1538 5.26442 12.346 4.90385 13.4615 4.90385Z"
10+
fill="currentColor"
11+
/>
12+
</SvgIcon>
13+
)
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import Box from "@material-ui/core/Box"
2+
import { Story } from "@storybook/react"
3+
import React from "react"
4+
import { UserDropdown, UserDropdownProps } from "./UserDropdown"
5+
6+
export default {
7+
title: "Page/UserDropdown",
8+
component: UserDropdown,
9+
argTypes: {
10+
onSignOut: { action: "Sign Out" },
11+
},
12+
}
13+
14+
const Template: Story<UserDropdownProps> = (args: UserDropdownProps) => (
15+
<Box style={{ backgroundColor: "#000", width: 88 }}>
16+
<UserDropdown {...args} />
17+
</Box>
18+
)
19+
20+
export const Example = Template.bind({})
21+
Example.args = {
22+
user: { id: "1", username: "CathyCoder", email: "cathy@coder.com", created_at: "dawn" },
23+
onSignOut: () => {
24+
return Promise.resolve()
25+
},
26+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { screen } from "@testing-library/react"
2+
import React from "react"
3+
import { render } from "../../test_helpers"
4+
import { MockUser } from "../../test_helpers/entities"
5+
import { Language, UserDropdown, UserDropdownProps } from "./UserDropdown"
6+
7+
const renderAndClick = async (props: Partial<UserDropdownProps> = {}) => {
8+
render(<UserDropdown user={props.user ?? MockUser} onSignOut={props.onSignOut ?? jest.fn()} />)
9+
const trigger = await screen.findByTestId("user-dropdown-trigger")
10+
trigger.click()
11+
}
12+
13+
describe("UserDropdown", () => {
14+
describe("when the trigger is clicked", () => {
15+
it("opens the menu", async () => {
16+
await renderAndClick()
17+
expect(screen.getByText(Language.accountLabel)).toBeDefined()
18+
expect(screen.getByText(Language.docsLabel)).toBeDefined()
19+
expect(screen.getByText(Language.signOutLabel)).toBeDefined()
20+
})
21+
})
22+
23+
describe("when the menu is open", () => {
24+
describe("and sign out is clicked", () => {
25+
it("calls the onSignOut function", async () => {
26+
const onSignOut = jest.fn()
27+
await renderAndClick({ onSignOut })
28+
screen.getByText(Language.signOutLabel).click()
29+
expect(onSignOut).toBeCalledTimes(1)
30+
})
31+
})
32+
})
33+
34+
it("has the correct link for the documentation item", async () => {
35+
await renderAndClick()
36+
37+
const link = screen.getByText(Language.docsLabel).closest("a")
38+
if (!link) {
39+
throw new Error("Anchor tag not found for the documentation menu item")
40+
}
41+
42+
expect(link.getAttribute("href")).toBe("https://coder.com/docs")
43+
})
44+
45+
it("has the correct link for the account item", async () => {
46+
await renderAndClick()
47+
48+
const link = screen.getByText(Language.accountLabel).closest("a")
49+
if (!link) {
50+
throw new Error("Anchor tag not found for the account menu item")
51+
}
52+
53+
expect(link.getAttribute("href")).toBe("/preferences")
54+
})
55+
})

site/src/components/Navbar/UserDropdown.tsx

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,23 @@ import ListItemIcon from "@material-ui/core/ListItemIcon"
44
import ListItemText from "@material-ui/core/ListItemText"
55
import MenuItem from "@material-ui/core/MenuItem"
66
import { fade, makeStyles } from "@material-ui/core/styles"
7+
import AccountIcon from "@material-ui/icons/AccountCircleOutlined"
78
import KeyboardArrowDown from "@material-ui/icons/KeyboardArrowDown"
89
import KeyboardArrowUp from "@material-ui/icons/KeyboardArrowUp"
910
import React, { useState } from "react"
11+
import { Link } from "react-router-dom"
1012
import { UserResponse } from "../../api/types"
1113
import { LogoutIcon } from "../Icons"
14+
import { DocsIcon } from "../Icons/DocsIcon"
1215
import { UserAvatar } from "../User"
1316
import { UserProfileCard } from "../User/UserProfileCard"
1417
import { BorderedMenu } from "./BorderedMenu"
1518

19+
export const Language = {
20+
accountLabel: "Account",
21+
docsLabel: "Documentation",
22+
signOutLabel: "Sign Out",
23+
}
1624
export interface UserDropdownProps {
1725
user: UserResponse
1826
onSignOut: () => void
@@ -32,7 +40,7 @@ export const UserDropdown: React.FC<UserDropdownProps> = ({ user, onSignOut }: U
3240
return (
3341
<>
3442
<div>
35-
<MenuItem onClick={handleDropdownClick}>
43+
<MenuItem onClick={handleDropdownClick} data-testid="user-dropdown-trigger">
3644
<div className={styles.inner}>
3745
<Badge overlap="circle">
3846
<UserAvatar username={user.username} />
@@ -65,13 +73,31 @@ export const UserDropdown: React.FC<UserDropdownProps> = ({ user, onSignOut }: U
6573
<div className={styles.userInfo}>
6674
<UserProfileCard user={user} />
6775

68-
<Divider className={styles.divider} />
76+
<Divider />
77+
78+
<Link to="/preferences" className={styles.link}>
79+
<MenuItem className={styles.menuItem} onClick={handleDropdownClick}>
80+
<ListItemIcon className={styles.icon}>
81+
<AccountIcon />
82+
</ListItemIcon>
83+
<ListItemText primary={Language.accountLabel} />
84+
</MenuItem>
85+
</Link>
86+
87+
<a href="https://coder.com/docs" target="_blank" rel="noreferrer" className={styles.link}>
88+
<MenuItem className={styles.menuItem} onClick={handleDropdownClick}>
89+
<ListItemIcon className={styles.icon}>
90+
<DocsIcon />
91+
</ListItemIcon>
92+
<ListItemText primary={Language.docsLabel} />
93+
</MenuItem>
94+
</a>
6995

7096
<MenuItem className={styles.menuItem} onClick={onSignOut}>
7197
<ListItemIcon className={styles.icon}>
7298
<LogoutIcon />
7399
</ListItemIcon>
74-
<ListItemText primary="Sign Out" />
100+
<ListItemText primary={Language.signOutLabel} />
75101
</MenuItem>
76102
</div>
77103
</BorderedMenu>
@@ -84,6 +110,7 @@ export const useStyles = makeStyles((theme) => ({
84110
marginTop: theme.spacing(1),
85111
marginBottom: theme.spacing(1),
86112
},
113+
87114
inner: {
88115
display: "flex",
89116
alignItems: "center",
@@ -94,12 +121,14 @@ export const useStyles = makeStyles((theme) => ({
94121
userInfo: {
95122
marginBottom: theme.spacing(1),
96123
},
124+
97125
arrowIcon: {
98126
color: fade(theme.palette.primary.contrastText, 0.7),
99127
marginLeft: theme.spacing(1),
100128
width: 16,
101129
height: 16,
102130
},
131+
103132
arrowIconUp: {
104133
color: theme.palette.primary.contrastText,
105134
},
@@ -114,6 +143,11 @@ export const useStyles = makeStyles((theme) => ({
114143
},
115144
},
116145

146+
link: {
147+
textDecoration: "none",
148+
color: "inherit",
149+
},
150+
117151
icon: {
118152
color: theme.palette.text.secondary,
119153
},

site/src/pages/preferences/index.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import Box from "@material-ui/core/Box"
2+
import Paper from "@material-ui/core/Paper"
3+
import React from "react"
4+
import { Header } from "../../components/Header"
5+
import { Footer } from "../../components/Page"
6+
7+
export const PreferencesPage: React.FC = () => {
8+
return (
9+
<Box display="flex" flexDirection="column">
10+
<Header title="Preferences" />
11+
<Paper style={{ maxWidth: "1380px", margin: "1em auto", width: "100%" }}>Preferences here!</Paper>
12+
<Footer />
13+
</Box>
14+
)
15+
}

0 commit comments

Comments
 (0)