Add "Create a new campaign" modal (#37044)

This commit is contained in:
Gan Eng Chin 2023-03-13 13:24:47 +08:00 committed by GitHub
commit 7bf2cedc5c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 576 additions and 5 deletions

View File

@ -8,4 +8,5 @@ export const TYPES = {
RECEIVE_RECOMMENDED_CHANNELS_ERROR:
'RECEIVE_RECOMMENDED_CHANNELS_ERROR' as const,
RECEIVE_CAMPAIGNS: 'RECEIVE_CAMPAIGNS' as const,
RECEIVE_CAMPAIGN_TYPES: 'RECEIVE_CAMPAIGN_TYPES' as const,
};

View File

@ -2,11 +2,13 @@
* Internal dependencies
*/
import { TYPES } from './action-types';
import { isApiFetchError } from './guards';
import {
ApiFetchError,
RegisteredChannel,
RecommendedChannel,
Campaign,
CampaignType,
} from './types';
export const receiveRegisteredChannelsSuccess = (
@ -75,10 +77,29 @@ export const receiveCampaigns = ( response: CampaignsResponse ) => {
};
};
export const receiveCampaignTypes = (
data: Array< CampaignType > | ApiFetchError
) => {
if ( isApiFetchError( data ) ) {
return {
type: TYPES.RECEIVE_CAMPAIGN_TYPES,
payload: data,
error: true as const,
};
}
return {
type: TYPES.RECEIVE_CAMPAIGN_TYPES,
payload: data,
error: false as const,
};
};
export type Action = ReturnType<
| typeof receiveRegisteredChannelsSuccess
| typeof receiveRegisteredChannelsError
| typeof receiveRecommendedChannelsSuccess
| typeof receiveRecommendedChannelsError
| typeof receiveCampaigns
| typeof receiveCampaignTypes
>;

View File

@ -25,6 +25,10 @@ const initialState = {
pages: undefined,
total: undefined,
},
campaignTypes: {
data: undefined,
error: undefined,
},
};
export const reducer: Reducer< State, Action > = (
@ -80,6 +84,18 @@ export const reducer: Reducer< State, Action > = (
},
};
case TYPES.RECEIVE_CAMPAIGN_TYPES:
return {
...state,
campaignTypes: action.error
? {
error: action.payload,
}
: {
data: action.payload,
},
};
default:
return state;
}

View File

@ -12,12 +12,14 @@ import {
receiveRecommendedChannelsSuccess,
receiveRecommendedChannelsError,
receiveCampaigns,
receiveCampaignTypes,
} from './actions';
import { awaitResponseJson } from './controls';
import {
RegisteredChannel,
RecommendedChannel,
Campaign,
CampaignType,
ApiFetchError,
} from './types';
import { API_NAMESPACE } from './constants';
@ -114,3 +116,17 @@ export function* getCampaigns( page: number, perPage: number ) {
throw error;
}
}
export function* getCampaignTypes() {
try {
const data: CampaignType[] = yield apiFetch( {
path: `${ API_NAMESPACE }/campaign-types`,
} );
yield receiveCampaignTypes( data );
} catch ( error ) {
if ( isApiFetchError( error ) ) {
yield receiveCampaignTypes( error );
}
}
}

View File

