chore: remove UnitTranslationPlugin (#1352)

* chore: remove UnitTranslationPlugin

* chore: add plugin slot for sequence
This commit is contained in:
Leangseu Kim
2024-04-22 12:14:33 -04:00
committed by GitHub
parent 75f56ea4bd
commit 72381a783b
36 changed files with 9 additions and 1937 deletions

1
.env
View File

@@ -4,7 +4,6 @@
NODE_ENV='production'
ACCESS_TOKEN_COOKIE_NAME=''
AI_TRANSLATIONS_URL=''
BASE_URL=''
CONTACT_URL=''
CREDENTIALS_BASE_URL=''

View File

@@ -4,7 +4,6 @@
NODE_ENV='development'
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
AI_TRANSLATIONS_URL='http://localhost:18760'
BASE_URL='http://localhost:2000'
CONTACT_URL='http://localhost:18000/contact'
CREDENTIALS_BASE_URL='http://localhost:18150'

View File

@@ -4,7 +4,6 @@
NODE_ENV='test'
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
AI_TRANSLATIONS_URL='http://localhost:18760'
BASE_URL='http://localhost:2000'
CONTACT_URL='http://localhost:18000/contact'
CREDENTIALS_BASE_URL='http://localhost:18150'

View File

@@ -1,4 +1,4 @@
import UnitTranslationPlugin from '@plugins/UnitTranslationPlugin';
import UnitTranslationPlugin from '@edx/unit-translation-selector-plugin';
import { PLUGIN_OPERATIONS, DIRECT_PLUGIN } from '@openedx/frontend-plugin-framework';
// Load environment variables from .env file

View File

@@ -15,7 +15,6 @@ const config = createConfig('jest', {
// See https://stackoverflow.com/questions/72382316/jest-encountered-an-unexpected-token-react-markdown
'react-markdown': '<rootDir>/node_modules/react-markdown/react-markdown.min.js',
'@src/(.*)': '<rootDir>/src/$1',
'@plugins/(.*)': '<rootDir>/plugins/$1',
},
testTimeout: 30000,
globalSetup: "./global-setup.js",

View File

@@ -1,17 +0,0 @@
## How to develop plugin
You can define plugin in `env.config.jsx` see `example.env.config.jsx` as example.
## Current caveat
- The way for how I deal with override method is still wonky
- The redux still require middleware to ignore the plugin's action from serializing
- I am not sure how it behave with useCallback, useMemo, ...etc
- There are still open question on how to write it properly
## Current work that should consider core part and extendable for the future plugin framework
- `usePluingsCallback` is the callback supose to be some level of equality to be using `React.useCallback`. It would try to execute the function, then any plugin that try `registerOverrideMethod`. The order of the it being run isn't the determined. There are a couple things I want to add:
- I might consider testing it with `zustand` library to make sure it is portable and not rely on `redux`. I tried to do this with provider, but it seems to run into infinite loop of trigger changed.
- `registerOverrideMethod` is working like a way to register callback that behave like a middleware. It ran the default one, then pass the result of the default one to the plugin. Any plugin that register the override can update the value. Alternatively, we can override the function completely instead applying each affect. Or we can support both. But it requires a bit more thought out architecture.

View File

@@ -1,29 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<UnitTranslationPlugin /> render TranslationSelection when translation is enabled and language is available 1`] = `
<TranslationSelection
availableLanguages={
Array [
"en",
]
}
courseId="courseId"
id="id"
language="en"
unitId="unitId"
/>
`;
exports[`<UnitTranslationPlugin /> render translation when the user is staff 1`] = `
<TranslationSelection
availableLanguages={
Array [
"en",
]
}
courseId="courseId"
id="id"
language="en"
unitId="unitId"
/>
`;

View File

@@ -1,90 +0,0 @@
import { getConfig, camelCaseObject } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { logError } from '@edx/frontend-platform/logging';
import { stringify } from 'query-string';
export const fetchTranslationConfig = async (courseId) => {
const url = `${
getConfig().LMS_BASE_URL
}/api/translatable_xblocks/config/?course_id=${encodeURIComponent(courseId)}`;
try {
const { data } = await getAuthenticatedHttpClient().get(url);
return {
enabled: data.feature_enabled,
availableLanguages: data.available_translation_languages || [
{
code: 'en',
label: 'English',
},
{
code: 'es',
label: 'Spanish',
},
],
};
} catch (error) {
logError(`Translation plugin fail to fetch from ${url}`, error);
return {
enabled: false,
availableLanguages: [],
};
}
};
export async function getTranslationFeedback({
courseId,
translationLanguage,
unitId,
userId,
}) {
const params = stringify({
translation_language: translationLanguage,
course_id: encodeURIComponent(courseId),
unit_id: encodeURIComponent(unitId),
user_id: userId,
});
const fetchFeedbackUrl = `${
getConfig().AI_TRANSLATIONS_URL
}/api/v1/whole-course-translation-feedback?${params}`;
try {
const { data } = await getAuthenticatedHttpClient().get(fetchFeedbackUrl);
return camelCaseObject(data);
} catch (error) {
logError(
`Translation plugin fail to fetch from ${fetchFeedbackUrl}`,
error,
);
return {};
}
}
export async function createTranslationFeedback({
courseId,
feedbackValue,
translationLanguage,
unitId,
userId,
}) {
const createFeedbackUrl = `${
getConfig().AI_TRANSLATIONS_URL
}/api/v1/whole-course-translation-feedback/`;
try {
const { data } = await getAuthenticatedHttpClient().post(
createFeedbackUrl,
{
course_id: courseId,
feedback_value: feedbackValue,
translation_language: translationLanguage,
unit_id: unitId,
user_id: userId,
},
);
return camelCaseObject(data);
} catch (error) {
logError(
`Translation plugin fail to create feedback from ${createFeedbackUrl}`,
error,
);
return {};
}
}

View File

@@ -1,125 +0,0 @@
import { camelCaseObject } from '@edx/frontend-platform';
import { logError } from '@edx/frontend-platform/logging';
import { stringify } from 'query-string';
import {
fetchTranslationConfig,
getTranslationFeedback,
createTranslationFeedback,
} from './api';
const mockGetMethod = jest.fn();
const mockPostMethod = jest.fn();
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedHttpClient: () => ({
get: mockGetMethod,
post: mockPostMethod,
}),
}));
jest.mock('@edx/frontend-platform/logging', () => ({
logError: jest.fn(),
}));
describe('UnitTranslation api', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('fetchTranslationConfig', () => {
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const expectedResponse = {
feature_enabled: true,
available_translation_languages: [
{
code: 'en',
label: 'English',
},
{
code: 'es',
label: 'Spanish',
},
],
};
it('should fetch translation config', async () => {
const expectedUrl = `http://localhost:18000/api/translatable_xblocks/config/?course_id=${encodeURIComponent(
courseId,
)}`;
mockGetMethod.mockResolvedValueOnce({ data: expectedResponse });
const result = await fetchTranslationConfig(courseId);
expect(result).toEqual({
enabled: true,
availableLanguages: expectedResponse.available_translation_languages,
});
expect(mockGetMethod).toHaveBeenCalledWith(expectedUrl);
});
it('should return disabled and unavailable languages on error', async () => {
mockGetMethod.mockRejectedValueOnce(new Error('error'));
const result = await fetchTranslationConfig(courseId);
expect(result).toEqual({
enabled: false,
availableLanguages: [],
});
expect(logError).toHaveBeenCalled();
});
});
describe('getTranslationFeedback', () => {
const props = {
courseId: 'course-v1:edX+DemoX+Demo_Course',
translationLanguage: 'es',
unitId: 'unit-v1:edX+DemoX+Demo_Course+type@video+block@video',
userId: 'test_user',
};
const expectedResponse = {
feedback: 'good',
};
it('should fetch translation feedback', async () => {
const params = stringify({
translation_language: props.translationLanguage,
course_id: encodeURIComponent(props.courseId),
unit_id: encodeURIComponent(props.unitId),
user_id: props.userId,
});
const expectedUrl = `http://localhost:18760/api/v1/whole-course-translation-feedback?${params}`;
mockGetMethod.mockResolvedValueOnce({ data: expectedResponse });
const result = await getTranslationFeedback(props);
expect(result).toEqual(camelCaseObject(expectedResponse));
expect(mockGetMethod).toHaveBeenCalledWith(expectedUrl);
});
it('should return empty object on error', async () => {
mockGetMethod.mockRejectedValueOnce(new Error('error'));
const result = await getTranslationFeedback(props);
expect(result).toEqual({});
expect(logError).toHaveBeenCalled();
});
});
describe('createTranslationFeedback', () => {
const props = {
courseId: 'course-v1:edX+DemoX+Demo_Course',
feedbackValue: 'good',
translationLanguage: 'es',
unitId: 'unit-v1:edX+DemoX+Demo_Course+type@video+block@video',
userId: 'test_user',
};
it('should create translation feedback', async () => {
const expectedUrl = 'http://localhost:18760/api/v1/whole-course-translation-feedback/';
mockPostMethod.mockResolvedValueOnce({});
await createTranslationFeedback(props);
expect(mockPostMethod).toHaveBeenCalledWith(expectedUrl, {
course_id: props.courseId,
feedback_value: props.feedbackValue,
translation_language: props.translationLanguage,
unit_id: props.unitId,
user_id: props.userId,
});
});
it('should log error on failure', async () => {
mockPostMethod.mockRejectedValueOnce(new Error('error'));
await createTranslationFeedback(props);
expect(logError).toHaveBeenCalled();
});
});
});

