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:
Ihor Romaniuk
2024-05-30 18:28:07 +02:00
committed by GitHub
parent 1c3610e9af
commit d76c0cc6ea
21 changed files with 288 additions and 74 deletions

View File

@@ -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} />

View File

@@ -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,

View File

@@ -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);

View File

@@ -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} />

View File

@@ -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}`}

View File

@@ -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"

View File

@@ -16,7 +16,6 @@
.outline-sidebar-heading-wrapper {
border: 1px solid #d7d3d1;
align-self: flex-start;
&.sticky {
position: sticky;

View File

@@ -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,
})}
>

View File

@@ -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;

View File

@@ -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();
});
});

View File

@@ -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,
};

View File

@@ -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);

View File

@@ -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,
};

View File

@@ -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();

View File

@@ -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));
};

View File

@@ -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);
});
});

View File

@@ -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;

View File

@@ -0,0 +1,2 @@
// eslint-disable-next-line import/prefer-default-export
export { default as DashedCircleIcon } from './DashedCircleIcon';

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;
}
}
}