feat: add shipping marketplace recommendations (https://github.com/woocommerce/woocommerce-admin/pull/7446)

This commit is contained in:
Francesco 2021-08-11 15:09:32 -07:00 committed by GitHub
parent 3398e03999
commit c1730d16f1
14 changed files with 616 additions and 0 deletions

View File

@ -0,0 +1,4 @@
Significance: patch
Type: Add
Added shipping plugin recommendations to settings page (#7446).

View File

@ -8,6 +8,7 @@ import QueryString, { parse } from 'qs';
* Internal dependencies * Internal dependencies
*/ */
import { PaymentRecommendations } from '../payments'; import { PaymentRecommendations } from '../payments';
import { ShippingRecommendations } from '../shipping';
import { EmbeddedBodyProps } from './embedded-body-props'; import { EmbeddedBodyProps } from './embedded-body-props';
import './style.scss'; import './style.scss';
@ -21,6 +22,7 @@ function isWPPage(
const EMBEDDED_BODY_COMPONENT_LIST: React.ElementType[] = [ const EMBEDDED_BODY_COMPONENT_LIST: React.ElementType[] = [
PaymentRecommendations, PaymentRecommendations,
ShippingRecommendations,
]; ];
/** /**

View File

@ -1,5 +1,6 @@
export type EmbeddedBodyProps = { export type EmbeddedBodyProps = {
page: string; page: string;
tab: string; tab: string;
zone_id?: string;
section?: string; section?: string;
}; };

View File

@ -0,0 +1,14 @@
.woocommerce-dismissable-list {
margin: 0 20px 10px 20px;
animation: isLoaded;
animation-duration: 250ms;
@media (min-width: #{ ($break-medium) }) {
margin-left: 0;
margin-right: 0;
}
.woocommerce-dismissable-list__controls {
text-align: center;
}
}

View File

@ -0,0 +1,89 @@
/**
* External dependencies
*/
import { useDispatch, useSelect } from '@wordpress/data';
import { Button, Card, CardHeader } from '@wordpress/components';
import { OPTIONS_STORE_NAME } from '@woocommerce/data';
import { EllipsisMenu } from '@woocommerce/components';
import { __ } from '@wordpress/i18n';
import { createContext, useContext } from '@wordpress/element';
import classNames from 'classnames';
/**
* Internal dependencies
*/
import './dismissable-list.scss';
// using a context provider for the option name so that the option name prop doesn't need to be passed to the `DismissableListHeading` too
const OptionNameContext = createContext( '' );
export const DismissableListHeading: React.FC< {
onDismiss?: () => void;
} > = ( { children, onDismiss = () => null } ) => {
const { updateOptions } = useDispatch( OPTIONS_STORE_NAME );
const dismissOptionName = useContext( OptionNameContext );
const handleDismissClick = () => {
onDismiss();
updateOptions( {
[ dismissOptionName ]: 'yes',
} );
};
return (
<CardHeader>
<div className="woocommerce-dismissable-list__header">
{ children }
</div>
<div>
<EllipsisMenu
label={ __( 'Task List Options', 'woocommerce-admin' ) }
renderContent={ () => (
<div className="woocommerce-dismissable-list__controls">
<Button onClick={ handleDismissClick }>
{ __( 'Hide this', 'woocommerce-admin' ) }
</Button>
</div>
) }
/>
</div>
</CardHeader>
);
};
export const DismissableList: React.FC< {
dismissOptionName: string;
className?: string;
} > = ( { children, className, dismissOptionName } ) => {
const isVisible = useSelect( ( select ) => {
const { getOption, hasFinishedResolution } = select(
OPTIONS_STORE_NAME
);
const hasFinishedResolving = hasFinishedResolution( 'getOption', [
dismissOptionName,
] );
const isDismissed = getOption( dismissOptionName ) === 'yes';
return hasFinishedResolving && ! isDismissed;
} );
if ( ! isVisible ) {
return null;
}
return (
<Card
size="medium"
className={ classNames(
'woocommerce-dismissable-list',
className
) }
>
<OptionNameContext.Provider value={ dismissOptionName }>
{ children }
</OptionNameContext.Provider>
</Card>
);
};

View File

@ -0,0 +1,99 @@
/**
* External dependencies
*/
import { render, screen } from '@testing-library/react';
import { useDispatch, useSelect } from '@wordpress/data';
import userEvent from '@testing-library/user-event';
/**
* Internal dependencies
*/
import { DismissableList, DismissableListHeading } from '../dismissable-list';
jest.mock( '@wordpress/data', () => ( {
...jest.requireActual( '@wordpress/data' ),
useSelect: jest.fn(),
useDispatch: jest.fn(),
} ) );
const DismissableListMock = ( { children } ) => (
<DismissableList dismissOptionName="dismissable_option_mock">
{ children }
<span>dismissible children</span>
</DismissableList>
);
describe( 'DismissableList', () => {
beforeEach( () => {
useSelect.mockImplementation( ( fn ) =>
fn( () => ( {
getOption: () => false,
hasFinishedResolution: () => true,
} ) )
);
useDispatch.mockReturnValue( { updateOptions: () => null } );
} );
it( 'should not render its children when the option is not resolved', () => {
useSelect.mockImplementation( ( fn ) =>
fn( () => ( {
getOption: () => false,
hasFinishedResolution: () => false,
} ) )
);
render( <DismissableListMock /> );
expect(
screen.queryByText( 'dismissible children' )
).not.toBeInTheDocument();
} );
it( 'should not render its children when the option is dismissed', () => {
useSelect.mockImplementation( ( fn ) =>
fn( () => ( {
getOption: () => 'yes',
hasFinishedResolution: () => true,
} ) )
);
render( <DismissableListMock /> );
expect(
screen.queryByText( 'dismissible children' )
).not.toBeInTheDocument();
} );
it( 'render its children', () => {
render( <DismissableListMock /> );
expect(
screen.queryByText( 'dismissible children' )
).toBeInTheDocument();
} );
it( 'should allow dismissing the option through the DismissableListHeading component', () => {
const handleDismissMock = jest.fn();
const updateOptionsMock = jest.fn();
useDispatch.mockReturnValue( { updateOptions: updateOptionsMock } );
render(
<DismissableListMock>
<DismissableListHeading onDismiss={ handleDismissMock }>
heading content mock
</DismissableListHeading>
</DismissableListMock>
);
expect(
screen.queryByText( 'heading content mock' )
).toBeInTheDocument();
userEvent.click( screen.getByTitle( 'Task List Options' ) );
userEvent.click( screen.getByText( 'Hide this' ) );
expect( handleDismissMock ).toHaveBeenCalled();
expect( updateOptionsMock ).toHaveBeenCalledWith(
expect.objectContaining( {
dismissable_option_mock: 'yes',
} )
);
} );
} );

View File

@ -0,0 +1 @@
export * from './shipping-recommendations-wrapper';

View File

@ -0,0 +1,48 @@
/**
* External dependencies
*/
import { lazy, Suspense } from '@wordpress/element';
/**
* Internal dependencies
*/
import { EmbeddedBodyProps } from '../embedded-body-layout/embedded-body-props';
import RecommendationsEligibilityWrapper from '../settings-recommendations/recommendations-eligibility-wrapper';
const ShippingRecommendationsLoader = lazy(
() =>
import(
/* webpackChunkName: "shipping-recommendations" */ './shipping-recommendations'
)
);
export const ShippingRecommendations: React.FC< EmbeddedBodyProps > = ( {
page,
tab,
section,
zone_id,
} ) => {
if ( page !== 'wc-settings' ) {
return null;
}
if ( tab !== 'shipping' ) {
return null;
}
if ( Boolean( section ) ) {
return null;
}
if ( Boolean( zone_id ) ) {
return null;
}
return (
<RecommendationsEligibilityWrapper>
<Suspense fallback={ null }>
<ShippingRecommendationsLoader />
</Suspense>
</RecommendationsEligibilityWrapper>
);
};

View File

@ -0,0 +1,50 @@
.woocommerce-recommended-shipping-extensions {
&__more_options_cta {
// adding some breathing room between the "external" icon and the text
.gridicon {
margin-left: $gap-smallest;
}
}
// overwriting the list's default colors/behavior
.woocommerce-list__item {
> .woocommerce-list__item-inner {
align-items: flex-start;
}
&:hover {
background-color: $white;
.woocommerce-list__item-title {
color: $gray-900;
}
}
}
.woocommerce-list__item-title {
font-size: 14px;
color: $gray-900;
font-weight: 600;
}
.woocommerce-pill {
margin-left: $gap-smallest;
margin-top: $gap-smallest;
margin-bottom: $gap-smallest;
padding: 2px 8px;
@media (min-width: #{ ($break-mobile) }) {
margin-top: 0;
margin-bottom: 0;
}
}
.woocommerce-list__item-after .components-button {
margin-left: $gap-small;
}
.woocommerce-list__item-text,
.woocommerce-recommended-shipping__header-heading {
max-width: 749px;
}
}

View File

@ -0,0 +1,120 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useSelect, useDispatch } from '@wordpress/data';
import { useState, Children } from '@wordpress/element';
import { Text } from '@woocommerce/experimental';
import { PLUGINS_STORE_NAME } from '@woocommerce/data';
import ExternalIcon from 'gridicons/dist/external';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore VisuallyHidden is present, it's just not typed
// eslint-disable-next-line @woocommerce/dependency-group
import { CardFooter, Button, VisuallyHidden } from '@wordpress/components';
/**
* Internal dependencies
*/
import { createNoticesFromResponse } from '../lib/notices';
import {
DismissableList,
DismissableListHeading,
} from '../settings-recommendations/dismissable-list';
import WooCommerceServicesItem from './woocommerce-services-item';
import './shipping-recommendations.scss';
const useInstallPlugin = () => {
const [ pluginsBeingSetup, setPluginsBeingSetup ] = useState<
Array< string >
>( [] );
const { installAndActivatePlugins } = useDispatch( PLUGINS_STORE_NAME );
const handleSetup = ( slugs: string[] ): PromiseLike< void > => {
if ( pluginsBeingSetup.length > 0 ) {
return Promise.resolve();
}
setPluginsBeingSetup( slugs );
return installAndActivatePlugins( slugs )
.then( () => {
setPluginsBeingSetup( [] );
} )
.catch( ( response: { errors: Record< string, string > } ) => {
createNoticesFromResponse( response );
setPluginsBeingSetup( [] );
return Promise.reject();
} );
};
return [ pluginsBeingSetup, handleSetup ] as const;
};
const ShippingRecommendationsList: React.FC = ( { children } ) => (
<DismissableList
className="woocommerce-recommended-shipping-extensions"
dismissOptionName="woocommerce_settings_shipping_recommendations_hidden"
>
<DismissableListHeading>
<Text variant="title.small" as="p" size="20" lineHeight="28px">
{ __( 'Recommended shipping solutions', 'woocommerce-admin' ) }
</Text>
<Text
className="woocommerce-recommended-shipping__header-heading"
variant="caption"
as="p"
size="12"
lineHeight="16px"
>
{ __(
'We recommend adding one of the following shipping extensions to your store. The extension will be installed and activated for you when you click "Get started".',
'woocommerce-admin'
) }
</Text>
</DismissableListHeading>
<ul className="woocommerce-list">
{ Children.map( children, ( item ) => (
<li className="woocommerce-list__item">{ item }</li>
) ) }
</ul>
<CardFooter>
<Button
className="woocommerce-recommended-shipping-extensions__more_options_cta"
href="https://woocommerce.com/product-category/woocommerce-extensions/shipping-methods/?utm_source=shipping_recommendations"
target="_blank"
isTertiary
>
{ __( 'See more options', 'woocommerce-admin' ) }
<VisuallyHidden>
{ __( '(opens in a new tab)', 'woocommerce-admin' ) }
</VisuallyHidden>
<ExternalIcon size={ 18 } />
</Button>
</CardFooter>
</DismissableList>
);
const ShippingRecommendations: React.FC = () => {
const [ pluginsBeingSetup, setupPlugin ] = useInstallPlugin();
const activePlugins = useSelect< string[] >( ( select ) =>
select( PLUGINS_STORE_NAME ).getActivePlugins()
);
if ( activePlugins.includes( 'woocommerce-services' ) ) {
return null;
}
return (
<ShippingRecommendationsList>
<WooCommerceServicesItem
pluginsBeingSetup={ pluginsBeingSetup }
onSetupClick={ setupPlugin }
/>
</ShippingRecommendationsList>
);
};
export default ShippingRecommendations;

View File

@ -0,0 +1,88 @@
/**
* External dependencies
*/
import { act, render, screen, waitFor } from '@testing-library/react';
import { useDispatch, useSelect } from '@wordpress/data';
import userEvent from '@testing-library/user-event';
/**
* Internal dependencies
*/
import ShippingRecommendations from '../shipping-recommendations';
jest.mock( '@wordpress/data', () => ( {
...jest.requireActual( '@wordpress/data' ),
useSelect: jest.fn(),
useDispatch: jest.fn(),
} ) );
jest.mock( '../../settings-recommendations/dismissable-list', () => ( {
DismissableList: ( { children } ) => children,
DismissableListHeading: ( { children } ) => children,
} ) );
jest.mock( '../../lib/notices', () => ( {
createNoticesFromResponse: () => null,
} ) );
describe( 'ShippingRecommendations', () => {
beforeEach( () => {
useSelect.mockImplementation( ( fn ) =>
fn( () => ( {
getActivePlugins: () => [],
isJetpackConnected: () => false,
} ) )
);
useDispatch.mockReturnValue( {
installAndActivatePlugins: () => Promise.resolve(),
createSuccessNotice: () => null,
} );
} );
it( 'should not render when WCS is already installed', () => {
useSelect.mockImplementation( ( fn ) =>
fn( () => ( {
getActivePlugins: () => [ 'woocommerce-services' ],
} ) )
);
render( <ShippingRecommendations /> );
expect(
screen.queryByText( 'Woocommerce Shipping' )
).not.toBeInTheDocument();
} );
it( 'should render WCS when not installed', () => {
render( <ShippingRecommendations /> );
expect(
screen.queryByText( 'Woocommerce Shipping' )
).toBeInTheDocument();
} );
it( 'allows to install WCS', async () => {
const installAndActivatePluginsMock = jest
.fn()
.mockResolvedValue( undefined );
const successNoticeMock = jest.fn();
useDispatch.mockReturnValue( {
installAndActivatePlugins: installAndActivatePluginsMock,
isJetpackConnected: () => false,
createSuccessNotice: successNoticeMock,
} );
render( <ShippingRecommendations /> );
expect( installAndActivatePluginsMock ).not.toHaveBeenCalled();
expect( successNoticeMock ).not.toHaveBeenCalled();
act( () => {
userEvent.click( screen.getByText( 'Get started' ) );
} );
expect( installAndActivatePluginsMock ).toHaveBeenCalled();
await waitFor( () => {
expect( successNoticeMock ).toHaveBeenCalledWith(
'🎉 WooCommerce Shipping is installed!',
expect.anything()
);
} );
} );
} );

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120"><path fill="#7d57a4" d="M0 0h120v120H0z"/><path fill="#fff" d="M67.48 53.55c-1.19-.26-2.33.42-3.43 2.03-.87 1.26-1.45 2.56-1.74 3.91-.16.77-.24 1.58-.24 2.41 0 .97.19 1.96.58 2.99.48 1.26 1.13 1.96 1.93 2.12.8.16 1.69-.19 2.66-1.06 1.22-1.09 2.06-2.72 2.51-4.88.16-.77.24-1.58.24-2.41 0-.97-.19-1.96-.58-2.99-.48-1.25-1.12-1.96-1.93-2.12zm20.62 0c-1.19-.26-2.33.42-3.43 2.03-.87 1.26-1.45 2.56-1.74 3.91-.16.77-.24 1.58-.24 2.41 0 .97.19 1.96.58 2.99.48 1.26 1.13 1.96 1.93 2.12.8.16 1.69-.19 2.66-1.06 1.22-1.09 2.06-2.72 2.51-4.88.16-.77.24-1.58.24-2.41 0-.97-.19-1.96-.58-2.99-.48-1.25-1.12-1.96-1.93-2.12z"/><path fill="#fff" d="M92.76 40H27.24c-4.14 0-7.5 3.36-7.5 7.5v24.98c0 4.14 3.36 7.5 7.5 7.5h31.04l14.19 7.9-3.22-7.9h23.5c4.14 0 7.5-3.36 7.5-7.5V47.5c.01-4.14-3.35-7.5-7.49-7.5zM52.74 72.91c.06.84-.07 1.55-.38 2.16-.4.74-.98 1.13-1.75 1.19-.87.06-1.73-.35-2.6-1.22-3.06-3.14-5.49-7.81-7.28-14-2.12 4.21-3.71 7.37-4.75 9.48-1.93 3.72-3.59 5.62-4.97 5.72-.9.06-1.66-.69-2.29-2.26-1.69-4.3-3.5-12.63-5.44-24.97-.13-.86.05-1.6.52-2.21.47-.61 1.16-.95 2.06-1.02 1.67-.12 2.63.67 2.88 2.36 1.03 6.86 2.14 12.69 3.31 17.48l7.21-13.72c.66-1.24 1.48-1.9 2.47-1.97 1.44-.1 2.35.82 2.71 2.76.82 4.36 1.86 8.11 3.12 11.25.86-8.35 2.31-14.39 4.34-18.11.48-.9 1.21-1.39 2.17-1.46.77-.05 1.46.16 2.08.65.62.49.95 1.12 1 1.89.04.58-.07 1.1-.32 1.57-1.28 2.38-2.34 6.34-3.18 11.89-.82 5.34-1.13 9.53-.91 12.54zm20.2-5.16c-1.96 3.28-4.54 4.92-7.72 4.92-.58 0-1.18-.07-1.79-.19-2.32-.48-4.07-1.75-5.26-3.81-1.06-1.8-1.59-3.97-1.59-6.52 0-3.38.85-6.47 2.56-9.27 2-3.28 4.57-4.92 7.72-4.92.58 0 1.17.07 1.79.19 2.32.48 4.07 1.75 5.26 3.81 1.06 1.77 1.59 3.93 1.59 6.47-.01 3.38-.86 6.48-2.56 9.32zm20.62 0c-1.96 3.28-4.54 4.92-7.72 4.92-.58 0-1.17-.07-1.78-.19-2.32-.48-4.07-1.75-5.26-3.81-1.06-1.8-1.59-3.97-1.59-6.52 0-3.38.85-6.47 2.56-9.27 2-3.28 4.57-4.92 7.72-4.92.58 0 1.17.07 1.78.19 2.32.48 4.07 1.75 5.26 3.81 1.06 1.77 1.59 3.93 1.59 6.47 0 3.38-.86 6.48-2.56 9.32z"/></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -0,0 +1,6 @@
.woocommerce-services-item {
&__logo {
width: 36px;
height: auto;
}
}

View File

@ -0,0 +1,93 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useSelect, useDispatch } from '@wordpress/data';
import { Button, ExternalLink } from '@wordpress/components';
import { Pill } from '@woocommerce/components';
import { PLUGINS_STORE_NAME } from '@woocommerce/data';
import { getAdminLink, getSetting } from '@woocommerce/wc-admin-settings';
/**
* Internal dependencies
*/
import './woocommerce-services-item.scss';
import WooIcon from './woo-icon.svg';
const WooCommerceServicesItem: React.FC< {
pluginsBeingSetup: Array< string >;
onSetupClick: ( slugs: string[] ) => PromiseLike< void >;
} > = ( { onSetupClick, pluginsBeingSetup } ) => {
const wcAdminAssetUrl = getSetting( 'wcAdminAssetUrl', '' );
const { createSuccessNotice } = useDispatch( 'core/notices' );
const isSiteConnectedToJetpack = useSelect( ( select ) =>
select( PLUGINS_STORE_NAME ).isJetpackConnected()
);
const handleSetupClick = () => {
onSetupClick( [ 'woocommerce-services' ] ).then( () => {
const actions = [];
if ( ! isSiteConnectedToJetpack ) {
actions.push( {
url: getAdminLink( 'plugins.php' ),
label: __(
'Finish the setup by connecting your store to Jetpack.',
'woocommerce-admin'
),
} );
}
createSuccessNotice(
__(
'🎉 WooCommerce Shipping is installed!',
'woocommerce-admin'
),
{
actions,
}
);
} );
};
return (
<div className="woocommerce-list__item-inner woocommerce-services-item">
<div className="woocommerce-list__item-before">
<img
className="woocommerce-services-item__logo"
src={ WooIcon }
alt=""
/>
</div>
<div className="woocommerce-list__item-text">
<span className="woocommerce-list__item-title">
{ __( 'Woocommerce Shipping', 'woocommerce-admin' ) }
<Pill>{ __( 'Recommended', 'woocommerce-admin' ) }</Pill>
</span>
<span className="woocommerce-list__item-content">
{ __(
'Print USPS and DHL Express labels straight from your WooCommerce dashboard and save on shipping.',
'woocommerce-admin'
) }
<br />
<ExternalLink href="https://woocommerce.com/woocommerce-shipping/">
{ __( 'Learn more', 'woocommerce-admin' ) }
</ExternalLink>
</span>
</div>
<div className="woocommerce-list__item-after">
<Button
isSecondary
onClick={ handleSetupClick }
isBusy={ pluginsBeingSetup.includes(
'woocommerce-services'
) }
disabled={ pluginsBeingSetup.length > 0 }
>
{ __( 'Get started', 'woocommerce-admin' ) }
</Button>
</div>
</div>
);
};
export default WooCommerceServicesItem;