Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 6560f2e

Browse files
author
G r e y
authored
refactor(site): generalize UserCell component (#484)
Summary: This is a first step in porting over v1 AuditLog in a refactored/cleaned up fashion. The existing `UserCell` component was generalized for re-use across various tables (AuditLog, Users, Orgs). Details: - Move UserCell to `components/Table/Cells` - Add tests and stories for UserCell Impact: This unblocks future work in list views like the audit log, user management panel and organizations management panel. Relations: - This commit relates to #472, but does not finish it. - This commit should not merge until after #465 and #483 because it's based on them.
1 parent 038dd54 commit 6560f2e

File tree

10 files changed

+222
-20
lines changed

10 files changed

+222
-20
lines changed

site/src/api/types.ts

+7
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,10 @@ export interface Workspace {
5959
export interface APIKeyResponse {
6060
key: string
6161
}
62+
63+
export interface UserAgent {
64+
readonly browser: string
65+
readonly device: string
66+
readonly ip_address: string
67+
readonly os: string
68+
}

site/src/components/Navbar/UserDropdown.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export const UserDropdown: React.FC<UserDropdownProps> = ({ user, onSignOut }: U
3636
<MenuItem onClick={handleDropdownClick}>
3737
<div className={styles.inner}>
3838
<Badge overlap="circle">
39-
<UserAvatar user={user} />
39+
<UserAvatar username={user.username} />
4040
</Badge>
4141
{anchorEl ? (
4242
<KeyboardArrowUp className={`${styles.arrowIcon} ${styles.arrowIconUp}`} />
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { ComponentMeta, Story } from "@storybook/react"
2+
import React from "react"
3+
import { MockUser, MockUserAgent } from "../../../test_helpers"
4+
import { UserCell, UserCellProps } from "./UserCell"
5+
6+
export default {
7+
title: "Table/Cells/UserCell",
8+
component: UserCell,
9+
} as ComponentMeta<typeof UserCell>
10+
11+
const Template: Story<UserCellProps> = (args) => <UserCell {...args} />
12+
13+
export const AuditLogExample = Template.bind({})
14+
AuditLogExample.args = {
15+
Avatar: {
16+
username: MockUser.username,
17+
},
18+
caption: MockUserAgent.ip_address,
19+
primaryText: MockUser.email,
20+
onPrimaryTextSelect: () => {
21+
return
22+
},
23+
}
24+
25+
export const AuditLogEmptyUserExample = Template.bind({})
26+
AuditLogEmptyUserExample.args = {
27+
Avatar: {
28+
username: MockUser.username,
29+
},
30+
caption: MockUserAgent.ip_address,
31+
primaryText: "Deleted User",
32+
onPrimaryTextSelect: undefined,
33+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { MockUser, MockUserAgent, WrapperComponent } from "../../../test_helpers"
2+
import { UserCell, UserCellProps } from "./UserCell"
3+
import React from "react"
4+
import { fireEvent, render, screen } from "@testing-library/react"
5+
6+
namespace Helpers {
7+
export const Props: UserCellProps = {
8+
Avatar: {
9+
username: MockUser.username,
10+
},
11+
caption: MockUserAgent.ip_address,
12+
primaryText: MockUser.username,
13+
onPrimaryTextSelect: jest.fn(),
14+
}
15+
16+
export const Component: React.FC<UserCellProps> = (props) => (
17+
<WrapperComponent>
18+
<UserCell {...props} />
19+
</WrapperComponent>
20+
)
21+
}
22+
23+
describe("UserCell", () => {
24+
// callbacks
25+
it("calls onPrimaryTextSelect when primaryText is clicked", () => {
26+
// Given
27+
const onPrimaryTextSelectMock = jest.fn()
28+
const props: UserCellProps = {
29+
...Helpers.Props,
30+
onPrimaryTextSelect: onPrimaryTextSelectMock,
31+
}
32+
33+
// When - click the user's email address
34+
render(<Helpers.Component {...props} />)
35+
fireEvent.click(screen.getByText(props.primaryText))
36+
37+
// Then - callback was fired once
38+
expect(onPrimaryTextSelectMock).toHaveBeenCalledTimes(1)
39+
})
40+
41+
// primaryText
42+
it("renders primaryText as a link when onPrimaryTextSelect is defined", () => {
43+
// Given
44+
const props: UserCellProps = Helpers.Props
45+
46+
// When
47+
render(<Helpers.Component {...props} />)
48+
const primaryTextNode = screen.getByText(props.primaryText)
49+
50+
// Then
51+
expect(primaryTextNode.tagName).toBe("A")
52+
})
53+
it("renders primaryText without a link when onPrimaryTextSelect is undefined", () => {
54+
// Given
55+
const props: UserCellProps = {
56+
...Helpers.Props,
57+
onPrimaryTextSelect: undefined,
58+
}
59+
60+
// When
61+
render(<Helpers.Component {...props} />)
62+
const primaryTextNode = screen.getByText(props.primaryText)
63+
64+
// Then
65+
expect(primaryTextNode.tagName).toBe("P")
66+
})
67+
68+
// caption
69+
it("renders caption", () => {
70+
// Given
71+
const caption = "definitely a caption"
72+
const props: UserCellProps = {
73+
...Helpers.Props,
74+
caption,
75+
}
76+
77+
// When
78+
render(<Helpers.Component {...props} />)
79+
80+
// Then
81+
expect(screen.getByText(caption)).toBeDefined()
82+
})
83+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import Box from "@material-ui/core/Box"
2+
import Link from "@material-ui/core/Link"
3+
import { makeStyles } from "@material-ui/core/styles"
4+
import Typography from "@material-ui/core/Typography"
5+
import React from "react"
6+
import { UserAvatar, UserAvatarProps } from "../../User"
7+
8+
export interface UserCellProps {
9+
Avatar: UserAvatarProps
10+
/**
11+
* primaryText is rendered beside the avatar
12+
*/
13+
primaryText: string /* | React.ReactNode <-- if needed */
14+
/**
15+
* caption is rendered beneath the avatar and primaryText
16+
*/
17+
caption?: string /* | React.ReactNode <-- if needed */
18+
/**
19+
* onPrimaryTextSelect, if defined, is called when the primaryText is clicked
20+
*/
21+
onPrimaryTextSelect?: () => void
22+
}
23+
24+
const useStyles = makeStyles((theme) => ({
25+
primaryText: {
26+
color: theme.palette.text.primary,
27+
fontFamily: theme.typography.fontFamily,
28+
fontSize: "16px",
29+
lineHeight: "15px",
30+
marginBottom: "5px",
31+
},
32+
}))
33+
34+
/**
35+
* UserCell is a single cell in an audit log table row that contains user-level
36+
* information
37+
*/
38+
export const UserCell: React.FC<UserCellProps> = ({ Avatar, caption, primaryText, onPrimaryTextSelect }) => {
39+
const styles = useStyles()
40+
41+
return (
42+
<Box alignItems="center" display="flex" flexDirection="row">
43+
<Box display="flex" margin="auto 14px auto 0">
44+
<UserAvatar {...Avatar} />
45+
</Box>
46+
47+
<Box display="flex" flexDirection="column">
48+
{onPrimaryTextSelect ? (
49+
<Link className={styles.primaryText} onClick={onPrimaryTextSelect}>
50+
{primaryText}
51+
</Link>
52+
) : (
53+
<Typography className={styles.primaryText}>{primaryText}</Typography>
54+
)}
55+
56+
{caption && (
57+
<Typography color="textSecondary" variant="caption">
58+
{caption}
59+
</Typography>
60+
)}
61+
</Box>
62+
</Box>
63+
)
64+
}
+4-17
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,12 @@
11
import Avatar from "@material-ui/core/Avatar"
22
import React from "react"
3-
import { UserResponse } from "../../api/types"
3+
import { firstLetter } from "../../util/first-letter"
44

55
export interface UserAvatarProps {
6-
user: UserResponse
76
className?: string
7+
username: string
88
}
99

10-
export const UserAvatar: React.FC<UserAvatarProps> = ({ user, className }) => {
11-
return <Avatar className={className}>{firstLetter(user.username)}</Avatar>
12-
}
13-
14-
/**
15-
* `firstLetter` extracts the first character and returns it, uppercased
16-
*
17-
* If the string is empty or null, returns an empty string
18-
*/
19-
export const firstLetter = (str: string): string => {
20-
if (str && str.length > 0) {
21-
return str[0].toLocaleUpperCase()
22-
}
23-
24-
return ""
10+
export const UserAvatar: React.FC<UserAvatarProps> = ({ username, className }) => {
11+
return <Avatar className={className}>{firstLetter(username)}</Avatar>
2512
}

site/src/components/User/UserProfileCard.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export const UserProfileCard: React.FC<UserProfileCardProps> = ({ user }) => {
1515
return (
1616
<div className={styles.root}>
1717
<div className={styles.avatarContainer}>
18-
<UserAvatar className={styles.avatar} user={user} />
18+
<UserAvatar className={styles.avatar} username={user.username} />
1919
</div>
2020
<Typography className={styles.userName}>{user.username}</Typography>
2121
<Typography className={styles.userEmail}>{user.email}</Typography>

site/src/test_helpers/entities.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Provisioner, Organization, Project, Workspace, UserResponse } from "../api/types"
1+
import { Provisioner, Organization, Project, Workspace, UserResponse, UserAgent } from "../api/types"
22

33
export const MockSessionToken = { session_token: "my-session-token" }
44

@@ -41,3 +41,10 @@ export const MockWorkspace: Workspace = {
4141
project_id: MockProject.id,
4242
owner_id: MockUser.id,
4343
}
44+
45+
export const MockUserAgent: UserAgent = {
46+
browser: "Chrome 99.0.4844",
47+
device: "Other",
48+
ip_address: "11.22.33.44",
49+
os: "Windows 10",
50+
}

site/src/util/first-letter.test.ts

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { firstLetter } from "./first-letter"
2+
3+
describe("first-letter", () => {
4+
it.each<[string, string]>([
5+
["", ""],
6+
["User", "U"],
7+
["test", "T"],
8+
])(`firstLetter(%p) returns %p`, (input, expected) => {
9+
expect(firstLetter(input)).toBe(expected)
10+
})
11+
})

site/src/util/first-letter.ts

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* firstLetter extracts the first character and returns it, uppercased.
3+
*/
4+
export const firstLetter = (str: string): string => {
5+
if (str.length > 0) {
6+
return str[0].toLocaleUpperCase()
7+
}
8+
9+
return ""
10+
}

0 commit comments

Comments
 (0)