* Add initial payment recommendations code for the payments settings

* Added request data for the recommended payments

* Some styling updates and make sure it does not show when marketplace suggestions is disabled

* Update url

* Update comment in php class

* Add tests

* Fix lint errors

* Remove unnecessary type

* Fix lint error

* Fix broken test

* Convert plugin package to typescript

* Fix lint errors

* Add changelog

* Add support for locale-data

* Fix track name

* Fix up the tests

* Fix lint errors

* Address PR feedback

* Add tests for option hydration

* Make types more robust in plugins reducer

* Made use of SlotFill component instead of page registry and router

* Removed console log, and fixed types

* Add newer version of i18n to data package, for newer types

* Make the request to WooCommerce.com more restrictive

* Fix path of import

* Update PHP with suggested changes

* Remove SlotFill with applyFilters

* Update copy and PR feedback

* Update package lock

* Updated package lock

* Fix the package lock

* Added dot, and some minor styling changes

* Add test instructions
This commit is contained in:
louwie17 2021-04-20 14:17:19 -03:00 committed by GitHub
parent 15897e1bbb
commit be7dd2dd5e
41 changed files with 2228 additions and 541 deletions

View File

@ -34,6 +34,9 @@ module.exports = {
'no-use-before-define': 'off',
'@typescript-eslint/no-use-before-define': [ 'error' ],
'jsdoc/require-param': 'off',
// Making use of typescript no-shadow instead, fixes issues with enum.
'no-shadow': 'off',
'@typescript-eslint/no-shadow': [ 'error' ],
},
},
],

View File

@ -4,9 +4,9 @@
### Remove PayPal for India #6828
- Setup a new store and set your country to `India`.
- Go to 'Choose Payment method' Checklist on the home page.
- Verify that PayPal is not presented as a payment method.
- Setup a new store and set your country to `India`.
- Go to 'Choose Payment method' Checklist on the home page.
- Verify that PayPal is not presented as a payment method.
### Add event recording to start of gateway connections #6801
@ -23,7 +23,7 @@
1. Go to Settings -> General.
2. Set your store timezone significantly ahead of or behind the timezone you currently reside in.
3. Create a test order and mark complete.
4. Navigate to various analytics reports and note the time filter is based on the current store time. E.g., If your store timezone is 12 hours ahead of your current time, you may see `1st - 23rd` instead of `1st - 22nd` for "Month to date" depending on your time of day.
4. Navigate to various analytics reports and note the time filter is based on the current store time. E.g., If your store timezone is 12 hours ahead of your current time, you may see `1st - 23rd` instead of `1st - 22nd` for "Month to date" depending on your time of day.
5. Note that the recently added order shows up in analytics reports.
6. Change your timezone and repeat, testing with both locations (e.g., `Amsterdam`) and also UTC offsets (e.g., `UTC-6`).
@ -47,6 +47,7 @@
5. Navigate to Homescreen.
6. Navigate back to previous Analytics Report.
7. Ensure that the time period is _still_ what you set on step 2.
### Refactor payments to allow management of methods #6786
1. Do not select "CBD industry" as a store industry during onboarding.
@ -57,8 +58,6 @@
### Fix varation bug with Products reports #6647
### Fix varation bug with Products reports #6647
1. Add two variable products. You want to have at least one variable for each product.
Product A - color:black
@ -99,6 +98,21 @@ In case the report shows "no data", please reimport historical data by following
- Click on the task again
- It should now redirect to the shipping settings page.
### Add recommended payment methods in payment settings. #6760
- Create a new store and finish the onboarding flow, making sure your store location is filled out and within US | PR | AU | CA | GB | IE | NZ
- Visit **Woocommerce > Settings > Payments** you might have to wait a couple seconds, but it should show a card with **Recommended ways to get paid** listing 3 different payment providers (WC Payments, Stripe, and Paypal).
- Click `Get started` on one of the providers, it will show a loading icon (installing the plugin), once done it should redirect you to the plugin set up page.
- Check if the plugin is installed and activated.
- Go back to the payment settings page
- Notice how the plugin you had previously installed and activated does not display anymore.
- Go to **WooCommerce > Settings > Advanced > WooCommerce.com** and un-select **Show Suggestions** and save
- Go to the payments setting screen again, the card should not be displayed.
- Enable the **Show Suggestions** again in **WooCommerce > Settings > Advanced > WooCommerce.com**
- Go to the payments setting screen again, the card should be displayed.
- Click on the 3 dots of the card, click `Hide this`, it should make the card disappear, it should also not show on refresh.
This can't be shown again unless the `woocommerce_show_marketplace_suggestions` option is deleted (through PHPMyAdmin or using `wp option delete woocommerce_show_marketplace_suggestions`).
## 2.2.0
### Fixed event tracking for merchant email notes #6616

View File

@ -0,0 +1,54 @@
/**
* External dependencies
*/
import { applyFilters } from '@wordpress/hooks';
import QueryString, { parse } from 'qs';
/**
* Internal dependencies
*/
import { PaymentRecommendations } from '../payments';
import { EmbeddedBodyProps } from './embedded-body-props';
import './style.scss';
type QueryParams = EmbeddedBodyProps;
function isWPPage(
params: QueryParams | QueryString.ParsedQs
): params is QueryParams {
return ( params as QueryParams ).page !== undefined;
}
const EMBEDDED_BODY_COMPONENT_LIST: React.ElementType[] = [
PaymentRecommendations,
];
/**
* This component is appended to the bottom of the WooCommerce non-react pages (like settings).
* You can add a component by writing a Fill component from slot-fill with the `embedded-body-layout` name.
*
* Each Fill component receives QueryParams, consisting of a page, tab, and section string.
*/
export const EmbeddedBodyLayout = () => {
const query = parse( location.search.substring( 1 ) );
let queryParams: QueryParams = { page: '', tab: '' };
if ( isWPPage( query ) ) {
queryParams = query;
}
const componentList = applyFilters(
'woocommerce_admin_embedded_layout_components',
EMBEDDED_BODY_COMPONENT_LIST,
queryParams
) as React.ElementType< EmbeddedBodyProps >[];
return (
<div
className="woocommerce-embedded-layout__primary"
id="woocommerce-embedded-layout__primary"
>
{ componentList.map( ( Comp, index ) => {
return <Comp key={ index } { ...queryParams } />;
} ) }
</div>
);
};

View File

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

View File

@ -0,0 +1 @@
export * from './embedded-body-layout';

View File

@ -0,0 +1,8 @@
.woocommerce-embedded-layout__primary {
padding: 0 20px;
}
@media (max-width: #{ ($break-medium) }) {
.woocommerce-embedded-layout__primary {
padding: 0;
}
}

View File

@ -0,0 +1,73 @@
/**
* External dependencies
*/
import { render } from '@testing-library/react';
import { addFilter } from '@wordpress/hooks';
/**
* Internal dependencies
*/
import { EmbeddedBodyLayout } from '../embedded-body-layout';
import { PaymentRecommendations } from '../../payments';
jest.mock( '@woocommerce/data', () => ( {
useUser: () => ( {
currentUserCan: jest.fn(),
} ),
} ) );
jest.mock( '../../payments', () => ( {
PaymentRecommendations: ( {
page,
tab,
section,
}: {
page: string;
tab: string;
section?: string;
} ) => (
<div>
payment_recommendations
<span>page:{ page }</span>
<span>tab:{ tab }</span>
<span>section:{ section || '' }</span>
</div>
),
} ) );
const stubLocation = ( location: string ) => {
jest.spyOn( window, 'location', 'get' ).mockReturnValue( {
...window.location,
search: location,
} );
};
describe( 'Embedded layout', () => {
it( 'should render a fill component with matching name, and provide query params', async () => {
stubLocation( '?page=settings&tab=test' );
const { queryByText, container } = render( <EmbeddedBodyLayout /> );
expect( queryByText( 'payment_recommendations' ) ).toBeInTheDocument();
expect( queryByText( 'page:settings' ) ).toBeInTheDocument();
expect( queryByText( 'tab:test' ) ).toBeInTheDocument();
expect( queryByText( 'section:' ) ).toBeInTheDocument();
} );
it( 'should render a component added through the filter - woocommerce_admin_embedded_layout_components', () => {
addFilter(
'woocommerce_admin_embedded_layout_components',
'namespace',
( components, query ) => {
return [
...components,
() => {
return <div>new_component</div>;
},
];
}
);
stubLocation( '?page=settings&tab=test' );
const { queryByText, container } = render( <EmbeddedBodyLayout /> );
expect( queryByText( 'new_component' ) ).toBeInTheDocument();
} );
} );

View File

@ -14,6 +14,7 @@ import {
import './stylesheets/_index.scss';
import { PageLayout, EmbedLayout, PrimaryLayout as NoticeArea } from './layout';
import { CustomerEffortScoreTracksContainer } from './customer-effort-score-tracks';
import { EmbeddedBodyLayout } from './embedded-body-layout';
// Modify webpack pubilcPath at runtime based on location of WordPress Plugin.
// eslint-disable-next-line no-undef,camelcase
@ -72,6 +73,11 @@ if ( appRoot ) {
</div>,
wpBody.insertBefore( noticeContainer, wrap )
);
const embeddedBodyContainer = document.createElement( 'div' );
render(
<EmbeddedBodyLayout />,
wpBody.insertBefore( embeddedBodyContainer, wrap.nextSibling )
);
}
// Render the CustomerEffortScoreTracksContainer only if

View File

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

View File

@ -0,0 +1,31 @@
/**
* External dependencies
*/
import { lazy, Suspense } from '@wordpress/element';
/**
* Internal dependencies
*/
import { EmbeddedBodyProps } from '../embedded-body-layout/embedded-body-props';
const PaymentRecommendationsChunk = lazy(
() =>
import(
/* webpackChunkName: "payment-recommendations" */ './payment-recommendations'
)
);
export const PaymentRecommendations: React.FC< EmbeddedBodyProps > = ( {
page,
tab,
section,
} ) => {
if ( page === 'wc-settings' && tab === 'checkout' && ! section ) {
return (
<Suspense fallback={ null }>
<PaymentRecommendationsChunk />
</Suspense>
);
}
return null;
};

View File

@ -0,0 +1,74 @@
.woocommerce-recommended-payments-card {
margin: 0 15px 10px 0;
animation: isLoaded;
animation-duration: 2000ms;
.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-review-activity-card__section-controls {
text-align: center;
}
.components-card__body {
padding: 0;
}
.woocommerce-pill {
margin-left: 4px;
padding: 2px 8px;
}
@media (max-width: #{ ($break-mobile) }) {
.woocommerce-pill {
margin-top: 4px;
margin-bottom: 4px;
}
}
.components-card__footer {
padding: $gap $gap-large;
}
.woocommerce-list__item-enter {
opacity: 0;
max-height: 100vh;
transform: none;
}
.woocommerce-list__item-enter-active {
opacity: 1;
transition: opacity 200ms;
}
.woocommerce-list__item-after .components-button {
margin-left: $gap-small;
}
.woocommerce-list__item-text,
.woocommerce-recommended-payments__header-heading {
max-width: 749px;
}
}
@media (max-width: #{ ($break-medium) }) {
.woocommerce-recommended-payments-card {
margin: 0 0 10px 0;
}
}

View File

