Skip to content

Commit

Permalink
feat: [FC-0056] courseware sidebar enhancement (#1398)
Browse files Browse the repository at this point in the history
- Display section and sequence progress
- Add tracking event to the unit button
- Hide the horizontal unit navigation with enabled sidebar navigation
  • Loading branch information
ihor-romaniuk committed May 30, 2024
1 parent 1c3610e commit d76c0cc
Show file tree
Hide file tree
Showing 21 changed files with 288 additions and 74 deletions.
75 changes: 41 additions & 34 deletions src/courseware/course/sequence/Sequence.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -144,54 +146,58 @@ 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}
sequenceId={sequenceId}
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 />}
Expand All @@ -216,6 +222,7 @@ const Sequence = ({
originalUserIsStaff={originalUserIsStaff}
canAccessProctoredExams={canAccessProctoredExams}
>
{isEnabledOutlineSidebar && renderUnitNavigation(true)}
{defaultContent}
</SequenceExamWrapper>
<CourseLicense license={license || undefined} />
Expand Down
30 changes: 21 additions & 9 deletions src/courseware/course/sequence/Sequence.test.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Factory } from 'rosie';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
Expand All @@ -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 });
Expand Down Expand Up @@ -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 }} />,
Expand All @@ -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');
Expand All @@ -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 }} />,
Expand Down Expand Up @@ -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', () => {
Expand All @@ -179,7 +185,9 @@ describe('Sequence', () => {
)];

beforeAll(async () => {
testStore = await initializeTestStore({ courseMetadata, unitBlocks, sequenceBlocks }, false);
testStore = await initializeTestStore({
courseMetadata, unitBlocks, sequenceBlocks, enableNavigationSidebar,
}, false);
});

beforeEach(() => {
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -21,6 +21,7 @@ const UnitNavigation = ({
unitId,
onClickPrevious,
onClickNext,
isAtTop,
}) => {
const {
isFirstUnit, isLastUnit, nextLink, previousLink,
Expand All @@ -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}
Expand Down Expand Up @@ -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>
Expand All @@ -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);
2 changes: 1 addition & 1 deletion src/courseware/course/sidebar/SidebarTriggers.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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} />
Expand Down
2 changes: 1 addition & 1 deletion src/courseware/course/sidebar/common/SidebarBase.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@

.outline-sidebar-heading-wrapper {
border: 1px solid #d7d3d1;
align-self: flex-start;

&.sticky {
position: sticky;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})}
>
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,19 @@ 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 {
id,
complete,
title,
sequenceIds,
completionStat,
} = section;

const activeSequenceId = useSelector(getSequenceId);
Expand All @@ -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}
Expand Down Expand Up @@ -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,
};
Expand Down
Loading

0 comments on commit d76c0cc

Please sign in to comment.