mirror of https://github.com/coder/coder.git
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:
parent
038dd54ab3
commit
6560f2e340
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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}`} />
|
||||
|
|
|
@ -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,
|
||||
}
|
|
@ -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()
|
||||
})
|
||||
})
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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",
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
|
@ -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 ""
|
||||
}
|
Loading…
Reference in New Issue