@ -0,0 +1,227 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useSelect, useDispatch } from '@wordpress/data';
import { decodeEntities } from '@wordpress/html-entities';
import {
Card,
CardBody,
CardHeader,
CardFooter,
Button,
} from '@wordpress/components';
import { useEffect, useRef, useState } from '@wordpress/element';
import { EllipsisMenu, List, Pill } from '@woocommerce/components';
import { Text } from '@woocommerce/experimental';
import {
PLUGINS_STORE_NAME,
SETTINGS_STORE_NAME,
WCDataSelector,
Plugin,
WPDataSelectors,
OPTIONS_STORE_NAME,
} from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks';
/**
* Internal dependencies
*/
import './payment-recommendations.scss';
import { getCountryCode } from '../dashboard/utils';
import { getAdminLink } from '../wc-admin-settings';
import { createNoticesFromResponse } from '../lib/notices';
import { isWCPaySupported } from '../task-list/tasks/payments/methods/wcpay';
const SEE_MORE_LINK =
'https://woocommerce.com/product-category/woocommerce-extensions/payment-gateways/?utm_source=payments_recommendations';
const DISMISS_OPTION = 'woocommerce_setting_payments_recommendations_hidden';
const SHOW_MARKETPLACE_SUGGESTION_OPTION =
'woocommerce_show_marketplace_suggestions';
type SettingsSelector = WPDataSelectors & {
getSettings: (
type: string
) => { general: { woocommerce_default_country?: string } };
};
type OptionsSelector = WPDataSelectors & {
getOption: ( option: string ) => boolean | string;
};
export function getPaymentRecommendationData(
select: WCDataSelector
): {
displayable: boolean;
recommendedPlugins?: Plugin[];
} {
const { getOption, isResolving: isResolvingOption } = select(
OPTIONS_STORE_NAME
) as OptionsSelector;
const { getSettings } = select( SETTINGS_STORE_NAME ) as SettingsSelector;
const { getRecommendedPlugins } = select( PLUGINS_STORE_NAME );
const { general: settings = {} } = getSettings( 'general' );
const marketplaceSuggestions = getOption(
SHOW_MARKETPLACE_SUGGESTION_OPTION
);
const hidden = getOption( DISMISS_OPTION );
const countryCode = settings.woocommerce_default_country
? getCountryCode( settings.woocommerce_default_country )
: null;
const countrySupported = countryCode
? isWCPaySupported( countryCode )
: false;
const isRequestingOptions =
isResolvingOption( 'getOption', [ DISMISS_OPTION ] ) ||
isResolvingOption( 'getOption', [
SHOW_MARKETPLACE_SUGGESTION_OPTION,
] );
const displayable =
! isRequestingOptions &&
hidden !== 'yes' &&
marketplaceSuggestions === 'yes' &&
countrySupported;
let plugins;
if ( displayable ) {
// don't get recommended plugins until it is displayable.
plugins = getRecommendedPlugins( 'payments' );
}
return {
displayable,
recommendedPlugins: plugins,
};
}
const PaymentRecommendations: React.FC = () => {
const [ installingPlugin, setInstallingPlugin ] = useState< string | null >(
null
);
const { updateOptions } = useDispatch( OPTIONS_STORE_NAME );
const { installAndActivatePlugins } = useDispatch( PLUGINS_STORE_NAME );
const { displayable, recommendedPlugins } = useSelect(
getPaymentRecommendationData
);
const triggeredPageViewRef = useRef( false );
const shouldShowRecommendations =
displayable && recommendedPlugins && recommendedPlugins.length > 0;
useEffect( () => {
if ( shouldShowRecommendations && ! triggeredPageViewRef.current ) {
triggeredPageViewRef.current = true;
recordEvent( 'settings_payments_recommendations_pageview', {} );
}
}, [ shouldShowRecommendations ] );
if ( ! shouldShowRecommendations ) {
return null;
}
const dismissPaymentRecommendations = () => {
recordEvent( 'settings_payments_recommendations_dismiss', {} );
updateOptions( {
[ DISMISS_OPTION ]: 'yes',
} );
};
const setupPlugin = ( plugin: Plugin ) => {
if ( installingPlugin ) {
return;
}
setInstallingPlugin( plugin.product );
recordEvent( 'settings_payments_recommendations_setup', {
extension_selected: plugin.product,
} );
installAndActivatePlugins( [ plugin.product ] )
.then( () => {
window.location.href = getAdminLink(
plugin[ 'setup-link' ].replace( '/wp-admin/', '' )
);
} )
.catch( ( response: { errors: Record< string, string > } ) => {
createNoticesFromResponse( response );
setInstallingPlugin( null );
} );
};
const pluginsList = ( recommendedPlugins || [] ).map(
( plugin: Plugin ) => {
return {
key: plugin.slug,
title: (
<>
{ plugin.title }
{ plugin.recommended && (
<Pill>
{ __( 'Recommended', 'woocommerce-admin' ) }
</Pill>
) }
</>
),
content: decodeEntities( plugin.copy ),
after: (
<Button
isSecondary
onClick={ () => setupPlugin( plugin ) }
isBusy={ installingPlugin === plugin.product }
disabled={ !! installingPlugin }
>
{ plugin[ 'button-text' ] }
</Button>
),
before: <img src={ plugin.icon } alt="" />,
};
}
);
return (
<Card size="large" className="woocommerce-recommended-payments-card">
<CardHeader size="medium">
<div className="woocommerce-recommended-payments-card__header">
<Text variant="title.small">
{ __(
'Recommended ways to get paid',
'woocommerce-admin'
) }
</Text>
<Text
className={
'woocommerce-recommended-payments__header-heading'
}
variant="caption"
>
{ __(
'We recommend adding one of the following payment extensions to your store. The extension will be installed and activated for you when you click "Get started".',
'woocommerce-admin'
) }
</Text>
</div>
<div className="woocommerce-card__menu woocommerce-card__header-item">
<EllipsisMenu
label={ __( 'Task List Options', 'woocommerce-admin' ) }
renderContent={ () => (
<div className="woocommerce-review-activity-card__section-controls">
<Button
onClick={ dismissPaymentRecommendations }
>
{ __( 'Hide this', 'woocommerce-admin' ) }
</Button>
</div>
) }
/>
</div>
</CardHeader>
<CardBody>
<List items={ pluginsList } />
</CardBody>
<CardFooter>
<Button href={ SEE_MORE_LINK } isTertiary>
{ __( 'See more options', 'woocommerce-admin' ) }
</Button>
</CardFooter>
</Card>
);
};
export default PaymentRecommendations;

View File