@ -17,3 +17,7 @@ export const getRecommendedChannels = ( state: State ) => {
export const getCampaigns = ( state: State ) => {
return state.campaigns;
};
export const getCampaignTypes = ( state: State ) => {
return state.campaignTypes;
};

View File

@ -72,8 +72,26 @@ export type CampaignsState = {
total?: number;
};
export type CampaignType = {
id: string;
name: string;
description: string;
channel: {
slug: string;
name: string;
};
create_url: string;
icon_url: string;
};
export type CampaignTypesState = {
data?: Array< CampaignType >;
error?: ApiFetchError;
};
export type State = {
registeredChannels: RegisteredChannelsState;
recommendedChannels: RecommendedChannelsState;
campaigns: CampaignsState;
campaignTypes: CampaignTypesState;
};

View File

@ -1,3 +1,4 @@
export { useInstalledPlugins } from './useInstalledPlugins';
export { useRegisteredChannels } from './useRegisteredChannels';
export { useRecommendedChannels } from './useRecommendedChannels';
export { useCampaignTypes } from './useCampaignTypes';

View File

@ -0,0 +1,59 @@
/**
* External dependencies
*/
import { useDispatch, useSelect } from '@wordpress/data';
import { useCallback } from '@wordpress/element';
/**
* Internal dependencies
*/
import { STORE_KEY } from '~/marketing/data-multichannel/constants';
import {
CampaignTypesState,
CampaignType as APICampaignType,
ApiFetchError,
} from '~/marketing/data-multichannel/types';
import { CampaignType } from '~/marketing/types/CampaignType';
type UseCampaignTypes = {
loading: boolean;
data?: Array< CampaignType >;
error?: ApiFetchError;
refetch: () => void;
};
const convert = ( campaignType: APICampaignType ): CampaignType => {
return {
id: campaignType.id,
icon: campaignType.icon_url,
name: campaignType.name,
description: campaignType.description,
createUrl: campaignType.create_url,
channelName: campaignType.channel.name,
channelSlug: campaignType.channel.slug,
};
};
export const useCampaignTypes = (): UseCampaignTypes => {
const { invalidateResolution } = useDispatch( STORE_KEY );
const refetch = useCallback( () => {
invalidateResolution( 'getCampaignTypes', [] );
}, [ invalidateResolution ] );
return useSelect< UseCampaignTypes >(
( select ) => {
const { hasFinishedResolution, getCampaignTypes } =
select( STORE_KEY );
const campaignTypesState = getCampaignTypes< CampaignTypesState >();
return {
loading: ! hasFinishedResolution( 'getCampaignTypes', [] ),
data: campaignTypesState.data?.map( convert ),
error: campaignTypesState.error,
refetch,
};
},
[ refetch ]
);
};

View File

@ -8,12 +8,21 @@ import userEvent from '@testing-library/user-event';
* Internal dependencies
*/
import { useCampaigns } from './useCampaigns';
import { useCampaignTypes } from '~/marketing/hooks';
import { Campaigns } from './Campaigns';
jest.mock( './useCampaigns', () => ( {
useCampaigns: jest.fn(),
} ) );
jest.mock( '~/marketing/hooks', () => ( {
useCampaignTypes: jest.fn(),
} ) );
jest.mock( './CreateNewCampaignModal', () => ( {
CreateNewCampaignModal: () => <div>Mocked CreateNewCampaignModal</div>,
} ) );
/**
* Create a test campaign data object.
*/
@ -38,6 +47,9 @@ describe( 'Campaigns component', () => {
data: undefined,
meta: undefined,
} );
( useCampaignTypes as jest.Mock ).mockReturnValue( {
loading: true,
} );
const { container } = render( <Campaigns /> );
const tablePlaceholder = container.querySelector(
@ -54,6 +66,9 @@ describe( 'Campaigns component', () => {
data: undefined,
meta: undefined,
} );
( useCampaignTypes as jest.Mock ).mockReturnValue( {
loading: false,
} );
render( <Campaigns /> );
@ -71,6 +86,9 @@ describe( 'Campaigns component', () => {
total: 0,
},
} );
( useCampaignTypes as jest.Mock ).mockReturnValue( {
loading: false,
} );
render( <Campaigns /> );
@ -88,6 +106,9 @@ describe( 'Campaigns component', () => {
total: 1,
},
} );
( useCampaignTypes as jest.Mock ).mockReturnValue( {
loading: false,
} );
const { container } = render( <Campaigns /> );
@ -114,6 +135,9 @@ describe( 'Campaigns component', () => {
total: 6,
},
} );
( useCampaignTypes as jest.Mock ).mockReturnValue( {
loading: false,
} );
render( <Campaigns /> );
@ -131,4 +155,29 @@ describe( 'Campaigns component', () => {
// Campaign info in the second page.
expect( screen.getByText( 'Campaign 6' ) ).toBeInTheDocument();
} );
it( 'renders a "Create new campaign" button in the card header, and upon clicking, displays the "Create a new campaign" modal', async () => {
( useCampaigns as jest.Mock ).mockReturnValue( {
loading: false,
error: undefined,
data: [ createTestCampaign( '1' ) ],
meta: {
total: 1,
},
} );
( useCampaignTypes as jest.Mock ).mockReturnValue( {
loading: false,
} );
render( <Campaigns /> );
await userEvent.click(
screen.getByRole( 'button', { name: 'Create new campaign' } )
);
// Mocked CreateNewCampaignModal should be displayed.
expect(
screen.getByText( 'Mocked CreateNewCampaignModal' )
).toBeInTheDocument();
} );
} );

