first commit

This commit is contained in:
Rachit Bhargava
2023-07-21 17:12:10 -04:00
parent d0fe47dde4
commit 5d0f0734d8
14003 changed files with 2829464 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
export const namespace = 'woocommerce-google-analytics';
export const actionPrefix = 'experimental__woocommerce_blocks';

View File

@@ -0,0 +1,304 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { addAction } from '@wordpress/hooks';
import type {
ProductResponseItem,
CartResponseItem,
StoreCart,
} from '@woocommerce/types';
/**
* Internal dependencies
*/
import { namespace, actionPrefix } from './constants';
import {
getProductFieldObject,
getProductImpressionObject,
trackEvent,
trackCheckoutStep,
trackCheckoutOption,
} from './utils';
/**
* Track customer progress through steps of the checkout. Triggers the event when the step changes:
* 1 - Contact information
* 2 - Shipping address
* 3 - Billing address
* 4 - Shipping options
* 5 - Payment options
*
* @summary Track checkout progress with begin_checkout and checkout_progress
* @see https://developers.google.com/analytics/devguides/collection/gtagjs/enhanced-ecommerce#1_measure_checkout_steps
*/
addAction(
`${ actionPrefix }-checkout-render-checkout-form`,
namespace,
trackCheckoutStep( 0 )
);
addAction(
`${ actionPrefix }-checkout-set-email-address`,
namespace,
trackCheckoutStep( 1 )
);
addAction(
`${ actionPrefix }-checkout-set-shipping-address`,
namespace,
trackCheckoutStep( 2 )
);
addAction(
`${ actionPrefix }-checkout-set-billing-address`,
namespace,
trackCheckoutStep( 3 )
);
addAction(
`${ actionPrefix }-checkout-set-phone-number`,
namespace,
( { step, ...rest }: { step: string; storeCart: StoreCart } ): void => {
trackCheckoutStep( step === 'shipping' ? 2 : 3 )( rest );
}
);
/**
* Choose a shipping rate
*
* @summary Track the shipping rate being set using set_checkout_option
* @see https://developers.google.com/analytics/devguides/collection/gtagjs/enhanced-ecommerce#2_measure_checkout_options
*/
addAction(
`${ actionPrefix }-checkout-set-selected-shipping-rate`,
namespace,
( { shippingRateId }: { shippingRateId: string } ): void => {
trackCheckoutOption( {
step: 4,
option: __( 'Shipping Method', 'woo-gutenberg-products-block' ),
value: shippingRateId,
} )();
}
);
/**
* Choose a payment method
*
* @summary Track the payment method being set using set_checkout_option
* @see https://developers.google.com/analytics/devguides/collection/gtagjs/enhanced-ecommerce#2_measure_checkout_options
*/
addAction(
`${ actionPrefix }-checkout-set-active-payment-method`,
namespace,
( { paymentMethodSlug }: { paymentMethodSlug: string } ): void => {
trackCheckoutOption( {
step: 5,
option: __( 'Payment Method', 'woo-gutenberg-products-block' ),
value: paymentMethodSlug,
} )();
}
);
/**
* Add Payment Information
*
* This event signifies a user has submitted their payment information. Note, this is used to indicate checkout
* submission, not `purchase` which is triggered on the thanks page.
*
* @summary Track the add_payment_info event
* @see https://developers.google.com/gtagjs/reference/ga4-events#add_payment_info
*/
addAction( `${ actionPrefix }-checkout-submit`, namespace, (): void => {
trackEvent( 'add_payment_info' );
} );
/**
* Add to cart.
*
* This event signifies that an item was added to a cart for purchase.
*
* @summary Track the add_to_cart event
* @see https://developers.google.com/gtagjs/reference/ga4-events#add_to_cart
*/
addAction(
`${ actionPrefix }-cart-add-item`,
namespace,
( {
product,
quantity = 1,
}: {
product: ProductResponseItem;
quantity: number;
} ): void => {
trackEvent( 'add_to_cart', {
event_category: 'ecommerce',
event_label: __( 'Add to Cart', 'woo-gutenberg-products-block' ),
items: [ getProductFieldObject( product, quantity ) ],
} );
}
);
/**
* Remove item from the cart
*
* @summary Track the remove_from_cart event
* @see https://developers.google.com/gtagjs/reference/ga4-events#remove_from_cart
*/
addAction(
`${ actionPrefix }-cart-remove-item`,
namespace,
( {
product,
quantity = 1,
}: {
product: CartResponseItem;
quantity: number;
} ): void => {
trackEvent( 'remove_from_cart', {
event_category: 'ecommerce',
event_label: __(
'Remove Cart Item',
'woo-gutenberg-products-block'
),
items: [ getProductFieldObject( product, quantity ) ],
} );
}
);
/**
* Change cart item quantities
*
* @summary Custom change_cart_quantity event.
*/
addAction(
`${ actionPrefix }-cart-set-item-quantity`,
namespace,
( {
product,
quantity = 1,
}: {
product: CartResponseItem;
quantity: number;
} ): void => {
trackEvent( 'change_cart_quantity', {
event_category: 'ecommerce',
event_label: __(
'Change Cart Item Quantity',
'woo-gutenberg-products-block'
),
items: [ getProductFieldObject( product, quantity ) ],
} );
}
);
/**
* Product List View
*
* @summary Track the view_item_list event
* @see https://developers.google.com/gtagjs/reference/ga4-events#view_item_list
*/
addAction(
`${ actionPrefix }-product-list-render`,
namespace,
( {
products,
listName = __( 'Product List', 'woo-gutenberg-products-block' ),
}: {
products: Array< ProductResponseItem >;
listName: string;
} ): void => {
if ( products.length === 0 ) {
return;
}
trackEvent( 'view_item_list', {
event_category: 'engagement',
event_label: __(
'Viewing products',
'woo-gutenberg-products-block'
),
items: products.map( ( product, index ) => ( {
...getProductImpressionObject( product, listName ),
list_position: index + 1,
} ) ),
} );
}
);
/**
* Product View Link Clicked
*
* @summary Track the select_content event
* @see https://developers.google.com/gtagjs/reference/ga4-events#select_content
*/
addAction(
`${ actionPrefix }-product-view-link`,
namespace,
( {
product,
listName,
}: {
product: ProductResponseItem;
listName: string;
} ): void => {
trackEvent( 'select_content', {
content_type: 'product',
items: [ getProductImpressionObject( product, listName ) ],
} );
}
);
/**
* Product Search
*
* @summary Track the search event
* @see https://developers.google.com/gtagjs/reference/ga4-events#search
*/
addAction(
`${ actionPrefix }-product-search`,
namespace,
( { searchTerm }: { searchTerm: string } ): void => {
trackEvent( 'search', {
search_term: searchTerm,
} );
}
);
/**
* Single Product View
*
* @summary Track the view_item event
* @see https://developers.google.com/gtagjs/reference/ga4-events#view_item
*/
addAction(
`${ actionPrefix }-product-render`,
namespace,
( {
product,
listName,
}: {
product: ProductResponseItem;
listName: string;
} ): void => {
if ( product ) {
trackEvent( 'view_item', {
items: [ getProductImpressionObject( product, listName ) ],
} );
}
}
);
/**
* Track notices as Exception events.
*
* @summary Track the exception event
* @see https://developers.google.com/analytics/devguides/collection/gtagjs/exceptions
*/
addAction(
`${ actionPrefix }-store-notice-create`,
namespace,
( { status, content }: { status: string; content: string } ): void => {
if ( status === 'error' ) {
trackEvent( 'exception', {
description: content,
fatal: false,
} );
}
}
);

