diff --git a/plugins/woocommerce-admin/client/homescreen/activity-panel/reviews/test/index.js b/plugins/woocommerce-admin/client/homescreen/activity-panel/reviews/test/index.js
index b3559a832a4..d75c3a15cc0 100644
--- a/plugins/woocommerce-admin/client/homescreen/activity-panel/reviews/test/index.js
+++ b/plugins/woocommerce-admin/client/homescreen/activity-panel/reviews/test/index.js
@@ -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' ) );
diff --git a/plugins/woocommerce-admin/client/navigation/components/Item/index.js b/plugins/woocommerce-admin/client/navigation/components/Item/index.js
index fccc608f7a5..ff47b180221 100644
--- a/plugins/woocommerce-admin/client/navigation/components/Item/index.js
+++ b/plugins/woocommerce-admin/client/navigation/components/Item/index.js
@@ -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', {
diff --git a/plugins/woocommerce-admin/client/task-list/tasks/payments/RemotePayments/components/PaymentConnect.js b/plugins/woocommerce-admin/client/task-list/tasks/payments/RemotePayments/components/PaymentConnect.js
index 4d9c6fbc6df..cde2506dcba 100644
--- a/plugins/woocommerce-admin/client/task-list/tasks/payments/RemotePayments/components/PaymentConnect.js
+++ b/plugins/woocommerce-admin/client/task-list/tasks/payments/RemotePayments/components/PaymentConnect.js
@@ -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 (
-
+ if ( state === 'loading' ) {
+ return ;
+ }
+
+ return (
+ <>
+ { hasFills ? (
+ markConfigured( key ),
+ } }
+ id={ key }
+ />
+ ) : (
+ <>
+
+ { helpText }
+ >
+ ) }
+ >
);
};
diff --git a/plugins/woocommerce-admin/client/task-list/tasks/payments/RemotePayments/components/PaymentMethod.js b/plugins/woocommerce-admin/client/task-list/tasks/payments/RemotePayments/components/PaymentMethod.js
index 3c5eab666b2..ec29eee5782 100644
--- a/plugins/woocommerce-admin/client/task-list/tasks/payments/RemotePayments/components/PaymentMethod.js
+++ b/plugins/woocommerce-admin/client/task-list/tasks/payments/RemotePayments/components/PaymentMethod.js
@@ -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 ) => (
+
+ );
+
return (
-
+ { hasFills ? (
+
+ ) : (
+
+ ) }
);
diff --git a/plugins/woocommerce-admin/packages/components/src/index.js b/plugins/woocommerce-admin/packages/components/src/index.js
index a7f3d4ca144..b07839d35dd 100644
--- a/plugins/woocommerce-admin/packages/components/src/index.js
+++ b/plugins/woocommerce-admin/packages/components/src/index.js
@@ -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';
diff --git a/plugins/woocommerce-admin/packages/components/src/settings-form/README.md b/plugins/woocommerce-admin/packages/components/src/settings-form/README.md
new file mode 100644
index 00000000000..ec9a78a529a
--- /dev/null
+++ b/plugins/woocommerce-admin/packages/components/src/settings-form/README.md
@@ -0,0 +1,44 @@
+# SettingsForm
+
+A component to handle form state and provide input helper props.
+
+## Usage
+
+```jsx
+const initialValues = { firstName: '' };
+
+ {
+ 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
diff --git a/plugins/woocommerce-admin/packages/components/src/settings-form/index.js b/plugins/woocommerce-admin/packages/components/src/settings-form/index.js
new file mode 100644
index 00000000000..da256df3c53
--- /dev/null
+++ b/plugins/woocommerce-admin/packages/components/src/settings-form/index.js
@@ -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 (
+
+ );
+};
diff --git a/plugins/woocommerce-admin/packages/components/src/settings-form/stories/index.js b/plugins/woocommerce-admin/packages/components/src/settings-form/stories/index.js
new file mode 100644
index 00000000000..ea8a7007698
--- /dev/null
+++ b/plugins/woocommerce-admin/packages/components/src/settings-form/stories/index.js
@@ -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 (
+ <>
+ setSubmitted( values ) }
+ validate={ validate }
+ />
+ Submitted:
+ { submitted ? JSON.stringify( submitted, null, 3 ) : 'None' }
+ >
+ );
+};
+
+export const Basic = () => ;
+
+export default {
+ title: 'WooCommerce Admin/components/SettingsForm',
+ component: SettingsForm,
+};
diff --git a/plugins/woocommerce-admin/packages/components/src/settings-form/style.scss b/plugins/woocommerce-admin/packages/components/src/settings-form/style.scss
new file mode 100644
index 00000000000..df8ad8755cb
--- /dev/null
+++ b/plugins/woocommerce-admin/packages/components/src/settings-form/style.scss
@@ -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;
+ }
+ }
+ }
+}
diff --git a/plugins/woocommerce-admin/packages/components/src/settings-form/types/index.js b/plugins/woocommerce-admin/packages/components/src/settings-form/types/index.js
new file mode 100644
index 00000000000..8ec30026aff
--- /dev/null
+++ b/plugins/woocommerce-admin/packages/components/src/settings-form/types/index.js
@@ -0,0 +1,4 @@
+export { SettingText } from './setting-text';
+export { SettingPassword } from './setting-password';
+export { SettingCheckbox } from './setting-checkbox';
+export { SettingSelect } from './setting-select';
diff --git a/plugins/woocommerce-admin/packages/components/src/settings-form/types/setting-checkbox.js b/plugins/woocommerce-admin/packages/components/src/settings-form/types/setting-checkbox.js
new file mode 100644
index 00000000000..73f56e418ba
--- /dev/null
+++ b/plugins/woocommerce-admin/packages/components/src/settings-form/types/setting-checkbox.js
@@ -0,0 +1,17 @@
+/**
+ * External dependencies
+ */
+import { CheckboxControl } from '@wordpress/components';
+
+export const SettingCheckbox = ( { field, ...props } ) => {
+ const { label, id, description } = field;
+
+ return (
+
+ );
+};
diff --git a/plugins/woocommerce-admin/packages/components/src/settings-form/types/setting-password.js b/plugins/woocommerce-admin/packages/components/src/settings-form/types/setting-password.js
new file mode 100644
index 00000000000..1d5c820cb2e
--- /dev/null
+++ b/plugins/woocommerce-admin/packages/components/src/settings-form/types/setting-password.js
@@ -0,0 +1,8 @@
+/**
+ * Internal dependencies
+ */
+import { SettingText } from './setting-text';
+
+export const SettingPassword = ( props ) => {
+ return ;
+};
diff --git a/plugins/woocommerce-admin/packages/components/src/settings-form/types/setting-select.js b/plugins/woocommerce-admin/packages/components/src/settings-form/types/setting-select.js
new file mode 100644
index 00000000000..d7f5531e312
--- /dev/null
+++ b/plugins/woocommerce-admin/packages/components/src/settings-form/types/setting-select.js
@@ -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 (
+
+ );
+};
diff --git a/plugins/woocommerce-admin/packages/components/src/settings-form/types/setting-text.js b/plugins/woocommerce-admin/packages/components/src/settings-form/types/setting-text.js
new file mode 100644
index 00000000000..23c43ac3aaa
--- /dev/null
+++ b/plugins/woocommerce-admin/packages/components/src/settings-form/types/setting-text.js
@@ -0,0 +1,18 @@
+/**
+ * Internal dependencies
+ */
+import { TextControl } from '../../index';
+
+export const SettingText = ( { field, type = 'text', ...props } ) => {
+ const { id, label, description } = field;
+
+ return (
+
+ );
+};
diff --git a/plugins/woocommerce-admin/packages/components/src/style.scss b/plugins/woocommerce-admin/packages/components/src/style.scss
index af7df1e4600..1b8a1886c56 100644
--- a/plugins/woocommerce-admin/packages/components/src/style.scss
+++ b/plugins/woocommerce-admin/packages/components/src/style.scss
@@ -39,3 +39,4 @@
@import 'view-more-list/style.scss';
@import 'web-preview/style.scss';
@import 'badge/style.scss';
+@import 'settings-form/style.scss';
diff --git a/plugins/woocommerce-admin/packages/components/src/woo-remote-payment-settings/README.md b/plugins/woocommerce-admin/packages/components/src/woo-remote-payment-settings/README.md
new file mode 100644
index 00000000000..aa9611a1eae
--- /dev/null
+++ b/plugins/woocommerce-admin/packages/components/src/woo-remote-payment-settings/README.md
@@ -0,0 +1,32 @@
+# WooRemotePaymentSettings Slot & Fill
+
+A Slotfill component that will replace the component involved in displaying the form while adding a gateway via the payment task.
+
+## Usage
+
+```jsx
+
+ {({defaultSettings: DefaultSettings}) => Fill Content
}
+
+
+
+```
+
+### 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 component. Any provided props will override the given defaults |
+| `defaultSubmit` | Function | The default submit handler that is provided to the