Commit 2c7ec3719f5 for woocommerce
commit 2c7ec3719f5fd4f90d976b309b05e0b907860ddb
Author: Cem Ünalan <raicem@users.noreply.github.com>
Date: Thu Sep 10 16:29:14 2026 +0300
Fix three small in-app Marketplace UI issues, round two (#68429)
* WOOMKT-849: Relabel the WooCommerce.com account menu item and point it at My account
When the store is not connected, the marketplace account menu offered
"Connect account" and a second item labelled "WooCommerce.com account"
that opened woocommerce.com/my-dashboard/. Next to the connect item the
label read like another way to connect, and a logged-out visitor landed
on a login page with no explanation.
Relabel the item "Your WooCommerce.com account" so it is clearly a link
out to the merchant's account, and use the shared My account constant
for both this item and the connected email item, so the header matches
the rest of the marketplace.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* WOOMKT-846: Show a "Results for" heading above marketplace search results
After searching, the extensions, themes and business services tabs
showed a filtered grid with nothing saying it was filtered. Only the
search field, which still held the term, hinted at it.
Add a heading naming the search term at the top of the results, above
the filter row, in the same type as the Discover group titles. It stays
in place when a search finds nothing, so the empty state still says what
was searched for, and it is absent when browsing without a term.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* WOOMKT-825: Show the marketplace search clear button only when there is text to clear
The search field always showed a "Close search" button, even when empty.
With nothing to clear it did nothing on most tabs, and on Discover it
jumped to the Extensions tab. The button came from SearchControl's
onClose prop, which forces the button to render and has been deprecated
since WordPress 6.8.
Drop onClose and let SearchControl show its own reset button, which only
appears while the field has text. Emptying the field while a search is
active now clears the results as well, so the reset button keeps doing
what the old close button did for a real search.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Collapse the three Marketplace changelog entries into one
* Drop Linear ticket references from the changelog entry
* Use accessible text queries for child-component mocks in Products tests
Replaces the data-testid assertions on the ProductListContent and
NoResults mocks with getByText, per the repo's React Testing Library
query-order guideline.
* Open the WooCommerce.com account menu links in a new tab
Both the connected email item and the "Your WooCommerce.com account"
item leave the store admin, so they now open in a new tab with an
external icon and screen reader text saying so. Also drops a redundant
comment from the search results heading styles.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Herman <KokkieH@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/WOOMKT-825-846-849 b/plugins/woocommerce/changelog/WOOMKT-825-846-849
new file mode 100644
index 00000000000..86fc7d17f56
--- /dev/null
+++ b/plugins/woocommerce/changelog/WOOMKT-825-846-849
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Three small Marketplace fixes: the clear button in the search field only shows when there is text to clear and resets the results, search results get a "Results for" heading, and the WooCommerce.com account menu link reads "Your WooCommerce.com account" and points at My account.
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/header-account/header-account.scss b/plugins/woocommerce/client/admin/client/marketplace/components/header-account/header-account.scss
index db5909cc921..12f7be265d9 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/components/header-account/header-account.scss
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/header-account/header-account.scss
@@ -21,6 +21,11 @@
margin-right: $grid-unit-10;
}
+ &__menu-external-icon {
+ flex-shrink: 0;
+ margin-inline-start: $grid-unit-05;
+ }
+
&__menu-text {
display: flex;
flex-direction: column;
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/header-account/header-account.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/header-account/header-account.tsx
index c2d07070587..1f07b8364d3 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/components/header-account/header-account.tsx
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/header-account/header-account.tsx
@@ -7,6 +7,7 @@ import {
DropdownMenu,
MenuGroup,
MenuItem as OriginalMenuItem,
+ VisuallyHidden,
} from '@wordpress/components';
import {
Icon,
@@ -24,12 +25,14 @@ import { recordEvent } from '@woocommerce/tracks';
import './header-account.scss';
import { getAdminSetting } from '../../../utils/admin-settings';
import HeaderAccountModal from './header-account-modal';
-import { MARKETPLACE_HOST } from '../constants';
+import { MARKETPLACE_MY_ACCOUNT_PATH } from '../constants';
import { connectUrl } from '../../utils/functions';
// Make TS happy: The MenuItem component passes these as an href prop to the underlying button.
interface MenuItemProps extends ComponentProps< typeof OriginalMenuItem > {
href?: string; // Explicitly declare `href`
+ target?: string;
+ rel?: string;
}
const MenuItem = ( props: MenuItemProps ) => <OriginalMenuItem { ...props } />;
@@ -52,10 +55,17 @@ export default function HeaderAccount( {
const userEmail = wccomSettings?.userEmail;
const avatarURL = wccomSettings?.userAvatar;
- const accountURL = MARKETPLACE_HOST + '/my-dashboard/';
+ const accountURL = MARKETPLACE_MY_ACCOUNT_PATH;
const accountOrConnect = isConnected ? accountURL : connectionURL;
const isInApp = page === 'wc-addons';
+ const newTabProps = { target: '_blank', rel: 'noopener noreferrer' };
+ const newTabText = (
+ <VisuallyHidden as="span">
+ { __( '(opens in a new tab)', 'woocommerce' ) }
+ </VisuallyHidden>
+ );
+
const avatar = () => {
// Render the default avatar SVG when the user isn't connected, when
// the connected user has no avatar URL, or when the avatar image
@@ -126,7 +136,13 @@ export default function HeaderAccount( {
/>
<span className="woocommerce-marketplace__main-text">
{ userEmail }
+ { newTabText }
</span>
+ <Icon
+ icon={ external }
+ size={ 16 }
+ className="woocommerce-marketplace__menu-external-icon"
+ />
</>
);
}
@@ -186,6 +202,7 @@ export default function HeaderAccount( {
<MenuItem
className="woocommerce-marketplace__menu-item"
href={ accountOrConnect }
+ { ...( isConnected ? newTabProps : {} ) }
onClick={ () => {
if ( isConnected ) {
recordEvent(
@@ -205,6 +222,7 @@ export default function HeaderAccount( {
{ page === 'wc-addons' && ! isConnected && (
<MenuItem
href={ accountURL }
+ { ...newTabProps }
onClick={ () =>
recordEvent(
'header_account_view_click',
@@ -218,9 +236,10 @@ export default function HeaderAccount( {
className="woocommerce-marketplace__menu-icon"
/>
{ __(
- 'WooCommerce.com account',
+ 'Your WooCommerce.com account',
'woocommerce'
) }
+ { newTabText }
</MenuItem>
) }
</MenuGroup>
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/header-account/test/header-account.test.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/header-account/test/header-account.test.tsx
new file mode 100644
index 00000000000..d71db3750c3
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/header-account/test/header-account.test.tsx
@@ -0,0 +1,102 @@
+/**
+ * External dependencies
+ */
+import { fireEvent, render, screen } from '@testing-library/react';
+import React from 'react';
+
+let mockSettings: Record< string, unknown > = {};
+
+jest.mock( '../../../../utils/admin-settings', () => ( {
+ getAdminSetting: jest.fn( () => mockSettings ),
+} ) );
+
+jest.mock( '@woocommerce/tracks', () => ( {
+ recordEvent: jest.fn(),
+} ) );
+
+jest.mock( '../../../utils/functions', () => ( {
+ connectUrl: jest.fn( () => 'http://example.test/connect' ),
+} ) );
+
+/**
+ * Internal dependencies
+ */
+import HeaderAccount from '../header-account';
+
+function openMenu( page: string ) {
+ render( <HeaderAccount page={ page } /> );
+ fireEvent.click( screen.getByRole( 'button', { name: 'User options' } ) );
+}
+
+describe( 'HeaderAccount menu', () => {
+ beforeEach( () => {
+ mockSettings = {
+ isConnected: false,
+ userEmail: '',
+ userAvatar: '',
+ };
+ } );
+
+ it( 'offers connect and a link to the WooCommerce.com account when not connected in the marketplace', () => {
+ openMenu( 'wc-addons' );
+
+ expect(
+ screen.getByRole( 'menuitem', { name: /Connect account/ } )
+ ).toHaveAttribute( 'href', 'http://example.test/connect' );
+ const accountItem = screen.getByRole( 'menuitem', {
+ name: 'Your WooCommerce.com account (opens in a new tab)',
+ } );
+ expect( accountItem ).toHaveAttribute(
+ 'href',
+ 'https://woocommerce.com/my-account/'
+ );
+ expect( accountItem ).toHaveAttribute( 'target', '_blank' );
+ expect( accountItem ).toHaveAttribute( 'rel', 'noopener noreferrer' );
+ expect(
+ screen.getByRole( 'menuitem', { name: /Connect account/ } )
+ ).not.toHaveAttribute( 'target' );
+ expect(
+ document.querySelector( 'a[href*="my-dashboard"]' )
+ ).toBeNull();
+ } );
+
+ it( 'links the connected email to the WooCommerce.com account and hides the extra item', () => {
+ mockSettings = {
+ isConnected: true,
+ userEmail: 'merchant@example.com',
+ userAvatar: '',
+ };
+ openMenu( 'wc-addons' );
+
+ const emailItem = screen.getByRole( 'menuitem', {
+ name: 'merchant@example.com (opens in a new tab)',
+ } );
+ expect( emailItem ).toHaveAttribute(
+ 'href',
+ 'https://woocommerce.com/my-account/'
+ );
+ expect( emailItem ).toHaveAttribute( 'target', '_blank' );
+ expect( emailItem ).toHaveAttribute( 'rel', 'noopener noreferrer' );
+ expect(
+ screen.queryByRole( 'menuitem', {
+ name: /Your WooCommerce.com account/,
+ } )
+ ).toBeNull();
+ expect(
+ screen.getByRole( 'menuitem', { name: 'Disconnect account' } )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'does not show the account link outside the marketplace when not connected', () => {
+ openMenu( 'wc-admin' );
+
+ expect(
+ screen.getByRole( 'menuitem', { name: /Connect account/ } )
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole( 'menuitem', {
+ name: /Your WooCommerce.com account/,
+ } )
+ ).toBeNull();
+ } );
+} );
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/products/products.scss b/plugins/woocommerce/client/admin/client/marketplace/components/products/products.scss
index 43b42a21261..aa97b563d79 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/components/products/products.scss
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/products/products.scss
@@ -10,6 +10,13 @@
}
+.woocommerce-marketplace__search-results-heading {
+ font-size: 20px;
+ font-weight: 400;
+ line-height: 28px;
+ margin: 0 0 $grid-unit-20;
+}
+
.woocommerce-marketplace__sub-header {
display: flex;
align-items: center;
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/products/products.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/products/products.tsx
index 054836f4c94..1298bf56973 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/components/products/products.tsx
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/products/products.tsx
@@ -1,7 +1,7 @@
/**
* External dependencies
*/
-import { __ } from '@wordpress/i18n';
+import { __, sprintf } from '@wordpress/i18n';
import { createInterpolateElement, useContext } from '@wordpress/element';
import { getNewPath, navigateTo, useQuery } from '@woocommerce/navigation';
import { Button } from '@wordpress/components';
@@ -79,6 +79,7 @@ export default function Products( props: ProductsProps ) {
const showQualityBadgeFilter = props.type === ProductType.extension;
const hasNoResults = ! isLoading && products.length === 0;
const showCategorySelector = Boolean( props.categorySelector );
+ const searchTerm = props.searchTerm?.trim() ?? '';
// The sub-header stays mounted across the loading/empty/loaded states so
// the filter toggle keeps keyboard focus while toggling triggers a refetch.
@@ -115,6 +116,15 @@ export default function Products( props: ProductsProps ) {
return (
<div className={ containerClassName }>
+ { searchTerm && (
+ <h2 className="woocommerce-marketplace__search-results-heading">
+ { sprintf(
+ /* translators: %s: the search term the merchant entered. */
+ __( 'Results for “%s”', 'woocommerce' ),
+ searchTerm
+ ) }
+ </h2>
+ ) }
{ subHeader }
{ isLoading && (
<ProductLoader hasTitle={ false } type={ props.type } />
@@ -147,7 +157,7 @@ export default function Products( props: ProductsProps ) {
<span key="wp-theme-directory-copy">
{ createInterpolateElement(
__(
- ' Browse the <a>WordPress.org theme directory</a> to discover more.',
+ 'Browse the <a>WordPress.org theme directory</a> to discover more.',
'woocommerce'
),
{
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/products/test/products.test.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/products/test/products.test.tsx
new file mode 100644
index 00000000000..f55310c5985
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/products/test/products.test.tsx
@@ -0,0 +1,121 @@
+/**
+ * External dependencies
+ */
+import { render, screen } from '@testing-library/react';
+import React from 'react';
+
+jest.mock( '@woocommerce/navigation', () => ( {
+ getNewPath: jest.fn( () => '/new-path' ),
+ navigateTo: jest.fn(),
+ useQuery: jest.fn( () => ( {} ) ),
+} ) );
+
+jest.mock( '~/utils/admin-settings', () => ( {
+ ADMIN_URL: 'http://example.test/wp-admin/',
+} ) );
+
+jest.mock( '../../category-selector/category-selector', () => ( {
+ __esModule: true,
+ default: () => <div data-testid="category-selector" />,
+} ) );
+
+jest.mock( '../../quality-badge/quality-badge-filter', () => ( {
+ __esModule: true,
+ default: () => <div data-testid="quality-badge-filter" />,
+} ) );
+
+jest.mock( '../../product-list-content/product-list-content', () => ( {
+ __esModule: true,
+ default: () => <span>Product list content</span>,
+} ) );
+
+jest.mock( '../../product-list-content/no-results', () => ( {
+ __esModule: true,
+ default: () => <span>No results</span>,
+} ) );
+
+jest.mock( '../../product-loader/product-loader', () => ( {
+ __esModule: true,
+ default: () => <div data-testid="product-loader" />,
+} ) );
+
+/**
+ * Internal dependencies
+ */
+import Products from '../products';
+import { MarketplaceContext } from '../../../contexts/marketplace-context';
+import { MarketplaceContextType } from '../../../contexts/types';
+import { Product, ProductType } from '../../product-list/types';
+
+const context = {
+ isLoading: false,
+} as unknown as MarketplaceContextType;
+
+const product: Product = {
+ id: 1,
+ title: 'Test extension',
+ image: '',
+ type: ProductType.extension,
+ description: '',
+ vendorName: '',
+ vendorUrl: '',
+ icon: '',
+ url: '',
+ price: 0,
+ isInstallable: false,
+ currency: 'USD',
+ isOnSale: false,
+ regularPrice: 0,
+ averageRating: null,
+ reviewsCount: null,
+};
+
+function renderProducts( props: {
+ searchTerm?: string;
+ products?: Product[];
+} ) {
+ return render(
+ <MarketplaceContext.Provider value={ context }>
+ <Products
+ type={ ProductType.extension }
+ categorySelector={ true }
+ products={ props.products ?? [ product ] }
+ searchTerm={ props.searchTerm }
+ />
+ </MarketplaceContext.Provider>
+ );
+}
+
+describe( 'Products search results heading', () => {
+ it( 'names the search term above the results', () => {
+ renderProducts( { searchTerm: 'shipping' } );
+
+ expect(
+ screen.getByRole( 'heading', { name: 'Results for “shipping”' } )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText( 'Product list content' )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'keeps the heading when the search finds nothing', () => {
+ renderProducts( { searchTerm: 'zzzz', products: [] } );
+
+ expect(
+ screen.getByRole( 'heading', { name: 'Results for “zzzz”' } )
+ ).toBeInTheDocument();
+ expect( screen.getByText( 'No results' ) ).toBeInTheDocument();
+ } );
+
+ it( 'shows no heading when browsing without a search term', () => {
+ renderProducts( {} );
+
+ expect( screen.queryByRole( 'heading' ) ).toBeNull();
+ } );
+
+ it( 'shows no heading for a blank search term', () => {
+ renderProducts( { searchTerm: ' ' } );
+
+ expect( screen.queryByRole( 'heading' ) ).toBeNull();
+ } );
+} );
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/search/search.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/search/search.tsx
index d695a47930e..24f16fb8836 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/components/search/search.tsx
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/search/search.tsx
@@ -63,9 +63,15 @@ function Search(): React.JSX.Element {
}
};
- const onClose = () => {
- setSearchTerm( '' );
- runSearch( '' );
+ // SearchControl only shows its reset button while the field has text,
+ // and reset empties the field through onChange. When a search is active,
+ // emptying the field also clears the results.
+ const onChange = ( value: string ) => {
+ setSearchTerm( value );
+
+ if ( value === '' && query.term ) {
+ runSearch( '' );
+ }
};
const onFocus = () => {
@@ -80,9 +86,8 @@ function Search(): React.JSX.Element {
label={ searchPlaceholder }
placeholder={ searchPlaceholder }
value={ searchTerm }
- onChange={ setSearchTerm }
+ onChange={ onChange }
onKeyUp={ handleKeyUp }
- onClose={ onClose }
onFocus={ onFocus }
className="woocommerce-marketplace__search"
/>
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/search/test/search.test.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/search/test/search.test.tsx
new file mode 100644
index 00000000000..8e10e8ff02a
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/search/test/search.test.tsx
@@ -0,0 +1,100 @@
+/**
+ * External dependencies
+ */
+import { fireEvent, render, screen } from '@testing-library/react';
+import React from 'react';
+import { navigateTo } from '@woocommerce/navigation';
+
+let mockQuery: Record< string, string > = {};
+
+jest.mock( '@woocommerce/navigation', () => ( {
+ getNewPath: jest.fn(
+ ( query: Record< string, string > ) =>
+ '/extensions?' + new URLSearchParams( query ).toString()
+ ),
+ navigateTo: jest.fn(),
+ useQuery: jest.fn( () => mockQuery ),
+} ) );
+
+jest.mock( '@woocommerce/tracks', () => ( {
+ recordEvent: jest.fn(),
+} ) );
+
+/**
+ * Internal dependencies
+ */
+import Search from '../search';
+
+const navigateToMock = navigateTo as unknown as jest.Mock;
+
+function lastNavigationQuery() {
+ const url: string = navigateToMock.mock.calls.at( -1 )[ 0 ].url;
+ return Object.fromEntries( new URLSearchParams( url.split( '?' )[ 1 ] ) );
+}
+
+describe( 'Marketplace search field', () => {
+ beforeEach( () => {
+ mockQuery = {};
+ navigateToMock.mockClear();
+ } );
+
+ it( 'shows no clear button while the field is empty', () => {
+ render( <Search /> );
+
+ expect( screen.queryByRole( 'button' ) ).toBeNull();
+ } );
+
+ it( 'shows a clear button once something is typed', () => {
+ render( <Search /> );
+
+ fireEvent.change( screen.getByRole( 'searchbox' ), {
+ target: { value: 'ship' },
+ } );
+
+ expect(
+ screen.getByRole( 'button', { name: 'Reset search' } )
+ ).toBeInTheDocument();
+ expect( navigateToMock ).not.toHaveBeenCalled();
+ } );
+
+ it( 'runs the search on Enter and moves to the extensions tab from Discover', () => {
+ render( <Search /> );
+ const input = screen.getByRole( 'searchbox' );
+
+ fireEvent.change( input, { target: { value: 'shipping' } } );
+ fireEvent.keyUp( input, { key: 'Enter' } );
+
+ expect( lastNavigationQuery() ).toMatchObject( {
+ term: 'shipping',
+ tab: 'extensions',
+ search: '1',
+ } );
+ } );
+
+ it( 'clears an active search when the clear button is used', () => {
+ mockQuery = { tab: 'extensions', term: 'shipping' };
+ render( <Search /> );
+
+ expect( screen.getByRole( 'searchbox' ) ).toHaveValue( 'shipping' );
+
+ fireEvent.click(
+ screen.getByRole( 'button', { name: 'Reset search' } )
+ );
+
+ expect( screen.getByRole( 'searchbox' ) ).toHaveValue( '' );
+ const query = lastNavigationQuery();
+ expect( query.term ).toBeUndefined();
+ expect( query.tab ).toBe( 'extensions' );
+ } );
+
+ it( 'does not navigate when the field is emptied with no search active', () => {
+ render( <Search /> );
+ const input = screen.getByRole( 'searchbox' );
+
+ fireEvent.change( input, { target: { value: 'a' } } );
+ fireEvent.change( input, { target: { value: '' } } );
+
+ expect( navigateToMock ).not.toHaveBeenCalled();
+ expect( screen.queryByRole( 'button' ) ).toBeNull();
+ } );
+} );