diff --git a/.env b/.env index c882fde382..0208b01f5b 100644 --- a/.env +++ b/.env @@ -11,6 +11,7 @@ CREDIT_HELP_LINK_URL='' CSRF_TOKEN_API_PATH='' DISCOVERY_API_BASE_URL='' DISCUSSIONS_MFE_BASE_URL='' +SIDEBAR_MFE_BASE_URL='' ECOMMERCE_BASE_URL='' ENABLE_JUMPNAV='true' ENABLE_NOTICES='' diff --git a/.env.development b/.env.development index f19135140b..0c68567558 100644 --- a/.env.development +++ b/.env.development @@ -11,6 +11,7 @@ CREDIT_HELP_LINK_URL='https://edx.readthedocs.io/projects/edx-guide-for-students CSRF_TOKEN_API_PATH='/csrf/api/v1/token' DISCOVERY_API_BASE_URL='http://localhost:18381' DISCUSSIONS_MFE_BASE_URL='http://localhost:2002' +SIDEBAR_MFE_BASE_URL='http://apps.local.edly.io:9090' ECOMMERCE_BASE_URL='http://localhost:18130' ENABLE_JUMPNAV='true' ENABLE_NOTICES='' diff --git a/.env.test b/.env.test index 147c12c182..390ba92704 100644 --- a/.env.test +++ b/.env.test @@ -11,6 +11,7 @@ CREDIT_HELP_LINK_URL='https://edx.readthedocs.io/projects/edx-guide-for-students CSRF_TOKEN_API_PATH='/csrf/api/v1/token' DISCOVERY_API_BASE_URL='http://localhost:18381' DISCUSSIONS_MFE_BASE_URL='http://localhost:2002' +SIDEBAR_MFE_BASE_URL='http://localhost:9090' ECOMMERCE_BASE_URL='http://localhost:18130' ENABLE_JUMPNAV='true' ENABLE_NOTICES='' diff --git a/.eslintrc.js b/.eslintrc.js index 9ed6c9fe81..b47abab542 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,6 +1,16 @@ const { createConfig } = require('@edx/frontend-build'); module.exports = createConfig('eslint', { + rules: { + 'import/no-unresolved': 'off', + }, + 'settings': { + 'import/resolver': { + 'node': { + 'paths': ['src'], + }, + }, + }, overrides: [{ files: ["**/__tests__/**/*.[jt]s?(x)", "**/?(*.)+(spec|test).[jt]s?(x)", "setupTest.js"], rules: { diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index d38a3991c9..4e80103170 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -1,11 +1,11 @@ name: validate on: push: - branches: - - master + branches: + - master pull_request: - branches: - - '**' + branches: + - master jobs: tests: runs-on: ubuntu-latest diff --git a/docs/decisions/0010-outline-sidebar.md b/docs/decisions/0010-outline-sidebar.md new file mode 100644 index 0000000000..0468f3fc98 --- /dev/null +++ b/docs/decisions/0010-outline-sidebar.md @@ -0,0 +1,13 @@ +# Add Outline Sidebar + +Following the [DISCOVERY](https://agile-jira.pearson.com/browse/PADV-213) made and the proposed [DESIGN](https://lucid.app/lucidchart/d52cc785-409f-4964-af29-ff277baa5bc5/edit?invitationId=inv_e569412e-e9f0-44aa-a9fa-54123d272f1a&referringApp=slack&page=l2M~LaFs47mo#), the MFE sidebar navigation is added in Frontend-App-Learning. + +Using currently logic in Frontend-App-Learning, **Outline Sidebar** is integrated following Discussion Sidebar. Using the already created **SidebarBase** function, the integration of the Outline sidebar is done through an iframe, which allows the integration of a Microfrontend located in a certain path, in this case using the port :9090, called **SIDEBAR_MFE_BASE_URL**. + +Then, using **SidebarTriggerBase**, the respective integration of the trigger is done, where to locate it in a different div from the one that already exists in src/courseware/course/Course.jsx module, the **SidebarOutlineTrigger** module is created. + +To show the Outline Sidebar it is necessary to take the context through the **SidebarContext** function to src/courseware/course/sequence/Sequence.jsx where if the Outline Sidebar is active it will be shown on the left, otherwise another sidebar will be shown in the default from the platform. + +## Next Step: Add Navigation Functionality + +The next step for the Outline Sidebar integration is to make the navigation more user friendly where it goes to the subsection the user needs without the need to open another tab. diff --git a/jest.config.js b/jest.config.js index 7020176f69..066b235bf6 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,6 +1,7 @@ const { createConfig } = require('@edx/frontend-build'); module.exports = createConfig('jest', { + modulePaths: ['src'], setupFilesAfterEnv: [ '/src/setupTest.js', ], diff --git a/src/constants.js b/src/constants.js new file mode 100644 index 0000000000..07c94be656 --- /dev/null +++ b/src/constants.js @@ -0,0 +1,3 @@ +/* eslint-disable import/prefer-default-export */ + +export const SIDEBAREVENT = 'outline_event'; diff --git a/src/courseware/CoursewareContainer.jsx b/src/courseware/CoursewareContainer.jsx index f22d840e67..96515d6e1f 100644 --- a/src/courseware/CoursewareContainer.jsx +++ b/src/courseware/CoursewareContainer.jsx @@ -1,9 +1,11 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; -import { history } from '@edx/frontend-platform'; +import { history, getConfig } from '@edx/frontend-platform'; import { createSelector } from '@reduxjs/toolkit'; import { defaultMemoize as memoize } from 'reselect'; +import { postEventToIframe } from '../eventsHandler'; +import { SIDEBAREVENT } from '../constants'; import { checkBlockCompletion, @@ -269,6 +271,13 @@ class CoursewareContainer extends Component { if (nextSequence !== null) { history.push(`/course/${courseId}/${nextSequence.id}/first`); + // Function to send the event to Outline Navigation SIdebar. + postEventToIframe( + document.getElementById('OutlineSidebar'), + SIDEBAREVENT, + [getConfig().SIDEBAR_MFE_BASE_URL], + ); + const celebrateFirstSection = course && course.celebrations && course.celebrations.firstSection; if (celebrateFirstSection && sequence.sectionId !== nextSequence.sectionId) { handleNextSectionCelebration(sequenceId, nextSequence.id); @@ -278,11 +287,30 @@ class CoursewareContainer extends Component { handlePreviousSequenceClick = () => { const { previousSequence, courseId } = this.props; + if (previousSequence !== null) { history.push(`/course/${courseId}/${previousSequence.id}/last`); + + // Function to send the event to Outline Navigation SIdebar. + postEventToIframe( + document.getElementById('OutlineSidebar'), + SIDEBAREVENT, + [getConfig().SIDEBAR_MFE_BASE_URL], + ); } } + handleOutlineSidebarNavigationClick = (id) => { + const { + courseId, + sequenceId, + } = this.props; + + if (id !== null && id !== sequenceId) { + history.push(`/course/${courseId}/${id}/first`); + } + }; + render() { const { courseStatus, @@ -310,6 +338,7 @@ class CoursewareContainer extends Component { nextSequenceHandler={this.handleNextSequenceClick} previousSequenceHandler={this.handlePreviousSequenceClick} unitNavigationHandler={this.handleUnitNavigationClick} + sidebarNavigationClickHandler={this.handleOutlineSidebarNavigationClick} /> ); diff --git a/src/courseware/course/Course.jsx b/src/courseware/course/Course.jsx index e6bf5b7072..ff0b48f47f 100644 --- a/src/courseware/course/Course.jsx +++ b/src/courseware/course/Course.jsx @@ -5,6 +5,8 @@ import { useDispatch } from 'react-redux'; import { getConfig } from '@edx/frontend-platform'; import { breakpoints, useWindowSize } from '@edx/paragon'; +import OutlineSidebar from './sidebar/sidebars/outline/OutlineSidebar'; +import SidebarOutlineTrigger from './sidebar/SidebarOutlineTrigger'; import { AlertList } from '../../generic/user-messages'; import Sequence from './sequence'; @@ -28,6 +30,7 @@ function Course({ nextSequenceHandler, previousSequenceHandler, unitNavigationHandler, + sidebarNavigationClickHandler, windowWidth, }) { const course = useModel('coursewareMeta', courseId); @@ -88,7 +91,7 @@ function Course({ {`${pageTitleBreadCrumbs.join(' | ')} | ${getConfig().SITE_NAME}`} -
+
)} -
+ +
+ {shouldDisplayTriggers && ( + + )} +
- +
+ +
+ +
+
{ const nextSequenceHandler = jest.fn(); const previousSequenceHandler = jest.fn(); const unitNavigationHandler = jest.fn(); + const sidebarNavigationClickHandler = jest.fn(); const courseMetadata = Factory.build('courseMetadata'); const unitBlocks = Array.from({ length: 3 }).map(() => Factory.build( @@ -207,6 +208,7 @@ describe('Course', () => { nextSequenceHandler, previousSequenceHandler, unitNavigationHandler, + sidebarNavigationClickHandler, }; render(, { store: testStore }); @@ -219,6 +221,7 @@ describe('Course', () => { expect(previousSequenceHandler).not.toHaveBeenCalled(); expect(nextSequenceHandler).not.toHaveBeenCalled(); expect(unitNavigationHandler).toHaveBeenCalledTimes(4); + expect(sidebarNavigationClickHandler).not.toHaveBeenCalled(); }); describe('Sequence alerts display', () => { diff --git a/src/courseware/course/sequence/Sequence.jsx b/src/courseware/course/sequence/Sequence.jsx index f0b5c57f28..43b77fa238 100644 --- a/src/courseware/course/sequence/Sequence.jsx +++ b/src/courseware/course/sequence/Sequence.jsx @@ -1,6 +1,6 @@ /* eslint-disable no-use-before-define */ import React, { - useEffect, useState, + useEffect, useState, useContext, } from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; @@ -14,6 +14,8 @@ import { history } from '@edx/frontend-platform'; import SequenceExamWrapper from '@edx/frontend-lib-special-exams'; import { breakpoints, useWindowSize } from '@edx/paragon'; +import SidebarContext from '../sidebar/SidebarContext'; +import { ID } from '../sidebar/sidebars/outline/OutlineTrigger'; import PageLoading from '../../../generic/PageLoading'; import { useModel } from '../../../generic/model-store'; import { useSequenceBannerTextAlert, useSequenceEntranceExamAlert } from '../../../alerts/sequence-alerts/hooks'; @@ -37,6 +39,7 @@ function Sequence({ unitNavigationHandler, nextSequenceHandler, previousSequenceHandler, + sidebarNavigationClickHandler, intl, mmp2p, }) { @@ -75,6 +78,20 @@ function Sequence({ unitNavigationHandler(destinationUnitId); }; + function handleSidebarNavigation(event) { + if (event.data.message === 'outline_sidebar_navigation_started') { + sidebarNavigationClickHandler(event.data.subsection_id); + } + } + // Event Listener for frontend-app-sidebar-navigation + useEffect(() => { + window.addEventListener('message', handleSidebarNavigation); + // Cleanup eventListener + return () => { + window.removeEventListener('message', handleSidebarNavigation); + }; + }); + const logEvent = (eventName, widgetPlacement, targetUnitId) => { // Note: tabs are tracked with a 1-indexed position // as opposed to a 0-index used throughout this MFE @@ -148,6 +165,11 @@ function Sequence({ history.push(`/course/${courseId}/course-end`); }; + const { + currentSidebar, + } = useContext(SidebarContext); + const isOutlineActive = currentSidebar === ID; + const defaultContent = (
@@ -202,7 +224,7 @@ function Sequence({ )}
- + {isOutlineActive ? null : } {/** [MM-P2P] Experiment */} {(mmp2p.state.isEnabled && mmp2p.flyover.isVisible) && ( @@ -245,6 +267,7 @@ Sequence.propTypes = { unitNavigationHandler: PropTypes.func.isRequired, nextSequenceHandler: PropTypes.func.isRequired, previousSequenceHandler: PropTypes.func.isRequired, + sidebarNavigationClickHandler: PropTypes.func.isRequired, intl: intlShape.isRequired, /** [MM-P2P] Experiment */ diff --git a/src/courseware/course/sequence/Sequence.test.jsx b/src/courseware/course/sequence/Sequence.test.jsx index 0836c2c9f3..cd1d0aa60e 100644 --- a/src/courseware/course/sequence/Sequence.test.jsx +++ b/src/courseware/course/sequence/Sequence.test.jsx @@ -2,6 +2,7 @@ import React from 'react'; import { Factory } from 'rosie'; import { sendTrackEvent } from '@edx/frontend-platform/analytics'; import { breakpoints } from '@edx/paragon'; +import { act } from 'react-dom/test-utils'; import { loadUnit, render, screen, fireEvent, waitFor, initializeTestStore, } from '../../../setupTest'; @@ -32,6 +33,7 @@ describe('Sequence', () => { previousSequenceHandler: () => {}, toggleNotificationTray: () => {}, setNotificationStatus: () => {}, + sidebarNavigationClickHandler: () => {}, }; }); @@ -236,6 +238,7 @@ describe('Sequence', () => { unitNavigationHandler: jest.fn(), previousSequenceHandler: jest.fn(), nextSequenceHandler: jest.fn(), + sidebarNavigationClickHandler: jest.fn(), }; render(, { store: testStore }); await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).toBeInTheDocument()); @@ -246,6 +249,7 @@ describe('Sequence', () => { fireEvent.click(screen.getByRole('button', { name: /next/i })); expect(testData.nextSequenceHandler).not.toHaveBeenCalled(); + expect(testData.sidebarNavigationClickHandler).not.toHaveBeenCalled(); // As `previousSequenceHandler` and `nextSequenceHandler` are mocked, we aren't really changing the position here. // Therefore the next unit will still be `the initial one + 1`. expect(testData.unitNavigationHandler).toHaveBeenNthCalledWith(2, unitBlocks[unitNumber + 1].id); @@ -260,6 +264,7 @@ describe('Sequence', () => { sequenceId: sequenceBlocks[0].id, unitNavigationHandler: jest.fn(), previousSequenceHandler: jest.fn(), + sidebarNavigationClickHandler: jest.fn(), }; render(, { store: testStore }); loadUnit(); @@ -269,6 +274,7 @@ describe('Sequence', () => { expect(testData.previousSequenceHandler).not.toHaveBeenCalled(); expect(testData.unitNavigationHandler).not.toHaveBeenCalled(); + expect(testData.sidebarNavigationClickHandler).not.toHaveBeenCalled(); expect(sendTrackEvent).not.toHaveBeenCalled(); }); @@ -279,6 +285,7 @@ describe('Sequence', () => { sequenceId: sequenceBlocks[sequenceBlocks.length - 1].id, unitNavigationHandler: jest.fn(), nextSequenceHandler: jest.fn(), + sidebarNavigationClickHandler: jest.fn(), }; render(, { store: testStore }); loadUnit(); @@ -288,6 +295,7 @@ describe('Sequence', () => { expect(testData.nextSequenceHandler).not.toHaveBeenCalled(); expect(testData.unitNavigationHandler).not.toHaveBeenCalled(); + expect(testData.sidebarNavigationClickHandler).not.toHaveBeenCalled(); expect(sendTrackEvent).not.toHaveBeenCalled(); }); @@ -320,6 +328,7 @@ describe('Sequence', () => { unitNavigationHandler: jest.fn(), previousSequenceHandler: jest.fn(), nextSequenceHandler: jest.fn(), + sidebarNavigationClickHandler: jest.fn(), }; render(, { store: innerTestStore }); @@ -329,10 +338,12 @@ describe('Sequence', () => { screen.getAllByRole('button', { name: /previous/i }).forEach(button => fireEvent.click(button)); expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(2); expect(testData.unitNavigationHandler).not.toHaveBeenCalled(); + expect(testData.sidebarNavigationClickHandler).not.toHaveBeenCalled(); screen.getAllByRole('button', { name: /next/i }).forEach(button => fireEvent.click(button)); expect(testData.nextSequenceHandler).toHaveBeenCalledTimes(2); expect(testData.unitNavigationHandler).not.toHaveBeenCalled(); + expect(testData.sidebarNavigationClickHandler).not.toHaveBeenCalled(); expect(sendTrackEvent).toHaveBeenNthCalledWith(1, 'edx.ui.lms.sequence.previous_selected', { current_tab: 1, @@ -419,3 +430,31 @@ describe('Sequence', () => { }); }); }); + +describe('window.addEventListener', () => { + it('calls sidebarNavigationClickHandler with the correct argument', () => { + global.window = Object.create(window); + const sidebarNavigationClickHandler = jest.fn(); + + // Add Event listener. + window.addEventListener('message', event => { + if (event.data.message === 'outline_sidebar_navigation_started') { + sidebarNavigationClickHandler(event.data.subsection_id); + } + }); + + const event = { + data: { + message: 'outline_sidebar_navigation_started', + subsection_id: 'xBlockSubsectionId', + }, + }; + + act(() => { + window.dispatchEvent(new MessageEvent('message', event)); + }); + + // Assert that the sidebarNavigationClickHandler function was called with the correct argument + expect(sidebarNavigationClickHandler).toHaveBeenCalledWith('xBlockSubsectionId'); + }); +}); diff --git a/src/courseware/course/sidebar/SidebarOutlineTrigger.jsx b/src/courseware/course/sidebar/SidebarOutlineTrigger.jsx new file mode 100644 index 0000000000..bc4978651f --- /dev/null +++ b/src/courseware/course/sidebar/SidebarOutlineTrigger.jsx @@ -0,0 +1,15 @@ +import React from 'react'; +import TriggerButton from './TriggerButton'; +import { ID } from './sidebars/outline/OutlineTrigger'; + +function SidebarOutlineTrigger() { + return ( +
+ +
+ ); +} + +SidebarOutlineTrigger.propTypes = {}; + +export default SidebarOutlineTrigger; diff --git a/src/courseware/course/sidebar/SidebarTriggers.jsx b/src/courseware/course/sidebar/SidebarTriggers.jsx index b578f41bb4..67c3aa67f2 100644 --- a/src/courseware/course/sidebar/SidebarTriggers.jsx +++ b/src/courseware/course/sidebar/SidebarTriggers.jsx @@ -1,29 +1,14 @@ -import classNames from 'classnames'; -import React, { useContext } from 'react'; -import SidebarContext from './SidebarContext'; -import { SIDEBAR_ORDER, SIDEBARS } from './sidebars'; +import React from 'react'; +import { SIDEBAR_ORDER } from './sidebars'; +import TriggerButton from './TriggerButton'; function SidebarTriggers() { - const { - toggleSidebar, - currentSidebar, - } = useContext(SidebarContext); return ( -
- {SIDEBAR_ORDER.map((sidebarId) => { - const { Trigger } = SIDEBARS[sidebarId]; - const isActive = sidebarId === currentSidebar; - return ( -
- toggleSidebar(sidebarId)} key={sidebarId} /> -
- ); - })} -
+
+ {SIDEBAR_ORDER.map((sidebarId) => ( + + ))} +
); } diff --git a/src/courseware/course/sidebar/TriggerButton.jsx b/src/courseware/course/sidebar/TriggerButton.jsx new file mode 100644 index 0000000000..4d5b91bc1c --- /dev/null +++ b/src/courseware/course/sidebar/TriggerButton.jsx @@ -0,0 +1,31 @@ +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React, { useContext } from 'react'; +import SidebarContext from './SidebarContext'; +import { SIDEBARS } from './sidebars'; + +function TriggerButton({ + sidebarId, +}) { + const { + toggleSidebar, + currentSidebar, + } = useContext(SidebarContext); + const { Trigger } = SIDEBARS[sidebarId]; + const isActive = sidebarId === currentSidebar; + return ( +
+ toggleSidebar(sidebarId)} key={sidebarId} /> +
+ ); +} + +TriggerButton.propTypes = { + sidebarId: PropTypes.string.isRequired, +}; + +export default TriggerButton; diff --git a/src/courseware/course/sidebar/sidebars/index.js b/src/courseware/course/sidebar/sidebars/index.js index 9128fd0e48..9b3494faf6 100644 --- a/src/courseware/course/sidebar/sidebars/index.js +++ b/src/courseware/course/sidebar/sidebars/index.js @@ -1,7 +1,13 @@ +import * as outline from './outline'; import * as notifications from './notifications'; import * as discusssions from './discussions'; export const SIDEBARS = { + [outline.ID]: { + ID: outline.ID, + Sidebar: outline.Sidebar, + Trigger: outline.Trigger, + }, [notifications.ID]: { ID: notifications.ID, Sidebar: notifications.Sidebar, diff --git a/src/courseware/course/sidebar/sidebars/outline/OutlineSidebar.jsx b/src/courseware/course/sidebar/sidebars/outline/OutlineSidebar.jsx new file mode 100644 index 0000000000..304fef863f --- /dev/null +++ b/src/courseware/course/sidebar/sidebars/outline/OutlineSidebar.jsx @@ -0,0 +1,55 @@ +import { ensureConfig, getConfig } from '@edx/frontend-platform'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import React, { useContext } from 'react'; +import SidebarBase from '../../common/SidebarBase'; +import SidebarContext from '../../SidebarContext'; +import { ID } from './OutlineTrigger'; + +import messages from './messages'; + +ensureConfig(['SIDEBAR_MFE_BASE_URL']); + +function OutlineSidebar({ + intl, +}) { + const { + courseId, + unitId, + } = useContext(SidebarContext); + const outlineUrl = `${getConfig().SIDEBAR_MFE_BASE_URL}/${courseId}/${unitId}`; + const savedScrollPosition = window.scrollY; + let allowScroll = true; + window.addEventListener('scroll', () => { + if (allowScroll) { + window.scrollTo(0, savedScrollPosition); + } + allowScroll = false; + }); + + return ( + +