Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export const MessageWithSourcesExample: React.FunctionComponent = () => {
name="Bot"
role="bot"
avatar={patternflyAvatar}
content="Example with sources"
content="This example has a body description that's within the recommended limit of 2 lines:"
sources={{
sources: [
{
Expand Down Expand Up @@ -43,7 +43,36 @@ export const MessageWithSourcesExample: React.FunctionComponent = () => {
name="Bot"
role="bot"
avatar={patternflyAvatar}
content="Example with very long sources"
content="This example has a body description that's longer than the recommended limit of 2 lines, with a link to load the rest of the description:"
sources={{
sources: [
{
title: 'Getting started with Red Hat OpenShift',
link: '#',
body: 'Red Hat OpenShift on IBM Cloud is a managed offering to create your own cluster of compute hosts where you can deploy and manage containerized apps on IBM Cloud.',
hasShowMore: true
},
{
title: 'Azure Red Hat OpenShift documentation',
link: '#',
body: 'Microsoft Azure Red Hat OpenShift allows you to deploy a production ready Red Hat OpenShift cluster in Azure.',
hasShowMore: true
},
{
title: 'OKD Documentation: Home',
link: '#',
body: 'OKD is a distribution of Kubernetes optimized for continuous application development and multi-tenant deployment. OKD also serves as the upstream code base upon.',
hasShowMore: true
}
],
onSetPage
}}
/>
<Message
name="Bot"
role="bot"
avatar={patternflyAvatar}
content="This example has a truncated title:"
sources={{
sources: [
{
Expand All @@ -66,7 +95,7 @@ export const MessageWithSourcesExample: React.FunctionComponent = () => {
name="Bot"
role="bot"
avatar={patternflyAvatar}
content="Example with only 1 source"
content="This example only includes 1 source:"
sources={{
sources: [
{
Expand All @@ -83,7 +112,7 @@ export const MessageWithSourcesExample: React.FunctionComponent = () => {
name="Bot"
role="bot"
avatar={patternflyAvatar}
content="Example with sources that include a title and link"
content="This example has a title and no body description:"
sources={{
sources: [
{ title: 'Getting started with Red Hat OpenShift', link: '#', isExternal: true },
Expand All @@ -105,7 +134,7 @@ export const MessageWithSourcesExample: React.FunctionComponent = () => {
name="Bot"
role="bot"
avatar={patternflyAvatar}
content="Example with link-only sources (not recommended)"
content="This example displays the source as a link, without a title (not recommended)"
sources={{
sources: [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ If you are using Retrieval-Augmented Generation, you may want to display sources

If a source will open outside of the ChatBot window, add an external link icon via `isExternal`.

The API for a source requires a link at minimum, but we strongly recommend providing a more descriptive title and body description so users have enough context. The title is limited to 1 line and the body is limited to 2 lines.
The API for a source requires a link at minimum, but we strongly recommend providing a more descriptive title and body description so users have enough context. For the best clarity and readability, we strongly recommend limiting the title to 1 line and the body to 2 lines. If the body description is more than 2 lines, use the "long sources" or "very long sources" variant.

```js file="./MessageWithSources.tsx"

Expand Down
10 changes: 3 additions & 7 deletions packages/module/src/SourcesCard/SourcesCard.scss
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
box-shadow: var(--pf-t--global--box-shadow--sm);
}

.pf-chatbot__sources-card-body {
.pf-chatbot__sources-card-body-text {
display: block;
display: -webkit-box;
height: 2.8125rem;
Expand All @@ -25,11 +25,6 @@
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: var(--pf-t--global--spacer--md);
}

.pf-chatbot__sources-card-no-footer {
margin-bottom: var(--pf-t--global--spacer--lg);
}

.pf-chatbot__sources-card-footer-container {
Expand All @@ -38,13 +33,14 @@
var(--pf-t--global--spacer--sm) !important;
.pf-chatbot__sources-card-footer {
display: flex;
justify-content: space-between;
align-items: center;

&-buttons {
display: flex;
gap: var(--pf-t--global--spacer--xs);
align-items: center;
justify-content: space-between;
flex: 1;

.pf-v6-c-button {
border-radius: var(--pf-t--global--border--radius--pill);
Expand Down
47 changes: 30 additions & 17 deletions packages/module/src/SourcesCard/SourcesCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ describe('SourcesCard', () => {
expect(screen.getByText('Source 1')).toBeTruthy();
// no buttons or navigation when there is only 1 source
expect(screen.queryByRole('button')).toBeFalsy();
expect(screen.queryByText('1 of 1')).toBeFalsy();
expect(screen.queryByText('1/1')).toBeFalsy();
});

it('should render card correctly if one source with a title is passed in', () => {
Expand Down Expand Up @@ -48,7 +48,7 @@ describe('SourcesCard', () => {
);
expect(screen.getByText('2 sources')).toBeTruthy();
expect(screen.getByText('How to make an apple pie')).toBeTruthy();
expect(screen.getByText('1 of 2')).toBeTruthy();
expect(screen.getByText('1/2')).toBeTruthy();
screen.getByRole('button', { name: /Go to previous page/i });
screen.getByRole('button', { name: /Go to next page/i });
});
Expand All @@ -63,12 +63,12 @@ describe('SourcesCard', () => {
/>
);
expect(screen.getByText('How to make an apple pie')).toBeTruthy();
expect(screen.getByText('1 of 2')).toBeTruthy();
expect(screen.getByText('1/2')).toBeTruthy();
expect(screen.getByRole('button', { name: /Go to previous page/i })).toBeDisabled();
await userEvent.click(screen.getByRole('button', { name: /Go to next page/i }));
expect(screen.queryByText('How to make an apple pie')).toBeFalsy();
expect(screen.getByText('How to make cookies')).toBeTruthy();
expect(screen.getByText('2 of 2')).toBeTruthy();
expect(screen.getByText('2/2')).toBeTruthy();
expect(screen.getByRole('button', { name: /Go to previous page/i })).toBeEnabled();
expect(screen.getByRole('button', { name: /Go to next page/i })).toBeDisabled();
});
Expand Down Expand Up @@ -101,19 +101,6 @@ describe('SourcesCard', () => {
expect(screen.getByRole('button', { name: /Go to next page/i })).toBeDisabled();
});

it('should change ofWord appropriately', () => {
render(
<SourcesCard
sources={[
{ title: 'How to make an apple pie', link: '' },
{ title: 'How to make cookies', link: '' }
]}
ofWord={'de'}
/>
);
expect(screen.getByText('1 de 2')).toBeTruthy();
});

it('should render navigation aria label appropriately', () => {
render(
<SourcesCard
Expand Down Expand Up @@ -230,4 +217,30 @@ describe('SourcesCard', () => {
await userEvent.click(screen.getByRole('button', { name: /Go to previous page/i }));
expect(spy).toHaveBeenCalledTimes(2);
});

it('should handle showMore appropriately', async () => {
render(
<SourcesCard
sources={[
{
title: 'Getting started with Red Hat OpenShift',
link: '#',
body: 'Red Hat OpenShift on IBM Cloud is a managed offering to create your own cluster of compute hosts where you can deploy and manage containerized apps on IBM Cloud ...',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
body: 'Red Hat OpenShift on IBM Cloud is a managed offering to create your own cluster of compute hosts where you can deploy and manage containerized apps on IBM Cloud ...',
body: 'Red Hat OpenShift on IBM Cloud is a managed offering to create your own cluster of compute hosts where you can deploy and manage containerized apps on IBM Cloud ...',

I can't remember if we explicitly decided last week -- do we want to take the ellipses off of these examples?

Copy link
Copy Markdown
Member Author

@rebeccaalpert rebeccaalpert Apr 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed the ellipses on the "show more" example, but left the others alone. The ellipsis in the text don't actually get shown in the other examples. It's a matter of taste whether we remove them or not. This is just a test fwiw.

hasShowMore: true
},
{
title: 'Azure Red Hat OpenShift documentation',
link: '#',
body: 'Microsoft Azure Red Hat OpenShift allows you to deploy a production ready Red Hat OpenShift cluster in Azure ...'
},
{
title: 'OKD Documentation: Home',
link: '#',
body: 'OKD is a distribution of Kubernetes optimized for continuous application development and multi-tenant deployment. OKD also serves as the upstream code base upon ...'
}
]}
/>
);
expect(screen.getByRole('region')).toHaveAttribute('class', 'pf-v6-c-expandable-section__content');
});
});
52 changes: 41 additions & 11 deletions packages/module/src/SourcesCard/SourcesCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
CardFooter,
CardProps,
CardTitle,
ExpandableSection,
ExpandableSectionVariant,
Icon,
pluralize,
Truncate
Expand All @@ -23,12 +25,18 @@ export interface SourcesCardProps extends CardProps {
className?: string;
/** Flag indicating if the pagination is disabled. */
isDisabled?: boolean;
/** Label for the English word "of". */
/** @deprecated ofWord has been deprecated. Label for the English word "of." */
ofWord?: string;
/** Accessible label for the pagination component. */
paginationAriaLabel?: string;
/** Content rendered inside the paginated card */
sources: { title?: string; link: string; body?: React.ReactNode | string; isExternal?: boolean }[];
sources: {
title?: string;
link: string;
body?: React.ReactNode | string;
isExternal?: boolean;
hasShowMore?: boolean;
}[];
/** Label for the English word "source" */
sourceWord?: string;
/** Plural for sourceWord */
Expand All @@ -43,12 +51,15 @@ export interface SourcesCardProps extends CardProps {
onPreviousClick?: (event: React.SyntheticEvent<HTMLButtonElement>, page: number) => void;
/** Function called when page is changed. */
onSetPage?: (event: React.MouseEvent | React.KeyboardEvent | MouseEvent, newPage: number) => void;
/** Label for English words "show more" */
showMoreWords?: string;
/** Label for English words "show less" */
showLessWords?: string;
Comment thread
rebeccaalpert marked this conversation as resolved.
}

const SourcesCard: React.FunctionComponent<SourcesCardProps> = ({
className,
isDisabled,
ofWord = 'of',
paginationAriaLabel = 'Pagination',
sources,
sourceWord = 'source',
Expand All @@ -58,9 +69,16 @@ const SourcesCard: React.FunctionComponent<SourcesCardProps> = ({
onNextClick,
onPreviousClick,
onSetPage,
showMoreWords = 'show more',
showLessWords = 'show less',
...props
}: SourcesCardProps) => {
const [page, setPage] = React.useState(1);
const [isExpanded, setIsExpanded] = React.useState(false);

const onToggle = (_event: React.MouseEvent, isExpanded: boolean) => {
setIsExpanded(isExpanded);
};

const handleNewPage = (_evt: React.MouseEvent | React.KeyboardEvent | MouseEvent, newPage: number) => {
setPage(newPage);
Expand Down Expand Up @@ -93,10 +111,23 @@ const SourcesCard: React.FunctionComponent<SourcesCardProps> = ({
</Button>
</CardTitle>
{sources[page - 1].body && (
<CardBody
className={`pf-chatbot__sources-card-body ${sources.length === 1 && 'pf-chatbot__sources-card-no-footer'}`}
>
{sources[page - 1].body}
<CardBody className={`pf-chatbot__sources-card-body`}>
{sources[page - 1].hasShowMore ? (
// prevents extra VO announcements of button text - parent Message has aria-live
<div aria-live="off">
<ExpandableSection
variant={ExpandableSectionVariant.truncate}
toggleText={isExpanded ? showLessWords : showMoreWords}
onToggle={onToggle}
isExpanded={isExpanded}
truncateMaxLines={2}
>
{sources[page - 1].body}
</ExpandableSection>
</div>
) : (
<div className="pf-chatbot__sources-card-body-text">{sources[page - 1].body}</div>
)}
</CardBody>
)}
{sources.length > 1 && (
Expand Down Expand Up @@ -129,6 +160,9 @@ const SourcesCard: React.FunctionComponent<SourcesCardProps> = ({
</svg>
</Icon>
</Button>
<span aria-hidden="true">
{page}/{sources.length}
</span>
<Button
variant={ButtonVariant.plain}
isDisabled={isDisabled || page === sources.length}
Expand Down Expand Up @@ -156,10 +190,6 @@ const SourcesCard: React.FunctionComponent<SourcesCardProps> = ({
</Icon>
</Button>
</nav>

<span aria-hidden="true">
{page} {ofWord} {sources.length}
</span>
</div>
</CardFooter>
)}
Expand Down
Loading