Skip to content

Commit

Permalink
refactor(site): generalize UserCell component (#484)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
G r e y committed Mar 23, 2022
1 parent 038dd54 commit 6560f2e
Show file tree
Hide file tree
Showing 10 changed files with 222 additions and 20 deletions.
7 changes: 7 additions & 0 deletions site/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,10 @@ export interface Workspace {
export interface APIKeyResponse {
key: string
}

export interface UserAgent {
readonly browser: string
readonly device: string
readonly ip_address: string
readonly os: string
}
2 changes: 1 addition & 1 deletion site/src/components/Navbar/UserDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export const UserDropdown: React.FC<UserDropdownProps> = ({ user, onSignOut }: U
<MenuItem onClick={handleDropdownClick}>
<div className={styles.inner}>
<Badge overlap="circle">
<UserAvatar user={user} />
<UserAvatar username={user.username} />
</Badge>
{anchorEl ? (
<KeyboardArrowUp className={`${styles.arrowIcon} ${styles.arrowIconUp}`} />
Expand Down
33 changes: 33 additions & 0 deletions site/src/components/Table/Cells/UserCell.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { ComponentMeta, Story } from "@storybook/react"
import React from "react"
import { MockUser, MockUserAgent } from "../../../test_helpers"
import { UserCell, UserCellProps } from "./UserCell"

export default {
title: "Table/Cells/UserCell",
component: UserCell,
} as ComponentMeta<typeof UserCell>

const Template: Story<UserCellProps> = (args) => <UserCell {...args} />

export const AuditLogExample = Template.bind({})
AuditLogExample.args = {
Avatar: {
username: MockUser.username,
},
caption: MockUserAgent.ip_address,
primaryText: MockUser.email,
onPrimaryTextSelect: () => {
return
},
}

export const AuditLogEmptyUserExample = Template.bind({})
AuditLogEmptyUserExample.args = {
Avatar: {
username: MockUser.username,
},
caption: MockUserAgent.ip_address,
primaryText: "Deleted User",
onPrimaryTextSelect: undefined,
}
83 changes: 83 additions & 0 deletions site/src/components/Table/Cells/UserCell.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { MockUser, MockUserAgent, WrapperComponent } from "../../../test_helpers"
import { UserCell, UserCellProps } from "./UserCell"
import React from "react"
import { fireEvent, render, screen } from "@testing-library/react"

namespace Helpers {
export const Props: UserCellProps = {
Avatar: {
username: MockUser.username,
},
caption: MockUserAgent.ip_address,
primaryText: MockUser.username,
onPrimaryTextSelect: jest.fn(),
}

export const Component: React.FC<UserCellProps> = (props) => (
<WrapperComponent>
<UserCell {...props} />
</WrapperComponent>
)
}

describe("UserCell", () => {
// callbacks
it("calls onPrimaryTextSelect when primaryText is clicked", () => {
// Given
const onPrimaryTextSelectMock = jest.fn()
const props: UserCellProps = {
...Helpers.Props,
onPrimaryTextSelect: onPrimaryTextSelectMock,
}

// When - click the user's email address
render(<Helpers.Component {...props} />)
fireEvent.click(screen.getByText(props.primaryText))

// Then - callback was fired once
expect(onPrimaryTextSelectMock).toHaveBeenCalledTimes(1)
})

// primaryText
it("renders primaryText as a link when onPrimaryTextSelect is defined", () => {
// Given
const props: UserCellProps = Helpers.Props

// When
render(<Helpers.Component {...props} />)
const primaryTextNode = screen.getByText(props.primaryText)

// Then
expect(primaryTextNode.tagName).toBe("A")
})
it("renders primaryText without a link when onPrimaryTextSelect is undefined", () => {
// Given
const props: UserCellProps = {
...Helpers.Props,
onPrimaryTextSelect: undefined,
}

// When
render(<Helpers.Component {...props} />)
const primaryTextNode = screen.getByText(props.primaryText)

// Then
expect(primaryTextNode.tagName).toBe("P")
})

// caption
it("renders caption", () => {
// Given
const caption = "definitely a caption"
const props: UserCellProps = {
...Helpers.Props,
caption,
}

// When
render(<Helpers.Component {...props} />)

// Then
expect(screen.getByText(caption)).toBeDefined()
})
})
64 changes: 64 additions & 0 deletions site/src/components/Table/Cells/UserCell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import Box from "@material-ui/core/Box"
import Link from "@material-ui/core/Link"
import { makeStyles } from "@material-ui/core/styles"
import Typography from "@material-ui/core/Typography"
import React from "react"
import { UserAvatar, UserAvatarProps } from "../../User"

export interface UserCellProps {
Avatar: UserAvatarProps
/**
* primaryText is rendered beside the avatar
*/
primaryText: string /* | React.ReactNode <-- if needed */
/**
* caption is rendered beneath the avatar and primaryText
*/
caption?: string /* | React.ReactNode <-- if needed */
/**
* onPrimaryTextSelect, if defined, is called when the primaryText is clicked
*/
onPrimaryTextSelect?: () => void
}

const useStyles = makeStyles((theme) => ({
primaryText: {
color: theme.palette.text.primary,
fontFamily: theme.typography.fontFamily,
fontSize: "16px",
lineHeight: "15px",
marginBottom: "5px",
},
}))

/**
* UserCell is a single cell in an audit log table row that contains user-level
* information
*/
export const UserCell: React.FC<UserCellProps> = ({ Avatar, caption, primaryText, onPrimaryTextSelect }) => {
const styles = useStyles()

return (
<Box alignItems="center" display="flex" flexDirection="row">
<Box display="flex" margin="auto 14px auto 0">
<UserAvatar {...Avatar} />
</Box>

<Box display="flex" flexDirection="column">
{onPrimaryTextSelect ? (
<Link className={styles.primaryText} onClick={onPrimaryTextSelect}>
{primaryText}
</Link>
) : (
<Typography className={styles.primaryText}>{primaryText}</Typography>
)}

{caption && (
<Typography color="textSecondary" variant="caption">
{caption}
</Typography>
)}
</Box>
</Box>
)
}
21 changes: 4 additions & 17 deletions site/src/components/User/UserAvatar.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,12 @@
import Avatar from "@material-ui/core/Avatar"
import React from "react"
import { UserResponse } from "../../api/types"
import { firstLetter } from "../../util/first-letter"

export interface UserAvatarProps {
user: UserResponse
className?: string
username: string
}

export const UserAvatar: React.FC<UserAvatarProps> = ({ user, className }) => {
return <Avatar className={className}>{firstLetter(user.username)}</Avatar>
}

/**
* `firstLetter` extracts the first character and returns it, uppercased
*
* If the string is empty or null, returns an empty string
*/
export const firstLetter = (str: string): string => {
if (str && str.length > 0) {
return str[0].toLocaleUpperCase()
}

return ""
export const UserAvatar: React.FC<UserAvatarProps> = ({ username, className }) => {
return <Avatar className={className}>{firstLetter(username)}</Avatar>
}
2 changes: 1 addition & 1 deletion site/src/components/User/UserProfileCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export const UserProfileCard: React.FC<UserProfileCardProps> = ({ user }) => {
return (
<div className={styles.root}>
<div className={styles.avatarContainer}>
<UserAvatar className={styles.avatar} user={user} />
<UserAvatar className={styles.avatar} username={user.username} />
</div>
<Typography className={styles.userName}>{user.username}</Typography>
<Typography className={styles.userEmail}>{user.email}</Typography>
Expand Down
9 changes: 8 additions & 1 deletion site/src/test_helpers/entities.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Provisioner, Organization, Project, Workspace, UserResponse } from "../api/types"
import { Provisioner, Organization, Project, Workspace, UserResponse, UserAgent } from "../api/types"

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

Expand Down Expand Up @@ -41,3 +41,10 @@ export const MockWorkspace: Workspace = {
project_id: MockProject.id,
owner_id: MockUser.id,
}

export const MockUserAgent: UserAgent = {
browser: "Chrome 99.0.4844",
device: "Other",
ip_address: "11.22.33.44",
os: "Windows 10",
}
11 changes: 11 additions & 0 deletions site/src/util/first-letter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { firstLetter } from "./first-letter"

describe("first-letter", () => {
it.each<[string, string]>([
["", ""],
["User", "U"],
["test", "T"],
])(`firstLetter(%p) returns %p`, (input, expected) => {
expect(firstLetter(input)).toBe(expected)
})
})
10 changes: 10 additions & 0 deletions site/src/util/first-letter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* firstLetter extracts the first character and returns it, uppercased.
*/
export const firstLetter = (str: string): string => {
if (str.length > 0) {
return str[0].toLocaleUpperCase()
}

return ""
}

0 comments on commit 6560f2e

Please sign in to comment.