View File

@@ -1,204 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<FeedbackWidget /> render feedback widget 1`] = `
<div
className="d-none"
>
<div
className="sequence w-100"
>
<div
className="ml-4 mr-2"
>
<ActionRow>
Rate this page translation
<Spacer />
<div>
<IconButton
alt="positive-feedback"
className="m-1"
iconAs="Icon"
id="positive-feedback-button"
onClick={[MockFunction onThumbsUpClick]}
src="ThumbUpOutline"
variant="secondary"
/>
<IconButton
alt="negative-feedback"
className="mr-2"
iconAs="Icon"
id="negative-feedback-button"
onClick={[MockFunction onThumbsDownClick]}
src="ThumbDownOffAlt"
variant="secondary"
/>
</div>
<div
className="mb-1 text-light action-row-divider"
>
|
</div>
<div>
<IconButton
alt="close-feedback"
className="ml-1 mr-2 float-right"
iconAs="Icon"
id="close-feedback-button"
onClick={[MockFunction closeFeedbackWidget]}
src="Close"
variant="secondary"
/>
</div>
</ActionRow>
</div>
</div>
</div>
`;
exports[`<FeedbackWidget /> render gratitude text 1`] = `
<div
className="d-none"
>
<div
className="sequence w-100"
>
<div
className="ml-4 mr-4"
>
<ActionRow
className="m-2 justify-content-center"
>
Thank you! Your feedback matters.
</ActionRow>
</div>
</div>
</div>
`;
exports[`<FeedbackWidget /> renders hidden by default 1`] = `
<div
className="d-none"
>
<div
className="sequence w-100"
>
<div
className="ml-4 mr-2"
>
<ActionRow>
Rate this page translation
<Spacer />
<div>
<IconButton
alt="positive-feedback"
className="m-1"
iconAs="Icon"
id="positive-feedback-button"
onClick={[MockFunction onThumbsUpClick]}
src="ThumbUpOutline"
variant="secondary"
/>
<IconButton
alt="negative-feedback"
className="mr-2"
iconAs="Icon"
id="negative-feedback-button"
onClick={[MockFunction onThumbsDownClick]}
src="ThumbDownOffAlt"
variant="secondary"
/>
</div>
<div
className="mb-1 text-light action-row-divider"
>
|
</div>
<div>
<IconButton
alt="close-feedback"
className="ml-1 mr-2 float-right"
iconAs="Icon"
id="close-feedback-button"
onClick={[MockFunction closeFeedbackWidget]}
src="Close"
variant="secondary"
/>
</div>
</ActionRow>
</div>
<div
className="ml-4 mr-4"
>
<ActionRow
className="m-2 justify-content-center"
>
Thank you! Your feedback matters.
</ActionRow>
</div>
</div>
</div>
`;
exports[`<FeedbackWidget /> renders show when elemReady is true 1`] = `
<div
className="sequence-container d-inline-flex flex-row w-100"
>
<div
className="sequence w-100"
>
<div
className="ml-4 mr-2"
>
<ActionRow>
Rate this page translation
<Spacer />
<div>
<IconButton
alt="positive-feedback"
className="m-1"
iconAs="Icon"
id="positive-feedback-button"
onClick={[MockFunction onThumbsUpClick]}
src="ThumbUpOutline"
variant="secondary"
/>
<IconButton
alt="negative-feedback"
className="mr-2"
iconAs="Icon"
id="negative-feedback-button"
onClick={[MockFunction onThumbsDownClick]}
src="ThumbDownOffAlt"
variant="secondary"
/>
</div>
<div
className="mb-1 text-light action-row-divider"
>
|
</div>
<div>
<IconButton
alt="close-feedback"
className="ml-1 mr-2 float-right"
iconAs="Icon"
id="close-feedback-button"
onClick={[MockFunction closeFeedbackWidget]}
src="Close"
variant="secondary"
/>
</div>
</ActionRow>
</div>
<div
className="ml-4 mr-4"
>
<ActionRow
className="m-2 justify-content-center"
>
Thank you! Your feedback matters.
</ActionRow>
</div>
</div>
</div>
`;

View File

@@ -1,116 +0,0 @@
import React, {
useEffect, useRef, useState,
} from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { ActionRow, IconButton, Icon } from '@openedx/paragon';
import { Close, ThumbUpOutline, ThumbDownOffAlt } from '@openedx/paragon/icons';
import './index.scss';
import messages from './messages';
import useFeedbackWidget from './useFeedbackWidget';
const FeedbackWidget = ({
courseId,
translationLanguage,
unitId,
userId,
}) => {
const { formatMessage } = useIntl();
const ref = useRef(null);
const [elemReady, setElemReady] = useState(false);
const {
closeFeedbackWidget,
showFeedbackWidget,
showGratitudeText,
onThumbsUpClick,
onThumbsDownClick,
} = useFeedbackWidget({
courseId,
translationLanguage,
unitId,
userId,
});
useEffect(() => {
if (ref.current) {
const domNode = document.getElementById('whole-course-translation-feedback-widget');
domNode.appendChild(ref.current);
setElemReady(true);
}
}, [ref.current]);
return (
<div ref={ref} className={(elemReady) ? 'sequence-container d-inline-flex flex-row w-100' : 'd-none'}>
{(showFeedbackWidget || showGratitudeText) ? (
<div className="sequence w-100">
{
showFeedbackWidget && (
<div className="ml-4 mr-2">
<ActionRow>
{formatMessage(messages.rateTranslationText)}
<ActionRow.Spacer />
<div>
<IconButton
src={ThumbUpOutline}
iconAs={Icon}
alt="positive-feedback"
onClick={onThumbsUpClick}
variant="secondary"
className="m-1"
id="positive-feedback-button"
/>
<IconButton
src={ThumbDownOffAlt}
iconAs={Icon}
alt="negative-feedback"
onClick={onThumbsDownClick}
variant="secondary"
className="mr-2"
id="negative-feedback-button"
/>
</div>
<div className="mb-1 text-light action-row-divider">
|
</div>
<div>
<IconButton
src={Close}
iconAs={Icon}
alt="close-feedback"
onClick={closeFeedbackWidget}
variant="secondary"
className="ml-1 mr-2 float-right"
id="close-feedback-button"
/>
</div>
</ActionRow>
</div>
)
}
{
showGratitudeText && (
<div className="ml-4 mr-4">
<ActionRow className="m-2 justify-content-center">
{formatMessage(messages.gratitudeText)}
</ActionRow>
</div>
)
}
</div>
) : null}
</div>
);
};
FeedbackWidget.propTypes = {
courseId: PropTypes.string.isRequired,
translationLanguage: PropTypes.string.isRequired,
userId: PropTypes.string.isRequired,
unitId: PropTypes.string.isRequired,
};
FeedbackWidget.defaultProps = {};
export default FeedbackWidget;

View File

@@ -1,4 +0,0 @@
.action-row-divider {
font-size: 31px;
font-weight: 100;
}

View File

@@ -1,107 +0,0 @@
import { useState } from 'react';
import { shallow } from '@edx/react-unit-test-utils';
import FeedbackWidget from './index';
import useFeedbackWidget from './useFeedbackWidget';
jest.mock('react', () => ({
...jest.requireActual('react'),
useState: jest.fn((value) => [value, jest.fn()]),
}));
jest.mock('@openedx/paragon', () => jest.requireActual('@edx/react-unit-test-utils').mockComponents({
ActionRow: {
Spacer: 'Spacer',
},
IconButton: 'IconButton',
Icon: 'Icon',
}));
jest.mock('@openedx/paragon/icons', () => ({
Close: 'Close',
ThumbUpOutline: 'ThumbUpOutline',
ThumbDownOffAlt: 'ThumbDownOffAlt',
}));
jest.mock('./useFeedbackWidget');
jest.mock('@edx/frontend-platform/i18n', () => {
const i18n = jest.requireActual('@edx/frontend-platform/i18n');
const { formatMessage } = jest.requireActual('@edx/react-unit-test-utils');
return {
...i18n,
useIntl: jest.fn(() => ({
formatMessage,
})),
};
});
describe('<FeedbackWidget />', () => {
const props = {
courseId: 'course-v1:edX+DemoX+Demo_Course',
translationLanguage: 'es',
unitId:
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@37b72b3915204b70acb00c55b604b563',
userId: '123',
};
const mockUseFeedbackWidget = ({ showFeedbackWidget, showGratitudeText }) => {
useFeedbackWidget.mockReturnValueOnce({
closeFeedbackWidget: jest.fn().mockName('closeFeedbackWidget'),
sendFeedback: jest.fn().mockName('sendFeedback'),
onThumbsUpClick: jest.fn().mockName('onThumbsUpClick'),
onThumbsDownClick: jest.fn().mockName('onThumbsDownClick'),
showFeedbackWidget,
showGratitudeText,
});
};
it('renders hidden by default', () => {
mockUseFeedbackWidget({
showFeedbackWidget: true,
showGratitudeText: true,
});
const wrapper = shallow(<FeedbackWidget {...props} />);
expect(wrapper.snapshot).toMatchSnapshot();
expect(wrapper.instance.findByType('div')[0].props.className).toContain(
'd-none',
);
});
it('renders show when elemReady is true', () => {
mockUseFeedbackWidget({
showFeedbackWidget: true,
showGratitudeText: true,
});
useState.mockReturnValueOnce([true, jest.fn()]);
const wrapper = shallow(<FeedbackWidget {...props} />);
expect(wrapper.snapshot).toMatchSnapshot();
expect(wrapper.instance.findByType('div')[0].props.className).not.toContain(
'd-none',
);
});
it('render empty when showFeedbackWidget and showGratitudeText are false', () => {
mockUseFeedbackWidget({
showFeedbackWidget: false,
showGratitudeText: false,
});
useState.mockReturnValueOnce([true, jest.fn()]);
const wrapper = shallow(<FeedbackWidget {...props} />);
expect(wrapper.instance.findByType('div')[0].children.length).toBe(0);
});
it('render feedback widget', () => {
mockUseFeedbackWidget({
showFeedbackWidget: true,
showGratitudeText: false,
});
const wrapper = shallow(<FeedbackWidget {...props} />);
expect(wrapper.snapshot).toMatchSnapshot();
});
it('render gratitude text', () => {
mockUseFeedbackWidget({
showFeedbackWidget: false,
showGratitudeText: true,
});
const wrapper = shallow(<FeedbackWidget {...props} />);
expect(wrapper.snapshot).toMatchSnapshot();
});
});

View File

@@ -1,16 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
rateTranslationText: {
id: 'feedbackWidget.rateTranslationText',
defaultMessage: 'Rate this page translation',
description: 'Title for the feedback widget action row.',
},
gratitudeText: {
id: 'feedbackWidget.gratitudeText',
defaultMessage: 'Thank you! Your feedback matters.',
description: 'Title for secondary action row.',
},
});
export default messages;

View File

@@ -1,82 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { createTranslationFeedback, getTranslationFeedback } from '../data/api';
const useFeedbackWidget = ({
courseId,
translationLanguage,
unitId,
userId,
}) => {
const [showFeedbackWidget, setShowFeedbackWidget] = useState(false);
const [showGratitudeText, setShowGratitudeText] = useState(false);
const closeFeedbackWidget = useCallback(() => {
setShowFeedbackWidget(false);
}, [setShowFeedbackWidget]);
const openFeedbackWidget = useCallback(() => {
setShowFeedbackWidget(true);
}, [setShowFeedbackWidget]);
useEffect(async () => {
const translationFeedback = await getTranslationFeedback({
courseId,
translationLanguage,
unitId,
userId,
});
setShowFeedbackWidget(!translationFeedback);
}, [
courseId,
translationLanguage,
unitId,
userId,
]);
const openGratitudeText = useCallback(() => {
setShowGratitudeText(true);
setTimeout(() => {
setShowGratitudeText(false);
}, 3000);
}, [setShowGratitudeText]);
const sendFeedback = useCallback(async (feedbackValue) => {
await createTranslationFeedback({
courseId,
feedbackValue,
translationLanguage,
unitId,
userId,
});
closeFeedbackWidget();
openGratitudeText();
}, [
courseId,
translationLanguage,
unitId,
userId,
closeFeedbackWidget,
openGratitudeText,
]);
const onThumbsUpClick = useCallback(() => {
sendFeedback(true);
}, [sendFeedback]);
const onThumbsDownClick = useCallback(() => {
sendFeedback(false);
}, [sendFeedback]);
return {
closeFeedbackWidget,
openFeedbackWidget,
openGratitudeText,
sendFeedback,
showFeedbackWidget,
showGratitudeText,
onThumbsUpClick,
onThumbsDownClick,
};
};
export default useFeedbackWidget;

View File

@@ -1,163 +0,0 @@
import { renderHook, act } from '@testing-library/react-hooks';
import useFeedbackWidget from './useFeedbackWidget';
import { createTranslationFeedback, getTranslationFeedback } from '../data/api';
jest.mock('../data/api', () => ({
createTranslationFeedback: jest.fn(),
getTranslationFeedback: jest.fn(),
}));
const initialProps = {
courseId: 'course-v1:edX+DemoX+Demo_Course',
translationLanguage: 'es',
unitId: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc',
userId: 3,
};
const newProps = {
courseId: 'course-v1:edX+DemoX+Demo_Course',
translationLanguage: 'fr',
unitId: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc',
userId: 3,
};
describe('useFeedbackWidget', () => {
beforeEach(async () => {
getTranslationFeedback.mockReturnValue('');
});
afterEach(() => {
jest.restoreAllMocks();
});
test('closeFeedbackWidget behavior', () => {
const { result, waitFor } = renderHook(() => useFeedbackWidget(initialProps));
waitFor(() => expect(result.current.showFeedbackWidget.toBe(true)));
act(() => {
result.current.closeFeedbackWidget();
});
expect(result.current.showFeedbackWidget).toBe(false);
});
test('openFeedbackWidget behavior', () => {
const { result } = renderHook(() => useFeedbackWidget(initialProps));
act(() => {
result.current.closeFeedbackWidget();
});
expect(result.current.showFeedbackWidget).toBe(false);
act(() => {
result.current.openFeedbackWidget();
});
expect(result.current.showFeedbackWidget).toBe(true);
});
test('openGratitudeText behavior', async () => {
const { result, waitFor } = renderHook(() => useFeedbackWidget(initialProps));
expect(result.current.showGratitudeText).toBe(false);
act(() => {
result.current.openGratitudeText();
});
expect(result.current.showGratitudeText).toBe(true);
// Wait for 3 seconds to hide the gratitude text
waitFor(() => {
expect(result.current.showGratitudeText).toBe(false);
}, { timeout: 3000 });
});
test('sendFeedback behavior', () => {
const { result, waitFor } = renderHook(() => useFeedbackWidget(initialProps));
const feedbackValue = true;
waitFor(() => expect(result.current.showFeedbackWidget.toBe(true)));
expect(result.current.showGratitudeText).toBe(false);
act(() => {
result.current.sendFeedback(feedbackValue);
});
waitFor(() => {
expect(result.current.showFeedbackWidget).toBe(false);
expect(result.current.showGratitudeText).toBe(true);
});
expect(createTranslationFeedback).toHaveBeenCalledWith({
courseId: initialProps.courseId,
feedbackValue,
translationLanguage: initialProps.translationLanguage,
unitId: initialProps.unitId,
userId: initialProps.userId,
});
// Wait for 3 seconds to hide the gratitude text
waitFor(() => {
expect(result.current.showGratitudeText).toBe(false);
}, { timeout: 3000 });
});
test('onThumbsUpClick behavior', () => {
const { result } = renderHook(() => useFeedbackWidget(initialProps));
act(() => {
result.current.onThumbsUpClick();
});
expect(createTranslationFeedback).toHaveBeenCalledWith({
courseId: initialProps.courseId,
feedbackValue: true,
translationLanguage: initialProps.translationLanguage,
unitId: initialProps.unitId,
userId: initialProps.userId,
});
});
test('onThumbsDownClick behavior', () => {
const { result } = renderHook(() => useFeedbackWidget(initialProps));
act(() => {
result.current.onThumbsDownClick();
});
expect(createTranslationFeedback).toHaveBeenCalledWith({
courseId: initialProps.courseId,
feedbackValue: false,
translationLanguage: initialProps.translationLanguage,
unitId: initialProps.unitId,
userId: initialProps.userId,
});
});
test('fetch feedback on initialization', () => {
const { waitFor } = renderHook(() => useFeedbackWidget(initialProps));
waitFor(() => {
expect(getTranslationFeedback).toHaveBeenCalledWith({
courseId: initialProps.courseId,
translationLanguage: initialProps.translationLanguage,
unitId: initialProps.unitId,
userId: initialProps.userId,
});
});
});
test('fetch feedback on props update', () => {
const { rerender, waitFor } = renderHook(() => useFeedbackWidget(initialProps));
waitFor(() => {
expect(getTranslationFeedback).toHaveBeenCalledWith({
courseId: initialProps.courseId,
translationLanguage: initialProps.translationLanguage,
unitId: initialProps.unitId,
userId: initialProps.userId,
});
});
rerender(newProps);
waitFor(() => {
expect(getTranslationFeedback).toHaveBeenCalledWith({
courseId: newProps.courseId,
translationLanguage: newProps.translationLanguage,
unitId: newProps.unitId,
userId: newProps.userId,
});
});
});
});

View File

@@ -1,53 +0,0 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { useModel } from '@src/generic/model-store';
import { VERIFIED_MODES } from '@src/constants';
import TranslationSelection from './translation-selection';
import { fetchTranslationConfig } from './data/api';
const UnitTranslationPlugin = ({ id, courseId, unitId }) => {
const { language, enrollmentMode } = useModel('coursewareMeta', courseId);
const { isStaff } = useModel('courseHomeMeta', courseId);
const [translationConfig, setTranslationConfig] = useState({
enabled: false,
availableLanguages: [],
});
const hasVerifiedEnrollment = isStaff || (
enrollmentMode !== null
&& enrollmentMode !== undefined
&& VERIFIED_MODES.includes(enrollmentMode)
);
useEffect(() => {
if (hasVerifiedEnrollment) {
fetchTranslationConfig(courseId).then(setTranslationConfig);
}
}, []);
const { enabled, availableLanguages } = translationConfig;
if (!hasVerifiedEnrollment || !enabled || !language || !availableLanguages.length) {
return null;
}
return (
<TranslationSelection
id={id}
courseId={courseId}
language={language}
availableLanguages={availableLanguages}
unitId={unitId}
/>
);
};
UnitTranslationPlugin.propTypes = {
id: PropTypes.string.isRequired,
courseId: PropTypes.string.isRequired,
unitId: PropTypes.string.isRequired,
};
export default UnitTranslationPlugin;

View File

@@ -1,102 +0,0 @@
import { when } from 'jest-when';
import { shallow } from '@edx/react-unit-test-utils';
import { useState } from 'react';
import { useModel } from '@src/generic/model-store';
import UnitTranslationPlugin from './index';
jest.mock('@src/generic/model-store');
jest.mock('./data/api', () => ({
fetchTranslationConfig: jest.fn(),
}));
jest.mock('./translation-selection', () => 'TranslationSelection');
jest.mock('react', () => ({
...jest.requireActual('react'),
useState: jest.fn(),
}));
describe('<UnitTranslationPlugin />', () => {
const props = {
id: 'id',
courseId: 'courseId',
unitId: 'unitId',
};
const mockInitialState = ({
enabled = true,
availableLanguages = ['en'],
language = 'en',
enrollmentMode = 'verified',
isStaff = false,
}) => {
useState.mockReturnValueOnce([{ enabled, availableLanguages }, jest.fn()]);
when(useModel)
.calledWith('coursewareMeta', props.courseId)
.mockReturnValueOnce({ language, enrollmentMode });
when(useModel)
.calledWith('courseHomeMeta', props.courseId)
.mockReturnValueOnce({ isStaff });
};
beforeEach(() => {
jest.clearAllMocks();
});
it('render empty when translation is not enabled', () => {
mockInitialState({ enabled: false });
const wrapper = shallow(<UnitTranslationPlugin {...props} />);
expect(wrapper.isEmptyRender()).toBe(true);
});
it('render empty when available languages is empty', () => {
mockInitialState({
availableLanguages: [],
});
const wrapper = shallow(<UnitTranslationPlugin {...props} />);
expect(wrapper.isEmptyRender()).toBe(true);
});
it('render empty when course language has not been set', () => {
mockInitialState({
language: null,
});
const wrapper = shallow(<UnitTranslationPlugin {...props} />);
expect(wrapper.isEmptyRender()).toBe(true);
});
it('render empty when student is enroll as verified', () => {
mockInitialState({
enrollmentMode: 'audit',
});
const wrapper = shallow(<UnitTranslationPlugin {...props} />);
expect(wrapper.isEmptyRender()).toBe(true);
});
it('render translation when the user is staff', () => {
mockInitialState({
enrollmentMode: 'audit',
isStaff: true,
});
const wrapper = shallow(<UnitTranslationPlugin {...props} />);
expect(wrapper.snapshot).toMatchSnapshot();
});
it('render TranslationSelection when translation is enabled and language is available', () => {
mockInitialState({});
const wrapper = shallow(<UnitTranslationPlugin {...props} />);
expect(wrapper.snapshot).toMatchSnapshot();
});
});

View File

@@ -1,82 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
StandardModal,
ActionRow,
Button,
Icon,
ListBox,
ListBoxOption,
} from '@openedx/paragon';
import { Check } from '@openedx/paragon/icons';
import useTranslationModal from './useTranslationModal';
import messages from './messages';
import './TranslationModal.scss';
const TranslationModal = ({
isOpen,
close,
selectedLanguage,
setSelectedLanguage,
availableLanguages,
}) => {
const { formatMessage } = useIntl();
const { selectedIndex, setSelectedIndex, onSubmit } = useTranslationModal({
selectedLanguage,
setSelectedLanguage,
close,
availableLanguages,
});
return (
<StandardModal
title={formatMessage(messages.languageSelectionModalTitle)}
isOpen={isOpen}
onClose={close}
footerNode={(
<ActionRow>
<ActionRow.Spacer />
<Button variant="tertiary" onClick={close}>
{formatMessage(messages.cancelButtonText)}
</Button>
<Button onClick={onSubmit}>
{formatMessage(messages.submitButtonText)}
</Button>
</ActionRow>
)}
>
<ListBox className="listbox-container">
{availableLanguages.map(({ code, label }, index) => (
<ListBoxOption
className="d-flex justify-content-between"
key={code}
selectedOptionIndex={selectedIndex}
onSelect={() => setSelectedIndex(index)}
>
{label}
{selectedIndex === index && <Icon src={Check} />}
</ListBoxOption>
))}
</ListBox>
</StandardModal>
);
};
TranslationModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
close: PropTypes.func.isRequired,
selectedLanguage: PropTypes.string.isRequired,
setSelectedLanguage: PropTypes.func.isRequired,
availableLanguages: PropTypes.arrayOf(
PropTypes.shape({
code: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
}),
).isRequired,
};
export default TranslationModal;

View File

@@ -1,7 +0,0 @@
.listbox-container {
max-height: 400px;
:last-child {
margin-bottom: 5px;
}
}

View File

@@ -1,59 +0,0 @@
import { shallow } from '@edx/react-unit-test-utils';
import TranslationModal from './TranslationModal';
jest.mock('./useTranslationModal', () => ({
__esModule: true,
default: () => ({
selectedIndex: 0,
setSelectedIndex: jest.fn(),
onSubmit: jest.fn().mockName('onSubmit'),
}),
}));
jest.mock('@openedx/paragon', () => jest.requireActual('@edx/react-unit-test-utils').mockComponents({
StandardModal: 'StandardModal',
ActionRow: {
Spacer: 'Spacer',
},
Button: 'Button',
Icon: 'Icon',
ListBox: 'ListBox',
ListBoxOption: 'ListBoxOption',
}));
jest.mock('@openedx/paragon/icons', () => ({
Check: jest.fn().mockName('icons.Check'),
}));
jest.mock('@edx/frontend-platform/i18n', () => {
const i18n = jest.requireActual('@edx/frontend-platform/i18n');
const { formatMessage } = jest.requireActual('@edx/react-unit-test-utils');
return {
...i18n,
useIntl: jest.fn(() => ({
formatMessage,
})),
};
});
describe('TranslationModal', () => {
const props = {
isOpen: true,
close: jest.fn().mockName('close'),
selectedLanguage: 'en',
setSelectedLanguage: jest.fn().mockName('setSelectedLanguage'),
availableLanguages: [
{
code: 'en',
label: 'English',
},
{
code: 'es',
label: 'Spanish',
},
],
};
it('renders correctly', () => {
const wrapper = shallow(<TranslationModal {...props} />);
expect(wrapper.snapshot).toMatchSnapshot();
expect(wrapper.instance.findByType('ListBoxOption')).toHaveLength(2);
});
});

View File

@@ -1,49 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`TranslationModal renders correctly 1`] = `
<StandardModal
footerNode={
<ActionRow>
<Spacer />
<Button
onClick={[MockFunction close]}
variant="tertiary"
>
Cancel
</Button>
<Button
onClick={[MockFunction onSubmit]}
>
Submit
</Button>
</ActionRow>
}
isOpen={true}
onClose={[MockFunction close]}
title="Translate this course"
>
<ListBox
className="listbox-container"
>
<ListBoxOption
className="d-flex justify-content-between"
key="en"
onSelect={[Function]}
selectedOptionIndex={0}
>
English
<Icon
src={[MockFunction icons.Check]}
/>
</ListBoxOption>
<ListBoxOption
className="d-flex justify-content-between"
key="es"
onSelect={[Function]}
selectedOptionIndex={0}
>
Spanish
</ListBoxOption>
</ListBox>
</StandardModal>
`;

View File

@@ -1,50 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<TranslationSelection /> renders 1`] = `
<Fragment>
<ProductTour
tours={
Array [
Object {
"abitrarily": "defined",
},
]
}
/>
<IconButton
alt="change-language"
className="mr-2 mb-2 float-right"
iconAs="Icon"
id="translation-selection-button"
onClick={[MockFunction open]}
src="Language"
variant="primary"
/>
<TranslationModal
availableLanguages={
Array [
Object {
"code": "en",
"label": "English",
},
Object {
"code": "es",
"label": "Spanish",
},
]
}
close={[MockFunction close]}
courseId="course-v1:edX+DemoX+Demo_Course"
id="plugin-test-id"
isOpen={false}
selectedLanguage="en"
setSelectedLanguage={[MockFunction setSelectedLanguage]}
/>
<FeedbackWidget
courseId="course-v1:edX+DemoX+Demo_Course"
translationLanguage="en"
unitId="unit-test-id"
userId="123"
/>
</Fragment>
`;

