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 https://github.com/coder/coder/issues/472, but does not finish it.
- This commit should not merge until after https://github.com/coder/coder/pull/465 and https://github.com/coder/coder/pull/483 because it's
based on them.
This commit is contained in:
G r e y 2022-03-23 10:28:34 -04:00 committed by GitHub
parent 038dd54ab3
commit 6560f2e340
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 222 additions and 20 deletions

View File

@ -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
}

View File

@ -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}`} />

View File

@ -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,
}

View File

@ -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()
})
})

View File

@ -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>
)
}

View File

@ -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>
}

View File

@ -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>

View File

@ -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" }
@ -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",
}

View File

@ -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)
})
})

View File

@ -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 ""
}