View File

@@ -0,0 +1,118 @@
/**
* External dependencies
*/
import type {
ProductResponseItem,
CartResponseItem,
StoreCart,
} from '@woocommerce/types';
interface ImpressionItem extends Gtag.Item {
list_name?: string;
}
/**
* Formats data into the productFieldObject shape.
*
* @see https://developers.google.com/analytics/devguides/collection/gtagjs/enhanced-ecommerce#product-data
*/
export const getProductFieldObject = (
product: ProductResponseItem | CartResponseItem,
quantity: number | undefined
): Gtag.Item => {
const productIdentifier = product.sku ? product.sku : '#' + product.id;
const productCategory =
'categories' in product && product.categories.length
? product.categories[ 0 ].name
: '';
return {
id: productIdentifier,
name: product.name,
quantity,
category: productCategory,
price: (
parseInt( product.prices.price, 10 ) /
10 ** product.prices.currency_minor_unit
).toString(),
};
};
/**
* Formats data into the impressionFieldObject shape.
*
* @see https://developers.google.com/analytics/devguides/collection/gtagjs/enhanced-ecommerce#impression-data
*/
export const getProductImpressionObject = (
product: ProductResponseItem,
listName: string
): ImpressionItem => {
const productIdentifier = product.sku ? product.sku : '#' + product.id;
const productCategory = product.categories.length
? product.categories[ 0 ].name
: '';
return {
id: productIdentifier,
name: product.name,
list_name: listName,
category: productCategory,
price: (
parseInt( product.prices.price, 10 ) /
10 ** product.prices.currency_minor_unit
).toString(),
};
};
/**
* Track an event using the global gtag function.
*/
export const trackEvent = (
eventName: Gtag.EventNames | string,
eventParams?: Gtag.ControlParams | Gtag.EventParams | Gtag.CustomParams
): void => {
if ( typeof gtag !== 'function' ) {
throw new Error( 'Function gtag not implemented.' );
}
// eslint-disable-next-line no-console
console.log( `Tracking event ${ eventName }` );
window.gtag( 'event', eventName, eventParams );
};
let currentStep = -1;
export const trackCheckoutStep =
( step: number ) =>
( { storeCart }: { storeCart: StoreCart } ): void => {
if ( currentStep === step ) {
return;
}
trackEvent( step === 0 ? 'begin_checkout' : 'checkout_progress', {
items: storeCart.cartItems.map( getProductFieldObject ),
coupon: storeCart.cartCoupons[ 0 ]?.code || '',
currency: storeCart.cartTotals.currency_code,
value: (
parseInt( storeCart.cartTotals.total_price, 10 ) /
10 ** storeCart.cartTotals.currency_minor_unit
).toString(),
checkout_step: step,
} );
currentStep = step;
};
export const trackCheckoutOption =
( {
step,
option,
value,
}: {
step: number;
option: string;
value: string;
} ) =>
(): void => {
trackEvent( 'set_checkout_option', {
checkout_step: step,
checkout_option: option,
value,
} );
currentStep = step;
};

View File

@@ -0,0 +1 @@
export const PAYMENT_METHOD_NAME = 'bacs';

View File

@@ -0,0 +1,53 @@
/**
* External dependencies
*/
import { registerPaymentMethod } from '@woocommerce/blocks-registry';
import { __ } from '@wordpress/i18n';
import { getSetting } from '@woocommerce/settings';
import { decodeEntities } from '@wordpress/html-entities';
/**
* Internal dependencies
*/
import { PAYMENT_METHOD_NAME } from './constants';
const settings = getSetting( 'bacs_data', {} );
const defaultLabel = __(
'Direct bank transfer',
'woocommerce'
);
const label = decodeEntities( settings.title ) || defaultLabel;
/**
* Content component
*/
const Content = () => {
return decodeEntities( settings.description || '' );
};
/**
* Label component
*
* @param {*} props Props from payment API.
*/
const Label = ( props ) => {
const { PaymentMethodLabel } = props.components;
return <PaymentMethodLabel text={ label } />;
};
/**
* Bank transfer (BACS) payment method config object.
*/
const bankTransferPaymentMethod = {
name: PAYMENT_METHOD_NAME,
label: <Label />,
content: <Content />,
edit: <Content />,
canMakePayment: () => true,
ariaLabel: label,
supports: {
features: settings?.supports ?? [],
},
};
registerPaymentMethod( bankTransferPaymentMethod );

View File

@@ -0,0 +1 @@
export const PAYMENT_METHOD_NAME = 'cheque';

View File

@@ -0,0 +1,50 @@
/**
* External dependencies
*/
import { registerPaymentMethod } from '@woocommerce/blocks-registry';
import { __ } from '@wordpress/i18n';
import { getSetting } from '@woocommerce/settings';
import { decodeEntities } from '@wordpress/html-entities';
/**
* Internal dependencies
*/
import { PAYMENT_METHOD_NAME } from './constants';
const settings = getSetting( 'cheque_data', {} );
const defaultLabel = __( 'Check payment', 'woocommerce' );
const label = decodeEntities( settings.title ) || defaultLabel;
/**
* Content component
*/
const Content = () => {
return decodeEntities( settings.description || '' );
};
/**
* Label component
*
* @param {*} props Props from payment API.
*/
const Label = ( props ) => {
const { PaymentMethodLabel } = props.components;
return <PaymentMethodLabel text={ label } />;
};
/**
* Cheque payment method config object.
*/
const offlineChequePaymentMethod = {
name: PAYMENT_METHOD_NAME,
label: <Label />,
content: <Content />,
edit: <Content />,
canMakePayment: () => true,
ariaLabel: label,
supports: {
features: settings?.supports ?? [],
},
};
registerPaymentMethod( offlineChequePaymentMethod );

