From 80cb7ceb889854d65ba5467f8060cfaa8a992c0c Mon Sep 17 00:00:00 2001 From: jb-twilio <109178184+jb-twilio@users.noreply.github.com> Date: Thu, 17 Nov 2022 13:34:58 -0500 Subject: [PATCH] feat: add useChatLogger hook (#2793) * feat: add useChatLogger hook * chore: adjust Chat type annotation * chore: add missing peer/dev deps --- .changeset/little-plums-turn.md | 6 + .../paste-codemods/tools/.cache/mappings.json | 2 + .../chat-log/__tests__/ChatLogger.spec.tsx | 35 +++++ .../chat-log/__tests__/useChatLogger.spec.tsx | 88 ++++++++++++ .../components/chat-log/package.json | 2 + .../components/chat-log/src/ChatBookend.tsx | 4 +- .../components/chat-log/src/ChatEvent.tsx | 4 +- .../components/chat-log/src/ChatLog.tsx | 2 +- .../components/chat-log/src/ChatLogger.tsx | 48 +++++++ .../components/chat-log/src/ChatMessage.tsx | 3 +- .../components/chat-log/src/index.tsx | 2 + .../components/chat-log/src/useChatLogger.ts | 45 ++++++ .../components/UseChatLogger.stories.tsx | 136 ++++++++++++++++++ .../chat-log/stories/index.stories.tsx | 3 +- .../src/component-examples/ChatLogExamples.ts | 63 ++++++++ .../src/pages/components/chat-log/index.mdx | 47 ++++++ yarn.lock | 2 + 17 files changed, 484 insertions(+), 8 deletions(-) create mode 100644 .changeset/little-plums-turn.md create mode 100644 packages/paste-core/components/chat-log/__tests__/ChatLogger.spec.tsx create mode 100644 packages/paste-core/components/chat-log/__tests__/useChatLogger.spec.tsx create mode 100644 packages/paste-core/components/chat-log/src/ChatLogger.tsx create mode 100644 packages/paste-core/components/chat-log/src/useChatLogger.ts create mode 100644 packages/paste-core/components/chat-log/stories/components/UseChatLogger.stories.tsx diff --git a/.changeset/little-plums-turn.md b/.changeset/little-plums-turn.md new file mode 100644 index 0000000000..c05c4142da --- /dev/null +++ b/.changeset/little-plums-turn.md @@ -0,0 +1,6 @@ +--- +'@twilio-paste/chat-log': minor +'@twilio-paste/core': minor +--- + +[ChatLog]: add useChatLogger hook diff --git a/packages/paste-codemods/tools/.cache/mappings.json b/packages/paste-codemods/tools/.cache/mappings.json index 0078115961..38622380ab 100644 --- a/packages/paste-codemods/tools/.cache/mappings.json +++ b/packages/paste-codemods/tools/.cache/mappings.json @@ -33,10 +33,12 @@ "ChatBubble": "@twilio-paste/core/chat-log", "ChatEvent": "@twilio-paste/core/chat-log", "ChatLog": "@twilio-paste/core/chat-log", + "ChatLogger": "@twilio-paste/core/chat-log", "ChatMessage": "@twilio-paste/core/chat-log", "ChatMessageMeta": "@twilio-paste/core/chat-log", "ChatMessageMetaItem": "@twilio-paste/core/chat-log", "ComposerAttachmentCard": "@twilio-paste/core/chat-log", + "useChatLogger": "@twilio-paste/core/chat-log", "Checkbox": "@twilio-paste/core/checkbox", "CheckboxDisclaimer": "@twilio-paste/core/checkbox", "CheckboxGroup": "@twilio-paste/core/checkbox", diff --git a/packages/paste-core/components/chat-log/__tests__/ChatLogger.spec.tsx b/packages/paste-core/components/chat-log/__tests__/ChatLogger.spec.tsx new file mode 100644 index 0000000000..e55453e252 --- /dev/null +++ b/packages/paste-core/components/chat-log/__tests__/ChatLogger.spec.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import {screen, render} from '@testing-library/react'; + +import {ChatLogger, ChatMessage, ChatBubble} from '../src'; +import type {Chat} from '../src/useChatLogger'; + +const chats: Chat[] = [ + { + id: 'uid1', + variant: 'inbound', + content: ( + + hi + + ), + }, + { + id: 'uid2', + variant: 'outbound', + content: ( + + hello + + ), + }, +]; + +describe('ChatLogger', () => { + it('should render', () => { + render(); + expect(screen.getByRole('log')).toBeDefined(); + expect(screen.getByRole('list')).toBeDefined(); + expect(screen.getAllByRole('listitem')).toHaveLength(2); + }); +}); diff --git a/packages/paste-core/components/chat-log/__tests__/useChatLogger.spec.tsx b/packages/paste-core/components/chat-log/__tests__/useChatLogger.spec.tsx new file mode 100644 index 0000000000..c0976daefe --- /dev/null +++ b/packages/paste-core/components/chat-log/__tests__/useChatLogger.spec.tsx @@ -0,0 +1,88 @@ +import * as React from 'react'; +import {renderHook, act} from '@testing-library/react-hooks'; + +import {useChatLogger, ChatBubble, ChatMessage} from '../src'; + +const chat = { + id: 'custom-id-123', + variant: 'inbound', + content: ( + + hi + + ), +} as const; + +describe('useChatLogger', () => { + it('returns expected result with defaults', () => { + const {result} = renderHook(() => useChatLogger()); + + expect(result.current).toMatchObject({ + chats: [], + pop: expect.any(Function), + push: expect.any(Function), + }); + }); + + it('returns expected result with initialization', () => { + const {result} = renderHook(() => useChatLogger(chat)); + + expect(result.current.chats).toHaveLength(1); + expect(result.current.pop).toBeInstanceOf(Function); + expect(result.current.push).toBeInstanceOf(Function); + expect(result.current.chats[0]).toMatchObject(chat); + }); + + describe('push', () => { + it('pushes new chats with an id', () => { + const {result} = renderHook(() => useChatLogger()); + expect(result.current.chats).toHaveLength(0); + + act(() => { + result.current.push(chat); + }); + + expect(result.current.chats).toHaveLength(1); + expect(result.current.chats[0]).toMatchObject(chat); + }); + + it('pushes new chats without an id', () => { + const {result} = renderHook(() => useChatLogger()); + expect(result.current.chats).toHaveLength(0); + + act(() => { + const chatWithoutCustomId = {...chat, id: undefined}; + result.current.push(chatWithoutCustomId); + }); + + expect(result.current.chats).toHaveLength(1); + expect(result.current.chats[0]).toMatchObject({ + id: expect.stringMatching(/^uid/), + }); + }); + }); + + describe('pop', () => { + it('pops chats with an id', () => { + const {result} = renderHook(() => useChatLogger(chat)); + expect(result.current.chats).toHaveLength(1); + + act(() => { + result.current.pop(chat.id); + }); + + expect(result.current.chats).toHaveLength(0); + }); + + it('pops chats without an id', () => { + const {result} = renderHook(() => useChatLogger(chat)); + expect(result.current.chats).toHaveLength(1); + + act(() => { + result.current.pop(); + }); + + expect(result.current.chats).toHaveLength(0); + }); + }); +}); diff --git a/packages/paste-core/components/chat-log/package.json b/packages/paste-core/components/chat-log/package.json index 776eb0e59e..f6f8a435c9 100644 --- a/packages/paste-core/components/chat-log/package.json +++ b/packages/paste-core/components/chat-log/package.json @@ -26,6 +26,7 @@ }, "peerDependencies": { "@twilio-paste/anchor": "^9.0.0", + "@twilio-paste/animation-library": "^0.3.9", "@twilio-paste/box": "^7.0.0", "@twilio-paste/button": "^11.0.0", "@twilio-paste/customization": "^5.0.0", @@ -43,6 +44,7 @@ }, "devDependencies": { "@twilio-paste/anchor": "^9.0.1", + "@twilio-paste/animation-library": "^0.3.9", "@twilio-paste/box": "^7.1.1", "@twilio-paste/button": "^11.1.4", "@twilio-paste/customization": "^5.0.1", diff --git a/packages/paste-core/components/chat-log/src/ChatBookend.tsx b/packages/paste-core/components/chat-log/src/ChatBookend.tsx index 8313621af0..a9650fec45 100644 --- a/packages/paste-core/components/chat-log/src/ChatBookend.tsx +++ b/packages/paste-core/components/chat-log/src/ChatBookend.tsx @@ -13,8 +13,8 @@ const ChatBookend = React.forwardRef( return ( ( return ( (({children, element = 'CHAT_LOG', ...props}, ref) => { return ( - + {children} diff --git a/packages/paste-core/components/chat-log/src/ChatLogger.tsx b/packages/paste-core/components/chat-log/src/ChatLogger.tsx new file mode 100644 index 0000000000..baac56ab73 --- /dev/null +++ b/packages/paste-core/components/chat-log/src/ChatLogger.tsx @@ -0,0 +1,48 @@ +import * as React from 'react'; +import {Box} from '@twilio-paste/box'; +import {useTransition, animated, useReducedMotion} from '@twilio-paste/animation-library'; + +import {ChatLog} from './ChatLog'; +import type {Chat} from './useChatLogger'; + +const AnimatedChat = animated(Box); +type StyleProps = React.ComponentProps['style']; + +export interface ChatLoggerProps { + chats: Chat[]; + children?: never; +} + +const buildTransitionX = (chat: Chat): number => { + if (chat.variant === 'inbound') return -100; + if (chat.variant === 'outbound') return 100; + return 0; +}; + +const ChatLogger: React.FC = ({chats}) => { + const transitions = useTransition(chats, { + keys: (chat: Chat) => chat.id, + from: (chat: Chat): StyleProps => ({opacity: 0, x: buildTransitionX(chat)}), + enter: {opacity: 1, x: 0}, + leave: (chat: Chat): StyleProps => ({opacity: 0, x: buildTransitionX(chat)}), + config: { + mass: 1, + tension: 150, + friction: 20, + }, + }); + + const animatedChats = useReducedMotion() + ? chats.map((chat) => React.cloneElement(chat.content, {key: chat.id})) + : transitions((styles: StyleProps, chat: Chat, {key}: {key: string}) => ( + + {chat.content} + + )); + + return {animatedChats}; +}; + +ChatLogger.displayName = 'ChatLogger'; + +export {ChatLogger}; diff --git a/packages/paste-core/components/chat-log/src/ChatMessage.tsx b/packages/paste-core/components/chat-log/src/ChatMessage.tsx index e26c38b003..189190697f 100644 --- a/packages/paste-core/components/chat-log/src/ChatMessage.tsx +++ b/packages/paste-core/components/chat-log/src/ChatMessage.tsx @@ -31,8 +31,7 @@ export const ChatMessage = React.forwardRef( return ( void; +type PopChat = (id?: string) => void; + +export type Chat = { + id: string; + variant?: MessageVariants; + content: React.ReactElement; +}; + +export type PartialIDChat = Omit & Partial>; + +export type UseChatLogger = (...initialChats: PartialIDChat[]) => { + chats: Chat[]; + push: PushChat; + pop: PopChat; +}; + +const chatWithId = (chat: PartialIDChat): Chat => ({...chat, id: chat.id || uid(chat.content)}); + +export const useChatLogger: UseChatLogger = (...initialChats) => { + const parsedInitialChats = React.useMemo(() => initialChats.map(chatWithId), [initialChats]); + + const [chats, setChats] = React.useState(parsedInitialChats); + + const push: PushChat = React.useCallback( + (next) => { + setChats((prev) => prev.concat(chatWithId(next))); + }, + [setChats] + ); + + const pop: PopChat = React.useCallback( + (id) => { + setChats((prev) => (id ? prev.filter((chat) => chat.id !== id) : prev.slice(0, -1))); + }, + [setChats] + ); + + return {push, pop, chats}; +}; diff --git a/packages/paste-core/components/chat-log/stories/components/UseChatLogger.stories.tsx b/packages/paste-core/components/chat-log/stories/components/UseChatLogger.stories.tsx new file mode 100644 index 0000000000..12210c18c8 --- /dev/null +++ b/packages/paste-core/components/chat-log/stories/components/UseChatLogger.stories.tsx @@ -0,0 +1,136 @@ +import * as React from 'react'; +import type {Story} from '@storybook/react'; +import {useUID} from '@twilio-paste/uid-library'; +import {Input} from '@twilio-paste/input'; +import {Label} from '@twilio-paste/label'; +import {Stack} from '@twilio-paste/stack'; +import {Button} from '@twilio-paste/button'; +import {OrderedList, ListItem} from '@twilio-paste/list'; +import {RadioButtonGroup, RadioButton} from '@twilio-paste/radio-button-group'; + +import {ChatLogger, ChatMessage, ChatBubble, useChatLogger} from '../../src'; +import type {MessageVariants} from '../../src'; +import type {PartialIDChat} from '../../src/useChatLogger'; + +// eslint-disable-next-line import/no-default-export +export default { + title: 'Components/ChatLog', +}; + +export const UseChatLogger: Story = () => { + const pushID = useUID(); + const popID = useUID(); + const messageID = useUID(); + const variantId = useUID(); + + const {chats, push, pop} = useChatLogger( + { + variant: 'inbound', + content: ( + + Hi my name is Jane Doe how can I help you? + + ), + }, + { + variant: 'outbound', + content: ( + + I need some help with the Twilio API + + ), + }, + { + variant: 'inbound', + content: ( + + Of course! Can you provide more detail? + + ), + } + ); + + const handlePushSubmit: React.FormEventHandler = (e) => { + e.preventDefault(); + const form = e.currentTarget; + const data = new FormData(form); + const message = data.get('message'); + const variant = (data.get('variant') || 'inbound') as MessageVariants; + const id = data.get('id'); + + const chat: PartialIDChat = { + variant, + content: ( + + {message} + + ), + }; + + if (id) { + chat.id = id?.toString(); + } + + push(chat); + form.reset(); + }; + + const handlePopSubmit: React.FormEventHandler = (e) => { + e.preventDefault(); + const form = e.currentTarget; + const data = new FormData(form); + const id = data.get('id')?.toString(); + + pop(id); + form.reset(); + }; + + return ( + + +
+ + Push +
+ + +
+
+ + +
+ + inbound + outbound + + +
+
+
+ + Pop +
+ + +
+ +
+
+ + {chats.map(({id}) => ( + + {id} + + ))} + +
+ +
+ ); +}; diff --git a/packages/paste-core/components/chat-log/stories/index.stories.tsx b/packages/paste-core/components/chat-log/stories/index.stories.tsx index 60697cd341..5ecb41c6a7 100644 --- a/packages/paste-core/components/chat-log/stories/index.stories.tsx +++ b/packages/paste-core/components/chat-log/stories/index.stories.tsx @@ -52,7 +52,8 @@ export const ScrollingChatLog: Story = () => { {showButton ? ( ) `.trim(); + +export const chatLoggerExample = ` +const chatFactory = ([ message, variant, metaLabel, meta ]) => { + const time = new Date().toLocaleString( + 'en-US', + { hour: 'numeric', minute: "numeric", hour12: true } + ) + + return { + variant, + content: ( + + {message} + + {meta + time} + + + ) + } +}; + +const chatTemplates = [ + ["Hello", "inbound", "said by Gibby Radki at ", "Gibby Radki・"], + ["Hi there", "outbound", "said by you at ", ""], + ["Greetings", "inbound", "said by Gibby Radki at ", "Gibby Radki・"], + ["Good to meet you", "outbound", "said by you at ", ""] +]; + +const ChatLoggerExample = () => { + const [templateIdx, setTemplateIdx] = React.useState(2); + const { chats, push, pop } = useChatLogger( + chatFactory(chatTemplates[0]), + chatFactory(chatTemplates[1]) + ); + + const pushChat = () => { + const template = chatTemplates[templateIdx]; + push(chatFactory(template)); + setTemplateIdx((idx) => ++idx % chatTemplates.length); + } + + const popChat = () => { + pop(); + setTemplateIdx((idx) => idx === 0 ? idx : --idx % chatTemplates.length); + } + + return( + + + + + + + + ) +} + +render(); +`.trim(); diff --git a/packages/paste-website/src/pages/components/chat-log/index.mdx b/packages/paste-website/src/pages/components/chat-log/index.mdx index bec0dcf2a2..0a46e41256 100644 --- a/packages/paste-website/src/pages/components/chat-log/index.mdx +++ b/packages/paste-website/src/pages/components/chat-log/index.mdx @@ -21,11 +21,16 @@ import { ChatMessage, ChatMessageMeta, ChatMessageMetaItem, + useChatLogger, + ChatLogger, } from '@twilio-paste/chat-log'; import Changelog from '@twilio-paste/chat-log/CHANGELOG.md'; import {DownloadIcon} from '@twilio-paste/icons/esm/DownloadIcon'; import {HelpText} from '@twilio-paste/help-text'; import {Callout, CalloutHeading, CalloutText} from '@twilio-paste/callout'; +import {Button} from '@twilio-paste/button'; +import {ButtonGroup} from '@twilio-paste/button-group'; +import {Stack} from '@twilio-paste/stack'; import {SidebarCategoryRoutes} from '../../../constants'; import { @@ -40,6 +45,7 @@ import { basicChatEvent, basicChatBookend, kitchenSink, + chatLoggerExample, } from '../../../component-examples/ChatLogExamples.ts'; export const pageQuery = graphql` @@ -315,6 +321,47 @@ This example combines all the separate features displayed previously into one ex {kitchenSink} +### useChatLogger hook + +The `useChatLogger` hook provides a hook based approach to managing chat state. It is best used with the `` component. + +`useChatLogger` returns 3 things: + +- An array of `chats`. +- A `push` method used to add a chat, optionally with a custom ID +- A `pop` method used to remove a chat, optionally via its ID. + +##### ChatLogger component + +The `` component handles rendering the chats it is passed via props. It handles how chats enter and leave the UI. + +``` +const { chats }= useChatLogger(); +return ; +``` + +##### Adding and removing a chat + +You can push or pop a chat based on an action or event. In this example it's based on a button click: + + + {chatLoggerExample} + + ## Usage Guide ### API diff --git a/yarn.lock b/yarn.lock index 49e6c287d3..901b6200ac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9960,6 +9960,7 @@ __metadata: resolution: "@twilio-paste/chat-log@workspace:packages/paste-core/components/chat-log" dependencies: "@twilio-paste/anchor": ^9.0.1 + "@twilio-paste/animation-library": ^0.3.9 "@twilio-paste/box": ^7.1.1 "@twilio-paste/button": ^11.1.4 "@twilio-paste/customization": ^5.0.1 @@ -9976,6 +9977,7 @@ __metadata: react-dom: ^17.0.2 peerDependencies: "@twilio-paste/anchor": ^9.0.0 + "@twilio-paste/animation-library": ^0.3.9 "@twilio-paste/box": ^7.0.0 "@twilio-paste/button": ^11.0.0 "@twilio-paste/customization": ^5.0.0