@ -0,0 +1,403 @@
/**
* External dependencies
*/
import { render, fireEvent, waitFor } from '@testing-library/react';
import { useSelect, useDispatch } from '@wordpress/data';
import { recordEvent } from '@woocommerce/tracks';
import {
PLUGINS_STORE_NAME,
SETTINGS_STORE_NAME,
OPTIONS_STORE_NAME,
WCDataStoreName,
WPDataSelectors,
Plugin,
} from '@woocommerce/data';
/**
* Internal dependencies
*/
import PaymentRecommendations, {
getPaymentRecommendationData,
} from '../payment-recommendations';
import { isWCPaySupported } from '../../task-list/tasks/payments/methods/wcpay';
import { getAdminLink } from '../../wc-admin-settings';
import { createNoticesFromResponse } from '~/lib/notices';
jest.mock( '@woocommerce/tracks', () => ( { recordEvent: jest.fn() } ) );
jest.mock( '@wordpress/data', () => ( {
...jest.requireActual( '@wordpress/data' ),
useSelect: jest.fn(),
useDispatch: jest.fn().mockImplementation( () => ( {
updateOptions: jest.fn(),
installAndActivatePlugins: jest.fn(),
} ) ),
} ) );
jest.mock( '@woocommerce/components', () => ( {
EllipsisMenu: ( {
renderContent: Content,
}: {
renderContent: React.FunctionComponent;
} ) => <Content />,
List: ( {
items,
}: {
items: { key: string; title: string; after?: React.Component }[];
} ) => (
<div>
{ items.map( ( item ) => (
<div key={ item.key }>
<span>{ item.title }</span>
{ item.after }
</div>
) ) }
</div>
),
} ) );
jest.mock( '../../task-list/tasks/payments/methods/wcpay', () => ( {
isWCPaySupported: jest.fn(),
} ) );
jest.mock( '../../wc-admin-settings', () => ( {
getAdminLink: jest
.fn()
.mockImplementation( ( link: string ) => 'https://test.ca/' + link ),
} ) );
jest.mock( '../../lib/notices', () => ( {
createNoticesFromResponse: jest.fn().mockImplementation( () => {
// do nothing
} ),
} ) );
const storeSelectors: WPDataSelectors = {
hasStartedResolution: () => true,
hasFinishedResolution: () => true,
isResolving: () => false,
};
describe( 'Payment recommendations', () => {
it( 'should render nothing with no recommendedPlugins and country not defined', () => {
( useSelect as jest.Mock ).mockReturnValue( {
displayable: false,
recommendedPlugins: undefined,
} );
const { container } = render( <PaymentRecommendations /> );
expect( container.firstChild ).toBe( null );
} );
describe( 'getPaymentRecommendationData', () => {
const plugin = {
title: 'test',
slug: 'test',
product: 'test',
} as Plugin;
const baseSelectValues = {
plugins: undefined,
optionsResolving: false,
country: undefined,
optionValues: {},
};
const recommendedPluginsMock = jest.fn();
const createFakeSelect = ( data: {
optionsResolving?: boolean;
optionValues: Record< string, boolean | string >;
country?: string;
plugins?: Plugin[];
} ) => {
return jest
.fn()
.mockImplementation( ( storeName: WCDataStoreName ) => {
switch ( storeName ) {
case OPTIONS_STORE_NAME:
return {
isResolving: () => data.optionsResolving,
getOption: ( option: string ) =>
data.optionValues[ option ],
};
case SETTINGS_STORE_NAME:
return {
getSettings: () => ( {
general: {
woocommerce_default_country:
data.country,
},
} ),
};
case PLUGINS_STORE_NAME:
return {
getRecommendedPlugins: recommendedPluginsMock.mockReturnValue(
data.plugins
),
};
}
} );
};
it( 'should render nothing if the country is not supported', () => {
( isWCPaySupported as jest.Mock ).mockReturnValue( false );
const selectData = getPaymentRecommendationData(
createFakeSelect( {
...baseSelectValues,
country: 'FR',
plugins: [ plugin ],
} )
);
expect( selectData.displayable ).toBe( false );
} );
it( 'should not call getRecommendedPlugins when displayable is false', () => {
( isWCPaySupported as jest.Mock ).mockReturnValue( false );
const selectData = getPaymentRecommendationData(
createFakeSelect( {
...baseSelectValues,
country: 'FR',
plugins: [ plugin ],
} )
);
expect( selectData.displayable ).toBe( false );
expect( recommendedPluginsMock ).not.toHaveBeenCalled();
} );
it( 'should have displayable as true if country is supported', () => {
( isWCPaySupported as jest.Mock ).mockReturnValue( true );
const selectData = getPaymentRecommendationData(
createFakeSelect( {
...baseSelectValues,
country: 'US',
plugins: [ plugin ],
optionValues: {
woocommerce_show_marketplace_suggestions: 'yes',
},
} )
);
expect( selectData.displayable ).toBeTruthy();
} );
it( 'should have displayable as false if hidden is set to true', () => {
( isWCPaySupported as jest.Mock ).mockReturnValue( true );
const selectData = getPaymentRecommendationData(
createFakeSelect( {
...baseSelectValues,
country: 'US',
plugins: [ plugin ],
optionValues: {
woocommerce_setting_payments_recommendations_hidden:
'yes',
},
} )
);
expect( selectData.displayable ).toBeFalsy();
} );
it( 'should set displayable to true if isHidden is not defined', () => {
( isWCPaySupported as jest.Mock ).mockReturnValue( true );
const selectData = getPaymentRecommendationData(
createFakeSelect( {
...baseSelectValues,
country: 'US',
plugins: [ plugin ],
optionValues: {
woocommerce_setting_payments_recommendations_hidden:
'no',
woocommerce_show_marketplace_suggestions: 'yes',
},
} )
);
expect( selectData.displayable ).toBeTruthy();
} );
it( 'have displayable as false if still requesting options', () => {
( isWCPaySupported as jest.Mock ).mockReturnValue( true );
const selectData = getPaymentRecommendationData(
createFakeSelect( {
...baseSelectValues,
country: 'US',
plugins: [ plugin ],
optionsResolving: true,
} )
);
expect( selectData.displayable ).toBeFalsy();
} );
it( 'should not render if showMarketplaceSuggestion is set to "no"', () => {
( isWCPaySupported as jest.Mock ).mockReturnValue( true );
const selectData = getPaymentRecommendationData(
createFakeSelect( {
...baseSelectValues,
country: 'US',
plugins: [ plugin ],
optionValues: {
woocommerce_show_marketplace_suggestions: 'no',
},
} )
);
expect( selectData.displayable ).toBeFalsy();
} );
} );
it( 'should render the list if displayable is true and has recommendedPlugins', () => {
( isWCPaySupported as jest.Mock ).mockReturnValue( true );
( useSelect as jest.Mock ).mockReturnValue( {
displayable: true,
recommendedPlugins: [ { title: 'test', slug: 'test' } ],
} );
const { container, getByText } = render( <PaymentRecommendations /> );
expect( container.firstChild ).not.toBeNull();
expect( getByText( 'test' ) ).toBeInTheDocument();
} );
it( 'should not trigger event payments_recommendations_pageview, when it is not rendered', () => {
( recordEvent as jest.Mock ).mockClear();
( isWCPaySupported as jest.Mock ).mockReturnValue( true );
( useSelect as jest.Mock ).mockReturnValue( {
displayable: false,
} );
const { container } = render( <PaymentRecommendations /> );
expect( container.firstChild ).toBeNull();
expect( recordEvent ).not.toHaveBeenCalledWith(
'settings_payments_recommendations_pageview',
{}
);
} );
it( 'should trigger event payments_recommendations_pageview, when first rendered', () => {
( isWCPaySupported as jest.Mock ).mockReturnValue( true );
( useSelect as jest.Mock ).mockReturnValue( {
displayable: true,
recommendedPlugins: [ { title: 'test', slug: 'test' } ],
} );
const { container } = render( <PaymentRecommendations /> );
expect( container.firstChild ).not.toBeNull();
expect( recordEvent ).toHaveBeenCalledWith(
'settings_payments_recommendations_pageview',
{}
);
} );
it( 'should not render if there are no recommendedPlugins', () => {
( isWCPaySupported as jest.Mock ).mockReturnValue( true );
( useSelect as jest.Mock ).mockReturnValue( {
displayable: true,
recommendedPlugins: [],
} );
const { container } = render( <PaymentRecommendations /> );
expect( container.firstChild ).toBeNull();
} );
describe( 'interactions', () => {
let oldLocation: Location;
const mockLocation = {
href: 'test',
} as Location;
const updateOptionsMock = jest.fn();
const installAndActivateMock = jest
.fn()
.mockImplementation( () => Promise.resolve() );
beforeEach( () => {
( isWCPaySupported as jest.Mock ).mockReturnValue( true );
( useDispatch as jest.Mock ).mockReturnValue( {
updateOptions: updateOptionsMock,
installAndActivatePlugins: installAndActivateMock,
} );
( useSelect as jest.Mock ).mockReturnValue( {
displayable: true,
recommendedPlugins: [
{
title: 'test',
slug: 'test',
product: 'test-product',
'button-text': 'install',
'setup-link': '/wp-admin/random-link',
},
{
title: 'another',
slug: 'another',
product: 'another-product',
'button-text': 'install2',
'setup-link': '/wp-admin/random-link',
},
],
} );
oldLocation = global.window.location;
mockLocation.href = 'test';
Object.defineProperty( global.window, 'location', {
value: mockLocation,
} );
} );
afterEach( () => {
Object.defineProperty( global.window, 'location', oldLocation );
} );
it( 'should install plugin and trigger event and redirect when finished, when clicking the action button', async () => {
const { container, getByText } = render(
<PaymentRecommendations />
);
expect( container.firstChild ).not.toBeNull();
fireEvent.click( getByText( 'install' ) );
expect( installAndActivateMock ).toHaveBeenCalledWith( [
'test-product',
] );
expect( recordEvent ).toHaveBeenCalledWith(
'settings_payments_recommendations_setup',
{
extension_selected: 'test-product',
}
);
await waitFor( () => {
expect( getAdminLink ).toHaveBeenCalledWith( 'random-link' );
} );
expect( mockLocation.href ).toEqual(
'https://test.ca/random-link'
);
} );
it( 'should call create notice if install and activate failed', async () => {
installAndActivateMock.mockClear();
installAndActivateMock.mockImplementation(
() =>
new Promise( ( resolve, reject ) => {
throw {
code: 500,
message: 'failed to install plugin',
};
} )
);
const { container, getByText } = render(
<PaymentRecommendations />
);
expect( container.firstChild ).not.toBeNull();
fireEvent.click( getByText( 'install' ) );
expect( installAndActivateMock ).toHaveBeenCalledWith( [
'test-product',
] );
expect( recordEvent ).toHaveBeenCalledWith(
'settings_payments_recommendations_setup',
{
extension_selected: 'test-product',
}
);
await waitFor( () => {
expect( createNoticesFromResponse ).toHaveBeenCalled();
} );
expect( mockLocation.href ).toEqual( 'test' );
} );
} );
} );

View File

