From a9e1876d05c228484e8e893c352dbdaa50ba113a Mon Sep 17 00:00:00 2001 From: Rebecca Alpert Date: Tue, 9 Sep 2025 14:14:41 -0400 Subject: [PATCH 1/2] feat(SourcesCardBase): Expose just card portion of SourcesCard Allows consumers to custom-roll their own layouts using preexisting component. --- .../src/SourcesCard/SourcesCard.test.tsx | 329 +----------------- .../module/src/SourcesCard/SourcesCard.tsx | 179 +--------- .../SourcesCardBase/SourcesCardBase.test.tsx | 236 +++++++++++++ .../src/SourcesCardBase/SourcesCardBase.tsx | 242 +++++++++++++ packages/module/src/SourcesCardBase/index.ts | 3 + packages/module/src/index.ts | 9 +- 6 files changed, 497 insertions(+), 501 deletions(-) create mode 100644 packages/module/src/SourcesCardBase/SourcesCardBase.test.tsx create mode 100644 packages/module/src/SourcesCardBase/SourcesCardBase.tsx create mode 100644 packages/module/src/SourcesCardBase/index.ts diff --git a/packages/module/src/SourcesCard/SourcesCard.test.tsx b/packages/module/src/SourcesCard/SourcesCard.test.tsx index 5d8bc3507..6e1befeb1 100644 --- a/packages/module/src/SourcesCard/SourcesCard.test.tsx +++ b/packages/module/src/SourcesCard/SourcesCard.test.tsx @@ -1,42 +1,15 @@ import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom'; import SourcesCard from './SourcesCard'; describe('SourcesCard', () => { - it('should render card correctly if one source with only a link is passed in', () => { - render(); - expect(screen.getByText('1 source')).toBeTruthy(); - 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/1')).toBeFalsy(); - }); - - it('should render card correctly if one source with a title is passed in', () => { + it('should render sources correctly if one source is passed in', () => { render(); expect(screen.getByText('1 source')).toBeTruthy(); expect(screen.getByText('How to make an apple pie')).toBeTruthy(); }); - it('should render card correctly if one source with a body is passed in', () => { - render(); - expect(screen.getByText('1 source')).toBeTruthy(); - expect(screen.getByText('To make an apple pie, you must first...')).toBeTruthy(); - }); - - it('should render card correctly if one source with a title and body is passed in', () => { - render( - - ); - expect(screen.getByText('1 source')).toBeTruthy(); - expect(screen.getByText('How to make an apple pie')).toBeTruthy(); - expect(screen.getByText('To make an apple pie, you must first...')).toBeTruthy(); - }); - - it('should render multiple cards correctly', () => { + it('should render sources correctly when there is more than one', () => { render( { screen.getByRole('button', { name: /Go to previous page/i }); screen.getByRole('button', { name: /Go to next page/i }); }); - - it('should navigate between cards correctly', async () => { - render( - - ); - expect(screen.getByText('How to make an apple pie')).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/2')).toBeTruthy(); - expect(screen.getByRole('button', { name: /Go to previous page/i })).toBeEnabled(); - expect(screen.getByRole('button', { name: /Go to next page/i })).toBeDisabled(); - }); - - it('should apply className appropriately', () => { - render( - - ); - const element = screen.getByRole('navigation'); - expect(element).toHaveClass('test'); - }); - - it('should disable pagination appropriately', () => { - render( - - ); - expect(screen.getByRole('button', { name: /Go to previous page/i })).toBeDisabled(); - expect(screen.getByRole('button', { name: /Go to next page/i })).toBeDisabled(); - }); - - it('should render navigation aria label appropriately', () => { - render( - - ); - expect(screen.getByRole('navigation', { name: /Pagination/i })).toBeTruthy(); - }); - - it('should change paginationAriaLabel appropriately', () => { - render( - - ); - expect(screen.getByRole('navigation', { name: /Navegación/i })).toBeTruthy(); - }); - - it('should change sourceWord appropriately', () => { - render(); - expect(screen.getByText('1 fuente')).toBeTruthy(); - }); - - it('should sourceWordPlural appropriately', () => { - render( - - ); - expect(screen.getByText('2 fuentes')).toBeTruthy(); - }); - - it('should change toNextPageAriaLabel appropriately', () => { - render( - - ); - expect(screen.getByRole('button', { name: /Pase a la siguiente página/i })).toBeTruthy(); - }); - - it('should change toPreviousPageAriaLabel appropriately', () => { - render( - - ); - expect(screen.getByRole('button', { name: /Presione para regresar a la página anterior/i })).toBeTruthy(); - }); - - it('should call onNextClick appropriately', async () => { - const spy = jest.fn(); - render( - - ); - await userEvent.click(screen.getByRole('button', { name: /Go to next page/i })); - expect(spy).toHaveBeenCalled(); - }); - - it('should call onPreviousClick appropriately', async () => { - const spy = jest.fn(); - render( - - ); - await userEvent.click(screen.getByRole('button', { name: /Go to next page/i })); - await userEvent.click(screen.getByRole('button', { name: /Go to previous page/i })); - expect(spy).toHaveBeenCalled(); - }); - - it('should call onSetPage appropriately', async () => { - const spy = jest.fn(); - render( - - ); - await userEvent.click(screen.getByRole('button', { name: /Go to next page/i })); - expect(spy).toHaveBeenCalledTimes(1); - await userEvent.click(screen.getByRole('button', { name: /Go to previous page/i })); - expect(spy).toHaveBeenCalledTimes(2); - }); - - it('should handle showMore appropriately', async () => { - render( - - ); - expect(screen.getByRole('region')).toHaveAttribute('class', 'pf-v6-c-expandable-section__content'); - }); - - it('should call onClick appropriately', async () => { - const spy = jest.fn(); - render(); - await userEvent.click(screen.getByRole('link', { name: /How to make an apple pie/i })); - expect(spy).toHaveBeenCalled(); - }); - - it('should apply titleProps appropriately', () => { - render( - - ); - expect(screen.getByRole('link', { name: /How to make an apple pie/i })).toHaveClass('test'); - }); - - it('should apply cardTitleProps appropriately', () => { - render( - - ); - expect(screen.getByTestId('card-title')).toHaveClass('test'); - }); - - it('should apply cardBodyProps appropriately', () => { - render( - - ); - expect(screen.getByTestId('card-body')).toHaveClass('test'); - }); - - it('should apply cardFooterProps appropriately', () => { - render( - - ); - expect(screen.getByTestId('card-footer')).toHaveClass('test'); - }); - - it('should apply truncateProps appropriately', () => { - render( - - ); - expect(screen.getByTestId('card-truncate')).toHaveClass('test'); - }); - - it('should apply custom footer appropriately when there is one source', () => { - render( - I am a custom footer }]} /> - ); - expect(screen.getByText('I am a custom footer')); - expect(screen.queryByText('1/1')).toBeFalsy(); - }); - - it('should apply custom footer appropriately when are multiple sources', () => { - render( - I am a custom footer }, - { title: 'How to bake bread', link: '' } - ]} - /> - ); - expect(screen.getByText('I am a custom footer')); - // does not show navigation bar - expect(screen.queryByText('1/2')).toBeFalsy(); - }); - - it('should apply footer props to custom footer appropriately', () => { - render( - I am a custom footer }]} - /> - ); - expect(screen.getByText('I am a custom footer')); - expect(screen.getByTestId('card-footer')).toHaveClass('test'); - }); - - it('should apply subtitle appropriately', () => { - render( - - ); - expect(screen.getByText('How to make an apple pie')); - expect(screen.getByText('You must first create the universe')); - }); }); diff --git a/packages/module/src/SourcesCard/SourcesCard.tsx b/packages/module/src/SourcesCard/SourcesCard.tsx index 446db1d28..0c63cbe53 100644 --- a/packages/module/src/SourcesCard/SourcesCard.tsx +++ b/packages/module/src/SourcesCard/SourcesCard.tsx @@ -1,29 +1,18 @@ // ============================================================================ // Chatbot Main - Messages - Sources Card // ============================================================================ -import type { FunctionComponent, MouseEvent as ReactMouseEvent, KeyboardEvent as ReactKeyboardEvent } from 'react'; -import { useState } from 'react'; +import type { FunctionComponent } from 'react'; // Import PatternFly components import { - Button, ButtonProps, - ButtonVariant, - Card, - CardBody, CardBodyProps, - CardFooter, CardFooterProps, CardProps, - CardTitle, CardTitleProps, - ExpandableSection, - ExpandableSectionVariant, - Icon, pluralize, - Truncate, TruncateProps } from '@patternfly/react-core'; -import { ExternalLinkSquareAltIcon } from '@patternfly/react-icons'; +import SourcesCardBase from '../SourcesCardBase'; export interface SourcesCardProps extends CardProps { /** Additional classes for the pagination navigation container. */ @@ -84,167 +73,15 @@ export interface SourcesCardProps extends CardProps { } const SourcesCard: FunctionComponent = ({ - className, - isDisabled, - paginationAriaLabel = 'Pagination', sources, sourceWord = 'source', sourceWordPlural = 'sources', - toNextPageAriaLabel = 'Go to next page', - toPreviousPageAriaLabel = 'Go to previous page', - onNextClick, - onPreviousClick, - onSetPage, - showMoreWords = 'show more', - showLessWords = 'show less', - isCompact, - cardTitleProps, - cardBodyProps, - cardFooterProps, ...props -}: SourcesCardProps) => { - const [page, setPage] = useState(1); - const [isExpanded, setIsExpanded] = useState(false); - - const onToggle = (_event: ReactMouseEvent, isExpanded: boolean) => { - setIsExpanded(isExpanded); - }; - - const handleNewPage = (_evt: ReactMouseEvent | ReactKeyboardEvent | MouseEvent, newPage: number) => { - setPage(newPage); - onSetPage && onSetPage(_evt, newPage); - }; - - const renderTitle = (title?: string, truncateProps?: TruncateProps) => { - if (title) { - return ; - } - return `Source ${page}`; - }; - - return ( -
- {pluralize(sources.length, sourceWord, sourceWordPlural)} - - -
- - {sources[page - 1].subtitle && ( - {sources[page - 1].subtitle} - )} -
-
- {sources[page - 1].body && ( - - {sources[page - 1].hasShowMore ? ( - // prevents extra VO announcements of button text - parent Message has aria-live -
- - {sources[page - 1].body} - -
- ) : ( -
{sources[page - 1].body}
- )} -
- )} - {sources[page - 1].footer ? ( - - {sources[page - 1].footer} - - ) : ( - sources.length > 1 && ( - -
- -
-
- ) - )} -
-
- ); -}; +}: SourcesCardProps) => ( +
+ {pluralize(sources.length, sourceWord, sourceWordPlural)} + +
+); export default SourcesCard; diff --git a/packages/module/src/SourcesCardBase/SourcesCardBase.test.tsx b/packages/module/src/SourcesCardBase/SourcesCardBase.test.tsx new file mode 100644 index 000000000..382bbf233 --- /dev/null +++ b/packages/module/src/SourcesCardBase/SourcesCardBase.test.tsx @@ -0,0 +1,236 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import SourcesCardBase from './SourcesCardBase'; + +describe('SourcesCardBase', () => { + it('should render card correctly if one source with only a link is passed in', () => { + render(); + 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/1')).toBeFalsy(); + }); + + it('should render card correctly if one source with a title is passed in', () => { + render(); + expect(screen.getByText('How to make an apple pie')).toBeTruthy(); + }); + + it('should render card correctly if one source with a body is passed in', () => { + render(); + expect(screen.getByText('To make an apple pie, you must first...')).toBeTruthy(); + }); + + it('should render card correctly if one source with a title and body is passed in', () => { + render( + + ); + expect(screen.getByText('How to make an apple pie')).toBeTruthy(); + expect(screen.getByText('To make an apple pie, you must first...')).toBeTruthy(); + }); + + it('should render multiple cards correctly', () => { + render( + + ); + expect(screen.getByText('How to make an apple pie')).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 }); + }); + + it('should navigate between cards correctly', async () => { + render( + + ); + expect(screen.getByText('How to make an apple pie')).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/2')).toBeTruthy(); + expect(screen.getByRole('button', { name: /Go to previous page/i })).toBeEnabled(); + expect(screen.getByRole('button', { name: /Go to next page/i })).toBeDisabled(); + }); + + it('should apply className appropriately', () => { + render( + + ); + const element = screen.getByRole('navigation'); + expect(element).toHaveClass('test'); + }); + + it('should disable pagination appropriately', () => { + render( + + ); + expect(screen.getByRole('button', { name: /Go to previous page/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /Go to next page/i })).toBeDisabled(); + }); + + it('should render navigation aria label appropriately', () => { + render( + + ); + expect(screen.getByRole('navigation', { name: /Pagination/i })).toBeTruthy(); + }); + + it('should change paginationAriaLabel appropriately', () => { + render( + + ); + expect(screen.getByRole('navigation', { name: /Navegación/i })).toBeTruthy(); + }); + + it('should change toNextPageAriaLabel appropriately', () => { + render( + + ); + expect(screen.getByRole('button', { name: /Pase a la siguiente página/i })).toBeTruthy(); + }); + + it('should change toPreviousPageAriaLabel appropriately', () => { + render( + + ); + expect(screen.getByRole('button', { name: /Presione para regresar a la página anterior/i })).toBeTruthy(); + }); + + it('should call onNextClick appropriately', async () => { + const spy = jest.fn(); + render( + + ); + await userEvent.click(screen.getByRole('button', { name: /Go to next page/i })); + expect(spy).toHaveBeenCalled(); + }); + + it('should call onPreviousClick appropriately', async () => { + const spy = jest.fn(); + render( + + ); + await userEvent.click(screen.getByRole('button', { name: /Go to next page/i })); + await userEvent.click(screen.getByRole('button', { name: /Go to previous page/i })); + expect(spy).toHaveBeenCalled(); + }); + + it('should call onSetPage appropriately', async () => { + const spy = jest.fn(); + render( + + ); + await userEvent.click(screen.getByRole('button', { name: /Go to next page/i })); + expect(spy).toHaveBeenCalledTimes(1); + await userEvent.click(screen.getByRole('button', { name: /Go to previous page/i })); + expect(spy).toHaveBeenCalledTimes(2); + }); + + it('should handle showMore appropriately', async () => { + render( + + ); + expect(screen.getByRole('region')).toHaveAttribute('class', 'pf-v6-c-expandable-section__content'); + }); + + it('should call onClick appropriately', async () => { + const spy = jest.fn(); + render(); + await userEvent.click(screen.getByRole('link', { name: /How to make an apple pie/i })); + expect(spy).toHaveBeenCalled(); + }); + + it('should apply titleProps appropriately', () => { + render( + + ); + expect(screen.getByRole('link', { name: /How to make an apple pie/i })).toHaveClass('test'); + }); +}); diff --git a/packages/module/src/SourcesCardBase/SourcesCardBase.tsx b/packages/module/src/SourcesCardBase/SourcesCardBase.tsx new file mode 100644 index 000000000..64423971c --- /dev/null +++ b/packages/module/src/SourcesCardBase/SourcesCardBase.tsx @@ -0,0 +1,242 @@ +// ============================================================================ +// Chatbot Main - Messages - Sources Card +// ============================================================================ +import type { FunctionComponent, MouseEvent as ReactMouseEvent, KeyboardEvent as ReactKeyboardEvent } from 'react'; +import { useState } from 'react'; +// Import PatternFly components +import { + Button, + ButtonProps, + ButtonVariant, + Card, + CardBody, + CardBodyProps, + CardFooter, + CardFooterProps, + CardProps, + CardTitle, + CardTitleProps, + ExpandableSection, + ExpandableSectionVariant, + Icon, + Truncate, + TruncateProps +} from '@patternfly/react-core'; +import { ExternalLinkSquareAltIcon } from '@patternfly/react-icons'; + +export interface SourcesCardBaseProps extends CardProps { + /** Additional classes for the pagination navigation container. */ + className?: string; + /** Flag indicating if the pagination is disabled. */ + isDisabled?: boolean; + /** @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 of sources card */ + title?: string; + /** Subtitle of sources card */ + subtitle?: string; + /** Link to source */ + link: string; + /** Body of sources card */ + body?: React.ReactNode | string; + /** Whether link is external */ + isExternal?: boolean; + /** Whether sources card is expandable */ + hasShowMore?: boolean; + /** onClick event applied to the title of the Sources card */ + onClick?: React.MouseEventHandler; + /** Any additional props applied to the title of the Sources card */ + titleProps?: ButtonProps; + /** Custom footer applied to the Sources card */ + footer?: React.ReactNode; + /** Additional props passed to Truncate component */ + truncateProps?: TruncateProps; + }[]; + /** Accessible label for the button which moves to the next page. */ + toNextPageAriaLabel?: string; + /** Accessible label for the button which moves to the previous page. */ + toPreviousPageAriaLabel?: string; + /** Function called when user clicks to navigate to next page. */ + onNextClick?: (event: React.SyntheticEvent, page: number) => void; + /** Function called when user clicks to navigate to previous page. */ + onPreviousClick?: (event: React.SyntheticEvent, 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; + /** Additional props passed to card title */ + cardTitleProps?: CardTitleProps; + /** Additional props passed to card body */ + cardBodyProps?: CardBodyProps; + /** Additional props passed to card footer */ + cardFooterProps?: CardFooterProps; +} + +const SourcesCardBase: FunctionComponent = ({ + className, + isDisabled, + paginationAriaLabel = 'Pagination', + sources, + toNextPageAriaLabel = 'Go to next page', + toPreviousPageAriaLabel = 'Go to previous page', + onNextClick, + onPreviousClick, + onSetPage, + showMoreWords = 'show more', + showLessWords = 'show less', + isCompact, + cardTitleProps, + cardBodyProps, + cardFooterProps, + ...props +}: SourcesCardBaseProps) => { + const [page, setPage] = useState(1); + const [isExpanded, setIsExpanded] = useState(false); + + const onToggle = (_event: ReactMouseEvent, isExpanded: boolean) => { + setIsExpanded(isExpanded); + }; + + const handleNewPage = (_evt: ReactMouseEvent | ReactKeyboardEvent | MouseEvent, newPage: number) => { + setPage(newPage); + onSetPage && onSetPage(_evt, newPage); + }; + + const renderTitle = (title?: string, truncateProps?: TruncateProps) => { + if (title) { + return ; + } + return `Source ${page}`; + }; + + return ( +
+ + +
+ + {sources[page - 1].subtitle && ( + {sources[page - 1].subtitle} + )} +
+
+ {sources[page - 1].body && ( + + {sources[page - 1].hasShowMore ? ( + // prevents extra VO announcements of button text - parent Message has aria-live +
+ + {sources[page - 1].body} + +
+ ) : ( +
{sources[page - 1].body}
+ )} +
+ )} + {sources[page - 1].footer ? ( + + {sources[page - 1].footer} + + ) : ( + sources.length > 1 && ( + +
+ +
+
+ ) + )} +
+
+ ); +}; + +export default SourcesCardBase; diff --git a/packages/module/src/SourcesCardBase/index.ts b/packages/module/src/SourcesCardBase/index.ts new file mode 100644 index 000000000..2bb0938b6 --- /dev/null +++ b/packages/module/src/SourcesCardBase/index.ts @@ -0,0 +1,3 @@ +export { default } from './SourcesCardBase'; + +export * from './SourcesCardBase'; diff --git a/packages/module/src/index.ts b/packages/module/src/index.ts index 110f71eff..73f999332 100644 --- a/packages/module/src/index.ts +++ b/packages/module/src/index.ts @@ -90,14 +90,17 @@ export * from './SourceDetailsMenuItem'; export { default as SourcesCard } from './SourcesCard'; export * from './SourcesCard'; +export { default as SourcesCardBase } from './SourcesCardBase'; +export * from './SourcesCardBase'; + export { default as TermsOfUse } from './TermsOfUse'; export * from './TermsOfUse'; -export { default as ToolResponse } from './ToolResponse'; -export * from './ToolResponse'; - export { default as ToolCall } from './ToolCall'; export * from './ToolCall'; +export { default as ToolResponse } from './ToolResponse'; +export * from './ToolResponse'; + export { default as tracking } from './tracking'; export * from './tracking'; From 88ba2209f531c50bb9a889fcd5d4f985d7124bc0 Mon Sep 17 00:00:00 2001 From: Rebecca Alpert Date: Thu, 18 Sep 2025 10:16:01 -0400 Subject: [PATCH 2/2] Address feedback --- packages/module/src/SourcesCard/SourcesCard.scss | 5 ++++- packages/module/src/SourcesCardBase/SourcesCardBase.tsx | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/module/src/SourcesCard/SourcesCard.scss b/packages/module/src/SourcesCard/SourcesCard.scss index aa403dd08..fb1892db5 100644 --- a/packages/module/src/SourcesCard/SourcesCard.scss +++ b/packages/module/src/SourcesCard/SourcesCard.scss @@ -1,10 +1,13 @@ -.pf-chatbot__source { +.pf-chatbot__source, +.pf-chatbot__sources-card-base { display: flex; flex-direction: column; gap: var(--pf-t--global--spacer--sm); padding-block-start: var(--pf-t--global--spacer--sm); max-width: 22.5rem; +} +.pf-chatbot__sources-card-base { a { color: var(--pf-t--global--text--color--link--default) !important; -webkit-text-decoration: var(--pf-t--global--text-decoration--link--line--default) !important; diff --git a/packages/module/src/SourcesCardBase/SourcesCardBase.tsx b/packages/module/src/SourcesCardBase/SourcesCardBase.tsx index 64423971c..dac16abc3 100644 --- a/packages/module/src/SourcesCardBase/SourcesCardBase.tsx +++ b/packages/module/src/SourcesCardBase/SourcesCardBase.tsx @@ -116,7 +116,7 @@ const SourcesCardBase: FunctionComponent = ({ }; return ( -
+