feat: handle courseware paths more liberally (#395)

Valid courseware URLs currently include:
* /course/:courseId
* /course/:courseId/:sequenceId
* /course/:courseId/:sequenceId/:unitId

In this commit we add support for:
* /course/:courseId/:sectionId
* /course/:courseId/:sectionId/:unitId
* /course/:courseId/:unitId

All URL forms still redirect to:
  /course/:courseId/:sequenceId/:unitId

See ADR #8 for more context.

All changes:
* refactor: allow courseBlocks factory to build multiple sections
* refactor: make CoursewareContainer tests less brittle & stateful
* feat: handle courseware paths more liberally
* refactor: reorder, rename, & comment redirection functions

TNL-7796
This commit is contained in:
Kyle McCormick
2021-04-01 09:10:00 -04:00
committed by GitHub
parent 6a376b20c7
commit 353964e75c
12 changed files with 621 additions and 237 deletions

View File

@@ -37,6 +37,9 @@ Today, if the URL only specifies the course ID, we need to pick a sequence to sh
Similarly, if the URL doesn't contain a unit ID, we use the `position` field of the sequence to determine which unit we want to display from that sequence. If the position isn't specified in the sequence, we choose the first unit of the sequence. After determining which unit to display, we update the URL to match. After the URL is updated, the application will attempt to load that unit via an iFrame.
_This URL scheme has been expanded upon in
[ADR #8: Liberal courseware path handling](./0008-liberal-courseware-path-handling.md)._
## "Container" components vs. display components
This application makes use of a few "container" components at the top level - CoursewareContainer and CourseHomeContainer.

View File

@@ -0,0 +1,90 @@
# Liberal courseware path handling
## Status
Accepted
_This updates some of the content in [ADR #2: Courseware page decisions](./0002-courseware-page-decisions.md)._
## Context
The courseware container currently accepts three path forms:
1. `/course/:courseId`
2. `/course/:courseId/:sequenceId`
3. `/course/:courseId/:sequenceId/:unitId`
Forms #1 and #2 are always redirected to Form #3 via simple set of rules:
* If the sequenceId is not specified, choose the first sequence in the course.
* If the unitId is not specified, choose the active unit in the sequence,
or the first unit if none are active.
Thus, Form #3 is effectively the canonoical path;
all Learning MFE units should be served from it.
We acknowledge that the best user experience is to link directly to the canonoical
path when possible, since it skips the redirection steps.
Still, there are times when it is necessary or prudent to link just to a course or
a sequence.
Through recent work in the LMS, we are realizing that there are _also_ times where it
would be simpler or more performant to link a user to an
_entire section without specifying a squence_ or to a
_unit without including the sequence_.
Specifically, this capability would let as avoid further modulestore or
block transformer queries in order to discern the course structure when trying to
direct a learner to a section or unit.
Futhermore, we hypothesize that being able to build a Learning MFE courseware link
with just a unit ID or a section ID will be a nice simplifying quality for future
development or debugging.
## Decision
The courseware container will accept five total path forms:
1. `/course/:courseId`
2. `/course/:courseId/:sectionId`
3. `/course/:courseId/:sectionId/:unitId`
4. `/course/:courseId/:sequenceId`
5. `/course/:courseId/:unitId`
6. `/course/:courseId/:sequenceId/:unitId`
The redirection rules are as follows:
* Forms #1 redirects to Form #4 by selecting the first sequence in the course.
* Form #2 redirects to Form #4 by selecting to the first sequence in the section.
* Form #3 redirects to Form #5 by dropping the section ID.
* Form #4 redirects to Form #6 by choosing the active unit in the sequence
(or the first unit, if none are active).
* Form #5 redirects to Form #6 by filling in the ID of the sequence that the
specified unit belongs to (in the edge case where the unit belongs to multiple
sequences, the first sequence is selected).
As before, Form #5 is the canonocial courseware path, which is always redirected to
by any of the other courseware path forms.
## Consequences
The above decision is implemented.
## Further work
At some point, we may decide to further extend the URL scheme to be
more human-readable.
We can't make UsageKeys themselves more readable because they're tied to student state,
but we could introduce a new optional `slug` field on Sequences,
which would be captured and propagated to the learning_sequences API.
We could eventually do something similar to Units, since those slugs only have to be sequence-local.
So eventually, URLs could look less like:
```
https://learning.edx.org/course/course-v1:edX+DemoX.1+2T2019/block-v1:edX+DemoX.1+2T2019+type@sequential+block@e0a61b3d5a2046949e76d12cac5df493/block-v1:edX+DemoX.1+2T2019+type@vertical+block@52dbad5a28e140f291a476f92ce11996
```
And more like:
```
https://learning.edx.org/course/course-v1:edX+DemoX.1+2T2019/Being_Social/Teams
```

View File

@@ -19,14 +19,6 @@ import { TabPage } from '../tab-page';
import Course from './course';
import { handleNextSectionCelebration } from './course/celebration';
const checkExamRedirect = memoize((sequenceStatus, sequence) => {
if (sequenceStatus === 'loaded') {
if (sequence.isTimeLimited && sequence.lmsWebUrl !== undefined) {
global.location.assign(sequence.lmsWebUrl);
}
}
});
const checkResumeRedirect = memoize((courseStatus, courseId, sequenceId, firstSequenceId) => {
if (courseStatus === 'loaded' && !sequenceId) {
// Note that getResumeBlock is just an API call, not a redux thunk.
@@ -41,8 +33,42 @@ const checkResumeRedirect = memoize((courseStatus, courseId, sequenceId, firstSe
}
});
const checkContentRedirect = memoize((courseId, sequenceStatus, sequenceId, sequence, unitId) => {
if (sequenceStatus === 'loaded' && sequenceId && !unitId) {
const checkSectionUnitToUnitRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId) => {
if (courseStatus === 'loaded' && sequenceStatus === 'failed' && section && unitId) {
history.replace(`/course/${courseId}/${unitId}`);
}
});
const checkSectionToSequenceRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId) => {
if (courseStatus === 'loaded' && sequenceStatus === 'failed' && section && !unitId) {
// If the section is non-empty, redirect to its first sequence.
if (section.sequenceIds && section.sequenceIds[0]) {
history.replace(`/course/${courseId}/${section.sequenceIds[0]}`);
// Otherwise, just go to the course root, letting the resume redirect take care of things.
} else {
history.replace(`/course/${courseId}`);
}
}
});
const checkUnitToSequenceUnitRedirect = memoize((courseStatus, courseId, sequenceStatus, unit) => {
if (courseStatus === 'loaded' && sequenceStatus === 'failed' && unit) {
// If the sequence failed to load as a sequence, but it *did* load as a unit, then
// insert the unit's parent sequenceId into the URL.
history.replace(`/course/${courseId}/${unit.sequenceId}/${unit.id}`);
}
});
const checkSpecialExamRedirect = memoize((sequenceStatus, sequence) => {
if (sequenceStatus === 'loaded') {
if (sequence.isTimeLimited && sequence.lmsWebUrl !== undefined) {
global.location.assign(sequence.lmsWebUrl);
}
}
});
const checkSequenceToSequenceUnitRedirect = memoize((courseId, sequenceStatus, sequence, unitId) => {
if (sequenceStatus === 'loaded' && sequence.id && !unitId) {
if (sequence.unitIds !== undefined && sequence.unitIds.length > 0) {
const nextUnitId = sequence.unitIds[sequence.activeUnitIndex];
// This is a replace because we don't want this change saved in the browser's history.
@@ -97,6 +123,8 @@ class CoursewareContainer extends Component {
sequenceStatus,
sequence,
firstSequenceId,
unitViaSequenceId,
sectionViaSequenceId,
match: {
params: {
courseId: routeCourseId,
@@ -110,15 +138,52 @@ class CoursewareContainer extends Component {
this.checkFetchCourse(routeCourseId);
this.checkFetchSequence(routeSequenceId);
// Redirect to the legacy experience for exams.
checkExamRedirect(sequenceStatus, sequence);
// All courseware URLs should normalize to the format /course/:courseId/:sequenceId/:unitId
// via the series of redirection rules below.
// See docs/decisions/0008-liberal-courseware-path-handling.md for more context.
// (It would be ideal to move this logic into the thunks layer and perform
// all URL-changing checks at once. This should be done once the MFE is moved
// to the new Outlines API. See TNL-8182.)
// Determine if we need to redirect because our URL is incomplete.
checkContentRedirect(courseId, sequenceStatus, sequenceId, sequence, routeUnitId);
// Determine if we can resume where we left off.
// Check resume redirect:
// /course/:courseId -> /course/:courseId/:sequenceId/:unitId
// based on sequence/unit where user was last active.
checkResumeRedirect(courseStatus, courseId, sequenceId, firstSequenceId);
// Check section-unit to unit redirect:
// /course/:courseId/:sectionId/:unitId -> /course/:courseId/:unitId
// by simply ignoring the :sectionId.
// (It may be desirable at some point to be smarter here; for example, we could replace
// :sectionId with the parent sequence of :unitId and/or check whether the :unitId
// is actually within :sectionId. However, the way our Redux store is currently factored,
// the unit's metadata is not available to us if the section isn't loadable.)
// Before performing this redirect, we *do* still check that a section is loadable;
// otherwise, we could get stuck in a redirect loop, since a sequence that failed to load
// would endlessly redirect to itself through `checkSectionUnitToUnitRedirect`
// and `checkUnitToSequenceUnitRedirect`.
checkSectionUnitToUnitRedirect(courseStatus, courseId, sequenceStatus, sectionViaSequenceId, routeUnitId);
// Check section to sequence redirect:
// /course/:courseId/:sectionId -> /course/:courseId/:sequenceId
// by redirecting to the first sequence within the section.
checkSectionToSequenceRedirect(courseStatus, courseId, sequenceStatus, sectionViaSequenceId, routeUnitId);
// Check unit to sequence-unit redirect:
// /course/:courseId/:unitId -> /course/:courseId/:sequenceId/:unitId
// by filling in the ID of the parent sequence of :unitId.
checkUnitToSequenceUnitRedirect(courseStatus, courseId, sequenceStatus, unitViaSequenceId);
// Check special exam redirect:
// /course/:courseId/:sequenceId(/:unitId) -> :legacyWebUrl
// because special exams are currently still served in the legacy LMS frontend.
checkSpecialExamRedirect(sequenceStatus, sequence);
// Check to sequence to sequence-unit redirect:
// /course/:courseId/:sequenceId -> /course/:courseId/:sequenceId/:unitId
// by filling in the ID the most-recently-active unit in the sequence, OR
// the ID of the first unit the sequence if none is active.
checkSequenceToSequenceUnitRedirect(courseId, sequenceStatus, sequence, routeUnitId);
// Check if we should save our sequence position. Only do this when the route unit ID changes.
this.checkSaveSequencePosition(routeUnitId);
}
@@ -249,13 +314,24 @@ class CoursewareContainer extends Component {
}
}
const unitShape = PropTypes.shape({
id: PropTypes.string.isRequired,
sequenceId: PropTypes.string.isRequired,
});
const sequenceShape = PropTypes.shape({
id: PropTypes.string.isRequired,
unitIds: PropTypes.arrayOf(PropTypes.string).isRequired,
sectionId: PropTypes.string.isRequired,
isTimeLimited: PropTypes.bool,
lmsWebUrl: PropTypes.string,
});
const sectionShape = PropTypes.shape({
id: PropTypes.string.isRequired,
sequenceIds: PropTypes.arrayOf(PropTypes.string).isRequired,
});
const courseShape = PropTypes.shape({
canLoadCourseware: PropTypes.shape({
errorCode: PropTypes.string,
@@ -278,6 +354,8 @@ CoursewareContainer.propTypes = {
sequenceStatus: PropTypes.oneOf(['loaded', 'loading', 'failed']).isRequired,
nextSequence: sequenceShape,
previousSequence: sequenceShape,
unitViaSequenceId: unitShape,
sectionViaSequenceId: sectionShape,
course: courseShape,
sequence: sequenceShape,
saveSequencePosition: PropTypes.func.isRequired,
@@ -292,6 +370,8 @@ CoursewareContainer.defaultProps = {
firstSequenceId: null,
nextSequence: null,
previousSequence: null,
unitViaSequenceId: null,
sectionViaSequenceId: null,
course: null,
sequence: null,
};
@@ -367,6 +447,18 @@ const firstSequenceIdSelector = createSelector(
},
);
const sectionViaSequenceIdSelector = createSelector(
(state) => state.models.sections || {},
(state) => state.courseware.sequenceId,
(sectionsById, sequenceId) => (sectionsById[sequenceId] ? sectionsById[sequenceId] : null),
);
const unitViaSequenceIdSelector = createSelector(
(state) => state.models.units || {},
(state) => state.courseware.sequenceId,
(unitsById, sequenceId) => (unitsById[sequenceId] ? unitsById[sequenceId] : null),
);
const mapStateToProps = (state) => {
const {
courseId, sequenceId, courseStatus, sequenceStatus,
@@ -382,6 +474,8 @@ const mapStateToProps = (state) => {
previousSequence: previousSequenceSelector(state),
nextSequence: nextSequenceSelector(state),
firstSequenceId: firstSequenceIdSelector(state),
sectionViaSequenceId: sectionViaSequenceIdSelector(state),
unitViaSequenceId: unitViaSequenceIdSelector(state),
};
};

View File

@@ -14,7 +14,7 @@ import tabMessages from '../tab-page/messages';
import { initializeMockApp } from '../setupTest';
import CoursewareContainer from './CoursewareContainer';
import { buildSimpleCourseBlocks } from '../shared/data/__factories__/courseBlocks.factory';
import { buildSimpleCourseBlocks, buildBinaryCourseBlocks } from '../shared/data/__factories__/courseBlocks.factory';
import initializeStore from '../store';
import { appendBrowserTimezoneToUrl } from '../utils';
@@ -43,6 +43,37 @@ describe('CoursewareContainer', () => {
let component;
let axiosMock;
// This is a standard set of data that can be used in CoursewareContainer tests.
// By default, `setUpMockRequests()` will configure the mock LMS API to return use this data.
// Certain test cases override these in order to test with special blocks/metadata.
const defaultCourseMetadata = Factory.build('courseMetadata');
const defaultCourseId = defaultCourseMetadata.id;
const defaultUnitBlocks = [
Factory.build(
'block',
{ type: 'vertical' },
{ courseId: defaultCourseId },
),
Factory.build(
'block',
{ type: 'vertical' },
{ courseId: defaultCourseId },
),
Factory.build(
'block',
{ type: 'vertical' },
{ courseId: defaultCourseId },
),
];
const {
courseBlocks: defaultCourseBlocks,
sequenceBlocks: [defaultSequenceBlock],
} = buildSimpleCourseBlocks(
defaultCourseId,
defaultCourseMetadata.name,
{ unitBlocks: defaultUnitBlocks },
);
beforeEach(() => {
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
@@ -66,6 +97,47 @@ describe('CoursewareContainer', () => {
);
});
function setUpMockRequests(options = {}) {
// If we weren't given course blocks or metadata, use the defaults.
const courseBlocks = options.courseBlocks || defaultCourseBlocks;
const courseMetadata = options.courseMetadata || defaultCourseMetadata;
const courseId = courseMetadata.id;
// If we weren't given a list of sequence metadatas for URL mocking,
// then construct it ourselves by looking at courseBlocks.
const sequenceMetadatas = options.sequenceMetadatas || (
Object.values(courseBlocks.blocks)
.filter(block => block.type === 'sequential')
.map(sequenceBlock => Factory.build(
'sequenceMetadata',
{},
{
courseId,
sequenceBlock,
unitBlocks: sequenceBlock.children.map(unitId => courseBlocks.blocks[unitId]),
},
))
);
const courseBlocksUrlRegExp = new RegExp(`${getConfig().LMS_BASE_URL}/api/courses/v2/blocks/*`);
axiosMock.onGet(courseBlocksUrlRegExp).reply(200, courseBlocks);
const courseMetadataUrl = appendBrowserTimezoneToUrl(`${getConfig().LMS_BASE_URL}/api/courseware/course/${courseId}`);
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
sequenceMetadatas.forEach(sequenceMetadata => {
const sequenceMetadataUrl = `${getConfig().LMS_BASE_URL}/api/courseware/sequence/${sequenceMetadata.item_id}`;
axiosMock.onGet(sequenceMetadataUrl).reply(200, sequenceMetadata);
});
}
async function loadContainer() {
const { container } = render(component);
// Wait for the page spinner to be removed, such that we can wait for our main
// content to load before making any assertions.
await waitForElementToBeRemoved(screen.getByRole('status'));
return container;
}
it('should initialize to show a spinner', () => {
history.push('/course/abc123');
render(component);
@@ -78,13 +150,8 @@ describe('CoursewareContainer', () => {
});
describe('when receiving successful course data', () => {
let courseId;
let courseMetadata;
let courseBlocks;
let sequenceMetadata;
let sequenceBlock;
let unitBlocks;
const courseMetadata = defaultCourseMetadata;
const courseId = defaultCourseId;
function assertLoadedHeader(container) {
const courseHeader = container.querySelector('.course-header');
@@ -95,64 +162,27 @@ describe('CoursewareContainer', () => {
expect(courseHeader.querySelector('.course-title')).toHaveTextContent(courseMetadata.name);
}
function assertSequenceNavigation(container) {
function assertSequenceNavigation(container, expectedUnitCount = 3) {
// Ensure we had appropriate sequence navigation buttons. We should only have one unit.
const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation button');
expect(sequenceNavButtons).toHaveLength(5);
expect(sequenceNavButtons).toHaveLength(expectedUnitCount + 2);
expect(sequenceNavButtons[0]).toHaveTextContent('Previous');
// Prove this button is rendering an SVG tasks icon, meaning it's a unit/vertical.
expect(sequenceNavButtons[1].querySelector('svg')).toHaveClass('fa-tasks');
expect(sequenceNavButtons[4]).toHaveTextContent('Next');
}
function setupMockRequests() {
axiosMock.onGet(appendBrowserTimezoneToUrl(`${getConfig().LMS_BASE_URL}/api/courseware/course/${courseId}`)).reply(200, courseMetadata);
axiosMock.onGet(new RegExp(`${getConfig().LMS_BASE_URL}/api/courses/v2/blocks/*`)).reply(200, courseBlocks);
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/courseware/sequence/${sequenceBlock.id}`).reply(200, sequenceMetadata);
expect(sequenceNavButtons[sequenceNavButtons.length - 1]).toHaveTextContent('Next');
}
beforeEach(async () => {
// On page load, SequenceContext attempts to scroll to the top of the page.
global.scrollTo = jest.fn();
courseMetadata = Factory.build('courseMetadata');
courseId = courseMetadata.id;
const customUnitBlocks = [
Factory.build(
'block',
{ type: 'vertical' },
{ courseId },
),
Factory.build(
'block',
{ type: 'vertical' },
{ courseId },
),
Factory.build(
'block',
{ type: 'vertical' },
{ courseId },
),
];
const result = buildSimpleCourseBlocks(courseId, courseMetadata.name, { unitBlocks: customUnitBlocks });
courseBlocks = result.courseBlocks;
unitBlocks = result.unitBlocks;
// eslint-disable-next-line prefer-destructuring
sequenceBlock = result.sequenceBlock[0];
sequenceMetadata = Factory.build(
'sequenceMetadata',
{},
{ courseId, unitBlocks, sequenceBlock },
);
setupMockRequests();
setUpMockRequests();
});
describe('when the URL only contains a course ID', () => {
const sequenceBlock = defaultSequenceBlock;
const unitBlocks = defaultUnitBlocks;
it('should use the resume block repsonse to pick a unit if it contains one', async () => {
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/courseware/resume/${courseId}`).reply(200, {
sectionId: sequenceBlock.id,
@@ -160,11 +190,7 @@ describe('CoursewareContainer', () => {
});
history.push(`/course/${courseId}`);
const { container } = render(component);
// This is an important line that ensures the spinner has been removed - and thus our main
// content has been loaded - prior to proceeding with our expectations.
await waitForElementToBeRemoved(screen.getByRole('status'));
const container = await loadContainer();
assertLoadedHeader(container);
assertSequenceNavigation(container);
@@ -175,25 +201,19 @@ describe('CoursewareContainer', () => {
});
it('should use the first sequence ID and activeUnitIndex if the resume block response is empty', async () => {
// OVERRIDE SEQUENCE METADATA:
// set the position to the third unit so we can prove activeUnitIndex is working
sequenceMetadata = Factory.build(
const sequenceMetadata = Factory.build(
'sequenceMetadata',
{ position: 3 }, // position index is 1-based and is converted to 0-based for activeUnitIndex
{ courseId, unitBlocks, sequenceBlock },
);
setUpMockRequests({ sequenceMetadatas: [sequenceMetadata] });
// Re-call the mock setup now that sequenceMetadata is different.
setupMockRequests();
// Note how there is no sectionId/unitId returned in this mock response!
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/courseware/resume/${courseId}`).reply(200, {});
history.push(`/course/${courseId}`);
const { container } = render(component);
// This is an important line that ensures the spinner has been removed - and thus our main
// content has been loaded - prior to proceeding with our expectations.
await waitForElementToBeRemoved(screen.getByRole('status'));
const container = await loadContainer();
assertLoadedHeader(container);
assertSequenceNavigation(container);
@@ -204,14 +224,110 @@ describe('CoursewareContainer', () => {
});
});
describe('when the URL contains a section ID instead of a sequence ID', () => {
const {
courseBlocks, unitTree, sequenceTree, sectionTree,
} = buildBinaryCourseBlocks(
courseId, courseMetadata.name,
);
function setUrl(urlSequenceId, urlUnitId = null) {
history.push(`/course/${courseId}/${urlSequenceId}/${urlUnitId || ''}`);
}
function assertLocation(container, sequenceId, unitId) {
const expectedUrl = `http://localhost/course/${courseId}/${sequenceId}/${unitId}`;
expect(global.location.href).toEqual(expectedUrl);
expect(container.querySelector('.fake-unit')).toHaveTextContent(unitId);
}
beforeEach(async () => {
setUpMockRequests({ courseBlocks });
});
describe('when the URL contains a unit ID', () => {
it('should ignore the section ID and redirect based on the unit ID', async () => {
const urlUnit = unitTree[1][1][1];
setUrl(sectionTree[1].id, urlUnit.id);
const container = await loadContainer();
assertLoadedHeader(container);
assertSequenceNavigation(container, 2);
assertLocation(container, sequenceTree[1][1].id, urlUnit.id);
});
});
describe('when the URL does not contain a unit ID', () => {
it('should choose a unit within the section\'s first sequence', async () => {
setUrl(sectionTree[1].id);
const container = await loadContainer();
assertLoadedHeader(container);
assertSequenceNavigation(container, 2);
assertLocation(container, sequenceTree[1][0].id, unitTree[1][0][0].id);
});
});
describe('when the section is empty', () => {
// Make a (shallow-)copy of the course blocks.
// Remove all descendents of the second section.
const blocksWithEmptySection = { ...courseBlocks.blocks };
blocksWithEmptySection[sectionTree[1].id] = {
...sectionTree[1],
children: [],
};
sequenceTree[1].forEach(sequence => { delete blocksWithEmptySection[sequence.id]; });
unitTree[1].flat().forEach(unit => { delete blocksWithEmptySection[unit.id]; });
const courseBlocksWithEmptySection = {
...courseBlocks,
blocks: blocksWithEmptySection,
};
beforeEach(async () => {
setUpMockRequests({ courseBlocks: courseBlocksWithEmptySection });
});
it('should ignore the section ID and instead redirect to the course root', async () => {
setUrl(sectionTree[1].id);
await loadContainer();
expect(global.location.href).toEqual(`http://localhost/course/${courseId}`);
});
it('should ignore the section and unit IDs and instead to the course root', async () => {
// Specific unit ID used here shouldn't matter; is ignored due to empty section.
setUrl(sectionTree[1].id, unitTree[0][0][0]);
await loadContainer();
expect(global.location.href).toEqual(`http://localhost/course/${courseId}`);
});
});
});
describe('when the URL only contains a unit ID', () => {
const { courseBlocks, unitTree, sequenceTree } = buildBinaryCourseBlocks(courseId, courseMetadata.name);
beforeEach(async () => {
setUpMockRequests({ courseBlocks });
});
it('should insert the sequence ID into the URL', async () => {
const unit = unitTree[1][0][1];
history.push(`/course/${courseId}/${unit.id}`);
const container = await loadContainer();
assertLoadedHeader(container);
assertSequenceNavigation(container, 2);
const expectedSequenceId = sequenceTree[1][0].id;
const expectedUrl = `http://localhost/course/${courseId}/${expectedSequenceId}/${unit.id}`;
expect(global.location.href).toEqual(expectedUrl);
expect(container.querySelector('.fake-unit')).toHaveTextContent(unit.id);
});
});
describe('when the URL contains a course ID and sequence ID', () => {
const sequenceBlock = defaultSequenceBlock;
const unitBlocks = defaultUnitBlocks;
it('should pick the first unit if position was not defined (activeUnitIndex becomes 0)', async () => {
history.push(`/course/${courseId}/${sequenceBlock.id}`);
const { container } = render(component);
// This is an important line that ensures the spinner has been removed - and thus our main
// content has been loaded - prior to proceeding with our expectations.
await waitForElementToBeRemoved(screen.getByRole('status'));
const container = await loadContainer();
assertLoadedHeader(container);
assertSequenceNavigation(container);
@@ -222,22 +338,15 @@ describe('CoursewareContainer', () => {
});
it('should use activeUnitIndex to pick a unit from the sequence', async () => {
// OVERRIDE SEQUENCE METADATA:
sequenceMetadata = Factory.build(
const sequenceMetadata = Factory.build(
'sequenceMetadata',
{ position: 3 }, // position index is 1-based and is converted to 0-based for activeUnitIndex
{ courseId, unitBlocks, sequenceBlock },
);
// Re-call the mock setup now that sequenceMetadata is different.
setupMockRequests();
setUpMockRequests({ sequenceMetadatas: [sequenceMetadata] });
history.push(`/course/${courseId}/${sequenceBlock.id}`);
const { container } = render(component);
// This is an important line that ensures the spinner has been removed - and thus our main
// content has been loaded - prior to proceeding with our expectations.
await waitForElementToBeRemoved(screen.getByRole('status'));
const container = await loadContainer();
assertLoadedHeader(container);
assertSequenceNavigation(container);
@@ -249,13 +358,12 @@ describe('CoursewareContainer', () => {
});
describe('when the URL contains a course, sequence, and unit ID', () => {
const sequenceBlock = defaultSequenceBlock;
const unitBlocks = defaultUnitBlocks;
it('should load the specified unit', async () => {
history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[2].id}`);
const { container } = render(component);
// This is an important line that ensures the spinner has been removed - and thus our main
// content has been loaded - prior to proceeding with our expectations.
await waitForElementToBeRemoved(screen.getByRole('status'));
const container = await loadContainer();
assertLoadedHeader(container);
assertSequenceNavigation(container);
@@ -266,16 +374,12 @@ describe('CoursewareContainer', () => {
});
it('should navigate between units and check block completion', async () => {
history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[0].id}`);
const { container } = render(component);
axiosMock.onPost(`${courseId}/xblock/${sequenceBlock.id}/handler/xmodule_handler/get_completion`).reply(200, {
complete: true,
});
// This is an important line that ensures the spinner has been removed - and thus our main
// content has been loaded - prior to proceeding with our expectations.
await waitForElementToBeRemoved(screen.getByRole('status'));
history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[0].id}`);
const container = await loadContainer();
const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation button');
const sequenceNextButton = sequenceNavButtons[4];
@@ -288,6 +392,8 @@ describe('CoursewareContainer', () => {
describe('when the current sequence is an exam', () => {
const { location } = window;
const sequenceBlock = defaultSequenceBlock;
const unitBlocks = defaultUnitBlocks;
beforeEach(() => {
delete window.location;
@@ -301,21 +407,15 @@ describe('CoursewareContainer', () => {
});
it('should redirect to the sequence lmsWebUrl', async () => {
// OVERRIDE SEQUENCE METADATA:
sequenceMetadata = Factory.build(
const sequenceMetadata = Factory.build(
'sequenceMetadata',
{ is_time_limited: true }, // position index is 1-based and is converted to 0-based for activeUnitIndex
{ courseId, unitBlocks, sequenceBlock },
);
setUpMockRequests({ sequenceMetadatas: [sequenceMetadata] });
// Re-call the mock setup now that sequenceMetadata is different.
setupMockRequests();
history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[2].id}`);
render(component);
// This is an important line that ensures the spinner has been removed - and thus our main
// content has been loaded - prior to proceeding with our expectations.
await waitForElementToBeRemoved(screen.getByRole('status'));
await loadContainer();
expect(global.location.assign).toHaveBeenCalledWith(sequenceBlock.lms_web_url);
});
@@ -323,10 +423,8 @@ describe('CoursewareContainer', () => {
});
describe('when receiving a can_load_courseware error_code', () => {
let courseMetadata;
function setupWithDeniedStatus(errorCode) {
courseMetadata = Factory.build('courseMetadata', {
function setUpWithDeniedStatus(errorCode) {
const courseMetadata = Factory.build('courseMetadata', {
can_load_courseware: {
has_access: false,
error_code: errorCode,
@@ -334,66 +432,43 @@ describe('CoursewareContainer', () => {
},
});
const courseId = courseMetadata.id;
const { courseBlocks, unitBlocks, sequenceBlock } = buildSimpleCourseBlocks(courseId, courseMetadata.name);
const sequenceMetadata = Factory.build(
'sequenceMetadata',
{},
{ courseId, unitBlocks, sequenceBlock },
);
let forbiddenCourseUrl = `${getConfig().LMS_BASE_URL}/api/courseware/course/${courseId}`;
forbiddenCourseUrl = appendBrowserTimezoneToUrl(forbiddenCourseUrl);
const courseBlocksUrlRegExp = new RegExp(`${getConfig().LMS_BASE_URL}/api/courses/v2/blocks/*`);
const sequenceMetadataUrl = `${getConfig().LMS_BASE_URL}/api/courseware/sequence/${sequenceBlock.id}`;
axiosMock.onGet(forbiddenCourseUrl).reply(200, courseMetadata);
axiosMock.onGet(courseBlocksUrlRegExp).reply(200, courseBlocks);
axiosMock.onGet(sequenceMetadataUrl).reply(200, sequenceMetadata);
const { courseBlocks } = buildSimpleCourseBlocks(courseId, courseMetadata.name);
setUpMockRequests({ courseBlocks, courseMetadata });
history.push(`/course/${courseId}`);
return courseMetadata;
}
it('should go to course home for an enrollment_required error code', async () => {
setupWithDeniedStatus('enrollment_required');
render(component);
await waitForElementToBeRemoved(screen.getByRole('status'));
const courseMetadata = setUpWithDeniedStatus('enrollment_required');
await loadContainer();
expect(global.location.href).toEqual(`http://localhost/redirect/course-home/${courseMetadata.id}`);
});
it('should go to course home for an authentication_required error code', async () => {
setupWithDeniedStatus('authentication_required');
render(component);
await waitForElementToBeRemoved(screen.getByRole('status'));
const courseMetadata = setUpWithDeniedStatus('authentication_required');
await loadContainer();
expect(global.location.href).toEqual(`http://localhost/redirect/course-home/${courseMetadata.id}`);
});
it('should go to dashboard for an unfulfilled_milestones error code', async () => {
setupWithDeniedStatus('unfulfilled_milestones');
render(component);
await waitForElementToBeRemoved(screen.getByRole('status'));
setUpWithDeniedStatus('unfulfilled_milestones');
await loadContainer();
expect(global.location.href).toEqual('http://localhost/redirect/dashboard');
});
it('should go to the dashboard with an attached access_response_error for an audit_expired error code', async () => {
setupWithDeniedStatus('audit_expired');
render(component);
await waitForElementToBeRemoved(screen.getByRole('status'));
setUpWithDeniedStatus('audit_expired');
await loadContainer();
expect(global.location.href).toEqual('http://localhost/redirect/dashboard?access_response_error=uhoh%20oh%20no');
});
it('should go to the dashboard with a notlive start date for a course_not_started error code', async () => {
setupWithDeniedStatus('course_not_started');
render(component);
await waitForElementToBeRemoved(screen.getByRole('status'));
setUpWithDeniedStatus('course_not_started');
await loadContainer();
const startDate = '2/5/2013'; // This date is based on our courseMetadata factory's sample data.
expect(global.location.href).toEqual(`http://localhost/redirect/dashboard?notlive=${startDate}`);

View File

@@ -40,29 +40,29 @@ describe('Sequence', () => {
});
it('renders correctly for gated content', async () => {
const sequenceBlock = [Factory.build(
const sequenceBlocks = [Factory.build(
'block',
{ type: 'sequential', children: [unitBlocks.map(block => block.id)] },
{ courseId: courseMetadata.id },
)];
const gatedContent = {
gated: true,
prereq_id: `${sequenceBlock[0].id}-prereq`,
prereq_section_name: `${sequenceBlock[0].display_name}-prereq`,
gated_section_name: sequenceBlock[0].display_name,
prereq_id: `${sequenceBlocks[0].id}-prereq`,
prereq_section_name: `${sequenceBlocks[0].display_name}-prereq`,
gated_section_name: sequenceBlocks[0].display_name,
};
const sequenceMetadata = [Factory.build(
'sequenceMetadata',
{ gated_content: gatedContent },
{ courseId: courseMetadata.id, unitBlocks, sequenceBlock: sequenceBlock[0] },
{ courseId: courseMetadata.id, unitBlocks, sequenceBlock: sequenceBlocks[0] },
)];
const testStore = await initializeTestStore(
{
courseMetadata, unitBlocks, sequenceBlock, sequenceMetadata,
courseMetadata, unitBlocks, sequenceBlocks, sequenceMetadata,
}, false,
);
const { container } = render(
<Sequence {...mockData} {...{ sequenceId: sequenceBlock[0].id }} />,
<Sequence {...mockData} {...{ sequenceId: sequenceBlocks[0].id }} />,
{ store: testStore },
);
@@ -83,7 +83,7 @@ describe('Sequence', () => {
// application redirects away from the page. Note that this component is not responsible for
// that redirect behavior, so there's no record of it here.
// See CoursewareContainer.jsx "checkExamRedirect" function.
const sequenceBlock = [Factory.build(
const sequenceBlocks = [Factory.build(
'block',
{ type: 'sequential', children: [unitBlocks.map(block => block.id)] },
{ courseId: courseMetadata.id },
@@ -91,15 +91,15 @@ describe('Sequence', () => {
const sequenceMetadata = [Factory.build(
'sequenceMetadata',
{ is_time_limited: true },
{ courseId: courseMetadata.id, unitBlocks, sequenceBlock: sequenceBlock[0] },
{ courseId: courseMetadata.id, unitBlocks, sequenceBlock: sequenceBlocks[0] },
)];
const testStore = await initializeTestStore(
{
courseMetadata, unitBlocks, sequenceBlock, sequenceMetadata,
courseMetadata, unitBlocks, sequenceBlocks, sequenceMetadata,
}, false,
);
const { container } = render(
<Sequence {...mockData} {...{ sequenceId: sequenceBlock[0].id }} />,
<Sequence {...mockData} {...{ sequenceId: sequenceBlocks[0].id }} />,
{ store: testStore },
);
@@ -131,7 +131,7 @@ describe('Sequence', () => {
describe('sequence and unit navigation buttons', () => {
let testStore;
const sequenceBlock = [Factory.build(
const sequenceBlocks = [Factory.build(
'block',
{ type: 'sequential', children: [unitBlocks.map(block => block.id)] },
{ courseId: courseMetadata.id },
@@ -142,7 +142,7 @@ describe('Sequence', () => {
)];
beforeAll(async () => {
testStore = await initializeTestStore({ courseMetadata, unitBlocks, sequenceBlock }, false);
testStore = await initializeTestStore({ courseMetadata, unitBlocks, sequenceBlocks }, false);
});
beforeEach(() => {
@@ -152,7 +152,7 @@ describe('Sequence', () => {
it('navigates to the previous sequence if the unit is the first in the sequence', async () => {
const testData = {
...mockData,
sequenceId: sequenceBlock[1].id,
sequenceId: sequenceBlocks[1].id,
previousSequenceHandler: jest.fn(),
};
render(<Sequence {...testData} />, { store: testStore });
@@ -187,7 +187,7 @@ describe('Sequence', () => {
const testData = {
...mockData,
unitId: unitBlocks[unitBlocks.length - 1].id,
sequenceId: sequenceBlock[0].id,
sequenceId: sequenceBlocks[0].id,
nextSequenceHandler: jest.fn(),
};
render(<Sequence {...testData} />, { store: testStore });
@@ -222,7 +222,7 @@ describe('Sequence', () => {
const testData = {
...mockData,
unitId: unitBlocks[unitNumber].id,
sequenceId: sequenceBlock[0].id,
sequenceId: sequenceBlocks[0].id,
unitNavigationHandler: jest.fn(),
previousSequenceHandler: jest.fn(),
nextSequenceHandler: jest.fn(),
@@ -246,7 +246,7 @@ describe('Sequence', () => {
const testData = {
...mockData,
unitId: unitBlocks[0].id,
sequenceId: sequenceBlock[0].id,
sequenceId: sequenceBlocks[0].id,
unitNavigationHandler: jest.fn(),
previousSequenceHandler: jest.fn(),
};
@@ -265,7 +265,7 @@ describe('Sequence', () => {
const testData = {
...mockData,
unitId: unitBlocks[unitBlocks.length - 1].id,
sequenceId: sequenceBlock[sequenceBlock.length - 1].id,
sequenceId: sequenceBlocks[sequenceBlocks.length - 1].id,
unitNavigationHandler: jest.fn(),
nextSequenceHandler: jest.fn(),
};
@@ -281,7 +281,7 @@ describe('Sequence', () => {
});
it('handles the navigation buttons for empty sequence', async () => {
const testSequenceBlock = [Factory.build(
const testSequenceBlocks = [Factory.build(
'block',
{ type: 'sequential', children: [unitBlocks.map(block => block.id)] },
{ courseId: courseMetadata.id },
@@ -294,18 +294,18 @@ describe('Sequence', () => {
{ type: 'sequential', children: [unitBlocks.map(block => block.id)] },
{ courseId: courseMetadata.id },
)];
const testSequenceMetadata = testSequenceBlock.map(block => Factory.build(
const testSequenceMetadata = testSequenceBlocks.map(block => Factory.build(
'sequenceMetadata',
{},
{ courseId: courseMetadata.id, unitBlocks: block.children.length ? unitBlocks : [], sequenceBlock: block },
));
const innerTestStore = await initializeTestStore({
courseMetadata, unitBlocks, sequenceBlock: testSequenceBlock, sequenceMetadata: testSequenceMetadata,
courseMetadata, unitBlocks, sequenceBlocks: testSequenceBlocks, sequenceMetadata: testSequenceMetadata,
}, false);
const testData = {
...mockData,
unitId: unitBlocks[0].id,
sequenceId: testSequenceBlock[1].id,
sequenceId: testSequenceBlocks[1].id,
unitNavigationHandler: jest.fn(),
previousSequenceHandler: jest.fn(),
nextSequenceHandler: jest.fn(),
@@ -356,7 +356,7 @@ describe('Sequence', () => {
const testData = {
...mockData,
unitId: unitBlocks[currentTabNumber - 1].id,
sequenceId: sequenceBlock[0].id,
sequenceId: sequenceBlocks[0].id,
unitNavigationHandler: jest.fn(),
};
render(<Sequence {...testData} />, { store: testStore });

View File

@@ -46,7 +46,7 @@ describe('Sequence Navigation', () => {
});
it('renders locked button for gated content', async () => {
const sequenceBlock = [Factory.build(
const sequenceBlocks = [Factory.build(
'block',
{ type: 'sequential', children: [unitBlocks.map(block => block.id)] },
{ courseId: courseMetadata.id },
@@ -54,12 +54,12 @@ describe('Sequence Navigation', () => {
const sequenceMetadata = [Factory.build(
'sequenceMetadata',
{ gated_content: { gated: true } },
{ courseId: courseMetadata.id, unitBlocks, sequenceBlock: sequenceBlock[0] },
{ courseId: courseMetadata.id, unitBlocks, sequenceBlock: sequenceBlocks[0] },
)];
const testStore = await initializeTestStore({ unitBlocks, sequenceBlock, sequenceMetadata }, false);
const testStore = await initializeTestStore({ unitBlocks, sequenceBlocks, sequenceMetadata }, false);
const testData = {
...mockData,
sequenceId: sequenceBlock[0].id,
sequenceId: sequenceBlocks[0].id,
onNavigate: jest.fn(),
};
render(<SequenceNavigation {...testData} />, { store: testStore });

View File

@@ -75,8 +75,8 @@ export default function buildSimpleCourseAndSequenceMetadata(options = {}) {
});
const courseId = courseMetadata.id;
const simpleCourseBlocks = buildSimpleCourseBlocks(courseId, courseMetadata.name, options);
const { unitBlocks, sequenceBlock } = simpleCourseBlocks;
const sequenceMetadata = options.sequenceMetadata || sequenceBlock.map(block => Factory.build(
const { unitBlocks, sequenceBlocks } = simpleCourseBlocks;
const sequenceMetadata = options.sequenceMetadata || sequenceBlocks.map(block => Factory.build(
'sequenceMetadata',
{},
{

View File

@@ -164,6 +164,7 @@ function normalizeSequenceMetadata(sequence) {
return {
sequence: {
id: sequence.item_id,
blockType: sequence.tag,
unitIds: sequence.items.map(unit => unit.id),
bannerText: sequence.banner_text,
format: sequence.format,

View File

@@ -24,18 +24,18 @@ describe('Data layer integration tests', () => {
// building minimum set of api responses to test all thunks
const courseMetadata = Factory.build('courseMetadata');
const courseId = courseMetadata.id;
const { courseBlocks, unitBlocks, sequenceBlock } = buildSimpleCourseBlocks(courseId);
const { courseBlocks, unitBlocks, sequenceBlocks } = buildSimpleCourseBlocks(courseId);
const sequenceMetadata = Factory.build(
'sequenceMetadata',
{},
{ courseId, unitBlocks, sequenceBlock: sequenceBlock[0] },
{ courseId, unitBlocks, sequenceBlock: sequenceBlocks[0] },
);
let courseUrl = `${courseBaseUrl}/${courseId}`;
courseUrl = appendBrowserTimezoneToUrl(courseUrl);
const sequenceUrl = `${sequenceBaseUrl}/${sequenceMetadata.item_id}`;
const sequenceId = sequenceBlock[0].id;
const sequenceId = sequenceBlocks[0].id;
const unitId = unitBlocks[0].id;
let store;
@@ -115,6 +115,22 @@ describe('Data layer integration tests', () => {
expect(store.getState().courseware.sequenceStatus).toEqual('failed');
});
it('Should result in fetch failure if a non-sequential block is returned', async () => {
const sectionMetadata = {
...sequenceMetadata,
// 'chapter' is the block_type of a Section, which the sequence metadata
// API will happily return if requested, since SectionBlock is implemented
// as a subclass of SequenceBlock.
tag: 'chapter',
};
axiosMock.onGet(sequenceUrl).reply(200, sectionMetadata);
await executeThunk(thunks.fetchSequence(sequenceId), store.dispatch);
expect(loggingService.logError).toHaveBeenCalled();
expect(store.getState().courseware.sequenceStatus).toEqual('failed');
});
it('Should fetch and normalize metadata, and then update existing models with sequence metadata', async () => {
axiosMock.onGet(courseUrl).reply(200, courseMetadata);
axiosMock.onGet(courseBlocksUrlRegExp).reply(200, courseBlocks);

View File

@@ -93,15 +93,26 @@ export function fetchSequence(sequenceId) {
dispatch(fetchSequenceRequest({ sequenceId }));
try {
const { sequence, units } = await getSequenceMetadata(sequenceId);
dispatch(updateModel({
modelType: 'sequences',
model: sequence,
}));
dispatch(updateModels({
modelType: 'units',
models: units,
}));
dispatch(fetchSequenceSuccess({ sequenceId }));
if (sequence.blockType !== 'sequential') {
// Some other block types (particularly 'chapter') can be returned
// by this API. We want to error in that case, since downstream
// courseware code is written to render Sequences of Units.
logError(
`Requested sequence '${sequenceId}' `
+ `has block type '${sequence.blockType}'; expected block type 'sequential'.`,
);
dispatch(fetchSequenceFailure({ sequenceId }));
} else {
dispatch(updateModel({
modelType: 'sequences',
model: sequence,
}));
dispatch(updateModels({
modelType: 'units',
models: units,
}));
dispatch(fetchSequenceSuccess({ sequenceId }));
}
} catch (error) {
logError(error);
dispatch(fetchSequenceFailure({ sequenceId }));

View File

@@ -132,7 +132,7 @@ export async function initializeTestStore(options = {}, overrideStore = true) {
axiosMock.reset();
const {
courseBlocks, sequenceBlock, courseMetadata, sequenceMetadata,
courseBlocks, sequenceBlocks, courseMetadata, sequenceMetadata,
} = buildSimpleCourseAndSequenceMetadata(options);
let forbiddenCourseUrl = `${getConfig().LMS_BASE_URL}/api/courseware/course/${courseMetadata.id}`;
@@ -153,7 +153,7 @@ export async function initializeTestStore(options = {}, overrideStore = true) {
!options.excludeFetchCourse && await executeThunk(fetchCourse(courseMetadata.id), store.dispatch);
if (!options.excludeFetchSequence) {
await Promise.all(sequenceBlock
await Promise.all(sequenceBlocks
.map(block => executeThunk(fetchSequence(block.id), store.dispatch)));
}

View File

@@ -16,35 +16,39 @@ const getBlocks = (attr) => {
Factory.define('courseBlocks')
.option('courseId', 'course-v1:edX+DemoX+Demo_Course')
.option('units', ['courseId'], courseId => ([
.option('units', ['courseId'], courseId => [
Factory.build(
'block',
{ type: 'vertical' },
{ courseId },
),
]))
.option('sequence', ['courseId', 'units'], (courseId, child) => Factory.build(
])
.option('sequences', ['courseId', 'units'], (courseId, units) => [
Factory.build(
'block',
{ type: 'sequential', children: getIds(units) },
{ courseId },
),
])
.option('sections', ['courseId', 'sequences'], (courseId, sequences) => [
Factory.build(
'block',
{ type: 'chapter', children: getIds(sequences) },
{ courseId },
),
])
.option('course', ['courseId', 'sections'], (courseId, sections) => Factory.build(
'block',
{ type: 'sequential', children: getIds(child) },
{ courseId },
))
.option('section', ['courseId', 'sequence'], (courseId, child) => Factory.build(
'block',
{ type: 'chapter', children: getIds(child) },
{ courseId },
))
.option('course', ['courseId', 'section'], (courseId, child) => Factory.build(
'block',
{ type: 'course', children: getIds(child) },
{ type: 'course', children: getIds(sections) },
{ courseId },
))
.attr(
'blocks',
['course', 'section', 'sequence', 'units'],
(course, section, sequence, units) => ({
['course', 'sections', 'sequences', 'units'],
(course, sections, sequences, units) => ({
[course.id]: course,
...getBlocks(section),
...getBlocks(sequence),
...getBlocks(sections),
...getBlocks(sequences),
...getBlocks(units),
}),
)
@@ -59,19 +63,19 @@ export function buildSimpleCourseBlocks(courseId, title, options = {}) {
{ type: 'vertical' },
{ courseId },
)];
const sequenceBlock = options.sequenceBlock || [Factory.build(
const sequenceBlocks = options.sequenceBlocks || [Factory.build(
'block',
{ type: 'sequential', children: unitBlocks.map(block => block.id) },
{ courseId },
)];
const sectionBlock = options.sectionBlock || Factory.build(
const sectionBlocks = options.sectionBlocks || [Factory.build(
'block',
{ type: 'chapter', children: sequenceBlock.map(block => block.id) },
{ type: 'chapter', children: sequenceBlocks.map(block => block.id) },
{ courseId },
);
const courseBlock = options.courseBlocks || Factory.build(
)];
const courseBlock = options.courseBlock || Factory.build(
'block',
{ type: 'course', display_name: title, children: [sectionBlock.id] },
{ type: 'course', display_name: title, children: sectionBlocks.map(block => block.id) },
{ courseId },
);
return {
@@ -84,14 +88,14 @@ export function buildSimpleCourseBlocks(courseId, title, options = {}) {
},
{
units: unitBlocks,
sequence: sequenceBlock,
section: sectionBlock,
sequences: sequenceBlocks,
sections: sectionBlocks,
course: courseBlock,
},
),
unitBlocks,
sequenceBlock,
sectionBlock,
sequenceBlocks,
sectionBlocks,
courseBlock,
};
}
@@ -100,12 +104,12 @@ export function buildSimpleCourseBlocks(courseId, title, options = {}) {
* Builds a course with a single chapter and sequence, but no units.
*/
export function buildMinimalCourseBlocks(courseId, title, options = {}) {
const sequenceBlock = options.sequenceBlock || [Factory.build(
const sequenceBlocks = options.sequenceBlocks || [Factory.build(
'block',
{ display_name: 'Title of Sequence', type: 'sequential' },
{ courseId },
)];
const sectionBlock = options.sectionBlock || Factory.build(
const sectionBlocks = options.sectionBlocks || [Factory.build(
'block',
{
type: 'chapter',
@@ -114,13 +118,13 @@ export function buildMinimalCourseBlocks(courseId, title, options = {}) {
effort_time: 15,
effort_activities: 2,
resume_block: options.resumeBlock || false,
children: sequenceBlock.map(block => block.id),
children: sequenceBlocks.map(block => block.id),
},
{ courseId },
);
)];
const courseBlock = options.courseBlock || Factory.build(
'block',
{ type: 'course', display_name: title, children: [sectionBlock.id] },
{ type: 'course', display_name: title, children: sectionBlocks.map(block => block.id) },
{ courseId },
);
return {
@@ -128,15 +132,105 @@ export function buildMinimalCourseBlocks(courseId, title, options = {}) {
'courseBlocks',
{ courseId },
{
sequence: sequenceBlock,
section: sectionBlock,
sequences: sequenceBlocks,
sections: sectionBlocks,
course: courseBlock,
units: [],
},
),
unitBlocks: [],
sequenceBlock,
sectionBlock,
sequenceBlocks,
sectionBlocks,
courseBlock,
};
}
/**
* Builds a course with two branches at each node. That is:
*
* Crs
* |
* Sec--------+-------Sec
* | |
* Seq---+---Seq Seq---+---Seq
* | | | |
* U--+--U U--+--U U--+--U U--+--U
* ^
*
* Each left branch is indexed 0, and each right branch is indexed 1.
* So, the caret in the diagram above is pointing to `unitTree[1][0][1]`,
* whose parent is `sequenceTree[1][0]`, whose parent is `sectionTree[1]`.
*/
export function buildBinaryCourseBlocks(courseId, title) {
const sectionTree = [];
const sequenceTree = [[], []];
const unitTree = [[[], []], [[], []]];
[0, 1].forEach(sectionIndex => {
[0, 1].forEach(sequenceIndex => {
[0, 1].forEach(unitIndex => {
unitTree[sectionIndex][sequenceIndex][unitIndex] = Factory.build(
'block',
{ type: 'vertical' },
{ courseId },
);
});
sequenceTree[sectionIndex][sequenceIndex] = Factory.build(
'block',
{ type: 'sequential', children: unitTree[sectionIndex][sequenceIndex].map(block => block.id) },
{ courseId },
);
});
sectionTree[sectionIndex] = Factory.build(
'block',
{ type: 'chapter', children: sequenceTree[sectionIndex].map(block => block.id) },
{ courseId },
);
});
const courseBlock = Factory.build(
'block',
{ type: 'course', display_name: title, children: sectionTree.map(block => block.id) },
{ courseId },
);
const sectionBlocks = [
sectionTree[0],
sectionTree[1],
];
const sequenceBlocks = [
sequenceTree[0][0],
sequenceTree[0][1],
sequenceTree[1][0],
sequenceTree[1][1],
];
const unitBlocks = [
unitTree[0][0][0],
unitTree[0][0][1],
unitTree[0][1][0],
unitTree[0][1][1],
unitTree[1][0][0],
unitTree[1][0][1],
unitTree[1][1][0],
unitTree[1][1][1],
];
return {
// Expose blocks as a combined list, lists separated by type, and as
// trees separated by type. The caller can decide which they want to
// work with.
courseBlocks: Factory.build(
'courseBlocks',
{ courseId },
{
units: unitBlocks,
sequences: sequenceBlocks,
sections: sectionBlocks,
course: courseBlock,
},
),
unitBlocks,
sequenceBlocks,
sectionBlocks,
courseBlock,
unitTree,
sequenceTree,
sectionTree,
};
}