Skip to content

Commit

Permalink
feat: add language selection
Browse files Browse the repository at this point in the history
  • Loading branch information
leangseu-edx committed Feb 23, 2024
1 parent 8335dec commit 4806dc6
Show file tree
Hide file tree
Showing 15 changed files with 304 additions and 11 deletions.
3 changes: 2 additions & 1 deletion src/courseware/course/Course.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ describe('Course', () => {
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();

expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
// one from translation product tour
expect(screen.getAllByRole('dialog')).toHaveLength(1);
expect(screen.queryByRole('button', { name: 'Learn About Verified Certificates' })).not.toBeInTheDocument();

loadUnit();
Expand Down
3 changes: 2 additions & 1 deletion src/courseware/course/sequence/Sequence.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,8 @@ describe('Sequence', () => {
render(<Sequence {...mockData} />, { wrapWithRouter: true });
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
// `Previous`, `Bookmark` and `Close Tray` buttons
expect(screen.getAllByRole('button')).toHaveLength(3);
// `Change Language`, `Dismiss` and `Try it` buttons from translation selection.
expect(screen.getAllByRole('button')).toHaveLength(6);
// Renders `Next` button plus one button for each unit.
expect(screen.getAllByRole('link')).toHaveLength(1 + unitBlocks.length);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,18 @@ 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>
<TranslationSelection
courseId="test-course-id"
/>
</div>
<h2
className="sr-only"
>
Expand Down
14 changes: 9 additions & 5 deletions src/courseware/course/sequence/Unit/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,11 @@ import UnitSuspense from './UnitSuspense';
import { modelKeys, views } from './constants';
import { useExamAccess, useShouldDisplayHonorCode } from './hooks';
import { getIFrameUrl } from './urls';
import TranslationSelection from './translation-selection';
import { getTranslateLanguage } from './translation-selection/useTranslationSelection';

const Unit = ({
courseId,
format,
onLoaded,
id,
courseId, format, onLoaded, id,
}) => {
const { formatMessage } = useIntl();
const { authenticatedUser } = React.useContext(AppContext);
Expand All @@ -27,17 +26,22 @@ const Unit = ({
const unit = useModel(modelKeys.units, id);
const isProcessing = unit.bookmarkedUpdateState === 'loading';
const view = authenticatedUser ? views.student : views.public;
const translateLanguage = getTranslateLanguage(courseId);

const iframeUrl = getIFrameUrl({
id,
view,
format,
examAccess,
translateLanguage,
});

return (
<div className="unit">
<h1 className="mb-0 h3">{unit.title}</h1>
<div className="mb-0">
<h3 className="h3">{unit.title}</h3>
<TranslationSelection courseId={courseId} />
</div>
<h2 className="sr-only">{formatMessage(messages.headerPlaceholder)}</h2>
<BookmarkButton
unitId={unit.id}
Expand Down
4 changes: 4 additions & 0 deletions src/courseware/course/sequence/Unit/index.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ jest.mock('./ContentIFrame', () => 'ContentIFrame');
jest.mock('./UnitSuspense', () => 'UnitSuspense');
jest.mock('../honor-code', () => 'HonorCode');
jest.mock('../lock-paywall', () => 'LockPaywall');
jest.mock('./translation-selection', () => 'TranslationSelection');
jest.mock('./translation-selection/useTranslationSelection', () => ({
getTranslateLanguage: jest.fn().mockReturnValue('test-translate-language'),
}));

jest.mock('../../../../generic/model-store', () => ({
useModel: jest.fn(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import React from 'react';
import PropTypes from 'prop-types';

import {
StandardModal,
ActionRow,
Button,
Icon,
ListBox,
ListBoxOption,
} from '@edx/paragon';
import { Check } from '@edx/paragon/icons';

import useTranslationSelection, { languages } from './useTranslationSelection';

import './TranslationModal.scss';

const TranslationModal = ({ courseId, isOpen, close }) => {
const { selectedIndex, setSelectedIndex, onSubmit } = useTranslationSelection({ courseId, close });

return (
<StandardModal
title="Translate this course"
isOpen={isOpen}
onClose={close}
footerNode={(
<ActionRow>
<ActionRow.Spacer />
<Button variant="tertiary" onClick={close}>
Cancel
</Button>
<Button onClick={onSubmit}>Submit</Button>
</ActionRow>
)}
>
<ListBox className="listbox-container">
{languages.map(([key, value], index) => (
<ListBoxOption
className="d-flex justify-content-between"
key={key}
selectedOptionIndex={selectedIndex}
onSelect={() => setSelectedIndex(index)}
>
{value}
{selectedIndex === index && <Icon src={Check} />}
</ListBoxOption>
))}
</ListBox>
</StandardModal>
);
};

TranslationModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
close: PropTypes.func.isRequired,
courseId: PropTypes.string.isRequired,
};

export default TranslationModal;
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.listbox-container {
max-height: 400px;

:last-child {
margin-bottom: 5px;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`<TranslationSelection /> renders 1`] = `undefined`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import React from 'react';
import PropTypes from 'prop-types';
import { IconButton, Icon, ProductTour } from '@edx/paragon';
import { Language } from '@edx/paragon/icons';
import TranslationModal from './TranslationModal';
import useTranslationTour from './useTranslationTour';

const TranslationSelection = ({ courseId }) => {
const {
translationTour, isOpen, open, close,
} = useTranslationTour();

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} />
</>
);
};

TranslationSelection.propTypes = {
courseId: PropTypes.string.isRequired,
};

TranslationSelection.defaultProps = {};

export default TranslationSelection;
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { shallow } from '@edx/react-unit-test-utils';

import TranslationSelection from './index';

jest.mock('@edx/paragon', () => ({
IconButton: 'IconButton',
Icon: 'Icon',
ProductTour: 'ProductTour',
}));
jest.mock('@edx/paragon/icons', () => ({
Language: 'Language',
}));
jest.mock('./useTranslationTour', () => ({
translationTour: {
abitrarily: 'defined',
},
isOpen: false,
open: jest.fn().mockName('open'),
close: jest.fn().mockName('close'),
}));

describe('<TranslationSelection />', () => {
it('renders', () => {
const wrapper = shallow(<TranslationSelection courseId="course-v1:edX+DemoX+Demo_Course" />);
expect(wrapper.snapshot).toMatchSnapshot();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { defineMessages } from '@edx/frontend-platform/i18n';

// title: 'New translation feature!',
// body: 'Now you can easily translate course content.',
const messages = defineMessages({
translationModalTitle: {
id: 'translationSelection.translationModalTitle',
defaultMessage: 'This is a standard modal dialog',
description: 'Title for the translation modal.',
},
translationModalBody: {
id: 'translationSelection.translationModalBody',
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.',
},
});

export default messages;
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { useCallback } from 'react';
import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils';
import {
getLocalStorage,
setLocalStorage,
} from '../../../../../data/localStorage';

export const selectedLanguageKey = 'selectedLanguages';

export const languages = Object.entries({
en: 'English',
es: 'Spanish',
});

export const stateKeys = StrictDict({
selectedIndex: 'selectedIndex',
});

// TODO: this should be rewrite in the future decision. Currently it return
// null if the language is English or not set.
export const getTranslateLanguage = (courseId) => {
const selectedLanguageItem = getLocalStorage(selectedLanguageKey) || {};
return selectedLanguageItem[courseId] !== 'en'
? selectedLanguageItem[courseId]
: null;
};

const useTranslationSelection = ({ courseId, close }) => {
const selectedLanguageItem = getLocalStorage(selectedLanguageKey) || {};
const selectedLanguage = selectedLanguageItem[courseId] || 'en';
const [selectedIndex, setSelectedIndex] = useKeyedState(
stateKeys.selectedIndex,
languages.findIndex(([key]) => key === selectedLanguage),
);

const onSubmit = useCallback(() => {
const newSelectedLanguage = languages[selectedIndex][0];
setLocalStorage(newSelectedLanguage, {
...selectedLanguageItem,
[courseId]: newSelectedLanguage,
});
close();
}, [selectedIndex, setSelectedIndex]);

return {
selectedIndex,
setSelectedIndex,
onSubmit,
};
};

export default useTranslationSelection;
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { useCallback } from 'react';

import { useIntl } from '@edx/frontend-platform/i18n';
import { useToggle } from '@edx/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 endTour = useCallback(() => {
global.localStorage.setItem(hasSeenTranslationTourKey, 'true');
setIsTourEnabled(false);
}, [isTourEnabled, setIsTourEnabled]);

const [isOpen, open, close] = useToggle(false);

const translationTour = isTourEnabled
? {
tourId: 'translation',
enabled: isTourEnabled,
onDismiss: endTour,
onEnd: () => {
endTour();
open();
},
checkpoints: [
{
title: formatMessage(messages.translationModalTitle),
body: formatMessage(messages.translationModalBody),
placement: 'bottom',
target: '#translation-selection-button',
showDismissButton: true,
endButtonText: formatMessage(messages.tryItButtonText),
dismissButtonText: formatMessage(messages.dismissButtonText),
},
],
}
: {};

return {
translationTour,
isOpen,
open,
close,
};
};

export default useTranslationTour;
2 changes: 2 additions & 0 deletions src/courseware/course/sequence/Unit/urls.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@ export const getIFrameUrl = ({
view,
format,
examAccess,
translateLanguage,
}) => {
const xblockUrl = `${getConfig().LMS_BASE_URL}/xblock/${id}`;
const params = stringify({
...iframeParams,
view,
...(format && { format }),
...(!examAccess.blockAccess && { exam_access: examAccess.accessToken }),
...(translateLanguage && { translate_lang: translateLanguage }),
});
return `${xblockUrl}?${params}`;
};
Expand Down
Loading

0 comments on commit 4806dc6

Please sign in to comment.