From 6b7c758142209d1937795431366aabe07763f3b3 Mon Sep 17 00:00:00 2001 From: "K. Adam White" Date: Tue, 16 Feb 2016 12:35:16 -0500 Subject: [PATCH] Move filter & parameter chaining methods into mixins This PR extracts the filtering and query parameter methods into mixin functions that can be added to a constructor to grant it the specified methods. In addition to this significant (architectural) change, filters and parameter setters no longer have implicit interdependencies: with the exception of .param itself, which is a part of the base WP-Request class, all filter methods depend on each other directly where needed so that any filter can be included into a subclass with no restrictions. The goal of this patch is to 1. decrease code duplication between endpoints 2. better surface and test edge cases in parameter and filter methods 3. simplify the ability to add filtering functionality to programatically- generated endpoint handlers (see #145) To accomplish these goals I - moved CollectionRequest filter methods into a filter mixin module - moved CollectionRequest parameter methods into a parameter mixin module - moved filter and parameter mixins from other endpoints (page, taxonomies, comments) into the filter and parameter mixin modules as appropriate - unit tested the new filter and parameter mixin modules - adjusted how `.filter` and `.param` function to address edge cases exposed during testing, such as unexpected behavior when calling `.filter` or `.param` with no second argument. - allowed filters to initialize their own private state objects (the `this._filters` and `this._taxonomyFilters` objects), so that they can be included into a prototype without any further pre-work required to make them function with that constructor - removed all existing explicit definition of `this._filters` and `this._taxonomyFilters` objects in endpoint request handler constructors, since those objects will now be created only when needed There is currently some duplicate testing logic between the mixin files and the request handler tests themselves; this is not seen as a detriment but since the mixins are fully tested the assertions in the constructor test suites may eventually be replaced with tests that verify the mixins have been applied properly. --- lib/comments.js | 46 +-- lib/{shared => lib}/alphanumeric-sort.js | 0 lib/media.js | 18 - lib/mixins/filters.js | 218 ++++++++++ lib/mixins/parameters.js | 166 ++++++++ lib/pages.js | 42 +- lib/posts.js | 18 - lib/shared/collection-request.js | 301 ++------------ lib/shared/wp-request.js | 16 +- lib/taxonomies.js | 51 +-- lib/types.js | 9 - lib/users.js | 9 - tests/integration/pages.js | 199 +++++++++ tests/integration/posts.js | 15 + tests/unit/lib/media.js | 2 - tests/unit/lib/mixins/filters.js | 436 ++++++++++++++++++++ tests/unit/lib/mixins/parameters.js | 330 +++++++++++++++ tests/unit/lib/pages.js | 2 - tests/unit/lib/posts.js | 2 - tests/unit/lib/shared/collection-request.js | 17 +- tests/unit/lib/shared/wp-request.js | 15 + tests/unit/lib/taxonomies.js | 1 - tests/unit/lib/types.js | 1 - tests/unit/lib/users.js | 1 - 24 files changed, 1453 insertions(+), 462 deletions(-) rename lib/{shared => lib}/alphanumeric-sort.js (100%) create mode 100644 lib/mixins/filters.js create mode 100644 lib/mixins/parameters.js create mode 100644 tests/integration/pages.js create mode 100644 tests/unit/lib/mixins/filters.js create mode 100644 tests/unit/lib/mixins/parameters.js diff --git a/lib/comments.js b/lib/comments.js index 0b3c8630..5ecb9c38 100644 --- a/lib/comments.js +++ b/lib/comments.js @@ -5,8 +5,12 @@ * @beta */ var CollectionRequest = require( './shared/collection-request' ); +var pick = require( 'lodash' ).pick; +var extend = require( 'node.extend' ); var inherit = require( 'util' ).inherits; +var parameters = require( './mixins/parameters' ); + /** * CommentsRequest extends CollectionRequest to handle the /comments API endpoint * @@ -73,6 +77,12 @@ function CommentsRequest( options ) { // CommentsRequest extends CollectionRequest inherit( CommentsRequest, CollectionRequest ); +// Mixins +extend( CommentsRequest.prototype, pick( parameters, [ + 'parent', + 'forPost' +] ) ); + /** * A hash table of path keys and regex validators for those path elements * @@ -143,40 +153,4 @@ CommentsRequest.prototype.term = function( term ) { return this; }; -/** - * Search for hierarchical taxonomy terms that are children of the parent term - * indicated by the provided term ID - * - * @example - * - * wp.categories().parent( 42 ).then(function( categories ) { - * console.log( 'all of these categories are sub-items of cat ID#42:' ); - * console.log( categories ); - * }); - * - * @method parent - * @chainable - * @param {Number} parentId The ID of a (hierarchical) taxonomy term - * @return {CommentsRequest} The CommentsRequest instance (for chaining) - */ -CommentsRequest.prototype.parent = function( parentId ) { - this.param( 'parent', parentId, true ); - - return this; -}; - -/** - * Specify the post for which to retrieve terms - * - * @method forPost - * @chainable - * @param {String|Number} post The ID of the post for which to retrieve terms - * @return {CommentsRequest} The CommentsRequest instance (for chaining) - */ -CommentsRequest.prototype.forPost = function( postId ) { - this.param( 'post', postId ); - - return this; -}; - module.exports = CommentsRequest; diff --git a/lib/shared/alphanumeric-sort.js b/lib/lib/alphanumeric-sort.js similarity index 100% rename from lib/shared/alphanumeric-sort.js rename to lib/lib/alphanumeric-sort.js diff --git a/lib/media.js b/lib/media.js index 110465b3..6df9a575 100644 --- a/lib/media.js +++ b/lib/media.js @@ -28,24 +28,6 @@ function MediaRequest( options ) { */ this._options = options || {}; - /** - * A hash of filter values to parse into the final request URI - * @property _filters - * @type Object - * @private - * @default {} - */ - this._filters = {}; - - /** - * A hash of taxonomy terms to parse into the final request URI - * @property _taxonomyFilters - * @type Object - * @private - * @default {} - */ - this._taxonomyFilters = {}; - /** * A hash of non-filter query parameters * diff --git a/lib/mixins/filters.js b/lib/mixins/filters.js new file mode 100644 index 00000000..0e785da8 --- /dev/null +++ b/lib/mixins/filters.js @@ -0,0 +1,218 @@ +'use strict'; +/** + * Filter methods that can be mixed in to a request constructor's prototype to + * allow that request to take advantage of the `?filter[]=` aliases for WP_Query + * parameters for collection endpoints. These are most relevant to posts, pages + * and CPTs. + * + * @module filters + */ +var _ = require( 'lodash' ); +var extend = require( 'node.extend' ); +var alphaNumericSort = require( '../lib/alphanumeric-sort' ); + +var filterMixins = {}; + +// Filter Methods +// ============== + +/** + * Specify key-value pairs by which to filter the API results (commonly used + * to retrieve only posts meeting certain criteria, such as posts within a + * particular category or by a particular author). + * + * @example + * // Set a single property: + * wp.filter( 'post_type', 'cpt_event' )... + * + * // Set multiple properties at once: + * wp.filter({ + * post_status: 'publish', + * category_name: 'news' + * })... + * + * // Chain calls to .filter(): + * wp.filter( 'post_status', 'publish' ).filter( 'category_name', 'news' )... + * + * @method filter + * @chainable + * @param {String|Object} props A filter property name string, or object of name/value pairs + * @param {String|Number|Array} [value] The value(s) corresponding to the provided filter property + * @return The request instance (for chaining) + */ +filterMixins.filter = function( props, value ) { + /* jshint validthis:true */ + + if ( ! props || _.isString( props ) && typeof value === 'undefined' ) { + // We have no filter to set, or no value to set for that filter + return this; + } + + // convert the property name string `props` and value `value` into an object + if ( _.isString( props ) ) { + props = _.zipObject([[ props, value ]]); + } + + this._filters = extend( this._filters, props ); + + return this; +}; + +/** + * Restrict the query results to posts matching one or more taxonomy terms. + * + * @method taxonomy + * @chainable + * @param {String} taxonomy The name of the taxonomy to filter by + * @param {String|Number|Array} term A string or integer, or array thereof, representing terms + * @return The request instance (for chaining) + */ +filterMixins.taxonomy = function( taxonomy, term ) { + /* jshint validthis:true */ + var termIsArray = _.isArray( term ); + var termIsNumber = termIsArray ? + term.reduce(function( allAreNumbers, term ) { + return allAreNumbers && _.isNumber( term ); + }, true ) : + _.isNumber( term ); + var termIsString = termIsArray ? + term.reduce(function( allAreStrings, term ) { + return allAreStrings && _.isString( term ); + }, true ) : + _.isString( term ); + var taxonomyTerms; + + if ( ! termIsString && ! termIsNumber ) { + throw new Error( 'term must be a number, string, or array of numbers or strings' ); + } + + if ( taxonomy === 'category' ) { + if ( termIsString ) { + // Query param for filtering by category slug is "category_name" + taxonomy = 'category_name'; + } else { + // The boolean check above ensures that if taxonomy === 'category' and + // term is not a string, then term must be a number and therefore an ID: + // Query param for filtering by category ID is "cat" + taxonomy = 'cat'; + } + } else if ( taxonomy === 'post_tag' ) { + // tag is used in place of post_tag in the public query variables + taxonomy = 'tag'; + } + + // Ensure the taxonomy filters object is available + this._taxonomyFilters = this._taxonomyFilters || {}; + + // Ensure there's an array of terms available for this taxonomy + taxonomyTerms = ( this._taxonomyFilters[ taxonomy ] || [] ) + // Insert the provided terms into the specified taxonomy's terms array + .concat( term ) + // Sort array + .sort( alphaNumericSort ); + + // De-dupe + this._taxonomyFilters[ taxonomy ] = _.unique( taxonomyTerms, true ); + + return this; +}; + +/** + * Convenience wrapper for `.taxonomy( 'category', ... )`. + * + * @method category + * @chainable + * @param {String|Number|Array} category A string or integer, or array thereof, representing terms + * @return The request instance (for chaining) + */ +filterMixins.category = function( category ) { + /* jshint validthis:true */ + return filterMixins.taxonomy.call( this, 'category', category ); +}; + +/** + * Convenience wrapper for `.taxonomy( 'tag', ... )`. + * + * @method tag + * @chainable + * @param {String|Number|Array} tag A tag term string or array of tag term strings + * @return The request instance (for chaining) + */ +filterMixins.tag = function( tag ) { + /* jshint validthis:true */ + return filterMixins.taxonomy.call( this, 'tag', tag ); +}; + +/** + * Query for posts published in a given year. + * + * @method year + * @chainable + * @param {Number} year integer representation of year requested + * @returns The request instance (for chaining) + */ +filterMixins.year = function( year ) { + /* jshint validthis:true */ + return filterMixins.filter.call( this, 'year', year ); +}; + +/** + * Query for posts published in a given month, either by providing the number + * of the requested month (e.g. 3), or the month's name as a string (e.g. "March") + * + * @method month + * @chainable + * @param {Number|String} month Integer for month (1) or month string ("January") + * @returns The request instance (for chaining) + */ +filterMixins.month = function( month ) { + /* jshint validthis:true */ + var monthDate; + if ( _.isString( month ) ) { + // Append a arbitrary day and year to the month to parse the string into a Date + monthDate = new Date( Date.parse( month + ' 1, 2012' ) ); + + // If the generated Date is NaN, then the passed string is not a valid month + if ( isNaN( monthDate ) ) { + return this; + } + + // JS Dates are 0 indexed, but the WP API requires a 1-indexed integer + month = monthDate.getMonth() + 1; + } + + // If month is a Number, add the monthnum filter to the request + if ( _.isNumber( month ) ) { + return filterMixins.filter.call( this, 'monthnum', month ); + } + + return this; +}; + +/** + * Add the day filter into the request to retrieve posts for a given day + * + * @method day + * @chainable + * @param {Number} day Integer representation of the day requested + * @returns The request instance (for chaining) + */ +filterMixins.day = function( day ) { + /* jshint validthis:true */ + return filterMixins.filter.call( this, 'day', day ); +}; + +/** + * Specify that we are requesting a page by its path (specific to Page resources) + * + * @method path + * @chainable + * @param {String} path The root-relative URL path for a page + * @returns The request instance (for chaining) + */ +filterMixins.path = function( path ) { + /* jshint validthis:true */ + return filterMixins.filter.call( this, 'pagename', path ); +}; + +module.exports = filterMixins; diff --git a/lib/mixins/parameters.js b/lib/mixins/parameters.js new file mode 100644 index 00000000..c6d2ac0b --- /dev/null +++ b/lib/mixins/parameters.js @@ -0,0 +1,166 @@ +'use strict'; +/*jshint -W106 */// Disable underscore_case warnings in this file b/c WP uses them +/** + * Filter methods that can be mixed in to a request constructor's prototype to + * allow that request to take advantage of top-level query parameters for + * collection endpoints. These are most relevant to posts, pages and CPTs, but + * pagination helpers are applicable to any collection. + * + * @module filters + */ +var _ = require( 'lodash' ); + +var parameterMixins = {}; + +// Needed for .author mixin, as author by ID is a parameter and by Name is a filter +var filter = require( './filters' ).filter; + +// Pagination Methods +// ================== + +/** + * Set the pagination of a request. Use in conjunction with `.perPage()` for explicit + * pagination handling. (The number of pages in a response can be retrieved from the + * response's `_paging.totalPages` property.) + * + * @method page + * @chainable + * @param {Number} pageNumber The page number of results to retrieve + * @return The request instance (for chaining) + */ +parameterMixins.page = function( pageNumber ) { + /* jshint validthis:true */ + return this.param( 'page', pageNumber ); +}; + +/** + * Set the number of items to be returned in a page of responses. + * + * @method perPage + * @chainable + * @param {Number} itemsPerPage The number of items to return in one page of results + * @return The request instance (for chaining) + */ +parameterMixins.perPage = function( itemsPerPage ) { + /* jshint validthis:true */ + return this.param( 'per_page', itemsPerPage ); +}; + +// Parameter Methods +// ================= + +/** + * Query a collection for members with a specific slug. + * + * @method slug + * @chainable + * @param {String} slug A post slug (slug), e.g. "hello-world" + * @return The request instance (for chaining) + */ +parameterMixins.slug = function( slug ) { + /* jshint validthis:true */ + return this.param( 'slug', slug ); +}; + +/** + * Alias for .slug() + * + * @method name + * @alias slug + * @chainable + * @param {String} slug A post name (slug), e.g. "hello-world" + * @return The request instance (for chaining) + */ +parameterMixins.name = function( slug ) { + /* jshint validthis:true */ + return parameterMixins.slug.call( this, slug ); +}; + +/** + * Filter results to those matching the specified search terms. + * + * @method search + * @chainable + * @param {String} searchString A string to search for within post content + * @return The request instance (for chaining) + */ +parameterMixins.search = function( searchString ) { + /* jshint validthis:true */ + return this.param( 'search', searchString ); +}; + +/** + * Query for posts by a specific author. + * This method will replace any previous 'author' query parameters that had been set. + * + * Note that this method will either set the "author" top-level query parameter, + * or else the "author_name" filter parameter: this is irregular as most parameter + * helper methods either set a top level parameter or a filter, not both. + * + * @method author + * @chainable + * @param {String|Number} author The nicename or ID for a particular author + * @return The request instance (for chaining) + */ +parameterMixins.author = function( author ) { + /* jshint validthis:true */ + if ( typeof author === 'undefined' ) { + return this; + } + if ( _.isString( author ) ) { + this.param( 'author', null ); + return filter.call( this, 'author_name', author ); + } + if ( _.isNumber( author ) ) { + filter.call( this, 'author_name', null ); + return this.param( 'author', author ); + } + if ( author === null ) { + filter.call( this, 'author_name', null ); + return this.param( 'author', null ); + } + throw new Error( 'author must be either a nicename string or numeric ID' ); +}; + +/** + * Search for hierarchical taxonomy terms that are children of the parent term + * indicated by the provided term ID + * + * @example + * + * wp.pages().parent( 3 ).then(function( pages ) { + * // console.log( 'all of these pages are nested below page ID#3:' ); + * // console.log( pages ); + * }); + * + * wp.categories().parent( 42 ).then(function( categories ) { + * console.log( 'all of these categories are sub-items of cat ID#42:' ); + * console.log( categories ); + * }); + * + * @method parent + * @chainable + * @param {Number} parentId The ID of a (hierarchical) taxonomy term + * @return The request instance (for chaining) + */ +parameterMixins.parent = function( parentId ) { + /* jshint validthis:true */ + return this.param( 'parent', parentId, true ); +}; + +/** + * Specify the post for which to retrieve terms (relevant for *e.g.* taxonomy + * and comment collection endpoints). `forPost` is used to avoid conflicting + * with the `.post()` method, which corresponds to the HTTP POST action. + * + * @method forPost + * @chainable + * @param {String|Number} post The ID of the post for which to retrieve terms + * @return The request instance (for chaining) + */ +parameterMixins.forPost = function( postId ) { + /* jshint validthis:true */ + return this.param( 'post', postId ); +}; + +module.exports = parameterMixins; diff --git a/lib/pages.js b/lib/pages.js index 8f591e1f..f870d19d 100644 --- a/lib/pages.js +++ b/lib/pages.js @@ -5,8 +5,12 @@ * @beta */ var CollectionRequest = require( './shared/collection-request' ); +var pick = require( 'lodash' ).pick; +var extend = require( 'node.extend' ); var inherit = require( 'util' ).inherits; +var filters = require( './mixins/filters' ); + /** * PagesRequest extends CollectionRequest to handle the /posts API endpoint * @@ -28,24 +32,6 @@ function PagesRequest( options ) { */ this._options = options || {}; - /** - * A hash of filter values to parse into the final request URI - * @property _filters - * @type Object - * @private - * @default {} - */ - this._filters = {}; - - /** - * A hash of taxonomy terms to parse into the final request URI - * @property _taxonomyFilters - * @type Object - * @private - * @default {} - */ - this._taxonomyFilters = {}; - /** * A hash of non-filter query parameters * @@ -90,6 +76,12 @@ function PagesRequest( options ) { inherit( PagesRequest, CollectionRequest ); +// Mixins +extend( PagesRequest.prototype, pick( filters, [ + // Specify that we are requesting a page by its path + 'path' +] ) ); + /** * A hash table of path keys and regex validators for those path elements * @@ -180,18 +172,4 @@ PagesRequest.prototype.revisions = function() { return this.auth(); }; -/** - * Specify that we are requesting a page by its path - * - * @method path - * @chainable - * @param {String} path The root-relative URL path for a page - * @return {PagesRequest} The PagesRequest instance (for chaining) - */ -PagesRequest.prototype.path = function( path ) { - return this.filter({ - pagename: path - }); -}; - module.exports = PagesRequest; diff --git a/lib/posts.js b/lib/posts.js index 62900ab5..b0d75cb4 100644 --- a/lib/posts.js +++ b/lib/posts.js @@ -28,24 +28,6 @@ function PostsRequest( options ) { */ this._options = options || {}; - /** - * A hash of filter values to parse into the final request URI - * @property _filters - * @type Object - * @private - * @default {} - */ - this._filters = {}; - - /** - * A hash of taxonomy terms to parse into the final request URI - * @property _taxonomyFilters - * @type Object - * @private - * @default {} - */ - this._taxonomyFilters = {}; - /** * A hash of non-filter query parameters * diff --git a/lib/shared/collection-request.js b/lib/shared/collection-request.js index 9dc89e58..f839582f 100644 --- a/lib/shared/collection-request.js +++ b/lib/shared/collection-request.js @@ -1,16 +1,16 @@ 'use strict'; -/*jshint -W106 */// Disable underscore_case warnings in this file b/c WP uses them /** * @module WP * @submodule CollectionRequest * @beta */ var WPRequest = require( './wp-request' ); -var _ = require( 'lodash' ); +var pick = require( 'lodash' ).pick; var extend = require( 'node.extend' ); var inherit = require( 'util' ).inherits; -var alphaNumericSort = require( './alphanumeric-sort' ); +var filters = require( '../mixins/filters' ); +var parameters = require( '../mixins/parameters' ); /** * CollectionRequest extends WPRequest with properties & methods for filtering collections @@ -100,276 +100,29 @@ function CollectionRequest( options ) { inherit( CollectionRequest, WPRequest ); -// Private helper methods -// ====================== - -/** - * Utility function for sorting arrays of numbers or strings. - * - * @param {String|Number} a The first comparator operand - * @param {String|Number} a The second comparator operand - * @return -1 if the values are backwards, 1 if they're ordered, and 0 if they're the same - */ -function alphaNumericSort( a, b ) { - if ( a > b ) { - return 1; - } - if ( a < b ) { - return -1; - } - return 0; -} - -// Prototype Methods -// ================= - -/** - * Set the pagination of a request. Use in conjunction with `.perPage()` for explicit - * pagination handling. (The number of pages in a response can be retrieved from the - * response's `_paging.totalPages` property.) - * - * @method page - * @chainable - * @param {Number} pageNumber The page number of results to retrieve - * @return {CollectionRequest} The CollectionRequest instance (for chaining) - */ -CollectionRequest.prototype.page = function( pageNumber ) { - return this.param( 'page', pageNumber ); -}; - -/** - * Set the number of items to be returned in a page of responses. - * - * @method perPage - * @chainable - * @param {Number} itemsPerPage The number of items to return in one page of results - * @return {CollectionRequest} The CollectionRequest instance (for chaining) - */ -CollectionRequest.prototype.perPage = function( itemsPerPage ) { - return this.param( 'per_page', itemsPerPage ); -}; - -/** - * Specify key-value pairs by which to filter the API results (commonly used - * to retrieve only posts meeting certain criteria, such as posts within a - * particular category or by a particular author). - * - * @example - * // Set a single property: - * wp.filter( 'post_type', 'cpt_event' )... - * - * // Set multiple properties at once: - * wp.filter({ - * post_status: 'publish', - * category_name: 'news' - * })... - * - * // Chain calls to .filter(): - * wp.filter( 'post_status', 'publish' ).filter( 'category_name', 'news' )... - * - * @method filter - * @chainable - * @param {String|Object} props A filter property name string, or object of name/value pairs - * @param {String|Number|Array} [value] The value(s) corresponding to the provided filter property - * @return {CollectionRequest} The CollectionRequest instance (for chaining) - */ -CollectionRequest.prototype.filter = function( props, value ) { - // convert the property name string `props` and value `value` into an object - if ( _.isString( props ) && value ) { - props = _.zipObject([[ props, value ]]); - } - - this._filters = extend( this._filters, props ); - - return this; -}; - -/** - * Restrict the query results to posts matching one or more taxonomy terms. - * - * @method taxonomy - * @chainable - * @param {String} taxonomy The name of the taxonomy to filter by - * @param {String|Number|Array} term A string or integer, or array thereof, representing terms - * @return {CollectionRequest} The CollectionRequest instance (for chaining) - */ -CollectionRequest.prototype.taxonomy = function( taxonomy, term ) { - var termIsArray = _.isArray( term ); - var termIsNumber = termIsArray ? _.isNumber( term[ 0 ] ) : _.isNumber( term ); - var termIsString = termIsArray ? _.isString( term[ 0 ] ) : _.isString( term ); - var taxonomyTerms; - - if ( ! termIsString && ! termIsNumber ) { - throw new Error( 'term must be a number, string, or array of numbers or strings' ); - } - - if ( taxonomy === 'category' ) { - if ( termIsString ) { - // Query param for filtering by category slug is category_name - taxonomy = 'category_name'; - } else if ( termIsNumber ) { - // Query param for filtering by category slug is category_name - taxonomy = 'cat'; - } - } else if ( taxonomy === 'post_tag' ) { - // tag is used in place of post_tag in the public query variables - taxonomy = 'tag'; - } - - // Ensure there's an array of terms available for this taxonomy - taxonomyTerms = this._taxonomyFilters[ taxonomy ] || []; - - // Insert the provided terms into the specified taxonomy's terms array - if ( termIsArray ) { - taxonomyTerms = taxonomyTerms.concat( term ); - } else { - taxonomyTerms.push( term ); - } - - // Sort array - taxonomyTerms.sort( alphaNumericSort ); - - // De-dupe - this._taxonomyFilters[ taxonomy ] = _.unique( taxonomyTerms, true ); - - return this; -}; - -/** - * Convenience wrapper for `.taxonomy( 'category', ... )`. - * - * @method category - * @chainable - * @param {String|Number|Array} category A string or integer, or array thereof, representing terms - * @return {CollectionRequest} The CollectionRequest instance (for chaining) - */ -CollectionRequest.prototype.category = function( category ) { - return this.taxonomy( 'category', category ); -}; - -/** - * Convenience wrapper for `.taxonomy( 'tag', ... )`. - * - * @method tag - * @chainable - * @param {String|Number|Array} tag A tag term string or array of tag term strings - * @return {CollectionRequest} The CollectionRequest instance (for chaining) - */ -CollectionRequest.prototype.tag = function( tag ) { - return this.taxonomy( 'tag', tag ); -}; - -/** - * Filter results to those matching the specified search terms. - * - * @method search - * @param {String} searchString A string to search for within post content - * @return {CollectionRequest} The CollectionRequest instance (for chaining) - */ -CollectionRequest.prototype.search = function( searchString ) { - return this.param( 'search', searchString ); -}; - -/** - * Query for posts by a specific author. - * This method will replace any previous 'author' query parameters that had been set. - * - * @method author - * @chainable - * @param {String|Number} author The nicename or ID for a particular author - * @return {CollectionRequest} The CollectionRequest instance (for chaining) - */ -CollectionRequest.prototype.author = function( author ) { - if ( _.isString( author ) ) { - delete this._params.author; - return this.filter( 'author_name', author ); - } - if ( _.isNumber( author ) ) { - delete this._filters.author_name; - return this.param( 'author', author ); - } - throw new Error( 'author must be either a nicename string or numeric ID' ); -}; - -/** - * Query a collection for members with a specific slug. - * - * @method slug - * @chainable - * @param {String} slug A post slug (slug), e.g. "hello-world" - * @return {CollectionRequest} The CollectionRequest instance (for chaining) - */ -CollectionRequest.prototype.slug = function( slug ) { - return this.param( 'slug', slug ); -}; - -/** - * Alias for .slug() - * - * @method name - * @alias slug - * @chainable - * @param {String} slug A post name (slug), e.g. "hello-world" - * @return {CollectionRequest} The CollectionRequest instance (for chaining) - */ -CollectionRequest.prototype.name = function( slug ) { - return this.slug( slug ); -}; - -/** - * Query for posts published in a given year. - * - * @method year - * @chainable - * @param {Number} year integer representation of year requested - * @returns {CollectionRequest} The CollectionRequest instance (for chaining) - */ -CollectionRequest.prototype.year = function( year ) { - return this.filter( 'year', year ); -}; - -/** - * Query for posts published in a given month, either by providing the number - * of the requested month (e.g. 3), or the month's name as a string (e.g. "March") - * - * @method month - * @chainable - * @param {Number|String} month Integer for month (1) or month string ("January") - * @returns {CollectionRequest} The PostsRequest instance (for chaining) - */ -CollectionRequest.prototype.month = function( month ) { - var monthDate; - if ( _.isString( month ) ) { - // Append a arbitrary day and year to the month to parse the string into a Date - monthDate = new Date( Date.parse( month + ' 1, 2012' ) ); - - // If the generated Date is NaN, then the passed string is not a valid month - if ( isNaN( monthDate ) ) { - return this; - } - - // JS Dates are 0 indexed, but the WP API requires a 1-indexed integer - return this.filter( 'monthnum', monthDate.getMonth() + 1 ); - } - - // If month is a Number, add the monthnum filter to the request - if ( _.isNumber( month ) ) { - return this.filter( 'monthnum', month ); - } - - return this; -}; - -/** - * Add the day filter into the request to retrieve posts for a given day - * - * @method day - * @chainable - * @param {Number} day Integer representation of the day requested - * @returns {CollectionRequest} The CollectionRequest instance (for chaining) - */ -CollectionRequest.prototype.day = function( day ) { - return this.filter( 'day', day ); -}; +// Mixins +extend( CollectionRequest.prototype, pick( filters, [ + // Dependency of all other filter parameters, as well as parameterMixins.author + 'filter', + // Taxomy handling + 'taxonomy', + 'category', + 'tag', + // Date filter handling + 'year', + 'month', + 'day' +] ) ); + +extend( CollectionRequest.prototype, pick( parameters, [ + // Pagination + 'page', + 'perPage', + // Other query parameters + 'slug', + 'name', + 'search', + 'author' +] ) ); module.exports = CollectionRequest; diff --git a/lib/shared/wp-request.js b/lib/shared/wp-request.js index 0c4c0b8d..8acdfead 100644 --- a/lib/shared/wp-request.js +++ b/lib/shared/wp-request.js @@ -16,7 +16,7 @@ var _ = require( 'lodash' ); var extend = require( 'node.extend' ); // TODO: reorganize library so that this has a better home -var alphaNumericSort = require( './alphanumeric-sort' ); +var alphaNumericSort = require( '../lib/alphanumeric-sort' ); /** * WPRequest is the base API request object constructor @@ -394,14 +394,20 @@ WPRequest.prototype._renderQuery = function() { WPRequest.prototype.param = function( props, value, merge ) { merge = merge || false; - // We can use the same iterator function below to handle explicit key-value pairs if we - // convert them into to an object we can iterate over: - if ( _.isString( props ) && typeof value !== 'undefined' ) { + if ( ! props || _.isString( props ) && typeof value === 'undefined' ) { + // We have no property to set, or no value to set for that property + return this; + } + + // We can use the same iterator function below to handle explicit key-value + // pairs if we convert them into to an object we can iterate over: + if ( _.isString( props ) ) { props = _.zipObject([[ props, value ]]); } // Iterate through the properties - _.each( props, function( value, key ) { + Object.keys( props ).forEach(function( key ) { + var value = props[ key ]; var currentVal = this._params[ key ]; // Simple case: setting for the first time, or not merging diff --git a/lib/taxonomies.js b/lib/taxonomies.js index ccdace4d..850d7d52 100644 --- a/lib/taxonomies.js +++ b/lib/taxonomies.js @@ -5,7 +5,10 @@ * @beta */ var CollectionRequest = require( './shared/collection-request' ); +var extend = require( 'node.extend' ); var inherit = require( 'util' ).inherits; +var parameters = require( './mixins/parameters' ); +var pick = require( 'lodash' ).pick; /** * TaxonomiesRequest extends CollectionRequest to handle the /taxonomies API endpoint @@ -28,15 +31,6 @@ function TaxonomiesRequest( options ) { */ this._options = options || {}; - /** - * A hash of filter values to parse into the final request URI - * @property _filters - * @type Object - * @private - * @default {} - */ - this._filters = {}; - /** * A hash of non-filter query parameters * @@ -126,40 +120,9 @@ TaxonomiesRequest.prototype.term = function( term ) { return this; }; -/** - * Search for hierarchical taxonomy terms that are children of the parent term - * indicated by the provided term ID - * - * @example - * - * wp.categories().parent( 42 ).then(function( categories ) { - * console.log( 'all of these categories are sub-items of cat ID#42:' ); - * console.log( categories ); - * }); - * - * @method parent - * @chainable - * @param {Number} parentId The ID of a (hierarchical) taxonomy term - * @return {TaxonomiesRequest} The TaxonomiesRequest instance (for chaining) - */ -TaxonomiesRequest.prototype.parent = function( parentId ) { - this.param( 'parent', parentId, true ); - - return this; -}; - -/** - * Specify the post for which to retrieve terms - * - * @method forPost - * @chainable - * @param {String|Number} post The ID of the post for which to retrieve terms - * @return {TaxonomiesRequest} The TaxonomiesRequest instance (for chaining) - */ -TaxonomiesRequest.prototype.forPost = function( postId ) { - this.param( 'post', postId ); - - return this; -}; +extend( TaxonomiesRequest.prototype, pick( parameters, [ + 'parent', + 'forPost' +] ) ); module.exports = TaxonomiesRequest; diff --git a/lib/types.js b/lib/types.js index b5a0ab17..0ac658ca 100644 --- a/lib/types.js +++ b/lib/types.js @@ -28,15 +28,6 @@ function TypesRequest( options ) { */ this._options = options || {}; - /** - * A hash of filter values to parse into the final request URI - * @property _filters - * @type Object - * @private - * @default {} - */ - this._filters = {}; - /** * A hash of non-filter query parameters * diff --git a/lib/users.js b/lib/users.js index cd2ca054..f5116f6d 100644 --- a/lib/users.js +++ b/lib/users.js @@ -29,15 +29,6 @@ function UsersRequest( options ) { */ this._options = options || {}; - /** - * A hash of filter values to parse into the final request URI - * @property _filters - * @type Object - * @private - * @default {} - */ - this._filters = {}; - /** * A hash of non-filter query parameters * diff --git a/tests/integration/pages.js b/tests/integration/pages.js new file mode 100644 index 00000000..9b7fd038 --- /dev/null +++ b/tests/integration/pages.js @@ -0,0 +1,199 @@ +'use strict'; +var chai = require( 'chai' ); +// Variable to use as our "success token" in promise assertions +var SUCCESS = 'success'; +// Chai-as-promised and the `expect( prom ).to.eventually.equal( SUCCESS ) is +// used to ensure that the assertions running within the promise chains are +// actually run. +chai.use( require( 'chai-as-promised' ) ); +var expect = chai.expect; + +var WP = require( '../../' ); +var WPRequest = require( '../../lib/shared/wp-request.js' ); + +// Define some arrays to use ensuring the returned data is what we expect +// it to be (e.g. an array of the titles from pages on the first page) +var expectedResults = { + titles: { + page1: [ + 'Page Markup And Formatting', + 'Page Image Alignment', + 'Level 3b', + 'Level 3a', + 'Level 2b', + 'Level 2a', + 'Page B', + 'Page A', + 'Blog', + 'Front Page' + ], + page2: [ + 'Clearing Floats', + 'About The Tests', + 'Level 1', + 'Level 2', + 'Level 3', + 'Page with comments disabled', + 'Page with comments', + 'Lorem Ipsum' + ] + } +}; + +// Inspecting the titles of the returned pages arrays is an easy way to +// validate that the right page of results was returned +function getTitles( pages ) { + return pages.map(function( post ) { + return post.title.rendered; + }); +} + +describe( 'integration: pages()', function() { + var wp; + + beforeEach(function() { + wp = new WP({ + endpoint: 'http://wpapi.loc/wp-json' + }); + }); + + it( 'can be used to retrieve a list of recent pages', function() { + var prom = wp.pages().get().then(function( pages ) { + expect( pages ).to.be.an( 'array' ); + expect( pages.length ).to.equal( 10 ); + return SUCCESS; + }); + return expect( prom ).to.eventually.equal( SUCCESS ); + }); + + it( 'fetches the 10 most recent pages by default', function() { + var prom = wp.pages().get().then(function( pages ) { + expect( getTitles( pages ) ).to.deep.equal( expectedResults.titles.page1 ); + return SUCCESS; + }); + return expect( prom ).to.eventually.equal( SUCCESS ); + }); + + describe( 'paging properties', function() { + + it( 'are exposed as _paging on the response array', function() { + var prom = wp.pages().get().then(function( pages ) { + expect( pages ).to.have.property( '_paging' ); + expect( pages._paging ).to.be.an( 'object' ); + return SUCCESS; + }); + return expect( prom ).to.eventually.equal( SUCCESS ); + }); + + it( 'include the total number of pages', function() { + var prom = wp.pages().get().then(function( pages ) { + expect( pages._paging ).to.have.property( 'total' ); + expect( pages._paging.total ).to.equal( '18' ); + return SUCCESS; + }); + return expect( prom ).to.eventually.equal( SUCCESS ); + }); + + it( 'include the total number of pages available', function() { + var prom = wp.pages().get().then(function( pages ) { + expect( pages._paging ).to.have.property( 'totalPages' ); + expect( pages._paging.totalPages ).to.equal( '2' ); + return SUCCESS; + }); + return expect( prom ).to.eventually.equal( SUCCESS ); + }); + + it( 'provides a bound WPRequest for the next page as .next', function() { + var prom = wp.pages().get().then(function( pages ) { + expect( pages._paging ).to.have.property( 'next' ); + expect( pages._paging.next ).to.be.an( 'object' ); + expect( pages._paging.next ).to.be.an.instanceOf( WPRequest ); + expect( pages._paging.next._options.endpoint ).to + .equal( 'http://wpapi.loc/wp-json/wp/v2/pages?page=2' ); + // Get last page & ensure "next" no longer appears + return wp.pages().page( pages._paging.totalPages ).get().then(function( pages ) { + expect( pages._paging ).not.to.have.property( 'next' ); + expect( getTitles( pages ) ).to.deep.equal( expectedResults.titles.page2 ); + return SUCCESS; + }); + }); + return expect( prom ).to.eventually.equal( SUCCESS ); + }); + + it( 'allows access to the next page of results via .next', function() { + var prom = wp.pages().get().then(function( pages ) { + return pages._paging.next.get().then(function( pages ) { + expect( pages ).to.be.an( 'array' ); + expect( pages.length ).to.equal( 8 ); + expect( getTitles( pages ) ).to.deep.equal( expectedResults.titles.page2 ); + return SUCCESS; + }); + }); + return expect( prom ).to.eventually.equal( SUCCESS ); + }); + + it( 'provides a bound WPRequest for the previous page as .prev', function() { + var prom = wp.pages().get().then(function( pages ) { + expect( pages._paging ).not.to.have.property( 'prev' ); + return pages._paging.next.get().then(function( pages ) { + expect( pages._paging ).to.have.property( 'prev' ); + expect( pages._paging.prev ).to.be.an( 'object' ); + expect( pages._paging.prev ).to.be.an.instanceOf( WPRequest ); + expect( pages._paging.prev._options.endpoint ).to + .equal( 'http://wpapi.loc/wp-json/wp/v2/pages?page=1' ); + return SUCCESS; + }); + }); + return expect( prom ).to.eventually.equal( SUCCESS ); + }); + + it( 'allows access to the previous page of results via .prev', function() { + var prom = wp.pages().page( 2 ).get().then(function( pages ) { + expect( getTitles( pages ) ).to.deep.equal( expectedResults.titles.page2 ); + return pages._paging.prev.get().then(function( pages ) { + expect( pages ).to.be.an( 'array' ); + expect( pages.length ).to.equal( 10 ); + expect( getTitles( pages ) ).to.deep.equal( expectedResults.titles.page1 ); + return SUCCESS; + }); + }); + return expect( prom ).to.eventually.equal( SUCCESS ); + }); + + }); + + describe( 'filter methods', function() { + + describe( 'slug', function() { + + it( 'can be used to return only pages with the specified slug', function() { + var prom = wp.pages().slug( 'clearing-floats' ).get().then(function( pages ) { + expect( pages.length ).to.equal( 1 ); + expect( getTitles( pages ) ).to.deep.equal([ + 'Clearing Floats' + ]); + return SUCCESS; + }); + return expect( prom ).to.eventually.equal( SUCCESS ); + }); + + }); + + describe( 'path', function() { + + it( 'can be used to return only pages with the specified URL path', function() { + var prom = wp.pages().path( 'level-1/level-2/level-3a' ).get().then(function( pages ) { + expect( pages.length ).to.equal( 1 ); + expect( getTitles( pages ) ).to.deep.equal([ + 'Level 3a' + ]); + return SUCCESS; + }); + return expect( prom ).to.eventually.equal( SUCCESS ); + }); + + }); + + }); + +}); diff --git a/tests/integration/posts.js b/tests/integration/posts.js index 5fefa29f..b885991b 100644 --- a/tests/integration/posts.js +++ b/tests/integration/posts.js @@ -180,6 +180,21 @@ describe( 'integration: posts()', function() { describe( 'filter methods', function() { + describe( 'slug', function() { + + it( 'can be used to return only posts with the specified slug', function() { + var prom = wp.posts().slug( 'template-excerpt-generated' ).get().then(function( posts ) { + expect( posts.length ).to.equal( 1 ); + expect( getTitles( posts ) ).to.deep.equal([ + 'Template: Excerpt (Generated)' + ]); + return SUCCESS; + }); + return expect( prom ).to.eventually.equal( SUCCESS ); + }); + + }); + describe( 'tag', function() { it( 'can be used to return only posts with a provided tag', function() { diff --git a/tests/unit/lib/media.js b/tests/unit/lib/media.js index ff26dd2c..22a3f635 100644 --- a/tests/unit/lib/media.js +++ b/tests/unit/lib/media.js @@ -33,8 +33,6 @@ describe( 'wp.media', function() { }); it( 'should intitialize instance properties', function() { - expect( media._filters ).to.deep.equal( {} ); - expect( media._taxonomyFilters ).to.deep.equal( {} ); expect( media._path ).to.deep.equal( {} ); expect( media._params ).to.deep.equal( {} ); expect( media._template ).to.equal( 'media(/:id)' ); diff --git a/tests/unit/lib/mixins/filters.js b/tests/unit/lib/mixins/filters.js new file mode 100644 index 00000000..c5beb8c8 --- /dev/null +++ b/tests/unit/lib/mixins/filters.js @@ -0,0 +1,436 @@ +'use strict'; +var expect = require( 'chai' ).expect; + +var inherit = require( 'util' ).inherits; + +var filterMixins = require( '../../../../lib/mixins/filters' ); +var WPRequest = require( '../../../../lib/shared/wp-request' ); + +describe( 'mixins: filter', function() { + var Req; + var req; + var getQueryStr; + + beforeEach(function() { + Req = function() { + WPRequest.apply( this, arguments ); + }; + inherit( Req, WPRequest ); + + req = new Req(); + + getQueryStr = function( req ) { + var query = req + ._renderQuery() + .replace( /^\?/, '' ); + return decodeURIComponent( query ); + }; + }); + + describe( '.filter()', function() { + + beforeEach(function() { + Req.prototype.filter = filterMixins.filter; + }); + + it( 'mixin method is defined', function() { + expect( filterMixins ).to.have.property( 'filter' ); + }); + + it( 'is a function', function() { + expect( filterMixins.filter ).to.be.a( 'function' ); + }); + + it( 'supports chaining', function() { + expect( req.filter() ).to.equal( req ); + }); + + it( 'will nave no effect if called with no filter value', function() { + var result = req.filter( 'a' ); + expect( getQueryStr( result ) ).to.equal( '' ); + }); + + it( 'sets the filter query parameter on a request instance', function() { + var result = req.filter( 'a', 'b' ); + expect( getQueryStr( result ) ).to.equal( 'filter[a]=b' ); + }); + + it( 'can set multiple filters on the request', function() { + var result = req.filter( 'a', 'b' ).filter( 'c', 'd' ); + expect( getQueryStr( result ) ).to.equal( 'filter[a]=b&filter[c]=d' ); + }); + + it( 'will overwrite previously-set filter values', function() { + var result = req.filter( 'a', 'b' ).filter( 'a', 'd' ); + expect( getQueryStr( result ) ).to.equal( 'filter[a]=d' ); + }); + + it( 'will unset a filter if called with an empty string', function() { + var result = req.filter( 'a', 'b' ).filter( 'a', '' ); + expect( getQueryStr( result ) ).to.equal( '' ); + }); + + it( 'will unset a filter if called with null', function() { + var result = req.filter( 'a', 'b' ).filter( 'a', null ); + expect( getQueryStr( result ) ).to.equal( '' ); + }); + + it( 'can set multiple filters in one call when passed an object', function() { + var result = req.filter({ + a: 'b', + c: 'd', + e: 'f' + }); + expect( getQueryStr( result ) ).to.equal( 'filter[a]=b&filter[c]=d&filter[e]=f' ); + }); + + it( 'can set multiple filters on the request when passed an object', function() { + var result = req + .filter({ + a: 'b', + c: 'd' + }) + .filter({ + e: 'f' + }); + expect( getQueryStr( result ) ).to.equal( 'filter[a]=b&filter[c]=d&filter[e]=f' ); + }); + + it( 'will overwrite multiple previously-set filter values when passed an object', function() { + var result = req + .filter({ + a: 'b', + c: 'd', + e: 'f' + }) + .filter({ + a: 'g', + c: 'h', + i: 'j' + }); + expect( getQueryStr( result ) ).to.equal( 'filter[a]=g&filter[c]=h&filter[e]=f&filter[i]=j' ); + }); + + }); + + describe( 'taxonomy()', function() { + + beforeEach(function() { + Req.prototype.taxonomy = filterMixins.taxonomy; + }); + + it( 'mixin is defined', function() { + expect( filterMixins ).to.have.property( 'taxonomy' ); + }); + + it( 'is a function', function() { + expect( filterMixins.taxonomy ).to.be.a( 'function' ); + }); + + it( 'supports chaining', function() { + expect( req.taxonomy( 'tag', 'foo' ) ).to.equal( req ); + }); + + describe( 'argument type check errors', function() { + + it( 'errors if no term is provided', function() { + expect(function() { req.taxonomy( 'tag' ); }).to.throw(); + }); + + it( 'does not error if the term is a string', function() { + expect(function() { req.taxonomy( 'tag', 'cat' ); }).not.to.throw(); + }); + + it( 'does not error if the term is an array of strings', function() { + expect(function() { req.taxonomy( 'tag', [ 'cat', 'dog' ] ); }).not.to.throw(); + }); + + it( 'does not error if term is a number', function() { + expect(function() { req.taxonomy( 'cat', 7 ); }).not.to.throw(); + }); + + it( 'does not error if term is an array of numbers', function() { + expect(function() { req.taxonomy( 'cat', [ 7, 11 ] ); }).not.to.throw(); + }); + + it( 'errors if the term is null', function() { + expect(function() { req.taxonomy( 'tag', null ); }).to.throw(); + }); + + it( 'errors if the term is a boolean', function() { + expect(function() { req.taxonomy( 'tag', true ); }).to.throw(); + expect(function() { req.taxonomy( 'tag', false ); }).to.throw(); + }); + + it( 'errors if the term is a Date', function() { + expect(function() { req.taxonomy( 'tag', new Date() ); }).to.throw(); + }); + + it( 'errors if the term is an object', function() { + expect(function() { req.taxonomy( 'tag', {} ); }).to.throw(); + }); + + it( 'errors if the term is an array of types other than strings or numbers', function() { + expect(function() { req.taxonomy( 'tag', [ null ] ); }).to.throw(); + }); + + it( 'errors if the term is not all strings or numbers', function() { + expect(function() { req.taxonomy( 'tag', [ 'cat', null ] ); }).to.throw(); + expect(function() { req.taxonomy( 'cat', [ 7, null ] ); }).to.throw(); + expect(function() { req.taxonomy( 'cat', [ 'foo', 7 ] ); }).to.throw(); + }); + + }); + + describe( 'filter name aliasing behavior', function() { + + it( 'sets the "category_name" filter for categories where the term is a string', function() { + var result = req.taxonomy( 'category', 'str' ); + expect( getQueryStr( result ) ).to.equal( 'filter[category_name]=str' ); + }); + + it( 'sets the "cat" filter for categories where the term is a number', function() { + var result = req.taxonomy( 'category', 7 ); + expect( getQueryStr( result ) ).to.equal( 'filter[cat]=7' ); + }); + + it( 'sets the "tag" filter if the taxonomy is "post_tag"', function() { + var result = req.taxonomy( 'post_tag', 'sometag' ); + expect( getQueryStr( result ) ).to.equal( 'filter[tag]=sometag' ); + }); + + }); + + describe( 'filter value setting behavior', function() { + + it( 'de-duplicates taxonomy terms (will only set a term once)', function() { + var result = req.taxonomy( 'tag', 'cat' ).taxonomy( 'tag', 'cat' ); + expect( getQueryStr( result ) ).to.equal( 'filter[tag]=cat' ); + }); + + it( 'supports setting an array of string terms', function() { + // TODO: Multiple terms may be deprecated by API! + var result = req.taxonomy( 'tag', [ 'a', 'b' ] ); + expect( getQueryStr( result ) ).to.equal( 'filter[tag]=a+b' ); + }); + + it( 'supports setting an array of numeric terms', function() { + // TODO: Multiple terms may be deprecated by API! + var result = req.taxonomy( 'tag', [ 1, 2 ] ); + expect( getQueryStr( result ) ).to.equal( 'filter[tag]=1+2' ); + }); + + it( 'does not overwrite previously-specified terms on subsequent calls', function() { + // TODO: Multiple terms may be deprecated by API! + var result = req.taxonomy( 'tag', 'a' ).taxonomy( 'tag', [ 'b' ] ); + expect( getQueryStr( result ) ).to.equal( 'filter[tag]=a+b' ); + }); + + it( 'sorts provided terms', function() { + var result = req.taxonomy( 'tag', 'z' ).taxonomy( 'tag', 'a' ); + expect( getQueryStr( result ) ).to.equal( 'filter[tag]=a+z' ); + }); + + }); + + }); + + describe( 'category()', function() { + + beforeEach(function() { + // By only applying .category and not .taxonomy, we implicitly test that + // .category does not depend on the .taxonomy mixin having been added + Req.prototype.category = filterMixins.category; + }); + + it( 'mixin is defined', function() { + expect( filterMixins ).to.have.property( 'category' ); + }); + + it( 'is a function', function() { + expect( filterMixins.category ).to.be.a( 'function' ); + }); + + it( 'supports chaining', function() { + expect( req.category( 'foo' ) ).to.equal( req ); + }); + + it( 'is an alias for .taxonomy( "category", ... )', function() { + Req.prototype.taxonomy = filterMixins.taxonomy; + [ 'str', 7, [ 'a', 'b' ] ].forEach(function( term ) { + var result1 = ( new Req() ).category( term ); + var result2 = ( new Req() ).taxonomy( 'category', term ); + expect( getQueryStr( result1 ) ).to.equal( getQueryStr( result2 ) ); + }); + }); + + it( 'sets the "category_name" filter for categories where the term is a string', function() { + var result = req.category( 'str' ); + expect( getQueryStr( result ) ).to.equal( 'filter[category_name]=str' ); + }); + + it( 'sets the "cat" filter for categories where the term is a number', function() { + var result = req.category( 7 ); + expect( getQueryStr( result ) ).to.equal( 'filter[cat]=7' ); + }); + + }); + + describe( 'tag()', function() { + + beforeEach(function() { + // By only applying .tag and not .taxonomy, we implicitly test that + // .tag does not depend on the .taxonomy mixin having been added + Req.prototype.tag = filterMixins.tag; + }); + + it( 'mixin is defined', function() { + expect( filterMixins ).to.have.property( 'tag' ); + }); + + it( 'is a function', function() { + expect( filterMixins.tag ).to.be.a( 'function' ); + }); + + it( 'supports chaining', function() { + expect( req.tag( 'foo' ) ).to.equal( req ); + }); + + it( 'is an alias for .taxonomy( "tag", ... )', function() { + Req.prototype.taxonomy = filterMixins.taxonomy; + [ 'str', 7, [ 'a', 'b' ] ].forEach(function( term ) { + var result1 = ( new Req() ).tag( term ); + var result2 = ( new Req() ).taxonomy( 'tag', term ); + expect( getQueryStr( result1 ) ).to.equal( getQueryStr( result2 ) ); + }); + }); + + it( 'sets the "tag" filter', function() { + var result = req.tag( 'str' ); + expect( getQueryStr( result ) ).to.equal( 'filter[tag]=str' ); + }); + + }); + + describe( 'date filters', function() { + + describe( 'year()', function() { + + beforeEach(function() { + // By only applying .year and not .filter, we implicitly test that + // .year does not depend on the .filter mixin having been added + Req.prototype.year = filterMixins.year; + }); + + it( 'mixin is defined', function() { + expect( filterMixins ).to.have.property( 'year' ); + }); + + it( 'is a function', function() { + expect( filterMixins.year ).to.be.a( 'function' ); + }); + + it( 'supports chaining', function() { + expect( req.year( 'foo' ) ).to.equal( req ); + }); + + it( 'is an alias for .filter( "year", ... )', function() { + Req.prototype.filter = filterMixins.filter; + var result1 = ( new Req() ).year( '2015' ); + var result2 = ( new Req() ).filter( 'year', '2015' ); + expect( getQueryStr( result1 ) ).to.equal( getQueryStr( result2 ) ); + }); + + it( 'sets the "year" filter', function() { + var result = req.year( 'str' ); + expect( getQueryStr( result ) ).to.equal( 'filter[year]=str' ); + }); + + }); + + describe( 'month()', function() { + + beforeEach(function() { + // By only applying .month and not .filter, we implicitly test that + // .month does not depend on the .filter mixin having been added + Req.prototype.month = filterMixins.month; + }); + + it( 'mixin is defined', function() { + expect( filterMixins ).to.have.property( 'month' ); + }); + + it( 'is a function', function() { + expect( filterMixins.month ).to.be.a( 'function' ); + }); + + it( 'supports chaining', function() { + expect( req.month( 'foo' ) ).to.equal( req ); + }); + + it( 'is an alias for .filter( "monthnum", ... )', function() { + Req.prototype.filter = filterMixins.filter; + var result1 = ( new Req() ).month( 7 ); + var result2 = ( new Req() ).filter( 'monthnum', 7 ); + expect( getQueryStr( result1 ) ).to.equal( getQueryStr( result2 ) ); + }); + + it( 'sets the "monthnum" filter', function() { + var result = req.month( 1 ); + expect( getQueryStr( result ) ).to.equal( 'filter[monthnum]=1' ); + }); + + it( 'converts named months into their numeric monthnum equivalent', function() { + var result = req.month( 'March' ); + expect( getQueryStr( result ) ).to.equal( 'filter[monthnum]=3' ); + }); + + it( 'returns without setting any filter if an invalid month string is provided', function() { + var result = req.month( 'Not a month' )._renderURI(); + expect( result.match( /filter/ ) ).to.equal( null ); + }); + + it( 'returns without setting any filter if an invalid argument is provided', function() { + var result = req.month( [ 'arrrr', 'i', 'be', 'an', 'array!' ] )._renderURI(); + expect( result.match( /filter/ ) ).to.equal( null ); + }); + + }); + + describe( 'day()', function() { + + beforeEach(function() { + // By only applying .day and not .filter, we implicitly test that + // .day does not depend on the .filter mixin having been added + Req.prototype.day = filterMixins.day; + }); + + it( 'mixin is defined', function() { + expect( filterMixins ).to.have.property( 'day' ); + }); + + it( 'is a function', function() { + expect( filterMixins.day ).to.be.a( 'function' ); + }); + + it( 'supports chaining', function() { + expect( req.day( 'foo' ) ).to.equal( req ); + }); + + it( 'is an alias for .filter( "day", ... )', function() { + Req.prototype.filter = filterMixins.filter; + var result1 = ( new Req() ).day( '2015' ); + var result2 = ( new Req() ).filter( 'day', '2015' ); + expect( getQueryStr( result1 ) ).to.equal( getQueryStr( result2 ) ); + }); + + it( 'sets the "day" filter', function() { + var result = req.day( 'str' ); + expect( getQueryStr( result ) ).to.equal( 'filter[day]=str' ); + }); + + }); + + }); + +}); diff --git a/tests/unit/lib/mixins/parameters.js b/tests/unit/lib/mixins/parameters.js new file mode 100644 index 00000000..49a822ec --- /dev/null +++ b/tests/unit/lib/mixins/parameters.js @@ -0,0 +1,330 @@ +'use strict'; +var expect = require( 'chai' ).expect; + +var inherit = require( 'util' ).inherits; + +var parameterMixins = require( '../../../../lib/mixins/parameters' ); +var WPRequest = require( '../../../../lib/shared/wp-request' ); + +describe( 'mixins: parameters', function() { + var Req; + var req; + var getQueryStr; + + beforeEach(function() { + Req = function() { + WPRequest.apply( this, arguments ); + }; + inherit( Req, WPRequest ); + + req = new Req(); + + getQueryStr = function( req ) { + var query = req + ._renderQuery() + .replace( /^\?/, '' ); + return decodeURIComponent( query ); + }; + }); + + describe( 'pagination parameters', function() { + + describe( '.page()', function() { + + beforeEach(function() { + Req.prototype.page = parameterMixins.page; + }); + + it( 'mixin method is defined', function() { + expect( parameterMixins ).to.have.property( 'page' ); + }); + + it( 'is a function', function() { + expect( parameterMixins.page ).to.be.a( 'function' ); + }); + + it( 'supports chaining', function() { + expect( req.page() ).to.equal( req ); + }); + + it( 'has no effect when called with no argument', function() { + var result = req.page(); + expect( getQueryStr( result ) ).to.equal( '' ); + }); + + it( 'sets the "page" query parameter when provided a value', function() { + var result = req.page( 7 ); + expect( getQueryStr( result ) ).to.equal( 'page=7' ); + }); + + }); + + describe( '.perPage()', function() { + + beforeEach(function() { + Req.prototype.perPage = parameterMixins.perPage; + }); + + it( 'mixin method is defined', function() { + expect( parameterMixins ).to.have.property( 'perPage' ); + }); + + it( 'is a function', function() { + expect( parameterMixins.perPage ).to.be.a( 'function' ); + }); + + it( 'supports chaining', function() { + expect( req.perPage() ).to.equal( req ); + }); + + it( 'has no effect when called with no argument', function() { + var result = req.perPage(); + expect( getQueryStr( result ) ).to.equal( '' ); + }); + + it( 'sets the "per_page" query parameter when provided a value', function() { + var result = req.perPage( 7 ); + expect( getQueryStr( result ) ).to.equal( 'per_page=7' ); + }); + + }); + + }); + + describe( 'name parameters', function() { + + describe( '.slug()', function() { + + beforeEach(function() { + Req.prototype.slug = parameterMixins.slug; + }); + + it( 'mixin method is defined', function() { + expect( parameterMixins ).to.have.property( 'slug' ); + }); + + it( 'is a function', function() { + expect( parameterMixins.slug ).to.be.a( 'function' ); + }); + + it( 'supports chaining', function() { + expect( req.slug() ).to.equal( req ); + }); + + it( 'has no effect when called with no argument', function() { + var result = req.slug(); + expect( getQueryStr( result ) ).to.equal( '' ); + }); + + it( 'sets the "slug" query parameter when provided a value', function() { + var result = req.slug( 'my-slug' ); + expect( getQueryStr( result ) ).to.equal( 'slug=my-slug' ); + }); + + }); + + describe( '.name()', function() { + + beforeEach(function() { + Req.prototype.name = parameterMixins.name; + }); + + it( 'mixin method is defined', function() { + expect( parameterMixins ).to.have.property( 'name' ); + }); + + it( 'is a function', function() { + expect( parameterMixins.name ).to.be.a( 'function' ); + }); + + it( 'supports chaining', function() { + expect( req.name() ).to.equal( req ); + }); + + it( 'has no effect when called with no argument', function() { + var result = req.name(); + expect( getQueryStr( result ) ).to.equal( '' ); + }); + + it( 'is equivalent to .slug', function() { + Req.prototype.slug = parameterMixins.slug; + var result1 = ( new Req() ).name( 'myname' ); + var result2 = ( new Req() ).slug( 'myname' ); + expect( getQueryStr( result1 ) ).to.equal( getQueryStr( result2 ) ); + }); + + it( 'sets the "slug" query parameter when provided a value', function() { + var result = req.name( 7 ); + expect( getQueryStr( result ) ).to.equal( 'slug=7' ); + }); + + }); + + }); + + describe( 'search', function() { + + beforeEach(function() { + Req.prototype.search = parameterMixins.search; + }); + + it( 'mixin method is defined', function() { + expect( parameterMixins ).to.have.property( 'search' ); + }); + + it( 'is a function', function() { + expect( parameterMixins.search ).to.be.a( 'function' ); + }); + + it( 'supports chaining', function() { + expect( req.search() ).to.equal( req ); + }); + + it( 'has no effect when called with no argument', function() { + var result = req.search(); + expect( getQueryStr( result ) ).to.equal( '' ); + }); + + it( 'sets the "search" query parameter when provided a value', function() { + var result = req.search( 'my search string' ); + expect( getQueryStr( result ) ).to.equal( 'search=my search string' ); + }); + + it( 'overwrites previously-set values on subsequent calls', function() { + var result = req.search( 'query' ).search( 'newquery' ); + expect( getQueryStr( result ) ).to.equal( 'search=newquery' ); + }); + + }); + + describe( 'author', function() { + + beforeEach(function() { + Req.prototype.author = parameterMixins.author; + }); + + it( 'mixin method is defined', function() { + expect( parameterMixins ).to.have.property( 'author' ); + }); + + it( 'is a function', function() { + expect( parameterMixins.author ).to.be.a( 'function' ); + }); + + it( 'supports chaining', function() { + expect( req.author( 1 ) ).to.equal( req ); + }); + + it( 'has no effect when called with no argument', function() { + var result = req.author(); + expect( getQueryStr( result ) ).to.equal( '' ); + }); + + it( 'throws an error when called with a non-string, non-numeric value', function() { + expect(function() { req.author({}); }).to.throw(); + }); + + it( 'sets the "author" query parameter when provided a numeric value', function() { + var result = req.author( 1138 ); + expect( getQueryStr( result ) ).to.equal( 'author=1138' ); + }); + + it( 'sets the "author_name" filter when provided a string value', function() { + var result = req.author( 'han-solo' ); + expect( getQueryStr( result ) ).to.equal( 'filter[author_name]=han-solo' ); + }); + + it( 'unsets author when called with an empty string', function() { + var result = req.author( 'jorge-luis-borges' ).author( '' ); + expect( getQueryStr( result ) ).to.equal( '' ); + }); + + it( 'unsets author when called with null', function() { + var result = req.author( 7 ).author( null ); + expect( getQueryStr( result ) ).to.equal( '' ); + }); + + it( 'unsets author parameter when called with author name string', function() { + var result = req.author( 7 ).author( 'haruki-murakami' ); + expect( getQueryStr( result ) ).to.equal( 'filter[author_name]=haruki-murakami' ); + }); + + it( 'unsets author name filter when called with numeric author id', function() { + var result = req.author( 'haruki-murakami' ).author( 7 ); + expect( getQueryStr( result ) ).to.equal( 'author=7' ); + }); + + }); + + describe( 'parent', function() { + + beforeEach(function() { + Req.prototype.parent = parameterMixins.parent; + }); + + it( 'mixin method is defined', function() { + expect( parameterMixins ).to.have.property( 'parent' ); + }); + + it( 'is a function', function() { + expect( parameterMixins.parent ).to.be.a( 'function' ); + }); + + it( 'supports chaining', function() { + expect( req.parent() ).to.equal( req ); + }); + + it( 'has no effect when called with no argument', function() { + var result = req.parent(); + expect( getQueryStr( result ) ).to.equal( '' ); + }); + + it( 'sets the "parent" query parameter when provided a value', function() { + var result = req.parent( 42 ); + expect( getQueryStr( result ) ).to.equal( 'parent=42' ); + }); + + it( 'merges values on subsequent calls', function() { + // TODO: Is this how the API actually functions? + var result = req.parent( 42 ).parent( 2501 ); + expect( getQueryStr( result ) ).to.equal( 'parent[]=2501&parent[]=42' ); + }); + + }); + + describe( 'forPost', function() { + + beforeEach(function() { + Req.prototype.forPost = parameterMixins.forPost; + }); + + it( 'mixin method is defined', function() { + expect( parameterMixins ).to.have.property( 'forPost' ); + }); + + it( 'is a function', function() { + expect( parameterMixins.forPost ).to.be.a( 'function' ); + }); + + it( 'supports chaining', function() { + expect( req.forPost() ).to.equal( req ); + }); + + it( 'has no effect when called with no argument', function() { + var result = req.forPost(); + expect( getQueryStr( result ) ).to.equal( '' ); + }); + + it( 'sets the "post" query parameter when provided a value', function() { + var result = req.forPost( 3263827 ); + expect( getQueryStr( result ) ).to.equal( 'post=3263827' ); + }); + + it( 'overwrites previously-set values on subsequent calls', function() { + var result = req.forPost( 1138 ).forPost( 2501 ); + expect( getQueryStr( result ) ).to.equal( 'post=2501' ); + }); + + }); + +}); diff --git a/tests/unit/lib/pages.js b/tests/unit/lib/pages.js index fb7916f0..625137dd 100644 --- a/tests/unit/lib/pages.js +++ b/tests/unit/lib/pages.js @@ -33,8 +33,6 @@ describe( 'wp.pages', function() { }); it( 'should intitialize instance properties', function() { - expect( pages._filters ).to.deep.equal( {} ); - expect( pages._taxonomyFilters ).to.deep.equal( {} ); expect( pages._path ).to.deep.equal( {} ); expect( pages._params ).to.deep.equal( {} ); expect( pages._template ).to.equal( 'pages(/:id)(/:action)(/:commentId)' ); diff --git a/tests/unit/lib/posts.js b/tests/unit/lib/posts.js index f98ac415..cd431078 100644 --- a/tests/unit/lib/posts.js +++ b/tests/unit/lib/posts.js @@ -33,8 +33,6 @@ describe( 'wp.posts', function() { }); it( 'should intitialize instance properties', function() { - expect( posts._filters ).to.deep.equal( {} ); - expect( posts._taxonomyFilters ).to.deep.equal( {} ); expect( posts._path ).to.deep.equal( {} ); expect( posts._template ).to.equal( 'posts(/:id)(/:action)(/:actionId)' ); var _supportedMethods = posts._supportedMethods.sort().join( '|' ); diff --git a/tests/unit/lib/shared/collection-request.js b/tests/unit/lib/shared/collection-request.js index ee291401..2180ca1e 100644 --- a/tests/unit/lib/shared/collection-request.js +++ b/tests/unit/lib/shared/collection-request.js @@ -7,6 +7,7 @@ chai.use( require( 'sinon-chai' ) ); var CollectionRequest = require( '../../../../lib/shared/collection-request' ); var WPRequest = require( '../../../../lib/shared/wp-request' ); +var filterMixins = require( '../../../../lib/mixins/filters' ); describe( 'CollectionRequest', function() { @@ -302,11 +303,11 @@ describe( 'CollectionRequest', function() { describe( 'category()', function() { - it( 'delegates to taxonomy()', function() { - sinon.stub( request, 'taxonomy' ); + it( 'delegates to taxonomy() mixin', function() { + sinon.stub( filterMixins, 'taxonomy' ); request.category( 'news' ); - expect( request.taxonomy ).to.have.been.calledWith( 'category', 'news' ); - request.taxonomy.restore(); + expect( filterMixins.taxonomy ).to.have.been.calledWith( 'category', 'news' ); + filterMixins.taxonomy.restore(); }); it( 'should be chainable, and accumulates values', function() { @@ -320,11 +321,11 @@ describe( 'CollectionRequest', function() { describe( 'tag()', function() { - it( 'delegates to taxonomy()', function() { - sinon.stub( request, 'taxonomy' ); + it( 'delegates to taxonomy() mixin', function() { + sinon.stub( filterMixins, 'taxonomy' ); request.tag( 'the-good-life' ); - expect( request.taxonomy ).to.have.been.calledWith( 'tag', 'the-good-life' ); - request.taxonomy.restore(); + expect( filterMixins.taxonomy ).to.have.been.calledWith( 'tag', 'the-good-life' ); + filterMixins.taxonomy.restore(); }); it( 'should be chainable, and accumulates values', function() { diff --git a/tests/unit/lib/shared/wp-request.js b/tests/unit/lib/shared/wp-request.js index 53e52cce..8dc23578 100644 --- a/tests/unit/lib/shared/wp-request.js +++ b/tests/unit/lib/shared/wp-request.js @@ -98,6 +98,11 @@ describe( 'WPRequest', function() { expect( request.param ).to.be.a( 'function' ); }); + it( 'will have no effect if called without any arguments', function() { + request.param(); + expect( request._renderQuery() ).to.equal( '' ); + }); + it( 'will set a query parameter value', function() { request.param( 'key', 'value' ); expect( request._renderQuery() ).to.equal( '?key=value' ); @@ -119,6 +124,16 @@ describe( 'WPRequest', function() { expect( request._renderQuery() ).to.equal( '' ); }); + it( 'will have no effect if called with no value', function() { + request.param( 'key' ); + expect( request._renderQuery() ).to.equal( '' ); + }); + + it( 'will have no effect if called with an empty object', function() { + request.param({}); + expect( request._renderQuery() ).to.equal( '' ); + }); + it( 'should set the internal _params hash', function() { request.param( 'type', 'some_cpt' ); expect( request._renderQuery() ).to.equal( '?type=some_cpt' ); diff --git a/tests/unit/lib/taxonomies.js b/tests/unit/lib/taxonomies.js index 2eb5f0c9..5e836e07 100644 --- a/tests/unit/lib/taxonomies.js +++ b/tests/unit/lib/taxonomies.js @@ -34,7 +34,6 @@ describe( 'wp.taxonomies', function() { it( 'should intitialize instance properties', function() { var _supportedMethods = taxonomies._supportedMethods.sort().join( '|' ); - expect( taxonomies._filters ).to.deep.equal( {} ); expect( taxonomies._path ).to.deep.equal({ collection: 'taxonomies' }); expect( taxonomies._params ).to.deep.equal( {} ); expect( taxonomies._template ).to.equal( '(:collection)(/:term)' ); diff --git a/tests/unit/lib/types.js b/tests/unit/lib/types.js index 6345512c..e717ed6d 100644 --- a/tests/unit/lib/types.js +++ b/tests/unit/lib/types.js @@ -34,7 +34,6 @@ describe( 'wp.types', function() { it( 'should intitialize instance properties', function() { var _supportedMethods = types._supportedMethods.sort().join( '|' ); - expect( types._filters ).to.deep.equal( {} ); expect( types._path ).to.deep.equal( {} ); expect( types._params ).to.deep.equal( {} ); expect( types._template ).to.equal( 'types(/:type)' ); diff --git a/tests/unit/lib/users.js b/tests/unit/lib/users.js index 0f72b7e0..c7337e07 100644 --- a/tests/unit/lib/users.js +++ b/tests/unit/lib/users.js @@ -38,7 +38,6 @@ describe( 'wp.users', function() { }); it( 'should initialize instance properties', function() { - expect( users._filters ).to.deep.equal( {} ); expect( users._path ).to.deep.equal( {} ); expect( users._params ).to.deep.equal( {} ); var _supportedMethods = users._supportedMethods.sort().join( '|' );