Commit 5de94b83a64 for woocommerce
commit 5de94b83a6486503d7c59c1548cacbc06c3bea0b
Author: Luigi Teschio <gigitux@gmail.com>
Date: Wed Apr 29 10:20:07 2026 +0200
[DataViews - All Products] Implement tab navigation (#64449)
* Refactor product actions and improve layout handling in the experimental products app
* lint code
* Update packages/js/experimental-products-app/typings/index.d.ts
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
* fix hook
* lint code
* Implement filters
* Add changefile(s) from automation for the following project(s): @woocommerce/experimental-products-app
* [DataViews - All Products] Implement bulk editing
* clean up code
* fix logic
* fix unit test
* improve test
* update configuration
* implement status tab
* fix import
* add link to the title
* clean up code
* lint code
* fix code
* address commit
* fix build
* improve code quality
* update logic
---------
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
diff --git a/packages/js/experimental-products-app/src/dataviews-actions/index.test.tsx b/packages/js/experimental-products-app/src/dataviews-actions/actions.test.tsx
similarity index 90%
rename from packages/js/experimental-products-app/src/dataviews-actions/index.test.tsx
rename to packages/js/experimental-products-app/src/dataviews-actions/actions.test.tsx
index da6cfaf2697..55d861ea405 100644
--- a/packages/js/experimental-products-app/src/dataviews-actions/index.test.tsx
+++ b/packages/js/experimental-products-app/src/dataviews-actions/actions.test.tsx
@@ -8,9 +8,8 @@ import type { Action } from '@wordpress/dataviews';
/**
* Internal dependencies
*/
-import { duplicateProductAction, moveToTrashAction } from './index';
+import { duplicateProductAction, moveToTrashAction } from './actions';
import type { ProductEntityRecord } from '../fields/types';
-import type { ProductListQuery } from '../product-list/query';
jest.mock( '@wordpress/api-fetch', () => jest.fn() );
@@ -59,10 +58,6 @@ function getCallbackAction( action: Action< ProductEntityRecord > ) {
}
describe( 'product list actions', () => {
- const query: ProductListQuery = {
- page: 1,
- per_page: 20,
- };
const product = {
id: 12,
status: 'draft',
@@ -158,12 +153,9 @@ describe( 'product list actions', () => {
it( 'moves products to trash through coreStore root/product and refreshes the query', async () => {
deleteEntityRecord.mockResolvedValue( { id: 12 } );
- await getCallbackAction( moveToTrashAction( { query } ) ).callback(
- [ product ],
- {
- onActionPerformed,
- }
- );
+ await getCallbackAction( moveToTrashAction() ).callback( [ product ], {
+ onActionPerformed,
+ } );
expect( deleteEntityRecord ).toHaveBeenCalledWith(
'root',
@@ -174,10 +166,7 @@ describe( 'product list actions', () => {
throwOnError: true,
}
);
- expect( invalidateResolution ).toHaveBeenCalledWith(
- 'getEntityRecords',
- [ 'root', 'product', query ]
- );
+
expect( createSuccessNotice ).toHaveBeenCalledWith(
'Product successfully deleted',
{ type: 'snackbar' }
diff --git a/packages/js/experimental-products-app/src/dataviews-actions/index.tsx b/packages/js/experimental-products-app/src/dataviews-actions/actions.tsx
similarity index 92%
rename from packages/js/experimental-products-app/src/dataviews-actions/index.tsx
rename to packages/js/experimental-products-app/src/dataviews-actions/actions.tsx
index a0547a399e4..5386755f552 100644
--- a/packages/js/experimental-products-app/src/dataviews-actions/index.tsx
+++ b/packages/js/experimental-products-app/src/dataviews-actions/actions.tsx
@@ -16,15 +16,6 @@ import { useMemo } from '@wordpress/element';
* Internal dependencies
*/
import type { ProductEntityRecord } from '../fields/types';
-import type { ProductListQuery } from '../product-list/query';
-
-type ProductActionsOptions = {
- query: ProductListQuery;
-};
-
-type ProductActionDependencies = {
- query: ProductListQuery;
-};
function getErrorMessage( error: unknown ): string {
if ( error instanceof Error ) {
@@ -259,9 +250,7 @@ export const duplicateProductAction = (): Action< ProductEntityRecord > => ( {
},
} );
-export const moveToTrashAction = (
- dependencies: ProductActionDependencies
-): Action< ProductEntityRecord > => ( {
+export const moveToTrashAction = (): Action< ProductEntityRecord > => ( {
id: 'move-to-trash-product',
label: __( 'Move to trash', 'woocommerce' ),
supportsBulk: true,
@@ -270,8 +259,7 @@ export const moveToTrashAction = (
return product.status !== 'trash';
},
async callback( items, { onActionPerformed } ) {
- const { deleteEntityRecord, invalidateResolution } =
- dispatch( coreStore );
+ const { deleteEntityRecord } = dispatch( coreStore );
const { createErrorNotice, createSuccessNotice } =
dispatch( noticesStore );
@@ -289,11 +277,6 @@ export const moveToTrashAction = (
);
if ( successfulItems.length > 0 ) {
- await invalidateResolution( 'getEntityRecords', [
- 'root',
- 'product',
- dependencies.query,
- ] );
createSuccessNotice(
successfulItems.length === 1
? __( 'Product successfully deleted', 'woocommerce' )
@@ -327,14 +310,14 @@ export const moveToTrashAction = (
},
} );
-export const useProductActions = ( { query }: ProductActionsOptions ) => {
+export const useProductActions = () => {
return useMemo(
() => [
editAction(),
viewAction(),
duplicateProductAction(),
- moveToTrashAction( { query } ),
+ moveToTrashAction(),
],
- [ query ]
+ []
);
};
diff --git a/packages/js/experimental-products-app/src/dataviews-actions/index.ts b/packages/js/experimental-products-app/src/dataviews-actions/index.ts
new file mode 100644
index 00000000000..4b969aa27b9
--- /dev/null
+++ b/packages/js/experimental-products-app/src/dataviews-actions/index.ts
@@ -0,0 +1 @@
+export { useProductActions } from './actions';
diff --git a/packages/js/experimental-products-app/src/product-list/constants.ts b/packages/js/experimental-products-app/src/product-list/constants.ts
new file mode 100644
index 00000000000..0240ed80c1a
--- /dev/null
+++ b/packages/js/experimental-products-app/src/product-list/constants.ts
@@ -0,0 +1,58 @@
+/**
+ * External dependencies
+ */
+import type { View } from '@wordpress/dataviews';
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../fields/types';
+import {
+ DEFAULT_PRODUCT_TABLE_LAYOUT,
+ DEFAULT_PRODUCT_TABLE_VIEW,
+} from './layouts';
+
+export const PAGE_SIZE = 20;
+
+export const EMPTY_ARRAY: ProductEntityRecord[] = [];
+
+export const DEFAULT_LAYOUTS = {
+ table: DEFAULT_PRODUCT_TABLE_LAYOUT,
+};
+
+export const DEFAULT_VIEW: View = {
+ ...DEFAULT_PRODUCT_TABLE_VIEW,
+ page: 1,
+};
+
+export const PRODUCT_LIST_TAB_VALUES = [
+ 'all',
+ 'publish',
+ 'draft',
+ 'trash',
+] as const;
+
+export type StatusTab = ( typeof PRODUCT_LIST_TAB_VALUES )[ number ];
+
+export const PRODUCT_LIST_TABS: Array< {
+ value: StatusTab;
+ label: string;
+} > = [
+ {
+ value: 'all',
+ label: __( 'All', 'woocommerce' ),
+ },
+ {
+ value: 'publish',
+ label: __( 'Published', 'woocommerce' ),
+ },
+ {
+ value: 'draft',
+ label: __( 'Draft', 'woocommerce' ),
+ },
+ {
+ value: 'trash',
+ label: __( 'Trash', 'woocommerce' ),
+ },
+];
diff --git a/packages/js/experimental-products-app/src/product-list/fields.tsx b/packages/js/experimental-products-app/src/product-list/fields.tsx
index 638116a1fbd..7cd45203ff1 100644
--- a/packages/js/experimental-products-app/src/product-list/fields.tsx
+++ b/packages/js/experimental-products-app/src/product-list/fields.tsx
@@ -92,9 +92,6 @@ const PRODUCT_LIST_FIELD_IDS = [
'upsell_ids',
'cross_sell_ids',
'linked_products_count',
- 'seo_title',
- 'seo_description',
- 'seo_preview',
] as const;
const PRODUCT_LIST_FIELD_EXTENSIONS: Record< string, ProductFieldExtensions > =
diff --git a/packages/js/experimental-products-app/src/product-list/index.tsx b/packages/js/experimental-products-app/src/product-list/index.tsx
index b2d179065e6..84c08ed6fe4 100644
--- a/packages/js/experimental-products-app/src/product-list/index.tsx
+++ b/packages/js/experimental-products-app/src/product-list/index.tsx
@@ -4,27 +4,38 @@
import { DataViews, View } from '@wordpress/dataviews';
import { useState, useMemo, useCallback, useEffect } from '@wordpress/element';
import { privateApis as routerPrivateApis } from '@wordpress/router';
-import { store as coreStore, useEntityRecords } from '@wordpress/core-data';
-import { __ } from '@wordpress/i18n';
+import { store as coreStore } from '@wordpress/core-data';
import { useSelect } from '@wordpress/data';
import clsx from 'clsx';
-import { Button, Stack } from '@wordpress/ui';
+import { Button, Stack, Tabs } from '@wordpress/ui';
import { privateApis as editorPrivateApis } from '@wordpress/editor';
import { Page } from '@wordpress/admin-ui';
import { addQueryArgs } from '@wordpress/url';
import { getAdminLink } from '@woocommerce/settings';
+import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import { unlock } from '../lock-unlock';
import type { ProductEntityRecord } from '../fields/types';
-import { productFields } from './fields';
import {
- DEFAULT_PRODUCT_TABLE_LAYOUT,
- DEFAULT_PRODUCT_TABLE_VIEW,
-} from './layouts';
+ DEFAULT_LAYOUTS,
+ DEFAULT_VIEW,
+ EMPTY_ARRAY,
+ PAGE_SIZE,
+ PRODUCT_LIST_TABS,
+} from './constants';
+import { productFields } from './fields';
import { buildProductListQuery } from './query';
+import {
+ getItemId,
+ getProductListNavigationPath,
+ getProductListTab,
+ getSelectionFromPostId,
+ getStatusForProductListTab,
+ isProductEditorAccessible,
+} from './utils';
import { useProductActions } from '../dataviews-actions';
const { usePostActions } = unlock( editorPrivateApis );
@@ -37,16 +48,6 @@ export type ProductListProps = {
postType?: string;
};
-const PAGE_SIZE = 20;
-const EMPTY_ARRAY: ProductEntityRecord[] = [];
-const DEFAULT_LAYOUTS = {
- table: DEFAULT_PRODUCT_TABLE_LAYOUT,
-};
-const DEFAULT_VIEW: View = {
- ...DEFAULT_PRODUCT_TABLE_VIEW,
- page: 1,
-};
-
/**
* This function abstracts working with default & custom views by
* providing a [ state, setState ] tuple based on the URL parameters.
@@ -54,63 +55,142 @@ const DEFAULT_VIEW: View = {
* Consumers use the provided tuple to work with state
* and don't have to deal with the specifics of default & custom views.
*
- * @param {string} postType Post type to retrieve default views for.
* @return {Array} The [ state, setState ] tuple.
*/
-function useView( postType: string ): [ View, ( view: View ) => void ] {
- const {
- params: { activeView = 'all', isCustom = 'false' },
- } = useLocation();
+function useView(): [ View, ( view: View ) => void ] {
+ const { query: { activeView = 'all' } = {} } = useLocation();
const [ view, setView ] = useState< View >( DEFAULT_VIEW );
- // When activeView or isCustom URL parameters change, reset the view.
+ // When activeView URL parameter changes, reset the view.
useEffect( () => {
setView( DEFAULT_VIEW );
- }, [ activeView, isCustom, postType ] );
+ }, [ activeView ] );
return [ view, setView ];
}
-function getItemId( item: ProductEntityRecord ) {
- return item.id.toString();
-}
-
export default function ProductList( { className }: ProductListProps ) {
- const history = useHistory();
+ const { navigate } = useHistory();
const location = useLocation();
- const {
- postId,
- postType = 'product',
- isCustom,
- activeView = 'all',
- } = location.params;
- const [ selection, setSelection ] = useState( [ postId ] );
- const [ view, setView ] = useView( postType );
-
- const queryParams = useMemo(
- () => buildProductListQuery( view ),
- [ view ]
+ const currentQuery = useMemo(
+ () =>
+ ( location.query || {} ) as {
+ postId?: string;
+ activeView?: string;
+ postType?: string;
+ },
+ [ location.query ]
+ );
+ const { postId, postType = 'product', activeView = 'all' } = currentQuery;
+ const selectedTabFromLocation = getProductListTab( activeView );
+ const [ selectedTab, setSelectedTab ] = useState( selectedTabFromLocation );
+ const [ selection, setSelection ] = useState( () =>
+ getSelectionFromPostId( postId )
);
+ const [ view, setView ] = useView();
+
+ useEffect( () => {
+ setSelectedTab( selectedTabFromLocation );
+ }, [ selectedTabFromLocation ] );
+
+ useEffect( () => {
+ setSelection( getSelectionFromPostId( postId ) );
+ }, [ postId ] );
+
+ const queryParams = useMemo( () => {
+ const query = buildProductListQuery( view );
+ const productStatus = getStatusForProductListTab( selectedTab );
+
+ if ( productStatus ) {
+ query.status = productStatus;
+ }
+
+ return query;
+ }, [ selectedTab, view ] );
const onChangeSelection = useCallback(
( items: string[] ) => {
setSelection( items );
- history.push( {
- ...location.params,
- postId: items.join( ',' ),
- } );
+
+ const nextParams = { ...currentQuery };
+
+ if ( items.length > 0 ) {
+ nextParams.postId = items.join( ',' );
+ } else {
+ delete nextParams.postId;
+ }
+
+ navigate(
+ getProductListNavigationPath( location.path, nextParams )
+ );
+ },
+ [ currentQuery, navigate, location.path ]
+ );
+
+ const onChangeTab = useCallback(
+ ( value: string | null ) => {
+ if ( ! value ) {
+ return;
+ }
+
+ const nextTab = getProductListTab( value );
+
+ if ( nextTab === selectedTab ) {
+ return;
+ }
+
+ setSelectedTab( nextTab );
+ setSelection( [] );
+
+ const nextParams = {
+ ...currentQuery,
+ activeView: nextTab,
+ };
+
+ delete nextParams.postId;
+
+ navigate(
+ getProductListNavigationPath( location.path, nextParams )
+ );
},
- [ history, location.params ]
+ [ currentQuery, navigate, location.path, selectedTab ]
);
const {
records,
totalItems: totalCount,
isResolving: isLoading,
- } = useEntityRecords< ProductEntityRecord >(
- 'root',
- 'product',
- queryParams
+ hasResolved,
+ } = useSelect(
+ ( select ) => {
+ const {
+ getEntityRecords,
+ isResolving,
+ hasFinishedResolution,
+ getEntityRecordsTotalItems,
+ } = select( coreStore );
+ return {
+ records: getEntityRecords< ProductEntityRecord >(
+ 'root',
+ 'product',
+ queryParams
+ ),
+ totalItems: getEntityRecordsTotalItems( 'root', 'product', {
+ ...queryParams,
+ } ),
+ isResolving: isResolving( 'getEntityRecords', [
+ 'root',
+ 'product',
+ queryParams,
+ ] ),
+ hasResolved: hasFinishedResolution( 'getEntityRecords', [
+ 'root',
+ 'product',
+ queryParams,
+ ] ),
+ };
+ },
+ [ queryParams ]
);
const paginationInfo = useMemo(
@@ -140,9 +220,7 @@ export default function ProductList( { className }: ProductListProps ) {
postType,
context: 'list',
} );
- const productActions = useProductActions( {
- query: queryParams,
- } );
+ const productActions = useProductActions();
const actions = useMemo(
() => [
...productActions,
@@ -221,11 +299,11 @@ export default function ProductList( { className }: ProductListProps ) {
actions={ pageActions }
>
<DataViews
- key={ activeView + isCustom }
+ key={ activeView }
paginationInfo={ paginationInfo }
fields={ productFields }
data={ records || EMPTY_ARRAY }
- isLoading={ isLoading }
+ isLoading={ isLoading && ! hasResolved }
view={ view }
actions={ actions }
onChangeView={ setView }
@@ -233,7 +311,60 @@ export default function ProductList( { className }: ProductListProps ) {
getItemId={ getItemId }
selection={ selection }
defaultLayouts={ DEFAULT_LAYOUTS }
- />
+ isItemClickable={ isProductEditorAccessible }
+ renderItemLink={ ( { item, ...props } ) => (
+ <a
+ { ...props }
+ href={ getAdminLink(
+ addQueryArgs( 'post.php', {
+ post: item.id,
+ action: 'edit',
+ } )
+ ) }
+ >
+ { props.children }
+ </a>
+ ) }
+ >
+ <Stack
+ direction="row"
+ align="center"
+ justify="space-between"
+ gap="sm"
+ className="woocommerce-product-list__toolbar"
+ >
+ { /* Tabs component should not be used: https://github.com/woocommerce/woocommerce/issues/64478 */ }
+ <Tabs.Root
+ value={ selectedTab }
+ onValueChange={ onChangeTab }
+ >
+ <Tabs.List
+ variant="minimal"
+ aria-label={ __(
+ 'Filter products by status',
+ 'woocommerce'
+ ) }
+ >
+ { PRODUCT_LIST_TABS.map( ( tab ) => (
+ <Tabs.Tab key={ tab.value } value={ tab.value }>
+ { tab.label }
+ </Tabs.Tab>
+ ) ) }
+ </Tabs.List>
+ </Tabs.Root>
+ <Stack direction="row" align="center" gap="xs">
+ <DataViews.Search
+ label={ __( 'Search products', 'woocommerce' ) }
+ />
+ <DataViews.FiltersToggle />
+ <DataViews.LayoutSwitcher />
+ <DataViews.ViewConfig />
+ </Stack>
+ </Stack>
+ <DataViews.FiltersToggled />
+ <DataViews.Layout />
+ <DataViews.Footer />
+ </DataViews>
</Page>
);
}
diff --git a/packages/js/experimental-products-app/src/product-list/query.test.ts b/packages/js/experimental-products-app/src/product-list/query.test.ts
index 190ecf03aa8..1281e32c8f2 100644
--- a/packages/js/experimental-products-app/src/product-list/query.test.ts
+++ b/packages/js/experimental-products-app/src/product-list/query.test.ts
@@ -37,6 +37,11 @@ describe( 'buildProductListQuery', () => {
const query = buildProductListQuery( {
...baseView,
filters: [
+ {
+ field: 'product_status',
+ operator: 'is',
+ value: 'draft',
+ },
{
field: 'type',
operator: 'isAny',
@@ -62,6 +67,7 @@ describe( 'buildProductListQuery', () => {
expect( query ).toEqual(
expect.objectContaining( {
+ status: 'draft',
include_types: [ 'simple', 'variable' ],
category: '12,13',
stock_status: 'outofstock',
diff --git a/packages/js/experimental-products-app/src/product-list/query.ts b/packages/js/experimental-products-app/src/product-list/query.ts
index bbb09cc5746..357002a9358 100644
--- a/packages/js/experimental-products-app/src/product-list/query.ts
+++ b/packages/js/experimental-products-app/src/product-list/query.ts
@@ -8,9 +8,9 @@ import type {
ProductType,
} from '@woocommerce/data';
-export type ProductListQuery = ProductQuery & {
+export type ProductListQuery = Omit< ProductQuery, 'status' > & {
+ status?: ProductStatus | ProductStatus[];
search_name_or_sku?: string;
- include_status?: ProductStatus[];
exclude_status?: ProductStatus[];
include_types?: ProductType[];
exclude_types?: ProductType[];
@@ -77,7 +77,7 @@ function applyStatusFilter( query: ProductListQuery, filter: Filter ) {
return;
}
- query.include_status = values;
+ query.status = values.length === 1 ? values[ 0 ] : values;
}
function applyTypeFilter( query: ProductListQuery, filter: Filter ) {
diff --git a/packages/js/experimental-products-app/src/product-list/utils.ts b/packages/js/experimental-products-app/src/product-list/utils.ts
new file mode 100644
index 00000000000..e458360ce38
--- /dev/null
+++ b/packages/js/experimental-products-app/src/product-list/utils.ts
@@ -0,0 +1,56 @@
+/**
+ * External dependencies
+ */
+import { addQueryArgs } from '@wordpress/url';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord, ProductStatus } from '../fields/types';
+import { PRODUCT_LIST_TAB_VALUES, type StatusTab } from './constants';
+
+export function getProductListNavigationPath(
+ path: string,
+ params: Record< string, string >
+) {
+ const [ pathname = '/' ] = path.split( '?' );
+
+ return addQueryArgs( pathname, params );
+}
+
+export function getItemId( item: ProductEntityRecord ) {
+ return item.id.toString();
+}
+
+function isProductListTabValue( value: string ): value is StatusTab {
+ return PRODUCT_LIST_TAB_VALUES.includes( value as StatusTab );
+}
+
+export function getProductListTab( value?: string ): StatusTab {
+ if ( value && isProductListTabValue( value ) ) {
+ return value;
+ }
+
+ return 'all';
+}
+
+export function getStatusForProductListTab(
+ tab: StatusTab
+): ProductStatus | undefined {
+ switch ( tab ) {
+ case 'publish':
+ case 'draft':
+ case 'trash':
+ return tab;
+ default:
+ return undefined;
+ }
+}
+
+export function getSelectionFromPostId( postId?: string ) {
+ return postId?.split( ',' ).filter( Boolean ) ?? [];
+}
+
+export function isProductEditorAccessible( item: ProductEntityRecord ) {
+ return item.status !== 'trash';
+}
diff --git a/packages/js/experimental-products-app/src/style.scss b/packages/js/experimental-products-app/src/style.scss
index 02125a21b6e..6c4dff50ba0 100644
--- a/packages/js/experimental-products-app/src/style.scss
+++ b/packages/js/experimental-products-app/src/style.scss
@@ -20,3 +20,11 @@
.woocommerce-product-list {
height: 85vh;
}
+
+.woocommerce-product-list__toolbar {
+ padding: 0 var(--wpds-dimension-padding-2xl, 24px);
+}
+
+.woocommerce-product-list .dataviews-wrapper {
+ height: 100%;
+}