Make the `useCampaigns` hook support being used with multiple instances (#41211)
* Add tests for the `useCampaigns` hook in the woocommerce-admin marketing. * Add `channels` to the deps of `useSelect` in the `useCampaigns` hook to avoid outdated hook states. * Make the `useCampaigns` hook support being used with multiple instances. - Include the `perPage` in the page key of campaigns in the multichannel's data store. - Move the processing of querying the campaigns paging to the selector of the multichannel's data store. - Move `total` in each campaign's paging of the multichannel's data store. - Remove unused properties and their types: `state.campaigns.perPage` and `state.campaigns.total` * Add changelog * Multichannel data store - Move the `total` from `campaigns.pages[*]` to `campaigns.meta` to have only one total value. Related to: - https://github.com/woocommerce/woocommerce/pull/41211#discussion_r1409634640 - https://github.com/woocommerce/woocommerce/pull/41211#discussion_r1409590559 * Multichannel data store - Return the pagination meta of campaigns along with the `getCampaigns` selector. Address: https://github.com/woocommerce/woocommerce/pull/41211#discussion_r1412266127
This commit is contained in:
parent
7957314723
commit
d57b9e1d05
|
@ -21,9 +21,10 @@ const initialState = {
|
||||||
error: undefined,
|
error: undefined,
|
||||||
},
|
},
|
||||||
campaigns: {
|
campaigns: {
|
||||||
perPage: undefined,
|
pages: {},
|
||||||
pages: undefined,
|
meta: {
|
||||||
total: undefined,
|
total: undefined,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
campaignTypes: {
|
campaignTypes: {
|
||||||
data: undefined,
|
data: undefined,
|
||||||
|
@ -66,13 +67,15 @@ export const reducer: Reducer< State, Action > = (
|
||||||
};
|
};
|
||||||
|
|
||||||
case TYPES.RECEIVE_CAMPAIGNS:
|
case TYPES.RECEIVE_CAMPAIGNS:
|
||||||
|
const { meta } = action;
|
||||||
|
const key = `${ meta.page }-${ meta.perPage }`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
campaigns: {
|
campaigns: {
|
||||||
perPage: action.meta.perPage,
|
|
||||||
pages: {
|
pages: {
|
||||||
...state.campaigns.pages,
|
...state.campaigns.pages,
|
||||||
[ action.meta.page ]: action.error
|
[ key ]: action.error
|
||||||
? {
|
? {
|
||||||
error: action.payload,
|
error: action.payload,
|
||||||
}
|
}
|
||||||
|
@ -80,7 +83,9 @@ export const reducer: Reducer< State, Action > = (
|
||||||
data: action.payload,
|
data: action.payload,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
total: action.meta.total,
|
meta: {
|
||||||
|
total: meta.total,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -13,9 +13,17 @@ export const getRecommendedChannels = ( state: State ) => {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get campaigns from state.
|
* Get campaigns from state.
|
||||||
|
*
|
||||||
|
* @param state State passed in from the data store.
|
||||||
|
* @param page Page number. First page is `1`.
|
||||||
|
* @param perPage Page size, i.e. number of records in one page.
|
||||||
*/
|
*/
|
||||||
export const getCampaigns = ( state: State ) => {
|
export const getCampaigns = ( state: State, page: number, perPage: number ) => {
|
||||||
return state.campaigns;
|
const key = `${ page }-${ perPage }`;
|
||||||
|
return {
|
||||||
|
campaignsPage: state.campaigns.pages[ key ] || null,
|
||||||
|
meta: state.campaigns.meta,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getCampaignTypes = ( state: State ) => {
|
export const getCampaignTypes = ( state: State ) => {
|
||||||
|
|
|
@ -66,12 +66,20 @@ export type CampaignsPage = {
|
||||||
error?: ApiFetchError;
|
error?: ApiFetchError;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CampaignsState = {
|
export type CampaignsMeta = {
|
||||||
perPage?: number;
|
|
||||||
pages?: Record< number, CampaignsPage >;
|
|
||||||
total?: number;
|
total?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CampaignsState = {
|
||||||
|
pages: Record< string, CampaignsPage >;
|
||||||
|
meta: CampaignsMeta;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CampaignsPagination = {
|
||||||
|
campaignsPage: CampaignsPage | null;
|
||||||
|
meta: CampaignsMeta;
|
||||||
|
};
|
||||||
|
|
||||||
export type CampaignType = {
|
export type CampaignType = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
|
@ -0,0 +1,230 @@
|
||||||
|
/**
|
||||||
|
* External dependencies
|
||||||
|
*/
|
||||||
|
import { renderHook } from '@testing-library/react-hooks/dom';
|
||||||
|
import { waitFor } from '@testing-library/react';
|
||||||
|
import { dispatch } from '@wordpress/data';
|
||||||
|
import 'whatwg-fetch'; /* eslint-disable-line import/no-unresolved */ /* To make sure Response is available */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal dependencies
|
||||||
|
*/
|
||||||
|
import { useCampaigns } from '../useCampaigns';
|
||||||
|
import { useRegisteredChannels } from '../useRegisteredChannels';
|
||||||
|
import { STORE_KEY } from '../../data-multichannel/constants';
|
||||||
|
import { RegisteredChannel } from '../../types';
|
||||||
|
import { Campaign as APICampaign } from '../../data-multichannel/types';
|
||||||
|
import '../../data-multichannel'; // To ensure the store is registered
|
||||||
|
|
||||||
|
type Channel = Pick< RegisteredChannel, 'slug' | 'title' | 'icon' >;
|
||||||
|
|
||||||
|
jest.mock( '@wordpress/api-fetch', () =>
|
||||||
|
jest.fn( ( { path } ) => {
|
||||||
|
const total = 9;
|
||||||
|
|
||||||
|
const params = new URLSearchParams( path.replace( /^[^?]*/, '' ) );
|
||||||
|
const page = Number( params.get( 'page' ) );
|
||||||
|
const perPage = Number( params.get( 'per_page' ) );
|
||||||
|
|
||||||
|
if ( ! Number.isInteger( page ) || ! Number.isInteger( perPage ) ) {
|
||||||
|
return Promise.reject(
|
||||||
|
new Response( '{"message": "invalid query"}', { status: 400 } )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const length = Math.min( perPage, total - ( page - 1 ) * perPage );
|
||||||
|
|
||||||
|
const campaigns: Array< APICampaign > = Array.from( { length } ).map(
|
||||||
|
( _, index ) => {
|
||||||
|
const id = `${ page }_${ index + 1 }`;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
channel: 'extension-foo',
|
||||||
|
title: `Campaign ${ id }`,
|
||||||
|
manage_url: `https://test/extension-foo?path=setup&id=${ id }`,
|
||||||
|
cost: {
|
||||||
|
value: ( ( page * perPage + index ) * 0.25 ).toString(),
|
||||||
|
currency: 'USD',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// For testing fallbacks when data fields are not available
|
||||||
|
if ( campaigns[ 2 ] ) {
|
||||||
|
campaigns[ 2 ].channel = 'intentional-mismatch-channel';
|
||||||
|
campaigns[ 2 ].cost = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response( JSON.stringify( campaigns ), {
|
||||||
|
headers: new Headers( { 'x-wp-total': total.toString() } ),
|
||||||
|
} )
|
||||||
|
);
|
||||||
|
} )
|
||||||
|
);
|
||||||
|
|
||||||
|
jest.mock( '../useRegisteredChannels', () => ( {
|
||||||
|
useRegisteredChannels: jest.fn(),
|
||||||
|
} ) );
|
||||||
|
|
||||||
|
function mockRegisteredChannels( ...channels: Array< Channel > ) {
|
||||||
|
(
|
||||||
|
useRegisteredChannels as jest.MockedFunction<
|
||||||
|
typeof useRegisteredChannels
|
||||||
|
>
|
||||||
|
).mockReturnValue( {
|
||||||
|
loading: false,
|
||||||
|
data: channels.map( ( channel ) => ( {
|
||||||
|
...channel,
|
||||||
|
// The following is not relevant to this test scope
|
||||||
|
description: '',
|
||||||
|
isSetupCompleted: true,
|
||||||
|
setupUrl: '',
|
||||||
|
manageUrl: '',
|
||||||
|
syncStatus: 'synced',
|
||||||
|
issueType: 'none',
|
||||||
|
issueText: '',
|
||||||
|
} ) ),
|
||||||
|
refetch: () => {},
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
|
||||||
|
describe( 'useCampaigns', () => {
|
||||||
|
beforeEach( () => {
|
||||||
|
dispatch( STORE_KEY ).invalidateResolutionForStoreSelector(
|
||||||
|
'getCampaigns'
|
||||||
|
);
|
||||||
|
|
||||||
|
mockRegisteredChannels( {
|
||||||
|
slug: 'extension-foo',
|
||||||
|
title: 'Extension Foo',
|
||||||
|
icon: 'https://test/foo.png',
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
|
||||||
|
it( 'should return correct data', async () => {
|
||||||
|
const { result } = renderHook( () => useCampaigns() );
|
||||||
|
|
||||||
|
await waitFor( () => expect( result.current.loading ).toBe( false ) );
|
||||||
|
expect( result.current.error ).toBeUndefined();
|
||||||
|
|
||||||
|
// Campaign matched with a channel
|
||||||
|
expect( result.current.data?.[ 0 ] ).toEqual( {
|
||||||
|
id: 'extension-foo|1_1',
|
||||||
|
title: 'Campaign 1_1',
|
||||||
|
description: '',
|
||||||
|
cost: 'USD 1.25',
|
||||||
|
manageUrl: 'https://test/extension-foo?path=setup&id=1_1',
|
||||||
|
icon: 'https://test/foo.png',
|
||||||
|
channelName: 'Extension Foo',
|
||||||
|
channelSlug: 'extension-foo',
|
||||||
|
} );
|
||||||
|
|
||||||
|
// Campaign didn't match any channel
|
||||||
|
expect( result.current.data?.[ 2 ] ).toEqual( {
|
||||||
|
id: 'intentional-mismatch-channel|1_3',
|
||||||
|
title: 'Campaign 1_3',
|
||||||
|
description: '',
|
||||||
|
cost: '',
|
||||||
|
manageUrl: 'https://test/extension-foo?path=setup&id=1_3',
|
||||||
|
icon: '',
|
||||||
|
channelName: '',
|
||||||
|
channelSlug: 'intentional-mismatch-channel',
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
|
||||||
|
it( 'should handle error', async () => {
|
||||||
|
const { result } = renderHook( () => useCampaigns( 1.5 ) );
|
||||||
|
|
||||||
|
await waitFor( () => expect( result.current.loading ).toBe( false ) );
|
||||||
|
expect( result.current.data ).toBeUndefined();
|
||||||
|
expect( result.current.error ).toEqual( { message: 'invalid query' } );
|
||||||
|
} );
|
||||||
|
|
||||||
|
it( 'should handle pagination according to the page and perPage arguments', async () => {
|
||||||
|
// Initial page
|
||||||
|
const { result, rerender } = renderHook<
|
||||||
|
{ page: number; perPage: number },
|
||||||
|
ReturnType< typeof useCampaigns >
|
||||||
|
>( ( { page, perPage } ) => useCampaigns( page, perPage ), {
|
||||||
|
initialProps: { page: 1, perPage: 5 },
|
||||||
|
} );
|
||||||
|
|
||||||
|
await waitFor( () => expect( result.current.loading ).toBe( false ) );
|
||||||
|
expect( result.current.meta ).toEqual( { total: 9 } );
|
||||||
|
expect( result.current.data ).toHaveLength( 5 );
|
||||||
|
expect( result.current.data?.[ 0 ].id ).toEqual( 'extension-foo|1_1' );
|
||||||
|
expect( result.current.data?.[ 4 ].id ).toEqual( 'extension-foo|1_5' );
|
||||||
|
|
||||||
|
// Change page
|
||||||
|
rerender( { page: 2, perPage: 5 } );
|
||||||
|
|
||||||
|
await waitFor( () => expect( result.current.loading ).toBe( false ) );
|
||||||
|
expect( result.current.data ).toHaveLength( 4 );
|
||||||
|
expect( result.current.data?.[ 0 ].id ).toEqual( 'extension-foo|2_1' );
|
||||||
|
expect( result.current.data?.[ 1 ].id ).toEqual( 'extension-foo|2_2' );
|
||||||
|
|
||||||
|
// Change page to a page that doesn't exist
|
||||||
|
rerender( { page: 3, perPage: 5 } );
|
||||||
|
|
||||||
|
await waitFor( () => expect( result.current.loading ).toBe( false ) );
|
||||||
|
expect( result.current.data ).toEqual( [] );
|
||||||
|
|
||||||
|
// Change perPage
|
||||||
|
rerender( { page: 3, perPage: 4 } );
|
||||||
|
|
||||||
|
await waitFor( () => expect( result.current.loading ).toBe( false ) );
|
||||||
|
expect( result.current.meta ).toEqual( { total: 9 } );
|
||||||
|
expect( result.current.data ).toHaveLength( 1 );
|
||||||
|
expect( result.current.data?.[ 0 ].id ).toEqual( 'extension-foo|3_1' );
|
||||||
|
} );
|
||||||
|
|
||||||
|
it( 'should update data accordingly once the registered channels are updated', async () => {
|
||||||
|
const { result, rerender } = renderHook( () => useCampaigns() );
|
||||||
|
|
||||||
|
await waitFor( () => expect( result.current.loading ).toBe( false ) );
|
||||||
|
expect( result.current.data?.[ 0 ] ).toMatchObject( {
|
||||||
|
id: 'extension-foo|1_1',
|
||||||
|
channelName: 'Extension Foo',
|
||||||
|
icon: 'https://test/foo.png',
|
||||||
|
} );
|
||||||
|
|
||||||
|
// Update registered channels
|
||||||
|
mockRegisteredChannels( {
|
||||||
|
slug: 'extension-foo',
|
||||||
|
title: 'Extension Bar',
|
||||||
|
icon: 'https://test/bar.png',
|
||||||
|
} );
|
||||||
|
|
||||||
|
rerender();
|
||||||
|
|
||||||
|
expect( result.current.data?.[ 0 ] ).toMatchObject( {
|
||||||
|
id: 'extension-foo|1_1',
|
||||||
|
channelName: 'Extension Bar',
|
||||||
|
icon: 'https://test/bar.png',
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
|
||||||
|
it( 'should be able to use different arguments for different instances at the same time', async () => {
|
||||||
|
const { result: resultA } = renderHook( () => useCampaigns( 1, 2 ) );
|
||||||
|
const { result: resultB } = renderHook( () => useCampaigns( 2, 2 ) );
|
||||||
|
const { result: resultC } = renderHook( () => useCampaigns( 2, 4 ) );
|
||||||
|
|
||||||
|
await waitFor( () => expect( resultA.current.loading ).toBe( false ) );
|
||||||
|
await waitFor( () => expect( resultB.current.loading ).toBe( false ) );
|
||||||
|
await waitFor( () => expect( resultC.current.loading ).toBe( false ) );
|
||||||
|
|
||||||
|
expect( resultA.current.data ).toHaveLength( 2 );
|
||||||
|
expect( resultA.current.data?.[ 0 ].id ).toEqual( 'extension-foo|1_1' );
|
||||||
|
expect( resultA.current.data?.[ 1 ].id ).toEqual( 'extension-foo|1_2' );
|
||||||
|
|
||||||
|
expect( resultB.current.data ).toHaveLength( 2 );
|
||||||
|
expect( resultB.current.data?.[ 0 ].id ).toEqual( 'extension-foo|2_1' );
|
||||||
|
expect( resultB.current.data?.[ 1 ].id ).toEqual( 'extension-foo|2_2' );
|
||||||
|
|
||||||
|
expect( resultC.current.data ).toHaveLength( 4 );
|
||||||
|
expect( resultC.current.data?.[ 0 ].id ).toEqual( 'extension-foo|2_1' );
|
||||||
|
expect( resultC.current.data?.[ 3 ].id ).toEqual( 'extension-foo|2_4' );
|
||||||
|
} );
|
||||||
|
} );
|
|
@ -9,7 +9,7 @@ import { useSelect } from '@wordpress/data';
|
||||||
import { Campaign } from '~/marketing/types';
|
import { Campaign } from '~/marketing/types';
|
||||||
import { STORE_KEY } from '~/marketing/data-multichannel/constants';
|
import { STORE_KEY } from '~/marketing/data-multichannel/constants';
|
||||||
import {
|
import {
|
||||||
CampaignsState,
|
CampaignsPagination,
|
||||||
Campaign as APICampaign,
|
Campaign as APICampaign,
|
||||||
ApiFetchError,
|
ApiFetchError,
|
||||||
} from '~/marketing/data-multichannel/types';
|
} from '~/marketing/data-multichannel/types';
|
||||||
|
@ -36,7 +36,7 @@ export const useCampaigns = ( page = 1, perPage = 5 ): UseCampaignsType => {
|
||||||
return useSelect(
|
return useSelect(
|
||||||
( select ) => {
|
( select ) => {
|
||||||
const { hasFinishedResolution, getCampaigns } = select( STORE_KEY );
|
const { hasFinishedResolution, getCampaigns } = select( STORE_KEY );
|
||||||
const campaignsState = getCampaigns< CampaignsState >(
|
const { campaignsPage, meta } = getCampaigns< CampaignsPagination >(
|
||||||
page,
|
page,
|
||||||
perPage
|
perPage
|
||||||
);
|
);
|
||||||
|
@ -62,25 +62,16 @@ export const useCampaigns = ( page = 1, perPage = 5 ): UseCampaignsType => {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const error =
|
|
||||||
campaignsState.pages && campaignsState.pages[ page ]?.error;
|
|
||||||
|
|
||||||
const data =
|
|
||||||
campaignsState.pages &&
|
|
||||||
campaignsState.pages[ page ]?.data?.map( convert );
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loading: ! hasFinishedResolution( 'getCampaigns', [
|
loading: ! hasFinishedResolution( 'getCampaigns', [
|
||||||
page,
|
page,
|
||||||
perPage,
|
perPage,
|
||||||
] ),
|
] ),
|
||||||
data,
|
data: campaignsPage?.data?.map( convert ),
|
||||||
error,
|
error: campaignsPage?.error,
|
||||||
meta: {
|
meta,
|
||||||
total: campaignsState.total,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
[ page, perPage ]
|
[ page, perPage, channels ]
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
Significance: patch
|
||||||
|
Type: tweak
|
||||||
|
|
||||||
|
Make the `useCampaigns` hook support being used with multiple instances.
|
Loading…
Reference in New Issue