diff --git a/src/Messages.ts b/src/Messages.ts index bb29f924..f5b1a360 100644 --- a/src/Messages.ts +++ b/src/Messages.ts @@ -459,6 +459,17 @@ const messages = defineMessages({ id: 'helpPanel.search.favoriteService', defaultMessage: 'Favorite {title}', }, + + // Catalog Header + catalogHeaderTitle: { + id: 'catalog.header.title', + defaultMessage: 'Learning resources', + }, + catalogHeaderDescription: { + id: 'catalog.header.description', + defaultMessage: + 'Get quick access to documentation, quick starts, learning paths, and more related to {bundleTitle}. For all learning resources across the Hybrid Cloud Console, browse the All Learning catalog.', + }, }); export default messages; diff --git a/src/Viewer.stories.tsx b/src/Viewer.stories.tsx new file mode 100644 index 00000000..487d91b1 --- /dev/null +++ b/src/Viewer.stories.tsx @@ -0,0 +1,320 @@ +import type { Meta, StoryObj } from '@storybook/react-webpack5'; +import React, { Suspense } from 'react'; +import { IntlProvider } from 'react-intl'; +import { HttpResponse, http } from 'msw'; +import { expect, fn, userEvent, waitFor, within } from 'storybook/test'; +import { Viewer } from './Viewer'; +import { UnwrappedLoader } from '@redhat-cloud-services/frontend-components-utilities/useSuspenseLoader/useSuspenseLoader'; +import fetchAllData from './utils/fetchAllData'; + +/** + * Mock data for learning resources + */ +const mockFilters = { + data: { + categories: [ + { + categoryId: 'product-families', + categoryName: 'Product families', + categoryData: [ + { + group: 'Product families', + data: [ + { + id: 'insights', + filterLabel: 'RHEL', + cardLabel: 'RHEL', + }, + ], + }, + ], + }, + ], + }, +}; + +const mockQuickstarts = { + data: [ + { + content: { + metadata: { + name: 'doc-1', + tags: [{ kind: 'bundle', value: 'settings' }], + externalDocumentation: true, + favorite: false, + }, + spec: { + displayName: 'Getting started with Settings', + description: 'Overview of console settings', + type: { text: 'Documentation', color: 'orange' }, + link: { href: 'https://docs.redhat.com/settings' }, + }, + }, + }, + { + content: { + metadata: { + name: 'doc-2', + tags: [{ kind: 'bundle', value: 'settings' }], + externalDocumentation: true, + favorite: true, + }, + spec: { + displayName: 'Configuring integrations', + description: 'How to set up cloud integrations', + type: { text: 'Documentation', color: 'orange' }, + link: { href: 'https://docs.redhat.com/integrations' }, + }, + }, + }, + { + content: { + metadata: { + name: 'qs-1', + tags: [{ kind: 'bundle', value: 'settings' }], + favorite: false, + }, + spec: { + displayName: 'Configure console settings', + description: 'Step-by-step guide to configure console settings', + type: { text: 'Quick start', color: 'green' }, + link: { href: 'https://console.redhat.com/settings/quick-start' }, + }, + }, + }, + ], +}; + +const mockMswHandlers = [ + http.get('/api/quickstarts/v1/quickstarts/filters', () => { + return HttpResponse.json(mockFilters); + }), + http.get('/api/quickstarts/v1/quickstarts', () => { + return HttpResponse.json(mockQuickstarts); + }), + http.get('/api/quickstarts/v1/quickstarts/favorites', () => { + return HttpResponse.json({ data: [] }); + }), +]; + +/** + * Wrapper providing Chrome, IntlProvider and data loading. + */ +const ViewerWrapper = ({ bundle = 'settings' }: { bundle?: string }) => { + /* eslint-disable rulesdir/no-chrome-api-call-from-window */ + const originalRef = React.useRef<{ + getBundleData: typeof window.insights.chrome.getBundleData; + auth: typeof window.insights.chrome.auth; + hideGlobalFilter: typeof window.insights.chrome.hideGlobalFilter; + updateDocumentTitle: typeof window.insights.chrome.updateDocumentTitle; + } | null>(null); + + if (typeof window !== 'undefined' && window.insights?.chrome) { + if (!originalRef.current) { + originalRef.current = { + getBundleData: window.insights.chrome.getBundleData, + auth: window.insights.chrome.auth, + hideGlobalFilter: window.insights.chrome.hideGlobalFilter, + updateDocumentTitle: window.insights.chrome.updateDocumentTitle, + }; + } + window.insights.chrome.getBundleData = () => ({ + bundleId: bundle, + bundleTitle: bundle.charAt(0).toUpperCase() + bundle.slice(1), + }); + window.insights.chrome.auth = { + getUser: async () => ({ + identity: { + internal: { account_id: '12345' }, + user: { username: 'test-user' }, + }, + }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + window.insights.chrome.hideGlobalFilter = fn(); + window.insights.chrome.updateDocumentTitle = fn(); + } + + React.useEffect(() => { + return () => { + if (originalRef.current && window.insights?.chrome) { + window.insights.chrome.getBundleData = + originalRef.current.getBundleData; + window.insights.chrome.auth = originalRef.current.auth; + window.insights.chrome.hideGlobalFilter = + originalRef.current.hideGlobalFilter; + window.insights.chrome.updateDocumentTitle = + originalRef.current.updateDocumentTitle; + } + }; + }, [bundle]); + /* eslint-enable rulesdir/no-chrome-api-call-from-window */ + + // Create a mock loader + const mockLoader = (() => { + return [mockFilters, mockQuickstarts.data.map((item) => item.content)]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any as UnwrappedLoader; + + return ( + + Loading...}> +
+ +
+
+
+ ); +}; + +const meta: Meta = { + title: 'Pages/Learning Resources Viewer', + component: ViewerWrapper, + parameters: { + layout: 'fullscreen', + msw: { + handlers: mockMswHandlers, + }, + }, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +/** + * Full Learning Resources page with all sections. + * Shows header, filter bar, expandable sections, and sidebar. + */ +export const Default: Story = { + args: { bundle: 'settings' }, +}; + +/** + * Test that the page header link opens correctly. + */ +export const HeaderLinkWorks: Story = { + args: { bundle: 'settings' }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Wait for the page to load by finding the h1 + const title = await canvas.findByRole( + 'heading', + { + name: /Learning resources/i, + level: 1, + }, + { timeout: 5000 } + ); + expect(title).toBeInTheDocument(); + + // Find and verify the header link + const link = await canvas.findByRole('link', { + name: /All Learning catalog/i, + }); + expect(link).toHaveAttribute('href', '/learning-resources'); + expect(link).toHaveAttribute('target', '_blank'); + }, +}; + +/** + * Test that sections can be expanded and collapsed. + */ +export const SectionsExpandCollapse: Story = { + args: { bundle: 'settings' }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Wait for content to load - find the Documentation section toggle + const docToggle = await canvas.findByRole( + 'button', + { + name: /Documentation/i, + }, + { timeout: 5000 } + ); + expect(docToggle).toBeInTheDocument(); + + // Should be expanded by default + expect(docToggle).toHaveAttribute('aria-expanded', 'true'); + + // Collapse it + await userEvent.click(docToggle); + + await waitFor(() => { + expect(docToggle).toHaveAttribute('aria-expanded', 'false'); + }); + + // Expand it again + await userEvent.click(docToggle); + + await waitFor(() => { + expect(docToggle).toHaveAttribute('aria-expanded', 'true'); + }); + }, +}; + +/** + * Test that the filter search works. + */ +export const FilterSearch: Story = { + args: { bundle: 'settings' }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Wait for page to load + await waitFor( + async () => { + const searchInput = await canvas.findByPlaceholderText( + /Filter by keywords/i + ); + expect(searchInput).toBeInTheDocument(); + }, + { timeout: 5000 } + ); + + const searchInput = canvas.getByPlaceholderText(/Filter by keywords/i); + + // Type in the search + await userEvent.type(searchInput, 'integrations'); + + // Verify the input value + expect(searchInput).toHaveValue('integrations'); + }, +}; + +/** + * Test that the "Jump to section" sidebar works. + */ +export const JumpToSectionWorks: Story = { + args: { bundle: 'settings' }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Wait for sidebar to load and find the Jump to section heading + const jumpText = await canvas.findByText( + /Jump to section/i, + {}, + { timeout: 5000 } + ); + expect(jumpText).toBeInTheDocument(); + + // Verify sidebar navigation links are present (with counts in parentheses) + const bookmarksLink = canvas.getByRole('link', { name: /Bookmarks/i }); + const docsLink = canvas.getByRole('link', { + name: /Documentation \(\d+\)/i, + }); + + expect(bookmarksLink).toBeInTheDocument(); + expect(docsLink).toBeInTheDocument(); + }, +}; + +/** + * Viewer with different bundle. + */ +export const WithDifferentBundle: Story = { + args: { bundle: 'ansible' }, +}; diff --git a/src/Viewer.tsx b/src/Viewer.tsx index 94621c1b..40562620 100644 --- a/src/Viewer.tsx +++ b/src/Viewer.tsx @@ -5,11 +5,9 @@ import { EmptyState, PageGroup, PageSection, - Pagination, Sidebar, SidebarContent, SidebarPanel, - StackItem, } from '@patternfly/react-core'; import CatalogHeader from './components/CatalogHeader'; import CatalogFilter from './components/CatalogFilter'; @@ -48,11 +46,6 @@ export const Viewer = ({ const { documentation, learningPaths, other, bookmarks, quickStarts } = useQuickStarts(allQuickStarts, localFilter); - const [pagination, setPagination] = useState({ - count: bookmarks.length, - perPage: 20, - page: 1, - }); const quickStartsCount = quickStarts.length + @@ -88,16 +81,9 @@ export const Viewer = ({ hasBodyWrapper={false} className="pf-v6-u-p-lg lr-c-catalog__header" > - - - - - - + + +
+ +
{showBookmarks && ( } - rightTitle={ - { - setPagination((pagination) => ({ - ...pagination, - page: newPage, - })); - }} - widgetId="pagination-options-menu-top" - onPerPageSelect={(_e, perPage) => - setPagination((pagination) => ({ - ...pagination, - perPage, - })) - } - isCompact - /> - } - isExpandable={false} - sectionQuickStarts={bookmarks.slice( - (pagination.page - 1) * pagination.perPage, - pagination.page * (pagination.perPage - 1) + 1 - )} + sectionQuickStarts={bookmarks} /> - )} - - - void; +}) => { + const [searchValue, setSearchValue] = React.useState(''); + + const handleChange = (value: string) => { + setSearchValue(value); + onSearchInputChange?.(value); + }; + + return ( + +
+ +
+ Current search: “{searchValue}” +
+
+
+ ); +}; + +const meta: Meta = { + title: 'Components/Catalog/CatalogFilter', + component: CatalogFilterWrapper, + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +/** + * Default filter bar with search input and item count. + */ +export const Default: Story = { + args: { + quickStartsCount: 42, + }, +}; + +/** + * Filter bar with no items. + */ +export const NoItems: Story = { + args: { + quickStartsCount: 0, + }, +}; + +/** + * Filter bar with many items. + */ +export const ManyItems: Story = { + args: { + quickStartsCount: 156, + }, +}; + +/** + * Test that typing in the search input triggers the callback. + */ +export const SearchInputTriggersCallback: Story = { + args: { + quickStartsCount: 42, + onSearchInputChange: fn(), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + // Find the search input + const searchInput = await canvas.findByPlaceholderText( + 'Filter by keywords...' + ); + expect(searchInput).toBeInTheDocument(); + + // Type in the search input + await userEvent.type(searchInput, 'documentation'); + + // Wait for the callback to be called + await waitFor(() => { + expect(args.onSearchInputChange).toHaveBeenCalled(); + }); + + // Verify the last call had the correct value + const calls = (args.onSearchInputChange as ReturnType).mock + .calls; + const lastCall = calls[calls.length - 1]; + expect(lastCall[0]).toBe('documentation'); + }, +}; + +/** + * Test that clearing the search input works. + */ +export const ClearSearch: Story = { + args: { + quickStartsCount: 42, + onSearchInputChange: fn(), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + const searchInput = await canvas.findByPlaceholderText( + 'Filter by keywords...' + ); + + // Type some text + await userEvent.type(searchInput, 'test'); + + // Clear the input + await userEvent.clear(searchInput); + + await waitFor(() => { + const calls = (args.onSearchInputChange as ReturnType).mock + .calls; + const lastCall = calls[calls.length - 1]; + expect(lastCall[0]).toBe(''); + }); + }, +}; + +/** + * Verify the item count is displayed and styled correctly (bold). + */ +export const ItemCountDisplayed: Story = { + args: { + quickStartsCount: 99, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // The count is rendered by QuickStartCatalogFilterCountWrapper from PatternFly + // which typically shows "X Learning resources" or similar + await waitFor(() => { + const countText = canvas.getByText(/99/); + expect(countText).toBeInTheDocument(); + }); + }, +}; diff --git a/src/components/CatalogFilter.tsx b/src/components/CatalogFilter.tsx index b6e558c5..e9cd8bce 100644 --- a/src/components/CatalogFilter.tsx +++ b/src/components/CatalogFilter.tsx @@ -13,13 +13,13 @@ const CatalogFilter = ({ }) => { return ( - + onSearchInputChange(str)} /> - + diff --git a/src/components/CatalogHeader.scss b/src/components/CatalogHeader.scss index 976e2030..f5aa2ff1 100644 --- a/src/components/CatalogHeader.scss +++ b/src/components/CatalogHeader.scss @@ -1,5 +1,11 @@ .lr-c-catalog__header { - &-bundle { - color: var(--pf-t--global--text--color--subtle); + // Icon sizing to match other settings pages + .iconMinWidth-1-2-2 { + min-width: 60px; + + img { + width: 48px; + height: 48px; + } } } \ No newline at end of file diff --git a/src/components/CatalogHeader.stories.tsx b/src/components/CatalogHeader.stories.tsx new file mode 100644 index 00000000..328d862e --- /dev/null +++ b/src/components/CatalogHeader.stories.tsx @@ -0,0 +1,119 @@ +import type { Meta, StoryObj } from '@storybook/react-webpack5'; +import React from 'react'; +import { IntlProvider } from 'react-intl'; +import { expect, spyOn, userEvent, waitFor, within } from 'storybook/test'; +import CatalogHeader from './CatalogHeader'; + +/** + * Wrapper providing IntlProvider and chrome bundle overrides. + */ +const CatalogHeaderWrapper = ({ bundle = 'settings' }: { bundle?: string }) => { + /* eslint-disable rulesdir/no-chrome-api-call-from-window */ + const originalRef = React.useRef<{ + getBundleData: typeof window.insights.chrome.getBundleData; + } | null>(null); + + if (typeof window !== 'undefined' && window.insights?.chrome) { + if (!originalRef.current) { + originalRef.current = { + getBundleData: window.insights.chrome.getBundleData, + }; + } + window.insights.chrome.getBundleData = () => ({ + bundleId: bundle, + bundleTitle: bundle.charAt(0).toUpperCase() + bundle.slice(1), + }); + } + + React.useEffect(() => { + return () => { + if (originalRef.current && window.insights?.chrome) { + window.insights.chrome.getBundleData = + originalRef.current.getBundleData; + } + }; + }, [bundle]); + /* eslint-enable rulesdir/no-chrome-api-call-from-window */ + + return ( + +
+ +
+
+ ); +}; + +const meta: Meta = { + title: 'Components/Catalog/CatalogHeader', + component: CatalogHeaderWrapper, + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +/** + * Default catalog header with Settings bundle. + * Shows icon, title, description, and link to All Learning catalog. + */ +export const Default: Story = { + args: { bundle: 'settings' }, +}; + +/** + * Test that the "All Learning catalog" link opens in a new tab. + */ +export const LinkOpensInNewTab: Story = { + args: { bundle: 'settings' }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const openSpy = spyOn(window, 'open').mockImplementation(() => null); + + try { + const link = await canvas.findByRole('link', { + name: /All Learning catalog/i, + }); + + // Verify link attributes + expect(link).toHaveAttribute('href', '/learning-resources'); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + + // Click the link + await userEvent.click(link); + + // Note: The link won't actually call window.open since it's a regular tag, + // but we verify the attributes are correct for accessibility and security + } finally { + openSpy.mockRestore(); + } + }, +}; + +/** + * Catalog header with different bundle (e.g., Ansible). + */ +export const WithDifferentBundle: Story = { + args: { bundle: 'ansible' }, +}; + +/** + * Verify the header displays the correct bundle title in the description. + */ +export const DisplaysBundleTitle: Story = { + args: { bundle: 'openshift' }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Wait for the description to appear with the bundle name + await waitFor(() => { + const description = canvas.getByText(/related to Openshift/i); + expect(description).toBeInTheDocument(); + }); + }, +}; diff --git a/src/components/CatalogHeader.tsx b/src/components/CatalogHeader.tsx index fa25b348..340271fc 100644 --- a/src/components/CatalogHeader.tsx +++ b/src/components/CatalogHeader.tsx @@ -1,7 +1,16 @@ -import { Stack, StackItem, Title } from '@patternfly/react-core'; +import { + Content, + ContentVariants, + Divider, + Flex, + FlexItem, + Title, +} from '@patternfly/react-core'; import React from 'react'; +import { FormattedMessage } from 'react-intl'; import './CatalogHeader.scss'; import { useChrome } from '@redhat-cloud-services/frontend-components/useChrome'; +import messages from '../Messages'; const CatalogHeader = () => { // FIXME: Add missing type to the types lib @@ -10,22 +19,41 @@ const CatalogHeader = () => { const { getBundleData } = useChrome(); const { bundleTitle } = getBundleData(); return ( - - - - {bundleTitle} - - - - - Learning Resources - - - + ); }; diff --git a/src/components/CatalogSection.scss b/src/components/CatalogSection.scss index f0e13cb1..16d3a73d 100644 --- a/src/components/CatalogSection.scss +++ b/src/components/CatalogSection.scss @@ -1,16 +1,30 @@ .lr-c-catalog-section { + background-color: var(--pf-t--global--background--color--secondary--default); + padding: var(--pf-t--global--spacer--md); + border-radius: var(--pf-t--global--border--radius--medium); + margin-bottom: var(--pf-t--global--spacer--lg); + + // Fix expandable section arrow rotation + --pf-v6-c-expandable-section__toggle-icon--Rotate: 0deg; + + &.pf-m-expanded { + --pf-v6-c-expandable-section__toggle-icon--Rotate: 90deg; + } + &__static { // No PF variable exists for this padding-left: 28px; } + .pf-v6-c-expandable-section__toggle { - display: flex; - align-items: center; padding-top: 0; } + .pf-v6-c-expandable-section__content { margin-top: 0; } + + h3 { color: var(--pf-t--global--text--color--regular); span { diff --git a/src/components/CatalogSection.stories.tsx b/src/components/CatalogSection.stories.tsx new file mode 100644 index 00000000..d1324d81 --- /dev/null +++ b/src/components/CatalogSection.stories.tsx @@ -0,0 +1,292 @@ +import type { Meta, StoryObj } from '@storybook/react-webpack5'; +import React from 'react'; +import { IntlProvider } from 'react-intl'; +import { expect, fn, userEvent, waitFor, within } from 'storybook/test'; +import { BookmarkedIcon } from './common/BookmarkIcon'; +import CatalogSection from './CatalogSection'; +import { ExtendedQuickstart } from '../utils/fetchQuickstarts'; +import { FilterMap } from '../utils/filtersInterface'; +import { TagsEnum } from '../utils/tagsEnum'; + +const emptyFilterMap: FilterMap = { + [TagsEnum.ProductFamilies]: {}, + [TagsEnum.UseCase]: {}, +}; + +const mockQuickstarts: ExtendedQuickstart[] = [ + { + apiVersion: 'console.openshift.io/v1', + kind: 'QuickStarts', + metadata: { + name: 'doc-sample-1', + tags: [], + favorite: false, + }, + spec: { + version: 0.1, + displayName: 'Getting started with Red Hat Hybrid Cloud Console', + icon: , + description: 'Overview and basic instructions for using the console.', + type: { text: 'Documentation', color: 'orange' }, + link: { href: 'https://docs.redhat.com/example' }, + }, + }, + { + apiVersion: 'console.openshift.io/v1', + kind: 'QuickStarts', + metadata: { + name: 'doc-sample-2', + tags: [], + favorite: false, + }, + spec: { + version: 0.1, + displayName: 'Configuring notifications and integrations', + icon: , + description: 'Configuring settings for event-triggered notifications.', + type: { text: 'Documentation', color: 'orange' }, + link: { href: 'https://docs.redhat.com/notifications' }, + }, + }, +]; + +const mockBookmarkedQuickstarts: ExtendedQuickstart[] = [ + { + apiVersion: 'console.openshift.io/v1', + kind: 'QuickStarts', + metadata: { + name: 'bookmarked-sample-1', + tags: [], + favorite: true, + }, + spec: { + version: 0.1, + displayName: 'Configuring cloud integrations for Red Hat services', + icon: , + description: 'How to link your Red Hat account to a public cloud.', + type: { text: 'Documentation', color: 'orange' }, + link: { href: 'https://docs.redhat.com/cloud-integration' }, + }, + }, +]; + +const CatalogSectionWrapper = ({ + sectionCount, + sectionQuickStarts, + sectionName, + sectionTitle, + sectionDescription, + isExpandable = true, + emptyBody, + purgeCache = fn(), +}: { + sectionCount: number; + sectionQuickStarts: ExtendedQuickstart[]; + sectionName: string; + sectionTitle: React.ReactNode; + sectionDescription?: string; + isExpandable?: boolean; + emptyBody?: React.ReactNode; + purgeCache?: () => void; +}) => { + return ( + +
+ +
+
+ ); +}; + +const meta: Meta = { + title: 'Components/Catalog/CatalogSection', + component: CatalogSectionWrapper, + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +/** + * Default expandable section with documentation items. + */ +export const DocumentationSection: Story = { + args: { + sectionName: 'documentation', + sectionTitle: 'Documentation', + sectionDescription: 'Technical information for using the service', + sectionCount: mockQuickstarts.length, + sectionQuickStarts: mockQuickstarts, + }, +}; + +/** + * Bookmarks section with bookmarked icon. + */ +export const BookmarksSection: Story = { + args: { + sectionName: 'bookmarks', + sectionTitle: ( + + + Bookmarks + + ), + sectionCount: mockBookmarkedQuickstarts.length, + sectionQuickStarts: mockBookmarkedQuickstarts, + }, +}; + +/** + * Empty section with no items. + */ +export const EmptySection: Story = { + args: { + sectionName: 'learning-paths', + sectionTitle: 'Learning paths', + sectionDescription: 'Collections of learning materials', + sectionCount: 0, + sectionQuickStarts: [], + }, +}; + +/** + * Disabled section (empty and expandable). + * When a section has 0 items and isExpandable=true, it renders as disabled. + */ +export const DisabledSection: Story = { + args: { + sectionName: 'other-content-types', + sectionTitle: 'Other content types', + sectionDescription: 'Tutorials, videos, e-books', + sectionCount: 0, + sectionQuickStarts: [], + isExpandable: true, + }, +}; + +/** + * Test that clicking the expandable section toggle collapses/expands the content. + */ +export const ExpandCollapseInteraction: Story = { + args: { + sectionName: 'quick-starts', + sectionTitle: 'Quick starts', + sectionDescription: 'Step-by-step instructions and tasks', + sectionCount: mockQuickstarts.length, + sectionQuickStarts: mockQuickstarts, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Find the expandable section toggle button + const toggleButton = await canvas.findByRole('button', { + name: /Quick starts/i, + }); + + // Section should be expanded by default + expect(toggleButton).toHaveAttribute('aria-expanded', 'true'); + + // Click to collapse + await userEvent.click(toggleButton); + + await waitFor(() => { + expect(toggleButton).toHaveAttribute('aria-expanded', 'false'); + }); + + // Click to expand again + await userEvent.click(toggleButton); + + await waitFor(() => { + expect(toggleButton).toHaveAttribute('aria-expanded', 'true'); + }); + }, +}; + +/** + * Test that the arrow icon rotates correctly. + */ +export const ArrowRotation: Story = { + args: { + sectionName: 'documentation', + sectionTitle: 'Documentation', + sectionCount: mockQuickstarts.length, + sectionQuickStarts: mockQuickstarts, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const toggleButton = await canvas.findByRole('button', { + name: /Documentation/i, + }); + + // Expanded state - arrow should point down (90deg rotation) + expect(toggleButton).toHaveAttribute('aria-expanded', 'true'); + + // Collapse the section + await userEvent.click(toggleButton); + + await waitFor(() => { + // Collapsed state - arrow should point right (0deg rotation) + expect(toggleButton).toHaveAttribute('aria-expanded', 'false'); + }); + }, +}; + +/** + * Section with many items to test scrolling behavior. + */ +export const SectionWithManyItems: Story = { + args: { + sectionName: 'documentation', + sectionTitle: 'Documentation', + sectionDescription: 'Technical information for using the service', + sectionCount: 10, + sectionQuickStarts: Array.from({ length: 10 }, (_, i) => ({ + ...mockQuickstarts[0], + metadata: { + ...mockQuickstarts[0].metadata, + name: `doc-sample-${i}`, + }, + spec: { + ...mockQuickstarts[0].spec, + displayName: `Documentation Item ${i + 1}`, + description: `Sample documentation description ${i + 1}`, + }, + })), + }, +}; + +/** + * Verify that the badge displays the correct count. + */ +export const BadgeDisplaysCount: Story = { + args: { + sectionName: 'documentation', + sectionTitle: 'Documentation', + sectionCount: 42, + sectionQuickStarts: mockQuickstarts, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Find the badge with the count + const badge = await canvas.findByText('42'); + expect(badge).toBeInTheDocument(); + expect(badge).toHaveClass('pf-v6-c-badge'); + }, +}; diff --git a/src/components/GlobalLearningResourcesPage/GlobalLearningResourcesQuickstartItem.tsx b/src/components/GlobalLearningResourcesPage/GlobalLearningResourcesQuickstartItem.tsx index 2db8feea..1d0b2bfc 100644 --- a/src/components/GlobalLearningResourcesPage/GlobalLearningResourcesQuickstartItem.tsx +++ b/src/components/GlobalLearningResourcesPage/GlobalLearningResourcesQuickstartItem.tsx @@ -71,7 +71,10 @@ const GlobalLearningResourcesQuickstartItem: React.FC< }; return ( - +