Merge branch 'openedx:master' into master

This commit is contained in:
Nathan Sprenkle
2025-06-05 13:30:54 -04:00
committed by GitHub
19 changed files with 122 additions and 46 deletions

View File

@@ -17,8 +17,9 @@
"lint:fix": "fedx-scripts eslint --fix --ext .js --ext .jsx --ext .ts --ext .tsx .",
"snapshot": "fedx-scripts jest --updateSnapshot",
"start": "fedx-scripts webpack-dev-server --progress",
"start:with-theme": "paragon install-theme && npm start && npm install",
"dev": "PUBLIC_PATH=/learning/ MFE_CONFIG_API_URL='http://localhost:8000/api/mfe_config/v1' fedx-scripts webpack-dev-server --progress --host apps.local.openedx.io",
"test": "fedx-scripts jest --coverage --passWithNoTests",
"test": "NODE_ENV=test fedx-scripts jest --coverage --passWithNoTests",
"test:watch": "fedx-scripts jest --watch --passWithNoTests",
"types": "tsc --noEmit"
},
@@ -97,4 +98,4 @@
],
"normalizeFilenames": "^.+?(\\..+?)\\.\\w+$"
}
}
}

View File

@@ -21,9 +21,9 @@ const DiscussionsNotificationsSidebar = () => {
showTitleBar={false}
showBorder={false}
>
<DiscussionsSidebar />
{!hideNotificationbar && <div className="my-1.5" />}
<NotificationTray />
{!hideNotificationbar && <div className="my-1.5" />}
<DiscussionsSidebar />
</SidebarBase>
);
};

View File

