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 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>', handouts_html: '<ul><li>Handout 1</li></ul>',
offer: null, offer: null,
welcome_message_html: '<p>Welcome to this course!</p>', welcome_message_html: '<p>Welcome to this course!</p>',
mfe_short_url_is_active: true,
}); });

View File

@@ -16,6 +16,7 @@ Object {
"proctoredExamsEnabledWaffleFlag": false, "proctoredExamsEnabledWaffleFlag": false,
"sequenceId": null, "sequenceId": null,
"sequenceStatus": "loading", "sequenceStatus": "loading",
"shortLinkFeatureFlag": false,
"specialExamsEnabledWaffleFlag": false, "specialExamsEnabledWaffleFlag": false,
}, },
"models": Object { "models": Object {
@@ -321,6 +322,7 @@ Object {
"proctoredExamsEnabledWaffleFlag": false, "proctoredExamsEnabledWaffleFlag": false,
"sequenceId": null, "sequenceId": null,
"sequenceStatus": "loading", "sequenceStatus": "loading",
"shortLinkFeatureFlag": false,
"specialExamsEnabledWaffleFlag": false, "specialExamsEnabledWaffleFlag": false,
}, },
"models": Object { "models": Object {
@@ -423,6 +425,7 @@ Object {
"due": null, "due": null,
"effortActivities": 2, "effortActivities": 2,
"effortTime": 15, "effortTime": 15,
"hash_key": "abcdabcd1",
"icon": null, "icon": null,
"id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1", "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", "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, "hasVisitedCourse": false,
"url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+Test+Block@12345abcde", "url": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+Test+Block@12345abcde",
}, },
"shortLinkFeatureFlag": true,
"timeOffsetMillis": 0, "timeOffsetMillis": 0,
"userHasPassingGrade": undefined, "userHasPassingGrade": undefined,
"verifiedMode": Object { "verifiedMode": Object {
@@ -507,6 +511,7 @@ Object {
"proctoredExamsEnabledWaffleFlag": false, "proctoredExamsEnabledWaffleFlag": false,
"sequenceId": null, "sequenceId": null,
"sequenceStatus": "loading", "sequenceStatus": "loading",
"shortLinkFeatureFlag": false,
"specialExamsEnabledWaffleFlag": false, "specialExamsEnabledWaffleFlag": false,
}, },
"models": Object { "models": Object {

View File

@@ -144,6 +144,7 @@ export function normalizeOutlineBlocks(courseId, blocks) {
// link to the MFE ourselves). // link to the MFE ourselves).
showLink: !!block.legacy_web_url, showLink: !!block.legacy_web_url,
title: block.display_name, title: block.display_name,
hash_key: block.hash_key,
}; };
break; break;
@@ -353,6 +354,7 @@ export async function getOutlineTabData(courseId) {
const userHasPassingGrade = data.user_has_passing_grade; const userHasPassingGrade = data.user_has_passing_grade;
const verifiedMode = camelCaseObject(data.verified_mode); const verifiedMode = camelCaseObject(data.verified_mode);
const welcomeMessageHtml = data.welcome_message_html; const welcomeMessageHtml = data.welcome_message_html;
const shortLinkFeatureFlag = data.mfe_short_url_is_active;
return { return {
accessExpiration, accessExpiration,
@@ -374,6 +376,7 @@ export async function getOutlineTabData(courseId) {
userHasPassingGrade, userHasPassingGrade,
verifiedMode, verifiedMode,
welcomeMessageHtml, welcomeMessageHtml,
shortLinkFeatureFlag,
}; };
} }

View File

@@ -32,7 +32,7 @@ describe('DatesTab', () => {
component = ( component = (
<AppProvider store={store}> <AppProvider store={store}>
<UserMessagesProvider> <UserMessagesProvider>
<Route path="/course/:courseId/dates"> <Route path="/c/:courseId/dates">
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome"> <TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
<DatesTab /> <DatesTab />
</TabContainer> </TabContainer>
@@ -81,7 +81,7 @@ describe('DatesTab', () => {
beforeEach(() => { beforeEach(() => {
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata); axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
axiosMock.onGet(datesUrl).reply(200, datesTabData); 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); render(component);
}); });
@@ -147,7 +147,7 @@ describe('DatesTab', () => {
describe('Suggested schedule messaging', () => { describe('Suggested schedule messaging', () => {
beforeEach(() => { beforeEach(() => {
setMetadata({ is_self_paced: true, is_enrolled: true }); setMetadata({ is_self_paced: true, is_enrolled: true });
history.push(`/course/${courseId}/dates`); history.push(`/c/${courseId}/dates`);
}); });
it('renders SuggestedScheduleHeader', async () => { it('renders SuggestedScheduleHeader', async () => {
@@ -316,7 +316,7 @@ describe('DatesTab', () => {
beforeEach(() => { beforeEach(() => {
axiosMock.onGet(datesUrl).reply(200, datesTabData); 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 () => { it('redirects to course survey for a survey_required error code', async () => {

View File

@@ -156,7 +156,7 @@ describe('Outline Tab', () => {
await fetchAndRender(); await fetchAndRender();
const sequenceLink = screen.getByText('Title of Sequence'); 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: { courseBlocks: {
sequences, sequences,
}, },
shortLinkFeatureFlag,
} = useModel('outline', courseId); } = useModel('outline', courseId);
const [open, setOpen] = useState(defaultOpen); const [open, setOpen] = useState(defaultOpen);
@@ -39,6 +40,28 @@ function Section({
useEffect(() => { useEffect(() => {
setOpen(defaultOpen); 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 = ( const sectionTitle = (
<div className="row w-100 m-0"> <div className="row w-100 m-0">
@@ -96,15 +119,7 @@ function Section({
)} )}
> >
<ol className="list-unstyled"> <ol className="list-unstyled">
{sequenceIds.map((sequenceId, index) => ( {sequenceLinks}
<SequenceLink
key={sequenceId}
id={sequenceId}
courseId={courseId}
sequence={sequences[sequenceId]}
first={index === 0}
/>
))}
</ol> </ol>
</Collapsible> </Collapsible>
</li> </li>

View File

@@ -46,7 +46,7 @@ function SequenceLink({
// canLoadCourseware is true if the Courseware MFE is enabled, false otherwise // canLoadCourseware is true if the Courseware MFE is enabled, false otherwise
const coursewareUrl = ( const coursewareUrl = (
canLoadCourseware canLoadCourseware
? <Link to={`/course/${courseId}/${id}`}>{title}</Link> ? <Link to={`/c/${courseId}/${id}`}>{title}</Link>
: <Hyperlink destination={legacyWebUrl}>{title}</Hyperlink> : <Hyperlink destination={legacyWebUrl}>{title}</Hyperlink>
); );
const displayTitle = showLink ? coursewareUrl : title; const displayTitle = showLink ? coursewareUrl : title;

View File

@@ -17,15 +17,21 @@ import { TabPage } from '../tab-page';
import Course from './course'; import Course from './course';
import { handleNextSectionCelebration } from './course/celebration'; 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) => { const checkResumeRedirect = memoize((courseStatus, courseId, sequenceId, firstSequenceId) => {
if (courseStatus === 'loaded' && !sequenceId) { if (courseStatus === 'loaded' && !sequenceId) {
// Note that getResumeBlock is just an API call, not a redux thunk. // Note that getResumeBlock is just an API call, not a redux thunk.
getResumeBlock(courseId).then((data) => { getResumeBlock(courseId).then((data) => {
// This is a replace because we don't want this change saved in the browser's history. // This is a replace because we don't want this change saved in the browser's history.
if (data.sectionId && data.unitId) { if (data.sectionId && data.unitId) {
history.replace(`/course/${courseId}/${data.sectionId}/${data.unitId}`); history.replace(`/c/${courseId}/${data.sectionId}/${data.unitId}`);
} else if (firstSequenceId) { } 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) => { const checkSectionUnitToUnitRedirect = memoize((courseStatus, courseId, sequenceStatus, section, unitId) => {
if (courseStatus === 'loaded' && sequenceStatus === 'failed' && 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 (courseStatus === 'loaded' && sequenceStatus === 'failed' && section && !unitId) {
// If the section is non-empty, redirect to its first sequence. // If the section is non-empty, redirect to its first sequence.
if (section.sequenceIds && section.sequenceIds[0]) { 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. // Otherwise, just go to the course root, letting the resume redirect take care of things.
} else { } 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 (courseStatus === 'loaded' && sequenceStatus === 'failed' && unit) {
// If the sequence failed to load as a sequence, but it *did* load as a unit, then // 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. // 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) { if (sequence.unitIds !== undefined && sequence.unitIds.length > 0) {
const nextUnitId = sequence.unitIds[sequence.activeUnitIndex]; const nextUnitId = sequence.unitIds[sequence.activeUnitIndex];
// This is a replace because we don't want this change saved in the browser's history. // 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: { match: {
params: { params: {
courseId: routeCourseId, courseId: routeCourseId,
sequenceId: routeSequenceId, sequenceId: routeSequenceHash,
}, },
}, },
} = this.props; } = this.props;
// Load data whenever the course or sequence ID changes. // Load data whenever the course or sequence ID changes.
this.checkFetchCourse(routeCourseId); this.checkFetchCourse(routeCourseId);
this.checkFetchSequence(routeSequenceId); this.checkFetchSequence(routeSequenceHash);
} }
componentDidUpdate() { componentDidUpdate() {
@@ -127,18 +133,28 @@ class CoursewareContainer extends Component {
firstSequenceId, firstSequenceId,
unitViaSequenceId, unitViaSequenceId,
sectionViaSequenceId, sectionViaSequenceId,
unitIdHashKeyMap,
shortLinkFeatureFlag,
match: { match: {
params: { params: {
courseId: routeCourseId, courseId: routeCourseId,
sequenceId: routeSequenceId, sequenceId: routeSequenceHash,
unitId: routeUnitId, unitId: routeUnitId,
}, },
}, },
} = this.props; } = this.props;
// Load data whenever the course or sequence ID changes. // Load data whenever the course or sequence ID changes.
this.checkFetchCourse(routeCourseId); 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 // All courseware URLs should normalize to the format /course/:courseId/:sequenceId/:unitId
// via the series of redirection rules below. // via the series of redirection rules below.
@@ -201,7 +217,7 @@ class CoursewareContainer extends Component {
} = this.props; } = this.props;
this.props.checkBlockCompletion(courseId, sequenceId, routeUnitId); this.props.checkBlockCompletion(courseId, sequenceId, routeUnitId);
history.push(`/course/${courseId}/${sequenceId}/${nextUnitId}`); history.push(`/c/${courseId}/${sequenceId}/${nextUnitId}`);
} }
handleNextSequenceClick = () => { handleNextSequenceClick = () => {
@@ -211,16 +227,20 @@ class CoursewareContainer extends Component {
nextSequence, nextSequence,
sequence, sequence,
sequenceId, sequenceId,
shortLinkFeatureFlag,
} = this.props; } = this.props;
if (nextSequence !== null) { if (nextSequence !== null) {
let nextSequenceParam = nextSequence.id;
if (shortLinkFeatureFlag) {
nextSequenceParam = nextSequence.hash_key;
}
let nextUnitId = null; let nextUnitId = null;
if (nextSequence.unitIds.length > 0) { if (nextSequence.unitIds.length > 0) {
[nextUnitId] = nextSequence.unitIds; [nextUnitId] = nextSequence.unitIds;
history.push(`/course/${courseId}/${nextSequence.id}/${nextUnitId}`); history.push(`/c/${courseId}/${nextSequenceParam}/${nextUnitId}`);
} else { } else {
// Some sequences have no units. This will show a blank page with prev/next buttons. // 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; const celebrateFirstSection = course && course.celebrations && course.celebrations.firstSection;
@@ -231,14 +251,22 @@ class CoursewareContainer extends Component {
} }
handlePreviousSequenceClick = () => { handlePreviousSequenceClick = () => {
const { previousSequence, courseId } = this.props; const {
previousSequence,
courseId,
shortLinkFeatureFlag,
} = this.props;
if (previousSequence !== null) { if (previousSequence !== null) {
let previousSequenceParam = previousSequence.id;
if (shortLinkFeatureFlag) {
previousSequenceParam = previousSequence.hash_key;
}
if (previousSequence.unitIds.length > 0) { if (previousSequence.unitIds.length > 0) {
const previousUnitId = previousSequence.unitIds[previousSequence.unitIds.length - 1]; const previousUnitId = previousSequence.unitIds[previousSequence.unitIds.length - 1];
history.push(`/course/${courseId}/${previousSequence.id}/${previousUnitId}`); history.push(`/c/${courseId}/${previousSequenceParam}/${previousUnitId}`);
} else { } else {
// Some sequences have no units. This will show a blank page with prev/next buttons. // 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, courseStatus,
courseId, courseId,
sequenceId, sequenceId,
sequence,
shortLinkFeatureFlag,
unitIdHashKeyMap,
match: { match: {
params: { params: {
unitId: routeUnitId, unitId: routeUnitId,
@@ -255,18 +286,30 @@ class CoursewareContainer extends Component {
}, },
} = this.props; } = 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 ( return (
<TabPage <TabPage
activeTabSlug="courseware" activeTabSlug="courseware"
courseId={courseId} courseId={courseId}
unitId={routeUnitId} unitId={updatedUnitId || routeUnitId}
courseStatus={courseStatus} courseStatus={courseStatus}
metadataModel="coursewareMeta" metadataModel="coursewareMeta"
> >
<Course <Course
courseId={courseId} courseId={courseId}
sequenceId={sequenceId} sequenceId={updatedSequenceId || sequenceId}
unitId={routeUnitId} unitId={updatedUnitId || routeUnitId}
nextSequenceHandler={this.handleNextSequenceClick} nextSequenceHandler={this.handleNextSequenceClick}
previousSequenceHandler={this.handlePreviousSequenceClick} previousSequenceHandler={this.handlePreviousSequenceClick}
unitNavigationHandler={this.handleUnitNavigationClick} unitNavigationHandler={this.handleUnitNavigationClick}
@@ -285,6 +328,7 @@ const sequenceShape = PropTypes.shape({
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,
unitIds: PropTypes.arrayOf(PropTypes.string).isRequired, unitIds: PropTypes.arrayOf(PropTypes.string).isRequired,
sectionId: PropTypes.string.isRequired, sectionId: PropTypes.string.isRequired,
hash_key: PropTypes.string.isRequired,
isTimeLimited: PropTypes.bool, isTimeLimited: PropTypes.bool,
isProctored: PropTypes.bool, isProctored: PropTypes.bool,
legacyWebUrl: PropTypes.string, legacyWebUrl: PropTypes.string,
@@ -318,6 +362,7 @@ CoursewareContainer.propTypes = {
previousSequence: sequenceShape, previousSequence: sequenceShape,
unitViaSequenceId: unitShape, unitViaSequenceId: unitShape,
sectionViaSequenceId: sectionShape, sectionViaSequenceId: sectionShape,
unitIdHashKeyMap: unitShape,
course: courseShape, course: courseShape,
sequence: sequenceShape, sequence: sequenceShape,
saveSequencePosition: PropTypes.func.isRequired, saveSequencePosition: PropTypes.func.isRequired,
@@ -326,6 +371,7 @@ CoursewareContainer.propTypes = {
fetchSequence: PropTypes.func.isRequired, fetchSequence: PropTypes.func.isRequired,
specialExamsEnabledWaffleFlag: PropTypes.bool.isRequired, specialExamsEnabledWaffleFlag: PropTypes.bool.isRequired,
proctoredExamsEnabledWaffleFlag: PropTypes.bool.isRequired, proctoredExamsEnabledWaffleFlag: PropTypes.bool.isRequired,
shortLinkFeatureFlag: PropTypes.bool.isRequired,
}; };
CoursewareContainer.defaultProps = { CoursewareContainer.defaultProps = {
@@ -338,6 +384,7 @@ CoursewareContainer.defaultProps = {
sectionViaSequenceId: null, sectionViaSequenceId: null,
course: null, course: null,
sequence: null, sequence: null,
unitIdHashKeyMap: null,
}; };
const currentCourseSelector = createSelector( const currentCourseSelector = createSelector(
@@ -349,7 +396,16 @@ const currentCourseSelector = createSelector(
const currentSequenceSelector = createSelector( const currentSequenceSelector = createSelector(
(state) => state.models.sequences || {}, (state) => state.models.sequences || {},
(state) => state.courseware.sequenceId, (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( const sequenceIdsSelector = createSelector(
@@ -369,11 +425,18 @@ const previousSequenceSelector = createSelector(
sequenceIdsSelector, sequenceIdsSelector,
(state) => state.models.sequences || {}, (state) => state.models.sequences || {},
(state) => state.courseware.sequenceId, (state) => state.courseware.sequenceId,
(sequenceIds, sequencesById, sequenceId) => { (state) => state.models.sequenceIdToHashKeyMap,
(sequenceIds, sequencesById, sequenceId, sequenceMap) => {
if (!sequenceId || sequenceIds.length === 0) { if (!sequenceId || sequenceIds.length === 0) {
return null; 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; const previousSequenceId = sequenceIndex > 0 ? sequenceIds[sequenceIndex - 1] : null;
return previousSequenceId !== null ? sequencesById[previousSequenceId] : null; return previousSequenceId !== null ? sequencesById[previousSequenceId] : null;
}, },
@@ -383,11 +446,18 @@ const nextSequenceSelector = createSelector(
sequenceIdsSelector, sequenceIdsSelector,
(state) => state.models.sequences || {}, (state) => state.models.sequences || {},
(state) => state.courseware.sequenceId, (state) => state.courseware.sequenceId,
(sequenceIds, sequencesById, sequenceId) => { (state) => state.models.sequenceIdToHashKeyMap,
(sequenceIds, sequencesById, sequenceId, sequenceMap) => {
if (!sequenceId || sequenceIds.length === 0) { if (!sequenceId || sequenceIds.length === 0) {
return null; 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; const nextSequenceId = sequenceIndex < sequenceIds.length - 1 ? sequenceIds[sequenceIndex + 1] : null;
return nextSequenceId !== null ? sequencesById[nextSequenceId] : null; return nextSequenceId !== null ? sequencesById[nextSequenceId] : null;
}, },
@@ -420,7 +490,21 @@ const sectionViaSequenceIdSelector = createSelector(
const unitViaSequenceIdSelector = createSelector( const unitViaSequenceIdSelector = createSelector(
(state) => state.models.units || {}, (state) => state.models.units || {},
(state) => state.courseware.sequenceId, (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) => { const mapStateToProps = (state) => {
@@ -431,6 +515,7 @@ const mapStateToProps = (state) => {
sequenceStatus, sequenceStatus,
specialExamsEnabledWaffleFlag, specialExamsEnabledWaffleFlag,
proctoredExamsEnabledWaffleFlag, proctoredExamsEnabledWaffleFlag,
shortLinkFeatureFlag,
} = state.courseware; } = state.courseware;
return { return {
@@ -440,6 +525,7 @@ const mapStateToProps = (state) => {
sequenceStatus, sequenceStatus,
specialExamsEnabledWaffleFlag, specialExamsEnabledWaffleFlag,
proctoredExamsEnabledWaffleFlag, proctoredExamsEnabledWaffleFlag,
shortLinkFeatureFlag,
course: currentCourseSelector(state), course: currentCourseSelector(state),
sequence: currentSequenceSelector(state), sequence: currentSequenceSelector(state),
previousSequence: previousSequenceSelector(state), previousSequence: previousSequenceSelector(state),
@@ -447,6 +533,7 @@ const mapStateToProps = (state) => {
firstSequenceId: firstSequenceIdSelector(state), firstSequenceId: firstSequenceIdSelector(state),
sectionViaSequenceId: sectionViaSequenceIdSelector(state), sectionViaSequenceId: sectionViaSequenceIdSelector(state),
unitViaSequenceId: unitViaSequenceIdSelector(state), unitViaSequenceId: unitViaSequenceIdSelector(state),
unitIdHashKeyMap: unitIdHashKeyMapSelector(state),
}; };
}; };

View File

@@ -85,9 +85,9 @@ describe('CoursewareContainer', () => {
<Switch> <Switch>
<Route <Route
path={[ path={[
'/course/:courseId/:sequenceId/:unitId', '/c/:courseId/:sequenceId/:unitId',
'/course/:courseId/:sequenceId', '/c/:courseId/:sequenceId',
'/course/:courseId', '/c/:courseId',
]} ]}
component={CoursewareContainer} component={CoursewareContainer}
/> />
@@ -128,8 +128,10 @@ describe('CoursewareContainer', () => {
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata); axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
sequenceMetadatas.forEach(sequenceMetadata => { 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); 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`; 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: {} }); axiosMock.onGet(proctoredExamApiUrl).reply(200, { exam: {}, active_attempt: {} });
}); });
@@ -144,7 +146,7 @@ describe('CoursewareContainer', () => {
} }
it('should initialize to show a spinner', () => { it('should initialize to show a spinner', () => {
history.push('/course/abc123'); history.push('/c/abc123');
render(component); render(component);
const spinner = screen.getByRole('status'); 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 () => { 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, { axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/courseware/resume/${courseId}`).reply(200, {
sectionId: sequenceBlock.id, sectionId: sequenceBlock.hash_key,
unitId: unitBlocks[1].id, unitId: unitBlocks[1].hash_key,
}); });
history.push(`/course/${courseId}`); history.push(`/c/${courseId}`);
const container = await loadContainer(); const container = await loadContainer();
assertLoadedHeader(container); assertLoadedHeader(container);
@@ -202,7 +204,7 @@ describe('CoursewareContainer', () => {
expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents'); expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents');
expect(container.querySelector('.fake-unit')).toHaveTextContent(courseId); 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 () => { 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! // Note how there is no sectionId/unitId returned in this mock response!
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/courseware/resume/${courseId}`).reply(200, {}); axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/courseware/resume/${courseId}`).reply(200, {});
history.push(`/course/${courseId}`); history.push(`/c/${courseId}`);
const container = await loadContainer(); const container = await loadContainer();
assertLoadedHeader(container); assertLoadedHeader(container);
@@ -237,11 +239,11 @@ describe('CoursewareContainer', () => {
); );
function setUrl(urlSequenceId, urlUnitId = null) { function setUrl(urlSequenceId, urlUnitId = null) {
history.push(`/course/${courseId}/${urlSequenceId}/${urlUnitId || ''}`); history.push(`/c/${courseId}/${urlSequenceId}/${urlUnitId || ''}`);
} }
function assertLocation(container, sequenceId, unitId) { 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(global.location.href).toEqual(expectedUrl);
expect(container.querySelector('.fake-unit')).toHaveTextContent(unitId); expect(container.querySelector('.fake-unit')).toHaveTextContent(unitId);
} }
@@ -257,7 +259,7 @@ describe('CoursewareContainer', () => {
const container = await loadContainer(); const container = await loadContainer();
assertLoadedHeader(container); assertLoadedHeader(container);
assertSequenceNavigation(container, 2); 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(); const container = await loadContainer();
assertLoadedHeader(container); assertLoadedHeader(container);
assertSequenceNavigation(container, 2); 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 () => { it('should ignore the section ID and instead redirect to the course root', async () => {
setUrl(sectionTree[1].id); setUrl(sectionTree[1].id);
await loadContainer(); 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 () => { 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. // Specific unit ID used here shouldn't matter; is ignored due to empty section.
setUrl(sectionTree[1].id, unitTree[0][0][0]); setUrl(sectionTree[1].id, unitTree[0][0][0]);
await loadContainer(); 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 () => { it('should insert the sequence ID into the URL', async () => {
const unit = unitTree[1][0][1]; const unit = unitTree[1][0][1];
history.push(`/course/${courseId}/${unit.id}`); history.push(`/c/${courseId}/${unit.id}`);
const container = await loadContainer(); const container = await loadContainer();
assertLoadedHeader(container); assertLoadedHeader(container);
assertSequenceNavigation(container, 2); assertSequenceNavigation(container, 2);
const expectedSequenceId = sequenceTree[1][0].id; const expectedSequenceId = sequenceTree[1][0].hash_key;
const expectedUrl = `http://localhost/course/${courseId}/${expectedSequenceId}/${unit.id}`; const expectedUrl = `http://localhost/c/${courseId}/${expectedSequenceId}/${unit.hash_key}`;
expect(global.location.href).toEqual(expectedUrl); 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; const unitBlocks = defaultUnitBlocks;
it('should pick the first unit if position was not defined (activeUnitIndex becomes 0)', async () => { 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(); const container = await loadContainer();
assertLoadedHeader(container); assertLoadedHeader(container);
@@ -339,7 +341,7 @@ describe('CoursewareContainer', () => {
expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents'); expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents');
expect(container.querySelector('.fake-unit')).toHaveTextContent(courseId); 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 () => { it('should use activeUnitIndex to pick a unit from the sequence', async () => {
@@ -350,7 +352,7 @@ describe('CoursewareContainer', () => {
); );
setUpMockRequests({ sequenceMetadatas: [sequenceMetadata] }); setUpMockRequests({ sequenceMetadatas: [sequenceMetadata] });
history.push(`/course/${courseId}/${sequenceBlock.id}`); history.push(`/c/${courseId}/${sequenceBlock.hash_key}`);
const container = await loadContainer(); const container = await loadContainer();
assertLoadedHeader(container); assertLoadedHeader(container);
@@ -358,7 +360,7 @@ describe('CoursewareContainer', () => {
expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents'); expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents');
expect(container.querySelector('.fake-unit')).toHaveTextContent(courseId); 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; const unitBlocks = defaultUnitBlocks;
it('should load the specified unit', async () => { 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(); const container = await loadContainer();
assertLoadedHeader(container); assertLoadedHeader(container);
@@ -375,7 +377,7 @@ describe('CoursewareContainer', () => {
expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents'); expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents');
expect(container.querySelector('.fake-unit')).toHaveTextContent(courseId); 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 () => { it('should navigate between units and check block completion', async () => {
@@ -383,7 +385,7 @@ describe('CoursewareContainer', () => {
complete: true, 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 container = await loadContainer();
const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation button'); const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation button');
@@ -391,7 +393,7 @@ describe('CoursewareContainer', () => {
expect(sequenceNextButton).toHaveTextContent('Next'); expect(sequenceNextButton).toHaveTextContent('Next');
fireEvent.click(sequenceNavButtons[4]); 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] }); 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(); await loadContainer();
expect(global.location.assign).toHaveBeenCalledWith(sequenceBlock.legacy_web_url); expect(global.location.assign).toHaveBeenCalledWith(sequenceBlock.legacy_web_url);
@@ -440,7 +442,7 @@ describe('CoursewareContainer', () => {
const { courseBlocks, sequenceBlocks, unitBlocks } = buildSimpleCourseBlocks(courseId, courseMetadata.name); const { courseBlocks, sequenceBlocks, unitBlocks } = buildSimpleCourseBlocks(courseId, courseMetadata.name);
setUpMockRequests({ courseBlocks, courseMetadata }); 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 }; return { courseMetadata, unitBlocks };
} }

View File

@@ -25,6 +25,12 @@ export default () => {
path={`${path}/courseware/:courseId/unit/:unitId`} path={`${path}/courseware/:courseId/unit/:unitId`}
component={CoursewareRedirect} component={CoursewareRedirect}
/> />
<PageRoute
path={`${path}/:courseId/:sequenceId/:unitId`}
render={({ match }) => {
global.location.assign(`/c/${match.params.courseId}/${match.params.sequenceId}/${match.params.unitId}`);
}}
/>
<PageRoute <PageRoute
path={`${path}/course-home/:courseId`} path={`${path}/course-home/:courseId`}
render={({ match }) => { render={({ match }) => {

View File

@@ -40,7 +40,7 @@ function CourseExit({ intl }) {
} else if (mode === COURSE_EXIT_MODES.celebration) { } else if (mode === COURSE_EXIT_MODES.celebration) {
body = (<CourseCelebration />); body = (<CourseCelebration />);
} else { } else {
return (<Redirect to={`/course/${courseId}`} />); return (<Redirect to={`/c/${courseId}`} />);
} }
return ( return (

View File

@@ -94,7 +94,7 @@ describe('Course Exit Pages', () => {
}, },
}); });
await fetchAndRender(<CourseExit />); 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 gated = sequence && sequence.gatedContent !== undefined && sequence.gatedContent.gated;
const goToCourseExitPage = () => { const goToCourseExitPage = () => {
history.push(`/course/${courseId}/course-end`); history.push(`/c/${courseId}/course-end`);
}; };
const defaultContent = ( const defaultContent = (

View File

@@ -89,9 +89,10 @@ function Unit({
/** [MM-P2P] Experiment */ /** [MM-P2P] Experiment */
mmp2p, mmp2p,
}) { }) {
const unit = useModel('units', id);
const { authenticatedUser } = useContext(AppContext); const { authenticatedUser } = useContext(AppContext);
const view = authenticatedUser ? 'student_view' : 'public_view'; 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) { if (format) {
iframeUrl += `&format=${format}`; iframeUrl += `&format=${format}`;
} }
@@ -101,7 +102,6 @@ function Unit({
const [modalOptions, setModalOptions] = useState({ open: false }); const [modalOptions, setModalOptions] = useState({ open: false });
const [shouldDisplayHonorCode, setShouldDisplayHonorCode] = useState(false); const [shouldDisplayHonorCode, setShouldDisplayHonorCode] = useState(false);
const unit = useModel('units', id);
const course = useModel('coursewareMeta', courseId); const course = useModel('coursewareMeta', courseId);
const { const {
contentTypeGatingEnabled, contentTypeGatingEnabled,

View File

@@ -44,6 +44,7 @@ describe('Unit', () => {
id: unit.id, id: unit.id,
courseId: courseMetadata.id, courseId: courseMetadata.id,
format: 'Homework', format: 'Homework',
decoded_id: unit.decoded_id,
}; };
}); });
@@ -53,7 +54,7 @@ describe('Unit', () => {
const renderedUnit = screen.getByTitle(unit.display_name); const renderedUnit = screen.getByTitle(unit.display_name);
expect(renderedUnit).toHaveAttribute('height', String(0)); expect(renderedUnit).toHaveAttribute('height', String(0));
expect(renderedUnit).toHaveAttribute( 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, intl, courseId, prereqSectionName, prereqId, sequenceTitle,
}) { }) {
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
history.push(`/course/${courseId}/${prereqId}`); history.push(`/c/${courseId}/${prereqId}`);
}); });
return ( return (

View File

@@ -38,6 +38,6 @@ describe('Content Lock', () => {
render(<ContentLock {...mockData} />); render(<ContentLock {...mockData} />);
fireEvent.click(screen.getByRole('button')); 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 siteName = getConfig().SITE_NAME;
const honorCodeUrl = `${process.env.TERMS_OF_SERVICE_URL}#honor-code`; 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 = () => { const handleAgree = () => {
dispatch(saveIntegritySignature(courseId)); dispatch(saveIntegritySignature(courseId));

View File

@@ -28,6 +28,6 @@ describe('Honor Code', () => {
const cancelButton = screen.getByText('Cancel'); const cancelButton = screen.getByText('Cancel');
fireEvent.click(cancelButton); 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_special_exams_enabled: false,
is_mfe_proctored_exams_enabled: false, is_mfe_proctored_exams_enabled: false,
recommendations: null, recommendations: null,
mfe_short_url_is_active: true,
}); });

View File

@@ -36,6 +36,9 @@ Factory.define('sequenceMetadata')
prereq_section_name: `${sequenceBlock.display_name}-prereq`, prereq_section_name: `${sequenceBlock.display_name}-prereq`,
gated_section_name: sequenceBlock.display_name, 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( .attr('items', ['unitBlocks', 'sequenceBlock'], (unitBlocks, sequenceBlock) => unitBlocks.map(
unitBlock => ({ unitBlock => ({
href: '', href: '',
@@ -44,10 +47,12 @@ Factory.define('sequenceMetadata')
bookmarked: unitBlock.bookmarked || false, bookmarked: unitBlock.bookmarked || false,
path: `Chapter Display Name > ${sequenceBlock.display_name} > ${unitBlock.display_name}`, path: `Chapter Display Name > ${sequenceBlock.display_name} > ${unitBlock.display_name}`,
type: unitBlock.type, type: unitBlock.type,
hash_key: unitBlock.hash_key,
complete: unitBlock.complete || null, complete: unitBlock.complete || null,
content: '', content: '',
page_title: unitBlock.display_name, page_title: unitBlock.display_name,
contains_content_type_gated_content: unitBlock.contains_content_type_gated_content, contains_content_type_gated_content: unitBlock.contains_content_type_gated_content,
decoded_id: unitBlock.decoded_id,
}), }),
)) ))
.attrs({ .attrs({

View File

@@ -38,6 +38,7 @@ export function normalizeBlocks(courseId, blocks) {
title: block.display_name, title: block.display_name,
legacyWebUrl: block.legacy_web_url, legacyWebUrl: block.legacy_web_url,
unitIds: block.children || [], unitIds: block.children || [],
hash_key: block.hash_key,
}; };
break; break;
case 'vertical': case 'vertical':
@@ -46,6 +47,7 @@ export function normalizeBlocks(courseId, blocks) {
id: block.id, id: block.id,
title: block.display_name, title: block.display_name,
legacyWebUrl: block.legacy_web_url, legacyWebUrl: block.legacy_web_url,
hash_key: block.hash_key,
}; };
break; break;
default: default:
@@ -87,7 +89,6 @@ export function normalizeBlocks(courseId, blocks) {
}); });
} }
}); });
return models; return models;
} }
@@ -222,6 +223,7 @@ function normalizeMetadata(metadata) {
specialExamsEnabledWaffleFlag: data.is_mfe_special_exams_enabled, specialExamsEnabledWaffleFlag: data.is_mfe_special_exams_enabled,
proctoredExamsEnabledWaffleFlag: data.is_mfe_proctored_exams_enabled, proctoredExamsEnabledWaffleFlag: data.is_mfe_proctored_exams_enabled,
isMasquerading: data.original_user_is_staff && !data.is_staff, 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, saveUnitPosition: sequence.save_position,
showCompletion: sequence.show_completion, showCompletion: sequence.show_completion,
allowProctoringOptOut: sequence.allow_proctoring_opt_out, allowProctoringOptOut: sequence.allow_proctoring_opt_out,
hash_key: sequence.hash_key,
decoded_id: sequence.decoded_id,
}, },
units: sequence.items.map(unit => ({ units: sequence.items.map(unit => ({
id: unit.id, id: unit.id,
@@ -268,14 +272,14 @@ function normalizeSequenceMetadata(sequence) {
contentType: unit.type, contentType: unit.type,
graded: unit.graded, graded: unit.graded,
containsContentTypeGatedContent: unit.contains_content_type_gated_content, containsContentTypeGatedContent: unit.contains_content_type_gated_content,
decoded_id: unit.decoded_id,
hash_key: unit.hash_key,
})), })),
}; };
} }
export async function getSequenceMetadata(sequenceId) { export async function getSequenceMetadata(sequenceId) {
const { data } = await getAuthenticatedHttpClient() const { data } = await getAuthenticatedHttpClient()
.get(`${getConfig().LMS_BASE_URL}/api/courseware/sequence/${sequenceId}`, {}); .get(`${getConfig().LMS_BASE_URL}/api/courseware/sequence/${sequenceId}`, {});
return normalizeSequenceMetadata(data); return normalizeSequenceMetadata(data);
} }

View File

@@ -254,8 +254,7 @@ describe('Data layer integration tests', () => {
}); });
describe('Test checkBlockCompletion', () => { 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 () => { it('Should fail to check completion and log error', async () => {
axiosMock.onPost(getCompletionURL).networkError(); axiosMock.onPost(getCompletionURL).networkError();
@@ -283,7 +282,7 @@ describe('Data layer integration tests', () => {
}); });
describe('Test saveSequencePosition', () => { 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 () => { it('Should change and revert sequence model activeUnitIndex in case of error', async () => {
axiosMock.onPost(gotoPositionURL).networkError(); axiosMock.onPost(gotoPositionURL).networkError();

View File

@@ -15,6 +15,7 @@ const slice = createSlice({
sequenceId: null, sequenceId: null,
specialExamsEnabledWaffleFlag: false, specialExamsEnabledWaffleFlag: false,
proctoredExamsEnabledWaffleFlag: false, proctoredExamsEnabledWaffleFlag: false,
shortLinkFeatureFlag: false,
}, },
reducers: { reducers: {
setsSpecialExamsEnabled: (state, { payload }) => { setsSpecialExamsEnabled: (state, { payload }) => {
@@ -23,6 +24,9 @@ const slice = createSlice({
setsProctoredExamsEnabled: (state, { payload }) => { setsProctoredExamsEnabled: (state, { payload }) => {
state.proctoredExamsEnabledWaffleFlag = payload.proctoredExamsEnabledWaffleFlag; state.proctoredExamsEnabledWaffleFlag = payload.proctoredExamsEnabledWaffleFlag;
}, },
setsShortLinkFeatureFlag: (state, { payload }) => {
state.shortLinkFeatureFlag = payload.shortLinkFeatureFlag;
},
fetchCourseRequest: (state, { payload }) => { fetchCourseRequest: (state, { payload }) => {
state.courseId = payload.courseId; state.courseId = payload.courseId;
state.courseStatus = LOADING; state.courseStatus = LOADING;
@@ -57,6 +61,7 @@ const slice = createSlice({
export const { export const {
setsSpecialExamsEnabled, setsSpecialExamsEnabled,
setsProctoredExamsEnabled, setsProctoredExamsEnabled,
setsShortLinkFeatureFlag,
fetchCourseRequest, fetchCourseRequest,
fetchCourseSuccess, fetchCourseSuccess,
fetchCourseFailure, fetchCourseFailure,

View File

@@ -14,6 +14,7 @@ import {
import { import {
setsSpecialExamsEnabled, setsSpecialExamsEnabled,
setsProctoredExamsEnabled, setsProctoredExamsEnabled,
setsShortLinkFeatureFlag,
fetchCourseRequest, fetchCourseRequest,
fetchCourseSuccess, fetchCourseSuccess,
fetchCourseFailure, fetchCourseFailure,
@@ -100,6 +101,7 @@ function mergeLearningSequencesWithCourseBlocks(learningSequencesModels, courseB
effortActivities: blocksSequence.effortActivities, effortActivities: blocksSequence.effortActivities,
effortTime: blocksSequence.effortTime, effortTime: blocksSequence.effortTime,
legacyWebUrl: blocksSequence.legacyWebUrl, legacyWebUrl: blocksSequence.legacyWebUrl,
hash_key: blocksSequence.hash_key,
unitIds: blocksSequence.unitIds, unitIds: blocksSequence.unitIds,
}; };
@@ -151,6 +153,9 @@ export function fetchCourse(courseId) {
dispatch(setsProctoredExamsEnabled({ dispatch(setsProctoredExamsEnabled({
proctoredExamsEnabledWaffleFlag: courseMetadataResult.value.proctoredExamsEnabledWaffleFlag, proctoredExamsEnabledWaffleFlag: courseMetadataResult.value.proctoredExamsEnabledWaffleFlag,
})); }));
dispatch(setsShortLinkFeatureFlag({
shortLinkFeatureFlag: courseMetadataResult.value.shortLinkFeatureFlag,
}));
} }
if (courseBlocksResult.status === 'fulfilled') { if (courseBlocksResult.status === 'fulfilled') {
@@ -176,10 +181,18 @@ export function fetchCourse(courseId) {
modelType: 'sequences', modelType: 'sequences',
modelsMap: sequences, modelsMap: sequences,
})); }));
dispatch(addModelsMap({
modelType: 'sequenceIdToHashKeyMap',
modelsMap: sequences,
}));
dispatch(updateModelsMap({ dispatch(updateModelsMap({
modelType: 'units', modelType: 'units',
modelsMap: units, modelsMap: units,
})); }));
dispatch(addModelsMap({
modelType: 'unitIdToHashKeyMap',
modelsMap: units,
}));
} }
const fetchedMetadata = courseMetadataResult.status === 'fulfilled'; const fetchedMetadata = courseMetadataResult.status === 'fulfilled';
@@ -231,10 +244,18 @@ export function fetchSequence(sequenceId) {
modelType: 'sequences', modelType: 'sequences',
model: sequence, model: sequence,
})); }));
dispatch(updateModel({
modelType: 'sequenceIdToHashKeyMap',
model: sequence,
}));
dispatch(updateModels({ dispatch(updateModels({
modelType: 'units', modelType: 'units',
models: units, models: units,
})); }));
dispatch(updateModels({
modelType: 'unitIdToHashKeyMap',
models: units,
}));
dispatch(fetchSequenceSuccess({ sequenceId })); dispatch(fetchSequenceSuccess({ sequenceId }));
} }
} catch (error) { } catch (error) {
@@ -247,16 +268,24 @@ export function fetchSequence(sequenceId) {
export function checkBlockCompletion(courseId, sequenceId, unitId) { export function checkBlockCompletion(courseId, sequenceId, unitId) {
return async (dispatch, getState) => { return async (dispatch, getState) => {
const { models } = 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. return; // do nothing. Things don't get uncompleted after they are completed.
} }
try { try {
const isComplete = await getBlockCompletion(courseId, sequenceId, unitId); const isComplete = await getBlockCompletion(courseId, modelsSequenceId, modelsUnitId);
dispatch(updateModel({ dispatch(updateModel({
modelType: 'units', modelType: 'units',
model: { model: {
id: unitId, id: modelsUnitId,
complete: isComplete, complete: isComplete,
}, },
})); }));
@@ -269,23 +298,27 @@ export function checkBlockCompletion(courseId, sequenceId, unitId) {
export function saveSequencePosition(courseId, sequenceId, activeUnitIndex) { export function saveSequencePosition(courseId, sequenceId, activeUnitIndex) {
return async (dispatch, getState) => { return async (dispatch, getState) => {
const { models } = 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. // Optimistically update the position.
dispatch(updateModel({ dispatch(updateModel({
modelType: 'sequences', modelType: 'sequences',
model: { model: {
id: sequenceId, id: modelsSequenceId,
activeUnitIndex, activeUnitIndex,
}, },
})); }));
try { 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 // Update again under the assumption that the above call succeeded, since it doesn't return a
// meaningful response. // meaningful response.
dispatch(updateModel({ dispatch(updateModel({
modelType: 'sequences', modelType: 'sequences',
model: { model: {
id: sequenceId, id: modelsSequenceId,
activeUnitIndex, activeUnitIndex,
}, },
})); }));
@@ -294,7 +327,7 @@ export function saveSequencePosition(courseId, sequenceId, activeUnitIndex) {
dispatch(updateModel({ dispatch(updateModel({
modelType: 'sequences', modelType: 'sequences',
model: { model: {
id: sequenceId, id: modelsSequenceId,
activeUnitIndex: initialActiveUnitIndex, activeUnitIndex: initialActiveUnitIndex,
}, },
})); }));

View File

@@ -6,6 +6,9 @@ function add(state, modelType, model) {
if (state[modelType] === undefined) { if (state[modelType] === undefined) {
state[modelType] = {}; state[modelType] = {};
} }
if (modelType.includes('IdToHashKeyMap')) {
state[modelType][model.hash_key] = id;
}
state[modelType][id] = model; state[modelType][id] = model;
} }
@@ -13,6 +16,9 @@ function update(state, modelType, model) {
if (state[modelType] === undefined) { if (state[modelType] === undefined) {
state[modelType] = {}; state[modelType] = {};
} }
if (modelType.includes('IdToHashKeyMap')) {
state[modelType][model.hash_key] = model.id;
}
state[modelType][model.id] = { ...state[modelType][model.id], ...model }; state[modelType][model.id] = { ...state[modelType][model.id], ...model };
} }

View File

@@ -34,20 +34,21 @@ subscribe(APP_READY, () => {
<UserMessagesProvider> <UserMessagesProvider>
<Switch> <Switch>
<PageRoute path="/redirect" component={CoursewareRedirectLandingPage} /> <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"> <TabContainer tab="outline" fetch={fetchOutlineTab} slice="courseHome">
<OutlineTab /> <OutlineTab />
</TabContainer> </TabContainer>
</PageRoute> </PageRoute>
<PageRoute path="/course/:courseId/dates"> <PageRoute path="/c/:courseId/dates">
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome"> <TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
<DatesTab /> <DatesTab />
</TabContainer> </TabContainer>
</PageRoute> </PageRoute>
<PageRoute <PageRoute
path={[ path={[
'/course/:courseId/progress/:targetUserId/', '/c/:courseId/progress/:targetUserId/',
'/course/:courseId/progress', '/c/:courseId/progress',
]} ]}
render={({ match }) => ( render={({ match }) => (
<TabContainer <TabContainer
@@ -59,16 +60,16 @@ subscribe(APP_READY, () => {
</TabContainer> </TabContainer>
)} )}
/> />
<PageRoute path="/course/:courseId/course-end"> <PageRoute path="/c/:courseId/course-end">
<TabContainer tab="courseware" fetch={fetchCourse} slice="courseware"> <TabContainer tab="courseware" fetch={fetchCourse} slice="courseware">
<CourseExit /> <CourseExit />
</TabContainer> </TabContainer>
</PageRoute> </PageRoute>
<PageRoute <PageRoute
path={[ path={[
'/course/:courseId/:sequenceId/:unitId', '/c/:courseId/:sequenceId/:unitId',
'/course/:courseId/:sequenceId', '/c/:courseId/:sequenceId',
'/course/:courseId', '/c/:courseId',
]} ]}
component={CoursewareContainer} component={CoursewareContainer}
/> />

View File

@@ -22,6 +22,13 @@ Factory.define('block')
return blockId; return blockId;
}) })
.attr(
'hash_key', ['block_id'],
(blockId) => {
const len = blockId.length;
return blockId.substring(23, len);
},
)
.attr( .attr(
'id', 'id',
['id', 'block_id', 'type', 'courseId'], ['id', 'block_id', 'type', 'courseId'],
@@ -35,25 +42,33 @@ Factory.define('block')
return `block-v1:${courseInfo}+type@${type}+block@${blockId}`; 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( .attr(
'student_view_url', 'student_view_url',
['student_view_url', 'host', 'id'], ['student_view_url', 'host', 'decoded_id'],
(url, host, id) => { (url, host, decodedId) => {
if (url) { if (url) {
return url; return url;
} }
return `${host}/xblock/${id}`; return `${host}/xblock/${decodedId}`;
}, },
) )
.attr( .attr(
'legacy_web_url', 'legacy_web_url',
['legacy_web_url', 'host', 'courseId', 'id'], ['legacy_web_url', 'host', 'courseId', 'decoded_id'],
(url, host, courseId, id) => { (url, host, courseId, decodedId) => {
if (url) { if (url) {
return 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', () => { it('renders correctly', () => {
history.push(`/course/${courseId}`); history.push(`/c/${courseId}`);
render( render(
<Route path="/course/:courseId"> <Route path="/c/:courseId">
<TabContainer {...mockData} /> <TabContainer {...mockData} />
</Route>, </Route>,
); );
@@ -48,11 +48,11 @@ describe('Tab Container', () => {
it('Should handle passing in a targetUserId', () => { it('Should handle passing in a targetUserId', () => {
const targetUserId = '1'; const targetUserId = '1';
history.push(`/course/${courseId}/progress/${targetUserId}/`); history.push(`/c/${courseId}/progress/${targetUserId}/`);
render( render(
<Route <Route
path="/course/:courseId/progress/:targetUserId/" path="/c/:courseId/progress/:targetUserId/"
render={({ match }) => ( render={({ match }) => (
<TabContainer <TabContainer
fetch={() => mockFetch(match.params.courseId, match.params.targetUserId)} fetch={() => mockFetch(match.params.courseId, match.params.targetUserId)}