diff --git a/client/lib/query-manager/theme/index.js b/client/lib/query-manager/theme/index.js
index dcfaedb17f59d8..df7368c5d58610 100644
--- a/client/lib/query-manager/theme/index.js
+++ b/client/lib/query-manager/theme/index.js
@@ -1,16 +1,128 @@
+/**
+ * External dependencies
+ */
+import { cloneDeep, get, isEqual, keyBy, range } from 'lodash';
+
/**
* Internal dependencies
*/
import PaginatedQueryManager from '../paginated';
import ThemeQueryKey from './key';
-import { isThemeMatchingQuery } from './util';
import { DEFAULT_THEME_QUERY } from './constants';
/**
* ThemeQueryManager manages themes which can be queried
*/
export default class ThemeQueryManager extends PaginatedQueryManager {
- matches = isThemeMatchingQuery;
+ /**
+ * Signal that an item(s) has been received for tracking. Optionally
+ * specify that items received are intended for patch application, or that
+ * they are associated with a query. This function does not mutate the
+ * instance state. Instead, it returns a new instance of ThemeQueryManager if
+ * the tracked items have been modified, or the current instance otherwise.
+ *
+ * Note that we implement our own receive() method instead of just relying on
+ * that of the base class. We choose to override the base class's receive()
+ * so the results are kept in the order they are received from the endpoint.
+ * The themes query REST API endpoint uses ElasticSearch to sort results by
+ * relevancy, which we cannot easily mimick on the client side.
+ *
+ * @param {(Array|Object)} items Item(s) to be received
+ * @param {Object} options Options for receive
+ * @param {Boolean} options.patch Apply changes as partial
+ * @param {Object} options.query Query set to set or replace
+ * @param {Boolean} options.mergeQuery Add to existing query set
+ * @param {Number} options.found Total found items for query
+ * @return {QueryManager} New instance if changed, or
+ * same instance otherwise
+ */
+ receive( items, options = {} ) {
+ // Create the updated manager based on this instance, appending the newly received items
+ const nextManager = new this.constructor(
+ {
+ ...this.data,
+ items: {
+ ...this.data.items,
+ ...keyBy( items, this.options.itemKey )
+ },
+ queries: this.data.queries
+ },
+ this.options
+ );
+
+ // If manager is the same instance, assume no changes have been made
+ if ( this === nextManager ) {
+ return nextManager;
+ }
+
+ // If no query was passed, return the QueryManager with only the new items appended
+ if ( ! options.query ) {
+ return nextManager;
+ }
+
+ // If we're already storing the query and associated items, return this instance.
+ if ( isEqual( super.getItems( options.query ), items ) ) {
+ return this;
+ }
+
+ const queryKey = this.constructor.QueryKey.stringify( options.query );
+ const page = options.query.page || this.constructor.DEFAULT_QUERY.page;
+ const perPage = options.query.number || this.constructor.DEFAULT_QUERY.number;
+ const startOffset = ( page - 1 ) * perPage;
+ const nextQuery = get( this.data.queries, queryKey, { itemKeys: [], found: options.found } );
+
+ // Coerce received single item to array
+ if ( ! Array.isArray( items ) ) {
+ items = [ items ];
+ }
+
+ // If the item set for the queried page is identical, there are no
+ // updates to be made
+ const pageItemKeys = items.map( ( item ) => item[ this.options.itemKey ] );
+
+ // If we've reached this point, we know that we've received a paged
+ // set of data where our assumed item set is incorrect.
+ const modifiedNextQuery = cloneDeep( nextQuery );
+
+ // Found count is not always reliable, usually in consideration of user
+ // capabilities. If we receive a set of items with a count not matching
+ // the expected number for the query, we recalculate the found value to
+ // reflect that this is the last set we can expect to receive. Found is
+ // correct only if the count of items matches expected query number.
+ if ( modifiedNextQuery.hasOwnProperty( 'found' ) && perPage !== items.length ) {
+ // Otherwise, found count should be corrected to equal the number
+ // of items received added to the summed per page total. Note that
+ // we can reach this point if receiving the last page of items, but
+ // the updated value should still be correct given this logic.
+ modifiedNextQuery.found = ( ( page - 1 ) * perPage ) + items.length;
+ }
+
+ // If found is known from options, ensure that we fill the end of the
+ // array with undefined entries until found count
+ if ( modifiedNextQuery.hasOwnProperty( 'found' ) ) {
+ modifiedNextQuery.itemKeys = range( 0, modifiedNextQuery.found ).map( ( index ) => {
+ return modifiedNextQuery.itemKeys[ index ];
+ } );
+ }
+
+ // Splice results into their proper place
+ modifiedNextQuery.itemKeys.splice( startOffset, perPage, ...pageItemKeys );
+
+ return new this.constructor(
+ {
+ ...this.data,
+ items: {
+ ...this.data.items,
+ ...keyBy( items, this.options.itemKey )
+ },
+ queries: {
+ ...this.data.queries,
+ [ queryKey ]: modifiedNextQuery
+ }
+ },
+ this.options
+ );
+ }
}
ThemeQueryManager.QueryKey = ThemeQueryKey;
diff --git a/client/lib/query-manager/theme/test/index.js b/client/lib/query-manager/theme/test/index.js
index dbda31783cc1bb..40090c78e36d3e 100644
--- a/client/lib/query-manager/theme/test/index.js
+++ b/client/lib/query-manager/theme/test/index.js
@@ -1,222 +1,17 @@
/**
* External dependencies
*/
-import { expect } from 'chai';
+//import { expect } from 'chai';
/**
* Internal dependencies
*/
-import ThemeQueryManager from '../';
+//import ThemeQueryManager from '../';
/**
* Constants
*/
-const DEFAULT_THEME = {
- name: 'Twenty Something',
- author: 'the WordPress team',
- screenshot: 'https://i0.wp.com/theme.wordpress.com/wp-content/themes/pub/twentysomething/screenshot.png',
- screenshots: [ 'https://i0.wp.com/theme.files.wordpress.com/2015/12/twentysomething-featured-image.jpg?ssl=1' ],
- stylesheet: 'pub/twentysomething',
- taxonomies: {
- theme_subject: [
- {
- name: 'Blog',
- slug: 'blog',
- term_id: '273'
- },
- {
- name: 'Lifestream',
- slug: 'lifestream',
- term_id: '652270'
- },
- {
- name: 'Journal',
- slug: 'journal',
- term_id: '96'
- }
- ],
- theme_color: [
- {
- name: 'Black',
- slug: 'black',
- term_id: '59007'
- },
- {
- name: 'Blue',
- slug: 'blue',
- term_id: '9150'
- },
- {
- name: 'Gray',
- slug: 'gray',
- term_id: '147520'
- }
- ]
- },
- demo_uri: 'https://twentysomethingdemo.wordpress.com/',
- descriptionLong: 'The annual WordPress theme for this year is a modern take on an ever-popular layout. ' +
- 'The horizontal header area with an optional right sidebar works perfectly for both blogs and websites.',
- description: 'This is a modernized take on an ever-popular WordPress layout' +
- ' — the horizontal masthead with an optional right sidebar that works perfectly for blogs and websites.'
-};
describe( 'ThemeQueryManager', () => {
- let manager;
- beforeEach( () => {
- manager = new ThemeQueryManager();
- } );
-
- describe( '#matches()', () => {
- context( 'query.search', () => {
- it( 'should return false for a non-matching search', () => {
- const isMatch = manager.matches( {
- search: 'nonexisting'
- }, DEFAULT_THEME );
-
- expect( isMatch ).to.be.false;
- } );
-
- it( 'should return true for a matching title search', () => {
- const isMatch = manager.matches( {
- search: 'Twenty'
- }, DEFAULT_THEME );
-
- expect( isMatch ).to.be.true;
- } );
-
- it( 'should return true for a falsey title search', () => {
- const isMatch = manager.matches( {
- search: null
- }, DEFAULT_THEME );
-
- expect( isMatch ).to.be.true;
- } );
-
- it( 'should return true for a matching content search', () => {
- const isMatch = manager.matches( {
- search: 'modern'
- }, DEFAULT_THEME );
-
- expect( isMatch ).to.be.true;
- } );
-
- it( 'should return true for a matching author search', () => {
- const isMatch = manager.matches( {
- search: 'team'
- }, DEFAULT_THEME );
-
- expect( isMatch ).to.be.true;
- } );
-
- it( 'should return true for a matching filter search', () => {
- const isMatch = manager.matches( {
- search: 'journal'
- }, DEFAULT_THEME );
-
- expect( isMatch ).to.be.true;
- } );
-
- it( 'should search case-insensitive', () => {
- const isMatch = manager.matches( {
- search: 'Sidebar'
- }, DEFAULT_THEME );
-
- expect( isMatch ).to.be.true;
- } );
-
- it( 'should separately test title and content fields', () => {
- const isMatch = manager.matches( {
- search: 'TwentyThe'
- }, DEFAULT_THEME );
-
- expect( isMatch ).to.be.false;
- } );
- } );
-
- context( 'query.filters', () => {
- it( 'should return false if theme does not include filter', () => {
- const isMatch = manager.matches( {
- filters: 'nosuchfilter'
- }, DEFAULT_THEME );
-
- expect( isMatch ).to.be.false;
- } );
-
- it( 'should return false on a partial match', () => {
- const isMatch = manager.matches( {
- filters: 'ourna'
- }, DEFAULT_THEME );
-
- expect( isMatch ).to.be.false;
- } );
-
- it( 'should return true if theme includes filter', () => {
- const isMatch = manager.matches( {
- filters: 'journal'
- }, DEFAULT_THEME );
-
- expect( isMatch ).to.be.true;
- } );
-
- context( 'with multiple filters from a single taxonomy', () => {
- it( 'should return false if theme doesn\'t match all filters', () => {
- const isMatch = manager.matches( {
- filters: 'journal,business'
- }, DEFAULT_THEME );
-
- expect( isMatch ).to.be.false;
- } );
- it( 'should return true if theme matches all filters', () => {
- const isMatch = manager.matches( {
- filters: 'journal,blog'
- }, DEFAULT_THEME );
-
- expect( isMatch ).to.be.true;
- } );
- } );
-
- context( 'with multiple filters from different taxonomies', () => {
- it( 'should return false if theme doesn\'t match all filters', () => {
- const isMatch = manager.matches( {
- filters: 'journal,green'
- }, DEFAULT_THEME );
-
- expect( isMatch ).to.be.false;
- } );
- it( 'should return true if theme matches all filters', () => {
- const isMatch = manager.matches( {
- filters: 'journal,black'
- }, DEFAULT_THEME );
-
- expect( isMatch ).to.be.true;
- } );
- } );
- } );
-
- context( 'query.tier', () => {
- it( 'should return true for a free theme when querying for all themes', () => {
- const isMatch = manager.matches( {
- tier: ''
- }, DEFAULT_THEME );
-
- expect( isMatch ).to.be.true;
- } );
-
- it( 'should return true for a free theme when querying for free themes', () => {
- const isMatch = manager.matches( {
- tier: 'free'
- }, DEFAULT_THEME );
-
- expect( isMatch ).to.be.true;
- } );
-
- it( 'should return false for a free theme when querying for premium themes', () => {
- const isMatch = manager.matches( {
- tier: 'premium'
- }, DEFAULT_THEME );
-
- expect( isMatch ).to.be.false;
- } );
- } );
- } );
+ // TODO
} );
diff --git a/client/lib/query-manager/theme/test/util.js b/client/lib/query-manager/theme/test/util.js
deleted file mode 100644
index b82ae0c5aeb5f9..00000000000000
--- a/client/lib/query-manager/theme/test/util.js
+++ /dev/null
@@ -1,41 +0,0 @@
-/**
- * External dependencies
- */
-import { expect } from 'chai';
-
-/**
- * Internal dependencies
- */
-import { isPremium } from '../util';
-
-describe( 'utils', () => {
- describe( '#isPremium()', () => {
- it( 'given no theme object, should return false', () => {
- const premium = isPremium();
- expect( premium ).to.be.false;
- } );
-
- it( 'given a theme object with no stylesheet attr, should return false', () => {
- const premium = isPremium( {
- id: 'twentysixteen'
- } );
- expect( premium ).to.be.false;
- } );
-
- it( 'given a theme object with a stylesheet attr that doesn\'t start with "premium/", should return false', () => {
- const premium = isPremium( {
- id: 'twentysixteen',
- stylesheet: 'pub/twentysixteen'
- } );
- expect( premium ).to.be.false;
- } );
-
- it( 'given a theme object with a stylesheet attr that starts with "premium/", should return true', () => {
- const premium = isPremium( {
- id: 'mood',
- stylesheet: 'premium/mood'
- } );
- expect( premium ).to.be.true;
- } );
- } );
-} );
diff --git a/client/lib/query-manager/theme/util.js b/client/lib/query-manager/theme/util.js
deleted file mode 100644
index c7cefc0d7fe387..00000000000000
--- a/client/lib/query-manager/theme/util.js
+++ /dev/null
@@ -1,73 +0,0 @@
-/**
- * External dependencies
- */
-import { get, startsWith, every, some, includes } from 'lodash';
-
-/**
- * Internal dependencies
- */
-import { DEFAULT_THEME_QUERY } from './constants';
-
-const SEARCH_TAXONOMIES = [ 'subject', 'feature', 'color', 'style', 'column', 'layout' ];
-/**
- * Whether a given theme object is premium.
- *
- * @param {Object} theme Theme object
- * @return {Boolean} True if the theme is premium
- */
-export function isPremium( theme ) {
- const themeStylesheet = get( theme, 'stylesheet', false );
- return themeStylesheet && startsWith( themeStylesheet, 'premium/' );
-}
-
-/**
- * Returns true if the theme matches the given query, or false otherwise.
- *
- * @param {Object} query Query object
- * @param {Object} theme Item to consider
- * @return {Boolean} Whether theme matches query
- */
-export function isThemeMatchingQuery( query, theme ) {
- const queryWithDefaults = { ...DEFAULT_THEME_QUERY, ...query };
- return every( queryWithDefaults, ( value, key ) => {
- switch ( key ) {
- case 'search':
- if ( ! value ) {
- return true;
- }
-
- const search = value.toLowerCase();
-
- const foundInTaxonomies = some( SEARCH_TAXONOMIES, ( taxonomy ) => (
- theme.taxonomies && some( theme.taxonomies[ 'theme_' + taxonomy ], ( { name } ) => (
- includes( name.toLowerCase(), search )
- ) )
- ) );
-
- return foundInTaxonomies || (
- ( theme.name && includes( theme.name.toLowerCase(), search ) ) ||
- ( theme.author && includes( theme.author.toLowerCase(), search ) ) ||
- ( theme.descriptionLong && includes( theme.descriptionLong.toLowerCase(), search ) )
- );
-
- case 'filters':
- // TODO: Change filters object shape to be more like post's terms, i.e.
- // { color: 'blue,red', feature: 'post-slider' }
- const filters = value.split( ',' );
- return every( filters, ( filter ) => (
- some( theme.taxonomies, ( terms ) => (
- some( terms, { slug: filter } )
- ) )
- ) );
-
- case 'tier':
- if ( ! value ) {
- return true;
- }
- const queryingForPremium = value === 'premium';
- return queryingForPremium === isPremium( theme );
- }
-
- return true;
- } );
-}
diff --git a/client/state/themes/selectors.js b/client/state/themes/selectors.js
index 1b57ea9675a889..e9222108de2538 100644
--- a/client/state/themes/selectors.js
+++ b/client/state/themes/selectors.js
@@ -21,9 +21,9 @@ import {
getDeserializedThemesQueryDetails,
getNormalizedThemesQuery,
getSerializedThemesQuery,
- getSerializedThemesQueryWithoutPage
+ getSerializedThemesQueryWithoutPage,
+ isPremium
} from './utils';
-import { isPremium } from 'lib/query-manager/theme/util';
import { DEFAULT_THEME_QUERY } from './constants';
/**
diff --git a/client/state/themes/test/utils.js b/client/state/themes/test/utils.js
index 71659b0cf442fe..f6d5d3ce8e7214 100644
--- a/client/state/themes/test/utils.js
+++ b/client/state/themes/test/utils.js
@@ -7,6 +7,7 @@ import { expect } from 'chai';
* Internal dependencies
*/
import {
+ isPremium,
normalizeWpcomTheme,
normalizeWporgTheme,
getThemeIdFromStylesheet,
@@ -14,9 +15,40 @@ import {
getSerializedThemesQuery,
getDeserializedThemesQueryDetails,
getSerializedThemesQueryWithoutPage,
+ isThemeMatchingQuery
} from '../utils';
describe( 'utils', () => {
+ describe( '#isPremium()', () => {
+ it( 'given no theme object, should return false', () => {
+ const premium = isPremium();
+ expect( premium ).to.be.false;
+ } );
+
+ it( 'given a theme object with no stylesheet attr, should return false', () => {
+ const premium = isPremium( {
+ id: 'twentysixteen'
+ } );
+ expect( premium ).to.be.false;
+ } );
+
+ it( 'given a theme object with a stylesheet attr that doesn\'t start with "premium/", should return false', () => {
+ const premium = isPremium( {
+ id: 'twentysixteen',
+ stylesheet: 'pub/twentysixteen'
+ } );
+ expect( premium ).to.be.false;
+ } );
+
+ it( 'given a theme object with a stylesheet attr that starts with "premium/", should return true', () => {
+ const premium = isPremium( {
+ id: 'mood',
+ stylesheet: 'premium/mood'
+ } );
+ expect( premium ).to.be.true;
+ } );
+ } );
+
describe( '#normalizeWpcomTheme()', () => {
it( 'should return an empty object when given no argument', () => {
const normalizedTheme = normalizeWpcomTheme();
@@ -184,4 +216,174 @@ describe( 'utils', () => {
expect( serializedQuery ).to.equal( '2916284:{"search":"Hello"}' );
} );
} );
+
+ describe( '#matches()', () => {
+ const DEFAULT_THEME = {
+ name: 'Twenty Something',
+ author: 'the WordPress team',
+ screenshot: 'https://i0.wp.com/theme.wordpress.com/wp-content/themes/pub/twentysomething/screenshot.png',
+ screenshots: [ 'https://i0.wp.com/theme.files.wordpress.com/2015/12/twentysomething-featured-image.jpg?ssl=1' ],
+ stylesheet: 'pub/twentysomething',
+ taxonomies: {
+ theme_feature: [
+ {
+ name: 'Custom Header',
+ slug: 'custom-header'
+ },
+ {
+ name: 'Infinite Scroll',
+ slug: 'infinite-scroll'
+ }
+ ],
+ theme_color: [
+ {
+ name: 'Black',
+ slug: 'black',
+ term_id: '59007'
+ },
+ {
+ name: 'Blue',
+ slug: 'blue',
+ term_id: '9150'
+ },
+ {
+ name: 'Gray',
+ slug: 'gray',
+ term_id: '147520'
+ }
+ ]
+ },
+ demo_uri: 'https://twentysomethingdemo.wordpress.com/',
+ descriptionLong: 'The annual WordPress theme for this year is a modern take on an ever-popular layout. ' +
+ 'The horizontal header area with an optional right sidebar works perfectly for both blogs and websites.',
+ description: 'This is a modernized take on an ever-popular WordPress layout' +
+ ' — the horizontal masthead with an optional right sidebar that works perfectly for blogs and websites.'
+ };
+
+ context( 'query.search', () => {
+ it( 'should return false for a non-matching search', () => {
+ const isMatch = isThemeMatchingQuery( {
+ search: 'nonexisting'
+ }, DEFAULT_THEME );
+
+ expect( isMatch ).to.be.false;
+ } );
+
+ it( 'should return true for a matching title search', () => {
+ const isMatch = isThemeMatchingQuery( {
+ search: 'Twenty'
+ }, DEFAULT_THEME );
+
+ expect( isMatch ).to.be.true;
+ } );
+
+ it( 'should return true for a falsey title search', () => {
+ const isMatch = isThemeMatchingQuery( {
+ search: null
+ }, DEFAULT_THEME );
+
+ expect( isMatch ).to.be.true;
+ } );
+
+ it( 'should return true for a matching content search', () => {
+ const isMatch = isThemeMatchingQuery( {
+ search: 'modern'
+ }, DEFAULT_THEME );
+
+ expect( isMatch ).to.be.true;
+ } );
+
+ it( 'should return true for a matching author search', () => {
+ const isMatch = isThemeMatchingQuery( {
+ search: 'team'
+ }, DEFAULT_THEME );
+
+ expect( isMatch ).to.be.true;
+ } );
+
+ it( 'should return true for a matching filter search', () => {
+ const isMatch = isThemeMatchingQuery( {
+ search: 'infinite'
+ }, DEFAULT_THEME );
+
+ expect( isMatch ).to.be.true;
+ } );
+
+ it( 'should search case-insensitive', () => {
+ const isMatch = isThemeMatchingQuery( {
+ search: 'Sidebar'
+ }, DEFAULT_THEME );
+
+ expect( isMatch ).to.be.true;
+ } );
+
+ it( 'should separately test title and content fields', () => {
+ const isMatch = isThemeMatchingQuery( {
+ search: 'TwentyThe'
+ }, DEFAULT_THEME );
+
+ expect( isMatch ).to.be.false;
+ } );
+ } );
+
+ context( 'query.filters', () => {
+ it( 'should return false if theme does not include filter', () => {
+ const isMatch = isThemeMatchingQuery( {
+ filters: 'nosuchfilter'
+ }, DEFAULT_THEME );
+
+ expect( isMatch ).to.be.false;
+ } );
+
+ it( 'should return false on a partial match', () => {
+ const isMatch = isThemeMatchingQuery( {
+ filters: 'ourna'
+ }, DEFAULT_THEME );
+
+ expect( isMatch ).to.be.false;
+ } );
+
+ it( 'should return true if theme includes filter', () => {
+ const isMatch = isThemeMatchingQuery( {
+ filters: 'infinite-scroll'
+ }, DEFAULT_THEME );
+
+ expect( isMatch ).to.be.true;
+ } );
+
+ context( 'with multiple filters from a single taxonomy', () => {
+ it( 'should return false if theme doesn\'t match all filters', () => {
+ const isMatch = isThemeMatchingQuery( {
+ filters: 'infinite-scroll,business'
+ }, DEFAULT_THEME );
+
+ expect( isMatch ).to.be.false;
+ } );
+ it( 'should return true if theme matches all filters', () => {
+ const isMatch = isThemeMatchingQuery( {
+ filters: 'infinite-scroll,custom-header'
+ }, DEFAULT_THEME );
+
+ expect( isMatch ).to.be.true;
+ } );
+ } );
+
+ context( 'with multiple filters from different taxonomies', () => {
+ it( 'should return false if theme doesn\'t match all filters', () => {
+ const isMatch = isThemeMatchingQuery( {
+ filters: 'infinite-scroll,green'
+ }, DEFAULT_THEME );
+
+ expect( isMatch ).to.be.false;
+ } );
+ it( 'should return true if theme matches all filters', () => {
+ const isMatch = isThemeMatchingQuery( {
+ filters: 'infinite-scroll,black'
+ }, DEFAULT_THEME );
+
+ expect( isMatch ).to.be.true;
+ } );
+ } );
+ } );
+ } );
} );
diff --git a/client/state/themes/utils.js b/client/state/themes/utils.js
index c42ce34e4bc14b..12d13faf1fd320 100644
--- a/client/state/themes/utils.js
+++ b/client/state/themes/utils.js
@@ -2,18 +2,32 @@
* External dependencies
*/
import startsWith from 'lodash/startsWith';
-import { filter, get, map, mapKeys, omit, omitBy, split } from 'lodash';
+import {
+ every,
+ filter,
+ get,
+ includes,
+ map,
+ mapKeys,
+ omit,
+ omitBy,
+ some,
+ split
+} from 'lodash';
/**
* Internal dependencies
*/
-import { isThemeMatchingQuery } from 'lib/query-manager/theme/util';
import { DEFAULT_THEME_QUERY } from './constants';
/**
* Constants
*/
const REGEXP_SERIALIZED_QUERY = /^(?:(\d+):)?(.*)$/;
+// Used for client-side filtering of results from Jetpack sites. Note that Jetpack sites
+// only return the 'feature' taxonomy (in the guise of an array called `tags` which
+// we normalize to taxonomies.theme_feature to be consistent with results from WPCOM.)
+const SEARCH_TAXONOMIES = [ 'feature' ];
export const oldShowcaseUrl = '//wordpress.com/themes/';
@@ -21,6 +35,17 @@ export const oldShowcaseUrl = '//wordpress.com/themes/';
* Utility
*/
+/**
+ * Whether a given theme object is premium.
+ *
+ * @param {Object} theme Theme object
+ * @return {Boolean} True if the theme is premium
+ */
+export function isPremium( theme ) {
+ const themeStylesheet = get( theme, 'stylesheet', false );
+ return themeStylesheet && startsWith( themeStylesheet, 'premium/' );
+}
+
/**
* Normalizes a theme obtained from the WordPress.com REST API
*
@@ -171,3 +196,48 @@ export function isPremiumTheme( theme ) {
export function filterThemesForJetpack( themes, query ) {
return filter( themes, theme => isThemeMatchingQuery( theme, query ) );
}
+
+/**
+ * Returns true if the theme matches the given query, or false otherwise.
+ *
+ * @param {Object} query Query object
+ * @param {Object} theme Item to consider
+ * @return {Boolean} Whether theme matches query
+ */
+export function isThemeMatchingQuery( query, theme ) {
+ const queryWithDefaults = { ...DEFAULT_THEME_QUERY, ...query };
+ return every( queryWithDefaults, ( value, key ) => {
+ switch ( key ) {
+ case 'search':
+ if ( ! value ) {
+ return true;
+ }
+
+ const search = value.toLowerCase();
+
+ const foundInTaxonomies = some( SEARCH_TAXONOMIES, ( taxonomy ) => (
+ theme.taxonomies && some( theme.taxonomies[ 'theme_' + taxonomy ], ( { name } ) => (
+ includes( name.toLowerCase(), search )
+ ) )
+ ) );
+
+ return foundInTaxonomies || (
+ ( theme.name && includes( theme.name.toLowerCase(), search ) ) ||
+ ( theme.author && includes( theme.author.toLowerCase(), search ) ) ||
+ ( theme.descriptionLong && includes( theme.descriptionLong.toLowerCase(), search ) )
+ );
+
+ case 'filters':
+ // TODO: Change filters object shape to be more like post's terms, i.e.
+ // { color: 'blue,red', feature: 'post-slider' }
+ const filters = value.split( ',' );
+ return every( filters, ( f ) => (
+ some( theme.taxonomies, ( terms ) => (
+ some( terms, { slug: f } )
+ ) )
+ ) );
+ }
+
+ return true;
+ } );
+}