Compare commits

...

9 Commits

Author SHA1 Message Date
Simon Chen
0834baee05 feat: add courseware search feature to course home 2023-04-11 11:07:43 -04:00
alangsto
d897663b73 feat: upgrade special exams version and add required config values (#1097) 2023-04-06 09:55:06 -04:00
Muhammad Adeel Tajamul
2e4eb158f2 feat: added url param to open discussion sidebar (#1092) 2023-04-04 13:33:35 +05:00
Jenkins
35b229bd1b chore(i18n): update translations 2023-04-02 17:09:43 -04:00
Muhammad Adeel Tajamul
4ebd569792 feat: added open/close state of discussion sidebar in local storage (#1086) 2023-03-28 15:39:00 +05:00
lunyachek
52235ebc1c feat: create component to decode params 2023-03-27 14:54:42 -04:00
Jenkins
aa380e8619 chore(i18n): update translations 2023-03-26 17:09:41 -04:00
lunyachek
4cf0c7f4d7 feat: Add border for active tab in course navigation at Live page 2023-03-22 10:36:33 -04:00
alangsto
743650a99e chore: pin frontend lib special exams version (#1088) 2023-03-17 13:35:26 -04:00
20 changed files with 443 additions and 40 deletions

2
.env
View File

@@ -29,6 +29,8 @@ LOGO_WHITE_URL=''
LEGACY_THEME_NAME=''
MARKETING_SITE_BASE_URL=''
ORDER_HISTORY_URL=''
PROCTORED_EXAM_FAQ_URL=''
PROCTORED_EXAM_RULES_URL=''
REFRESH_ACCESS_TOKEN_ENDPOINT=''
SEARCH_CATALOG_URL=''
SEGMENT_KEY=''

View File

@@ -29,6 +29,8 @@ LEGACY_THEME_NAME=''
MARKETING_SITE_BASE_URL='http://localhost:18000'
ORDER_HISTORY_URL='http://localhost:1996/orders'
PORT=2000
PROCTORED_EXAM_FAQ_URL=''
PROCTORED_EXAM_RULES_URL=''
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEARCH_CATALOG_URL='http://localhost:18000/courses'
SEGMENT_KEY=''

View File

@@ -29,6 +29,8 @@ LEGACY_THEME_NAME=''
MARKETING_SITE_BASE_URL='http://localhost:18000'
ORDER_HISTORY_URL='http://localhost:1996/orders'
PORT=2000
PROCTORED_EXAM_FAQ_URL=''
PROCTORED_EXAM_RULES_URL=''
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEARCH_CATALOG_URL='http://localhost:18000/courses'
SEGMENT_KEY=''

33
package-lock.json generated
View File

@@ -12,7 +12,7 @@
"@edx/brand": "npm:@edx/brand-openedx@1.2.0",
"@edx/frontend-component-footer": "11.6.3",
"@edx/frontend-component-header": "3.6.4",
"@edx/frontend-lib-special-exams": "2.4.0",
"@edx/frontend-lib-special-exams": "~2.8.0",
"@edx/frontend-platform": "3.4.1",
"@edx/paragon": "20.28.4",
"@fortawesome/fontawesome-svg-core": "1.3.0",
@@ -3272,9 +3272,9 @@
}
},
"node_modules/@edx/frontend-lib-special-exams": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@edx/frontend-lib-special-exams/-/frontend-lib-special-exams-2.4.0.tgz",
"integrity": "sha512-p1O3K/jEausWckVX+Gp2OAZ2xT2uSWy5/J+St9bqCUXvoAO6UjQHINtzOjqw76PKX8WwYfioUOul2ejq3WUG9w==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@edx/frontend-lib-special-exams/-/frontend-lib-special-exams-2.8.0.tgz",
"integrity": "sha512-a3YqQVlYvjp5msOeFSD2bgPMEcxsesHpfrFs0VmxKJj4GYT7dt8k9qgVRblryKuFb7v6UFr/VhF3uJQhnTZFQg==",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "1.2.34",
"@fortawesome/free-brands-svg-icons": "5.11.2",
@@ -6853,6 +6853,8 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.2.tgz",
"integrity": "sha512-E4bfmKAhGiSTvMfL1Myyycaub+cUEU2/IvpylXkUu7CHBkBj1f/ikdzbD7YQ6FKUbixDxeYvB/xY4fvyroDlQg==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0",
@@ -6868,7 +6870,9 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true
"dev": true,
"optional": true,
"peer": true
},
"node_modules/ajv-keywords": {
"version": "3.5.2",
@@ -28482,9 +28486,9 @@
}
},
"@edx/frontend-lib-special-exams": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@edx/frontend-lib-special-exams/-/frontend-lib-special-exams-2.4.0.tgz",
"integrity": "sha512-p1O3K/jEausWckVX+Gp2OAZ2xT2uSWy5/J+St9bqCUXvoAO6UjQHINtzOjqw76PKX8WwYfioUOul2ejq3WUG9w==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@edx/frontend-lib-special-exams/-/frontend-lib-special-exams-2.8.0.tgz",
"integrity": "sha512-a3YqQVlYvjp5msOeFSD2bgPMEcxsesHpfrFs0VmxKJj4GYT7dt8k9qgVRblryKuFb7v6UFr/VhF3uJQhnTZFQg==",
"requires": {
"@fortawesome/fontawesome-svg-core": "1.2.34",
"@fortawesome/free-brands-svg-icons": "5.11.2",
@@ -31345,15 +31349,14 @@
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"dev": true,
"requires": {
"ajv": "^8.0.0"
},
"requires": {},
"dependencies": {
"ajv": {
"version": "8.11.2",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.2.tgz",
"version": "https://registry.npmjs.org/ajv/-/ajv-8.11.2.tgz",
"integrity": "sha512-E4bfmKAhGiSTvMfL1Myyycaub+cUEU2/IvpylXkUu7CHBkBj1f/ikdzbD7YQ6FKUbixDxeYvB/xY4fvyroDlQg==",
"dev": true,
"optional": true,
"peer": true,
"requires": {
"fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0",
@@ -31365,7 +31368,9 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true
"dev": true,
"optional": true,
"peer": true
}
}
},

View File

@@ -32,7 +32,7 @@
"@edx/brand": "npm:@edx/brand-openedx@1.2.0",
"@edx/frontend-component-footer": "11.6.3",
"@edx/frontend-component-header": "3.6.4",
"@edx/frontend-lib-special-exams": "2.4.0",
"@edx/frontend-lib-special-exams": "~2.8.0",
"@edx/frontend-platform": "3.4.1",
"@edx/paragon": "20.28.4",
"@fortawesome/fontawesome-svg-core": "1.3.0",

View File

@@ -420,3 +420,10 @@ export async function unsubscribeFromCourseGoal(token) {
return getAuthenticatedHttpClient().post(url.href)
.then(res => camelCaseObject(res));
}
export async function searchCourseContentFromAPI(courseId, searchKeyword) {
const url = new URL(`${getConfig().LMS_BASE_URL}/search/${courseId}`);
const formData = `search_string=${searchKeyword}&page_size=20&page_index=0`;
return getAuthenticatedHttpClient().post(url.href, formData)
.then(res => camelCaseObject(res));
}

View File

@@ -12,6 +12,7 @@ import {
postDismissWelcomeMessage,
postRequestCert,
getLiveTabIframe,
searchCourseContentFromAPI,
} from './api';
import {
@@ -139,3 +140,18 @@ export function processEvent(eventData, getTabData) {
}
};
}
export function searchCourseContent(courseId, searchKeyword) {
return async (dispatch) => {
searchCourseContentFromAPI(courseId, searchKeyword).then(response => {
const { data } = response;
dispatch(addModel({
modelType: 'contentSearchResults',
model: {
id: courseId,
...data,
},
}));
});
};
}