@ -8193,6 +8193,27 @@
"@types/react": "^16"
}
},
"@types/react-router": {
"version": "5.1.13",
"resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.13.tgz",
"integrity": "sha512-ZIuaO9Yrln54X6elg8q2Ivp6iK6p4syPsefEYAhRDAoqNh48C8VYUmB9RkXjKSQAJSJV0mbIFCX7I4vZDcHrjg==",
"dev": true,
"requires": {
"@types/history": "*",
"@types/react": "*"
}
},
"@types/react-router-dom": {
"version": "5.1.7",
"resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.1.7.tgz",
"integrity": "sha512-D5mHD6TbdV/DNHYsnwBTv+y73ei+mMjrkGrla86HthE4/PVvL1J94Bu3qABU+COXzpL23T1EZapVVpwHuBXiUg==",
"dev": true,
"requires": {
"@types/history": "*",
"@types/react": "*",
"@types/react-router": "*"
}
},
"@types/react-syntax-highlighter": {
"version": "11.0.4",
"resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-11.0.4.tgz",
@ -8369,6 +8390,12 @@
}
}
},
"@types/wordpress__api-fetch": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@types/wordpress__api-fetch/-/wordpress__api-fetch-3.2.4.tgz",
"integrity": "sha512-vGS91UAQBA1PPIP5ubI2td9XUROjMwH3ytyspJUEdWpcEgRUXkEybTEL26OrFFhtY1eiELmNVGuVS1MeF0xhAA==",
"dev": true
},
"@types/wordpress__components": {
"version": "9.8.6",
"resolved": "https://registry.npmjs.org/@types/wordpress__components/-/wordpress__components-9.8.6.tgz",
@ -8415,6 +8442,16 @@
"redux": "^4.0.1"
}
},
"@types/wordpress__data-controls": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@types/wordpress__data-controls/-/wordpress__data-controls-1.0.4.tgz",
"integrity": "sha512-PejgPEZNS+tSupgU3mFd3lAdrPJgm8Jvh2uEHjSxTYgDDk7TMjoBOX069sid4kF9ck5nDLqg59UcPUDT0F2PnA==",
"dev": true,
"requires": {
"@types/wordpress__api-fetch": "*",
"@types/wordpress__data": "*"
}
},
"@types/wordpress__notices": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/@types/wordpress__notices/-/wordpress__notices-1.5.4.tgz",
@ -9672,6 +9709,7 @@
"requires": {
"@woocommerce/date": "2.1.0",
"@woocommerce/navigation": "5.2.0",
"@wordpress/i18n": "3.17.0",
"rememo": "^3.0.0"
},
"dependencies": {
@ -9686,6 +9724,22 @@
"@wordpress/i18n": "3.11.0",
"lodash": "4.17.15",
"moment": "2.27.0"
},
"dependencies": {
"@wordpress/i18n": {
"version": "3.11.0",
"resolved": "https://registry.npmjs.org/@wordpress/i18n/-/i18n-3.11.0.tgz",
"integrity": "sha512-wcu8NBxaSu8b4Bj+Nt4dMQvziQrfdgTeEGSRy9GzJChTVpFdyZT88zAaPbK+W8yqFaX3zMSf4rHpZSP6QvWkQg==",
"dev": true,
"requires": {
"@babel/runtime": "^7.9.2",
"gettext-parser": "^1.3.1",
"lodash": "^4.17.15",
"memize": "^1.1.0",
"sprintf-js": "^1.1.1",
"tannin": "^1.2.0"
}
}
}
},
"@woocommerce/navigation": {
@ -9724,17 +9778,25 @@
}
},
"@wordpress/i18n": {
"version": "3.11.0",
"resolved": "https://registry.npmjs.org/@wordpress/i18n/-/i18n-3.11.0.tgz",
"integrity": "sha512-wcu8NBxaSu8b4Bj+Nt4dMQvziQrfdgTeEGSRy9GzJChTVpFdyZT88zAaPbK+W8yqFaX3zMSf4rHpZSP6QvWkQg==",
"version": "3.17.0",
"resolved": "https://registry.npmjs.org/@wordpress/i18n/-/i18n-3.17.0.tgz",
"integrity": "sha512-CTZ0oezI6BT5GlmiE4X0fzRY6i7bNsX6hxiROkGlpREY6q4s1pnwhM8ggLIaP18Bvkb/HDkUEENDrv3iwM/LIQ==",
"dev": true,
"requires": {
"@babel/runtime": "^7.9.2",
"@babel/runtime": "^7.12.5",
"gettext-parser": "^1.3.1",
"lodash": "^4.17.15",
"lodash": "^4.17.19",
"memize": "^1.1.0",
"sprintf-js": "^1.1.1",
"tannin": "^1.2.0"
},
"dependencies": {
"lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true
}
}
},
"core-js": {
@ -10794,7 +10856,7 @@
"version": "file:packages/navigation",
"dev": true,
"requires": {
"@woocommerce/experimental": "1.0.0",
"@woocommerce/experimental": "file:packages/experimental",
"history": "4.10.1",
"qs": "6.9.6"
},

View File

@ -159,9 +159,10 @@
"@types/jest": "26.0.22",
"@types/lodash": "4.14.168",
"@types/puppeteer": "5.4.3",
"@types/react-router-dom": "^5.1.7",
"@types/wordpress__components": "9.8.6",
"@types/wordpress__data-controls": "^1.0.4",
"@typescript-eslint/eslint-plugin": "4.22.0",
"@woocommerce/explat": "file:packages/explat",
"@woocommerce/api": "0.1.2",
"@woocommerce/components": "file:packages/components",
"@woocommerce/csv-export": "file:packages/csv-export",
@ -172,6 +173,7 @@
"@woocommerce/dependency-extraction-webpack-plugin": "file:packages/dependency-extraction-webpack-plugin",
"@woocommerce/eslint-plugin": "file:packages/eslint-plugin",
"@woocommerce/experimental": "file:packages/experimental",
"@woocommerce/explat": "file:packages/explat",
"@woocommerce/navigation": "file:packages/navigation",
"@woocommerce/notices": "file:packages/notices",
"@woocommerce/number": "file:packages/number",

View File

@ -23,6 +23,7 @@
"dependencies": {
"@woocommerce/date": "file:../date",
"@woocommerce/navigation": "file:../navigation",
"@wordpress/i18n": "3.17.0",
"rememo": "^3.0.0"
},
"publishConfig": {

View File

@ -18,12 +18,15 @@ import type { REPORTS_STORE_NAME } from './reports';
import type { ITEMS_STORE_NAME } from './items';
import { OnboardingSelectors } from './onboarding/selectors';
import { WPDataSelectors } from './types';
import { PluginSelectors } from './plugins/selectors';
export * from './types';
export { SETTINGS_STORE_NAME } from './settings';
export { withSettingsHydration } from './settings/with-settings-hydration';
export { useSettings } from './settings/use-settings';
export { PLUGINS_STORE_NAME } from './plugins';
export type { Plugin } from './plugins/types';
export { pluginNames } from './plugins/constants';
export { withPluginsHydration } from './plugins/with-plugins-hydration';
@ -36,7 +39,10 @@ export { useUser } from './user/use-user';
export { useUserPreferences } from './user/use-user-preferences';
export { OPTIONS_STORE_NAME } from './options';
export { withOptionsHydration } from './options/with-options-hydration';
export {
withOptionsHydration,
useOptionsHydration,
} from './options/with-options-hydration';
export { REVIEWS_STORE_NAME } from './reviews';
@ -77,7 +83,7 @@ export { EXPORT_STORE_NAME } from './export';
export { IMPORT_STORE_NAME } from './import';
type WCDataStoreName =
export type WCDataStoreName =
| typeof REVIEWS_STORE_NAME
| typeof SETTINGS_STORE_NAME
| typeof PLUGINS_STORE_NAME
@ -91,12 +97,12 @@ type WCDataStoreName =
// As we add types to all the package selectors we can fill out these unknown types with real ones. See one
// of the already typed selectors for an example of how you can do this.
type WCSelectorType< T > = T extends typeof REVIEWS_STORE_NAME
export type WCSelectorType< T > = T extends typeof REVIEWS_STORE_NAME
? WPDataSelectors
: T extends typeof SETTINGS_STORE_NAME
? WPDataSelectors
: T extends typeof PLUGINS_STORE_NAME
? WPDataSelectors
? PluginSelectors
: T extends typeof ONBOARDING_STORE_NAME
? OnboardingSelectors
: T extends typeof USER_STORE_NAME

View File

@ -0,0 +1,95 @@
/**
* External dependencies
*/
import { render } from '@testing-library/react';
import { useSelect } from '@wordpress/data';
/**
* Internal dependencies
*/
import {
useOptionsHydration,
withOptionsHydration,
} from '../with-options-hydration';
jest.mock( '@wordpress/data', () => ( {
...jest.requireActual( '@wordpress/data' ),
useSelect: jest.fn(),
} ) );
const optionData = {
option: 'val',
option2: 'val2',
option3: 'val3',
};
const TestHookComponent = () => {
useOptionsHydration( optionData );
return <div></div>;
};
const TestHigherOrderComponent = withOptionsHydration( optionData )( () => (
<div></div>
) );
describe( 'withOptionsHydration', () => {
const isResolvingMock = jest.fn();
const hasFinishedMock = jest.fn();
const startResolutionMock = jest.fn();
const receiveOptionsMock = jest.fn();
beforeEach( () => {
useSelect.mockImplementation( ( callback ) => {
callback(
() => ( {
isResolving: isResolvingMock,
hasFinishedResolution: hasFinishedMock,
} ),
{
dispatch: () => ( {
startResolution: startResolutionMock,
finishResolution: jest.fn(),
receiveOptions: receiveOptionsMock,
} ),
}
);
} );
} );
afterEach( () => {
jest.clearAllMocks();
} );
it.each( [
[ 'useOptionsHydration', TestHookComponent ],
[ 'withOptionsHydration', TestHigherOrderComponent ],
] )(
'%s should call receiveOptions and startResolution when options have not been received yet',
( name, Comp ) => {
isResolvingMock.mockReturnValue( false );
hasFinishedMock.mockReturnValue( false );
render( <Comp /> );
expect( receiveOptionsMock ).toHaveBeenLastCalledWith( {
option3: 'val3',
} );
expect( receiveOptionsMock ).toHaveBeenCalledTimes( 3 );
expect( startResolutionMock ).toHaveBeenCalledTimes( 3 );
}
);
it.each( [
[ 'useOptionsHydration', TestHookComponent ],
[ 'withOptionsHydration', TestHigherOrderComponent ],
] )(
'%s should not call receiveOptions and startResolution when options have been received',
( name, Comp ) => {
isResolvingMock.mockReturnValue( false );
hasFinishedMock.mockReturnValue( true );
render( <Comp /> );
expect( receiveOptionsMock ).not.toHaveBeenLastCalledWith( {
option3: 'val3',
} );
expect( receiveOptionsMock ).toHaveBeenCalledTimes( 0 );
expect( startResolutionMock ).toHaveBeenCalledTimes( 0 );
}
);
} );

View File

@ -10,37 +10,39 @@ import { useRef } from '@wordpress/element';
*/
import { STORE_NAME } from './constants';
export const useOptionsHydration = ( data ) => {
const dataRef = useRef( data );
useSelect( ( select, registry ) => {
if ( ! dataRef.current ) {
return;
}
const { isResolving, hasFinishedResolution } = select( STORE_NAME );
const {
startResolution,
finishResolution,
receiveOptions,
} = registry.dispatch( STORE_NAME );
const names = Object.keys( dataRef.current );
names.forEach( ( name ) => {
if (
! isResolving( 'getOption', [ name ] ) &&
! hasFinishedResolution( 'getOption', [ name ] )
) {
startResolution( 'getOption', [ name ] );
receiveOptions( { [ name ]: dataRef.current[ name ] } );
finishResolution( 'getOption', [ name ] );
}
} );
}, [] );
};
export const withOptionsHydration = ( data ) =>
createHigherOrderComponent(
( OriginalComponent ) => ( props ) => {
const dataRef = useRef( data );
useSelect( ( select, registry ) => {
if ( ! dataRef.current ) {
return;
}
const { isResolving, hasFinishedResolution } = select(
STORE_NAME
);
const {
startResolution,
finishResolution,
receiveOptions,
} = registry.dispatch( STORE_NAME );
const names = Object.keys( dataRef.current );
names.forEach( ( name ) => {
if (
! isResolving( 'getOption', [ name ] ) &&
! hasFinishedResolution( 'getOption', [ name ] )
) {
startResolution( 'getOption', [ name ] );
receiveOptions( { [ name ]: dataRef.current[ name ] } );
finishResolution( 'getOption', [ name ] );
}
} );
}, [] );
useOptionsHydration( data );
return <OriginalComponent { ...props } />;
},

View File

@ -1,11 +0,0 @@
const TYPES = {
UPDATE_ACTIVE_PLUGINS: 'UPDATE_ACTIVE_PLUGINS',
UPDATE_INSTALLED_PLUGINS: 'UPDATE_INSTALLED_PLUGINS',
SET_IS_REQUESTING: 'SET_IS_REQUESTING',
SET_ERROR: 'SET_ERROR',
UPDATE_JETPACK_CONNECTION: 'UPDATE_JETPACK_CONNECTION',
UPDATE_JETPACK_CONNECT_URL: 'UPDATE_JETPACK_CONNECT_URL',
SET_PAYPAL_ONBOARDING_STATUS: 'SET_PAYPAL_ONBOARDING_STATUS',
};
export default TYPES;

View File

@ -0,0 +1,10 @@
export enum ACTION_TYPES {
UPDATE_ACTIVE_PLUGINS = 'UPDATE_ACTIVE_PLUGINS',
UPDATE_INSTALLED_PLUGINS = 'UPDATE_INSTALLED_PLUGINS',
SET_IS_REQUESTING = 'SET_IS_REQUESTING',
SET_ERROR = 'SET_ERROR',
UPDATE_JETPACK_CONNECTION = 'UPDATE_JETPACK_CONNECTION',
UPDATE_JETPACK_CONNECT_URL = 'UPDATE_JETPACK_CONNECT_URL',
SET_PAYPAL_ONBOARDING_STATUS = 'SET_PAYPAL_ONBOARDING_STATUS',
SET_RECOMMENDED_PLUGINS = 'SET_RECOMMENDED_PLUGINS',
}

View File

@ -1,226 +0,0 @@
/**
* External dependencies
*/
import { apiFetch, dispatch, select } from '@wordpress/data-controls';
import { _n, sprintf } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import { pluginNames, STORE_NAME } from './constants';
import TYPES from './action-types';
import { WC_ADMIN_NAMESPACE } from '../constants';
export function updateActivePlugins( active, replace = false ) {
return {
type: TYPES.UPDATE_ACTIVE_PLUGINS,
active,
replace,
};
}
export function updateInstalledPlugins( installed, replace = false ) {
return {
type: TYPES.UPDATE_INSTALLED_PLUGINS,
installed,
replace,
};
}
export function setIsRequesting( selector, isRequesting ) {
return {
type: TYPES.SET_IS_REQUESTING,
selector,
isRequesting,
};
}
export function setError( selector, error ) {
return {
type: TYPES.SET_ERROR,
selector,
error,
};
}
export function updateIsJetpackConnected( jetpackConnection ) {
return {
type: TYPES.UPDATE_JETPACK_CONNECTION,
jetpackConnection,
};
}
export function updateJetpackConnectUrl( redirectUrl, jetpackConnectUrl ) {
return {
type: TYPES.UPDATE_JETPACK_CONNECT_URL,
jetpackConnectUrl,
redirectUrl,
};
}
export function* installPlugins( plugins ) {
yield setIsRequesting( 'installPlugins', true );
try {
const results = yield apiFetch( {
path: `${ WC_ADMIN_NAMESPACE }/plugins/install`,
method: 'POST',
data: { plugins: plugins.join( ',' ) },
} );
if ( results.data.installed.length ) {
yield updateInstalledPlugins( results.data.installed );
}
if ( Object.keys( results.errors.errors ).length ) {
throw results.errors.errors;
}
yield setIsRequesting( 'installPlugins', false );
return results;
} catch ( error ) {
yield setError( 'installPlugins', error );
throw new Error( formatErrorMessage( error ) );
}
}
export function* activatePlugins( plugins ) {
yield setIsRequesting( 'activatePlugins', true );
try {
const results = yield apiFetch( {
path: `${ WC_ADMIN_NAMESPACE }/plugins/activate`,
method: 'POST',
data: { plugins: plugins.join( ',' ) },
} );
if ( results.data.activated.length ) {
yield updateActivePlugins( results.data.activated );
}
if ( Object.keys( results.errors.errors ).length ) {
throw results.errors.errors;
}
yield setIsRequesting( 'activatePlugins', false );
return results;
} catch ( error ) {
yield setError( 'activatePlugins', error );
throw new Error( formatErrors( error ) );
}
}
export function* installAndActivatePlugins( plugins ) {
try {
yield dispatch( STORE_NAME, 'installPlugins', plugins );
const activations = yield dispatch(
STORE_NAME,
'activatePlugins',
plugins
);
return activations;
} catch ( error ) {
throw error;
}
}
export const createErrorNotice = ( errorMessage ) => {
return dispatch( 'core/notices', 'createNotice', errorMessage );
};
export function* connectToJetpack( getAdminLink ) {
const url = yield select( STORE_NAME, 'getJetpackConnectUrl', {
redirect_url: getAdminLink( 'admin.php?page=wc-admin' ),
} );
const error = yield select(
STORE_NAME,
'getPluginsError',
'getJetpackConnectUrl'
);
if ( error ) {
throw new Error( error );
} else {
return url;
}
}
export function* installJetpackAndConnect( errorAction, getAdminLink ) {
try {
yield dispatch( STORE_NAME, 'installPlugins', [ 'jetpack' ] );
yield dispatch( STORE_NAME, 'activatePlugins', [ 'jetpack' ] );
const url = yield dispatch(
STORE_NAME,
'connectToJetpack',
getAdminLink
);
window.location = url;
} catch ( error ) {
yield errorAction( error.message );
}
}
export function* connectToJetpackWithFailureRedirect(
failureRedirect,
errorAction,
getAdminLink
) {
try {
const url = yield dispatch(
STORE_NAME,
'connectToJetpack',
getAdminLink
);
window.location = url;
} catch ( error ) {
yield errorAction( error.message );
window.location = failureRedirect;
}
}
export function formatErrors( response ) {
if ( response.errors ) {
// Replace the slug with a plugin name if a constant exists.
Object.keys( response.errors ).forEach( ( plugin ) => {
response.errors[ plugin ] = response.errors[ plugin ].map(
( pluginError ) => {
return pluginNames[ plugin ]
? pluginError.replace(
`\`${ plugin }\``,
pluginNames[ plugin ]
)
: pluginError;
}
);
} );
}
return response;
}
export function setPaypalOnboardingStatus( status ) {
return {
type: TYPES.SET_PAYPAL_ONBOARDING_STATUS,
paypalOnboardingStatus: status,
};
}
const formatErrorMessage = ( pluginErrors, actionType = 'install' ) => {
return sprintf(
/* translators: %(actionType): install or activate (the plugin). %(pluginName): a plugin slug (e.g. woocommerce-services). %(error): a single error message or in plural a comma separated error message list.*/
_n(
'Could not %(actionType)s %(pluginName)s plugin, %(error)s',
'Could not %(actionType)s the following plugins: %(pluginName)s with these Errors: %(error)s',
pluginErrors.length,
'woocommerce-admin'
),
{
actionType,
pluginName: Object.keys( pluginErrors ).join( ', ' ),
error: Object.values( pluginErrors ).join( ', \n' ),
}
);
};

View File

@ -0,0 +1,332 @@
/**
* External dependencies
*/
import { apiFetch, dispatch, select } from '@wordpress/data-controls';
import { _n, sprintf } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import { pluginNames, STORE_NAME } from './constants';
import { ACTION_TYPES as TYPES } from './action-types';
import { WC_ADMIN_NAMESPACE } from '../constants';
import { WPError } from '../types';
import {
PaypalOnboardingStatus,
PluginNames,
SelectorKeysWithActions,
} from './types';
type PluginsResponse< PluginData > = {
data: PluginData;
errors: WPError< PluginNames >;
success: boolean;
message: string;
} & Response;
type InstallPluginsResponse = PluginsResponse< {
installed: string[];
results: Record< string, boolean >;
} >;
type ActivatePluginsResponse = PluginsResponse< {
activated: string[];
active: string[];
} >;
function isWPError(
error: WPError< PluginNames > | string
): error is WPError< PluginNames > {
return ( error as WPError ).errors !== undefined;
}
export function formatErrors(
response: WPError< PluginNames > | string
): string {
if ( isWPError( response ) ) {
// Replace the slug with a plugin name if a constant exists.
( Object.keys( response.errors ) as PluginNames[] ).forEach(
( plugin ) => {
response.errors[ plugin ] = response.errors[ plugin ].map(
( pluginError ) => {
return pluginNames[ plugin ]
? pluginError.replace(
`\`${ plugin }\``,
pluginNames[ plugin ]
)
: pluginError;
}
);
}
);
}
return response as string;
}
const formatErrorMessage = (
pluginErrors: Record< PluginNames, string[] >,
actionType = 'install'
) => {
return sprintf(
/* translators: %(actionType): install or activate (the plugin). %(pluginName): a plugin slug (e.g. woocommerce-services). %(error): a single error message or in plural a comma separated error message list.*/
_n(
'Could not %(actionType)s %(pluginName)s plugin, %(error)s',
'Could not %(actionType)s the following plugins: %(pluginName)s with these Errors: %(error)s',
Object.keys( pluginErrors ).length,
'woocommerce-admin'
),
{
actionType,
pluginName: Object.keys( pluginErrors ).join( ', ' ),
error: Object.values( pluginErrors ).join( ', \n' ),
}
);
};
export function updateActivePlugins(
active: string[],
replace = false
): { type: TYPES.UPDATE_ACTIVE_PLUGINS; active: string[]; replace?: boolean } {
return {
type: TYPES.UPDATE_ACTIVE_PLUGINS,
active,
replace,
};
}
export function updateInstalledPlugins(
installed: string[],
replace = false
): {
type: TYPES.UPDATE_INSTALLED_PLUGINS;
installed: string[];
replace?: boolean;
} {
return {
type: TYPES.UPDATE_INSTALLED_PLUGINS,
installed,
replace,
};
}
export function setIsRequesting(
selector: SelectorKeysWithActions,
isRequesting: boolean
): {
type: TYPES.SET_IS_REQUESTING;
selector: SelectorKeysWithActions;
isRequesting: boolean;
} {
return {
type: TYPES.SET_IS_REQUESTING,
selector,
isRequesting,
};
}
export function setError(
selector: SelectorKeysWithActions,
error: Partial< Record< PluginNames, string[] > >
): {
type: TYPES.SET_ERROR;
selector: SelectorKeysWithActions;
error: Partial< Record< PluginNames, string[] > >;
} {
return {
type: TYPES.SET_ERROR,
selector,
error,
};
}
export function updateIsJetpackConnected(
jetpackConnection: boolean
): {
type: TYPES.UPDATE_JETPACK_CONNECTION;
jetpackConnection: boolean;
} {
return {
type: TYPES.UPDATE_JETPACK_CONNECTION,
jetpackConnection,
};
}
export function updateJetpackConnectUrl(
redirectUrl: string,
jetpackConnectUrl: string
): {
type: TYPES.UPDATE_JETPACK_CONNECT_URL;
redirectUrl: string;
jetpackConnectUrl: string;
} {
return {
type: TYPES.UPDATE_JETPACK_CONNECT_URL,
jetpackConnectUrl,
redirectUrl,
};
}
export function* installPlugins( plugins: string[] ) {
yield setIsRequesting( 'installPlugins', true );
try {
const results: InstallPluginsResponse = yield apiFetch( {
path: `${ WC_ADMIN_NAMESPACE }/plugins/install`,
method: 'POST',
data: { plugins: plugins.join( ',' ) },
} );
if ( results.data.installed.length ) {
yield updateInstalledPlugins( results.data.installed );
}
if ( Object.keys( results.errors.errors ).length ) {
throw results.errors.errors;
}
yield setIsRequesting( 'installPlugins', false );
return results;
} catch ( error ) {
yield setError( 'installPlugins', error );
throw new Error( formatErrorMessage( error ) );
}
}
export function* activatePlugins( plugins: string[] ) {
yield setIsRequesting( 'activatePlugins', true );
try {
const results: ActivatePluginsResponse = yield apiFetch( {
path: `${ WC_ADMIN_NAMESPACE }/plugins/activate`,
method: 'POST',
data: { plugins: plugins.join( ',' ) },
} );
if ( results.data.activated.length ) {
yield updateActivePlugins( results.data.activated );
}
if ( Object.keys( results.errors.errors ).length ) {
throw results.errors.errors;
}
yield setIsRequesting( 'activatePlugins', false );
return results;
} catch ( error ) {
yield setError( 'activatePlugins', error );
throw new Error( formatErrors( error ) );
}
}
export function* installAndActivatePlugins( plugins: string[] ) {
try {
yield dispatch( STORE_NAME, 'installPlugins', plugins );
const activations: InstallPluginsResponse = yield dispatch(
STORE_NAME,
'activatePlugins',
plugins
);
return activations;
} catch ( error ) {
throw error;
}
}
export const createErrorNotice = ( errorMessage: string ) => {
return dispatch( 'core/notices', 'createNotice', errorMessage );
};
export function* connectToJetpack(
getAdminLink: ( endpoint: string ) => string
) {
const url: string = yield select( STORE_NAME, 'getJetpackConnectUrl', {
redirect_url: getAdminLink( 'admin.php?page=wc-admin' ),
} );
const error: string = yield select(
STORE_NAME,
'getPluginsError',
'getJetpackConnectUrl'
);
if ( error ) {
throw new Error( error );
} else {
return url;
}
}
export function* installJetpackAndConnect(
errorAction: ( errorMesage: string ) => void,
getAdminLink: ( endpoint: string ) => string
) {
try {
yield dispatch( STORE_NAME, 'installPlugins', [ 'jetpack' ] );
yield dispatch( STORE_NAME, 'activatePlugins', [ 'jetpack' ] );
const url: string = yield dispatch(
STORE_NAME,
'connectToJetpack',
getAdminLink
);
window.location.href = url;
} catch ( error ) {
yield errorAction( error.message );
}
}
export function* connectToJetpackWithFailureRedirect(
failureRedirect: string,
errorAction: ( errorMesage: string ) => void,
getAdminLink: ( endpoint: string ) => string
) {
try {
const url: string = yield dispatch(
STORE_NAME,
'connectToJetpack',
getAdminLink
);
window.location.href = url;
} catch ( error ) {
yield errorAction( error.message );
window.location.href = failureRedirect;
}
}
export function setPaypalOnboardingStatus(
status: Partial< PaypalOnboardingStatus >
): {
type: TYPES.SET_PAYPAL_ONBOARDING_STATUS;
paypalOnboardingStatus: Partial< PaypalOnboardingStatus >;
} {
return {
type: TYPES.SET_PAYPAL_ONBOARDING_STATUS,
paypalOnboardingStatus: status,
};
}
export function setRecommendedPlugins(
type: string,
plugins: Plugin[]
): {
type: TYPES.SET_RECOMMENDED_PLUGINS;
recommendedType: string;
plugins: Plugin[];
} {
return {
type: TYPES.SET_RECOMMENDED_PLUGINS,
recommendedType: type,
plugins,
};
}
export type Actions =
| ReturnType< typeof updateActivePlugins >
| ReturnType< typeof updateInstalledPlugins >
| ReturnType< typeof setIsRequesting >
| ReturnType< typeof setError >
| ReturnType< typeof updateIsJetpackConnected >
| ReturnType< typeof updateJetpackConnectUrl >
| ReturnType< typeof setPaypalOnboardingStatus >
| ReturnType< typeof setRecommendedPlugins >;

View File

@ -1,115 +0,0 @@
/**
* External dependencies
*/
import { concat } from 'lodash';
/**
* Internal dependencies
*/
import TYPES from './action-types';
const plugins = (
state = {
active: [],
installed: [],
requesting: {},
errors: {},
jetpackConnectUrls: {},
},
{
type,
active,
installed,
selector,
isRequesting,
error,
jetpackConnection,
redirectUrl,
jetpackConnectUrl,
paypalOnboardingStatus,
replace,
}
) => {
switch ( type ) {
case TYPES.UPDATE_ACTIVE_PLUGINS:
state = {
...state,
active: replace ? active : concat( state.active, active ),
requesting: {
...state.requesting,
getActivePlugins: false,
activatePlugins: false,
},
errors: {
...state.errors,
getActivePlugins: false,
activatePlugins: false,
},
};
break;
case TYPES.UPDATE_INSTALLED_PLUGINS:
state = {
...state,
installed: replace
? installed
: concat( state.installed, installed ),
requesting: {
...state.requesting,
getInstalledPlugins: false,
installPlugin: false,
},
errors: {
...state.errors,
getInstalledPlugins: false,
installPlugin: false,
},
};
break;
case TYPES.SET_IS_REQUESTING:
state = {
...state,
requesting: {
...state.requesting,
[ selector ]: isRequesting,
},
};
break;
case TYPES.SET_ERROR:
state = {
...state,
requesting: {
...state.requesting,
[ selector ]: false,
},
errors: {
...state.errors,
[ selector ]: error,
},
};
break;
case TYPES.UPDATE_JETPACK_CONNECTION:
state = {
...state,
jetpackConnection,
};
break;
case TYPES.UPDATE_JETPACK_CONNECT_URL:
state = {
...state,
jetpackConnectUrls: {
...state.jetpackConnectUrl,
[ redirectUrl ]: jetpackConnectUrl,
},
};
break;
case TYPES.SET_PAYPAL_ONBOARDING_STATUS:
state = {
...state,
paypalOnboardingStatus,
};
break;
}
return state;
};
export default plugins;

View File

@ -0,0 +1,125 @@
/**
* External dependencies
*/
import { concat } from 'lodash';
/**
* Internal dependencies
*/
import { ACTION_TYPES as TYPES } from './action-types';
import { Actions } from './actions';
import { PluginsState } from './types';
const plugins = (
state: PluginsState = {
active: [],
installed: [],
requesting: {},
errors: {},
jetpackConnectUrls: {},
recommended: {},
},
payload?: Actions
): PluginsState => {
if ( payload && 'type' in payload ) {
switch ( payload.type ) {
case TYPES.UPDATE_ACTIVE_PLUGINS:
state = {
...state,
active: payload.replace
? payload.active
: ( concat(
state.active,
payload.active
) as string[] ),
requesting: {
...state.requesting,
getActivePlugins: false,
activatePlugins: false,
},
errors: {
...state.errors,
getActivePlugins: false,
activatePlugins: false,
},
};
break;
case TYPES.UPDATE_INSTALLED_PLUGINS:
state = {
...state,
installed: payload.replace
? payload.installed
: ( concat(
state.installed,
payload.installed
) as string[] ),
requesting: {
...state.requesting,
getInstalledPlugins: false,
installPlugins: false,
},
errors: {
...state.errors,
getInstalledPlugins: false,
installPlugin: false,
},
};
break;
case TYPES.SET_IS_REQUESTING:
state = {
...state,
requesting: {
...state.requesting,
[ payload.selector ]: payload.isRequesting,
},
};
break;
case TYPES.SET_ERROR:
state = {
...state,
requesting: {
...state.requesting,
[ payload.selector ]: false,
},
errors: {
...state.errors,
[ payload.selector ]: payload.error,
},
};
break;
case TYPES.UPDATE_JETPACK_CONNECTION:
state = {
...state,
jetpackConnection: payload.jetpackConnection,
};
break;
case TYPES.UPDATE_JETPACK_CONNECT_URL:
state = {
...state,
jetpackConnectUrls: {
...state.jetpackConnectUrls,
[ payload.redirectUrl ]: payload.jetpackConnectUrl,
},
};
break;
case TYPES.SET_PAYPAL_ONBOARDING_STATUS:
state = {
...state,
paypalOnboardingStatus: payload.paypalOnboardingStatus,
};
break;
case TYPES.SET_RECOMMENDED_PLUGINS:
state = {
...state,
recommended: {
...state.recommended,
[ payload.recommendedType ]: payload.plugins,
},
};
break;
}
}
return state;
};
export default plugins;

View File

@ -18,13 +18,29 @@ import {
updateIsJetpackConnected,
updateJetpackConnectUrl,
setPaypalOnboardingStatus,
setRecommendedPlugins,
} from './actions';
import { PaypalOnboardingStatus, RecommendedTypes } from './types';
type PluginGetResponse = {
plugins: string[];
} & Response;
type JetpackConnectionResponse = {
isActive: boolean;
} & Response;
type ConnectJetpackResponse = {
slug: 'jetpack';
name: string;
connectAction: string;
} & Response;
export function* getActivePlugins() {
yield setIsRequesting( 'getActivePlugins', true );
try {
const url = WC_ADMIN_NAMESPACE + '/plugins/active';
const results = yield apiFetch( {
const results: PluginGetResponse = yield apiFetch( {
path: url,
method: 'GET',
} );
@ -40,12 +56,12 @@ export function* getInstalledPlugins() {
try {
const url = WC_ADMIN_NAMESPACE + '/plugins/installed';
const results = yield apiFetch( {
const results: PluginGetResponse = yield apiFetch( {
path: url,
method: 'GET',
} );
yield updateInstalledPlugins( results, true );
yield updateInstalledPlugins( results.plugins, true );
} catch ( error ) {
yield setError( 'getInstalledPlugins', error );
}
@ -56,7 +72,7 @@ export function* isJetpackConnected() {
try {
const url = JETPACK_NAMESPACE + '/connection';
const results = yield apiFetch( {
const results: JetpackConnectionResponse = yield apiFetch( {
path: url,
method: 'GET',
} );
@ -69,7 +85,7 @@ export function* isJetpackConnected() {
yield setIsRequesting( 'isJetpackConnected', false );
}
export function* getJetpackConnectUrl( query ) {
export function* getJetpackConnectUrl( query: { redirect_url: string } ) {
yield setIsRequesting( 'getJetpackConnectUrl', true );
try {
@ -77,7 +93,7 @@ export function* getJetpackConnectUrl( query ) {
WC_ADMIN_NAMESPACE + '/plugins/connect-jetpack',
query
);
const results = yield apiFetch( {
const results: ConnectJetpackResponse = yield apiFetch( {
path: url,
method: 'GET',
} );
@ -93,10 +109,34 @@ export function* getJetpackConnectUrl( query ) {
yield setIsRequesting( 'getJetpackConnectUrl', false );
}
function* setOnboardingStatusWithOptions() {
const options: {
merchant_email_production: string;
merchant_id_production: string;
client_id_production: string;
client_secret_production: string;
} = yield select(
OPTIONS_STORE_NAME,
'getOption',
'woocommerce-ppcp-settings'
);
const onboarded =
options.merchant_email_production &&
options.merchant_id_production &&
options.client_id_production &&
options.client_secret_production;
yield setPaypalOnboardingStatus( {
production: {
state: onboarded ? 'onboarded' : 'unknown',
onboarded: onboarded ? true : false,
},
} );
}
export function* getPaypalOnboardingStatus() {
yield setIsRequesting( 'getPaypalOnboardingStatus', true );
const errorData = yield select(
const errorData: { data?: { status: number } } = yield select(
STORE_NAME,
'getPluginsError',
'getPaypalOnboardingStatus'
@ -107,7 +147,7 @@ export function* getPaypalOnboardingStatus() {
} else {
try {
const url = PAYPAL_NAMESPACE + '/onboarding/get-status';
const results = yield apiFetch( {
const results: PaypalOnboardingStatus = yield apiFetch( {
path: url,
method: 'GET',
} );
@ -122,21 +162,24 @@ export function* getPaypalOnboardingStatus() {
yield setIsRequesting( 'getPaypalOnboardingStatus', false );
}
function* setOnboardingStatusWithOptions() {
const options = yield select(
OPTIONS_STORE_NAME,
'getOption',
'woocommerce-ppcp-settings'
);
yield setPaypalOnboardingStatus( {
production: {
onboarded:
options.merchant_email_production &&
options.merchant_id_production &&
options.client_id_production &&
options.client_secret_production
? true
: false,
},
} );
const SUPPORTED_TYPES = [ 'payments' ];
export function* getRecommendedPlugins( type: RecommendedTypes ) {
if ( ! SUPPORTED_TYPES.includes( type ) ) {
return [];
}
yield setIsRequesting( 'getRecommendedPlugins', true );
try {
const url = WC_ADMIN_NAMESPACE + '/plugins/recommended-payment-plugins';
const results: Plugin[] = yield apiFetch( {
path: url,
method: 'GET',
} );
yield setRecommendedPlugins( type, results );
} catch ( error ) {
yield setError( 'getRecommendedPlugins', error );
}
yield setIsRequesting( 'getRecommendedPlugins', false );
}

View File

@ -1,34 +0,0 @@
export const getActivePlugins = ( state ) => {
return state.active || [];
};
export const getInstalledPlugins = ( state ) => {
return state.installed || [];
};
export const isPluginsRequesting = ( state, selector ) => {
return state.requesting[ selector ] || false;
};
export const getPluginsError = ( state, selector ) => {
return state.errors[ selector ] || false;
};
export const isJetpackConnected = ( state ) => state.jetpackConnection;
export const getJetpackConnectUrl = ( state, query ) => {
return state.jetpackConnectUrls[ query.redirect_url ];
};
export const getPluginInstallState = ( state, plugin ) => {
if ( state.active.includes( plugin ) ) {
return 'activated';
} else if ( state.installed.includes( plugin ) ) {
return 'installed';
}
return 'unavailable';
};
export const getPaypalOnboardingStatus = ( state ) =>
state.paypalOnboardingStatus;

View File

@ -0,0 +1,71 @@
/**
* Internal dependencies
*/
import { WPDataSelector, WPDataSelectors } from '../types';
import {
PluginsState,
RecommendedTypes,
SelectorKeysWithActions,
} from './types';
export const getActivePlugins = ( state: PluginsState ) => {
return state.active || [];
};
export const getInstalledPlugins = ( state: PluginsState ) => {
return state.installed || [];
};
export const isPluginsRequesting = (
state: PluginsState,
selector: SelectorKeysWithActions
) => {
return state.requesting[ selector ] || false;
};
export const getPluginsError = (
state: PluginsState,
selector: SelectorKeysWithActions
) => {
return state.errors[ selector ] || false;
};
export const isJetpackConnected = ( state: PluginsState ) =>
state.jetpackConnection;
export const getJetpackConnectUrl = (
state: PluginsState,
query: { redirect_url: string }
) => {
return state.jetpackConnectUrls[ query.redirect_url ];
};
export const getPluginInstallState = (
state: PluginsState,
plugin: string
) => {
if ( state.active.includes( plugin ) ) {
return 'activated';
} else if ( state.installed.includes( plugin ) ) {
return 'installed';
}
return 'unavailable';
};
export const getPaypalOnboardingStatus = ( state: PluginsState ) =>
state.paypalOnboardingStatus;
export const getRecommendedPlugins = (
state: PluginsState,
type: RecommendedTypes
) => {
return state.recommended[ type ];
};
// Types
export type PluginSelectors = {
getActivePlugins: WPDataSelector< typeof getActivePlugins >;
getInstalledPlugins: WPDataSelector< typeof getInstalledPlugins >;
getRecommendedPlugins: WPDataSelector< typeof getRecommendedPlugins >;
} & WPDataSelectors;

View File

@ -28,7 +28,11 @@ import {
import { STORE_NAME } from '../constants';
// Tests run faster in node env, and we just need access to the window global for this test
global.window = { location: '' };
global.window = {
location: {
href: '',
} as Location,
} as Window & typeof globalThis;
describe( 'installJetPackAndConnect', () => {
beforeEach( () => {
@ -36,7 +40,7 @@ describe( 'installJetPackAndConnect', () => {
} );
it( 'installs jetpack, then activates it', () => {
const installer = installJetpackAndConnect( () => {}, getAdminLink );
const installer = installJetpackAndConnect( () => '', getAdminLink );
// Run to first yield
installer.next();
@ -75,7 +79,7 @@ describe( 'installJetPackAndConnect', () => {
} );
it( 'redirects to the connect url if there are no errors', () => {
const installer = installJetpackAndConnect( undefined, getAdminLink );
const installer = installJetpackAndConnect( jest.fn(), getAdminLink );
// Run to yield any errors from getJetpackConnectUrl
installer.next();
@ -84,7 +88,7 @@ describe( 'installJetPackAndConnect', () => {
installer.next( 'https://example.com' );
installer.next();
expect( global.window.location ).toBe( 'https://example.com' );
expect( global.window.location.href ).toBe( 'https://example.com' );
} );
} );
@ -92,7 +96,7 @@ describe( 'connectToJetpack', () => {
it( 'redirects to the failure url if there is an error', () => {
const connect = connectToJetpackWithFailureRedirect(
'https://example.com/failure',
() => {},
jest.fn(),
getAdminLink
);
@ -100,13 +104,15 @@ describe( 'connectToJetpack', () => {
connect.throw( 'Failed' );
connect.next();
expect( global.window.location ).toBe( 'https://example.com/failure' );
expect( global.window.location.href ).toBe(
'https://example.com/failure'
);
} );
it( 'redirects to the jetpack url if there is no error', () => {
const connect = connectToJetpackWithFailureRedirect(
'https://example.com/failure',
() => {},
jest.fn(),
getAdminLink
);
@ -115,7 +121,9 @@ describe( 'connectToJetpack', () => {
connect.next();
connect.next();
expect( global.window.location ).toBe( 'https://example.com/success' );
expect( global.window.location.href ).toBe(
'https://example.com/success'
);
} );
it( 'calls the passed error handler if an exception is thrown into the generator', () => {

View File

@ -6,19 +6,22 @@
* Internal dependencies
*/
import reducer from '../reducer';
import TYPES from '../action-types';
import { ACTION_TYPES as TYPES } from '../action-types';
import { PluginsState } from '../types';
import { Actions } from '../actions';
const defaultState = {
const defaultState: PluginsState = {
active: [],
installed: [],
requesting: {},
errors: {},
jetpackConnectUrls: {},
recommended: {},
};
describe( 'plugins reducer', () => {
it( 'should return a default state', () => {
const state = reducer( undefined, {} );
const state = reducer( undefined );
expect( state ).toEqual( defaultState );
expect( state ).not.toBe( defaultState );
} );
@ -26,13 +29,14 @@ describe( 'plugins reducer', () => {
it( 'should handle UPDATE_ACTIVE_PLUGINS with replace', () => {
const state = reducer(
{
...defaultState,
active: [ 'plugins', 'to', 'overwrite' ],
},
{
type: TYPES.UPDATE_ACTIVE_PLUGINS,
active: [ 'jetpack' ],
replace: true,
}
} as Actions
);
/* eslint-disable dot-notation */
@ -48,6 +52,7 @@ describe( 'plugins reducer', () => {
it( 'should handle UPDATE_ACTIVE_PLUGINS with active plugins', () => {
const state = reducer(
{
...defaultState,
active: [ 'jetpack' ],
installed: [ 'jetpack' ],
requesting: {},
@ -55,9 +60,8 @@ describe( 'plugins reducer', () => {
},
{
type: TYPES.UPDATE_ACTIVE_PLUGINS,
installed: null,
active: [ 'woocommerce-services' ],
}
} as Actions
);
/* eslint-disable dot-notation */
@ -73,13 +77,14 @@ describe( 'plugins reducer', () => {
it( 'should handle UPDATE_INSTALLED_PLUGINS with replace', () => {
const state = reducer(
{
...defaultState,
active: [ 'plugins', 'to', 'overwrite' ],
},
{
type: TYPES.UPDATE_INSTALLED_PLUGINS,
installed: [ 'jetpack' ],
replace: true,
}
} as Actions
);
/* eslint-disable dot-notation */
@ -95,6 +100,7 @@ describe( 'plugins reducer', () => {
it( 'should handle UPDATE_INSTALLED_PLUGINS with installed plugins', () => {
const state = reducer(
{
...defaultState,
active: [ 'jetpack' ],
installed: [ 'jetpack' ],
requesting: {},
@ -103,7 +109,7 @@ describe( 'plugins reducer', () => {
{
type: TYPES.UPDATE_INSTALLED_PLUGINS,
installed: [ 'woocommerce-services' ],
}
} as Actions
);
/* eslint-disable dot-notation */
@ -121,7 +127,7 @@ describe( 'plugins reducer', () => {
type: TYPES.SET_IS_REQUESTING,
selector: 'getInstalledPlugins',
isRequesting: true,
} );
} as Actions );
/* eslint-disable dot-notation */
@ -133,12 +139,17 @@ describe( 'plugins reducer', () => {
const state = reducer( defaultState, {
type: TYPES.SET_ERROR,
selector: 'getInstalledPlugins',
error: { code: 'error' },
} );
error: { jetpack: [ 'error' ] },
} as Actions );
/* eslint-disable dot-notation */
expect( state.errors[ 'getInstalledPlugins' ].code ).toBe( 'error' );
expect(
( state.errors[ 'getInstalledPlugins' ] as Record<
string,
string[]
> ).jetpack[ 0 ]
).toBe( 'error' );
expect( state.requesting[ 'getInstalledPlugins' ] ).toBe( false );
/* eslint-enable dot-notation */
} );
@ -147,7 +158,7 @@ describe( 'plugins reducer', () => {
const state = reducer( defaultState, {
type: TYPES.UPDATE_JETPACK_CONNECTION,
jetpackConnection: true,
} );
} as Actions );
expect( state.jetpackConnection ).toBe( true );
} );
@ -157,7 +168,7 @@ describe( 'plugins reducer', () => {
type: TYPES.UPDATE_JETPACK_CONNECT_URL,
jetpackConnectUrl: 'http://connect.com',
redirectUrl: 'http://redirect.com',
} );
} as Actions );
expect( state.jetpackConnectUrls[ 'http://redirect.com' ] ).toBe(
'http://connect.com'

View File

@ -0,0 +1,56 @@
/**
* Internal dependencies
*/
import { pluginNames } from './constants';
export type RecommendedTypes = 'payments';
export type PluginNames = keyof typeof pluginNames;
export type SelectorKeysWithActions =
| 'getActivePlugins'
| 'getInstalledPlugins'
| 'getRecommendedPlugins'
| 'installPlugins'
| 'activatePlugins'
| 'isJetpackConnected'
| 'getJetpackConnectUrl'
| 'getPaypalOnboardingStatus';
export type PluginsState = {
active: string[];
installed: string[];
requesting: Partial< Record< SelectorKeysWithActions, boolean > >;
jetpackConnectUrls: Record< string, unknown >;
jetpackConnection?: boolean;
recommended: Partial< Record< RecommendedTypes, Plugin[] > >;
paypalOnboardingStatus?: Partial< PaypalOnboardingStatus >;
// TODO clarify what the error record's type is
errors: Record< string, unknown >;
};
export type Plugin = {
slug: string;
copy: string;
product: string;
title: string;
icon: string;
'button-text': string;
'setup-link': string;
recommended?: boolean;
};
type PaypalOnboardingState = 'unknown' | 'start' | 'progressive' | 'onboarded';
export type PaypalOnboardingStatus = {
environment: string;
onboarded: boolean;
state: PaypalOnboardingState;
sandbox: {
state: PaypalOnboardingState;
onboarded: boolean;
};
production: {
state: PaypalOnboardingState;
onboarded: boolean;
};
};

View File

@ -1,59 +0,0 @@
/**
* External dependencies
*/
import { createHigherOrderComponent } from '@wordpress/compose';
import { useSelect } from '@wordpress/data';
import { useRef } from '@wordpress/element';
/**
* Internal dependencies
*/
import { STORE_NAME } from './constants';
export const withPluginsHydration = ( data ) =>
createHigherOrderComponent(
( OriginalComponent ) => ( props ) => {
const dataRef = useRef( data );
useSelect( ( select, registry ) => {
if ( ! dataRef.current ) {
return;
}
const { isResolving, hasFinishedResolution } = select(
STORE_NAME
);
const {
startResolution,
finishResolution,
updateActivePlugins,
updateInstalledPlugins,
updateIsJetpackConnected,
} = registry.dispatch( STORE_NAME );
if (
! isResolving( 'getActivePlugins', [] ) &&
! hasFinishedResolution( 'getActivePlugins', [] )
) {
startResolution( 'getActivePlugins', [] );
startResolution( 'getInstalledPlugins', [] );
startResolution( 'isJetpackConnected', [] );
updateActivePlugins( dataRef.current.activePlugins, true );
updateInstalledPlugins(
dataRef.current.installedPlugins,
true
);
updateIsJetpackConnected(
dataRef.current.jetpackStatus &&
dataRef.current.jetpackStatus.isActive
);
finishResolution( 'getActivePlugins', [] );
finishResolution( 'getInstalledPlugins', [] );
finishResolution( 'isJetpackConnected', [] );
}
}, [] );
return <OriginalComponent { ...props } />;
},
'withPluginsHydration'
);

View File

@ -0,0 +1,83 @@
/**
* External dependencies
*/
import { createHigherOrderComponent } from '@wordpress/compose';
import { useSelect } from '@wordpress/data';
import { useRef } from '@wordpress/element';
/**
* Internal dependencies
*/
import { STORE_NAME } from './constants';
import { WCDataSelector, WPDataActions } from '../';
import * as actions from './actions';
type PluginHydrationData = {
installedPlugins: string[];
activePlugins: string[];
jetpackStatus?: { isActive: boolean };
};
export const withPluginsHydration = ( data: PluginHydrationData ) =>
createHigherOrderComponent(
( OriginalComponent: React.ComponentType ) => (
props: Record< string, unknown >
) => {
const dataRef = useRef( data );
useSelect(
(
select: WCDataSelector,
registry: {
dispatch: (
store: string
) => typeof actions & WPDataActions;
}
) => {
if ( ! dataRef.current ) {
return;
}
const { isResolving, hasFinishedResolution } = select(
STORE_NAME
);
const {
startResolution,
finishResolution,
updateActivePlugins,
updateInstalledPlugins,
updateIsJetpackConnected,
} = registry.dispatch( STORE_NAME );
if (
! isResolving( 'getActivePlugins', [] ) &&
! hasFinishedResolution( 'getActivePlugins', [] )
) {
startResolution( 'getActivePlugins', [] );
startResolution( 'getInstalledPlugins', [] );
startResolution( 'isJetpackConnected', [] );
updateActivePlugins(
dataRef.current.activePlugins,
true
);
updateInstalledPlugins(
dataRef.current.installedPlugins,
true
);
updateIsJetpackConnected(
dataRef.current.jetpackStatus &&
dataRef.current.jetpackStatus.isActive
? true
: false
);
finishResolution( 'getActivePlugins', [] );
finishResolution( 'getInstalledPlugins', [] );
finishResolution( 'isJetpackConnected', [] );
}
},
[]
);
return <OriginalComponent { ...props } />;
},
'withPluginsHydration'
);

View File

@ -4,5 +4,24 @@
export type WPDataSelectors = {
hasStartedResolution: ( selector: string, args?: string[] ) => boolean;
hasFinishedResolution: ( selector: string, args?: string[] ) => boolean;
isResolving: ( selector: string, args: [ ] ) => boolean;
isResolving: ( selector: string, args: string[] ) => boolean;
};
export type WPDataActions = {
startResolution: ( selector: string, args?: string[] ) => void;
finishResolution: ( selector: string, args?: string[] ) => void;
};
// Omitting state from selector parameter
export type WPDataSelector< T > = T extends (
state: infer S,
...args: infer A
) => infer R
? ( ...args: A ) => R
: T;
export type WPError< ErrorKey extends string = string, ErrorData = unknown > = {
errors: Record< ErrorKey, string[] >;
error_data?: Record< ErrorKey, ErrorData >;
additional_data?: Record< ErrorKey, ErrorData[] >;
};

View File

@ -82,6 +82,7 @@ Release and roadmap notes are available on the [WooCommerce Developers Blog](htt
- Dev: Add support for nonces in note actions #6726
- Dev: Add support for running php unit tests in PHP 8. #6678
- Dev: Add event recording to start of gateway connections #6801
- Feature: Add recommended payment methods in payment settings. #6760
- Fix: Event tracking for merchant email notes #6616
- Fix: Use the store timezone to make time data requests #6632
- Fix: Update the checked input radio button margin style #6701

View File

@ -8,6 +8,7 @@
namespace Automattic\WooCommerce\Admin\API;
use Automattic\WooCommerce\Admin\Features\Onboarding;
use Automattic\WooCommerce\Admin\PaymentPlugins;
use Automattic\WooCommerce\Admin\PluginsHelper;
use \Automattic\WooCommerce\Admin\Notes\InstallJPAndWCSPlugins;
@ -89,6 +90,19 @@ class Plugins extends \WC_REST_Data_Controller {
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/recommended-payment-plugins',
array(
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'recommended_payment_plugins' ),
'permission_callback' => array( $this, 'get_item_permissions_check' ),
),
'schema' => array( $this, 'get_item_schema' ),
)
);
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/connect-jetpack',
@ -400,6 +414,49 @@ class Plugins extends \WC_REST_Data_Controller {
) );
}
/**
* Return recommended payment plugins.
*
* @param WP_REST_Request $request Full details about the request.
* @return \WP_Error|\WP_HTTP_Response|\WP_REST_Response
*/
public function recommended_payment_plugins( $request ) {
// Default to marketing category (if no category set).
$all_plugins = PaymentPlugins::get_instance()->get_recommended_plugins();
$valid_plugins = [];
$per_page = $request->get_param( 'per_page' );
// We currently only support English suggestions, unless otherwise provided in locale-data.
$locale = get_locale();
$suggestion_locales = array(
'en_AU',
'en_CA',
'en_GB',
'en_NZ',
'en_US',
'en_ZA',
);
foreach ( $all_plugins as $plugin ) {
if ( ! PluginsHelper::is_plugin_active( $plugin['product'] ) ) {
if ( isset( $plugin['locale-data'] ) && isset( $plugin['locale-data'][ $locale ] ) ) {
$locale_plugin = array_merge( $plugin, $plugin['locale-data'][ $locale ] );
unset( $locale_plugin['locale-data'] );
$valid_plugins[] = $locale_plugin;
$suggestion_locales[] = $locale;
} else {
$valid_plugins[] = $plugin;
}
}
}
if ( ! in_array( $locale, $suggestion_locales, true ) ) {
// If not a supported locale we return an empty array.
return rest_ensure_response( array() );
}
return rest_ensure_response( array_slice( $valid_plugins, 0, $per_page ) );
}
/**
* Generates a Jetpack Connect URL.
*

View File

@ -0,0 +1,115 @@
<?php
/**
* WooCommerce Payment methods.
* NOTE: DO NOT edit this file in WooCommerce core, this is generated from woocommerce-admin.
*/
namespace Automattic\WooCommerce\Admin;
/**
* Contains backend logic for retrieving payment plugin recommendations.
*/
class PaymentPlugins {
/**
* Name of recommended plugins transient.
*
* @var string
*/
const RECOMMENDED_PLUGINS_TRANSIENT = 'wc_recommended_payment_plugins';
/**
* Class instance.
*
* @var PaymentPlugins instance
*/
protected static $instance = null;
/**
* Get class instance.
*/
public static function get_instance() {
if ( ! self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Load recommended payment plugins from WooCommerce.com
*
* @return array
*/
public function get_recommended_plugins() {
if ( ! self::allow_recommendations() ) {
return array();
}
$plugins_data = get_transient( self::RECOMMENDED_PLUGINS_TRANSIENT );
if ( false === $plugins_data ) {
include_once ABSPATH . '/wp-admin/includes/plugin-install.php';
$url = 'https://woocommerce.com/wp-json/wccom/marketplace-suggestions/1.0/payment-suggestions.json';
$request = wp_safe_remote_get( $url );
$plugins = [];
if ( ! is_wp_error( $request ) && 200 === $request['response']['code'] ) {
$plugins = json_decode( $request['body'], true );
}
foreach ( $plugins as $key => $plugin ) {
if ( ! array_key_exists( 'copy', $plugins[ $key ] ) ) {
$api = plugins_api(
'plugin_information',
array(
'slug' => $plugin['product'],
'fields' => array(
'short_description' => true,
),
)
);
if ( is_wp_error( $api ) ) {
continue;
}
$plugins[ $key ]['copy'] = $api->short_description;
}
}
$plugins_data = array(
'recommendations' => $plugins,
'updated' => time(),
);
set_transient(
self::RECOMMENDED_PLUGINS_TRANSIENT,
$plugins_data,
// Expire transient in 15 minutes if remote get failed.
// Cache an empty result to avoid repeated failed requests.
empty( $plugins ) ? 900 : 3 * DAY_IN_SECONDS
);
}
return array_values( $plugins_data['recommendations'] );
}
/**
* Should recommendations be displayed?
*
* @return bool
*/
private function allow_recommendations() {
// Suggestions are only displayed if user can install plugins.
if ( ! current_user_can( 'install_plugins' ) ) {
return false;
}
// Suggestions may be disabled via a setting under Accounts & Privacy.
if ( 'no' === get_option( 'woocommerce_show_marketplace_suggestions', 'yes' ) ) {
return false;
}
// User can disabled all suggestions via filter.
return apply_filters( 'woocommerce_allow_payment_recommendations', true );
}
}

View File

@ -109,4 +109,127 @@ class WC_Tests_API_Plugins extends WC_REST_Unit_Test_Case {
$this->assertEquals( 'woocommerce_rest_invalid_plugins', $data['code'] );
}
/**
* Test that recommended payment plugins are returned correctly.
*/
public function test_get_recommended_payment_plugins() {
wp_set_current_user( $this->user );
set_transient(
\Automattic\WooCommerce\Admin\PaymentPlugins::RECOMMENDED_PLUGINS_TRANSIENT,
array(
'recommendations' => array(
array(
'product' => 'plugin',
'title' => 'test',
),
),
)
);
$request = new WP_REST_Request( 'GET', $this->endpoint . '/recommended-payment-plugins' );
$response = $this->server->dispatch( $request );
$data = $response->get_data();
$this->assertEquals( 1, count( $data ) );
$this->assertEquals( 'plugin', $data[0]['product'] );
delete_transient( \Automattic\WooCommerce\Admin\PaymentPlugins::RECOMMENDED_PLUGINS_TRANSIENT );
}
/**
* Test that recommended payment plugins with locale data.
*/
public function test_get_recommended_payment_plugins_with_locale() {
wp_set_current_user( $this->user );
add_filter( 'locale', array( $this, 'set_france_locale' ) );
set_transient(
\Automattic\WooCommerce\Admin\PaymentPlugins::RECOMMENDED_PLUGINS_TRANSIENT,
array(
'recommendations' => array(
array(
'product' => 'plugin',
'title' => 'test',
'locale-data' => array(
'fr_FR' => array(
'title' => 'translated title',
),
),
),
),
)
);
$request = new WP_REST_Request( 'GET', $this->endpoint . '/recommended-payment-plugins' );
$response = $this->server->dispatch( $request );
$data = $response->get_data();
$this->assertEquals( 1, count( $data ) );
$this->assertEquals( 'plugin', $data[0]['product'] );
$this->assertEquals( 'translated title', $data[0]['title'] );
$this->assertEquals( false, isset( $data[0]['locale-data'] ) );
delete_transient( \Automattic\WooCommerce\Admin\PaymentPlugins::RECOMMENDED_PLUGINS_TRANSIENT );
remove_filter( 'locale', array( $this, 'set_france_locale' ) );
}
/**
* Test that recommended payment plugins with not default supported locale.
*/
public function test_get_recommended_payment_plugins_with_not_supported_locale() {
wp_set_current_user( $this->user );
add_filter( 'locale', array( $this, 'set_france_locale' ) );
set_transient(
\Automattic\WooCommerce\Admin\PaymentPlugins::RECOMMENDED_PLUGINS_TRANSIENT,
array(
'recommendations' => array(
array(
'product' => 'plugin',
'title' => 'test',
),
),
)
);
$request = new WP_REST_Request( 'GET', $this->endpoint . '/recommended-payment-plugins' );
$response = $this->server->dispatch( $request );
$data = $response->get_data();
// Return nothing as default is only english locales.
$this->assertEquals( 0, count( $data ) );
delete_transient( \Automattic\WooCommerce\Admin\PaymentPlugins::RECOMMENDED_PLUGINS_TRANSIENT );
remove_filter( 'locale', array( $this, 'set_france_locale' ) );
}
/**
* @return string locale
*/
public function set_france_locale() {
return 'fr_FR';
}
/**
* Test that recommended payment plugins are not returned when active.
*/
public function test_get_recommended_payment_plugins_that_are_active() {
wp_set_current_user( $this->user );
update_option( 'active_plugins', array( 'facebook-for-woocommerce/facebook-for-woocommerce.php' ) );
set_transient(
\Automattic\WooCommerce\Admin\PaymentPlugins::RECOMMENDED_PLUGINS_TRANSIENT,
array(
'recommendations' => array(
array(
'product' => 'facebook-for-woocommerce',
'title' => 'test',
),
),
)
);
$request = new WP_REST_Request( 'GET', $this->endpoint . '/recommended-payment-plugins' );
$response = $this->server->dispatch( $request );
$data = $response->get_data();
$this->assertEquals( 0, count( $data ) );
delete_transient( \Automattic\WooCommerce\Admin\PaymentPlugins::RECOMMENDED_PLUGINS_TRANSIENT );
delete_option( 'active_plugins' );
}
}

View File

@ -22,9 +22,9 @@
"<rootDir>/tests/js/setup-react-testing-library.js"
],
"testMatch": [
"**/__tests__/**/*.[jt]s",
"**/test/*.[jt]s",
"**/?(*.)test.[jt]s"
"**/__tests__/**/*.[jt]s?(x)",
"**/test/*.[jt]s?(x)",
"**/?(*.)test.[jt]s?(x)"
],
"transform": {
"^.+\\.[jt]sx?$": "babel-jest"

View File

@ -0,0 +1,10 @@
declare global {
interface Window {
wcSettings: {
preloadOptions: Record< string, unknown >;
};
}
}
/*~ If your module exports nothing, you'll need this line. Otherwise, delete it */
export {};