View File

@@ -1,115 +0,0 @@
import React, { useContext, useEffect } from 'react';
import PropTypes from 'prop-types';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { AppContext } from '@edx/frontend-platform/react';
import { IconButton, Icon, ProductTour } from '@openedx/paragon';
import { Language } from '@openedx/paragon/icons';
import { useDispatch } from 'react-redux';
import { stringifyUrl } from 'query-string';
import { registerOverrideMethod } from '@src/generic/plugin-store';
import TranslationModal from './TranslationModal';
import useTranslationTour from './useTranslationTour';
import useSelectLanguage from './useSelectLanguage';
import FeedbackWidget from '../feedback-widget';
const TranslationSelection = ({
id, courseId, language, availableLanguages, unitId,
}) => {
const {
authenticatedUser: { userId },
} = useContext(AppContext);
const dispatch = useDispatch();
const {
translationTour, isOpen, open, close,
} = useTranslationTour();
const { selectedLanguage, setSelectedLanguage } = useSelectLanguage({
courseId,
language,
});
useEffect(() => {
if ((courseId && language && selectedLanguage && unitId && userId) && (language !== selectedLanguage)) {
const eventName = 'edx.whole_course_translations.translation_requested';
const eventProperties = {
courseId,
sourceLanguage: language,
targetLanguage: selectedLanguage,
unitId,
userId,
};
sendTrackEvent(eventName, eventProperties);
}
}, [courseId, language, selectedLanguage, unitId, userId]);
useEffect(() => {
dispatch(
registerOverrideMethod({
pluginName: id,
methodName: 'getIFrameUrl',
method: (iframeUrl) => {
const finalUrl = stringifyUrl({
url: iframeUrl,
query: {
...(language
&& selectedLanguage
&& language !== selectedLanguage && {
src_lang: language,
dest_lang: selectedLanguage,
}),
},
});
return finalUrl;
},
}),
);
}, [language, selectedLanguage]);
return (
<>
<ProductTour tours={[translationTour]} />
<IconButton
src={Language}
iconAs={Icon}
alt="change-language"
onClick={open}
variant="primary"
className="mr-2 mb-2 float-right"
id="translation-selection-button"
/>
<TranslationModal
isOpen={isOpen}
close={close}
courseId={courseId}
selectedLanguage={selectedLanguage}
setSelectedLanguage={setSelectedLanguage}
availableLanguages={availableLanguages}
id={id}
/>
<FeedbackWidget
courseId={courseId}
translationLanguage={selectedLanguage}
unitId={unitId}
userId={userId}
/>
</>
);
};
TranslationSelection.propTypes = {
id: PropTypes.string.isRequired,
courseId: PropTypes.string.isRequired,
unitId: PropTypes.string.isRequired,
language: PropTypes.string.isRequired,
availableLanguages: PropTypes.arrayOf(PropTypes.shape({
code: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
})).isRequired,
};
TranslationSelection.defaultProps = {};
export default TranslationSelection;

