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:
Michael Terry
2022-02-17 14:10:24 -05:00
committed by GitHub
parent 616027df86
commit 3c52eb2e8d
28 changed files with 256 additions and 499 deletions

View File

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

View File

@@ -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 = {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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', () => {

View File

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