From 7a6df4119ae7974a0f381fc89f67184a696b5645 Mon Sep 17 00:00:00 2001 From: G r e y Date: Fri, 18 Mar 2022 06:55:01 +0000 Subject: [PATCH 1/6] feat(site): add UserCell component Summary: This is a first step in porting over v1 AuditLog in a refactored/cleaned up fashion. This isn't a direct port, since we do not yet have a UserAvatar component. Details: - Port over UserCell from v1, sans UserAvatar impl - Add tests and stories for UserCell Notes: We do not have a holistic solution for handling localization, but starting from some kind of easy way that collects/resources strings will make the migration significantly easier. It will also help out our product copy owner, @khorne3 with maintenance. An RFC regarding this might be necessitated. Impact: This change does not have any user-facing impact yet, because the UserCell is not yet rendered in the product. This enables an incremental approach to migrating in the FE of the Audit Log, which is still waiting on the BE port. Relations: - This commit relates to #472, but does not finish it. - This commit should not merge until after #465 and #483 because it's based on them. --- site/src/api/types.ts | 11 ++- .../components/AuditLog/UserCell.stories.tsx | 22 ++++++ .../src/components/AuditLog/UserCell.test.tsx | 77 +++++++++++++++++++ site/src/components/AuditLog/UserCell.tsx | 59 ++++++++++++++ site/src/test_helpers/entities.ts | 9 ++- 5 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 site/src/components/AuditLog/UserCell.stories.tsx create mode 100644 site/src/components/AuditLog/UserCell.test.tsx create mode 100644 site/src/components/AuditLog/UserCell.tsx diff --git a/site/src/api/types.ts b/site/src/api/types.ts index 376ffbc7761ef..4f41f59b27652 100644 --- a/site/src/api/types.ts +++ b/site/src/api/types.ts @@ -1,7 +1,3 @@ -export interface LoginResponse { - session_token: string -} - export interface UserResponse { readonly id: string readonly username: string @@ -59,3 +55,10 @@ export interface Workspace { export interface APIKeyResponse { key: string } + +export interface UserAgent { + readonly ip_address: string + readonly os: string + readonly browser: string + readonly device: string +} diff --git a/site/src/components/AuditLog/UserCell.stories.tsx b/site/src/components/AuditLog/UserCell.stories.tsx new file mode 100644 index 0000000000000..5cb6a13f8bbac --- /dev/null +++ b/site/src/components/AuditLog/UserCell.stories.tsx @@ -0,0 +1,22 @@ +import { ComponentMeta, Story } from "@storybook/react" +import React from "react" +import { MockUser, MockUserAgent } from "../../test_helpers" +import { UserCell, UserCellProps } from "./UserCell" + +export default { + title: "AuditLog/Cells/UserCell", + component: UserCell, + argTypes: { + onSelectEmail: { + action: "onSelectEmail", + }, + }, +} as ComponentMeta + +const Template: Story = (args) => + +export const Example = Template.bind({}) +Example.args = { + user: MockUser, + userAgent: MockUserAgent, +} diff --git a/site/src/components/AuditLog/UserCell.test.tsx b/site/src/components/AuditLog/UserCell.test.tsx new file mode 100644 index 0000000000000..daa5141ad386c --- /dev/null +++ b/site/src/components/AuditLog/UserCell.test.tsx @@ -0,0 +1,77 @@ +import { MockUser, MockUserAgent, WrapperComponent } from "../../test_helpers" +import { LANGUAGE, UserCell, UserCellProps } from "./UserCell" +import React from "react" +import { fireEvent, render, screen } from "@testing-library/react" + +namespace Helpers { + export const Props: UserCellProps = { + onSelectEmail: jest.fn(), + user: MockUser, + userAgent: MockUserAgent, + } + + export const Component: React.FC = (props) => ( + + + + ) +} + +describe("UserCell", () => { + // callbacks + it("calls onUserClick when an email address is clicked", () => { + // Given + const onSelectEmailMock = jest.fn() + const props: UserCellProps = { + ...Helpers.Props, + onSelectEmail: onSelectEmailMock, + } + + // When - click the user's email address + render() + fireEvent.click(screen.getByText(props.user.email)) + + // Then - callback was fired once + expect(onSelectEmailMock).toHaveBeenCalledTimes(1) + }) + + // email address cases + it("renders an existing members' email address", () => { + // Given + const props: UserCellProps = Helpers.Props + + // When + render() + + // Then - email address is visible + expect(screen.getByText(props.user.email)).toBeDefined() + }) + it(`renders '${LANGUAGE.emptyUser}' for non-existing members`, () => { + // Given + const props: UserCellProps = { + ...Helpers.Props, + user: { + ...MockUser, + email: "", + }, + } + + // When + render() + + // Then - 'Deleted user' is visible + expect(screen.getByText(LANGUAGE.emptyUser)).toBeDefined() + }) + + // ip address + it("renders user agent IP address", () => { + // Given + const props: UserCellProps = Helpers.Props + + // When + render() + + // Then - ip address is visible + expect(screen.getByText(props.userAgent.ip_address)).toBeDefined() + }) +}) diff --git a/site/src/components/AuditLog/UserCell.tsx b/site/src/components/AuditLog/UserCell.tsx new file mode 100644 index 0000000000000..76e035d8a050f --- /dev/null +++ b/site/src/components/AuditLog/UserCell.tsx @@ -0,0 +1,59 @@ +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 { UserAgent, UserResponse } from "../../api/types" + +export const LANGUAGE = { + emptyUser: "Deleted user", +} + +export interface UserCellProps { + onSelectEmail: () => void + user: UserResponse + userAgent: UserAgent +} + +const useStyles = makeStyles((theme) => ({ + primaryText: { + color: theme.palette.text.primary, + fontSize: "16px", + lineHeight: "15px", + marginBottom: "5px", + + "&.MuiTypography-caption": { + cursor: "pointer", + }, + }, +})) + +export const UserCell: React.FC = ({ onSelectEmail, user, userAgent }) => { + const styles = useStyles() + + return ( + + {/* TODO - adjust margin */} + {/* */} + {/* TODO - implement UserAvatar */} + {/* */} + {/* */} + + + {user.email ? ( + + {user.email} + + ) : ( + + {LANGUAGE.emptyUser} + + )} + + + {userAgent.ip_address} + + + + ) +} 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", +} From 4fe14318fd556493f28d50a4824f775ea586f83b Mon Sep 17 00:00:00 2001 From: G r e y Date: Fri, 18 Mar 2022 21:49:52 +0000 Subject: [PATCH 2/6] fixup! feat(site): add UserCell component --- site/src/api/types.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/site/src/api/types.ts b/site/src/api/types.ts index 4f41f59b27652..2b25fd38c7c9d 100644 --- a/site/src/api/types.ts +++ b/site/src/api/types.ts @@ -1,3 +1,7 @@ +export interface LoginResponse { + session_token: string +} + export interface UserResponse { readonly id: string readonly username: string From f006d0b078281eb25d9a82c1f726ab4f04697359 Mon Sep 17 00:00:00 2001 From: G r e y Date: Fri, 18 Mar 2022 21:53:03 +0000 Subject: [PATCH 3/6] document code --- site/src/components/AuditLog/UserCell.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/site/src/components/AuditLog/UserCell.tsx b/site/src/components/AuditLog/UserCell.tsx index 76e035d8a050f..7fed43ff37075 100644 --- a/site/src/components/AuditLog/UserCell.tsx +++ b/site/src/components/AuditLog/UserCell.tsx @@ -28,6 +28,10 @@ const useStyles = makeStyles((theme) => ({ }, })) +/** + * UserCell is a single cell in an audit log table row that contains user-level + * information + */ export const UserCell: React.FC = ({ onSelectEmail, user, userAgent }) => { const styles = useStyles() From c0a73603da46ce51af2b0560da1ea4d7a98888ff Mon Sep 17 00:00:00 2001 From: G r e y Date: Tue, 22 Mar 2022 23:52:06 +0000 Subject: [PATCH 4/6] fixup! document code --- .../src/components/AuditLog/UserCell.test.tsx | 77 ----------------- site/src/components/AuditLog/UserCell.tsx | 63 -------------- site/src/components/Navbar/UserDropdown.tsx | 2 +- .../Cells}/UserCell.stories.tsx | 15 ++-- .../components/Table/Cells/UserCell.test.tsx | 83 +++++++++++++++++++ site/src/components/Table/Cells/UserCell.tsx | 65 +++++++++++++++ site/src/components/User/UserAvatar.tsx | 21 +---- site/src/components/User/UserProfileCard.tsx | 2 +- site/src/util/first-letter.test.ts | 11 +++ site/src/util/first-letter.ts | 10 +++ 10 files changed, 184 insertions(+), 165 deletions(-) delete mode 100644 site/src/components/AuditLog/UserCell.test.tsx delete mode 100644 site/src/components/AuditLog/UserCell.tsx rename site/src/components/{AuditLog => Table/Cells}/UserCell.stories.tsx (57%) 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/components/AuditLog/UserCell.test.tsx b/site/src/components/AuditLog/UserCell.test.tsx deleted file mode 100644 index daa5141ad386c..0000000000000 --- a/site/src/components/AuditLog/UserCell.test.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { MockUser, MockUserAgent, WrapperComponent } from "../../test_helpers" -import { LANGUAGE, UserCell, UserCellProps } from "./UserCell" -import React from "react" -import { fireEvent, render, screen } from "@testing-library/react" - -namespace Helpers { - export const Props: UserCellProps = { - onSelectEmail: jest.fn(), - user: MockUser, - userAgent: MockUserAgent, - } - - export const Component: React.FC = (props) => ( - - - - ) -} - -describe("UserCell", () => { - // callbacks - it("calls onUserClick when an email address is clicked", () => { - // Given - const onSelectEmailMock = jest.fn() - const props: UserCellProps = { - ...Helpers.Props, - onSelectEmail: onSelectEmailMock, - } - - // When - click the user's email address - render() - fireEvent.click(screen.getByText(props.user.email)) - - // Then - callback was fired once - expect(onSelectEmailMock).toHaveBeenCalledTimes(1) - }) - - // email address cases - it("renders an existing members' email address", () => { - // Given - const props: UserCellProps = Helpers.Props - - // When - render() - - // Then - email address is visible - expect(screen.getByText(props.user.email)).toBeDefined() - }) - it(`renders '${LANGUAGE.emptyUser}' for non-existing members`, () => { - // Given - const props: UserCellProps = { - ...Helpers.Props, - user: { - ...MockUser, - email: "", - }, - } - - // When - render() - - // Then - 'Deleted user' is visible - expect(screen.getByText(LANGUAGE.emptyUser)).toBeDefined() - }) - - // ip address - it("renders user agent IP address", () => { - // Given - const props: UserCellProps = Helpers.Props - - // When - render() - - // Then - ip address is visible - expect(screen.getByText(props.userAgent.ip_address)).toBeDefined() - }) -}) diff --git a/site/src/components/AuditLog/UserCell.tsx b/site/src/components/AuditLog/UserCell.tsx deleted file mode 100644 index 7fed43ff37075..0000000000000 --- a/site/src/components/AuditLog/UserCell.tsx +++ /dev/null @@ -1,63 +0,0 @@ -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 { UserAgent, UserResponse } from "../../api/types" - -export const LANGUAGE = { - emptyUser: "Deleted user", -} - -export interface UserCellProps { - onSelectEmail: () => void - user: UserResponse - userAgent: UserAgent -} - -const useStyles = makeStyles((theme) => ({ - primaryText: { - color: theme.palette.text.primary, - fontSize: "16px", - lineHeight: "15px", - marginBottom: "5px", - - "&.MuiTypography-caption": { - cursor: "pointer", - }, - }, -})) - -/** - * UserCell is a single cell in an audit log table row that contains user-level - * information - */ -export const UserCell: React.FC = ({ onSelectEmail, user, userAgent }) => { - const styles = useStyles() - - return ( - - {/* TODO - adjust margin */} - {/* */} - {/* TODO - implement UserAvatar */} - {/* */} - {/* */} - - - {user.email ? ( - - {user.email} - - ) : ( - - {LANGUAGE.emptyUser} - - )} - - - {userAgent.ip_address} - - - - ) -} 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/AuditLog/UserCell.stories.tsx b/site/src/components/Table/Cells/UserCell.stories.tsx similarity index 57% rename from site/src/components/AuditLog/UserCell.stories.tsx rename to site/src/components/Table/Cells/UserCell.stories.tsx index 5cb6a13f8bbac..456f60eb18acd 100644 --- a/site/src/components/AuditLog/UserCell.stories.tsx +++ b/site/src/components/Table/Cells/UserCell.stories.tsx @@ -1,14 +1,14 @@ import { ComponentMeta, Story } from "@storybook/react" import React from "react" -import { MockUser, MockUserAgent } from "../../test_helpers" +import { MockUser, MockUserAgent } from "../../../test_helpers" import { UserCell, UserCellProps } from "./UserCell" export default { - title: "AuditLog/Cells/UserCell", + title: "Table/Cells/UserCell", component: UserCell, argTypes: { - onSelectEmail: { - action: "onSelectEmail", + onPrimaryTextSelect: { + action: "onPrimaryTextSelect", }, }, } as ComponentMeta @@ -17,6 +17,9 @@ const Template: Story = (args) => export const Example = Template.bind({}) Example.args = { - user: MockUser, - userAgent: MockUserAgent, + Avatar: { + username: MockUser.username, + }, + caption: MockUserAgent.ip_address, + primaryText: MockUser.email, } 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..b48310510c40b --- /dev/null +++ b/site/src/components/Table/Cells/UserCell.tsx @@ -0,0 +1,65 @@ +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, + 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/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 "" +} From d05c7e3df79ca3ae3b9a40277552568ec5faa769 Mon Sep 17 00:00:00 2001 From: G r e y Date: Tue, 22 Mar 2022 23:55:57 +0000 Subject: [PATCH 5/6] reorder type --- site/src/api/types.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/src/api/types.ts b/site/src/api/types.ts index 2b25fd38c7c9d..0b700f0c459de 100644 --- a/site/src/api/types.ts +++ b/site/src/api/types.ts @@ -61,8 +61,8 @@ export interface APIKeyResponse { } export interface UserAgent { - readonly ip_address: string - readonly os: string readonly browser: string readonly device: string + readonly ip_address: string + readonly os: string } From ec4ac6d82c56748a1fc887baf6651a29bdd4379d Mon Sep 17 00:00:00 2001 From: G r e y Date: Wed, 23 Mar 2022 00:04:01 +0000 Subject: [PATCH 6/6] fixup! reorder type --- .../Table/Cells/UserCell.stories.tsx | 22 +++++++++++++------ site/src/components/Table/Cells/UserCell.tsx | 5 ++--- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/site/src/components/Table/Cells/UserCell.stories.tsx b/site/src/components/Table/Cells/UserCell.stories.tsx index 456f60eb18acd..b9c02e8fa3850 100644 --- a/site/src/components/Table/Cells/UserCell.stories.tsx +++ b/site/src/components/Table/Cells/UserCell.stories.tsx @@ -6,20 +6,28 @@ import { UserCell, UserCellProps } from "./UserCell" export default { title: "Table/Cells/UserCell", component: UserCell, - argTypes: { - onPrimaryTextSelect: { - action: "onPrimaryTextSelect", - }, - }, } as ComponentMeta const Template: Story = (args) => -export const Example = Template.bind({}) -Example.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.tsx b/site/src/components/Table/Cells/UserCell.tsx index b48310510c40b..a9ea47ef66e4c 100644 --- a/site/src/components/Table/Cells/UserCell.tsx +++ b/site/src/components/Table/Cells/UserCell.tsx @@ -24,6 +24,7 @@ export interface UserCellProps { const useStyles = makeStyles((theme) => ({ primaryText: { color: theme.palette.text.primary, + fontFamily: theme.typography.fontFamily, fontSize: "16px", lineHeight: "15px", marginBottom: "5px", @@ -49,9 +50,7 @@ export const UserCell: React.FC = ({ Avatar, caption, primaryText {primaryText} ) : ( - - {primaryText} - + {primaryText} )} {caption && (