View File

@@ -0,0 +1 @@
export const PAYMENT_METHOD_NAME = 'cod';

View File

@@ -0,0 +1,83 @@
/**
* External dependencies
*/
import { registerPaymentMethod } from '@woocommerce/blocks-registry';
import { __ } from '@wordpress/i18n';
import { getSetting } from '@woocommerce/settings';
import { decodeEntities } from '@wordpress/html-entities';
/**
* Internal dependencies
*/
import { PAYMENT_METHOD_NAME } from './constants';
const settings = getSetting( 'cod_data', {} );
const defaultLabel = __( 'Cash on delivery', 'woocommerce' );
const label = decodeEntities( settings.title ) || defaultLabel;
/**
* Content component
*/
const Content = () => {
return decodeEntities( settings.description || '' );
};
/**
* Label component
*
* @param {*} props Props from payment API.
*/
const Label = ( props ) => {
const { PaymentMethodLabel } = props.components;
return <PaymentMethodLabel text={ label } />;
};
/**
* Determine whether COD is available for this cart/order.
*
* @param {Object} props Incoming props for the component.
* @param {boolean} props.cartNeedsShipping True if the cart contains any physical/shippable products.
* @param {boolean} props.selectedShippingMethods
*
* @return {boolean} True if COD payment method should be displayed as a payment option.
*/
const canMakePayment = ( { cartNeedsShipping, selectedShippingMethods } ) => {
if ( ! settings.enableForVirtual && ! cartNeedsShipping ) {
// Store doesn't allow COD for virtual orders AND
// order doesn't contain any shippable products.
return false;
}
if ( ! settings.enableForShippingMethods.length ) {
// Store does not limit COD to specific shipping methods.
return true;
}
// Look for a supported shipping method in the user's selected
// shipping methods. If one is found, then COD is allowed.
const selectedMethods = Object.values( selectedShippingMethods );
// supported shipping methods might be global (eg. "Any flat rate"), hence
// this is doing a `String.prototype.includes` match vs a `Array.prototype.includes` match.
return settings.enableForShippingMethods.some( ( shippingMethodId ) => {
return selectedMethods.some( ( selectedMethod ) => {
return selectedMethod.includes( shippingMethodId );
} );
} );
};
/**
* Cash on Delivery (COD) payment method config object.
*/
const cashOnDeliveryPaymentMethod = {
name: PAYMENT_METHOD_NAME,
label: <Label />,
content: <Content />,
edit: <Content />,
canMakePayment,
ariaLabel: label,
supports: {
features: settings?.supports ?? [],
},
};
registerPaymentMethod( cashOnDeliveryPaymentMethod );

View File

@@ -0,0 +1 @@
export const PAYMENT_METHOD_NAME = 'paypal';

View File

@@ -0,0 +1,49 @@
/**
* External dependencies
*/
import { registerPaymentMethod } from '@woocommerce/blocks-registry';
import { __ } from '@wordpress/i18n';
import { getSetting, WC_ASSET_URL } from '@woocommerce/settings';
import { decodeEntities } from '@wordpress/html-entities';
/**
* Internal dependencies
*/
import { PAYMENT_METHOD_NAME } from './constants';
const settings = getSetting( 'paypal_data', {} );
/**
* Content component
*/
const Content = () => {
return decodeEntities( settings.description || '' );
};
const paypalPaymentMethod = {
name: PAYMENT_METHOD_NAME,
label: (
<img
src={ `${ WC_ASSET_URL }/images/paypal.png` }
alt={ decodeEntities(
settings.title || __( 'PayPal', 'woocommerce' )
) }
/>
),
placeOrderButtonLabel: __(
'Proceed to PayPal',
'woocommerce'
),
content: <Content />,
edit: <Content />,
canMakePayment: () => true,
ariaLabel: decodeEntities(
settings.title ||
__( 'Payment via PayPal', 'woocommerce' )
),
supports: {
features: settings.supports ?? [],
},
};
registerPaymentMethod( paypalPaymentMethod );

View File

@@ -0,0 +1,181 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { SelectControl, TextControl } from '@wordpress/components';
/**
* Internal dependencies
*/
import type { PickupLocation } from '../types';
import { countryStateOptions, states } from '../utils';
const Form = ( {
formRef,
values,
setValues,
}: {
formRef: React.RefObject< HTMLFormElement >;
values: PickupLocation;
setValues: React.Dispatch< React.SetStateAction< PickupLocation > >;
} ) => {
const { country: selectedCountry, state: selectedState } = values.address;
const setLocationField =
( field: keyof PickupLocation ) => ( newValue: string | boolean ) => {
setValues( ( prevValue: PickupLocation ) => ( {
...prevValue,
[ field ]: newValue,
} ) );
};
const setLocationAddressField =
( field: keyof PickupLocation[ 'address' ] ) =>
( newValue: string | boolean ) => {
setValues( ( prevValue ) => ( {
...prevValue,
address: {
...prevValue.address,
[ field ]: newValue,
},
} ) );
};
const countryHasStates =
states[ selectedCountry ] &&
Object.keys( states[ selectedCountry ] ).length > 0;
return (
<form ref={ formRef }>
<TextControl
label={ __( 'Location name', 'woo-gutenberg-products-block' ) }
name={ 'location_name' }
value={ values.name }
onChange={ setLocationField( 'name' ) }
autoComplete="off"
required={ true }
onInvalid={ (
event: React.InvalidEvent< HTMLInputElement >
) => {
event.target.setCustomValidity(
__(
'A Location title is required',
'woo-gutenberg-products-block'
)
);
} }
onInput={ ( event: React.ChangeEvent< HTMLInputElement > ) => {
event.target.setCustomValidity( '' );
} }
/>
<TextControl
label={ __( 'Address', 'woo-gutenberg-products-block' ) }
name={ 'location_address' }
placeholder={ __( 'Address', 'woo-gutenberg-products-block' ) }
value={ values.address.address_1 }
onChange={ setLocationAddressField( 'address_1' ) }
autoComplete="off"
/>
<TextControl
label={ __( 'City', 'woo-gutenberg-products-block' ) }
name={ 'location_city' }
hideLabelFromVision={ true }
placeholder={ __( 'City', 'woo-gutenberg-products-block' ) }
value={ values.address.city }
onChange={ setLocationAddressField( 'city' ) }
autoComplete="off"
/>
<TextControl
label={ __( 'Postcode / ZIP', 'woo-gutenberg-products-block' ) }
name={ 'location_postcode' }
hideLabelFromVision={ true }
placeholder={ __(
'Postcode / ZIP',
'woo-gutenberg-products-block'
) }
value={ values.address.postcode }
onChange={ setLocationAddressField( 'postcode' ) }
autoComplete="off"
/>
{ ! countryHasStates && (
<TextControl
placeholder={ __(
'State',
'woo-gutenberg-products-block'
) }
value={ selectedState }
onChange={ setLocationAddressField( 'state' ) }
/>
) }
<SelectControl
name="location_country_state"
label={ __(
'Country / State',
'woo-gutenberg-products-block'
) }
hideLabelFromVision={ true }
placeholder={ __(
'Country / State',
'woo-gutenberg-products-block'
) }
value={ ( () => {
if ( ! selectedState && countryHasStates ) {
return `${ selectedCountry }:${
Object.keys( states[ selectedCountry ] )[ 0 ]
}`;
}
return `${ selectedCountry }${
selectedState &&
states[ selectedCountry ]?.[ selectedState ]
? ':' + selectedState
: ''
}`;
} )() }
onChange={ ( val: string ) => {
const [ country, state = '' ] = val.split( ':' );
setLocationAddressField( 'country' )( country );
setLocationAddressField( 'state' )( state );
} }
>
{ countryStateOptions.options.map( ( option ) => {
if ( option.label ) {
return (
<optgroup
key={ option.label }
label={ option.label }
>
{ option.options.map( ( subOption ) => (
<option
key={ subOption.value }
value={ subOption.value }
>
{ subOption.label }
</option>
) ) }
</optgroup>
);
}
return (
<option
key={ option.options[ 0 ].value }
value={ option.options[ 0 ].value }
>
{ option.options[ 0 ].label }
</option>
);
} ) }
</SelectControl>
<TextControl
label={ __( 'Pickup details', 'woo-gutenberg-products-block' ) }
name={ 'pickup_details' }
value={ values.details }
onChange={ setLocationField( 'details' ) }
autoComplete="off"
/>
</form>
);
};
export default Form;

