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
@@ -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 }) => (
<div data-testid="collection-progress">
Progress: {completedTutorialCount}/{tutorialCount} {isInProgress ? '(in progress)' : ''}
</div>
),
parseCollectionProgress: () => ({ completedTutorialCount: 0, tutorialCount: 0, isInProgress: false }),
}))

vi.mock('components/tutorial-progress-icon', () => ({
default: () => <span data-testid="tutorial-progress-icon" />,
}))

vi.mock('components/tutorials-sidebar', () => ({
SectionList: ({ children }) => <div data-testid="section-list">{children}</div>,
}))

vi.mock('components/sidebar/components', () => ({
SidebarNavMenuItem: ({ item }) => (
<a href={item.href} data-active={item.isActive ? 'true' : 'false'}>
{item.title}
</a>
),
}))

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(<TutorialViewSidebarContent collection={mockCollection} items={mockItems} />)

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(
<ErrorBoundary>
<TutorialViewSidebarContent collection={mockCollection} items={mockItems} />
</ErrorBoundary>
)

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(
<ErrorBoundary>
<TutorialViewSidebarContent collection={mockCollection1} items={mockItems1} />
</ErrorBoundary>
)

expect(screen.getByText('Tutorial 1')).toBeInTheDocument()

rerender(
<ErrorBoundary>
<TutorialViewSidebarContent collection={mockCollection2} items={mockItems2} />
</ErrorBoundary>
)

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(<TutorialViewSidebarContent collection={mockCollection} items={mockItems} />)

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')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 (
<div className={s.collectionProgressContainer}>
<CollectionProgressStatusSection
completedTutorialCount={completedTutorialCount}
tutorialCount={tutorialCount}
isInProgress={isInProgress}
/>
{!isLoading && collection && id && tutorials && (
<CollectionProgressStatusSection
completedTutorialCount={completedTutorialCount}
tutorialCount={tutorialCount}
isInProgress={isInProgress}
/>
)}
</div>
)
}
Expand All @@ -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 = (
<TutorialProgressIcon status={tutorialProgressStatus} isActive={isActive} />
)

/**
* Only show the progress icon if we have valid IDs
*/
const trailingIcon =
tutorialId && collectionId ? (
<TutorialProgressIcon
status={tutorialProgressStatus}
isActive={isActive}
/>
) : null

return (
<SidebarNavMenuItem item={{ isActive, title: text, href, trailingIcon }} />
)
}

export default TutorialViewSidebarContent
function TutorialViewSidebarContentWithBoundary(props) {
return (
<ErrorBoundary>
<TutorialViewSidebarContent {...props} />
</ErrorBoundary>
)
}

export default TutorialViewSidebarContentWithBoundary
Loading