Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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",
Comment thread
bradenmacdonald marked this conversation as resolved.
Outdated
"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