View File

@@ -1,84 +0,0 @@
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { shallow } from '@edx/react-unit-test-utils';
import TranslationSelection from './index';
import { initializeTestStore, render } from '../../../src/setupTest';
jest.mock('react', () => ({
...jest.requireActual('react'),
useContext: jest.fn().mockName('useContext').mockReturnValue({
authenticatedUser: {
userId: '123',
},
}),
}));
jest.mock('@openedx/paragon', () => ({
IconButton: 'IconButton',
Icon: 'Icon',
ProductTour: 'ProductTour',
}));
jest.mock('@openedx/paragon/icons', () => ({
Language: 'Language',
}));
jest.mock('./useTranslationTour', () => () => ({
translationTour: {
abitrarily: 'defined',
},
isOpen: false,
open: jest.fn().mockName('open'),
close: jest.fn().mockName('close'),
}));
jest.mock('react-redux', () => ({
useDispatch: jest.fn().mockName('useDispatch'),
}));
jest.mock('@src/generic/plugin-store', () => ({
registerOverrideMethod: jest.fn().mockName('registerOverrideMethod'),
}));
jest.mock('./TranslationModal', () => 'TranslationModal');
jest.mock('./useSelectLanguage', () => () => ({
selectedLanguage: 'en',
setSelectedLanguage: jest.fn().mockName('setSelectedLanguage'),
}));
jest.mock('../feedback-widget', () => 'FeedbackWidget');
jest.mock('@edx/frontend-platform/analytics');
describe('<TranslationSelection />', () => {
const props = {
id: 'plugin-test-id',
courseId: 'course-v1:edX+DemoX+Demo_Course',
language: 'en',
availableLanguages: [
{
code: 'en',
label: 'English',
},
{
code: 'es',
label: 'Spanish',
},
],
unitId: 'unit-test-id',
};
it('renders', () => {
const wrapper = shallow(<TranslationSelection {...props} />);
expect(wrapper.snapshot).toMatchSnapshot();
});
it('does not send track event when source != target language', async () => {
await initializeTestStore();
render(<TranslationSelection {...props} />);
expect(sendTrackEvent).not.toHaveBeenCalled();
});
it('sends track event when source != target language', async () => {
await initializeTestStore();
render(<TranslationSelection {...props} />);
const eventName = 'edx.whole_course_translations.translation_requested';
const eventProperties = {
courseId: props.courseId,
sourceLanguage: props.language,
targetLanguage: 'en',
unitId: props.unitId,
userId: '123',
};
expect(sendTrackEvent).not.toHaveBeenCalledWith(eventName, eventProperties);
});
});

