Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
},
"peerDependencies": {
"@openedx/frontend-base": "^1.0.0 || 0.0.0-dev",
"react": "^18"
"react": "^18",
"prop-types": "*"
}
}
74 changes: 72 additions & 2 deletions src/platform/i18n.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import PropTypes from 'prop-types';
import { ComponentType, createElement, forwardRef } from 'react';
import { useIntl } from '@openedx/frontend-base';

/* Drop-in stand-in for `@edx/frontend-platform/i18n`.
*
* Re-exports frontend-base's full i18n surface. Sites alias
Expand All @@ -23,13 +27,79 @@ export {
getPrimaryLanguageSubtag,
getSupportedLanguageList,
handleRtl,
injectIntl,
IntlProvider,
intlShape,
isRtl,
LOCALE_CHANGED,
LOCALE_TOPIC,
mergeMessages,
updateLocale,
useIntl,
} from '@openedx/frontend-base';

/**
* prop-types is deprecated, but this minimal definition exists to preserve backwards compatibility in this shim, as
* some MFEs may still be importing `intlShape`.
* @deprecated
*/
export const intlShape = PropTypes.object;

/* `injectIntl` was dropped from frontend-base's i18n surface, but plenty of
* legacy code (especially class components) still relies on it, so this shim
* reimplements it on top of the `useIntl` hook. */

/* Derived from `useIntl` rather than imported by name: frontend-base only
* started exporting the `IntlShape` type after the release that still shipped
* its own `injectIntl`, and this shim supports both. */
export type IntlShape = ReturnType<typeof useIntl>;

export interface WrappedComponentProps {
intl: IntlShape,
}

/* Statics React (or the function object) owns; everything else on the wrapped
* component is copied onto the wrapper, the way react-intl does via
* hoist-non-react-statics. Copying `$$typeof`/`render`/`compare` would turn
* the wrapper into the component it wraps, so those are excluded too.
*
* This code exists so that e.g. something like the following works:
* const Card = injectIntl(BaseCard); // BaseCard.Header, BaseCard.Body
* <Card.Header /> // undefined without hoisting
*/
const nonHoistableStatics = new Set([
'$$typeof', '_init', '_payload', 'arguments', 'arity', 'callee', 'caller',
'childContextTypes', 'compare', 'contextType', 'contextTypes', 'defaultProps',
'displayName', 'getDefaultProps', 'getDerivedStateFromError',
'getDerivedStateFromProps', 'length', 'mixins', 'name', 'propTypes',
'prototype', 'render', 'type',
]);

/**
* Wraps a component so that it receives the current `IntlShape` as an `intl` prop.
*
* Refs are forwarded to the wrapped component, so `ref` on the wrapper reaches
* the same instance/node it would have without the wrapper. (This matches
* react-intl's `{ forwardRef: true }`; there's no reason to make it opt-in
* because the wrapper itself is never a useful ref target.)
*/
export function injectIntl<P extends WrappedComponentProps>(WrappedComponent: ComponentType<P>) {
const Injected = forwardRef<unknown, Omit<P, 'intl'>>((props, ref) => {
const intl = useIntl();
/* `ref` is only set on the element when the caller passed one, so function
* components that can't take a ref are left alone. */
return createElement(WrappedComponent, { ...props, intl, ref: ref ?? undefined } as unknown as P);
});

Injected.displayName = `injectIntl(${WrappedComponent.displayName ?? WrappedComponent.name ?? 'Component'})`;

for (const key of Object.getOwnPropertyNames(WrappedComponent)) {
if (nonHoistableStatics.has(key)) {
continue;
}
const descriptor = Object.getOwnPropertyDescriptor(WrappedComponent, key);
if (descriptor !== undefined) {
Object.defineProperty(Injected, key, descriptor);
}
}

return Object.assign(Injected, { WrappedComponent });
}
106 changes: 106 additions & 0 deletions src/platform/injectIntl.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/* Coverage for the `injectIntl` shim: frontend-base no longer ships one, so
* unlike the rest of `./i18n` this is our own implementation. */
import { Component, createRef, forwardRef, memo } from 'react';
import { render, screen } from '@testing-library/react';
import { IntlProvider } from '@openedx/frontend-base';

import { injectIntl, WrappedComponentProps } from './i18n';

const messages = { greeting: 'Hello, {name}!' };

function renderWithIntl(ui: React.ReactElement) {
return render(
<IntlProvider locale="en" messages={messages}>{ui}</IntlProvider>,
);
}

describe('injectIntl', () => {
it('passes an `intl` prop to a function component alongside its own props', () => {
function Greeting({ intl, name }: WrappedComponentProps & { name: string }) {
return <p>{intl.formatMessage({ id: 'greeting', description: 'Greets the user by name.' }, { name })}</p>;
}
const Wrapped = injectIntl(Greeting);

renderWithIntl(<Wrapped name="Ada" />);

expect(screen.getByText('Hello, Ada!')).toBeInTheDocument();
});

it('forwards refs to a class component instance', () => {
class Counter extends Component<WrappedComponentProps> {
locale() {
return this.props.intl.locale;
}

render() {
return <p>counter</p>;
}
}
const Wrapped = injectIntl(Counter);
const ref = createRef<Counter>();

renderWithIntl(<Wrapped ref={ref} />);

expect(ref.current).toBeInstanceOf(Counter);
expect(ref.current?.locale()).toEqual('en');
});

it('forwards refs through a forwardRef component to the DOM node', () => {
const Input = forwardRef<HTMLInputElement, WrappedComponentProps>(
function Input({ intl }, ref) {
return <input ref={ref} aria-label={intl.locale} />;
},
);
const Wrapped = injectIntl(Input);
const ref = createRef<HTMLInputElement>();

renderWithIntl(<Wrapped ref={ref} />);

expect(ref.current).toBe(screen.getByLabelText('en'));
});

it('keeps a memoized component memoized', () => {
let renders = 0;
const Memoized = memo(function Memoized({ intl }: WrappedComponentProps) {
renders += 1;
return <p>{intl.locale}</p>;
});
const Wrapped = injectIntl(Memoized);

const { rerender } = renderWithIntl(<Wrapped />);
rerender(
<IntlProvider locale="en" messages={messages}><Wrapped /></IntlProvider>,
);

expect(renders).toEqual(1);
});

it('does not attach a ref when the caller passes none', () => {
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
function Plain({ intl }: WrappedComponentProps) {
return <p>{intl.locale}</p>;
}
const Wrapped = injectIntl(Plain);

renderWithIntl(<Wrapped />);

/* React warns "Function components cannot be given refs" if we hand a ref
* to a component that can't hold one. */
expect(spy).not.toHaveBeenCalled();
spy.mockRestore();
});

it('sets a displayName and exposes the wrapped component and its statics', () => {
function Labelled({ intl }: WrappedComponentProps) {
return <p>{intl.locale}</p>;
}
Labelled.displayName = 'Labelled';
Labelled.someStatic = 'kept';

const Wrapped = injectIntl(Labelled);

expect(Wrapped.displayName).toEqual('injectIntl(Labelled)');
expect(Wrapped.WrappedComponent).toBe(Labelled);
expect((Wrapped as typeof Wrapped & { someStatic: string }).someStatic).toEqual('kept');
});
});
4 changes: 2 additions & 2 deletions src/platform/reExports.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/* Smoke test: the symbols re-exported from `./i18n`, `./auth`, and the
* platform-aggregate `./` are the same object identities frontend-base
* exports. Catches accidental wrappers that would silently desync from
* the underlying implementation. */
* the underlying implementation. `injectIntl` is deliberately absent: it has
* no frontend-base counterpart and is shimmed locally (see injectIntl.test.tsx). */
import * as base from '@openedx/frontend-base';

import * as platformAuth from './auth';
Expand All @@ -13,7 +14,6 @@ describe('platform re-exports', () => {
expect(platformI18n.useIntl).toBe(base.useIntl);
expect(platformI18n.defineMessages).toBe(base.defineMessages);
expect(platformI18n.IntlProvider).toBe(base.IntlProvider);
expect(platformI18n.injectIntl).toBe(base.injectIntl);
expect(platformI18n.FormattedMessage).toBe(base.FormattedMessage);
expect(platformI18n.configureI18n).toBe(base.configureI18n);
});
Expand Down