Commit 45ec5453 authored by Robert Knight's avatar Robert Knight

Remove unused `wrapReactComponent` utility

Also remove the `angular-util` helpers which are no longer used after
this change.
parent 7bbb8a60
/* global angular */
/**
* Converts a camelCase name into hyphenated ('camel-case') form.
*
* This matches how Angular maps directive names to HTML tag names.
*/
function hyphenate(name) {
const uppercasePattern = /([A-Z])/g;
return name.replace(uppercasePattern, '-$1').toLowerCase();
}
/**
* A helper for instantiating an AngularJS directive in a unit test.
*
* Usage:
* var domElement = createDirective(document, 'myComponent', {
* attrA: 'initial-value'
* }, {
* scopeProperty: scopeValue
* },
* 'Hello, world!');
*
* Will generate '<my-component attr-a="attrA">Hello, world!</my-component>' and
* compile and link it with the scope:
*
* { attrA: 'initial-value', scopeProperty: scopeValue }
*
* The initial value may be a callback function to invoke. eg:
*
* var domElement = createDirective(document, 'myComponent', {
* onEvent: function () {
* console.log('event triggered');
* }
* });
*
* If the callback accepts named arguments, these need to be specified
* via an object with 'args' and 'callback' properties:
*
* var domElement = createDirective(document, 'myComponent', {
* onEvent: {
* args: ['arg1'],
* callback: function (arg1) {
* console.log('callback called with arg', arg1);
* }
* }
* });
*
* @param {Document} document - The DOM Document to create the element in
* @param {string} name - The name of the directive to instantiate
* @param {Object} [attrs] - A map of attribute names (in camelCase) to initial
* values.
* @param {Object} [initialScope] - A dictionary of properties to set on the
* scope when the element is linked
* @param {string} [initialHtml] - Initial inner HTML content for the directive
* element.
* @param {Object} [opts] - Object specifying options for creating the
* directive:
* 'parentElement' - The parent element for the new
* directive. Defaults to document.body
*
* @return {DOMElement} The Angular jqLite-wrapped DOM element for the component.
* The returned object has a link(scope) method which will
* re-link the component with new properties.
*/
export function createDirective(
document,
name,
attrs,
initialScope,
initialHtml,
opts
) {
attrs = attrs || {};
initialScope = initialScope || {};
initialHtml = initialHtml || '';
opts = opts || {};
opts.parentElement = opts.parentElement || document.body;
// Create a template consisting of a single element, the directive
// we want to create and compile it.
let $compile;
let $scope;
angular.mock.inject(function (_$compile_, _$rootScope_) {
$compile = _$compile_;
$scope = _$rootScope_.$new();
});
const templateElement = document.createElement(hyphenate(name));
Object.keys(attrs).forEach(function (key) {
const attrName = hyphenate(key);
let attrKey = key;
if (typeof attrs[key] === 'function') {
// If the input property is a function, generate a function expression,
// eg. `<my-component on-event="onEvent()">`
attrKey += '()';
} else if (attrs[key].callback) {
// If the input property is a function which accepts arguments,
// generate the argument list.
// eg. `<my-component on-change="onChange(newValue)">`
attrKey += '(' + attrs[key].args.join(',') + ')';
}
templateElement.setAttribute(attrName, attrKey);
});
templateElement.innerHTML = initialHtml;
// Add the element to the document's body so that
// it responds to events, becomes visible, reports correct
// values for its dimensions etc.
opts.parentElement.appendChild(templateElement);
// setup initial scope
Object.keys(attrs).forEach(function (key) {
if (attrs[key].callback) {
$scope[key] = attrs[key].callback;
} else {
$scope[key] = attrs[key];
}
});
// compile the template
const linkFn = $compile(templateElement);
// link the component, passing in the initial
// scope values. The caller can then re-render/link
// the template passing in different properties
// and verify the output
const linkDirective = function (props) {
const childScope = $scope.$new();
angular.extend(childScope, props);
const element = linkFn(childScope);
element.scope = childScope;
childScope.$digest();
element.ctrl = element.controller(name);
if (!element.ctrl) {
throw new Error(
'Failed to create "' +
name +
'" directive in test.' +
'Did you forget to register it with angular.module(...).directive() ?'
);
}
return element;
};
return linkDirective(initialScope);
}
import angular from 'angular';
import { Component, createElement } from 'preact';
import { useContext } from 'preact/hooks';
import propTypes from 'prop-types';
import { Injector } from '../../../shared/injector';
import { createDirective } from '../../components/test/angular-util';
import { ServiceContext } from '../service-context';
import wrapReactComponent from '../wrap-react-component';
// Saved `onDblClick` prop from last render of `Button`.
// This makes it easy to call it with different arguments.
let lastOnDblClickCallback;
// Saved service context from last render of `Button`.
// Components in the tree can use this to get at Angular services.
let lastServiceContext;
function Button({ label, isDisabled, onClick, onDblClick }) {
// We don't actually use the `onDblClick` handler in this component.
// It exists just to test callbacks that take arguments.
lastOnDblClickCallback = onDblClick;
lastServiceContext = useContext(ServiceContext);
return (
<button disabled={isDisabled} onClick={onClick}>
{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,
};
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 };
}
let servicesInjector;
beforeEach(() => {
const servicesInjector = new Injector();
servicesInjector.register('theme', { value: 'dark' });
angular
.module('app', [])
.component('btn', wrapReactComponent(Button, servicesInjector));
angular.mock.module('app');
});
afterEach(() => {
if (console.error.restore) {
console.error.restore();
}
});
it('derives Angular component "bindings" from React "propTypes"', () => {
const ngComponent = wrapReactComponent(Button, servicesInjector);
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('exposes Angular services to the React component and descendants', () => {
lastServiceContext = null;
renderButton();
assert.ok(lastServiceContext);
assert.equal(lastServiceContext.get('theme'), '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, servicesInjector));
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, servicesInjector),
'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 prop `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('supports invoking callback properties if a digest cycle is already in progress', () => {
const { element, onDblClick } = renderButton();
element.scope.$apply(() => {
lastOnDblClickCallback({ count: 1 });
});
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, servicesInjector));
angular.mock.module('app');
const element = createDirective(document, 'parent');
assert.isNull(element[0].querySelector('.click-indicator'));
const btn = element.find('button')[0];
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'));
});
});
import { createElement, render } from 'preact';
import { ServiceContext } from './service-context';
function useExpressionBinding(propName) {
return propName.match(/^on[A-Z]/);
}
/**
* @typedef {import('../../shared/injector').Injector} Injector
*/
/**
* Controller for an Angular component that wraps a React component.
*
* This is responsible for taking the inputs to the Angular component and
* rendering the React component in the DOM node where the Angular component
* has been created.
*/
class ReactController {
constructor($element, services, $scope, type) {
/** The DOM element where the React component should be rendered. */
this.domElement = $element[0];
/**
* The services injector, used by this component and its descendants.
*
* @type {Injector}
*/
this.services = services;
/** The React component function or class. */
this.type = type;
/** The input props to the React component. */
this.props = {};
// 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}"`
);
}
// Test whether a digest cycle is already in progress using `$$phase`,
// in which case there is no need to trigger one with `$apply`.
//
// Most of the time there will be no digest cycle in progress, but this
// can happen if a change made by Angular code indirectly causes a
// component to call a function prop.
if ($scope.$root.$$phase) {
this[propName](arg);
} else {
$scope.$apply(() => {
this[propName](arg);
});
}
};
});
}
$onChanges(changes) {
// Copy updated property values from parent Angular component to React
// props. This callback is run when the component is initially created as
// well as subsequent updates.
Object.keys(changes).forEach(propName => {
if (!useExpressionBinding(propName)) {
this.props[propName] = changes[propName].currentValue;
}
});
this.updateReactComponent();
}
$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.domElement);
}
updateReactComponent() {
// Render component, with a `ServiceContext.Provider` wrapper which
// provides access to services via `withServices` or `useContext`
// in child components.
render(
<ServiceContext.Provider value={this.services}>
<this.type {...this.props} />
</ServiceContext.Provider>,
this.domElement
);
}
}
/**
* 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 a service, it can get at
* them using the `withServices` wrapper from service-context.js.
*
* @param {Function} type - The React component class or function
* @param {Injector} services
* Dependency injection container providing services that components use.
* @return {Object} -
* An AngularJS component spec for use with `angular.component(...)`
*/
export default function wrapReactComponent(type, services) {
if (!type.propTypes) {
throw new Error(
`React component ${type.name} does not specify its inputs using "propTypes"`
);
}
/**
* Create an AngularJS component controller that renders the specific React
* component being wrapped.
*/
// @ngInject
function createController($element, $scope) {
return new ReactController($element, services, $scope, type);
}
const bindings = {};
Object.keys(type.propTypes).forEach(propName => {
bindings[propName] = useExpressionBinding(propName) ? '&' : '<';
});
return {
bindings,
controller: createController,
};
}
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