diff --git a/site/src/api/types.ts b/site/src/api/types.ts index 376ffbc7761ef..0b700f0c459de 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 cfd100cd36965..4a5f741ab08c1 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 0000000000000..b9c02e8fa3850 --- /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 0000000000000..33b493bbc4747 --- /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 0000000000000..a9ea47ef66e4c --- /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 12070717908c9..69c5e39464edf 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 882bea250cf43..9b987cd7845c7 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 7633161b4a23a..be8df4487ccfc 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: "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", +} diff --git a/site/src/util/first-letter.test.ts b/site/src/util/first-letter.test.ts new file mode 100644 index 0000000000000..e6ce0949365ac --- /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 0000000000000..7402615915b41 --- /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 "" +}