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; + } ); +}