[Product Block Editor]: update the attributes list optimistically when a new attribute is created (#46717)

* tweak optimisticQueryUpdate type

* sort items optimistically

* update attributes list optimistically

* do not invalidate when creating a new attribute

* changelog

* changelog

* update tests

* update options param in test

* wait for the promise when creating new attribute

* fix the rest of tests
This commit is contained in:
Damián Suárez 2024-04-26 07:53:13 -04:00 committed by GitHub
parent 24c2832e4f
commit 8d2f88da71
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 95 additions and 37 deletions

View File

@ -0,0 +1,4 @@
Significance: patch
Type: update
Data: sort the items when updating optimistically

View File

@ -101,9 +101,47 @@ export const createReducer = (
};
let items = state.items;
let queryItems = Object.keys( data ).map( ( key ) => +key );
let itemsCount = state.itemsCount;
if ( typeof options?.optimisticQueryUpdate === 'object' ) {
/*
* Check it needs to update the store with the new item,
* optimistically.
*/
if ( options?.optimisticQueryUpdate ) {
/*
* If the query has an order_by property, sort the items
* by the order_by property.
*
* The sort criteria could be different from the
* the server side.
* Ensure to keep in sync with the server side, for instance,
* by invalidating the cache.
*
* Todo: Add a mechanism to use the server side sorting criteria.
*/
if ( options.optimisticQueryUpdate?.order_by ) {
type OrderBy = keyof Item;
const order_by = options.optimisticQueryUpdate
?.order_by as OrderBy;
let sortingData = Object.values( data );
sortingData = sortingData.sort( ( a, b ) =>
( a[ order_by ] as string )
.toLowerCase()
.localeCompare(
(
b[ order_by ] as string
).toLowerCase()
)
);
queryItems = sortingData.map( ( item ) =>
Number( item.id )
);
}
const getItemQuery = getRequestIdentifier(
CRUD_ACTIONS.GET_ITEMS,
options.optimisticQueryUpdate
@ -118,9 +156,7 @@ export const createReducer = (
...state.items,
[ getItemQuery ]: {
...state.items[ getItemQuery ],
data: Object.keys( data ).map(
( key ) => +key
),
data: queryItems,
},
};

View File

@ -326,7 +326,7 @@ describe( 'crud reducer', () => {
};
const options = {
optimisticQueryUpdate: false,
optimisticQueryUpdate: {},
};
const resourceName = getRequestIdentifier(

View File

@ -30,6 +30,7 @@ export type Item = {
export type ItemQuery = BaseQueryParams & {
[ key: string ]: unknown;
parent_id?: IdType;
order_by?: string;
};
export type Params = {
@ -41,7 +42,7 @@ type WithRequiredProperty< Type, Key extends keyof Type > = Type & {
};
export type CrudActionOptions = {
optimisticQueryUpdate?: boolean | Partial< Item >;
optimisticQueryUpdate?: ItemQuery;
};
export type CrudActions<

View File

@ -28,6 +28,7 @@ export type QueryProductAttribute = {
type Query = {
context?: string;
order_by?: string;
};
type ReadOnlyProperties = 'id';
@ -60,7 +61,7 @@ export interface CustomActionDispatchers extends ActionDispatchers {
createProductAttribute: (
x: Partial< QueryProductAttribute >,
options?: {
optimisticQueryUpdate: Partial< QueryProductAttribute > | boolean;
optimisticQueryUpdate: Partial< QueryProductAttribute >;
}
) => Promise< ProductAttribute >;
}

View File

@ -0,0 +1,4 @@
Significance: patch
Type: update
[Product Block Editor]: update the attributes list optimistically when a new attribute is created

View File

@ -43,9 +43,11 @@ export const AttributeInputField: React.FC< AttributeInputFieldProps > = ( {
createNewAttributesAsGlobal = false,
} ) => {
const { createErrorNotice } = useDispatch( 'core/notices' );
const { createProductAttribute, invalidateResolution } = useDispatch(
const { createProductAttribute } = useDispatch(
EXPERIMENTAL_PRODUCT_ATTRIBUTES_STORE_NAME
) as unknown as ProductAttributesActions & WPDataActions;
const sortCriteria = { order_by: 'name' };
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const { attributes, isLoading } = useSelect( ( select: WCDataSelector ) => {
@ -53,8 +55,10 @@ export const AttributeInputField: React.FC< AttributeInputFieldProps > = ( {
EXPERIMENTAL_PRODUCT_ATTRIBUTES_STORE_NAME
);
return {
isLoading: ! hasFinishedResolution( 'getProductAttributes' ),
attributes: getProductAttributes(),
isLoading: ! hasFinishedResolution( 'getProductAttributes', [
sortCriteria,
] ),
attributes: getProductAttributes( sortCriteria ),
};
} );
@ -118,12 +122,16 @@ export const AttributeInputField: React.FC< AttributeInputFieldProps > = ( {
source: TRACKS_SOURCE,
} );
if ( createNewAttributesAsGlobal ) {
createProductAttribute( {
name: attribute.name,
generate_slug: true,
} ).then(
createProductAttribute(
{
name: attribute.name,
generate_slug: true,
},
{
optimisticQueryUpdate: sortCriteria,
}
).then(
( newAttr ) => {
invalidateResolution( 'getProductAttributes' );
onChange( { ...newAttr, options: [] } );
},
( error ) => {

View File

@ -5,6 +5,7 @@ import { render, waitFor } from '@testing-library/react';
import { useDispatch, useSelect } from '@wordpress/data';
import { useState, createElement } from '@wordpress/element';
import {
ProductAttribute,
ProductProductAttribute,
QueryProductAttribute,
} from '@woocommerce/data';
@ -20,7 +21,6 @@ jest.mock( '@wordpress/data', () => ( {
useDispatch: jest.fn().mockReturnValue( {
createErrorNotice: jest.fn(),
createProductAttribute: jest.fn(),
invalidateResolution: jest.fn(),
} ),
} ) );
@ -278,7 +278,7 @@ describe( 'AttributeInputField', () => {
} );
describe( 'createNewAttributesAsGlobal is true', () => {
it( 'should create a new global attribute and invalidate product attributes', async () => {
it( 'should create a new global attribute', async () => {
const onChangeMock = jest.fn();
( useSelect as jest.Mock ).mockReturnValue( {
isLoading: false,
@ -287,11 +287,7 @@ describe( 'AttributeInputField', () => {
const createProductAttributeMock = jest
.fn()
.mockImplementation(
(
newAttribute: Partial<
Omit< ProductProductAttribute, 'id' >
>
) => {
( newAttribute: Partial< ProductAttribute > ) => {
return Promise.resolve( {
name: newAttribute.name,
id: 123,
@ -299,11 +295,9 @@ describe( 'AttributeInputField', () => {
} );
}
);
const invalidateResolutionMock = jest.fn();
( useDispatch as jest.Mock ).mockReturnValue( {
createErrorNotice: jest.fn(),
createProductAttribute: createProductAttributeMock,
invalidateResolution: invalidateResolutionMock,
} );
const { queryByText } = render(
<AttributeInputField
@ -313,15 +307,20 @@ describe( 'AttributeInputField', () => {
);
queryByText( 'Update Input' )?.click();
queryByText( 'Create "Co"' )?.click();
expect( createProductAttributeMock ).toHaveBeenCalledWith( {
name: 'Co',
generate_slug: true,
} );
await waitFor( () => {
expect( invalidateResolutionMock ).toHaveBeenCalledWith(
'getProductAttributes'
expect( createProductAttributeMock ).toHaveBeenCalledWith(
{
name: 'Co',
generate_slug: true,
},
{
optimisticQueryUpdate: {
order_by: 'name',
},
}
);
} );
expect( onChangeMock ).toHaveBeenCalledWith( {
name: 'Co',
slug: 'co',
@ -344,12 +343,10 @@ describe( 'AttributeInputField', () => {
message: 'Duplicate slug',
} );
} );
const invalidateResolutionMock = jest.fn();
const createErrorNoticeMock = jest.fn();
( useDispatch as jest.Mock ).mockReturnValue( {
createErrorNotice: createErrorNoticeMock,
createProductAttribute: createProductAttributeMock,
invalidateResolution: invalidateResolutionMock,
} );
const { queryByText } = render(
<AttributeInputField
@ -359,17 +356,24 @@ describe( 'AttributeInputField', () => {
);
queryByText( 'Update Input' )?.click();
queryByText( 'Create "Co"' )?.click();
expect( createProductAttributeMock ).toHaveBeenCalledWith( {
name: 'Co',
generate_slug: true,
} );
expect( createProductAttributeMock ).toHaveBeenCalledWith(
{
name: 'Co',
generate_slug: true,
},
{
optimisticQueryUpdate: {
order_by: 'name',
},
}
);
await waitFor( () => {
expect( createErrorNoticeMock ).toHaveBeenCalledWith(
'Duplicate slug',
{ explicitDismiss: true }
);
} );
expect( invalidateResolutionMock ).not.toHaveBeenCalled();
expect( onChangeMock ).not.toHaveBeenCalled();
} );
} );