View File

@@ -1,41 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
translationTourModalTitle: {
id: 'translationSelection.translationTourModalTitle',
defaultMessage: 'New translation feature!',
description: 'Title for the translation modal.',
},
translationTourModalBody: {
id: 'translationSelection.translationTourModalBody',
defaultMessage: 'Now you can easily translate course content.',
description: 'Body for the translation modal.',
},
tryItButtonText: {
id: 'translationSelection.tryItButtonText',
defaultMessage: 'Try it',
description: 'Button text for the translation modal.',
},
dismissButtonText: {
id: 'translationSelection.dismissButtonText',
defaultMessage: 'Dismiss',
description: 'Button text for the translation modal.',
},
languageSelectionModalTitle: {
id: 'translationSelection.languageSelectionModalTitle',
defaultMessage: 'Translate this course',
description: 'Title for the translation modal.',
},
cancelButtonText: {
id: 'translationSelection.cancelButtonText',
defaultMessage: 'Cancel',
description: 'Button text for the translation modal.',
},
submitButtonText: {
id: 'translationSelection.submitButtonText',
defaultMessage: 'Submit',
description: 'Button text for the translation modal.',
},
});
export default messages;

View File

@@ -1,35 +0,0 @@
import { useCallback } from 'react';
import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils';
import {
getLocalStorage,
setLocalStorage,
} from '@src/data/localStorage';
export const selectedLanguageKey = 'selectedLanguages';
export const stateKeys = StrictDict({
selectedLanguage: 'selectedLanguage',
});
const useSelectLanguage = ({ courseId, language }) => {
const selectedLanguageItem = getLocalStorage(selectedLanguageKey) || {};
const [selectedLanguage, updateSelectedLanguage] = useKeyedState(
stateKeys.selectedLanguage,
selectedLanguageItem[courseId] || language,
);
const setSelectedLanguage = useCallback((newSelectedLanguage) => {
setLocalStorage(selectedLanguageKey, {
...selectedLanguageItem,
[courseId]: newSelectedLanguage,
});
updateSelectedLanguage(newSelectedLanguage);
});
return {
selectedLanguage,
setSelectedLanguage,
};
};
export default useSelectLanguage;

