diff --git a/SUMMARY.md b/SUMMARY.md index b2adcb25a..3200c2826 100644 --- a/SUMMARY.md +++ b/SUMMARY.md @@ -42,6 +42,7 @@ * [first()](/docs/api/ShallowWrapper/first.md) * [forEach(fn)](/docs/api/ShallowWrapper/forEach.md) * [get(index)](/docs/api/ShallowWrapper/get.md) + * [getWrappingComponent()](/docs/api/ShallowWrapper/getWrappingComponent.md) * [getElement(index)](/docs/api/ShallowWrapper/getElement.md) * [getElements(index)](/docs/api/ShallowWrapper/getElements.md) * [hasClass(className)](/docs/api/ShallowWrapper/hasClass.md) @@ -104,6 +105,7 @@ * [forEach(fn)](/docs/api/ReactWrapper/forEach.md) * [get(index)](/docs/api/ReactWrapper/get.md) * [getDOMNode()](/docs/api/ReactWrapper/getDOMNode.md) + * [getWrappingComponent()](/docs/api/ReactWrapper/getWrappingComponent.md) * [hasClass(className)](/docs/api/ReactWrapper/hasClass.md) * [hostNodes()](/docs/api/ReactWrapper/hostNodes.md) * [html()](/docs/api/ReactWrapper/html.md) diff --git a/docs/api/ReactWrapper/getWrappingComponent.md b/docs/api/ReactWrapper/getWrappingComponent.md new file mode 100644 index 000000000..4f6f6dd25 --- /dev/null +++ b/docs/api/ReactWrapper/getWrappingComponent.md @@ -0,0 +1,45 @@ +# `.getWrappingComponent() => ReactWrapper` + +If a `wrappingComponent` was passed in `options`, this methods returns a `ReactWrapper` around the rendered `wrappingComponent`. This `ReactWrapper` can be used to update the `wrappingComponent`'s props, state, etc. + + +#### Returns + +`ReactWrapper`: A `ReactWrapper` around the rendered `wrappingComponent` + + + +#### Examples + +```jsx +import { Provider } from 'react-redux'; +import { Router } from 'react-router'; +import store from './my/app/store'; +import mockStore from './my/app/mockStore'; + +function MyProvider(props) { + const { children, customStore } = props; + + return ( + + + {children} + + + ); +} +MyProvider.propTypes = { + children: PropTypes.node, + customStore: PropTypes.shape({}), +}; +MyProvider.defaultProps = { + children: null, + customStore: null, +}; + +const wrapper = mount(, { + wrappingComponent: MyProvider, +}); +const provider = wrapper.getWrappingComponent(); +provider.setProps({ customStore: mockStore }); +``` diff --git a/docs/api/ShallowWrapper/getWrappingComponent.md b/docs/api/ShallowWrapper/getWrappingComponent.md new file mode 100644 index 000000000..d40dc1667 --- /dev/null +++ b/docs/api/ShallowWrapper/getWrappingComponent.md @@ -0,0 +1,45 @@ +# `.getWrappingComponent() => ShallowWrapper` + +If a `wrappingComponent` was passed in `options`, this methods returns a `ShallowWrapper` around the rendered `wrappingComponent`. This `ShallowWrapper` can be used to update the `wrappingComponent`'s props, state, etc. + + +#### Returns + +`ShallowWrapper`: A `ShallowWrapper` around the rendered `wrappingComponent` + + + +#### Examples + +```jsx +import { Provider } from 'react-redux'; +import { Router } from 'react-router'; +import store from './my/app/store'; +import mockStore from './my/app/mockStore'; + +function MyProvider(props) { + const { children, customStore } = props; + + return ( + + + {children} + + + ); +} +MyProvider.propTypes = { + children: PropTypes.node, + customStore: PropTypes.shape({}), +}; +MyProvider.defaultProps = { + children: null, + customStore: null, +}; + +const wrapper = shallow(, { + wrappingComponent: MyProvider, +}); +const provider = wrapper.getWrappingComponent(); +provider.setProps({ customStore: mockStore }); +``` diff --git a/docs/api/mount.md b/docs/api/mount.md index 58b4e1f16..a5bdb704b 100644 --- a/docs/api/mount.md +++ b/docs/api/mount.md @@ -49,6 +49,8 @@ describe('', () => { - `options.context`: (`Object` [optional]): Context to be passed into the component - `options.attachTo`: (`DOMElement` [optional]): DOM Element to attach the component to. - `options.childContextTypes`: (`Object` [optional]): Merged contextTypes for all children of the wrapper. +- `options.wrappingComponent`: (`ComponentType` [optional]): A component that will render as a parent of the `node`. It can be used to provide context to the `node`, among other things. See the [`getWrappingComponent()` docs](ReactWrapper/getWrappingComponent.md) for an example. **Note**: `wrappingComponent` _must_ render its children. +- `options.wrappingComponentProps`: (`Object` [optional]): Initial props to pass to the `wrappingComponent` if it is specified. #### Returns @@ -177,6 +179,9 @@ Manually sets context of the root component. #### [`.instance() => ReactComponent|DOMComponent`](ReactWrapper/instance.md) Returns the wrapper's underlying instance. +#### [`.getWrappingComponent() => ReactWrapper`](ReactWrapper/getWrappingComponent.md) +Returns a wrapper representing the `wrappingComponent`, if one was passed. + #### [`.unmount() => ReactWrapper`](ReactWrapper/unmount.md) A method that un-mounts the component. diff --git a/docs/api/shallow.md b/docs/api/shallow.md index 7fd47d65a..77065a61a 100644 --- a/docs/api/shallow.md +++ b/docs/api/shallow.md @@ -50,6 +50,8 @@ describe('', () => { - `options.disableLifecycleMethods`: (`Boolean` [optional]): If set to true, `componentDidMount` is not called on the component, and `componentDidUpdate` is not called after [`setProps`](ShallowWrapper/setProps.md) and [`setContext`](ShallowWrapper/setContext.md). Default to `false`. +- `options.wrappingComponent`: (`ComponentType` [optional]): A component that will render as a parent of the `node`. It can be used to provide context to the `node`, among other things. See the [`getWrappingComponent()` docs](ShallowWrapper/getWrappingComponent.md) for an example. **Note**: `wrappingComponent` _must_ render its children. +- `options.wrappingComponentProps`: (`Object` [optional]): Initial props to pass to the `wrappingComponent` if it is specified. #### Returns @@ -187,6 +189,9 @@ Manually sets props of the root component. #### [`.setContext(context) => ShallowWrapper`](ShallowWrapper/setContext.md) Manually sets context of the root component. +#### [`.getWrappingComponent() => ShallowWrapper`](ShallowWrapper/getWrappingComponent.md) +Returns a wrapper representing the `wrappingComponent`, if one was passed. + #### [`.instance() => ReactComponent`](ShallowWrapper/instance.md) Returns the instance of the root component. diff --git a/packages/enzyme-test-suite/test/ReactWrapper-spec.jsx b/packages/enzyme-test-suite/test/ReactWrapper-spec.jsx index d23c3df93..895c88245 100644 --- a/packages/enzyme-test-suite/test/ReactWrapper-spec.jsx +++ b/packages/enzyme-test-suite/test/ReactWrapper-spec.jsx @@ -176,6 +176,191 @@ describeWithDOM('mount', () => { expect(() => wrapper.state('key')).to.throw('ReactWrapper::state("key") requires that `state` not be `null` or `undefined`'); }); + describeIf(is('>= 0.14'), 'wrappingComponent', () => { + const realCreateMountRenderer = getAdapter().createMountRenderer; + let wrapper; + + class More extends React.Component { + render() { + return null; + } + } + + class TestProvider extends React.Component { + getChildContext() { + const { value, renderMore } = this.props; + + return { + testContext: value || 'Hello world!', + renderMore: renderMore || false, + }; + } + + render() { + const { children } = this.props; + + return children; + } + } + TestProvider.childContextTypes = { + testContext: PropTypes.string, + renderMore: PropTypes.bool, + }; + + class MyWrappingComponent extends React.Component { + render() { + const { children, contextValue, renderMore } = this.props; + + return ( +
+ {children} +
+ ); + } + } + + class MyComponent extends React.Component { + render() { + const { testContext, renderMore } = this.context; + + return ( +
+
Context says: {testContext}
+ {renderMore && } +
+ ); + } + } + MyComponent.contextTypes = TestProvider.childContextTypes; + + beforeEach(() => { + wrapper = mount(, { + wrappingComponent: MyWrappingComponent, + }); + }); + + it('mounts the passed node as the root as per usual', () => { + expect(wrapper.type()).to.equal(MyComponent); + expect(wrapper.parent().exists()).to.equal(false); + expect(() => wrapper.setProps({ foo: 'bar' })).not.to.throw(); + }); + + it('renders the root in the wrapping component', () => { + // Context will only be set properly if the root node is rendered as a descendent of + // the wrapping component. + expect(wrapper.text()).to.equal('Context says: Hello world!'); + }); + + it('supports mounting the wrapping component with initial props', () => { + wrapper.unmount(); + wrapper = mount(, { + wrappingComponent: MyWrappingComponent, + wrappingComponentProps: { contextValue: 'I can be set!' }, + }); + expect(wrapper.text()).to.equal('Context says: I can be set!'); + }); + + it('throws an error if the wrappingComponent does not render its children', () => { + class BadWrapper extends React.Component { + render() { + return
; + } + } + expect(() => mount(, { + wrappingComponent: BadWrapper, + })).to.throw('`wrappingComponent` must render its children!'); + }); + + describe('getWrappingComponent()', () => { + let wrappingComponent; + + beforeEach(() => { + wrappingComponent = wrapper.getWrappingComponent(); + }); + + it('gets a ReactWrapper for the wrappingComponent', () => { + expect(wrappingComponent.type()).to.equal(MyWrappingComponent); + expect(wrappingComponent.parent().exists()).to.equal(false); + + wrappingComponent.setProps({ contextValue: 'this is a test.' }); + expect(wrapper.text()).to.equal('Context says: this is a test.'); + }); + + it('updates the wrapper when the wrappingComponent is updated', () => { + wrappingComponent.setProps({ renderMore: true }); + expect(wrapper.find(More).exists()).to.equal(true); + }); + + it('updates the wrappingComponent when the root is updated', () => { + wrapper.unmount(); + expect(wrappingComponent.exists()).to.equal(false); + }); + + it('handles the wrapper being unmounted', () => { + wrapper.unmount(); + wrappingComponent.update(); + expect(wrappingComponent.exists()).to.equal(false); + expect(() => wrappingComponent.setProps({})).to.throw('The wrapping component may not be updated if the root is unmounted.'); + }); + + it('handles a partial prop update', () => { + wrappingComponent.setProps({ contextValue: 'hello' }); + wrappingComponent.setProps({ foo: 'bar' }); + expect(wrappingComponent.prop('foo')).to.equal('bar'); + expect(wrappingComponent.prop('contextValue')).to.equal('hello'); + }); + + it('cannot be called on the non-root', () => { + expect(() => wrapper.find('div').getWrappingComponent()).to.throw('ReactWrapper::getWrappingComponent() can only be called on the root'); + }); + + it('cannot be called on itself', () => { + expect(() => wrappingComponent.getWrappingComponent()).to.throw('ReactWrapper::getWrappingComponent() can only be called on the root'); + }); + + it('throws an error if `wrappingComponent` was not provided', () => { + wrapper.unmount(); + wrapper = mount(); + expect(() => wrapper.getWrappingComponent()).to.throw('ReactWrapper::getWrappingComponent() can only be called on a wrapper that was originally passed a `wrappingComponent` option'); + }); + }); + + wrap() + .withOverrides(() => getAdapter(), () => ({ + RootFinder: undefined, + createMountRenderer: (...args) => { + const renderer = realCreateMountRenderer(...args); + delete renderer.getWrappingComponentRenderer; + renderer.getNode = () => null; + return renderer; + }, + isCustomComponent: undefined, + })) + .describe('with an old adapter', () => { + it('renders fine when wrappingComponent is not passed', () => { + wrapper = mount(); + }); + + it('throws an error if wrappingComponent is passed', () => { + expect(() => mount(, { + wrappingComponent: MyWrappingComponent, + })).to.throw('your adapter does not support `wrappingComponent`. Try upgrading it!'); + }); + }); + }); + + itIf(is('<=0.13'), 'throws an error if wrappingComponent is passed', () => { + class WrappingComponent extends React.Component { + render() { + const { children } = this.props; + return children; + } + } + expect(() => mount(
, { + wrappingComponent: WrappingComponent, + })).to.throw('your adapter does not support `wrappingComponent`. Try upgrading it!'); + }); + describeIf(is('>= 16.3'), 'uses the isValidElementType from the Adapter to validate the prop type of Component', () => { const Foo = () => null; const Bar = () => null; diff --git a/packages/enzyme-test-suite/test/ShallowWrapper-spec.jsx b/packages/enzyme-test-suite/test/ShallowWrapper-spec.jsx index 86459e854..e3bdd924e 100644 --- a/packages/enzyme-test-suite/test/ShallowWrapper-spec.jsx +++ b/packages/enzyme-test-suite/test/ShallowWrapper-spec.jsx @@ -166,6 +166,198 @@ describe('shallow', () => { `.trim()); expect(() => wrapper.state('key')).to.throw('ShallowWrapper::state("key") requires that `state` not be `null` or `undefined`'); }); + + describeIf(is('>= 0.14'), 'wrappingComponent', () => { + let wrapper; + + class More extends React.Component { + render() { + return null; + } + } + + class StateTester extends React.Component { + render() { + return null; + } + } + + class TestProvider extends React.Component { + getChildContext() { + const { value, renderMore, renderStateTester } = this.props; + + return { + testContext: value || 'Hello world!', + renderMore: renderMore || false, + renderStateTester: renderStateTester || false, + }; + } + + render() { + const { children } = this.props; + + return {children}; + } + } + TestProvider.childContextTypes = { + testContext: PropTypes.string, + renderMore: PropTypes.bool, + renderStateTester: PropTypes.bool, + }; + + class MyWrappingComponent extends React.Component { + constructor() { + super(); + this.state = { renderStateTester: false }; + } + + render() { + const { children, contextValue, renderMore } = this.props; + const { renderStateTester } = this.state; + + return ( +
+ +
+ {children} +
+
+
+ ); + } + } + + class MyComponent extends React.Component { + render() { + const { + testContext, + renderMore = true, + renderStateTester, + explicitContext, + } = this.context; + return ( +
+
Context says: {testContext}{explicitContext}
+ {renderMore && } + {renderStateTester && } +
+ ); + } + } + MyComponent.contextTypes = { + ...TestProvider.childContextTypes, + explicitContext: PropTypes.bool, + }; + + beforeEach(() => { + wrapper = shallow(, { + wrappingComponent: MyWrappingComponent, + context: { + explicitContext: ' stop!', + }, + }); + }); + + it('mounts the passed node as the root as per usual', () => { + expect(wrapper.type()).to.equal('div'); + expect(wrapper.parent().exists()).to.equal(false); + expect(() => wrapper.setProps({ foo: 'bar' })).not.to.throw(); + }); + + it('renders the root in the wrapping component', () => { + // Context will only be set properly if the root node is rendered as a descendent of + // the wrapping component. + expect(wrapper.text()).to.equal('Context says: Hello world! stop!'); + }); + + it('supports mounting the wrapping component with initial props', () => { + wrapper.unmount(); + wrapper = shallow(, { + wrappingComponent: MyWrappingComponent, + wrappingComponentProps: { contextValue: 'I can be set!' }, + }); + expect(wrapper.text()).to.equal('Context says: I can be set!'); + }); + + it('throws an error if the wrappingComponent does not render its children', () => { + class BadWrapper extends React.Component { + render() { + return
; + } + } + expect(() => shallow(, { + wrappingComponent: BadWrapper, + })).to.throw('`wrappingComponent` must render its children!'); + }); + + describe('getWrappingComponent()', () => { + let wrappingComponent; + + beforeEach(() => { + wrappingComponent = wrapper.getWrappingComponent(); + }); + + it('gets a ShallowWrapper for the wrappingComponent', () => { + expect(wrappingComponent.parent().exists()).to.equal(false); + wrappingComponent.setProps({ contextValue: 'this is a test.' }); + expect(wrapper.text()).to.equal('Context says: this is a test. stop!'); + }); + + it('updates the primary wrapper after a state update', () => { + wrappingComponent.setState({ renderStateTester: true }); + expect(wrapper.find(StateTester).exists()).to.equal(true); + expect(wrapper.text()).to.equal('Context says: Hello world! stop!'); + }); + + it('updates the wrapper when the wrappingComponent is updated', () => { + wrappingComponent.setProps({ renderMore: true }); + expect(wrapper.find(More).exists()).to.equal(true); + expect(wrapper.text()).to.equal('Context says: Hello world! stop!'); + }); + + it('handles the wrapper being unmounted', () => { + wrapper.unmount(); + expect(wrappingComponent.debug()).to.equal(''); + }); + + it('cannot be called on the non-root', () => { + expect(() => wrapper.find('div').getWrappingComponent()).to.throw('ShallowWrapper::getWrappingComponent() can only be called on the root'); + }); + + it('cannot be called on itself', () => { + expect(() => wrappingComponent.getWrappingComponent()).to.throw('ShallowWrapper::getWrappingComponent() can only be called on the root'); + }); + + it('throws an error if `wrappingComponent` was not provided', () => { + wrapper.unmount(); + wrapper = shallow(); + expect(() => wrapper.getWrappingComponent()).to.throw('ShallowWrapper::getWrappingComponent() can only be called on a wrapper that was originally passed a `wrappingComponent` option'); + }); + }); + + wrap() + .withOverrides(() => getAdapter(), () => ({ + RootFinder: undefined, + wrapWithWrappingComponent: undefined, + isCustomComponent: undefined, + })) + .describe('with an old adapter', () => { + it('renders fine when wrappingComponent is not passed', () => { + wrapper = shallow(); + expect(wrapper.type()).to.equal('div'); + }); + + it('throws an error if wrappingComponent is passed', () => { + expect(() => shallow(, { + wrappingComponent: MyWrappingComponent, + })).to.throw('your adapter does not support `wrappingComponent`. Try upgrading it!'); + }); + }); + }); }); describe('context', () => { diff --git a/packages/enzyme/src/ReactWrapper.js b/packages/enzyme/src/ReactWrapper.js index 1a8f75edc..6ce446934 100644 --- a/packages/enzyme/src/ReactWrapper.js +++ b/packages/enzyme/src/ReactWrapper.js @@ -14,6 +14,7 @@ import { privateSet, cloneElement, renderedDive, + isCustomComponent, } from './Utils'; import getAdapter from './getAdapter'; import { debugNodes } from './Debug'; @@ -35,6 +36,9 @@ const UNRENDERED = sym('__unrendered__'); const ROOT = sym('__root__'); const OPTIONS = sym('__options__'); const ROOT_NODES = sym('__rootNodes__'); +const WRAPPING_COMPONENT = sym('__wrappingComponent__'); +const LINKED_ROOTS = sym('__linkedRoots__'); +const UPDATED_BY = sym('__updatedBy__'); /** * Finds all nodes in the current wrapper nodes' render trees that match the provided predicate @@ -105,20 +109,35 @@ class ReactWrapper { throw new TypeError('ReactWrapper can only wrap valid elements'); } - privateSet(this, UNRENDERED, nodes); const renderer = adapter.createRenderer({ mode: 'mount', ...options }); privateSet(this, RENDERER, renderer); renderer.render(nodes, options.context); privateSet(this, ROOT, this); privateSetNodes(this, this[RENDERER].getNode()); + privateSet(this, OPTIONS, options); + privateSet(this, LINKED_ROOTS, []); + + if (isCustomComponent(options.wrappingComponent, adapter)) { + if (typeof this[RENDERER].getWrappingComponentRenderer !== 'function') { + throw new TypeError('your adapter does not support `wrappingComponent`. Try upgrading it!'); + } + + // eslint-disable-next-line no-use-before-define + privateSet(this, WRAPPING_COMPONENT, new WrappingComponentWrapper( + this, this[RENDERER].getWrappingComponentRenderer(), + )); + this[LINKED_ROOTS].push(this[WRAPPING_COMPONENT]); + } } else { - privateSet(this, UNRENDERED, null); privateSet(this, RENDERER, root[RENDERER]); privateSet(this, ROOT, root); privateSetNodes(this, nodes); privateSet(this, ROOT_NODES, root[NODES]); + privateSet(this, OPTIONS, root[OPTIONS]); + privateSet(this, LINKED_ROOTS, []); } - privateSet(this, OPTIONS, root ? root[OPTIONS] : options); + privateSet(this, UNRENDERED, nodes); + privateSet(this, UPDATED_BY, null); } /** @@ -222,6 +241,23 @@ class ReactWrapper { return this.single('instance', () => this[NODE].instance); } + /** + * If a `wrappingComponent` was passed in `options`, this methods returns a `ReactWrapper` around + * the rendered `wrappingComponent`. This `ReactWrapper` can be used to update the + * `wrappingComponent`'s props, state, etc. + * + * @returns ReactWrapper + */ + getWrappingComponent() { + if (this[ROOT] !== this) { + throw new Error('ReactWrapper::getWrappingComponent() can only be called on the root'); + } + if (!this[OPTIONS].wrappingComponent) { + throw new Error('ReactWrapper::getWrappingComponent() can only be called on a wrapper that was originally passed a `wrappingComponent` option'); + } + return this[WRAPPING_COMPONENT]; + } + /** * Forces a re-render. Useful to run before checking the render output if something external * may be updating the state of the component somewhere. @@ -236,6 +272,20 @@ class ReactWrapper { return root.update(); } privateSetNodes(this, this[RENDERER].getNode()); + this[LINKED_ROOTS].forEach((linkedRoot) => { + if (linkedRoot !== this[UPDATED_BY]) { + /* eslint-disable no-param-reassign */ + // Only update a linked it root if it is not the originator of our update(). + // This is needed to prevent infinite recursion when there is a bi-directional + // link between two roots. + linkedRoot[UPDATED_BY] = this; + try { + linkedRoot.update(); + } finally { + linkedRoot[UPDATED_BY] = null; + } + } + }); return this; } @@ -1176,6 +1226,28 @@ class ReactWrapper { } } +/** + * A *special* "root" wrapper that represents the component passed as `wrappingComponent`. + * It is linked to the primary root such that updates to it will update the primary, + * and vice versa. + * + * @class WrappingComponentWrapper + */ +class WrappingComponentWrapper extends ReactWrapper { + /* eslint-disable class-methods-use-this */ + constructor(root, renderer) { + super(renderer.getNode(), root); + + privateSet(this, ROOT, this); + privateSet(this, RENDERER, renderer); + this[LINKED_ROOTS].push(root); + } + + getWrappingComponent() { + throw new TypeError('ReactWrapper::getWrappingComponent() can only be called on the root'); + } +} + if (ITERATOR_SYMBOL) { Object.defineProperty(ReactWrapper.prototype, ITERATOR_SYMBOL, { configurable: true, diff --git a/packages/enzyme/src/ShallowWrapper.js b/packages/enzyme/src/ShallowWrapper.js index fca3632d6..95a8f1c36 100644 --- a/packages/enzyme/src/ShallowWrapper.js +++ b/packages/enzyme/src/ShallowWrapper.js @@ -10,6 +10,7 @@ import { typeOfNode, isReactElementAlike, displayNameOfNode, + isCustomComponent, isCustomComponentElement, ITERATOR_SYMBOL, makeOptions, @@ -41,6 +42,9 @@ const OPTIONS = sym('__options__'); const SET_STATE = sym('__setState__'); const ROOT_NODES = sym('__rootNodes__'); const CHILD_CONTEXT = sym('__childContext__'); +const WRAPPING_COMPONENT = sym('__wrappingComponent__'); +const PRIMARY_WRAPPER = sym('__primaryWrapper__'); +const ROOT_FINDER = sym('__rootFinder__'); /** * Finds all nodes in the current wrapper nodes' render trees that match the provided predicate @@ -273,6 +277,90 @@ function mockSCUIfgDSFPReturnNonNull(node, state) { } } +/** + * Recursively dive()s every custom component in a wrapper until + * the target component is found. + * + * @param {ShallowWrapper} wrapper A ShallowWrapper to search + * @param {ComponentType} target A react custom component that, when found, will end recursion + * @param {Adapter} adapter An Enzyme adapter + * @returns {ShallowWrapper|undefined} A ShallowWrapper for the target, or + * undefined if it can't be found + */ +function deepRender(wrapper, target, adapter) { + const node = wrapper[NODE]; + const element = node && adapter.nodeToElement(node); + if (wrapper.type() === target) { + return wrapper.dive(); + } + if (element && isCustomComponentElement(element, adapter)) { + return deepRender(wrapper.dive(), target, adapter); + } + const children = wrapper.children(); + for (let i = 0; i < children.length; i += 1) { + const found = deepRender(children.at(i), target, adapter); + if (typeof found !== 'undefined') { + return found; + } + } + return undefined; +} + +/** + * Deep-renders the `wrappingComponent` and returns the context that should + * be accessible to the primary wrapper. + * + * @param {WrappingComponentWrapper} wrapper The `WrappingComponentWrapper` for a + * `wrappingComponent` + * @param {Adapter} adapter An Enzyme adapter + * @returns {object} The context collected + */ +function getContextFromWrappingComponent(wrapper, adapter) { + const rootFinder = deepRender(wrapper, wrapper[ROOT_FINDER], adapter); + if (!rootFinder) { + throw new Error('`wrappingComponent` must render its children!'); + } + return rootFinder[OPTIONS].context; +} + +/** + * Makes options specifically for `ShallowWrapper`. Most of the logic here is around rendering + * a `wrappingComponent` (if one was provided) and adding the child context of that component + * to `options.context`. + * + * @param {ReactElement} nodes the nodes passed to `ShallowWrapper` + * @param {ShallowWrapper} root this `ShallowWrapper`'s parent. If this is passed, options are + * not transformed. + * @param {*} passedOptions the options passed to `ShallowWrapper`. + * @param {*} wrapper the `ShallowWrapper` itself + * @returns {Object} the decorated and transformed options + */ +function makeShallowOptions(nodes, root, passedOptions, wrapper) { + const options = makeOptions(passedOptions); + const adapter = getAdapter(passedOptions); + if (root || !isCustomComponent(options.wrappingComponent, adapter)) { + return options; + } + if (typeof adapter.wrapWithWrappingComponent !== 'function') { + throw new TypeError('your adapter does not support `wrappingComponent`. Try upgrading it!'); + } + const { node: wrappedNode, RootFinder } = adapter.wrapWithWrappingComponent(nodes, options); + // eslint-disable-next-line no-use-before-define + const wrappingComponent = new WrappingComponentWrapper(wrappedNode, wrapper, RootFinder); + const wrappingComponentContext = getContextFromWrappingComponent( + wrappingComponent, adapter, + ); + privateSet(wrapper, WRAPPING_COMPONENT, wrappingComponent); + return { + ...options, + context: { + ...options.context, + ...wrappingComponentContext, + }, + }; +} + + /** * @class ShallowWrapper */ @@ -280,7 +368,7 @@ class ShallowWrapper { constructor(nodes, root, passedOptions = {}) { validateOptions(passedOptions); - const options = makeOptions(passedOptions); + const options = makeShallowOptions(nodes, root, passedOptions, this); const adapter = getAdapter(options); const lifecycles = getAdapterLifecycles(adapter); @@ -414,6 +502,23 @@ class ShallowWrapper { return this[RENDERER].getNode().instance; } + /** + * If a `wrappingComponent` was passed in `options`, this methods returns a `ShallowWrapper` + * around the rendered `wrappingComponent`. This `ShallowWrapper` can be used to update the + * `wrappingComponent`'s props, state, etc. + * + * @returns ShallowWrapper + */ + getWrappingComponent() { + if (this[ROOT] !== this) { + throw new Error('ShallowWrapper::getWrappingComponent() can only be called on the root'); + } + if (!this[OPTIONS].wrappingComponent) { + throw new Error('ShallowWrapper::getWrappingComponent() can only be called on a wrapper that was originally passed a `wrappingComponent` option'); + } + return this[WRAPPING_COMPONENT]; + } + /** * Forces a re-render. Useful to run before checking the render output if something external * may be updating the state of the component somewhere. @@ -440,6 +545,9 @@ class ShallowWrapper { */ unmount() { this[RENDERER].unmount(); + if (this[ROOT][WRAPPING_COMPONENT]) { + this[ROOT][WRAPPING_COMPONENT].unmount(); + } return this; } @@ -1568,6 +1676,60 @@ class ShallowWrapper { } } +/** + * Updates the context of the primary wrapper when the + * `wrappingComponent` re-renders. + */ +function updatePrimaryRootContext(wrappingComponent) { + const context = getContextFromWrappingComponent( + wrappingComponent, + getAdapter(wrappingComponent[OPTIONS]), + ); + wrappingComponent[PRIMARY_WRAPPER].setContext({ + ...wrappingComponent[PRIMARY_WRAPPER][OPTIONS].context, + ...context, + }); +} + +/** + * A *special* "root" wrapper that represents the component passed as `wrappingComponent`. + * It is linked to the primary root such that updates to it will update the primary. + * + * @class WrappingComponentWrapper + */ +class WrappingComponentWrapper extends ShallowWrapper { + constructor(nodes, root, RootFinder) { + super(nodes); + privateSet(this, PRIMARY_WRAPPER, root); + privateSet(this, ROOT_FINDER, RootFinder); + } + + /** + * Like rerender() on ShallowWrapper, except it also does a "full render" of + * itself and updates the primary ShallowWrapper's context. + */ + rerender(...args) { + const result = super.rerender(...args); + updatePrimaryRootContext(this); + return result; + } + + /** + * Like setState() on ShallowWrapper, except it also does a "full render" of + * itself and updates the primary ShallowWrapper's context. + */ + setState(...args) { + const result = super.setState(...args); + updatePrimaryRootContext(this); + return result; + } + + // eslint-disable-next-line class-methods-use-this + getWrappingComponent() { + throw new Error('ShallowWrapper::getWrappingComponent() can only be called on the root'); + } +} + if (ITERATOR_SYMBOL) { Object.defineProperty(ShallowWrapper.prototype, ITERATOR_SYMBOL, { configurable: true, diff --git a/packages/enzyme/src/Utils.js b/packages/enzyme/src/Utils.js index 54f1e3674..4637c1181 100644 --- a/packages/enzyme/src/Utils.js +++ b/packages/enzyme/src/Utils.js @@ -49,6 +49,13 @@ export function makeOptions(options) { }; } +export function isCustomComponent(component, adapter) { + if (adapter.isCustomComponent) { + return !!adapter.isCustomComponent(component); + } + return typeof component === 'function'; +} + export function isCustomComponentElement(inst, adapter) { if (adapter.isCustomComponentElement) { return !!adapter.isCustomComponentElement(inst);