feat: stop calling course blocks rest API and assume LS exists (#803)
- Assume that Learning Sequences is available (waffle has been removed) - Stop calling course blocks API, which provided mostly duplicated information now. - Refactor a bit to avoid needing to globally know which units exist in sequences. That is now provided just-in-time for only the current sequence. - Add /first and /last URLs that you can use instead of unit IDs in URL paths, in service of the above point. AA-1040 AA-1153
This commit is contained in:
@@ -49,7 +49,7 @@ function Course({
|
||||
const dispatch = useDispatch();
|
||||
const celebrateFirstSection = celebrations && celebrations.firstSection;
|
||||
const [firstSectionCelebrationOpen, setFirstSectionCelebrationOpen] = useState(shouldCelebrateOnSectionLoad(
|
||||
courseId, sequenceId, unitId, celebrateFirstSection, dispatch, celebrations,
|
||||
courseId, sequenceId, celebrateFirstSection, dispatch, celebrations,
|
||||
));
|
||||
// If streakLengthToCelebrate is populated, that modal takes precedence. Wait til the next load to display
|
||||
// the weekly goal celebration modal.
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable consistent-return */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { history } from '@edx/frontend-platform';
|
||||
@@ -8,8 +7,6 @@ import {
|
||||
sendTrackingLogEvent,
|
||||
sendTrackEvent,
|
||||
} from '@edx/frontend-platform/analytics';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { checkBlockCompletion } from '../data';
|
||||
|
||||
export default function JumpNavMenuItem({
|
||||
title,
|
||||
@@ -19,7 +16,6 @@ export default function JumpNavMenuItem({
|
||||
sequences,
|
||||
isDefault,
|
||||
}) {
|
||||
const dispatch = useDispatch();
|
||||
function logEvent(targetUrl) {
|
||||
const eventName = 'edx.ui.lms.jump_nav.selected';
|
||||
const payload = {
|
||||
@@ -32,22 +28,14 @@ export default function JumpNavMenuItem({
|
||||
sendTrackingLogEvent(eventName, payload);
|
||||
}
|
||||
|
||||
function lazyloadUrl() {
|
||||
function destinationUrl() {
|
||||
if (isDefault) {
|
||||
return `/course/${courseId}/${currentSequence}/${currentUnit}`;
|
||||
}
|
||||
const destinationString = sequences.forEach(sequence => sequence.unitIds.forEach(unitId => {
|
||||
const complete = dispatch(checkBlockCompletion(
|
||||
courseId,
|
||||
sequence.id, unitId,
|
||||
))
|
||||
.then(value => value);
|
||||
if (!complete) { return `/course/${courseId}/${sequence.id}/${unitId}`; }
|
||||
}));
|
||||
return destinationString || `/course/${courseId}/${sequences[0].id}/${sequences[0].unitIds[0]}`;
|
||||
return `/course/${courseId}/${sequences[0].id}`;
|
||||
}
|
||||
function handleClick() {
|
||||
const url = lazyloadUrl();
|
||||
const url = destinationUrl();
|
||||
logEvent(url);
|
||||
history.push(url);
|
||||
}
|
||||
@@ -64,11 +52,6 @@ export default function JumpNavMenuItem({
|
||||
|
||||
const sequenceShape = PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
unitIds: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
sectionId: PropTypes.string.isRequired,
|
||||
isTimeLimited: PropTypes.bool,
|
||||
isProctored: PropTypes.bool,
|
||||
legacyWebUrl: PropTypes.string,
|
||||
});
|
||||
|
||||
JumpNavMenuItem.propTypes = {
|
||||
|
||||
@@ -6,12 +6,6 @@ import { fireEvent } from '../../setupTest';
|
||||
jest.mock('@edx/frontend-platform');
|
||||
jest.mock('@edx/frontend-platform/analytics');
|
||||
|
||||
const mockCheckBlock = jest.fn(() => Promise.resolve(true)); // check all units
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
useDispatch: () => mockCheckBlock,
|
||||
}));
|
||||
|
||||
const mockData = {
|
||||
sectionId: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations',
|
||||
sequenceId: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions',
|
||||
@@ -22,33 +16,9 @@ const mockData = {
|
||||
sequences: [
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@19a30717eff543078a5d94ae9d6c18a5',
|
||||
blockType: 'sequential',
|
||||
unitIds: [
|
||||
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@867dddb6f55d410caaa9c1eb9c6743ec',
|
||||
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@4f6c1b4e316a419ab5b6bf30e6c708e9',
|
||||
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@3dc16db8d14842e38324e95d4030b8a0',
|
||||
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@4a1bba2a403f40bca5ec245e945b0d76',
|
||||
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@256f17a44983429fb1a60802203ee4e0',
|
||||
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@e3601c0abee6427d8c17e6d6f8fdddd1',
|
||||
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@134df56c516a4a0dbb24dd5facef746e',
|
||||
],
|
||||
legacyWebUrl: 'http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@19a30717eff543078a5d94ae9d6c18a5?experience=legacy',
|
||||
sectionId: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations',
|
||||
},
|
||||
{
|
||||
id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions',
|
||||
title: 'Homework - Question Styles',
|
||||
legacyWebUrl: 'http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions?experience=legacy',
|
||||
unitIds: [
|
||||
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@2152d4a4aadc4cb0af5256394a3d1fc7',
|
||||
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@47dbd5f836544e61877a483c0b75606c',
|
||||
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@54bb9b142c6c4c22afc62bcb628f0e68',
|
||||
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0c92347a5c00',
|
||||
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_1fef54c2b23b',
|
||||
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@2889db1677a549abb15eb4d886f95d1c',
|
||||
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@e8a5cc2aed424838853defab7be45e42',
|
||||
],
|
||||
sectionId: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations',
|
||||
},
|
||||
],
|
||||
isDefault: false,
|
||||
@@ -64,6 +34,5 @@ describe('JumpNavMenuItem', () => {
|
||||
expect(screen.getByText('Demo Menu Item'));
|
||||
const navButton = screen.queryAllByRole('button')[0];
|
||||
fireEvent.click(navButton);
|
||||
expect(mockCheckBlock).toBeCalledTimes(14); // number of units to check on load.
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,11 +8,10 @@ import { updateModel } from '../../../generic/model-store';
|
||||
const CELEBRATION_LOCAL_STORAGE_KEY = 'CelebrationModal.showOnSectionLoad';
|
||||
|
||||
// Records clicks through the end of a section, so that we can know whether we should celebrate when we finish loading
|
||||
function handleNextSectionCelebration(sequenceId, nextSequenceId, nextUnitId) {
|
||||
function handleNextSectionCelebration(sequenceId, nextSequenceId) {
|
||||
setLocalStorage(CELEBRATION_LOCAL_STORAGE_KEY, {
|
||||
prevSequenceId: sequenceId,
|
||||
nextSequenceId,
|
||||
nextUnitId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -45,7 +44,7 @@ function recordWeeklyGoalCelebration(org, courseId) {
|
||||
|
||||
// Looks at local storage to see whether we just came from the end of a section.
|
||||
// Note! This does have side effects (will clear some local storage and may start an api call).
|
||||
function shouldCelebrateOnSectionLoad(courseId, sequenceId, unitId, celebrateFirstSection, dispatch, celebrations) {
|
||||
function shouldCelebrateOnSectionLoad(courseId, sequenceId, celebrateFirstSection, dispatch, celebrations) {
|
||||
const celebrationIds = getLocalStorage(CELEBRATION_LOCAL_STORAGE_KEY);
|
||||
if (!celebrationIds) {
|
||||
return false;
|
||||
@@ -54,10 +53,9 @@ function shouldCelebrateOnSectionLoad(courseId, sequenceId, unitId, celebrateFir
|
||||
const {
|
||||
prevSequenceId,
|
||||
nextSequenceId,
|
||||
nextUnitId,
|
||||
} = celebrationIds;
|
||||
const onTargetUnit = sequenceId === nextSequenceId && (!nextUnitId || unitId === nextUnitId);
|
||||
let shouldCelebrate = onTargetUnit && celebrateFirstSection;
|
||||
const onTargetSequence = sequenceId === nextSequenceId;
|
||||
let shouldCelebrate = onTargetSequence && celebrateFirstSection;
|
||||
|
||||
if (shouldCelebrate && celebrations.streakLengthToCelebrate) {
|
||||
// We don't want two modals to show up on the same page.
|
||||
@@ -67,7 +65,7 @@ function shouldCelebrateOnSectionLoad(courseId, sequenceId, unitId, celebrateFir
|
||||
postCelebrationComplete(courseId, { first_section: false });
|
||||
}
|
||||
|
||||
if (sequenceId !== prevSequenceId && !onTargetUnit) {
|
||||
if (sequenceId !== prevSequenceId && !onTargetSequence) {
|
||||
// Don't clear until we move off of current/prev sequence
|
||||
clearLocalStorage(CELEBRATION_LOCAL_STORAGE_KEY);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { waitFor } from '@testing-library/react';
|
||||
|
||||
import { fetchCourse } from '../../data';
|
||||
import { buildSimpleCourseBlocks } from '../../../shared/data/__factories__/courseBlocks.factory';
|
||||
import { buildOutlineFromBlocks } from '../../data/__factories__/learningSequencesOutline.factory';
|
||||
import {
|
||||
initializeMockApp, logUnhandledRequests, render, screen,
|
||||
} from '../../../setupTest';
|
||||
@@ -27,11 +28,10 @@ describe('Course Exit Pages', () => {
|
||||
user_has_passing_grade: true,
|
||||
end: '2014-02-05T05:00:00Z',
|
||||
});
|
||||
const defaultCourseBlocks = buildSimpleCourseBlocks(defaultMetadata.id, defaultMetadata.name);
|
||||
const { courseBlocks: defaultCourseBlocks } = buildSimpleCourseBlocks(defaultMetadata.id, defaultMetadata.name);
|
||||
|
||||
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/courseware/course/${defaultMetadata.id}`;
|
||||
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
|
||||
const courseBlocksUrlRegExp = new RegExp(`${getConfig().LMS_BASE_URL}/api/courses/v2/blocks/*`);
|
||||
const discoveryRecommendationsUrl = new RegExp(`${getConfig().DISCOVERY_API_BASE_URL}/api/v1/course_recommendations/*`);
|
||||
const enrollmentsUrl = new RegExp(`${getConfig().LMS_BASE_URL}/api/enrollment/v1/enrollment*`);
|
||||
const learningSequencesUrlRegExp = new RegExp(`${getConfig().LMS_BASE_URL}/api/learning_sequences/v1/course_outline/*`);
|
||||
@@ -50,11 +50,10 @@ describe('Course Exit Pages', () => {
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata);
|
||||
axiosMock.onGet(courseBlocksUrlRegExp).reply(200, defaultCourseBlocks);
|
||||
axiosMock.onGet(discoveryRecommendationsUrl).reply(200,
|
||||
Factory.build('courseRecommendations', {}, { numRecs: 2 }));
|
||||
axiosMock.onGet(enrollmentsUrl).reply(200, []);
|
||||
axiosMock.onGet(learningSequencesUrlRegExp).reply(403, {});
|
||||
axiosMock.onGet(learningSequencesUrlRegExp).reply(200, buildOutlineFromBlocks(defaultCourseBlocks));
|
||||
|
||||
logUnhandledRequests(axiosMock);
|
||||
});
|
||||
@@ -368,10 +367,9 @@ describe('Course Exit Pages', () => {
|
||||
describe('Course in progress experience', () => {
|
||||
it('Displays link to dates tab', async () => {
|
||||
setMetadata({ user_has_passing_grade: false });
|
||||
const courseBlocks = buildSimpleCourseBlocks(defaultMetadata.id, defaultMetadata.name,
|
||||
const { courseBlocks } = buildSimpleCourseBlocks(defaultMetadata.id, defaultMetadata.name,
|
||||
{ hasScheduledContent: true });
|
||||
axiosMock.onGet(courseBlocksUrlRegExp).reply(200, courseBlocks);
|
||||
axiosMock.onGet(learningSequencesUrlRegExp).reply(403, {});
|
||||
axiosMock.onGet(learningSequencesUrlRegExp).reply(200, buildOutlineFromBlocks(courseBlocks));
|
||||
|
||||
await fetchAndRender(<CourseInProgress />);
|
||||
expect(screen.getByText('More content is coming soon!')).toBeInTheDocument();
|
||||
|
||||
@@ -52,6 +52,7 @@ function Sequence({
|
||||
const sequence = useModel('sequences', sequenceId);
|
||||
const unit = useModel('units', unitId);
|
||||
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
|
||||
const sequenceMightBeUnit = useSelector(state => state.courseware.sequenceMightBeUnit);
|
||||
const shouldDisplayNotificationTriggerInSequence = useWindowSize().width < responsiveBreakpoints.small.minWidth;
|
||||
|
||||
const handleNext = () => {
|
||||
@@ -132,7 +133,10 @@ function Sequence({
|
||||
}
|
||||
}, [(unit || {}).id]);
|
||||
|
||||
if (sequenceStatus === 'loading') {
|
||||
// If sequence might be a unit, we want to keep showing a spinner - the courseware container will redirect us when
|
||||
// it knows which sequence to actually go to.
|
||||
const loading = sequenceStatus === 'loading' || (sequenceStatus === 'failed' && sequenceMightBeUnit);
|
||||
if (loading) {
|
||||
if (!sequenceId) {
|
||||
return (<div> {intl.formatMessage(messages['learn.sequence.no.content'])} </div>);
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ describe('Sequence', () => {
|
||||
it('renders correctly for gated content', async () => {
|
||||
const sequenceBlocks = [Factory.build(
|
||||
'block',
|
||||
{ type: 'sequential', children: [unitBlocks.map(block => block.id)] },
|
||||
{ type: 'sequential', children: unitBlocks.map(block => block.id) },
|
||||
{ courseId: courseMetadata.id },
|
||||
)];
|
||||
const gatedContent = {
|
||||
@@ -86,7 +86,7 @@ describe('Sequence', () => {
|
||||
it('renders correctly for hidden after due content', async () => {
|
||||
const sequenceBlocks = [Factory.build(
|
||||
'block',
|
||||
{ type: 'sequential', children: [unitBlocks.map(block => block.id)] },
|
||||
{ type: 'sequential', children: unitBlocks.map(block => block.id) },
|
||||
{ courseId: courseMetadata.id },
|
||||
)];
|
||||
const sequenceMetadata = [Factory.build(
|
||||
@@ -138,11 +138,11 @@ describe('Sequence', () => {
|
||||
let testStore;
|
||||
const sequenceBlocks = [Factory.build(
|
||||
'block',
|
||||
{ type: 'sequential', children: [unitBlocks.map(block => block.id)] },
|
||||
{ type: 'sequential', children: unitBlocks.map(block => block.id) },
|
||||
{ courseId: courseMetadata.id },
|
||||
), Factory.build(
|
||||
'block',
|
||||
{ type: 'sequential', children: [unitBlocks.map(block => block.id)] },
|
||||
{ type: 'sequential', children: unitBlocks.map(block => block.id) },
|
||||
{ courseId: courseMetadata.id },
|
||||
)];
|
||||
|
||||
@@ -291,7 +291,7 @@ describe('Sequence', () => {
|
||||
it('handles the navigation buttons for empty sequence', async () => {
|
||||
const testSequenceBlocks = [Factory.build(
|
||||
'block',
|
||||
{ type: 'sequential', children: [unitBlocks.map(block => block.id)] },
|
||||
{ type: 'sequential', children: unitBlocks.map(block => block.id) },
|
||||
{ courseId: courseMetadata.id },
|
||||
), Factory.build(
|
||||
'block',
|
||||
@@ -299,7 +299,7 @@ describe('Sequence', () => {
|
||||
{ courseId: courseMetadata.id },
|
||||
), Factory.build(
|
||||
'block',
|
||||
{ type: 'sequential', children: [unitBlocks.map(block => block.id)] },
|
||||
{ type: 'sequential', children: unitBlocks.map(block => block.id) },
|
||||
{ courseId: courseMetadata.id },
|
||||
)];
|
||||
const testSequenceMetadata = testSequenceBlocks.map(block => Factory.build(
|
||||
|
||||
@@ -18,7 +18,7 @@ describe('Unit', () => {
|
||||
const unitBlocks = [
|
||||
Factory.build(
|
||||
'block',
|
||||
{ type: 'problem', graded: 'true' },
|
||||
{ type: 'vertical', graded: 'true' },
|
||||
{ courseId: courseMetadata.id },
|
||||
), Factory.build(
|
||||
'block',
|
||||
@@ -32,7 +32,7 @@ describe('Unit', () => {
|
||||
),
|
||||
Factory.build(
|
||||
'block',
|
||||
{ type: 'problem', graded: false },
|
||||
{ type: 'vertical', graded: false },
|
||||
{ courseId: courseMetadata.id },
|
||||
),
|
||||
];
|
||||
|
||||
@@ -15,7 +15,7 @@ describe('Sequence Navigation', () => {
|
||||
const courseMetadata = Factory.build('courseMetadata');
|
||||
const unitBlocks = Array.from({ length: 3 }).map(() => Factory.build(
|
||||
'block',
|
||||
{ type: 'problem' },
|
||||
{ type: 'vertical' },
|
||||
{ courseId: courseMetadata.id },
|
||||
));
|
||||
|
||||
@@ -48,7 +48,7 @@ describe('Sequence Navigation', () => {
|
||||
it('renders locked button for gated content', async () => {
|
||||
const sequenceBlocks = [Factory.build(
|
||||
'block',
|
||||
{ type: 'sequential', children: [unitBlocks.map(block => block.id)] },
|
||||
{ type: 'sequential', children: unitBlocks.map(block => block.id) },
|
||||
{ courseId: courseMetadata.id },
|
||||
)];
|
||||
const sequenceMetadata = [Factory.build(
|
||||
@@ -70,7 +70,7 @@ describe('Sequence Navigation', () => {
|
||||
expect(testData.onNavigate).not.toHaveBeenCalled();
|
||||
// TODO: Not sure if this is working as expected, because the `contentType="lock"` will be overridden by the value
|
||||
// from Redux. To make this provide a `fa-icon` lock we could introduce something like `overriddenContentType`.
|
||||
expect(unitButton.firstChild).toHaveClass('fa-edit');
|
||||
expect(unitButton.firstChild).toHaveClass('fa-tasks');
|
||||
});
|
||||
|
||||
it('renders correctly and handles unit button clicks', () => {
|
||||
|
||||
@@ -12,6 +12,16 @@ import messages from './messages';
|
||||
// This component exists to peek ahead at the next sequence and grab its estimated effort.
|
||||
// If we should be showing the next sequence's effort, we display the title and effort instead of "Next".
|
||||
|
||||
/**
|
||||
* Note: this component is basically ignored and just acts as a pass-through to children components right now because
|
||||
* effort estimation is no longer attached to the sequence model. It used to be attached, via the LMS blocks API and
|
||||
* its block transformers. But as part of the effort to remove reliance on modulestore blocks on the LMS side, we
|
||||
* stopped calling that API and we lost effort estimation in the deal.
|
||||
*
|
||||
* See https://openedx.atlassian.net/browse/AA-930 for the initiative to refactor Effort Estimation to avoid the
|
||||
* modulestore, which would allow us to revive the usefulness of this component again.
|
||||
*/
|
||||
|
||||
function UnitNavigationEffortEstimate({
|
||||
children,
|
||||
intl,
|
||||
|
||||
Reference in New Issue
Block a user