View File

@ -4,6 +4,7 @@
import { __ } from '@wordpress/i18n';
import { useState } from '@wordpress/element';
import {
Button,
Card,
CardHeader,
CardBody,
@ -25,6 +26,7 @@ import {
*/
import { CardHeaderTitle } from '~/marketing/components';
import { useCampaigns } from './useCampaigns';
import { CreateNewCampaignModal } from './CreateNewCampaignModal';
import './Campaigns.scss';
const tableCaption = __( 'Campaigns', 'woocommerce' );
@ -53,6 +55,7 @@ const perPage = 5;
*/
export const Campaigns = () => {
const [ page, setPage ] = useState( 1 );
const [ isModalOpen, setModalOpen ] = useState( false );
const { loading, data, meta } = useCampaigns( page, perPage );
const total = meta?.total;
@ -159,6 +162,17 @@ export const Campaigns = () => {
<CardHeaderTitle>
{ __( 'Campaigns', 'woocommerce' ) }
</CardHeaderTitle>
<Button
variant="secondary"
onClick={ () => setModalOpen( true ) }
>
{ __( 'Create new campaign', 'woocommerce' ) }
</Button>
{ isModalOpen && (
<CreateNewCampaignModal
onRequestClose={ () => setModalOpen( false ) }
/>
) }
</CardHeader>
{ getContent() }
{ total && total > perPage && (

View File

@ -0,0 +1,54 @@
.woocommerce-marketing-create-campaign-modal {
max-width: 600px;
.components-modal__content {
padding-bottom: $gap;
}
.woocommerce-marketing-new-campaigns {
padding-top: $gap-large;
padding-bottom: $gap;
&__question-label {
font-size: 14px;
font-weight: 600;
line-height: 17px;
margin-bottom: 16px;
}
}
.components-button.is-link {
text-decoration: none;
font-size: 14px;
font-weight: 600;
line-height: 17px;
}
.woocommerce_marketing_plugin_card_body {
padding: $gap 0;
}
.woocommerce-marketing-new-campaign-type {
padding: $gap 0;
&__name {
font-weight: 600;
font-size: 14px;
line-height: 20px;
}
&__description {
color: #555d66;
}
img,
svg {
display: block;
}
}
.woocommerce-marketing-add-channels {
padding-top: $gap;
border-top: solid 1px $gray-300;
}
}

View File

@ -0,0 +1,136 @@
/**
* External dependencies
*/
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
/**
* Internal dependencies
*/
import {
useCampaignTypes,
useRecommendedChannels,
useRegisteredChannels,
} from '~/marketing/hooks';
import { CreateNewCampaignModal } from './CreateNewCampaignModal';
jest.mock( '@woocommerce/components', () => {
const originalModule = jest.requireActual( '@woocommerce/components' );
return {
__esModule: true,
...originalModule,
Spinner: () => <div data-testid="spinner">Spinner</div>,
};
} );
jest.mock( '~/marketing/hooks', () => ( {
useCampaignTypes: jest.fn(),
useRecommendedChannels: jest.fn(),
useRegisteredChannels: jest.fn(),
} ) );
const google = {
id: 'google-ads',
icon: 'https://woocommerce.com/wp-content/uploads/2021/06/woo-GoogleListingsAds-jworee.png',
name: 'Google Ads',
description:
'Boost your product listings with a campaign that is automatically optimized to meet your goals.',
createUrl:
'https://wc1.test/wp-admin/admin.php?page=wc-admin&path=/google/dashboard&subpath=/campaigns/create',
channelName: 'Google Listings and Ads',
channelSlug: 'google-listings-and-ads',
};
const pinterest = {
title: 'Pinterest for WooCommerce',
description:
'Grow your business on Pinterest! Use this official plugin to allow shoppers to Pin products while browsing your store, track conversions, and advertise on Pinterest.',
url: 'https://woocommerce.com/products/pinterest-for-woocommerce/?utm_source=marketingtab&utm_medium=product&utm_campaign=wcaddons',
direct_install: true,
icon: 'https://woocommerce.com/wp-content/plugins/wccom-plugins/marketing-tab-rest-api/icons/pinterest.svg',
product: 'pinterest-for-woocommerce',
plugin: 'pinterest-for-woocommerce/pinterest-for-woocommerce.php',
categories: [ 'marketing' ],
subcategories: [ { slug: 'sales-channels', name: 'Sales channels' } ],
tags: [
{
slug: 'built-by-woocommerce',
name: 'Built by WooCommerce',
},
],
show_extension_promotions: true,
};
const amazon = {
title: 'Amazon, eBay & Walmart Integration for WooCommerce',
description:
'Convert Woocommerce into a fully-featured omnichannel commerce platform, leveraging powerful automation and real-time sync to connect your brand with millions of new customers on the world\u2019s largest online marketplaces.',
url: 'https://woocommerce.com/products/amazon-ebay-integration/?utm_source=marketingtab&utm_medium=product&utm_campaign=wcaddons',
direct_install: false,
icon: 'https://woocommerce.com/wp-content/plugins/wccom-plugins/marketing-tab-rest-api/icons/amazon-ebay.svg',
product: 'amazon-ebay-integration',
plugin: 'woocommerce-amazon-ebay-integration/woocommerce-amazon-ebay-integration.php',
categories: [ 'marketing' ],
subcategories: [ { slug: 'sales-channels', name: 'Sales channels' } ],
tags: [],
};
describe( 'CreateNewCampaignModal component', () => {
it( 'renders new campaign types with recommended channels', async () => {
( useCampaignTypes as jest.Mock ).mockReturnValue( {
data: [ google ],
} );
( useRecommendedChannels as jest.Mock ).mockReturnValue( {
data: [ pinterest, amazon ],
} );
( useRegisteredChannels as jest.Mock ).mockReturnValue( {
refetch: jest.fn(),
} );
render( <CreateNewCampaignModal onRequestClose={ () => {} } /> );
expect( screen.getByText( 'Google Ads' ) ).toBeInTheDocument();
expect(
screen.getByText(
'Boost your product listings with a campaign that is automatically optimized to meet your goals.'
)
).toBeInTheDocument();
// Click button to expand recommended channels section.
await userEvent.click(
screen.getByRole( 'button', {
name: 'Add channels for other campaign types',
} )
);
expect(
screen.getByText( 'Pinterest for WooCommerce' )
).toBeInTheDocument();
expect(
screen.getByText(
'Amazon, eBay & Walmart Integration for WooCommerce'
)
).toBeInTheDocument();
} );
it( 'does not render recommended channels section when there are no recommended channels', async () => {
( useCampaignTypes as jest.Mock ).mockReturnValue( {
data: [ google ],
} );
( useRecommendedChannels as jest.Mock ).mockReturnValue( {
data: [],
} );
( useRegisteredChannels as jest.Mock ).mockReturnValue( {
refetch: jest.fn(),
} );
render( <CreateNewCampaignModal onRequestClose={ () => {} } /> );
// The expand button should not be there.
expect(
screen.queryByRole( 'button', {
name: 'Add channels for other campaign types',
} )
).not.toBeInTheDocument();
} );
} );

View File

@ -0,0 +1,156 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useState } from '@wordpress/element';
import {
Button,
Modal,
Icon,
Flex,
FlexBlock,
FlexItem,
} from '@wordpress/components';
import { chevronUp, chevronDown, external } from '@wordpress/icons';
import classnames from 'classnames';
/**
* Internal dependencies
*/
import {
useRecommendedChannels,
useCampaignTypes,
useRegisteredChannels,
} from '~/marketing/hooks';
import { SmartPluginCardBody } from '~/marketing/components';
import './CreateNewCampaignModal.scss';
const isExternalURL = ( url: string ) =>
new URL( url ).origin !== location.origin;
/**
* Props for CreateNewCampaignModal, which is based on Modal.Props.
*
* Modal's title and children props are omitted because they are specified within the component
* and not needed to be specified by the consumer.
*/
type CreateCampaignModalProps = Omit< Modal.Props, 'title' | 'children' >;
export const CreateNewCampaignModal = ( props: CreateCampaignModalProps ) => {
const { className, ...restProps } = props;
const [ collapsed, setCollapsed ] = useState( true );
const { data: campaignTypes, refetch: refetchCampaignTypes } =
useCampaignTypes();
const { refetch: refetchRegisteredChannels } = useRegisteredChannels();
const { data: recommendedChannels } = useRecommendedChannels();
const refetch = () => {
refetchCampaignTypes();
refetchRegisteredChannels();
};
return (
<Modal
{ ...restProps }
className={ classnames(
className,
'woocommerce-marketing-create-campaign-modal'
) }
title={ __( 'Create a new campaign', 'woocommerce' ) }
>
<div className="woocommerce-marketing-new-campaigns">
<div className="woocommerce-marketing-new-campaigns__question-label">
{ !! campaignTypes?.length
? __(
'Where would you like to promote your products?',
'woocommerce'
)
: __( 'No campaign types found.', 'woocommerce' ) }
</div>
{ campaignTypes?.map( ( el ) => (
<Flex
key={ el.id }
className="woocommerce-marketing-new-campaign-type"
gap={ 4 }
>
<FlexItem>
<img
src={ el.icon }
alt={ el.name }
width="32"
height="32"
/>
</FlexItem>
<FlexBlock>
<Flex direction="column" gap={ 1 }>
<FlexItem className="woocommerce-marketing-new-campaign-type__name">
{ el.name }
</FlexItem>
<FlexItem className="woocommerce-marketing-new-campaign-type__description">
{ el.description }
</FlexItem>
</Flex>
</FlexBlock>
<FlexItem>
<Button
variant="secondary"
href={ el.createUrl }
target={
isExternalURL( el.createUrl )
? '_blank'
: '_self'
}
>
<Flex gap={ 1 }>
<FlexItem>
{ __( 'Create', 'woocommerce' ) }
</FlexItem>
{ isExternalURL( el.createUrl ) && (
<FlexItem>
<Icon
icon={ external }
size={ 16 }
/>
</FlexItem>
) }
</Flex>
</Button>
</FlexItem>
</Flex>
) ) }
</div>
{ !! recommendedChannels?.length && (
<div className="woocommerce-marketing-add-channels">
<Flex direction="column">
<FlexItem>
<Button
variant="link"
onClick={ () => setCollapsed( ! collapsed ) }
>
{ __(
'Add channels for other campaign types',
'woocommerce'
) }
<Icon
icon={ collapsed ? chevronDown : chevronUp }
size={ 24 }
/>
</Button>
</FlexItem>
{ ! collapsed && (
<FlexItem>
{ recommendedChannels.map( ( el ) => (
<SmartPluginCardBody
key={ el.plugin }
plugin={ el }
onInstalledAndActivated={ refetch }
/>
) ) }
</FlexItem>
) }
</Flex>
</div>
) }
</Modal>
);
};

View File

@ -0,0 +1 @@
export { CreateNewCampaignModal } from './CreateNewCampaignModal';

View File

@ -12,6 +12,7 @@ import { CenteredSpinner } from '~/marketing/components';
import {
useRegisteredChannels,
useRecommendedChannels,
useCampaignTypes,
} from '~/marketing/hooks';
import { getAdminSetting } from '~/utils/admin-settings';
import { Campaigns } from './Campaigns';
@ -22,26 +23,37 @@ import { LearnMarketing } from './LearnMarketing';
import './MarketingOverviewMultichannel.scss';
export const MarketingOverviewMultichannel: React.FC = () => {
const {
loading: loadingCampaignTypes,
data: dataCampaignTypes,
refetch: refetchCampaignTypes,
} = useCampaignTypes();
const {
loading: loadingRegistered,
data: dataRegistered,
refetch,
refetch: refetchRegisteredChannels,
} = useRegisteredChannels();
const { loading: loadingRecommended, data: dataRecommended } =
useRecommendedChannels();
const { currentUserCan } = useUser();
const shouldShowExtensions =
getAdminSetting( 'allowMarketplaceSuggestions', false ) &&
currentUserCan( 'install_plugins' );
if (
( loadingCampaignTypes && ! dataCampaignTypes ) ||
( loadingRegistered && ! dataRegistered ) ||
( loadingRecommended && ! dataRecommended )
) {
return <CenteredSpinner />;
}
const shouldShowExtensions =
getAdminSetting( 'allowMarketplaceSuggestions', false ) &&
currentUserCan( 'install_plugins' );
const refetch = () => {
refetchCampaignTypes();
refetchRegisteredChannels();
};
return (
<div className="woocommerce-marketing-overview-multichannel">
{ !! dataRegistered?.length && <Campaigns /> }

View File

@ -0,0 +1,9 @@
export type CampaignType = {
id: string;
icon: string;
name: string;
description: string;
createUrl: string;
channelName: string;
channelSlug: string;
};

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add "Create a new campaign" modal in Campaigns card in Multichannel Marketing page.