Commit ffab5c8d authored by Robert Knight's avatar Robert Knight

Add React context and component wrapper for injecting services

To support accessing Angular services from nested React components
without having to pass them through each parent, create a helper which
wraps a component to look up service dependencies via an injector
exposed using the context API, and inject the dependencies as props.
parent 4e975cf0
'use strict';
/**
* This module provides dependency injection of services into React
* components via React's "context" API [1].
*
* It is initially being used to enable React components to depend on Angular
* services/values without having to plumb the services through the tree of
* components.
*
* [1] See https://reactjs.org/docs/context.html#api and
* https://reactjs.org/docs/hooks-reference.html#usecontext
*/
const { useContext } = require('preact/hooks');
const { createContext, createElement } = require('preact');
const fallbackInjector = {
get(service) {
throw new Error(
`Missing ServiceContext provider to provide "${service}" prop`
);
},
};
/**
* Context type for a service dependency injector.
*
* The value should be an object with a `get(serviceName)` method which returns
* the instance of the named value or service.
*
* Consumers will either use this directly via `useContext` or use the
* `withServices` wrapper.
*/
const ServiceContext = createContext(fallbackInjector);
/**
* Wrap a React component to inject any services it depends upon as props.
*
* Components declare their service dependencies in an `injectedProps` static
* property.
*
* Any props which are passed directly will override injected props.
*
* @example
* function MyComponent({ settings }) {
* return ...
* }
*
* // Declare services that are injected from context rather than passed by
* // the parent.
* MyComponent.injectedProps = ['settings']
*
* // Wrap `MyComponent` to inject any passed props.
* module.exports = withServices(MyComponent);
*/
function withServices(Component) {
if (!Component.injectedProps) {
// This component doesn't depend on any services, so there is no need
// to wrap it.
return Component;
}
function Wrapper(props) {
const $injector = useContext(ServiceContext);
const services = {};
for (let service of Component.injectedProps) {
if (!(service in props)) {
services[service] = $injector.get(service);
}
}
return <Component {...services} {...props} />;
}
const wrappedName = Component.displayName || Component.name;
Wrapper.displayName = `withServices(${wrappedName})`;
// Forward the prop types, except for those expected to be injected via
// the `ServiceContext`.
Wrapper.propTypes = { ...Component.propTypes };
Component.injectedProps.forEach(prop => {
delete Wrapper.propTypes[prop];
});
return Wrapper;
}
module.exports = {
ServiceContext,
withServices,
};
'use strict';
const propTypes = require('prop-types');
const { createElement, render } = require('preact');
const { ServiceContext, withServices } = require('../service-context');
describe('service-context', () => {
describe('withServices', () => {
let container;
let lastProps;
function TestComponent(props) {
lastProps = props;
}
TestComponent.injectedProps = ['aService'];
const WrappedComponent = withServices(TestComponent);
beforeEach(() => {
lastProps = null;
container = document.createElement('div');
});
it('returns the input component if there are no service dependencies', () => {
function TestComponent() {}
assert.equal(withServices(TestComponent), TestComponent);
});
it('looks up services that a Component depends on and injects them as props', () => {
const testService = {};
const injector = {
get: sinon.stub().returns(testService),
};
render(
<ServiceContext.Provider value={injector}>
<WrappedComponent />
</ServiceContext.Provider>,
container
);
assert.deepEqual(lastProps, { aService: testService });
assert.calledWith(injector.get, 'aService');
});
it('copies propTypes except for injected properties to wrapper', () => {
function TestComponent() {}
TestComponent.propTypes = {
notInjected: propTypes.string,
injected: propTypes.string,
};
TestComponent.injectedProps = ['injected'];
const Wrapped = withServices(TestComponent);
assert.deepEqual(Wrapped.propTypes, { notInjected: propTypes.string });
assert.isUndefined(Wrapped.injectedProps);
});
it('does not look up services if they are passed as props', () => {
const testService = {};
const injector = {
get: sinon.stub(),
};
render(
<ServiceContext.Provider value={injector}>
<WrappedComponent aService={testService} />
</ServiceContext.Provider>,
container
);
assert.notCalled(injector.get);
});
it('throws if injector is not available', () => {
assert.throws(() => {
render(<WrappedComponent />, container);
}, /Missing ServiceContext/);
});
});
});
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