From 77eb7517b534abe4de074be28e16b9c4a38f3e27 Mon Sep 17 00:00:00 2001 From: Dustin Sanders Date: Sat, 7 May 2016 18:42:11 -0500 Subject: [PATCH] Added support for descendent, >, ~, and + (#217) --- docs/api/selector.md | 7 +- src/ComplexSelector.js | 125 +++++++++++++++++++++++ src/MountedTraversal.js | 5 - src/ReactWrapper.js | 17 +++- src/ShallowTraversal.js | 5 - src/ShallowWrapper.js | 13 ++- test/ComplexSelector-spec.js | 191 +++++++++++++++++++++++++++++++++++ test/ReactWrapper-spec.js | 13 --- test/ShallowWrapper-spec.js | 11 -- 9 files changed, 339 insertions(+), 48 deletions(-) create mode 100644 src/ComplexSelector.js create mode 100644 test/ComplexSelector-spec.js diff --git a/docs/api/selector.md b/docs/api/selector.md index 1beac687a..18093f6a0 100644 --- a/docs/api/selector.md +++ b/docs/api/selector.md @@ -40,17 +40,16 @@ input#input-name label[foo=true] ``` -Are all valid selectors in enzyme. At this time, however, any contextual CSS selector syntax that -requires knowledge of a node's ancestors or siblings is not yet supported. For instance: +Enzyme also gives support for the following contextual selectors ``` .foo .bar .foo > .bar +.foo + .bar +.foo ~ .bar .foo input ``` -Are all unsupported selectors in enzyme. - **Want more CSS support?** diff --git a/src/ComplexSelector.js b/src/ComplexSelector.js new file mode 100644 index 000000000..1e691c39b --- /dev/null +++ b/src/ComplexSelector.js @@ -0,0 +1,125 @@ +import split from 'lodash/split'; + +export default class ComplexSelector { + constructor(buildPredicate, findWhereUnwrapped, childrenOfNode) { + this.buildPredicate = buildPredicate; + this.findWhereUnwrapped = findWhereUnwrapped; + this.childrenOfNode = childrenOfNode; + } + + getSelectors(selector) { + const cleaned = selector.replace(/\s{2,}/g, ' '); + const selectors = split(cleaned, ' '); + return selectors.reduce((list, sel) => { + if (sel === '+' || sel === '~') { + const temp = list.pop(); + list.push(sel, temp); + return list; + } + + list.push(sel); + return list; + }, []); + } + + handleSelectors(selectors, wrapper) { + const recurseSelector = (offset, fn, pre) => { + const predicate = pre || this.buildPredicate(selectors[offset]); + const nextWrapper = this.findWhereUnwrapped(wrapper, predicate, fn); + const nextSelectors = selectors.slice(offset + 1); + return this.handleSelectors(nextSelectors, nextWrapper); + }; + + const buildSiblingPredicate = (first, second) => { + const firstPredicate = this.buildPredicate(first); + const secondPredicate = this.buildPredicate(second); + + return (child) => { + if (firstPredicate(child)) { + return (sibling) => secondPredicate(sibling); + } + + return false; + }; + }; + + let predicate; + let selectSiblings; + + if (selectors.length) { + switch (selectors[0]) { + case '>': + return recurseSelector(1, this.treeFilterDirect()); + case '+': + predicate = buildSiblingPredicate(selectors[1], selectors[2]); + selectSiblings = (children, pre, results, idx) => { + const adjacent = children[idx + 1]; + if (pre(adjacent)) { results.push(adjacent); } + }; + + return recurseSelector(2, this.treeFindSiblings(selectSiblings), predicate); + case '~': + predicate = buildSiblingPredicate(selectors[1], selectors[2]); + selectSiblings = (children, pre, results, idx) => + children.slice(idx + 1).map(child => + (pre(child) ? results.push(child) : null) + ); + + return recurseSelector(2, this.treeFindSiblings(selectSiblings), predicate); + default: + return recurseSelector(0); + } + } + + return wrapper; + } + + find(selector, wrapper) { + if (typeof selector === 'string') { + const selectors = this.getSelectors(selector); + + return this.handleSelectors(selectors, wrapper); + } + + const predicate = this.buildPredicate(selector); + return this.findWhereUnwrapped(wrapper, predicate); + } + + treeFilterDirect() { + return (tree, fn) => { + const results = []; + this.childrenOfNode(tree).forEach(child => { + if (fn(child)) { + results.push(child); + } + }); + + return results; + }; + } + + treeFindSiblings(selectSiblings) { + return (tree, fn) => { + const results = []; + const list = [this.childrenOfNode(tree)]; + + const traverseChildren = (children) => + children.forEach((child, i) => { + const secondPredicate = fn(child); + + list.push(this.childrenOfNode(child)); + + if (secondPredicate) { + selectSiblings(children, secondPredicate, results, i); + } + }); + + while (list.length) { + traverseChildren(list.shift()); + } + + return results; + }; + } + +} diff --git a/src/MountedTraversal.js b/src/MountedTraversal.js index 34995c42b..251e0d0fd 100644 --- a/src/MountedTraversal.js +++ b/src/MountedTraversal.js @@ -6,9 +6,7 @@ import { nodeEqual, propsOfNode, isFunctionalComponent, - isSimpleSelector, splitSelector, - selectorError, selectorType, isCompoundSelector, AND, @@ -208,9 +206,6 @@ export function buildInstPredicate(selector) { return inst => instHasType(inst, selector); case 'string': - if (!isSimpleSelector(selector)) { - throw selectorError(selector); - } if (isCompoundSelector.test(selector)) { return AND(splitSelector(selector).map(buildInstPredicate)); } diff --git a/src/ReactWrapper.js b/src/ReactWrapper.js index 4ed834bff..f98b5dceb 100644 --- a/src/ReactWrapper.js +++ b/src/ReactWrapper.js @@ -1,4 +1,5 @@ import React from 'react'; +import ComplexSelector from './ComplexSelector'; import cheerio from 'cheerio'; import flatten from 'lodash/flatten'; import unique from 'lodash/uniq'; @@ -35,10 +36,11 @@ import { * * @param {ReactWrapper} wrapper * @param {Function} predicate + * @param {Function} filter * @returns {ReactWrapper} */ -function findWhereUnwrapped(wrapper, predicate) { - return wrapper.flatMap(n => treeFilter(n.node, predicate)); +function findWhereUnwrapped(wrapper, predicate, filter = treeFilter) { + return wrapper.flatMap(n => filter(n.node, predicate)); } /** @@ -91,6 +93,11 @@ export default class ReactWrapper { this.length = this.nodes.length; } this.options = options; + this.complexSelector = new ComplexSelector( + buildInstPredicate, + findWhereUnwrapped, + childrenOfInst + ); } /** @@ -270,8 +277,7 @@ export default class ReactWrapper { * @returns {ReactWrapper} */ find(selector) { - const predicate = buildInstPredicate(selector); - return findWhereUnwrapped(this, predicate); + return this.complexSelector.find(selector, this); } /** @@ -641,7 +647,8 @@ export default class ReactWrapper { const nodes = this.nodes.map((n, i) => fn.call(this, this.wrap(n), i)); const flattened = flatten(nodes, true); const uniques = unique(flattened); - return this.wrap(uniques); + const compacted = compact(uniques); + return this.wrap(compacted); } /** diff --git a/src/ShallowTraversal.js b/src/ShallowTraversal.js index 2a783f694..dab70f69d 100644 --- a/src/ShallowTraversal.js +++ b/src/ShallowTraversal.js @@ -4,9 +4,7 @@ import isSubset from 'is-subset'; import { coercePropValue, propsOfNode, - isSimpleSelector, splitSelector, - selectorError, isCompoundSelector, selectorType, AND, @@ -117,9 +115,6 @@ export function buildPredicate(selector) { return node => node && node.type === selector; case 'string': - if (!isSimpleSelector(selector)) { - throw selectorError(selector); - } if (isCompoundSelector.test(selector)) { return AND(splitSelector(selector).map(buildPredicate)); } diff --git a/src/ShallowWrapper.js b/src/ShallowWrapper.js index 4589fcd79..38d723331 100644 --- a/src/ShallowWrapper.js +++ b/src/ShallowWrapper.js @@ -1,4 +1,5 @@ import React from 'react'; +import ComplexSelector from './ComplexSelector'; import flatten from 'lodash/flatten'; import unique from 'lodash/uniq'; import compact from 'lodash/compact'; @@ -33,10 +34,11 @@ import { * * @param {ShallowWrapper} wrapper * @param {Function} predicate + * @param {Function} filter * @returns {ShallowWrapper} */ -function findWhereUnwrapped(wrapper, predicate) { - return wrapper.flatMap(n => treeFilter(n.node, predicate)); +function findWhereUnwrapped(wrapper, predicate, filter = treeFilter) { + return wrapper.flatMap(n => filter(n.node, predicate)); } /** @@ -79,6 +81,7 @@ export default class ShallowWrapper { this.length = this.nodes.length; } this.options = options; + this.complexSelector = new ComplexSelector(buildPredicate, findWhereUnwrapped, childrenOfNode); } /** @@ -236,8 +239,7 @@ export default class ShallowWrapper { * @returns {ShallowWrapper} */ find(selector) { - const predicate = buildPredicate(selector); - return findWhereUnwrapped(this, predicate); + return this.complexSelector.find(selector, this); } /** @@ -629,7 +631,8 @@ export default class ShallowWrapper { const nodes = this.nodes.map((n, i) => fn.call(this, this.wrap(n), i)); const flattened = flatten(nodes, true); const uniques = unique(flattened); - return this.wrap(uniques); + const compacted = compact(uniques); + return this.wrap(compacted); } /** diff --git a/test/ComplexSelector-spec.js b/test/ComplexSelector-spec.js new file mode 100644 index 000000000..b3e2acb2a --- /dev/null +++ b/test/ComplexSelector-spec.js @@ -0,0 +1,191 @@ +import { describeWithDOM } from './_helpers'; +import React from 'react'; +import { expect } from 'chai'; +import { + mount, + shallow, +} from '../src/'; + +const tests = [ + { + name: 'mount', + renderMethod: mount, + describeMethod: describeWithDOM, + }, + { + name: 'shallow', + renderMethod: shallow, + describeMethod: describe, + }, +]; + +describe('ComplexSelector', () => { + tests.forEach(({ describeMethod, name, renderMethod }) => { + describeMethod(name, () => { + it('simple descendent', () => { + const wrapper = renderMethod( +
+
+ inside top div +
+ +
+ +
+ ); + + expect(wrapper.find('span').length).to.equal(2); + expect(wrapper.find('.top-div span').length).to.equal(1); + }); + + it('nested descendent', () => { + const wrapper = renderMethod( +
+
+

+
+
+

+

+
+

+

+

+ ); + + expect(wrapper.find('h1').length).to.equal(3); + expect(wrapper.find('.my-div h1').length).to.equal(2); + }); + + it('deep descendent', () => { + const wrapper = renderMethod( +
+
+
+ +
+

+

+
+
+
+

+

+ ); + + expect(wrapper.find('h1').length).to.equal(2); + expect(wrapper.find('div .inner span .way-inner h1').length).to.equal(1); + }); + + it('direct descendent', () => { + const wrapper = renderMethod( +
+
+
Direct
+
+
Nested
+
+
+
Outside
+
+ ); + + expect(wrapper.find('.to-find').length).to.equal(3); + const descendent = wrapper.find('.container > .to-find'); + expect(descendent.length).to.equal(1); + expect(descendent.text()).to.equal('Direct'); + }); + + it('simple adjacent', () => { + const wrapper = renderMethod( +
+
+
Adjacent
+
Not Adjacent
+
+ ); + + expect(wrapper.find('.sibling').length).to.equal(2); + const toFind = wrapper.find('.to-find + .sibling'); + expect(toFind.length).to.equal(1); + expect(toFind.text()).to.equal('Adjacent'); + }); + + it('nested adjacent', () => { + const wrapper = renderMethod( +
+
+
Adjacent
+
+
Not Adjacent
+
+
+
Adjacent
+
+
Not Adjacent
+
+
+ ); + + expect(wrapper.find('.to-find').length).to.equal(3); + const toFind = wrapper.find('.to-find + .sibling'); + expect(toFind.length).to.equal(2); + toFind.map(found => expect(found.text()).to.equal('Adjacent')); + }); + + it('simple general siblings', () => { + const wrapper = renderMethod( +
+ + + + +
+ +
+
+ ); + + expect(wrapper.find('.to-find ~ span').length).to.equal(3); + }); + + it('nested general siblings', () => { + const wrapper = renderMethod( +
+ Top + + +
+
+ Top + + +
+
+
+ ); + + const spans = wrapper.find('span'); + const siblings = wrapper.find('span ~ span'); + expect(spans.length - 2).to.equal(siblings.length); + siblings.map(sibling => expect(sibling.text()).to.not.equal('Top')); + }); + + it('.foo + div > span', () => { + const wrapper = renderMethod( +
+
+
+ +
+
+ +
+
+ ); + + expect(wrapper.find('.foo + div > span').length).to.equal(1); + }); + }); + }); +}); diff --git a/test/ReactWrapper-spec.js b/test/ReactWrapper-spec.js index 9febd1ff2..80ad0722c 100644 --- a/test/ReactWrapper-spec.js +++ b/test/ReactWrapper-spec.js @@ -285,7 +285,6 @@ describeWithDOM('mount', () => { expect(wrapper.find('span[htmlFor="foo"][preserveAspectRatio="xMaxYMax"]')).to.have.length(1); }); - it('should not find property when undefined', () => { const wrapper = mount(
@@ -332,7 +331,6 @@ describeWithDOM('mount', () => { expect(wrapper.find('[key]')).to.have.length(0); }); - it('should find multiple elements based on a class name', () => { const wrapper = mount(
@@ -367,17 +365,6 @@ describeWithDOM('mount', () => { expect(wrapper.find('button').length).to.equal(1); }); - it('should throw on a complex selector', () => { - const wrapper = mount( -
- - -
- ); - expect(() => wrapper.find('.foo .foo')).to.throw(Error); - }); - it('should support object property selectors', () => { const wrapper = mount(
diff --git a/test/ShallowWrapper-spec.js b/test/ShallowWrapper-spec.js index b7e816c36..684503a71 100644 --- a/test/ShallowWrapper-spec.js +++ b/test/ShallowWrapper-spec.js @@ -463,17 +463,6 @@ describe('shallow', () => { expect(wrapper.find('button').length).to.equal(1); }); - it('should throw on a complex selector', () => { - const wrapper = shallow( -
- - -
- ); - expect(() => wrapper.find('.foo .foo')).to.throw(Error); - }); - it('should support object property selectors', () => { const wrapper = shallow(