View File

@@ -0,0 +1,94 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Button } from '@wordpress/components';
import { useRef, useState } from '@wordpress/element';
import type { UniqueIdentifier } from '@dnd-kit/core';
/**
* Internal dependencies
*/
import { SettingsModal } from '../../shared-components';
import Form from './form';
import type { PickupLocation } from '../types';
const EditLocation = ( {
locationData,
editingLocation,
onClose,
onSave,
onDelete,
}: {
locationData: PickupLocation | null;
editingLocation: UniqueIdentifier | 'new';
onClose: () => void;
onSave: ( location: PickupLocation ) => void;
onDelete: () => void;
} ): JSX.Element | null => {
const formRef = useRef( null );
const [ values, setValues ] = useState< PickupLocation >(
locationData as PickupLocation
);
if ( ! locationData ) {
return null;
}
return (
<SettingsModal
onRequestClose={ onClose }
title={
editingLocation === 'new'
? __( 'Pickup location', 'woo-gutenberg-products-block' )
: __(
'Edit pickup location',
'woo-gutenberg-products-block'
)
}
actions={
<>
{ editingLocation !== 'new' && (
<Button
variant="link"
className="button-link-delete"
onClick={ () => {
onDelete();
onClose();
} }
>
{ __(
'Delete location',
'woo-gutenberg-products-block'
) }
</Button>
) }
<Button variant="secondary" onClick={ onClose }>
{ __( 'Cancel', 'woo-gutenberg-products-block' ) }
</Button>
<Button
variant="primary"
onClick={ () => {
const form =
formRef?.current as unknown as HTMLFormElement;
if ( form.reportValidity() ) {
onSave( values );
onClose();
}
} }
>
{ __( 'Done', 'woo-gutenberg-products-block' ) }
</Button>
</>
}
>
<Form
formRef={ formRef }
values={ values }
setValues={ setValues }
/>
</SettingsModal>
);
};
export default EditLocation;

View File

@@ -0,0 +1,194 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { createInterpolateElement, useState } from '@wordpress/element';
import { ADMIN_URL } from '@woocommerce/settings';
import { CHECKOUT_PAGE_ID } from '@woocommerce/block-settings';
import {
CheckboxControl,
SelectControl,
TextControl,
ExternalLink,
Notice,
} from '@wordpress/components';
import styled from '@emotion/styled';
/**
* Internal dependencies
*/
import { SettingsCard, SettingsSection } from '../shared-components';
import { useSettingsContext } from './settings-context';
const GeneralSettingsDescription = () => (
<>
<h2>{ __( 'General', 'woo-gutenberg-products-block' ) }</h2>
<p>
{ __(
'Enable or disable local pickup on your store, and define costs. Local pickup is only available from the block checkout.',
'woo-gutenberg-products-block'
) }
</p>
<ExternalLink
href={ `${ ADMIN_URL }post.php?post=${ CHECKOUT_PAGE_ID }&action=edit` }
>
{ __( 'View checkout page', 'woo-gutenberg-products-block' ) }
</ExternalLink>
</>
);
const StyledNotice = styled( Notice )`
margin-left: 0;
margin-right: 0;
`;
const GeneralSettings = () => {
const { settings, setSettingField, readOnlySettings } =
useSettingsContext();
const [ showCosts, setShowCosts ] = useState( !! settings.cost );
return (
<SettingsSection Description={ GeneralSettingsDescription }>
<SettingsCard>
{ readOnlySettings.hasLegacyPickup && (
<StyledNotice status="warning" isDismissible={ false }>
{ createInterpolateElement(
__(
'Enabling this will produce duplicate options at checkout. Remove the local pickup shipping method from your <a>shipping zones</a>.',
'woo-gutenberg-products-block'
),
{
a: (
// eslint-disable-next-line jsx-a11y/anchor-has-content
<a
href={ `${ ADMIN_URL }admin.php?page=wc-settings&tab=shipping` }
target="_blank"
rel="noopener noreferrer"
/>
),
}
) }
</StyledNotice>
) }
<CheckboxControl
checked={ settings.enabled }
name="local_pickup_enabled"
onChange={ setSettingField( 'enabled' ) }
label={ __(
'Enable local pickup',
'woo-gutenberg-products-block'
) }
help={ __(
'When enabled, local pickup will appear as an option on the block based checkout.',
'woo-gutenberg-products-block'
) }
/>
<TextControl
label={ __( 'Title', 'woo-gutenberg-products-block' ) }
name="local_pickup_title"
help={ __(
'This is the shipping method title shown to customers.',
'woo-gutenberg-products-block'
) }
placeholder={ __(
'Local Pickup',
'woo-gutenberg-products-block'
) }
value={ settings.title }
onChange={ setSettingField( 'title' ) }
disabled={ false }
autoComplete="off"
required={ true }
onInvalid={ (
event: React.InvalidEvent< HTMLInputElement >
) => {
event.target.setCustomValidity(
__(
'Local pickup title is required',
'woo-gutenberg-products-block'
)
);
} }
onInput={ (
event: React.ChangeEvent< HTMLInputElement >
) => {
event.target.setCustomValidity( '' );
} }
/>
<CheckboxControl
checked={ showCosts }
onChange={ () => {
setShowCosts( ! showCosts );
setSettingField( 'cost' )( '' );
} }
label={ __(
'Add a price for customers who choose local pickup',
'woo-gutenberg-products-block'
) }
help={ __(
'By default, the local pickup shipping method is free.',
'woo-gutenberg-products-block'
) }
/>
{ showCosts ? (
<>
<TextControl
label={ __(
'Cost',
'woo-gutenberg-products-block'
) }
name="local_pickup_cost"
help={ __(
'Optional cost to charge for local pickup.',
'woo-gutenberg-products-block'
) }
placeholder={ __(
'Free',
'woo-gutenberg-products-block'
) }
type="number"
pattern="[0-9]+\.?[0-9]*"
min={ 0 }
value={ settings.cost }
onChange={ setSettingField( 'cost' ) }
disabled={ false }
autoComplete="off"
/>
<SelectControl
label={ __(
'Taxes',
'woo-gutenberg-products-block'
) }
name="local_pickup_tax_status"
help={ __(
'If a cost is defined, this controls if taxes are applied to that cost.',
'woo-gutenberg-products-block'
) }
options={ [
{
label: __(
'Taxable',
'woo-gutenberg-products-block'
),
value: 'taxable',
},
{
label: __(
'Not taxable',
'woo-gutenberg-products-block'
),
value: 'none',
},
] }
value={ settings.tax_status }
onChange={ setSettingField( 'tax_status' ) }
disabled={ false }
/>
</>
) : null }
</SettingsCard>
</SettingsSection>
);
};
export default GeneralSettings;