View File

@@ -1,63 +0,0 @@
import { mockUseKeyedState } from '@edx/react-unit-test-utils';
import {
getLocalStorage,
setLocalStorage,
} from '@src/data/localStorage';
import useSelectLanguage, {
stateKeys,
selectedLanguageKey,
} from './useSelectLanguage';
const state = mockUseKeyedState(stateKeys);
jest.mock('react', () => ({
...jest.requireActual('react'),
useCallback: jest.fn((cb, prereqs) => (...args) => [
cb(...args),
{ cb, prereqs },
]),
}));
jest.mock('@src/data/localStorage', () => ({
getLocalStorage: jest.fn(),
setLocalStorage: jest.fn(),
}));
describe('useSelectLanguage', () => {
const props = {
courseId: 'test-course-id',
language: 'en',
};
const languages = [
{ code: 'en', label: 'English' },
{ code: 'es', label: 'Spanish' },
];
beforeEach(() => {
jest.clearAllMocks();
state.mock();
});
afterEach(() => {
state.resetVals();
});
languages.forEach(({ code, label }) => {
it(`initializes selectedLanguage to the selected language (${label})`, () => {
getLocalStorage.mockReturnValueOnce({ [props.courseId]: code });
const { selectedLanguage } = useSelectLanguage(props);
state.expectInitializedWith(stateKeys.selectedLanguage, code);
expect(selectedLanguage).toBe(code);
});
});
test('setSelectedLanguage behavior', () => {
const { setSelectedLanguage } = useSelectLanguage(props);
setSelectedLanguage('es');
state.expectSetStateCalledWith(stateKeys.selectedLanguage, 'es');
expect(setLocalStorage).toHaveBeenCalledWith(selectedLanguageKey, {
[props.courseId]: 'es',
});
});
});

