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

@@ -0,0 +1,204 @@
// 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

@@ -0,0 +1,116 @@
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

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

View File

@@ -0,0 +1,107 @@
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

@@ -0,0 +1,16 @@
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

@@ -0,0 +1,82 @@
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

@@ -0,0 +1,163 @@
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,
});
});
});
});