View File

@@ -0,0 +1,16 @@
/**
* External dependencies
*/
import { render } from '@wordpress/element';
/**
* Internal dependencies
*/
import SettingsPage from './settings-page';
const settingsContainer = document.getElementById(
'wc-shipping-method-pickup-location-settings-container'
);
if ( settingsContainer ) {
render( <SettingsPage />, settingsContainer );
}

View File

@@ -0,0 +1,165 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useState } from '@wordpress/element';
import type { UniqueIdentifier } from '@dnd-kit/core';
import { isBoolean } from '@woocommerce/types';
import { ToggleControl, Button, ExternalLink } from '@wordpress/components';
import styled from '@emotion/styled';
/**
* Internal dependencies
*/
import {
SettingsSection,
SortableTable,
SortableData,
} from '../shared-components';
import EditLocation from './edit-location';
import type { SortablePickupLocation } from './types';
import { useSettingsContext } from './settings-context';
import { getUserFriendlyAddress } from './utils';
const LocationSettingsDescription = () => (
<>
<h2>{ __( 'Pickup locations', 'woo-gutenberg-products-block' ) }</h2>
<p>
{ __(
'Define pickup locations for your customers to choose from during checkout.',
'woo-gutenberg-products-block'
) }
</p>
<ExternalLink href="https://woocommerce.com/document/local-pickup/">
{ __( 'Learn more', 'woo-gutenberg-products-block' ) }
</ExternalLink>
</>
);
const StyledAddress = styled.address`
color: #757575;
font-style: normal;
display: inline;
margin-left: 12px;
`;
const LocationSettings = () => {
const {
pickupLocations,
setPickupLocations,
toggleLocation,
updateLocation,
readOnlySettings,
} = useSettingsContext();
const [ editingLocation, setEditingLocation ] =
useState< UniqueIdentifier >( '' );
const tableColumns = [
{
name: 'name',
label: __( 'Pickup location', 'woo-gutenberg-products-block' ),
width: '50%',
renderCallback: ( row: SortableData ): JSX.Element => (
<>
{ row.name }
<StyledAddress>
{ getUserFriendlyAddress( row.address ) }
</StyledAddress>
</>
),
},
{
name: 'enabled',
label: __( 'Enabled', 'woo-gutenberg-products-block' ),
align: 'right',
renderCallback: ( row: SortableData ): JSX.Element => (
<ToggleControl
checked={ isBoolean( row.enabled ) ? row.enabled : false }
onChange={ () => toggleLocation( row.id ) }
/>
),
},
{
name: 'edit',
label: '',
align: 'center',
width: '1%',
renderCallback: ( row: SortableData ): JSX.Element => (
<button
type="button"
className="button-link-edit button-link"
onClick={ () => {
setEditingLocation( row.id );
} }
>
{ __( 'Edit', 'woo-gutenberg-products-block' ) }
</button>
),
},
];
const FooterContent = (): JSX.Element => (
<Button
variant="secondary"
onClick={ () => {
setEditingLocation( 'new' );
} }
>
{ __( 'Add pickup location', 'woo-gutenberg-products-block' ) }
</Button>
);
return (
<SettingsSection Description={ LocationSettingsDescription }>
<SortableTable
className="pickup-locations"
columns={ tableColumns }
data={ pickupLocations }
setData={ ( newData ) => {
setPickupLocations( newData as SortablePickupLocation[] );
} }
placeholder={ __(
'When you add a pickup location, it will appear here.',
'woo-gutenberg-products-block'
) }
footerContent={ FooterContent }
/>
{ editingLocation && (
<EditLocation
locationData={
editingLocation === 'new'
? {
name: '',
details: '',
enabled: true,
address: {
address_1: '',
city: '',
state: readOnlySettings.storeState,
postcode: '',
country: readOnlySettings.storeCountry,
},
}
: pickupLocations.find( ( { id } ) => {
return id === editingLocation;
} ) || null
}
editingLocation={ editingLocation }
onSave={ ( values ) => {
updateLocation(
editingLocation,
values as SortablePickupLocation
);
} }
onClose={ () => setEditingLocation( '' ) }
onDelete={ () => {
updateLocation( editingLocation, null );
setEditingLocation( '' );
} }
/>
) }
</SettingsSection>
);
};
export default LocationSettings;

View File

