diff --git a/src/components/tutorials-sidebar/components/tutorial-view-sidebar-content/__tests__/tutorial-view-sidebar-content.test.tsx b/src/components/tutorials-sidebar/components/tutorial-view-sidebar-content/__tests__/tutorial-view-sidebar-content.test.tsx new file mode 100644 index 0000000000..80350f14fa --- /dev/null +++ b/src/components/tutorials-sidebar/components/tutorial-view-sidebar-content/__tests__/tutorial-view-sidebar-content.test.tsx @@ -0,0 +1,160 @@ +import { vi } from 'vitest' + +// Mock hooks to avoid provider/context requirements and network +vi.mock('hooks/progress', () => ({ + useCollectionProgress: () => ({ data: {}, isLoading: false }), + useTutorialProgress: () => ({ tutorialProgressStatus: 'not-started' }), +})) + +// Mock child components to simple renderers +vi.mock('components/collection-progress-group', () => ({ + CollectionProgressStatusSection: ({ completedTutorialCount, tutorialCount, isInProgress }) => ( +
+ Progress: {completedTutorialCount}/{tutorialCount} {isInProgress ? '(in progress)' : ''} +
+ ), + parseCollectionProgress: () => ({ completedTutorialCount: 0, tutorialCount: 0, isInProgress: false }), +})) + +vi.mock('components/tutorial-progress-icon', () => ({ + default: () => , +})) + +vi.mock('components/tutorials-sidebar', () => ({ + SectionList: ({ children }) =>
{children}
, +})) + +vi.mock('components/sidebar/components', () => ({ + SidebarNavMenuItem: ({ item }) => ( + + {item.title} + + ), +})) + +import { render, screen } from '@testing-library/react' +import { describe, it, expect } from 'vitest' +import { ErrorBoundary } from 'components/error-boundary' +import TutorialViewSidebarContent from '../index' +import type { Collection } from 'lib/learn-client/types' +import type { TutorialListItemProps } from 'components/tutorials-sidebar/types' +import userEvent from '@testing-library/user-event' + +describe('TutorialViewSidebarContent', () => { + it('renders with data and correct href', () => { + const mockCollection = { + id: 'collection-id', + slug: 'collection-slug', + tutorials: [{ id: 'tutorial-id', title: 'Tutorial Title' }], + } as unknown as Collection + + const mockItems: TutorialListItemProps[] = [ + { + text: 'Tutorial Title', + href: '/tutorial', + isActive: false, + tutorialId: 'tutorial-id', + collectionId: 'collection-id', + }, + ] + + render() + + expect(screen.getByText('Tutorial Title')).toBeInTheDocument() + const link = screen.getByText('Tutorial Title').closest('a') + expect(link).toHaveAttribute('href', '/tutorial') + }) + + it('handles loading state gracefully (no crash, container renders)', () => { + const mockCollection = {} as unknown as Collection // Simulate loading/missing data + const mockItems: TutorialListItemProps[] = [] + + render( + + + + ) + + expect(screen.queryByText('Tutorial Title')).not.toBeInTheDocument() + expect(screen.getByTestId('section-list')).toBeInTheDocument() + }) + + it('handles rapid navigation (re-render) without errors', async () => { + const mockCollection1 = { + id: 'collection-1', + slug: 'collection-1-slug', + tutorials: [{ id: 'tutorial-1', title: 'Tutorial 1' }], + } as unknown as Collection + + const mockCollection2 = { + id: 'collection-2', + slug: 'collection-2-slug', + tutorials: [{ id: 'tutorial-2', title: 'Tutorial 2' }], + } as unknown as Collection + + const mockItems1: TutorialListItemProps[] = [ + { + text: 'Tutorial 1', + href: '/tutorial-1', + isActive: false, + tutorialId: 'tutorial-1', + collectionId: 'collection-1', + }, + ] + + const mockItems2: TutorialListItemProps[] = [ + { + text: 'Tutorial 2', + href: '/tutorial-2', + isActive: false, + tutorialId: 'tutorial-2', + collectionId: 'collection-2', + }, + ] + + const { rerender } = render( + + + + ) + + expect(screen.getByText('Tutorial 1')).toBeInTheDocument() + + rerender( + + + + ) + + expect(screen.getByText('Tutorial 2')).toBeInTheDocument() + expect(screen.queryByText('Tutorial 1')).not.toBeInTheDocument() + }) + + it('navigates correctly when clicking a sidebar item (no error)', async () => { + const mockCollection = { + id: 'collection-id', + slug: 'collection-slug', + tutorials: [{ id: 'tutorial-id', title: 'Tutorial Title' }], + } as unknown as Collection + + const mockItems: TutorialListItemProps[] = [ + { + text: 'Tutorial Title', + href: '/tutorial', + isActive: false, + tutorialId: 'tutorial-id', + collectionId: 'collection-id', + }, + ] + + render() + + const linkEl = screen.getByText('Tutorial Title') + await userEvent.click(linkEl) + + expect(screen.getByText('Tutorial Title')).toBeInTheDocument() + expect(screen.queryByText(/Something went wrong/)).not.toBeInTheDocument() + const link = linkEl.closest('a') + expect(link).toHaveAttribute('href', '/tutorial') + }) +}) diff --git a/src/components/tutorials-sidebar/components/tutorial-view-sidebar-content/index.tsx b/src/components/tutorials-sidebar/components/tutorial-view-sidebar-content/index.tsx index d70376f4d6..74f5d38cab 100644 --- a/src/components/tutorials-sidebar/components/tutorial-view-sidebar-content/index.tsx +++ b/src/components/tutorials-sidebar/components/tutorial-view-sidebar-content/index.tsx @@ -15,11 +15,13 @@ import { import TutorialProgressIcon from 'components/tutorial-progress-icon' import { SidebarNavMenuItem } from 'components/sidebar/components' import { SectionList } from 'components/tutorials-sidebar' +import { ErrorBoundary } from 'components/error-boundary' // Types import { TutorialListItemProps } from 'components/tutorials-sidebar/types' // Styles import s from './tutorial-view-sidebar-content.module.css' import { Collection } from 'lib/learn-client/types' +import React from 'react' /** * Renders sidebar content for tutorial views. @@ -56,30 +58,44 @@ function TutorialViewSidebarContent({ * Displays collection progress status. */ function CollectionProgress({ collection }: { collection: Collection }) { - const { id, slug, tutorials } = collection + const { id, slug, tutorials } = collection ?? {} /** * Get collection progress, which affects the * CTA bar we display for the collection. */ - const { data: progressData } = useCollectionProgress({ collectionId: id }) + const { data: progressData, isLoading } = useCollectionProgress({ + collectionId: id, + }) /** * Parse the progress-related information we need from the progress records, * current collection slug, and list of tutorials in this collection. */ - const { completedTutorialCount, tutorialCount, isInProgress } = useMemo( - () => parseCollectionProgress(progressData, tutorials.length, { id, slug }), - [progressData, tutorials, id, slug] - ) + const { completedTutorialCount, tutorialCount, isInProgress } = + useMemo(() => { + if (!collection || !id || !tutorials) { + return { + completedTutorialCount: 0, + tutorialCount: 0, + isInProgress: false, + } + } + return parseCollectionProgress(progressData, tutorials.length, { + id, + slug, + }) + }, [progressData, tutorials, id, slug, collection]) return (
- + {!isLoading && collection && id && tutorials && ( + + )}
) } @@ -96,19 +112,35 @@ function TutorialListItem({ collectionId, }: TutorialListItemProps) { /** - * Query for progress, and display the appropriate status icon + * Query for progress if we have valid IDs */ const { tutorialProgressStatus } = useTutorialProgress({ tutorialId, collectionId, }) - const trailingIcon = ( - - ) + + /** + * Only show the progress icon if we have valid IDs + */ + const trailingIcon = + tutorialId && collectionId ? ( + + ) : null return ( ) } -export default TutorialViewSidebarContent +function TutorialViewSidebarContentWithBoundary(props) { + return ( + + + + ) +} + +export default TutorialViewSidebarContentWithBoundary