fix: various i18n issues

* fix: adding messages for i18n issues related to placeholders

* fix: adding messages for i18n issues related to import tag wizard stepper titles

* fix: changing name to duplicated id on i18n message

* fix: replacing hardcoded string with constants to solve i18n issue

* fix: typo on title prop

* fix: adding components prop name correctly

* test: adding ut for select video modal

* chore: adding description to placeholder, changing extension to constant file and adding uts for code coverage

* chore: removing outdated comment lines
This commit is contained in:
jacobo-dominguez-wgu
2025-05-12 10:49:03 -06:00
committed by GitHub
parent 26c6a71624
commit db07092880
13 changed files with 257 additions and 63 deletions

View File

@@ -9,30 +9,30 @@ import messages from './messages';
import AccessibilityBody from './AccessibilityBody';
import AccessibilityForm from './AccessibilityForm';
import { COMMUNITY_ACCESSIBILITY_LINK, ACCESSIBILITY_EMAIL } from './constants';
const AccessibilityPage = ({
// injected
intl,
}) => {
const communityAccessibilityLink = 'https://www.edx.org/accessibility';
const email = 'accessibility@edx.org';
return (
<>
<Helmet>
<title>
{intl.formatMessage(messages.pageTitle, {
siteName: process.env.SITE_NAME,
})}
</title>
</Helmet>
<Header isHiddenMainMenu />
<Container size="xl" classNamae="px-4">
<AccessibilityBody {...{ email, communityAccessibilityLink }} />
<AccessibilityForm accessibilityEmail={email} />
</Container>
<StudioFooterSlot />
</>
);
};
}) => (
<>
<Helmet>
<title>
{intl.formatMessage(messages.pageTitle, {
siteName: process.env.SITE_NAME,
})}
</title>
</Helmet>
<Header isHiddenMainMenu />
<Container size="xl" classNamae="px-4">
<AccessibilityBody
{...{ email: ACCESSIBILITY_EMAIL, communityAccessibilityLink: COMMUNITY_ACCESSIBILITY_LINK }}
/>
<AccessibilityForm accessibilityEmail={ACCESSIBILITY_EMAIL} />
</Container>
<StudioFooterSlot />
</>
);
AccessibilityPage.propTypes = {
// injected

View File

@@ -0,0 +1,2 @@
export const COMMUNITY_ACCESSIBILITY_LINK = 'https://www.edx.org/accessibility';
export const ACCESSIBILITY_EMAIL = 'accessibility@edx.org';

View File

@@ -2,8 +2,11 @@ import React from 'react';
import PropTypes from 'prop-types';
import { Form } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import EditConfirmationButtons from './EditConfirmationButtons';
import messages from './messages';
const EditableHeader = ({
handleChange,
updateTitle,
@@ -11,20 +14,23 @@ const EditableHeader = ({
inputRef,
localTitle,
cancelEdit,
}) => (
<Form.Group onBlur={(e) => updateTitle(e)}>
<Form.Control
style={{ paddingInlineEnd: 'calc(1rem + 84px)' }}
autoFocus
trailingElement={<EditConfirmationButtons {...{ updateTitle, cancelEdit }} />}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder="Title"
ref={inputRef}
value={localTitle}
/>
</Form.Group>
);
}) => {
const intl = useIntl();
return (
<Form.Group onBlur={(e) => updateTitle(e)}>
<Form.Control
style={{ paddingInlineEnd: 'calc(1rem + 84px)' }}
autoFocus
trailingElement={<EditConfirmationButtons {...{ updateTitle, cancelEdit }} />}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={intl.formatMessage(messages.editTitlePlaceholder)}
ref={inputRef}
value={localTitle}
/>
</Form.Group>
);
};
EditableHeader.defaultProps = {
inputRef: null,
};

View File

@@ -17,6 +17,11 @@ const messages = defineMessages({
defaultMessage: 'Edit Title',
description: 'Screen reader label title for icon button to edit the xblock title',
},
editTitlePlaceholder: {
id: 'authoring.texteditor.header.editTitleLabelPlaceholder',
defaultMessage: 'Title',
description: 'Screen reader label title for icon button to edit the xblock title',
},
cancelTitleEdit: {
id: 'authoring.texteditor.header.cancelTitleEdit',
defaultMessage: 'Cancel',

View File

@@ -3,36 +3,33 @@ import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { Button } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { thunkActions } from '../../../data/redux';
import BaseModal from '../../../sharedComponents/BaseModal';
// This 'module' self-import hack enables mocking during tests.
// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
// should be re-thought and cleaned up to avoid this pattern.
// eslint-disable-next-line import/no-self-import
import * as module from './SelectVideoModal';
import messages from './messages';
export const hooks = {
videoList: ({ fetchVideos }) => {
export const useVideoList = ({ fetchVideos }) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [videos, setVideos] = React.useState(null);
// eslint-disable-next-line react-hooks/rules-of-hooks
React.useEffect(() => {
fetchVideos({ onSuccess: setVideos });
}, []);
return videos;
},
onSelectClick: ({ setSelection, videos }) => () => setSelection(videos[0]),
const [videos, setVideos] = React.useState(null);
// eslint-disable-next-line react-hooks/rules-of-hooks
React.useEffect(() => {
fetchVideos({ onSuccess: setVideos });
}, []);
return videos;
};
export const useOnSelectClick = ({ setSelection, videos }) => () => setSelection(videos[0]);
export const SelectVideoModal = ({
fetchVideos,
isOpen,
close,
setSelection,
}) => {
const videos = module.hooks.videoList({ fetchVideos });
const onSelectClick = module.hooks.onSelectClick({
const intl = useIntl();
const videos = useVideoList({ fetchVideos });
const onSelectClick = useOnSelectClick({
setSelection,
videos,
});
@@ -41,13 +38,13 @@ export const SelectVideoModal = ({
<BaseModal
isOpen={isOpen}
close={close}
title="Add a video"
title={intl.formatMessage(messages.selectVideoModalTitle)}
confirmAction={<Button variant="primary" onClick={onSelectClick}>Next</Button>}
>
{/* Content selection */}
{videos && (videos.map(
img => (
<div key={img.externalUrl} />
<div key={img.externalUrl}>{img.externalUrl}</div>
),
))}
</BaseModal>
@@ -62,7 +59,7 @@ SelectVideoModal.propTypes = {
fetchVideos: PropTypes.func.isRequired,
};
export const mapStateToProps = () => ({});
export const mapStateToProps = null;
export const mapDispatchToProps = {
fetchVideos: thunkActions.app.fetchVideos,
};

View File

@@ -0,0 +1,131 @@
import React from 'react';
import {
render, screen, fireEvent, waitFor,
} from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { Provider } from 'react-redux';
import configureStore from 'redux-mock-store';
import { SelectVideoModal, useVideoList, useOnSelectClick } from './SelectVideoModal';
import messages from './messages';
describe('SelectVideoModal', () => {
const mockStore = configureStore([]);
const mockFetchVideos = jest.fn();
const mockSetSelection = jest.fn();
const mockClose = jest.fn();
let store;
beforeEach(() => {
store = mockStore({});
jest.clearAllMocks();
});
it('renders the modal with the correct title', () => {
render(
<Provider store={store}>
<IntlProvider locale="en" messages={messages}>
<SelectVideoModal
isOpen
close={mockClose}
setSelection={mockSetSelection}
fetchVideos={mockFetchVideos}
/>
</IntlProvider>
</Provider>,
);
expect(screen.getByText(messages.selectVideoModalTitle.defaultMessage)).toBeInTheDocument();
});
it('calls close when the modal is closed', () => {
render(
<Provider store={store}>
<IntlProvider locale="en" messages={messages}>
<SelectVideoModal
isOpen
close={mockClose}
setSelection={mockSetSelection}
fetchVideos={mockFetchVideos}
/>
</IntlProvider>
</Provider>,
);
fireEvent.click(screen.getByRole('button', { name: /close/i }));
expect(mockClose).toHaveBeenCalled();
});
it('renders a div for each video in the videos array', () => {
const videos = [
{ externalUrl: 'video1.mp4' },
{ externalUrl: 'video2.mp4' },
];
const fetchVideos = jest.fn(({ onSuccess }) => onSuccess(videos));
render(
<Provider store={store}>
<IntlProvider locale="en" messages={messages}>
<SelectVideoModal
isOpen
close={jest.fn()}
setSelection={jest.fn()}
fetchVideos={fetchVideos}
/>
</IntlProvider>
</Provider>,
);
// Assert that a div is rendered for each video
videos.forEach((video) => {
expect(screen.getByText(video.externalUrl)).toBeInTheDocument();
});
});
});
describe('SelectVideoModal hooks', () => {
describe('useVideoList', () => {
it('fetches videos and sets them in state', async () => {
// eslint-disable-next-line react/prop-types
const TestComponent = ({ fetchVideos }) => {
const videos = useVideoList({ fetchVideos });
return (
<div data-testid="videos">
{videos ? videos.map((v) => v.externalUrl).join(', ') : 'Loading'}
</div>
);
};
const videos = [
{ externalUrl: 'video1.mp4' },
{ externalUrl: 'video2.mp4' },
];
const fetchVideos = jest.fn(({ onSuccess }) => {
onSuccess(videos);
});
render(<TestComponent fetchVideos={fetchVideos} />);
await waitFor(() => expect(screen.getByTestId('videos').textContent).toBe(videos.map((v) => v.externalUrl).join(', ')));
expect(fetchVideos).toHaveBeenCalled();
});
});
describe('useOnSelectClick', () => {
it('calls setSelection with the first video', () => {
const mockSetSelection = jest.fn();
const videos = [
{ externalUrl: 'video1.mp4' },
{ externalUrl: 'video2.mp4' },
];
const onSelectClick = useOnSelectClick({
setSelection: mockSetSelection,
videos,
});
onSelectClick();
expect(mockSetSelection).toHaveBeenCalledWith(videos[0]);
});
});
});

View File

@@ -0,0 +1,11 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
selectVideoModalTitle: {
id: 'authoring.videoEditor.selectVideoModal.title',
defaultMessage: 'Add a video',
description: 'Title for the select video modal.',
},
});
export default messages;

View File

@@ -5,7 +5,7 @@ import { Alert, Form, Hyperlink } from '@openedx/paragon';
import {
Warning as WarningIcon,
} from '@openedx/paragon/icons';
import { FormattedMessage, injectIntl } from '@edx/frontend-platform/i18n';
import { FormattedMessage, injectIntl, useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';
import PrereqSettings from './PrereqSettings';
@@ -32,6 +32,8 @@ const AdvancedTab = ({
} = values;
let examTypeValue = 'none';
const intl = useIntl();
if (isTimeLimited && isProctoredExam) {
if (isOnboardingExam) {
examTypeValue = 'onboardingExam';
@@ -183,7 +185,7 @@ const AdvancedTab = ({
<Form.Control
onChange={setCurrentTimeLimit}
value={timeLimit}
placeholder="HH:MM"
placeholder={intl.formatMessage(messages.timeLimitPlaceholder)}
pattern="^[0-9][0-9]:[0-5][0-9]$"
/>
</Form.Group>

View File

@@ -239,6 +239,11 @@ const messages = defineMessages({
id: 'course-authoring.course-outline.configure-modal.advanced-tab.time-allotted',
defaultMessage: 'Time allotted (HH:MM):',
},
timeLimitPlaceholder: {
id: 'course-authoring.course-outline.configure-modal.advanced-tab.time-limit-placeholder',
defaultMessage: 'HH:MM',
description: 'The placeholder for the time limit input field, two digits for hours and two digits for minutes colons in between',
},
timeLimitDescription: {
id: 'course-authoring.course-outline.configure-modal.advanced-tab.time-limit-description',
defaultMessage: 'Select a time allotment for the exam. If it is over 24 hours, type in the amount of time. You can grant individual learners extra time to complete the exam through the Instructor Dashboard.',

View File

@@ -3,6 +3,7 @@ import { useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import { SearchField } from '@openedx/paragon';
import { debounce } from 'lodash';
import { useIntl } from '@edx/frontend-platform/i18n';
import { getStudioHomeCoursesParams } from '../../../data/selectors';
import { updateStudioHomeCoursesCustomParams } from '../../../data/slice';
@@ -11,6 +12,7 @@ import { LoadingSpinner } from '../../../../generic/Loading';
import CoursesTypesFilterMenu from './courses-types-filter-menu';
import CoursesOrderFilterMenu from './courses-order-filter-menu';
import './index.scss';
import messages from './messages';
/* regex to check if a string has only whitespace
example " "
@@ -33,6 +35,8 @@ const CoursesFilters = ({
} = studioHomeCoursesParams;
const [inputSearchValue, setInputSearchValue] = useState('');
const intl = useIntl();
const getFilterTypeData = (baseFilters) => ({
archivedCourses: { ...baseFilters, archivedOnly: true, activeOnly: undefined },
activeCourses: { ...baseFilters, activeOnly: true, archivedOnly: undefined },
@@ -107,7 +111,7 @@ const CoursesFilters = ({
value={cleanFilters ? '' : inputSearchValue}
className="mr-4"
data-testid="input-filter-courses-search"
placeholder="Search"
placeholder={intl.formatMessage(messages.coursesSearchPlaceholder)}
/>
{isLoading && (
<span className="search-field-loading" data-testid="loading-search-spinner">

View File

@@ -0,0 +1,11 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
coursesSearchPlaceholder: {
id: 'course-authoring.studio-home.courses.search-placeholder',
defaultMessage: 'Search',
},
});
export default messages;

View File

@@ -40,7 +40,7 @@ const ExportStep = ({ taxonomy }) => {
const intl = useIntl();
return (
<Stepper.Step eventKey="export" title="export">
<Stepper.Step eventKey="export" title={intl.formatMessage(messages.importWizardStepperExportStepTitle)}>
<Stack gap={3} data-testid="export-step">
<p>{intl.formatMessage(messages.importWizardStepExportBody, { br: linebreak })}</p>
<Stack gap={3} direction="horizontal">
@@ -97,7 +97,7 @@ const UploadStep = ({
};
return (
<Stepper.Step eventKey="upload" title="upload" hasError={!!importPlanError}>
<Stepper.Step eventKey="upload" title={intl.formatMessage(messages.importWizardStepperUploadStepTitle)} hasError={!!importPlanError}>
<Stack gap={3} data-testid="upload-step">
<p>{
reimport
@@ -190,7 +190,7 @@ const PopulateStep = ({
};
return (
<Stepper.Step eventKey="populate" title="populate">
<Stepper.Step eventKey="populate" title={intl.formatMessage(messages.importWizardStepperPopulateStepTitle)}>
<Stack gap={3} data-testid="populate-step">
<Form.Group>
<Form.Label>{ intl.formatMessage(messages.importWizardStepPopulateTaxonomyName) }</Form.Label>
@@ -222,7 +222,7 @@ const PlanStep = ({ importPlan }) => {
const intl = useIntl();
return (
<Stepper.Step eventKey="plan" title="plan">
<Stepper.Step eventKey="plan" title={intl.formatMessage(messages.importWizardStepperPlanStepTitle)}>
<Stack gap={3} data-testid="plan-step">
{intl.formatMessage(messages.importWizardStepPlanBody, { br: linebreak, changeCount: importPlan?.length })}
<ul className="h-200px" style={{ overflow: 'scroll' }}>
@@ -249,7 +249,7 @@ const ConfirmStep = ({ importPlan }) => {
const intl = useIntl();
return (
<Stepper.Step eventKey="confirm" title="confirm">
<Stepper.Step eventKey="confirm" title={intl.formatMessage(messages.importWizardStepperConfirmStepTitle)}>
<Stack data-testid="confirm-step">
{intl.formatMessage(
messages.importWizardStepConfirmBody,

View File

@@ -154,6 +154,26 @@ const messages = defineMessages({
id: 'course-authoring.import-tags.error-alert.title',
defaultMessage: 'Import error',
},
importWizardStepperExportStepTitle: {
id: 'course-authoring.import-tags.wizard.stepper.export-step.title',
defaultMessage: 'Export',
},
importWizardStepperUploadStepTitle: {
id: 'course-authoring.import-tags.wizard.stepper.upload-step.title',
defaultMessage: 'Upload',
},
importWizardStepperPopulateStepTitle: {
id: 'course-authoring.import-tags.wizard.stepper.populate-step.title',
defaultMessage: 'Populate',
},
importWizardStepperPlanStepTitle: {
id: 'course-authoring.import-tags.wizard.stepper.plan-step.title',
defaultMessage: 'Plan',
},
importWizardStepperConfirmStepTitle: {
id: 'course-authoring.import-tags.wizard.stepper.confirm-step.title',
defaultMessage: 'Confirm',
},
});
export default messages;