[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:
parent
24c2832e4f
commit
8d2f88da71
|
@ -0,0 +1,4 @@
|
|||
Significance: patch
|
||||
Type: update
|
||||
|
||||
Data: sort the items when updating optimistically
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -326,7 +326,7 @@ describe( 'crud reducer', () => {
|
|||
};
|
||||
|
||||
const options = {
|
||||
optimisticQueryUpdate: false,
|
||||
optimisticQueryUpdate: {},
|
||||
};
|
||||
|
||||
const resourceName = getRequestIdentifier(
|
||||
|
|
|
@ -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<
|
||||
|
|
|
@ -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 >;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
Significance: patch
|
||||
Type: update
|
||||
|
||||
[Product Block Editor]: update the attributes list optimistically when a new attribute is created
|
|
@ -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 ) => {
|
||||
|
|
|
@ -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();
|
||||
} );
|
||||
} );
|
||||
|
|
Loading…
Reference in New Issue