Adding Slotfill extension components for remote payments (https://github.com/woocommerce/woocommerce-admin/pull/6932)

This commit is contained in:
Joel Thiessen 2021-05-11 09:36:56 -07:00 committed by GitHub
parent c904690cac
commit 073a220b59
21 changed files with 621 additions and 87 deletions

View File

@ -7,6 +7,7 @@ import { render, screen, fireEvent } from '@testing-library/react';
* Internal dependencies
*/
import { ReviewsPanel } from '../';
import { getByTextWithMarkup } from '../../../../../tests/js/util';
const REVIEW = {
id: 10,
@ -47,13 +48,6 @@ const REVIEW = {
},
};
jest.mock( '@woocommerce/components', () => ( {
...jest.requireActual( '@woocommerce/components' ),
Link: ( { children } ) => {
return <>{ children }</>;
},
} ) );
jest.mock( '../checkmark-circle-icon', () =>
jest.fn().mockImplementation( () => '[checkmark-circle-icon]' )
);
@ -66,6 +60,7 @@ describe( 'ReviewsPanel', () => {
isError={ false }
isRequesting={ false }
reviews={ [] }
createNotice={ () => {} }
/>
);
expect( screen.queryByRole( 'section' ) ).toBeNull();
@ -78,9 +73,11 @@ describe( 'ReviewsPanel', () => {
isError={ false }
isRequesting={ false }
reviews={ [ REVIEW ] }
createNotice={ () => {} }
/>
);
expect( screen.getByText( 'Reviewer reviewed Cap' ) ).not.toBeNull();
expect( getByTextWithMarkup( 'Reviewer reviewed Cap' ) ).not.toBeNull();
} );
it( 'should render checkmark circle icon in the review title, if review is verfied owner', () => {
@ -90,6 +87,7 @@ describe( 'ReviewsPanel', () => {
isError={ false }
isRequesting={ false }
reviews={ [ { ...REVIEW, verified: true } ] }
createNotice={ () => {} }
/>
);
const header = screen.getByRole( 'heading', { level: 3 } );
@ -104,6 +102,7 @@ describe( 'ReviewsPanel', () => {
isError={ false }
isRequesting={ false }
reviews={ [ REVIEW ] }
createNotice={ () => {} }
/>
);
expect( screen.queryByText( 'Approve' ) ).toBeInTheDocument();
@ -122,6 +121,7 @@ describe( 'ReviewsPanel', () => {
isRequesting={ false }
reviews={ [ REVIEW ] }
updateReview={ clickHandler }
createNotice={ () => {} }
/>
);
fireEvent.click( screen.getByText( 'Approve' ) );
@ -141,6 +141,7 @@ describe( 'ReviewsPanel', () => {
isRequesting={ false }
reviews={ [ REVIEW ] }
updateReview={ clickHandler }
createNotice={ () => {} }
/>
);
fireEvent.click( screen.getByText( 'Mark as spam' ) );
@ -160,6 +161,7 @@ describe( 'ReviewsPanel', () => {
isRequesting={ false }
reviews={ [ REVIEW ] }
deleteReview={ clickHandler }
createNotice={ () => {} }
/>
);
fireEvent.click( screen.getByText( 'Delete' ) );

View File

@ -7,7 +7,7 @@ import { WooNavigationItem } from '@woocommerce/navigation';
const Item = ( { item } ) => {
const slot = useSlot( 'woocommerce_navigation_' + item.id );
const hasFills = Boolean( slot.fills && slot.fills.length );
const hasFills = Boolean( slot?.fills?.length );
const trackClick = ( id ) => {
recordEvent( 'navigation_click', {

View File

@ -2,20 +2,66 @@
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { Button } from '@wordpress/components';
import interpolateComponents from 'interpolate-components';
import { useDispatch, useSelect } from '@wordpress/data';
import { Form, Link, TextControl } from '@woocommerce/components';
import {
Link,
SettingsForm,
WooRemotePaymentSettings,
Spinner,
} from '@woocommerce/components';
import apiFetch from '@wordpress/api-fetch';
import { useEffect, useState } from '@wordpress/element';
import { OPTIONS_STORE_NAME } from '@woocommerce/data';
import { useSlot, Text } from '@woocommerce/experimental';
export const PaymentConnect = ( {
markConfigured,
method,
recordConnectStartEvent,
} ) => {
const { api_details_url: apiDetailsUrl, fields, key, title } = method;
const {
api_details_url: apiDetailsUrl,
fields: fieldsConfig,
key,
title,
} = method;
const { updateOptions } = useDispatch( OPTIONS_STORE_NAME );
const { createNotice } = useDispatch( 'core/notices' );
const slot = useSlot( `woocommerce_remote_payment_settings_${ key }` );
const hasFills = Boolean( slot?.fills?.length );
const [ state, setState ] = useState( 'loading' );
const [ fields, setFields ] = useState( null );
// This transform will be obsolete when we can derive essential fields from the API
const settingsTransform = ( settings ) => {
const essentialFields = fieldsConfig.map( ( field ) => field.name );
return Object.values( settings ).filter( ( setting ) =>
essentialFields.includes( setting.id )
);
};
// TODO: Will soon be replaced with the payments data store implemented in #6918
useEffect( () => {
apiFetch( {
path: `/wc/v3/payment_gateways/${ key }/`,
} )
.then( ( results ) => {
setFields( settingsTransform( results.settings ) );
setState( 'loaded' );
} )
.catch( ( e ) => {
setState( 'error' );
/* eslint-disable no-console */
console.error(
`Error fetching information for payment gateway ${ key }`,
e.message
);
/* eslint-enable no-console */
} );
}, [] );
const isOptionsRequesting = useSelect( ( select ) => {
const { isOptionsUpdating } = select( OPTIONS_STORE_NAME );
@ -23,37 +69,6 @@ export const PaymentConnect = ( {
return isOptionsUpdating();
} );
const getInitialConfigValues = () => {
if ( fields ) {
return fields.reduce( ( data, field ) => {
return {
...data,
[ field.name ]: '',
};
}, {} );
}
};
const validate = ( values ) => {
if ( fields ) {
return fields.reduce( ( errors, field ) => {
if ( ! values[ field.name ] ) {
// Matches any word that is capitalized aside from abrevitions like ID.
const label = field.label.replace(
/([A-Z][a-z]+)/,
( val ) => val.toLowerCase()
);
return {
...errors,
[ field.name ]: __( 'Please enter your ' ) + label,
};
}
return errors;
}, {} );
}
return {};
};
const updateSettings = async ( values ) => {
const options = {};
@ -87,6 +102,26 @@ export const PaymentConnect = ( {
}
};
const validate = ( values ) => {
const errors = {};
const getField = ( fieldId ) =>
fields.find( ( field ) => field.id === fieldId );
for ( const [ valueKey, value ] of Object.entries( values ) ) {
const field = getField( valueKey );
// Matches any word that is capitalized aside from abrevitions like ID.
const label = field.label.replace( /([A-Z][a-z]+)/g, ( val ) =>
val.toLowerCase()
);
if ( ! value ) {
errors[ valueKey ] = `Please enter your ${ label }`;
}
}
return errors;
};
const helpText = interpolateComponents( {
mixedString: __(
'Your API details can be obtained from your {{link/}}',
@ -103,39 +138,51 @@ export const PaymentConnect = ( {
},
} );
return (
<Form
initialValues={ getInitialConfigValues() }
onSubmitCallback={ updateSettings }
const DefaultSettings = ( props ) => (
<SettingsForm
fields={ fields }
isBusy={ isOptionsRequesting }
onSubmit={ updateSettings }
onButtonClick={ () => recordConnectStartEvent( key ) }
buttonLabel={ __( 'Proceed', 'woocommerce-admin' ) }
validate={ validate }
>
{ ( { getInputProps, handleSubmit } ) => {
{ ...props }
/>
);
if ( state === 'error' ) {
return (
<Text>
{
( __( 'There was an error loading the payment fields' ),
'woocommerce-admin' )
}
</Text>
);
}
if ( state === 'loading' ) {
return <Spinner />;
}
return (
<>
{ ( fields || [] ).map( ( field ) => (
<TextControl
key={ field.name }
label={ field.label }
required
{ ...getInputProps( field.name ) }
/>
) ) }
<Button
isPrimary
isBusy={ isOptionsRequesting }
onClick={ ( event ) => {
recordConnectStartEvent( key );
handleSubmit( event );
{ hasFills ? (
<WooRemotePaymentSettings.Slot
fillProps={ {
defaultSettings: DefaultSettings,
defaultSubmit: updateSettings,
defaultFields: fields,
markConfigured: () => markConfigured( key ),
} }
>
{ __( 'Proceed', 'woocommerce-admin' ) }
</Button>
id={ key }
/>
) : (
<>
<DefaultSettings />
<p>{ helpText }</p>
</>
);
} }
</Form>
) }
</>
);
};

View File

@ -9,9 +9,11 @@ import {
PLUGINS_STORE_NAME,
pluginNames,
} from '@woocommerce/data';
import { Plugins, Stepper } from '@woocommerce/components';
import { Plugins, Stepper, WooRemotePayment } from '@woocommerce/components';
import { recordEvent } from '@woocommerce/tracks';
import { useSelect } from '@wordpress/data';
import { useSlot } from '@woocommerce/experimental';
/**
* Internal dependencies
@ -24,7 +26,15 @@ export const PaymentMethod = ( {
method,
recordConnectStartEvent,
} ) => {
const { key, plugins, title } = method;
const {
key,
plugins,
title,
post_install_script: postInstallScript,
} = method;
const slot = useSlot( `woocommerce_remote_payment_${ key }` );
const hasFills = Boolean( slot?.fills?.length );
useEffect( () => {
recordEvent( 'payments_task_stepper_view', {
payment_method: key,
@ -71,6 +81,12 @@ export const PaymentMethod = ( {
recordEvent( 'tasklist_payment_install_method', {
plugins,
} );
if ( postInstallScript ) {
const script = document.createElement( 'script' );
script.src = postInstallScript;
document.body.append( script );
}
} }
onError={ ( errors, response ) =>
createNoticesFromResponse( response )
@ -102,19 +118,31 @@ export const PaymentMethod = ( {
};
}, [ title ] );
const DefaultStepper = ( props ) => (
<Stepper
isVertical
isPending={ ! installStep.isComplete || isOptionsRequesting }
currentStep={ installStep.isComplete ? 'connect' : 'install' }
steps={ [ installStep, connectStep ] }
{ ...props }
/>
);
return (
<Card className="woocommerce-task-payment-method woocommerce-task-card">
<CardBody>
<Stepper
isVertical
isPending={
! installStep.isComplete || isOptionsRequesting
}
currentStep={
installStep.isComplete ? 'connect' : 'install'
}
steps={ [ installStep, connectStep ] }
{ hasFills ? (
<WooRemotePayment.Slot
fillProps={ {
defaultStepper: DefaultStepper,
defaultInstallStep: installStep,
defaultConnectStep: connectStep,
} }
id={ key }
/>
) : (
<DefaultStepper />
) }
</CardBody>
</Card>
);

View File

@ -63,3 +63,6 @@ export { default as useFilters } from './higher-order/use-filters';
export { default as ViewMoreList } from './view-more-list';
export { default as WebPreview } from './web-preview';
export { Badge } from './badge';
export { default as WooRemotePayment } from './woo-remote-payment';
export { default as WooRemotePaymentSettings } from './woo-remote-payment-settings';
export { SettingsForm } from './settings-form';

View File

@ -0,0 +1,44 @@
# SettingsForm
A component to handle form state and provide input helper props.
## Usage
```jsx
const initialValues = { firstName: '' };
<SettingsForm
fields={ fields }
onSubmit={ ( values ) => {
setSubmitted( values );
} }
isBusy={ false }
onButtonClick={ () => {} }
onChange={ () => {} }
validate={ () => ( {} ) }
buttonLabel="Submit"
/>;
```
### Props
| Name | Type | Default | Description |
| --------------- | -------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `fields` | {} or [] | [] | An object to describe the structure and types of all fields, matching the structure returned by the [Settings API](https://docs.woocommerce.com/document/settings-api/) |
| `isBusy` | Boolean | false | Boolean indicating busy state of submit button |
| `onButtonClick` | Function | `noop` | Callback function executed when submit button is clicked (in addition to form submission) |
| `onSubmit` | Function | `noop` | Function to call when a form is submitted with valid fields |
| `onChange` | Function | `noop` | Function to call when any values on the form are changed |
| `validate` | Function | `noop` | A function that is passed a list of all values and should return an `errors` object with error response |
| `buttonLabel` | String | "Proceed" | Label for submit button. |
### Fields structure
Please reference the [WordPress settings API documentation](https://docs.woocommerce.com/document/settings-api/) to better understand the structure expected for the fields property. This component accepts the object returned via the `settings` property when querying a gateway via the API, or simply the array provided by `Object.values(settings)`.
### Currently Supported Types
- Text
- Password
- Checkbox
- Select

View File

@ -0,0 +1,96 @@
/**
* External dependencies
*/
import { Button } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import { Form } from '../index';
import {
SettingText,
SettingPassword,
SettingCheckbox,
SettingSelect,
} from './types';
const typeMap = {
text: SettingText,
password: SettingPassword,
checkbox: SettingCheckbox,
select: SettingSelect,
default: SettingText,
};
export const SettingsForm = ( {
fields: baseFields = [],
isBusy = false,
onSubmit = () => {},
onButtonClick = () => {},
onChange = () => {},
validate = () => ( {} ),
buttonLabel = __( 'Proceed', 'woocommerce-admin' ),
} ) => {
// Support accepting fields in the format provided by the API (object), but transform to Array
const fields =
baseFields instanceof Array ? baseFields : Object.values( baseFields );
const getInitialConfigValues = () => {
if ( fields ) {
return fields.reduce(
( data, field ) => ( {
...data,
[ field.id ]: field.value,
} ),
{}
);
}
};
return (
<Form
initialValues={ getInitialConfigValues() }
onChangeCallback={ onChange }
onSubmitCallback={ onSubmit }
validate={ validate }
>
{ ( { getInputProps, handleSubmit } ) => {
return (
<div className="woocommerce-component-settings">
{ fields.map( ( field ) => {
if ( field.type && ! ( field.type in typeMap ) ) {
/* eslint-disable no-console */
console.warn(
`Field type of ${ field.type } not current supported in SettingsForm component`
);
/* eslint-enable no-console */
return null;
}
const Control = typeMap[ field.type || 'default' ];
return (
<Control
key={ field.id }
field={ field }
{ ...getInputProps( field.id ) }
/>
);
} ) }
<Button
isPrimary
isBusy={ isBusy }
onClick={ ( event ) => {
handleSubmit( event );
onButtonClick();
} }
>
{ buttonLabel }
</Button>
</div>
);
} }
</Form>
);
};

View File

@ -0,0 +1,98 @@
/**
* External dependencies
*/
import { SettingsForm } from '@woocommerce/components';
import { useState } from '@wordpress/element';
const fields = [
{
id: 'user_name',
label: 'Username',
description: 'This is your username.',
type: 'text',
value: '',
default: '',
tip: 'This is your username.',
placeholder: '',
},
{
id: 'pass_phrase',
label: 'Passphrase',
description:
'* Required. Needed to ensure the data passed through is secure.',
type: 'password',
value: '',
default: '',
tip: '* Required. Needed to ensure the data passed through is secure.',
placeholder: '',
},
{
id: 'button_type',
label: 'Button Type',
description: 'Select the button type you would like to show.',
type: 'select',
value: 'buy',
default: 'buy',
tip: 'Select the button type you would like to show.',
placeholder: '',
options: {
default: 'Default',
buy: 'Buy',
donate: 'Donate',
branded: 'Branded',
custom: 'Custom',
},
},
{
id: 'checkbox_sample',
label: 'Checkbox style',
description: 'This is an example checkbox field.',
type: 'checkbox',
value: 'yes',
default: 'yes',
tip: 'This is an example checkbox field.',
placeholder: '',
},
];
const getField = ( fieldId ) =>
fields.find( ( field ) => field.id === fieldId );
const validate = ( values ) => {
const errors = {};
for ( const [ key, value ] of Object.entries( values ) ) {
const field = getField( key );
if ( ! value ) {
errors[ key ] =
field.type === 'checkbox'
? 'This is required'
: `Please enter your ${ field.label.toLowerCase() }`;
}
}
return errors;
};
const SettingsExample = () => {
const [ submitted, setSubmitted ] = useState( null );
return (
<>
<SettingsForm
fields={ fields }
onSubmit={ ( values ) => setSubmitted( values ) }
validate={ validate }
/>
<h4>Submitted:</h4>
<p>{ submitted ? JSON.stringify( submitted, null, 3 ) : 'None' }</p>
</>
);
};
export const Basic = () => <SettingsExample />;
export default {
title: 'WooCommerce Admin/components/SettingsForm',
component: SettingsForm,
};

View File

@ -0,0 +1,13 @@
.woocommerce-component-settings {
.components-base-control {
margin-top: $gap;
margin-bottom: $gap;
position: relative;
&.has-error {
.components-base-control__help {
left: 0 !important;
}
}
}
}

View File

@ -0,0 +1,4 @@
export { SettingText } from './setting-text';
export { SettingPassword } from './setting-password';
export { SettingCheckbox } from './setting-checkbox';
export { SettingSelect } from './setting-select';

View File

@ -0,0 +1,17 @@
/**
* External dependencies
*/
import { CheckboxControl } from '@wordpress/components';
export const SettingCheckbox = ( { field, ...props } ) => {
const { label, id, description } = field;
return (
<CheckboxControl
title={ description }
key={ id }
label={ label }
{ ...props }
/>
);
};

View File

@ -0,0 +1,8 @@
/**
* Internal dependencies
*/
import { SettingText } from './setting-text';
export const SettingPassword = ( props ) => {
return <SettingText { ...props } type="password" />;
};

View File

@ -0,0 +1,38 @@
/**
* External dependencies
*/
import { useMemo } from '@wordpress/element';
/**
* Internal dependencies
*/
import { SelectControl } from '../../index';
const transformOptions = ( options ) => {
return Object.keys( options ).reduce( ( all, curr ) => {
all.push( {
key: curr,
label: options[ curr ],
value: { id: curr },
} );
return all;
}, [] );
};
export const SettingSelect = ( { field, ...props } ) => {
const { description, id, label, options = {} } = field;
const transformedOptions = useMemo( () => transformOptions( options ), [
options,
] );
return (
<SelectControl
title={ description }
label={ label }
key={ id }
options={ transformedOptions }
{ ...props }
/>
);
};

View File

@ -0,0 +1,18 @@
/**
* Internal dependencies
*/
import { TextControl } from '../../index';
export const SettingText = ( { field, type = 'text', ...props } ) => {
const { id, label, description } = field;
return (
<TextControl
type={ type }
title={ description }
key={ id }
label={ label }
{ ...props }
/>
);
};

View File

@ -39,3 +39,4 @@
@import 'view-more-list/style.scss';
@import 'web-preview/style.scss';
@import 'badge/style.scss';
@import 'settings-form/style.scss';

View File

@ -0,0 +1,32 @@
# WooRemotePaymentSettings Slot & Fill
A Slotfill component that will replace the <Settings /> component involved in displaying the form while adding a gateway via the payment task.
## Usage
```jsx
<WooRemotePaymentSettings id={ key }>
{({defaultSettings: DefaultSettings}) => <p>Fill Content</p>}
</WooRemotePaymentSettings>
<WooRemotePaymentSettings.Slot id={ key } />
```
### WooRemotePaymentSettings (fill)
This is the fill component. You must provide the `id` prop to identify the slot that this will occupy. If you provide a function as the child of your fill (as shown above), you will receive some helper props to assist in creating your fill:
| Name | Type | Description |
| ----------------- | --------- | --------------------------------------------------------------------------------------------------------- |
| `defaultSettings` | Component | The default instance of the <SettingsForm> component. Any provided props will override the given defaults |
| `defaultSubmit` | Function | The default submit handler that is provided to the <Form> component |
| `defaultFields` | Array | An array of the field configuration objects provided by the API |
| `markConfigured` | Function | A helper function that will mark your gateway as configured |
### WooRemotePaymentSettings.Slot (slot)
This is the slot component, and will not be used as frequently. It must also receive the required `id` prop that will be identical to the fill `id`.
| Name | Type | Description |
| ----------- | ------ | ---------------------------------------------------------------------------------- |
| `fillProps` | Object | The props that will be provided to the fills, by default these are described above |

View File

@ -0,0 +1,17 @@
/**
* External dependencies
*/
import { Slot, Fill } from '@wordpress/components';
const WooRemotePaymentSettings = ( { id, ...props } ) => (
<Fill name={ 'woocommerce_remote_payment_settings_' + id } { ...props } />
);
WooRemotePaymentSettings.Slot = ( { id, fillProps } ) => (
<Slot
name={ 'woocommerce_remote_payment_settings_' + id }
fillProps={ fillProps }
/>
);
export default WooRemotePaymentSettings;

View File

@ -0,0 +1,31 @@
# WooRemotePayment Slot & Fill
A Slotfill component that will replace the <Stepper /> involved in the installation for a gateway via the payment task.
## Usage
```jsx
<WooRemotePayment id={ key }>
{({defaultStepper: DefaultStepper}) => <p>Fill Content</p>}
</WooRemotePayment>
<WooRemotePayment.Slot id={ key } />
```
### WooRemotePayment (fill)
This is the fill component. You must provide the `id` prop to identify the slot that this will occupy. If you provide a function as the child of your fill (as shown above), you will receive some helper props to assist in creating your fill:
| Name | Type | Description |
| -------------------- | --------- | ---------------------------------------------------------------------------------------------------- |
| `defaultStepper` | Component | The default instance of the <Stepper> component. Any provided props will override the given defaults |
| `defaultInstallStep` | Object | The object that describes the default step configuration for installation of the gateway |
| `defaultConnectStep` | Object | The object that describes the default step configuration for configuration of the gateway |
### WooRemotePayment.Slot (slot)
This is the slot component, and will not be used as frequently. It must also receive the required `id` prop that will be identical to the fill `id`.
| Name | Type | Description |
| ----------- | ------ | ---------------------------------------------------------------------------------- |
| `fillProps` | Object | The props that will be provided to the fills, by default these are described above |

View File

@ -0,0 +1,14 @@
/**
* External dependencies
*/
import { Slot, Fill } from '@wordpress/components';
const WooRemotePayment = ( { id, ...props } ) => (
<Fill name={ 'woocommerce_remote_payment_' + id } { ...props } />
);
WooRemotePayment.Slot = ( { id, fillProps } ) => (
<Slot name={ 'woocommerce_remote_payment_' + id } fillProps={ fillProps } />
);
export default WooRemotePayment;

View File

@ -74,7 +74,7 @@ Release and roadmap notes are available on the [WooCommerce Developers Blog](htt
== Changelog ==
== Unreleased ==
- Enhancement: Adding Slotfills for remote payments and SettingsForm component. #6932
- Fix: Make `Search` accept synchronous `autocompleter.options`. #6884
- Add: Consume remote payment methods on frontend #6867
- Add: Add plugin installer to allow installation of plugins via URL #6805

View File

@ -0,0 +1,23 @@
/**
* External dependencies
*/
import { screen } from '@testing-library/react';
/**
* For use with react-testing-library, like getText but allows the text to reside in multiple elements
*
* @param {Object} query - Original query.
*
* @return {Array} - Array of two arrays, first including truthy values, and second including falsy.
*/
const withMarkup = ( query ) => ( text ) =>
query( ( content, node ) => {
const hasText = ( domNode ) => domNode.textContent === text;
const childrenDontHaveText = Array.from( node.children ).every(
( child ) => ! hasText( child )
);
return hasText( node ) && childrenDontHaveText;
} );
export const getByTextWithMarkup = withMarkup( screen.getByText );