From be7dd2dd5e8b288d5a162bafe63e0c4191acad18 Mon Sep 17 00:00:00 2001 From: louwie17 Date: Tue, 20 Apr 2021 14:17:19 -0300 Subject: [PATCH] Add recommended payment methods (https://github.com/woocommerce/woocommerce-admin/pull/6760) * 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 --- plugins/woocommerce-admin/.eslintrc.js | 3 + .../woocommerce-admin/TESTING-INSTRUCTIONS.md | 26 +- .../embedded-body-layout.tsx | 54 +++ .../embedded-body-props.ts | 5 + .../client/embedded-body-layout/index.ts | 1 + .../client/embedded-body-layout/style.scss | 8 + .../test/embedded-body-layout.test.tsx | 73 ++++ plugins/woocommerce-admin/client/index.js | 6 + .../client/payments/index.ts | 1 + .../payment-recommendations-wrapper.tsx | 31 ++ .../payments/payment-recommendations.scss | 74 ++++ .../payments/payment-recommendations.tsx | 227 ++++++++++ .../test/payment-recommendations.test.tsx | 403 ++++++++++++++++++ plugins/woocommerce-admin/package-lock.json | 74 +++- plugins/woocommerce-admin/package.json | 4 +- .../packages/data/package.json | 1 + .../packages/data/src/index.ts | 14 +- .../options/test/with-options-hydration.js | 95 +++++ .../src/options/with-options-hydration.js | 58 +-- .../packages/data/src/plugins/action-types.js | 11 - .../packages/data/src/plugins/action-types.ts | 10 + .../packages/data/src/plugins/actions.js | 226 ---------- .../packages/data/src/plugins/actions.ts | 332 +++++++++++++++ .../data/src/plugins/{index.js => index.ts} | 0 .../packages/data/src/plugins/reducer.js | 115 ----- .../packages/data/src/plugins/reducer.ts | 125 ++++++ .../plugins/{resolvers.js => resolvers.ts} | 93 ++-- .../packages/data/src/plugins/selectors.js | 34 -- .../packages/data/src/plugins/selectors.ts | 71 +++ .../plugins/test/{actions.js => actions.ts} | 24 +- .../plugins/test/{reducer.js => reducer.ts} | 39 +- .../packages/data/src/plugins/types.ts | 56 +++ .../src/plugins/with-plugins-hydration.js | 59 --- .../src/plugins/with-plugins-hydration.tsx | 83 ++++ .../packages/data/src/types.ts | 21 +- plugins/woocommerce-admin/readme.txt | 1 + plugins/woocommerce-admin/src/API/Plugins.php | 57 +++ .../woocommerce-admin/src/PaymentPlugins.php | 115 +++++ .../woocommerce-admin/tests/api/plugins.php | 123 ++++++ .../tests/js/jest.config.json | 6 +- plugins/woocommerce-admin/typings/global.d.ts | 10 + 41 files changed, 2228 insertions(+), 541 deletions(-) create mode 100644 plugins/woocommerce-admin/client/embedded-body-layout/embedded-body-layout.tsx create mode 100644 plugins/woocommerce-admin/client/embedded-body-layout/embedded-body-props.ts create mode 100644 plugins/woocommerce-admin/client/embedded-body-layout/index.ts create mode 100644 plugins/woocommerce-admin/client/embedded-body-layout/style.scss create mode 100644 plugins/woocommerce-admin/client/embedded-body-layout/test/embedded-body-layout.test.tsx create mode 100644 plugins/woocommerce-admin/client/payments/index.ts create mode 100644 plugins/woocommerce-admin/client/payments/payment-recommendations-wrapper.tsx create mode 100644 plugins/woocommerce-admin/client/payments/payment-recommendations.scss create mode 100644 plugins/woocommerce-admin/client/payments/payment-recommendations.tsx create mode 100644 plugins/woocommerce-admin/client/payments/test/payment-recommendations.test.tsx create mode 100644 plugins/woocommerce-admin/packages/data/src/options/test/with-options-hydration.js delete mode 100644 plugins/woocommerce-admin/packages/data/src/plugins/action-types.js create mode 100644 plugins/woocommerce-admin/packages/data/src/plugins/action-types.ts delete mode 100644 plugins/woocommerce-admin/packages/data/src/plugins/actions.js create mode 100644 plugins/woocommerce-admin/packages/data/src/plugins/actions.ts rename plugins/woocommerce-admin/packages/data/src/plugins/{index.js => index.ts} (100%) delete mode 100644 plugins/woocommerce-admin/packages/data/src/plugins/reducer.js create mode 100644 plugins/woocommerce-admin/packages/data/src/plugins/reducer.ts rename plugins/woocommerce-admin/packages/data/src/plugins/{resolvers.js => resolvers.ts} (60%) delete mode 100644 plugins/woocommerce-admin/packages/data/src/plugins/selectors.js create mode 100644 plugins/woocommerce-admin/packages/data/src/plugins/selectors.ts rename plugins/woocommerce-admin/packages/data/src/plugins/test/{actions.js => actions.ts} (85%) rename plugins/woocommerce-admin/packages/data/src/plugins/test/{reducer.js => reducer.ts} (86%) create mode 100644 plugins/woocommerce-admin/packages/data/src/plugins/types.ts delete mode 100644 plugins/woocommerce-admin/packages/data/src/plugins/with-plugins-hydration.js create mode 100644 plugins/woocommerce-admin/packages/data/src/plugins/with-plugins-hydration.tsx create mode 100644 plugins/woocommerce-admin/src/PaymentPlugins.php create mode 100644 plugins/woocommerce-admin/typings/global.d.ts diff --git a/plugins/woocommerce-admin/.eslintrc.js b/plugins/woocommerce-admin/.eslintrc.js index 12872c4e2fa..0f68dd797f3 100644 --- a/plugins/woocommerce-admin/.eslintrc.js +++ b/plugins/woocommerce-admin/.eslintrc.js @@ -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' ], }, }, ], diff --git a/plugins/woocommerce-admin/TESTING-INSTRUCTIONS.md b/plugins/woocommerce-admin/TESTING-INSTRUCTIONS.md index f55002bdb6f..14cb45eb0fa 100644 --- a/plugins/woocommerce-admin/TESTING-INSTRUCTIONS.md +++ b/plugins/woocommerce-admin/TESTING-INSTRUCTIONS.md @@ -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 diff --git a/plugins/woocommerce-admin/client/embedded-body-layout/embedded-body-layout.tsx b/plugins/woocommerce-admin/client/embedded-body-layout/embedded-body-layout.tsx new file mode 100644 index 00000000000..822dff5f2dc --- /dev/null +++ b/plugins/woocommerce-admin/client/embedded-body-layout/embedded-body-layout.tsx @@ -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 ( +
+ { componentList.map( ( Comp, index ) => { + return ; + } ) } +
+ ); +}; diff --git a/plugins/woocommerce-admin/client/embedded-body-layout/embedded-body-props.ts b/plugins/woocommerce-admin/client/embedded-body-layout/embedded-body-props.ts new file mode 100644 index 00000000000..ff168b1cba6 --- /dev/null +++ b/plugins/woocommerce-admin/client/embedded-body-layout/embedded-body-props.ts @@ -0,0 +1,5 @@ +export type EmbeddedBodyProps = { + page: string; + tab: string; + section?: string; +}; diff --git a/plugins/woocommerce-admin/client/embedded-body-layout/index.ts b/plugins/woocommerce-admin/client/embedded-body-layout/index.ts new file mode 100644 index 00000000000..d23ecce9a41 --- /dev/null +++ b/plugins/woocommerce-admin/client/embedded-body-layout/index.ts @@ -0,0 +1 @@ +export * from './embedded-body-layout'; diff --git a/plugins/woocommerce-admin/client/embedded-body-layout/style.scss b/plugins/woocommerce-admin/client/embedded-body-layout/style.scss new file mode 100644 index 00000000000..340ff87789e --- /dev/null +++ b/plugins/woocommerce-admin/client/embedded-body-layout/style.scss @@ -0,0 +1,8 @@ +.woocommerce-embedded-layout__primary { + padding: 0 20px; +} +@media (max-width: #{ ($break-medium) }) { + .woocommerce-embedded-layout__primary { + padding: 0; + } +} diff --git a/plugins/woocommerce-admin/client/embedded-body-layout/test/embedded-body-layout.test.tsx b/plugins/woocommerce-admin/client/embedded-body-layout/test/embedded-body-layout.test.tsx new file mode 100644 index 00000000000..def8e106901 --- /dev/null +++ b/plugins/woocommerce-admin/client/embedded-body-layout/test/embedded-body-layout.test.tsx @@ -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; + } ) => ( +
+ payment_recommendations + page:{ page } + tab:{ tab } + section:{ section || '' } +
+ ), +} ) ); + +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( ); + + 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
new_component
; + }, + ]; + } + ); + stubLocation( '?page=settings&tab=test' ); + const { queryByText, container } = render( ); + + expect( queryByText( 'new_component' ) ).toBeInTheDocument(); + } ); +} ); diff --git a/plugins/woocommerce-admin/client/index.js b/plugins/woocommerce-admin/client/index.js index 7d072f4c840..2432dee7b0b 100644 --- a/plugins/woocommerce-admin/client/index.js +++ b/plugins/woocommerce-admin/client/index.js @@ -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 ) { , wpBody.insertBefore( noticeContainer, wrap ) ); + const embeddedBodyContainer = document.createElement( 'div' ); + render( + , + wpBody.insertBefore( embeddedBodyContainer, wrap.nextSibling ) + ); } // Render the CustomerEffortScoreTracksContainer only if diff --git a/plugins/woocommerce-admin/client/payments/index.ts b/plugins/woocommerce-admin/client/payments/index.ts new file mode 100644 index 00000000000..ecc1e526d9e --- /dev/null +++ b/plugins/woocommerce-admin/client/payments/index.ts @@ -0,0 +1 @@ +export * from './payment-recommendations-wrapper'; diff --git a/plugins/woocommerce-admin/client/payments/payment-recommendations-wrapper.tsx b/plugins/woocommerce-admin/client/payments/payment-recommendations-wrapper.tsx new file mode 100644 index 00000000000..a448c53057c --- /dev/null +++ b/plugins/woocommerce-admin/client/payments/payment-recommendations-wrapper.tsx @@ -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 ( + + + + ); + } + return null; +}; diff --git a/plugins/woocommerce-admin/client/payments/payment-recommendations.scss b/plugins/woocommerce-admin/client/payments/payment-recommendations.scss new file mode 100644 index 00000000000..2cbc1f214c4 --- /dev/null +++ b/plugins/woocommerce-admin/client/payments/payment-recommendations.scss @@ -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; + } +} diff --git a/plugins/woocommerce-admin/client/payments/payment-recommendations.tsx b/plugins/woocommerce-admin/client/payments/payment-recommendations.tsx new file mode 100644 index 00000000000..31aea890301 --- /dev/null +++ b/plugins/woocommerce-admin/client/payments/payment-recommendations.tsx @@ -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 && ( + + { __( 'Recommended', 'woocommerce-admin' ) } + + ) } + + ), + content: decodeEntities( plugin.copy ), + after: ( + + ), + before: , + }; + } + ); + + return ( + + +
+ + { __( + 'Recommended ways to get paid', + 'woocommerce-admin' + ) } + + + { __( + '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' + ) } + +
+
+ ( +
+ +
+ ) } + /> +
+
+ + + + + + +
+ ); +}; + +export default PaymentRecommendations; diff --git a/plugins/woocommerce-admin/client/payments/test/payment-recommendations.test.tsx b/plugins/woocommerce-admin/client/payments/test/payment-recommendations.test.tsx new file mode 100644 index 00000000000..ad769ea40a8 --- /dev/null +++ b/plugins/woocommerce-admin/client/payments/test/payment-recommendations.test.tsx @@ -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; + } ) => , + List: ( { + items, + }: { + items: { key: string; title: string; after?: React.Component }[]; + } ) => ( +
+ { items.map( ( item ) => ( +
+ { item.title } + { item.after } +
+ ) ) } +
+ ), +} ) ); +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( ); + + 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( ); + + 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( ); + + 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( ); + + 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( ); + + 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( + + ); + + 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( + + ); + + 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' ); + } ); + } ); +} ); diff --git a/plugins/woocommerce-admin/package-lock.json b/plugins/woocommerce-admin/package-lock.json index ae6704249cf..1a7fe4df748 100644 --- a/plugins/woocommerce-admin/package-lock.json +++ b/plugins/woocommerce-admin/package-lock.json @@ -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" }, diff --git a/plugins/woocommerce-admin/package.json b/plugins/woocommerce-admin/package.json index 8cc2600d9a6..e1669ee8a78 100644 --- a/plugins/woocommerce-admin/package.json +++ b/plugins/woocommerce-admin/package.json @@ -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", diff --git a/plugins/woocommerce-admin/packages/data/package.json b/plugins/woocommerce-admin/packages/data/package.json index 02047ae203f..2fe15f6c0ae 100644 --- a/plugins/woocommerce-admin/packages/data/package.json +++ b/plugins/woocommerce-admin/packages/data/package.json @@ -23,6 +23,7 @@ "dependencies": { "@woocommerce/date": "file:../date", "@woocommerce/navigation": "file:../navigation", + "@wordpress/i18n": "3.17.0", "rememo": "^3.0.0" }, "publishConfig": { diff --git a/plugins/woocommerce-admin/packages/data/src/index.ts b/plugins/woocommerce-admin/packages/data/src/index.ts index 0a43a6defc9..86194cb6260 100644 --- a/plugins/woocommerce-admin/packages/data/src/index.ts +++ b/plugins/woocommerce-admin/packages/data/src/index.ts @@ -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 diff --git a/plugins/woocommerce-admin/packages/data/src/options/test/with-options-hydration.js b/plugins/woocommerce-admin/packages/data/src/options/test/with-options-hydration.js new file mode 100644 index 00000000000..fb4e92eba43 --- /dev/null +++ b/plugins/woocommerce-admin/packages/data/src/options/test/with-options-hydration.js @@ -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
; +}; + +const TestHigherOrderComponent = withOptionsHydration( optionData )( () => ( +
+) ); + +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( ); + 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( ); + expect( receiveOptionsMock ).not.toHaveBeenLastCalledWith( { + option3: 'val3', + } ); + expect( receiveOptionsMock ).toHaveBeenCalledTimes( 0 ); + expect( startResolutionMock ).toHaveBeenCalledTimes( 0 ); + } + ); +} ); diff --git a/plugins/woocommerce-admin/packages/data/src/options/with-options-hydration.js b/plugins/woocommerce-admin/packages/data/src/options/with-options-hydration.js index 2289963ebcf..32948d6d3c4 100644 --- a/plugins/woocommerce-admin/packages/data/src/options/with-options-hydration.js +++ b/plugins/woocommerce-admin/packages/data/src/options/with-options-hydration.js @@ -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 ; }, diff --git a/plugins/woocommerce-admin/packages/data/src/plugins/action-types.js b/plugins/woocommerce-admin/packages/data/src/plugins/action-types.js deleted file mode 100644 index 1eea2abe1c5..00000000000 --- a/plugins/woocommerce-admin/packages/data/src/plugins/action-types.js +++ /dev/null @@ -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; diff --git a/plugins/woocommerce-admin/packages/data/src/plugins/action-types.ts b/plugins/woocommerce-admin/packages/data/src/plugins/action-types.ts new file mode 100644 index 00000000000..2df914b2dbd --- /dev/null +++ b/plugins/woocommerce-admin/packages/data/src/plugins/action-types.ts @@ -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', +} diff --git a/plugins/woocommerce-admin/packages/data/src/plugins/actions.js b/plugins/woocommerce-admin/packages/data/src/plugins/actions.js deleted file mode 100644 index c7ca3f8d08d..00000000000 --- a/plugins/woocommerce-admin/packages/data/src/plugins/actions.js +++ /dev/null @@ -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' ), - } - ); -}; diff --git a/plugins/woocommerce-admin/packages/data/src/plugins/actions.ts b/plugins/woocommerce-admin/packages/data/src/plugins/actions.ts new file mode 100644 index 00000000000..f090203f310 --- /dev/null +++ b/plugins/woocommerce-admin/packages/data/src/plugins/actions.ts @@ -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 >; diff --git a/plugins/woocommerce-admin/packages/data/src/plugins/index.js b/plugins/woocommerce-admin/packages/data/src/plugins/index.ts similarity index 100% rename from plugins/woocommerce-admin/packages/data/src/plugins/index.js rename to plugins/woocommerce-admin/packages/data/src/plugins/index.ts diff --git a/plugins/woocommerce-admin/packages/data/src/plugins/reducer.js b/plugins/woocommerce-admin/packages/data/src/plugins/reducer.js deleted file mode 100644 index 2cfa3db25de..00000000000 --- a/plugins/woocommerce-admin/packages/data/src/plugins/reducer.js +++ /dev/null @@ -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; diff --git a/plugins/woocommerce-admin/packages/data/src/plugins/reducer.ts b/plugins/woocommerce-admin/packages/data/src/plugins/reducer.ts new file mode 100644 index 00000000000..29758e89e4e --- /dev/null +++ b/plugins/woocommerce-admin/packages/data/src/plugins/reducer.ts @@ -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; diff --git a/plugins/woocommerce-admin/packages/data/src/plugins/resolvers.js b/plugins/woocommerce-admin/packages/data/src/plugins/resolvers.ts similarity index 60% rename from plugins/woocommerce-admin/packages/data/src/plugins/resolvers.js rename to plugins/woocommerce-admin/packages/data/src/plugins/resolvers.ts index d1a462c1d86..626bcf9699f 100644 --- a/plugins/woocommerce-admin/packages/data/src/plugins/resolvers.js +++ b/plugins/woocommerce-admin/packages/data/src/plugins/resolvers.ts @@ -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 ); } diff --git a/plugins/woocommerce-admin/packages/data/src/plugins/selectors.js b/plugins/woocommerce-admin/packages/data/src/plugins/selectors.js deleted file mode 100644 index 734fcaa173e..00000000000 --- a/plugins/woocommerce-admin/packages/data/src/plugins/selectors.js +++ /dev/null @@ -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; diff --git a/plugins/woocommerce-admin/packages/data/src/plugins/selectors.ts b/plugins/woocommerce-admin/packages/data/src/plugins/selectors.ts new file mode 100644 index 00000000000..445b4ca9e15 --- /dev/null +++ b/plugins/woocommerce-admin/packages/data/src/plugins/selectors.ts @@ -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; diff --git a/plugins/woocommerce-admin/packages/data/src/plugins/test/actions.js b/plugins/woocommerce-admin/packages/data/src/plugins/test/actions.ts similarity index 85% rename from plugins/woocommerce-admin/packages/data/src/plugins/test/actions.js rename to plugins/woocommerce-admin/packages/data/src/plugins/test/actions.ts index f6dc1ca85c8..482848a102d 100644 --- a/plugins/woocommerce-admin/packages/data/src/plugins/test/actions.js +++ b/plugins/woocommerce-admin/packages/data/src/plugins/test/actions.ts @@ -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', () => { diff --git a/plugins/woocommerce-admin/packages/data/src/plugins/test/reducer.js b/plugins/woocommerce-admin/packages/data/src/plugins/test/reducer.ts similarity index 86% rename from plugins/woocommerce-admin/packages/data/src/plugins/test/reducer.js rename to plugins/woocommerce-admin/packages/data/src/plugins/test/reducer.ts index 476f65310d7..65ba5980313 100644 --- a/plugins/woocommerce-admin/packages/data/src/plugins/test/reducer.js +++ b/plugins/woocommerce-admin/packages/data/src/plugins/test/reducer.ts @@ -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' diff --git a/plugins/woocommerce-admin/packages/data/src/plugins/types.ts b/plugins/woocommerce-admin/packages/data/src/plugins/types.ts new file mode 100644 index 00000000000..e664774c753 --- /dev/null +++ b/plugins/woocommerce-admin/packages/data/src/plugins/types.ts @@ -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; + }; +}; diff --git a/plugins/woocommerce-admin/packages/data/src/plugins/with-plugins-hydration.js b/plugins/woocommerce-admin/packages/data/src/plugins/with-plugins-hydration.js deleted file mode 100644 index 3d1f3c15e0b..00000000000 --- a/plugins/woocommerce-admin/packages/data/src/plugins/with-plugins-hydration.js +++ /dev/null @@ -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 ; - }, - 'withPluginsHydration' - ); diff --git a/plugins/woocommerce-admin/packages/data/src/plugins/with-plugins-hydration.tsx b/plugins/woocommerce-admin/packages/data/src/plugins/with-plugins-hydration.tsx new file mode 100644 index 00000000000..3fcce208333 --- /dev/null +++ b/plugins/woocommerce-admin/packages/data/src/plugins/with-plugins-hydration.tsx @@ -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 ; + }, + 'withPluginsHydration' + ); diff --git a/plugins/woocommerce-admin/packages/data/src/types.ts b/plugins/woocommerce-admin/packages/data/src/types.ts index e16213a7904..637fbdb14a4 100644 --- a/plugins/woocommerce-admin/packages/data/src/types.ts +++ b/plugins/woocommerce-admin/packages/data/src/types.ts @@ -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[] >; }; diff --git a/plugins/woocommerce-admin/readme.txt b/plugins/woocommerce-admin/readme.txt index 8444bf5eab9..d0ea4ca5d9a 100644 --- a/plugins/woocommerce-admin/readme.txt +++ b/plugins/woocommerce-admin/readme.txt @@ -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 diff --git a/plugins/woocommerce-admin/src/API/Plugins.php b/plugins/woocommerce-admin/src/API/Plugins.php index 7a183a9e6d4..f68fe1ff140 100644 --- a/plugins/woocommerce-admin/src/API/Plugins.php +++ b/plugins/woocommerce-admin/src/API/Plugins.php @@ -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. * diff --git a/plugins/woocommerce-admin/src/PaymentPlugins.php b/plugins/woocommerce-admin/src/PaymentPlugins.php new file mode 100644 index 00000000000..e0a523d43f5 --- /dev/null +++ b/plugins/woocommerce-admin/src/PaymentPlugins.php @@ -0,0 +1,115 @@ + $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 ); + } +} + diff --git a/plugins/woocommerce-admin/tests/api/plugins.php b/plugins/woocommerce-admin/tests/api/plugins.php index 9bde2e0d947..9b2b051ef81 100644 --- a/plugins/woocommerce-admin/tests/api/plugins.php +++ b/plugins/woocommerce-admin/tests/api/plugins.php @@ -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' ); + } } diff --git a/plugins/woocommerce-admin/tests/js/jest.config.json b/plugins/woocommerce-admin/tests/js/jest.config.json index 59bb94397bc..21a87b330be 100644 --- a/plugins/woocommerce-admin/tests/js/jest.config.json +++ b/plugins/woocommerce-admin/tests/js/jest.config.json @@ -22,9 +22,9 @@ "/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" diff --git a/plugins/woocommerce-admin/typings/global.d.ts b/plugins/woocommerce-admin/typings/global.d.ts new file mode 100644 index 00000000000..9f8cb71d656 --- /dev/null +++ b/plugins/woocommerce-admin/typings/global.d.ts @@ -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 {};