Commit c2a063d4 authored by Robert Knight's avatar Robert Knight

Add a utility that wraps React/Preact components as AngularJS components

Add a factory which takes a React/Preact component and returns an AngularJS
component that wraps it.

The wrapper handles rendering of the component and injecting Angular
services that the component needs as props.
parent 7eec2da8
......@@ -35,6 +35,9 @@ const angular = require('angular');
// it must be require'd after angular is first require'd
require('autofill-event');
// Enable debugging checks for Preact.
require('preact/debug');
// Setup Angular integration for Raven
if (appConfig.raven) {
raven.angularModule(angular);
......
'use strict';
const angular = require('angular');
const { Component, createElement } = require('preact');
const propTypes = require('prop-types');
const wrapReactComponent = require('../wrap-react-component');
const { createDirective } = require('../../directive/test/util');
// Saved `onDblClick` prop from last render of `Button`.
// This makes it easy to call it with different arguments.
let lastOnDblClickCallback;
function Button({ label, isDisabled, onClick, theme, onDblClick }) {
// We don't actually use the `onDblClick` handler in this component.
// It exists just to test callbacks that take arguments.
lastOnDblClickCallback = onDblClick;
return (
<button disabled={isDisabled} onClick={onClick} className={'btn--' + theme}>
{label}
</button>
);
}
Button.propTypes = {
// Simple input properties passed by parent component.
label: propTypes.string.isRequired,
isDisabled: propTypes.bool,
// A required callback with no arguments.
onClick: propTypes.func.isRequired,
// An optional callback with a `{ click }` argument.
onDblClick: propTypes.func,
// A property whose value comes from Angular dependency injection rather than
// the parent component.
theme: propTypes.string,
};
Button.injectedProps = ['theme'];
describe('wrapReactComponent', () => {
function renderButton() {
const onClick = sinon.stub();
const onDblClick = sinon.stub();
const element = createDirective(document, 'btn', {
label: 'Edit',
isDisabled: false,
onClick,
onDblClick: {
args: ['count'],
callback: onDblClick,
},
});
return { element, onClick, onDblClick };
}
beforeEach(() => {
angular
.module('app', [])
.component('btn', wrapReactComponent(Button))
.value('theme', 'dark');
angular.mock.module('app');
});
afterEach(() => {
if (console.error.restore) {
console.error.restore();
}
});
it('derives Angular component "bindings" from React "propTypes"', () => {
const ngComponent = wrapReactComponent(Button);
assert.deepEqual(ngComponent.bindings, {
label: '<',
isDisabled: '<',
onClick: '&',
onDblClick: '&',
// nb. Props passed via dependency injection should not appear here.
});
});
it('renders the React component when the Angular component is created', () => {
const { element, onClick } = renderButton();
const btnEl = element[0].querySelector('button');
assert.ok(btnEl);
// Check that properties are passed correctly.
assert.equal(btnEl.textContent, 'Edit');
assert.equal(btnEl.disabled, false);
// Verify that events result in callbacks being invoked.
btnEl.click();
assert.called(onClick);
});
it('gets values for injected properties from Angular', () => {
const { element } = renderButton();
const btnEl = element[0].querySelector('button');
assert.equal(btnEl.className, 'btn--dark');
});
it('updates the React component when the Angular component is updated', () => {
const { element } = renderButton();
const btnEl = element[0].querySelector('button');
assert.equal(btnEl.textContent, 'Edit');
// Change the inputs and re-render.
element.scope.label = 'Saving...';
element.scope.$digest();
// Check that the text _of the original DOM element_, was updated.
assert.equal(btnEl.textContent, 'Saving...');
});
it('removes the React component when the Angular component is destroyed', () => {
// Create parent Angular component which renders a React child.
const parentComponent = {
controllerAs: 'vm',
bindings: {
showChild: '<',
},
template: '<child ng-if="vm.showChild"></child>',
};
// Create a React child which needs to do some cleanup when destroyed.
const childUnmounted = sinon.stub();
class ChildComponent extends Component {
componentWillUnmount() {
childUnmounted();
}
}
ChildComponent.propTypes = {};
angular
.module('app', [])
.component('parent', parentComponent)
.component('child', wrapReactComponent(ChildComponent));
angular.mock.module('app');
// Render the component with the child initially visible.
const element = createDirective(document, 'parent', { showChild: true });
// Re-render with the child removed and check that the React component got
// destroyed properly.
element.scope.showChild = false;
element.scope.$digest();
assert.called(childUnmounted);
});
it('throws an error if the developer forgets to set propTypes', () => {
function TestComponent() {
return <div>Hello world</div>;
}
assert.throws(
() => wrapReactComponent(TestComponent),
'React component TestComponent does not specify its inputs using "propTypes"'
);
});
// Input property checking is handled by React when debug checks are enabled.
// This test just makes sure that these checks are working as expected.
it('throws an error if property types do not match when component is rendered', () => {
const { element } = renderButton();
const btnEl = element[0].querySelector('button');
assert.equal(btnEl.textContent, 'Edit');
// Incorrectly set label to a number, instead of a string.
element.scope.label = 123;
const consoleError = sinon.stub(console, 'error');
element.scope.$digest();
assert.calledWithMatch(
consoleError,
/Invalid Button `label` of type `number`/
);
});
it('throws an error if a callback is passed a non-object argument', () => {
renderButton();
assert.throws(() => {
lastOnDblClickCallback('not an object');
}, 'onDblClick callback must be invoked with an object. Was passed "not an object"');
});
it('supports invoking callback properties', () => {
const { onDblClick } = renderButton();
lastOnDblClickCallback({ count: 1 });
// The React component calls `onDblClick({ count: 1 })`. The template which
// renders the Angular wrapper contains an expression which references
// those variables (`<btn on-dbl-click="doSomething(count)">`) and the end
// result is that the callback gets passed the value of `count`.
assert.calledWith(onDblClick, 1);
});
it('triggers a digest cycle when invoking callback properties', () => {
// Create an Angular component which passes an `on-{event}` callback down
// to a child React component.
const parentComponent = {
controller() {
this.clicked = false;
},
controllerAs: 'vm',
template: `
<child on-click="vm.clicked = true"></child>
<div class="click-indicator" ng-if="vm.clicked">Clicked</div>
`,
};
function Child({ onClick }) {
return <button onClick={onClick}>Click me</button>;
}
Child.propTypes = { onClick: propTypes.func };
angular
.module('app', [])
.component('parent', parentComponent)
.component('child', wrapReactComponent(Child));
angular.mock.module('app');
const element = createDirective(document, 'parent');
assert.isNull(element[0].querySelector('.click-indicator'));
const btn = element.find('button');
btn.click();
// Check that parent component DOM has been updated to reflect new state of
// `vm.clicked`. This requires the `btn.click()` call to trigger a digest
// cycle.
assert.ok(element[0].querySelector('.click-indicator'));
});
});
'use strict';
const { createElement, render } = require('preact');
function useExpressionBinding(propName) {
return propName.match(/^on[A-Z]/);
}
/**
* Base controller class for React component wrappers.
*
* This is responsible for rendering the React component into the DOM element
* created by the Angular wrapper component.
*/
class ReactController {
constructor($element, $scope, injectedProps, type) {
/** The DOM element where the React component should be rendered. */
this.element = $element[0];
/** The React component function or class. */
this.type = type;
/** The input props to the React component. */
this.props = injectedProps;
// Wrap callback properties (eg. `onClick`) with `$scope.$apply` to trigger
// a digest cycle after the function is called. This ensures that the
// parent Angular component will update properly afterwards.
Object.keys(this.type.propTypes).forEach(propName => {
if (!useExpressionBinding(propName)) {
return;
}
this.props[propName] = arg => {
if (arg !== Object(arg)) {
throw new Error(
`${propName} callback must be invoked with an object. ` +
`Was passed "${arg}"`
);
}
$scope.$apply(() => {
this[propName](arg);
});
};
});
}
$onInit() {
// Copy properties supplied by the parent Angular component to React props.
Object.keys(this.type.propTypes).forEach(propName => {
if (propName in this.props) {
// Skip properties already handled in the constructor.
return;
}
this.props[propName] = this[propName];
});
this.render();
}
$onChanges(changes) {
// Copy updated property values from parent Angular component to React
// props.
Object.keys(changes).forEach(propName => {
if (!useExpressionBinding(propName)) {
this.props[propName] = changes[propName].currentValue;
}
});
this.render();
}
$onDestroy() {
// Unmount the rendered React component. Although Angular will remove the
// element itself, this is necessary to run any cleanup/unmount lifecycle
// hooks in the React component tree.
render(createElement(null), this.element);
}
render() {
// Create or update the React component.
render(createElement(this.type, this.props), this.element);
}
}
function objectWithKeysAndValues(keys, values) {
const obj = {};
for (let i = 0; i < keys.length; i++) {
obj[keys[i]] = values[i];
}
return obj;
}
/**
* Create an AngularJS component which wraps a React component.
*
* The React component must specify its expected inputs using the `propTypes`
* property on the function or class (see
* https://reactjs.org/docs/typechecking-with-proptypes.html). Props use
* one-way ('<') bindings except for those with names matching /^on[A-Z]/ which
* are assumed to be callbacks that use expression ('&') bindings.
*
* If the React component needs access to an Angular service, the service
* should be added to `propTypes` and the name listed in an `injectedProps`
* array:
*
* @example
* // In `MyComponent.js`:
* function MyComponent({ theme }) {
* return <div>You are using the {theme} theme</div>
* }
* MyComponent.propTypes = {
* theme: propTypes.string,
* }
* MyComponent.injectedProps = ['theme'];
*
* // In the Angular bootstrap code:
* angular
* .module(...)
* .component('my-component', wrapReactComponent(MyComponent))
* .value('theme', 'dark');
*
* @param {Function} type - The React component class or function
* @return {Object} -
* An AngularJS component spec for use with `angular.component(...)`
*/
function wrapReactComponent(type) {
if (!type.propTypes) {
throw new Error(
`React component ${
type.name
} does not specify its inputs using "propTypes"`
);
}
// Create controller.
const injectedPropNames = type.injectedProps || [];
class Controller extends ReactController {
constructor($element, $scope, ...injectedPropValues) {
const injectedProps = objectWithKeysAndValues(
injectedPropNames,
injectedPropValues
);
super($element, $scope, injectedProps, type);
}
}
Controller.$inject = ['$element', '$scope', ...injectedPropNames];
// Create bindings object.
const bindings = {};
Object.keys(type.propTypes)
.filter(name => !injectedPropNames.includes(name))
.forEach(propName => {
bindings[propName] = useExpressionBinding(propName) ? '&' : '<';
});
return {
bindings,
controller: Controller,
};
}
module.exports = wrapReactComponent;
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment