refactor: replaced injectIntl with useIntl (#1239)

* refactor: replaced injectIntl with useIntl

* test: update tests for useIntl hook implementation

* fix: add missing trailing comma

* test: fix failing component tests and remove deprecated defaultProps

- Fix SwitchContent component defaultProps warning with default parameters
- Fix Visibility component formatMessage errors and remove defaultProps
- Fix FormControls component intl provider issues with useIntl mock
- Fix EditButton component defaultProps and message formatting
- Update EditableItemHeader, EmptyContent components
- Replace React defaultProps with ES6 default parameters across components
- Update test mocking to properly handle useIntl hook
- All 82 tests now pass (previously 4 failed, 78 passed)

Resolves React deprecation warnings and modernizes component patterns.

* fix: add missing trailing comma
This commit is contained in:
Simone Saturno
2025-08-18 16:00:13 +02:00
committed by GitHub
parent c73d1f96a0
commit d2ed3e54ee
10 changed files with 176 additions and 122 deletions

View File

@@ -1,21 +1,26 @@
import React from 'react';
import { Helmet } from 'react-helmet';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import messages from './messages';
const Head = ({ intl }) => (
<Helmet>
<title>
{intl.formatMessage(messages['profile.page.title'], { siteName: getConfig().SITE_NAME })}
</title>
<link rel="shortcut icon" href={getConfig().FAVICON_URL} type="image/x-icon" />
</Helmet>
);
Head.propTypes = {
intl: intlShape.isRequired,
const Head = () => {
const intl = useIntl();
return (
<Helmet>
<title>
{intl.formatMessage(messages['profile.page.title'], {
siteName: getConfig().SITE_NAME,
})}
</title>
<link
rel="shortcut icon"
href={getConfig().FAVICON_URL}
type="image/x-icon"
/>
</Helmet>
);
};
export default injectIntl(Head);
export default Head;

View File

@@ -1,46 +1,41 @@
import React from 'react';
import PropTypes from 'prop-types';
import { EditOutline } from '@openedx/paragon/icons';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button, OverlayTrigger, Tooltip } from '@openedx/paragon';
import messages from './EditButton.messages';
const EditButton = ({
onClick, className, style, intl,
}) => (
<OverlayTrigger
key="top"
placement="top"
overlay={(
<Tooltip variant="light" id="tooltip-top">
<p className="h5 font-weight-normal m-0 p-0">
{intl.formatMessage(messages['profile.editbutton.edit'])}
</p>
</Tooltip>
)}
>
<Button
variant="link"
size="sm"
className={className}
onClick={onClick}
style={style}
const EditButton = ({ onClick, className = null, style = null }) => {
const intl = useIntl();
return (
<OverlayTrigger
key="top"
placement="top"
overlay={(
<Tooltip variant="light" id="tooltip-top">
<p className="h5 font-weight-normal m-0 p-0">
{intl.formatMessage(messages['profile.editbutton.edit'])}
</p>
</Tooltip>
)}
>
<EditOutline className="text-gray-700" />
</Button>
</OverlayTrigger>
);
<Button
variant="link"
size="sm"
className={className}
onClick={onClick}
style={style}
>
<EditOutline className="text-gray-700" />
</Button>
</OverlayTrigger>
);
};
export default injectIntl(EditButton);
export default EditButton;
EditButton.propTypes = {
onClick: PropTypes.func.isRequired,
className: PropTypes.string,
style: PropTypes.object, // eslint-disable-line
intl: intlShape.isRequired,
};
EditButton.defaultProps = {
className: null,
style: null,
};

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import EditButton from './EditButton';
const messages = {
'profile.editbutton.edit': 'Edit',
};
describe('EditButton', () => {
it('renders and calls onClick when clicked', () => {
const onClick = jest.fn();
const { getByRole } = render(
<IntlProvider locale="en" messages={messages}>
<EditButton onClick={onClick} />
</IntlProvider>,
);
const button = getByRole('button');
fireEvent.click(button);
expect(onClick).toHaveBeenCalled();
});
});

View File

@@ -7,12 +7,12 @@ import { Visibility } from './Visibility';
import { useIsOnMobileScreen } from '../../data/hooks';
const EditableItemHeader = ({
content,
showVisibility,
visibility,
showEditButton,
onClickEdit,
headingId,
content = '',
showVisibility = false,
visibility = 'private',
showEditButton = false,
onClickEdit = () => {},
headingId = null,
}) => {
const isMobileView = useIsOnMobileScreen();
return (
@@ -57,13 +57,3 @@ EditableItemHeader.propTypes = {
visibility: PropTypes.oneOf(['private', 'all_users']),
headingId: PropTypes.string,
};
EditableItemHeader.defaultProps = {
onClickEdit: () => {
},
showVisibility: false,
showEditButton: false,
content: '',
visibility: 'private',
headingId: null,
};

View File

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPlus } from '@fortawesome/free-solid-svg-icons';
const EmptyContent = ({ children, onClick, showPlusIcon }) => (
const EmptyContent = ({ children = null, onClick = null, showPlusIcon = true }) => (
<div className="p-0 m-0">
{onClick ? (
<button
@@ -27,9 +27,3 @@ EmptyContent.propTypes = {
children: PropTypes.node,
showPlusIcon: PropTypes.bool,
};
EmptyContent.defaultProps = {
onClick: null,
children: null,
showPlusIcon: true,
};

View File

@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button, StatefulButton } from '@openedx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import messages from './FormControls.messages';
@@ -9,8 +9,13 @@ import { VisibilitySelect } from './Visibility';
import { useIsVisibilityEnabled } from '../../data/hooks';
const FormControls = ({
cancelHandler, changeHandler, visibility, visibilityId, saveState, intl,
cancelHandler,
changeHandler,
visibility = 'private',
visibilityId,
saveState = null,
}) => {
const intl = useIntl();
const buttonState = saveState === 'error' ? null : saveState;
const isVisibilityEnabled = useIsVisibilityEnabled();
@@ -42,18 +47,24 @@ const FormControls = ({
type="submit"
state={buttonState}
labels={{
default: intl.formatMessage(messages['profile.formcontrols.button.save']),
pending: intl.formatMessage(messages['profile.formcontrols.button.saving']),
complete: intl.formatMessage(messages['profile.formcontrols.button.saved']),
default: intl.formatMessage(
messages['profile.formcontrols.button.save'],
),
pending: intl.formatMessage(
messages['profile.formcontrols.button.saving'],
),
complete: intl.formatMessage(
messages['profile.formcontrols.button.saved'],
),
}}
onClick={(e) => {
// Swallow clicks if the state is pending.
// We do this instead of disabling the button to prevent
// it from losing focus (disabled elements cannot have focus).
// Disabling it would causes upstream issues in focus management.
// Swallowing the onSubmit event on the form would be better, but
// we would have to add that logic for every field given our
// current structure of the application.
// Swallow clicks if the state is pending.
// We do this instead of disabling the button to prevent
// it from losing focus (disabled elements cannot have focus).
// Disabling it would causes upstream issues in focus management.
// Swallowing the onSubmit event on the form would be better, but
// we would have to add that logic for every field given our
// current structure of the application.
if (buttonState === 'pending') {
e.preventDefault();
}
@@ -66,7 +77,7 @@ const FormControls = ({
);
};
export default injectIntl(FormControls);
export default FormControls;
FormControls.propTypes = {
saveState: PropTypes.oneOf([null, 'pending', 'complete', 'error']),
@@ -74,11 +85,4 @@ FormControls.propTypes = {
visibilityId: PropTypes.string.isRequired,
cancelHandler: PropTypes.func.isRequired,
changeHandler: PropTypes.func.isRequired,
intl: intlShape.isRequired,
};
FormControls.defaultProps = {
visibility: 'private',
saveState: null,
};

View File

@@ -15,6 +15,9 @@ jest.mock('@edx/frontend-platform/i18n', () => {
const actual = jest.requireActual('@edx/frontend-platform/i18n');
return {
...actual,
useIntl: () => ({
formatMessage: (msg) => msg.id, // returns id so we can assert on it
}),
injectIntl: (Component) => (props) => (
<Component
{...props}
@@ -27,6 +30,10 @@ jest.mock('@edx/frontend-platform/i18n', () => {
};
});
jest.mock('../../data/hooks', () => ({
useIsVisibilityEnabled: () => true,
}));
describe('FormControls', () => {
it('renders Save button label when saveState is null', () => {
render(<FormControls {...defaultProps} />);

View File

@@ -17,7 +17,7 @@ const onChildExit = (htmlNode) => {
}
};
const SwitchContent = ({ expression, cases, className }) => {
const SwitchContent = ({ expression = null, cases, className = null }) => {
const getContent = (caseKey) => {
if (cases[caseKey]) {
if (typeof cases[caseKey] === 'string') {
@@ -51,9 +51,4 @@ SwitchContent.propTypes = {
className: PropTypes.string,
};
SwitchContent.defaultProps = {
expression: null,
className: null,
};
export default SwitchContent;

View File

@@ -1,17 +1,20 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faEyeSlash, faEye } from '@fortawesome/free-regular-svg-icons';
import messages from './Visibility.messages';
const Visibility = ({ to, intl }) => {
const Visibility = ({ to = 'private' }) => {
const intl = useIntl();
const icon = to === 'private' ? faEyeSlash : faEye;
const label = to === 'private'
? intl.formatMessage(messages['profile.visibility.who.just.me'])
: intl.formatMessage(messages['profile.visibility.who.everyone'], { siteName: getConfig().SITE_NAME });
: intl.formatMessage(messages['profile.visibility.who.everyone'], {
siteName: getConfig().SITE_NAME,
});
return (
<span className="ml-auto small text-muted">
@@ -22,15 +25,17 @@ const Visibility = ({ to, intl }) => {
Visibility.propTypes = {
to: PropTypes.oneOf(['private', 'all_users']),
intl: intlShape.isRequired,
};
Visibility.defaultProps = {
to: 'private',
};
const VisibilitySelect = ({ intl, className, ...props }) => {
const { value } = props;
const VisibilitySelect = ({
id = null,
className = null,
name = 'visibility',
value = null,
onChange = null,
...props
}) => {
const intl = useIntl();
const icon = value === 'private' ? faEyeSlash : faEye;
return (
@@ -38,12 +43,21 @@ const VisibilitySelect = ({ intl, className, ...props }) => {
<span className="d-inline-block ml-1 mr-2 width-24px">
<FontAwesomeIcon icon={icon} />
</span>
<select className="d-inline-block form-control" {...props}>
<select
className="d-inline-block form-control"
id={id}
name={name}
value={value}
onChange={onChange}
{...props}
>
<option key="private" value="private">
{intl.formatMessage(messages['profile.visibility.who.just.me'])}
</option>
<option key="all_users" value="all_users">
{intl.formatMessage(messages['profile.visibility.who.everyone'], { siteName: getConfig().SITE_NAME })}
{intl.formatMessage(messages['profile.visibility.who.everyone'], {
siteName: getConfig().SITE_NAME,
})}
</option>
</select>
</span>
@@ -56,21 +70,6 @@ VisibilitySelect.propTypes = {
name: PropTypes.string,
value: PropTypes.oneOf(['private', 'all_users']),
onChange: PropTypes.func,
intl: intlShape.isRequired,
};
VisibilitySelect.defaultProps = {
id: null,
className: null,
name: 'visibility',
value: null,
onChange: null,
};
const intlVisibility = injectIntl(Visibility);
const intlVisibilitySelect = injectIntl(VisibilitySelect);
export {
intlVisibility as Visibility,
intlVisibilitySelect as VisibilitySelect,
};
export { Visibility, VisibilitySelect };

View File

@@ -0,0 +1,43 @@
import React from 'react';
import { render } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { Visibility, VisibilitySelect } from './Visibility';
import '@testing-library/jest-dom';
const messages = {
'profile.visibility.who.just.me': 'Just me',
'profile.visibility.who.everyone': 'Everyone',
};
describe('Visibility', () => {
it('shows the correct icon and label for private', () => {
const { getByText } = render(
<IntlProvider locale="en" messages={messages}>
<Visibility to="private" />
</IntlProvider>,
);
expect(getByText(/just me/i)).toBeInTheDocument();
});
it('shows the correct icon and label for all_users', () => {
const { getByText } = render(
<IntlProvider locale="en" messages={messages}>
<Visibility to="all_users" />
</IntlProvider>,
);
expect(getByText(/everyone/i)).toBeInTheDocument();
});
});
describe('VisibilitySelect', () => {
it('renders both options', () => {
const { getByRole, getAllByRole } = render(
<IntlProvider locale="en" messages={messages}>
<VisibilitySelect value="private" onChange={() => {}} />
</IntlProvider>,
);
const select = getByRole('combobox');
const options = getAllByRole('option');
expect(select).toBeInTheDocument();
expect(options.length).toBe(2);
});
});