Skip to content

Commit 882373b

Browse files
hschawestephl3
andauthored
Add message promotions to lg-chat (#3149)
* Spacious message promotions * Compact message promotions * Promotions callback handler, tests, md links in new tab. * Promotion link as external learnmore * Cleanup * Add stories for promotions, make link inline, update README * Run changeset * Update README based on recent changes * Run linter * Apply suggestions from code review Co-authored-by: Stephen Lee <[email protected]> * PR review: README improvements * PR review: Remove promotions from spacious msg variant * PR review: styling, Badge component, test with Chromatic, rm unused items * Require promotion URL * Actually support promotions darkmode [REAL] * Center text / badge * Rename onPromotionClick and Update message README * Fix table * Removing obvious info from README * Removing obvious info from README --------- Co-authored-by: Stephen Lee <[email protected]>
1 parent 2b6a7c0 commit 882373b

16 files changed

+309
-1
lines changed

.changeset/dirty-monkeys-camp.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@lg-chat/message': minor
3+
---
4+
5+
Add `Message.Promotion` as `Message` subcomponent for promotional content

chat/message/README.md

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ return (
6161

6262
### Compound Components
6363

64-
The `Message` component uses a compound component pattern, allowing you to compose different parts of a message using subcomponents like `Message.Actions`, `Message.Links`, and `Message.VerifiedBanner`.
64+
The `Message` component uses a compound component pattern, allowing you to compose different parts of a message using subcomponents like `Message.Actions`, `Message.Links`, `Message.Promotion`, and `Message.VerifiedBanner`.
6565

6666
**Note 1:** All compound components only render in the `compact` variant.
6767
**Note 2:** The layout and order of compound components are enforced by the `Message` component itself. Even if you change the order of subcomponents in your JSX, they will be rendered in the correct, intended order within the message bubble. This ensures consistent UI and accessibility regardless of how you compose your message children.
@@ -155,6 +155,33 @@ const MessageWithLinks = () => {
155155
};
156156
```
157157

158+
### Message.Promotion
159+
160+
```tsx
161+
import React from 'react';
162+
import {
163+
LeafyGreenChatProvider,
164+
Variant,
165+
} from '@lg-chat/leafygreen-chat-provider';
166+
import { Message } from '@lg-chat/message';
167+
168+
const MessageWithPromotion = () => {
169+
const handlePromotionClick = () => console.log('Promotion clicked');
170+
171+
return (
172+
<LeafyGreenChatProvider variant={Variant.Compact}>
173+
<Message isSender={false} messageBody="Test message">
174+
<Message.Promotion
175+
promotionText="Go learn more about this skill!"
176+
promotionUrl="https://learn.mongodb.com/skills"
177+
onPromotionLinkClick={handlePromotionClick}
178+
/>
179+
</Message>
180+
</LeafyGreenChatProvider>
181+
);
182+
};
183+
```
184+
158185
### Message.VerifiedBanner
159186

160187
```tsx
@@ -223,10 +250,16 @@ const Example = () => {
223250
];
224251

225252
const handleLinkClick = () => console.log('Link clicked');
253+
const handlePromotionClick = () => console.log('Promotion clicked');
226254

227255
return (
228256
<LeafyGreenChatProvider variant={Variant.Compact}>
229257
<Message isSender={false} messageBody="Test message">
258+
<Message.Promotion
259+
promotionText="Go learn more about this skill!"
260+
promotionUrl="https://learn.mongodb.com/skills"
261+
onPromotionLinkClick={handlePromotionClick}
262+
/>
230263
<Message.Actions
231264
onClickCopy={handleCopy}
232265
onClickRetry={handleRetry}
@@ -286,6 +319,15 @@ const Example = () => {
286319
| `onLinkClick` _(optional)_ | `({ children: string }) => void` | A callback function that is called when any link is clicked. | |
287320
| `...` | `HTMLElementProps<'div'>` | Props spread on the root element | |
288321

322+
### Message.Promotion
323+
324+
| Prop | Type | Description | Default |
325+
| ----------------------------------- | ------------------------- | ---------------------------------------- | ------- |
326+
| `promotionText` | `string` | Promotion text content. | |
327+
| `promotionUrl` | `string` | Promotion URL for the "Learn More" link. | |
328+
| `onPromotionLinkClick` _(optional)_ | `() => void` | Promotion onClick callback handler. | |
329+
| `...` | `HTMLElementProps<'div'>` | Props spread on the root element | |
330+
289331
### Message.VerifiedBanner
290332

291333
| Prop | Type | Description | Default |
@@ -361,6 +403,10 @@ The component manages its own internal state for:
361403

362404
- Expansion state: Controls whether the links section is expanded or collapsed
363405

406+
### Message.Promotion
407+
408+
The `MessagePromotion` component displays promotional content with an award icon and "Learn More" link.
409+
364410
### Message.VerifiedBanner
365411

366412
The `VerifiedBanner` component displays verification information for messages.

chat/message/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
},
1616
"dependencies": {
1717
"@leafygreen-ui/avatar": "workspace:^",
18+
"@leafygreen-ui/badge": "workspace:^",
1819
"@leafygreen-ui/banner": "workspace:^",
1920
"@leafygreen-ui/emotion": "workspace:^",
2021
"@leafygreen-ui/hooks": "workspace:^",

chat/message/src/Message.stories.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,15 @@ const getLinksChild = () => (
118118
/>
119119
);
120120

121+
const getPromotionChild = () => (
122+
<Message.Promotion
123+
promotionText="Go learn more about this skill!"
124+
promotionUrl="https://learn.mongodb.com/skills"
125+
// eslint-disable-next-line no-console
126+
onPromotionLinkClick={() => console.log('Promotion clicked')}
127+
/>
128+
);
129+
121130
const meta: StoryMetaType<typeof Message> = {
122131
title: 'Composition/Chat/Message',
123132
component: Message,
@@ -282,13 +291,24 @@ export const WithMessageLinksExpanded: StoryObj<MessageStoryProps> = {
282291
},
283292
};
284293

294+
export const WithPromotion: StoryObj<MessageStoryProps> = {
295+
render: Template,
296+
args: {
297+
children: getPromotionChild(),
298+
isSender: false,
299+
messageBody: ASSISTANT_TEXT,
300+
},
301+
};
302+
285303
export const WithAllSubComponents: StoryObj<MessageStoryProps> = {
286304
render: Template,
287305
args: {
306+
variant: Variant.Compact,
288307
children: (
289308
<>
290309
{getActionsChild()}
291310
{getLinksChild()}
311+
{getPromotionChild()}
292312
{getVerifiedBannerChild()}
293313
</>
294314
),

chat/message/src/Message/CompactMessage.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ export const CompactMessage = forwardRef<HTMLDivElement, MessageProps>(
4545
MessageSubcomponentProperty.VerifiedBanner,
4646
);
4747
const links = findChild(children, MessageSubcomponentProperty.Links);
48+
const promotion = findChild(
49+
children,
50+
MessageSubcomponentProperty.Promotion,
51+
);
4852

4953
// Filter out subcomponents from children
5054
const remainingChildren = filterChildren(
@@ -82,6 +86,7 @@ export const CompactMessage = forwardRef<HTMLDivElement, MessageProps>(
8286
>
8387
{messageBody ?? ''}
8488
</MessageContent>
89+
{promotion}
8590
{actions}
8691
{verifiedBanner}
8792
{links}

chat/message/src/Message/Message.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,15 @@ import { MessageActions } from '../MessageActions';
1313
import { MessageVerifiedBanner } from '../MessageBanner';
1414
import { MessageContext } from '../MessageContext';
1515
import { MessageLinks } from '../MessageLinks';
16+
import { MessagePromotion } from '../MessagePromotion';
1617

1718
import { CompactMessage } from './CompactMessage';
1819
import {
1920
type ActionsType,
2021
type LinksType,
2122
type MessageProps,
2223
MessageSubcomponentProperty,
24+
type PromotionType,
2325
type VerifiedBannerType,
2426
} from './Message.types';
2527
import { SpaciousMessage } from './SpaciousMessage';
@@ -108,8 +110,12 @@ Links[MessageSubcomponentProperty.Links] = true;
108110
const VerifiedBanner = MessageVerifiedBanner as VerifiedBannerType;
109111
VerifiedBanner[MessageSubcomponentProperty.VerifiedBanner] = true;
110112

113+
const Promotion = MessagePromotion as PromotionType;
114+
Promotion[MessageSubcomponentProperty.Promotion] = true;
115+
111116
export const Message = Object.assign(BaseMessage, {
112117
Actions,
113118
Links,
114119
VerifiedBanner,
120+
Promotion,
115121
});

chat/message/src/Message/Message.types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
import { type MessageContainerProps } from '../MessageContainer';
1313
import { type MessageContentProps } from '../MessageContent';
1414
import { type MessageLinksProps } from '../MessageLinks';
15+
import { type MessagePromotionProps } from '../MessagePromotion';
1516

1617
export const Align = {
1718
Right: 'right',
@@ -115,6 +116,7 @@ export const MessageSubcomponentProperty = {
115116
Actions: 'isLGMessageActions',
116117
VerifiedBanner: 'isLGMessageVerifiedBanner',
117118
Links: 'isLGMessageLinks',
119+
Promotion: 'isPromotion',
118120
} as const;
119121

120122
/**
@@ -135,3 +137,7 @@ export type VerifiedBannerType =
135137
ForwardRefExoticComponent<MessageVerifiedBannerProps> & {
136138
[MessageSubcomponentProperty.VerifiedBanner]?: boolean;
137139
};
140+
141+
export type PromotionType = ForwardRefExoticComponent<MessagePromotionProps> & {
142+
[MessageSubcomponentProperty.Promotion]?: boolean;
143+
};
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import React from 'react';
2+
import { render, screen } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
4+
import { axe } from 'jest-axe';
5+
6+
import { MessagePromotion, type MessagePromotionProps } from '.';
7+
8+
const defaultProps: MessagePromotionProps = {
9+
promotionText: 'Go learn a new skill!',
10+
promotionUrl: 'https://learn.mongodb.com/skills',
11+
};
12+
13+
const renderMessagePromotion = (props: Partial<MessagePromotionProps> = {}) => {
14+
const { container } = render(
15+
<MessagePromotion
16+
data-testid="message-promotion"
17+
{...defaultProps}
18+
{...props}
19+
/>,
20+
);
21+
return { container };
22+
};
23+
24+
describe('MessagePromotion', () => {
25+
describe('a11y', () => {
26+
test('does not have basic accessibility issues', async () => {
27+
const { container } = renderMessagePromotion();
28+
const results = await axe(container);
29+
expect(results).toHaveNoViolations();
30+
});
31+
});
32+
33+
describe('rendering', () => {
34+
test('renders promotion text & link', () => {
35+
const promotionText = 'This is a test promotion message';
36+
renderMessagePromotion({
37+
promotionText,
38+
promotionUrl: 'https://learn.mongodb.com/skills',
39+
});
40+
41+
expect(screen.getByText(promotionText)).toBeInTheDocument();
42+
expect(screen.getByText('Learn More')).toBeInTheDocument();
43+
});
44+
});
45+
46+
describe('callback handling', () => {
47+
test('onPromotionLinkClick is called when promotion element is clicked', async () => {
48+
const mockOnClick = jest.fn();
49+
50+
renderMessagePromotion({
51+
...defaultProps,
52+
onPromotionLinkClick: mockOnClick,
53+
});
54+
55+
const learnMoreLink = screen.getByText('Learn More');
56+
await userEvent.click(learnMoreLink);
57+
58+
expect(mockOnClick).toHaveBeenCalledTimes(1);
59+
});
60+
61+
test('onPromotionLinkClick is not required', () => {
62+
expect(() => {
63+
renderMessagePromotion({
64+
...defaultProps,
65+
onPromotionLinkClick: undefined,
66+
});
67+
}).not.toThrow();
68+
});
69+
});
70+
});
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { css } from '@leafygreen-ui/emotion';
2+
import { spacing } from '@leafygreen-ui/tokens';
3+
4+
const BADGE_HEIGHT = spacing[600];
5+
const BADGE_WIDTH = spacing[900];
6+
7+
export const promotionContainerStyles = css`
8+
display: flex;
9+
flex-direction: row;
10+
align-items: center;
11+
gap: 0px ${spacing[200]}px;
12+
`;
13+
14+
export const badgeStyles = css`
15+
display: flex;
16+
flex-direction: row;
17+
justify-content: center;
18+
19+
width: ${BADGE_WIDTH}px;
20+
height: ${BADGE_HEIGHT}px;
21+
`;
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import React from 'react';
2+
3+
import Badge, { Variant } from '@leafygreen-ui/badge';
4+
import Icon from '@leafygreen-ui/icon';
5+
import LeafyGreenProvider, {
6+
useDarkMode,
7+
} from '@leafygreen-ui/leafygreen-provider';
8+
import { Body, Link } from '@leafygreen-ui/typography';
9+
10+
import {
11+
badgeStyles,
12+
promotionContainerStyles,
13+
} from './MessagePromotion.styles';
14+
import { type MessagePromotionProps } from './MessagePromotion.types';
15+
16+
/**
17+
* Renders promotional content with an award icon and "Learn More" link.
18+
*
19+
* @returns The rendered promotional message component.
20+
*/
21+
export function MessagePromotion({
22+
promotionText,
23+
promotionUrl,
24+
onPromotionLinkClick,
25+
darkMode: darkModeProp,
26+
...rest
27+
}: MessagePromotionProps) {
28+
const { darkMode } = useDarkMode(darkModeProp);
29+
return (
30+
<LeafyGreenProvider darkMode={darkMode}>
31+
<div className={promotionContainerStyles}>
32+
<div>
33+
<Badge variant={Variant.Green} className={badgeStyles}>
34+
<Icon glyph="Award" />
35+
</Badge>
36+
</div>
37+
<Body as="span" {...rest}>
38+
{promotionText}
39+
<>
40+
{' '}
41+
<Link href={promotionUrl} onClick={onPromotionLinkClick}>
42+
Learn More
43+
</Link>
44+
</>
45+
</Body>
46+
</div>
47+
</LeafyGreenProvider>
48+
);
49+
}

0 commit comments

Comments
 (0)