Skip to content

Commit 33c16b6

Browse files
authored
Themes: filter the Marketplace tab to the themes a site's plan includes (#54026)
1 parent bc62631 commit 33c16b6

8 files changed

Lines changed: 292 additions & 4 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Significance: patch
2+
Type: added
3+
4+
Themes: on the Marketplace tab, filter the themes to the ones included with the site's plan.

‎projects/packages/jetpack-mu-wpcom/src/features/wpcom-themes/css/themes-tab.css‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,23 @@
3030
width: fit-content;
3131
margin-block-start: 10px;
3232
}
33+
34+
/* Between core's tabs and its Feature Filter button. */
35+
.wpcom-themes-tab__pricing {
36+
margin-inline: 10px;
37+
}
38+
39+
/* Sized like core's own selects in this bar, to match its search box.
40+
Core middle-aligns selects; baseline puts the text level with the tabs. */
41+
.wp-filter .wpcom-themes-tab__pricing select {
42+
min-height: 32px;
43+
line-height: 2.14286;
44+
vertical-align: baseline;
45+
}
46+
47+
@media screen and (max-width: 782px) {
48+
49+
.wp-filter .wpcom-themes-tab__pricing select {
50+
min-height: 40px;
51+
}
52+
}

‎projects/packages/jetpack-mu-wpcom/src/features/wpcom-themes/js/themes-tab-tracks.test.mts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,4 +81,26 @@ describe( 'trackThemesTab', () => {
8181
assert.equal( events.length, 2 );
8282
assert.equal( ( events[ 1 ] as unknown[] )[ 0 ], 'wpcom_marketplace_tab_theme_purchase_click' );
8383
} );
84+
85+
it( "records a change of the pricing filter, and not of core's own filters", () => {
86+
type OnChange = ( event: { target: unknown } ) => void;
87+
88+
const events: unknown[] = [];
89+
let onChange: OnChange = () => {};
90+
const doc = {
91+
addEventListener: ( type: string, handler: OnChange ) => {
92+
if ( type === 'change' ) {
93+
onChange = handler;
94+
}
95+
},
96+
} as unknown as Document;
97+
98+
trackThemesTab( doc, ( ...event ) => events.push( event ) );
99+
onChange( { target: { dataset: { wpcomThemesPricing: '' }, value: 'included' } } );
100+
onChange( { target: { dataset: {}, value: 'blog' } } );
101+
102+
assert.deepEqual( events, [
103+
[ 'wpcom_marketplace_tab_theme_pricing_filter', { pricing: 'included' } ],
104+
] );
105+
} );
84106
} );

‎projects/packages/jetpack-mu-wpcom/src/features/wpcom-themes/js/themes-tab-tracks.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
const PURCHASE_EVENT = 'wpcom_marketplace_tab_theme_purchase_click';
2+
const PRICING_EVENT = 'wpcom_marketplace_tab_theme_pricing_filter';
23

34
type Track = ( name: string, props: Record< string, unknown > ) => void;
45

@@ -41,7 +42,7 @@ export function clickEvent( dataset: DOMStringMap ):
4142
}
4243

