Compare commits

...

65 Commits

Author SHA1 Message Date
Kristin Aoki
8882026a01 Fix misspelled variable names 2021-08-12 16:24:23 -04:00
Kristin Aoki
c6578d4e2e Update url to take direct sequence id 2021-08-12 16:20:07 -04:00
Kristin Aoki
fe4680646e Update modelsSequenceId and modelsUnitId definitions 2021-08-12 16:18:40 -04:00
Kristin Aoki
c09ba48615 Update sequence id for goto links 2021-08-12 15:33:35 -04:00
Kristin Aoki
c46da1dc34 Merge branch 'master' into KristinAoki/TNL-8511 2021-08-12 15:10:40 -04:00
Kristin Aoki
9ca5c61088 Fix id references 2021-08-12 15:07:23 -04:00
Kristin Aoki
a17e2a1a15 Update unitViaSequenceId to check if id is not block id 2021-08-12 14:35:11 -04:00
Kristin Aoki
ea02b2f70f Update links 2021-08-12 14:32:02 -04:00
Kristin Aoki
5fa33e4015 Update checkBlockCompletion function unitID 2021-08-12 14:30:25 -04:00
Kristin Aoki
569b628961 Update sequenceMetadata response 2021-08-12 11:09:43 -04:00
Kristin Aoki
43eb58974a Update url length function 2021-08-12 11:00:25 -04:00
Kristin Aoki
6f2281c1a4 Add hash_key to id translations 2021-08-12 10:01:31 -04:00
Kristin Aoki
5538b48ebb Update prereq_id 2021-08-12 09:59:27 -04:00
Kristin Aoki
847cdfa0bd Fix misplace semicolon 2021-08-12 09:50:51 -04:00
Kristin Aoki
38db0ebfe1 Add page route and redirect to handle old urls 2021-08-12 09:50:06 -04:00
Kristin Aoki
7b57b06ed5 Update id generation 2021-08-11 12:08:41 -04:00
Kristin Aoki
9c2190980e Add condition to know how to render sequence links 2021-08-11 10:37:36 -04:00
Kristin Aoki
b4c83a38aa Update conditional checks to be based on hash_key instead of id 2021-08-11 10:36:52 -04:00
Kristin Aoki
5efc22220f Update model to store by id instead of hash key 2021-08-11 10:35:17 -04:00
Kristin Aoki
0ba9ed7d31 Add hash key and mapping model 2021-08-11 10:34:32 -04:00
Kristin Aoki
a32a58019d Add id to hash key mapping for sequences and units 2021-08-11 10:33:54 -04:00
Kristin Aoki
367c8ad0df Add new feature flag 2021-08-11 10:33:15 -04:00
Kristin Aoki
ea93aea4dd Add check for routeUnitId 2021-08-06 09:52:19 -04:00
Kristin Aoki
e05428e01d Add new flag and function for old urls 2021-08-05 14:59:10 -04:00
Kristin Aoki
24de9d7add Add new feature flag 2021-08-05 14:58:24 -04:00
Kristin Aoki
4e136d9c55 Add dispatch for new feature flag 2021-08-05 14:57:50 -04:00
Kristin Aoki
296607fb76 Add new flag declarations 2021-08-05 14:57:18 -04:00
Kristin Aoki
544e11b628 Add new flag to courseMetadata 2021-08-05 09:32:29 -04:00
Kristin Aoki
75b195bdc0 Fix typos 2021-08-05 09:29:58 -04:00
Kristin Aoki
07042d9908 Update object key for unit and sequence to store by id if no hash_key 2021-08-04 11:10:29 -04:00
Kristin Aoki
2d1a13ab0a Remove unused definitions 2021-08-03 10:09:11 -04:00
Kristin Aoki
7fde146edd Remove unused parameters 2021-08-03 10:05:55 -04:00
Kristin Aoki
5f0968e348 Update hash key to be consistently generated for snapshots 2021-08-02 16:22:33 -04:00
Kristin Aoki
20935e7860 Update snapshot to reflect new block hash keys 2021-08-02 16:20:32 -04:00
Kristin Aoki
40ea41996f Fix broken sequence URL variable 2021-08-02 15:54:26 -04:00
Kristin Aoki
f0fab488a5 Fix broken urls 2021-08-02 15:48:48 -04:00
Kristin Aoki
7f2df8b886 Update snapshot to reflect new storage of blocks by hash key 2021-08-02 15:36:15 -04:00
Kristin Aoki
9b33f20eaa Fix route path bug 2021-08-02 15:23:25 -04:00
Kristin Aoki
7242583f13 Fix variable in API call 2021-08-02 15:12:05 -04:00
Kristin Aoki
229692255f Fix broken sequence metadata URL 2021-08-02 15:03:14 -04:00
Kristin Aoki
96a5753b1b Merge branch 'master' into KristinAoki/TNL-8511 2021-08-02 13:13:22 -04:00
Kristin Aoki
7b45c8b6fa Fix broken redirect 2021-07-30 16:24:52 -04:00
Kristin Aoki
f2d7e119a5 Update API call urls 2021-07-30 15:22:07 -04:00
Kristin Aoki
4baf78c79e Update links to match the new pattern 2021-07-30 15:20:01 -04:00
Kristin Aoki
d517f94c49 Add more information about the url changes 2021-07-30 15:18:31 -04:00
Kristin Aoki
43ff07af3e Update course exit url 2021-07-29 15:17:12 -04:00
Kristin Aoki
aeca68fd56 Update block id variables 2021-07-29 14:17:51 -04:00
Kristin Aoki
29a24aa62e Fix broken api calls 2021-07-29 14:17:08 -04:00
Kristin Aoki
4be725b4c2 Update iframe url parameter variables 2021-07-29 14:09:51 -04:00
Kristin Aoki
c592753182 Merge branch 'master' into KristinAoki/TNL-8511 2021-07-28 15:56:44 -04:00
Kristin Aoki
174be4adc7 Update iframeUrl to use decoded_id instead of id 2021-07-26 16:39:14 -04:00
Kristin Aoki
388b9dfe59 Fix undefined error in sequence metadata 2021-07-26 16:35:03 -04:00
Kristin Aoki
e4ec845bd4 Update link path to match new format 2021-07-26 15:11:07 -04:00
Kristin Aoki
e96d885114 Update model to store sequence based on hash_key 2021-07-26 15:09:38 -04:00
Kristin Aoki
eb70d3733d Merge branch 'KristinAoki/TNL-8511' of github.com:edx/frontend-app-learning into KristinAoki/TNL-8511 2021-07-21 09:24:12 -04:00
Kristin Aoki
fcda48513a Remove commented out code 2021-07-21 09:22:16 -04:00
Kristin Aoki
abac174e2e Update model to base storage off hash_key for sequence and unit 2021-07-21 09:18:04 -04:00
Kristin Aoki
457dc4b279 Update 0009-courseware-url-shortening.md 2021-07-15 14:52:48 -04:00
Kristin Aoki
3b2f91cd32 Update 0009-courseware-url-shortening.md 2021-07-15 14:44:12 -04:00
Kristin Aoki
19f318679f Add example url 2021-07-13 16:28:45 -04:00
Kristin Aoki
d38c07a206 Update 0009-courseware-url-shortening.md 2021-07-13 15:33:12 -04:00
Kristin Aoki
3b2bbbdbc4 Merge branch 'master' into KristinAoki/TNL-8511 2021-07-13 15:09:49 -04:00
Kristin Aoki
832107f084 Add reference to ADR 009 2021-07-13 14:51:33 -04:00
Kristin Aoki
b23a6330f1 Add ADR 2021-07-13 14:50:40 -04:00
Kristin Aoki
8970352cdd Update path 2021-07-13 13:15:11 -04:00
31 changed files with 365 additions and 115 deletions

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}
/>

View File

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

View File

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