@@ -1,5 +1,5 @@
#course-sidebar {
@media (max-width: -1 + map-get($grid-breakpoints, "lg")) {
@media (max-width: map-get($grid-breakpoints, "lg")) {
overflow-y: scroll;
padding: 0 .625rem !important;
}

View File

@@ -66,7 +66,7 @@
border-radius: 0;
padding: map-get($spacers, 3\.5) map-get($spacers, 4) map-get($spacers, 3\.5) map-get($spacers, 5);
@media (max-width: -1 + map-get($grid-breakpoints, "sm")) {
@media (max-width: map-get($grid-breakpoints, "sm")) {
padding-left: map-get($spacers, 4);
}
@@ -90,7 +90,7 @@
ol li > a {
padding: map-get($spacers, 3\.5) map-get($spacers, 4) map-get($spacers, 3\.5) map-get($spacers, 5\.5);
@media (max-width: -1 + map-get($grid-breakpoints, "sm")) {
@media (max-width: map-get($grid-breakpoints, "sm")) {
padding-left: map-get($spacers, 4\.5);
}

View File

@@ -102,6 +102,21 @@ describe('<CourseOutlineTray />', () => {
expect(mockToggleSidebar).toHaveBeenCalledWith(null);
});
it('collapses sidebar correctly when screen is resized', async () => {
const mockToggleSidebar = jest.fn();
await initTestStore();
renderWithProvider({ toggleSidebar: mockToggleSidebar });
const collapseBtn = screen.getByRole('button', { name: messages.toggleCourseOutlineTrigger.defaultMessage });
expect(collapseBtn).toBeInTheDocument();
// Simulate screen resize
window.innerWidth = 500;
window.dispatchEvent(new Event('resize'));
expect(mockToggleSidebar).toHaveBeenCalledWith(null);
});
it('navigates to section or sequence level correctly on click by back/section button', async () => {
const user = userEvent.setup();
await initTestStore();

View File

@@ -6,12 +6,12 @@ import {
import { DashedCircleIcon } from '../icons';
const CompletionIcon = ({ completionStat: { completed = 0, total = 0 } }) => {
const CompletionIcon = ({ completionStat: { completed = 0, total = 0 }, enabled }) => {
const percentage = total !== 0 ? Math.min((completed / total) * 100, 100) : 0;
const remainder = 100 - percentage;
switch (true) {
case !completed:
case !completed || !enabled:
return <LmsCompletionSolidIcon className="text-gray-300" data-testid="completion-solid-icon" />;
case completed === total:
return <CheckCircleIcon className="text-success" data-testid="check-circle-icon" />;
@@ -25,6 +25,7 @@ CompletionIcon.propTypes = {
completed: PropTypes.number,
total: PropTypes.number,
}).isRequired,
enabled: PropTypes.bool.isRequired,
};
export default CompletionIcon;

View File

@@ -3,21 +3,33 @@ import { render, screen } from '@testing-library/react';
import CompletionIcon from './CompletionIcon';
describe('CompletionIcon', () => {
it('renders check circle icon when completion is equal to total', () => {
it('renders check circle icon when completion is equal to total and completion tracking is enabled', () => {
const completionStat = { completed: 5, total: 5 };
render(<CompletionIcon completionStat={completionStat} />);
render(<CompletionIcon completionStat={completionStat} enabled />);
expect(screen.getByTestId('check-circle-icon')).toBeInTheDocument();
});
it('renders dashed circle icon when completion is between 0 and total', () => {
it('renders dashed circle icon when completion is between 0 and total and completion tracking is enabled', () => {
const completionStat = { completed: 2, total: 5 };
render(<CompletionIcon completionStat={completionStat} />);
render(<CompletionIcon completionStat={completionStat} enabled />);
expect(screen.getByTestId('dashed-circle-icon')).toBeInTheDocument();
});
it('renders completion solid icon when completion is 0', () => {
it('renders completion solid icon when completion is between 0 and total and completion tracking is not enabled', () => {
const completionStat = { completed: 2, total: 5 };
render(<CompletionIcon completionStat={completionStat} enabled={false} />);
expect(screen.getByTestId('completion-solid-icon')).toBeInTheDocument();
});
it('renders completion solid icon when completion is 0 and enabled', () => {
const completionStat = { completed: 0, total: 5 };
render(<CompletionIcon completionStat={completionStat} />);
render(<CompletionIcon completionStat={completionStat} enabled />);
expect(screen.getByTestId('completion-solid-icon')).toBeInTheDocument();
});
it('renders completion solid icon when completion is at any value and not enabled', () => {
const completionStat = { completed: 0, total: 5 };
render(<CompletionIcon completionStat={completionStat} enabled={false} />);
expect(screen.getByTestId('completion-solid-icon')).toBeInTheDocument();
});
});

View File

@@ -18,21 +18,24 @@ const SidebarSection = ({ section, handleSelectSection }) => {
completionStat,
} = section;
const { activeSequenceId } = useCourseOutlineSidebar();
const { activeSequenceId, isEnabledCompletionTracking } = useCourseOutlineSidebar();
const isActiveSection = sequenceIds.includes(activeSequenceId);
const sectionTitle = (
<>
<div className="col-auto p-0">
<CompletionIcon completionStat={completionStat} />
<CompletionIcon completionStat={completionStat} enabled={isEnabledCompletionTracking} />
</div>
<div className="col-10 ml-3 p-0 flex-grow-1 text-dark-500 text-left text-break">
{title}
<span className="sr-only">
, {intl.formatMessage(complete
? courseOutlineMessages.completedSection
: courseOutlineMessages.incompleteSection)}
</span>
{isEnabledCompletionTracking && (
<span className="sr-only">
, {intl.formatMessage(complete
? courseOutlineMessages.completedSection
: courseOutlineMessages.incompleteSection)}
</span>
)}
</div>
</>
);

View File

@@ -28,22 +28,24 @@ const SidebarSequence = ({
} = sequence;
const [open, setOpen] = useState(defaultOpen);
const { activeSequenceId, units } = useCourseOutlineSidebar();
const { activeSequenceId, units, isEnabledCompletionTracking } = useCourseOutlineSidebar();
const isActiveSequence = id === activeSequenceId;
const sectionTitle = (
<>
<div className="col-auto p-0" style={{ fontSize: '1.1rem' }}>
<CompletionIcon completionStat={completionStat} />
<CompletionIcon completionStat={completionStat} enabled={isEnabledCompletionTracking} />
</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>
{specialExamInfo && <span className="align-middle small text-muted">{specialExamInfo}</span>}
<span className="sr-only">
, {intl.formatMessage(complete
? courseOutlineMessages.completedAssignment
: courseOutlineMessages.incompleteAssignment)}
</span>
{isEnabledCompletionTracking && (
<span className="sr-only">
, {intl.formatMessage(complete
? courseOutlineMessages.completedAssignment
: courseOutlineMessages.incompleteAssignment)}
</span>
)}
</div>
</>
);
@@ -69,6 +71,7 @@ const SidebarSequence = ({
activeUnitId={activeUnitId}
isFirst={index === 0}
isLocked={type === UNIT_ICON_TYPES.lock}
isCompletionTrackingEnabled={isEnabledCompletionTracking}
/>
))}
</ol>

View File

@@ -66,7 +66,7 @@ describe('<SidebarSequence />', () => {
expect(screen.queryByText(unit.title)).not.toBeInTheDocument();
});
it('renders correctly when sequence is not collapsed and complete', async () => {
it('renders correctly when sequence is not collapsed and complete and completion tracking enabled', async () => {
const user = userEvent.setup();
await initTestStore();
renderWithProvider({

View File

@@ -15,6 +15,7 @@ const SidebarUnit = ({
isActive,
isLocked,
activeUnitId,
isCompletionTrackingEnabled,
}) => {
const intl = useIntl();
const {
@@ -24,6 +25,7 @@ const SidebarUnit = ({
} = unit;
const iconType = isLocked ? UNIT_ICON_TYPES.lock : icon;
const completeAndEnabled = complete && isCompletionTrackingEnabled;
return (
<li className={classNames({ 'bg-info-100': isActive, 'border-top border-light': !isFirst })}>
@@ -36,15 +38,17 @@ const SidebarUnit = ({
}}
>
<div className="col-auto p-0">
<UnitIcon type={iconType} isCompleted={complete} />
<UnitIcon type={iconType} isCompleted={completeAndEnabled} />
</div>
<div className="col-10 p-0 ml-3 text-break">
<span className="align-middle">
{title}
</span>
<span className="sr-only">
, {intl.formatMessage(complete ? messages.completedUnit : messages.incompleteUnit)}
</span>
{isCompletionTrackingEnabled && (
<span className="sr-only">
, {intl.formatMessage(complete ? messages.completedUnit : messages.incompleteUnit)}
</span>
)}
</div>
</UnitLinkWrapper>
</li>
@@ -66,6 +70,7 @@ SidebarUnit.propTypes = {
courseId: PropTypes.string.isRequired,
sequenceId: PropTypes.string.isRequired,
activeUnitId: PropTypes.string.isRequired,
isCompletionTrackingEnabled: PropTypes.bool.isRequired,
};
export default SidebarUnit;

View File

@@ -50,6 +50,7 @@ describe('<SidebarUnit />', () => {
unit={{ ...unit, icon: 'video', isLocked: false }}
isActive={false}
activeUnitId={unit.id}
isCompletionTrackingEnabled
{...props}
/>
</MemoryRouter>
@@ -68,7 +69,7 @@ describe('<SidebarUnit />', () => {
expect(container.querySelector('.text-success')).not.toBeInTheDocument();
});
it('renders correctly when unit is complete', async () => {
it('renders correctly when unit is complete and tracking enabled', async () => {
await initTestStore();
const container = renderWithProvider({ unit: { ...unit, complete: true } });

View File

@@ -1,7 +1,10 @@
import { useContext, useEffect, useState } from 'react';
import {
useContext, useEffect, useLayoutEffect, useState,
} from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router-dom';
import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import { breakpoints } from '@openedx/paragon';
import { useModel } from '@src/generic/model-store';
import { LOADED } from '@src/constants';
@@ -22,7 +25,10 @@ import { ID } from './constants';
export const useCourseOutlineSidebar = () => {
const dispatch = useDispatch();
const isCollapsedOutlineSidebar = window.sessionStorage.getItem('hideCourseOutlineSidebar');
const { enableNavigationSidebar: isEnabledSidebar } = useSelector(getCoursewareOutlineSidebarSettings);
const {
enableNavigationSidebar: isEnabledSidebar,
enableCompletionTracking: isEnabledCompletionTracking,
} = useSelector(getCoursewareOutlineSidebarSettings);
const courseOutlineShouldUpdate = useSelector(getCourseOutlineShouldUpdate);
const courseOutlineStatus = useSelector(getCourseOutlineStatus);
const sequenceStatus = useSelector(getSequenceStatus);
@@ -51,10 +57,14 @@ export const useCourseOutlineSidebar = () => {
} = course.entranceExamData || {};
const isActiveEntranceExam = entranceExamEnabled && !entranceExamPassed;
const collapseSidebar = () => {
toggleSidebar(null);
window.sessionStorage.setItem('hideCourseOutlineSidebar', 'true');
};
const handleToggleCollapse = () => {
if (currentSidebar === ID) {
toggleSidebar(null);
window.sessionStorage.setItem('hideCourseOutlineSidebar', 'true');
collapseSidebar();
} else {
toggleSidebar(ID);
window.sessionStorage.removeItem('hideCourseOutlineSidebar');
@@ -104,12 +114,28 @@ export const useCourseOutlineSidebar = () => {
}
}, [courseId, isEnabledSidebar, courseOutlineShouldUpdate]);
// Collapse sidebar if screen resized to a width that displays the sidebar automatically
useLayoutEffect(() => {
const handleResize = () => {
// breakpoints.large.maxWidth is 1200px and currently the breakpoint for showing the sidebar
if (currentSidebar === ID && global.innerWidth < breakpoints.large.maxWidth) {
collapseSidebar();
}
};
global.addEventListener('resize', handleResize);
return () => {
global.removeEventListener('resize', handleResize);
};
}, [isOpen]);
return {
courseId,
unitId,
currentSidebar,
shouldDisplayFullScreen,
isEnabledSidebar,
isEnabledCompletionTracking,
isOpen,
setIsOpen,
handleToggleCollapse,

View File

@@ -1,5 +1,5 @@
.discussions-sidebar-frame {
@media (max-width: -1 + map-get($grid-breakpoints, "xl")) {
@media (max-width: map-get($grid-breakpoints, "xl")) {
max-height: calc(100vh - 65px);
}
}

View File

@@ -115,5 +115,6 @@ export async function getCoursewareOutlineSidebarToggles(courseId) {
return {
enable_navigation_sidebar: data.enable_navigation_sidebar || false,
always_open_auxiliary_sidebar: data.always_open_auxiliary_sidebar || false,
enable_completion_tracking: data.enable_completion_tracking || false,
};
}

View File

@@ -113,6 +113,7 @@ describe('Data layer integration tests', () => {
axiosMock.onGet(coursewareSidebarSettingsUrl).reply(200, {
enable_navigation_sidebar: true,
always_open_auxiliary_sidebar: true,
enable_completion_tracking: true,
});
await executeThunk(thunks.fetchCourse(courseId), store.dispatch);
@@ -126,6 +127,7 @@ describe('Data layer integration tests', () => {
expect(state.courseware.coursewareOutlineSidebarSettings).toEqual({
enableNavigationSidebar: true,
alwaysOpenAuxiliarySidebar: true,
enableCompletionTracking: true,
});
// check that at least one key camel cased, thus course data normalized
@@ -154,6 +156,7 @@ describe('Data layer integration tests', () => {
expect(state.courseware.coursewareOutlineSidebarSettings).toEqual({
enableNavigationSidebar: false,
alwaysOpenAuxiliarySidebar: false,
enableCompletionTracking: false,
});
// check that at least one key camel cased, thus course data normalized

View File

@@ -90,8 +90,11 @@ export function fetchCourse(courseId) {
const {
enable_navigation_sidebar: enableNavigationSidebar,
always_open_auxiliary_sidebar: alwaysOpenAuxiliarySidebar,
enable_completion_tracking: enableCompletionTracking,
} = coursewareOutlineSidebarTogglesResult.value;
dispatch(setCoursewareOutlineSidebarToggles({ enableNavigationSidebar, alwaysOpenAuxiliarySidebar }));
dispatch(setCoursewareOutlineSidebarToggles(
{ enableNavigationSidebar, alwaysOpenAuxiliarySidebar, enableCompletionTracking },
));
}
// Log errors for each request if needed. Outline failures may occur

View File

@@ -87,7 +87,7 @@
}
.notification-btn {
@media (max-width: -1 + map-get($grid-breakpoints, "sm")) {
@media (max-width: map-get($grid-breakpoints, "sm")) {
height: 3rem;
}
}
@@ -96,7 +96,7 @@
display: flex;
flex-grow: 1;
@media (max-width: -1 + map-get($grid-breakpoints, "sm")) {
@media (max-width: map-get($grid-breakpoints, "sm")) {
max-width: 100%;
}
@@ -104,7 +104,7 @@
margin: -1px -1px 0;
}
@media (max-width: -1 + map-get($grid-breakpoints, "sm")) {
@media (max-width: map-get($grid-breakpoints, "sm")) {
width: 100% !important;
}
@@ -249,7 +249,7 @@
justify-content: center;
align-items: center;
@media (max-width: -1 + map-get($grid-breakpoints, "sm")) {
@media (max-width: map-get($grid-breakpoints, "sm")) {
padding-top: 1rem;
padding-bottom: 1rem;
}
@@ -332,7 +332,7 @@
max-width: 640px;
margin: 0 auto;
@media (max-width: -1 + map-get($grid-breakpoints, "sm")) {
@media (max-width: map-get($grid-breakpoints, "sm")) {
flex-direction: column;
gap: $spacer;
}

View File

@@ -179,6 +179,7 @@ export async function initializeTestStore(options = {}, overrideStore = true) {
const provider = options?.provider || 'legacy';
const enableNavigationSidebar = options.enableNavigationSidebar || { enable_navigation_sidebar: true };
const alwaysOpenAuxiliarySidebar = options.alwaysOpenAuxiliarySidebar || { always_open_auxiliary_sidebar: true };
const enableCompletionTracking = options.enableCompletionTracking || { enable_completion_tracking: true };
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
axiosMock.onGet(courseHomeMetadataUrl).reply(200, courseHomeMetadata);
@@ -187,6 +188,7 @@ export async function initializeTestStore(options = {}, overrideStore = true) {
axiosMock.onGet(coursewareSidebarSettingsUrl).reply(200, {
...enableNavigationSidebar,
...alwaysOpenAuxiliarySidebar,
...enableCompletionTracking,
});
axiosMock.onGet(outlineSidebarUrl).reply(200, {