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 (
+
+
+
+
+
+ {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