diff --git a/src/course-home/data/__factories__/courseBlocks.factory.js b/src/course-home/data/__factories__/courseBlocks.factory.js index 1fa6dd90..a31a4090 100644 --- a/src/course-home/data/__factories__/courseBlocks.factory.js +++ b/src/course-home/data/__factories__/courseBlocks.factory.js @@ -61,10 +61,16 @@ export default function buildSimpleCourseBlocks(courseId, title, options = {}) { )]; const sectionBlock = options.sectionBlock || Factory.build( 'block', - { type: 'chapter', children: sequenceBlock.map(block => block.id), resume_block: false }, + { + type: 'chapter', + display_name: 'Title of Section', + complete: options.complete || false, + resume_block: options.resumeBlock || false, + children: sequenceBlock.map(block => block.id), + }, { courseId }, ); - const courseBlock = options.courseBlocks || Factory.build( + const courseBlock = options.courseBlock || Factory.build( 'block', { type: 'course', display_name: title, children: [sectionBlock.id] }, { courseId }, diff --git a/src/course-home/data/__factories__/outlineTabData.factory.js b/src/course-home/data/__factories__/outlineTabData.factory.js index da622209..61476826 100644 --- a/src/course-home/data/__factories__/outlineTabData.factory.js +++ b/src/course-home/data/__factories__/outlineTabData.factory.js @@ -19,7 +19,7 @@ Factory.define('outlineTabData') }) .attr('course_goals', [], () => ({ goal_options: [], - selected_goal: {}, + selected_goal: null, })) .attr('enroll_alert', { can_enroll: true, diff --git a/src/course-home/data/__snapshots__/redux.test.js.snap b/src/course-home/data/__snapshots__/redux.test.js.snap index b88bb17d..08c8efe5 100644 --- a/src/course-home/data/__snapshots__/redux.test.js.snap +++ b/src/course-home/data/__snapshots__/redux.test.js.snap @@ -358,7 +358,7 @@ Object { "sequenceIds": Array [ "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1", ], - "title": "bcdabcdabcdabcdabcdabcdabcdabcd2", + "title": "Title of Section", }, }, "sequences": Object { @@ -377,7 +377,7 @@ Object { "courseExpiredHtml": "
Course expired
", "courseGoals": Object { "goalOptions": Array [], - "selectedGoal": Object {}, + "selectedGoal": null, }, "courseTools": Array [ Object { diff --git a/src/course-home/dates-tab/DatesTab.test.jsx b/src/course-home/dates-tab/DatesTab.test.jsx index 5d1fb0eb..03bf7f2d 100644 --- a/src/course-home/dates-tab/DatesTab.test.jsx +++ b/src/course-home/dates-tab/DatesTab.test.jsx @@ -19,31 +19,26 @@ import { UserMessagesProvider } from '../../generic/user-messages'; initializeMockApp(); describe('DatesTab', () => { - let store; - let component; let axiosMock; - let courseId; + + const store = initializeStore(); + const component = ( + + + + + + + + + + ); + const courseMetadata = Factory.build('courseHomeMetadata'); + const { courseId } = courseMetadata; beforeEach(() => { - store = initializeStore(); - component = ( - - - - - - - - - - ); - axiosMock = new MockAdapter(getAuthenticatedHttpClient()); - - const courseMetadata = Factory.build('courseHomeMetadata'); - courseId = courseMetadata.courseId; axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`).reply(200, courseMetadata); - history.push(`/course/${courseId}/dates`); // so tab can pull course id from url }); diff --git a/src/course-home/outline-tab/DateSummary.jsx b/src/course-home/outline-tab/DateSummary.jsx index acc36cd4..a0d36480 100644 --- a/src/course-home/outline-tab/DateSummary.jsx +++ b/src/course-home/outline-tab/DateSummary.jsx @@ -35,11 +35,10 @@ export default function DateSummary({ &&
{dateBlock.title}
} {dateBlock.description - &&
{dateBlock.description}
} + &&
{dateBlock.description}
} {!linkedTitle && dateBlock.link && {dateBlock.linkText}} - ); } diff --git a/src/course-home/outline-tab/OutlineTab.test.jsx b/src/course-home/outline-tab/OutlineTab.test.jsx index 9a1b0637..c75e4da8 100644 --- a/src/course-home/outline-tab/OutlineTab.test.jsx +++ b/src/course-home/outline-tab/OutlineTab.test.jsx @@ -1,107 +1,342 @@ import React from 'react'; import { Factory } from 'rosie'; import { getConfig } from '@edx/frontend-platform'; -import MockAdapter from 'axios-mock-adapter'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import OutlineTab from './OutlineTab'; +import MockAdapter from 'axios-mock-adapter'; +import userEvent from '@testing-library/user-event'; + +import { ALERT_TYPES } from '../../generic/user-messages'; +import buildSimpleCourseBlocks from '../data/__factories__/courseBlocks.factory'; import { - fireEvent, initializeTestStore, logUnhandledRequests, render, screen, waitFor, + fireEvent, initializeMockApp, logUnhandledRequests, render, screen, waitFor, } from '../../setupTest'; import executeThunk from '../../utils'; import * as thunks from '../data/thunks'; -import { ALERT_TYPES } from '../../generic/user-messages'; +import initializeStore from '../../store'; +import OutlineTab from './OutlineTab'; +initializeMockApp(); jest.mock('@edx/frontend-platform/analytics'); describe('Outline Tab', () => { - let store; let axiosMock; - const courseMetadata = Factory.build('courseMetadata'); - const courseHomeMetadata = Factory.build( - 'courseHomeMetadata', { - course_id: courseMetadata.id, - }, - { courseTabs: courseMetadata.tabs }, - ); - const outlineTabData = Factory.build('outlineTabData', { - courseId: courseMetadata.id, - resume_course: { - has_visited_course: false, - url: `${getConfig().LMS_BASE_URL}/courses/${courseMetadata.id}/jump_to/block-v1:edX+Test+Block@12345abcde`, - }, - }); - const outlineUrl = new RegExp(`${getConfig().LMS_BASE_URL}/api/course_home/v1/outline/*`); const courseMetadataUrl = new RegExp(`${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/*`); + const goalUrl = new RegExp(`${getConfig().LMS_BASE_URL}/api/course_home/v1/save_course_goal`); + const outlineUrl = new RegExp(`${getConfig().LMS_BASE_URL}/api/course_home/v1/outline/*`); + + const store = initializeStore(); + + const courseMetadata = Factory.build('courseHomeMetadata'); + const { courseId } = courseMetadata; + const outlineTabData = Factory.build('outlineTabData'); beforeEach(async () => { - store = await initializeTestStore({ excludeFetchCourse: true, excludeFetchSequence: true, courseMetadata }); - axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata); axiosMock.onGet(outlineUrl).reply(200, outlineTabData); - axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadata); + axiosMock.onPost(goalUrl).reply(200, { header: 'Success' }); logUnhandledRequests(axiosMock); - await executeThunk(thunks.fetchOutlineTab(courseMetadata.id), store.dispatch); + await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch); }); - it('displays link to start course', () => { - render(); - expect(screen.getByRole('link', { name: 'Start Course' })).toBeInTheDocument(); - }); - - it('displays link to resume course', async () => { - const outlineTabDataHasVisited = Factory.build('outlineTabData', { - courseId: courseMetadata.id, - resume_course: { - has_visited_course: true, - url: `${getConfig().LMS_BASE_URL}/courses/${courseMetadata.id}/jump_to/block-v1:edX+Test+Block@12345abcde`, - }, + describe('Course Outline', () => { + it('displays link to start course', () => { + render(, { store }); + expect(screen.getByRole('link', { name: 'Start Course' })).toBeInTheDocument(); }); - axiosMock.onGet(outlineUrl).reply(200, outlineTabDataHasVisited); - await executeThunk(thunks.fetchOutlineTab(courseMetadata.id), store.dispatch); - render(); + it('displays link to resume course', async () => { + const outlineTabDataHasVisited = Factory.build('outlineTabData', { + courseId, + resume_course: { + has_visited_course: true, + url: `${getConfig().LMS_BASE_URL}/courses/${courseId}/jump_to/block-v1:edX+Test+Block@12345abcde`, + }, + }); + axiosMock.onGet(outlineUrl).reply(200, outlineTabDataHasVisited); + await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch); - expect(screen.getByRole('link', { name: 'Resume Course' })).toBeInTheDocument(); + render(, { store }); + + expect(screen.getByRole('link', { name: 'Resume Course' })).toBeInTheDocument(); + }); + + it('expands section that contains resume block', async () => { + const { courseBlocks } = await buildSimpleCourseBlocks(courseId, outlineTabData.title, { resumeBlock: true }); + const outlineTabDataResumeBlock = Factory.build('outlineTabData', { + courseId, + course_blocks: { blocks: courseBlocks.blocks }, + }); + axiosMock.onGet(outlineUrl).reply(200, outlineTabDataResumeBlock); + await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch); + + render(, { store }); + const expandedSectionNode = screen.getByRole('button', { name: /Title of Section/ }); + expect(expandedSectionNode).toHaveAttribute('aria-expanded', 'true'); + }); + + it('handles expand/collapse all button click', () => { + render(, { store }); + // Button renders as "Expand All" + const expandButton = screen.getByRole('button', { name: 'Expand All' }); + expect(expandButton).toBeInTheDocument(); + + // Section initially renders collapsed + const collapsedSectionNode = screen.getByRole('button', { name: /section/ }); + expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'false'); + + // Click to expand section + userEvent.click(expandButton); + expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'true'); + + // Click to collapse section + userEvent.click(expandButton); + expect(collapsedSectionNode).toHaveAttribute('aria-expanded', 'false'); + }); + + it('displays correct icon for complete assignment', async () => { + const { courseBlocks } = await buildSimpleCourseBlocks(courseId, outlineTabData.title, { complete: true }); + const outlineTabDataCompleteAssignment = Factory.build('outlineTabData', { + courseId, + course_blocks: { blocks: courseBlocks.blocks }, + }); + axiosMock.onGet(outlineUrl).reply(200, outlineTabDataCompleteAssignment); + await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch); + + render(, { store }); + expect(screen.getByTitle('Completed section')).toBeInTheDocument(); + }); + + it('displays correct icon for incomplete assignment', async () => { + const { courseBlocks } = await buildSimpleCourseBlocks(courseId, outlineTabData.title, { complete: false }); + const outlineTabDataIncompleteAssignment = Factory.build('outlineTabData', { + courseId, + course_blocks: { blocks: courseBlocks.blocks }, + }); + axiosMock.onGet(outlineUrl).reply(200, outlineTabDataIncompleteAssignment); + await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch); + + render(, { store }); + expect(screen.getByTitle('Incomplete section')).toBeInTheDocument(); + }); + }); + + describe('Welcome Message', () => { + it('does not render show more/less button under 200 characters', () => { + render(, { store }); + expect(screen.getByTestId('alert-container-welcome')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Show more' })).not.toBeInTheDocument(); + }); + + describe('over 200 characters', () => { + beforeEach(async () => { + const outlineTabDataLongMessage = Factory.build('outlineTabData', { + courseId, + welcome_message_html: '

' + + 'Welcome to Demonstration Course!!! This message is over 200 characters long. We would like to test the ' + + 'shorten message feature! When the page renders, this text should be shortened because it is very long.' + + '

', + }); + axiosMock.onGet(outlineUrl).reply(200, outlineTabDataLongMessage); + await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch); + + render(, { store }); + }); + + it('shortens message', async () => { + expect(screen.getByTestId('short-welcome-message-iframe')).toBeInTheDocument(); + const showMoreButton = screen.queryByRole('button', { name: 'Show More' }); + expect(showMoreButton).toBeInTheDocument(); + }); + + it('renders show more/less button and handles click', async () => { + expect(screen.getByTestId('alert-container-welcome')).toBeInTheDocument(); + let showMoreButton = screen.getByRole('button', { name: 'Show More' }); + expect(showMoreButton).toBeInTheDocument(); + + userEvent.click(showMoreButton); + let showLessButton = screen.getByRole('button', { name: 'Show Less' }); + expect(showLessButton).toBeInTheDocument(); + expect(screen.getByTestId('long-welcome-message-iframe')).toBeInTheDocument(); + + userEvent.click(showLessButton); + showLessButton = screen.queryByRole('button', { name: 'Show Less' }); + expect(showLessButton).not.toBeInTheDocument(); + showMoreButton = screen.getByRole('button', { name: 'Show More' }); + expect(showMoreButton).toBeInTheDocument(); + }); + }); + + it('does not display if no update available', async () => { + const outlineTabDataSansUpdate = Factory.build('outlineTabData', { + courseId, + welcome_message_html: null, + }); + axiosMock.onGet(outlineUrl).reply(200, outlineTabDataSansUpdate); + await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch); + + render(, { store }); + expect(screen.queryByTestId('alert-container-welcome')).not.toBeInTheDocument(); + }); + }); + + describe('Course Goals', () => { + const goalOptions = [ + ['certify', 'Earn a certificate'], + ['complete', 'Complete the course'], + ['explore', 'Explore the course'], + ['unsure', 'Not sure yet'], + ]; + + it('does not render goal widgets if no goals available', () => { + render(, { store }); + expect(screen.queryByTestId('course-goal-card')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Goal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('edit-goal-selector')).not.toBeInTheDocument(); + }); + + describe('goal is not set', () => { + beforeEach(async () => { + const outlineTabDataGoalNotSet = Factory.build('outlineTabData', { + courseId, + course_goals: { + goal_options: goalOptions, + selected_goal: null, + }, + }); + axiosMock.onGet(outlineUrl).reply(200, outlineTabDataGoalNotSet); + await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch); + + render(, { store }); + }); + + it('renders goal card', () => { + expect(screen.queryByLabelText('Goal')).not.toBeInTheDocument(); + expect(screen.getByTestId('course-goal-card')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Earn a certificate' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Complete the course' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Explore the course' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Not sure yet' })).toBeInTheDocument(); + }); + + it('renders goal selector on goal selection', async () => { + const certifyGoalButton = screen.getByRole('button', { name: 'Earn a certificate' }); + fireEvent.click(certifyGoalButton); + + const goalSelector = await screen.findByTestId('edit-goal-selector'); + expect(goalSelector).toBeInTheDocument(); + }); + }); + + describe('goal is set', () => { + beforeEach(async () => { + const outlineTabDataGoalSet = Factory.build('outlineTabData', { + courseId, + course_goals: { + goal_options: goalOptions, + selected_goal: { text: 'Earn a certificate', key: 'certify' }, + }, + }); + + axiosMock.onGet(outlineUrl).reply(200, outlineTabDataGoalSet); + await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch); + + render(, { store }); + }); + + it('renders edit goal selector', () => { + expect(screen.getByLabelText('Goal')).toBeInTheDocument(); + expect(screen.getByTestId('edit-goal-selector')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Earn a certificate' })).toBeInTheDocument(); + }); + + it('updates goal on click', async () => { + // Open dropdown + const dropdownButtonNode = screen.getByRole('button', { name: 'Earn a certificate' }); + await waitFor(() => { + expect(dropdownButtonNode).toBeInTheDocument(); + }); + fireEvent.click(dropdownButtonNode); + + // Select a new goal + const unsureButtonNode = screen.getByRole('button', { name: 'Not sure yet' }); + await waitFor(() => { + expect(unsureButtonNode).toBeInTheDocument(); + }); + fireEvent.click(unsureButtonNode); + + // Verify the request was made + await waitFor(() => { + expect(axiosMock.history.post[0].url).toMatch(goalUrl); + expect(axiosMock.history.post[0].data).toMatch(`{"course_id":"${courseId}","goal_key":"unsure"}`); + }); + }); + }); + }); + + describe('Course Handouts', () => { + it('renders title when handouts are available', () => { + render(, { store }); + expect(screen.queryByRole('heading', { name: 'Course Handouts' })).toBeInTheDocument(); + }); + + it('does not display title if no handouts available', async () => { + const outlineTabDataSansHandout = Factory.build('outlineTabData', { + courseId, + handouts_html: null, + }); + axiosMock.onGet(outlineUrl).reply(200, outlineTabDataSansHandout); + await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch); + + render(, { store }); + expect(screen.queryByRole('heading', { name: 'Course Handouts' })).not.toBeInTheDocument(); + }); }); describe('Alert List', () => { describe('Enrollment Alert', () => { - const extraText = outlineTabData.enroll_alert.extra_text; - const alertMessage = `You must be enrolled in the course to see course content. ${extraText}`; - const staffMessage = 'You are viewing this course as staff, and are not enrolled.'; + let extraText; + let alertMessage; + let staffMessage; + + beforeEach(() => { + extraText = outlineTabData.enroll_alert.extra_text; + alertMessage = `You must be enrolled in the course to see course content. ${extraText}`; + staffMessage = 'You are viewing this course as staff, and are not enrolled.'; + }); it('does not display enrollment alert for enrolled user', async () => { const courseHomeMetadataForEnrolledUser = Factory.build( - 'courseHomeMetadata', { course_id: courseMetadata.id, is_enrolled: true }, + 'courseHomeMetadata', { course_id: courseId, is_enrolled: true }, { courseTabs: courseMetadata.tabs }, ); axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadataForEnrolledUser); - await executeThunk(thunks.fetchOutlineTab(courseMetadata.id), store.dispatch); + await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch); - render(); + render(, { store }); expect(screen.queryByText(alertMessage)).not.toBeInTheDocument(); }); it('does not display enrollment button if enrollment is not available', async () => { const outlineTabDataCannotEnroll = Factory.build('outlineTabData', { - courseId: courseMetadata.id, + courseId, enroll_alert: { can_enroll: false, extra_text: extraText, }, }); axiosMock.onGet(outlineUrl).reply(200, outlineTabDataCannotEnroll); - await executeThunk(thunks.fetchOutlineTab(courseMetadata.id), store.dispatch); + await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch); - render(); + render(, { store }); expect(screen.queryByRole('button', { name: 'Enroll Now' })).not.toBeInTheDocument(); }); it('displays enrollment alert for unenrolled user', async () => { - render(); + render(, { store }); const alert = await screen.findByText(alertMessage); expect(alert).toHaveAttribute('role', 'alert'); @@ -113,7 +348,7 @@ describe('Outline Tab', () => { it('displays different message for unenrolled staff user', async () => { const courseHomeMetadataForUnenrolledStaff = Factory.build( - 'courseHomeMetadata', { course_id: courseMetadata.id, is_staff: true }, + 'courseHomeMetadata', { course_id: courseId, is_staff: true }, { courseTabs: courseMetadata.tabs }, ); axiosMock.onGet(courseMetadataUrl).reply(200, courseHomeMetadataForUnenrolledStaff); @@ -121,14 +356,14 @@ describe('Outline Tab', () => { // show, which makes this test easier to write. If there's only one, it's easy to query // for below. const outlineTabDataCannotEnroll = Factory.build('outlineTabData', { - courseId: courseMetadata.id, + courseId, offer_html: null, course_expired_html: null, }); axiosMock.onGet(outlineUrl).reply(200, outlineTabDataCannotEnroll); - await executeThunk(thunks.fetchOutlineTab(courseMetadata.id), store.dispatch); + await executeThunk(thunks.fetchOutlineTab(courseId), store.dispatch); - render(); + render(, { store }); const alert = await screen.findByText(staffMessage); expect(alert).toHaveAttribute('role', 'alert'); @@ -146,13 +381,13 @@ describe('Outline Tab', () => { window.location = { reload: jest.fn(), }; - render(); + render(, { store }); const button = await screen.findByRole('button', { name: 'Enroll Now' }); fireEvent.click(button); await waitFor(() => expect(axiosMock.history.post).toHaveLength(1)); expect(axiosMock.history.post[0].data) - .toEqual(JSON.stringify({ course_details: { course_id: courseMetadata.id } })); + .toEqual(JSON.stringify({ course_details: { course_id: courseId } })); expect(window.location.reload).toHaveBeenCalledTimes(1); window.location = location; diff --git a/src/course-home/outline-tab/widgets/CourseGoalCard.jsx b/src/course-home/outline-tab/widgets/CourseGoalCard.jsx index 7c068423..1247b9b0 100644 --- a/src/course-home/outline-tab/widgets/CourseGoalCard.jsx +++ b/src/course-home/outline-tab/widgets/CourseGoalCard.jsx @@ -34,7 +34,7 @@ function CourseGoalCard({ } return ( - +
diff --git a/src/course-home/outline-tab/widgets/UpdateGoalSelector.jsx b/src/course-home/outline-tab/widgets/UpdateGoalSelector.jsx index 8d27dba5..5134d718 100644 --- a/src/course-home/outline-tab/widgets/UpdateGoalSelector.jsx +++ b/src/course-home/outline-tab/widgets/UpdateGoalSelector.jsx @@ -45,7 +45,7 @@ function UpdateGoalSelector({
- + {selectedGoal.text} diff --git a/src/course-home/outline-tab/widgets/WelcomeMessage.jsx b/src/course-home/outline-tab/widgets/WelcomeMessage.jsx index 9b62b5a6..5bb8977c 100644 --- a/src/course-home/outline-tab/widgets/WelcomeMessage.jsx +++ b/src/course-home/outline-tab/widgets/WelcomeMessage.jsx @@ -53,12 +53,14 @@ function WelcomeMessage({ courseId, intl }) { {showShortMessage ? ( ) : (