diff --git a/README.md b/README.md index 4390221..647392e 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,82 @@ + teaspoon ======== -A jQuery like API for querying React elements and rendered components. +Just the right amount of abstraction for writing clear, and concise React component tests. + + + +**Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)* + +- [Getting Started](#getting-started) + - [Using selectors](#using-selectors) + - [Complex selectors](#complex-selectors) +- [Testing patterns](#testing-patterns) + - [Using `tap()`](#using-tap) + - [Test specific querying ("ref" style querying).](#test-specific-querying-ref-style-querying) +- [Adding collection methods and pseudo selectors](#adding-collection-methods-and-pseudo-selectors) +- [API](#api) + - [Rendering](#rendering) + - [`$.fn.render([Bool renderIntoDocument, HTMLElement mountPoint, Object context ])`](#fnrenderbool-renderintodocument-htmlelement-mountpoint-object-context-) + - [`$.fn.shallowRender([props]) -> ElementCollection`](#fnshallowrenderprops---elementcollection) + - [`$.element.fn.update()`](#elementfnupdate) + - [`$.instance.fn.unmount()`](#instancefnunmount) + - [Utility methods and properties](#utility-methods-and-properties) + - [`$.selector` -> selector _(alias: $.s)_](#selector---selector-_alias-s_) + - [`$.dom -> HTMLElement`](#dom---htmlelement) + - [`$.fn.length`](#fnlength) + - [`$.fn.unwrap()`](#fnunwrap) + - [`$.fn.get() -> Array` (alias: toArray())](#fnget---array-alias-toarray) + - [`$.fn.tap() -> function(Collection)`](#fntap---functioncollection) + - [`$.fn.each(Function iteratorFn)`](#fneachfunction-iteratorfn) + - [`$.fn.map(Function iteratorFn)`](#fnmapfunction-iteratorfn) + - [`$.fn.reduce(Function iteratorFn, [initialValue]) -> Collection`](#fnreducefunction-iteratorfn-initialvalue---collection) + - [`$.fn.reduceRight(Function iteratorFn) -> Collection`](#fnreducerightfunction-iteratorfn---collection) + - [`$.fn.some(Function iteratorFn) -> bool`](#fnsomefunction-iteratorfn---bool) + - [`$.fn.every(Function iteratorFn) -> bool`](#fneveryfunction-iteratorfn---bool) + - [`$.instance.fn.dom -> HTMLElement`](#instancefndom---htmlelement) + - [Accessors](#accessors) + - [`$.fn.prop`](#fnprop) + - [`$.fn.state`](#fnstate) + - [`$.fn.context`](#fncontext) + - [Traversal methods](#traversal-methods) + - [`$.fn.find(selector)`](#fnfindselector) + - [`$.fn.filter(selector)`](#fnfilterselector) + - [`$.fn.is(selector) -> Bool`](#fnisselector---bool) + - [`$.fn.children([selector])`](#fnchildrenselector) + - [`$.fn.parent([selector])`](#fnparentselector) + - [`$.fn.parents([selector])`](#fnparentsselector) + - [`$.fn.closest([selector])`](#fnclosestselector) + - [`$.fn.first([selector])`](#fnfirstselector) + - [`$.fn.last([selector])`](#fnlastselector) + - [`$.fn.only()`](#fnonly) + - [`$.fn.single(selector)`](#fnsingleselector) + - [`$.fn.text()`](#fntext) + - [Events](#events) + - [`$.instance.fn.trigger(String eventName, [Object data])`](#instancefntriggerstring-eventname-object-data) + - [`$.element.fn.trigger(String eventName, [Object data])`](#elementfntriggerstring-eventname-object-data) + + + +## Getting Started + +To get started install teaspoon via npm: + +```sh +npm i --save-dev teaspoon +``` -## API +Teaspoon is test environment agnostic, so you can (and should) bring your own test runner and frameworks. +If you plan on doing normal component rendering (not just shallow rendering) you will also need a DOM environment, +whether that's a browser, headless browser, or jsdom. -Like jQuery the exported function creates a collection of nodes, except in this case you select React elements instead -of DOM nodes. +Like jQuery teaspoon exports a function that creates a collection of nodes; except in this case +you select React elements instead of DOM nodes. ```js import $ from 'teaspoon'; -let $div = $(
); +let $div = $(
); $div.length // 1 $div[0] // ReactElement{ type: 'div', props: {} ... } @@ -29,66 +94,82 @@ let elements = ( ); -var $elements = $(elements); +let $elements = $(elements); $elements.find('div.fun-div').length // 1 $elements.find(MyInput).length // 2 ``` -`teaspoon` actually supports _two_ types of collections, we've already seen Element Collections, -but you can also work with Component _instance_ collections as well for querying rendered components. +Along with plain ol' ReactElements you can also use teaspoon to traverse a rendered component tree. +Teaspoon also does a bunch of work under the hood to normalize the traversal behavior of DOM components, +Custom Components, and Stateless function Components. ```js -let instance = ReactDOM.render(, mountNode) +let Greeting = props =>
hello {props.name}
; + +let instance = ReactDOM.render(, mountNode) let $instance = $(instance); -$instance.dom() // HTMLElement +$instance.find('strong').text() // "John" ``` -There is even a quick way to switch between them. +That's nice but a bit verbose, luckily teaspoon lets you switch between both collection types +(element and instance) nice and succinctly. ```js -let elements = ( - - - -
- -); +let Greeting = props =>
hello {props.name}
; + +// renders `` into the DOM and returns an collection of instances +let $elements = $().render(); / -var $elements = $(elements).render(); // renders `` into the DOM and returns an InstanceCollection +$elements.find('strong').text() // "John" -$elements.find(MyInput).dom() // HTMLElement{ tagName: 'input' ... } +$elements.unmount() // removes the mounted component and returns a collection of elements -$elements.unmount() // removes the mounted component and returns an ElementCollection +//or with shallow rendering +$elements.shallowRender() + .find('strong').text() // "John" ``` ### Using selectors -The supported selector syntax is subset of standard css selectors. You can query by tag: `'div > li'` or -by `className` with `'.my-class'`. Attribute selectors work on props: `'[show=true]'` or `'[name="my-input"]'`. -You can even use the `has()` pseudo selector for selecting parents. You can also use two React -specific pseudo selectors: `':dom'` and `':composite'` to select DOM and Composite Components respectively. - -Unlike normal css selectors though, React Elements often have prop values, and element types that are not serializable -to a string. What if you needed to select a `MyList` component by its "tag" or wanted to get all elements with -a `date` prop equal to today? - -With Component names you can use function or `displayName` of the component if you trust them. - -```js -$().render().find('div > List.foo') -``` - -Alternatively, and more robustly, you use a [tagged template string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/template_strings#Tagged_template_strings). +The supported selector syntax is subset of standard css selectors: + +- classes: `.foo` +- attributes: `div[propName="hi"]` or `div[boolProp]` +- `>`: direct descendent `div > .foo` +- `+`: adjacent sibling selector +- `~`: general sibling selector +- `:has()`: parent selector `div:has(a.foo)` +- `:not()`: negation +- `:first-child` +- `:last-child` +- `:text` matches "text" (renderable) nodes, which may be a non string value (like a number) +- `:dom` matches only DOM components +- `:composite` matches composite (user defined) components +- `:contains(some text)` matches nodes that have a text node descendent containing the provided text +- `:textContent(some text)` matches whose text content matches the provided text + +Selector support is derived from the underlying selector engine: [bill](https://github.com/jquense/bill). New minor +versions of bill are released independent of teaspoon, so you can always check there to see what is supported on the +cutting edge. + +### Complex selectors + +Unlike normal css selectors, React elements and components often have prop values, and component types that are +not serializable to a string; components are often best selected by their actual class and not a name, and +prop values can complex objects such as a `date` prop equaling `new Date()`. + +For components, we've already seen that you can use the function name or the `displayName`, but +sometimes they aren't available. A less brittle approach is to select by the function _itself_. You can +use a [tagged template string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/template_strings#Tagged_template_strings). via the `$.selector` (also aliased as `$.s`) function for writing complex selectors like so: -``` -//select all ``s that are children of divs -$.s`div > ${List}` +```js +$.s`div > ${Greeting}` -//select components with `start` props equal to `min` +// select components with `start` props _strictly_ equal to `min` let min = 10 $.s`[start=${min}]` ``` @@ -96,151 +177,253 @@ $.s`[start=${min}]` If you don't want to use the newer syntax you can also call the `selector` function directly like: ```js -$.s('div > ', List, '.foo') // equivalent to: $.s`div > ${List}.foo` +$.s('div > ', Greeting, '.foo') // equivalent to: $.s`div > ${Greeting}.foo` ``` -### Common Collection methods +Use can use these complex selectors in any place a selector is allowed: -The methods are shared by both Element and Instance Collections. +```js +let Name = props => {props.name}; +let Time = props => {props.date.toLocaleString()} +let Greeting = props =>
hello its:
; + +let now = new Date(); +let $inst = $(); + +$inst + .render() + .find($.s`${Greeting} > strong`) + .text() + +$inst + .shallowRender() + .find($.s`${Time}[date=${now}]`) + .only() +``` -#### `$.selector` -> selector _(alias: $.s)_ +## Testing patterns -Selector creation function. +As far as testing libraries go `teaspoon` has fairly few opinions about how to do stuff, so you can adapt whatever +testing practices and patterns you like. However there are some patterns and paths that fall out naturally from +teaspoon's API. -#### `$.fn.find(selector)` +### Using `tap()` -Search all descendants of the current collection, matching against -the provided selector. +[`tap()`](#fntap---functioncollection) provides a way to quickly step in the middle of a chain of queries and +collections to make a quite assertion. Below we quickly make a few changes to the component props and +check that the rendered output is what we'd expect. ```js -$(
  • item 1
).find('ul > li') +let Greeting = props =>
hello {props.name}
; + +$() + .tap(collection => { + collection + .first('div > :text') + .unwrap() + .should.equal('hello rikki-tikki-tavi') + }) + .props('name', 'Nagaina') + .tap(collection => { + collection + .first('div > :text') + .unwrap() + .should.equal('hello Nagaina') + }) + .unmount() ``` -#### `$.fn.filter(selector)` +### Test specific querying ("ref" style querying). -Filter the current collection matching against the provided -selector. +An age old struggle and gotcha with testing HTML output is that tests are usually not very resilient to +DOM structure changes. You may move a save button into a (or out of) some div your test used to find the button +breaking the test. A classic technique to avoid this is the just use css classes, however it can be hard to +distinguish between styling classes, and testing hooks. + +In a React environment we can do one better, adding test specific hooks. This is a pattern taken up by libraries like +[react-test-tree](https://github.com/QubitProducts/react-test-tree), and while `teaspoon` doesn't specifically "support" +that style of selection, its selector engine is more than powerful enough to allow that pattern of querying. + +You can choose any prop name you like, but we recommend picking one that likely to collide with a +component's "real" props. In this example lets use `_testID` ```js -let $list = $([ -
  • 1
  • , -
  • 2
  • , -
  • 3
  • , -]); +let Greeting = props =>
    hello {props.name}
    ; -$list.filter('.foo').length // 1 +$(Greeting).render() + .prop({ name: 'Betty' }) + .find('[_testID=name]') + .text() + .should.equal('Betty') ``` -### `$.fn.children([selector])` +You can adapt and expand this pattern however your team likes, maybe just using the single testing prop or a suite. +You can also add some helper methods or pseudo selectors to help codify enforce your teams testing conventions -Return the children of the current selection, optionally filtered by those matching a provided selector. +## Adding collection methods and pseudo selectors -__note:__ rendered "Composite" components will only ever have one child since Components can only return a single node. +Teaspoon also allows extending itself and adding new pseudo selectors using a fairly straight forward API. + +To add a new method for all collection types add it to `$.fn` +(or `$.prototype` if the jQuery convention bothers you). ```js -let $list = $( -
      -
    • 1
    • -
    • 2
    • -
    • 3
    • -
    -); +// Returns all DOM node descendants and filters by a selector +$.fn.domNodes = function(selector) { + return this + .find(':dom') + .filter(selector) +} + +// also works with shallowRender() +$().render().domNodes('.foo') +``` -$list.children().length // 3 +If you want to make a method only available to either instance of element collections you can extend +`$.instance.fn` or `$.element.fn` following the same pattern as above. -$list.children('.foo').length // 1 +For new pseudo selectors you can use the `registerPseudo(String name, Function test)` API which provides +a hook into the css selector engine used by teaspoon: [bill](https://github.com/jquense/bill). Pseudo selectors _do_ +introduce a new object not extensively covered here, the `Node`. Quickly put, a Node is a light abstraction that +encapsulates both component instances and React elements, in order to provide a common traversal API across tree types. +You can read about them and their +properties [here](https://github.com/jquense/bill#matchselector-elementorinstance---arraynode). + +```js +$.registerPseudo('disabled', (node, innerSelector)=> { + let domNode = $.dom(node.instance); + + // Nodes can be wrapped in a teaspoon collection + return $(node).is('[disabled]') + || (domNode && domNode.disabled) +}) ``` -#### `$.fn.is(selector) -> Bool` +If you want your psuedo selector to accept something other than a _selector_ as it's inner argument +(as in `:has('foo')`), then pass `false` as the second argument (`registerPseudo(myPseudo, false, testFunction)`). -Test if each item in the collection matches the provided -selector. +## API -#### `$.fn.first([selector])` +Teaspoon does what it can to abstract away the differences between element and instance collections into a +common API, however everything doesn't coalesce nicely, so some methods are only relevant and available for +collections of instances and some for collections of elements. -return the first item in a collection, alternatively search all -collection descendants matching the provided selector and return -the first match. +Methods that are common to both collections are listed as: `$.fn.methodName` -#### `$.fn.last([selector])` +Whereas methods that are specific to a collection type are +listed as: `$.instance.fn.methodName` and `$.element.fn.methodName` respectively -return the last item in a collection, alternatively search all -collection descendants matching the provided selector and return -the last match. +### Rendering -#### `$.fn.only()` +##### `$.fn.render([Bool renderIntoDocument, HTMLElement mountPoint, Object context ])` -Assert that the current collection as only one item. +Renders the first element of the Collection into the DOM using `ReactDom.render`. By default +the component won't be added to the page `document`, you can pass `true` as the first parameter to render into the +document.body. Additional you can provide your own DOM node to mount the component into. + +`render()` returns a new _InstanceCollection_ ```js -let $list = $( -
      -
    • 1
    • -
    • 2
    • -
    • 3
    • -
    +let elements = ( + +
    + ); -$list.find('li').only('li') // Error! Matched more than one
  • +let $elements = $(elements).render(); + +// accessible by document.querySelectorAll +$elements = $(elements).render(true); -$list.find('li').only('.foo').length // 1 +// mount the component to the +$elements = $(elements).render(document.createElement('span')); ``` -#### `$.fn.single(selector)` +##### `$.fn.shallowRender([props]) -> ElementCollection` -Find and assert that only item matches the provided selector. +Use the React shallow renderer utilities to _shallowly_ render the first element of the collection. ```js -let $list = $( -
      -
    • 1
    • -
    • 2
    • -
    • 3
    • -
    -); +let MyComponent ()=>
    Hi there!
    -$list.single('li') // Error! Matched more than one
  • +$() + .find('div') + .length // 0 -$list.single('.foo').length // 1 +$() + .shallowRender() + .find('div') + .length // 1 ``` -#### `$.fn.unwrap()` +##### `$.element.fn.update()` -Unwraps a collection of a single item returning the item. Equivalent to `$el[0]`; throws when there -is more than one item in the collection. +Since shallow collections not not "live" in the same way a real rendered component tree is, you may +need to manually update the root collection to flush changes (such as those triggered by a child component). +In general you may not have to ever use `update()` since teaspoon tries to take care of all that for +you by spying on the `componentDidUpdate` lifecycle hook of root component instance. -#### `$.fn.prop(String propName)` +##### `$.instance.fn.unmount()` -Return a prop value. +Unmount the current tree and remove it from the DOM. `unmount()` returns an +ElementCollection of the _root_ component element. -#### `$.fn.state(String propName)` +```js +let $inst = $(); +let rendered = $inst.render(); -Return a state value. +//do some stuff...then: +rendered.umount() +``` -__remember:__ when shallow rendering, only the "root" element will possibly have state. +### Utility methods and properties -#### `$.fn.context(String propName)` +The methods are shared by both Element and Instance Collections. -Return a context value. +##### `$.selector` -> selector _(alias: $.s)_ -__remember:__ when shallow rendering, only the "root" element will possibly have context. +Selector creation function. -#### `$.fn.text()` +##### `$.dom -> HTMLElement` -Return the text content of the matched Collection. +Returns the DOM nodes for a component instance, if it exists. + +##### `$.fn.length` + +The length of the collection. + +##### `$.fn.unwrap()` + +Unwraps a collection of a single item returning the item. Equivalent to `$el[0]`; throws when there +is more than one item in the collection. ```js -$(
    Hello Johnhi!
    ) + .find('strong') + .unwrap() // -> hi! ``` -#### `$.fn.get() -> Array` +##### `$.fn.get() -> Array` (alias: toArray()) Returns a real JavaScript array of the collection items. -#### `$.fn.each(Function iteratorFn)` +##### `$.fn.tap() -> function(Collection)` + +Run an arbitrary function against the collection, helpful for making assertions while chaining. -An analog to `[].forEach`; iterates over the collection calling the `iteratorFn` with each item, idx, and collection +```js +$().render() + .prop({ name: 'John '}) + .tap(collection => + expect(collection.children().length).to.equal(2)) + .find('.foo') +``` + +##### `$.fn.each(Function iteratorFn)` + +An analog to `Array.prototype.forEach`; iterates over the collection calling the `iteratorFn` +with each item, index, and collection. ```js $().render() @@ -250,9 +433,22 @@ $().render() }) ``` -#### `$.fn.reduce(Function iteratorFn, [initialValue])` +##### `$.fn.map(Function iteratorFn)` + +An analog to `Array.prototype..map`; maps over the collection calling the `iteratorFn` +with each item, index, and collection. + +```js +$().render() + .find('div') + .map((node, index, collection) => { + //do something + }) +``` + +##### `$.fn.reduce(Function iteratorFn, [initialValue]) -> Collection` -An analog to `[].reduce`, returns a new _reduced_ teaspoon Collection +An analog to `Array.prototype..reduce`, returns a new _reduced_ teaspoon Collection ```js $().render() @@ -262,92 +458,207 @@ $().render() }, '') ``` -### ElementCollection API +##### `$.fn.reduceRight(Function iteratorFn) -> Collection` -ElementCollections are created when selecting ReactElements. They -also have all the above "common" methods +An analog to `Array.prototype.reduceRight`. -#### `$(ReactElement element) -> ElementCollection` +##### `$.fn.some(Function iteratorFn) -> bool` -Create an ElementCollection from an Element or array of Elements. +An analog to `Array.prototype.some`. -#### `$.fn.render([Bool renderIntoDocument, HTMLElement mountPoint ]) -> InstanceCollection` +##### `$.fn.every(Function iteratorFn) -> bool` -Renders the first element of the ElementCollection into the DOM using `ReactDom.render`. By default -the component won't be added to the page `document`, you can pass `true` as the first parameter to render into the -document.body. Additional you can provide your own DOM node to mount the component into. +An analog to `Array.prototype.every`. -`render()` returns a new _InstanceCollection_ +##### `$.instance.fn.dom -> HTMLElement` + +Returns the DOM nodes for each item in the Collection, if the exist + +### Accessors + +##### `$.fn.prop` + +Set or get props from a component or element. + +Setting props can only be down on __root__ collections given the +reactive nature of data flow in react trees. + +- `.prop()`: retrieve all props +- `.prop(propName)`: retrieve a single prop +- `.prop(propName, propValue, [callback])`: update a single prop value +- `.prop(newProps, [callback])`: merge `newProps` into the current set of props. + +##### `$.fn.state` + +Set or get state from a component or element. In shallowly rendered trees only the __root__ component +can be stateful. + +- `.state()`: retrieve state +- `.state(stateName)`: retrieve a single state value +- `.state(stateName, stateValue, [callback])`: update a single state value +- `.state(newState, [callback])`: merge `newState` into the current state. + +##### `$.fn.context` + +Set or get state from a component or element. In shallowly rendered trees only the __root__ component +can have context. + +- `.context()`: retrieve context +- `.context(String contextName)`: retrieve a single context value +- `.context(String contextName, Any contextValue, [Function callback])`: update a single context value +- `.context(Object newContext, [Function callback])`: replace current context. + +### Traversal methods + +##### `$.fn.find(selector)` + +Search all descendants of the current collection, matching against +the provided selector. ```js -let elements = ( - -
    - -); +$( +
    +
      +
    • item 1
    • +
    +
    +).find('ul > li') +``` -var $elements = $(elements).render(); +##### `$.fn.filter(selector)` -$elements = $(elements).render(true); //accessible by document.querySelectorAll +Filter the current collection matching against the provided +selector. + +```js +let $list = $([ +
  • 1
  • , +
  • 2
  • , +
  • 3
  • , +]); -$elements = $(elements).render(true, document.createElement('span')); //mounts the component to the +$list.filter('.foo').length // 1 ``` -#### `$.fn.shallowRender([props]) -> ElementCollection` +##### `$.fn.is(selector) -> Bool` -Use the React shallow renderer utilities to _shallowly_ render the first element of the collection. +Test if each item in the collection matches the provided +selector. + +##### `$.fn.children([selector])` + +Return the children of the current selection, optionally filtered by those matching a provided selector. + +__note:__ rendered "Composite" components will only ever have one child since Components can only return a single node. ```js -let MyComponent ()=>
    Hi there!
    +let $list = $( +
      +
    • 1
    • +
    • 2
    • +
    • 3
    • +
    +); -$().find('div').length // 0 +$list.children().length // 3 -$().shallowRender().is('div') // true +$list.children('.foo').length // 1 ``` -### `$.fn.update()` -Rerenders and updates a shallowly rendered element collection. +##### `$.fn.parent([selector])` + +Get the parent of each node in the current collection, optionally filtered by a selector. + +##### `$.fn.parents([selector])` + +Get the ancestors of each node in the current collection, optionally filtered by a selector. -__note:__ `.update()` must be called on a "root" collection (the result of `.shallowRender()`) +##### `$.fn.closest([selector])` -#### `$.fn.trigger(String eventName, [Object data])` +For each node in the set, get the first element that matches the selector by testing the element +and traversing up through its ancestors. -"trigger" an event on an element. More of convenience method than a real event trigger, since shallow rendering -doesn't actually involve DOM event system. `trigger()` looks for a function prop of the element and calls it, it also -updates the root collection, so that any state/context/prop changes at the top component propagate down. +##### `$.fn.first([selector])` + +return the first item in a collection, alternatively search all +collection descendants matching the provided selector and return +the first match. + +##### `$.fn.last([selector])` + +return the last item in a collection, alternatively search all +collection descendants matching the provided selector and return +the last match. + +##### `$.fn.only()` + +Assert that the current collection as only one item. ```js -let root = $().shallowRender() +let $list = $( +
      +
    • 1
    • +
    • 2
    • +
    • 3
    • +
    +); -root.find('button').trigger('click', { - target: { value: 'hello' } -}) +$list.find('li').only() // Error! Matched more than one
  • + +$list.find('li.foo').only().length // 1 ``` -### InstanceCollection +##### `$.fn.single(selector)` -InstanceCollections are created when selecting Component instances, such as -the result of a `ReactDOM.render()` call. +Find and assert that only item matches the provided selector. -The public "instances" for components differ. DOM component instances -are the DOM nodes themselves, and Stateless Components technically don't have any -(we use the DOM node though). One key advantage to over the normal React -test utils is that here you can continue to chain `find` and `filter` on -DOM and Stateless components. +```js +let $list = $( +
      +
    • 1
    • +
    • 2
    • +
    • 3
    • +
    +); -#### `$.fn.dom -> HTMLElement` +$list.single('li') // Error! Matched more than one
  • -Returns the DOM nodes for each item in the Collection, if the exist +$list.single('.foo').length // 1 +``` -#### `$.fn.unmount -> HTMLElement` +##### `$.fn.text()` -Unmount the current tree and remove it from the DOM. `unmount()` returns an -ElementCollection of the _root_ component element. +Return the text content of the matched Collection. + +```js +let $els = $(
    Hello John).render() + .trigger('click', { target: { value: 'hello ' } }). +``` + +##### `$.element.fn.trigger(String eventName, [Object data])` -#### `$.fn.trigger(String eventName, [Object data])` +Simulates (poorly) event triggering for shallow collections. The method looks for a prop +following the convention 'on[EventName]': `trigger('click')` calls `props.onClick()`, and rerenders the root collection -Trigger a "synthetic" (React) event on the collection items. +Events don't bubble and don't have a proper event object. ```js -$().render().trigger('click', { target: { value: 'hello' } }). + $().shallowRender() + .find('button') + .trigger('click', { target: { value: 'hello ' } }). ``` diff --git a/lib/element.js b/lib/element.js index 1ebf8bb..3ffaec0 100644 --- a/lib/element.js +++ b/lib/element.js @@ -63,7 +63,7 @@ _extends(eQuery.fn, { if (instance === null) instance = _reactDom2['default'].render(utils.wrapStateless(element), mount); - return _instance2['default'](instance, utils.getInternalInstance(instance), mount); + return _instance2['default'](instance, utils.(instance), mount); }, shallowRender: function shallowRender(props) { diff --git a/lib/instance.js b/lib/instance.js index 2e8f2fa..8296058 100644 --- a/lib/instance.js +++ b/lib/instance.js @@ -51,7 +51,7 @@ var $ = _QueryCollection2['default'](utils.match, _bill2['default'], function in mount = mount || context && context._mountPoint || utils.getMountPoint(first); - this.context = context && context.context || context || utils.getInternalInstance(utils.getRootInstance(mount)); + this.context = context && context.context || context || utils.(utils.getRootInstance(mount)); this._mountPoint = mount; this._privateInstances = Object.create(null); diff --git a/lib/utils.js b/lib/utils.js index 10a7796..2f45240 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -3,7 +3,7 @@ exports.__esModule = true; exports.isCompositeComponent = isCompositeComponent; exports.getInstances = getInstances; -exports.getInternalInstance = getInternalInstance; +exports. = ; exports.wrapStateless = wrapStateless; exports.getMountPoint = getMountPoint; exports.getRootInstance = getRootInstance; @@ -65,7 +65,7 @@ function isCompositeComponent(inst) { function getInstances(component) { var _public = component, - _private = getInternalInstance(component); + _private = (component); if (component.getPublicInstance) { _public = component.getPublicInstance(); @@ -79,7 +79,7 @@ function getInstances(component) { return { 'private': _private, 'public': _public }; } -function getInternalInstance(component) { +function (component) { if (!component) return; if (component.getPublicInstance) return component; diff --git a/package.json b/package.json index 1251782..ca05512 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "scripts": { "test": "karma start --single-run", "tdd": "karma start", - "build": "babel src --out-dir lib && cpy ./README.md ./lib", + "toc": "doctoc README.md --github", + "build": "babel src --out-dir lib && npm run toc && cpy ./README.md ./lib", "release": "release" }, "repository": { @@ -34,10 +35,13 @@ "babel-plugin-object-assign": "^1.2.1", "chai": "^3.3.0", "cpy": "^3.4.1", + "doctoc": "^0.15.0", + "isparta": "^4.0.0", "karma": "^0.13.10", "karma-chai": "^0.1.0", "karma-chrome-launcher": "^0.2.0", "karma-cli": "^0.1.0", + "karma-coverage": "^0.5.3", "karma-mocha": "^0.2.0", "karma-mocha-reporter": "^1.1.1", "karma-sinon": "^1.0.4", @@ -52,9 +56,10 @@ "webpack": "^1.12.2" }, "dependencies": { - "bill": "^2.0.0", + "bill": "^2.0.3", "dom-helpers": "^2.4.0", "lodash": "^3.10.1", + "promise": "^7.1.1", "react-addons-test-utils": "^0.14.0-rc1", "warning": "^2.1.0" }, diff --git a/src/QueryCollection.js b/src/QueryCollection.js index d397c89..f92f85b 100644 --- a/src/QueryCollection.js +++ b/src/QueryCollection.js @@ -1,7 +1,10 @@ -import common from './common'; -import { createNode } from 'bill/node'; + import { selector } from 'bill'; -import { match, getPublicInstances } from './utils'; +import { + isQueryCollection, getPublicInstances + , unwrapAndCreateNode, attachElementsToCollection } from './utils'; + +import common from './common'; export default function createCollection(ctor) { let $ = QueryCollection @@ -12,35 +15,26 @@ export default function createCollection(ctor) { let elements = element == null ? [] : [].concat(element); - if (element && $.isQueryCollection(element)) { + if (element && isQueryCollection(element)) { return new element.constructor(element.get(), element) } this._isQueryCollection = true - this.context = lastCollection || this - this.nodes = elements.map(el => createNode(el)) - this.length = elements.length + this.root = lastCollection || this - getPublicInstances(this.nodes) - .forEach((el, idx)=> this[idx] = el) + attachElementsToCollection(this, elements) - return ctor.call(this, element, lastCollection) + return ctor.call(this, elements, lastCollection) } - Object.assign($, { - match, - selector, - s: selector, - isQueryCollection(inst) { - return !!inst._isQueryCollection - } - }) - - $.fn = $.prototype = { - constructor: $, - } + $.fn = $.prototype = Object.create(common) - common($) + Object.defineProperty($.prototype, 'constructor', { + value: $, + enumerable: false, + writable: true, + configurable: true + }) return $ } diff --git a/src/common.js b/src/common.js index a6a4d9c..10145ac 100644 --- a/src/common.js +++ b/src/common.js @@ -1,7 +1,10 @@ -import { findAll } from 'bill'; +import invariant from 'invariant'; +import { findAll, registerPseudo } from 'bill'; import { NODE_TYPES } from 'bill/node'; import * as utils from './utils'; import findIndex from 'lodash/array/findIndex'; +import create from 'lodash/object/create' +let { assertLength, is } = utils; function indexOfNode(arr, findNode) { return findIndex(arr, (node, i) => { @@ -14,141 +17,186 @@ function noTextNodes(nodes) { return nodes.filter(node => node.nodeType !== NODE_TYPES.TEXT) } -function assertLength(collection, method) { - if (collection.length === 0) - throw new Error('the method `' + method + '()` found no matching elements') - return collection -} +let $ = (t, ...args) => new t.constructor(...args) -export default function($){ - Object.assign($, { - dom(component){ - return utils.findDOMNode(component) - } - }) - // return values - ;['every', 'some'] - .forEach(method => { - let fn = [][method]; +let common = { + + _reduce(...args) { + return $(this, this.nodes.reduce(...args), this) + }, + + _map(cb){ + var result = [] + this.each((...args) => result.push(cb(...args))) + return result + }, + + each(fn, thisArg) { + [].forEach.call(this, fn, thisArg || this) + return this; + }, + + tap(fn) { + fn.call(this, this) + return this + }, + + get() { + var result = [] + this.each(el => result.push(el)) + return result + }, + + find(selector, includeSelf = false) { + return this._reduce((result, node) => { + return result.concat(utils.match(selector, node, includeSelf)) + }, []) + }, - $.fn[method] = function (...args) { - return fn.apply(this, args) + traverse(test) { + return this._reduce((result, node) => { + return result.concat(findAll(node, test)) + }, []) + }, + + filter(selector) { + if (!selector) return this + + return this._reduce((result, node) => { + return is(selector, node) ? result.concat(node) : result + }, []) + }, + + is(selector) { + return this.filter(selector).length === this.length + }, + + children(selector) { + return this + ._reduce((result, node) => result.concat(noTextNodes(node.children)), []) + .filter(selector) + }, + + parent(selector) { + return this._reduce((nodes, node) => { + let match = true; + + if (node = node.parentNode) { + if (selector) + match = is(selector, node) + + if (match && nodes.indexOf(node) === -1) + nodes.push(node) } - }) + return nodes + }, []) + }, + + parents(selector) { + return this._reduce((nodes, node) => { + while (node = node.parentNode) { + let match = true; - // return collections - ;['map', 'reduce', 'reduceRight'] - .forEach(method => { - let fn = [][method]; + if (selector) + match = is(selector, node) - $.fn[method] = function (...args) { - return $(fn.apply(this, args)) + if (match && nodes.indexOf(node) === -1) + nodes.push(node) } - }) - - Object.assign($.fn, { - - _reduce(...args) { - return $(this.nodes.reduce(...args), this) - }, - - _map(cb){ - var result = [] - this.each((...args) => result.push(cb(...args))) - return result - }, - - each(fn, thisArg) { - [].forEach.call(this, fn, thisArg || this) - return this; - }, - - tap(fn) { - fn.call(this, this) - return this - }, - - get() { - var result = [] - this.each(el => result.push(el)) - return result - }, - - find(selector, includeSelf = false) { - return this._reduce((result, node) => { - return result.concat(utils.match(selector, node, includeSelf)) - }, []) - }, - - traverse(test) { - return this._reduce((result, node) => { - return result.concat(findAll(node, test)) - }, []) - }, - - filter(selector) { - if (!selector) return this - - let matches = utils.match(selector, this.context.nodes[0], true); - - return this._reduce((result, node) => { - if (indexOfNode(matches, node) !== -1) - result.push(node); - - return result - }, []) - }, - - is(selector) { - return this.filter(selector).length === this.length - }, - - children(selector) { - return this - ._reduce((result, node) => result.concat(noTextNodes(node.children)), []) - .filter(selector) - }, - - text() { - let isText = el => typeof el === 'string'; - - return this.find(':text').nodes - .reduce((str, node) => str + node.element, '') - }, - - first(selector) { - return selector - ? this.find(selector).first() - : $(assertLength(this, 'first')[0], this) - }, - - last(selector) { - return selector - ? this.find(selector).last() - : $(assertLength(this, 'last')[this.length - 1], this) - }, - - only() { - if (this.length !== 1) - throw new Error('The query found: ' + this.length + ' items not 1') - - return this.first() - }, - - single(selector) { - return selector - ? this.find(selector).only() - : this.only() - }, - - unwrap() { - return this.single()[0] - }, - - elements() { - return this.nodes.map(node => node.element) + + return nodes + }, []) + }, + + closest(selector) { + let test = selector ? n => is(selector, n) : (() => true) + + return this._reduce((nodes, node) => { + do { + node = node.parentNode + } + while (node && !test(node)) + + if (node && nodes.indexOf(node) === -1) + nodes.push(node) + + return nodes + }, []) + }, + + text() { + let isText = el => typeof el === 'string'; + + return this.find(':text').nodes + .reduce((str, node) => str + node.element, '') + }, + + first(selector) { + return selector + ? this.find(selector).first() + : $(this, assertLength(this, 'first')[0], this) + }, + + last(selector) { + return selector + ? this.find(selector).last() + : $(this, assertLength(this, 'last')[this.length - 1], this) + }, + + only() { + if (this.length !== 1) + throw new Error('The query found: ' + this.length + ' items not 1') + + return this.first() + }, + + single(selector) { + return selector + ? this.find(selector).only() + : this.only() + }, + + unwrap() { + return this.single()[0] + }, + + elements() { + return this.nodes.map(node => node.element) + } +} + +function unwrap(arr){ + return arr && arr.length === 1 ? arr[0] : arr +} + +// return values +;['every', 'some'] + .forEach(method => { + let fn = [][method]; + + common[method] = function (...args) { + return fn.apply(this, args) + } + }) + +// return collections +;['map', 'reduce', 'reduceRight'] + .forEach(method => { + let fn = [][method]; + + common[method] = function (...args) { + return $(this, fn.apply(this, args)) } }) + +let aliases = { + get: 'alias', + each: 'forEach' } + +Object.keys(aliases) + .forEach(method => common[aliases[method]] = common[method]) + +export default common diff --git a/src/element.js b/src/element.js index 1cadba2..c9a6e80 100644 --- a/src/element.js +++ b/src/element.js @@ -6,10 +6,25 @@ import createQueryCollection from './QueryCollection'; import iQuery from './instance' import * as utils from './utils'; import { selector } from 'bill'; +import { createNode } from 'bill/node'; +import invariant from 'invariant'; -function assertRoot(inst, msg) { - if (inst.context && inst.context !== inst) - throw new Error(msg || 'You can only preform this action on "root" element.') +let { + assertLength, assertRoot, assertStateful + , render, attachElementsToCollection } = utils; + +let createCallback = (collection, fn) => ()=> fn.call(collection, collection) + +function getShallowInstance(renderer) { + return renderer && renderer._instance._instance; +} + +function getShallowTreeWithRoot(renderer) { + let children = renderer.getRenderOutput() + , instance = getShallowInstance(renderer) + , element = createNode(instance).element; + + return React.cloneElement(element, { children }) } function spyOnUpdate(inst, fn) { @@ -22,39 +37,43 @@ function spyOnUpdate(inst, fn) { } } - - let $ = createQueryCollection(function (elements, lastCollection) { - if (lastCollection && lastCollection.renderer) { - this.context = this; - this._renderer = renderer; // different name to protect back compat - spyOnUpdate(this._instance(), ()=> this.update()) + if (lastCollection) { + this._rendered = lastCollection._rendered } }) -$.instance = iQuery - Object.assign($.fn, { _instance() { - return this._renderer && this._renderer._instance._instance; + return getShallowInstance(this._renderer); }, - render(intoDocument, mountPoint) { + render(intoDocument, mountPoint, context) { + if (arguments.length && typeof intoDocument !== 'boolean') { + context = mountPoint + mountPoint = intoDocument + intoDocument = false + } + + if (mountPoint && !(mountPoint instanceof HTMLElement)) { + context = mountPoint + mountPoint = null + } + var mount = mountPoint || document.createElement('div') - , element = this[0]; + , element = assertLength(this, 'render')[0]; if (intoDocument) document.body.appendChild(mount) - let instance = ReactDOM.render(element, mount); + let { instance, wrapper } = render(element, mount, null, context) - if (instance === null) { - instance = ReactDOM.render(utils.wrapStateless(element), mount) - instance = utils.getInternalInstance(instance) - } + let collection = iQuery(instance); - return iQuery(instance); + collection._mountPoint = mount + collection._rootWrapper = wrapper; + return collection; }, shallowRender(props, context) { @@ -69,35 +88,107 @@ Object.assign($.fn, { if (isDomElement) return $(element) - if (!this.renderer) - this.renderer = ReactTestUtils.createRenderer() + let renderer = ReactTestUtils.createRenderer() - this.renderer.render(element, context); + renderer.render(element, context); - let collection = $(this.renderer.getRenderOutput()); + let collection = $(getShallowTreeWithRoot(renderer)); - collection._renderer = this.renderer; + collection._rendered = true; + collection._renderer = renderer; + + spyOnUpdate(collection._instance(), ()=> collection.update()) return collection; }, update() { + assertRoot(this) if (!this._renderer) throw new Error('You can only preform this action on a "root" element.') - this.context = - this[0] = this._renderer.getRenderOutput() + attachElementsToCollection(this, getShallowTreeWithRoot(this._renderer)) return this }, - prop(key) { - return key ? this[0].props[key] : this[0].props; + props(...args) { + let value = utils.collectArgs(...args) + let node = assertLength(this, 'props').nodes[0] + + if (args.length === 0 || (typeof value === 'string' && args.length === 1)) { + let element = node.element + return value ? element.props[value] : element.props + } + + if (this._rendered) { + assertRoot(this, 'changing the props on a shallow rendered child is an anti-pattern, ' + + 'since the elements props will be overridden by its parent in the next update() of the root element') + + this._renderer.render(React.cloneElement(this[0], value)); + attachElementsToCollection(this, getShallowTreeWithRoot(this._renderer)) + return this + } + + return this.map(el => React.isValidElement(el) + ? React.cloneElement(el, value) : el) }, - state(key) { - assertRoot(this, 'Only "root" rendered elements can have state') - let state = this._instance().state; - return key && state ? state[key] : state + state(...args) { + let value = utils.collectArgs(...args) + , callback = args[2] || args[1]; + + assertLength(this, 'state') + assertStateful(this.nodes[0]) + + invariant(this._rendered, + 'Only rendered trees can be stateful; ' + + 'use either `shallowRender` or `render` first before inspecting or setting state.' + ) + + assertRoot(this, + 'Only the root component of shallowly rendered tree is instantiated; ' + + 'children elements are stateless so inspecting or setting state on them does\'t make sense ' + + 'use DOM rendering to verifying child state, or select and shallowRender the child itself.' + ) + + if (args.length === 0 || (typeof value === 'string' && args.length === 1)) { + let key = value + , state = this._instance().state; + + return key && state ? state[key] : state + } + + callback = typeof callback === 'function' + ? createCallback(this, callback) : undefined + + this._instance().setState(value, callback) + + return this + }, + + context(...args) { + let value = utils.collectArgs(...args) + let inst = assertLength(this, 'context')._instance() + let context = inst.context + + invariant(this._rendered, + 'Only rendered trees can pass context; ' + + 'use either `shallowRender` or `render` first before inspecting or setting context.' + ) + + assertRoot(this, + 'Only the root component of a shallowly rendered tree is instantiated; ' + + 'The children are jsut plain elements and are not passed context.' + ) + + if (args.length === 0 || (typeof value === 'string' && args.length === 1)) { + return value && context ? context[value] : context + } + + this._renderer.render(this[0], { ...context, ...value }); + attachElementsToCollection(this, getShallowTreeWithRoot(this._renderer)) + + return this }, trigger(event, ...args) { @@ -107,8 +198,6 @@ Object.assign($.fn, { return this.each(component => { component.props[event] && component.props[event](...args) - - this._root && this._root.update() }); } diff --git a/src/index.js b/src/index.js index 002d3c3..3fb60cf 100644 --- a/src/index.js +++ b/src/index.js @@ -1,18 +1,57 @@ -import eQuery from './element' -import iQuery from './instance' +import ElementCollection from './element' +import InstanceCollection from './instance' +import commonPrototype from './common'; +import { selector, registerPseudo } from 'bill'; +import { createNode, NODE_TYPES } from 'bill/node'; +import { match, getPublicInstances } from './utils'; + import * as utils from './utils'; let isComponent = el => utils.isDOMComponent(el) || utils.isCompositeComponent(el) +let $ = NodeCollection; function NodeCollection(elements) { - let first = [].concat(elements).filter(e => !!e)[0]; + let first = [].concat(elements).filter(e => !!e)[0] + , node = first && createNode(first); - if (first && isComponent(first)) - return new iQuery(elements); + if (node && node.privateInstance) + return new InstanceCollection(elements) - return new eQuery(elements) + return new ElementCollection(elements); } -NodeCollection = Object.assign(NodeCollection, eQuery, iQuery) +$.fn = $.prototype = commonPrototype + +Object.assign($, { + match, + selector, + s: selector, + isQueryCollection: utils.isQueryCollection, + dom: utils.findDOMNode +}) + +$.element = ElementCollection +$.instance = InstanceCollection + +$.registerPseudo = (pseudo, isSelector, fn)=> { + if (typeof isSelector === 'function') + fn = isSelector, isSelector = true; + + registerPseudo(pseudo, isSelector, test => + node => fn(node, test)) +} + +$.registerPseudo('contains', false, (node, text) => { + return ($(node).text() || '').indexOf(text) !== -1 +}) + +$.registerPseudo('textContent', false, (node, text) => { + let textContent = node.children + .filter(n => n.nodeType === NODE_TYPES.TEXT) + .map(n => n.element) + .join('') + + return (!text && !!textContent) || text === textContent +}) -module.exports = NodeCollection +module.exports = $ diff --git a/src/instance.js b/src/instance.js index a1cfcd1..d36d58f 100644 --- a/src/instance.js +++ b/src/instance.js @@ -9,66 +9,105 @@ import createCollection from './QueryCollection'; import * as utils from './utils'; import selector from 'bill'; +let { assertLength, assertRoot, assertStateful, collectArgs } = utils; + +let createCallback = (collection, fn) => ()=> fn.call(collection, collection) + +function getSetterMethod(key){ + return function (...args) { + let value = collectArgs(...args) + let node = assertLength(this, key).nodes[0] + let data = node.instance && node.instance[key] + + if (args.length === 0 || (typeof value === 'string' && args.length === 1)) { + if (!data) + data = node.privateInstance._currentElement[key]; + + return value && data ? data[value] : data + } + + if (key === 'props') + utils.render(this, value) + else if (key === 'context') + utils.render(this, null, { ...data, ...value }) + else throw new Error('invalid key: ' + key) + + return this + } +} + let $ = createCollection(function (element, lastCollection) { let first = this.nodes[0] if (!lastCollection) { - this._mountPoint = utils.getMountPoint(first.instance) - } -}) - -Object.assign($, { - dom(component){ - return utils.findDOMNode(component) + try { + // no idea if I can do this in 0.15 + this._mountPoint = utils.getMountPoint(first.instance) + } + catch (err) {} } + else + this._mountPoint = lastCollection._mountPoint }) Object.assign($.fn, { - _reduce(cb, initial){ - return $(this.nodes.reduce(cb, initial), this) + render(...args) { + let collection = new ElementCollection(this.elements()[0]) + + return collection.render(...args) + }, + + shallowRender(...args) { + let collection = new ElementCollection(this.elements()[0]) + + return collection.shallowRender(...args) }, unmount() { let inBody = this._mountPoint.parentNode - , nextContext = this.context.nodes[0].element; + , nextContext = this.root.nodes[0].element; ReactDOM.unmountComponentAtNode(this._mountPoint) if (inBody) document.body.removeChild(this._mountPoint) - this.context = null + this.root = null - return eQuery(nextContext) + return ElementCollection(nextContext) }, dom() { - return unwrap(this._map($.dom)) + return unwrap(this._map(utils.findDOMNode)) }, - prop(key, value, cb) { - if (typeof key === 'string') { - if (arguments.length === 1) - return this.nodes[0].element.props[key]; - else - key = { [key]: value } + props: getSetterMethod('props'), + + context: getSetterMethod('context'), + + state(...args) { + let value = collectArgs(...args) + , callback = args[2] || args[1] + + let node = assertStateful( + assertLength(this, 'state').nodes[0] + ) + + if (args.length === 0 || (typeof value === 'string' && args.length === 1)) { + let key = value + , state = node.instance.state; + + return key && state ? state[key] : state } - // this.node(inst => { - // ReactUpdateQueue.enqueueSetPropsInternal(inst, props) - // if (cb) - // ReactUpdateQueue.enqueueCallbackInternal(inst, cb) - // }) - }, + callback = typeof callback === 'function' + ? createCallback(this, callback) : undefined + + node.instance.setState(value, callback) - // state(key) { - // return this._privateInstances[0].state[key]; - // }, - // - // context(key) { - // return this._privateInstances[0].context[key]; - // }, + return this + }, trigger(event, data) { data = data || {} @@ -80,7 +119,7 @@ Object.assign($.fn, { throw new TypeError( '"' + event + '" is not a supported DOM event') return this.each(component => - ReactTestUtils.Simulate[event]($.dom(component), data)) + ReactTestUtils.Simulate[event](utils.findDOMNode(component), data)) } }) @@ -90,4 +129,4 @@ function unwrap(arr){ export default $; -import eQuery from './element'; +import ElementCollection from './element'; diff --git a/src/utils.js b/src/utils.js index 5b73bd5..241fdf3 100644 --- a/src/utils.js +++ b/src/utils.js @@ -5,18 +5,77 @@ import { getID, getNode, findReactContainerForID , getReactRootID, _instancesByReactRootID } from 'react/lib/ReactMount'; import ReactTestUtils from'react-addons-test-utils'; +import invariant from 'invariant'; -import closest from 'dom-helpers/query/closest'; -import { match as _match, selector as s } from 'bill'; - -import { findAll as instanceTraverse } from 'bill/instance-selector'; -import { findAll as elementTraverse } from 'bill/element-selector'; +import { match as _match, selector as s, compile } from 'bill'; +import { createNode, NODE_TYPES } from 'bill/node'; export let isDOMComponent = ReactTestUtils.isDOMComponent; -export function attachToInstance(inst, publicNodes) { - inst.length = publicNodes.length; - publicNodes.forEach((pn, idx) => inst[idx] = pn) +export function assertLength(collection, method) { + invariant(!!collection.length, + 'the method `%s()` found no matching elements', method + ) + return collection +} + +export function assertStateful(node) { + invariant( + node.nodeType === NODE_TYPES.COMPOSITE && !isStatelessComponent(node), + 'You are trying to inspect or set state on a stateless component ' + + 'such as a DOM node or functional component' + ); + + return node +} +export function assertRoot(collection, msg) { + invariant(!collection.root || collection.root === collection, + msg || 'You can only preform this action on a "root" element.' + ) + return collection +} + +export function render(element, mount, props, context) { + let wrapper, prevWrapper; + + if (isQueryCollection(element)) { + let node = element.nodes[0]; + + assertRoot(element) + context = props + props = mount + mount = element._mountPoint || mount + prevWrapper = element._rootWrapper + element = node.element; + } + + if (props) + element = React.cloneElement(element, props); + + if (context) + wrapper = element = wrapElement(element, context, prevWrapper) + + let instance = ReactDOM.render(element, mount); + + if (instance === null) { + wrapper = wrapElement(element, null, prevWrapper) + instance = ReactDOM.render(wrapper, mount) + } + + if (wrapper) { + wrapper = wrapper.type; + } + + return { wrapper, instance }; +} + +export function collectArgs(key, value) { + if (typeof key === 'string') { + if (arguments.length > 1) + key = { [key]: value } + } + + return key } export function isCompositeComponent(inst) { @@ -28,13 +87,42 @@ export function isCompositeComponent(inst) { return inst === null || typeof inst.render === 'function' && typeof inst.setState === 'function'; } + +export function isStatelessComponent(node) { + let privInst = node.privateInstance + return privInst && privInst.getPublicInstance && privInst.getPublicInstance() === null +} + +export function isQueryCollection(collection) { + return !!(collection && collection._isQueryCollection) +} + +export function unwrapAndCreateNode(subject) { + let node = createNode(subject) + , inst = node.instance + + if (inst && inst.__isTspWrapper) + return unwrapAndCreateNode(node.children[0]) + + return node +} + +export function attachElementsToCollection(collection, elements) { + collection.nodes = [].concat(elements).filter(el => !!el).map(unwrapAndCreateNode) + collection.length = collection.nodes.length + + getPublicInstances(collection.nodes) + .forEach((el, idx)=> collection[idx] = el) +} + export function getPublicInstances(nodes) { let isInstanceTree = false; return nodes.map(node => { let privInst = node.privateInstance; - if (isInstanceTree && !privInst && React.isValidElement(node.element)) - throw new Error('Polymorphic collections are not allowed') + invariant(!(isInstanceTree && !privInst && React.isValidElement(node.element)), + 'Polymorphic collections are not allowed' + ) isInstanceTree = !!privInst return getPublicInstance(node) }) @@ -46,44 +134,42 @@ export function getPublicInstance(node) { if (!privInst) inst = node.element - else if (privInst.getPublicInstance && privInst.getPublicInstance() === null) + else if (isStatelessComponent(node)) inst = ReactDOM.findDOMNode(privInst._instance) - else if (inst && inst.__isStatelessWrapper) - inst = ReactDOM.findDOMNode(inst) - return inst } -export function getInternalInstance(component){ - if (!component) return - - if (component.getPublicInstance) - return component - - if (component.__isStatelessWrapper) - return ReactInstanceMap.get(component)._renderedComponent - - if (component._reactInternalComponent) - return component._reactInternalComponent - - return ReactInstanceMap.get(component) -} - -export function wrapStateless(Element){ - class StatelessWrapper extends React.Component { +/** + * Wrap an element in order to provide context or an instance in the case of + * stateless functional components. `prevWrapper` is necessary for + * rerendering a wrapped root component, recreating a wrapper each time breaks + * React reconciliation b/c `current.type !== prev.type`. Instead we reuse and + * mutate (for childContextTypes) the original component type. + */ +export function wrapElement(element, context, prevWrapper) { + let TspWrapper = prevWrapper || class extends React.Component { constructor(){ super() - this.__isStatelessWrapper = true + this.__isTspWrapper = true } - render(){ - return Element + getChildContext() { + return this.props.context } + render() { + return this.props.children + } + } + + if (context) { + TspWrapper.childContextTypes = Object.keys(context) + .reduce((t, k) => ({ ...t, [k]: React.PropTypes.any }), {}) } - return + return {element} } + export function getMountPoint(instance){ var id = getID(findDOMNode(instance)); return findReactContainerForID(id); @@ -98,43 +184,15 @@ export function findDOMNode(component){ ? component : component && component._rootID ? getNode(component._rootID) - : ReactDOM.findDOMNode(component) -} - -export function getInstanceChildren(inst){ - let publicInst; - - if (!inst) return []; - - if (inst.getPublicInstance) - publicInst = inst.getPublicInstance() - - if (ReactTestUtils.isDOMComponent(publicInst)) { - let renderedChildren = inst._renderedChildren || {}; - - return Object.keys(renderedChildren) - .map(key => renderedChildren[key]) - .filter(node => typeof node._currentElement !== 'string' ) - } - else if (isCompositeComponent(publicInst)) { - let rendered = inst._renderedComponent; - if (rendered && typeof rendered._currentElement !== 'string') - return [rendered] - } - - return [] + : component ? ReactDOM.findDOMNode(component) : null } -export function match(selector, tree, includeSelf){ - if (typeof selector === 'function') - selector = s`${selector}` +let buildSelector = sel => typeof sel === 'function' ? s`${sel}` : sel - return _match(selector, tree, includeSelf) +export function is(selector, tree, includeSelf) { + return !!compile(buildSelector(selector))(tree) } -export function traverse(tree, test, includeSelf = true){ - if (React.isValidElement(tree)) - return elementTraverse(tree, test, includeSelf) - - return instanceTraverse(tree, test, includeSelf) +export function match(selector, tree, includeSelf) { + return _match(buildSelector(selector), tree, includeSelf) } diff --git a/test/common.js b/test/common.js index f2df446..7e07307 100644 --- a/test/common.js +++ b/test/common.js @@ -1,6 +1,10 @@ import React from 'react'; +import ReactTestUtils from 'react-addons-test-utils'; import { unmountComponentAtNode, render } from 'react-dom'; import $ from '../src'; +import ElementCollection from '../src/element'; +import InstanceCollection from '../src/instance'; +import commonPrototype from '../src/common'; describe('common', ()=> { let Stateless = () => foo @@ -14,19 +18,20 @@ describe('common', ()=> { ) let Example = React.createClass({ - // to test getters - getInitialState(){ return { count: 1 } }, + contextTypes: { + question: React.PropTypes.string + }, + + getInitialState(){ return { greeting: 'hello there: ' } }, render() { return ( -
    -
    - {'hello there: ' + (this.props.name || 'person')} - - foo - - {list} -
    -
    +
    + {this.state.greeting + (this.props.name || 'person') + (this.context.question || '')} + + foo + + {list} +
    ) } }) @@ -36,9 +41,50 @@ describe('common', ()=> { $(
    )[0].type.should.equal('div') }) + it('collection types should have the same common prototype', ()=>{ + Object.getPrototypeOf(ElementCollection.prototype) + .should.equal(Object.getPrototypeOf(InstanceCollection.prototype)) + .and.equal($.prototype) + .and.equal(commonPrototype) + }) + + it('$ should have a reference to ElementCollection', ()=>{ + $.element.should.equal(ElementCollection) + }) + + it('$ should have a reference to InstanceCollection', ()=>{ + $.instance.should.equal(InstanceCollection) + }) + + it('should allow extending both collections by assigning to .fn', ()=>{ + $.fn.type = function(){ + return this.nodes[0].element.type + } + + $(
    ).type() + .should.equal('div') + + $(ReactTestUtils.renderIntoDocument()).type() + .should.equal(Example) + + delete $.fn.type + }) + + it('extending an single collection type should not effect the other.', ()=>{ + $.element.fn.type = function(){ + return this.nodes[0].element.type + } + + $(
    ).type() + .should.equal('div') + + expect($(ReactTestUtils.renderIntoDocument()).type) + .to.not.exist + }) + it('should shallow render element', ()=> { let inst = $().shallowRender() - inst[0].type.should.equal('main') + inst[0].type.should.equal(Example) }) it('should render element', ()=> { @@ -50,10 +96,11 @@ describe('common', ()=> { it('should render element using provided mount element', ()=> { let mount = document.createElement('div') - let instance = $(
    ).render(false, mount) + let instance = $(
    ).render(mount) mount.children[0].classList.contains('test').should.equal(true) instance._mountPoint.should.equal(mount) + document.contains(mount).should.equal(false) }) it('should render into document', ()=> { @@ -71,6 +118,8 @@ describe('common', ()=> { document.querySelectorAll('.test').length.should.equal(1) instance._mountPoint.should.equal(mount) + document.contains(mount).should.equal(true) + unmountComponentAtNode(instance._mountPoint) }) @@ -79,27 +128,31 @@ describe('common', ()=> { let instanceB = $(instance); instance.should.not.equal(instanceB) - expect(instance.context).to.equal(instanceB.context) + expect(instance.root).to.equal(instanceB.root) expect(instance[0]).to.equal(instanceB[0]) }) + describe(null, ()=> { let types = { - shallow: (elements) => $(elements).shallowRender(), - DOM: (elements) => $(elements).render(), + shallow: (elements, context) => $(elements).shallowRender(null, context), + DOM: (elements, context) => $(elements).render(context), } Object.keys(types).forEach(type => { - let render = types[type] - , isDomTest = type === 'DOM'; - + let render = types[type]; describe(type + ' rendering', function(){ + it('should maintain root elements after render', ()=>{ + render().is(Example) + }) + it('should work with Stateless components as root', ()=>{ let inst = render() inst[0].should.exist + inst.is(Stateless).should.equal(true) }) it('.get()', ()=> { @@ -119,6 +172,14 @@ describe('common', ()=> { expect(count).to.equal(inst.length); }) + it('registerPseudo() should allow pseudo extensions', ()=> { + $.registerPseudo('foo', (node, test) => { + return $(node).is('.foo') + }) + + render().find(':foo').length.should.equal(3) + }) + it('.tap()', ()=> { let spy = sinon.spy(function (n) { expect(n).to.exist.and.equal(this) }) , inst = render(); @@ -136,20 +197,116 @@ describe('common', ()=> { .tap(node => node.length.should.equal(3)) }) - // it('should get props', ()=>{ - // $() - // .prop('name').should.equal('rikki-tikki-tavi') - // - // render() - // .prop('name').should.equal('rikki-tikki-tavi') - // }) - // - // it('should get state', ()=>{ - // let inst = $() - // - // inst.state().should.eql({ count: 1 }); - // inst.shallowRender().state('count').should.equal(1) - // }) + it('props() should get props', ()=> { + // -- unrendered elements --- + $() + .props('name').should.equal('rikki-tikki-tavi') + $() + .props().name.should.equal('rikki-tikki-tavi') + + // -- rendered versions --- + render() + .props('name').should.equal('rikki-tikki-tavi') + + render() + .props().name.should.equal('rikki-tikki-tavi') + }) + + it('props() should change props', ()=> { + + render() + .tap(inst => { + inst.first('div > :text').unwrap() + .should.equal('hello there: rikki-tikki-tavi') + }) + .props('name', 'Nagaina') + .tap(inst => + inst.first('div > :text').unwrap() + .should.equal('hello there: Nagaina')) + .props({ name: 'Nag' }) + .tap(inst => + inst.first('div > :text').unwrap() + .should.equal('hello there: Nag')) + + }) + + it('props() should throw on empty collecitons', ()=> { + ;(() => render().find('article').props({ name: 'Steven' })) + .should.throw('the method `props()` found no matching elements') + + ;(() => render().find('article').props()) + .should.throw('the method `props()` found no matching elements') + }) + + it('state() should get component state', ()=>{ + let inst = render() + + inst.state().should.eql({ greeting: 'hello there: ' }); + inst.state('greeting').should.equal('hello there: ') + }) + + it('state() should change component state', done => { + + render() + .tap(inst => { + inst.first('div > :text').unwrap() + .should.equal('hello there: John') + }) + .state('greeting', 'yo yo! ') + .tap(inst => + inst.first('div > :text').unwrap() + .should.equal('yo yo! John')) + .state({ greeting: 'huzzah good sir: ' }, inst => { + inst.first('div > :text').unwrap() + .should.equal('huzzah good sir: John') + done() + }) + }) + + it('state() should throw on empty collections', ()=> { + ;(() => render().find('article').state({ name: 'Steven' })) + .should.throw('the method `state()` found no matching elements') + + ;(() => render().find('article').state()) + .should.throw('the method `state()` found no matching elements') + }) + + it('context() should get context', ()=> { + let context = { question: ', who dis?'}; + + render(, context) + .context('question').should.equal(context.question) + + render(, context) + .context().should.eql(context) + }) + + it('context() should change context', ()=> { + + render(, { question: ', who dis?'}) + .tap(inst => { + inst.first('div > :text').unwrap() + .should.equal('hello there: person, who dis?') + }) + .context('question', ', how are you?') + .tap(inst => + inst.first('div > :text').unwrap() + .should.equal('hello there: person, how are you?')) + .context({ question: ', whats the haps?' }) + .tap(inst => + inst.first('div > :text').unwrap() + .should.equal('hello there: person, whats the haps?')) + + }) + + it('context() should throw on empty collections', ()=> { + ;(() => render().find('article').context({ name: 'Steven' })) + .should.throw('the method `context()` found no matching elements') + + ;(() => render().find('article').context()) + .should.throw('the method `context()` found no matching elements') + }) + it('.find() by tag or classname', ()=> { render().find('li').length.should.equal(3) @@ -165,21 +322,21 @@ describe('common', ()=> { }) it('.find() by :dom', ()=>{ - render().find('main :dom').length.should.equal(6); + render().find('div :dom').length.should.equal(5); }) it('.find() should allow chaining ', (done)=> { render() .find('ul.foo, Stateless') - .tap(coll => { - expect(coll.length).to.equal(2) - coll.filter('ul').length.should.equal(1) - coll.filter(Stateless).length.should.equal(1) + .tap(inst => { + expect(inst.length).to.equal(2) + inst.filter('ul').length.should.equal(1) + inst.filter(Stateless).length.should.equal(1) }) .find('li') - .tap(coll => { - expect(coll.length).to.equal(3) - coll.get().every(node => $(node).is('li')) + .tap(inst => { + expect(inst.length).to.equal(3) + inst.get().every(node => $(node).is('li')) done() }) }) @@ -201,7 +358,6 @@ describe('common', ()=> { instance.filter().should.equal(instance) }) - it('.children()', ()=> { render() .find('ul') @@ -216,7 +372,47 @@ describe('common', ()=> { .length.should.equal(2) }) - it('.text() content', ()=>{ + it('.parent()', ()=> { + render() + .find('li') + .parent() + .tap(inst => + inst.elements()[0].type.should.equal('ul') + ) + .length.should.equal(1) + }) + + it('.parents()', ()=> { + render() + .find('li') + .parents() + .tap(inst => + inst.elements().map(e => e.type).should.eql(['ul', 'div', Example]) + ) + .length.should.equal(3) + }) + + it('.parents() with a selector', ()=> { + render() + .find('li') + .parents(':dom') + .tap(inst => + inst.elements().map(e => e.type).should.eql(['ul', 'div']) + ) + .length.should.equal(2) + }) + + it('.closest()', ()=> { + render() + .find('li') + .closest('div') + .tap(inst => + inst.elements()[0].type.should.equal('div') + ) + .length.should.equal(1) + }) + + it('.text()', ()=>{ render() .find(Stateless) .text().should.equal('foo') @@ -226,6 +422,22 @@ describe('common', ()=> { .text().should.equal('item 1item 2item 3') }) + it(':contains', ()=>{ + render() + .find(':contains(foo)') + .length.should.equal(3) + }) + + it(':textContent', ()=>{ + render() + .find('strong:textContent') + .length.should.equal(1) + + render() + .find(':textContent(foo)') + .length.should.equal(1) + }) + it('.first()', ()=> { render() diff --git a/test/dom.js b/test/dom.js index b6b9642..bef1c4b 100644 --- a/test/dom.js +++ b/test/dom.js @@ -3,7 +3,7 @@ import { unmountComponentAtNode, render } from 'react-dom'; import $ from '../src'; import * as utils from '../src/utils'; -describe('DOM rendering', ()=> { +describe('DOM rendering specific', ()=> { let Stateless = props =>
    {props.children}
    let List = class extends React.Component { render(){ @@ -55,7 +55,7 @@ describe('DOM rendering', ()=> { let next = instance.unmount() document.querySelectorAll('.test').length.should.equal(0) - expect(instance.context).to.not.exist + expect(instance.root).to.not.exist expect(mount.parentNode).to.not.exist expect(next[0].type).to.equal('div') @@ -72,20 +72,16 @@ describe('DOM rendering', ()=> { $.dom(div).should.equal(div) }) - it('should `get()` underlying element', ()=> { - let instance = $() + it('should throw when retrieving state from a stateless node', ()=> { + let msg = 'You are trying to inspect or set state on a stateless component ' + + 'such as a DOM node or functional component'; - instance.get()[0].should.equal(instance[0]) + ;(() => $().render().find('div').state()) + .should.throw(msg) + + ;(() => $().render().find(Stateless).state()) + .should.throw(msg) }) -// -// it('should set props', ()=> { -// let instance = $() -// -// instance.setProps({ min: 5 }) -// -// instance[0].props.min.should.equal(5) -// }) -// it('should trigger event', ()=> { let clickSpy = sinon.spy(); diff --git a/test/shallow.js b/test/shallow.js index 894344c..93e0f1a 100644 --- a/test/shallow.js +++ b/test/shallow.js @@ -2,7 +2,7 @@ import React, { cloneElement } from 'react'; import $ from '../src/element'; -describe.only('shallow rendering', ()=> { +describe('shallow rendering specific', ()=> { let counterRef, StatefulExample, updateSpy; beforeEach(() => { @@ -18,7 +18,10 @@ describe.only('shallow rendering', ()=> { render() { return (
    - + {this.props.name || 'folk'} + + {this.state.count} +
    ) } @@ -29,65 +32,91 @@ describe.only('shallow rendering', ()=> { it('should not try to render primitives', ()=>{ let el =
    - $(el).shallowRender().context[0].should.equal(el) + $(el).shallowRender().unwrap().should.equal(el) }) it('should render Composite Components', ()=>{ let el =
    - , Element = ()=> el; + , Example = ()=> el + , element = ; - $().shallowRender().unwrap().should.equal(el) + let inst = $(element).shallowRender(); + inst.unwrap().should.not.equal(element) + inst.unwrap().type.should.equal(Example) }) it('should filter out invalid Elements', ()=>{ - let instance = $( -
      - { false } - { null} - {'text'} -
    • hi 1
    • -
    • hi 2
    • -
    • hi 3
    • -
    - ) - - instance.children().length.should.equal(3) - instance.shallowRender().length.should.equal(3) + let instance = $([ + false, + null, + 'text', +
  • hi 1
  • , +
  • hi 2
  • , +
  • hi 3
  • , + ]) + + // breaking? 3 -> 4 + instance.length.should.equal(4) }) - it('should maintain state between renders', ()=>{ - let counter = $() - counter.shallowRender().state('count').should.equal(0) - counterRef.increment() - counter.shallowRender().state('count').should.equal(1) + it('prop() should throw when updating a non-root rendered collection', ()=> { + ;(() => $().shallowRender().find('span').props({ name: 'Steven' })) + .should.throw( + 'changing the props on a shallow rendered child is an anti-pattern, ' + + 'since the elements props will be overridden by its parent in the next update() of the root element' + ) }) - it('should update', ()=> { + it('should update when a root update occurs', ()=> { let counter = $().shallowRender() counter.state('count').should.equal(0) + counter.find('span').text().should.equal('0') + counterRef.increment() + updateSpy.should.have.been.calledOnce counter.state('count').should.equal(1) + counter.find('span').text().should.equal('1') }) - it('should throw when updating none root elements', ()=> { + it('should throw when updating non-root elements', ()=> { let counter = $().shallowRender() ;(() => counter.find('span').update()) .should.throw('You can only preform this action on a "root" element.') }) - it('should update root collections', ()=> { + it('should update root when props or state are changed', ()=> { + let inst = $().shallowRender(); + + inst + .props({ name: 'The boy' }) + .tap(inst => + inst.find('div > :first-child').unwrap().should.equal('The boy')) + .state({ count: 40 }) + .find('span').text().should.equal('40') + + updateSpy.should.have.been.calledOnce + }) + + it('trigger() should update root collections', ()=> { let inst = $().shallowRender(); inst .find('span') .trigger('click') - .context - .state('count').should.equal(1) + .root + .tap(inst => + inst.find('span').text().should.equal('1')) + .state('count').should.equal( + inst.state('count') + ) updateSpy.should.have.been.calledOnce }) + + + })