From d47433ee8385206df13376aeef336e8bcfa97ebb Mon Sep 17 00:00:00 2001 From: Kristin Aoki <42981026+KristinAoki@users.noreply.github.com> Date: Mon, 28 Oct 2024 10:31:17 -0400 Subject: [PATCH] feat: add functionality to see unit draft preview (#1501) * feat: add functionality to see unit draft preview * feat: add tests for course link redirects * fix: course redirect unit to sequnce unit redirect * fix: test coverage --- src/constants.ts | 2 + src/courseware/CoursewareContainer.jsx | 221 +++-- src/courseware/CoursewareContainer.test.jsx | 845 +++++++++++++++++- src/courseware/course/Course.jsx | 8 + src/courseware/course/sequence/Sequence.jsx | 1 + .../course/sequence/SequenceContent.jsx | 10 +- src/courseware/course/sequence/Unit/index.jsx | 7 +- .../course/sequence/Unit/index.test.jsx | 4 +- src/courseware/course/sequence/Unit/urls.js | 2 + .../course/sequence/Unit/urls.test.js | 22 +- .../SequenceNavigation.jsx | 64 +- .../sequence-navigation/UnitButton.jsx | 7 +- .../sequence-navigation/UnitNavigation.jsx | 60 +- .../generic/NextButton.jsx | 56 ++ .../generic/PreviousButton.jsx | 44 + src/courseware/data/api.js | 6 +- src/courseware/utils.jsx | 6 +- 17 files changed, 1194 insertions(+), 171 deletions(-) create mode 100644 src/courseware/course/sequence/sequence-navigation/generic/NextButton.jsx create mode 100644 src/courseware/course/sequence/sequence-navigation/generic/PreviousButton.jsx diff --git a/src/constants.ts b/src/constants.ts index ac8ec85151..484d84530d 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -13,6 +13,8 @@ export const DECODE_ROUTES = { '/course/:courseId/:sequenceId/:unitId', '/course/:courseId/:sequenceId', '/course/:courseId', + '/preview/course/:courseId/:sequenceId/:unitId', + '/preview/course/:courseId/:sequenceId', ], REDIRECT_HOME: 'home/:courseId', REDIRECT_SURVEY: 'survey/:courseId', diff --git a/src/courseware/CoursewareContainer.jsx b/src/courseware/CoursewareContainer.jsx index 8929419075..309b72daf8 100644 --- a/src/courseware/CoursewareContainer.jsx +++ b/src/courseware/CoursewareContainer.jsx @@ -19,62 +19,50 @@ import { handleNextSectionCelebration } from './course/celebration'; import withParamsAndNavigation from './utils'; // Look at where this is called in componentDidUpdate for more info about its usage -const checkResumeRedirect = memoize((courseStatus, courseId, sequenceId, firstSequenceId, navigate) => { - if (courseStatus === 'loaded' && !sequenceId) { - // Note that getResumeBlock is just an API call, not a redux thunk. - getResumeBlock(courseId).then((data) => { - // This is a replace because we don't want this change saved in the browser's history. - if (data.sectionId && data.unitId) { - navigate(`/course/${courseId}/${data.sectionId}/${data.unitId}`, { replace: true }); - } else if (firstSequenceId) { - navigate(`/course/${courseId}/${firstSequenceId}`, { replace: true }); - } - }); - } -}); +export const checkResumeRedirect = memoize( + (courseStatus, courseId, sequenceId, firstSequenceId, navigate, isPreview) => { + if (courseStatus === 'loaded' && !sequenceId) { + // Note that getResumeBlock is just an API call, not a redux thunk. + getResumeBlock(courseId).then((data) => { + // This is a replace because we don't want this change saved in the browser's history. + if (data.sectionId && data.unitId) { + const baseUrl = `/course/${courseId}/${data.sectionId}`; + const sequenceUrl = isPreview ? `/preview${baseUrl}` : baseUrl; + navigate(`${sequenceUrl}/${data.unitId}`, { replace: true }); + } else if (firstSequenceId) { + navigate(`/course/${courseId}/${firstSequenceId}`, { replace: true }); + } + }, () => {}); + } + }, +); // Look at where this is called in componentDidUpdate for more info about its usage -const checkSectionUnitToUnitRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId, navigate) => { +export const checkSectionUnitToUnitRedirect = memoize(( + courseStatus, + courseId, + sequenceStatus, + section, + unitId, + navigate, + isPreview, +) => { if (courseStatus === 'loaded' && sequenceStatus === 'failed' && section && unitId) { - navigate(`/course/${courseId}/${unitId}`, { replace: true }); + const baseUrl = `/course/${courseId}`; + const courseUrl = isPreview ? `/preview${baseUrl}` : baseUrl; + navigate(`${courseUrl}/${unitId}`, { replace: true }); } }); // Look at where this is called in componentDidUpdate for more info about its usage -const checkSectionToSequenceRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId, navigate) => { - if (courseStatus === 'loaded' && sequenceStatus === 'failed' && section && !unitId) { - // If the section is non-empty, redirect to its first sequence. - if (section.sequenceIds && section.sequenceIds[0]) { - navigate(`/course/${courseId}/${section.sequenceIds[0]}`, { replace: true }); - // Otherwise, just go to the course root, letting the resume redirect take care of things. - } else { - navigate(`/course/${courseId}`, { replace: true }); - } - } -}); - -// Look at where this is called in componentDidUpdate for more info about its usage -const checkUnitToSequenceUnitRedirect = memoize( - (courseStatus, courseId, sequenceStatus, sequenceMightBeUnit, sequenceId, section, routeUnitId, navigate) => { - if (courseStatus === 'loaded' && sequenceStatus === 'failed' && !section && !routeUnitId) { - if (sequenceMightBeUnit) { - // If the sequence failed to load as a sequence, but it is marked as a possible unit, then - // we need to look up the correct parent sequence for it, and redirect there. - const unitId = sequenceId; // just for clarity during the rest of this method - getSequenceForUnitDeprecated(courseId, unitId).then( - parentId => { - if (parentId) { - navigate(`/course/${courseId}/${parentId}/${unitId}`, { replace: true }); - } else { - navigate(`/course/${courseId}`, { replace: true }); - } - }, - () => { // error case - navigate(`/course/${courseId}`, { replace: true }); - }, - ); +export const checkSectionToSequenceRedirect = memoize( + (courseStatus, courseId, sequenceStatus, section, unitId, navigate) => { + if (courseStatus === 'loaded' && sequenceStatus === 'failed' && section && !unitId) { + // If the section is non-empty, redirect to its first sequence. + if (section.sequenceIds && section.sequenceIds[0]) { + navigate(`/course/${courseId}/${section.sequenceIds[0]}`, { replace: true }); + // Otherwise, just go to the course root, letting the resume redirect take care of things. } else { - // Invalid sequence that isn't a unit either. Redirect up to main course. navigate(`/course/${courseId}`, { replace: true }); } } @@ -82,41 +70,80 @@ const checkUnitToSequenceUnitRedirect = memoize( ); // Look at where this is called in componentDidUpdate for more info about its usage -const checkSequenceToSequenceUnitRedirect = memoize((courseId, sequenceStatus, sequence, unitId, navigate) => { - if (sequenceStatus === 'loaded' && sequence.id && !unitId) { - if (sequence.unitIds !== undefined && sequence.unitIds.length > 0) { - const nextUnitId = sequence.unitIds[sequence.activeUnitIndex]; - // This is a replace because we don't want this change saved in the browser's history. - navigate(`/course/${courseId}/${sequence.id}/${nextUnitId}`, { replace: true }); +export const checkUnitToSequenceUnitRedirect = memoize(( + courseStatus, + courseId, + sequenceStatus, + sequenceMightBeUnit, + sequenceId, + section, + routeUnitId, + navigate, + isPreview, +) => { + if (courseStatus === 'loaded' && sequenceStatus === 'failed' && !section && !routeUnitId) { + if (sequenceMightBeUnit) { + // If the sequence failed to load as a sequence, but it is marked as a possible unit, then + // we need to look up the correct parent sequence for it, and redirect there. + const unitId = sequenceId; // just for clarity during the rest of this method + getSequenceForUnitDeprecated(courseId, unitId).then( + parentId => { + if (parentId) { + const baseUrl = `/course/${courseId}/${parentId}`; + const sequenceUrl = isPreview ? `/preview${baseUrl}` : baseUrl; + navigate(`${sequenceUrl}/${unitId}`, { replace: true }); + } else { + navigate(`/course/${courseId}`, { replace: true }); + } + }, + () => { // error case + navigate(`/course/${courseId}`, { replace: true }); + }, + ); + } else { + // Invalid sequence that isn't a unit either. Redirect up to main course. + navigate(`/course/${courseId}`, { replace: true }); } } }); // Look at where this is called in componentDidUpdate for more info about its usage -const checkSequenceUnitMarkerToSequenceUnitRedirect = memoize( - (courseId, sequenceStatus, sequence, unitId, navigate) => { +export const checkSequenceToSequenceUnitRedirect = memoize( + (courseId, sequenceStatus, sequence, unitId, navigate, isPreview) => { + if (sequenceStatus === 'loaded' && sequence.id && !unitId) { + if (sequence.unitIds !== undefined && sequence.unitIds.length > 0) { + const baseUrl = `/course/${courseId}/${sequence.id}`; + const sequenceUrl = isPreview ? `/preview${baseUrl}` : baseUrl; + const nextUnitId = sequence.unitIds[sequence.activeUnitIndex]; + // This is a replace because we don't want this change saved in the browser's history. + navigate(`${sequenceUrl}/${nextUnitId}`, { replace: true }); + } + } + }, +); + +// Look at where this is called in componentDidUpdate for more info about its usage +export const checkSequenceUnitMarkerToSequenceUnitRedirect = memoize( + (courseId, sequenceStatus, sequence, unitId, navigate, isPreview) => { if (sequenceStatus !== 'loaded' || !sequence.id) { return; } + const baseUrl = `/course/${courseId}/${sequence.id}`; const hasUnits = sequence.unitIds?.length > 0; - if (unitId === 'first') { - if (hasUnits) { + if (hasUnits) { + const sequenceUrl = isPreview ? `/preview${baseUrl}` : baseUrl; + if (unitId === 'first') { const firstUnitId = sequence.unitIds[0]; - navigate(`/course/${courseId}/${sequence.id}/${firstUnitId}`, { replace: true }); - } else { - // No units... go to general sequence page - navigate(`/course/${courseId}/${sequence.id}`, { replace: true }); - } - } else if (unitId === 'last') { - if (hasUnits) { + navigate(`${sequenceUrl}/${firstUnitId}`, { replace: true }); + } else if (unitId === 'last') { const lastUnitId = sequence.unitIds[sequence.unitIds.length - 1]; - navigate(`/course/${courseId}/${sequence.id}/${lastUnitId}`, { replace: true }); - } else { - // No units... go to general sequence page - navigate(`/course/${courseId}/${sequence.id}`, { replace: true }); + navigate(`${sequenceUrl}/${lastUnitId}`, { replace: true }); } + } else { + // No units... go to general sequence page + navigate(baseUrl, { replace: true }); } }, ); @@ -169,6 +196,7 @@ class CoursewareContainer extends Component { routeSequenceId, routeUnitId, navigate, + isPreview, } = this.props; // Load data whenever the course or sequence ID changes. @@ -197,7 +225,7 @@ class CoursewareContainer extends Component { // Check resume redirect: // /course/:courseId -> /course/:courseId/:sequenceId/:unitId // based on sequence/unit where user was last active. - checkResumeRedirect(courseStatus, courseId, sequenceId, firstSequenceId, navigate); + checkResumeRedirect(courseStatus, courseId, sequenceId, firstSequenceId, navigate, isPreview); // Check section-unit to unit redirect: // /course/:courseId/:sectionId/:unitId -> /course/:courseId/:unitId @@ -210,33 +238,69 @@ class CoursewareContainer extends Component { // otherwise, we could get stuck in a redirect loop, since a sequence that failed to load // would endlessly redirect to itself through `checkSectionUnitToUnitRedirect` // and `checkUnitToSequenceUnitRedirect`. - checkSectionUnitToUnitRedirect(courseStatus, courseId, sequenceStatus, sectionViaSequenceId, routeUnitId, navigate); + checkSectionUnitToUnitRedirect( + courseStatus, + courseId, + sequenceStatus, + sectionViaSequenceId, + routeUnitId, + navigate, + isPreview, + ); // Check section to sequence redirect: // /course/:courseId/:sectionId -> /course/:courseId/:sequenceId // by redirecting to the first sequence within the section. - checkSectionToSequenceRedirect(courseStatus, courseId, sequenceStatus, sectionViaSequenceId, routeUnitId, navigate); + checkSectionToSequenceRedirect( + courseStatus, + courseId, + sequenceStatus, + sectionViaSequenceId, + routeUnitId, + navigate, + ); // Check unit to sequence-unit redirect: // /course/:courseId/:unitId -> /course/:courseId/:sequenceId/:unitId // by filling in the ID of the parent sequence of :unitId. - checkUnitToSequenceUnitRedirect(( - courseStatus, courseId, sequenceStatus, sequenceMightBeUnit, - sequenceId, sectionViaSequenceId, routeUnitId, navigate - )); + checkUnitToSequenceUnitRedirect( + courseStatus, + courseId, + sequenceStatus, + sequenceMightBeUnit, + sequenceId, + sectionViaSequenceId, + routeUnitId, + navigate, + isPreview, + ); // Check sequence to sequence-unit redirect: // /course/:courseId/:sequenceId -> /course/:courseId/:sequenceId/:unitId // by filling in the ID the most-recently-active unit in the sequence, OR // the ID of the first unit the sequence if none is active. - checkSequenceToSequenceUnitRedirect(courseId, sequenceStatus, sequence, routeUnitId, navigate); + checkSequenceToSequenceUnitRedirect( + courseId, + sequenceStatus, + sequence, + routeUnitId, + navigate, + isPreview, + ); // Check sequence-unit marker to sequence-unit redirect: // /course/:courseId/:sequenceId/first -> /course/:courseId/:sequenceId/:unitId // /course/:courseId/:sequenceId/last -> /course/:courseId/:sequenceId/:unitId // by filling in the ID the first or last unit in the sequence. // "Sequence unit marker" is an invented term used only in this component. - checkSequenceUnitMarkerToSequenceUnitRedirect(courseId, sequenceStatus, sequence, routeUnitId, navigate); + checkSequenceUnitMarkerToSequenceUnitRedirect( + courseId, + sequenceStatus, + sequence, + routeUnitId, + navigate, + isPreview, + ); } handleUnitNavigationClick = () => { @@ -334,6 +398,7 @@ CoursewareContainer.propTypes = { fetchCourse: PropTypes.func.isRequired, fetchSequence: PropTypes.func.isRequired, navigate: PropTypes.func.isRequired, + isPreview: PropTypes.bool.isRequired, }; CoursewareContainer.defaultProps = { diff --git a/src/courseware/CoursewareContainer.test.jsx b/src/courseware/CoursewareContainer.test.jsx index 1db3514d82..b1012746d1 100644 --- a/src/courseware/CoursewareContainer.test.jsx +++ b/src/courseware/CoursewareContainer.test.jsx @@ -16,11 +16,19 @@ import tabMessages from '../tab-page/messages'; import { initializeMockApp, waitFor } from '../setupTest'; import { DECODE_ROUTES } from '../constants'; -import CoursewareContainer from './CoursewareContainer'; +import CoursewareContainer, { + checkResumeRedirect, + checkSectionToSequenceRedirect, + checkSectionUnitToUnitRedirect, + checkSequenceToSequenceUnitRedirect, + checkSequenceUnitMarkerToSequenceUnitRedirect, + checkUnitToSequenceUnitRedirect, +} from './CoursewareContainer'; import { buildSimpleCourseBlocks, buildBinaryCourseBlocks } from '../shared/data/__factories__/courseBlocks.factory'; import initializeStore from '../store'; import { appendBrowserTimezoneToUrl } from '../utils'; import { buildOutlineFromBlocks } from './data/__factories__/learningSequencesOutline.factory'; +import { getSequenceForUnitDeprecatedUrl } from './data/api'; // NOTE: Because the unit creates an iframe, we choose to mock it out as its rendering isn't // pertinent to this test. Instead, we render a simple div that displays the properties we expect @@ -525,3 +533,838 @@ describe('CoursewareContainer', () => { }); }); }); + +describe('Course redirect functions', () => { + let navigate; + let axiosMock; + + beforeEach(() => { + navigate = jest.fn(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + }); + + describe('isPreview equals true', () => { + describe('checkSequenceUnitMarkerToSequenceUnitRedirect', () => { + it('return when sequence is not loaded', () => { + checkSequenceUnitMarkerToSequenceUnitRedirect( + 'courseId', + 'loading', + { id: 'sequence_1', unitIds: ['unit_1'] }, + 'first', + navigate, + true, + ); + + expect(navigate).not.toHaveBeenCalled(); + }); + + it('return when sequence id is null', () => { + checkSequenceUnitMarkerToSequenceUnitRedirect( + 'courseId', + 'loaded', + { id: null, unitIds: ['unit_1'] }, + 'first', + navigate, + true, + ); + + expect(navigate).not.toHaveBeenCalled(); + }); + + it('calls navigate with first unit id', () => { + checkSequenceUnitMarkerToSequenceUnitRedirect( + 'courseId', + 'loaded', + { id: 'sequence_1', unitIds: ['unit_1', 'unit_2'] }, + 'first', + navigate, + true, + ); + const expectedUrl = '/preview/course/courseId/sequence_1/unit_1'; + + expect(navigate).toHaveBeenCalledWith(expectedUrl, { replace: true }); + }); + + it('calls navigate with last unit id', () => { + checkSequenceUnitMarkerToSequenceUnitRedirect( + 'courseId', + 'loaded', + { id: 'sequence_1', unitIds: ['unit_1', 'unit_2'] }, + 'last', + navigate, + true, + ); + const expectedUrl = '/preview/course/courseId/sequence_1/unit_2'; + + expect(navigate).toHaveBeenCalledWith(expectedUrl, { replace: true }); + }); + }); + + describe('checkSequenceToSequenceUnitRedirect', () => { + it('calls navigate with next unit id', () => { + checkSequenceToSequenceUnitRedirect( + 'courseId', + 'loaded', + { id: 'sequence_1', unitIds: ['unit_1', 'unit_2'], activeUnitIndex: 0 }, + null, + navigate, + true, + ); + const expectedUrl = '/preview/course/courseId/sequence_1/unit_1'; + + expect(navigate).toHaveBeenCalledWith(expectedUrl, { replace: true }); + }); + + it('returns when sequence status is loading', () => { + checkSequenceToSequenceUnitRedirect( + 'courseId', + 'loading', + { id: 'sequence_1', unitIds: ['unit_1', 'unit_2'], activeUnitIndex: 0 }, + null, + navigate, + true, + ); + + expect(navigate).not.toHaveBeenCalled(); + }); + + it('returns when sequence id is null', () => { + checkSequenceToSequenceUnitRedirect( + 'courseId', + 'loading', + { unitIds: ['unit_1', 'unit_2'], activeUnitIndex: 0 }, + null, + navigate, + true, + ); + + expect(navigate).not.toHaveBeenCalled(); + }); + + it('returns when unit id is defined', () => { + checkSequenceToSequenceUnitRedirect( + 'courseId', + 'loaded', + { id: 'sequence_1', unitIds: ['unit_1', 'unit_2'], activeUnitIndex: 0 }, + 'unit_2', + navigate, + true, + ); + + expect(navigate).not.toHaveBeenCalled(); + }); + + it('returns when unit ids are undefiend', () => { + checkSequenceToSequenceUnitRedirect( + 'courseId', + 'loaded', + { id: 'sequence_1', activeUnitIndex: 0 }, + null, + navigate, + true, + ); + + expect(navigate).not.toHaveBeenCalled(); + }); + }); + + describe('checkUnitToSequenceUnitRedirect', () => { + const { href: apiUrl } = getSequenceForUnitDeprecatedUrl('courseId'); + + it('calls navigate with parentId and sequenceId', () => { + const getSequenceForUnitDeprecated = jest.fn(); + axiosMock.onGet(apiUrl).reply(200, { + blocks: [{ + id: 'sequence_1', + type: 'sequential', + children: ['unit_1'], + }], + }); + + checkUnitToSequenceUnitRedirect( + 'loaded', + 'courseId', + 'failed', + true, + 'unit_1', + false, + null, + navigate, + true, + ); + const expectedUrl = '/course/courseId/sequence_1'; + + waitFor(() => { + expect(getSequenceForUnitDeprecated).toHaveBeenCalled(); + expect(navigate).toHaveBeenCalledWith(expectedUrl, { replace: true }); + }); + }); + + it('calls navigate to course page when getSequenceForUnitDeprecated errors', () => { + const getSequenceForUnitDeprecated = jest.fn(); + axiosMock.onGet(apiUrl).reply(404); + + checkUnitToSequenceUnitRedirect( + 'loaded', + 'courseId', + 'failed', + true, + 'unit_1', + false, + null, + navigate, + true, + ); + const expectedUrl = '/course/courseId'; + + waitFor(() => { + expect(getSequenceForUnitDeprecated).toHaveBeenCalled(); + expect(navigate).toHaveBeenCalledWith(expectedUrl, { replace: true }); + }); + }); + + it('calls navigate to course page when no parent id is returned', () => { + const getSequenceForUnitDeprecated = jest.fn(); + axiosMock.onGet(apiUrl).reply(200, { + blocks: [{ + id: 'sequence_1', + type: 'sequential', + children: ['block_1'], + }], + }); + + checkUnitToSequenceUnitRedirect( + 'loaded', + 'courseId', + 'failed', + true, + 'unit_1', + false, + null, + navigate, + true, + ); + const expectedUrl = '/course/courseId'; + + waitFor(() => { + expect(getSequenceForUnitDeprecated).toHaveBeenCalled(); + expect(navigate).toHaveBeenCalledWith(expectedUrl, { replace: true }); + }); + }); + + it('calls navigate to course page when sequnce is not unit', () => { + const getSequenceForUnitDeprecated = jest.fn(); + + checkUnitToSequenceUnitRedirect( + 'loaded', + 'courseId', + 'failed', + false, + 'unit_1', + false, + null, + navigate, + true, + ); + const expectedUrl = '/course/courseId'; + + waitFor(() => { + expect(getSequenceForUnitDeprecated).not.toHaveBeenCalled(); + expect(navigate).toHaveBeenCalledWith(expectedUrl, { replace: true }); + }); + }); + + it('returns when course status is loading', () => { + checkUnitToSequenceUnitRedirect( + 'loading', + 'courseId', + 'failed', + true, + 'unit_1', + false, + null, + navigate, + true, + ); + + expect(navigate).not.toHaveBeenCalled(); + }); + + it('returns when sequence status is not failed', () => { + checkUnitToSequenceUnitRedirect( + 'loaded', + 'courseId', + 'loaded', + true, + 'unit_1', + false, + null, + navigate, + true, + ); + + expect(navigate).not.toHaveBeenCalled(); + }); + + it('returns when section is defined', () => { + checkUnitToSequenceUnitRedirect( + 'loaded', + 'courseId', + 'loaded', + true, + 'unit_1', + true, + null, + navigate, + true, + ); + + expect(navigate).not.toHaveBeenCalled(); + }); + + it('returns when routeUnitId is defined', () => { + checkUnitToSequenceUnitRedirect( + 'loaded', + 'courseId', + 'loaded', + true, + 'unit_1', + false, + 'unit_1', + navigate, + true, + ); + + expect(navigate).not.toHaveBeenCalled(); + }); + }); + + describe('checkSectionUnitToUnitRedirect', () => { + it('calls navigate with unitId', () => { + checkSectionUnitToUnitRedirect( + 'loaded', + 'courseId', + 'failed', + true, + 'unit_2', + navigate, + true, + ); + const expectedUrl = '/preview/course/courseId/unit_2'; + + expect(navigate).toHaveBeenCalledWith(expectedUrl, { replace: true }); + }); + + it('returns when course status is loading', () => { + checkSectionUnitToUnitRedirect( + 'loading', + 'courseId', + 'loaded', + true, + 'unit_2', + navigate, + true, + ); + + expect(navigate).not.toHaveBeenCalled(); + }); + + it('returns when sequence status is loading', () => { + checkSectionUnitToUnitRedirect( + 'loaded', + 'courseId', + 'loaded', + true, + 'unit_2', + navigate, + true, + ); + + expect(navigate).not.toHaveBeenCalled(); + }); + + it('returns when section is null', () => { + checkSectionUnitToUnitRedirect( + 'loaded', + 'courseId', + 'loaded', + null, + 'unit_2', + navigate, + true, + ); + + expect(navigate).not.toHaveBeenCalled(); + }); + + it('returns when unitId is null', () => { + checkSectionUnitToUnitRedirect( + 'loaded', + 'courseId', + 'loaded', + true, + null, + navigate, + true, + ); + + expect(navigate).not.toHaveBeenCalled(); + }); + }); + + describe('checkResumeRedirect', () => { + it('calls navigate with unitId', () => { + axiosMock.onGet( + `${getConfig().LMS_BASE_URL}/api/courseware/resume/courseId`, + ).reply(200, { + section_id: 'section_1', + unitId: 'unit_1', + }); + checkResumeRedirect( + 'loaded', + 'courseId', + null, + 'sequence_1', + navigate, + true, + ); + const expectedUrl = '/preview/course/courseId/section_1/unit_1'; + + waitFor(() => { + expect(navigate).toHaveBeenCalledWith(expectedUrl, { replace: true }); + }); + }); + + it('calls navigate with firstSequenceId', () => { + axiosMock.onGet( + `${getConfig().LMS_BASE_URL}/api/courseware/resume/courseId`, + ).reply(200, { + section_id: 'section_1', + first_sequence_id: 'sequence_1', + }); + checkResumeRedirect( + 'loaded', + 'courseId', + null, + 'sequence_1', + navigate, + true, + ); + const expectedUrl = '/course/courseId/sequence_1'; + + waitFor(() => { + expect(navigate).toHaveBeenCalledWith(expectedUrl, { replace: true }); + }); + }); + + it('returns after calling getResumeBlock', () => { + const getResumeBlock = jest.fn(); + axiosMock.onGet( + `${getConfig().LMS_BASE_URL}/api/courseware/resume/courseId`, + ).reply(200, { + course_id: 'courseId', + }); + checkResumeRedirect( + 'loaded', + 'courseId', + null, + 'sequence_1', + navigate, + true, + ); + + waitFor(() => { + expect(getResumeBlock).toHaveBeenCalled(); + expect(navigate).not.toHaveBeenCalled(); + }); + }); + + it('returns when course status is loading', () => { + checkResumeRedirect( + 'loading', + 'courseId', + null, + 'sequence_1', + navigate, + true, + ); + + expect(navigate).not.toHaveBeenCalled(); + }); + + it('returns when sequenceId is defined', () => { + checkResumeRedirect( + 'loaded', + 'courseId', + 'sequence_3', + 'sequence_1', + navigate, + true, + ); + + expect(navigate).not.toHaveBeenCalled(); + }); + + it('returns when getResumeBlock throws error', () => { + const getResumeBlock = jest.fn(); + axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/courseware/resume/courseId`).reply(404); + checkResumeRedirect( + 'loaded', + 'courseId', + null, + 'sequence_1', + navigate, + true, + ); + + waitFor(() => { + expect(getResumeBlock).toHaveBeenCalled(); + expect(navigate).not.toHaveBeenCalled(); + }); + }); + }); + }); + + describe('isPreview equals false', () => { + describe('checkSectionToSequenceRedirect', () => { + it('calls navigate with section based sequence id', () => { + checkSectionToSequenceRedirect( + 'loaded', + 'courseId', + 'failed', + { sequenceIds: ['sequence_1'] }, + null, + navigate, + ); + const expectedUrl = '/course/courseId/sequence_1'; + + expect(navigate).toHaveBeenCalledWith(expectedUrl, { replace: true }); + }); + + it('calls navigate with course id only', () => { + checkSectionToSequenceRedirect( + 'loaded', + 'courseId', + 'failed', + { sequenceIds: [] }, + null, + navigate, + ); + const expectedUrl = '/course/courseId'; + + expect(navigate).toHaveBeenCalledWith(expectedUrl, { replace: true }); + }); + + it('returns when course status is loading', () => { + checkSectionToSequenceRedirect( + 'loading', + 'courseId', + 'failed', + { sequenceIds: [] }, + null, + navigate, + ); + + expect(navigate).not.toHaveBeenCalled(); + }); + + it('returns when sequence status is not failed', () => { + checkSectionToSequenceRedirect( + 'loaded', + 'courseId', + 'loading', + { sequenceIds: [] }, + null, + navigate, + ); + + expect(navigate).not.toHaveBeenCalled(); + }); + + it('returns when section is not defined', () => { + checkSectionToSequenceRedirect( + 'loaded', + 'courseId', + 'failed', + null, + null, + navigate, + ); + + expect(navigate).not.toHaveBeenCalled(); + }); + + it('returns when unitId is defined', () => { + checkSectionToSequenceRedirect( + 'loaded', + 'courseId', + 'failed', + null, + 'unit_1', + navigate, + ); + + expect(navigate).not.toHaveBeenCalled(); + }); + }); + + describe('checkResumeRedirect', () => { + it('calls navigate with unitId', () => { + axiosMock.onGet( + `${getConfig().LMS_BASE_URL}/api/courseware/resume/courseId`, + ).reply(200, { + section_id: 'section_1', + unitId: 'unit_1', + }); + checkResumeRedirect( + 'loaded', + 'courseId', + null, + 'sequence_1', + navigate, + false, + ); + const expectedUrl = '/preview/course/courseId/section_1/unit_1'; + + waitFor(() => { + expect(navigate).toHaveBeenCalledWith(expectedUrl, { replace: true }); + }); + }); + + it('calls navigate with firstSequenceId', () => { + axiosMock.onGet( + `${getConfig().LMS_BASE_URL}/api/courseware/resume/courseId`, + ).reply(200, { + section_id: 'section_1', + first_sequence_id: 'sequence_1', + }); + checkResumeRedirect( + 'loaded', + 'courseId', + null, + 'sequence_1', + navigate, + false, + ); + const expectedUrl = '/course/courseId/sequence_1'; + + waitFor(() => { + expect(navigate).toHaveBeenCalledWith(expectedUrl, { replace: true }); + }); + }); + }); + + describe('checkSequenceUnitMarkerToSequenceUnitRedirect', () => { + it('calls navigate with first unit id', () => { + checkSequenceUnitMarkerToSequenceUnitRedirect( + 'courseId', + 'loaded', + { id: 'sequence_1', unitIds: ['unit_1', 'unit_2'] }, + 'first', + navigate, + false, + ); + const expectedUrl = '/course/courseId/sequence_1/unit_1'; + + expect(navigate).toHaveBeenCalledWith(expectedUrl, { replace: true }); + }); + + it('calls navigate with base url when no unit id', () => { + checkSequenceUnitMarkerToSequenceUnitRedirect( + 'courseId', + 'loaded', + { id: 'sequence_1', unitIds: [] }, + 'first', + navigate, + true, + ); + const expectedUrl = '/course/courseId/sequence_1'; + + expect(navigate).toHaveBeenCalledWith(expectedUrl, { replace: true }); + }); + + it('calls navigate with last unit id', () => { + checkSequenceUnitMarkerToSequenceUnitRedirect( + 'courseId', + 'loaded', + { id: 'sequence_1', unitIds: ['unit_1', 'unit_2'] }, + 'last', + navigate, + false, + ); + const expectedUrl = '/course/courseId/sequence_1/unit_2'; + + expect(navigate).toHaveBeenCalledWith(expectedUrl, { replace: true }); + }); + }); + + describe('checkSequenceToSequenceUnitRedirect', () => { + it('calls navigate with next unit id', () => { + checkSequenceToSequenceUnitRedirect( + 'courseId', + 'loaded', + { id: 'sequence_1', unitIds: ['unit_1', 'unit_2'], activeUnitIndex: 0 }, + null, + navigate, + false, + ); + const expectedUrl = '/course/courseId/sequence_1/unit_1'; + + expect(navigate).toHaveBeenCalledWith(expectedUrl, { replace: true }); + }); + }); + + describe('checkUnitToSequenceUnitRedirect', () => { + const apiUrl = getSequenceForUnitDeprecatedUrl('courseId'); + + it('calls navigate with parentId and sequenceId', () => { + axiosMock.onGet(apiUrl).reply(200, { + parent: { id: 'sequence_1' }, + }); + checkUnitToSequenceUnitRedirect( + 'loaded', + 'courseId', + 'failed', + true, + 'unit_1', + false, + null, + navigate, + true, + ); + const expectedUrl = '/course/courseId/sequence_1'; + + waitFor(() => { + expect(navigate).toHaveBeenCalledWith(expectedUrl, { replace: true }); + }); + }); + + it('calls navigate to course page when getSequenceForUnitDeprecated errors', () => { + const getSequenceForUnitDeprecated = jest.fn(); + axiosMock.onGet(apiUrl).reply(404); + checkUnitToSequenceUnitRedirect( + 'loaded', + 'courseId', + 'failed', + true, + 'unit_1', + false, + null, + navigate, + true, + ); + const expectedUrl = '/course/courseId'; + + waitFor(() => { + expect(getSequenceForUnitDeprecated).toHaveBeenCalled(); + expect(navigate).toHaveBeenCalledWith(expectedUrl, { replace: true }); + }); + }); + + it('calls navigate to course page when no parent id is returned', () => { + const getSequenceForUnitDeprecated = jest.fn(); + axiosMock.onGet(apiUrl).reply(200, { + parent: { children: ['block_1'] }, + }); + checkUnitToSequenceUnitRedirect( + 'loaded', + 'courseId', + 'failed', + true, + 'unit_1', + false, + null, + navigate, + true, + ); + const expectedUrl = '/course/courseId'; + + waitFor(() => { + expect(getSequenceForUnitDeprecated).toHaveBeenCalled(); + expect(navigate).toHaveBeenCalledWith(expectedUrl, { replace: true }); + }); + }); + + it('returns when course status is loading', () => { + checkUnitToSequenceUnitRedirect( + 'loading', + 'courseId', + 'failed', + true, + 'unit_1', + false, + null, + navigate, + true, + ); + + expect(navigate).not.toHaveBeenCalled(); + }); + + it('returns when sequence status is not failed', () => { + checkUnitToSequenceUnitRedirect( + 'loaded', + 'courseId', + 'loaded', + true, + 'unit_1', + false, + null, + navigate, + true, + ); + + expect(navigate).not.toHaveBeenCalled(); + }); + + it('returns when section is defined', () => { + checkUnitToSequenceUnitRedirect( + 'loaded', + 'courseId', + 'loaded', + true, + 'unit_1', + true, + null, + navigate, + true, + ); + + expect(navigate).not.toHaveBeenCalled(); + }); + + it('returns when routeUnitId is defined', () => { + checkUnitToSequenceUnitRedirect( + 'loaded', + 'courseId', + 'loaded', + true, + 'unit_1', + false, + 'unit_1', + navigate, + true, + ); + + expect(navigate).not.toHaveBeenCalled(); + }); + }); + + describe('checkSectionUnitToUnitRedirect', () => { + it('calls navigate with unitId', () => { + checkSectionUnitToUnitRedirect( + 'loaded', + 'courseId', + 'failed', + true, + 'unit_2', + navigate, + false, + ); + const expectedUrl = '/course/courseId/unit_2'; + + expect(navigate).toHaveBeenCalledWith(expectedUrl, { replace: true }); + }); + }); + }); +}); diff --git a/src/courseware/course/Course.jsx b/src/courseware/course/Course.jsx index 131db82e09..3bfd91eca5 100644 --- a/src/courseware/course/Course.jsx +++ b/src/courseware/course/Course.jsx @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import { Helmet } from 'react-helmet'; import { useDispatch, useSelector } from 'react-redux'; import { getConfig } from '@edx/frontend-platform'; +import { useLocation, useNavigate } from 'react-router-dom'; import { breakpoints, useWindowSize } from '@openedx/paragon'; import { AlertList } from '@src/generic/user-messages'; @@ -38,6 +39,13 @@ const Course = ({ const section = useModel('sections', sequence ? sequence.sectionId : null); const { enableNavigationSidebar } = useSelector(getCoursewareOutlineSidebarSettings); const navigationDisabled = enableNavigationSidebar || (sequence?.navigationDisabled ?? false); + const navigate = useNavigate(); + const { pathname } = useLocation(); + + if (!isStaff && pathname.startsWith('/preview')) { + const courseUrl = pathname.replace('/preview', ''); + navigate(courseUrl, { replace: true }); + } const pageTitleBreadCrumbs = [ sequence, diff --git a/src/courseware/course/sequence/Sequence.jsx b/src/courseware/course/sequence/Sequence.jsx index 1a2a8f2d33..dce4561bb2 100644 --- a/src/courseware/course/sequence/Sequence.jsx +++ b/src/courseware/course/sequence/Sequence.jsx @@ -204,6 +204,7 @@ const Sequence = ({ sequenceId={sequenceId} unitId={unitId} unitLoadedHandler={handleUnitLoaded} + isStaff={isStaff} /> {unitHasLoaded && renderUnitNavigation(false)} diff --git a/src/courseware/course/sequence/SequenceContent.jsx b/src/courseware/course/sequence/SequenceContent.jsx index 35a9a27f9d..6c40c5bfd2 100644 --- a/src/courseware/course/sequence/SequenceContent.jsx +++ b/src/courseware/course/sequence/SequenceContent.jsx @@ -1,6 +1,6 @@ import React, { Suspense, useEffect } from 'react'; import PropTypes from 'prop-types'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import PageLoading from '../../../generic/PageLoading'; import { useModel } from '../../../generic/model-store'; @@ -11,12 +11,13 @@ const ContentLock = React.lazy(() => import('./content-lock')); const SequenceContent = ({ gated, - intl, courseId, sequenceId, unitId, unitLoadedHandler, + isStaff, }) => { + const intl = useIntl(); const sequence = useModel('sequences', sequenceId); // Go back to the top of the page whenever the unit or sequence changes. @@ -59,6 +60,7 @@ const SequenceContent = ({ key={unitId} id={unitId} onLoaded={unitLoadedHandler} + isStaff={isStaff} /> ); }; @@ -69,11 +71,11 @@ SequenceContent.propTypes = { sequenceId: PropTypes.string.isRequired, unitId: PropTypes.string, unitLoadedHandler: PropTypes.func.isRequired, - intl: intlShape.isRequired, + isStaff: PropTypes.bool.isRequired, }; SequenceContent.defaultProps = { unitId: null, }; -export default injectIntl(SequenceContent); +export default SequenceContent; diff --git a/src/courseware/course/sequence/Unit/index.jsx b/src/courseware/course/sequence/Unit/index.jsx index a3081c7e85..71663cadc9 100644 --- a/src/courseware/course/sequence/Unit/index.jsx +++ b/src/courseware/course/sequence/Unit/index.jsx @@ -1,6 +1,6 @@ import PropTypes from 'prop-types'; import React from 'react'; -import { useSearchParams } from 'react-router-dom'; +import { useSearchParams, useLocation } from 'react-router-dom'; import { AppContext } from '@edx/frontend-platform/react'; import { useIntl } from '@edx/frontend-platform/i18n'; @@ -22,15 +22,18 @@ const Unit = ({ format, onLoaded, id, + isStaff, }) => { const { formatMessage } = useIntl(); const [searchParams] = useSearchParams(); + const { pathname } = useLocation(); const { authenticatedUser } = React.useContext(AppContext); const examAccess = useExamAccess({ id }); const shouldDisplayHonorCode = useShouldDisplayHonorCode({ courseId, id }); const unit = useModel(modelKeys.units, id); const isProcessing = unit.bookmarkedUpdateState === 'loading'; const view = authenticatedUser ? views.student : views.public; + const shouldDisplayUnitPreview = pathname.startsWith('/preview') && isStaff; const getUrl = usePluginsCallback('getIFrameUrl', () => getIFrameUrl({ id, @@ -38,6 +41,7 @@ const Unit = ({ format, examAccess, jumpToId: searchParams.get('jumpToId'), + preview: shouldDisplayUnitPreview ? '1' : '0', })); const iframeUrl = getUrl(); @@ -74,6 +78,7 @@ Unit.propTypes = { format: PropTypes.string, id: PropTypes.string.isRequired, onLoaded: PropTypes.func, + isStaff: PropTypes.bool.isRequired, }; Unit.defaultProps = { diff --git a/src/courseware/course/sequence/Unit/index.test.jsx b/src/courseware/course/sequence/Unit/index.test.jsx index bdaf2743d0..4f9a221640 100644 --- a/src/courseware/course/sequence/Unit/index.test.jsx +++ b/src/courseware/course/sequence/Unit/index.test.jsx @@ -1,7 +1,7 @@ import React from 'react'; import { when } from 'jest-when'; import { formatMessage, shallow } from '@edx/react-unit-test-utils/dist'; -import { useSearchParams } from 'react-router-dom'; +import { useSearchParams, useLocation } from 'react-router-dom'; import { useModel } from '@src/generic/model-store'; @@ -55,6 +55,7 @@ const props = { format: 'test-format', onLoaded: jest.fn().mockName('props.onLoaded'), id: 'test-props-id', + isStaff: false, }; const context = { authenticatedUser: { test: 'user' } }; @@ -89,6 +90,7 @@ describe('Unit component', () => { beforeEach(() => { useSearchParams.mockImplementation(() => [searchParams, setSearchParams]); + useLocation.mockImplementation(() => ({ pathname: `/course/${props.courseId}` })); jest.clearAllMocks(); el = shallow(); }); diff --git a/src/courseware/course/sequence/Unit/urls.js b/src/courseware/course/sequence/Unit/urls.js index 7e9fa3c94e..d13fe6e39e 100644 --- a/src/courseware/course/sequence/Unit/urls.js +++ b/src/courseware/course/sequence/Unit/urls.js @@ -13,6 +13,7 @@ export const getIFrameUrl = ({ format, examAccess, jumpToId, + preview, }) => { const xblockUrl = `${getConfig().LMS_BASE_URL}/xblock/${id}`; return stringifyUrl({ @@ -20,6 +21,7 @@ export const getIFrameUrl = ({ query: { ...iframeParams, view, + preview, ...(format && { format }), ...(!examAccess.blockAccess && { exam_access: examAccess.accessToken }), jumpToId, // Pass jumpToId as query param as fragmentIdentifier is not passed to server. diff --git a/src/courseware/course/sequence/Unit/urls.test.js b/src/courseware/course/sequence/Unit/urls.test.js index 9de154e2d1..9540f19e01 100644 --- a/src/courseware/course/sequence/Unit/urls.test.js +++ b/src/courseware/course/sequence/Unit/urls.test.js @@ -17,6 +17,7 @@ const props = { view: 'test-view', format: 'test-format', examAccess: { blockAccess: false, accessToken: 'test-access-token' }, + preview: false, }; describe('urls module getIFrameUrl', () => { @@ -28,6 +29,7 @@ describe('urls module getIFrameUrl', () => { view: props.view, format: props.format, exam_access: props.examAccess.accessToken, + preview: props.preview, }, }); expect(getIFrameUrl(props)).toEqual(url); @@ -35,11 +37,12 @@ describe('urls module getIFrameUrl', () => { test('no format provided, exam access blocked', () => { const url = stringifyUrl({ url: `${config.LMS_BASE_URL}/xblock/${props.id}`, - query: { ...iframeParams, view: props.view }, + query: { ...iframeParams, view: props.view, preview: props.preview }, }); expect(getIFrameUrl({ id: props.id, view: props.view, + preview: props.preview, examAccess: { blockAccess: true }, })).toEqual(url); }); @@ -50,6 +53,7 @@ describe('urls module getIFrameUrl', () => { ...iframeParams, view: props.view, format: props.format, + preview: props.preview, exam_access: props.examAccess.accessToken, jumpToId: 'some-xblock-id', }, @@ -60,4 +64,20 @@ describe('urls module getIFrameUrl', () => { jumpToId: 'some-xblock-id', })).toEqual(url); }); + test('preview is true and url param equals 1', () => { + const url = stringifyUrl({ + url: `${config.LMS_BASE_URL}/xblock/${props.id}`, + query: { + ...iframeParams, + view: props.view, + format: props.format, + preview: true, + exam_access: props.examAccess.accessToken, + }, + }); + expect(getIFrameUrl({ + ...props, + preview: true, + })).toEqual(url); + }); }); diff --git a/src/courseware/course/sequence/sequence-navigation/SequenceNavigation.jsx b/src/courseware/course/sequence/sequence-navigation/SequenceNavigation.jsx index 38fd16bbc3..918f6c3ef3 100644 --- a/src/courseware/course/sequence/sequence-navigation/SequenceNavigation.jsx +++ b/src/courseware/course/sequence/sequence-navigation/SequenceNavigation.jsx @@ -1,15 +1,8 @@ import React from 'react'; -import { Link } from 'react-router-dom'; import PropTypes from 'prop-types'; -import { breakpoints, Button, useWindowSize } from '@openedx/paragon'; -import { ChevronLeft, ChevronRight } from '@openedx/paragon/icons'; +import { breakpoints, useWindowSize } from '@openedx/paragon'; import classNames from 'classnames'; -import { - injectIntl, - intlShape, - isRtl, - getLocale, -} from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { PluginSlot } from '@openedx/frontend-plugin-framework'; import { useSelector } from 'react-redux'; @@ -21,9 +14,10 @@ import { useSequenceNavigationMetadata } from './hooks'; import { useModel } from '../../../../generic/model-store'; import messages from './messages'; +import PreviousButton from './generic/PreviousButton'; +import NextButton from './generic/NextButton'; const SequenceNavigation = ({ - intl, unitId, sequenceId, className, @@ -36,6 +30,7 @@ const SequenceNavigation = ({ open, close, }) => { + const intl = useIntl(); const sequence = useModel('sequences', sequenceId); const { isFirstUnit, @@ -76,29 +71,21 @@ const SequenceNavigation = ({ ); }; - const renderPreviousButton = () => { - const disabled = isFirstUnit; - const prevArrow = isRtl(getLocale()) ? ChevronRight : ChevronLeft; - return navigationDisabledPrevSequence || ( - - ); - }; + const renderPreviousButton = () => navigationDisabledPrevSequence || ( + + ); const renderNextButton = () => { const { exitActive, exitText } = GetCourseExitNavigation(courseId, intl); const buttonText = (isLastUnit && exitText) ? exitText : intl.formatMessage(messages.nextButton); const disabled = isLastUnit && !exitActive; - const nextArrow = isRtl(getLocale()) ? ChevronLeft : ChevronRight; return navigationDisabledNextSequence || ( - + buttonLabel={shouldDisplayNotificationTriggerInSequence ? null : buttonText} + /> ); }; @@ -145,7 +126,6 @@ const SequenceNavigation = ({ }; SequenceNavigation.propTypes = { - intl: intlShape.isRequired, sequenceId: PropTypes.string.isRequired, unitId: PropTypes.string, className: PropTypes.string, @@ -169,4 +149,4 @@ SequenceNavigation.defaultProps = { nextSequenceHandler: null, }; -export default injectIntl(SequenceNavigation); +export default SequenceNavigation; diff --git a/src/courseware/course/sequence/sequence-navigation/UnitButton.jsx b/src/courseware/course/sequence/sequence-navigation/UnitButton.jsx index 48d236b861..3c99918963 100644 --- a/src/courseware/course/sequence/sequence-navigation/UnitButton.jsx +++ b/src/courseware/course/sequence/sequence-navigation/UnitButton.jsx @@ -1,5 +1,5 @@ import React, { useCallback } from 'react'; -import { Link } from 'react-router-dom'; +import { Link, useLocation } from 'react-router-dom'; import PropTypes from 'prop-types'; import { connect, useSelector } from 'react-redux'; import classNames from 'classnames'; @@ -22,6 +22,9 @@ const UnitButton = ({ showTitle, }) => { const { courseId, sequenceId } = useSelector(state => state.courseware); + const { pathname } = useLocation(); + const basePath = `/course/${courseId}/${sequenceId}/${unitId}`; + const unitPath = pathname.startsWith('/preview') ? `/preview${basePath}` : basePath; const handleClick = useCallback(() => { onClick(unitId); @@ -37,7 +40,7 @@ const UnitButton = ({ onClick={handleClick} title={title} as={Link} - to={`/course/${courseId}/${sequenceId}/${unitId}`} + to={unitPath} > {showTitle && {title}} diff --git a/src/courseware/course/sequence/sequence-navigation/UnitNavigation.jsx b/src/courseware/course/sequence/sequence-navigation/UnitNavigation.jsx index 9a338d58f0..d27e0acb6f 100644 --- a/src/courseware/course/sequence/sequence-navigation/UnitNavigation.jsx +++ b/src/courseware/course/sequence/sequence-navigation/UnitNavigation.jsx @@ -1,70 +1,53 @@ import classNames from 'classnames'; -import { Link } from 'react-router-dom'; import PropTypes from 'prop-types'; -import { Button } from '@openedx/paragon'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faChevronLeft, faChevronRight } from '@fortawesome/free-solid-svg-icons'; -import { - injectIntl, intlShape, isRtl, getLocale, -} from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { useSelector } from 'react-redux'; import { GetCourseExitNavigation } from '../../course-exit'; -import UnitNavigationEffortEstimate from './UnitNavigationEffortEstimate'; import { useSequenceNavigationMetadata } from './hooks'; import messages from './messages'; +import PreviousButton from './generic/PreviousButton'; +import NextButton from './generic/NextButton'; const UnitNavigation = ({ - intl, sequenceId, unitId, onClickPrevious, onClickNext, isAtTop, }) => { + const intl = useIntl(); const { isFirstUnit, isLastUnit, nextLink, previousLink, } = useSequenceNavigationMetadata(sequenceId, unitId); const { courseId } = useSelector(state => state.courseware); - const renderPreviousButton = () => { - const disabled = isFirstUnit; - const prevArrow = isRtl(getLocale()) ? faChevronRight : faChevronLeft; - return ( - - ); - }; + const renderPreviousButton = () => ( + + ); const renderNextButton = () => { const { exitActive, exitText } = GetCourseExitNavigation(courseId, intl); const buttonText = (isLastUnit && exitText) ? exitText : intl.formatMessage(messages.nextButton); const disabled = isLastUnit && !exitActive; - const nextArrow = isRtl(getLocale()) ? faChevronLeft : faChevronRight; return ( - + buttonLabel={buttonText} + nextLink={nextLink} + hasEffortEstimate + /> ); }; @@ -77,7 +60,6 @@ const UnitNavigation = ({ }; UnitNavigation.propTypes = { - intl: intlShape.isRequired, sequenceId: PropTypes.string.isRequired, unitId: PropTypes.string, onClickPrevious: PropTypes.func.isRequired, @@ -90,4 +72,4 @@ UnitNavigation.defaultProps = { isAtTop: false, }; -export default injectIntl(UnitNavigation); +export default UnitNavigation; diff --git a/src/courseware/course/sequence/sequence-navigation/generic/NextButton.jsx b/src/courseware/course/sequence/sequence-navigation/generic/NextButton.jsx new file mode 100644 index 0000000000..f0051df077 --- /dev/null +++ b/src/courseware/course/sequence/sequence-navigation/generic/NextButton.jsx @@ -0,0 +1,56 @@ +import PropTypes from 'prop-types'; +import { Link, useLocation } from 'react-router-dom'; +import { Button } from '@openedx/paragon'; +import { ChevronLeft, ChevronRight } from '@openedx/paragon/icons'; +import { isRtl, getLocale } from '@edx/frontend-platform/i18n'; + +import UnitNavigationEffortEstimate from '../UnitNavigationEffortEstimate'; + +const NextButton = ({ + onClick, + buttonLabel, + nextLink, + variant, + buttonStyle, + disabled, + hasEffortEstimate, +}) => { + const nextArrow = isRtl(getLocale()) ? ChevronLeft : ChevronRight; + const { pathname } = useLocation(); + const navLink = pathname.startsWith('/preview') ? `/preview${nextLink}` : nextLink; + const buttonContent = hasEffortEstimate ? ( + + {buttonLabel} + + ) : buttonLabel; + + return ( + + ); +}; + +NextButton.defaultProps = { + hasEffortEstimate: false, +}; + +NextButton.propTypes = { + onClick: PropTypes.func.isRequired, + buttonLabel: PropTypes.string.isRequired, + nextLink: PropTypes.string.isRequired, + variant: PropTypes.string.isRequired, + buttonStyle: PropTypes.string.isRequired, + disabled: PropTypes.bool.isRequired, + hasEffortEstimate: PropTypes.bool, +}; + +export default NextButton; diff --git a/src/courseware/course/sequence/sequence-navigation/generic/PreviousButton.jsx b/src/courseware/course/sequence/sequence-navigation/generic/PreviousButton.jsx new file mode 100644 index 0000000000..c79669c95d --- /dev/null +++ b/src/courseware/course/sequence/sequence-navigation/generic/PreviousButton.jsx @@ -0,0 +1,44 @@ +import PropTypes from 'prop-types'; +import { Link, useLocation } from 'react-router-dom'; +import { Button } from '@openedx/paragon'; +import { ChevronLeft, ChevronRight } from '@openedx/paragon/icons'; +import { isRtl, getLocale } from '@edx/frontend-platform/i18n'; + +const PreviousButton = ({ + onClick, + buttonLabel, + previousLink, + variant, + buttonStyle, + isFirstUnit, +}) => { + const disabled = isFirstUnit; + const prevArrow = isRtl(getLocale()) ? ChevronRight : ChevronLeft; + const { pathname } = useLocation(); + const navLink = pathname.startsWith('/preview') ? `/preview${previousLink}` : previousLink; + + return ( + + ); +}; + +PreviousButton.propTypes = { + onClick: PropTypes.func.isRequired, + buttonLabel: PropTypes.string.isRequired, + previousLink: PropTypes.string.isRequired, + variant: PropTypes.string.isRequired, + buttonStyle: PropTypes.string.isRequired, + isFirstUnit: PropTypes.bool.isRequired, +}; + +export default PreviousButton; diff --git a/src/courseware/data/api.js b/src/courseware/data/api.js index 1b1492973a..b3b58baa76 100644 --- a/src/courseware/data/api.js +++ b/src/courseware/data/api.js @@ -6,7 +6,7 @@ import { } from './utils'; // Do not add further calls to this API - we don't like making use of the modulestore if we can help it -export async function getSequenceForUnitDeprecated(courseId, unitId) { +export const getSequenceForUnitDeprecatedUrl = (courseId) => { const authenticatedUser = getAuthenticatedUser(); const url = new URL(`${getConfig().LMS_BASE_URL}/api/courses/v2/blocks/`); url.searchParams.append('course_id', courseId); @@ -14,6 +14,10 @@ export async function getSequenceForUnitDeprecated(courseId, unitId) { url.searchParams.append('depth', 3); url.searchParams.append('requested_fields', 'children,discussions_url'); + return url; +}; +export async function getSequenceForUnitDeprecated(courseId, unitId) { + const url = getSequenceForUnitDeprecatedUrl(courseId); const { data } = await getAuthenticatedHttpClient().get(url.href, {}); const parent = Object.values(data.blocks).find(block => block.type === 'sequential' && block.children.includes(unitId)); return parent?.id; diff --git a/src/courseware/utils.jsx b/src/courseware/utils.jsx index 2c4c337996..df91c4e01b 100644 --- a/src/courseware/utils.jsx +++ b/src/courseware/utils.jsx @@ -1,17 +1,21 @@ import React from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useLocation, useNavigate, useParams } from 'react-router-dom'; const withParamsAndNavigation = WrappedComponent => { const WithParamsNavigationComponent = props => { const { courseId, sequenceId, unitId } = useParams(); const navigate = useNavigate(); + const { pathname } = useLocation(); + const isPreview = pathname.startsWith('/preview'); + return ( );