View File

@@ -28,6 +28,7 @@ import { useModel } from '../../generic/model-store';
import WelcomeMessage from './widgets/WelcomeMessage';
import ProctoringInfoPanel from './widgets/ProctoringInfoPanel';
import AccountActivationAlert from '../../alerts/logistration-alert/AccountActivationAlert';
import CoursewareSearch from './widgets/CoursewareSearch';
const OutlineTab = ({ intl }) => {
const {
@@ -157,6 +158,7 @@ const OutlineTab = ({ intl }) => {
)}
<StartOrResumeCourseCard />
<WelcomeMessage courseId={courseId} />
<CoursewareSearch courseId={courseId} />
{rootCourseId && (
<>
<div className="row w-100 m-0 mb-3 justify-content-end">

View File

@@ -331,6 +331,16 @@ const messages = defineMessages({
defaultMessage: 'Onboarding Past Due',
description: 'Text that show when the deadline of proctortrack onboarding exam has passed, it appears on button that start the onboarding exam however for this case the button is disabled for obvious reason',
},
coursewareSearchInputLabel: {
id: 'learning.coursewareSearch.inputLabel',
defaultMessage: 'Course Content Search',
description: 'Search input label',
},
coursewareSearchButtonLabel: {
id: 'learning.coursewareSearch.buttonLabel',
defaultMessage: 'Search',
description: 'Search button label',
},
});
export default messages;

View File

@@ -0,0 +1,109 @@
import React, { useEffect, useState } from 'react';
import {
Button,
Form,
Container,
Hyperlink,
Layout,
} from '@edx/paragon';
import { useDispatch } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import { getConfig } from '@edx/frontend-platform';
import messages from '../messages';
import { useModel, updateModel } from '../../../generic/model-store';
import { searchCourseContent } from '../../data/thunks';
const CoursewareSearch = ({ courseId, intl }) => {
const {
org,
} = useModel('courseHomeMeta', courseId);
const eventProperties = {
org_key: org,
courserun_key: courseId,
};
const dispatch = useDispatch();
const {
results,
took,
} = useModel('contentSearchResults', courseId);
const [searchKeyword, setSearchKeyword] = useState('');
useEffect(() => {
if (!searchKeyword) {
dispatch(updateModel({
modelType: 'contentSearchResults',
model: {
id: courseId,
results: [],
took: false,
},
}));
}
}, [searchKeyword]);
const searchClick = () => {
sendTrackingLogEvent('edx.course.home.courseware_search.clicked', {
...eventProperties,
event_type: 'search',
keyword: searchKeyword,
});
dispatch(searchCourseContent(courseId, searchKeyword));
};
return (
<div>
<Form.Group>
<Form.Control
className="float-left w-75"
floatingLabel={intl.formatMessage(messages.coursewareSearchInputLabel)}
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
/>
<Button
variant="primary"
className="float-left"
onClick={() => searchClick()}
>
{intl.formatMessage(messages.coursewareSearchButtonLabel)}
</Button>
<div className="clearfix" />
</Form.Group>
{(took && results.length === 0) && (
<Container size="xl">
{
`Could not find any component matching "${searchKeyword}"`
}
</Container>
)}
{(took && results.length > 0) && results.map(resultItem => (
<Container
size="xl"
>
<Layout>
<Layout.Element>
<Hyperlink
destination={`${getConfig().LMS_BASE_URL}${resultItem.data.url}`}
>
{ resultItem.data.location.join('/') }
</Hyperlink>
</Layout.Element>
</Layout>
</Container>
))}
</div>
);
};
CoursewareSearch.propTypes = {
courseId: PropTypes.string.isRequired,
intl: intlShape.isRequired,
};
export default injectIntl(CoursewareSearch);

View File

@@ -7,6 +7,8 @@ import { PageRoute } from '@edx/frontend-platform/react';
import queryString from 'query-string';
import PageLoading from '../generic/PageLoading';
import DecodePageRoute from '../decode-page-route';
const CoursewareRedirectLandingPage = () => {
const { path } = useRouteMatch();
return (
@@ -21,7 +23,7 @@ const CoursewareRedirectLandingPage = () => {
/>
<Switch>
<PageRoute
<DecodePageRoute
path={`${path}/survey/:courseId`}
render={({ match }) => {
global.location.assign(`${getConfig().LMS_BASE_URL}/courses/${match.params.courseId}/survey`);
@@ -40,7 +42,7 @@ const CoursewareRedirectLandingPage = () => {
global.location.assign(`${getConfig().LMS_BASE_URL}${consentPath}`);
}}
/>
<PageRoute
<DecodePageRoute
path={`${path}/home/:courseId`}
render={({ match }) => {
global.location.assign(`/course/${match.params.courseId}/home`);

View File

@@ -1,12 +1,18 @@
import React from 'react';
import { Factory } from 'rosie';
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import MockAdapter from 'axios-mock-adapter';
import { breakpoints } from '@edx/paragon';
import {
fireEvent, getByRole, initializeTestStore, loadUnit, render, screen, waitFor,
act, fireEvent, getByRole, initializeTestStore, loadUnit, render, screen, waitFor,
} from '../../setupTest';
import { buildTopicsFromUnits } from '../data/__factories__/discussionTopics.factory';
import { handleNextSectionCelebration } from './celebration';
import * as celebrationUtils from './celebration/utils';
import Course from './Course';
import { executeThunk } from '../../utils';
import * as thunks from '../data/thunks';
jest.mock('@edx/frontend-platform/analytics');
@@ -43,6 +49,28 @@ describe('Course', () => {
setItemSpy.mockRestore();
});
const setupDiscussionSidebar = async (storageValue = false) => {
localStorage.clear();
const testStore = await initializeTestStore({ provider: 'openedx' });
const state = testStore.getState();
const { courseware: { courseId } } = state;
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/discussion/v1/courses/${courseId}`).reply(200, { provider: 'openedx' });
const topicsResponse = buildTopicsFromUnits(state.models.units);
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/discussion/v2/course_topics/${courseId}`)
.reply(200, topicsResponse);
await executeThunk(thunks.getCourseDiscussionTopics(courseId), testStore.dispatch);
const [firstUnitId] = Object.keys(state.models.units);
mockData.unitId = firstUnitId;
const [firstSequenceId] = Object.keys(state.models.sequences);
mockData.sequenceId = firstSequenceId;
if (storageValue !== null) {
localStorage.setItem('showDiscussionSidebar', storageValue);
}
await render(<Course {...mockData} />, { store: testStore });
};
it('loads learning sequence', async () => {
render(<Course {...mockData} />);
expect(screen.getByRole('navigation', { name: 'breadcrumb' })).toBeInTheDocument();
@@ -103,6 +131,7 @@ describe('Course', () => {
});
it('displays notification trigger and toggles active class on click', async () => {
localStorage.setItem('showDiscussionSidebar', false);
render(<Course {...mockData} />);
const notificationTrigger = screen.getByRole('button', { name: /Show notification tray/i });
@@ -114,6 +143,7 @@ describe('Course', () => {
it('handles click to open/close notification tray', async () => {
sessionStorage.clear();
localStorage.setItem('showDiscussionSidebar', false);
render(<Course {...mockData} />);
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"open"');
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
@@ -144,6 +174,7 @@ describe('Course', () => {
it('handles sessionStorage from a different course for the notification tray', async () => {
sessionStorage.clear();
localStorage.setItem('showDiscussionSidebar', false);
const courseMetadataSecondCourse = Factory.build('courseMetadata', { id: 'second_course' });
// set sessionStorage for a different course before rendering Course
@@ -186,6 +217,34 @@ describe('Course', () => {
expect(screen.getByText(Object.values(models.sequences)[0].title)).toBeInTheDocument();
});
[
{ value: true, visible: true },
{ value: false, visible: false },
{ value: null, visible: true },
].forEach(async ({ value, visible }) => (
it(`discussion sidebar is ${visible ? 'shown' : 'hidden'} when localstorage value is ${value}`, async () => {
await setupDiscussionSidebar(value);
const element = await waitFor(() => screen.findByTestId('sidebar-DISCUSSIONS'));
if (visible) {
expect(element).not.toHaveClass('d-none');
} else {
expect(element).toHaveClass('d-none');
}
})));
[
{ value: true, result: 'false' },
{ value: false, result: 'true' },
].forEach(async ({ value, result }) => (
it(`Discussion sidebar storage value is ${!value} when sidebar is ${value ? 'closed' : 'open'}`, async () => {
await setupDiscussionSidebar(value);
await act(async () => {
const button = await screen.queryByRole('button', { name: /Show discussions tray/i });
button.click();
});
expect(localStorage.getItem('showDiscussionSidebar')).toBe(result);
})));
it('passes handlers to the sequence', async () => {
const nextSequenceHandler = jest.fn();
const previousSequenceHandler = jest.fn();

View File

@@ -19,9 +19,17 @@ const SidebarProvider = ({
const shouldDisplayFullScreen = useWindowSize().width < breakpoints.large.minWidth;
const shouldDisplaySidebarOpen = useWindowSize().width > breakpoints.medium.minWidth;
const showNotificationsOnLoad = getSessionStorage(`notificationTrayStatus.${courseId}`) !== 'closed';
const initialSidebar = (verifiedMode && shouldDisplaySidebarOpen && showNotificationsOnLoad)
const query = new URLSearchParams(window.location.search);
if (query.get('sidebar') === 'true') {
localStorage.setItem('showDiscussionSidebar', true);
}
const showDiscussionSidebar = localStorage.getItem('showDiscussionSidebar') !== 'false';
const showNotificationSidebar = (verifiedMode && shouldDisplaySidebarOpen && showNotificationsOnLoad)
? SIDEBARS.NOTIFICATIONS.ID
: null;
const initialSidebar = showDiscussionSidebar
? SIDEBARS.DISCUSSIONS.ID
: showNotificationSidebar;
const [currentSidebar, setCurrentSidebar] = useState(initialSidebar);
const [notificationStatus, setNotificationStatus] = useState(getLocalStorage(`notificationStatus.${courseId}`));
const [upgradeNotificationCurrentState, setUpgradeNotificationCurrentState] = useState(getLocalStorage(`upgradeNotificationCurrentState.${courseId}`));
@@ -41,6 +49,11 @@ const SidebarProvider = ({
const toggleSidebar = useCallback((sidebarId) => {
// Switch to new sidebar or hide the current sidebar
if (currentSidebar === SIDEBARS.DISCUSSIONS.ID) {
localStorage.setItem('showDiscussionSidebar', false);
} else if (sidebarId === SIDEBARS.DISCUSSIONS.ID) {
localStorage.setItem('showDiscussionSidebar', true);
}
setCurrentSidebar(sidebarId === currentSidebar ? null : sidebarId);
}, [currentSidebar]);

View File

@@ -41,6 +41,7 @@ const SidebarBase = ({
'min-vh-100': !shouldDisplayFullScreen,
'd-none': currentSidebar !== sidebarId,
}, className)}
data-testid={`sidebar-${sidebarId}`}
style={{ width: shouldDisplayFullScreen ? '100%' : width }}
aria-label={ariaLabel}
>

View File

@@ -0,0 +1,16 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DecodePageRoute should not modify the url if it does not need to be decoded 1`] = `
<div>
PageRoute: {
"computedMatch": {
"path": "/course/:courseId/home",
"url": "/course/course-v1:edX+DemoX+Demo_Course/home",
"isExact": true,
"params": {
"courseId": "course-v1:edX+DemoX+Demo_Course"
}
}
}
</div>
`;

View File

@@ -0,0 +1,49 @@
import PropTypes from 'prop-types';
import { PageRoute } from '@edx/frontend-platform/react';
import React from 'react';
import { useHistory, generatePath } from 'react-router';
export const decodeUrl = (encodedUrl) => {
const decodedUrl = decodeURIComponent(encodedUrl);
if (encodedUrl === decodedUrl) {
return encodedUrl;
}
return decodeUrl(decodedUrl);
};
const DecodePageRoute = (props) => {
const history = useHistory();
if (props.computedMatch) {
const { url, path, params } = props.computedMatch;
Object.keys(params).forEach((param) => {
// only decode params not the entire url.
// it is just to be safe and less prone to errors
params[param] = decodeUrl(params[param]);
});
const newUrl = generatePath(path, params);
// if the url get decoded, reroute to the decoded url
if (newUrl !== url) {
history.replace(newUrl);
}
}
return <PageRoute {...props} />;
};
DecodePageRoute.propTypes = {
computedMatch: PropTypes.shape({
url: PropTypes.string.isRequired,
path: PropTypes.string.isRequired,
// eslint-disable-next-line react/forbid-prop-types
params: PropTypes.any,
}),
};
DecodePageRoute.defaultProps = {
computedMatch: null,
};
export default DecodePageRoute;

View File

@@ -0,0 +1,103 @@
import React from 'react';
import { render } from '@testing-library/react';
import { createMemoryHistory } from 'history';
import { Router, matchPath } from 'react-router';
import DecodePageRoute, { decodeUrl } from '.';
const decodedCourseId = 'course-v1:edX+DemoX+Demo_Course';
const encodedCourseId = encodeURIComponent(decodedCourseId);
const deepEncodedCourseId = (() => {
let path = encodedCourseId;
for (let i = 0; i < 5; i++) {
path = encodeURIComponent(path);
}
return path;
})();
jest.mock('@edx/frontend-platform/react', () => ({
PageRoute: (props) => `PageRoute: ${JSON.stringify(props, null, 2)}`,
}));
const renderPage = (props) => {
const memHistory = createMemoryHistory({
initialEntries: [props?.path],
});
const history = {
...memHistory,
replace: jest.fn(),
};
const { container } = render(
<Router history={history}>
<DecodePageRoute computedMatch={props} />
</Router>,
);
return {
container,
history,
props,
};
};
describe('DecodePageRoute', () => {
it('should not modify the url if it does not need to be decoded', () => {
const props = matchPath(`/course/${decodedCourseId}/home`, {
path: '/course/:courseId/home',
});
const { container, history } = renderPage(props);
expect(props.url).toContain(decodedCourseId);
expect(history.replace).not.toHaveBeenCalled();
expect(container).toMatchSnapshot();
});
it('should decode the url and replace the history if necessary', () => {
const props = matchPath(`/course/${encodedCourseId}/home`, {
path: '/course/:courseId/home',
});
const { history } = renderPage(props);
expect(props.url).not.toContain(decodedCourseId);
expect(props.url).toContain(encodedCourseId);
expect(history.replace.mock.calls[0][0]).toContain(decodedCourseId);
});
it('should decode the url multiple times if necessary', () => {
const props = matchPath(`/course/${deepEncodedCourseId}/home`, {
path: '/course/:courseId/home',
});
const { history } = renderPage(props);
expect(props.url).not.toContain(decodedCourseId);
expect(props.url).toContain(deepEncodedCourseId);
expect(history.replace.mock.calls[0][0]).toContain(decodedCourseId);
});
it('should only decode the url params and not the entire url', () => {
const decodedUnitId = 'some+thing';
const encodedUnitId = encodeURIComponent(decodedUnitId);
const props = matchPath(`/course/${deepEncodedCourseId}/${encodedUnitId}/${encodedUnitId}`, {
path: `/course/:courseId/${encodedUnitId}/:unitId`,
});
const { history } = renderPage(props);
const decodedUrls = history.replace.mock.calls[0][0].split('/');
// unitId get decoded
expect(decodedUrls.pop()).toContain(decodedUnitId);
// path remain encoded
expect(decodedUrls.pop()).toContain(encodedUnitId);
// courseId get decoded
expect(decodedUrls.pop()).toContain(decodedCourseId);
});
});
describe('decodeUrl', () => {
expect(decodeUrl(decodedCourseId)).toEqual(decodedCourseId);
expect(decodeUrl(encodedCourseId)).toEqual(decodedCourseId);
expect(decodeUrl(deepEncodedCourseId)).toEqual(decodedCourseId);
});

View File

@@ -117,7 +117,7 @@
"learning.outline.sequence-due-date-set": "{description} échéance {assignmentDue}",
"learning.outline.sequence-due-date-not-set": "{description}",
"progress.certificateStatus.unverifiedBody": "Afin de générer une attestation, vous devez effectuer une vérification d'identité. {idVerificationSupportLink}.",
"progress.certificateStatus.downloadableBody": "Présentez vos réalisations sur LinkedIn ou votre curriculum vitae aujourd'hui. Vous pouvez télécharger votre certificat maintenant et y accéder à tout moment depuis votre tableau de bord et votre profil.",
"progress.certificateStatus.downloadableBody": "Présentez vos réalisations sur LinkedIn ou votre curriculum vitae aujourd'hui. Vous pouvez télécharger votre attestation maintenant et y accéder à tout moment depuis votre tableau de bord et votre profil.",
"courseCelebration.certificateBody.notAvailable.endDate": "Les notes finales et toutes les attestations obtenues devraient être disponibles après le {endDate}.",
"progress.certificateStatus.notPassingHeader": "État de l'attestation",
"progress.certificateStatus.notPassingBody": "Pour être admissible à une attestation, vous devez avoir la note de passage.",
@@ -304,7 +304,7 @@
"courseExit.nextButton.endOfCourse": "Suivant (fin du cours)",
"courseExit.profileLink": "Profil",
"courseExit.programs.lastCourse": "Vous avez terminé le dernier cours de {title}!",
"courseCelebration.requestCertificateBodyText": "Pour accéder à votre certificat, demandez-le ci-dessous.",
"courseCelebration.requestCertificateBodyText": "Pour accéder à votre attestation, demandez-la ci-dessous.",
"courseCelebration.requestCertificateButton": "Demander une attestation",
"courseExit.searchOurCatalogLink": "Rechercher dans notre catalogue",
"courseCelebration.shareMessage": "Partagez votre succès sur les réseaux sociaux ou par courriel.",
@@ -414,23 +414,23 @@
"tours.existingUserTour.launchTourCheckpoint.body": "Nous avons récemment ajouté des nouvelles fonctions à l'expérience de cours. Vous avez besoin d'aide à les trouver? Prenez un tour guidé pour en apprendre plus.",
"tours.button.dismiss": "Rejeter",
"tours.button.next": "Suivant",
"tours.button.okay": "Okay",
"tours.button.okay": "D'accord",
"tours.button.beginTour": "Commencer la visite guidée",
"tours.button.launchTour": "Lancer la visite guidée",
"tours.newUserModal.body": "Faisons un tour rapide de {siteName} afin que vous puissiez tirer le meilleur parti de votre cours.",
"tours.newUserModal.title.welcome": "Bienvenu à votre",
"tours.newUserModal.title.welcome": "Bienvenue à votre",
"tours.button.skipForNow": "Ignorer pour l'instant",
"tours.datesCheckpoint.body": "Dates importantes afin de vous maintenir sur la bonne voie.",
"tours.datesCheckpoint.title": "Restez au courant des dates importantes",
"tours.outlineCheckpoint.body": "Vous pouvez explorer les sections du cours en utilisant la table des matières ci-dessous.",
"tours.outlineCheckpoint.title": "Suivez le cours!",
"tours.tabNavigationCheckpoint.body": "Ces onglets peuvent être utilisés pour accéder aux autres ressources de cours, tel que votre progression, le plan de cours, etc.",
"tours.tabNavigationCheckpoint.body": "Ces onglets peuvent être utilisés pour accéder aux autres ressources de cours, telles que votre progression, le plan de cours, etc.",
"tours.tabNavigationCheckpoint.title": "Ressources de cours additionnelles",
"tours.upgradeCheckpoint.body": "Travaillez vers une attestation et obtenez un accès complet au matériel de cours. Mettre à niveau maintenant!",
"tours.upgradeCheckpoint.title": "Débloquez votre cours",
"tours.weeklyGoalsCheckpoint.body": "Paramétrer un objectif encourage à compléter votre cours.",
"tours.weeklyGoalsCheckpoint.title": "Paramétrer un objectif de cours",
"tours.newUserModal.title": "{welcome} {siteName} au cours!",
"tours.newUserModal.title": "{welcome} cours sur {siteName} !",
"learning.effortEstimation.combinedEstimate": "{minutes} + {activities}",
"learning.effortEstimation.activities": "{activityCount, plural, one {# activité} many {# activités} other {# activités}}",
"learning.effortEstimation.minutesAbbreviated": "{minuteCount, plural, one {# min} many {# min} other {# min}}",

View File

@@ -37,6 +37,7 @@ import NoticesProvider from './generic/notices';
import PathFixesProvider from './generic/path-fixes';
import LiveTab from './course-home/live-tab/LiveTab';
import CourseAccessErrorPage from './generic/CourseAccessErrorPage';
import DecodePageRoute from './decode-page-route';
subscribe(APP_READY, () => {
ReactDOM.render(
@@ -50,28 +51,28 @@ subscribe(APP_READY, () => {
<Switch>
<PageRoute exact path="/goal-unsubscribe/:token" component={GoalUnsubscribe} />
<PageRoute path="/redirect" component={CoursewareRedirectLandingPage} />
<PageRoute path="/course/:courseId/access-denied" component={CourseAccessErrorPage} />
<PageRoute path="/course/:courseId/home">
<DecodePageRoute path="/course/:courseId/access-denied" component={CourseAccessErrorPage} />
<DecodePageRoute path="/course/:courseId/home">
<TabContainer tab="outline" fetch={fetchOutlineTab} slice="courseHome">
<OutlineTab />
</TabContainer>
</PageRoute>
<PageRoute path="/course/:courseId/live">
<TabContainer tab="live" fetch={fetchLiveTab} slice="courseHome">
</DecodePageRoute>
<DecodePageRoute path="/course/:courseId/live">
<TabContainer tab="lti_live" fetch={fetchLiveTab} slice="courseHome">
<LiveTab />
</TabContainer>
</PageRoute>
<PageRoute path="/course/:courseId/dates">
</DecodePageRoute>
<DecodePageRoute path="/course/:courseId/dates">
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
<DatesTab />
</TabContainer>
</PageRoute>
<PageRoute path="/course/:courseId/discussion/:path*">
</DecodePageRoute>
<DecodePageRoute path="/course/:courseId/discussion/:path*">
<TabContainer tab="discussion" fetch={fetchDiscussionTab} slice="courseHome">
<DiscussionTab />
</TabContainer>
</PageRoute>
<PageRoute
</DecodePageRoute>
<DecodePageRoute
path={[
'/course/:courseId/progress/:targetUserId/',
'/course/:courseId/progress',
@@ -86,12 +87,12 @@ subscribe(APP_READY, () => {
</TabContainer>
)}
/>
<PageRoute path="/course/:courseId/course-end">
<DecodePageRoute path="/course/:courseId/course-end">
<TabContainer tab="courseware" fetch={fetchCourse} slice="courseware">
<CourseExit />
</TabContainer>
</PageRoute>
<PageRoute
</DecodePageRoute>
<DecodePageRoute
path={[
'/course/:courseId/:sequenceId/:unitId',
'/course/:courseId/:sequenceId',
@@ -136,6 +137,8 @@ initialize({
TWITTER_URL: process.env.TWITTER_URL || null,
LEGACY_THEME_NAME: process.env.LEGACY_THEME_NAME || null,
EXAMS_BASE_URL: process.env.EXAMS_BASE_URL || null,
PROCTORED_EXAM_FAQ_URL: process.env.PROCTORED_EXAM_FAQ_URL || null,
PROCTORED_EXAM_RULES_URL: process.env.PROCTORED_EXAM_RULES_URL || null,
}, 'LearnerAppConfig');
},
},

View File

@@ -137,10 +137,12 @@ export async function initializeTestStore(options = {}, overrideStore = true) {
const discussionConfigUrl = new RegExp(`${getConfig().LMS_BASE_URL}/api/discussion/v1/courses/*`);
courseHomeMetadataUrl = appendBrowserTimezoneToUrl(courseHomeMetadataUrl);
const provider = options?.provider || 'legacy';
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
axiosMock.onGet(courseHomeMetadataUrl).reply(200, courseHomeMetadata);
axiosMock.onGet(learningSequencesUrlRegExp).reply(200, buildOutlineFromBlocks(courseBlocks));
axiosMock.onGet(discussionConfigUrl).reply(200, { provider: 'legacy' });
axiosMock.onGet(discussionConfigUrl).reply(200, { provider });
sequenceMetadata.forEach(metadata => {
const sequenceMetadataUrl = `${getConfig().LMS_BASE_URL}/api/courseware/sequence/${metadata.item_id}`;
axiosMock.onGet(sequenceMetadataUrl).reply(200, metadata);