@@ -0,0 +1,46 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import styled from '@emotion/styled';
import { Button } from '@wordpress/components';
/**
* Internal dependencies
*/
import { SettingsSection } from '../shared-components';
import { useSettingsContext } from './settings-context';
const SaveSectionWrapper = styled( SettingsSection )`
text-align: right;
padding-top: 0;
margin-top: 0;
`;
const SaveSettings = () => {
const { isSaving, save } = useSettingsContext();
return (
<SaveSectionWrapper className={ 'submit' }>
<Button
variant="primary"
isBusy={ isSaving }
disabled={ isSaving }
onClick={ (
event: React.MouseEvent< HTMLButtonElement, MouseEvent >
) => {
event.preventDefault();
const target = event.target as HTMLButtonElement;
if ( target?.form?.reportValidity() ) {
save();
}
} }
type="submit"
>
{ __( 'Save changes', 'woo-gutenberg-products-block' ) }
</Button>
</SaveSectionWrapper>
);
};
export default SaveSettings;

View File

@@ -0,0 +1,175 @@
/**
* External dependencies
*/
import {
createContext,
useContext,
useCallback,
useState,
} from '@wordpress/element';
import { cleanForSlug } from '@wordpress/url';
import type { UniqueIdentifier } from '@dnd-kit/core';
import apiFetch from '@wordpress/api-fetch';
import { dispatch } from '@wordpress/data';
import { __ } from '@wordpress/i18n';
import { isEqual } from 'lodash';
/**
* Internal dependencies
*/
import type {
SortablePickupLocation,
SettingsContextType,
ShippingMethodSettings,
} from './types';
import {
defaultSettings,
getInitialSettings,
defaultReadyOnlySettings,
readOnlySettings,
getInitialPickupLocations,
} from './utils';
const SettingsContext = createContext< SettingsContextType >( {
settings: defaultSettings,
readOnlySettings: defaultReadyOnlySettings,
setSettingField: () => () => void null,
pickupLocations: [],
setPickupLocations: () => void null,
toggleLocation: () => void null,
updateLocation: () => void null,
isSaving: false,
save: () => void null,
} );
export const useSettingsContext = (): SettingsContextType => {
return useContext( SettingsContext );
};
export const SettingsProvider = ( {
children,
}: {
children: JSX.Element[] | JSX.Element;
} ): JSX.Element => {
const [ isSaving, setIsSaving ] = useState( false );
const [ pickupLocations, setPickupLocations ] = useState<
SortablePickupLocation[]
>( getInitialPickupLocations );
const [ settings, setSettings ] =
useState< ShippingMethodSettings >( getInitialSettings );
const setSettingField = useCallback(
( field: keyof ShippingMethodSettings ) => ( newValue: unknown ) => {
setSettings( ( prevValue ) => ( {
...prevValue,
[ field ]: newValue,
} ) );
},
[]
);
const toggleLocation = useCallback( ( rowId: UniqueIdentifier ) => {
setPickupLocations( ( previousLocations: SortablePickupLocation[] ) => {
const locationIndex = previousLocations.findIndex(
( { id } ) => id === rowId
);
const updated = [ ...previousLocations ];
updated[ locationIndex ].enabled =
! previousLocations[ locationIndex ].enabled;
return updated;
} );
}, [] );
const updateLocation = (
rowId: UniqueIdentifier | 'new',
locationData: SortablePickupLocation
) => {
setPickupLocations( ( prevData ) => {
if ( rowId === 'new' ) {
return [
...prevData,
{
...locationData,
id:
cleanForSlug( locationData.name ) +
'-' +
prevData.length,
},
];
}
return prevData
.map( ( location ): SortablePickupLocation => {
if ( location.id === rowId ) {
return locationData;
}
return location;
} )
.filter( Boolean );
} );
};
const save = useCallback( () => {
const data = {
pickup_location_settings: {
enabled: settings.enabled ? 'yes' : 'no',
title: settings.title,
tax_status: [ 'taxable', 'none' ].includes(
settings.tax_status
)
? settings.tax_status
: 'taxable',
cost: settings.cost,
},
pickup_locations: pickupLocations.map( ( location ) => ( {
name: location.name,
address: location.address,
details: location.details,
enabled: location.enabled,
} ) ),
};
setIsSaving( true );
// @todo This should be improved to include error handling in case of API failure, or invalid data being sent that
// does not match the schema. This would fail silently on the API side.
apiFetch( {
path: '/wp/v2/settings',
method: 'POST',
data,
} ).then( ( response ) => {
setIsSaving( false );
if (
isEqual(
response.pickup_location_settings,
data.pickup_location_settings
) &&
isEqual( response.pickup_locations, data.pickup_locations )
) {
dispatch( 'core/notices' ).createSuccessNotice(
__(
'Local Pickup settings have been saved.',
'woo-gutenberg-products-block'
)
);
}
} );
}, [ settings, pickupLocations ] );
const settingsData = {
settings,
setSettingField,
readOnlySettings,
pickupLocations,
setPickupLocations,
toggleLocation,
updateLocation,
isSaving,
save,
};
return (
<SettingsContext.Provider value={ settingsData }>
{ children }
</SettingsContext.Provider>
);
};

View File

@@ -0,0 +1,37 @@
/**
* External dependencies
*/
import styled from '@emotion/styled';
/**
* Internal dependencies
*/
import GeneralSettings from './general-settings';
import LocationSettings from './location-settings';
import SaveSettings from './save';
import { SettingsProvider } from './settings-context';
const SettingsWrapper = styled.form`
margin: 48px auto 0;
max-width: 1032px;
display: flex;
flex-flow: column;
@media ( min-width: 960px ) {
padding: 0 56px;
}
`;
const SettingsPage = () => {
return (
<SettingsWrapper id="local-pickup-settings">
<SettingsProvider>
<GeneralSettings />
<LocationSettings />
<SaveSettings />
</SettingsProvider>
</SettingsWrapper>
);
};
export default SettingsPage;

View File

@@ -0,0 +1,54 @@
/**
* External dependencies
*/
import type { UniqueIdentifier } from '@dnd-kit/core';
/**
* Internal dependencies
*/
import type { SortableData } from '../shared-components';
export interface PickupLocation {
name: string;
details: string;
enabled: boolean;
address: {
address_1: string;
city: string;
state: string;
postcode: string;
country: string;
};
}
export interface SortablePickupLocation extends PickupLocation, SortableData {}
export type ShippingMethodSettings = {
enabled: boolean;
title: string;
tax_status: string;
cost: string;
};
export type ReadOnlySettings = {
storeCountry: string;
storeState: string;
hasLegacyPickup: boolean;
};
export type SettingsContextType = {
settings: ShippingMethodSettings;
readOnlySettings: ReadOnlySettings;
setSettingField: (
field: keyof ShippingMethodSettings
) => ( value: unknown ) => void;
pickupLocations: SortablePickupLocation[];
setPickupLocations: ( locations: SortablePickupLocation[] ) => void;
toggleLocation: ( rowId: UniqueIdentifier ) => void;
updateLocation: (
rowId: UniqueIdentifier | 'new',
location: SortablePickupLocation | null
) => void;
isSaving: boolean;
save: () => void;
};

