feat: [FC-0056] courseware sidebar enhancement (#1398)
- Display section and sequence progress - Add tracking event to the unit button - Hide the horizontal unit navigation with enabled sidebar navigation
This commit is contained in:
@@ -15,6 +15,7 @@ import PageLoading from '@src/generic/PageLoading';
|
||||
import { useModel } from '@src/generic/model-store';
|
||||
import { useSequenceBannerTextAlert, useSequenceEntranceExamAlert } from '@src/alerts/sequence-alerts/hooks';
|
||||
|
||||
import { getCoursewareOutlineSidebarSettings } from '../../data/selectors';
|
||||
import CourseLicense from '../course-license';
|
||||
import Sidebar from '../sidebar/Sidebar';
|
||||
import NewSidebar from '../new-sidebar/Sidebar';
|
||||
@@ -49,6 +50,7 @@ const Sequence = ({
|
||||
const unit = useModel('units', unitId);
|
||||
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
|
||||
const sequenceMightBeUnit = useSelector(state => state.courseware.sequenceMightBeUnit);
|
||||
const { enableNavigationSidebar: isEnabledOutlineSidebar } = useSelector(getCoursewareOutlineSidebarSettings);
|
||||
|
||||
const handleNext = () => {
|
||||
const nextIndex = sequence.unitIds.indexOf(unitId) + 1;
|
||||
@@ -144,33 +146,50 @@ const Sequence = ({
|
||||
|
||||
const gated = sequence && sequence.gatedContent !== undefined && sequence.gatedContent.gated;
|
||||
|
||||
const renderUnitNavigation = (isAtTop) => (
|
||||
<UnitNavigation
|
||||
sequenceId={sequenceId}
|
||||
unitId={unitId}
|
||||
isAtTop={isAtTop}
|
||||
onClickPrevious={() => {
|
||||
logEvent('edx.ui.lms.sequence.previous_selected', 'bottom');
|
||||
handlePrevious();
|
||||
}}
|
||||
onClickNext={() => {
|
||||
logEvent('edx.ui.lms.sequence.next_selected', 'bottom');
|
||||
handleNext();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const defaultContent = (
|
||||
<>
|
||||
<div className="sequence-container d-inline-flex flex-row w-100">
|
||||
<CourseOutlineTrigger />
|
||||
<CourseOutlineTray />
|
||||
<div className="sequence w-100">
|
||||
<div className="sequence-navigation-container">
|
||||
<SequenceNavigation
|
||||
sequenceId={sequenceId}
|
||||
unitId={unitId}
|
||||
className="mb-4"
|
||||
nextHandler={() => {
|
||||
logEvent('edx.ui.lms.sequence.next_selected', 'top');
|
||||
handleNext();
|
||||
}}
|
||||
onNavigate={(destinationUnitId) => {
|
||||
logEvent('edx.ui.lms.sequence.tab_selected', 'top', destinationUnitId);
|
||||
handleNavigate(destinationUnitId);
|
||||
}}
|
||||
previousHandler={() => {
|
||||
logEvent('edx.ui.lms.sequence.previous_selected', 'top');
|
||||
handlePrevious();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{!isEnabledOutlineSidebar && (
|
||||
<div className="sequence-navigation-container">
|
||||
<SequenceNavigation
|
||||
sequenceId={sequenceId}
|
||||
unitId={unitId}
|
||||
nextHandler={() => {
|
||||
logEvent('edx.ui.lms.sequence.next_selected', 'top');
|
||||
handleNext();
|
||||
}}
|
||||
onNavigate={(destinationUnitId) => {
|
||||
logEvent('edx.ui.lms.sequence.tab_selected', 'top', destinationUnitId);
|
||||
handleNavigate(destinationUnitId);
|
||||
}}
|
||||
previousHandler={() => {
|
||||
logEvent('edx.ui.lms.sequence.previous_selected', 'top');
|
||||
handlePrevious();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="unit-container flex-grow-1">
|
||||
<div className="unit-container flex-grow-1 pt-4">
|
||||
<SequenceContent
|
||||
courseId={courseId}
|
||||
gated={gated}
|
||||
@@ -178,20 +197,7 @@ const Sequence = ({
|
||||
unitId={unitId}
|
||||
unitLoadedHandler={handleUnitLoaded}
|
||||
/>
|
||||
{unitHasLoaded && (
|
||||
<UnitNavigation
|
||||
sequenceId={sequenceId}
|
||||
unitId={unitId}
|
||||
onClickPrevious={() => {
|
||||
logEvent('edx.ui.lms.sequence.previous_selected', 'bottom');
|
||||
handlePrevious();
|
||||
}}
|
||||
onClickNext={() => {
|
||||
logEvent('edx.ui.lms.sequence.next_selected', 'bottom');
|
||||
handleNext();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{unitHasLoaded && renderUnitNavigation(false)}
|
||||
</div>
|
||||
</div>
|
||||
{isNewDiscussionSidebarViewEnabled ? <NewSidebar /> : <Sidebar />}
|
||||
@@ -216,6 +222,7 @@ const Sequence = ({
|
||||
originalUserIsStaff={originalUserIsStaff}
|
||||
canAccessProctoredExams={canAccessProctoredExams}
|
||||
>
|
||||
{isEnabledOutlineSidebar && renderUnitNavigation(true)}
|
||||
{defaultContent}
|
||||
</SequenceExamWrapper>
|
||||
<CourseLicense license={license || undefined} />
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Factory } from 'rosie';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
@@ -25,6 +24,7 @@ describe('Sequence', () => {
|
||||
{ type: 'vertical' },
|
||||
{ courseId: courseMetadata.id },
|
||||
));
|
||||
const enableNavigationSidebar = { enable_navigation_sidebar: false };
|
||||
|
||||
beforeAll(async () => {
|
||||
const store = await initializeTestStore({ courseMetadata, unitBlocks });
|
||||
@@ -92,7 +92,11 @@ describe('Sequence', () => {
|
||||
{ courseId: courseMetadata.id, unitBlocks, sequenceBlock: sequenceBlocks[0] },
|
||||
)];
|
||||
const testStore = await initializeTestStore({
|
||||
courseMetadata, unitBlocks, sequenceBlocks, sequenceMetadata,
|
||||
courseMetadata,
|
||||
unitBlocks,
|
||||
sequenceBlocks,
|
||||
sequenceMetadata,
|
||||
enableNavigationSidebar: { enable_navigation_sidebar: true },
|
||||
}, false);
|
||||
const { container } = render(
|
||||
<SidebarWrapper overrideData={{ sequenceId: sequenceBlocks[0].id }} />,
|
||||
@@ -102,8 +106,8 @@ describe('Sequence', () => {
|
||||
await waitFor(() => expect(screen.queryByText('Loading locked content messaging...')).toBeInTheDocument());
|
||||
// `Previous`, `Prerequisite` and `Close Tray` buttons.
|
||||
expect(screen.getAllByRole('button').length).toEqual(3);
|
||||
// `Active` and `Next` buttons.
|
||||
expect(screen.getAllByRole('link').length).toEqual(2);
|
||||
// `Next` button.
|
||||
expect(screen.getAllByRole('link').length).toEqual(1);
|
||||
|
||||
expect(screen.getByText('Content Locked')).toBeInTheDocument();
|
||||
const unitContainer = container.querySelector('.unit-container');
|
||||
@@ -125,7 +129,7 @@ describe('Sequence', () => {
|
||||
{ courseId: courseMetadata.id, unitBlocks, sequenceBlock: sequenceBlocks[0] },
|
||||
)];
|
||||
const testStore = await initializeTestStore({
|
||||
courseMetadata, unitBlocks, sequenceBlocks, sequenceMetadata,
|
||||
courseMetadata, unitBlocks, sequenceBlocks, sequenceMetadata, enableNavigationSidebar,
|
||||
}, false);
|
||||
render(
|
||||
<Sequence {...mockData} {...{ sequenceId: sequenceBlocks[0].id }} />,
|
||||
@@ -156,14 +160,16 @@ describe('Sequence', () => {
|
||||
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
|
||||
// `Previous`, `Prerequisite` and `Close Tray` buttons.
|
||||
expect(screen.getAllByRole('button')).toHaveLength(3);
|
||||
// Renders `Next` button plus one button for each unit.
|
||||
expect(screen.getAllByRole('link')).toHaveLength(1 + unitBlocks.length);
|
||||
// Renders `Next` button.
|
||||
expect(screen.getAllByRole('link')).toHaveLength(1);
|
||||
|
||||
loadUnit();
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||
// At this point there will be 2 `Previous` and 2 `Next` buttons.
|
||||
expect(screen.getAllByRole('button', { name: /previous/i }).length).toEqual(2);
|
||||
expect(screen.getAllByRole('link', { name: /next/i }).length).toEqual(2);
|
||||
// Renders two `Next` buttons for top and bottom unit navigations.
|
||||
expect(screen.getAllByRole('link')).toHaveLength(2);
|
||||
});
|
||||
|
||||
describe('sequence and unit navigation buttons', () => {
|
||||
@@ -179,7 +185,9 @@ describe('Sequence', () => {
|
||||
)];
|
||||
|
||||
beforeAll(async () => {
|
||||
testStore = await initializeTestStore({ courseMetadata, unitBlocks, sequenceBlocks }, false);
|
||||
testStore = await initializeTestStore({
|
||||
courseMetadata, unitBlocks, sequenceBlocks, enableNavigationSidebar,
|
||||
}, false);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -340,7 +348,11 @@ describe('Sequence', () => {
|
||||
{ courseId: courseMetadata.id, unitBlocks: block.children.length ? unitBlocks : [], sequenceBlock: block },
|
||||
));
|
||||
const innerTestStore = await initializeTestStore({
|
||||
courseMetadata, unitBlocks, sequenceBlocks: testSequenceBlocks, sequenceMetadata: testSequenceMetadata,
|
||||
courseMetadata,
|
||||
unitBlocks,
|
||||
sequenceBlocks: testSequenceBlocks,
|
||||
sequenceMetadata: testSequenceMetadata,
|
||||
enableNavigationSidebar,
|
||||
}, false);
|
||||
const testData = {
|
||||
...mockData,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Button } from '@openedx/paragon';
|
||||
@@ -21,6 +21,7 @@ const UnitNavigation = ({
|
||||
unitId,
|
||||
onClickPrevious,
|
||||
onClickNext,
|
||||
isAtTop,
|
||||
}) => {
|
||||
const {
|
||||
isFirstUnit, isLastUnit, nextLink, previousLink,
|
||||
@@ -33,7 +34,7 @@ const UnitNavigation = ({
|
||||
return (
|
||||
<Button
|
||||
variant="outline-secondary"
|
||||
className="previous-button mr-2 d-flex align-items-center justify-content-center"
|
||||
className="previous-button mr-sm-2 d-flex align-items-center justify-content-center"
|
||||
disabled={disabled}
|
||||
onClick={onClickPrevious}
|
||||
as={disabled ? undefined : Link}
|
||||
@@ -68,7 +69,7 @@ const UnitNavigation = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="unit-navigation d-flex">
|
||||
<div className={classNames('unit-navigation d-flex', { 'top-unit-navigation mb-3 w-100': isAtTop })}>
|
||||
{renderPreviousButton()}
|
||||
{renderNextButton()}
|
||||
</div>
|
||||
@@ -81,10 +82,12 @@ UnitNavigation.propTypes = {
|
||||
unitId: PropTypes.string,
|
||||
onClickPrevious: PropTypes.func.isRequired,
|
||||
onClickNext: PropTypes.func.isRequired,
|
||||
isAtTop: PropTypes.bool,
|
||||
};
|
||||
|
||||
UnitNavigation.defaultProps = {
|
||||
unitId: null,
|
||||
isAtTop: false,
|
||||
};
|
||||
|
||||
export default injectIntl(UnitNavigation);
|
||||
|
||||
@@ -20,7 +20,7 @@ const SidebarTriggers = () => {
|
||||
return (
|
||||
<div
|
||||
className={classNames({ 'ml-1': !isMobileView, 'border-primary-700 sidebar-active': isActive })}
|
||||
style={{ borderBottom: isActive ? '2px solid' : null }}
|
||||
style={{ borderBottom: '2px solid', borderColor: isActive ? 'inherit' : 'transparent' }}
|
||||
key={sidebarId}
|
||||
>
|
||||
<Trigger onClick={() => toggleSidebar(sidebarId)} key={sidebarId} />
|
||||
|
||||
@@ -38,7 +38,7 @@ const SidebarBase = ({
|
||||
<section
|
||||
className={classNames('ml-0 border border-light-400 rounded-sm h-auto align-top zindex-0', {
|
||||
'bg-white m-0 border-0 fixed-top vh-100 rounded-0': shouldDisplayFullScreen,
|
||||
'min-vh-100': !shouldDisplayFullScreen,
|
||||
'align-self-start': !shouldDisplayFullScreen,
|
||||
'd-none': currentSidebar !== sidebarId,
|
||||
}, className)}
|
||||
data-testid={`sidebar-${sidebarId}`}
|
||||
|
||||
@@ -63,7 +63,7 @@ const CourseOutlineTray = ({ intl }) => {
|
||||
};
|
||||
|
||||
const sidebarHeading = (
|
||||
<div className="outline-sidebar-heading-wrapper sticky d-flex justify-content-between align-items-center bg-light-200 p-2.5 pl-4">
|
||||
<div className="outline-sidebar-heading-wrapper sticky d-flex justify-content-between align-self-start align-items-center bg-light-200 p-2.5 pl-4">
|
||||
{isDisplaySequenceLevel && backButtonTitle ? (
|
||||
<Button
|
||||
variant="link"
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
|
||||
.outline-sidebar-heading-wrapper {
|
||||
border: 1px solid #d7d3d1;
|
||||
align-self: flex-start;
|
||||
|
||||
&.sticky {
|
||||
position: sticky;
|
||||
|
||||
@@ -25,8 +25,8 @@ const CourseOutlineTrigger = ({ intl, isMobileView }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames('outline-sidebar-heading-wrapper bg-light-200 collapsed', {
|
||||
'flex-shrink-0 mr-4 p-2.5 sticky': isDisplayForDesktopView,
|
||||
<div className={classNames('outline-sidebar-heading-wrapper bg-light-200 collapsed align-self-start', {
|
||||
'flex-shrink-0 mr-4 p-2.5': isDisplayForDesktopView,
|
||||
'p-0': isDisplayForMobileView,
|
||||
})}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
CheckCircle as CheckCircleIcon,
|
||||
LmsCompletionSolid as LmsCompletionSolidIcon,
|
||||
} from '@openedx/paragon/icons';
|
||||
|
||||
import { DashedCircleIcon } from '../icons';
|
||||
|
||||
const CompletionIcon = ({ completionStat: { completed = 0, total = 0 } }) => {
|
||||
const percentage = total !== 0 ? Math.min((completed / total) * 100, 100) : 0;
|
||||
const remainder = 100 - percentage;
|
||||
|
||||
switch (true) {
|
||||
case !completed:
|
||||
return <LmsCompletionSolidIcon className="text-gray-300" data-testid="completion-solid-icon" />;
|
||||
case completed === total:
|
||||
return <CheckCircleIcon className="text-success" data-testid="check-circle-icon" />;
|
||||
default:
|
||||
return <DashedCircleIcon percentage={percentage} remainder={remainder} data-testid="dashed-circle-icon" />;
|
||||
}
|
||||
};
|
||||
|
||||
CompletionIcon.propTypes = {
|
||||
completionStat: PropTypes.shape({
|
||||
completed: PropTypes.number,
|
||||
total: PropTypes.number,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default CompletionIcon;
|
||||
@@ -0,0 +1,23 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import CompletionIcon from './CompletionIcon';
|
||||
|
||||
describe('CompletionIcon', () => {
|
||||
it('renders check circle icon when completion is equal to total', () => {
|
||||
const completionStat = { completed: 5, total: 5 };
|
||||
render(<CompletionIcon completionStat={completionStat} />);
|
||||
expect(screen.getByTestId('check-circle-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders dashed circle icon when completion is between 0 and total', () => {
|
||||
const completionStat = { completed: 2, total: 5 };
|
||||
render(<CompletionIcon completionStat={completionStat} />);
|
||||
expect(screen.getByTestId('dashed-circle-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders completion solid icon when completion is 0', () => {
|
||||
const completionStat = { completed: 0, total: 5 };
|
||||
render(<CompletionIcon completionStat={completionStat} />);
|
||||
expect(screen.getByTestId('completion-solid-icon')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -3,14 +3,11 @@ import classNames from 'classnames';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button, Icon } from '@openedx/paragon';
|
||||
import {
|
||||
CheckCircle as CheckCircleIcon,
|
||||
ChevronRight as ChevronRightIcon,
|
||||
LmsCompletionSolid as LmsCompletionSolidIcon,
|
||||
} from '@openedx/paragon/icons';
|
||||
import { ChevronRight as ChevronRightIcon } from '@openedx/paragon/icons';
|
||||
|
||||
import courseOutlineMessages from '@src/course-home/outline-tab/messages';
|
||||
import { getSequenceId } from '@src/courseware/data/selectors';
|
||||
import CompletionIcon from './CompletionIcon';
|
||||
|
||||
const SidebarSection = ({ intl, section, handleSelectSection }) => {
|
||||
const {
|
||||
@@ -18,6 +15,7 @@ const SidebarSection = ({ intl, section, handleSelectSection }) => {
|
||||
complete,
|
||||
title,
|
||||
sequenceIds,
|
||||
completionStat,
|
||||
} = section;
|
||||
|
||||
const activeSequenceId = useSelector(getSequenceId);
|
||||
@@ -26,7 +24,7 @@ const SidebarSection = ({ intl, section, handleSelectSection }) => {
|
||||
const sectionTitle = (
|
||||
<>
|
||||
<div className="col-auto p-0">
|
||||
{complete ? <CheckCircleIcon className="text-success" /> : <LmsCompletionSolidIcon className="text-gray-300" />}
|
||||
<CompletionIcon completionStat={completionStat} />
|
||||
</div>
|
||||
<div className="col-10 ml-3 p-0 flex-grow-1 text-dark-500 text-left text-break">
|
||||
{title}
|
||||
@@ -63,6 +61,10 @@ SidebarSection.propTypes = {
|
||||
id: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
sequenceIds: PropTypes.arrayOf(PropTypes.string),
|
||||
completionStat: PropTypes.shape({
|
||||
completed: PropTypes.number,
|
||||
total: PropTypes.number,
|
||||
}),
|
||||
}).isRequired,
|
||||
handleSelectSection: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { render } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
@@ -40,7 +40,7 @@ describe('<SidebarSection />', () => {
|
||||
const { getByText, container } = render(<RootWrapper />);
|
||||
|
||||
expect(getByText(section.title)).toBeInTheDocument();
|
||||
expect(screen.getByText(`, ${courseOutlineMessages.incompleteSection.defaultMessage}`)).toBeInTheDocument();
|
||||
expect(getByText(`, ${courseOutlineMessages.incompleteSection.defaultMessage}`)).toBeInTheDocument();
|
||||
expect(container.querySelector('.text-success')).not.toBeInTheDocument();
|
||||
|
||||
const button = getByText(section.title);
|
||||
@@ -51,13 +51,13 @@ describe('<SidebarSection />', () => {
|
||||
|
||||
it('renders correctly when section is complete', async () => {
|
||||
await initTestStore();
|
||||
const { getByText, container } = render(
|
||||
<RootWrapper section={{ ...section, complete: true }} />,
|
||||
const { getByText, getByTestId } = render(
|
||||
<RootWrapper section={{ ...section, completionStat: { completed: 4, total: 4 }, complete: true }} />,
|
||||
);
|
||||
|
||||
expect(getByText(section.title)).toBeInTheDocument();
|
||||
expect(screen.getByText(`, ${courseOutlineMessages.completedSection.defaultMessage}`)).toBeInTheDocument();
|
||||
expect(container.querySelector('.text-success')).toBeInTheDocument();
|
||||
expect(getByText(`, ${courseOutlineMessages.completedSection.defaultMessage}`)).toBeInTheDocument();
|
||||
expect(getByTestId('check-circle-icon')).toBeInTheDocument();
|
||||
|
||||
const button = getByText(section.title);
|
||||
userEvent.click(button);
|
||||
|
||||
@@ -4,13 +4,10 @@ import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Collapsible } from '@openedx/paragon';
|
||||
import {
|
||||
CheckCircle as CheckCircleIcon,
|
||||
LmsCompletionSolid as LmsCompletionSolidIcon,
|
||||
} from '@openedx/paragon/icons';
|
||||
|
||||
import courseOutlineMessages from '@src/course-home/outline-tab/messages';
|
||||
import { getCourseOutline, getSequenceId } from '@src/courseware/data/selectors';
|
||||
import CompletionIcon from './CompletionIcon';
|
||||
import SidebarUnit from './SidebarUnit';
|
||||
import { UNIT_ICON_TYPES } from './UnitIcon';
|
||||
|
||||
@@ -28,6 +25,7 @@ const SidebarSequence = ({
|
||||
specialExamInfo,
|
||||
unitIds,
|
||||
type,
|
||||
completionStat,
|
||||
} = sequence;
|
||||
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
@@ -38,7 +36,7 @@ const SidebarSequence = ({
|
||||
const sectionTitle = (
|
||||
<>
|
||||
<div className="col-auto p-0" style={{ fontSize: '1.1rem' }}>
|
||||
{complete ? <CheckCircleIcon className="text-success" /> : <LmsCompletionSolidIcon className="text-gray-300" />}
|
||||
<CompletionIcon completionStat={completionStat} />
|
||||
</div>
|
||||
<div className="col-9 d-flex flex-column flex-grow-1 ml-3 mr-auto p-0 text-left">
|
||||
<span className="align-middle text-dark-500">{title}</span>
|
||||
@@ -92,6 +90,10 @@ SidebarSequence.propTypes = {
|
||||
type: PropTypes.string,
|
||||
specialExamInfo: PropTypes.string,
|
||||
unitIds: PropTypes.arrayOf(PropTypes.string),
|
||||
completionStat: PropTypes.shape({
|
||||
completed: PropTypes.number,
|
||||
total: PropTypes.number,
|
||||
}),
|
||||
}).isRequired,
|
||||
activeUnitId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
@@ -6,8 +6,8 @@ import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import courseOutlineMessages from '@src/course-home/outline-tab/messages';
|
||||
import { initializeMockApp, initializeTestStore } from '@src/setupTest';
|
||||
import SidebarSequence from './SidebarSequence';
|
||||
import messages from '../messages';
|
||||
import SidebarSequence from './SidebarSequence';
|
||||
|
||||
initializeMockApp();
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
|
||||
|
||||
import { checkBlockCompletion } from '@src/courseware/data';
|
||||
import UnitIcon, { UNIT_ICON_TYPES } from './UnitIcon';
|
||||
import { getCourseOutline } from '@src/courseware/data/selectors';
|
||||
import messages from '../messages';
|
||||
import UnitIcon, { UNIT_ICON_TYPES } from './UnitIcon';
|
||||
|
||||
const SidebarUnit = ({
|
||||
id,
|
||||
@@ -25,8 +27,31 @@ const SidebarUnit = ({
|
||||
icon = UNIT_ICON_TYPES.other,
|
||||
} = unit;
|
||||
const dispatch = useDispatch();
|
||||
const { sequences = {} } = useSelector(getCourseOutline);
|
||||
|
||||
const logEvent = (eventName, widgetPlacement) => {
|
||||
const findSequenceByUnitId = (unitId) => Object.values(sequences).find(seq => seq.unitIds.includes(unitId));
|
||||
const activeSequence = findSequenceByUnitId(activeUnitId);
|
||||
const targetSequence = findSequenceByUnitId(id);
|
||||
const payload = {
|
||||
id: activeUnitId,
|
||||
current_tab: activeSequence.unitIds.indexOf(activeUnitId) + 1,
|
||||
tab_count: activeSequence.unitIds.length,
|
||||
target_id: id,
|
||||
target_tab: targetSequence.unitIds.indexOf(id) + 1,
|
||||
widget_placement: widgetPlacement,
|
||||
};
|
||||
|
||||
if (activeSequence.id !== targetSequence.id) {
|
||||
payload.target_tab_count = targetSequence.unitIds.length;
|
||||
}
|
||||
|
||||
sendTrackEvent(eventName, payload);
|
||||
sendTrackingLogEvent(eventName, payload);
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
logEvent('edx.ui.lms.sequence.tab_selected', 'left');
|
||||
dispatch(checkBlockCompletion(courseId, sequenceId, activeUnitId));
|
||||
};
|
||||
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
|
||||
|
||||
import { initializeMockApp, initializeTestStore } from '@src/setupTest';
|
||||
import SidebarUnit from './SidebarUnit';
|
||||
|
||||
jest.mock('@edx/frontend-platform/analytics', () => ({
|
||||
sendTrackEvent: jest.fn(),
|
||||
sendTrackingLogEvent: jest.fn(),
|
||||
}));
|
||||
|
||||
initializeMockApp();
|
||||
|
||||
describe('<SidebarUnit />', () => {
|
||||
@@ -28,11 +35,12 @@ describe('<SidebarUnit />', () => {
|
||||
<MemoryRouter>
|
||||
<SidebarUnit
|
||||
isFirst
|
||||
id="unit1"
|
||||
id={unit.id}
|
||||
courseId="course123"
|
||||
sequenceId={sequenceId}
|
||||
unit={{ ...unit, icon: 'video', isLocked: false }}
|
||||
isActive={false}
|
||||
activeUnitId={unit.id}
|
||||
{...props}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
@@ -78,4 +86,22 @@ describe('<SidebarUnit />', () => {
|
||||
|
||||
expect(screen.getByText(unit.title)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends log event correctly when unit is clicked', async () => {
|
||||
await initTestStore();
|
||||
renderWithProvider({ unit: { ...unit } });
|
||||
const logData = {
|
||||
id: unit.id,
|
||||
current_tab: 1,
|
||||
tab_count: 1,
|
||||
target_id: unit.id,
|
||||
target_tab: 1,
|
||||
widget_placement: 'left',
|
||||
};
|
||||
|
||||
userEvent.click(screen.getByText(unit.title));
|
||||
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.sequence.tab_selected', logData);
|
||||
expect(sendTrackingLogEvent).toHaveBeenCalledWith('edx.ui.lms.sequence.tab_selected', logData);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const DashedCircleIcon = (props) => (
|
||||
<svg
|
||||
width={24}
|
||||
height={24}
|
||||
viewBox="0 0 40 40"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<circle
|
||||
cx="20"
|
||||
cy="20"
|
||||
r="15"
|
||||
stroke="#ccc"
|
||||
strokeWidth="3"
|
||||
strokeDasharray="2.6 2.3"
|
||||
fill="transparent"
|
||||
strokeDashoffset="27"
|
||||
/>
|
||||
<circle
|
||||
cx="20"
|
||||
cy="20"
|
||||
r="15"
|
||||
fill="transparent"
|
||||
stroke="#0d7d4d"
|
||||
strokeWidth="3"
|
||||
strokeDasharray={`${props.percentage} ${props.remainder}`}
|
||||
strokeDashoffset="29"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
DashedCircleIcon.propTypes = {
|
||||
percentage: PropTypes.number.isRequired,
|
||||
remainder: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
export default DashedCircleIcon;
|
||||
@@ -0,0 +1,2 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export { default as DashedCircleIcon } from './DashedCircleIcon';
|
||||
@@ -78,8 +78,12 @@ const slice = createSlice({
|
||||
const sequenceId = Object.keys(state.courseOutline.sequences)
|
||||
.find(id => state.courseOutline.sequences[id].unitIds.includes(unitId));
|
||||
const sequenceUnits = state.courseOutline.sequences[sequenceId].unitIds;
|
||||
const completedUnits = sequenceUnits.filter((id) => state.courseOutline.units[id].complete);
|
||||
const isAllUnitsAreComplete = sequenceUnits.every((id) => state.courseOutline.units[id].complete);
|
||||
|
||||
// Update amount of completed units of the sequence
|
||||
state.courseOutline.sequences[sequenceId].completionStat.completed = completedUnits.length;
|
||||
|
||||
if (isAllUnitsAreComplete) {
|
||||
state.courseOutline.sequences[sequenceId].complete = true;
|
||||
}
|
||||
@@ -98,6 +102,12 @@ const slice = createSlice({
|
||||
state.courseOutlineShouldUpdate = true;
|
||||
}
|
||||
|
||||
// Update amount of completed units of the section
|
||||
state.courseOutline.sections[sectionId].completionStat.completed = sectionSequences.reduce(
|
||||
(acc, id) => acc + state.courseOutline.sequences[id].completionStat.completed,
|
||||
0,
|
||||
);
|
||||
|
||||
if (isAllSequencesAreComplete) {
|
||||
state.courseOutline.sections[sectionId].complete = true;
|
||||
}
|
||||
|
||||
@@ -169,6 +169,10 @@ export function normalizeOutlineBlocks(courseId, blocks) {
|
||||
id: block.id,
|
||||
title: block.display_name,
|
||||
sequenceIds: block.children || [],
|
||||
completionStat: {
|
||||
completed: block.completion_stat?.completion,
|
||||
total: block.completion_stat?.completable_children,
|
||||
},
|
||||
};
|
||||
break;
|
||||
|
||||
@@ -181,6 +185,10 @@ export function normalizeOutlineBlocks(courseId, blocks) {
|
||||
type: block.type,
|
||||
specialExamInfo: block.special_exam_info,
|
||||
unitIds: block.children || [],
|
||||
completionStat: {
|
||||
completed: block.completion_stat?.completion,
|
||||
total: block.completion_stat?.completable_children,
|
||||
},
|
||||
};
|
||||
break;
|
||||
|
||||
|
||||
@@ -331,9 +331,13 @@
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
|
||||
@media (max-width: -1 + map-get($grid-breakpoints, "sm")) {
|
||||
flex-direction: column;
|
||||
gap: $spacer;
|
||||
}
|
||||
|
||||
.previous-button,
|
||||
.next-button {
|
||||
white-space: nowrap;
|
||||
border-radius: 4px;
|
||||
|
||||
&:focus:before {
|
||||
@@ -343,10 +347,31 @@
|
||||
|
||||
.next-button {
|
||||
flex-basis: 75%;
|
||||
|
||||
@media (max-width: -1 + map-get($grid-breakpoints, "sm")) {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.previous-button {
|
||||
flex-basis: 25%;
|
||||
|
||||
@media (max-width: -1 + map-get($grid-breakpoints, "sm")) {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.top-unit-navigation {
|
||||
max-width: 100%;
|
||||
justify-content: flex-end;
|
||||
|
||||
.next-button,
|
||||
.previous-button {
|
||||
@media (min-width: map-get($grid-breakpoints, "md")) {
|
||||
flex-basis: auto;
|
||||
min-width: 8rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user