View File

@@ -1,29 +0,0 @@
import { useCallback } from 'react';
import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils';
export const stateKeys = StrictDict({
selectedIndex: 'selectedIndex',
});
const useTranslationModal = ({
selectedLanguage, setSelectedLanguage, close, availableLanguages,
}) => {
const [selectedIndex, setSelectedIndex] = useKeyedState(
stateKeys.selectedIndex,
availableLanguages.findIndex((lang) => lang.code === selectedLanguage),
);
const onSubmit = useCallback(() => {
const newSelectedLanguage = availableLanguages[selectedIndex].code;
setSelectedLanguage(newSelectedLanguage);
close();
}, [selectedIndex]);
return {
selectedIndex,
setSelectedIndex,
onSubmit,
};
};
export default useTranslationModal;

View File

@@ -1,49 +0,0 @@
import { mockUseKeyedState } from '@edx/react-unit-test-utils';
import useTranslationModal, { stateKeys } from './useTranslationModal';
const state = mockUseKeyedState(stateKeys);
jest.mock('react', () => ({
...jest.requireActual('react'),
useCallback: jest.fn((cb, prereqs) => (...args) => ([
cb(...args), { cb, prereqs },
])),
}));
describe('useTranslationModal', () => {
const props = {
selectedLanguage: 'en',
setSelectedLanguage: jest.fn(),
close: jest.fn(),
availableLanguages: [
{ code: 'en', label: 'English' },
{ code: 'es', label: 'Spanish' },
],
};
beforeEach(() => {
jest.clearAllMocks();
state.mock();
});
afterEach(() => {
state.resetVals();
});
it('initializes selectedIndex to the index of the selected language', () => {
const { selectedIndex } = useTranslationModal(props);
state.expectInitializedWith(stateKeys.selectedIndex, 0);
expect(selectedIndex).toBe(0);
});
it('onSubmit updates the selected language and closes the modal', () => {
const { onSubmit } = useTranslationModal({
...props,
selectedLanguage: 'es',
});
onSubmit();
expect(props.setSelectedLanguage).toHaveBeenCalledWith('es');
expect(props.close).toHaveBeenCalled();
});
});

