From 6560f2e340de350983fe732473d13e6d58b1a3a7 Mon Sep 17 00:00:00 2001 From: G r e y Date: Wed, 23 Mar 2022 10:28:34 -0400 Subject: [PATCH] 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. --- site/src/api/types.ts | 7 ++ site/src/components/Navbar/UserDropdown.tsx | 2 +- .../Table/Cells/UserCell.stories.tsx | 33 ++++++++ .../components/Table/Cells/UserCell.test.tsx | 83 +++++++++++++++++++ site/src/components/Table/Cells/UserCell.tsx | 64 ++++++++++++++ site/src/components/User/UserAvatar.tsx | 21 +---- site/src/components/User/UserProfileCard.tsx | 2 +- site/src/test_helpers/entities.ts | 9 +- site/src/util/first-letter.test.ts | 11 +++ site/src/util/first-letter.ts | 10 +++ 10 files changed, 222 insertions(+), 20 deletions(-) create mode 100644 site/src/components/Table/Cells/UserCell.stories.tsx create mode 100644 site/src/components/Table/Cells/UserCell.test.tsx create mode 100644 site/src/components/Table/Cells/UserCell.tsx create mode 100644 site/src/util/first-letter.test.ts create mode 100644 site/src/util/first-letter.ts diff --git a/site/src/api/types.ts b/site/src/api/types.ts index 376ffbc776..0b700f0c45 100644 --- a/site/src/api/types.ts +++ b/site/src/api/types.ts @@ -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 +} diff --git a/site/src/components/Navbar/UserDropdown.tsx b/site/src/components/Navbar/UserDropdown.tsx index cfd100cd36..4a5f741ab0 100644 --- a/site/src/components/Navbar/UserDropdown.tsx +++ b/site/src/components/Navbar/UserDropdown.tsx @@ -36,7 +36,7 @@ export const UserDropdown: React.FC = ({ user, onSignOut }: U
- + {anchorEl ? ( diff --git a/site/src/components/Table/Cells/UserCell.stories.tsx b/site/src/components/Table/Cells/UserCell.stories.tsx new file mode 100644 index 0000000000..b9c02e8fa3 --- /dev/null +++ b/site/src/components/Table/Cells/UserCell.stories.tsx @@ -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 + +const Template: Story = (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, +} diff --git a/site/src/components/Table/Cells/UserCell.test.tsx b/site/src/components/Table/Cells/UserCell.test.tsx new file mode 100644 index 0000000000..33b493bbc4 --- /dev/null +++ b/site/src/components/Table/Cells/UserCell.test.tsx @@ -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 = (props) => ( + + + + ) +} + +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() + 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() + 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() + 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() + + // Then + expect(screen.getByText(caption)).toBeDefined() + }) +}) diff --git a/site/src/components/Table/Cells/UserCell.tsx b/site/src/components/Table/Cells/UserCell.tsx new file mode 100644 index 0000000000..a9ea47ef66 --- /dev/null +++ b/site/src/components/Table/Cells/UserCell.tsx @@ -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 = ({ Avatar, caption, primaryText, onPrimaryTextSelect }) => { + const styles = useStyles() + + return ( + + + + + + + {onPrimaryTextSelect ? ( + + {primaryText} + + ) : ( + {primaryText} + )} + + {caption && ( + + {caption} + + )} + + + ) +} diff --git a/site/src/components/User/UserAvatar.tsx b/site/src/components/User/UserAvatar.tsx index 1207071790..69c5e39464 100644 --- a/site/src/components/User/UserAvatar.tsx +++ b/site/src/components/User/UserAvatar.tsx @@ -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 = ({ user, className }) => { - return {firstLetter(user.username)} -} - -/** - * `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 = ({ username, className }) => { + return {firstLetter(username)} } diff --git a/site/src/components/User/UserProfileCard.tsx b/site/src/components/User/UserProfileCard.tsx index 882bea250c..9b987cd784 100644 --- a/site/src/components/User/UserProfileCard.tsx +++ b/site/src/components/User/UserProfileCard.tsx @@ -15,7 +15,7 @@ export const UserProfileCard: React.FC = ({ user }) => { return (
- +
{user.username} {user.email} diff --git a/site/src/test_helpers/entities.ts b/site/src/test_helpers/entities.ts index fdd0b756da..f00d9441f4 100644 --- a/site/src/test_helpers/entities.ts +++ b/site/src/test_helpers/entities.ts @@ -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", +} diff --git a/site/src/util/first-letter.test.ts b/site/src/util/first-letter.test.ts new file mode 100644 index 0000000000..e6ce094936 --- /dev/null +++ b/site/src/util/first-letter.test.ts @@ -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) + }) +}) diff --git a/site/src/util/first-letter.ts b/site/src/util/first-letter.ts new file mode 100644 index 0000000000..7402615915 --- /dev/null +++ b/site/src/util/first-letter.ts @@ -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 "" +}