4344
/**
44-
* Records each click on a purchase link the Marketplace tab offers.
45+
* Records each click on a purchase link the Marketplace tab offers, and each change of its pricing filter.
4546
*
4647
* @param doc - The Add Themes page.
4748
* @param track - Records one Tracks event.
@@ -64,4 +65,16 @@ export function trackThemesTab( doc: Document, track: Track ) {
6465
// On the document, as the links are created as cards render; capturing, so no handler can stop it.
6566
doc.addEventListener( 'click', onClick, true );
6667
doc.addEventListener( 'auxclick', onClick, true );
68+
69+
doc.addEventListener(
70+
'change',
71+
( event: Event ) => {
72+
const select = event.target as HTMLSelectElement;
73+
74+
if ( select.dataset?.wpcomThemesPricing !== undefined ) {
75+
track( PRICING_EVENT, { pricing: select.value } );
76+
}
77+
},
78+
true
79+
);
6780
}

‎projects/packages/jetpack-mu-wpcom/src/features/wpcom-themes/js/themes-tab.js‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,4 +280,53 @@
280280
)
281281
);
282282
}
283+
284+
const Collection = window.wp.themes.Collection;
285+
if ( ! list || ! Collection ) {
286+
return;
287+
}
288+
289+
const PRICING_PARAM = 'wpcom_pricing';
290+
291+
const select = document.createElement( 'select' );
292+
select.id = 'wpcom-themes-tab-pricing';
293+
select.dataset.wpcomThemesPricing = '';
294+
[
295+
[ 'all', config.allPlans ],
296+
[ config.included, config.includedLabel ],
297+
].forEach( ( [ value, text ] ) => {
298+
const option = document.createElement( 'option' );
299+
option.value = value;
300+
option.textContent = text;
301+
select.appendChild( option );
302+
} );
303+
304+
const label = document.createElement( 'label' );
305+
label.className = 'screen-reader-text';
306+
label.htmlFor = select.id;
307+
label.textContent = config.pricingLabel;
308+
309+
const pricing = document.createElement( 'span' );
310+
pricing.className = 'wpcom-themes-tab__pricing';
311+
pricing.hidden = true;
312+
pricing.appendChild( label );
313+
pricing.appendChild( select );
314+
list.insertAdjacentElement( 'afterend', pricing );
315+
316+
// Every view's request passes through here, so the filter shows only while the tab is listed.
317+
const query = Collection.prototype.query;
318+
Collection.prototype.query = function ( request ) {
319+
const onTab = !! request && request.browse === config.sort;
320+
pricing.hidden = ! onTab;
321+
322+
if ( onTab && select.value === config.included ) {
323+
return query.call( this, { ...request, [ PRICING_PARAM ]: select.value } );
324+
}
325+
326+
return query.call( this, request );
327+
};
328+
329+
select.addEventListener( 'change', () =>
330+
window.wp.themes.RunInstaller?.view?.browse( config.sort )
331+
);
283332
} )();

‎projects/packages/jetpack-mu-wpcom/src/features/wpcom-themes/js/themes-tab.test.mts‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ type FakeNode = {
1919
querySelector: ( selector: string ) => FakeNode | null;
2020
setAttribute: ( name: string, value: string ) => void;
2121
attributes: Record< string, string >;
22+
value?: string;
23+
listeners: Record< string, () => void >;
24+
addEventListener: ( type: string, handler: () => void ) => void;
2225
};
2326

2427
/**
@@ -40,6 +43,8 @@ function node( tagName?: string ): FakeNode {
4043
querySelector: () => null,
4144
attributes: {},
4245
setAttribute: ( name, value ) => void ( el.attributes[ name ] = value ),
46+
listeners: {},
47+
addEventListener: ( type, handler ) => void ( el.listeners[ type ] = handler ),
4348
};
4449
return el;
4550
}
@@ -430,3 +435,75 @@ it( 'stops marking a theme once it renders outside the tab', () => {
430435
);
431436
assert.equal( page.activate( 'click', 'organic-stax' ), null );
432437
} );
438+
439+
/**
440+
* Loads the script on a page with core's filter bar and theme collection.
441+
*
442+
* @return The pricing filter, and what core's collection and installer were asked for.
443+
*/
444+
function pricingPage() {
445+
const list = node( 'ul' );
446+
const requests: unknown[] = [];
447+
const browsed: string[] = [];
448+
const Collection = {
449+
prototype: { query: ( request: unknown ) => void requests.push( request ) },
450+
};
451+
const config = { sort: 'wpcom', label: 'Marketplace', included: 'included' };
452+
const context = {
453+
document: { querySelector: () => list, createElement: node, addEventListener: () => {} },
454+
window: {
455+
wpcomThemesTab: config,
456+
wp: {
457+
themes: {
458+
view: { Theme: { prototype: { render: () => undefined } } },
459+
Collection,
460+
RunInstaller: { view: { browse: ( section: string ) => void browsed.push( section ) } },
461+
},
462+
},
463+
},
464+
wpcomThemesTab: config,
465+
};
466+
const file = new URL( './themes-tab.js', import.meta.url ).pathname;
467+
vm.createContext( context );
468+
new vm.Script( readFileSync( file, 'utf8' ), { filename: file } ).runInContext( context );
469+
470+
const pricing = list.children.find( child => child.className === 'wpcom-themes-tab__pricing' );
471+
assert.ok( pricing );
472+
473+
return {
474+
pricing,
475+
select: pricing.children[ 1 ],
476+
query: ( request: Record< string, unknown > ) => Collection.prototype.query.call( {}, request ),
477+
// The script builds some requests in the vm's realm, which a strict deepEqual tells apart.
478+
requests: () => JSON.parse( JSON.stringify( requests ) ),
479+
browsed,
480+
};
481+
}
482+
483+
it( "narrows the tab's query and its later pages, and nothing else, to what the plan includes", () => {
484+
const page = pricingPage();
485+
page.query( { browse: 'wpcom' } );
486+
assert.equal( page.pricing.hidden, false );
487+
488+
page.select.value = 'included';
489+
page.query( { browse: 'wpcom' } );
490+
page.query( { browse: 'wpcom', wpcom_pricing: 'included', page: 2 } );
491+
page.query( { browse: 'popular' } );
492+
assert.equal( page.pricing.hidden, true );
493+
page.query( { search: 'blog' } );
494+
495+
assert.deepEqual( page.requests(), [
496+
{ browse: 'wpcom' },
497+
{ browse: 'wpcom', wpcom_pricing: 'included' },
498+
{ browse: 'wpcom', wpcom_pricing: 'included', page: 2 },
499+
{ browse: 'popular' },
500+
{ search: 'blog' },
501+
] );
502+
} );
503+
504+
it( 'lists the tab again when the filter changes', () => {
505+
const page = pricingPage();
506+
page.select.listeners.change();
507+
508+
assert.deepEqual( page.browsed, [ 'wpcom' ] );
509+
} );

