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

Skip to content

refactor(site): generalize UserCell component #484

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Mar 23, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@presleyp - do you like this organization (Table > Cells) ? Another possibility is a new directory "TableCells". I could go either way, just wanna find the one we all think is best.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah this seems good! Having a manageable number of top level categories will be nice.

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"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome!


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} />
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great choice

</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: "project-id",
owner_id: "test-user-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 ""
}