Compare commits
65 Commits
ttracy/MIC
...
KristinAok
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8882026a01 | ||
|
|
c6578d4e2e | ||
|
|
fe4680646e | ||
|
|
c09ba48615 | ||
|
|
c46da1dc34 | ||
|
|
9ca5c61088 | ||
|
|
a17e2a1a15 | ||
|
|
ea02b2f70f | ||
|
|
5fa33e4015 | ||
|
|
569b628961 | ||
|
|
43eb58974a | ||
|
|
6f2281c1a4 | ||
|
|
5538b48ebb | ||
|
|
847cdfa0bd | ||
|
|
38db0ebfe1 | ||
|
|
7b57b06ed5 | ||
|
|
9c2190980e | ||
|
|
b4c83a38aa | ||
|
|
5efc22220f | ||
|
|
0ba9ed7d31 | ||
|
|
a32a58019d | ||
|
|
367c8ad0df | ||
|
|
ea93aea4dd | ||
|
|
e05428e01d | ||
|
|
24de9d7add | ||
|
|
4e136d9c55 | ||
|
|
296607fb76 | ||
|
|
544e11b628 | ||
|
|
75b195bdc0 | ||
|
|
07042d9908 | ||
|
|
2d1a13ab0a | ||
|
|
7fde146edd | ||
|
|
5f0968e348 | ||
|
|
20935e7860 | ||
|
|
40ea41996f | ||
|
|
f0fab488a5 | ||
|
|
7f2df8b886 | ||
|
|
9b33f20eaa | ||
|
|
7242583f13 | ||
|
|
229692255f | ||
|
|
96a5753b1b | ||
|
|
7b45c8b6fa | ||
|
|
f2d7e119a5 | ||
|
|
4baf78c79e | ||
|
|
d517f94c49 | ||
|
|
43ff07af3e | ||
|
|
aeca68fd56 | ||
|
|
29a24aa62e | ||
|
|
4be725b4c2 | ||
|
|
c592753182 | ||
|
|
174be4adc7 | ||
|
|
388b9dfe59 | ||
|
|
e4ec845bd4 | ||
|
|
e96d885114 | ||
|
|
eb70d3733d | ||
|
|
fcda48513a | ||
|
|
abac174e2e | ||
|
|
457dc4b279 | ||
|
|
3b2f91cd32 | ||
|
|
19f318679f | ||
|
|
d38c07a206 | ||
|
|
3b2bbbdbc4 | ||
|
|
832107f084 | ||
|
|
b23a6330f1 | ||
|
|
8970352cdd |
@@ -88,3 +88,6 @@ And more like:
|
||||
```
|
||||
https://learning.edx.org/course/course-v1:edX+DemoX.1+2T2019/Being_Social/Teams
|
||||
```
|
||||
|
||||
_This further work has been expanded upon in
|
||||
[ADR #9: Courseware URL shortening](./0009-courseware-url-shortening.md)._
|
||||
|
||||
58
docs/decisions/0009-courseware-url-shortening.md
Normal file
58
docs/decisions/0009-courseware-url-shortening.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Courseware URL shortening
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
_This updates some of the content in [ADR #8: Liberal courseware path handling](./0008-liberal-courseware-path-handling.md)._
|
||||
|
||||
## Context
|
||||
|
||||
The current URL is not human-readable. The URL is composed of the UsageKeys for the current sequence and unit. We can't make UsageKeys themselves more readable because they're tied to student state.
|
||||
|
||||
This is what the URLs currently look 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
|
||||
|
||||
```
|
||||
|
||||
After exploring different URL patterns and possible redundancies in the current URL format, the following key points were noticed. The course, run, and organization are stated in every portion of the URL. We also do not need the URL to tell us the type of block since it has been determined that all URLs will follow the path` /course/:courseId/:sequenceId/:unitId`.
|
||||
|
||||
## Decision
|
||||
|
||||
The courseware URL will format to the following structure:
|
||||
|
||||
```
|
||||
|
||||
https://learning.edx.org/c/:courseId/:sequenceHash/:unitHash/:sectionSlug/:sequenceSlug/:unitSlug/
|
||||
|
||||
```
|
||||
|
||||
Example URL:
|
||||
|
||||
```
|
||||
|
||||
https://learning.edx.org/c/course-v1:edX+DemoX.1+2T2019/YmxvY2/njuRCq/optional-example-problem-types/stem-problems/code-grader
|
||||
|
||||
```
|
||||
|
||||
The fields definition and requirements ar as follows:
|
||||
|
||||
* :courseId (required) - same as the previous `courseId`.
|
||||
* :sequenceHash (required) - a `blake2b` version of the `sequenceId`'s `urlsafe_b64encode` .
|
||||
* :unitHash (required) - a `blake2b` version of the `unitId`'s `urlsafe_b64encode`.
|
||||
* :sectionSlug (optional) - `display_name` of the current sequence's parent section.
|
||||
* :sequenceSlug (optional) - `display_name` of the current sequence.
|
||||
* :unitSlug (optional) - `display_name` of the current unit
|
||||
|
||||
Partial paths will update with the required parameters as dicussed in [ADR #8: Liberal courseware path handling](./0008-liberal-courseware-path-handling.md). The `sequenceHash` and `unitHash` will shorten their respective ids using `hashlib.blake2b` with `digest_size` of 6 bytes. `Blake2b` will reduce the length of the id so the encoded version can also be short. Hashing will be handled by `blake2b` because it is the fastest hashing function in the `hashlib` library. The hash will be generated and mapped in LMS. The slugs based on `display_name` are optional because not all blocks have an associated `display_name` attributes, most likely to occur in OLX imports. The `display_name` will be pulled from the current section, sequence, and unit attribute, and if there is not an attribute `display`, the url will use the attribute `display_name_with_default`. The `display_name` will be formatted safely for a url using Django's [slugify](https://docs.djangoproject.com/en/3.2/ref/utils/#django.utils.text.slugify). Slugify allows unicode identifiers in the slug. If the slugs are omitted, it will redirect to the canonical version without the slugs.
|
||||
|
||||
## Consequences
|
||||
|
||||
If old URLs are not properly routed then the content and those links will no longer be accessible to the user. The old URLs could include, but not limited to, bookmarks and exams.
|
||||
|
||||
## Further work
|
||||
|
||||
At some point, we may decide to further extend the URL shortening to the entire platform. At the moment, the hashes for the sequences and units are generated when the sequences and units are being called. In the future, it would be better if the hashes would be generated and stored when the sequences and units are originally created. This would require `learning_sequences` to include a class for unit storage, which is not being stored at the moment.
|
||||
@@ -66,4 +66,5 @@ Factory.define('outlineTabData')
|
||||
handouts_html: '<ul><li>Handout 1</li></ul>',
|
||||
offer: null,
|
||||
welcome_message_html: '<p>Welcome to this course!</p>',
|
||||
mfe_short_url_is_active: true,
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ Object {
|
||||
"proctoredExamsEnabledWaffleFlag": false,
|
||||
"sequenceId": null,
|
||||
"sequenceStatus": "loading",
|
||||
"shortLinkFeatureFlag": false,
|
||||
"specialExamsEnabledWaffleFlag": false,
|
||||
},
|
||||
"models": Object {
|
||||
@@ -321,6 +322,7 @@ Object {
|
||||
"proctoredExamsEnabledWaffleFlag": false,
|
||||
"sequenceId": null,
|
||||
"sequenceStatus": "loading",
|
||||
"shortLinkFeatureFlag": false,
|
||||
"specialExamsEnabledWaffleFlag": false,
|
||||
},
|
||||
"models": Object {
|
||||
@@ -423,6 +425,7 @@ Object {
|
||||
"due": null,
|
||||
"effortActivities": 2,
|
||||
"effortTime": 15,
|
||||
"hash_key": "abcdabcd1",
|
||||
"icon": null,
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1",
|
||||
"legacyWebUrl": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1?experience=legacy",
|
||||
@@ -471,6 +474,7 @@ Object {
|
||||
"hasVisitedCourse": false,
|
||||
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+Test+Block@12345abcde",
|
||||
},
|
||||
"shortLinkFeatureFlag": true,
|
||||
"timeOffsetMillis": 0,
|
||||
"userHasPassingGrade": undefined,
|
||||
"verifiedMode": Object {
|
||||
@@ -507,6 +511,7 @@ Object {
|
||||
"proctoredExamsEnabledWaffleFlag": false,
|
||||
"sequenceId": null,
|
||||
"sequenceStatus": "loading",
|
||||
"shortLinkFeatureFlag": false,
|
||||
"specialExamsEnabledWaffleFlag": false,
|
||||
},
|
||||
"models": Object {
|
||||
|
||||
@@ -144,6 +144,7 @@ export function normalizeOutlineBlocks(courseId, blocks) {
|
||||
// link to the MFE ourselves).
|
||||
showLink: !!block.legacy_web_url,
|
||||
title: block.display_name,
|
||||
hash_key: block.hash_key,
|
||||
};
|
||||
break;
|
||||
|
||||
@@ -353,6 +354,7 @@ export async function getOutlineTabData(courseId) {
|
||||
const userHasPassingGrade = data.user_has_passing_grade;
|
||||
const verifiedMode = camelCaseObject(data.verified_mode);
|
||||
const welcomeMessageHtml = data.welcome_message_html;
|
||||
const shortLinkFeatureFlag = data.mfe_short_url_is_active;
|
||||
|
||||
return {
|
||||
accessExpiration,
|
||||
@@ -374,6 +376,7 @@ export async function getOutlineTabData(courseId) {
|
||||
userHasPassingGrade,
|
||||
verifiedMode,
|
||||
welcomeMessageHtml,
|
||||
shortLinkFeatureFlag,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ describe('DatesTab', () => {
|
||||
component = (
|
||||
<AppProvider store={store}>
|
||||
<UserMessagesProvider>
|
||||
<Route path="/course/:courseId/dates">
|
||||
<Route path="/c/:courseId/dates">
|
||||
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
|
||||
<DatesTab />
|
||||
</TabContainer>
|
||||
@@ -81,7 +81,7 @@ describe('DatesTab', () => {
|
||||
beforeEach(() => {
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
|
||||
axiosMock.onGet(datesUrl).reply(200, datesTabData);
|
||||
history.push(`/course/${courseId}/dates`); // so tab can pull course id from url
|
||||
history.push(`/c/${courseId}/dates`); // so tab can pull course id from url
|
||||
|
||||
render(component);
|
||||
});
|
||||
@@ -147,7 +147,7 @@ describe('DatesTab', () => {
|
||||
describe('Suggested schedule messaging', () => {
|
||||
beforeEach(() => {
|
||||
setMetadata({ is_self_paced: true, is_enrolled: true });
|
||||
history.push(`/course/${courseId}/dates`);
|
||||
history.push(`/c/${courseId}/dates`);
|
||||
});
|
||||
|
||||
it('renders SuggestedScheduleHeader', async () => {
|
||||
@@ -316,7 +316,7 @@ describe('DatesTab', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
axiosMock.onGet(datesUrl).reply(200, datesTabData);
|
||||
history.push(`/course/${courseId}/dates`); // so tab can pull course id from url
|
||||
history.push(`/c/${courseId}/dates`); // so tab can pull course id from url
|
||||
});
|
||||
|
||||
it('redirects to course survey for a survey_required error code', async () => {
|
||||
|
||||
@@ -156,7 +156,7 @@ describe('Outline Tab', () => {
|
||||
await fetchAndRender();
|
||||
|
||||
const sequenceLink = screen.getByText('Title of Sequence');
|
||||
expect(sequenceLink.getAttribute('href')).toContain(`/course/${courseId}`);
|
||||
expect(sequenceLink.getAttribute('href')).toContain(`/c/${courseId}`);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ function Section({
|
||||
courseBlocks: {
|
||||
sequences,
|
||||
},
|
||||
shortLinkFeatureFlag,
|
||||
} = useModel('outline', courseId);
|
||||
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
@@ -39,6 +40,28 @@ function Section({
|
||||
useEffect(() => {
|
||||
setOpen(defaultOpen);
|
||||
}, []);
|
||||
let sequenceLinks;
|
||||
if (shortLinkFeatureFlag) {
|
||||
sequenceLinks = sequenceIds.map((sequenceId, index) => (
|
||||
<SequenceLink
|
||||
key={sequenceId}
|
||||
id={sequences[sequenceId].hash_key}
|
||||
courseId={courseId}
|
||||
sequence={sequences[sequenceId]}
|
||||
first={index === 0}
|
||||
/>
|
||||
));
|
||||
} else {
|
||||
sequenceLinks = sequenceIds.map((sequenceId, index) => (
|
||||
<SequenceLink
|
||||
key={sequenceId}
|
||||
id={sequenceId}
|
||||
courseId={courseId}
|
||||
sequence={sequences[sequenceId]}
|
||||
first={index === 0}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
const sectionTitle = (
|
||||
<div className="row w-100 m-0">
|
||||
@@ -96,15 +119,7 @@ function Section({
|
||||
)}
|
||||
>
|
||||
<ol className="list-unstyled">
|
||||
{sequenceIds.map((sequenceId, index) => (
|
||||
<SequenceLink
|
||||
key={sequenceId}
|
||||
id={sequenceId}
|
||||
courseId={courseId}
|
||||
sequence={sequences[sequenceId]}
|
||||
first={index === 0}
|
||||
/>
|
||||
))}
|
||||
{sequenceLinks}
|
||||
</ol>
|
||||
</Collapsible>
|
||||
</li>
|
||||
|
||||
@@ -46,7 +46,7 @@ function SequenceLink({
|
||||
// canLoadCourseware is true if the Courseware MFE is enabled, false otherwise
|
||||
const coursewareUrl = (
|
||||
canLoadCourseware
|
||||
? <Link to={`/course/${courseId}/${id}`}>{title}</Link>
|
||||
? <Link to={`/c/${courseId}/${id}`}>{title}</Link>
|
||||
: <Hyperlink destination={legacyWebUrl}>{title}</Hyperlink>
|
||||
);
|
||||
const displayTitle = showLink ? coursewareUrl : title;
|
||||
|
||||
@@ -17,15 +17,21 @@ import { TabPage } from '../tab-page';
|
||||
import Course from './course';
|
||||
import { handleNextSectionCelebration } from './course/celebration';
|
||||
|
||||
const checkUrlLength = memoize((shortLinkFeatureFlag, courseStatus, courseId, sequence, unitHashKey) => {
|
||||
if (shortLinkFeatureFlag && courseStatus === 'loaded' && sequence && unitHashKey) {
|
||||
history.replace(`/c/${courseId}/${sequence.hash_key}/${unitHashKey}`);
|
||||
}
|
||||
});
|
||||
|
||||
const checkResumeRedirect = memoize((courseStatus, courseId, sequenceId, firstSequenceId) => {
|
||||
if (courseStatus === 'loaded' && !sequenceId) {
|
||||
// Note that getResumeBlock is just an API call, not a redux thunk.
|
||||
getResumeBlock(courseId).then((data) => {
|
||||
// This is a replace because we don't want this change saved in the browser's history.
|
||||
if (data.sectionId && data.unitId) {
|
||||
history.replace(`/course/${courseId}/${data.sectionId}/${data.unitId}`);
|
||||
history.replace(`/c/${courseId}/${data.sectionId}/${data.unitId}`);
|
||||
} else if (firstSequenceId) {
|
||||
history.replace(`/course/${courseId}/${firstSequenceId}`);
|
||||
history.replace(`/c/${courseId}/${firstSequenceId}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -33,7 +39,7 @@ const checkResumeRedirect = memoize((courseStatus, courseId, sequenceId, firstSe
|
||||
|
||||
const checkSectionUnitToUnitRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId) => {
|
||||
if (courseStatus === 'loaded' && sequenceStatus === 'failed' && section && unitId) {
|
||||
history.replace(`/course/${courseId}/${unitId}`);
|
||||
history.replace(`/c/${courseId}/${unitId}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -41,10 +47,10 @@ const checkSectionToSequenceRedirect = memoize((courseStatus, courseId, sequence
|
||||
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]}`);
|
||||
history.replace(`/c/${courseId}/${section.sequenceIds[0]}`);
|
||||
// Otherwise, just go to the course root, letting the resume redirect take care of things.
|
||||
} else {
|
||||
history.replace(`/course/${courseId}`);
|
||||
history.replace(`/c/${courseId}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -53,7 +59,7 @@ const checkUnitToSequenceUnitRedirect = memoize((courseStatus, courseId, sequenc
|
||||
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}`);
|
||||
history.replace(`/c/${courseId}/${unit.sequenceId}/${unit.id}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -72,7 +78,7 @@ const checkSequenceToSequenceUnitRedirect = memoize((courseId, sequenceStatus, s
|
||||
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.
|
||||
history.replace(`/course/${courseId}/${sequence.id}/${nextUnitId}`);
|
||||
history.replace(`/c/${courseId}/${sequence.id}/${nextUnitId}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -106,13 +112,13 @@ class CoursewareContainer extends Component {
|
||||
match: {
|
||||
params: {
|
||||
courseId: routeCourseId,
|
||||
sequenceId: routeSequenceId,
|
||||
sequenceId: routeSequenceHash,
|
||||
},
|
||||
},
|
||||
} = this.props;
|
||||
// Load data whenever the course or sequence ID changes.
|
||||
this.checkFetchCourse(routeCourseId);
|
||||
this.checkFetchSequence(routeSequenceId);
|
||||
this.checkFetchSequence(routeSequenceHash);
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
@@ -127,18 +133,28 @@ class CoursewareContainer extends Component {
|
||||
firstSequenceId,
|
||||
unitViaSequenceId,
|
||||
sectionViaSequenceId,
|
||||
unitIdHashKeyMap,
|
||||
shortLinkFeatureFlag,
|
||||
match: {
|
||||
params: {
|
||||
courseId: routeCourseId,
|
||||
sequenceId: routeSequenceId,
|
||||
sequenceId: routeSequenceHash,
|
||||
unitId: routeUnitId,
|
||||
},
|
||||
},
|
||||
} = this.props;
|
||||
|
||||
// Load data whenever the course or sequence ID changes.
|
||||
this.checkFetchCourse(routeCourseId);
|
||||
this.checkFetchSequence(routeSequenceId);
|
||||
this.checkFetchSequence(routeSequenceHash);
|
||||
if (sequence && routeSequenceHash.includes('block') && unitIdHashKeyMap) {
|
||||
let unitHashKey;
|
||||
Object.values(unitIdHashKeyMap).forEach(id => {
|
||||
if (id === routeUnitId) {
|
||||
unitHashKey = Object.keys(unitIdHashKeyMap).find(key => unitIdHashKeyMap[key] === id);
|
||||
}
|
||||
});
|
||||
checkUrlLength(shortLinkFeatureFlag, courseStatus, courseId, sequence, unitHashKey);
|
||||
}
|
||||
|
||||
// All courseware URLs should normalize to the format /course/:courseId/:sequenceId/:unitId
|
||||
// via the series of redirection rules below.
|
||||
@@ -201,7 +217,7 @@ class CoursewareContainer extends Component {
|
||||
} = this.props;
|
||||
|
||||
this.props.checkBlockCompletion(courseId, sequenceId, routeUnitId);
|
||||
history.push(`/course/${courseId}/${sequenceId}/${nextUnitId}`);
|
||||
history.push(`/c/${courseId}/${sequenceId}/${nextUnitId}`);
|
||||
}
|
||||
|
||||
handleNextSequenceClick = () => {
|
||||
@@ -211,16 +227,20 @@ class CoursewareContainer extends Component {
|
||||
nextSequence,
|
||||
sequence,
|
||||
sequenceId,
|
||||
shortLinkFeatureFlag,
|
||||
} = this.props;
|
||||
|
||||
if (nextSequence !== null) {
|
||||
let nextSequenceParam = nextSequence.id;
|
||||
if (shortLinkFeatureFlag) {
|
||||
nextSequenceParam = nextSequence.hash_key;
|
||||
}
|
||||
let nextUnitId = null;
|
||||
if (nextSequence.unitIds.length > 0) {
|
||||
[nextUnitId] = nextSequence.unitIds;
|
||||
history.push(`/course/${courseId}/${nextSequence.id}/${nextUnitId}`);
|
||||
history.push(`/c/${courseId}/${nextSequenceParam}/${nextUnitId}`);
|
||||
} else {
|
||||
// Some sequences have no units. This will show a blank page with prev/next buttons.
|
||||
history.push(`/course/${courseId}/${nextSequence.id}`);
|
||||
history.push(`/c/${courseId}/${nextSequenceParam}`);
|
||||
}
|
||||
|
||||
const celebrateFirstSection = course && course.celebrations && course.celebrations.firstSection;
|
||||
@@ -231,14 +251,22 @@ class CoursewareContainer extends Component {
|
||||
}
|
||||
|
||||
handlePreviousSequenceClick = () => {
|
||||
const { previousSequence, courseId } = this.props;
|
||||
const {
|
||||
previousSequence,
|
||||
courseId,
|
||||
shortLinkFeatureFlag,
|
||||
} = this.props;
|
||||
if (previousSequence !== null) {
|
||||
let previousSequenceParam = previousSequence.id;
|
||||
if (shortLinkFeatureFlag) {
|
||||
previousSequenceParam = previousSequence.hash_key;
|
||||
}
|
||||
if (previousSequence.unitIds.length > 0) {
|
||||
const previousUnitId = previousSequence.unitIds[previousSequence.unitIds.length - 1];
|
||||
history.push(`/course/${courseId}/${previousSequence.id}/${previousUnitId}`);
|
||||
history.push(`/c/${courseId}/${previousSequenceParam}/${previousUnitId}`);
|
||||
} else {
|
||||
// Some sequences have no units. This will show a blank page with prev/next buttons.
|
||||
history.push(`/course/${courseId}/${previousSequence.id}`);
|
||||
history.push(`/c/${courseId}/${previousSequenceParam}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -248,6 +276,9 @@ class CoursewareContainer extends Component {
|
||||
courseStatus,
|
||||
courseId,
|
||||
sequenceId,
|
||||
sequence,
|
||||
shortLinkFeatureFlag,
|
||||
unitIdHashKeyMap,
|
||||
match: {
|
||||
params: {
|
||||
unitId: routeUnitId,
|
||||
@@ -255,18 +286,30 @@ class CoursewareContainer extends Component {
|
||||
},
|
||||
} = this.props;
|
||||
|
||||
// This helps process old URLS that still use a blocks usage key in the URL.
|
||||
let updatedSequenceId;
|
||||
let updatedUnitId;
|
||||
if (shortLinkFeatureFlag && sequence) {
|
||||
if (!sequenceId.includes('block')) {
|
||||
updatedSequenceId = sequence.id;
|
||||
}
|
||||
if (routeUnitId && !routeUnitId.includes('block')) {
|
||||
updatedUnitId = unitIdHashKeyMap[routeUnitId];
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<TabPage
|
||||
activeTabSlug="courseware"
|
||||
courseId={courseId}
|
||||
unitId={routeUnitId}
|
||||
unitId={updatedUnitId || routeUnitId}
|
||||
courseStatus={courseStatus}
|
||||
metadataModel="coursewareMeta"
|
||||
>
|
||||
<Course
|
||||
courseId={courseId}
|
||||
sequenceId={sequenceId}
|
||||
unitId={routeUnitId}
|
||||
sequenceId={updatedSequenceId || sequenceId}
|
||||
unitId={updatedUnitId || routeUnitId}
|
||||
nextSequenceHandler={this.handleNextSequenceClick}
|
||||
previousSequenceHandler={this.handlePreviousSequenceClick}
|
||||
unitNavigationHandler={this.handleUnitNavigationClick}
|
||||
@@ -285,6 +328,7 @@ const sequenceShape = PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
unitIds: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
sectionId: PropTypes.string.isRequired,
|
||||
hash_key: PropTypes.string.isRequired,
|
||||
isTimeLimited: PropTypes.bool,
|
||||
isProctored: PropTypes.bool,
|
||||
legacyWebUrl: PropTypes.string,
|
||||
@@ -318,6 +362,7 @@ CoursewareContainer.propTypes = {
|
||||
previousSequence: sequenceShape,
|
||||
unitViaSequenceId: unitShape,
|
||||
sectionViaSequenceId: sectionShape,
|
||||
unitIdHashKeyMap: unitShape,
|
||||
course: courseShape,
|
||||
sequence: sequenceShape,
|
||||
saveSequencePosition: PropTypes.func.isRequired,
|
||||
@@ -326,6 +371,7 @@ CoursewareContainer.propTypes = {
|
||||
fetchSequence: PropTypes.func.isRequired,
|
||||
specialExamsEnabledWaffleFlag: PropTypes.bool.isRequired,
|
||||
proctoredExamsEnabledWaffleFlag: PropTypes.bool.isRequired,
|
||||
shortLinkFeatureFlag: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
CoursewareContainer.defaultProps = {
|
||||
@@ -338,6 +384,7 @@ CoursewareContainer.defaultProps = {
|
||||
sectionViaSequenceId: null,
|
||||
course: null,
|
||||
sequence: null,
|
||||
unitIdHashKeyMap: null,
|
||||
};
|
||||
|
||||
const currentCourseSelector = createSelector(
|
||||
@@ -349,7 +396,16 @@ const currentCourseSelector = createSelector(
|
||||
const currentSequenceSelector = createSelector(
|
||||
(state) => state.models.sequences || {},
|
||||
(state) => state.courseware.sequenceId,
|
||||
(sequencesById, sequenceId) => (sequencesById[sequenceId] ? sequencesById[sequenceId] : null),
|
||||
(state) => state.models.sequenceIdToHashKeyMap,
|
||||
(sequencesById, sequenceId, sequenceMap) => {
|
||||
if (!sequencesById[sequenceId] && Object.keys(sequencesById).length > 0 && sequenceMap) {
|
||||
if (sequenceId in sequenceMap) {
|
||||
const updatedSequenceId = sequenceMap[sequenceId];
|
||||
return sequencesById[updatedSequenceId];
|
||||
}
|
||||
}
|
||||
return sequencesById[sequenceId] ? sequencesById[sequenceId] : null;
|
||||
},
|
||||
);
|
||||
|
||||
const sequenceIdsSelector = createSelector(
|
||||
@@ -369,11 +425,18 @@ const previousSequenceSelector = createSelector(
|
||||
sequenceIdsSelector,
|
||||
(state) => state.models.sequences || {},
|
||||
(state) => state.courseware.sequenceId,
|
||||
(sequenceIds, sequencesById, sequenceId) => {
|
||||
(state) => state.models.sequenceIdToHashKeyMap,
|
||||
(sequenceIds, sequencesById, sequenceId, sequenceMap) => {
|
||||
if (!sequenceId || sequenceIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const sequenceIndex = sequenceIds.indexOf(sequenceId);
|
||||
let sequenceIndex = sequenceIds.indexOf(sequenceId);
|
||||
if (!sequencesById[sequenceId] && Object.keys(sequencesById).length > 0 && sequenceMap) {
|
||||
if (sequenceId in sequenceMap) {
|
||||
const updatedSequenceId = sequenceMap[sequenceId];
|
||||
sequenceIndex = sequenceIds.indexOf(updatedSequenceId);
|
||||
}
|
||||
}
|
||||
const previousSequenceId = sequenceIndex > 0 ? sequenceIds[sequenceIndex - 1] : null;
|
||||
return previousSequenceId !== null ? sequencesById[previousSequenceId] : null;
|
||||
},
|
||||
@@ -383,11 +446,18 @@ const nextSequenceSelector = createSelector(
|
||||
sequenceIdsSelector,
|
||||
(state) => state.models.sequences || {},
|
||||
(state) => state.courseware.sequenceId,
|
||||
(sequenceIds, sequencesById, sequenceId) => {
|
||||
(state) => state.models.sequenceIdToHashKeyMap,
|
||||
(sequenceIds, sequencesById, sequenceId, sequenceMap) => {
|
||||
if (!sequenceId || sequenceIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const sequenceIndex = sequenceIds.indexOf(sequenceId);
|
||||
let sequenceIndex = sequenceIds.indexOf(sequenceId);
|
||||
if (!sequencesById[sequenceId] && Object.keys(sequencesById).length > 0 && sequenceMap) {
|
||||
if (sequenceId in sequenceMap) {
|
||||
const updatedSequenceId = sequenceMap[sequenceId];
|
||||
sequenceIndex = sequenceIds.indexOf(updatedSequenceId);
|
||||
}
|
||||
}
|
||||
const nextSequenceId = sequenceIndex < sequenceIds.length - 1 ? sequenceIds[sequenceIndex + 1] : null;
|
||||
return nextSequenceId !== null ? sequencesById[nextSequenceId] : null;
|
||||
},
|
||||
@@ -420,7 +490,21 @@ const sectionViaSequenceIdSelector = createSelector(
|
||||
const unitViaSequenceIdSelector = createSelector(
|
||||
(state) => state.models.units || {},
|
||||
(state) => state.courseware.sequenceId,
|
||||
(unitsById, sequenceId) => (unitsById[sequenceId] ? unitsById[sequenceId] : null),
|
||||
(state) => state.models.unitIdHashKeyMap,
|
||||
(unitsById, sequenceId, unitMap) => {
|
||||
if (!unitsById[sequenceId] && Object.keys(unitsById).length > 0 && unitMap) {
|
||||
if (sequenceId in unitMap) {
|
||||
const updatedSequenceId = unitMap[sequenceId];
|
||||
return unitsById[updatedSequenceId];
|
||||
}
|
||||
}
|
||||
return unitsById[sequenceId] ? unitsById[sequenceId] : null;
|
||||
},
|
||||
);
|
||||
|
||||
const unitIdHashKeyMapSelector = createSelector(
|
||||
(state) => state.models.unitIdToHashKeyMap,
|
||||
(unitIdToHashKeyMap) => (unitIdToHashKeyMap),
|
||||
);
|
||||
|
||||
const mapStateToProps = (state) => {
|
||||
@@ -431,6 +515,7 @@ const mapStateToProps = (state) => {
|
||||
sequenceStatus,
|
||||
specialExamsEnabledWaffleFlag,
|
||||
proctoredExamsEnabledWaffleFlag,
|
||||
shortLinkFeatureFlag,
|
||||
} = state.courseware;
|
||||
|
||||
return {
|
||||
@@ -440,6 +525,7 @@ const mapStateToProps = (state) => {
|
||||
sequenceStatus,
|
||||
specialExamsEnabledWaffleFlag,
|
||||
proctoredExamsEnabledWaffleFlag,
|
||||
shortLinkFeatureFlag,
|
||||
course: currentCourseSelector(state),
|
||||
sequence: currentSequenceSelector(state),
|
||||
previousSequence: previousSequenceSelector(state),
|
||||
@@ -447,6 +533,7 @@ const mapStateToProps = (state) => {
|
||||
firstSequenceId: firstSequenceIdSelector(state),
|
||||
sectionViaSequenceId: sectionViaSequenceIdSelector(state),
|
||||
unitViaSequenceId: unitViaSequenceIdSelector(state),
|
||||
unitIdHashKeyMap: unitIdHashKeyMapSelector(state),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -85,9 +85,9 @@ describe('CoursewareContainer', () => {
|
||||
<Switch>
|
||||
<Route
|
||||
path={[
|
||||
'/course/:courseId/:sequenceId/:unitId',
|
||||
'/course/:courseId/:sequenceId',
|
||||
'/course/:courseId',
|
||||
'/c/:courseId/:sequenceId/:unitId',
|
||||
'/c/:courseId/:sequenceId',
|
||||
'/c/:courseId',
|
||||
]}
|
||||
component={CoursewareContainer}
|
||||
/>
|
||||
@@ -128,8 +128,10 @@ describe('CoursewareContainer', () => {
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
|
||||
|
||||
sequenceMetadatas.forEach(sequenceMetadata => {
|
||||
const sequenceMetadataUrl = `${getConfig().LMS_BASE_URL}/api/courseware/sequence/${sequenceMetadata.item_id}`;
|
||||
const sequenceMetadataUrl = `${getConfig().LMS_BASE_URL}/api/courseware/sequence/${sequenceMetadata.hash_key}`;
|
||||
axiosMock.onGet(sequenceMetadataUrl).reply(200, sequenceMetadata);
|
||||
const sequenceMetadataUrlFull = `${getConfig().LMS_BASE_URL}/api/courseware/sequence/${sequenceMetadata.item_id}`;
|
||||
axiosMock.onGet(sequenceMetadataUrlFull).reply(200, sequenceMetadata);
|
||||
const proctoredExamApiUrl = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/proctored_exam/attempt/course_id/${courseId}/content_id/${sequenceMetadata.item_id}?is_learning_mfe=true`;
|
||||
axiosMock.onGet(proctoredExamApiUrl).reply(200, { exam: {}, active_attempt: {} });
|
||||
});
|
||||
@@ -144,7 +146,7 @@ describe('CoursewareContainer', () => {
|
||||
}
|
||||
|
||||
it('should initialize to show a spinner', () => {
|
||||
history.push('/course/abc123');
|
||||
history.push('/c/abc123');
|
||||
render(component);
|
||||
|
||||
const spinner = screen.getByRole('status');
|
||||
@@ -190,11 +192,11 @@ describe('CoursewareContainer', () => {
|
||||
|
||||
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,
|
||||
unitId: unitBlocks[1].id,
|
||||
sectionId: sequenceBlock.hash_key,
|
||||
unitId: unitBlocks[1].hash_key,
|
||||
});
|
||||
|
||||
history.push(`/course/${courseId}`);
|
||||
history.push(`/c/${courseId}`);
|
||||
const container = await loadContainer();
|
||||
|
||||
assertLoadedHeader(container);
|
||||
@@ -202,7 +204,7 @@ describe('CoursewareContainer', () => {
|
||||
|
||||
expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents');
|
||||
expect(container.querySelector('.fake-unit')).toHaveTextContent(courseId);
|
||||
expect(container.querySelector('.fake-unit')).toHaveTextContent(unitBlocks[1].id);
|
||||
expect(container.querySelector('.fake-unit')).toHaveTextContent(unitBlocks[1].hash_key);
|
||||
});
|
||||
|
||||
it('should use the first sequence ID and activeUnitIndex if the resume block response is empty', async () => {
|
||||
@@ -217,7 +219,7 @@ describe('CoursewareContainer', () => {
|
||||
// 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}`);
|
||||
history.push(`/c/${courseId}`);
|
||||
const container = await loadContainer();
|
||||
|
||||
assertLoadedHeader(container);
|
||||
@@ -237,11 +239,11 @@ describe('CoursewareContainer', () => {
|
||||
);
|
||||
|
||||
function setUrl(urlSequenceId, urlUnitId = null) {
|
||||
history.push(`/course/${courseId}/${urlSequenceId}/${urlUnitId || ''}`);
|
||||
history.push(`/c/${courseId}/${urlSequenceId}/${urlUnitId || ''}`);
|
||||
}
|
||||
|
||||
function assertLocation(container, sequenceId, unitId) {
|
||||
const expectedUrl = `http://localhost/course/${courseId}/${sequenceId}/${unitId}`;
|
||||
const expectedUrl = `http://localhost/c/${courseId}/${sequenceId}/${unitId}`;
|
||||
expect(global.location.href).toEqual(expectedUrl);
|
||||
expect(container.querySelector('.fake-unit')).toHaveTextContent(unitId);
|
||||
}
|
||||
@@ -257,7 +259,7 @@ describe('CoursewareContainer', () => {
|
||||
const container = await loadContainer();
|
||||
assertLoadedHeader(container);
|
||||
assertSequenceNavigation(container, 2);
|
||||
assertLocation(container, sequenceTree[1][1].id, urlUnit.id);
|
||||
assertLocation(container, sequenceTree[1][1].hash_key, urlUnit.hash_key);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -267,7 +269,7 @@ describe('CoursewareContainer', () => {
|
||||
const container = await loadContainer();
|
||||
assertLoadedHeader(container);
|
||||
assertSequenceNavigation(container, 2);
|
||||
assertLocation(container, sequenceTree[1][0].id, unitTree[1][0][0].id);
|
||||
assertLocation(container, sequenceTree[1][0].hash_key, unitTree[1][0][0].hash_key);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -293,14 +295,14 @@ describe('CoursewareContainer', () => {
|
||||
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}`);
|
||||
expect(global.location.href).toEqual(`http://localhost/c/${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}`);
|
||||
expect(global.location.href).toEqual(`http://localhost/c/${courseId}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -314,15 +316,15 @@ describe('CoursewareContainer', () => {
|
||||
|
||||
it('should insert the sequence ID into the URL', async () => {
|
||||
const unit = unitTree[1][0][1];
|
||||
history.push(`/course/${courseId}/${unit.id}`);
|
||||
history.push(`/c/${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}`;
|
||||
const expectedSequenceId = sequenceTree[1][0].hash_key;
|
||||
const expectedUrl = `http://localhost/c/${courseId}/${expectedSequenceId}/${unit.hash_key}`;
|
||||
expect(global.location.href).toEqual(expectedUrl);
|
||||
expect(container.querySelector('.fake-unit')).toHaveTextContent(unit.id);
|
||||
expect(container.querySelector('.fake-unit')).toHaveTextContent(unit.hash_key);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -331,7 +333,7 @@ describe('CoursewareContainer', () => {
|
||||
const unitBlocks = defaultUnitBlocks;
|
||||
|
||||
it('should pick the first unit if position was not defined (activeUnitIndex becomes 0)', async () => {
|
||||
history.push(`/course/${courseId}/${sequenceBlock.id}`);
|
||||
history.push(`/c/${courseId}/${sequenceBlock.hash_key}`);
|
||||
const container = await loadContainer();
|
||||
|
||||
assertLoadedHeader(container);
|
||||
@@ -339,7 +341,7 @@ describe('CoursewareContainer', () => {
|
||||
|
||||
expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents');
|
||||
expect(container.querySelector('.fake-unit')).toHaveTextContent(courseId);
|
||||
expect(container.querySelector('.fake-unit')).toHaveTextContent(unitBlocks[0].id);
|
||||
expect(container.querySelector('.fake-unit')).toHaveTextContent(unitBlocks[0].hash_key);
|
||||
});
|
||||
|
||||
it('should use activeUnitIndex to pick a unit from the sequence', async () => {
|
||||
@@ -350,7 +352,7 @@ describe('CoursewareContainer', () => {
|
||||
);
|
||||
setUpMockRequests({ sequenceMetadatas: [sequenceMetadata] });
|
||||
|
||||
history.push(`/course/${courseId}/${sequenceBlock.id}`);
|
||||
history.push(`/c/${courseId}/${sequenceBlock.hash_key}`);
|
||||
const container = await loadContainer();
|
||||
|
||||
assertLoadedHeader(container);
|
||||
@@ -358,7 +360,7 @@ describe('CoursewareContainer', () => {
|
||||
|
||||
expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents');
|
||||
expect(container.querySelector('.fake-unit')).toHaveTextContent(courseId);
|
||||
expect(container.querySelector('.fake-unit')).toHaveTextContent(unitBlocks[2].id);
|
||||
expect(container.querySelector('.fake-unit')).toHaveTextContent(unitBlocks[2].hash_key);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -367,7 +369,7 @@ describe('CoursewareContainer', () => {
|
||||
const unitBlocks = defaultUnitBlocks;
|
||||
|
||||
it('should load the specified unit', async () => {
|
||||
history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[2].id}`);
|
||||
history.push(`/c/${courseId}/${sequenceBlock.hash_key}/${unitBlocks[2].hash_key}`);
|
||||
const container = await loadContainer();
|
||||
|
||||
assertLoadedHeader(container);
|
||||
@@ -375,7 +377,7 @@ describe('CoursewareContainer', () => {
|
||||
|
||||
expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents');
|
||||
expect(container.querySelector('.fake-unit')).toHaveTextContent(courseId);
|
||||
expect(container.querySelector('.fake-unit')).toHaveTextContent(unitBlocks[2].id);
|
||||
expect(container.querySelector('.fake-unit')).toHaveTextContent(unitBlocks[2].hash_key);
|
||||
});
|
||||
|
||||
it('should navigate between units and check block completion', async () => {
|
||||
@@ -383,7 +385,7 @@ describe('CoursewareContainer', () => {
|
||||
complete: true,
|
||||
});
|
||||
|
||||
history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[0].id}`);
|
||||
history.push(`/c/${courseId}/${sequenceBlock.hash_key}/${unitBlocks[0].id}`);
|
||||
const container = await loadContainer();
|
||||
|
||||
const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation button');
|
||||
@@ -391,7 +393,7 @@ describe('CoursewareContainer', () => {
|
||||
expect(sequenceNextButton).toHaveTextContent('Next');
|
||||
fireEvent.click(sequenceNavButtons[4]);
|
||||
|
||||
expect(global.location.href).toEqual(`http://localhost/course/${courseId}/${sequenceBlock.id}/${unitBlocks[1].id}`);
|
||||
expect(global.location.href).toEqual(`http://localhost/c/${courseId}/${sequenceBlock.hash_key}/${unitBlocks[1].id}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -419,7 +421,7 @@ describe('CoursewareContainer', () => {
|
||||
);
|
||||
setUpMockRequests({ sequenceMetadatas: [sequenceMetadata] });
|
||||
|
||||
history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[2].id}`);
|
||||
history.push(`/c/${courseId}/${sequenceBlock.hash_key}/${unitBlocks[2].hash_key}`);
|
||||
await loadContainer();
|
||||
|
||||
expect(global.location.assign).toHaveBeenCalledWith(sequenceBlock.legacy_web_url);
|
||||
@@ -440,7 +442,7 @@ describe('CoursewareContainer', () => {
|
||||
|
||||
const { courseBlocks, sequenceBlocks, unitBlocks } = buildSimpleCourseBlocks(courseId, courseMetadata.name);
|
||||
setUpMockRequests({ courseBlocks, courseMetadata });
|
||||
history.push(`/course/${courseId}/${sequenceBlocks[0].id}/${unitBlocks[0].id}`);
|
||||
history.push(`/c/${courseId}/${sequenceBlocks[0].hash_key}/${unitBlocks[0].hash_key}`);
|
||||
return { courseMetadata, unitBlocks };
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,12 @@ export default () => {
|
||||
path={`${path}/courseware/:courseId/unit/:unitId`}
|
||||
component={CoursewareRedirect}
|
||||
/>
|
||||
<PageRoute
|
||||
path={`${path}/:courseId/:sequenceId/:unitId`}
|
||||
render={({ match }) => {
|
||||
global.location.assign(`/c/${match.params.courseId}/${match.params.sequenceId}/${match.params.unitId}`);
|
||||
}}
|
||||
/>
|
||||
<PageRoute
|
||||
path={`${path}/course-home/:courseId`}
|
||||
render={({ match }) => {
|
||||
|
||||
@@ -40,7 +40,7 @@ function CourseExit({ intl }) {
|
||||
} else if (mode === COURSE_EXIT_MODES.celebration) {
|
||||
body = (<CourseCelebration />);
|
||||
} else {
|
||||
return (<Redirect to={`/course/${courseId}`} />);
|
||||
return (<Redirect to={`/c/${courseId}`} />);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -94,7 +94,7 @@ describe('Course Exit Pages', () => {
|
||||
},
|
||||
});
|
||||
await fetchAndRender(<CourseExit />);
|
||||
expect(global.location.href).toEqual(`http://localhost/course/${defaultMetadata.id}`);
|
||||
expect(global.location.href).toEqual(`http://localhost/c/${defaultMetadata.id}`);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@ function Sequence({
|
||||
|
||||
const gated = sequence && sequence.gatedContent !== undefined && sequence.gatedContent.gated;
|
||||
const goToCourseExitPage = () => {
|
||||
history.push(`/course/${courseId}/course-end`);
|
||||
history.push(`/c/${courseId}/course-end`);
|
||||
};
|
||||
|
||||
const defaultContent = (
|
||||
|
||||
@@ -89,9 +89,10 @@ function Unit({
|
||||
/** [MM-P2P] Experiment */
|
||||
mmp2p,
|
||||
}) {
|
||||
const unit = useModel('units', id);
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
const view = authenticatedUser ? 'student_view' : 'public_view';
|
||||
let iframeUrl = `${getConfig().LMS_BASE_URL}/xblock/${id}?show_title=0&show_bookmark_button=0&recheck_access=1&view=${view}`;
|
||||
let iframeUrl = `${getConfig().LMS_BASE_URL}/xblock/${(unit.decoded_id || id)}?show_title=0&show_bookmark_button=0&recheck_access=1&view=${view}`;
|
||||
if (format) {
|
||||
iframeUrl += `&format=${format}`;
|
||||
}
|
||||
@@ -101,7 +102,6 @@ function Unit({
|
||||
const [modalOptions, setModalOptions] = useState({ open: false });
|
||||
const [shouldDisplayHonorCode, setShouldDisplayHonorCode] = useState(false);
|
||||
|
||||
const unit = useModel('units', id);
|
||||
const course = useModel('coursewareMeta', courseId);
|
||||
const {
|
||||
contentTypeGatingEnabled,
|
||||
|
||||
@@ -44,6 +44,7 @@ describe('Unit', () => {
|
||||
id: unit.id,
|
||||
courseId: courseMetadata.id,
|
||||
format: 'Homework',
|
||||
decoded_id: unit.decoded_id,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -53,7 +54,7 @@ describe('Unit', () => {
|
||||
const renderedUnit = screen.getByTitle(unit.display_name);
|
||||
expect(renderedUnit).toHaveAttribute('height', String(0));
|
||||
expect(renderedUnit).toHaveAttribute(
|
||||
'src', `http://localhost:18000/xblock/${mockData.id}?show_title=0&show_bookmark_button=0&recheck_access=1&view=student_view&format=${mockData.format}`,
|
||||
'src', `http://localhost:18000/xblock/${mockData.decoded_id}?show_title=0&show_bookmark_button=0&recheck_access=1&view=student_view&format=${mockData.format}`,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ function ContentLock({
|
||||
intl, courseId, prereqSectionName, prereqId, sequenceTitle,
|
||||
}) {
|
||||
const handleClick = useCallback(() => {
|
||||
history.push(`/course/${courseId}/${prereqId}`);
|
||||
history.push(`/c/${courseId}/${prereqId}`);
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -38,6 +38,6 @@ describe('Content Lock', () => {
|
||||
render(<ContentLock {...mockData} />);
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
|
||||
expect(history.push).toHaveBeenCalledWith(`/course/${mockData.courseId}/${mockData.prereqId}`);
|
||||
expect(history.push).toHaveBeenCalledWith(`/c/${mockData.courseId}/${mockData.prereqId}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ function HonorCode({ intl, courseId }) {
|
||||
const siteName = getConfig().SITE_NAME;
|
||||
const honorCodeUrl = `${process.env.TERMS_OF_SERVICE_URL}#honor-code`;
|
||||
|
||||
const handleCancel = () => history.push(`/course/${courseId}/home`);
|
||||
const handleCancel = () => history.push(`/c/${courseId}/home`);
|
||||
|
||||
const handleAgree = () => {
|
||||
dispatch(saveIntegritySignature(courseId));
|
||||
|
||||
@@ -28,6 +28,6 @@ describe('Honor Code', () => {
|
||||
|
||||
const cancelButton = screen.getByText('Cancel');
|
||||
fireEvent.click(cancelButton);
|
||||
expect(history.push).toHaveBeenCalledWith(`/course/${mockData.courseId}/home`);
|
||||
expect(history.push).toHaveBeenCalledWith(`/c/${mockData.courseId}/home`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,4 +58,5 @@ Factory.define('courseMetadata')
|
||||
is_mfe_special_exams_enabled: false,
|
||||
is_mfe_proctored_exams_enabled: false,
|
||||
recommendations: null,
|
||||
mfe_short_url_is_active: true,
|
||||
});
|
||||
|
||||
@@ -36,6 +36,9 @@ Factory.define('sequenceMetadata')
|
||||
prereq_section_name: `${sequenceBlock.display_name}-prereq`,
|
||||
gated_section_name: sequenceBlock.display_name,
|
||||
}))
|
||||
|
||||
.attr('decoded_id', ['sequenceBlock'], sequenceBlock => sequenceBlock.decoded_id)
|
||||
.attr('hash_key', ['sequenceBlock'], sequenceBlock => sequenceBlock.hash_key)
|
||||
.attr('items', ['unitBlocks', 'sequenceBlock'], (unitBlocks, sequenceBlock) => unitBlocks.map(
|
||||
unitBlock => ({
|
||||
href: '',
|
||||
@@ -44,10 +47,12 @@ Factory.define('sequenceMetadata')
|
||||
bookmarked: unitBlock.bookmarked || false,
|
||||
path: `Chapter Display Name > ${sequenceBlock.display_name} > ${unitBlock.display_name}`,
|
||||
type: unitBlock.type,
|
||||
hash_key: unitBlock.hash_key,
|
||||
complete: unitBlock.complete || null,
|
||||
content: '',
|
||||
page_title: unitBlock.display_name,
|
||||
contains_content_type_gated_content: unitBlock.contains_content_type_gated_content,
|
||||
decoded_id: unitBlock.decoded_id,
|
||||
}),
|
||||
))
|
||||
.attrs({
|
||||
|
||||
@@ -38,6 +38,7 @@ export function normalizeBlocks(courseId, blocks) {
|
||||
title: block.display_name,
|
||||
legacyWebUrl: block.legacy_web_url,
|
||||
unitIds: block.children || [],
|
||||
hash_key: block.hash_key,
|
||||
};
|
||||
break;
|
||||
case 'vertical':
|
||||
@@ -46,6 +47,7 @@ export function normalizeBlocks(courseId, blocks) {
|
||||
id: block.id,
|
||||
title: block.display_name,
|
||||
legacyWebUrl: block.legacy_web_url,
|
||||
hash_key: block.hash_key,
|
||||
};
|
||||
break;
|
||||
default:
|
||||
@@ -87,7 +89,6 @@ export function normalizeBlocks(courseId, blocks) {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return models;
|
||||
}
|
||||
|
||||
@@ -222,6 +223,7 @@ function normalizeMetadata(metadata) {
|
||||
specialExamsEnabledWaffleFlag: data.is_mfe_special_exams_enabled,
|
||||
proctoredExamsEnabledWaffleFlag: data.is_mfe_proctored_exams_enabled,
|
||||
isMasquerading: data.original_user_is_staff && !data.is_staff,
|
||||
shortLinkFeatureFlag: data.mfe_short_url_is_active,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -258,6 +260,8 @@ function normalizeSequenceMetadata(sequence) {
|
||||
saveUnitPosition: sequence.save_position,
|
||||
showCompletion: sequence.show_completion,
|
||||
allowProctoringOptOut: sequence.allow_proctoring_opt_out,
|
||||
hash_key: sequence.hash_key,
|
||||
decoded_id: sequence.decoded_id,
|
||||
},
|
||||
units: sequence.items.map(unit => ({
|
||||
id: unit.id,
|
||||
@@ -268,14 +272,14 @@ function normalizeSequenceMetadata(sequence) {
|
||||
contentType: unit.type,
|
||||
graded: unit.graded,
|
||||
containsContentTypeGatedContent: unit.contains_content_type_gated_content,
|
||||
decoded_id: unit.decoded_id,
|
||||
hash_key: unit.hash_key,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getSequenceMetadata(sequenceId) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(`${getConfig().LMS_BASE_URL}/api/courseware/sequence/${sequenceId}`, {});
|
||||
|
||||
return normalizeSequenceMetadata(data);
|
||||
}
|
||||
|
||||
|
||||
@@ -254,8 +254,7 @@ describe('Data layer integration tests', () => {
|
||||
});
|
||||
|
||||
describe('Test checkBlockCompletion', () => {
|
||||
const getCompletionURL = `${getConfig().LMS_BASE_URL}/courses/${courseId}/xblock/${sequenceId}/handler/get_completion`;
|
||||
|
||||
const getCompletionURL = `${getConfig().LMS_BASE_URL}/courses/${courseId}/xblock/${sequenceMetadata.decoded_id}/handler/get_completion`;
|
||||
it('Should fail to check completion and log error', async () => {
|
||||
axiosMock.onPost(getCompletionURL).networkError();
|
||||
|
||||
@@ -283,7 +282,7 @@ describe('Data layer integration tests', () => {
|
||||
});
|
||||
|
||||
describe('Test saveSequencePosition', () => {
|
||||
const gotoPositionURL = `${getConfig().LMS_BASE_URL}/courses/${courseId}/xblock/${sequenceId}/handler/goto_position`;
|
||||
const gotoPositionURL = `${getConfig().LMS_BASE_URL}/courses/${courseId}/xblock/${sequenceMetadata.decoded_id}/handler/goto_position`;
|
||||
|
||||
it('Should change and revert sequence model activeUnitIndex in case of error', async () => {
|
||||
axiosMock.onPost(gotoPositionURL).networkError();
|
||||
|
||||
@@ -15,6 +15,7 @@ const slice = createSlice({
|
||||
sequenceId: null,
|
||||
specialExamsEnabledWaffleFlag: false,
|
||||
proctoredExamsEnabledWaffleFlag: false,
|
||||
shortLinkFeatureFlag: false,
|
||||
},
|
||||
reducers: {
|
||||
setsSpecialExamsEnabled: (state, { payload }) => {
|
||||
@@ -23,6 +24,9 @@ const slice = createSlice({
|
||||
setsProctoredExamsEnabled: (state, { payload }) => {
|
||||
state.proctoredExamsEnabledWaffleFlag = payload.proctoredExamsEnabledWaffleFlag;
|
||||
},
|
||||
setsShortLinkFeatureFlag: (state, { payload }) => {
|
||||
state.shortLinkFeatureFlag = payload.shortLinkFeatureFlag;
|
||||
},
|
||||
fetchCourseRequest: (state, { payload }) => {
|
||||
state.courseId = payload.courseId;
|
||||
state.courseStatus = LOADING;
|
||||
@@ -57,6 +61,7 @@ const slice = createSlice({
|
||||
export const {
|
||||
setsSpecialExamsEnabled,
|
||||
setsProctoredExamsEnabled,
|
||||
setsShortLinkFeatureFlag,
|
||||
fetchCourseRequest,
|
||||
fetchCourseSuccess,
|
||||
fetchCourseFailure,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import {
|
||||
setsSpecialExamsEnabled,
|
||||
setsProctoredExamsEnabled,
|
||||
setsShortLinkFeatureFlag,
|
||||
fetchCourseRequest,
|
||||
fetchCourseSuccess,
|
||||
fetchCourseFailure,
|
||||
@@ -100,6 +101,7 @@ function mergeLearningSequencesWithCourseBlocks(learningSequencesModels, courseB
|
||||
effortActivities: blocksSequence.effortActivities,
|
||||
effortTime: blocksSequence.effortTime,
|
||||
legacyWebUrl: blocksSequence.legacyWebUrl,
|
||||
hash_key: blocksSequence.hash_key,
|
||||
unitIds: blocksSequence.unitIds,
|
||||
};
|
||||
|
||||
@@ -151,6 +153,9 @@ export function fetchCourse(courseId) {
|
||||
dispatch(setsProctoredExamsEnabled({
|
||||
proctoredExamsEnabledWaffleFlag: courseMetadataResult.value.proctoredExamsEnabledWaffleFlag,
|
||||
}));
|
||||
dispatch(setsShortLinkFeatureFlag({
|
||||
shortLinkFeatureFlag: courseMetadataResult.value.shortLinkFeatureFlag,
|
||||
}));
|
||||
}
|
||||
|
||||
if (courseBlocksResult.status === 'fulfilled') {
|
||||
@@ -176,10 +181,18 @@ export function fetchCourse(courseId) {
|
||||
modelType: 'sequences',
|
||||
modelsMap: sequences,
|
||||
}));
|
||||
dispatch(addModelsMap({
|
||||
modelType: 'sequenceIdToHashKeyMap',
|
||||
modelsMap: sequences,
|
||||
}));
|
||||
dispatch(updateModelsMap({
|
||||
modelType: 'units',
|
||||
modelsMap: units,
|
||||
}));
|
||||
dispatch(addModelsMap({
|
||||
modelType: 'unitIdToHashKeyMap',
|
||||
modelsMap: units,
|
||||
}));
|
||||
}
|
||||
|
||||
const fetchedMetadata = courseMetadataResult.status === 'fulfilled';
|
||||
@@ -231,10 +244,18 @@ export function fetchSequence(sequenceId) {
|
||||
modelType: 'sequences',
|
||||
model: sequence,
|
||||
}));
|
||||
dispatch(updateModel({
|
||||
modelType: 'sequenceIdToHashKeyMap',
|
||||
model: sequence,
|
||||
}));
|
||||
dispatch(updateModels({
|
||||
modelType: 'units',
|
||||
models: units,
|
||||
}));
|
||||
dispatch(updateModels({
|
||||
modelType: 'unitIdToHashKeyMap',
|
||||
models: units,
|
||||
}));
|
||||
dispatch(fetchSequenceSuccess({ sequenceId }));
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -247,16 +268,24 @@ export function fetchSequence(sequenceId) {
|
||||
export function checkBlockCompletion(courseId, sequenceId, unitId) {
|
||||
return async (dispatch, getState) => {
|
||||
const { models } = getState();
|
||||
if (models.units[unitId].complete) {
|
||||
let modelsUnitId = unitId;
|
||||
let modelsSequenceId = sequenceId;
|
||||
if (!models.units[unitId]) {
|
||||
modelsUnitId = models.unitIdToHashKeyMap[unitId];
|
||||
}
|
||||
if (!models.sequences[sequenceId]) {
|
||||
modelsSequenceId = models.sequenceIdToHashKeyMap[sequenceId];
|
||||
}
|
||||
if (models.units[modelsUnitId].complete) {
|
||||
return; // do nothing. Things don't get uncompleted after they are completed.
|
||||
}
|
||||
|
||||
try {
|
||||
const isComplete = await getBlockCompletion(courseId, sequenceId, unitId);
|
||||
const isComplete = await getBlockCompletion(courseId, modelsSequenceId, modelsUnitId);
|
||||
dispatch(updateModel({
|
||||
modelType: 'units',
|
||||
model: {
|
||||
id: unitId,
|
||||
id: modelsUnitId,
|
||||
complete: isComplete,
|
||||
},
|
||||
}));
|
||||
@@ -269,23 +298,27 @@ export function checkBlockCompletion(courseId, sequenceId, unitId) {
|
||||
export function saveSequencePosition(courseId, sequenceId, activeUnitIndex) {
|
||||
return async (dispatch, getState) => {
|
||||
const { models } = getState();
|
||||
const initialActiveUnitIndex = models.sequences[sequenceId].activeUnitIndex;
|
||||
let modelsSequenceId = sequenceId;
|
||||
if (!models.sequences[sequenceId]) {
|
||||
modelsSequenceId = models.sequenceIdToHashKeyMap[sequenceId];
|
||||
}
|
||||
const initialActiveUnitIndex = models.sequences[modelsSequenceId].activeUnitIndex;
|
||||
// Optimistically update the position.
|
||||
dispatch(updateModel({
|
||||
modelType: 'sequences',
|
||||
model: {
|
||||
id: sequenceId,
|
||||
id: modelsSequenceId,
|
||||
activeUnitIndex,
|
||||
},
|
||||
}));
|
||||
try {
|
||||
await postSequencePosition(courseId, sequenceId, activeUnitIndex);
|
||||
await postSequencePosition(courseId, modelsSequenceId, activeUnitIndex);
|
||||
// Update again under the assumption that the above call succeeded, since it doesn't return a
|
||||
// meaningful response.
|
||||
dispatch(updateModel({
|
||||
modelType: 'sequences',
|
||||
model: {
|
||||
id: sequenceId,
|
||||
id: modelsSequenceId,
|
||||
activeUnitIndex,
|
||||
},
|
||||
}));
|
||||
@@ -294,7 +327,7 @@ export function saveSequencePosition(courseId, sequenceId, activeUnitIndex) {
|
||||
dispatch(updateModel({
|
||||
modelType: 'sequences',
|
||||
model: {
|
||||
id: sequenceId,
|
||||
id: modelsSequenceId,
|
||||
activeUnitIndex: initialActiveUnitIndex,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -6,6 +6,9 @@ function add(state, modelType, model) {
|
||||
if (state[modelType] === undefined) {
|
||||
state[modelType] = {};
|
||||
}
|
||||
if (modelType.includes('IdToHashKeyMap')) {
|
||||
state[modelType][model.hash_key] = id;
|
||||
}
|
||||
state[modelType][id] = model;
|
||||
}
|
||||
|
||||
@@ -13,6 +16,9 @@ function update(state, modelType, model) {
|
||||
if (state[modelType] === undefined) {
|
||||
state[modelType] = {};
|
||||
}
|
||||
if (modelType.includes('IdToHashKeyMap')) {
|
||||
state[modelType][model.hash_key] = model.id;
|
||||
}
|
||||
state[modelType][model.id] = { ...state[modelType][model.id], ...model };
|
||||
}
|
||||
|
||||
|
||||
@@ -34,20 +34,21 @@ subscribe(APP_READY, () => {
|
||||
<UserMessagesProvider>
|
||||
<Switch>
|
||||
<PageRoute path="/redirect" component={CoursewareRedirectLandingPage} />
|
||||
<PageRoute path="/course/:courseId/home">
|
||||
<PageRoute path="/course" component={CoursewareRedirectLandingPage} />
|
||||
<PageRoute path="/c/:courseId/home">
|
||||
<TabContainer tab="outline" fetch={fetchOutlineTab} slice="courseHome">
|
||||
<OutlineTab />
|
||||
</TabContainer>
|
||||
</PageRoute>
|
||||
<PageRoute path="/course/:courseId/dates">
|
||||
<PageRoute path="/c/:courseId/dates">
|
||||
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
|
||||
<DatesTab />
|
||||
</TabContainer>
|
||||
</PageRoute>
|
||||
<PageRoute
|
||||
path={[
|
||||
'/course/:courseId/progress/:targetUserId/',
|
||||
'/course/:courseId/progress',
|
||||
'/c/:courseId/progress/:targetUserId/',
|
||||
'/c/:courseId/progress',
|
||||
]}
|
||||
render={({ match }) => (
|
||||
<TabContainer
|
||||
@@ -59,16 +60,16 @@ subscribe(APP_READY, () => {
|
||||
</TabContainer>
|
||||
)}
|
||||
/>
|
||||
<PageRoute path="/course/:courseId/course-end">
|
||||
<PageRoute path="/c/:courseId/course-end">
|
||||
<TabContainer tab="courseware" fetch={fetchCourse} slice="courseware">
|
||||
<CourseExit />
|
||||
</TabContainer>
|
||||
</PageRoute>
|
||||
<PageRoute
|
||||
path={[
|
||||
'/course/:courseId/:sequenceId/:unitId',
|
||||
'/course/:courseId/:sequenceId',
|
||||
'/course/:courseId',
|
||||
'/c/:courseId/:sequenceId/:unitId',
|
||||
'/c/:courseId/:sequenceId',
|
||||
'/c/:courseId',
|
||||
]}
|
||||
component={CoursewareContainer}
|
||||
/>
|
||||
|
||||
@@ -22,6 +22,13 @@ Factory.define('block')
|
||||
|
||||
return blockId;
|
||||
})
|
||||
.attr(
|
||||
'hash_key', ['block_id'],
|
||||
(blockId) => {
|
||||
const len = blockId.length;
|
||||
return blockId.substring(23, len);
|
||||
},
|
||||
)
|
||||
.attr(
|
||||
'id',
|
||||
['id', 'block_id', 'type', 'courseId'],
|
||||
@@ -35,25 +42,33 @@ Factory.define('block')
|
||||
return `block-v1:${courseInfo}+type@${type}+block@${blockId}`;
|
||||
},
|
||||
)
|
||||
.attr(
|
||||
'decoded_id', ['block_id', 'type', 'courseId'],
|
||||
(blockId, type, courseId) => {
|
||||
const courseInfo = courseId.split(':')[1];
|
||||
|
||||
return `block-v1:${courseInfo}+type@${type}+block@${blockId}`;
|
||||
},
|
||||
)
|
||||
.attr(
|
||||
'student_view_url',
|
||||
['student_view_url', 'host', 'id'],
|
||||
(url, host, id) => {
|
||||
['student_view_url', 'host', 'decoded_id'],
|
||||
(url, host, decodedId) => {
|
||||
if (url) {
|
||||
return url;
|
||||
}
|
||||
|
||||
return `${host}/xblock/${id}`;
|
||||
return `${host}/xblock/${decodedId}`;
|
||||
},
|
||||
)
|
||||
.attr(
|
||||
'legacy_web_url',
|
||||
['legacy_web_url', 'host', 'courseId', 'id'],
|
||||
(url, host, courseId, id) => {
|
||||
['legacy_web_url', 'host', 'courseId', 'decoded_id'],
|
||||
(url, host, courseId, decodedId) => {
|
||||
if (url) {
|
||||
return url;
|
||||
}
|
||||
|
||||
return `${host}/courses/${courseId}/jump_to/${id}?experience=legacy`;
|
||||
return `${host}/courses/${courseId}/jump_to/${decodedId}?experience=legacy`;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -30,9 +30,9 @@ describe('Tab Container', () => {
|
||||
});
|
||||
|
||||
it('renders correctly', () => {
|
||||
history.push(`/course/${courseId}`);
|
||||
history.push(`/c/${courseId}`);
|
||||
render(
|
||||
<Route path="/course/:courseId">
|
||||
<Route path="/c/:courseId">
|
||||
<TabContainer {...mockData} />
|
||||
</Route>,
|
||||
);
|
||||
@@ -48,11 +48,11 @@ describe('Tab Container', () => {
|
||||
|
||||
it('Should handle passing in a targetUserId', () => {
|
||||
const targetUserId = '1';
|
||||
history.push(`/course/${courseId}/progress/${targetUserId}/`);
|
||||
history.push(`/c/${courseId}/progress/${targetUserId}/`);
|
||||
|
||||
render(
|
||||
<Route
|
||||
path="/course/:courseId/progress/:targetUserId/"
|
||||
path="/c/:courseId/progress/:targetUserId/"
|
||||
render={({ match }) => (
|
||||
<TabContainer
|
||||
fetch={() => mockFetch(match.params.courseId, match.params.targetUserId)}
|
||||
|
||||
Reference in New Issue
Block a user