Commit cd7dde9becb for woocommerce
commit cd7dde9becb87c47df94da6d586c748f10bbe468
Author: Peter Petrov <peter.petrov89@gmail.com>
Date: Wed Aug 19 12:54:11 2026 +0300
Add role column to Analytics customers report and CSV export (#67653)
* Add role column to Analytics customers report and CSV export
* Fix country cell index in customers table test after role column insertion
* Remove stale prepare_item_for_export baseline entry resolved by docblock fix
* Cover role column in customers report CSV export test
* Make role column visible by default in customers report
diff --git a/plugins/woocommerce/changelog/add-role-to-customers-report b/plugins/woocommerce/changelog/add-role-to-customers-report
new file mode 100644
index 00000000000..a6413b6f67b
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-role-to-customers-report
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add a Role column to the Analytics > Customers report and its CSV export, showing each customer's WordPress role(s).
diff --git a/plugins/woocommerce/client/admin/client/analytics/report/customers/table.js b/plugins/woocommerce/client/admin/client/analytics/report/customers/table.js
index 0cf14694016..cfe49039982 100644
--- a/plugins/woocommerce/client/admin/client/analytics/report/customers/table.js
+++ b/plugins/woocommerce/client/admin/client/analytics/report/customers/table.js
@@ -49,6 +49,10 @@ function CustomersReportTable( {
key: 'username',
hiddenByDefault: true,
},
+ {
+ label: __( 'Role', 'woocommerce' ),
+ key: 'role',
+ },
{
label: __( 'Last active', 'woocommerce' ),
key: 'date_last_active',
@@ -144,6 +148,7 @@ function CustomersReportTable( {
user_id: userId,
orders_count: ordersCount,
username,
+ role,
total_spend: totalSpend,
postcode,
city,
@@ -201,6 +206,10 @@ function CustomersReportTable( {
display: username,
value: username,
},
+ {
+ display: role,
+ value: role,
+ },
{
display: dateLastActiveDisplay,
value: dateLastActive,
diff --git a/plugins/woocommerce/client/admin/client/analytics/report/customers/test/table.test.js b/plugins/woocommerce/client/admin/client/analytics/report/customers/test/table.test.js
index 2178fb6b124..1a0a96d6ff2 100644
--- a/plugins/woocommerce/client/admin/client/analytics/report/customers/test/table.test.js
+++ b/plugins/woocommerce/client/admin/client/analytics/report/customers/test/table.test.js
@@ -71,8 +71,8 @@ const baseCustomer = {
country: '',
};
-// Country cell is the 9th column (0-indexed: 8) per getHeadersContent in table.js.
-const COUNTRY_COL = 8;
+// Country cell is the 10th column (0-indexed: 9) per getHeadersContent in table.js.
+const COUNTRY_COL = 9;
function getCountryCell( customer ) {
captured.getRowsContent = null;
@@ -162,8 +162,8 @@ describe( 'CustomersReportTable country cell', () => {
describe( 'CustomersReportTable phone cells', () => {
// Phone cells are the last two columns per getHeadersContent in table.js.
- const BILLING_PHONE_COL = 12;
- const SHIPPING_PHONE_COL = 13;
+ const BILLING_PHONE_COL = 13;
+ const SHIPPING_PHONE_COL = 14;
beforeEach( () => {
jest.clearAllMocks();
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index d07aefbf59c..ae53468e843 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -41085,12 +41085,6 @@ parameters:
count: 1
path: src/Admin/API/Reports/Customers/Controller.php
- -
- message: '#^@param object \$export_item does not accept actual type of parameter\: array\{name\: mixed, username\: mixed, last_active\: mixed, registered\: mixed, email\: mixed, orders_count\: mixed, total_spend\: string, avg_order_value\: string, \.\.\.\}\.$#'
- identifier: parameter.phpDocType
- count: 1
- path: src/Admin/API/Reports/Customers/Controller.php
-
-
message: '#^@param object \$item does not accept actual type of parameter\: array\.$#'
identifier: parameter.phpDocType
@@ -41121,12 +41115,6 @@ parameters:
count: 1
path: src/Admin/API/Reports/Customers/Controller.php
- -
- message: '#^Method Automattic\\WooCommerce\\Admin\\API\\Reports\\Customers\\Controller\:\:prepare_item_for_export\(\) should return array but returns object\.$#'
- identifier: return.type
- count: 1
- path: src/Admin/API/Reports/Customers/Controller.php
-
-
message: '#^Method Automattic\\WooCommerce\\Admin\\API\\Reports\\Customers\\Controller\:\:prepare_item_for_response\(\) has parameter \$request with generic class WP_REST_Request but does not specify its types\: T$#'
identifier: missingType.generics
diff --git a/plugins/woocommerce/src/Admin/API/Reports/Customers/Controller.php b/plugins/woocommerce/src/Admin/API/Reports/Customers/Controller.php
index a608bf67acc..236f607fb66 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Customers/Controller.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Customers/Controller.php
@@ -42,8 +42,19 @@ class Controller extends GenericController implements ExportableInterface {
* @return mixed Results from the data store.
*/
protected function get_datastore_data( $query_args = array() ) {
- $query = new Query( $query_args );
- return $query->get_data();
+ $query = new Query( $query_args );
+ $report_data = $query->get_data();
+
+ // Warm the user caches in one query so the per-item role lookup in
+ // prepare_item_for_response() doesn't query per row.
+ if ( ! empty( $report_data->data ) ) {
+ $user_ids = array_filter( array_map( 'absint', wp_list_pluck( $report_data->data, 'user_id' ) ) );
+ if ( ! empty( $user_ids ) ) {
+ cache_users( $user_ids );
+ }
+ }
+
+ return $report_data;
}
/**
@@ -240,6 +251,7 @@ class Controller extends GenericController implements ExportableInterface {
// Last active date is local time.
$data['date_last_active_gmt'] = wc_rest_prepare_date_response( $data['date_last_active'], false );
$data['date_last_active'] = wc_rest_prepare_date_response( $data['date_last_active'] );
+ $data['role'] = $this->get_user_role_names( $data['user_id'] ?? 0 );
// Rows can be served from a report cache written before these columns existed.
$data['billing_phone'] = $data['billing_phone'] ?? '';
$data['shipping_phone'] = $data['shipping_phone'] ?? '';
@@ -261,6 +273,35 @@ class Controller extends GenericController implements ExportableInterface {
return apply_filters( 'woocommerce_rest_prepare_report_customers', $response, $report, $request );
}
+ /**
+ * Get the localized role names of a user as a comma-separated string.
+ *
+ * Roles are resolved at response time rather than stored in the customer
+ * lookup table, since role changes (e.g. via WP_User::set_role() or plugins
+ * editing capabilities directly) would leave a stored copy stale.
+ *
+ * @param int $user_id User ID, 0 for guest customers.
+ * @return string Comma-separated localized role names, empty for guests and deleted users.
+ */
+ protected function get_user_role_names( $user_id ) {
+ if ( empty( $user_id ) ) {
+ return '';
+ }
+
+ $user = get_userdata( $user_id );
+ if ( ! $user ) {
+ return '';
+ }
+
+ $role_names = array();
+ foreach ( $user->roles as $role ) {
+ $name = wp_roles()->role_names[ $role ] ?? $role;
+ $role_names[] = translate_user_role( $name );
+ }
+
+ return implode( ', ', $role_names );
+ }
+
/**
* Prepare links for the request.
*
@@ -335,6 +376,12 @@ class Controller extends GenericController implements ExportableInterface {
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
+ 'role' => array(
+ 'description' => __( 'Role(s) of the user, comma-separated. Empty for guest customers.', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
'country' => array(
'description' => __( 'Country / Region.', 'woocommerce' ),
'type' => 'string',
@@ -707,6 +754,7 @@ class Controller extends GenericController implements ExportableInterface {
$export_columns = array(
'name' => __( 'Name', 'woocommerce' ),
'username' => __( 'Username', 'woocommerce' ),
+ 'role' => __( 'Role', 'woocommerce' ),
'last_active' => __( 'Last Active', 'woocommerce' ),
'registered' => __( 'Sign Up', 'woocommerce' ),
'email' => __( 'Email', 'woocommerce' ),
@@ -743,6 +791,7 @@ class Controller extends GenericController implements ExportableInterface {
$export_item = array(
'name' => $item['name'],
'username' => $item['username'],
+ 'role' => $item['role'] ?? '',
'last_active' => $item['date_last_active'],
'registered' => $item['date_registered'],
'email' => $item['email'],
@@ -760,7 +809,7 @@ class Controller extends GenericController implements ExportableInterface {
/**
* Filter the column values of an item being exported.
*
- * @param object $export_item Key value pair of Column ID => Row Value.
+ * @param array $export_item Key value pair of Column ID => Row Value.
* @param object $item Single report item/row.
* @since 4.0.0
*/
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-customers.php b/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-customers.php
index 4709afcae5b..7dbe140c5ac 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-customers.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-customers.php
@@ -79,6 +79,7 @@ class WC_Admin_Tests_API_Reports_Customers extends WC_REST_Unit_Test_Case {
$this->assertArrayHasKey( 'last_name', $schema );
$this->assertArrayHasKey( 'email', $schema );
$this->assertArrayHasKey( 'username', $schema );
+ $this->assertArrayHasKey( 'role', $schema );
$this->assertArrayHasKey( 'country', $schema );
$this->assertArrayHasKey( 'city', $schema );
$this->assertArrayHasKey( 'state', $schema );
@@ -107,7 +108,7 @@ class WC_Admin_Tests_API_Reports_Customers extends WC_REST_Unit_Test_Case {
$data = $response->get_data();
$properties = $data['schema']['properties'];
- $this->assertCount( 20, $properties );
+ $this->assertCount( 21, $properties );
$this->assert_report_item_schema( $properties );
}
@@ -203,6 +204,70 @@ class WC_Admin_Tests_API_Reports_Customers extends WC_REST_Unit_Test_Case {
$this->assertEquals( $admin_id, $reports[1]['user_id'] );
}
+ /**
+ * @testdox Should include localized user roles in the response and an empty role for guests.
+ */
+ public function test_customer_role_in_response() {
+ wp_set_current_user( $this->user );
+
+ $customer = WC_Helper_Customer::create_customer( 'rolecustomer', 'password', 'role-customer@example.com' );
+
+ $editor_id = wp_insert_user(
+ array(
+ 'user_login' => 'roleeditor',
+ 'user_pass' => 'password',
+ 'user_email' => 'role-editor@example.com',
+ 'role' => 'editor',
+ )
+ );
+ $editor = new WP_User( $editor_id );
+ $editor->add_role( 'shop_manager' );
+
+ // Editors are not synced as registered customers, so they enter the report via an order.
+ $editor_order = WC_Helper_Order::create_order( $editor_id );
+
+ // Order with guest customer (no account).
+ $guest_order = WC_Helper_Order::create_order( 0 );
+ $guest_order->set_billing_email( 'role-guest@example.com' );
+ $guest_order->save();
+
+ // Sync the lookup table directly to keep the test independent of the queue.
+ $this->assertNotFalse( CustomersDataStore::update_registered_customer( $customer->get_id() ) );
+ $this->assertGreaterThan( 0, CustomersDataStore::get_or_create_customer_from_order( $editor_order ) );
+ $this->assertGreaterThan( 0, CustomersDataStore::get_or_create_customer_from_order( $guest_order ) );
+
+ $request = new WP_REST_Request( 'GET', $this->endpoint );
+ $request->set_query_params( array( 'per_page' => 10 ) );
+ $response = $this->server->dispatch( $request );
+ $reports = $response->get_data();
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertCount( 3, $reports );
+
+ $roles_by_user_id = array();
+ foreach ( $reports as $report ) {
+ $roles_by_user_id[ (int) $report['user_id'] ] = $report['role'];
+ }
+
+ $this->assertEquals( 'Customer', $roles_by_user_id[ $customer->get_id() ], 'Registered customers should report their role' );
+ $this->assertEquals( 'Editor, Shop manager', $roles_by_user_id[ $editor_id ], 'Users with multiple roles should report all of them' );
+ $this->assertSame( '', $roles_by_user_id[0], 'Guest customers should report an empty role' );
+
+ $controller = new \Automattic\WooCommerce\Admin\API\Reports\Customers\Controller();
+ $export_columns = $controller->get_export_columns();
+ $this->assertArrayHasKey( 'role', $export_columns, 'CSV export should include a role column' );
+ $this->assertEquals( 'Role', $export_columns['role'] );
+
+ $export_roles_by_user_id = array();
+ foreach ( $reports as $report ) {
+ $export_item = $controller->prepare_item_for_export( $report );
+ $export_roles_by_user_id[ (int) $report['user_id'] ] = $export_item['role'];
+ }
+
+ $this->assertEquals( 'Editor, Shop manager', $export_roles_by_user_id[ $editor_id ], 'CSV export should carry the role value' );
+ $this->assertSame( '', $export_roles_by_user_id[0], 'CSV export should leave the role empty for guests' );
+ }
+
/**
* Test getting reports.
*