Compare commits
30 Commits
ilee2u/bug
...
bw/hackath
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9f4df7452 | ||
|
|
c341eb7d22 | ||
|
|
7317c9424a | ||
|
|
d897663b73 | ||
|
|
2e4eb158f2 | ||
|
|
35b229bd1b | ||
|
|
4ebd569792 | ||
|
|
52235ebc1c | ||
|
|
aa380e8619 | ||
|
|
4cf0c7f4d7 | ||
|
|
743650a99e | ||
|
|
39d89bee9e | ||
|
|
a601e431b2 | ||
|
|
7519bbe28e | ||
|
|
4b90dcbfc3 | ||
|
|
54cb52cb6d | ||
|
|
6dbd3f49dd | ||
|
|
678502bb40 | ||
|
|
bf77fc7ca1 | ||
|
|
421a9a5d2b | ||
|
|
dfe44cae56 | ||
|
|
a88571dae8 | ||
|
|
a4ea334692 | ||
|
|
97a1cb4ffc | ||
|
|
5166bfe056 | ||
|
|
33e3765b19 | ||
|
|
a13e7d7389 | ||
|
|
a4ea1b54a4 | ||
|
|
cd430ebb5d | ||
|
|
630d44a8cc |
3
.env
3
.env
@@ -15,6 +15,7 @@ ECOMMERCE_BASE_URL=''
|
||||
ENABLE_JUMPNAV='true'
|
||||
ENABLE_NOTICES=''
|
||||
ENTERPRISE_LEARNER_PORTAL_HOSTNAME=''
|
||||
EXAMS_BASE_URL=''
|
||||
FAVICON_URL=''
|
||||
IGNORED_ERROR_REGEX=''
|
||||
INSIGHTS_BASE_URL=''
|
||||
@@ -28,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=''
|
||||
|
||||
@@ -15,6 +15,7 @@ ECOMMERCE_BASE_URL='http://localhost:18130'
|
||||
ENABLE_JUMPNAV='true'
|
||||
ENABLE_NOTICES=''
|
||||
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
|
||||
EXAMS_BASE_URL='http://localhost:18740'
|
||||
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
|
||||
IGNORED_ERROR_REGEX=''
|
||||
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
|
||||
@@ -28,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=''
|
||||
|
||||
@@ -15,6 +15,7 @@ ECOMMERCE_BASE_URL='http://localhost:18130'
|
||||
ENABLE_JUMPNAV='true'
|
||||
ENABLE_NOTICES=''
|
||||
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
|
||||
EXAMS_BASE_URL='http://localhost:18740'
|
||||
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
|
||||
IGNORED_ERROR_REGEX=''
|
||||
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
|
||||
@@ -28,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=''
|
||||
|
||||
@@ -16,4 +16,4 @@ jobs:
|
||||
secrets:
|
||||
GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }}
|
||||
GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }}
|
||||
SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }}
|
||||
SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }}
|
||||
|
||||
20
.github/workflows/add-remove-label-on-comment.yml
vendored
Normal file
20
.github/workflows/add-remove-label-on-comment.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
# This workflow runs when a comment is made on the ticket
|
||||
# If the comment starts with "label: " it tries to apply
|
||||
# the label indicated in rest of comment.
|
||||
# If the comment starts with "remove label: ", it tries
|
||||
# to remove the indicated label.
|
||||
# Note: Labels are allowed to have spaces and this script does
|
||||
# not parse spaces (as often a space is legitimate), so the command
|
||||
# "label: really long lots of words label" will apply the
|
||||
# label "really long lots of words label"
|
||||
|
||||
name: Allows for the adding and removing of labels via comment
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
add_remove_labels:
|
||||
uses: openedx/.github/.github/workflows/add-remove-label-on-comment.yml@master
|
||||
|
||||
12
.github/workflows/self-assign-issue.yml
vendored
Normal file
12
.github/workflows/self-assign-issue.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
# This workflow runs when a comment is made on the ticket
|
||||
# If the comment starts with "assign me" it assigns the author to the
|
||||
# ticket (case insensitive)
|
||||
|
||||
name: Assign comment author to ticket if they say "assign me"
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
self_assign_by_comment:
|
||||
uses: openedx/.github/.github/workflows/self-assign-issue.yml@master
|
||||
26773
package-lock.json
generated
26773
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@@ -29,12 +29,12 @@
|
||||
"url": "https://github.com/openedx/frontend-app-learning/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
|
||||
"@edx/frontend-component-footer": "11.1.0",
|
||||
"@edx/frontend-component-header": "^3.6.0",
|
||||
"@edx/frontend-lib-special-exams": "^2.2.0",
|
||||
"@edx/frontend-platform": "2.5.1",
|
||||
"@edx/paragon": "^20.24.0",
|
||||
"@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.10.0",
|
||||
"@edx/frontend-platform": "4.1.0",
|
||||
"@edx/paragon": "20.28.4",
|
||||
"@fortawesome/fontawesome-svg-core": "1.3.0",
|
||||
"@fortawesome/free-brands-svg-icons": "5.15.4",
|
||||
"@fortawesome/free-regular-svg-icons": "5.15.4",
|
||||
@@ -44,18 +44,19 @@
|
||||
"@reduxjs/toolkit": "1.8.1",
|
||||
"classnames": "2.3.2",
|
||||
"core-js": "3.22.2",
|
||||
"history": "^5.3.0",
|
||||
"history": "5.3.0",
|
||||
"html-react-parser": "^3.0.15",
|
||||
"js-cookie": "3.0.1",
|
||||
"lodash.camelcase": "4.3.0",
|
||||
"prop-types": "15.8.1",
|
||||
"query-string": "^7.1.1",
|
||||
"query-string": "7.1.3",
|
||||
"react": "16.14.0",
|
||||
"react-dom": "16.14.0",
|
||||
"react-helmet": "6.1.0",
|
||||
"react-redux": "7.2.9",
|
||||
"react-router": "5.2.1",
|
||||
"react-router-dom": "5.3.0",
|
||||
"react-share": "^4.4.1",
|
||||
"react-share": "4.4.1",
|
||||
"redux": "4.1.2",
|
||||
"regenerator-runtime": "0.13.11",
|
||||
"reselect": "4.1.7",
|
||||
|
||||
@@ -39,6 +39,50 @@ const SequenceLink = ({
|
||||
const coursewareUrl = <Link to={`/course/${courseId}/${id}`}>{title}</Link>;
|
||||
const displayTitle = showLink ? coursewareUrl : title;
|
||||
|
||||
const dueDateMessage = (
|
||||
<FormattedMessage
|
||||
id="learning.outline.sequence-due-date-set"
|
||||
defaultMessage="{description} due {assignmentDue}"
|
||||
description="Used below an assignment title"
|
||||
values={{
|
||||
assignmentDue: (
|
||||
<FormattedTime
|
||||
key={`${id}-due`}
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
timeZoneName="short"
|
||||
value={due}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
),
|
||||
description: description || '',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const noDueDateMessage = (
|
||||
<FormattedMessage
|
||||
id="learning.outline.sequence-due-date-not-set"
|
||||
defaultMessage="{description}"
|
||||
description="Used below an assignment title"
|
||||
values={{
|
||||
assignmentDue: (
|
||||
<FormattedTime
|
||||
key={`${id}-due`}
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
timeZoneName="short"
|
||||
value={due}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
),
|
||||
description: description || '',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<li>
|
||||
<div className={classNames('', { 'mt-2 pt-2 border-top border-light': !first })}>
|
||||
@@ -70,31 +114,11 @@ const SequenceLink = ({
|
||||
<EffortEstimate className="ml-3 align-middle" block={sequence} />
|
||||
</div>
|
||||
</div>
|
||||
{due && (
|
||||
<div className="row w-100 m-0 ml-3 pl-3">
|
||||
<small className="text-body pl-2">
|
||||
<FormattedMessage
|
||||
id="learning.outline.sequence-due"
|
||||
defaultMessage="{description} due {assignmentDue}"
|
||||
description="Used below an assignment title"
|
||||
values={{
|
||||
assignmentDue: (
|
||||
<FormattedTime
|
||||
key={`${id}-due`}
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
timeZoneName="short"
|
||||
value={due}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
),
|
||||
description: description || '',
|
||||
}}
|
||||
/>
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
<div className="row w-100 m-0 ml-3 pl-3">
|
||||
<small className="text-body pl-2">
|
||||
{due ? dueDateMessage : noDueDateMessage}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { useDispatch } from 'react-redux';
|
||||
@@ -43,10 +43,8 @@ const Course = ({
|
||||
|
||||
// Below the tabs, above the breadcrumbs alerts (appearing in the order listed here)
|
||||
const dispatch = useDispatch();
|
||||
const celebrateFirstSection = celebrations && celebrations.firstSection;
|
||||
const [firstSectionCelebrationOpen, setFirstSectionCelebrationOpen] = useState(
|
||||
shouldCelebrateOnSectionLoad(courseId, sequenceId, celebrateFirstSection, dispatch, celebrations),
|
||||
);
|
||||
|
||||
const [firstSectionCelebrationOpen, setFirstSectionCelebrationOpen] = useState(false);
|
||||
// If streakLengthToCelebrate is populated, that modal takes precedence. Wait til the next load to display
|
||||
// the weekly goal celebration modal.
|
||||
const [weeklyGoalCelebrationOpen, setWeeklyGoalCelebrationOpen] = useState(
|
||||
@@ -68,6 +66,17 @@ const Course = ({
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const celebrateFirstSection = celebrations && celebrations.firstSection;
|
||||
setFirstSectionCelebrationOpen(shouldCelebrateOnSectionLoad(
|
||||
courseId,
|
||||
sequenceId,
|
||||
celebrateFirstSection,
|
||||
dispatch,
|
||||
celebrations,
|
||||
));
|
||||
}, [sequenceId]);
|
||||
|
||||
return (
|
||||
<SidebarProvider courseId={courseId} unitId={unitId}>
|
||||
<Helmet>
|
||||
|
||||
@@ -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,13 +143,14 @@ 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 });
|
||||
expect(screen.queryByRole('region', { name: /notification tray/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('region', { name: /notification tray/i })).not.toHaveClass('d-none');
|
||||
fireEvent.click(notificationShowButton);
|
||||
expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"closed"');
|
||||
expect(screen.queryByRole('region', { name: /notification tray/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('region', { name: /notification tray/i })).toHaveClass('d-none');
|
||||
});
|
||||
|
||||
it('handles reload persisting notification tray status', async () => {
|
||||
@@ -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();
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
useWindowSize,
|
||||
} from '@edx/paragon';
|
||||
|
||||
import { useDispatch } from 'react-redux';
|
||||
import ClapsMobile from './assets/claps_280x201.gif';
|
||||
import ClapsTablet from './assets/claps_456x328.gif';
|
||||
import messages from './messages';
|
||||
@@ -19,12 +20,13 @@ import { useModel } from '../../../generic/model-store';
|
||||
const CelebrationModal = ({
|
||||
courseId, intl, isOpen, onClose, ...rest
|
||||
}) => {
|
||||
const { org } = useModel('courseHomeMeta', courseId);
|
||||
const { org, celebrations } = useModel('courseHomeMeta', courseId);
|
||||
const dispatch = useDispatch();
|
||||
const wideScreen = useWindowSize().width >= breakpoints.small.minWidth;
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
recordFirstSectionCelebration(org, courseId);
|
||||
recordFirstSectionCelebration(org, courseId, celebrations, dispatch);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen]);
|
||||
|
||||
@@ -15,9 +15,20 @@ function handleNextSectionCelebration(sequenceId, nextSequenceId) {
|
||||
});
|
||||
}
|
||||
|
||||
function recordFirstSectionCelebration(org, courseId) {
|
||||
function recordFirstSectionCelebration(org, courseId, celebrations, dispatch) {
|
||||
// Tell the LMS
|
||||
postCelebrationComplete(courseId, { first_section: false });
|
||||
// Update our local copy of course data from LMS
|
||||
dispatch(updateModel({
|
||||
modelType: 'courseHomeMeta',
|
||||
model: {
|
||||
id: courseId,
|
||||
celebrations: {
|
||||
...celebrations,
|
||||
firstSection: false,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Tell our analytics
|
||||
const { administrator } = getAuthenticatedUser();
|
||||
|
||||
15
src/courseware/course/celebration/utils.test.jsx
Normal file
15
src/courseware/course/celebration/utils.test.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { recordFirstSectionCelebration } from './utils';
|
||||
|
||||
jest.mock('@edx/frontend-platform/analytics');
|
||||
jest.mock('./data/api');
|
||||
jest.mock('@edx/frontend-platform/auth', () => ({
|
||||
getAuthenticatedUser: jest.fn(() => ({ administrator: 'admin' })),
|
||||
}));
|
||||
|
||||
describe('recordFirstSectionCelebration', () => {
|
||||
it('updates the local copy of the course data from the LMS', async () => {
|
||||
const dispatchMock = jest.fn();
|
||||
recordFirstSectionCelebration('org', 'courseId', 'celebration', dispatchMock);
|
||||
expect(dispatchMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -74,8 +74,8 @@ describe('Sequence', () => {
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Loading locked content messaging...')).toBeInTheDocument());
|
||||
// `Previous`, `Active`, `Next` and `Prerequisite` buttons.
|
||||
expect(screen.getAllByRole('button').length).toEqual(4);
|
||||
// `Previous`, `Active`, `Next`, `Prerequisite` and `Close Tray` buttons.
|
||||
expect(screen.getAllByRole('button').length).toEqual(5);
|
||||
|
||||
expect(screen.getByText('Content Locked')).toBeInTheDocument();
|
||||
const unitContainer = container.querySelector('.unit-container');
|
||||
@@ -126,7 +126,7 @@ describe('Sequence', () => {
|
||||
render(<Sequence {...mockData} />);
|
||||
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
|
||||
// Renders navigation buttons plus one button for each unit.
|
||||
expect(screen.getAllByRole('button')).toHaveLength(3 + unitBlocks.length);
|
||||
expect(screen.getAllByRole('button')).toHaveLength(4 + unitBlocks.length);
|
||||
|
||||
loadUnit();
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { AppContext, ErrorPage } from '@edx/frontend-platform/react';
|
||||
import { Modal } from '@edx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, {
|
||||
Suspense, useCallback, useContext, useEffect, useLayoutEffect, useState,
|
||||
} from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { processEvent } from '../../../course-home/data/thunks';
|
||||
import { useEventListener } from '../../../generic/hooks';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
import PageLoading from '../../../generic/PageLoading';
|
||||
import { fetchCourse } from '../../data';
|
||||
import BookmarkButton from '../bookmark/BookmarkButton';
|
||||
import ShareButton from '../share/ShareButton';
|
||||
import messages from './messages';
|
||||
|
||||
const HonorCode = React.lazy(() => import('./honor-code'));
|
||||
const LockPaywall = React.lazy(() => import('./lock-paywall'));
|
||||
|
||||
/**
|
||||
* Feature policy for iframe, allowing access to certain courseware-related media.
|
||||
*
|
||||
* We must use the wildcard (*) origin for each feature, as courseware content
|
||||
* may be embedded in external iframes. Notably, xblock-lti-consumer is a popular
|
||||
* block that iframes external course content.
|
||||
|
||||
* This policy was selected in conference with the edX Security Working Group.
|
||||
* Changes to it should be vetted by them (security@edx.org).
|
||||
*/
|
||||
const IFRAME_FEATURE_POLICY = (
|
||||
'microphone *; camera *; midi *; geolocation *; encrypted-media *'
|
||||
);
|
||||
|
||||
/**
|
||||
* We discovered an error in Firefox where - upon iframe load - React would cease to call any
|
||||
* useEffect hooks until the user interacts with the page again. This is particularly confusing
|
||||
* when navigating between sequences, as the UI partially updates leaving the user in a nebulous
|
||||
* state.
|
||||
*
|
||||
* We were able to solve this error by using a layout effect to update some component state, which
|
||||
* executes synchronously on render. Somehow this forces React to continue it's lifecycle
|
||||
* immediately, rather than waiting for user interaction. This layout effect could be anywhere in
|
||||
* the parent tree, as far as we can tell - we chose to add a conspicuously 'load bearing' (that's
|
||||
* a joke) one here so it wouldn't be accidentally removed elsewhere.
|
||||
*
|
||||
* If we remove this hook when one of these happens:
|
||||
* 1. React figures out that there's an issue here and fixes a bug.
|
||||
* 2. We cease to use an iframe for unit rendering.
|
||||
* 3. Firefox figures out that there's an issue in their iframe loading and fixes a bug.
|
||||
* 4. We stop supporting Firefox.
|
||||
* 5. An enterprising engineer decides to create a repo that reproduces the problem, submits it to
|
||||
* Firefox/React for review, and they kindly help us figure out what in the world is happening
|
||||
* so we can fix it.
|
||||
*
|
||||
* This hook depends on the unit id just to make sure it re-evaluates whenever the ID changes. If
|
||||
* we change whether or not the Unit component is re-mounted when the unit ID changes, this may
|
||||
* become important, as this hook will otherwise only evaluate the useLayoutEffect once.
|
||||
*/
|
||||
function useLoadBearingHook(id) {
|
||||
const setValue = useState(0)[1];
|
||||
useLayoutEffect(() => {
|
||||
setValue(currentValue => currentValue + 1);
|
||||
}, [id]);
|
||||
}
|
||||
|
||||
export function sendUrlHashToFrame(frame) {
|
||||
const { hash } = window.location;
|
||||
if (hash) {
|
||||
// The url hash will be sent to LMS-served iframe in order to find the location of the
|
||||
// hash within the iframe.
|
||||
frame.contentWindow.postMessage({ hashName: hash }, `${getConfig().LMS_BASE_URL}`);
|
||||
}
|
||||
}
|
||||
|
||||
const Unit = ({
|
||||
courseId,
|
||||
format,
|
||||
onLoaded,
|
||||
id,
|
||||
intl,
|
||||
}) => {
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
const view = authenticatedUser ? 'student_view' : 'public_view';
|
||||
let iframeUrl = `${getConfig().LMS_BASE_URL}/xblock/${id}?show_title=0&show_bookmark_button=0&recheck_access=1&view=${view}`;
|
||||
if (format) {
|
||||
iframeUrl += `&format=${format}`;
|
||||
}
|
||||
|
||||
const [iframeHeight, setIframeHeight] = useState(0);
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
const [showError, setShowError] = useState(false);
|
||||
const [modalOptions, setModalOptions] = useState({ open: false });
|
||||
const [shouldDisplayHonorCode, setShouldDisplayHonorCode] = useState(false);
|
||||
|
||||
const unit = useModel('units', id);
|
||||
const course = useModel('coursewareMeta', courseId);
|
||||
const {
|
||||
contentTypeGatingEnabled,
|
||||
userNeedsIntegritySignature,
|
||||
} = course;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
// Do not remove this hook. See function description.
|
||||
useLoadBearingHook(id);
|
||||
|
||||
useEffect(() => {
|
||||
if (userNeedsIntegritySignature && unit.graded) {
|
||||
setShouldDisplayHonorCode(true);
|
||||
} else {
|
||||
setShouldDisplayHonorCode(false);
|
||||
}
|
||||
}, [userNeedsIntegritySignature]);
|
||||
|
||||
const receiveMessage = useCallback(({ data }) => {
|
||||
const {
|
||||
type,
|
||||
payload,
|
||||
} = data;
|
||||
if (type === 'plugin.resize') {
|
||||
setIframeHeight(payload.height);
|
||||
if (!hasLoaded && iframeHeight === 0 && payload.height > 0) {
|
||||
setHasLoaded(true);
|
||||
if (onLoaded) {
|
||||
onLoaded();
|
||||
}
|
||||
}
|
||||
} else if (type === 'plugin.modal') {
|
||||
payload.open = true;
|
||||
setModalOptions(payload);
|
||||
} else if (data.offset) {
|
||||
// We listen for this message from LMS to know when the page needs to
|
||||
// be scrolled to another location on the page.
|
||||
window.scrollTo(0, data.offset + document.getElementById('unit-iframe').offsetTop);
|
||||
}
|
||||
}, [id, setIframeHeight, hasLoaded, iframeHeight, setHasLoaded, onLoaded]);
|
||||
useEventListener('message', receiveMessage);
|
||||
useEffect(() => {
|
||||
sendUrlHashToFrame(document.getElementById('unit-iframe'));
|
||||
}, [id, setIframeHeight, hasLoaded, iframeHeight, setHasLoaded, onLoaded]);
|
||||
|
||||
return (
|
||||
<div className="unit">
|
||||
<h1 className="mb-0 h3">{unit.title}</h1>
|
||||
<h2 className="sr-only">{intl.formatMessage(messages.headerPlaceholder)}</h2>
|
||||
<BookmarkButton
|
||||
unitId={unit.id}
|
||||
isBookmarked={unit.bookmarked}
|
||||
isProcessing={unit.bookmarkedUpdateState === 'loading'}
|
||||
/>
|
||||
{/* TODO: social share exp. Need to remove later */}
|
||||
{(window.expSocialShareAboutUrls && window.expSocialShareAboutUrls[unit.id] !== undefined) && (
|
||||
<ShareButton url={window.expSocialShareAboutUrls[unit.id]} />
|
||||
)}
|
||||
{contentTypeGatingEnabled && unit.containsContentTypeGatedContent && (
|
||||
<Suspense
|
||||
fallback={(
|
||||
<PageLoading
|
||||
srMessage={intl.formatMessage(messages.loadingLockedContent)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<LockPaywall courseId={courseId} />
|
||||
</Suspense>
|
||||
)}
|
||||
{shouldDisplayHonorCode && (
|
||||
<Suspense
|
||||
fallback={(
|
||||
<PageLoading
|
||||
srMessage={intl.formatMessage(messages.loadingHonorCode)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<HonorCode courseId={courseId} />
|
||||
</Suspense>
|
||||
)}
|
||||
{!shouldDisplayHonorCode && !hasLoaded && !showError && (
|
||||
<PageLoading
|
||||
srMessage={intl.formatMessage(messages.loadingSequence)}
|
||||
/>
|
||||
)}
|
||||
{!shouldDisplayHonorCode && !hasLoaded && showError && (
|
||||
<ErrorPage />
|
||||
)}
|
||||
{modalOptions.open && (
|
||||
<Modal
|
||||
body={(
|
||||
<>
|
||||
{modalOptions.body
|
||||
? <div className="unit-modal">{ modalOptions.body }</div>
|
||||
: (
|
||||
<iframe
|
||||
title={modalOptions.title}
|
||||
allow={IFRAME_FEATURE_POLICY}
|
||||
frameBorder="0"
|
||||
src={modalOptions.url}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100vh',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
onClose={() => { setModalOptions({ open: false }); }}
|
||||
open
|
||||
dialogClassName="modal-lti"
|
||||
/>
|
||||
)}
|
||||
{!shouldDisplayHonorCode && (
|
||||
<div className="unit-iframe-wrapper">
|
||||
<iframe
|
||||
id="unit-iframe"
|
||||
title={unit.title}
|
||||
src={iframeUrl}
|
||||
allow={IFRAME_FEATURE_POLICY}
|
||||
allowFullScreen
|
||||
height={iframeHeight}
|
||||
scrolling="no"
|
||||
referrerPolicy="origin"
|
||||
onLoad={() => {
|
||||
// onLoad *should* only fire after everything in the iframe has finished its own load events.
|
||||
// Which means that the plugin.resize message (which calls setHasLoaded above) will have fired already
|
||||
// for a successful load. If it *has not fired*, we are in an error state. For example, the backend
|
||||
// could have given us a 4xx or 5xx response.
|
||||
if (!hasLoaded) {
|
||||
setShowError(true);
|
||||
}
|
||||
|
||||
window.onmessage = (e) => {
|
||||
if (e.data.event_name) {
|
||||
dispatch(processEvent(e.data, fetchCourse));
|
||||
}
|
||||
};
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Unit.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
format: PropTypes.string,
|
||||
id: PropTypes.string.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
onLoaded: PropTypes.func,
|
||||
};
|
||||
|
||||
Unit.defaultProps = {
|
||||
format: null,
|
||||
onLoaded: undefined,
|
||||
};
|
||||
|
||||
export default injectIntl(Unit);
|
||||
139
src/courseware/course/sequence/Unit/ContentIFrame.jsx
Normal file
139
src/courseware/course/sequence/Unit/ContentIFrame.jsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
import { ErrorPage } from '@edx/frontend-platform/react';
|
||||
import { Modal } from '@edx/paragon';
|
||||
import PageLoading from '../../../../generic/PageLoading';
|
||||
import LocalIFrame from './LocalIFrame';
|
||||
import { renderers } from './constants';
|
||||
import hooks from './hooks';
|
||||
|
||||
/**
|
||||
* Feature policy for iframe, allowing access to certain courseware-related media.
|
||||
*
|
||||
* We must use the wildcard (*) origin for each feature, as courseware content
|
||||
* may be embedded in external iframes. Notably, xblock-lti-consumer is a popular
|
||||
* block that iframes external course content.
|
||||
|
||||
* This policy was selected in conference with the edX Security Working Group.
|
||||
* Changes to it should be vetted by them (security@edx.org).
|
||||
*/
|
||||
const IFRAME_FEATURE_POLICY = (
|
||||
'microphone *; camera *; midi *; geolocation *; encrypted-media *'
|
||||
);
|
||||
|
||||
const ContentIFrame = ({
|
||||
iframeUrl,
|
||||
showContent,
|
||||
loadingMessage,
|
||||
id,
|
||||
elementId,
|
||||
onLoaded,
|
||||
title,
|
||||
childBlocks,
|
||||
}) => {
|
||||
const {
|
||||
hasLoaded,
|
||||
showError,
|
||||
modalOptions,
|
||||
handleModalClose,
|
||||
handleIFrameLoad,
|
||||
iframeHeight,
|
||||
} = hooks.useIFrameBehavior({
|
||||
id,
|
||||
elementId,
|
||||
onLoaded,
|
||||
title,
|
||||
});
|
||||
|
||||
const renderModal = () => (
|
||||
<Modal
|
||||
body={(
|
||||
<>
|
||||
{modalOptions.body
|
||||
? <div className="unit-modal">{ modalOptions.body }</div>
|
||||
: (
|
||||
<iframe
|
||||
title={modalOptions.title}
|
||||
allow={IFRAME_FEATURE_POLICY}
|
||||
frameBorder="0"
|
||||
src={modalOptions.url}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100vh',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
onClose={handleModalClose}
|
||||
open
|
||||
dialogClassName="modal-lti"
|
||||
/>
|
||||
);
|
||||
|
||||
const renderChild = (childBlock) => {
|
||||
const Renderer = renderers[childBlock.type];
|
||||
return (<Renderer key={childBlock.id} {...childBlock.student_view_data} block={childBlock} />);
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (iframeUrl) {
|
||||
return (
|
||||
<>
|
||||
{!hasLoaded && (
|
||||
showError ? <ErrorPage /> : <PageLoading srMessage={loadingMessage} />
|
||||
)}
|
||||
<div className="unit-iframe-wrapper">
|
||||
<iframe
|
||||
id={elementId}
|
||||
title={title}
|
||||
src={iframeUrl}
|
||||
allow={IFRAME_FEATURE_POLICY}
|
||||
allowFullScreen
|
||||
height={iframeHeight}
|
||||
scrolling="no"
|
||||
referrerPolicy="origin"
|
||||
onLoad={handleIFrameLoad}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{childBlocks.map(renderChild)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{showContent && renderContent()}
|
||||
{modalOptions.open && renderModal()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
ContentIFrame.propTypes = {
|
||||
iframeUrl: PropTypes.string,
|
||||
id: PropTypes.string.isRequired,
|
||||
showContent: PropTypes.bool.isRequired,
|
||||
loadingMessage: PropTypes.node.isRequired,
|
||||
elementId: PropTypes.string.isRequired,
|
||||
onLoaded: PropTypes.func,
|
||||
title: PropTypes.node.isRequired,
|
||||
childBlocks: PropTypes.arrayOf(PropTypes.shape({
|
||||
type: PropTypes.string,
|
||||
student_view_data: PropTypes.shape({
|
||||
enabled: PropTypes.bool,
|
||||
}),
|
||||
})).isRequired,
|
||||
};
|
||||
|
||||
ContentIFrame.defaultProps = {
|
||||
iframeUrl: null,
|
||||
onLoaded: () => ({}),
|
||||
};
|
||||
|
||||
export default ContentIFrame;
|
||||
23
src/courseware/course/sequence/Unit/LocalIFrame.jsx
Normal file
23
src/courseware/course/sequence/Unit/LocalIFrame.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
const LocalIFrame = ({
|
||||
children,
|
||||
title,
|
||||
...props
|
||||
}) => {
|
||||
const [contentRef, setContentRef] = React.useState(null);
|
||||
const mountNode = contentRef?.contentWindow?.document?.body;
|
||||
return (
|
||||
<iframe title={title} {...props} ref={setContentRef}>
|
||||
{mountNode && createPortal(children, mountNode)}
|
||||
</iframe>
|
||||
);
|
||||
};
|
||||
|
||||
LocalIFrame.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
title: PropTypes.node.isRequired,
|
||||
};
|
||||
export default LocalIFrame;
|
||||
12
src/courseware/course/sequence/Unit/constants.js
Normal file
12
src/courseware/course/sequence/Unit/constants.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import HTMLRenderer from './renderers/HTMLRenderer';
|
||||
|
||||
export const renderers = {
|
||||
html: HTMLRenderer,
|
||||
};
|
||||
|
||||
export const FRendlyTypes = Object.keys(renderers);
|
||||
|
||||
export default {
|
||||
renderers,
|
||||
FRendlyTypes,
|
||||
};
|
||||
211
src/courseware/course/sequence/Unit/hooks.js
Normal file
211
src/courseware/course/sequence/Unit/hooks.js
Normal file
@@ -0,0 +1,211 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { processEvent } from '../../../../course-home/data/thunks';
|
||||
import { useEventListener } from '../../../../generic/hooks';
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
import { fetchCourse } from '../../../data';
|
||||
|
||||
import { FRendlyTypes } from './constants';
|
||||
|
||||
const useFetchStudentData = ({
|
||||
id,
|
||||
}) => {
|
||||
const [blocks, setBlocks] = useState(null);
|
||||
const [children, setChildren] = useState(null);
|
||||
const [isFRendly, setIsFRendly] = useState(false);
|
||||
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (children) {
|
||||
setIsFRendly(children.every(child => FRendlyTypes.includes(child.type)));
|
||||
}
|
||||
}, [children, setIsFRendly]);
|
||||
|
||||
useEffect(() => {
|
||||
if (blocks) {
|
||||
setChildren(blocks[id].children.map(childID => blocks[childID]));
|
||||
}
|
||||
}, [blocks, setChildren]);
|
||||
|
||||
useEffect(() => {
|
||||
let sequenceUrl;
|
||||
if (authenticatedUser) {
|
||||
const { username } = authenticatedUser;
|
||||
sequenceUrl = `${getConfig().LMS_BASE_URL}/api/courses/v2/blocks/${id}?username=${username}&requested_fields=children&depth=all&student_view_data=video,html`;
|
||||
getAuthenticatedHttpClient().get(sequenceUrl).then(response => {
|
||||
console.log({ response });
|
||||
setBlocks(response.data.blocks);
|
||||
});
|
||||
}
|
||||
}, [authenticatedUser, setBlocks]);
|
||||
console.log({ isFRendly, children });
|
||||
return { children, isFRendly };
|
||||
};
|
||||
|
||||
const useUnitData = ({
|
||||
courseId,
|
||||
format,
|
||||
id,
|
||||
}) => {
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
const view = authenticatedUser ? 'student_view' : 'public_view';
|
||||
let iframeUrl = `${getConfig().LMS_BASE_URL}/xblock/${id}?show_title=0&show_bookmark_button=0&recheck_access=1&view=${view}`;
|
||||
if (format) {
|
||||
iframeUrl += `&format=${format}`;
|
||||
}
|
||||
|
||||
const { isFRendly, children } = useFetchStudentData({ id });
|
||||
|
||||
const [shouldDisplayHonorCode, setShouldDisplayHonorCode] = useState(false);
|
||||
|
||||
const unit = useModel('units', id);
|
||||
const course = useModel('coursewareMeta', courseId);
|
||||
const {
|
||||
contentTypeGatingEnabled,
|
||||
userNeedsIntegritySignature,
|
||||
} = course;
|
||||
|
||||
useEffect(() => {
|
||||
if (userNeedsIntegritySignature && unit.graded) {
|
||||
setShouldDisplayHonorCode(true);
|
||||
} else {
|
||||
setShouldDisplayHonorCode(false);
|
||||
}
|
||||
}, [userNeedsIntegritySignature]);
|
||||
|
||||
return {
|
||||
contentTypeGatingEnabled,
|
||||
iframeUrl,
|
||||
shouldDisplayHonorCode,
|
||||
unit,
|
||||
isFRendly,
|
||||
children,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* We discovered an error in Firefox where - upon iframe load - React would cease to call any
|
||||
* useEffect hooks until the user interacts with the page again. This is particularly confusing
|
||||
* when navigating between sequences, as the UI partially updates leaving the user in a nebulous
|
||||
* state.
|
||||
*
|
||||
* We were able to solve this error by using a layout effect to update some component state, which
|
||||
* executes synchronously on render. Somehow this forces React to continue it's lifecycle
|
||||
* immediately, rather than waiting for user interaction. This layout effect could be anywhere in
|
||||
* the parent tree, as far as we can tell - we chose to add a conspicuously 'load bearing' (that's
|
||||
* a joke) one here so it wouldn't be accidentally removed elsewhere.
|
||||
*
|
||||
* If we remove this hook when one of these happens:
|
||||
* 1. React figures out that there's an issue here and fixes a bug.
|
||||
* 2. We cease to use an iframe for unit rendering.
|
||||
* 3. Firefox figures out that there's an issue in their iframe loading and fixes a bug.
|
||||
* 4. We stop supporting Firefox.
|
||||
* 5. An enterprising engineer decides to create a repo that reproduces the problem, submits it to
|
||||
* Firefox/React for review, and they kindly help us figure out what in the world is happening
|
||||
* so we can fix it.
|
||||
*
|
||||
* This hook depends on the unit id just to make sure it re-evaluates whenever the ID changes. If
|
||||
* we change whether or not the Unit component is re-mounted when the unit ID changes, this may
|
||||
* become important, as this hook will otherwise only evaluate the useLayoutEffect once.
|
||||
*/
|
||||
export const useLoadBearingHook = (id) => {
|
||||
const setValue = useState(0)[1];
|
||||
useLayoutEffect(() => {
|
||||
setValue(currentValue => currentValue + 1);
|
||||
}, [id]);
|
||||
};
|
||||
|
||||
export const sendUrlHashToFrame = (frame) => {
|
||||
const { hash } = window.location;
|
||||
if (hash) {
|
||||
// The url hash will be sent to LMS-served iframe in order to find the location of the
|
||||
// hash within the iframe.
|
||||
frame.contentWindow.postMessage({ hashName: hash }, `${getConfig().LMS_BASE_URL}`);
|
||||
}
|
||||
};
|
||||
|
||||
const useIFrameBehavior = ({
|
||||
id,
|
||||
elementId,
|
||||
onLoaded,
|
||||
}) => {
|
||||
// Do not remove this hook. See function description.
|
||||
useLoadBearingHook(id);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [iframeHeight, setIframeHeight] = useState(0);
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
const [showError, setShowError] = useState(false);
|
||||
const [modalOptions, setModalOptions] = useState({ open: false });
|
||||
|
||||
useEffect(() => {
|
||||
sendUrlHashToFrame(document.getElementById(elementId));
|
||||
}, [id, setIframeHeight, hasLoaded, iframeHeight, setHasLoaded, onLoaded]);
|
||||
|
||||
const receiveMessage = useCallback(({ data }) => {
|
||||
const { type, payload } = data;
|
||||
if (type === 'plugin.resize') {
|
||||
setIframeHeight(payload.height);
|
||||
if (!hasLoaded && iframeHeight === 0 && payload.height > 0) {
|
||||
setHasLoaded(true);
|
||||
if (onLoaded) {
|
||||
onLoaded();
|
||||
}
|
||||
}
|
||||
} else if (type === 'plugin.modal') {
|
||||
payload.open = true;
|
||||
setModalOptions(payload);
|
||||
} else if (data.offset) {
|
||||
// We listen for this message from LMS to know when the page needs to
|
||||
// be scrolled to another location on the page.
|
||||
window.scrollTo(0, data.offset + document.getElementById('unit-iframe').offsetTop);
|
||||
}
|
||||
}, [id, setIframeHeight, hasLoaded, iframeHeight, setHasLoaded, onLoaded]);
|
||||
useEventListener('message', receiveMessage);
|
||||
|
||||
/**
|
||||
* onLoad *should* only fire after everything in the iframe has finished its own load events.
|
||||
* Which means that the plugin.resize message (which calls setHasLoaded above) will have fired already
|
||||
* for a successful load. If it *has not fired*, we are in an error state. For example, the backend
|
||||
* could have given us a 4xx or 5xx response.
|
||||
*/
|
||||
const handleIFrameLoad = () => {
|
||||
if (!hasLoaded) {
|
||||
setShowError(true);
|
||||
}
|
||||
window.onmessage = (e) => {
|
||||
if (e.data.event_name) {
|
||||
dispatch(processEvent(e.data, fetchCourse));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setModalOptions({ open: false });
|
||||
};
|
||||
|
||||
return {
|
||||
iframeHeight,
|
||||
handleCloseModal,
|
||||
modalOptions,
|
||||
handleIFrameLoad,
|
||||
showError,
|
||||
hasLoaded,
|
||||
};
|
||||
};
|
||||
|
||||
export default {
|
||||
useIFrameBehavior,
|
||||
useUnitData,
|
||||
};
|
||||
89
src/courseware/course/sequence/Unit/index.jsx
Normal file
89
src/courseware/course/sequence/Unit/index.jsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Suspense } from 'react';
|
||||
import PageLoading from '../../../../generic/PageLoading';
|
||||
import BookmarkButton from '../../bookmark/BookmarkButton';
|
||||
import messages from '../messages';
|
||||
import ContentIFrame from './ContentIFrame';
|
||||
import hooks from './hooks';
|
||||
|
||||
const HonorCode = React.lazy(() => import('../honor-code'));
|
||||
const LockPaywall = React.lazy(() => import('../lock-paywall'));
|
||||
|
||||
const Unit = ({
|
||||
courseId,
|
||||
format,
|
||||
onLoaded,
|
||||
id,
|
||||
}) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const {
|
||||
unit,
|
||||
contentTypeGatingEnabled,
|
||||
shouldDisplayHonorCode,
|
||||
iframeUrl,
|
||||
isFRendly,
|
||||
children,
|
||||
} = hooks.useUnitData({
|
||||
courseId,
|
||||
format,
|
||||
id,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="unit">
|
||||
<h1 className="mb-0 h3">{unit.title}</h1>
|
||||
<h2 className="sr-only">{formatMessage(messages.headerPlaceholder)}</h2>
|
||||
<BookmarkButton
|
||||
unitId={unit.id}
|
||||
isBookmarked={unit.bookmarked}
|
||||
isProcessing={unit.bookmarkedUpdateState === 'loading'}
|
||||
/>
|
||||
{contentTypeGatingEnabled && unit.containsContentTypeGatedContent && (
|
||||
<Suspense
|
||||
fallback={(
|
||||
<PageLoading
|
||||
srMessage={formatMessage(messages.loadingLockedContent)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<LockPaywall courseId={courseId} />
|
||||
</Suspense>
|
||||
)}
|
||||
{shouldDisplayHonorCode && (
|
||||
<Suspense
|
||||
fallback={(
|
||||
<PageLoading
|
||||
srMessage={formatMessage(messages.loadingHonorCode)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<HonorCode courseId={courseId} />
|
||||
</Suspense>
|
||||
)}
|
||||
<ContentIFrame
|
||||
showContent={!shouldDisplayHonorCode}
|
||||
{...(isFRendly ? { childBlocks: children } : { iframeUrl })}
|
||||
loadingMessage={formatMessage(messages.loadingSequence)}
|
||||
id={id}
|
||||
elementId="unit-iframe"
|
||||
onLoaded={onLoaded}
|
||||
title={unit.title}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Unit.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
format: PropTypes.string,
|
||||
id: PropTypes.string.isRequired,
|
||||
onLoaded: PropTypes.func,
|
||||
};
|
||||
|
||||
Unit.defaultProps = {
|
||||
format: null,
|
||||
onLoaded: undefined,
|
||||
};
|
||||
|
||||
export default Unit;
|
||||
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import parse from 'html-react-parser';
|
||||
|
||||
const HTMLRenderer = ({ html }) => {
|
||||
console.log({ html });
|
||||
return (<div dangerouslySetInnerHTML={{ __html: html }} />);
|
||||
// return parse(html);
|
||||
};
|
||||
|
||||
HTMLRenderer.propTypes = {
|
||||
html: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default HTMLRenderer;
|
||||
@@ -1,18 +1,15 @@
|
||||
import React, { useContext } from 'react';
|
||||
import SidebarContext from './SidebarContext';
|
||||
import { SIDEBARS } from './sidebars';
|
||||
import React from 'react';
|
||||
import { SIDEBAR_ORDER, SIDEBARS } from './sidebars';
|
||||
|
||||
const Sidebar = () => {
|
||||
const {
|
||||
currentSidebar,
|
||||
} = useContext(SidebarContext);
|
||||
if (!currentSidebar) {
|
||||
return null;
|
||||
}
|
||||
const CurrentSidebar = SIDEBARS[currentSidebar].Sidebar;
|
||||
return (
|
||||
<CurrentSidebar />
|
||||
);
|
||||
};
|
||||
const Sidebar = () => (
|
||||
<>
|
||||
{
|
||||
SIDEBAR_ORDER.map((sideBarId) => {
|
||||
const SidebarToRender = SIDEBARS[sideBarId].Sidebar;
|
||||
return <SidebarToRender />;
|
||||
})
|
||||
}
|
||||
</>
|
||||
);
|
||||
|
||||
export default Sidebar;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -34,12 +34,14 @@ const SidebarBase = ({
|
||||
|
||||
useEventListener('message', receiveMessage);
|
||||
|
||||
return currentSidebar === sidebarId && (
|
||||
return (
|
||||
<section
|
||||
className={classNames('ml-0 ml-lg-4 border border-light-400 rounded-sm h-auto align-top', {
|
||||
'bg-white m-0 border-0 fixed-top vh-100 rounded-0': shouldDisplayFullScreen,
|
||||
'min-vh-100': !shouldDisplayFullScreen,
|
||||
'd-none': currentSidebar !== sidebarId,
|
||||
}, className)}
|
||||
data-testid={`sidebar-${sidebarId}`}
|
||||
style={{ width: shouldDisplayFullScreen ? '100%' : width }}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
|
||||
@@ -34,6 +34,8 @@ const DiscussionsSidebar = ({ intl }) => {
|
||||
src={`${discussionsUrl}?inContextSidebar`}
|
||||
className="d-flex w-100 h-100 border-0"
|
||||
title={intl.formatMessage(messages.discussionsTitle)}
|
||||
allow="clipboard-write"
|
||||
loading="lazy"
|
||||
/>
|
||||
</SidebarBase>
|
||||
);
|
||||
|
||||
16
src/decode-page-route/__snapshots__/index.test.jsx.snap
Normal file
16
src/decode-page-route/__snapshots__/index.test.jsx.snap
Normal 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>
|
||||
`;
|
||||
49
src/decode-page-route/index.jsx
Normal file
49
src/decode-page-route/index.jsx
Normal 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;
|
||||
103
src/decode-page-route/index.test.jsx
Normal file
103
src/decode-page-route/index.test.jsx
Normal 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);
|
||||
});
|
||||
@@ -10,16 +10,19 @@ import { getNotices } from './api';
|
||||
*/
|
||||
const NoticesProvider = ({ children }) => {
|
||||
const [isRedirected, setIsRedirected] = useState();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(async () => {
|
||||
if (getConfig().ENABLE_NOTICES) {
|
||||
const data = await getNotices();
|
||||
if (data && data.results && data.results.length > 0) {
|
||||
const { results } = data;
|
||||
setIsRedirected(true);
|
||||
window.location.replace(`${results[0]}?next=${window.location.href}`);
|
||||
|
||||
useEffect(() => {
|
||||
async function getData() {
|
||||
if (getConfig().ENABLE_NOTICES) {
|
||||
const data = await getNotices();
|
||||
if (data && data.results && data.results.length > 0) {
|
||||
const { results } = data;
|
||||
setIsRedirected(true);
|
||||
window.location.replace(`${results[0]}?next=${window.location.href}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
getData();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
||||
@@ -114,7 +114,8 @@
|
||||
"learning.proctoringPanel.onboardingButtonNotOpen": "يفتح الامتحان التحضيري في: {releaseDate}",
|
||||
"learning.proctoringPanel.reviewRequirementsButton": "مراجعة التعليمات و متطلبات النظام",
|
||||
"learning.proctoringPanel.onboardingButtonPastDue": "انقضى أجَل الامتحان التحضيري",
|
||||
"learning.outline.sequence-due": "{description} للتسليم قبل {assignmentDue}",
|
||||
"learning.outline.sequence-due-date-set": "{description} due {assignmentDue}",
|
||||
"learning.outline.sequence-due-date-not-set": "{description}",
|
||||
"progress.certificateStatus.unverifiedBody": "لتوليد شهادة، يجب عليك إكمال التحقق من هويتك. {idVerificationSupportLink}.",
|
||||
"progress.certificateStatus.downloadableBody": "اعرض إنجازاتك اليوم على لينكد إن أو ضمن سيرتك الذاتية. يمكنك تحميل شهادتك الآن و الوصول إليها في أي وقت من لوحة معلوماتك و ملفك الشخصي.",
|
||||
"courseCelebration.certificateBody.notAvailable.endDate": "تمت جدولة ظهور الدرجات النهائية و أي شهادات مكتسبة ابتداءً من {endDate}.",
|
||||
|
||||
@@ -114,7 +114,8 @@
|
||||
"learning.proctoringPanel.onboardingButtonNotOpen": "Onboarding Opens: {releaseDate}",
|
||||
"learning.proctoringPanel.reviewRequirementsButton": "Review instructions and system requirements",
|
||||
"learning.proctoringPanel.onboardingButtonPastDue": "Onboarding Past Due",
|
||||
"learning.outline.sequence-due": "{description} due {assignmentDue}",
|
||||
"learning.outline.sequence-due-date-set": "{description} due {assignmentDue}",
|
||||
"learning.outline.sequence-due-date-not-set": "{description}",
|
||||
"progress.certificateStatus.unverifiedBody": "In order to generate a certificate, you must complete ID verification. {idVerificationSupportLink}.",
|
||||
"progress.certificateStatus.downloadableBody": "Showcase your accomplishment on LinkedIn or your resumé today. You can download your certificate now and access it any time from your Dashboard and Profile.",
|
||||
"courseCelebration.certificateBody.notAvailable.endDate": "Final grades and any earned certificates are scheduled to be available after {endDate}.",
|
||||
|
||||
@@ -114,7 +114,8 @@
|
||||
"learning.proctoringPanel.onboardingButtonNotOpen": "Apertura de la integración: {releaseDate}",
|
||||
"learning.proctoringPanel.reviewRequirementsButton": "Revisar las instrucciones y los requisitos del sistema",
|
||||
"learning.proctoringPanel.onboardingButtonPastDue": "Inducción vencida",
|
||||
"learning.outline.sequence-due": "Fecha límite para {description}: {assignmentDue}",
|
||||
"learning.outline.sequence-due-date-set": "Fecha límite para {description}: {assignmentDue}",
|
||||
"learning.outline.sequence-due-date-not-set": "{description}",
|
||||
"progress.certificateStatus.unverifiedBody": "Para generar un certificado, debes completar la verificación de identidad. {idVerificationSupportLink}.",
|
||||
"progress.certificateStatus.downloadableBody": "Muestra tu logro en LinkedIn o en tu currículum. Puedes descargar tu certificado ahora y acceder a él en cualquier momento desde tu panel de estudiante y tu perfil.",
|
||||
"courseCelebration.certificateBody.notAvailable.endDate": "Las calificaciones finales y los certificados obtenidos están programados para estar disponibles después de {endDate}.",
|
||||
|
||||
@@ -114,7 +114,8 @@
|
||||
"learning.proctoringPanel.onboardingButtonNotOpen": "Ouverture de l'intégration : {releaseDate}",
|
||||
"learning.proctoringPanel.reviewRequirementsButton": "Examiner les instructions et la configuration système requise",
|
||||
"learning.proctoringPanel.onboardingButtonPastDue": "Intégration en retard",
|
||||
"learning.outline.sequence-due": "{description} échéance {assignmentDue}",
|
||||
"learning.outline.sequence-due-date-set": "{description} due {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.",
|
||||
"courseCelebration.certificateBody.notAvailable.endDate": "Les notes finales et toutes les attestations obtenues devraient être disponibles après le {endDate}.",
|
||||
|
||||
@@ -114,9 +114,10 @@
|
||||
"learning.proctoringPanel.onboardingButtonNotOpen": "Ouverture de l'intégration : {releaseDate}",
|
||||
"learning.proctoringPanel.reviewRequirementsButton": "Examiner les instructions et la configuration système requise",
|
||||
"learning.proctoringPanel.onboardingButtonPastDue": "Intégration en retard",
|
||||
"learning.outline.sequence-due": "{description} échéance {assignmentDue}",
|
||||
"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.",
|
||||
@@ -141,7 +142,7 @@
|
||||
"progress.completion.donut.label": "complété",
|
||||
"progress.completion.body": "Cela représente la part du contenu du cours que vous avez terminé. Notez que certains contenus peuvent ne pas encore être publiés.",
|
||||
"progress.completion.tooltip.locked": "Contenu que vous avez terminé.",
|
||||
"progress.completion.header": "Achèvement du cours",
|
||||
"progress.completion.header": "Complétion du cours",
|
||||
"progress.completion.tooltip": "Contenu auquel vous avez accès et que vous n'avez pas terminé.",
|
||||
"progress.completion.tooltip.complete": "Contenu verrouillé et disponible uniquement pour ceux qui effectuent une mise à niveau.",
|
||||
"progress.completion.donut.percentComplete": "Vous avez terminé {percent}% du contenu de ce cours.",
|
||||
@@ -303,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.",
|
||||
@@ -398,7 +399,7 @@
|
||||
"learning.generic.upgradeNotification.pursueAverifiedCertificate": "Obtenir une attestation vérifiée",
|
||||
"learning.generic.upgradeNotification.code": "Utilisez le code {code} lors du paiement",
|
||||
"learning.generic.upsell.verifiedCertBullet.verifiedCert": "attestation vérifiée",
|
||||
"learning.generic.upsell.verifiedCertBullet": "Obtenez un {verifiedCertLink} d'achèvement pour le mettre en valeur sur votre CV",
|
||||
"learning.generic.upsell.verifiedCertBullet": "Obtenez une {verifiedCertLink} de complétion pour la mettre en valeur sur votre CV",
|
||||
"learning.generic.upsell.unlockGradedBullet.gradedAssignments": "évaluations notées",
|
||||
"learning.generic.upsell.unlockGradedBullet": "Débloquer votre accès à toutes les activités du cours, incluant {gradedAssignmentsInBoldText}",
|
||||
"learning.generic.upsell.fullAccessBullet.fullAccess": "Accès complet",
|
||||
@@ -413,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}}",
|
||||
|
||||
@@ -114,7 +114,8 @@
|
||||
"learning.proctoringPanel.onboardingButtonNotOpen": "Onboarding Opens: {releaseDate}",
|
||||
"learning.proctoringPanel.reviewRequirementsButton": "Review instructions and system requirements",
|
||||
"learning.proctoringPanel.onboardingButtonPastDue": "Onboarding Past Due",
|
||||
"learning.outline.sequence-due": "{description} due {assignmentDue}",
|
||||
"learning.outline.sequence-due-date-set": "{description} due {assignmentDue}",
|
||||
"learning.outline.sequence-due-date-not-set": "{description}",
|
||||
"progress.certificateStatus.unverifiedBody": "In order to generate a certificate, you must complete ID verification. {idVerificationSupportLink}.",
|
||||
"progress.certificateStatus.downloadableBody": "Showcase your accomplishment on LinkedIn or your resumé today. You can download your certificate now and access it any time from your Dashboard and Profile.",
|
||||
"courseCelebration.certificateBody.notAvailable.endDate": "Final grades and any earned certificates are scheduled to be available after {endDate}.",
|
||||
|
||||
@@ -114,7 +114,8 @@
|
||||
"learning.proctoringPanel.onboardingButtonNotOpen": "Onboarding Opens: {releaseDate}",
|
||||
"learning.proctoringPanel.reviewRequirementsButton": "Review instructions and system requirements",
|
||||
"learning.proctoringPanel.onboardingButtonPastDue": "Onboarding Past Due",
|
||||
"learning.outline.sequence-due": "{description} due {assignmentDue}",
|
||||
"learning.outline.sequence-due-date-set": "{description} due {assignmentDue}",
|
||||
"learning.outline.sequence-due-date-not-set": "{description}",
|
||||
"progress.certificateStatus.unverifiedBody": "In order to generate a certificate, you must complete ID verification. {idVerificationSupportLink}.",
|
||||
"progress.certificateStatus.downloadableBody": "Showcase your accomplishment on LinkedIn or your resumé today. You can download your certificate now and access it any time from your Dashboard and Profile.",
|
||||
"courseCelebration.certificateBody.notAvailable.endDate": "Final grades and any earned certificates are scheduled to be available after {endDate}.",
|
||||
|
||||
@@ -114,7 +114,8 @@
|
||||
"learning.proctoringPanel.onboardingButtonNotOpen": "Onboarding Opens: {releaseDate}",
|
||||
"learning.proctoringPanel.reviewRequirementsButton": "Review instructions and system requirements",
|
||||
"learning.proctoringPanel.onboardingButtonPastDue": "Onboarding Past Due",
|
||||
"learning.outline.sequence-due": "{description} due {assignmentDue}",
|
||||
"learning.outline.sequence-due-date-set": "{description} due {assignmentDue}",
|
||||
"learning.outline.sequence-due-date-not-set": "{description}",
|
||||
"progress.certificateStatus.unverifiedBody": "In order to generate a certificate, you must complete ID verification. {idVerificationSupportLink}.",
|
||||
"progress.certificateStatus.downloadableBody": "Showcase your accomplishment on LinkedIn or your resumé today. You can download your certificate now and access it any time from your Dashboard and Profile.",
|
||||
"courseCelebration.certificateBody.notAvailable.endDate": "Final grades and any earned certificates are scheduled to be available after {endDate}.",
|
||||
|
||||
@@ -114,7 +114,8 @@
|
||||
"learning.proctoringPanel.onboardingButtonNotOpen": "Onboarding Opens: {releaseDate}",
|
||||
"learning.proctoringPanel.reviewRequirementsButton": "Review instructions and system requirements",
|
||||
"learning.proctoringPanel.onboardingButtonPastDue": "Onboarding Past Due",
|
||||
"learning.outline.sequence-due": "{description} due {assignmentDue}",
|
||||
"learning.outline.sequence-due-date-set": "{description} due {assignmentDue}",
|
||||
"learning.outline.sequence-due-date-not-set": "{description}",
|
||||
"progress.certificateStatus.unverifiedBody": "In order to generate a certificate, you must complete ID verification. {idVerificationSupportLink}.",
|
||||
"progress.certificateStatus.downloadableBody": "Showcase your accomplishment on LinkedIn or your resumé today. You can download your certificate now and access it any time from your Dashboard and Profile.",
|
||||
"courseCelebration.certificateBody.notAvailable.endDate": "Final grades and any earned certificates are scheduled to be available after {endDate}.",
|
||||
|
||||
@@ -114,7 +114,8 @@
|
||||
"learning.proctoringPanel.onboardingButtonNotOpen": "Onboarding Opens: {releaseDate}",
|
||||
"learning.proctoringPanel.reviewRequirementsButton": "Review instructions and system requirements",
|
||||
"learning.proctoringPanel.onboardingButtonPastDue": "Onboarding Past Due",
|
||||
"learning.outline.sequence-due": "{description} due {assignmentDue}",
|
||||
"learning.outline.sequence-due-date-set": "{description} due {assignmentDue}",
|
||||
"learning.outline.sequence-due-date-not-set": "{description}",
|
||||
"progress.certificateStatus.unverifiedBody": "In order to generate a certificate, you must complete ID verification. {idVerificationSupportLink}.",
|
||||
"progress.certificateStatus.downloadableBody": "Showcase your accomplishment on LinkedIn or your resumé today. You can download your certificate now and access it any time from your Dashboard and Profile.",
|
||||
"courseCelebration.certificateBody.notAvailable.endDate": "Final grades and any earned certificates are scheduled to be available after {endDate}.",
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
"learning.accessExpiration.header": "Audit Access Expires {date}",
|
||||
"learning.accessExpiration.body": "You lose all access to this course, including your progress, on {date}.",
|
||||
"instructorToolbar.pageBanner.courseHasExpired": "This learner no longer has access to this course. Their access expired on {date}.",
|
||||
"learning.accessExpiration.upgradeNow": "Upgrade now",
|
||||
"learning.accessExpiration.upgradeNow": "马上升级",
|
||||
"learning.activeEnterprise.alert": " {changeActiveEnterprise}.",
|
||||
"learning.activeEnterprise.change.alert": "change enterprise now",
|
||||
"learning.outline.alert.start.short": "Course starts {timeRemaining} at {courseStartTime}.",
|
||||
"learning.outline.alert.end.long": "This course is ending {timeRemaining} on {courseEndDate}.",
|
||||
"learning.outline.alert.end.calendar": "Don’t forget to add a calendar reminder!",
|
||||
"instructorToolbar.pageBanner.courseHasNotStarted": "This learner does not yet have access to this course. The course starts on {date}.",
|
||||
"learning.enrollment.alert": "You must be enrolled in the course to see course content.",
|
||||
"learning.enrollment.alert": "您必须报读此课程才能查看课程内容。",
|
||||
"learning.staff.enrollment.alert": "You are viewing this course as staff, and are not enrolled.",
|
||||
"learning.enrollment.enrollNow.Inline": "Enroll now",
|
||||
"learning.enrollment.enrollNow.Sentence": "Enroll now.",
|
||||
@@ -22,17 +22,17 @@
|
||||
"account-activation.alert.title": "Activate your account so you can log back in",
|
||||
"learn.sequence.entranceExamTextNotPassing": "To access course materials, you must score {entranceExamMinimumScorePct}% or higher on this exam. Your current score is {entranceExamCurrentScore}%.",
|
||||
"learn.sequence.entranceExamTextPassed": "Your score is {entranceExamCurrentScore}%. You have passed the entrance exam.",
|
||||
"learning.dates.badge.completed": "Completed",
|
||||
"learning.dates.badge.completed": "已完成",
|
||||
"learning.dates.badge.dueNext": "Due next",
|
||||
"learning.dates.badge.pastDue": "Past due",
|
||||
"learning.dates.title": "Important dates",
|
||||
"learning.dates.badge.today": "Today",
|
||||
"learning.dates.title": "重要日期",
|
||||
"learning.dates.badge.today": "今天",
|
||||
"learning.dates.badge.unreleased": "Not yet released",
|
||||
"learning.dates.badge.verifiedOnly": "Verified only",
|
||||
"learning.goals.unsubscribe.contact": "contact support",
|
||||
"learning.goals.unsubscribe.contact": "请联系技术支持",
|
||||
"learning.goals.unsubscribe.description": "You will no longer receive email reminders about your goal for {courseTitle}.",
|
||||
"learning.goals.unsubscribe.errorHeader": "Something went wrong",
|
||||
"learning.goals.unsubscribe.goToDashboard": "Go to dashboard",
|
||||
"learning.goals.unsubscribe.goToDashboard": "去控制面板",
|
||||
"learning.goals.unsubscribe.header": "You’ve unsubscribed from goal reminders",
|
||||
"learning.goals.unsubscribe.loading": "Unsubscribing…",
|
||||
"learning.goals.unsubscribe.errorDescription": "We were unable to unsubscribe you from goal reminder emails. Please try again later or {contactSupport} for help.",
|
||||
@@ -52,21 +52,21 @@
|
||||
"learning.outline.goalButton.screenReader.text": "Casual",
|
||||
"learning.outline.certificateAlt": "Example Certificate",
|
||||
"learning.outline.collapseAll": "Collapse all",
|
||||
"learning.outline.completedAssignment": "Completed",
|
||||
"learning.outline.completedAssignment": "已完成",
|
||||
"learning.outline.completedSection": "Completed section",
|
||||
"learning.outline.dates": "Important dates",
|
||||
"learning.outline.dates": "重要日期",
|
||||
"learning.outline.editGoal": "Edit goal",
|
||||
"learning.outline.expandAll": "Expand all",
|
||||
"learning.outline.goal": "Goal",
|
||||
"learning.outline.goalReminderDetail": "If we notice you’re not quite at your goal, we’ll send you an email reminder.",
|
||||
"learning.outline.goalUnsure": "Not sure yet",
|
||||
"learning.outline.handouts": "Course Handouts",
|
||||
"learning.outline.incompleteAssignment": "Incomplete",
|
||||
"learning.outline.goalUnsure": "再想想",
|
||||
"learning.outline.handouts": "课程讲义",
|
||||
"learning.outline.incompleteAssignment": "未完成",
|
||||
"learning.outline.incompleteSection": "Incomplete section",
|
||||
"learning.outline.goalButton.intense.text": "5 days a week",
|
||||
"learning.outline.goalButton.intense.title": "Intense",
|
||||
"learning.outline.learnMore": "Learn More",
|
||||
"learning.outline.altText.openSection": "Open",
|
||||
"learning.outline.learnMore": "了解更多",
|
||||
"learning.outline.altText.openSection": "打开",
|
||||
"learning.proctoringPanel.header": "This course contains proctored exams",
|
||||
"learning.outline.goalButton.regular.text": "3 days a week",
|
||||
"learning.outline.goalButton.regular.title": "Regular",
|
||||
@@ -79,19 +79,19 @@
|
||||
"learning.outline.setWeeklyGoalDetail": "Setting a goal motivates you to finish the course. You can always change it later.",
|
||||
"learning.outline.start": "Start course",
|
||||
"learning.outline.startBlurb": "Begin your course today",
|
||||
"learning.outline.tools": "Course Tools",
|
||||
"learning.outline.tools": "课程工具",
|
||||
"learning.outline.upgradeButton": "Upgrade ({symbol}{price})",
|
||||
"learning.outline.upgradeTitle": "Pursue a verified certificate",
|
||||
"learning.outline.upgradeTitle": "考取认证证书",
|
||||
"learning.outline.welcomeMessage": "Welcome Message",
|
||||
"learning.outline.welcomeMessageShowMoreButton": "Show More",
|
||||
"learning.outline.welcomeMessageShowLessButton": "Show Less",
|
||||
"learning.outline.goalWelcome": "Welcome to",
|
||||
"learning.proctoringPanel.status.notStarted": "Not Started",
|
||||
"learning.proctoringPanel.status.started": "Started",
|
||||
"learning.proctoringPanel.status.submitted": "Submitted",
|
||||
"learning.proctoringPanel.status.verified": "Verified",
|
||||
"learning.proctoringPanel.status.rejected": "Rejected",
|
||||
"learning.proctoringPanel.status.error": "Error",
|
||||
"learning.outline.welcomeMessageShowMoreButton": "显示更多",
|
||||
"learning.outline.welcomeMessageShowLessButton": "查看收起",
|
||||
"learning.outline.goalWelcome": "欢迎来到",
|
||||
"learning.proctoringPanel.status.notStarted": "尚未开始",
|
||||
"learning.proctoringPanel.status.started": "已经开始",
|
||||
"learning.proctoringPanel.status.submitted": "已提交",
|
||||
"learning.proctoringPanel.status.verified": "已经过身份认证的",
|
||||
"learning.proctoringPanel.status.rejected": "拒绝",
|
||||
"learning.proctoringPanel.status.error": "错误",
|
||||
"learning.proctoringPanel.status.otherCourseApproved": "Approved in Another Course",
|
||||
"learning.proctoringPanel.status.expiringSoon": "Expiring Soon",
|
||||
"learning.proctoringPanel.status.expired": "Expired",
|
||||
@@ -114,7 +114,8 @@
|
||||
"learning.proctoringPanel.onboardingButtonNotOpen": "Onboarding Opens: {releaseDate}",
|
||||
"learning.proctoringPanel.reviewRequirementsButton": "Review instructions and system requirements",
|
||||
"learning.proctoringPanel.onboardingButtonPastDue": "Onboarding Past Due",
|
||||
"learning.outline.sequence-due": "{description} due {assignmentDue}",
|
||||
"learning.outline.sequence-due-date-set": "{description} due {assignmentDue}",
|
||||
"learning.outline.sequence-due-date-not-set": "{description}",
|
||||
"progress.certificateStatus.unverifiedBody": "In order to generate a certificate, you must complete ID verification. {idVerificationSupportLink}.",
|
||||
"progress.certificateStatus.downloadableBody": "Showcase your accomplishment on LinkedIn or your resumé today. You can download your certificate now and access it any time from your Dashboard and Profile.",
|
||||
"courseCelebration.certificateBody.notAvailable.endDate": "Final grades and any earned certificates are scheduled to be available after {endDate}.",
|
||||
@@ -132,13 +133,13 @@
|
||||
"progress.certificateStatus.viewableButton": "View my certificate",
|
||||
"progress.certificateStatus.notAvailableHeader": "Certificate status",
|
||||
"progress.certificateBody.notAvailable.endDate": "Final grades and any earned certificates are scheduled to be available after {endDate}.",
|
||||
"progress.certificateStatus.upgradeHeader": "Earn a certificate",
|
||||
"progress.certificateStatus.upgradeHeader": "获取证书",
|
||||
"progress.certificateStatus.upgradeBody": "You are in an audit track and do not qualify for a certificate. In order to work towards a certificate, upgrade your course today.",
|
||||
"progress.certificateStatus.upgradeButton": "Upgrade now",
|
||||
"progress.certificateStatus.upgradeButton": "马上升级",
|
||||
"progress.certificateStatus.unverifiedHomeHeader.v2": "Verify your identity to qualify for a certificate.",
|
||||
"progress.certificateStatus.unverifiedHomeButton": "Verify my ID",
|
||||
"progress.certificateStatus.unverifiedHomeBody": "In order to generate a certificate for this course, you must complete the ID verification process.",
|
||||
"progress.completion.donut.label": "completed",
|
||||
"progress.completion.donut.label": "完成",
|
||||
"progress.completion.body": "This represents how much of the course content you have completed. Note that some content may not yet be released.",
|
||||
"progress.completion.tooltip.locked": "Content that you have completed.",
|
||||
"progress.completion.header": "Course completion",
|
||||
@@ -150,11 +151,11 @@
|
||||
"progress.creditInformation.creditNotEligible": "You are no longer eligible for credit in this course. Learn more about {creditLink}.",
|
||||
"progress.creditInformation.creditEligible": "\n You have met the requirements for credit in this course. Go to your\n {dashboardLink} to purchase course credit. Or learn more about {creditLink}.",
|
||||
"progress.creditInformation.creditPartialEligible": "You have not yet met the requirements for credit. Learn more about {creditLink}.",
|
||||
"progress.creditInformation.completed": "Completed",
|
||||
"progress.creditInformation.completed": "已完成",
|
||||
"progress.creditInformation.courseCredit": "course credit",
|
||||
"progress.creditInformation.minimumGrade": "Minimum grade for credit ({minGrade}%)",
|
||||
"progress.creditInformation.requirementsHeader": "Requirements for course credit",
|
||||
"progress.creditInformation.upcoming": "Upcoming",
|
||||
"progress.creditInformation.upcoming": "即将到来",
|
||||
"progress.creditInformation.verificationFailed": "Verification failed",
|
||||
"progress.creditInformation.verificationSubmitted": "Verification submitted",
|
||||
"progress.ungradedAlert": "For progress on ungraded aspects of the course, view your {outlineLink}.",
|
||||
@@ -172,18 +173,18 @@
|
||||
"progress.courseGrade.preview.body.unlockCertificate": "Unlock to view grades and work towards a certificate.",
|
||||
"progress.courseGrade.partialpreview.body.unlockCertificate": "Unlock to work towards a certificate.",
|
||||
"progress.courseGrade.preview.body.upgradeDeadlinePassed": "The deadline to upgrade in this course has passed.",
|
||||
"progress.courseGrade.preview.button.upgrade": "Upgrade now",
|
||||
"progress.courseGrade.preview.button.upgrade": "马上升级",
|
||||
"progress.courseGrade.gradeRange.tooltip": "Grade ranges for this course:",
|
||||
"progress.courseOutline": "Course Outline",
|
||||
"progress.courseOutline": "课程大纲",
|
||||
"progress.courseGrade.label.currentGrade": "Your current grade",
|
||||
"progress.detailedGrades": "Detailed grades",
|
||||
"progress.detailedGrades.emptyTable": "You currently have no graded problem scores.",
|
||||
"progress.footnotes.title": "Grade summary footnotes",
|
||||
"progress.gradeSummary.grade": "Grade",
|
||||
"progress.gradeSummary.grade": "成绩",
|
||||
"progress.courseGrade.grades": "Grades",
|
||||
"progress.courseGrade.gradesAndCredit": "Grades & Credit",
|
||||
"progress.courseGrade.gradeRange.Tooltip": "Grade range tooltip",
|
||||
"progress.gradeSummary": "Grade summary",
|
||||
"progress.gradeSummary": "评分汇总",
|
||||
"progress.gradeSummary.limitedAccessExplanation": "You have limited access to graded assignments as part of the audit track in this course.",
|
||||
"progress.gradeSummary.tooltip.alt": "Grade summary tooltip",
|
||||
"progress.gradeSummary.tooltip.body": "Your course assignment's weight is determined by your instructor. By multiplying your grade by the weight for that assignment type, your weighted grade is calculated. Your weighted grade is what's used to determine if you pass the course.",
|
||||
@@ -192,33 +193,33 @@
|
||||
"progress.courseGrade.label.passingGrade": "Passing grade",
|
||||
"progress.detailedGrades.problemScore.label": "Problem Scores:",
|
||||
"progress.detailedGrades.problemScore.toggleButton": "Toggle individual problem scores for {subsectionTitle}",
|
||||
"progress.detailedGrades.overridden": "Section grade has been overridden.",
|
||||
"progress.score": "Score",
|
||||
"progress.weight": "Weight",
|
||||
"progress.detailedGrades.overridden": "这部分的成绩已经被修订。",
|
||||
"progress.score": "分数",
|
||||
"progress.weight": "权重",
|
||||
"progress.weightedGrade": "Weighted grade",
|
||||
"progress.weightedGradeSummary": "Your current weighted grade summary",
|
||||
"progress.header": "Your progress",
|
||||
"progress.header.targetUser": "Course progress for {username}",
|
||||
"progress.link.studio": "View grading in Studio",
|
||||
"progress.relatedLinks.datesCard.description": "A schedule view of your course due dates and upcoming assignments.",
|
||||
"progress.relatedLinks.datesCard.link": "Dates",
|
||||
"progress.relatedLinks.datesCard.link": "日期",
|
||||
"progress.relatedLinks.outlineCard.description": "A birds-eye view of your course content.",
|
||||
"progress.relatedLinks.outlineCard.link": "Course Outline",
|
||||
"progress.relatedLinks.outlineCard.link": "课程大纲",
|
||||
"progress.relatedLinks": "Related links",
|
||||
"datesBanner.suggestedSchedule": "We’ve built a suggested schedule to help you stay on track. But don’t worry—it’s flexible so you can learn at your own pace.",
|
||||
"datesBanner.upgradeToCompleteGradedBanner.header": "Upgrade to unlock",
|
||||
"datesBanner.upgradeToCompleteGradedBanner.header": "升级解锁",
|
||||
"datesBanner.upgradeToCompleteGradedBanner.body": "You are auditing this course, which means that you are unable to participate in graded assignments. To complete graded assignments as part of this course, you can upgrade today.",
|
||||
"datesBanner.upgradeToCompleteGradedBanner.button": "Upgrade now",
|
||||
"datesBanner.upgradeToCompleteGradedBanner.button": "马上升级",
|
||||
"datesBanner.upgradeToResetBanner.body": "To keep yourself on track, you can update this schedule and shift the past due assignments into the future. Don’t worry—you won’t lose any of the progress you’ve made when you shift your due dates.",
|
||||
"datesBanner.upgradeToResetBanner.button": "Upgrade to shift due dates",
|
||||
"datesBanner.resetDatesBanner.header": "It looks like you missed some important deadlines based on our suggested schedule.",
|
||||
"datesBanner.resetDatesBanner.body": "To keep yourself on track, you can update this schedule and shift the past due assignments into the future. Don’t worry—you won’t lose any of the progress you’ve made when you shift your due dates.",
|
||||
"datesBanner.resetDatesBanner.button": "Shift due dates",
|
||||
"learn.navigation.course.tabs.label": "Course Material",
|
||||
"unit.bookmark.button.add.bookmark": "Bookmark this page",
|
||||
"unit.bookmark.button.remove.bookmark": "Bookmarked",
|
||||
"learn.navigation.course.tabs.label": "课程资料",
|
||||
"unit.bookmark.button.add.bookmark": "收藏此页",
|
||||
"unit.bookmark.button.remove.bookmark": "已收藏",
|
||||
"learning.celebration.completed": "You just completed the first section of your course.",
|
||||
"learning.celebration.congrats": "Congratulations!",
|
||||
"learning.celebration.congrats": "恭喜!",
|
||||
"learning.celebration.earned": "You earned it!",
|
||||
"learning.celebration.emailSubject": "I'm on my way to completing {title} online with {platform}!",
|
||||
"learning.celebration.forward": "Keep going",
|
||||
@@ -230,38 +231,38 @@
|
||||
"learning.celebration.setGoal": "Setting a goal can help you {strongText} in your course.",
|
||||
"calculator.instructions.button.label": "Calculator Instructions",
|
||||
"calculator.instructions": "For detailed information, see the {expressions_link}.",
|
||||
"calculator.instructions.support.title": "Help Center",
|
||||
"calculator.instructions.support.title": "帮助中心",
|
||||
"calculator.instructions.useful.tips": "Useful tips:",
|
||||
"calculator.hint1": "Use parentheses () to make expressions clear. You can use parentheses inside other parentheses.",
|
||||
"calculator.hint2": "Do not use spaces in expressions.",
|
||||
"calculator.hint3": "For constants, indicate multiplication explicitly (example: 5*c).",
|
||||
"calculator.hint4": "For affixes, type the number and affix without a space (example: 5c).",
|
||||
"calculator.hint5": "For functions, type the name of the function, then the expression in parentheses.",
|
||||
"calculator.instruction.table.to.use.heading": "To Use",
|
||||
"calculator.instruction.table.type.heading": "Type",
|
||||
"calculator.instruction.table.examples.heading": "Examples",
|
||||
"calculator.instruction.table.to.use.numbers": "Numbers",
|
||||
"calculator.instruction.table.to.use.numbers.type1": "Integers",
|
||||
"calculator.instruction.table.to.use.numbers.type2": "Fractions",
|
||||
"calculator.instruction.table.to.use.numbers.type3": "Decimals",
|
||||
"calculator.instruction.table.to.use.operators": "Operators",
|
||||
"calculator.hint1": "使用括号()使表达式更明确。您可以在括号内使用括号。",
|
||||
"calculator.hint2": "不要在表达式中使用空格。",
|
||||
"calculator.hint3": "对于常数,明确表明乘法(例如:5*c)。",
|
||||
"calculator.hint4": "对于词缀,输入数字和词组,不带空格(例:5c)。",
|
||||
"calculator.hint5": "对于函数,输入函数的名字,然后在括号中输入表达式。",
|
||||
"calculator.instruction.table.to.use.heading": "来使用",
|
||||
"calculator.instruction.table.type.heading": "类型",
|
||||
"calculator.instruction.table.examples.heading": "例子",
|
||||
"calculator.instruction.table.to.use.numbers": "数字",
|
||||
"calculator.instruction.table.to.use.numbers.type1": "整数",
|
||||
"calculator.instruction.table.to.use.numbers.type2": "分数",
|
||||
"calculator.instruction.table.to.use.numbers.type3": "小数",
|
||||
"calculator.instruction.table.to.use.operators": "操作",
|
||||
"calculator.instruction.table.to.use.operators.type1": "(add, subtract, multiply, divide)",
|
||||
"calculator.instruction.table.to.use.operators.type2": "(raise to a power)",
|
||||
"calculator.instruction.table.to.use.operators.type3": "(parallel resistors)",
|
||||
"calculator.instruction.table.to.use.constants": "Constants",
|
||||
"calculator.instruction.table.to.use.affixes": "Affixes",
|
||||
"calculator.instruction.table.to.use.constants": "常量",
|
||||
"calculator.instruction.table.to.use.affixes": "词缀",
|
||||
"calculator.instruction.table.to.use.affixes.type": "Percent sign (%)",
|
||||
"calculator.instruction.table.to.use.basic.functions": "Basic functions",
|
||||
"calculator.instruction.table.to.use.trig.functions": "Trigonometric functions",
|
||||
"calculator.instruction.table.to.use.scientific.notation": "Scientific notation",
|
||||
"calculator.instruction.table.to.use.basic.functions": "基本功能",
|
||||
"calculator.instruction.table.to.use.trig.functions": "三角函数",
|
||||
"calculator.instruction.table.to.use.scientific.notation": "科学记数法",
|
||||
"calculator.instruction.table.to.use.scientific.notation.type1": "{exponentSyntax} and the exponent",
|
||||
"calculator.instruction.table.to.use.scientific.notation.type2": "{notationSyntax} notation",
|
||||
"calculator.instruction.table.to.use.scientific.notation.type3": "{notationSyntax} and the exponent",
|
||||
"calculator.button.label": "Calculator",
|
||||
"calculator.input.field.label": "Calculator Input",
|
||||
"calculator.submit.button.label": "Calculate",
|
||||
"calculator.button.label": "计算器",
|
||||
"calculator.input.field.label": "计算器输入",
|
||||
"calculator.submit.button.label": "计算",
|
||||
"calculator.result.field.label": "Calculator Result",
|
||||
"calculator.result.field.placeholder": "Result",
|
||||
"calculator.result.field.placeholder": "结果",
|
||||
"notes.button.show": "Show Notes",
|
||||
"notes.button.hide": "Hide Notes",
|
||||
"courseExit.catalogSearchSuggestion": "Looking to learn more? {searchOurCatalogLink} to find more courses and programs to explore.",
|
||||
@@ -271,7 +272,7 @@
|
||||
"courseCelebration.certificateBody.upgradable": "It’s not too late to upgrade. For {price} you will unlock access to all graded\n assignments in this course. Upon completion, you will receive a verified certificate which is a\n valuable credential to improve your job prospects and advance your career, or highlight your\n certificate in school applications.",
|
||||
"courseCelebration.upgradeDiscountCodePrompt": "Use code {code} at checkout for {percent}% off!",
|
||||
"courseCelebration.recommendations.heading": "Keep building your skills with these courses!",
|
||||
"courseCelebration.recommendations.label": "Course",
|
||||
"courseCelebration.recommendations.label": "课程",
|
||||
"courseCelebration.recommendations.formatting.list_join": "{style, select, punctuation {, } conjunction { {sp}and } other { }}",
|
||||
"courseCelebration.recommendations.browse_catalog": "Explore more courses",
|
||||
"courseCelebration.recommendations.loading_recommendations": "Loading recommendations",
|
||||
@@ -282,33 +283,33 @@
|
||||
"courseCelebration.certificateHeader.notAvailable": "Your grade and certificate status will be available soon.",
|
||||
"courseCelebration.certificateBody.notAvailable.accessCertificate": "If you have earned a passing grade, your certificate will be automatically issued.",
|
||||
"courseCelebration.certificateHeader.unverified": "You must complete verification to receive your certificate.",
|
||||
"courseCelebration.certificateHeader.requestable": "Congratulations, you qualified for a certificate!",
|
||||
"courseCelebration.certificateHeader.requestable": "恭喜!您已具备获得证书的资格!",
|
||||
"courseCelebration.certificateHeader.upgradable": "Upgrade to pursue a verified certificate",
|
||||
"courseCelebration.certificateImage": "Sample certificate",
|
||||
"courseCelebration.completedCourseHeader": "You have completed your course.",
|
||||
"courseCelebration.congratulationsHeader": "Congratulations!",
|
||||
"courseCelebration.congratulationsHeader": "恭喜!",
|
||||
"courseCelebration.congratulationsImage": "Four people raising their hands in celebration",
|
||||
"courseExit.courseInProgressDescription": "It looks like there is more content in this course that will be released in the future. Look out for email updates or check back on your course for when this content will be available.",
|
||||
"courseExit.courseInProgressHeader": "More content is coming soon!",
|
||||
"courseExit.dashboardLink": "Dashboard",
|
||||
"courseExit.dashboardLink": "课程面板",
|
||||
"courseExit.endOfCourseDescription": "Unfortunately, you are not currently eligible for a certificate. You need to receive a passing grade to be eligible for a certificate.",
|
||||
"courseExit.endOfCourseHeader": "You’ve reached the end of the course!",
|
||||
"courseExit.endOfCourseTitle": "End of Course",
|
||||
"courseExit.idVerificationSupportLink": "Learn more about ID verification",
|
||||
"courseCelebration.linkedinAddToProfileButton": "Add to LinkedIn profile",
|
||||
"courseCelebration.linkedinAddToProfileButton": "添加至LinkedIn用户资料中",
|
||||
"courseExit.programs.microBachelors.learnMore": "Learn more about how your MicroBachelors credential can be applied for credit.",
|
||||
"courseExit.programs.microMasters.learnMore": "Learn more about the process of applying MicroMasters certificates to Master’s degrees.",
|
||||
"courseExit.programs.microMasters.mastersMessage": "If you’re interested in using your MicroMasters certificate towards a Master’s program, you can get started today!",
|
||||
"learn.sequence.navigation.complete.button": "Complete the course",
|
||||
"learn.sequence.navigation.complete.button": "完成课程",
|
||||
"courseExit.nextButton.endOfCourse": "Next (end of course)",
|
||||
"courseExit.profileLink": "Profile",
|
||||
"courseExit.profileLink": "个人主页",
|
||||
"courseExit.programs.lastCourse": "You have completed the last course in {title}!",
|
||||
"courseCelebration.requestCertificateBodyText": "In order to access your certificate, request it below.",
|
||||
"courseCelebration.requestCertificateButton": "Request certificate",
|
||||
"courseExit.searchOurCatalogLink": "Search our catalog",
|
||||
"courseCelebration.shareMessage": "Share your success on social media or email.",
|
||||
"courseExit.social.shareCompletionMessage": "I just completed {title} with {platform}!",
|
||||
"courseExit.upgradeButton": "Upgrade now",
|
||||
"courseExit.upgradeButton": "马上升级",
|
||||
"courseExit.upgradeLink": "upgrade now",
|
||||
"courseCelebration.verificationPending": "Your ID verification is pending and your certificate will be available once approved.",
|
||||
"courseExit.verifiedCertificateSupportLink": "Learn more about verified certificates",
|
||||
@@ -319,31 +320,31 @@
|
||||
"courseExit.viewGradesButton": "View grades",
|
||||
"courseExit.programCompletion.dashboardMessage": "To view your certificate status, check the Programs section of your {programLink}.",
|
||||
"courseExit.upgradeFootnote": "Access to this course and its materials are available on your dashboard until {expirationDate}. To extend access, {upgradeLink}.",
|
||||
"learn.course.license.allRightsReserved.text": "All Rights Reserved",
|
||||
"learn.course.license.allRightsReserved.text": "保留所有权利",
|
||||
"learn.course.license.creativeCommons.terms.preamble": "Creative Commons licensed content, with terms as follows:",
|
||||
"learn.course.license.creativeCommons.terms.by": "Attribution",
|
||||
"learn.course.license.creativeCommons.terms.nc": "Noncommercial",
|
||||
"learn.course.license.creativeCommons.terms.nd": "No Derivatives",
|
||||
"learn.course.license.creativeCommons.terms.sa": "Share Alike",
|
||||
"learn.course.license.creativeCommons.terms.by": "署名",
|
||||
"learn.course.license.creativeCommons.terms.nc": "非营利性的",
|
||||
"learn.course.license.creativeCommons.terms.nd": "无相关衍生作品",
|
||||
"learn.course.license.creativeCommons.terms.sa": "分享",
|
||||
"learn.course.license.creativeCommons.terms.zero": "No terms",
|
||||
"learn.course.license.creativeCommons.text": "Some Rights Reserved",
|
||||
"learn.breadcrumb.navigation.course.home": "Course",
|
||||
"learn.course.license.creativeCommons.text": "保留部分权利",
|
||||
"learn.breadcrumb.navigation.course.home": "课程",
|
||||
"notification.tray.container": "Notification tray",
|
||||
"notification.open.button": "Show notification tray",
|
||||
"notification.close.button": "Close notification tray",
|
||||
"responsive.close.notification": "Back to course",
|
||||
"notification.tray.title": "Notifications",
|
||||
"notification.tray.title": "通知",
|
||||
"notification.tray.no.message": "You have no new notifications at this time.",
|
||||
"learn.contentLock.content.locked": "Content Locked",
|
||||
"learn.contentLock.content.locked": "未解锁内容",
|
||||
"learn.contentLock.complete.prerequisite": "You must complete the prerequisite: ''{prereqSectionName}'' to access this content.",
|
||||
"learn.contentLock.goToSection": "Go To Prerequisite Section",
|
||||
"learn.hiddenAfterDue.gradeAvailable": "If you have completed this assignment, your grade is available on the {progressPage}.",
|
||||
"learn.hiddenAfterDue.header": "The due date for this assignment has passed.",
|
||||
"learn.hiddenAfterDue.header": "本次作业已经过了截止日期。",
|
||||
"learn.hiddenAfterDue.description": "Because the due date has passed, this assignment is no longer available.",
|
||||
"learn.hiddenAfterDue.progressPage": "progress page",
|
||||
"learn.honorCode.content": "Honesty and academic integrity are important to {siteName} and the institutions providing courses and programs on the {siteName} site. By clicking “I agree” below, I confirm that I have read, understand, and will abide by the {link} for the {siteName} Site.",
|
||||
"learn.honorCode.name": "Honor Code",
|
||||
"learn.honorCode.cancel": "Cancel",
|
||||
"learn.honorCode.name": "诚信准则",
|
||||
"learn.honorCode.cancel": "取消",
|
||||
"learn.honorCode.agree": "I agree",
|
||||
"learn.lockPaywall.title": "Graded assignments are locked",
|
||||
"learn.lockPaywall.content": "Upgrade to gain access to locked features like this one and get the most out of your course.",
|
||||
@@ -357,25 +358,25 @@
|
||||
"learn.loading.content.lock": "Loading locked content messaging...",
|
||||
"learn.loading.learning.sequence": "Loading learning sequence...",
|
||||
"learn.sequence.no.content": "There is no content here.",
|
||||
"learn.sequence.navigation.next.button": "Next",
|
||||
"learn.sequence.navigation.next.button": "下一节",
|
||||
"learn.sequence.navigation.next.up.button": "Next Up: {title}",
|
||||
"learn.sequence.navigation.previous.button": "Previous",
|
||||
"learn.sequence.navigation.previous.button": "上一节",
|
||||
"learn.course.sequence.navigation.mobile.menu": "{current} of {total}",
|
||||
"learn.sequence.share.button": "Share this content",
|
||||
"learn.sequence.share.modal.title": "Title",
|
||||
"learn.sequence.share.modal.title": "标题",
|
||||
"learn.sequence.share.modal.body": "Copy the link below to share this content.",
|
||||
"learn.sequence.share.quote": "Here's a fun clip from a class I'm taking on @edXonline.\n",
|
||||
"discussions.sidebar.title": "Discussions",
|
||||
"discussions.sidebar.title": "讨论",
|
||||
"discussions.sidebar.open.button": "Show discussions tray",
|
||||
"learn.redirect.interstitial.message": "Redirecting...",
|
||||
"learn.loading.error": "Error: {error}",
|
||||
"learn.loading.error": "错误: {error}",
|
||||
"learning.celebration.emailBody": "What are you spending your time learning?",
|
||||
"learning.social.shareEmail": "Share your progress via email.",
|
||||
"learning.social.shareService": "Share your progress on {service}.",
|
||||
"general.altText.close": "Close",
|
||||
"learning.logistration.register": "register",
|
||||
"learning.logistration.login": "sign in",
|
||||
"general.signIn.sentenceCase": "Sign in",
|
||||
"general.altText.close": "关闭",
|
||||
"learning.logistration.register": "注册",
|
||||
"learning.logistration.login": "登录",
|
||||
"general.signIn.sentenceCase": "登录",
|
||||
"learn.course.tabs.navigation.overflow.menu": "More...",
|
||||
"learning.offer.screenReaderPrices": "Original price: {originalPrice}, discount price: {discountedPrice}",
|
||||
"learning.upgradeButton.screenReaderInlinePrices": "Original price: {originalPrice}",
|
||||
@@ -395,7 +396,7 @@
|
||||
"learning.generic.upgradeNotification.accessExpiration": "Upgrade your course today",
|
||||
"learning.generic.upgradeNotification.accessExpirationUrgent": "Course Access Expiration",
|
||||
"learning.generic.upgradeNotification.accessExpirationPast": "Course Access Expiration",
|
||||
"learning.generic.upgradeNotification.pursueAverifiedCertificate": "Pursue a verified certificate",
|
||||
"learning.generic.upgradeNotification.pursueAverifiedCertificate": "获取认证证书",
|
||||
"learning.generic.upgradeNotification.code": "Use code {code} at checkout",
|
||||
"learning.generic.upsell.verifiedCertBullet.verifiedCert": "verified certificate",
|
||||
"learning.generic.upsell.verifiedCertBullet": "Earn a {verifiedCertLink} of completion to showcase on your resumé",
|
||||
@@ -411,8 +412,8 @@
|
||||
"tours.abandonTour.launchTourCheckpoint.body": "Feeling lost? Launch the tour any time for some quick tips to get the most out of the experience.",
|
||||
"tours.sequenceNavigationCheckpoint.body": "The top bar within your course allows you to easily jump to different sections and shows you what’s coming up.",
|
||||
"tours.existingUserTour.launchTourCheckpoint.body": "We’ve recently added a few new features to the course experience. Want some help looking around? Take a tour to learn more.",
|
||||
"tours.button.dismiss": "Dismiss",
|
||||
"tours.button.next": "Next",
|
||||
"tours.button.dismiss": "忽略",
|
||||
"tours.button.next": "下一节",
|
||||
"tours.button.okay": "Okay",
|
||||
"tours.button.beginTour": "Begin tour",
|
||||
"tours.button.launchTour": "Launch tour",
|
||||
@@ -434,7 +435,7 @@
|
||||
"learning.effortEstimation.activities": "{activityCount, plural, one {# activity} other {# activities}}",
|
||||
"learning.effortEstimation.minutesAbbreviated": "{minuteCount, plural, one {# min} other {# min}}",
|
||||
"learning.effortEstimation.minutesFull": "{minuteCount, plural, one {# minute} other {# minutes}}",
|
||||
"learning.streakCelebration.congratulations": "Congratulations!",
|
||||
"learning.streakCelebration.congratulations": "恭喜!",
|
||||
"learning.streakCelebration.body": "Keep it up, you’re on a roll!",
|
||||
"learning.streakCelebration.button": "Keep it up",
|
||||
"learning.streakCelebration.buttonSrOnly": "Close modal and continue",
|
||||
|
||||
@@ -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',
|
||||
@@ -135,6 +136,9 @@ initialize({
|
||||
TWITTER_HASHTAG: process.env.TWITTER_HASHTAG || null,
|
||||
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');
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user