View File

@@ -1,62 +0,0 @@
import { useCallback } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useToggle } from '@openedx/paragon';
import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils';
import messages from './messages';
const hasSeenTranslationTourKey = 'hasSeenTranslationTour';
export const stateKeys = StrictDict({
showTranslationTour: 'showTranslationTour',
});
const useTranslationTour = () => {
const { formatMessage } = useIntl();
const [isTourEnabled, setIsTourEnabled] = useKeyedState(
stateKeys.showTranslationTour,
global.localStorage.getItem(hasSeenTranslationTourKey) !== 'true',
);
const [isOpen, open, close] = useToggle(false);
const endTour = useCallback(() => {
global.localStorage.setItem(hasSeenTranslationTourKey, 'true');
setIsTourEnabled(false);
}, [isTourEnabled, setIsTourEnabled]);
const tryIt = useCallback(() => {
endTour();
open();
}, [endTour, open]);
const translationTour = isTourEnabled
? {
tourId: 'translation',
enabled: isTourEnabled,
onDismiss: endTour,
onEnd: tryIt,
checkpoints: [
{
title: formatMessage(messages.translationTourModalTitle),
body: formatMessage(messages.translationTourModalBody),
placement: 'bottom',
target: '#translation-selection-button',
showDismissButton: true,
endButtonText: formatMessage(messages.tryItButtonText),
dismissButtonText: formatMessage(messages.dismissButtonText),
},
],
}
: {};
return {
translationTour,
isOpen,
open,
close,
};
};
export default useTranslationTour;

View File

@@ -1,95 +0,0 @@
import { mockUseKeyedState } from '@edx/react-unit-test-utils';
import { useToggle } from '@openedx/paragon';
import useTranslationTour, { stateKeys } from './useTranslationTour';
jest.mock('react', () => ({
...jest.requireActual('react'),
useCallback: jest.fn((cb, prereqs) => () => {
cb();
return { useCallback: { cb, prereqs } };
}),
}));
jest.mock('@openedx/paragon', () => ({
useToggle: jest.fn(),
}));
jest.mock('@edx/frontend-platform/i18n', () => {
const i18n = jest.requireActual('@edx/frontend-platform/i18n');
const { formatMessage } = jest.requireActual('@edx/react-unit-test-utils');
// this provide consistent for the test on different platform/timezone
const formatDate = jest.fn(date => new Date(date).toISOString()).mockName('useIntl.formatDate');
return {
...i18n,
useIntl: jest.fn(() => ({
formatMessage,
formatDate,
})),
defineMessages: m => m,
FormattedMessage: () => 'FormattedMessage',
};
});
jest.mock('@src/data/localStorage', () => ({
getLocalStorage: jest.fn(),
setLocalStorage: jest.fn(),
}));
const state = mockUseKeyedState(stateKeys);
describe('useTranslationSelection', () => {
const mockLocalStroage = {
getItem: jest.fn(),
setItem: jest.fn(),
};
const toggleOpen = jest.fn();
const toggleClose = jest.fn();
useToggle.mockReturnValue([false, toggleOpen, toggleClose]);
beforeEach(() => {
jest.clearAllMocks();
state.mock();
window.localStorage = mockLocalStroage;
});
afterEach(() => {
state.resetVals();
delete window.localStorage;
});
it('do not have translation tour if user already seen it', () => {
mockLocalStroage.getItem.mockReturnValueOnce('not seen');
const { translationTour } = useTranslationTour();
expect(translationTour.enabled).toBe(true);
});
it('show translation tour if user has not seen it', () => {
mockLocalStroage.getItem.mockReturnValueOnce('true');
const { translationTour } = useTranslationTour();
expect(translationTour).toMatchObject({});
});
test('open and close as pass from useToggle', () => {
const { isOpen, open, close } = useTranslationTour();
expect(isOpen).toBe(false);
expect(toggleOpen).toBe(open);
expect(toggleClose).toBe(close);
});
test('end tour on dismiss button click', () => {
mockLocalStroage.getItem.mockReturnValueOnce('not seen');
const { translationTour } = useTranslationTour();
translationTour.onDismiss();
expect(mockLocalStroage.setItem).toHaveBeenCalledWith(
'hasSeenTranslationTour',
'true',
);
state.expectSetStateCalledWith(stateKeys.showTranslationTour, false);
});
test('end tour and open modal on try it button click', () => {
mockLocalStroage.getItem.mockReturnValueOnce('not seen');
const { translationTour } = useTranslationTour();
translationTour.onEnd();
state.expectSetStateCalledWith(stateKeys.showTranslationTour, false);
expect(toggleOpen).toHaveBeenCalled();
});
});

View File

@@ -14,6 +14,7 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import { useSelector } from 'react-redux';
import SequenceExamWrapper from '@edx/frontend-lib-special-exams';
import { breakpoints, useWindowSize } from '@openedx/paragon';
import { PluginSlot } from '@openedx/frontend-plugin-framework';
import PageLoading from '../../../generic/PageLoading';
import { useModel } from '../../../generic/model-store';
@@ -200,7 +201,13 @@ const Sequence = ({
</div>
{enableNewSidebar === 'true' ? <NewSidebar /> : <Sidebar />}
</div>
<div id="whole-course-translation-feedback-widget" />
<PluginSlot
id="sequence_container_plugin"
pluginProps={{
courseId,
unitId,
}}
/>
</>
);

View File

@@ -150,7 +150,6 @@ initialize({
handlers: {
config: () => {
mergeConfig({
AI_TRANSLATIONS_URL: process.env.AI_TRANSLATIONS_URL || null,
CONTACT_URL: process.env.CONTACT_URL || null,
CREDENTIALS_BASE_URL: process.env.CREDENTIALS_BASE_URL || null,
CREDIT_HELP_LINK_URL: process.env.CREDIT_HELP_LINK_URL || null,

View File

@@ -6,7 +6,6 @@ const config = createConfig('webpack-dev');
config.resolve.alias = {
...config.resolve.alias,
'@src': path.resolve(__dirname, 'src'),
'@plugins': path.resolve(__dirname, 'plugins'),
};
module.exports = config;

View File

@@ -18,7 +18,6 @@ config.plugins.push(
config.resolve.alias = {
...config.resolve.alias,
'@src': path.resolve(__dirname, 'src'),
'@plugins': path.resolve(__dirname, 'plugins'),
};
module.exports = config;