Commit dafafe09789 for woocommerce

commit dafafe09789e685f86720014bc5a915f643db108
Author: Miroslav Mitev <m1r0@users.noreply.github.com>
Date:   Fri Sep 4 16:27:00 2026 +0300

    Analytics: filter Downloads by customer name, username or email (#68139)

    * Analytics: filter Downloads by customer name, username or email

    The Downloads report's advanced filter only searched customer usernames,
    which shop managers rarely know. It now searches name, username and email,
    and is relabelled from "Username" to "Customer".

    The filter key and its customer_includes/customer_excludes query params are
    unchanged, so existing URLs and the report's advanced-filters JS hook keep
    working.

    Renames the name-only customer completer to customerNames, matching its
    siblings usernames and emails, and frees up the customers type for the
    general search. The Customers report's Name filter moves to customerNames
    to keep its current behaviour; its Single Customer filter keeps customers
    and so now matches on username and email too.

    Also falls back to username, then email, when resolving a selected
    customer's label, since customers registered without a first or last name
    previously resolved to a blank tag.

    * Analytics: keep the Customers report table search name-only

    The Customers report table's search box renders <Search type={searchBy}>,
    and its searchBy is "customers", so broadening that completer to
    searchby=all changed the box along with the Downloads filter.

    Two things broke. The free text row ("All customers with names that
    include X") lived on the customers completer, so moving it to
    customerNames removed it from the only place that renders it -
    appendFreeTextSearch drops it silently when the completer has none.
    And the box round-trips the picked label through
    /wc-analytics/customers?search=<label>, which matches on name alone, so
    a customer registered without a name now resolved to nothing and left
    the table empty.

    Split the search type from the query parameter name: ReportTable takes a
    searchType prop defaulting to searchBy, and the Customers report sets it
    to customerNames. searchBy stays "customers" because it also names the
    query parameter.

    * Analytics: restore customer matches and free text the search dropped

    Two follow-ups from review of the broadened customers completer.

    The API matches the search term against first_name, last_name, username
    and email joined with spaces, but the completer returned those fields as
    separate keywords. SelectControl filters the API's results against those
    keywords, so a term spanning two of them was matched server-side and then
    dropped before it reached the dropdown. Searching "Bloggs bloggs" for Zoe
    Bloggs, whose username is bloggs, returned nothing. Include the joined
    string as a keyword as well.

    getFreeTextOptions moved off the customers type when the name-only
    behaviour split out into customerNames, which silently removed the free
    text row for anyone outside this repo rendering <Search type="customers"
    allowFreeTextSearch>. Restore it, worded for the fields the type now
    searches.

    Also covers the customer label fallback, which had no test.

    * Analytics: drop the comment on the search result overflow-wrap

    * Update changelog entry for the customers free text option

    * Analytics: skip the search highlight when a match spans two fields

    * Analytics: exclude guests from the Downloads report customer filter

    * Update changelog entry for the registered customers search type

    * Analytics: move the broadened customer search to its own type

    * Analytics: keep a suggestion's closing bracket on the same line

    * Update changelog entry for the new registered customers type

    * Analytics: name the customers search after the field it searches

    * Update changelog entry for the customer names search type

    * Analytics: leave a missing customer name out of the search keywords

    getOptionKeywords declares string[], but a customer registered without a
    first or last name has an empty name. Search filtered it out on the way
    through, so nothing broke, but the completer now returns the keywords it
    promises.

    * Analytics: list every Search type in the component's README

    attributes and variableProducts were missing from the type list, and the
    intro still said only products were supported.

    * Add a changelog entry for the customer filter tag label

    ---------

    Co-authored-by: Peter Petrov <peter.petrov89@gmail.com>

diff --git a/packages/js/components/changelog/wooa7s-1541-downloads-customer-filter b/packages/js/components/changelog/wooa7s-1541-downloads-customer-filter
new file mode 100644
index 00000000000..fbabedca813
--- /dev/null
+++ b/packages/js/components/changelog/wooa7s-1541-downloads-customer-filter
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Search: add a `registeredCustomers` type that matches registered customers on name, username or email, and a `customerNames` type that replaces `customers`. `customers` is now a deprecated alias of `customerNames` and behaves identically.
diff --git a/packages/js/components/src/search/README.md b/packages/js/components/src/search/README.md
index 4be719bcacf..71365a764df 100644
--- a/packages/js/components/src/search/README.md
+++ b/packages/js/components/src/search/README.md
@@ -1,7 +1,7 @@
 # Search

 A search box which autocompletes results while typing, allowing for the user to select an existing object
-(product, order, customer, etc). Currently only products are supported.
+(product, order, customer, etc). The `type` prop lists the objects that can be searched.

 ## Usage

@@ -21,7 +21,7 @@ Name | Type | Default | Description
 `allowFreeTextSearch` | Boolean | `false` | Render additional options in the autocompleter to allow free text entering depending on the type
 `className` | String | `null` | Class name applied to parent div
 `onChange` | Function | `noop` | Function called when selected results change, passed result list
-`type` | One of: 'categories', 'countries', 'coupons', 'customers', 'downloadIps', 'emails', 'orders', 'products', 'taxes', 'usernames', 'variations', 'custom' | `null` | (required) The object type to be used in searching
+`type` | One of: 'attributes', 'categories', 'countries', 'coupons', 'customerNames', 'customers', 'downloadIps', 'emails', 'orders', 'products', 'registeredCustomers', 'taxes', 'usernames', 'variableProducts', 'variations', 'custom' | `null` | (required) The object type to be used in searching
 `autocompleter` | Completer | `null` | Custom [completer](https://github.com/WordPress/gutenberg/tree/trunk/packages/components/src/autocomplete#the-completer-interface) to be used in searching. Required when `type` is 'custom'
 `placeholder` | String | `null` | A placeholder for the search input
 `selected` | Array | `[]` | An array of objects describing selected values. If the label of the selected value is omitted, the Tag of that value will not be rendered inside the search box.
@@ -30,6 +30,21 @@ Name | Type | Default | Description
 `staticResults` | Boolean | `false` | Render results list positioned statically instead of absolutely
 `disabled` | Boolean | `false` | Whether the control is disabled or not

+### Customer types
+
+Four types search customers. They differ in which fields they match and whether guests are included.
+
+Type | Fields searched | Customers offered
+--- | --- | ---
+`customerNames` | Name | All, including guests
+`usernames` | Username | All, including guests
+`emails` | Email | All, including guests
+`registeredCustomers` | Name, username and email | Registered only
+
+`registeredCustomers` excludes guests because it exists for reports that match customers through their user id, which guests don't have. Its suggestions append the username or email when that is what matched, so it's clear why a customer is listed.
+
+`customers` is a deprecated alias of `customerNames`. It behaves identically, but its name suggests it searches the whole customer record rather than the name alone. Use `customerNames`.
+
 ### `selected` item structure

 - `id`: One of type: number, string
diff --git a/packages/js/components/src/search/autocompleters/customer-names.tsx b/packages/js/components/src/search/autocompleters/customer-names.tsx
new file mode 100644
index 00000000000..c53e2afa8de
--- /dev/null
+++ b/packages/js/components/src/search/autocompleters/customer-names.tsx
@@ -0,0 +1,94 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { addQueryArgs } from '@wordpress/url';
+import apiFetch from '@wordpress/api-fetch';
+import interpolateComponents from '@automattic/interpolate-components';
+import { createElement } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import { computeSuggestionMatch } from './utils';
+import { AutoCompleter } from './types';
+
+/**
+ * Searches customers by name. Its siblings `usernames` and `emails` search the
+ * field they are named after, and `registeredCustomers` searches all three.
+ */
+const completer: AutoCompleter = {
+	name: 'customer-names',
+	className: 'woocommerce-search__customer-names-result',
+	options( name ) {
+		const query = name
+			? {
+					search: name,
+					searchby: 'name',
+					per_page: 10,
+			  }
+			: {};
+		return apiFetch( {
+			path: addQueryArgs( '/wc-analytics/customers', query ),
+		} );
+	},
+	isDebounced: true,
+	getOptionIdentifier( customer ) {
+		return customer.id;
+	},
+	getOptionKeywords( customer ) {
+		return [ customer.name ];
+	},
+	getFreeTextOptions( query ) {
+		const label = (
+			<span key="name" className="woocommerce-search__result-name">
+				{ interpolateComponents( {
+					mixedString: __(
+						'All customers with names that include {{query /}}',
+						'woocommerce'
+					),
+					components: {
+						query: (
+							<strong className="components-form-token-field__suggestion-match">
+								{ query }
+							</strong>
+						),
+					},
+				} ) }
+			</span>
+		);
+		const nameOption = {
+			key: 'name',
+			label,
+			value: { id: query, name: query },
+		};
+
+		return [ nameOption ];
+	},
+	getOptionLabel( customer, query ) {
+		const match = computeSuggestionMatch( customer.name, query );
+		return (
+			<span
+				key="name"
+				className="woocommerce-search__result-name"
+				aria-label={ customer.name }
+			>
+				{ match?.suggestionBeforeMatch }
+				<strong className="components-form-token-field__suggestion-match">
+					{ match?.suggestionMatch }
+				</strong>
+				{ match?.suggestionAfterMatch }
+			</span>
+		);
+	},
+	// This is slightly different than gutenberg/Autocomplete, we don't support different methods
+	// of replace/insertion, so we can just return the value.
+	getOptionCompletion( customer ) {
+		return {
+			key: customer.id,
+			label: customer.name,
+		};
+	},
+};
+
+export default completer;
diff --git a/packages/js/components/src/search/autocompleters/customers.tsx b/packages/js/components/src/search/autocompleters/customers.tsx
index 2e82c678f70..050140b40ae 100644
--- a/packages/js/components/src/search/autocompleters/customers.tsx
+++ b/packages/js/components/src/search/autocompleters/customers.tsx
@@ -1,90 +1,20 @@
-/**
- * External dependencies
- */
-import { __ } from '@wordpress/i18n';
-import { addQueryArgs } from '@wordpress/url';
-import apiFetch from '@wordpress/api-fetch';
-import interpolateComponents from '@automattic/interpolate-components';
-import { createElement } from '@wordpress/element';
-
 /**
  * Internal dependencies
  */
-import { computeSuggestionMatch } from './utils';
+import customerNames from './customer-names';
 import { AutoCompleter } from './types';

+/**
+ * Searches customers by name.
+ *
+ * @deprecated Use `customerNames`, which is named after the field it searches,
+ * the way the sibling `usernames` and `emails` completers are. This is an alias
+ * of it and behaves identically.
+ */
 const completer: AutoCompleter = {
+	...customerNames,
 	name: 'customers',
 	className: 'woocommerce-search__customers-result',
-	options( name ) {
-		const query = name
-			? {
-					search: name,
-					searchby: 'name',
-					per_page: 10,
-			  }
-			: {};
-		return apiFetch( {
-			path: addQueryArgs( '/wc-analytics/customers', query ),
-		} );
-	},
-	isDebounced: true,
-	getOptionIdentifier( customer ) {
-		return customer.id;
-	},
-	getOptionKeywords( customer ) {
-		return [ customer.name ];
-	},
-	getFreeTextOptions( query ) {
-		const label = (
-			<span key="name" className="woocommerce-search__result-name">
-				{ interpolateComponents( {
-					mixedString: __(
-						'All customers with names that include {{query /}}',
-						'woocommerce'
-					),
-					components: {
-						query: (
-							<strong className="components-form-token-field__suggestion-match">
-								{ query }
-							</strong>
-						),
-					},
-				} ) }
-			</span>
-		);
-		const nameOption = {
-			key: 'name',
-			label,
-			value: { id: query, name: query },
-		};
-
-		return [ nameOption ];
-	},
-	getOptionLabel( customer, query ) {
-		const match = computeSuggestionMatch( customer.name, query );
-		return (
-			<span
-				key="name"
-				className="woocommerce-search__result-name"
-				aria-label={ customer.name }
-			>
-				{ match?.suggestionBeforeMatch }
-				<strong className="components-form-token-field__suggestion-match">
-					{ match?.suggestionMatch }
-				</strong>
-				{ match?.suggestionAfterMatch }
-			</span>
-		);
-	},
-	// This is slightly different than gutenberg/Autocomplete, we don't support different methods
-	// of replace/insertion, so we can just return the value.
-	getOptionCompletion( customer ) {
-		return {
-			key: customer.id,
-			label: customer.name,
-		};
-	},
 };

 export default completer;
diff --git a/packages/js/components/src/search/autocompleters/index.ts b/packages/js/components/src/search/autocompleters/index.ts
index 072af7163af..168d19bba3a 100644
--- a/packages/js/components/src/search/autocompleters/index.ts
+++ b/packages/js/components/src/search/autocompleters/index.ts
@@ -5,11 +5,13 @@ export { default as attributes } from './attributes';
 export { default as productCategory } from './categories';
 export { default as countries } from './countries';
 export { default as coupons } from './coupons';
+export { default as customerNames } from './customer-names';
 export { default as customers } from './customers';
 export { default as downloadIps } from './download-ips';
 export { default as emails } from './emails';
 export { default as orders } from './orders';
 export { default as product } from './product';
+export { default as registeredCustomers } from './registered-customers';
 export { default as taxes } from './taxes';
 export { default as usernames } from './usernames';
 export { default as variableProduct } from './variable-product';
diff --git a/packages/js/components/src/search/autocompleters/registered-customers.tsx b/packages/js/components/src/search/autocompleters/registered-customers.tsx
new file mode 100644
index 00000000000..323b406e1b4
--- /dev/null
+++ b/packages/js/components/src/search/autocompleters/registered-customers.tsx
@@ -0,0 +1,149 @@
+/**
+ * External dependencies
+ */
+import { __, sprintf } from '@wordpress/i18n';
+import { addQueryArgs } from '@wordpress/url';
+import { decodeEntities } from '@wordpress/html-entities';
+import apiFetch from '@wordpress/api-fetch';
+import { createElement } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import { computeSuggestionMatch } from './utils';
+import { AutoCompleter } from './types';
+
+type Customer = {
+	id: number;
+	name?: string;
+	username?: string;
+	email?: string;
+};
+
+/**
+ * Get the name to display for a customer. Customers can be registered without a
+ * first or last name, so fall back to the fields that are always set.
+ *
+ * @param customer Customer as returned by the API.
+ * @return The customer's display name.
+ */
+const getCustomerName = ( customer: Customer ) =>
+	customer.name || customer.username || customer.email || '';
+
+/**
+ * Get the text of a suggestion. The name is used on its own when it matches the
+ * search term, otherwise the username or email that did match is appended so
+ * it's clear why the customer is listed.
+ *
+ * @param customer Customer as returned by the API.
+ * @param query    The search term.
+ * @return The suggestion text.
+ */
+const getSuggestion = ( customer: Customer, query: string ) => {
+	const name = getCustomerName( customer );
+	const search = query.toLocaleLowerCase();
+
+	if ( name.toLocaleLowerCase().includes( search ) ) {
+		return name;
+	}
+
+	const matched = [ customer.username, customer.email ].find(
+		( field ) => field?.toLocaleLowerCase().includes( search )
+	);
+
+	if ( ! matched ) {
+		return name;
+	}
+
+	return sprintf(
+		/* translators: 1: Customer name. 2: The customer username or email address that matched the search term. */
+		__( '%1$s (%2$s)', 'woocommerce' ),
+		name,
+		matched
+	);
+};
+
+/**
+ * A customer search across name, username and email, restricted to registered
+ * customers for reports that can't match guests (e.g. Downloads, which matches
+ * customers to downloads through their user id).
+ */
+const completer: AutoCompleter = {
+	name: 'registered-customers',
+	className: 'woocommerce-search__registered-customers-result',
+	options( search ) {
+		const query = search
+			? {
+					search,
+					searchby: 'all',
+					user_type: 'registered',
+					per_page: 10,
+			  }
+			: { user_type: 'registered' };
+		return apiFetch( {
+			path: addQueryArgs( '/wc-analytics/customers', query ),
+		} );
+	},
+	isDebounced: true,
+	getOptionIdentifier( customer ) {
+		return customer.id;
+	},
+	getOptionKeywords( customer ) {
+		const fields = [
+			customer.name,
+			customer.username,
+			customer.email,
+		].filter( Boolean );
+		// The API matches the search term against these fields joined together,
+		// so the joined string has to be a keyword too. Without it a term that
+		// spans two fields is matched by the API and then filtered back out.
+		return [ ...fields, fields.join( ' ' ) ];
+	},
+	getOptionLabel( customer, query ) {
+		const suggestion = getSuggestion( customer, query );
+
+		// A term can match the customer's fields joined together without
+		// appearing in any single one (see getOptionKeywords). There is no
+		// match to highlight in the suggestion then, so render it plain.
+		if (
+			! suggestion
+				.toLocaleLowerCase()
+				.includes( query.toLocaleLowerCase() )
+		) {
+			return (
+				<span
+					key="name"
+					className="woocommerce-search__result-name"
+					aria-label={ suggestion }
+				>
+					{ decodeEntities( suggestion ) }
+				</span>
+			);
+		}
+
+		const match = computeSuggestionMatch( suggestion, query );
+		return (
+			<span
+				key="name"
+				className="woocommerce-search__result-name"
+				aria-label={ suggestion }
+			>
+				{ match?.suggestionBeforeMatch }
+				<strong className="components-form-token-field__suggestion-match">
+					{ match?.suggestionMatch }
+				</strong>
+				{ match?.suggestionAfterMatch }
+			</span>
+		);
+	},
+	// This is slightly different than gutenberg/Autocomplete, we don't support different methods
+	// of replace/insertion, so we can just return the value.
+	getOptionCompletion( customer ) {
+		return {
+			key: customer.id,
+			label: getCustomerName( customer ),
+		};
+	},
+};
+
+export default completer;
diff --git a/packages/js/components/src/search/autocompleters/test/customer-names.test.tsx b/packages/js/components/src/search/autocompleters/test/customer-names.test.tsx
new file mode 100644
index 00000000000..6f086b65b12
--- /dev/null
+++ b/packages/js/components/src/search/autocompleters/test/customer-names.test.tsx
@@ -0,0 +1,60 @@
+/**
+ * External dependencies
+ */
+import apiFetch from '@wordpress/api-fetch';
+import { getQueryArg } from '@wordpress/url';
+
+/**
+ * Internal dependencies
+ */
+import customerNames from '../customer-names';
+import customers from '../customers';
+
+jest.mock( '@wordpress/api-fetch', () => jest.fn() );
+
+const mockedApiFetch = apiFetch as unknown as jest.Mock;
+
+describe( 'customer names autocompleter', () => {
+	beforeEach( () => {
+		mockedApiFetch.mockReset();
+		mockedApiFetch.mockResolvedValue( [] );
+	} );
+
+	it( 'searches the name field only', () => {
+		customerNames.options( 'zoe' );
+		const path = mockedApiFetch.mock.calls[ 0 ][ 0 ].path;
+
+		expect( getQueryArg( path, 'searchby' ) ).toBe( 'name' );
+		expect( getQueryArg( path, 'user_type' ) ).toBeUndefined();
+	} );
+
+	it( 'matches customers on their name only', () => {
+		expect(
+			customerNames.getOptionKeywords( {
+				id: 1,
+				name: 'Zoe Bloggs',
+				username: 'bloggs',
+				email: 'zoe@example.test',
+			} )
+		).toEqual( [ 'Zoe Bloggs' ] );
+	} );
+} );
+
+describe( 'deprecated customers autocompleter', () => {
+	beforeEach( () => {
+		mockedApiFetch.mockReset();
+		mockedApiFetch.mockResolvedValue( [] );
+	} );
+
+	it( 'behaves like the customer names completer it aliases', () => {
+		const { name, className, ...behaviour } = customerNames;
+		expect( customers ).toMatchObject( behaviour );
+	} );
+
+	it( 'keeps its own name and class so existing consumers are unaffected', () => {
+		expect( customers.name ).toBe( 'customers' );
+		expect( customers.className ).toBe(
+			'woocommerce-search__customers-result'
+		);
+	} );
+} );
diff --git a/packages/js/components/src/search/autocompleters/test/registered-customers.test.tsx b/packages/js/components/src/search/autocompleters/test/registered-customers.test.tsx
new file mode 100644
index 00000000000..5021561aa22
--- /dev/null
+++ b/packages/js/components/src/search/autocompleters/test/registered-customers.test.tsx
@@ -0,0 +1,115 @@
+/**
+ * External dependencies
+ */
+import apiFetch from '@wordpress/api-fetch';
+import { getQueryArg } from '@wordpress/url';
+import { render, screen } from '@testing-library/react';
+import { createElement } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import registeredCustomers from '../registered-customers';
+
+jest.mock( '@wordpress/api-fetch', () => jest.fn() );
+
+const mockedApiFetch = apiFetch as unknown as jest.Mock;
+
+describe( 'registered customers autocompleter', () => {
+	const named = {
+		id: 1,
+		name: 'Zoe Bloggs',
+		username: 'bloggs',
+		email: 'zoe@example.test',
+	};
+	const unnamed = {
+		id: 2,
+		name: '',
+		username: 'zoemarketing',
+		email: 'hello@zoeshop.test',
+	};
+
+	beforeEach( () => {
+		mockedApiFetch.mockReset();
+		mockedApiFetch.mockResolvedValue( [] );
+	} );
+
+	const getLabelText = ( customer: unknown, query: string ) => {
+		const { container } = render(
+			<>{ registeredCustomers.getOptionLabel( customer, query ) }</>
+		);
+		return container.textContent;
+	};
+
+	it( 'searches every customer field, and excludes guests', () => {
+		registeredCustomers.options( 'zoe' );
+		const path = mockedApiFetch.mock.calls[ 0 ][ 0 ].path;
+
+		expect( getQueryArg( path, 'searchby' ) ).toBe( 'all' );
+		expect( getQueryArg( path, 'user_type' ) ).toBe( 'registered' );
+	} );
+
+	it( 'matches customers on their name, username and email', () => {
+		expect( registeredCustomers.getOptionKeywords( named ) ).toEqual( [
+			'Zoe Bloggs',
+			'bloggs',
+			'zoe@example.test',
+			// The API matches against the fields joined together, so a search
+			// term spanning two of them has to be a keyword as well.
+			'Zoe Bloggs bloggs zoe@example.test',
+		] );
+	} );
+
+	it( 'leaves out the name of a customer registered without one', () => {
+		expect( registeredCustomers.getOptionKeywords( unnamed ) ).toEqual( [
+			'zoemarketing',
+			'hello@zoeshop.test',
+			'zoemarketing hello@zoeshop.test',
+		] );
+	} );
+
+	it( 'shows the name on its own when it matches the search term', () => {
+		expect( getLabelText( named, 'zoe' ) ).toBe( 'Zoe Bloggs' );
+	} );
+
+	it( 'shows the matched field alongside the name when the name does not match', () => {
+		expect( getLabelText( named, 'example.test' ) ).toBe(
+			'Zoe Bloggs (zoe@example.test)'
+		);
+	} );
+
+	it( 'falls back to the username when the customer has no name', () => {
+		expect( getLabelText( unnamed, 'zoemarketing' ) ).toBe(
+			'zoemarketing'
+		);
+		expect( registeredCustomers.getOptionCompletion( unnamed ) ).toEqual( {
+			key: 2,
+			label: 'zoemarketing',
+		} );
+	} );
+
+	it( 'highlights the part of the suggestion that matched', () => {
+		render( <>{ registeredCustomers.getOptionLabel( named, 'Blog' ) }</> );
+
+		expect( screen.getByText( 'Blog' ).tagName ).toBe( 'STRONG' );
+	} );
+
+	it( 'highlights the match inside the appended field', () => {
+		render( <>{ registeredCustomers.getOptionLabel( named, 'example' ) }</> );
+
+		expect( screen.getByText( 'example' ).tagName ).toBe( 'STRONG' );
+	} );
+
+	it( 'skips the highlight when the match spans two fields', () => {
+		// 'Bloggs bloggs' only matches the name and username joined together,
+		// so there is no match to highlight in the suggestion.
+		const { container } = render(
+			<>
+				{ registeredCustomers.getOptionLabel( named, 'Bloggs bloggs' ) }
+			</>
+		);
+
+		expect( container.textContent ).toBe( 'Zoe Bloggs' );
+		expect( container.querySelector( 'strong' ) ).toBeNull();
+	} );
+} );
diff --git a/packages/js/components/src/search/search.tsx b/packages/js/components/src/search/search.tsx
index 73f5147da76..ba2fb352362 100644
--- a/packages/js/components/src/search/search.tsx
+++ b/packages/js/components/src/search/search.tsx
@@ -14,12 +14,14 @@ import {
 	attributes,
 	countries,
 	coupons,
+	customerNames,
 	customers,
 	downloadIps,
 	emails,
 	orders,
 	product,
 	productCategory,
+	registeredCustomers,
 	taxes,
 	usernames,
 	variableProduct,
@@ -65,6 +67,8 @@ export class Search extends Component< SearchProps, SearchState > {
 				return countries;
 			case 'coupons':
 				return coupons;
+			case 'customerNames':
+				return customerNames;
 			case 'customers':
 				return customers;
 			case 'downloadIps':
@@ -75,6 +79,8 @@ export class Search extends Component< SearchProps, SearchState > {
 				return orders;
 			case 'products':
 				return product;
+			case 'registeredCustomers':
+				return registeredCustomers;
 			case 'taxes':
 				return taxes;
 			case 'usernames':
diff --git a/packages/js/components/src/search/style.scss b/packages/js/components/src/search/style.scss
index 6f50c26b3e7..b62cb5a3a8c 100644
--- a/packages/js/components/src/search/style.scss
+++ b/packages/js/components/src/search/style.scss
@@ -121,4 +121,5 @@

 .woocommerce-search__result-name {
 	text-decoration: underline; // @todo Not actually a link, should underline?
+	word-break: break-all;
 }
diff --git a/packages/js/components/src/search/types.ts b/packages/js/components/src/search/types.ts
index 75d1c2775d7..833e5560b99 100644
--- a/packages/js/components/src/search/types.ts
+++ b/packages/js/components/src/search/types.ts
@@ -9,11 +9,13 @@ export type SearchType =
 	| 'categories'
 	| 'countries'
 	| 'coupons'
+	| 'customerNames'
 	| 'customers'
 	| 'downloadIps'
 	| 'emails'
 	| 'orders'
 	| 'products'
+	| 'registeredCustomers'
 	| 'taxes'
 	| 'usernames'
 	| 'variableProducts'
diff --git a/plugins/woocommerce/changelog/wooa7s-1541-customer-filter-tag-label b/plugins/woocommerce/changelog/wooa7s-1541-customer-filter-tag-label
new file mode 100644
index 00000000000..67beb1324ce
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooa7s-1541-customer-filter-tag-label
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Analytics: label a customer filter tag with the username or email when the customer is registered without a name. Affects the Downloads and Customers reports.
diff --git a/plugins/woocommerce/changelog/wooa7s-1541-downloads-customer-filter b/plugins/woocommerce/changelog/wooa7s-1541-downloads-customer-filter
new file mode 100644
index 00000000000..0ed57fa8011
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooa7s-1541-downloads-customer-filter
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Analytics: search the Downloads report's advanced filter by customer name, username or email instead of username only.
diff --git a/plugins/woocommerce/client/admin/client/analytics/report/customers/config.js b/plugins/woocommerce/client/admin/client/analytics/report/customers/config.js
index 901000cf29e..ee4b752a17c 100644
--- a/plugins/woocommerce/client/admin/client/analytics/report/customers/config.js
+++ b/plugins/woocommerce/client/admin/client/analytics/report/customers/config.js
@@ -49,7 +49,7 @@ export const filters = applyFilters( CUSTOMERS_REPORT_FILTERS_FILTER, [
 						chartMode: 'item-comparison',
 						path: [ 'select_customer' ],
 						settings: {
-							type: 'customers',
+							type: 'customerNames',
 							param: 'customers',
 							getLabels: getCustomerLabels,
 							labels: {
@@ -127,7 +127,7 @@ export const advancedFilters = applyFilters(
 				],
 				input: {
 					component: 'Search',
-					type: 'customers',
+					type: 'customerNames',
 					getLabels: getRequestByIdString(
 						NAMESPACE + '/customers',
 						( customer ) => ( {
diff --git a/plugins/woocommerce/client/admin/client/analytics/report/downloads/config.js b/plugins/woocommerce/client/admin/client/analytics/report/downloads/config.js
index be0f480096f..8009676a270 100644
--- a/plugins/woocommerce/client/admin/client/analytics/report/downloads/config.js
+++ b/plugins/woocommerce/client/admin/client/analytics/report/downloads/config.js
@@ -63,8 +63,6 @@ export const filters = applyFilters( DOWNLOADS_REPORT_FILTERS_FILTER, [
 	},
 ] );

-/*eslint-disable max-len*/
-
 /**
  * Downloads Report Advanced Filters.
  *
@@ -115,49 +113,35 @@ export const advancedFilters = applyFilters(
 			},
 			customer: {
 				labels: {
-					add: __( 'Username', 'woocommerce' ),
-					placeholder: __(
-						'Search customer username',
-						'woocommerce'
-					),
-					remove: __(
-						'Remove customer username filter',
-						'woocommerce'
-					),
-					rule: __(
-						'Select a customer username filter match',
-						'woocommerce'
-					),
-					/* translators: A sentence describing a customer username filter. See screen shot for context: https://cloudup.com/ccxhyH2mEDg */
+					add: __( 'Customer', 'woocommerce' ),
+					placeholder: __( 'Search customer', 'woocommerce' ),
+					remove: __( 'Remove customer filter', 'woocommerce' ),
+					rule: __( 'Select a customer filter match', 'woocommerce' ),
+					/* translators: A sentence describing a customer filter. See screen shot for context: https://cloudup.com/ccxhyH2mEDg */
 					title: __(
-						'<title>Username</title> <rule/> <filter />',
+						'<title>Customer</title> <rule/> <filter />',
 						'woocommerce'
 					),
-					filter: __( 'Select customer username', 'woocommerce' ),
+					filter: __( 'Select customer', 'woocommerce' ),
 				},
 				rules: [
 					{
 						value: 'includes',
-						/* translators: Sentence fragment, logical, "Includes" refers to customer usernames including a given username(s). Screenshot for context: https://cloudup.com/ccxhyH2mEDg */
-						label: _x(
-							'Includes',
-							'customer usernames',
-							'woocommerce'
-						),
+						/* translators: Sentence fragment, logical, "Includes" refers to customers including a given customer(s). Screenshot for context: https://cloudup.com/ccxhyH2mEDg */
+						label: _x( 'Includes', 'customers', 'woocommerce' ),
 					},
 					{
 						value: 'excludes',
-						/* translators: Sentence fragment, logical, "Excludes" refers to customer usernames excluding a given username(s). Screenshot for context: https://cloudup.com/ccxhyH2mEDg */
-						label: _x(
-							'Excludes',
-							'customer usernames',
-							'woocommerce'
-						),
+						/* translators: Sentence fragment, logical, "Excludes" refers to customers excluding a given customer(s). Screenshot for context: https://cloudup.com/ccxhyH2mEDg */
+						label: _x( 'Excludes', 'customers', 'woocommerce' ),
 					},
 				],
 				input: {
 					component: 'Search',
-					type: 'usernames',
+					// The report matches customers to downloads through their
+					// user id, which guests don't have, so only registered
+					// customers can be offered.
+					type: 'registeredCustomers',
 					getLabels: getCustomerLabels,
 				},
 			},
@@ -246,4 +230,3 @@ export const advancedFilters = applyFilters(
 		},
 	}
 );
-/*eslint-enable max-len*/
diff --git a/plugins/woocommerce/client/admin/client/lib/async-requests/index.js b/plugins/woocommerce/client/admin/client/lib/async-requests/index.js
index 7e5e997e88d..e384839fb18 100644
--- a/plugins/woocommerce/client/admin/client/lib/async-requests/index.js
+++ b/plugins/woocommerce/client/admin/client/lib/async-requests/index.js
@@ -67,7 +67,9 @@ export const getCustomerLabels = getRequestByIdString(
 	NAMESPACE + '/customers',
 	( customer ) => ( {
 		key: customer.id,
-		label: customer.name,
+		// Customers can be registered without a first or last name, so fall back
+		// to the fields that are always set.
+		label: customer.name || customer.username || customer.email,
 	} )
 );

diff --git a/plugins/woocommerce/client/admin/client/lib/async-requests/test/index.js b/plugins/woocommerce/client/admin/client/lib/async-requests/test/index.js
new file mode 100644
index 00000000000..f6dbca49563
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/lib/async-requests/test/index.js
@@ -0,0 +1,54 @@
+/**
+ * External dependencies
+ */
+import apiFetch from '@wordpress/api-fetch';
+
+/**
+ * Internal dependencies
+ */
+import { getCustomerLabels } from '../index';
+
+jest.mock( '@wordpress/api-fetch', () => jest.fn() );
+
+describe( 'getCustomerLabels', () => {
+	beforeEach( () => {
+		apiFetch.mockReset();
+	} );
+
+	it( 'labels a customer with their name', async () => {
+		apiFetch.mockResolvedValue( [
+			{
+				id: 1,
+				name: 'Zoe Bloggs',
+				username: 'bloggs',
+				email: 'zoe@example.test',
+			},
+		] );
+
+		await expect( getCustomerLabels( '1' ) ).resolves.toEqual( [
+			{ key: 1, label: 'Zoe Bloggs' },
+		] );
+	} );
+
+	it( 'falls back to the username, then the email, for customers with no name', async () => {
+		apiFetch.mockResolvedValue( [
+			{
+				id: 2,
+				name: '',
+				username: 'zoemarketing',
+				email: 'hello@zoeshop.test',
+			},
+			{ id: 3, name: '', username: '', email: 'nobody@zoeshop.test' },
+		] );
+
+		await expect( getCustomerLabels( '2,3' ) ).resolves.toEqual( [
+			{ key: 2, label: 'zoemarketing' },
+			{ key: 3, label: 'nobody@zoeshop.test' },
+		] );
+	} );
+
+	it( 'does not call the API when there are no ids', async () => {
+		await expect( getCustomerLabels( '' ) ).resolves.toEqual( [] );
+		expect( apiFetch ).not.toHaveBeenCalled();
+	} );
+} );