View File

@@ -0,0 +1,127 @@
/**
* External dependencies
*/
import { cleanForSlug } from '@wordpress/url';
import { __ } from '@wordpress/i18n';
import { isObject } from '@woocommerce/types';
import { getSetting } from '@woocommerce/settings';
/**
* Internal dependencies
*/
import type {
PickupLocation,
SortablePickupLocation,
ShippingMethodSettings,
} from './types';
export const indexLocationsById = (
locations: PickupLocation[]
): SortablePickupLocation[] => {
return locations.map( ( value, index ) => {
return {
...value,
id: cleanForSlug( value.name ) + '-' + index,
};
} );
};
export const defaultSettings = {
enabled: false,
title: __( 'Local Pickup', 'woo-gutenberg-products-block' ),
tax_status: 'taxable',
cost: '',
};
export const defaultReadyOnlySettings = {
hasLegacyPickup: false,
storeCountry: '',
storeState: '',
};
declare global {
const hydratedScreenSettings: {
pickupLocationSettings: {
enabled: string;
title: string;
tax_status: string;
cost: string;
};
pickupLocations: PickupLocation[];
readonlySettings: typeof defaultReadyOnlySettings;
};
}
export const getInitialSettings = (): ShippingMethodSettings => {
const settings = hydratedScreenSettings.pickupLocationSettings;
return {
enabled: settings?.enabled
? settings?.enabled === 'yes'
: defaultSettings.enabled,
title: settings?.title || defaultSettings.title,
tax_status: settings?.tax_status || defaultSettings.tax_status,
cost: settings?.cost || defaultSettings.cost,
};
};
export const getInitialPickupLocations = (): SortablePickupLocation[] =>
indexLocationsById( hydratedScreenSettings.pickupLocations || [] );
export const readOnlySettings =
hydratedScreenSettings.readonlySettings || defaultReadyOnlySettings;
export const countries = getSetting< Record< string, string > >(
'countries',
[]
);
export const states = getSetting< Record< string, Record< string, string > > >(
'countryStates',
[]
);
export const getUserFriendlyAddress = ( address: unknown ) => {
const updatedAddress = isObject( address ) && {
...address,
country:
typeof address.country === 'string' && countries[ address.country ],
state:
typeof address.country === 'string' &&
typeof address.state === 'string' &&
states[ address.country ]?.[ address.state ]
? states[ address.country ][ address.state ]
: address.state,
};
return Object.values( updatedAddress )
.filter( ( value ) => value !== '' )
.join( ', ' );
};
// Outputs the list of countries and states in a single dropdown select.
const countryStateDropdownOptions = () => {
const countryStateOptions = Object.keys( countries ).map( ( country ) => {
const countryStates = states[ country ] || {};
if ( Object.keys( countryStates ).length === 0 ) {
return {
options: [
{
value: country,
label: countries[ country ],
},
],
};
}
const stateOptions = Object.keys( countryStates ).map( ( state ) => ( {
value: `${ country }:${ state }`,
label: `${ countries[ country ] }${ countryStates[ state ] }`,
} ) );
return {
label: countries[ country ],
options: [ ...stateOptions ],
};
} );
return {
options: countryStateOptions,
};
};
export const countryStateOptions = countryStateDropdownOptions();

View File

@@ -0,0 +1,4 @@
export { default as SettingsCard } from './settings-card';
export { default as SettingsSection } from './settings-section';
export { default as SettingsModal } from './settings-modal';
export * from './sortable-table';

View File

@@ -0,0 +1,65 @@
/**
* External dependencies
*/
import { Card, CardBody } from '@wordpress/components';
import styled from '@emotion/styled';
import type { ReactNode } from 'react';
const StyledCard = styled( Card )`
border-radius: 3px;
`;
const StyledCardBody = styled( CardBody )`
padding: 24px;
// increasing the specificity of the styles to override the Gutenberg ones
&.is-size-medium.is-size-medium {
padding: 24px;
}
h4 {
margin-top: 0;
margin-bottom: 1em;
}
> * {
margin-top: 0;
margin-bottom: 1.5em;
// fixing the spacing on the inputs and their help text, to ensure it is consistent
&:last-child {
margin-bottom: 0;
> :last-child {
margin-bottom: 0;
}
}
}
input,
select {
margin: 0;
}
// spacing adjustment on "Express checkouts > Show express checkouts on" list
ul > li:last-child {
margin-bottom: 0;
.components-base-control__field {
margin-bottom: 0;
}
}
`;
const SettingsCard = ( {
children,
...props
}: {
children: ReactNode;
} ): JSX.Element => (
<StyledCard>
<StyledCardBody { ...props }>{ children }</StyledCardBody>
</StyledCard>
);
export default SettingsCard;

View File

@@ -0,0 +1,88 @@
/**
* External dependencies
*/
import { Modal } from '@wordpress/components';
import styled from '@emotion/styled';
const StyledModal = styled( Modal )`
max-width: 600px;
border-radius: 4px;
@media ( min-width: 600px ) {
min-width: 560px;
}
.components-modal__header {
padding: 12px 24px;
border-bottom: 1px solid #e0e0e0;
position: relative;
height: auto;
width: auto;
margin: 0 -24px 16px;
@media ( max-width: 599px ) {
button {
display: none;
}
}
}
.components-modal__content {
margin: 0;
padding: 0 24px;
@media ( max-width: 599px ) {
display: flex;
flex-direction: column;
hr:last-of-type {
margin-top: auto;
}
}
.components-base-control {
label {
margin-top: 8px;
text-transform: none !important;
}
}
}
`;
const StyledFooter = styled.div`
display: flex;
justify-content: flex-end;
border-top: 1px solid #e0e0e0;
margin: 24px -24px 0;
padding: 24px;
> * {
&:not( :first-of-type ) {
margin-left: 8px;
}
}
.button-link-delete {
margin-right: auto;
color: #d63638;
}
`;
const SettingsModal = ( {
children,
actions,
title,
onRequestClose,
...props
}: {
children: React.ReactNode;
actions: React.ReactNode;
title: string;
onRequestClose: () => void;
} ): JSX.Element => (
<StyledModal title={ title } onRequestClose={ onRequestClose } { ...props }>
{ children }
<StyledFooter>{ actions }</StyledFooter>
</StyledModal>
);
export default SettingsModal;

View File