‎projects/packages/jetpack-mu-wpcom/src/features/wpcom-themes/wpcom-themes-tab.php‎

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ function wpcom_themes_get_purchases() {
4343
*/
4444
const WPCOM_THEMES_TAB_PER_PAGE = 100;
4545

46+
/**
47+
* The `wpcom_pricing` value that lists only the themes this site can install with nothing to buy.
48+
*/
49+
const WPCOM_THEMES_TAB_INCLUDED = 'included';
50+
4651
/**
4752
* Registers the feature flag.
4853
*
@@ -106,6 +111,10 @@ function wpcom_themes_tab_enqueue_script() {
106111
'subscribe' => __( 'Subscribe', 'jetpack-mu-wpcom' ),
107112
/* translators: %s: Theme name. */
108113
'subscribeLabel' => __( 'Subscribe to %s', 'jetpack-mu-wpcom' ),
114+
'pricingLabel' => __( 'Filter themes by plan', 'jetpack-mu-wpcom' ),
115+
'allPlans' => __( 'All plans', 'jetpack-mu-wpcom' ),
116+
'included' => WPCOM_THEMES_TAB_INCLUDED,
117+
'includedLabel' => __( 'Included with my plan', 'jetpack-mu-wpcom' ),
109118
'products' => WPCom_Themes_Store::PRODUCTS_URL,
110119
/* translators: %s: Price per month, for example $7.20. */
111120
'price' => __( '%s/mo, billed annually', 'jetpack-mu-wpcom' ),
@@ -213,21 +222,38 @@ function wpcom_themes_tab_add_plan( $theme ) {
213222
return wpcom_themes_tab_add_subscription( $theme );
214223
}
215224

216-
$plan = WPCom_Themes_Tiers::plan_for( $tier );
217-
218-
if ( '' === $plan || WPCom_Themes_Tiers::site_can_use( $tier ) ) {
225+
if ( wpcom_themes_tab_is_included( $tier, (string) ( $theme->slug ?? '' ) ) ) {
219226
$theme->plan_badge = __( 'Included with plan', 'jetpack-mu-wpcom' );
220227

221228
return $theme;
222229
}
223230

231+
$plan = WPCom_Themes_Tiers::plan_for( $tier );
224232
$theme->plan_badge = WPCom_Themes_Tiers::plan_name_for( $tier );
225233
$theme->required_plan = $plan;
226234
$theme->upgrade_url = wpcom_themes_tab_checkout_url( $plan, (string) ( $theme->slug ?? '' ) );
227235

228236
return $theme;
229237
}
230238

239+
/**
240+
* Whether this site can install a theme with nothing more to buy.
241+
*
242+
* @param string $tier Tier slug.
243+
* @param string $slug Theme slug.
244+
* @return bool
245+
*/
246+
function wpcom_themes_tab_is_included( string $tier, string $slug ) {
247+
// No plan includes a partner theme, so one counts only once its own subscription is paid for.
248+
if ( WPCom_Themes_Tiers::PARTNER === $tier ) {
249+
$subscription = wpcom_themes_tab_subscription( $slug );
250+
251+
return array() !== $subscription && $subscription['owned'];
252+
}
253+
254+
return '' === WPCom_Themes_Tiers::plan_for( $tier ) || WPCom_Themes_Tiers::site_can_use( $tier );
255+
}
256+
231257
/**
232258
* Offers the subscription a partner theme needs, which no plan includes.
233259
*
@@ -388,6 +414,20 @@ function wpcom_themes_tab_serve_themes_api( $result, $action, $args ) {
388414
}
389415

390416
$themes = wpcom_themes_tab_catalog();
417+
418+
// Filtered before paging, so the count and the pages match what the grid shows.
419+
if ( isset( $args->wpcom_pricing ) && WPCOM_THEMES_TAB_INCLUDED === $args->wpcom_pricing ) {
420+
$themes = array_values(
421+
array_filter(
422+
$themes,
423+
static function ( $theme ) {
424+
// Read as the mapper reads it, so the filter agrees with the card's badge.
425+
return wpcom_themes_tab_is_included( (string) ( $theme->theme_tier->slug ?? '' ), $theme->id );
426+
}
427+
)
428+
);
429+
}
430+
391431
$page = isset( $args->page ) ? max( 1, (int) $args->page ) : 1;
392432
$mapper = wpcom_themes_tab_mapper();
393433

‎projects/packages/jetpack-mu-wpcom/tests/php/features/wpcom-themes/Wpcom_Themes_Tab_Test.php‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -621,6 +621,69 @@ public function test_a_withdrawn_partner_theme_claims_nothing() {
621621
$this->assertFalse( isset( $theme->purchase_url ) );
622622
}
623623

624+
/**
625+
* The pricing filter keeps what the site can install with nothing to buy, before paging.
626+
*
627+
* @param bool $has_plan Whether the site's plan grants every tier.
628+
* @param array<object> $purchases The site's purchases.
629+
* @param string[] $listed Slugs the filter keeps.
630+
* @dataProvider provide_included_themes
631+
*/
632+
#[DataProvider( 'provide_included_themes' )]
633+
public function test_the_pricing_filter_lists_only_what_the_plan_includes( bool $has_plan, array $purchases, array $listed ) {
634+
$this->enable_tab();
635+
$this->site_has_plan( $has_plan );
636+
$GLOBALS['wpcom_get_site_purchases_test_value'] = $purchases;
637+
$this->answer_wpcom(
638+
array(
639+
'tier=partner' => array( 'body' => self::PARTNER_THEMES ),
640+
'/products' => self::STORE_PRODUCTS,
641+
'/wpcom/v2/themes' => array(
642+
'body' => array(
643+
'themes' => array(
644+
array_merge(
645+
self::THEME,
646+
array(
647+
'id' => 'free-theme',
648+
'theme_tier' => array( 'slug' => 'free' ),
649+
)
650+
),
651+
array_merge( self::THEME, array( 'id' => 'personal-theme' ) ),
652+
array_merge( self::THEME, array( 'theme_tier' => array( 'slug' => 'partner' ) ) ),
653+
),
654+
),
655+
),
656+
)
657+
);
658+
659+
$result = $this->query(
660+
array(
661+
'browse' => WPCOM_THEMES_TAB,
662+
'wpcom_pricing' => WPCOM_THEMES_TAB_INCLUDED,
663+
)
664+
);
665+
666+
$this->assertSame( $listed, array_column( $result->themes, 'slug' ) );
667+
$this->assertSame( count( $listed ), $result->info['results'] );
668+
}
669+
670+
/**
671+
* Plans and purchases, and the themes each leaves the filter listing.
672+
*
673+
* @return array<string, array{0: bool, 1: array<object>, 2: string[]}>
674+
*/
675+
public static function provide_included_themes(): array {
676+
return array(
677+
'no plan' => array( false, array(), array( 'free-theme' ) ),
678+
'a plan' => array( true, array(), array( 'free-theme', 'personal-theme' ) ),
679+
'a plan and a partner theme' => array(
680+
true,
681+
array( (object) array( 'billing_product_slug' => 'theme-stax' ) ),
682+
array( 'free-theme', 'personal-theme', 'organic-stax' ),
683+
),
684+
);
685+
}
686+
624687
/**
625688
* Say whether the site's plan grants every tier.
626689
*

0 commit comments

Comments
 (0)