feat: whole course translations (#1330)

* feat: add language selection

chore: update tests so we have less error message

test: update test

* test: update tests

* chore: remove duplicate translation

* chore: lint for console

* chore: remove comments

* chore: make sure the affect url frame refresh after the language selection change

* chore: add whole_course_translation and language to courseware meta (#1305)

* feat: Add feedback widget UI mock

Add unit tests

Fix snapshot

Clean Sequence component logEvent calls

Clean unit test

Put feedback widget behind whole course translation flag

Fix useFeedbackWidget test

* chore: add src and dest translation

* feat: first iteration of plugin translation

chore: update plugin instruction

* feat: Connect FeedbackWidget with backend services (#1325)

Connect FeedbackWidget with backend services

Move feedback widget to unit translation plugin

* feat: Add authentication to WCT feedback endpoints (#1329)

* chore: add fetch config and move feedback widget for the plugin

chore: rewrite and test the api request

chore: rebase

chore: update translation feedback

chore: test

chore: add more tests

* chore: rebase

* chore: update requested change

* chore: update package

* chore: upgrade frontend-lib-special-exams and frontend-lib-learning-assistant

* chore: update tests

* chore: remove unneeded package

* chore: update example config

* chore: add source-map-loader

* fix: feedback widget render error after submit feedback (#1335)

* fix: feedback widget render error after submit feedback

* fix: widget logic

---------

Co-authored-by: Rodrigo Martin <rodrigom_94@hotmail.com>
This commit is contained in:
Leangseu Kim
2024-03-27 13:39:36 -04:00
committed by GitHub
parent 3917262492
commit 36b3c36379
91 changed files with 4865 additions and 4582 deletions

View File

@@ -5,6 +5,7 @@ import React, {
import { getConfig } from '@edx/frontend-platform';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import {
sendTrackEvent,
sendTrackingLogEvent,
@@ -37,7 +38,10 @@ const Sequence = ({
previousSequenceHandler,
}) => {
const intl = useIntl();
const course = useModel('coursewareMeta', courseId);
const {
canAccessProctoredExams,
license,
} = useModel('coursewareMeta', courseId);
const {
isStaff,
originalUserIsStaff,
@@ -144,57 +148,60 @@ const Sequence = ({
const gated = sequence && sequence.gatedContent !== undefined && sequence.gatedContent.gated;
const defaultContent = (
<div className="sequence-container d-inline-flex flex-row w-100">
<div className={classNames('sequence w-100', { 'position-relative': shouldDisplayNotificationTriggerInSequence })}>
<div className="sequence-navigation-container">
<SequenceNavigation
sequenceId={sequenceId}
unitId={unitId}
className="mb-4"
nextHandler={() => {
logEvent('edx.ui.lms.sequence.next_selected', 'top');
handleNext();
}}
onNavigate={(destinationUnitId) => {
logEvent('edx.ui.lms.sequence.tab_selected', 'top', destinationUnitId);
handleNavigate(destinationUnitId);
}}
previousHandler={() => {
logEvent('edx.ui.lms.sequence.previous_selected', 'top');
handlePrevious();
}}
/>
{shouldDisplayNotificationTriggerInSequence && (
enableNewSidebar === 'true' ? <NewSidebarTriggers /> : <SidebarTriggers />
)}
</div>
<>
<div className="sequence-container d-inline-flex flex-row w-100">
<div className={classNames('sequence w-100', { 'position-relative': shouldDisplayNotificationTriggerInSequence })}>
<div className="sequence-navigation-container">
<SequenceNavigation
sequenceId={sequenceId}
unitId={unitId}
className="mb-4"
nextHandler={() => {
logEvent('edx.ui.lms.sequence.next_selected', 'top');
handleNext();
}}
onNavigate={(destinationUnitId) => {
logEvent('edx.ui.lms.sequence.tab_selected', 'top', destinationUnitId);
handleNavigate(destinationUnitId);
}}
previousHandler={() => {
logEvent('edx.ui.lms.sequence.previous_selected', 'top');
handlePrevious();
}}
/>
{shouldDisplayNotificationTriggerInSequence && (
enableNewSidebar === 'true' ? <NewSidebarTriggers /> : <SidebarTriggers />
)}
</div>
<div className="unit-container flex-grow-1">
<SequenceContent
courseId={courseId}
gated={gated}
sequenceId={sequenceId}
unitId={unitId}
unitLoadedHandler={handleUnitLoaded}
/>
{unitHasLoaded && (
<UnitNavigation
sequenceId={sequenceId}
unitId={unitId}
onClickPrevious={() => {
logEvent('edx.ui.lms.sequence.previous_selected', 'bottom');
handlePrevious();
}}
onClickNext={() => {
logEvent('edx.ui.lms.sequence.next_selected', 'bottom');
handleNext();
}}
/>
)}
<div className="unit-container flex-grow-1">
<SequenceContent
courseId={courseId}
gated={gated}
sequenceId={sequenceId}
unitId={unitId}
unitLoadedHandler={handleUnitLoaded}
/>
{unitHasLoaded && (
<UnitNavigation
sequenceId={sequenceId}
unitId={unitId}
onClickPrevious={() => {
logEvent('edx.ui.lms.sequence.previous_selected', 'bottom');
handlePrevious();
}}
onClickNext={() => {
logEvent('edx.ui.lms.sequence.next_selected', 'bottom');
handleNext();
}}
/>
)}
</div>
</div>
{enableNewSidebar === 'true' ? <NewSidebar /> : <Sidebar />}
</div>
{enableNewSidebar === 'true' ? <NewSidebar /> : <Sidebar />}
</div>
<div id="whole-course-translation-feedback-widget" />
</>
);
if (sequenceStatus === 'loaded') {
@@ -205,11 +212,11 @@ const Sequence = ({
courseId={courseId}
isStaff={isStaff}
originalUserIsStaff={originalUserIsStaff}
canAccessProctoredExams={course.canAccessProctoredExams}
canAccessProctoredExams={canAccessProctoredExams}
>
{defaultContent}
</SequenceExamWrapper>
<CourseLicense license={course.license || undefined} />
<CourseLicense license={license || undefined} />
</div>
);
}

View File

@@ -5,7 +5,7 @@ import { ErrorPage } from '@edx/frontend-platform/react';
import { StrictDict } from '@edx/react-unit-test-utils';
import { ModalDialog, Modal } from '@openedx/paragon';
import PageLoading from '../../../../generic/PageLoading';
import PageLoading from '@src/generic/PageLoading';
import * as hooks from './hooks';
/**

View File

@@ -4,7 +4,7 @@ import { ErrorPage } from '@edx/frontend-platform/react';
import { ModalDialog, Modal } from '@openedx/paragon';
import { shallow } from '@edx/react-unit-test-utils';
import PageLoading from '../../../../generic/PageLoading';
import PageLoading from '@src/generic/PageLoading';
import * as hooks from './hooks';
import ContentIFrame, { IFRAME_FEATURE_POLICY, testIDs } from './ContentIFrame';
@@ -19,7 +19,7 @@ jest.mock('@openedx/paragon', () => jest.requireActual('@edx/react-unit-test-uti
},
}));
jest.mock('../../../../generic/PageLoading', () => 'PageLoading');
jest.mock('@src/generic/PageLoading', () => 'PageLoading');
jest.mock('./hooks', () => ({
useIFrameBehavior: jest.fn(),

View File

@@ -3,8 +3,8 @@ import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useModel } from '../../../../generic/model-store';
import PageLoading from '../../../../generic/PageLoading';
import { useModel } from '@src/generic/model-store';
import PageLoading from '@src/generic/PageLoading';
import messages from '../messages';
import HonorCode from '../honor-code';

View File

@@ -2,8 +2,8 @@ import React from 'react';
import { formatMessage, shallow } from '@edx/react-unit-test-utils';
import { useModel } from '../../../../generic/model-store';
import PageLoading from '../../../../generic/PageLoading';
import { useModel } from '@src/generic/model-store';
import PageLoading from '@src/generic/PageLoading';
import messages from '../messages';
import HonorCode from '../honor-code';
@@ -25,8 +25,8 @@ jest.mock('react', () => ({
jest.mock('../honor-code', () => 'HonorCode');
jest.mock('../lock-paywall', () => 'LockPaywall');
jest.mock('../../../../generic/model-store', () => ({ useModel: jest.fn() }));
jest.mock('../../../../generic/PageLoading', () => 'PageLoading');
jest.mock('@src/generic/model-store', () => ({ useModel: jest.fn() }));
jest.mock('@src/generic/PageLoading', () => 'PageLoading');
jest.mock('./hooks', () => ({
useShouldDisplayHonorCode: jest.fn(() => false),

View File

@@ -20,11 +20,24 @@ exports[`Unit component output snapshot: not bookmarked, do not show content 1`]
<div
className="unit"
>
<h1
className="mb-0 h3"
<div
className="mb-0"
>
unit-title
</h1>
<h3
className="h3"
>
unit-title
</h3>
<PluginSlot
id="unit_title_plugin"
pluginProps={
Object {
"courseId": "test-course-id",
"unitId": "test-props-id",
}
}
/>
</div>
<h2
className="sr-only"
>

View File

@@ -5,9 +5,9 @@ import { useDispatch } from 'react-redux';
import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils';
import { logError } from '@edx/frontend-platform/logging';
import { fetchCourse } from '../../../../data';
import { processEvent } from '../../../../../course-home/data/thunks';
import { useEventListener } from '../../../../../generic/hooks';
import { fetchCourse } from '@src/courseware/data';
import { processEvent } from '@src/course-home/data/thunks';
import { useEventListener } from '@src/generic/hooks';
import { messageTypes } from '../constants';
import useLoadBearingHook from './useLoadBearingHook';

View File

@@ -5,9 +5,9 @@ import { getEffects, mockUseKeyedState } from '@edx/react-unit-test-utils';
import { logError } from '@edx/frontend-platform/logging';
import { getConfig } from '@edx/frontend-platform';
import { fetchCourse } from '../../../../data';
import { processEvent } from '../../../../../course-home/data/thunks';
import { useEventListener } from '../../../../../generic/hooks';
import { fetchCourse } from '@src/courseware/data';
import { processEvent } from '@src/course-home/data/thunks';
import { useEventListener } from '@src/generic/hooks';
import { messageTypes } from '../constants';
@@ -33,13 +33,13 @@ jest.mock('@edx/frontend-platform/logging', () => ({
logError: jest.fn(),
}));
jest.mock('../../../../data', () => ({
jest.mock('@src/courseware/data', () => ({
fetchCourse: jest.fn(),
}));
jest.mock('../../../../../course-home/data/thunks', () => ({
jest.mock('@src/course-home/data/thunks', () => ({
processEvent: jest.fn((...args) => ({ processEvent: args })),
}));
jest.mock('../../../../../generic/hooks', () => ({
jest.mock('@src/generic/hooks', () => ({
useEventListener: jest.fn(),
}));

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils/dist';
import { useEventListener } from '../../../../../generic/hooks';
import { useEventListener } from '@src/generic/hooks';
export const stateKeys = StrictDict({
isOpen: 'isOpen',

View File

@@ -1,5 +1,5 @@
import { mockUseKeyedState } from '@edx/react-unit-test-utils';
import { useEventListener } from '../../../../../generic/hooks';
import { useEventListener } from '@src/generic/hooks';
import { messageTypes } from '../constants';
import useModalIFrameData, { stateKeys, DEFAULT_HEIGHT } from './useModalIFrameData';
@@ -8,7 +8,7 @@ jest.mock('react', () => ({
...jest.requireActual('react'),
useCallback: jest.fn((cb, prereqs) => ({ cb, prereqs })),
}));
jest.mock('../../../../../generic/hooks', () => ({
jest.mock('@src/generic/hooks', () => ({
useEventListener: jest.fn(),
}));

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils/dist';
import { useModel } from '../../../../../generic/model-store';
import { useModel } from '@src/generic/model-store';
import { modelKeys } from '../constants';

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { getEffects, mockUseKeyedState } from '@edx/react-unit-test-utils';
import { useModel } from '../../../../../generic/model-store';
import { useModel } from '@src/generic/model-store';
import { modelKeys } from '../constants';
@@ -11,7 +11,7 @@ jest.mock('react', () => ({
...jest.requireActual('react'),
useEffect: jest.fn(),
}));
jest.mock('../../../../../generic/model-store', () => ({
jest.mock('@src/generic/model-store', () => ({
useModel: jest.fn(),
}));

View File

@@ -4,8 +4,10 @@ import React from 'react';
import { AppContext } from '@edx/frontend-platform/react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useModel } from '../../../../generic/model-store';
import { useModel } from '@src/generic/model-store';
import { usePluginsCallback } from '@src/generic/plugin-store';
import { PluginSlot } from '@openedx/frontend-plugin-framework';
import BookmarkButton from '../../bookmark/BookmarkButton';
import messages from '../messages';
import ContentIFrame from './ContentIFrame';
@@ -28,16 +30,27 @@ const Unit = ({
const isProcessing = unit.bookmarkedUpdateState === 'loading';
const view = authenticatedUser ? views.student : views.public;
const iframeUrl = getIFrameUrl({
const getUrl = usePluginsCallback('getIFrameUrl', () => getIFrameUrl({
id,
view,
format,
examAccess,
});
}));
const iframeUrl = getUrl();
return (
<div className="unit">
<h1 className="mb-0 h3">{unit.title}</h1>
<div className="mb-0">
<h3 className="h3">{unit.title}</h3>
<PluginSlot
id="unit_title_plugin"
pluginProps={{
courseId,
unitId: id,
}}
/>
</div>
<h2 className="sr-only">{formatMessage(messages.headerPlaceholder)}</h2>
<BookmarkButton
unitId={unit.id}

View File

@@ -1,7 +1,8 @@
import React from 'react';
import { when } from 'jest-when';
import { formatMessage, shallow } from '@edx/react-unit-test-utils/dist';
import { useModel } from '../../../../generic/model-store';
import { useModel } from '@src/generic/model-store';
import BookmarkButton from '../../bookmark/BookmarkButton';
import UnitSuspense from './UnitSuspense';
@@ -9,7 +10,7 @@ import ContentIFrame from './ContentIFrame';
import Unit from '.';
import messages from '../messages';
import { getIFrameUrl } from './urls';
import { views } from './constants';
import { modelKeys, views } from './constants';
import * as hooks from './hooks';
jest.mock('./hooks', () => ({ useUnitData: jest.fn() }));
@@ -22,14 +23,14 @@ jest.mock('@edx/frontend-platform/i18n', () => {
};
});
jest.mock('../../../../generic/PageLoading', () => 'PageLoading');
jest.mock('@src/generic/PageLoading', () => 'PageLoading');
jest.mock('../../bookmark/BookmarkButton', () => 'BookmarkButton');
jest.mock('./ContentIFrame', () => 'ContentIFrame');
jest.mock('./UnitSuspense', () => 'UnitSuspense');
jest.mock('../honor-code', () => 'HonorCode');
jest.mock('../lock-paywall', () => 'LockPaywall');
jest.mock('../../../../generic/model-store', () => ({
jest.mock('@src/generic/model-store', () => ({
useModel: jest.fn(),
}));
@@ -70,7 +71,14 @@ const unit = {
bookmarked: false,
bookmarkedUpdateState: 'pending',
};
useModel.mockReturnValue(unit);
const mockCoursewareMetaFn = jest.fn(() => ({ wholeCourseTranslationEnabled: false }));
const mockUnitsFn = jest.fn(() => unit);
when(useModel)
.calledWith('courseHomeMeta', props.courseId)
.mockImplementation(mockCoursewareMetaFn)
.calledWith(modelKeys.units, props.id)
.mockImplementation(mockUnitsFn);
let el;
describe('Unit component', () => {
@@ -113,7 +121,7 @@ describe('Unit component', () => {
});
describe('bookmarked, bookmark update pending', () => {
beforeEach(() => {
useModel.mockReturnValueOnce({ ...unit, bookmarked: true });
mockUnitsFn.mockReturnValueOnce({ ...unit, bookmarked: true });
renderComponent();
});
test('snapshot', () => {

View File

@@ -19,24 +19,43 @@ const props = {
examAccess: { blockAccess: false, accessToken: 'test-access-token' },
};
describe('urls module', () => {
describe('getIFrameUrl', () => {
test('format provided, exam access and token available', () => {
const params = stringify({
...iframeParams,
view: props.view,
format: props.format,
exam_access: props.examAccess.accessToken,
});
expect(getIFrameUrl(props)).toEqual(`${config.LMS_BASE_URL}/xblock/${props.id}?${params}`);
describe('urls module getIFrameUrl', () => {
test('format provided, exam access and token available', () => {
const params = stringify({
...iframeParams,
view: props.view,
format: props.format,
exam_access: props.examAccess.accessToken,
});
test('no format provided, exam access blocked', () => {
const params = stringify({ ...iframeParams, view: props.view });
expect(getIFrameUrl({
id: props.id,
view: props.view,
examAccess: { blockAccess: true },
})).toEqual(`${config.LMS_BASE_URL}/xblock/${props.id}?${params}`);
expect(getIFrameUrl(props)).toEqual(`${config.LMS_BASE_URL}/xblock/${props.id}?${params}`);
});
test('no format provided, exam access blocked', () => {
const params = stringify({ ...iframeParams, view: props.view });
expect(getIFrameUrl({
id: props.id,
view: props.view,
examAccess: { blockAccess: true },
})).toEqual(`${config.LMS_BASE_URL}/xblock/${props.id}?${params}`);
});
test('src and dest languages provided', () => {
const params = stringify({
...iframeParams,
view: props.view,
src_lang: 'test-src-lang',
dest_lang: 'test-dest-lang',
});
expect(getIFrameUrl({
...props,
srcLanguage: 'test-src-lang',
destLanguage: 'test-dest-lang',
})).toEqual(`${config.LMS_BASE_URL}/xblock/${props.id}?${params}`);
});
test('src and dest languages provided are the same', () => {
const params = stringify({ ...iframeParams, view: props.view });
expect(getIFrameUrl({
...props,
srcLanguage: 'test-lang',
destLanguage: 'test-lang',
})).toEqual(`${config.LMS_BASE_URL}/xblock/${props.id}?${params}`);
});
});

View File

@@ -31,8 +31,7 @@ const HiddenAfterDue = ({ courseId, intl }) => {
<>
<br />
<FormattedMessage
id="learn.hiddenAfterDue.gradeAvailable"
defaultMessage="If you have completed this assignment, your grade is available on the {progressPage}."
{...messages.gradeAvailable}
values={{
progressPage: progressLink,
}}

View File

@@ -79,7 +79,7 @@ const LockPaywall = ({
};
return (
<Alert variant="light" aria-live="off" icon={Locked} className="lock-paywall-container">
<Alert variant="light" aria-live="off" icon={Locked} className="lock-paywall-container" data-testId="lock-paywall-test-id">
<div className="row">
<div className="col">
<h4 aria-level="3">

View File

@@ -76,9 +76,9 @@ describe('Lock Paywall', () => {
it('does not display anything if course does not have verified mode', async () => {
const courseHomeMetadata = Factory.build('courseHomeMetadata', { verified_mode: null });
const testStore = await initializeTestStore({ courseHomeMetadata, excludeFetchSequence: true }, false);
const { container } = render(<LockPaywall {...mockData} courseId={courseHomeMetadata.id} />, { store: testStore });
render(<LockPaywall {...mockData} courseId={courseHomeMetadata.id} />, { store: testStore });
expect(container).toBeEmptyDOMElement();
expect(screen.queryByTestId('lock-paywall-test-id')).not.toBeInTheDocument();
});
it('displays past expiration message if expiration date has expired', async () => {

View File

@@ -110,7 +110,7 @@ const SequenceNavigation = ({
};
return sequenceStatus === LOADED && (
<nav id="courseware-sequenceNavigation" className={classNames('sequence-navigation', className, { 'mr-2': shouldDisplayNotificationTriggerInSequence })}>
<nav id="courseware-sequence-navigation" data-testid="courseware-sequence-navigation" className={classNames('sequence-navigation', className, { 'mr-2': shouldDisplayNotificationTriggerInSequence })}>
{renderPreviousButton()}
{renderUnitButtons()}
{renderNextButton()}

View File

@@ -33,9 +33,9 @@ describe('Sequence Navigation', () => {
it('is empty while loading', async () => {
const testStore = await initializeTestStore({ excludeFetchSequence: true }, false);
const { container } = render(<SequenceNavigation {...mockData} />, { store: testStore, wrapWithRouter: true });
render(<SequenceNavigation {...mockData} />, { store: testStore, wrapWithRouter: true });
expect(container).toBeEmptyDOMElement();
expect(screen.queryByTestId('courseware-sequence-navigation')).not.toBeInTheDocument();
});
it('renders empty div without unitId', () => {

View File

@@ -12,7 +12,7 @@ const SequenceNavigationDropdown = ({
unitIds,
}) => (
<Dropdown className="sequence-navigation-dropdown">
<Dropdown.Toggle variant="link" className="font-weight-normal w-100 border-right-0">
<Dropdown.Toggle id="navigation-toggle" variant="link" className="font-weight-normal w-100 border-right-0">
<FormattedMessage
defaultMessage="{current} of {total}"
description="The title of the mobile menu for sequence navigation of units"