@@ -0,0 +1,67 @@
/**
* External dependencies
*/
import React from '@wordpress/element';
import styled from '@emotion/styled';
const StyledSectionWrapper = styled.div`
display: flex;
flex-flow: column;
margin-bottom: 24px;
&:last-child {
margin-bottom: 0;
}
@media ( min-width: 800px ) {
flex-flow: row;
}
.components-base-control {
label {
text-transform: none !important;
}
}
`;
const StyledDescriptionWrapper = styled.div`
flex: 0 1 auto;
margin-bottom: 24px;
@media ( min-width: 800px ) {
flex: 0 0 250px;
margin: 0 32px 0 0;
}
h2 {
font-size: 16px;
line-height: 24px;
}
p {
font-size: 13px;
line-height: 17.89px;
margin: 12px 0;
}
> :last-child {
margin-bottom: 0;
}
`;
const StyledSectionControls = styled.div`
flex: 1 1 auto;
margin-bottom: 12px;
`;
const SettingsSection = ( {
Description = () => null,
children,
...props
}: {
// eslint-disable-next-line @typescript-eslint/naming-convention
Description?: () => JSX.Element | null;
children: React.ReactNode;
} ): JSX.Element => (
<StyledSectionWrapper { ...props }>
<StyledDescriptionWrapper>
<Description />
</StyledDescriptionWrapper>
<StyledSectionControls>{ children }</StyledSectionControls>
</StyledSectionWrapper>
);
export default SettingsSection;

View File

@@ -0,0 +1,300 @@
/**
* External dependencies
*/
import styled from '@emotion/styled';
import { Icon, dragHandle } from '@wordpress/icons';
import { useMemo } from '@wordpress/element';
import {
closestCenter,
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
DragEndEvent,
UniqueIdentifier,
} from '@dnd-kit/core';
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
import {
SortableContext,
verticalListSortingStrategy,
useSortable,
arrayMove,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { objectHasProp } from '@woocommerce/types';
export interface SortableData extends Record< string, unknown > {
id: UniqueIdentifier;
}
type ColumnProps = {
name: string;
label: string;
width?: string;
align?: string;
renderCallback?: ( row: SortableData ) => JSX.Element;
};
const TableRow = ( {
children,
id,
}: {
children: JSX.Element[];
id: UniqueIdentifier;
} ): JSX.Element => {
const { attributes, listeners, transform, transition, setNodeRef } =
useSortable( {
id,
} );
const style = {
transform: CSS.Transform.toString( transform ),
transition,
};
return (
<tr ref={ setNodeRef } style={ style }>
<>
<td style={ { width: '1%' } }>
<Icon
icon={ dragHandle }
size={ 14 }
className={ 'sortable-table__handle' }
{ ...attributes }
{ ...listeners }
/>
</td>
{ children }
</>
</tr>
);
};
const StyledTable = styled.table`
background: #fff;
border: 0;
border-radius: 3px;
box-shadow: 0 0 0 1px rgb( 0 0 0 / 10% );
border-spacing: 0;
width: 100%;
clear: both;
margin: 0;
font-size: 14px;
.align-left {
text-align: left;
.components-flex {
justify-content: flex-start;
gap: 0;
}
}
.align-right {
text-align: right;
.components-flex {
justify-content: flex-end;
gap: 0;
}
}
.align-center {
text-align: center;
> * {
margin: 0 auto;
}
.components-flex {
display: block;
}
}
.sortable-table__handle {
cursor: move;
}
th {
position: relative;
color: #2c3338;
text-align: left;
vertical-align: middle;
vertical-align: top;
word-wrap: break-word;
}
tbody {
td {
vertical-align: top;
margin-bottom: 9px;
}
}
tfoot {
td {
text-align: left;
vertical-align: middle;
}
}
thead,
tfoot,
tbody {
td,
th {
border-top: 1px solid rgb( 0 0 0 / 10% );
border-bottom: 1px solid rgb( 0 0 0 / 10% );
padding: 16px 0 16px 24px;
line-height: 1.5;
&:last-child {
padding-right: 24px;
}
> svg,
> .components-base-control {
margin: 3px 0;
}
}
}
thead th {
border-top: 0;
}
tfoot td {
border-bottom: 0;
}
`;
export const SortableTable = ( {
columns,
data,
setData,
className,
footerContent: FooterContent,
placeholder,
}: {
columns: ColumnProps[];
data: SortableData[];
setData: ( data: SortableData[] ) => void;
className?: string;
placeholder?: string | ( () => JSX.Element );
footerContent?: () => JSX.Element;
} ): JSX.Element => {
const items = useMemo( () => data.map( ( { id } ) => id ), [ data ] );
const sensors = useSensors(
useSensor( MouseSensor, {} ),
useSensor( TouchSensor, {} ),
useSensor( KeyboardSensor, {} )
);
function handleDragEnd( event: DragEndEvent ) {
const { active, over } = event;
if ( active !== null && over !== null && active?.id !== over?.id ) {
const newData = arrayMove(
data,
items.indexOf( active.id ),
items.indexOf( over.id )
);
setData( newData );
}
}
const getColumnProps = ( column: ColumnProps, parentClassName: string ) => {
const align = column?.align || 'left';
const width = column?.width || 'auto';
return {
className: `${ parentClassName }-${ column.name } align-${ align }`,
style: { width },
};
};
return (
<DndContext
sensors={ sensors }
onDragEnd={ handleDragEnd }
collisionDetection={ closestCenter }
modifiers={ [ restrictToVerticalAxis ] }
>
<StyledTable className={ `${ className } sortable-table` }>
<thead>
<tr>
{ columns.map( ( column, index ) => (
<th
key={ column.name }
{ ...getColumnProps(
column,
`sortable-table__column`
) }
colSpan={ index === 0 ? 2 : 1 }
>
{ column.label }
</th>
) ) }
</tr>
</thead>
{ FooterContent && (
<tfoot>
<tr>
<td colSpan={ columns.length + 1 }>
<FooterContent />
</td>
</tr>
</tfoot>
) }
<tbody>
<SortableContext
items={ items }
strategy={ verticalListSortingStrategy }
>
{ !! data.length ? (
data.map(
( row ) =>
row && (
<TableRow
key={ row.id }
id={ row.id }
className={ className }
>
{ columns.map( ( column ) => (
<td
key={ `${ row.id }-${ column.name }` }
{ ...getColumnProps(
column,
`sortable-table__column`
) }
>
{ column.renderCallback ? (
column.renderCallback(
row
)
) : (
<>
{ objectHasProp(
row,
column.name
) &&
row[
column.name
] }
</>
) }
</td>
) ) }
</TableRow>
)
)
) : (
<tr>
<td colSpan={ columns.length + 1 }>
{ placeholder }
</td>
</tr>
) }
</SortableContext>
</tbody>
</StyledTable>
</DndContext>
);
};
export default SortableTable;