Skip to content

Commit d906838

Browse files
tewarigrzpcibotgithub-actions[bot]saurabhdaware
authored
feat(blade): chat message component (#2513)
* feat/chat-bubble-api-decision * chore: api docs * chore: update is user message * fix: typo * feat: updated decisions * chore: more changes * chore: update decision and chatBubble * feat: done with right message * feat: response message * feat: api changes * chore: add motion * feat: add animation and update some props * chore: code refactor * chore: updated docs for chatbubble * chore: update width * chore: more refactor * fix: getStringFromReactText from utils * feat: add style props * feat: add message * chore: added more example * feat: add types * feat: added ref * feat: update types and example * chore: more ts and other changes * chore: update decision.md * chore: update ray icon * chore: update snops * chore: add more tests * feat: add sort and prompt icon [p0] (#2511) * feat: add sort and cherry pick icons * chore: add changeset * chore: change sort * fix: more lint changes * chore: few changes * chore: sort icon * chore: remove path * chore: update changeset * build(blade): update version (#2512) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * fix(Avatar): add missing ref on default avatar (#2516) * fix(Avatar): add missing ref on default avatar * Create nasty-frogs-end.md * build(blade): update version (#2518) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * feat: add chatbubble * chore: minior review changes * chore: more review changes * chore: typescript gymnastics * chore: use Meta constans * chore: remove lazy motion * chore: added jsDoc comments * chore: added tokens * chore: update comments * chore: remove stringChildType and change export * chore: fix another error * chore: added wrapper component * chore: more changes * chrore: add footer actions * chore: update tests and snap * feat: more tests * fix: add should add padding * chore: add typing animation * chore: file name refactor , new animation * chore:sender type * chore: removed max width * chore: update docs * chore: chatMessage refactor * chore: update snaps * chore: fix ts * chore: more changes * feat: chatMessage * chore: docs update * chore: update snaps * chore: update example * chore: change styledPropsBlade to BaseBoxProps * fix: types * chore: added more types * chore: import type * chore: change types and footer spacing * chore: update snaps * chore: more changes * chore: reverse rfc change * chore: update test * chore: update snap and wrapper * chore: update web snap * chore: add make box props * feat: update snaps * chore: more changes * chore: update type and example --------- Co-authored-by: rzpcibot <64553331+rzpcibot@users.noreply.github.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Saurabh Daware <saurabh.daware@razorpay.com>
1 parent 4b6b43a commit d906838

18 files changed

+3325
-0
lines changed

.changeset/dirty-otters-jump.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@razorpay/blade': minor
3+
---
4+
5+
feat(blade): add Chat Message component
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import React from 'react';
2+
import type { ChatMessageProps } from './types';
3+
import { Text } from '~components/Typography';
4+
import { throwBladeError } from '~utils/logger';
5+
6+
const ChatMessage = (_prop: ChatMessageProps): React.ReactElement => {
7+
throwBladeError({
8+
message: 'ChatMessage is not yet implemented for native',
9+
moduleName: 'ChatMessage',
10+
});
11+
12+
return <Text>ChatMessage is not available for Native mobile apps.</Text>;
13+
};
14+
15+
export { ChatMessage };
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import React from 'react';
2+
import { SelfMessageBubble } from './SelfMessageBubble.web';
3+
import { DefaultMessageBubble } from './DefaultMessageBubble.web';
4+
import type { ChatMessageProps } from './types';
5+
import { Text } from '~components/Typography';
6+
import BaseBox from '~components/Box/BaseBox';
7+
import { getStringFromReactText } from '~utils/getStringChildren';
8+
import { assignWithoutSideEffects } from '~utils/assignWithoutSideEffects';
9+
import type { BladeElementRef } from '~utils/types';
10+
import { MetaConstants, metaAttribute } from '~utils/metaAttribute';
11+
import { makeAnalyticsAttribute } from '~utils/makeAnalyticsAttribute';
12+
import { getStyledProps } from '~components/Box/styledProps';
13+
14+
const ButtonResetCss = {
15+
background: 'none',
16+
border: 'none',
17+
padding: undefined,
18+
cursor: 'pointer',
19+
color: 'inherit',
20+
font: 'inherit',
21+
textAlign: 'left' as const,
22+
outline: 'inherit',
23+
appearance: 'none',
24+
backgroundColor: 'inherit',
25+
};
26+
27+
const _ChatMessage: React.ForwardRefRenderFunction<BladeElementRef, ChatMessageProps> = (
28+
{
29+
messageType = 'default',
30+
senderType = 'self',
31+
isLoading = false,
32+
validationState = 'none',
33+
errorText,
34+
onClick,
35+
footerActions,
36+
children,
37+
leading,
38+
loadingText,
39+
wordBreak = 'break-word',
40+
maxWidth,
41+
...props
42+
}: ChatMessageProps,
43+
ref: React.Ref<BladeElementRef>,
44+
): React.ReactElement => {
45+
// since we can pass both string and Card component as children, we need to check if children is string or Card component
46+
// if children is string or array of string, we need to wrap it in Text component otherwise we will pass children as it is
47+
const shouldWrapInText =
48+
typeof children === 'string' ||
49+
(Array.isArray(children) && children.every((child) => typeof child === 'string')) ||
50+
isLoading;
51+
52+
const finalChildren = shouldWrapInText ? (
53+
<Text
54+
color={isLoading ? 'surface.text.gray.muted' : 'surface.text.gray.normal'}
55+
weight="regular"
56+
variant="body"
57+
size="medium"
58+
wordBreak={wordBreak}
59+
>
60+
{isLoading ? loadingText : getStringFromReactText(children as string | string[])}
61+
</Text>
62+
) : (
63+
(children as React.ReactElement)
64+
);
65+
66+
return (
67+
<BaseBox
68+
onClick={onClick}
69+
{...(onClick ? { ...ButtonResetCss } : {})}
70+
{...metaAttribute({ name: MetaConstants.ChatMessage, testID: props.testID })}
71+
{...makeAnalyticsAttribute(props)}
72+
{...getStyledProps(props)}
73+
maxWidth={maxWidth}
74+
ref={ref as never}
75+
as={onClick ? 'button' : undefined}
76+
>
77+
{senderType === 'self' ? (
78+
<SelfMessageBubble
79+
validationState={validationState}
80+
errorText={errorText}
81+
children={finalChildren}
82+
messageType={messageType}
83+
isChildText={shouldWrapInText}
84+
/>
85+
) : (
86+
<DefaultMessageBubble
87+
children={finalChildren}
88+
leading={leading}
89+
isLoading={isLoading}
90+
footerActions={footerActions}
91+
isChildText={shouldWrapInText}
92+
/>
93+
)}
94+
</BaseBox>
95+
);
96+
};
97+
98+
const ChatMessage = assignWithoutSideEffects(React.forwardRef(_ChatMessage), {
99+
displayName: 'ChatMessage',
100+
componentId: MetaConstants.ChatMessage,
101+
});
102+
export { ChatMessage };
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import React from 'react';
2+
import Rotate from './Rotate.web';
3+
import type { CommonChatMessageProps } from './types';
4+
import BaseBox from '~components/Box/BaseBox';
5+
6+
const DefaultMessageBubble = ({
7+
children,
8+
leading,
9+
isLoading,
10+
footerActions,
11+
isChildText,
12+
}: Pick<CommonChatMessageProps, 'children' | 'leading' | 'isLoading' | 'footerActions'> & {
13+
isChildText: boolean;
14+
}): React.ReactElement => {
15+
return (
16+
<BaseBox>
17+
<BaseBox
18+
display="grid"
19+
gridTemplateColumns="auto 1fr"
20+
gridTemplateRows="auto auto"
21+
columnGap="spacing.4"
22+
>
23+
<BaseBox padding="spacing.2">
24+
<Rotate animate={isLoading}>{leading as React.ReactElement}</Rotate>
25+
</BaseBox>
26+
27+
<BaseBox
28+
display="flex"
29+
alignItems="center"
30+
paddingY={isChildText ? 'spacing.2' : 'spacing.0'}
31+
>
32+
{children}
33+
</BaseBox>
34+
35+
<BaseBox gridColumn="2">{footerActions}</BaseBox>
36+
</BaseBox>
37+
</BaseBox>
38+
);
39+
};
40+
41+
export { DefaultMessageBubble };
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import React from 'react';
2+
import { m } from 'framer-motion';
3+
import { castWebType } from '~utils';
4+
import { useTheme } from '~components/BladeProvider';
5+
import { msToSeconds } from '~utils/msToSeconds';
6+
import { cssBezierToArray } from '~utils/cssBezierToArray';
7+
8+
const Rotate = ({
9+
children,
10+
animate,
11+
}: {
12+
children: React.ReactElement;
13+
animate?: boolean;
14+
}): React.ReactElement => {
15+
const { theme } = useTheme();
16+
17+
if (!animate) {
18+
return children;
19+
}
20+
21+
return (
22+
<m.div
23+
style={{
24+
display: 'flex',
25+
}}
26+
animate={{ rotate: 90 }}
27+
transition={{
28+
duration: msToSeconds(theme.motion.duration.gentle),
29+
repeat: Infinity,
30+
ease: cssBezierToArray(castWebType(theme.motion.easing.emphasized)),
31+
delay: msToSeconds(theme.motion.delay.gentle),
32+
}}
33+
>
34+
{children}
35+
</m.div>
36+
);
37+
};
38+
39+
export default Rotate;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import React from 'react';
2+
import type { CommonChatMessageProps } from './types';
3+
import { chatMessageToken } from './token';
4+
import BaseBox from '~components/Box/BaseBox';
5+
import { FormHint } from '~components/Form/FormHint';
6+
7+
const SelfMessageBubble = ({
8+
children,
9+
validationState,
10+
errorText = 'Message not sent. Tap to retry.',
11+
messageType,
12+
isChildText,
13+
}: Pick<CommonChatMessageProps, 'children' | 'validationState' | 'errorText' | 'messageType'> & {
14+
// is child is text then only add padding otherwise no need to add padding
15+
isChildText: boolean;
16+
}): React.ReactElement => {
17+
const isError = validationState === 'error';
18+
return (
19+
<BaseBox display="flex" flexDirection="column">
20+
<BaseBox
21+
backgroundColor={
22+
isError
23+
? chatMessageToken.self.backgroundColor.error
24+
: chatMessageToken.self.backgroundColor.default
25+
}
26+
padding={isChildText ? 'spacing.4' : 'spacing.0'}
27+
borderTopLeftRadius={chatMessageToken.self.borderTopLeftRadius}
28+
borderTopRightRadius={chatMessageToken.self.borderTopRightRadius}
29+
borderBottomLeftRadius={chatMessageToken.self.borderBottomLeftRadius}
30+
borderBottomRightRadius={
31+
messageType === 'last'
32+
? chatMessageToken.self.borderBottomRightRadiusForLastMessage
33+
: chatMessageToken.self.borderBottomRightRadius
34+
}
35+
width="fit-content"
36+
height="auto"
37+
alignSelf="flex-end"
38+
>
39+
{children}
40+
</BaseBox>
41+
<BaseBox alignSelf="flex-end">
42+
{isError && <FormHint type="error" errorText={errorText} size="small" />}
43+
</BaseBox>
44+
</BaseBox>
45+
);
46+
};
47+
48+
export { SelfMessageBubble };
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// test case for ChatMessage component
2+
import { fireEvent } from '@testing-library/react';
3+
import { ChatMessage } from '../ChatMessage';
4+
import renderWithSSR from '~utils/testing/renderWithSSR.web';
5+
import { RayIcon } from '~components/Icons';
6+
import { Card, CardBody } from '~components/Card';
7+
import { Box } from '~components/Box';
8+
import { Text } from '~components/Typography';
9+
import { Radio, RadioGroup } from '~components/Radio';
10+
11+
describe('<ChatMessage/>', () => {
12+
it('should render last message correctly', () => {
13+
const { container } = renderWithSSR(
14+
<ChatMessage senderType="self" messageType="last">
15+
{' '}
16+
This is a demo message{' '}
17+
</ChatMessage>,
18+
);
19+
expect(container).toMatchSnapshot();
20+
});
21+
it('should render last message correctly', () => {
22+
const { container } = renderWithSSR(
23+
<ChatMessage messageType="default" senderType="self">
24+
{' '}
25+
This is another demo message{' '}
26+
</ChatMessage>,
27+
);
28+
expect(container).toMatchSnapshot();
29+
});
30+
it('should render last message correctly', () => {
31+
const { container } = renderWithSSR(
32+
<ChatMessage
33+
senderType="other"
34+
leading={<RayIcon size="xlarge" color="surface.icon.onSea.onSubtle" />}
35+
>
36+
{' '}
37+
This is another demo message{' '}
38+
</ChatMessage>,
39+
);
40+
expect(container).toMatchSnapshot();
41+
});
42+
it('should render last message correctly', () => {
43+
const { container } = renderWithSSR(
44+
<ChatMessage
45+
senderType="other"
46+
leading={<RayIcon size="xlarge" color="surface.icon.onSea.onSubtle" />}
47+
loadingText="Analyzing your response..."
48+
>
49+
<Card>
50+
<CardBody>
51+
<Box display="flex" gap="8px" flexDirection="column">
52+
<Text variant="body" size="medium">
53+
Where do you want to collect payments?
54+
</Text>
55+
<RadioGroup>
56+
<Radio value="website">Website</Radio>
57+
<Radio value="android">Android App</Radio>
58+
<Radio value="ios">iOS App</Radio>
59+
</RadioGroup>
60+
</Box>
61+
</CardBody>
62+
</Card>
63+
</ChatMessage>,
64+
);
65+
expect(container).toMatchSnapshot();
66+
});
67+
it('it should fire onClick event when user clicks on message button', () => {
68+
const onClick = jest.fn();
69+
const { getByText } = renderWithSSR(
70+
<ChatMessage
71+
senderType="self"
72+
messageType="last"
73+
validationState="error"
74+
errorText="Message not sent. Tap to retry."
75+
onClick={onClick}
76+
>
77+
Can you help me with the docs?
78+
</ChatMessage>,
79+
);
80+
const message = getByText('Can you help me with the docs?');
81+
fireEvent.click(message);
82+
expect(onClick).toHaveBeenCalled();
83+
});
84+
it('should render loading message correctly when loadingText is passed as prop and children is Card component', () => {
85+
const { getByText } = renderWithSSR(
86+
<ChatMessage
87+
senderType="other"
88+
leading={<RayIcon size="xlarge" color="surface.icon.onSea.onSubtle" />}
89+
isLoading={true}
90+
loadingText="Analyzing your response..."
91+
>
92+
<Card>
93+
<CardBody>
94+
<Box display="flex" gap="8px" flexDirection="column">
95+
<Text variant="body" size="medium">
96+
Where do you want to collect payments?
97+
</Text>
98+
<RadioGroup>
99+
<Radio value="website">Website</Radio>
100+
<Radio value="android">Android App</Radio>
101+
<Radio value="ios">iOS App</Radio>
102+
</RadioGroup>
103+
</Box>
104+
</CardBody>
105+
</Card>
106+
</ChatMessage>,
107+
);
108+
const loadingTextElement = getByText('Analyzing your response...');
109+
expect(loadingTextElement).toBeInTheDocument();
110+
});
111+
it('should render footer actions correctly when footerActions prop is passed', () => {
112+
const { getByText } = renderWithSSR(
113+
<ChatMessage
114+
senderType="other"
115+
footerActions={
116+
<Box>
117+
<Box key={1}>Action 1</Box>,<Box key={2}>Action 2</Box>,<Box key={3}>Action 3</Box>,
118+
</Box>
119+
}
120+
>
121+
Can you help me with the docs?
122+
</ChatMessage>,
123+
);
124+
const action1 = getByText('Action 1');
125+
const action2 = getByText('Action 2');
126+
const action3 = getByText('Action 3');
127+
expect(action1).toBeInTheDocument();
128+
expect(action2).toBeInTheDocument();
129+
expect(action3).toBeInTheDocument();
130+
});
131+
});

0 commit comments

Comments
 (0)