no wp
This commit is contained in:
@@ -1,150 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useEffect } from '@wordpress/element';
|
||||
import {
|
||||
useCollection,
|
||||
useQueryStateByKey,
|
||||
} from '@woocommerce/base-context/hooks';
|
||||
import { decodeEntities } from '@wordpress/html-entities';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { getSettingWithCoercion } from '@woocommerce/settings';
|
||||
import {
|
||||
AttributeObject,
|
||||
AttributeTerm,
|
||||
isAttributeQueryCollection,
|
||||
isAttributeTermCollection,
|
||||
isBoolean,
|
||||
} from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { renderRemovableListItem, removeArgsFromFilterUrl } from './utils';
|
||||
import { removeAttributeFilterBySlug } from '../../utils/attributes-query';
|
||||
|
||||
interface ActiveAttributeFiltersProps {
|
||||
displayStyle: string;
|
||||
operator: 'and' | 'in';
|
||||
slugs: string[];
|
||||
attributeObject: AttributeObject;
|
||||
isLoadingCallback: ( val: boolean ) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that renders active attribute (terms) filters.
|
||||
*
|
||||
* @param {Object} props Incoming props for the component.
|
||||
* @param {Object} props.attributeObject The attribute object.
|
||||
* @param {Array} props.slugs The slugs for attributes.
|
||||
* @param {string} props.operator The operator for the filter.
|
||||
* @param {string} props.displayStyle The style used for displaying the filters.
|
||||
* @param {string} props.isLoadingCallback The callback to trigger the loading complete state.
|
||||
*/
|
||||
const ActiveAttributeFilters = ( {
|
||||
attributeObject,
|
||||
slugs = [],
|
||||
operator = 'in',
|
||||
displayStyle,
|
||||
isLoadingCallback,
|
||||
}: ActiveAttributeFiltersProps ) => {
|
||||
const { results, isLoading } = useCollection< AttributeTerm >( {
|
||||
namespace: '/wc/store/v1',
|
||||
resourceName: 'products/attributes/terms',
|
||||
resourceValues: [ attributeObject.id ],
|
||||
} );
|
||||
|
||||
const [ productAttributes, setProductAttributes ] = useQueryStateByKey(
|
||||
'attributes',
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect( () => {
|
||||
isLoadingCallback( isLoading );
|
||||
}, [ isLoading, isLoadingCallback ] );
|
||||
|
||||
if (
|
||||
! Array.isArray( results ) ||
|
||||
! isAttributeTermCollection( results ) ||
|
||||
! isAttributeQueryCollection( productAttributes )
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const attributeLabel = attributeObject.label;
|
||||
|
||||
const filteringForPhpTemplate = getSettingWithCoercion(
|
||||
'isRenderingPhpTemplate',
|
||||
false,
|
||||
isBoolean
|
||||
);
|
||||
|
||||
return (
|
||||
<li>
|
||||
<span className="wc-block-active-filters__list-item-type">
|
||||
{ attributeLabel }:
|
||||
</span>
|
||||
<ul>
|
||||
{ slugs.map( ( slug, index ) => {
|
||||
const termObject = results.find( ( term ) => {
|
||||
return term.slug === slug;
|
||||
} );
|
||||
|
||||
if ( ! termObject ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let prefix: string | JSX.Element = '';
|
||||
|
||||
if ( index > 0 && operator === 'and' ) {
|
||||
prefix = (
|
||||
<span className="wc-block-active-filters__list-item-operator">
|
||||
{ __( 'All', 'woo-gutenberg-products-block' ) }
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return renderRemovableListItem( {
|
||||
type: attributeLabel,
|
||||
name: decodeEntities( termObject.name || slug ),
|
||||
prefix,
|
||||
isLoading,
|
||||
removeCallback: () => {
|
||||
const currentAttribute = productAttributes.find(
|
||||
( { attribute } ) =>
|
||||
attribute === `pa_${ attributeObject.name }`
|
||||
);
|
||||
|
||||
// If only one attribute was selected, we remove both filter and query type from the URL.
|
||||
if ( currentAttribute?.slug.length === 1 ) {
|
||||
removeArgsFromFilterUrl(
|
||||
`query_type_${ attributeObject.name }`,
|
||||
`filter_${ attributeObject.name }`
|
||||
);
|
||||
} else {
|
||||
// Remove only the slug from the URL.
|
||||
removeArgsFromFilterUrl( {
|
||||
[ `filter_${ attributeObject.name }` ]:
|
||||
slug,
|
||||
} );
|
||||
}
|
||||
|
||||
if ( ! filteringForPhpTemplate ) {
|
||||
removeAttributeFilterBySlug(
|
||||
productAttributes,
|
||||
setProductAttributes,
|
||||
attributeObject,
|
||||
slug
|
||||
);
|
||||
}
|
||||
},
|
||||
showLabel: false,
|
||||
displayStyle,
|
||||
} );
|
||||
} ) }
|
||||
</ul>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActiveAttributeFilters;
|
||||
@@ -1,11 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
|
||||
export const blockAttributes = {
|
||||
heading: {
|
||||
type: 'string',
|
||||
default: __( 'Active filters', 'woo-gutenberg-products-block' ),
|
||||
},
|
||||
};
|
||||
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import classnames from 'classnames';
|
||||
import { useStyleProps } from '@woocommerce/base-hooks';
|
||||
import { isString } from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import Block from './block';
|
||||
import { parseAttributes } from './utils';
|
||||
|
||||
const BlockWrapper = ( props: Record< string, unknown > ) => {
|
||||
const styleProps = useStyleProps( props );
|
||||
const parsedBlockAttributes = parseAttributes( props );
|
||||
|
||||
return (
|
||||
<div
|
||||
className={ classnames(
|
||||
isString( props.className ) ? props.className : '',
|
||||
styleProps.className
|
||||
) }
|
||||
style={ styleProps.style }
|
||||
>
|
||||
<Block isEditor={ false } attributes={ parsedBlockAttributes } />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlockWrapper;
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"name": "woocommerce/active-filters",
|
||||
"version": "1.0.0",
|
||||
"title": "Active Filters Controls",
|
||||
"description": "Display the currently active filters.",
|
||||
"category": "woocommerce",
|
||||
"keywords": [ "WooCommerce" ],
|
||||
"supports": {
|
||||
"html": false,
|
||||
"multiple": false,
|
||||
"inserter": false,
|
||||
"color": {
|
||||
"text": true,
|
||||
"background": false
|
||||
},
|
||||
"lock": false
|
||||
},
|
||||
"attributes": {
|
||||
"displayStyle": {
|
||||
"type": "string",
|
||||
"default": "list"
|
||||
},
|
||||
"headingLevel": {
|
||||
"type": "number",
|
||||
"default": 3
|
||||
}
|
||||
},
|
||||
"textdomain": "woocommerce",
|
||||
"apiVersion": 2,
|
||||
"$schema": "https://schemas.wp.org/trunk/block.json"
|
||||
}
|
||||
@@ -1,420 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __, sprintf } from '@wordpress/i18n';
|
||||
import { useQueryStateByKey } from '@woocommerce/base-context/hooks';
|
||||
import { getSetting, getSettingWithCoercion } from '@woocommerce/settings';
|
||||
import { useMemo, useEffect, useState } from '@wordpress/element';
|
||||
import classnames from 'classnames';
|
||||
import { Label } from '@woocommerce/blocks-components';
|
||||
import {
|
||||
isAttributeQueryCollection,
|
||||
isBoolean,
|
||||
isRatingQueryCollection,
|
||||
isStockStatusQueryCollection,
|
||||
isStockStatusOptions,
|
||||
} from '@woocommerce/types';
|
||||
import { getUrlParameter } from '@woocommerce/utils';
|
||||
import FilterTitlePlaceholder from '@woocommerce/base-components/filter-placeholder';
|
||||
import { useIsMounted } from '@woocommerce/base-hooks';
|
||||
import type { BlockAttributes } from '@wordpress/blocks';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './style.scss';
|
||||
import { getAttributeFromTaxonomy } from '../../utils/attributes';
|
||||
import {
|
||||
formatPriceRange,
|
||||
renderRemovableListItem,
|
||||
removeArgsFromFilterUrl,
|
||||
cleanFilterUrl,
|
||||
maybeUrlContainsFilters,
|
||||
urlContainsAttributeFilter,
|
||||
StoreAttributes,
|
||||
} from './utils';
|
||||
import ActiveAttributeFilters from './active-attribute-filters';
|
||||
import FilterPlaceholders from './filter-placeholders';
|
||||
import { useSetWraperVisibility } from '../filter-wrapper/context';
|
||||
|
||||
interface ActiveFiltersBlockProps {
|
||||
/**
|
||||
* The attributes for this block.
|
||||
*/
|
||||
attributes: BlockAttributes;
|
||||
/**
|
||||
* Whether it's in the editor or frontend display.
|
||||
*/
|
||||
isEditor: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component displaying active filters.
|
||||
*/
|
||||
const ActiveFiltersBlock = ( {
|
||||
attributes: blockAttributes,
|
||||
isEditor = false,
|
||||
}: ActiveFiltersBlockProps ) => {
|
||||
const setWrapperVisibility = useSetWraperVisibility();
|
||||
const isMounted = useIsMounted();
|
||||
const componentHasMounted = isMounted();
|
||||
const filteringForPhpTemplate = getSettingWithCoercion(
|
||||
'isRenderingPhpTemplate',
|
||||
false,
|
||||
isBoolean
|
||||
);
|
||||
const [ isLoading, setIsLoading ] = useState( true );
|
||||
/*
|
||||
activeAttributeFilters is the only async query in this block. Because of this the rest of the filters will render null
|
||||
when in a loading state and activeAttributeFilters renders the placeholders.
|
||||
*/
|
||||
const shouldShowLoadingPlaceholders =
|
||||
maybeUrlContainsFilters() && ! isEditor && isLoading;
|
||||
const [ productAttributes, setProductAttributes ] = useQueryStateByKey(
|
||||
'attributes',
|
||||
[]
|
||||
);
|
||||
const [ productStockStatus, setProductStockStatus ] = useQueryStateByKey(
|
||||
'stock_status',
|
||||
[]
|
||||
);
|
||||
const [ minPrice, setMinPrice ] = useQueryStateByKey( 'min_price' );
|
||||
const [ maxPrice, setMaxPrice ] = useQueryStateByKey( 'max_price' );
|
||||
|
||||
const [ productRatings, setProductRatings ] =
|
||||
useQueryStateByKey( 'rating' );
|
||||
|
||||
const STOCK_STATUS_OPTIONS = getSetting( 'stockStatusOptions', [] );
|
||||
const STORE_ATTRIBUTES: StoreAttributes[] = getSetting( 'attributes', [] );
|
||||
const activeStockStatusFilters = useMemo( () => {
|
||||
if (
|
||||
shouldShowLoadingPlaceholders ||
|
||||
productStockStatus.length === 0 ||
|
||||
! isStockStatusQueryCollection( productStockStatus ) ||
|
||||
! isStockStatusOptions( STOCK_STATUS_OPTIONS )
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stockStatusLabel = __(
|
||||
'Stock Status',
|
||||
'woo-gutenberg-products-block'
|
||||
);
|
||||
|
||||
return (
|
||||
<li>
|
||||
<span className="wc-block-active-filters__list-item-type">
|
||||
{ stockStatusLabel }:
|
||||
</span>
|
||||
<ul>
|
||||
{ productStockStatus.map( ( slug ) => {
|
||||
return renderRemovableListItem( {
|
||||
type: stockStatusLabel,
|
||||
name: STOCK_STATUS_OPTIONS[ slug ],
|
||||
removeCallback: () => {
|
||||
removeArgsFromFilterUrl( {
|
||||
filter_stock_status: slug,
|
||||
} );
|
||||
if ( ! filteringForPhpTemplate ) {
|
||||
const newStatuses =
|
||||
productStockStatus.filter(
|
||||
( status ) => {
|
||||
return status !== slug;
|
||||
}
|
||||
);
|
||||
setProductStockStatus( newStatuses );
|
||||
}
|
||||
},
|
||||
showLabel: false,
|
||||
displayStyle: blockAttributes.displayStyle,
|
||||
} );
|
||||
} ) }
|
||||
</ul>
|
||||
</li>
|
||||
);
|
||||
}, [
|
||||
shouldShowLoadingPlaceholders,
|
||||
STOCK_STATUS_OPTIONS,
|
||||
productStockStatus,
|
||||
setProductStockStatus,
|
||||
blockAttributes.displayStyle,
|
||||
filteringForPhpTemplate,
|
||||
] );
|
||||
|
||||
const activePriceFilters = useMemo( () => {
|
||||
if (
|
||||
shouldShowLoadingPlaceholders ||
|
||||
( ! Number.isFinite( minPrice ) && ! Number.isFinite( maxPrice ) )
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return renderRemovableListItem( {
|
||||
type: __( 'Price', 'woo-gutenberg-products-block' ),
|
||||
name: formatPriceRange( minPrice, maxPrice ),
|
||||
removeCallback: () => {
|
||||
removeArgsFromFilterUrl( 'max_price', 'min_price' );
|
||||
if ( ! filteringForPhpTemplate ) {
|
||||
setMinPrice( undefined );
|
||||
setMaxPrice( undefined );
|
||||
}
|
||||
},
|
||||
displayStyle: blockAttributes.displayStyle,
|
||||
} );
|
||||
}, [
|
||||
shouldShowLoadingPlaceholders,
|
||||
minPrice,
|
||||
maxPrice,
|
||||
blockAttributes.displayStyle,
|
||||
setMinPrice,
|
||||
setMaxPrice,
|
||||
filteringForPhpTemplate,
|
||||
] );
|
||||
|
||||
const activeAttributeFilters = useMemo( () => {
|
||||
if (
|
||||
( ! isAttributeQueryCollection( productAttributes ) &&
|
||||
componentHasMounted ) ||
|
||||
( ! productAttributes.length &&
|
||||
! urlContainsAttributeFilter( STORE_ATTRIBUTES ) )
|
||||
) {
|
||||
if ( isLoading ) {
|
||||
setIsLoading( false );
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return productAttributes.map( ( attribute ) => {
|
||||
const attributeObject = getAttributeFromTaxonomy(
|
||||
attribute.attribute
|
||||
);
|
||||
|
||||
if ( ! attributeObject ) {
|
||||
if ( isLoading ) {
|
||||
setIsLoading( false );
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ActiveAttributeFilters
|
||||
attributeObject={ attributeObject }
|
||||
displayStyle={ blockAttributes.displayStyle }
|
||||
slugs={ attribute.slug }
|
||||
key={ attribute.attribute }
|
||||
operator={ attribute.operator }
|
||||
isLoadingCallback={ setIsLoading }
|
||||
/>
|
||||
);
|
||||
} );
|
||||
}, [
|
||||
productAttributes,
|
||||
componentHasMounted,
|
||||
STORE_ATTRIBUTES,
|
||||
isLoading,
|
||||
blockAttributes.displayStyle,
|
||||
] );
|
||||
|
||||
/**
|
||||
* Parse the filter URL to set the active rating fitlers.
|
||||
* This code should be moved to Rating Filter block once it's implemented.
|
||||
*/
|
||||
useEffect( () => {
|
||||
if ( ! filteringForPhpTemplate ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( productRatings.length && productRatings.length > 0 ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentRatings = getUrlParameter( 'rating_filter' )?.toString();
|
||||
|
||||
if ( ! currentRatings ) {
|
||||
return;
|
||||
}
|
||||
|
||||
setProductRatings( currentRatings.split( ',' ) );
|
||||
}, [ filteringForPhpTemplate, productRatings, setProductRatings ] );
|
||||
|
||||
const activeRatingFilters = useMemo( () => {
|
||||
if (
|
||||
shouldShowLoadingPlaceholders ||
|
||||
productRatings.length === 0 ||
|
||||
! isRatingQueryCollection( productRatings )
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ratingLabel = __( 'Rating', 'woo-gutenberg-products-block' );
|
||||
|
||||
return (
|
||||
<li>
|
||||
<span className="wc-block-active-filters__list-item-type">
|
||||
{ ratingLabel }:
|
||||
</span>
|
||||
<ul>
|
||||
{ productRatings.map( ( slug ) => {
|
||||
return renderRemovableListItem( {
|
||||
type: ratingLabel,
|
||||
name: sprintf(
|
||||
/* translators: %s is referring to the average rating value */
|
||||
__(
|
||||
'Rated %s out of 5',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
slug
|
||||
),
|
||||
removeCallback: () => {
|
||||
removeArgsFromFilterUrl( {
|
||||
rating_filter: slug,
|
||||
} );
|
||||
if ( ! filteringForPhpTemplate ) {
|
||||
const newRatings = productRatings.filter(
|
||||
( rating ) => {
|
||||
return rating !== slug;
|
||||
}
|
||||
);
|
||||
setProductRatings( newRatings );
|
||||
}
|
||||
},
|
||||
showLabel: false,
|
||||
displayStyle: blockAttributes.displayStyle,
|
||||
} );
|
||||
} ) }
|
||||
</ul>
|
||||
</li>
|
||||
);
|
||||
}, [
|
||||
shouldShowLoadingPlaceholders,
|
||||
productRatings,
|
||||
setProductRatings,
|
||||
blockAttributes.displayStyle,
|
||||
filteringForPhpTemplate,
|
||||
] );
|
||||
|
||||
const hasFilters = () => {
|
||||
return (
|
||||
productAttributes.length > 0 ||
|
||||
productStockStatus.length > 0 ||
|
||||
productRatings.length > 0 ||
|
||||
Number.isFinite( minPrice ) ||
|
||||
Number.isFinite( maxPrice )
|
||||
);
|
||||
};
|
||||
|
||||
if ( ! shouldShowLoadingPlaceholders && ! hasFilters() && ! isEditor ) {
|
||||
setWrapperVisibility( false );
|
||||
return null;
|
||||
}
|
||||
|
||||
const TagName =
|
||||
`h${ blockAttributes.headingLevel }` as keyof JSX.IntrinsicElements;
|
||||
|
||||
const heading = (
|
||||
<TagName className="wc-block-active-filters__title">
|
||||
{ blockAttributes.heading }
|
||||
</TagName>
|
||||
);
|
||||
|
||||
const filterHeading = shouldShowLoadingPlaceholders ? (
|
||||
<FilterTitlePlaceholder>{ heading }</FilterTitlePlaceholder>
|
||||
) : (
|
||||
heading
|
||||
);
|
||||
|
||||
const hasFilterableProducts = getSettingWithCoercion(
|
||||
'hasFilterableProducts',
|
||||
false,
|
||||
isBoolean
|
||||
);
|
||||
|
||||
if ( ! hasFilterableProducts ) {
|
||||
setWrapperVisibility( false );
|
||||
return null;
|
||||
}
|
||||
|
||||
setWrapperVisibility( true );
|
||||
|
||||
const listClasses = classnames( 'wc-block-active-filters__list', {
|
||||
'wc-block-active-filters__list--chips':
|
||||
blockAttributes.displayStyle === 'chips',
|
||||
'wc-block-active-filters--loading': shouldShowLoadingPlaceholders,
|
||||
} );
|
||||
|
||||
return (
|
||||
<>
|
||||
{ ! isEditor && blockAttributes.heading && filterHeading }
|
||||
<div className="wc-block-active-filters">
|
||||
<ul className={ listClasses }>
|
||||
{ isEditor ? (
|
||||
<>
|
||||
{ renderRemovableListItem( {
|
||||
type: __(
|
||||
'Size',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
name: __(
|
||||
'Small',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
displayStyle: blockAttributes.displayStyle,
|
||||
} ) }
|
||||
{ renderRemovableListItem( {
|
||||
type: __(
|
||||
'Color',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
name: __(
|
||||
'Blue',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
displayStyle: blockAttributes.displayStyle,
|
||||
} ) }
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FilterPlaceholders
|
||||
isLoading={ shouldShowLoadingPlaceholders }
|
||||
displayStyle={ blockAttributes.displayStyle }
|
||||
/>
|
||||
{ activePriceFilters }
|
||||
{ activeStockStatusFilters }
|
||||
{ activeAttributeFilters }
|
||||
{ activeRatingFilters }
|
||||
</>
|
||||
) }
|
||||
</ul>
|
||||
{ shouldShowLoadingPlaceholders ? (
|
||||
<span className="wc-block-active-filters__clear-all-placeholder" />
|
||||
) : (
|
||||
<button
|
||||
className="wc-block-active-filters__clear-all"
|
||||
onClick={ () => {
|
||||
cleanFilterUrl();
|
||||
if ( ! filteringForPhpTemplate ) {
|
||||
setMinPrice( undefined );
|
||||
setMaxPrice( undefined );
|
||||
setProductAttributes( [] );
|
||||
setProductStockStatus( [] );
|
||||
setProductRatings( [] );
|
||||
}
|
||||
} }
|
||||
>
|
||||
<Label
|
||||
label={ __(
|
||||
'Clear All',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
screenReaderLabel={ __(
|
||||
'Clear All Filters',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
/>
|
||||
</button>
|
||||
) }
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActiveFiltersBlock;
|
||||
@@ -1,45 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useBlockProps } from '@wordpress/block-editor';
|
||||
import classNames from 'classnames';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { blockAttributes } from './attributes';
|
||||
import metadata from './block.json';
|
||||
import { Attributes } from './types';
|
||||
|
||||
const v1 = {
|
||||
attributes: {
|
||||
...metadata.attributes,
|
||||
...blockAttributes,
|
||||
},
|
||||
save: ( { attributes }: { attributes: Attributes } ) => {
|
||||
const { className, displayStyle, heading, headingLevel } = attributes;
|
||||
const data = {
|
||||
'data-display-style': displayStyle,
|
||||
'data-heading': heading,
|
||||
'data-heading-level': headingLevel,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
{ ...useBlockProps.save( {
|
||||
className: classNames( 'is-loading', className ),
|
||||
} ) }
|
||||
{ ...data }
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="wc-block-active-filters__placeholder"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const deprecated = [ v1 ];
|
||||
|
||||
export default deprecated;
|
||||
@@ -1,105 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { InspectorControls, useBlockProps } from '@wordpress/block-editor';
|
||||
import type { BlockEditProps } from '@wordpress/blocks';
|
||||
import BlockTitle from '@woocommerce/editor-components/block-title';
|
||||
import {
|
||||
Disabled,
|
||||
PanelBody,
|
||||
withSpokenMessages,
|
||||
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
|
||||
__experimentalToggleGroupControl as ToggleGroupControl,
|
||||
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
|
||||
__experimentalToggleGroupControlOption as ToggleGroupControlOption,
|
||||
} from '@wordpress/components';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import Block from './block';
|
||||
import type { Attributes } from './types';
|
||||
import './editor.scss';
|
||||
import { UpgradeNotice } from '../filter-wrapper/upgrade';
|
||||
|
||||
const Edit = ( {
|
||||
attributes,
|
||||
setAttributes,
|
||||
clientId,
|
||||
}: BlockEditProps< Attributes > ) => {
|
||||
const { className, displayStyle, heading, headingLevel } = attributes;
|
||||
|
||||
const blockProps = useBlockProps( {
|
||||
className,
|
||||
} );
|
||||
|
||||
const getInspectorControls = () => {
|
||||
return (
|
||||
<InspectorControls key="inspector">
|
||||
<PanelBody
|
||||
title={ __(
|
||||
'Display Settings',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
>
|
||||
<ToggleGroupControl
|
||||
label={ __(
|
||||
'Display Style',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
value={ displayStyle }
|
||||
onChange={ ( value: Attributes[ 'displayStyle' ] ) =>
|
||||
setAttributes( {
|
||||
displayStyle: value,
|
||||
} )
|
||||
}
|
||||
className="wc-block-active-filter__style-toggle"
|
||||
>
|
||||
<ToggleGroupControlOption
|
||||
value="list"
|
||||
label={ __(
|
||||
'List',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
/>
|
||||
<ToggleGroupControlOption
|
||||
value="chips"
|
||||
label={ __(
|
||||
'Chips',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
/>
|
||||
</ToggleGroupControl>
|
||||
</PanelBody>
|
||||
</InspectorControls>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div { ...blockProps }>
|
||||
{ getInspectorControls() }
|
||||
<UpgradeNotice
|
||||
attributes={ attributes }
|
||||
clientId={ clientId }
|
||||
setAttributes={ setAttributes }
|
||||
filterType="active-filters"
|
||||
/>
|
||||
{ heading && (
|
||||
<BlockTitle
|
||||
className="wc-block-active-filters__title"
|
||||
headingLevel={ headingLevel }
|
||||
heading={ heading }
|
||||
onChange={ ( value: Attributes[ 'heading' ] ) =>
|
||||
setAttributes( { heading: value } )
|
||||
}
|
||||
/>
|
||||
) }
|
||||
<Disabled>
|
||||
<Block attributes={ attributes } isEditor={ true } />
|
||||
</Disabled>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default withSpokenMessages( Edit );
|
||||
@@ -1,9 +0,0 @@
|
||||
.wc-block-active-filters {
|
||||
.wc-block-active-filters__list-item-name {
|
||||
margin-top: 9px;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-active-filter__style-toggle {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
const FilterPlaceholders = ( {
|
||||
displayStyle,
|
||||
isLoading,
|
||||
}: {
|
||||
isLoading: boolean;
|
||||
displayStyle: string;
|
||||
} ) => {
|
||||
if ( ! isLoading ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{ [ ...Array( displayStyle === 'list' ? 2 : 3 ) ].map( ( x, i ) => (
|
||||
<li
|
||||
className={
|
||||
displayStyle === 'list'
|
||||
? 'show-loading-state-list'
|
||||
: 'show-loading-state-chips'
|
||||
}
|
||||
key={ i }
|
||||
>
|
||||
<span className="show-loading-state__inner" />
|
||||
</li>
|
||||
) ) }
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterPlaceholders;
|
||||
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { renderFrontend } from '@woocommerce/base-utils';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import Block from './block';
|
||||
import { parseAttributes } from './utils';
|
||||
|
||||
const getProps = ( el: HTMLElement ) => {
|
||||
return {
|
||||
attributes: parseAttributes( el.dataset ),
|
||||
isEditor: false,
|
||||
};
|
||||
};
|
||||
|
||||
renderFrontend( {
|
||||
selector: '.wp-block-woocommerce-active-filters',
|
||||
Block,
|
||||
getProps,
|
||||
} );
|
||||
@@ -1,51 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { registerBlockType } from '@wordpress/blocks';
|
||||
import { toggle } from '@woocommerce/icons';
|
||||
import { Icon } from '@wordpress/icons';
|
||||
import classNames from 'classnames';
|
||||
import { useBlockProps } from '@wordpress/block-editor';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import edit from './edit';
|
||||
import metadata from './block.json';
|
||||
import { blockAttributes } from './attributes';
|
||||
import { Attributes } from './types';
|
||||
import deprecated from './deprecated';
|
||||
|
||||
registerBlockType( metadata, {
|
||||
icon: {
|
||||
src: (
|
||||
<Icon
|
||||
icon={ toggle }
|
||||
className="wc-block-editor-components-block-icon"
|
||||
/>
|
||||
),
|
||||
},
|
||||
attributes: {
|
||||
...metadata.attributes,
|
||||
...blockAttributes,
|
||||
},
|
||||
edit,
|
||||
// Save the props to post content.
|
||||
save( { attributes }: { attributes: Attributes } ) {
|
||||
const { className } = attributes;
|
||||
|
||||
return (
|
||||
<div
|
||||
{ ...useBlockProps.save( {
|
||||
className: classNames( 'is-loading', className ),
|
||||
} ) }
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="wc-block-active-filters__placeholder"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
deprecated,
|
||||
} );
|
||||
@@ -1,196 +0,0 @@
|
||||
.wp-block-woocommerce-active-filters {
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
text-transform: inherit;
|
||||
}
|
||||
|
||||
.wc-block-filter-title-placeholder {
|
||||
.wc-block-active-filters__title {
|
||||
height: 1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-active-filters {
|
||||
margin-bottom: $gap-large;
|
||||
overflow: hidden;
|
||||
|
||||
.wc-block-active-filters__clear-all {
|
||||
@include filter-link-button();
|
||||
@include font-size(small);
|
||||
border: none;
|
||||
margin-top: 15px;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
float: right;
|
||||
|
||||
&,
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-active-filters__clear-all-placeholder {
|
||||
@include placeholder();
|
||||
display: inline-block;
|
||||
width: 80px;
|
||||
height: 1em;
|
||||
float: right;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.wc-block-active-filters__list {
|
||||
margin: 0 0 $gap-smallest;
|
||||
padding: 0;
|
||||
list-style: none outside;
|
||||
clear: both;
|
||||
|
||||
&.wc-block-active-filters--loading {
|
||||
margin-top: $gap-small;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
|
||||
&.wc-block-active-filters__list--chips {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 0 10px;
|
||||
}
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 9px 0 0;
|
||||
padding: 0;
|
||||
list-style: none outside;
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none outside;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
.wc-block-active-filters__list-item-type {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
> li:first-child {
|
||||
margin: 0;
|
||||
}
|
||||
li.show-loading-state-list {
|
||||
display: inline-block;
|
||||
|
||||
> span {
|
||||
@include placeholder();
|
||||
display: inline-block;
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
height: 1em;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
li.show-loading-state-chips {
|
||||
display: inline-block;
|
||||
|
||||
> span {
|
||||
@include placeholder();
|
||||
display: inline-block;
|
||||
box-shadow: none;
|
||||
border-radius: 13px;
|
||||
height: 1em;
|
||||
width: 100%;
|
||||
min-width: 70px;
|
||||
margin-right: 15px !important;
|
||||
}
|
||||
|
||||
&:last-of-type > span {
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
flex-grow: 1;
|
||||
max-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
> .wc-block-active-filters__list-item .wc-block-active-filters__list-item-name {
|
||||
margin: 9px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-active-filters__list-item-type {
|
||||
@include font-size(smaller);
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
margin: $gap 0 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.wc-block-active-filters__list-item-operator {
|
||||
font-weight: normal;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.wc-block-active-filters__list-item-name {
|
||||
@include font-size(small);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.wc-block-active-filters__list-item-remove {
|
||||
@include font-size(smaller);
|
||||
background: $gray-200;
|
||||
border: 0;
|
||||
border-radius: 25px;
|
||||
appearance: none;
|
||||
padding: 0;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
line-height: 16px;
|
||||
margin: 0 0.5em 0 0;
|
||||
color: currentColor;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background: $gray-600;
|
||||
|
||||
.wc-block-components-chip__remove-icon {
|
||||
fill: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: $gray-200;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-active-filters__list--chips {
|
||||
ul,
|
||||
li {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.wc-block-active-filters__list-item-type {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wc-block-components-chip {
|
||||
margin-top: em($gap-small * 0.25);
|
||||
margin-bottom: em($gap-small * 0.25);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export interface Attributes {
|
||||
heading: string;
|
||||
headingLevel: number;
|
||||
displayStyle: string;
|
||||
className?: string;
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __, sprintf } from '@wordpress/i18n';
|
||||
import { formatPrice } from '@woocommerce/price-format';
|
||||
import { Label, RemovableChip } from '@woocommerce/blocks-components';
|
||||
import { getQueryArgs, addQueryArgs, removeQueryArgs } from '@wordpress/url';
|
||||
import { changeUrl } from '@woocommerce/utils';
|
||||
import { Icon, closeSmall } from '@wordpress/icons';
|
||||
import { isString } from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import metadata from './block.json';
|
||||
|
||||
/**
|
||||
* Format a min/max price range to display.
|
||||
*
|
||||
* @param {number} minPrice The min price, if set.
|
||||
* @param {number} maxPrice The max price, if set.
|
||||
*/
|
||||
export const formatPriceRange = ( minPrice: number, maxPrice: number ) => {
|
||||
if ( Number.isFinite( minPrice ) && Number.isFinite( maxPrice ) ) {
|
||||
return sprintf(
|
||||
/* translators: %1$s min price, %2$s max price */
|
||||
__( 'Between %1$s and %2$s', 'woo-gutenberg-products-block' ),
|
||||
formatPrice( minPrice ),
|
||||
formatPrice( maxPrice )
|
||||
);
|
||||
}
|
||||
|
||||
if ( Number.isFinite( minPrice ) ) {
|
||||
return sprintf(
|
||||
/* translators: %s min price */
|
||||
__( 'From %s', 'woo-gutenberg-products-block' ),
|
||||
formatPrice( minPrice )
|
||||
);
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
/* translators: %s max price */
|
||||
__( 'Up to %s', 'woo-gutenberg-products-block' ),
|
||||
formatPrice( maxPrice )
|
||||
);
|
||||
};
|
||||
|
||||
interface RemovableListItemProps {
|
||||
type: string;
|
||||
name: string;
|
||||
prefix?: string | JSX.Element;
|
||||
showLabel?: boolean;
|
||||
isLoading?: boolean;
|
||||
displayStyle: string;
|
||||
removeCallback?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a removable item in the active filters block list.
|
||||
*
|
||||
* @param {Object} listItem The removable item to render.
|
||||
* @param {string} listItem.type Type string.
|
||||
* @param {string} listItem.name Name string.
|
||||
* @param {string} [listItem.prefix=''] Prefix shown before item name.
|
||||
* @param {Function} listItem.removeCallback Callback to remove item.
|
||||
* @param {string} listItem.displayStyle Whether it's a list or chips.
|
||||
* @param {boolean} [listItem.showLabel=true] Should the label be shown for
|
||||
* this item?
|
||||
*/
|
||||
export const renderRemovableListItem = ( {
|
||||
type,
|
||||
name,
|
||||
prefix = '',
|
||||
removeCallback = () => null,
|
||||
showLabel = true,
|
||||
displayStyle,
|
||||
}: RemovableListItemProps ) => {
|
||||
const prefixedName = prefix ? (
|
||||
<>
|
||||
{ prefix }
|
||||
|
||||
{ name }
|
||||
</>
|
||||
) : (
|
||||
name
|
||||
);
|
||||
const removeText = sprintf(
|
||||
/* translators: %s attribute value used in the filter. For example: yellow, green, small, large. */
|
||||
__( 'Remove %s filter', 'woo-gutenberg-products-block' ),
|
||||
name
|
||||
);
|
||||
|
||||
return (
|
||||
<li
|
||||
className="wc-block-active-filters__list-item"
|
||||
key={ type + ':' + name }
|
||||
>
|
||||
{ showLabel && (
|
||||
<span className="wc-block-active-filters__list-item-type">
|
||||
{ type + ': ' }
|
||||
</span>
|
||||
) }
|
||||
{ displayStyle === 'chips' ? (
|
||||
<RemovableChip
|
||||
element="span"
|
||||
text={ prefixedName }
|
||||
onRemove={ removeCallback }
|
||||
radius="large"
|
||||
ariaLabel={ removeText }
|
||||
/>
|
||||
) : (
|
||||
<span className="wc-block-active-filters__list-item-name">
|
||||
<button
|
||||
className="wc-block-active-filters__list-item-remove"
|
||||
onClick={ removeCallback }
|
||||
>
|
||||
<Icon
|
||||
className="wc-block-components-chip__remove-icon"
|
||||
icon={ closeSmall }
|
||||
size={ 16 }
|
||||
/>
|
||||
<Label screenReaderLabel={ removeText } />
|
||||
</button>
|
||||
{ prefixedName }
|
||||
</span>
|
||||
) }
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Update the current URL to update or remove provided query arguments.
|
||||
*
|
||||
*
|
||||
* @param {Array<string|Record<string, string>>} args Args to remove
|
||||
*/
|
||||
export const removeArgsFromFilterUrl = (
|
||||
...args: ( string | Record< string, string > )[]
|
||||
) => {
|
||||
if ( ! window ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = window.location.href;
|
||||
const currentQuery = getQueryArgs( url );
|
||||
const cleanUrl = removeQueryArgs( url, ...Object.keys( currentQuery ) );
|
||||
|
||||
args.forEach( ( item ) => {
|
||||
if ( typeof item === 'string' ) {
|
||||
return delete currentQuery[ item ];
|
||||
}
|
||||
if ( typeof item === 'object' ) {
|
||||
const key = Object.keys( item )[ 0 ];
|
||||
const currentQueryValue = currentQuery[ key ]
|
||||
.toString()
|
||||
.split( ',' );
|
||||
currentQuery[ key ] = currentQueryValue
|
||||
.filter( ( value ) => value !== item[ key ] )
|
||||
.join( ',' );
|
||||
}
|
||||
} );
|
||||
|
||||
const filteredQuery = Object.fromEntries(
|
||||
Object.entries( currentQuery ).filter( ( [ , value ] ) => value )
|
||||
);
|
||||
|
||||
const newUrl = addQueryArgs( cleanUrl, filteredQuery );
|
||||
|
||||
changeUrl( newUrl );
|
||||
};
|
||||
|
||||
/**
|
||||
* Prefixes typically expected before filters in the URL.
|
||||
*/
|
||||
const FILTER_QUERY_VALUES = [
|
||||
'min_price',
|
||||
'max_price',
|
||||
'rating_filter',
|
||||
'filter_',
|
||||
'query_type_',
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if the URL contains arguments that could be Woo filter keys.
|
||||
*/
|
||||
const keyIsAFilter = ( key: string ): boolean => {
|
||||
let keyIsFilter = false;
|
||||
|
||||
for ( let i = 0; FILTER_QUERY_VALUES.length > i; i++ ) {
|
||||
const keyToMatch = FILTER_QUERY_VALUES[ i ];
|
||||
const trimmedKey = key.substring( 0, keyToMatch.length );
|
||||
if ( keyToMatch === trimmedKey ) {
|
||||
keyIsFilter = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return keyIsFilter;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clean the filter URL.
|
||||
*/
|
||||
export const cleanFilterUrl = () => {
|
||||
if ( ! window ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = window.location.href;
|
||||
const args = getQueryArgs( url );
|
||||
const cleanUrl = removeQueryArgs( url, ...Object.keys( args ) );
|
||||
const remainingArgs = Object.fromEntries(
|
||||
Object.keys( args )
|
||||
.filter( ( arg ) => {
|
||||
if ( keyIsAFilter( arg ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} )
|
||||
.map( ( key ) => [ key, args[ key ] ] )
|
||||
);
|
||||
|
||||
const newUrl = addQueryArgs( cleanUrl, remainingArgs );
|
||||
|
||||
changeUrl( newUrl );
|
||||
};
|
||||
|
||||
export const maybeUrlContainsFilters = (): boolean => {
|
||||
if ( ! window ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const url = window.location.href;
|
||||
const args = getQueryArgs( url );
|
||||
const filterKeys = Object.keys( args );
|
||||
let maybeHasFilter = false;
|
||||
|
||||
for ( let i = 0; filterKeys.length > i; i++ ) {
|
||||
const key = filterKeys[ i ];
|
||||
if ( keyIsAFilter( key ) ) {
|
||||
maybeHasFilter = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return maybeHasFilter;
|
||||
};
|
||||
|
||||
export interface StoreAttributes {
|
||||
attribute_id: string;
|
||||
attribute_label: string;
|
||||
attribute_name: string;
|
||||
attribute_orderby: string;
|
||||
attribute_public: number;
|
||||
attribute_type: string;
|
||||
}
|
||||
|
||||
export const urlContainsAttributeFilter = (
|
||||
attributes: StoreAttributes[]
|
||||
): boolean => {
|
||||
if ( ! window ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const storeAttributeKeys = attributes.map(
|
||||
( attr ) => `filter_${ attr.attribute_name }`
|
||||
);
|
||||
|
||||
const url = window.location.href;
|
||||
const args = getQueryArgs( url );
|
||||
const urlFilterKeys = Object.keys( args );
|
||||
let filterIsInUrl = false;
|
||||
|
||||
for ( let i = 0; urlFilterKeys.length > i; i++ ) {
|
||||
const urlKey = urlFilterKeys[ i ];
|
||||
if ( storeAttributeKeys.includes( urlKey ) ) {
|
||||
filterIsInUrl = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return filterIsInUrl;
|
||||
};
|
||||
|
||||
export const parseAttributes = ( data: Record< string, unknown > ) => {
|
||||
return {
|
||||
heading: isString( data?.heading ) ? data.heading : '',
|
||||
headingLevel:
|
||||
( isString( data?.headingLevel ) &&
|
||||
parseInt( data.headingLevel, 10 ) ) ||
|
||||
metadata.attributes.headingLevel.default,
|
||||
displayStyle:
|
||||
( isString( data?.displayStyle ) && data.displayStyle ) ||
|
||||
metadata.attributes.displayStyle.default,
|
||||
};
|
||||
};
|
||||
@@ -1,11 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
|
||||
export const blockAttributes = {
|
||||
heading: {
|
||||
type: 'string',
|
||||
default: __( 'Filter by attribute', 'woo-gutenberg-products-block' ),
|
||||
},
|
||||
};
|
||||
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import classnames from 'classnames';
|
||||
import { useStyleProps } from '@woocommerce/base-hooks';
|
||||
import { isString } from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import Block from './block';
|
||||
import { parseAttributes } from './utils';
|
||||
|
||||
const BlockWrapper = ( props: Record< string, unknown > ) => {
|
||||
const styleProps = useStyleProps( props );
|
||||
const parsedBlockAttributes = parseAttributes( props );
|
||||
|
||||
return (
|
||||
<div
|
||||
className={ classnames(
|
||||
isString( props.className ) ? props.className : '',
|
||||
styleProps.className
|
||||
) }
|
||||
style={ styleProps.style }
|
||||
>
|
||||
<Block isEditor={ false } attributes={ parsedBlockAttributes } />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlockWrapper;
|
||||
@@ -1,58 +0,0 @@
|
||||
{
|
||||
"name": "woocommerce/attribute-filter",
|
||||
"version": "1.0.0",
|
||||
"title": "Filter by Attribute Controls",
|
||||
"description": "Enable customers to filter the product grid by selecting one or more attributes, such as color.",
|
||||
"category": "woocommerce",
|
||||
"keywords": [ "WooCommerce" ],
|
||||
"supports": {
|
||||
"html": false,
|
||||
"color": {
|
||||
"text": true,
|
||||
"background": false
|
||||
},
|
||||
"inserter": false,
|
||||
"lock": false
|
||||
},
|
||||
"attributes": {
|
||||
"className": {
|
||||
"type": "string",
|
||||
"default": ""
|
||||
},
|
||||
"attributeId": {
|
||||
"type": "number",
|
||||
"default": 0
|
||||
},
|
||||
"showCounts": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"queryType": {
|
||||
"type": "string",
|
||||
"default": "or"
|
||||
},
|
||||
"headingLevel": {
|
||||
"type": "number",
|
||||
"default": 3
|
||||
},
|
||||
"displayStyle": {
|
||||
"type": "string",
|
||||
"default": "list"
|
||||
},
|
||||
"showFilterButton": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"selectType": {
|
||||
"type": "string",
|
||||
"default": "multiple"
|
||||
},
|
||||
"isPreview": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"textdomain": "woocommerce",
|
||||
"apiVersion": 2,
|
||||
"$schema": "https://schemas.wp.org/trunk/block.json"
|
||||
}
|
||||
@@ -1,688 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __, sprintf } from '@wordpress/i18n';
|
||||
import { usePrevious, useShallowEqual } from '@woocommerce/base-hooks';
|
||||
import {
|
||||
useCollection,
|
||||
useQueryStateByKey,
|
||||
useQueryStateByContext,
|
||||
useCollectionData,
|
||||
} from '@woocommerce/base-context/hooks';
|
||||
import { useCallback, useEffect, useState, useMemo } from '@wordpress/element';
|
||||
import Label from '@woocommerce/base-components/filter-element-label';
|
||||
import FilterResetButton from '@woocommerce/base-components/filter-reset-button';
|
||||
import FilterSubmitButton from '@woocommerce/base-components/filter-submit-button';
|
||||
import isShallowEqual from '@wordpress/is-shallow-equal';
|
||||
import { decodeEntities } from '@wordpress/html-entities';
|
||||
import { getSettingWithCoercion } from '@woocommerce/settings';
|
||||
import { getQueryArgs, removeQueryArgs } from '@wordpress/url';
|
||||
import {
|
||||
AttributeQuery,
|
||||
AttributeTerm,
|
||||
isAttributeQueryCollection,
|
||||
isBoolean,
|
||||
isString,
|
||||
objectHasProp,
|
||||
} from '@woocommerce/types';
|
||||
import { Icon, chevronDown } from '@wordpress/icons';
|
||||
import {
|
||||
changeUrl,
|
||||
PREFIX_QUERY_ARG_FILTER_TYPE,
|
||||
PREFIX_QUERY_ARG_QUERY_TYPE,
|
||||
} from '@woocommerce/utils';
|
||||
import FormTokenField from '@woocommerce/base-components/form-token-field';
|
||||
import FilterTitlePlaceholder from '@woocommerce/base-components/filter-placeholder';
|
||||
import classnames from 'classnames';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { getAttributeFromID } from '../../utils/attributes';
|
||||
import { updateAttributeFilter } from '../../utils/attributes-query';
|
||||
import { previewAttributeObject, previewOptions } from './preview';
|
||||
import './style.scss';
|
||||
import {
|
||||
formatParams,
|
||||
getActiveFilters,
|
||||
areAllFiltersRemoved,
|
||||
isQueryArgsEqual,
|
||||
parseTaxonomyToGenerateURL,
|
||||
formatSlug,
|
||||
generateUniqueId,
|
||||
} from './utils';
|
||||
import { BlockAttributes, DisplayOption, GetNotice } from './types';
|
||||
import CheckboxFilter from './checkbox-filter';
|
||||
import { useSetWraperVisibility } from '../filter-wrapper/context';
|
||||
|
||||
/**
|
||||
* Component displaying an attribute filter.
|
||||
*
|
||||
* @param {Object} props Incoming props for the component.
|
||||
* @param {Object} props.attributes Incoming block attributes.
|
||||
* @param {boolean} props.isEditor Whether the component is being rendered in the editor.
|
||||
* @param {boolean} props.getNotice Get notice content if in editor.
|
||||
*/
|
||||
const AttributeFilterBlock = ( {
|
||||
attributes: blockAttributes,
|
||||
isEditor = false,
|
||||
getNotice = () => null,
|
||||
}: {
|
||||
attributes: BlockAttributes;
|
||||
isEditor?: boolean;
|
||||
getNotice?: GetNotice;
|
||||
} ) => {
|
||||
const hasFilterableProducts = getSettingWithCoercion(
|
||||
'hasFilterableProducts',
|
||||
false,
|
||||
isBoolean
|
||||
);
|
||||
|
||||
const filteringForPhpTemplate = getSettingWithCoercion(
|
||||
'isRenderingPhpTemplate',
|
||||
false,
|
||||
isBoolean
|
||||
);
|
||||
|
||||
const pageUrl = getSettingWithCoercion(
|
||||
'pageUrl',
|
||||
window.location.href,
|
||||
isString
|
||||
);
|
||||
|
||||
const [ hasSetFilterDefaultsFromUrl, setHasSetFilterDefaultsFromUrl ] =
|
||||
useState( false );
|
||||
|
||||
const attributeObject =
|
||||
blockAttributes.isPreview && ! blockAttributes.attributeId
|
||||
? previewAttributeObject
|
||||
: getAttributeFromID( blockAttributes.attributeId );
|
||||
|
||||
const initialFilters = useMemo(
|
||||
() => getActiveFilters( attributeObject ),
|
||||
[ attributeObject ]
|
||||
);
|
||||
|
||||
const [ checked, setChecked ] = useState( initialFilters );
|
||||
|
||||
/*
|
||||
FormTokenField forces the dropdown to reopen on reset, so we create a unique ID to use as the components key.
|
||||
This will force the component to remount on reset when we change this value.
|
||||
More info: https://github.com/woocommerce/woocommerce-blocks/pull/6920#issuecomment-1222402482
|
||||
*/
|
||||
const [ remountKey, setRemountKey ] = useState( generateUniqueId() );
|
||||
|
||||
const [ displayedOptions, setDisplayedOptions ] = useState<
|
||||
DisplayOption[]
|
||||
>(
|
||||
blockAttributes.isPreview && ! blockAttributes.attributeId
|
||||
? previewOptions
|
||||
: []
|
||||
);
|
||||
|
||||
const [ queryState ] = useQueryStateByContext();
|
||||
const [ productAttributesQuery, setProductAttributesQuery ] =
|
||||
useQueryStateByKey( 'attributes', [] );
|
||||
|
||||
const { results: attributeTerms, isLoading: attributeTermsLoading } =
|
||||
useCollection< AttributeTerm >( {
|
||||
namespace: '/wc/store/v1',
|
||||
resourceName: 'products/attributes/terms',
|
||||
resourceValues: [ attributeObject?.id || 0 ],
|
||||
shouldSelect: blockAttributes.attributeId > 0,
|
||||
query: { orderby: 'menu_order' },
|
||||
} );
|
||||
|
||||
const { results: filteredCounts, isLoading: filteredCountsLoading } =
|
||||
useCollectionData( {
|
||||
queryAttribute: {
|
||||
taxonomy: attributeObject?.taxonomy || '',
|
||||
queryType: blockAttributes.queryType,
|
||||
},
|
||||
queryState: {
|
||||
...queryState,
|
||||
},
|
||||
isEditor,
|
||||
} );
|
||||
|
||||
/**
|
||||
* Get count data about a given term by ID.
|
||||
*/
|
||||
const getFilteredTerm = useCallback(
|
||||
( id ) => {
|
||||
if (
|
||||
! objectHasProp( filteredCounts, 'attribute_counts' ) ||
|
||||
! Array.isArray( filteredCounts.attribute_counts )
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return filteredCounts.attribute_counts.find(
|
||||
( { term } ) => term === id
|
||||
);
|
||||
},
|
||||
[ filteredCounts ]
|
||||
);
|
||||
|
||||
/**
|
||||
* Compare intersection of all terms and filtered counts to get a list of options to display.
|
||||
*/
|
||||
useEffect( () => {
|
||||
/**
|
||||
* Checks if a term slug is in the query state.
|
||||
*
|
||||
* @param {string} termSlug The term of the slug to check.
|
||||
*/
|
||||
const isTermInQueryState = ( termSlug: string ) => {
|
||||
if ( ! queryState?.attributes ) {
|
||||
return false;
|
||||
}
|
||||
return queryState.attributes.some(
|
||||
( { attribute, slug = [] }: AttributeQuery ) =>
|
||||
attribute === attributeObject?.taxonomy &&
|
||||
slug.includes( termSlug )
|
||||
);
|
||||
};
|
||||
|
||||
if ( attributeTermsLoading || filteredCountsLoading ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! Array.isArray( attributeTerms ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newOptions = attributeTerms
|
||||
.map( ( term ) => {
|
||||
const filteredTerm = getFilteredTerm( term.id );
|
||||
|
||||
// If there is no match this term doesn't match the current product collection - only render if checked.
|
||||
if (
|
||||
! filteredTerm &&
|
||||
! checked.includes( term.slug ) &&
|
||||
! isTermInQueryState( term.slug )
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const count = filteredTerm ? filteredTerm.count : 0;
|
||||
|
||||
return {
|
||||
formattedValue: formatSlug( term.slug ),
|
||||
value: term.slug,
|
||||
name: decodeEntities( term.name ),
|
||||
label: (
|
||||
<Label
|
||||
name={ decodeEntities( term.name ) }
|
||||
count={ blockAttributes.showCounts ? count : null }
|
||||
/>
|
||||
),
|
||||
textLabel: blockAttributes.showCounts
|
||||
? `${ decodeEntities( term.name ) } (${ count })`
|
||||
: decodeEntities( term.name ),
|
||||
};
|
||||
} )
|
||||
.filter( ( option ): option is DisplayOption => !! option );
|
||||
|
||||
setDisplayedOptions( newOptions );
|
||||
setRemountKey( generateUniqueId() );
|
||||
}, [
|
||||
attributeObject?.taxonomy,
|
||||
attributeTerms,
|
||||
attributeTermsLoading,
|
||||
blockAttributes.showCounts,
|
||||
filteredCountsLoading,
|
||||
getFilteredTerm,
|
||||
checked,
|
||||
queryState.attributes,
|
||||
] );
|
||||
|
||||
/**
|
||||
* Returns an array of term objects that have been chosen via the checkboxes.
|
||||
*/
|
||||
const getSelectedTerms = useCallback(
|
||||
( newChecked ) => {
|
||||
if ( ! Array.isArray( attributeTerms ) ) {
|
||||
return [];
|
||||
}
|
||||
return attributeTerms.reduce( ( acc, term ) => {
|
||||
if ( newChecked.includes( term.slug ) ) {
|
||||
acc.push( term );
|
||||
}
|
||||
return acc;
|
||||
}, [] );
|
||||
},
|
||||
[ attributeTerms ]
|
||||
);
|
||||
|
||||
/**
|
||||
* Appends query params to the current pages URL and redirects them to the new URL for PHP rendered templates.
|
||||
*
|
||||
* @param {Object} query The object containing the active filter query.
|
||||
* @param {boolean} allFiltersRemoved If there are active filters or not.
|
||||
*/
|
||||
const updateFilterUrl = useCallback(
|
||||
( query, allFiltersRemoved = false ) => {
|
||||
query = query.map( ( item: AttributeQuery ) => ( {
|
||||
...item,
|
||||
slug: item.slug.map( ( slug: string ) =>
|
||||
decodeURIComponent( slug )
|
||||
),
|
||||
} ) );
|
||||
|
||||
if ( allFiltersRemoved ) {
|
||||
if ( ! attributeObject?.taxonomy ) {
|
||||
return;
|
||||
}
|
||||
const currentQueryArgKeys = Object.keys(
|
||||
getQueryArgs( window.location.href )
|
||||
);
|
||||
|
||||
const parsedTaxonomy = parseTaxonomyToGenerateURL(
|
||||
attributeObject.taxonomy
|
||||
);
|
||||
|
||||
const url = currentQueryArgKeys.reduce(
|
||||
( currentUrl, queryArg ) =>
|
||||
queryArg.includes(
|
||||
PREFIX_QUERY_ARG_QUERY_TYPE + parsedTaxonomy
|
||||
) ||
|
||||
queryArg.includes(
|
||||
PREFIX_QUERY_ARG_FILTER_TYPE + parsedTaxonomy
|
||||
)
|
||||
? removeQueryArgs( currentUrl, queryArg )
|
||||
: currentUrl,
|
||||
window.location.href
|
||||
);
|
||||
|
||||
const newUrl = formatParams( url, query );
|
||||
changeUrl( newUrl );
|
||||
} else {
|
||||
const newUrl = formatParams( pageUrl, query );
|
||||
const currentQueryArgs = getQueryArgs( window.location.href );
|
||||
const newUrlQueryArgs = getQueryArgs( newUrl );
|
||||
|
||||
if ( ! isQueryArgsEqual( currentQueryArgs, newUrlQueryArgs ) ) {
|
||||
changeUrl( newUrl );
|
||||
}
|
||||
}
|
||||
},
|
||||
[ pageUrl, attributeObject?.taxonomy ]
|
||||
);
|
||||
|
||||
const onSubmit = ( checkedFilters: string[] ) => {
|
||||
const query = updateAttributeFilter(
|
||||
productAttributesQuery,
|
||||
setProductAttributesQuery,
|
||||
attributeObject,
|
||||
getSelectedTerms( checkedFilters ),
|
||||
blockAttributes.queryType === 'or' ? 'in' : 'and'
|
||||
);
|
||||
|
||||
updateFilterUrl( query, checkedFilters.length === 0 );
|
||||
};
|
||||
|
||||
const updateCheckedFilters = useCallback(
|
||||
( checkedFilters: string[], force = false ) => {
|
||||
if ( isEditor ) {
|
||||
return;
|
||||
}
|
||||
|
||||
setChecked( checkedFilters );
|
||||
if ( force || ! blockAttributes.showFilterButton ) {
|
||||
updateAttributeFilter(
|
||||
productAttributesQuery,
|
||||
setProductAttributesQuery,
|
||||
attributeObject,
|
||||
getSelectedTerms( checkedFilters ),
|
||||
blockAttributes.queryType === 'or' ? 'in' : 'and'
|
||||
);
|
||||
}
|
||||
},
|
||||
[
|
||||
isEditor,
|
||||
setChecked,
|
||||
productAttributesQuery,
|
||||
setProductAttributesQuery,
|
||||
attributeObject,
|
||||
getSelectedTerms,
|
||||
blockAttributes.queryType,
|
||||
blockAttributes.showFilterButton,
|
||||
]
|
||||
);
|
||||
|
||||
const checkedQuery = useMemo( () => {
|
||||
if ( ! isAttributeQueryCollection( productAttributesQuery ) ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return productAttributesQuery
|
||||
.filter(
|
||||
( { attribute } ) => attribute === attributeObject?.taxonomy
|
||||
)
|
||||
.flatMap( ( { slug } ) => slug );
|
||||
}, [ productAttributesQuery, attributeObject?.taxonomy ] );
|
||||
|
||||
const currentCheckedQuery = useShallowEqual( checkedQuery );
|
||||
const previousCheckedQuery = usePrevious( currentCheckedQuery );
|
||||
// Track ATTRIBUTES QUERY changes so the block reflects current filters.
|
||||
useEffect( () => {
|
||||
if (
|
||||
previousCheckedQuery &&
|
||||
! isShallowEqual( previousCheckedQuery, currentCheckedQuery ) && // checked query changed
|
||||
! isShallowEqual( checked, currentCheckedQuery ) // checked query doesn't match the UI
|
||||
) {
|
||||
updateCheckedFilters( currentCheckedQuery );
|
||||
}
|
||||
}, [
|
||||
checked,
|
||||
currentCheckedQuery,
|
||||
previousCheckedQuery,
|
||||
updateCheckedFilters,
|
||||
] );
|
||||
|
||||
const multiple = blockAttributes.selectType !== 'single';
|
||||
|
||||
/**
|
||||
* When a checkbox in the list changes, update state.
|
||||
*/
|
||||
const onChange = useCallback(
|
||||
( checkedValue ) => {
|
||||
const previouslyChecked = checked.includes( checkedValue );
|
||||
let newChecked;
|
||||
|
||||
if ( ! multiple ) {
|
||||
newChecked = previouslyChecked ? [] : [ checkedValue ];
|
||||
} else {
|
||||
newChecked = checked.filter(
|
||||
( value ) => value !== checkedValue
|
||||
);
|
||||
|
||||
if ( ! previouslyChecked ) {
|
||||
newChecked.push( checkedValue );
|
||||
newChecked.sort();
|
||||
}
|
||||
}
|
||||
|
||||
updateCheckedFilters( newChecked );
|
||||
},
|
||||
[ checked, multiple, updateCheckedFilters ]
|
||||
);
|
||||
|
||||
/**
|
||||
* Update the filter URL on state change.
|
||||
*/
|
||||
useEffect( () => {
|
||||
if ( ! attributeObject || blockAttributes.showFilterButton ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
areAllFiltersRemoved( {
|
||||
currentCheckedFilters: checked,
|
||||
hasSetFilterDefaultsFromUrl,
|
||||
} )
|
||||
) {
|
||||
updateFilterUrl( productAttributesQuery, true );
|
||||
} else {
|
||||
updateFilterUrl( productAttributesQuery, false );
|
||||
}
|
||||
}, [
|
||||
hasSetFilterDefaultsFromUrl,
|
||||
updateFilterUrl,
|
||||
productAttributesQuery,
|
||||
attributeObject,
|
||||
checked,
|
||||
blockAttributes.showFilterButton,
|
||||
] );
|
||||
|
||||
/**
|
||||
* Try to get the current attribute filter from the URl.
|
||||
*/
|
||||
useEffect( () => {
|
||||
if ( hasSetFilterDefaultsFromUrl || attributeTermsLoading ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( initialFilters.length > 0 ) {
|
||||
setHasSetFilterDefaultsFromUrl( true );
|
||||
updateCheckedFilters( initialFilters, true );
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! filteringForPhpTemplate ) {
|
||||
setHasSetFilterDefaultsFromUrl( true );
|
||||
}
|
||||
}, [
|
||||
attributeObject,
|
||||
hasSetFilterDefaultsFromUrl,
|
||||
attributeTermsLoading,
|
||||
updateCheckedFilters,
|
||||
initialFilters,
|
||||
filteringForPhpTemplate,
|
||||
] );
|
||||
|
||||
const setWrapperVisibility = useSetWraperVisibility();
|
||||
|
||||
if ( ! hasFilterableProducts ) {
|
||||
setWrapperVisibility( false );
|
||||
return null;
|
||||
}
|
||||
|
||||
// Short-circuit if no attribute is selected.
|
||||
if ( ! attributeObject ) {
|
||||
if ( isEditor ) {
|
||||
return getNotice( 'noAttributes' );
|
||||
}
|
||||
setWrapperVisibility( false );
|
||||
return null;
|
||||
}
|
||||
|
||||
if ( displayedOptions.length === 0 && ! attributeTermsLoading ) {
|
||||
if ( isEditor ) {
|
||||
return getNotice( 'noProducts' );
|
||||
}
|
||||
}
|
||||
|
||||
const TagName =
|
||||
`h${ blockAttributes.headingLevel }` as keyof JSX.IntrinsicElements;
|
||||
const termsLoading = ! blockAttributes.isPreview && attributeTermsLoading;
|
||||
const countsLoading = ! blockAttributes.isPreview && filteredCountsLoading;
|
||||
|
||||
const isLoading =
|
||||
( termsLoading || countsLoading ) && displayedOptions.length === 0;
|
||||
|
||||
if ( ! isLoading && displayedOptions.length === 0 ) {
|
||||
setWrapperVisibility( false );
|
||||
return null;
|
||||
}
|
||||
|
||||
const showChevron = multiple
|
||||
? ! isLoading && checked.length < displayedOptions.length
|
||||
: ! isLoading && checked.length === 0;
|
||||
|
||||
const heading = (
|
||||
<TagName className="wc-block-attribute-filter__title">
|
||||
{ blockAttributes.heading }
|
||||
</TagName>
|
||||
);
|
||||
|
||||
const filterHeading = isLoading ? (
|
||||
<FilterTitlePlaceholder>{ heading }</FilterTitlePlaceholder>
|
||||
) : (
|
||||
heading
|
||||
);
|
||||
|
||||
setWrapperVisibility( true );
|
||||
|
||||
const getIsApplyButtonDisabled = () => {
|
||||
if ( termsLoading || countsLoading ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const activeFilters = getActiveFilters( attributeObject );
|
||||
if ( activeFilters.length === checked.length ) {
|
||||
return checked.every( ( value ) =>
|
||||
activeFilters.includes( value )
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{ ! isEditor && blockAttributes.heading && filterHeading }
|
||||
<div
|
||||
className={ classnames(
|
||||
'wc-block-attribute-filter',
|
||||
`style-${ blockAttributes.displayStyle }`
|
||||
) }
|
||||
>
|
||||
{ blockAttributes.displayStyle === 'dropdown' ? (
|
||||
<>
|
||||
<FormTokenField
|
||||
key={ remountKey }
|
||||
className={ classnames( {
|
||||
'single-selection': ! multiple,
|
||||
'is-loading': isLoading,
|
||||
} ) }
|
||||
suggestions={ displayedOptions
|
||||
.filter(
|
||||
( option ) =>
|
||||
! checked.includes( option.value )
|
||||
)
|
||||
.map( ( option ) => option.formattedValue ) }
|
||||
disabled={ isLoading }
|
||||
placeholder={ sprintf(
|
||||
/* translators: %s attribute name. */
|
||||
__(
|
||||
'Select %s',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
attributeObject.label
|
||||
) }
|
||||
onChange={ ( tokens: string[] ) => {
|
||||
if ( ! multiple && tokens.length > 1 ) {
|
||||
tokens = [ tokens[ tokens.length - 1 ] ];
|
||||
}
|
||||
|
||||
tokens = tokens.map( ( token ) => {
|
||||
const displayOption = displayedOptions.find(
|
||||
( option ) =>
|
||||
option.formattedValue === token
|
||||
);
|
||||
|
||||
return displayOption
|
||||
? displayOption.value
|
||||
: token;
|
||||
} );
|
||||
|
||||
const added = [ tokens, checked ].reduce(
|
||||
( a, b ) =>
|
||||
a.filter( ( c ) => ! b.includes( c ) )
|
||||
);
|
||||
|
||||
if ( added.length === 1 ) {
|
||||
return onChange( added[ 0 ] );
|
||||
}
|
||||
|
||||
const removed = [ checked, tokens ].reduce(
|
||||
( a, b ) =>
|
||||
a.filter( ( c ) => ! b.includes( c ) )
|
||||
);
|
||||
if ( removed.length === 1 ) {
|
||||
onChange( removed[ 0 ] );
|
||||
}
|
||||
} }
|
||||
value={ checked }
|
||||
displayTransform={ ( value: string ) => {
|
||||
const result = displayedOptions.find(
|
||||
( option ) =>
|
||||
[
|
||||
option.value,
|
||||
option.formattedValue,
|
||||
].includes( value )
|
||||
);
|
||||
return result ? result.textLabel : value;
|
||||
} }
|
||||
saveTransform={ formatSlug }
|
||||
messages={ {
|
||||
added: sprintf(
|
||||
/* translators: %s is the attribute label. */
|
||||
__(
|
||||
'%s filter added.',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
attributeObject.label
|
||||
),
|
||||
removed: sprintf(
|
||||
/* translators: %s is the attribute label. */
|
||||
__(
|
||||
'%s filter removed.',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
attributeObject.label
|
||||
),
|
||||
remove: sprintf(
|
||||
/* translators: %s is the attribute label. */
|
||||
__(
|
||||
'Remove %s filter.',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
attributeObject.label.toLocaleLowerCase()
|
||||
),
|
||||
__experimentalInvalid: sprintf(
|
||||
/* translators: %s is the attribute label. */
|
||||
__(
|
||||
'Invalid %s filter.',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
attributeObject.label.toLocaleLowerCase()
|
||||
),
|
||||
} }
|
||||
/>
|
||||
{ showChevron && (
|
||||
<Icon icon={ chevronDown } size={ 30 } />
|
||||
) }
|
||||
</>
|
||||
) : (
|
||||
<CheckboxFilter
|
||||
options={ displayedOptions }
|
||||
checked={ checked }
|
||||
onChange={ onChange }
|
||||
isLoading={ isLoading }
|
||||
isDisabled={ isLoading }
|
||||
/>
|
||||
) }
|
||||
</div>
|
||||
|
||||
<div className="wc-block-attribute-filter__actions">
|
||||
{ ( checked.length > 0 || isEditor ) && ! isLoading && (
|
||||
<FilterResetButton
|
||||
onClick={ () => {
|
||||
setChecked( [] );
|
||||
setRemountKey( generateUniqueId() );
|
||||
if ( hasSetFilterDefaultsFromUrl ) {
|
||||
onSubmit( [] );
|
||||
}
|
||||
} }
|
||||
screenReaderLabel={ __(
|
||||
'Reset attribute filter',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
/>
|
||||
) }
|
||||
{ blockAttributes.showFilterButton && (
|
||||
<FilterSubmitButton
|
||||
className="wc-block-attribute-filter__button"
|
||||
isLoading={ isLoading }
|
||||
disabled={ getIsApplyButtonDisabled() }
|
||||
onClick={ () => onSubmit( checked ) }
|
||||
/>
|
||||
) }
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttributeFilterBlock;
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { CheckboxList } from '@woocommerce/blocks-components';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { DisplayOption } from './types';
|
||||
|
||||
interface CheckboxFilterProps {
|
||||
className?: string;
|
||||
isLoading?: boolean;
|
||||
isDisabled?: boolean;
|
||||
limit?: number;
|
||||
checked?: string[];
|
||||
onChange: ( value: string ) => void;
|
||||
options?: DisplayOption[];
|
||||
}
|
||||
|
||||
const CheckboxFilter = ( {
|
||||
isLoading = false,
|
||||
options,
|
||||
checked,
|
||||
onChange,
|
||||
}: CheckboxFilterProps ) => {
|
||||
if ( isLoading ) {
|
||||
return (
|
||||
<>
|
||||
<span className="is-loading"></span>
|
||||
<span className="is-loading"></span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CheckboxList
|
||||
className="wc-block-attribute-filter-list"
|
||||
options={ options }
|
||||
checked={ checked }
|
||||
onChange={ onChange }
|
||||
isLoading={ isLoading }
|
||||
isDisabled={ isLoading }
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CheckboxFilter;
|
||||
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useBlockProps } from '@wordpress/block-editor';
|
||||
import { isFeaturePluginBuild } from '@woocommerce/block-settings';
|
||||
import classNames from 'classnames';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import type { BlockAttributes } from './types';
|
||||
import { blockAttributes } from './attributes';
|
||||
import metadata from './block.json';
|
||||
|
||||
const v1 = {
|
||||
supports: {
|
||||
...metadata.supports,
|
||||
...( isFeaturePluginBuild() && {
|
||||
__experimentalBorder: {
|
||||
radius: false,
|
||||
color: true,
|
||||
width: false,
|
||||
},
|
||||
} ),
|
||||
},
|
||||
attributes: {
|
||||
...metadata.attributes,
|
||||
showCounts: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
},
|
||||
...blockAttributes,
|
||||
},
|
||||
save: ( { attributes }: { attributes: BlockAttributes } ) => {
|
||||
const {
|
||||
className,
|
||||
showCounts,
|
||||
queryType,
|
||||
attributeId,
|
||||
heading,
|
||||
headingLevel,
|
||||
displayStyle,
|
||||
showFilterButton,
|
||||
selectType,
|
||||
} = attributes;
|
||||
const data: Record< string, unknown > = {
|
||||
'data-attribute-id': attributeId,
|
||||
'data-show-counts': showCounts,
|
||||
'data-query-type': queryType,
|
||||
'data-heading': heading,
|
||||
'data-heading-level': headingLevel,
|
||||
};
|
||||
if ( displayStyle !== 'list' ) {
|
||||
data[ 'data-display-style' ] = displayStyle;
|
||||
}
|
||||
if ( showFilterButton ) {
|
||||
data[ 'data-show-filter-button' ] = showFilterButton;
|
||||
}
|
||||
if ( selectType === 'single' ) {
|
||||
data[ 'data-select-type' ] = selectType;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
{ ...useBlockProps.save( {
|
||||
className: classNames( 'is-loading', className ),
|
||||
} ) }
|
||||
{ ...data }
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="wc-block-product-attribute-filter__placeholder"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const deprecated = [ v1 ];
|
||||
|
||||
export default deprecated;
|
||||
@@ -1,447 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { sort } from 'fast-sort';
|
||||
import { __, sprintf, _n } from '@wordpress/i18n';
|
||||
import { useState } from '@wordpress/element';
|
||||
import {
|
||||
InspectorControls,
|
||||
BlockControls,
|
||||
useBlockProps,
|
||||
} from '@wordpress/block-editor';
|
||||
import { Icon, category, external } from '@wordpress/icons';
|
||||
import { SearchListControl } from '@woocommerce/editor-components/search-list-control';
|
||||
import { getAdminLink, getSetting } from '@woocommerce/settings';
|
||||
import BlockTitle from '@woocommerce/editor-components/block-title';
|
||||
import classnames from 'classnames';
|
||||
import { SearchListItem } from '@woocommerce/editor-components/search-list-control/types';
|
||||
import { AttributeSetting } from '@woocommerce/types';
|
||||
import {
|
||||
Placeholder,
|
||||
Disabled,
|
||||
PanelBody,
|
||||
ToggleControl,
|
||||
Button,
|
||||
ToolbarGroup,
|
||||
Notice,
|
||||
withSpokenMessages,
|
||||
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
|
||||
__experimentalToggleGroupControl as ToggleGroupControl,
|
||||
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
|
||||
__experimentalToggleGroupControlOption as ToggleGroupControlOption,
|
||||
} from '@wordpress/components';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import Block from './block';
|
||||
import './editor.scss';
|
||||
import type { EditProps, GetNotice } from './types';
|
||||
import { UpgradeNotice } from '../filter-wrapper/upgrade';
|
||||
|
||||
const ATTRIBUTES = getSetting< AttributeSetting[] >( 'attributes', [] );
|
||||
|
||||
const noticeContent = {
|
||||
noAttributes: __(
|
||||
'Please select an attribute to use this filter!',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
noProducts: __(
|
||||
'There are no products with the selected attributes.',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
};
|
||||
|
||||
const getNotice: GetNotice = ( type ) => {
|
||||
const content = noticeContent[ type ];
|
||||
return content ? (
|
||||
<Notice status="warning" isDismissible={ false }>
|
||||
<p>{ content }</p>
|
||||
</Notice>
|
||||
) : null;
|
||||
};
|
||||
|
||||
const Edit = ( {
|
||||
attributes,
|
||||
setAttributes,
|
||||
debouncedSpeak,
|
||||
clientId,
|
||||
}: EditProps ) => {
|
||||
const {
|
||||
attributeId,
|
||||
displayStyle,
|
||||
heading,
|
||||
headingLevel,
|
||||
isPreview,
|
||||
queryType,
|
||||
showCounts,
|
||||
showFilterButton,
|
||||
selectType,
|
||||
} = attributes;
|
||||
|
||||
const [ isEditing, setIsEditing ] = useState(
|
||||
! attributeId && ! isPreview
|
||||
);
|
||||
|
||||
const blockProps = useBlockProps();
|
||||
|
||||
const getBlockControls = () => {
|
||||
return (
|
||||
<BlockControls>
|
||||
<ToolbarGroup
|
||||
controls={ [
|
||||
{
|
||||
icon: 'edit',
|
||||
title: __( 'Edit', 'woo-gutenberg-products-block' ),
|
||||
onClick: () => setIsEditing( ! isEditing ),
|
||||
isActive: isEditing,
|
||||
},
|
||||
] }
|
||||
/>
|
||||
</BlockControls>
|
||||
);
|
||||
};
|
||||
|
||||
const onChange = ( selected: SearchListItem[] ) => {
|
||||
if ( ! selected || ! selected.length ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedId = selected[ 0 ].id;
|
||||
const productAttribute = ATTRIBUTES.find(
|
||||
( attribute ) => attribute.attribute_id === selectedId.toString()
|
||||
);
|
||||
|
||||
if ( ! productAttribute || attributeId === selectedId ) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAttributes( {
|
||||
attributeId: selectedId as number,
|
||||
} );
|
||||
};
|
||||
|
||||
const renderAttributeControl = ( {
|
||||
isCompact,
|
||||
}: {
|
||||
isCompact: boolean;
|
||||
} ) => {
|
||||
const messages = {
|
||||
clear: __(
|
||||
'Clear selected attribute',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
list: __( 'Product Attributes', 'woo-gutenberg-products-block' ),
|
||||
noItems: __(
|
||||
"Your store doesn't have any product attributes.",
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
search: __(
|
||||
'Search for a product attribute:',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
selected: ( n: number ) =>
|
||||
sprintf(
|
||||
/* translators: %d is the number of attributes selected. */
|
||||
_n(
|
||||
'%d attribute selected',
|
||||
'%d attributes selected',
|
||||
n,
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
n
|
||||
),
|
||||
updated: __(
|
||||
'Product attribute search results updated.',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
};
|
||||
|
||||
const list = sort(
|
||||
ATTRIBUTES.map( ( item ) => {
|
||||
return {
|
||||
id: parseInt( item.attribute_id, 10 ),
|
||||
name: item.attribute_label,
|
||||
};
|
||||
} )
|
||||
).asc( 'name' );
|
||||
|
||||
return (
|
||||
<SearchListControl
|
||||
className="woocommerce-product-attributes"
|
||||
list={ list }
|
||||
selected={ list.filter( ( { id } ) => id === attributeId ) }
|
||||
onChange={ onChange }
|
||||
messages={ messages }
|
||||
isSingle
|
||||
isCompact={ isCompact }
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const getInspectorControls = () => {
|
||||
return (
|
||||
<InspectorControls key="inspector">
|
||||
<PanelBody
|
||||
title={ __(
|
||||
'Display Settings',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
>
|
||||
<ToggleControl
|
||||
label={ __(
|
||||
'Display product count',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
checked={ showCounts }
|
||||
onChange={ () =>
|
||||
setAttributes( {
|
||||
showCounts: ! showCounts,
|
||||
} )
|
||||
}
|
||||
/>
|
||||
<ToggleGroupControl
|
||||
label={ __(
|
||||
'Allow selecting multiple options?',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
value={ selectType || 'multiple' }
|
||||
onChange={ ( value: string ) =>
|
||||
setAttributes( {
|
||||
selectType: value,
|
||||
} )
|
||||
}
|
||||
className="wc-block-attribute-filter__multiple-toggle"
|
||||
>
|
||||
<ToggleGroupControlOption
|
||||
value="multiple"
|
||||
label={ __(
|
||||
'Multiple',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
/>
|
||||
<ToggleGroupControlOption
|
||||
value="single"
|
||||
label={ __(
|
||||
'Single',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
/>
|
||||
</ToggleGroupControl>
|
||||
{ selectType === 'multiple' && (
|
||||
<ToggleGroupControl
|
||||
label={ __(
|
||||
'Filter Conditions',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
help={
|
||||
queryType === 'and'
|
||||
? __(
|
||||
'Choose to return filter results for all of the attributes selected.',
|
||||
'woo-gutenberg-products-block'
|
||||
)
|
||||
: __(
|
||||
'Choose to return filter results for any of the attributes selected.',
|
||||
'woo-gutenberg-products-block'
|
||||
)
|
||||
}
|
||||
value={ queryType }
|
||||
onChange={ ( value: string ) =>
|
||||
setAttributes( {
|
||||
queryType: value,
|
||||
} )
|
||||
}
|
||||
className="wc-block-attribute-filter__conditions-toggle"
|
||||
>
|
||||
<ToggleGroupControlOption
|
||||
value="and"
|
||||
label={ __(
|
||||
'All',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
/>
|
||||
<ToggleGroupControlOption
|
||||
value="or"
|
||||
label={ __(
|
||||
'Any',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
/>
|
||||
</ToggleGroupControl>
|
||||
) }
|
||||
<ToggleGroupControl
|
||||
label={ __(
|
||||
'Display Style',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
value={ displayStyle }
|
||||
onChange={ ( value: string ) =>
|
||||
setAttributes( {
|
||||
displayStyle: value,
|
||||
} )
|
||||
}
|
||||
className="wc-block-attribute-filter__display-toggle"
|
||||
>
|
||||
<ToggleGroupControlOption
|
||||
value="list"
|
||||
label={ __(
|
||||
'List',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
/>
|
||||
<ToggleGroupControlOption
|
||||
value="dropdown"
|
||||
label={ __(
|
||||
'Dropdown',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
/>
|
||||
</ToggleGroupControl>
|
||||
<ToggleControl
|
||||
label={ __(
|
||||
"Show 'Apply filters' button",
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
help={ __(
|
||||
'Products will update when the button is clicked.',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
checked={ showFilterButton }
|
||||
onChange={ ( value ) =>
|
||||
setAttributes( {
|
||||
showFilterButton: value,
|
||||
} )
|
||||
}
|
||||
/>
|
||||
</PanelBody>
|
||||
<PanelBody
|
||||
title={ __(
|
||||
'Content Settings',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
initialOpen={ false }
|
||||
>
|
||||
{ renderAttributeControl( { isCompact: true } ) }
|
||||
</PanelBody>
|
||||
</InspectorControls>
|
||||
);
|
||||
};
|
||||
|
||||
const noAttributesPlaceholder = () => (
|
||||
<Placeholder
|
||||
className="wc-block-attribute-filter"
|
||||
icon={ <Icon icon={ category } /> }
|
||||
label={ __(
|
||||
'Filter by Attribute',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
instructions={ __(
|
||||
'Display a list of filters based on the selected attributes.',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
>
|
||||
<p>
|
||||
{ __(
|
||||
"Attributes are needed for filtering your products. You haven't created any attributes yet.",
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
</p>
|
||||
<Button
|
||||
className="wc-block-attribute-filter__add-attribute-button"
|
||||
isSecondary
|
||||
href={ getAdminLink(
|
||||
'edit.php?post_type=product&page=product_attributes'
|
||||
) }
|
||||
target="_top"
|
||||
>
|
||||
{ __( 'Add new attribute', 'woo-gutenberg-products-block' ) +
|
||||
' ' }
|
||||
<Icon icon={ external } />
|
||||
</Button>
|
||||
<Button
|
||||
className="wc-block-attribute-filter__read_more_button"
|
||||
isTertiary
|
||||
href="https://docs.woocommerce.com/document/managing-product-taxonomies/"
|
||||
target="_blank"
|
||||
>
|
||||
{ __( 'Learn more', 'woo-gutenberg-products-block' ) }
|
||||
</Button>
|
||||
</Placeholder>
|
||||
);
|
||||
|
||||
const onDone = () => {
|
||||
setIsEditing( false );
|
||||
debouncedSpeak(
|
||||
__(
|
||||
'Now displaying a preview of the Filter Products by Attribute block.',
|
||||
'woo-gutenberg-products-block'
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const renderEditMode = () => {
|
||||
return (
|
||||
<Placeholder
|
||||
className="wc-block-attribute-filter"
|
||||
icon={ <Icon icon={ category } /> }
|
||||
label={ __(
|
||||
'Filter by Attribute',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
>
|
||||
<div className="wc-block-attribute-filter__instructions">
|
||||
{ __(
|
||||
'Display a list of filters based on the selected attributes.',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
</div>
|
||||
<div className="wc-block-attribute-filter__selection">
|
||||
{ renderAttributeControl( { isCompact: false } ) }
|
||||
<Button isPrimary onClick={ onDone }>
|
||||
{ __( 'Done', 'woo-gutenberg-products-block' ) }
|
||||
</Button>
|
||||
</div>
|
||||
</Placeholder>
|
||||
);
|
||||
};
|
||||
|
||||
return Object.keys( ATTRIBUTES ).length === 0 ? (
|
||||
noAttributesPlaceholder()
|
||||
) : (
|
||||
<div { ...blockProps }>
|
||||
{ getBlockControls() }
|
||||
{ getInspectorControls() }
|
||||
<UpgradeNotice
|
||||
clientId={ clientId }
|
||||
attributes={ attributes }
|
||||
setAttributes={ setAttributes }
|
||||
filterType="attribute-filter"
|
||||
/>
|
||||
{ isEditing ? (
|
||||
renderEditMode()
|
||||
) : (
|
||||
<div className={ classnames( 'wc-block-attribute-filter' ) }>
|
||||
{ heading && (
|
||||
<BlockTitle
|
||||
className="wc-block-attribute-filter__title"
|
||||
headingLevel={ headingLevel }
|
||||
heading={ heading }
|
||||
onChange={ ( value: string ) =>
|
||||
setAttributes( { heading: value } )
|
||||
}
|
||||
/>
|
||||
) }
|
||||
<Disabled>
|
||||
<Block
|
||||
attributes={ attributes }
|
||||
isEditor={ true }
|
||||
getNotice={ getNotice }
|
||||
/>
|
||||
</Disabled>
|
||||
</div>
|
||||
) }
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default withSpokenMessages( Edit );
|
||||
@@ -1,53 +0,0 @@
|
||||
.editor-styles-wrapper .wp-block-woocommerce-attribute-filter {
|
||||
// We need to override it because by default the global styles applied the border-style: solid;
|
||||
// Our goal is not to have a border on main wrapper DOM element
|
||||
border-style: none !important;
|
||||
}
|
||||
|
||||
|
||||
.wc-block-attribute-filter {
|
||||
.components-placeholder__instructions {
|
||||
border-bottom: 1px solid #e0e2e6;
|
||||
width: 100%;
|
||||
padding-bottom: 1em;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
.components-placeholder__label svg {
|
||||
fill: currentColor;
|
||||
margin-right: 1ch;
|
||||
}
|
||||
.components-placeholder__fieldset {
|
||||
display: block; /* Disable flex box */
|
||||
}
|
||||
.woocommerce-search-list__search {
|
||||
border-top: 0;
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
.wc-block-attribute-filter__add-attribute-button {
|
||||
margin: 0 0 1em;
|
||||
vertical-align: middle;
|
||||
height: auto;
|
||||
padding: 0.5em 1em;
|
||||
|
||||
svg {
|
||||
fill: currentColor;
|
||||
margin-left: 0.5ch;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
.wc-block-attribute-filter__read_more_button {
|
||||
display: block;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.components-disabled {
|
||||
border-radius: inherit;
|
||||
border-color: inherit;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.wc-block-attribute-filter .components-notice__content {
|
||||
color: $black;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { renderFrontend } from '@woocommerce/base-utils';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import Block from './block';
|
||||
import { parseAttributes } from './utils';
|
||||
|
||||
const getProps = ( el: HTMLElement ) => {
|
||||
return {
|
||||
isEditor: false,
|
||||
attributes: parseAttributes( el.dataset ),
|
||||
};
|
||||
};
|
||||
|
||||
renderFrontend( {
|
||||
selector: '.wp-block-woocommerce-attribute-filter',
|
||||
Block,
|
||||
getProps,
|
||||
} );
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { registerBlockType } from '@wordpress/blocks';
|
||||
import { useBlockProps } from '@wordpress/block-editor';
|
||||
import { Icon, category } from '@wordpress/icons';
|
||||
import classNames from 'classnames';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import edit from './edit';
|
||||
import type { BlockAttributes } from './types';
|
||||
import { blockAttributes } from './attributes';
|
||||
import metadata from './block.json';
|
||||
import deprecated from './deprecated';
|
||||
|
||||
registerBlockType( metadata, {
|
||||
icon: {
|
||||
src: (
|
||||
<Icon
|
||||
icon={ category }
|
||||
className="wc-block-editor-components-block-icon"
|
||||
/>
|
||||
),
|
||||
},
|
||||
supports: {
|
||||
...metadata.supports,
|
||||
},
|
||||
attributes: {
|
||||
...metadata.attributes,
|
||||
...blockAttributes,
|
||||
},
|
||||
edit,
|
||||
// Save the props to post content.
|
||||
save( { attributes }: { attributes: BlockAttributes } ) {
|
||||
const { className } = attributes;
|
||||
|
||||
return (
|
||||
<div
|
||||
{ ...useBlockProps.save( {
|
||||
className: classNames( 'is-loading', className ),
|
||||
} ) }
|
||||
/>
|
||||
);
|
||||
},
|
||||
deprecated,
|
||||
} );
|
||||
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import Label from '@woocommerce/base-components/filter-element-label';
|
||||
import { AttributeObject } from '@woocommerce/types';
|
||||
|
||||
export const previewOptions = [
|
||||
{
|
||||
value: 'preview-1',
|
||||
formattedValue: 'preview-1',
|
||||
name: 'Blue',
|
||||
label: <Label name="Blue" count={ 3 } />,
|
||||
textLabel: 'Blue (3)',
|
||||
},
|
||||
{
|
||||
value: 'preview-2',
|
||||
formattedValue: 'preview-2',
|
||||
name: 'Green',
|
||||
label: <Label name="Green" count={ 3 } />,
|
||||
textLabel: 'Green (3)',
|
||||
},
|
||||
{
|
||||
value: 'preview-3',
|
||||
formattedValue: 'preview-3',
|
||||
name: 'Red',
|
||||
label: <Label name="Red" count={ 2 } />,
|
||||
textLabel: 'Red (2)',
|
||||
},
|
||||
];
|
||||
|
||||
export const previewAttributeObject: AttributeObject = {
|
||||
count: 0,
|
||||
has_archives: true,
|
||||
id: 0,
|
||||
label: 'Preview',
|
||||
name: 'preview',
|
||||
order: 'menu_order',
|
||||
parent: 0,
|
||||
taxonomy: 'preview',
|
||||
type: '',
|
||||
};
|
||||
@@ -1,216 +0,0 @@
|
||||
@import "../shared/styles/style";
|
||||
|
||||
.wp-block-woocommerce-attribute-filter {
|
||||
// We need to override it because by default the global styles applied the border-style: solid;
|
||||
// Our goal is not to have a border on main wrapper DOM element
|
||||
border-style: none !important;
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
text-transform: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-attribute-filter {
|
||||
margin-bottom: $gap;
|
||||
border-radius: inherit;
|
||||
border-color: inherit;
|
||||
|
||||
.is-loading {
|
||||
@include placeholder();
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
height: 1em;
|
||||
margin-top: $gap;
|
||||
}
|
||||
|
||||
&.style-dropdown {
|
||||
@include includeFormTokenFieldFix();
|
||||
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: $gap;
|
||||
align-items: flex-start;
|
||||
|
||||
.wc-block-components-filter-submit-button {
|
||||
height: 36px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
> svg {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-attribute-filter-list {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
|
||||
li {
|
||||
label {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input {
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wc-blocks-components-form-token-field-wrapper {
|
||||
flex-grow: 1;
|
||||
max-width: unset;
|
||||
width: 0;
|
||||
height: max-content;
|
||||
|
||||
&:not(.is-loading) {
|
||||
border: 1px solid $gray-700 !important;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&.is-loading {
|
||||
border-radius: em(4px);
|
||||
}
|
||||
|
||||
.components-form-token-field {
|
||||
border-radius: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-blocks-components-form-token-field-wrapper .components-form-token-field__input-container {
|
||||
@include reset-typography();
|
||||
border: 0;
|
||||
padding: $gap-smaller;
|
||||
border-radius: inherit;
|
||||
|
||||
.components-form-token-field__input {
|
||||
@include font-size(small);
|
||||
|
||||
&::placeholder {
|
||||
color: $black;
|
||||
}
|
||||
}
|
||||
|
||||
.components-form-token-field__suggestions-list {
|
||||
border: 1px solid $gray-700;
|
||||
border-radius: 4px;
|
||||
margin-top: $gap-smaller;
|
||||
max-height: 21em;
|
||||
|
||||
.components-form-token-field__suggestion {
|
||||
color: $black;
|
||||
border: 1px solid $gray-400;
|
||||
border-radius: 4px;
|
||||
margin: $gap-small;
|
||||
padding: $gap-small;
|
||||
}
|
||||
}
|
||||
|
||||
.components-form-token-field__token,
|
||||
.components-form-token-field__suggestion {
|
||||
@include font-size(small);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-attribute-filter__multiple-toggle,
|
||||
.wc-block-attribute-filter__conditions-toggle,
|
||||
.wc-block-attribute-filter__display-toggle {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wc-block-attribute-filter__instructions {
|
||||
padding-bottom: 1em;
|
||||
border-bottom: 1px solid $gray-100;
|
||||
}
|
||||
|
||||
.wc-block-attribute-filter__selection {
|
||||
padding-top: 1em;
|
||||
}
|
||||
|
||||
.woocommerce-product-attributes {
|
||||
.woocommerce-search-list__search {
|
||||
.components-base-control__label {
|
||||
@include reset-typography();
|
||||
@include font-size(regular);
|
||||
color: $gray-700;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-attribute-filter__actions {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: $gap;
|
||||
justify-content: flex-end;
|
||||
|
||||
.wc-block-components-filter-submit-button {
|
||||
margin-left: 0;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: auto;
|
||||
}
|
||||
}
|
||||
|
||||
// The specificity here is needed to overwrite the margin-top that is inherited on WC block template pages such as Shop.
|
||||
.wc-block-filter-submit-button.wc-block-components-filter-submit-button.wc-block-attribute-filter__button {
|
||||
margin-top: 0;
|
||||
@include font-size(small);
|
||||
}
|
||||
}
|
||||
|
||||
.editor-styles-wrapper .wc-block-components-checkbox {
|
||||
margin-top: em($gap);
|
||||
}
|
||||
|
||||
.wc-block-components-checkbox {
|
||||
margin-top: em($gap);
|
||||
}
|
||||
|
||||
.wc-blocks-components-form-token-field-wrapper:not(.single-selection) .components-form-token-field__input-container {
|
||||
padding: $gap-smallest 30px $gap-smallest $gap-smaller;
|
||||
|
||||
.components-form-token-field__token-text {
|
||||
background-color: $white;
|
||||
border: 1px solid;
|
||||
border-right: 0;
|
||||
border-radius: 25px 0 0 25px;
|
||||
padding: em($gap-smallest) em($gap-smaller) em($gap-smallest) em($gap-small);
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
> .components-form-token-field__input {
|
||||
margin: em($gap-smallest) 0;
|
||||
}
|
||||
|
||||
.components-button.components-form-token-field__remove-token {
|
||||
background-color: $white;
|
||||
border: 1px solid;
|
||||
border-left: 0;
|
||||
border-radius: 0 25px 25px 0;
|
||||
padding: 1px em($gap-smallest) 0 0;
|
||||
|
||||
&.has-icon svg {
|
||||
background-color: $gray-200;
|
||||
border-radius: 25px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.editor-styles-wrapper .wc-block-attribute-filter__button.wc-block-attribute-filter__button,
|
||||
.wc-block-attribute-filter__button.wc-block-attribute-filter__button {
|
||||
padding: em($gap-smaller) em($gap);
|
||||
@include font-size(small);
|
||||
height: max-content;
|
||||
width: max-content;
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import * as hooks from '@woocommerce/base-context/hooks';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import AttributeFilterBlock from '../block';
|
||||
import { BlockAttributes } from '../types';
|
||||
|
||||
jest.mock( '@woocommerce/base-context/hooks', () => ( {
|
||||
__esModule: true,
|
||||
...jest.requireActual( '@woocommerce/base-context/hooks' ),
|
||||
} ) );
|
||||
|
||||
const setWindowUrl = ( { url }: { url: string } ) => {
|
||||
global.window = Object.create( window );
|
||||
Object.defineProperty( window, 'location', {
|
||||
value: {
|
||||
href: url,
|
||||
},
|
||||
writable: true,
|
||||
} );
|
||||
};
|
||||
|
||||
const stubProductsAttributesTerms = () => [
|
||||
{
|
||||
id: 25,
|
||||
name: 'Large',
|
||||
slug: 'large',
|
||||
description: '',
|
||||
parent: 0,
|
||||
count: 1,
|
||||
},
|
||||
{
|
||||
id: 26,
|
||||
name: 'Medium',
|
||||
slug: 'medium',
|
||||
description: '',
|
||||
parent: 0,
|
||||
count: 1,
|
||||
},
|
||||
{
|
||||
id: 27,
|
||||
name: 'Small',
|
||||
slug: 'small',
|
||||
description: '',
|
||||
parent: 0,
|
||||
count: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const stubCollectionData = () => ( {
|
||||
price_range: null,
|
||||
attribute_counts: [
|
||||
{
|
||||
term: 25,
|
||||
count: 1,
|
||||
},
|
||||
{
|
||||
term: 26,
|
||||
count: 1,
|
||||
},
|
||||
{
|
||||
term: 27,
|
||||
count: 1,
|
||||
},
|
||||
],
|
||||
rating_counts: null,
|
||||
stock_status_counts: null,
|
||||
} );
|
||||
|
||||
interface SetupParams {
|
||||
initialUrl: string;
|
||||
}
|
||||
|
||||
const setup = ( params: SetupParams ) => {
|
||||
const setupParams: SetupParams = {
|
||||
initialUrl: params.initialUrl || 'https://woo.local',
|
||||
};
|
||||
const url =
|
||||
setupParams.initialUrl ||
|
||||
'http://woo.local/?filter_size=large&query_type_size=or';
|
||||
setWindowUrl( { url } );
|
||||
|
||||
const attributes: BlockAttributes = {
|
||||
attributeId: 2,
|
||||
showCounts: true,
|
||||
queryType: 'or',
|
||||
heading: 'Size',
|
||||
headingLevel: 3,
|
||||
displayStyle: 'list',
|
||||
showFilterButton: true,
|
||||
selectType: 'single',
|
||||
isPreview: false,
|
||||
};
|
||||
jest.spyOn( hooks, 'useCollection' ).mockReturnValue( {
|
||||
results: stubProductsAttributesTerms(),
|
||||
isLoading: false,
|
||||
} );
|
||||
|
||||
jest.spyOn( hooks, 'useCollectionData' ).mockReturnValue( {
|
||||
results: stubCollectionData(),
|
||||
isLoading: false,
|
||||
} );
|
||||
const utils = render( <AttributeFilterBlock attributes={ attributes } /> );
|
||||
const applyButton = screen.getByRole( 'button', { name: /apply/i } );
|
||||
const smallAttributeCheckbox = screen.getByRole( 'checkbox', {
|
||||
name: /small/i,
|
||||
} );
|
||||
|
||||
return {
|
||||
...utils,
|
||||
applyButton,
|
||||
smallAttributeCheckbox,
|
||||
};
|
||||
};
|
||||
|
||||
interface SetupWithSelectedFilterAttributesParams {
|
||||
filterSize: 'large' | 'medium' | 'small';
|
||||
}
|
||||
|
||||
const setupWithSelectedFilterAttributes = (
|
||||
params: SetupWithSelectedFilterAttributesParams
|
||||
) => {
|
||||
const setupParams: SetupWithSelectedFilterAttributesParams = {
|
||||
filterSize: params?.filterSize || 'large',
|
||||
};
|
||||
const utils = setup( {
|
||||
initialUrl: `http://woo.local/?filter_size=${ setupParams.filterSize }&query_type_size=or`,
|
||||
} );
|
||||
|
||||
return {
|
||||
...utils,
|
||||
};
|
||||
};
|
||||
|
||||
const setupWithoutSelectedFilterAttributes = () => {
|
||||
const utils = setup( { initialUrl: 'http://woo.local/' } );
|
||||
|
||||
return {
|
||||
...utils,
|
||||
};
|
||||
};
|
||||
|
||||
describe( 'Filter by Attribute block', () => {
|
||||
describe( 'Given no filter attribute is selected when page loads', () => {
|
||||
test( 'should disable Apply button when page loads', () => {
|
||||
const { applyButton } = setupWithoutSelectedFilterAttributes();
|
||||
|
||||
expect( applyButton ).toBeDisabled();
|
||||
} );
|
||||
|
||||
test( 'should enable Apply button when filter attributes are changed', () => {
|
||||
const { applyButton, smallAttributeCheckbox } =
|
||||
setupWithoutSelectedFilterAttributes();
|
||||
userEvent.click( smallAttributeCheckbox );
|
||||
|
||||
expect( applyButton ).not.toBeDisabled();
|
||||
} );
|
||||
} );
|
||||
|
||||
describe( 'Given filter attribute is already selected when page loads', () => {
|
||||
test( 'should disable Apply button when page loads', () => {
|
||||
const { applyButton } = setupWithSelectedFilterAttributes();
|
||||
|
||||
expect( applyButton ).toBeDisabled();
|
||||
} );
|
||||
|
||||
test( 'should enable Apply button when filter attributes are changed', () => {
|
||||
const { applyButton, smallAttributeCheckbox } =
|
||||
setupWithSelectedFilterAttributes();
|
||||
userEvent.click( smallAttributeCheckbox );
|
||||
|
||||
expect( applyButton ).not.toBeDisabled();
|
||||
} );
|
||||
|
||||
test( 'should disable Apply button when deselecting the same previously selected attribute', () => {
|
||||
const { applyButton, smallAttributeCheckbox } =
|
||||
setupWithSelectedFilterAttributes( { filterSize: 'small' } );
|
||||
userEvent.click( smallAttributeCheckbox );
|
||||
expect( applyButton ).not.toBeDisabled();
|
||||
|
||||
userEvent.click( smallAttributeCheckbox );
|
||||
expect( applyButton ).toBeDisabled();
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import type { ReactElement } from 'react';
|
||||
import type { BlockEditProps } from '@wordpress/blocks';
|
||||
|
||||
export interface BlockAttributes {
|
||||
className?: string;
|
||||
attributeId: number;
|
||||
showCounts: boolean;
|
||||
queryType: string;
|
||||
heading: string;
|
||||
headingLevel: number;
|
||||
displayStyle: string;
|
||||
showFilterButton: boolean;
|
||||
selectType: string;
|
||||
isPreview?: boolean;
|
||||
}
|
||||
|
||||
export interface EditProps extends BlockEditProps< BlockAttributes > {
|
||||
debouncedSpeak: ( label: string ) => void;
|
||||
}
|
||||
|
||||
export interface DisplayOption {
|
||||
value: string;
|
||||
name: string;
|
||||
label: JSX.Element;
|
||||
textLabel: string;
|
||||
formattedValue: string;
|
||||
}
|
||||
|
||||
export type Notices = 'noAttributes' | 'noProducts';
|
||||
export type GetNotice = ( type: Notices ) => ReactElement | null;
|
||||
@@ -1,148 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { addQueryArgs, removeQueryArgs } from '@wordpress/url';
|
||||
import { QueryArgs } from '@wordpress/url/build-types/get-query-args';
|
||||
import {
|
||||
getUrlParameter,
|
||||
PREFIX_QUERY_ARG_FILTER_TYPE,
|
||||
PREFIX_QUERY_ARG_QUERY_TYPE,
|
||||
} from '@woocommerce/utils';
|
||||
import { AttributeObject, isString } from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import metadata from './block.json';
|
||||
|
||||
interface Param {
|
||||
attribute: string;
|
||||
operator: string;
|
||||
slug: Array< string >;
|
||||
}
|
||||
|
||||
export function generateUniqueId() {
|
||||
return Math.floor( Math.random() * Date.now() );
|
||||
}
|
||||
|
||||
export const parseTaxonomyToGenerateURL = ( taxonomy: string ) =>
|
||||
taxonomy.replace( 'pa_', '' );
|
||||
|
||||
/**
|
||||
* Formats filter values into a string for the URL parameters needed for filtering PHP templates.
|
||||
*
|
||||
* @param {string} url Current page URL.
|
||||
* @param {Array} params Parameters and their constraints.
|
||||
*
|
||||
* @return {string} New URL with query parameters in it.
|
||||
*/
|
||||
export const formatParams = ( url: string, params: Array< Param > = [] ) => {
|
||||
const paramObject: Record< string, string > = {};
|
||||
|
||||
params.forEach( ( param ) => {
|
||||
const { attribute, slug, operator } = param;
|
||||
|
||||
// Custom filters are prefix with `pa_` so we need to remove this.
|
||||
const name = parseTaxonomyToGenerateURL( attribute );
|
||||
const values = slug.join( ',' );
|
||||
const queryType = `${ PREFIX_QUERY_ARG_QUERY_TYPE }${ name }`;
|
||||
const type = operator === 'in' ? 'or' : 'and';
|
||||
|
||||
// The URL parameter requires the prefix filter_ with the attribute name.
|
||||
paramObject[ `${ PREFIX_QUERY_ARG_FILTER_TYPE }${ name }` ] = values;
|
||||
paramObject[ queryType ] = type;
|
||||
} );
|
||||
|
||||
// Clean the URL before we add our new query parameters to it.
|
||||
const cleanUrl = removeQueryArgs( url, ...Object.keys( paramObject ) );
|
||||
|
||||
return addQueryArgs( cleanUrl, paramObject );
|
||||
};
|
||||
|
||||
export const areAllFiltersRemoved = ( {
|
||||
currentCheckedFilters,
|
||||
hasSetFilterDefaultsFromUrl,
|
||||
}: {
|
||||
currentCheckedFilters: Array< string >;
|
||||
hasSetFilterDefaultsFromUrl: boolean;
|
||||
} ) => hasSetFilterDefaultsFromUrl && currentCheckedFilters.length === 0;
|
||||
|
||||
export const getActiveFilters = (
|
||||
attributeObject: AttributeObject | undefined
|
||||
) => {
|
||||
if ( attributeObject ) {
|
||||
const defaultAttributeParam = getUrlParameter(
|
||||
`filter_${ attributeObject.name }`
|
||||
);
|
||||
const defaultCheckedValue =
|
||||
typeof defaultAttributeParam === 'string'
|
||||
? defaultAttributeParam.split( ',' )
|
||||
: [];
|
||||
|
||||
return defaultCheckedValue.map( ( value ) =>
|
||||
encodeURIComponent( value ).toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
export const isQueryArgsEqual = (
|
||||
currentQueryArgs: QueryArgs,
|
||||
newQueryArgs: QueryArgs
|
||||
) => {
|
||||
// The user can add same two filter blocks for the same attribute.
|
||||
// We removed the query type from the check to avoid refresh loop.
|
||||
const filteredNewQueryArgs = Object.entries( newQueryArgs ).reduce(
|
||||
( acc, [ key, value ] ) => {
|
||||
return key.includes( 'query_type' )
|
||||
? acc
|
||||
: {
|
||||
...acc,
|
||||
[ key ]: value,
|
||||
};
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
return Object.entries( filteredNewQueryArgs ).reduce(
|
||||
( isEqual, [ key, value ] ) =>
|
||||
currentQueryArgs[ key ] === value ? isEqual : false,
|
||||
true
|
||||
);
|
||||
};
|
||||
|
||||
export const formatSlug = ( slug: string ) =>
|
||||
slug
|
||||
.trim()
|
||||
.replace( /\s/g, '-' )
|
||||
.replace( /_/g, '-' )
|
||||
.replace( /-+/g, '-' )
|
||||
.replace( /[^a-zA-Z0-9-]/g, '' );
|
||||
|
||||
export const parseAttributes = ( data: Record< string, unknown > ) => {
|
||||
return {
|
||||
className: isString( data?.className ) ? data.className : '',
|
||||
attributeId: parseInt(
|
||||
isString( data?.attributeId ) ? data.attributeId : '0',
|
||||
10
|
||||
),
|
||||
showCounts: data?.showCounts === 'true',
|
||||
queryType:
|
||||
( isString( data?.queryType ) && data.queryType ) ||
|
||||
metadata.attributes.queryType.default,
|
||||
heading: isString( data?.heading ) ? data.heading : '',
|
||||
headingLevel:
|
||||
( isString( data?.headingLevel ) &&
|
||||
parseInt( data.headingLevel, 10 ) ) ||
|
||||
metadata.attributes.headingLevel.default,
|
||||
displayStyle:
|
||||
( isString( data?.displayStyle ) && data.displayStyle ) ||
|
||||
metadata.attributes.displayStyle.default,
|
||||
showFilterButton: data?.showFilterButton === 'true',
|
||||
selectType:
|
||||
( isString( data?.selectType ) && data.selectType ) ||
|
||||
metadata.attributes.selectType.default,
|
||||
isPreview: false,
|
||||
};
|
||||
};
|
||||
@@ -1,36 +0,0 @@
|
||||
{
|
||||
"name": "woocommerce/breadcrumbs",
|
||||
"version": "1.0.0",
|
||||
"title": "Store Breadcrumbs",
|
||||
"description": "Enable customers to keep track of their location within the store and navigate back to parent pages.",
|
||||
"category": "woocommerce",
|
||||
"keywords": [ "WooCommerce" ],
|
||||
"textdomain": "woocommerce",
|
||||
"attributes": {
|
||||
"contentJustification": {
|
||||
"type": "string"
|
||||
},
|
||||
"fontSize": {
|
||||
"type": "string",
|
||||
"default": "small"
|
||||
},
|
||||
"align": {
|
||||
"type": "string",
|
||||
"default": "wide"
|
||||
}
|
||||
},
|
||||
"supports": {
|
||||
"align": [ "wide", "full" ],
|
||||
"color": {
|
||||
"background": false,
|
||||
"link": true
|
||||
},
|
||||
"html": false,
|
||||
"typography": {
|
||||
"fontSize": true,
|
||||
"lineHeight": true
|
||||
}
|
||||
},
|
||||
"apiVersion": 2,
|
||||
"$schema": "https://schemas.wp.org/trunk/block.json"
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useBlockProps } from '@wordpress/block-editor';
|
||||
import { Disabled } from '@wordpress/components';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
|
||||
export interface Attributes {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Edit = () => {
|
||||
const blockProps = useBlockProps( {
|
||||
className: 'woocommerce wc-block-breadcrumbs',
|
||||
} );
|
||||
|
||||
return (
|
||||
<div { ...blockProps }>
|
||||
<Disabled>
|
||||
<a href="/">
|
||||
{ __( 'Breadcrumbs', 'woo-gutenberg-products-block' ) }
|
||||
</a>
|
||||
{ __( ' / Navigation / Path', 'woo-gutenberg-products-block' ) }
|
||||
</Disabled>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Edit;
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { SVG, Rect } from '@wordpress/primitives';
|
||||
|
||||
export const queryPaginationIcon = (
|
||||
<SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<Rect
|
||||
x="4"
|
||||
y="10.5"
|
||||
width="6"
|
||||
height="3"
|
||||
rx="1.5"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<Rect
|
||||
x="12"
|
||||
y="10.5"
|
||||
width="3"
|
||||
height="3"
|
||||
rx="1.5"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<Rect
|
||||
x="17"
|
||||
y="10.5"
|
||||
width="3"
|
||||
height="3"
|
||||
rx="1.5"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</SVG>
|
||||
);
|
||||
@@ -1,51 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { registerBlockType } from '@wordpress/blocks';
|
||||
import { isFeaturePluginBuild } from '@woocommerce/block-settings';
|
||||
import { Icon } from '@wordpress/icons';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import metadata from './block.json';
|
||||
import edit from './edit';
|
||||
import { queryPaginationIcon } from './icon';
|
||||
import './style.scss';
|
||||
|
||||
const featurePluginSupport = {
|
||||
...metadata.supports,
|
||||
...( isFeaturePluginBuild() && {
|
||||
typography: {
|
||||
...metadata.supports.typography,
|
||||
__experimentalFontFamily: true,
|
||||
__experimentalFontStyle: true,
|
||||
__experimentalFontWeight: true,
|
||||
__experimentalTextTransform: true,
|
||||
__experimentalDefaultControls: {
|
||||
fontSize: true,
|
||||
},
|
||||
},
|
||||
} ),
|
||||
};
|
||||
|
||||
registerBlockType( metadata, {
|
||||
icon: {
|
||||
src: (
|
||||
<Icon
|
||||
icon={ queryPaginationIcon }
|
||||
className="wc-block-editor-components-block-icon"
|
||||
/>
|
||||
),
|
||||
},
|
||||
attributes: {
|
||||
...metadata.attributes,
|
||||
},
|
||||
supports: {
|
||||
...featurePluginSupport,
|
||||
},
|
||||
edit,
|
||||
save() {
|
||||
return null;
|
||||
},
|
||||
} );
|
||||
@@ -1,9 +0,0 @@
|
||||
.woocommerce.wc-block-breadcrumbs {
|
||||
font-size: inherit;
|
||||
}
|
||||
.woocommerce.woocommerce-shop .wc-block-breadcrumbs {
|
||||
.woocommerce-breadcrumb {
|
||||
margin: auto;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { InspectorControls } from '@wordpress/block-editor';
|
||||
import { PanelBody, ToggleControl } from '@wordpress/components';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import type { BlockAttributes } from '@wordpress/blocks';
|
||||
|
||||
export const BlockSettings = ( {
|
||||
attributes,
|
||||
setAttributes,
|
||||
}: {
|
||||
attributes: BlockAttributes;
|
||||
setAttributes: ( attrs: BlockAttributes ) => void;
|
||||
} ) => {
|
||||
const { hasDarkControls } = attributes;
|
||||
return (
|
||||
<InspectorControls>
|
||||
<PanelBody title={ __( 'Style', 'woo-gutenberg-products-block' ) }>
|
||||
<ToggleControl
|
||||
label={ __(
|
||||
'Dark mode inputs',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
help={ __(
|
||||
'Inputs styled specifically for use on dark background colors.',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
checked={ hasDarkControls }
|
||||
onChange={ () =>
|
||||
setAttributes( {
|
||||
hasDarkControls: ! hasDarkControls,
|
||||
} )
|
||||
}
|
||||
/>
|
||||
</PanelBody>
|
||||
</InspectorControls>
|
||||
);
|
||||
};
|
||||
@@ -1,46 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { getBlockTypes } from '@wordpress/blocks';
|
||||
import { applyCheckoutFilter } from '@woocommerce/blocks-checkout';
|
||||
import { CART_STORE_KEY } from '@woocommerce/block-data';
|
||||
import { select } from '@wordpress/data';
|
||||
|
||||
// List of core block types to allow in inner block areas.
|
||||
const coreBlockTypes = [ 'core/paragraph', 'core/image', 'core/separator' ];
|
||||
|
||||
/**
|
||||
* Gets a list of allowed blocks types under a specific parent block type.
|
||||
*/
|
||||
export const getAllowedBlocks = ( block: string ): string[] => {
|
||||
const additionalCartCheckoutInnerBlockTypes = applyCheckoutFilter( {
|
||||
filterName: 'additionalCartCheckoutInnerBlockTypes',
|
||||
defaultValue: [],
|
||||
extensions: select( CART_STORE_KEY ).getCartData().extensions,
|
||||
arg: { block },
|
||||
validation: ( value ) => {
|
||||
if (
|
||||
Array.isArray( value ) &&
|
||||
value.every( ( item ) => typeof item === 'string' )
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
throw new Error(
|
||||
'allowedBlockTypes filters must return an array of strings.'
|
||||
);
|
||||
},
|
||||
} );
|
||||
|
||||
// Convert to set here so that we remove duplicated block types.
|
||||
return Array.from(
|
||||
new Set( [
|
||||
...getBlockTypes()
|
||||
.filter( ( blockType ) =>
|
||||
( blockType?.parent || [] ).includes( block )
|
||||
)
|
||||
.map( ( { name } ) => name ),
|
||||
...coreBlockTypes,
|
||||
...additionalCartCheckoutInnerBlockTypes,
|
||||
] )
|
||||
);
|
||||
};
|
||||
@@ -1,177 +0,0 @@
|
||||
/**
|
||||
* HACKS
|
||||
*
|
||||
* This file contains functionality to "lock" blocks i.e. to prevent blocks being moved or deleted. This needs to be
|
||||
* kept in place until native support for locking is available in WordPress (estimated WordPress 5.9).
|
||||
*/
|
||||
|
||||
/**
|
||||
* @todo Remove custom block locking (requires native WordPress support)
|
||||
*/
|
||||
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import {
|
||||
useBlockProps,
|
||||
store as blockEditorStore,
|
||||
} from '@wordpress/block-editor';
|
||||
import { isTextField } from '@wordpress/dom';
|
||||
import { subscribe, select as _select } from '@wordpress/data';
|
||||
import { useEffect, useRef } from '@wordpress/element';
|
||||
import { BACKSPACE, DELETE } from '@wordpress/keycodes';
|
||||
import { hasFilter } from '@wordpress/hooks';
|
||||
import { getBlockType } from '@wordpress/blocks';
|
||||
import type { MutableRefObject } from 'react';
|
||||
|
||||
/**
|
||||
* Toggle class on body.
|
||||
*
|
||||
* @param {string} className CSS Class name.
|
||||
* @param {boolean} add True to add, false to remove.
|
||||
*/
|
||||
const toggleBodyClass = ( className: string, add = true ) => {
|
||||
if ( add ) {
|
||||
window.document.body.classList.add( className );
|
||||
} else {
|
||||
window.document.body.classList.remove( className );
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* addClassToBody
|
||||
*
|
||||
* This components watches the current selected block and adds a class name to the body if that block is locked. If the
|
||||
* current block is not locked, it removes the class name. The appended body class is used to hide UI elements to prevent
|
||||
* the block from being deleted.
|
||||
*
|
||||
* We use a component so we can react to changes in the store.
|
||||
*/
|
||||
export const addClassToBody = (): void => {
|
||||
if ( ! hasFilter( 'blocks.registerBlockType', 'core/lock/addAttribute' ) ) {
|
||||
subscribe( () => {
|
||||
const blockEditorSelect = _select( blockEditorStore );
|
||||
|
||||
if ( ! blockEditorSelect ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedBlock = blockEditorSelect.getSelectedBlock();
|
||||
|
||||
if ( ! selectedBlock ) {
|
||||
return;
|
||||
}
|
||||
|
||||
toggleBodyClass(
|
||||
'wc-lock-selected-block--remove',
|
||||
!! selectedBlock?.attributes?.lock?.remove
|
||||
);
|
||||
|
||||
toggleBodyClass(
|
||||
'wc-lock-selected-block--move',
|
||||
!! selectedBlock?.attributes?.lock?.move
|
||||
);
|
||||
} );
|
||||
}
|
||||
};
|
||||
|
||||
const isBlockLocked = ( clientId: string ): boolean => {
|
||||
if ( ! clientId ) {
|
||||
return false;
|
||||
}
|
||||
const { getBlock } = _select( blockEditorStore );
|
||||
const block = getBlock( clientId );
|
||||
// If lock.remove is defined at the block instance (not using the default value)
|
||||
// Then we use it.
|
||||
if ( typeof block?.attributes?.lock?.remove === 'boolean' ) {
|
||||
return block.attributes.lock.remove;
|
||||
}
|
||||
|
||||
// If we don't have lock on the block instance, we check the type
|
||||
const blockType = getBlockType( block.name );
|
||||
if ( typeof blockType?.attributes?.lock?.default?.remove === 'boolean' ) {
|
||||
return blockType?.attributes?.lock?.default?.remove;
|
||||
}
|
||||
// If nothing is defined, return false
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* This is a hook we use in conjunction with useBlockProps. Its goal is to check if of the block's children is locked and being deleted.
|
||||
* It will stop the keydown event from propagating to stop it from being deleted via the keyboard.
|
||||
*
|
||||
*/
|
||||
const useLockedChildren = ( {
|
||||
ref,
|
||||
}: {
|
||||
ref: MutableRefObject< HTMLElement | undefined >;
|
||||
} ): void => {
|
||||
const lockInCore = hasFilter(
|
||||
'blocks.registerBlockType',
|
||||
'core/lock/addAttribute'
|
||||
);
|
||||
|
||||
const node = ref.current;
|
||||
return useEffect( () => {
|
||||
if ( ! node || lockInCore ) {
|
||||
return;
|
||||
}
|
||||
function onKeyDown( event: KeyboardEvent ) {
|
||||
const { keyCode, target } = event;
|
||||
|
||||
if ( ! ( target instanceof HTMLElement ) ) {
|
||||
return;
|
||||
}
|
||||
// We're not trying to delete something here.
|
||||
if ( keyCode !== BACKSPACE && keyCode !== DELETE ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// We're in a field, so we should let text be deleted.
|
||||
if ( isTextField( target ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Typecast to fix issue with isTextField.
|
||||
const targetNode = target as HTMLElement;
|
||||
|
||||
// Our target isn't a block.
|
||||
if ( targetNode.dataset.block === undefined ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clientId = targetNode.dataset.block;
|
||||
const isLocked = isBlockLocked( clientId );
|
||||
// Prevent the keyboard event from propogating if it supports locking.
|
||||
if ( isLocked ) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
}
|
||||
node.addEventListener( 'keydown', onKeyDown, {
|
||||
capture: true,
|
||||
passive: false,
|
||||
} );
|
||||
|
||||
return () => {
|
||||
node.removeEventListener( 'keydown', onKeyDown, {
|
||||
capture: true,
|
||||
} );
|
||||
};
|
||||
}, [ node, lockInCore ] );
|
||||
};
|
||||
|
||||
/**
|
||||
* This hook is a light wrapper to useBlockProps, it wraps that hook plus useLockBlock to pass data between them.
|
||||
*/
|
||||
export const useBlockPropsWithLocking = (
|
||||
props: Record< string, unknown > = {}
|
||||
): Record< string, unknown > => {
|
||||
const ref = useRef< HTMLElement >();
|
||||
const blockProps = useBlockProps( { ref, ...props } );
|
||||
useLockedChildren( {
|
||||
ref,
|
||||
} );
|
||||
return blockProps;
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
const expressIcon = (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke="#1E1E1E"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M18.25 12a6.25 6.25 0 1 1-12.5 0 6.25 6.25 0 0 1 12.5 0Z"
|
||||
/>
|
||||
<path fill="#1E1E1E" d="M10 3h4v3h-4z" />
|
||||
<rect width="1.5" height="5" x="11.25" y="8" fill="#1E1E1E" rx=".75" />
|
||||
<path
|
||||
fill="#1E1E1E"
|
||||
d="m15.7 4.816 1.66 1.078-1.114 1.718-1.661-1.078z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default expressIcon;
|
||||
@@ -1,5 +0,0 @@
|
||||
export * from './hacks';
|
||||
export * from './use-forced-layout';
|
||||
export * from './editor-utils';
|
||||
export * from './sidebar-notices';
|
||||
export * from './block-settings';
|
||||
@@ -1,175 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import {
|
||||
useExpressPaymentMethods,
|
||||
usePaymentMethodInterface,
|
||||
} from '@woocommerce/base-context/hooks';
|
||||
import {
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from '@wordpress/element';
|
||||
import { useEditorContext } from '@woocommerce/base-context';
|
||||
import deprecated from '@wordpress/deprecated';
|
||||
import { useDispatch, useSelect } from '@wordpress/data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import PaymentMethodErrorBoundary from './payment-method-error-boundary';
|
||||
import { STORE_KEY as PAYMENT_STORE_KEY } from '../../../data/payment/constants';
|
||||
|
||||
const ExpressPaymentMethods = () => {
|
||||
const { isEditor } = useEditorContext();
|
||||
|
||||
const { activePaymentMethod, paymentMethodData } = useSelect(
|
||||
( select ) => {
|
||||
const store = select( PAYMENT_STORE_KEY );
|
||||
return {
|
||||
activePaymentMethod: store.getActivePaymentMethod(),
|
||||
paymentMethodData: store.getPaymentMethodData(),
|
||||
};
|
||||
}
|
||||
);
|
||||
const {
|
||||
__internalSetActivePaymentMethod,
|
||||
__internalSetExpressPaymentStarted,
|
||||
__internalSetPaymentIdle,
|
||||
__internalSetPaymentError,
|
||||
__internalSetPaymentMethodData,
|
||||
__internalSetExpressPaymentError,
|
||||
} = useDispatch( PAYMENT_STORE_KEY );
|
||||
const { paymentMethods } = useExpressPaymentMethods();
|
||||
|
||||
const paymentMethodInterface = usePaymentMethodInterface();
|
||||
const previousActivePaymentMethod = useRef( activePaymentMethod );
|
||||
const previousPaymentMethodData = useRef( paymentMethodData );
|
||||
|
||||
/**
|
||||
* onExpressPaymentClick should be triggered when the express payment button is clicked.
|
||||
*
|
||||
* This will store the previous active payment method, set the express method as active, and set the payment status
|
||||
* to started.
|
||||
*/
|
||||
const onExpressPaymentClick = useCallback(
|
||||
( paymentMethodId ) => () => {
|
||||
previousActivePaymentMethod.current = activePaymentMethod;
|
||||
previousPaymentMethodData.current = paymentMethodData;
|
||||
__internalSetExpressPaymentStarted();
|
||||
__internalSetActivePaymentMethod( paymentMethodId );
|
||||
},
|
||||
[
|
||||
activePaymentMethod,
|
||||
paymentMethodData,
|
||||
__internalSetActivePaymentMethod,
|
||||
__internalSetExpressPaymentStarted,
|
||||
]
|
||||
);
|
||||
|
||||
/**
|
||||
* onExpressPaymentClose should be triggered when the express payment process is cancelled or closed.
|
||||
*
|
||||
* This restores the active method and returns the state to pristine.
|
||||
*/
|
||||
const onExpressPaymentClose = useCallback( () => {
|
||||
__internalSetPaymentIdle();
|
||||
__internalSetActivePaymentMethod(
|
||||
previousActivePaymentMethod.current,
|
||||
previousPaymentMethodData.current
|
||||
);
|
||||
}, [ __internalSetActivePaymentMethod, __internalSetPaymentIdle ] );
|
||||
|
||||
/**
|
||||
* onExpressPaymentError should be triggered when the express payment process errors.
|
||||
*
|
||||
* This shows an error message then restores the active method and returns the state to pristine.
|
||||
*/
|
||||
const onExpressPaymentError = useCallback(
|
||||
( errorMessage ) => {
|
||||
__internalSetPaymentError();
|
||||
__internalSetPaymentMethodData( errorMessage );
|
||||
__internalSetExpressPaymentError( errorMessage );
|
||||
__internalSetActivePaymentMethod(
|
||||
previousActivePaymentMethod.current,
|
||||
previousPaymentMethodData.current
|
||||
);
|
||||
},
|
||||
[
|
||||
__internalSetActivePaymentMethod,
|
||||
__internalSetPaymentError,
|
||||
__internalSetPaymentMethodData,
|
||||
__internalSetExpressPaymentError,
|
||||
]
|
||||
);
|
||||
|
||||
/**
|
||||
* Calling setExpressPaymentError directly is deprecated.
|
||||
*/
|
||||
const deprecatedSetExpressPaymentError = useCallback(
|
||||
( errorMessage = '' ) => {
|
||||
deprecated(
|
||||
'Express Payment Methods should use the provided onError handler instead.',
|
||||
{
|
||||
alternative: 'onError',
|
||||
plugin: 'woocommerce-gutenberg-products-block',
|
||||
link: 'https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/4228',
|
||||
}
|
||||
);
|
||||
if ( errorMessage ) {
|
||||
onExpressPaymentError( errorMessage );
|
||||
} else {
|
||||
__internalSetExpressPaymentError( '' );
|
||||
}
|
||||
},
|
||||
[ __internalSetExpressPaymentError, onExpressPaymentError ]
|
||||
);
|
||||
|
||||
/**
|
||||
* @todo Find a way to Memoize Express Payment Method Content
|
||||
*
|
||||
* Payment method content could potentially become a bottleneck if lots of logic is ran in the content component. It
|
||||
* Currently re-renders excessively but is not easy to useMemo because paymentMethodInterface could become stale.
|
||||
* paymentMethodInterface itself also updates on most renders.
|
||||
*/
|
||||
const entries = Object.entries( paymentMethods );
|
||||
const content =
|
||||
entries.length > 0 ? (
|
||||
entries.map( ( [ id, paymentMethod ] ) => {
|
||||
const expressPaymentMethod = isEditor
|
||||
? paymentMethod.edit
|
||||
: paymentMethod.content;
|
||||
return isValidElement( expressPaymentMethod ) ? (
|
||||
<li key={ id } id={ `express-payment-method-${ id }` }>
|
||||
{ cloneElement( expressPaymentMethod, {
|
||||
...paymentMethodInterface,
|
||||
onClick: onExpressPaymentClick( id ),
|
||||
onClose: onExpressPaymentClose,
|
||||
onError: onExpressPaymentError,
|
||||
setExpressPaymentError:
|
||||
deprecatedSetExpressPaymentError,
|
||||
} ) }
|
||||
</li>
|
||||
) : null;
|
||||
} )
|
||||
) : (
|
||||
<li key="noneRegistered">
|
||||
{ __(
|
||||
'No registered Payment Methods',
|
||||
'woocommerce'
|
||||
) }
|
||||
</li>
|
||||
);
|
||||
|
||||
return (
|
||||
<PaymentMethodErrorBoundary isEditor={ isEditor }>
|
||||
<ul className="wc-block-components-express-payment__event-buttons">
|
||||
{ content }
|
||||
</ul>
|
||||
</PaymentMethodErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExpressPaymentMethods;
|
||||
@@ -1,82 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { useExpressPaymentMethods } from '@woocommerce/base-context/hooks';
|
||||
import { noticeContexts } from '@woocommerce/base-context';
|
||||
import { StoreNoticesContainer } from '@woocommerce/blocks-components';
|
||||
import LoadingMask from '@woocommerce/base-components/loading-mask';
|
||||
import { useSelect } from '@wordpress/data';
|
||||
import { CHECKOUT_STORE_KEY, PAYMENT_STORE_KEY } from '@woocommerce/block-data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import ExpressPaymentMethods from '../express-payment-methods';
|
||||
import './style.scss';
|
||||
|
||||
const CartExpressPayment = () => {
|
||||
const { paymentMethods, isInitialized } = useExpressPaymentMethods();
|
||||
const {
|
||||
isCalculating,
|
||||
isProcessing,
|
||||
isAfterProcessing,
|
||||
isBeforeProcessing,
|
||||
isComplete,
|
||||
hasError,
|
||||
} = useSelect( ( select ) => {
|
||||
const store = select( CHECKOUT_STORE_KEY );
|
||||
return {
|
||||
isCalculating: store.isCalculating(),
|
||||
isProcessing: store.isProcessing(),
|
||||
isAfterProcessing: store.isAfterProcessing(),
|
||||
isBeforeProcessing: store.isBeforeProcessing(),
|
||||
isComplete: store.isComplete(),
|
||||
hasError: store.hasError(),
|
||||
};
|
||||
} );
|
||||
const isExpressPaymentMethodActive = useSelect( ( select ) =>
|
||||
select( PAYMENT_STORE_KEY ).isExpressPaymentMethodActive()
|
||||
);
|
||||
|
||||
if (
|
||||
! isInitialized ||
|
||||
( isInitialized && Object.keys( paymentMethods ).length === 0 )
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Set loading state for express payment methods when payment or checkout is in progress.
|
||||
const checkoutProcessing =
|
||||
isProcessing ||
|
||||
isAfterProcessing ||
|
||||
isBeforeProcessing ||
|
||||
( isComplete && ! hasError );
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoadingMask
|
||||
isLoading={
|
||||
isCalculating ||
|
||||
checkoutProcessing ||
|
||||
isExpressPaymentMethodActive
|
||||
}
|
||||
>
|
||||
<div className="wc-block-components-express-payment wc-block-components-express-payment--cart">
|
||||
<div className="wc-block-components-express-payment__content">
|
||||
<StoreNoticesContainer
|
||||
context={ noticeContexts.EXPRESS_PAYMENTS }
|
||||
/>
|
||||
<ExpressPaymentMethods />
|
||||
</div>
|
||||
</div>
|
||||
</LoadingMask>
|
||||
<div className="wc-block-components-express-payment-continue-rule wc-block-components-express-payment-continue-rule--cart">
|
||||
{ /* translators: Shown in the Cart block between the express payment methods and the Proceed to Checkout button */ }
|
||||
{ __( 'Or', 'woocommerce' ) }
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CartExpressPayment;
|
||||
@@ -1,113 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { useEditorContext, noticeContexts } from '@woocommerce/base-context';
|
||||
import { Title, StoreNoticesContainer } from '@woocommerce/blocks-components';
|
||||
import LoadingMask from '@woocommerce/base-components/loading-mask';
|
||||
import { CURRENT_USER_IS_ADMIN } from '@woocommerce/settings';
|
||||
import { CHECKOUT_STORE_KEY, PAYMENT_STORE_KEY } from '@woocommerce/block-data';
|
||||
import { useSelect } from '@wordpress/data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import ExpressPaymentMethods from '../express-payment-methods';
|
||||
import './style.scss';
|
||||
|
||||
const CheckoutExpressPayment = () => {
|
||||
const {
|
||||
isCalculating,
|
||||
isProcessing,
|
||||
isAfterProcessing,
|
||||
isBeforeProcessing,
|
||||
isComplete,
|
||||
hasError,
|
||||
} = useSelect( ( select ) => {
|
||||
const store = select( CHECKOUT_STORE_KEY );
|
||||
return {
|
||||
isCalculating: store.isCalculating(),
|
||||
isProcessing: store.isProcessing(),
|
||||
isAfterProcessing: store.isAfterProcessing(),
|
||||
isBeforeProcessing: store.isBeforeProcessing(),
|
||||
isComplete: store.isComplete(),
|
||||
hasError: store.hasError(),
|
||||
};
|
||||
} );
|
||||
const {
|
||||
availableExpressPaymentMethods,
|
||||
expressPaymentMethodsInitialized,
|
||||
isExpressPaymentMethodActive,
|
||||
} = useSelect( ( select ) => {
|
||||
const store = select( PAYMENT_STORE_KEY );
|
||||
return {
|
||||
availableExpressPaymentMethods:
|
||||
store.getAvailableExpressPaymentMethods(),
|
||||
expressPaymentMethodsInitialized:
|
||||
store.expressPaymentMethodsInitialized(),
|
||||
isExpressPaymentMethodActive: store.isExpressPaymentMethodActive(),
|
||||
};
|
||||
} );
|
||||
const { isEditor } = useEditorContext();
|
||||
|
||||
if (
|
||||
! expressPaymentMethodsInitialized ||
|
||||
( expressPaymentMethodsInitialized &&
|
||||
Object.keys( availableExpressPaymentMethods ).length === 0 )
|
||||
) {
|
||||
// Make sure errors are shown in the editor and for admins. For example,
|
||||
// when a payment method fails to register.
|
||||
if ( isEditor || CURRENT_USER_IS_ADMIN ) {
|
||||
return (
|
||||
<StoreNoticesContainer
|
||||
context={ noticeContexts.EXPRESS_PAYMENTS }
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Set loading state for express payment methods when payment or checkout is in progress.
|
||||
const checkoutProcessing =
|
||||
isProcessing ||
|
||||
isAfterProcessing ||
|
||||
isBeforeProcessing ||
|
||||
( isComplete && ! hasError );
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoadingMask
|
||||
isLoading={
|
||||
isCalculating ||
|
||||
checkoutProcessing ||
|
||||
isExpressPaymentMethodActive
|
||||
}
|
||||
>
|
||||
<div className="wc-block-components-express-payment wc-block-components-express-payment--checkout">
|
||||
<div className="wc-block-components-express-payment__title-container">
|
||||
<Title
|
||||
className="wc-block-components-express-payment__title"
|
||||
headingLevel="2"
|
||||
>
|
||||
{ __(
|
||||
'Express Checkout',
|
||||
'woocommerce'
|
||||
) }
|
||||
</Title>
|
||||
</div>
|
||||
<div className="wc-block-components-express-payment__content">
|
||||
<StoreNoticesContainer
|
||||
context={ noticeContexts.EXPRESS_PAYMENTS }
|
||||
/>
|
||||
<ExpressPaymentMethods />
|
||||
</div>
|
||||
</div>
|
||||
</LoadingMask>
|
||||
<div className="wc-block-components-express-payment-continue-rule wc-block-components-express-payment-continue-rule--checkout">
|
||||
{ __( 'Or continue below', 'woocommerce' ) }
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CheckoutExpressPayment;
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default as CartExpressPayment } from './cart-express-payment.js';
|
||||
export { default as CheckoutExpressPayment } from './checkout-express-payment.js';
|
||||
@@ -1,152 +0,0 @@
|
||||
$border-width: 1px;
|
||||
|
||||
.wc-block-components-express-payment {
|
||||
margin: auto;
|
||||
position: relative;
|
||||
|
||||
// nested class to avoid conflict with .editor-styles-wrapper ul
|
||||
.wc-block-components-express-payment__event-buttons {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
> li {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
|
||||
> img {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-components-express-payment--checkout {
|
||||
/* stylelint-disable-next-line function-calc-no-unspaced-operator */
|
||||
margin-top: calc($universal-border-radius * 3);
|
||||
|
||||
.wc-block-components-express-payment__event-buttons {
|
||||
list-style: none;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(calc(33% - 10px), 1fr));
|
||||
grid-gap: 10px;
|
||||
|
||||
@include breakpoint("<782px") {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-components-express-payment__title-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: -$universal-border-radius;
|
||||
vertical-align: middle;
|
||||
|
||||
// Pseudo-elements used to show the border before and after the title.
|
||||
&::before {
|
||||
border-left: $border-width solid currentColor;
|
||||
border-top: $border-width solid currentColor;
|
||||
border-radius: $universal-border-radius 0 0 0;
|
||||
content: "";
|
||||
display: block;
|
||||
height: $universal-border-radius - $border-width;
|
||||
margin-right: $gap-small;
|
||||
opacity: 0.3;
|
||||
pointer-events: none;
|
||||
width: #{$gap-large - $gap-small - $border-width * 2};
|
||||
}
|
||||
|
||||
&::after {
|
||||
border-right: $border-width solid currentColor;
|
||||
border-top: $border-width solid currentColor;
|
||||
border-radius: 0 $universal-border-radius 0 0;
|
||||
content: "";
|
||||
display: block;
|
||||
height: $universal-border-radius - $border-width;
|
||||
margin-left: $gap-small;
|
||||
opacity: 0.3;
|
||||
pointer-events: none;
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-components-express-payment__title {
|
||||
flex-grow: 0;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.wc-block-components-express-payment__content {
|
||||
@include with-translucent-border(0 $border-width $border-width);
|
||||
padding: #{$gap-large - $universal-border-radius} $gap-large $gap-large;
|
||||
|
||||
&::after {
|
||||
border-radius: 0 0 $universal-border-radius $universal-border-radius;
|
||||
}
|
||||
|
||||
> p {
|
||||
margin-bottom: em($gap);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-components-express-payment--cart {
|
||||
.wc-block-components-express-payment__event-buttons {
|
||||
> li {
|
||||
padding-bottom: $gap;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
|
||||
&:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-components-express-payment-continue-rule {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 0 $gap-large;
|
||||
margin: $gap-large 0;
|
||||
|
||||
&::before {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: " ";
|
||||
flex: 1;
|
||||
border-bottom: 1px solid;
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-components-express-payment-continue-rule--cart {
|
||||
margin: $gap 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.theme-twentynineteen {
|
||||
.wc-block-components-express-payment__title::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// For Twenty Twenty we need to increase specificity of the title.
|
||||
.theme-twentytwenty {
|
||||
.wc-block-components-express-payment .wc-block-components-express-payment__title {
|
||||
padding-left: $gap-small;
|
||||
padding-right: $gap-small;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export { default as PaymentMethods } from './payment-methods';
|
||||
export { default as ExpressPaymentMethods } from './express-payment-methods';
|
||||
export { CartExpressPayment, CheckoutExpressPayment } from './express-payment';
|
||||
export { default as SavedPaymentMethodOptions } from './saved-payment-method-options';
|
||||
@@ -1,30 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import NoticeBanner from '@woocommerce/base-components/notice-banner';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './style.scss';
|
||||
|
||||
/**
|
||||
* Render content when no payment methods are found depending on context.
|
||||
*/
|
||||
const NoPaymentMethods = () => {
|
||||
return (
|
||||
<NoticeBanner
|
||||
isDismissible={ false }
|
||||
className="wc-block-checkout__no-payment-methods-notice"
|
||||
status="error"
|
||||
>
|
||||
{ __(
|
||||
'There are no payment methods available. This may be an error on our side. Please contact us if you need any help placing your order.',
|
||||
'woocommerce'
|
||||
) }
|
||||
</NoticeBanner>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoPaymentMethods;
|
||||
@@ -1,3 +0,0 @@
|
||||
.components-notice.wc-block-checkout__no-payment-methods-notice {
|
||||
margin: 0 0 $gap;
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { useEditorContext } from '@woocommerce/base-context';
|
||||
import { CheckboxControl } from '@woocommerce/blocks-checkout';
|
||||
import { useSelect, useDispatch } from '@wordpress/data';
|
||||
import { CHECKOUT_STORE_KEY, PAYMENT_STORE_KEY } from '@woocommerce/block-data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import PaymentMethodErrorBoundary from './payment-method-error-boundary';
|
||||
|
||||
/**
|
||||
* Component used to render the contents of a payment method card.
|
||||
*
|
||||
* @param {Object} props Incoming props for the component.
|
||||
* @param {boolean} props.showSaveOption Whether that payment method allows saving
|
||||
* the data for future purchases.
|
||||
* @param {Object} props.children Content of the payment method card.
|
||||
*
|
||||
* @return {*} The rendered component.
|
||||
*/
|
||||
interface PaymentMethodCardProps {
|
||||
showSaveOption: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
const PaymentMethodCard = ( {
|
||||
children,
|
||||
showSaveOption,
|
||||
}: PaymentMethodCardProps ) => {
|
||||
const { isEditor } = useEditorContext();
|
||||
const { shouldSavePaymentMethod, customerId } = useSelect( ( select ) => {
|
||||
const paymentMethodStore = select( PAYMENT_STORE_KEY );
|
||||
const checkoutStore = select( CHECKOUT_STORE_KEY );
|
||||
return {
|
||||
shouldSavePaymentMethod:
|
||||
paymentMethodStore.getShouldSavePaymentMethod(),
|
||||
customerId: checkoutStore.getCustomerId(),
|
||||
};
|
||||
} );
|
||||
const { __internalSetShouldSavePaymentMethod } =
|
||||
useDispatch( PAYMENT_STORE_KEY );
|
||||
return (
|
||||
<PaymentMethodErrorBoundary isEditor={ isEditor }>
|
||||
{ children }
|
||||
{ customerId > 0 && showSaveOption && (
|
||||
<CheckboxControl
|
||||
className="wc-block-components-payment-methods__save-card-info"
|
||||
label={ __(
|
||||
'Save payment information to my account for future purchases.',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
checked={ shouldSavePaymentMethod }
|
||||
onChange={ () =>
|
||||
__internalSetShouldSavePaymentMethod(
|
||||
! shouldSavePaymentMethod
|
||||
)
|
||||
}
|
||||
/>
|
||||
) }
|
||||
</PaymentMethodErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentMethodCard;
|
||||
@@ -1,52 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { useState } from '@wordpress/element';
|
||||
import { CURRENT_USER_IS_ADMIN } from '@woocommerce/settings';
|
||||
import { StoreNoticesContainer } from '@woocommerce/blocks-components';
|
||||
import { noticeContexts } from '@woocommerce/base-context';
|
||||
import { NoticeType } from '@woocommerce/types';
|
||||
interface PaymentMethodErrorBoundaryProps {
|
||||
isEditor: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
const PaymentMethodErrorBoundary = ( {
|
||||
isEditor,
|
||||
children,
|
||||
}: PaymentMethodErrorBoundaryProps ) => {
|
||||
const [ errorMessage ] = useState( '' );
|
||||
const [ hasError ] = useState( false );
|
||||
if ( hasError ) {
|
||||
let errorText = __(
|
||||
'We are experiencing difficulties with this payment method. Please contact us for assistance.',
|
||||
'woo-gutenberg-products-block'
|
||||
);
|
||||
if ( isEditor || CURRENT_USER_IS_ADMIN ) {
|
||||
if ( errorMessage ) {
|
||||
errorText = errorMessage;
|
||||
} else {
|
||||
errorText = __(
|
||||
"There was an error with this payment method. Please verify it's configured correctly.",
|
||||
'woo-gutenberg-products-block'
|
||||
);
|
||||
}
|
||||
}
|
||||
const notices: NoticeType[] = [
|
||||
{
|
||||
id: '0',
|
||||
content: errorText,
|
||||
isDismissible: false,
|
||||
status: 'error',
|
||||
},
|
||||
];
|
||||
return (
|
||||
<StoreNoticesContainer
|
||||
additionalNotices={ notices }
|
||||
context={ noticeContexts.PAYMENTS }
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <>{ children }</>;
|
||||
};
|
||||
export default PaymentMethodErrorBoundary;
|
||||
@@ -1,108 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import {
|
||||
usePaymentMethodInterface,
|
||||
useStoreEvents,
|
||||
} from '@woocommerce/base-context/hooks';
|
||||
import { cloneElement, useCallback } from '@wordpress/element';
|
||||
import { useEditorContext } from '@woocommerce/base-context';
|
||||
import classNames from 'classnames';
|
||||
import { RadioControlAccordion } from '@woocommerce/blocks-components';
|
||||
import { useDispatch, useSelect } from '@wordpress/data';
|
||||
import { getPaymentMethods } from '@woocommerce/blocks-registry';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import PaymentMethodCard from './payment-method-card';
|
||||
import { noticeContexts } from '../../../base/context/event-emit';
|
||||
import { STORE_KEY as PAYMENT_STORE_KEY } from '../../../data/payment/constants';
|
||||
|
||||
/**
|
||||
* Component used to render all non-saved payment method options.
|
||||
*
|
||||
* @return {*} The rendered component.
|
||||
*/
|
||||
const PaymentMethodOptions = () => {
|
||||
const {
|
||||
activeSavedToken,
|
||||
activePaymentMethod,
|
||||
isExpressPaymentMethodActive,
|
||||
savedPaymentMethods,
|
||||
availablePaymentMethods,
|
||||
} = useSelect( ( select ) => {
|
||||
const store = select( PAYMENT_STORE_KEY );
|
||||
return {
|
||||
activeSavedToken: store.getActiveSavedToken(),
|
||||
activePaymentMethod: store.getActivePaymentMethod(),
|
||||
isExpressPaymentMethodActive: store.isExpressPaymentMethodActive(),
|
||||
savedPaymentMethods: store.getSavedPaymentMethods(),
|
||||
availablePaymentMethods: store.getAvailablePaymentMethods(),
|
||||
};
|
||||
} );
|
||||
const { __internalSetActivePaymentMethod } =
|
||||
useDispatch( PAYMENT_STORE_KEY );
|
||||
const paymentMethods = getPaymentMethods();
|
||||
const { ...paymentMethodInterface } = usePaymentMethodInterface();
|
||||
const { removeNotice } = useDispatch( 'core/notices' );
|
||||
const { dispatchCheckoutEvent } = useStoreEvents();
|
||||
const { isEditor } = useEditorContext();
|
||||
|
||||
const options = Object.keys( availablePaymentMethods ).map( ( name ) => {
|
||||
const { edit, content, label, supports } = paymentMethods[ name ];
|
||||
const component = isEditor ? edit : content;
|
||||
return {
|
||||
value: name,
|
||||
label:
|
||||
typeof label === 'string'
|
||||
? label
|
||||
: cloneElement( label, {
|
||||
components: paymentMethodInterface.components,
|
||||
} ),
|
||||
name: `wc-saved-payment-method-token-${ name }`,
|
||||
content: (
|
||||
<PaymentMethodCard showSaveOption={ supports.showSaveOption }>
|
||||
{ cloneElement( component, {
|
||||
__internalSetActivePaymentMethod,
|
||||
...paymentMethodInterface,
|
||||
} ) }
|
||||
</PaymentMethodCard>
|
||||
),
|
||||
};
|
||||
} );
|
||||
|
||||
const onChange = useCallback(
|
||||
( value ) => {
|
||||
__internalSetActivePaymentMethod( value );
|
||||
removeNotice( 'wc-payment-error', noticeContexts.PAYMENTS );
|
||||
dispatchCheckoutEvent( 'set-active-payment-method', {
|
||||
value,
|
||||
} );
|
||||
},
|
||||
[
|
||||
dispatchCheckoutEvent,
|
||||
removeNotice,
|
||||
__internalSetActivePaymentMethod,
|
||||
]
|
||||
);
|
||||
|
||||
const isSinglePaymentMethod =
|
||||
Object.keys( savedPaymentMethods ).length === 0 &&
|
||||
Object.keys( paymentMethods ).length === 1;
|
||||
|
||||
const singleOptionClass = classNames( {
|
||||
'disable-radio-control': isSinglePaymentMethod,
|
||||
} );
|
||||
return isExpressPaymentMethodActive ? null : (
|
||||
<RadioControlAccordion
|
||||
id={ 'wc-payment-method-options' }
|
||||
className={ singleOptionClass }
|
||||
selected={ activeSavedToken ? null : activePaymentMethod }
|
||||
onChange={ onChange }
|
||||
options={ options }
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentMethodOptions;
|
||||
@@ -1,69 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { Label } from '@woocommerce/blocks-components';
|
||||
import { useSelect } from '@wordpress/data';
|
||||
import { PAYMENT_STORE_KEY } from '@woocommerce/block-data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import NoPaymentMethods from './no-payment-methods';
|
||||
import PaymentMethodOptions from './payment-method-options';
|
||||
import SavedPaymentMethodOptions from './saved-payment-method-options';
|
||||
import './style.scss';
|
||||
|
||||
/**
|
||||
* PaymentMethods component.
|
||||
*
|
||||
* @return {*} The rendered component.
|
||||
*/
|
||||
const PaymentMethods = () => {
|
||||
const {
|
||||
paymentMethodsInitialized,
|
||||
availablePaymentMethods,
|
||||
savedPaymentMethods,
|
||||
} = useSelect( ( select ) => {
|
||||
const store = select( PAYMENT_STORE_KEY );
|
||||
return {
|
||||
paymentMethodsInitialized: store.paymentMethodsInitialized(),
|
||||
availablePaymentMethods: store.getAvailablePaymentMethods(),
|
||||
savedPaymentMethods: store.getSavedPaymentMethods(),
|
||||
};
|
||||
} );
|
||||
|
||||
if (
|
||||
paymentMethodsInitialized &&
|
||||
Object.keys( availablePaymentMethods ).length === 0
|
||||
) {
|
||||
return <NoPaymentMethods />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SavedPaymentMethodOptions />
|
||||
{ Object.keys( savedPaymentMethods ).length > 0 && (
|
||||
<Label
|
||||
label={ __(
|
||||
'Use another payment method.',
|
||||
'woocommerce'
|
||||
) }
|
||||
screenReaderLabel={ __(
|
||||
'Other available payment methods',
|
||||
'woocommerce'
|
||||
) }
|
||||
wrapperElement="p"
|
||||
wrapperProps={ {
|
||||
className: [
|
||||
'wc-block-components-checkout-step__description wc-block-components-checkout-step__description-payments-aligned',
|
||||
],
|
||||
} }
|
||||
/>
|
||||
) }
|
||||
<PaymentMethodOptions />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentMethods;
|
||||
@@ -1,186 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useMemo, cloneElement } from '@wordpress/element';
|
||||
import { __, sprintf } from '@wordpress/i18n';
|
||||
import { noticeContexts } from '@woocommerce/base-context';
|
||||
import {
|
||||
RadioControl,
|
||||
RadioControlOptionType,
|
||||
} from '@woocommerce/blocks-components';
|
||||
import {
|
||||
usePaymentMethodInterface,
|
||||
useStoreEvents,
|
||||
} from '@woocommerce/base-context/hooks';
|
||||
import { PAYMENT_STORE_KEY } from '@woocommerce/block-data';
|
||||
import { useDispatch, useSelect } from '@wordpress/data';
|
||||
import { getPaymentMethods } from '@woocommerce/blocks-registry';
|
||||
import { isNull } from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { getCanMakePaymentArg } from '../../../data/payment/utils/check-payment-methods';
|
||||
import { CustomerPaymentMethodConfiguration } from '../../../data/payment/types';
|
||||
|
||||
/**
|
||||
* Returns the option object for a cc or echeck saved payment method token.
|
||||
*/
|
||||
const getCcOrEcheckLabel = ( {
|
||||
method,
|
||||
expires,
|
||||
}: {
|
||||
method: CustomerPaymentMethodConfiguration;
|
||||
expires: string;
|
||||
} ): string => {
|
||||
return sprintf(
|
||||
/* translators: %1$s is referring to the payment method brand, %2$s is referring to the last 4 digits of the payment card, %3$s is referring to the expiry date. */
|
||||
__(
|
||||
'%1$s ending in %2$s (expires %3$s)',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
method.brand,
|
||||
method.last4,
|
||||
expires
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the option object for any non specific saved payment method.
|
||||
*/
|
||||
const getDefaultLabel = ( {
|
||||
method,
|
||||
}: {
|
||||
method: CustomerPaymentMethodConfiguration;
|
||||
} ): string => {
|
||||
/* For saved payment methods with brand & last 4 */
|
||||
if ( method.brand && method.last4 ) {
|
||||
return sprintf(
|
||||
/* translators: %1$s is referring to the payment method brand, %2$s is referring to the last 4 digits of the payment card. */
|
||||
__( '%1$s ending in %2$s', 'woo-gutenberg-products-block' ),
|
||||
method.brand,
|
||||
method.last4
|
||||
);
|
||||
}
|
||||
|
||||
/* For saved payment methods without brand & last 4 */
|
||||
return sprintf(
|
||||
/* translators: %s is the name of the payment method gateway. */
|
||||
__( 'Saved token for %s', 'woo-gutenberg-products-block' ),
|
||||
method.gateway
|
||||
);
|
||||
};
|
||||
|
||||
const SavedPaymentMethodOptions = () => {
|
||||
const { activeSavedToken, activePaymentMethod, savedPaymentMethods } =
|
||||
useSelect( ( select ) => {
|
||||
const store = select( PAYMENT_STORE_KEY );
|
||||
return {
|
||||
activeSavedToken: store.getActiveSavedToken(),
|
||||
activePaymentMethod: store.getActivePaymentMethod(),
|
||||
savedPaymentMethods: store.getSavedPaymentMethods(),
|
||||
};
|
||||
} );
|
||||
const { __internalSetActivePaymentMethod } =
|
||||
useDispatch( PAYMENT_STORE_KEY );
|
||||
const canMakePaymentArg = getCanMakePaymentArg();
|
||||
const paymentMethods = getPaymentMethods();
|
||||
const paymentMethodInterface = usePaymentMethodInterface();
|
||||
const { removeNotice } = useDispatch( 'core/notices' );
|
||||
const { dispatchCheckoutEvent } = useStoreEvents();
|
||||
|
||||
const options = useMemo< RadioControlOptionType[] >( () => {
|
||||
const types = Object.keys( savedPaymentMethods );
|
||||
|
||||
// Get individual payment methods from saved payment methods and put them into a unique array.
|
||||
const individualPaymentGateways = new Set(
|
||||
types.flatMap( ( type ) =>
|
||||
savedPaymentMethods[ type ].map(
|
||||
( paymentMethod ) => paymentMethod.method.gateway
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const gatewaysThatCanMakePayment = Array.from(
|
||||
individualPaymentGateways
|
||||
).filter( ( method ) => {
|
||||
return paymentMethods[ method ]?.canMakePayment(
|
||||
canMakePaymentArg
|
||||
);
|
||||
} );
|
||||
|
||||
const mappedOptions = types.flatMap( ( type ) => {
|
||||
const typeMethods = savedPaymentMethods[ type ];
|
||||
return typeMethods.map( ( paymentMethod ) => {
|
||||
const canMakePayment = gatewaysThatCanMakePayment.includes(
|
||||
paymentMethod.method.gateway
|
||||
);
|
||||
if ( ! canMakePayment ) {
|
||||
return void 0;
|
||||
}
|
||||
const isCC = type === 'cc' || type === 'echeck';
|
||||
const paymentMethodSlug = paymentMethod.method.gateway;
|
||||
return {
|
||||
name: `wc-saved-payment-method-token-${ paymentMethodSlug }`,
|
||||
label: isCC
|
||||
? getCcOrEcheckLabel( paymentMethod )
|
||||
: getDefaultLabel( paymentMethod ),
|
||||
value: paymentMethod.tokenId.toString(),
|
||||
onChange: ( token: string ) => {
|
||||
const savedTokenKey = `wc-${ paymentMethodSlug }-payment-token`;
|
||||
__internalSetActivePaymentMethod( paymentMethodSlug, {
|
||||
token,
|
||||
payment_method: paymentMethodSlug,
|
||||
[ savedTokenKey ]: token.toString(),
|
||||
isSavedToken: true,
|
||||
} );
|
||||
removeNotice(
|
||||
'wc-payment-error',
|
||||
noticeContexts.PAYMENTS
|
||||
);
|
||||
dispatchCheckoutEvent( 'set-active-payment-method', {
|
||||
paymentMethodSlug,
|
||||
} );
|
||||
},
|
||||
};
|
||||
} );
|
||||
} );
|
||||
return mappedOptions.filter(
|
||||
( option ) => typeof option !== 'undefined'
|
||||
) as RadioControlOptionType[];
|
||||
}, [
|
||||
savedPaymentMethods,
|
||||
paymentMethods,
|
||||
__internalSetActivePaymentMethod,
|
||||
removeNotice,
|
||||
dispatchCheckoutEvent,
|
||||
canMakePaymentArg,
|
||||
] );
|
||||
const savedPaymentMethodHandler =
|
||||
!! activeSavedToken &&
|
||||
paymentMethods[ activePaymentMethod ] &&
|
||||
typeof paymentMethods[ activePaymentMethod ]?.savedTokenComponent !==
|
||||
'undefined' &&
|
||||
! isNull( paymentMethods[ activePaymentMethod ].savedTokenComponent )
|
||||
? cloneElement(
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore - we know for sure that the savedTokenComponent is not null or undefined at this point.
|
||||
paymentMethods[ activePaymentMethod ].savedTokenComponent,
|
||||
{ token: activeSavedToken, ...paymentMethodInterface }
|
||||
)
|
||||
: null;
|
||||
|
||||
return options.length > 0 ? (
|
||||
<>
|
||||
<RadioControl
|
||||
id={ 'wc-payment-method-saved-tokens' }
|
||||
selected={ activeSavedToken }
|
||||
options={ options }
|
||||
onChange={ () => void 0 }
|
||||
/>
|
||||
{ savedPaymentMethodHandler }
|
||||
</>
|
||||
) : null;
|
||||
};
|
||||
|
||||
export default SavedPaymentMethodOptions;
|
||||
@@ -1,265 +0,0 @@
|
||||
.wc-block-card-elements {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
|
||||
.wc-block-components-validation-error {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-gateway-container {
|
||||
position: relative;
|
||||
margin-bottom: em($gap-large);
|
||||
white-space: nowrap;
|
||||
|
||||
&.wc-card-number-element {
|
||||
flex-basis: 15em;
|
||||
flex-grow: 1;
|
||||
// Currently, min() CSS function calls need to be wrapped with unquote.
|
||||
min-width: string.unquote("min(15em, 60%)");
|
||||
}
|
||||
|
||||
&.wc-card-expiry-element {
|
||||
flex-basis: 7em;
|
||||
margin-left: $gap-small;
|
||||
min-width: string.unquote("min(7em, calc(24% - #{$gap-small}))");
|
||||
}
|
||||
|
||||
&.wc-card-cvc-element {
|
||||
flex-basis: 7em;
|
||||
margin-left: $gap-small;
|
||||
// Notice the min width ems value is smaller than flex-basis. That's because
|
||||
// by default we want it to have the same width as `expiry-element`, but
|
||||
// if available space is scarce, `cvc-element` should get smaller faster.
|
||||
min-width: string.unquote("min(5em, calc(16% - #{$gap-small}))");
|
||||
}
|
||||
|
||||
.wc-block-gateway-input {
|
||||
@include font-size(regular);
|
||||
line-height: 1.375; // =22px when font-size is 16px.
|
||||
background-color: #fff;
|
||||
padding: em($gap-small) 0 em($gap-small) $gap;
|
||||
border-radius: $universal-border-radius;
|
||||
border: 1px solid $input-border-gray;
|
||||
width: 100%;
|
||||
font-family: inherit;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
height: 3em;
|
||||
color: $input-text-active;
|
||||
cursor: text;
|
||||
|
||||
&:focus {
|
||||
background-color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
label {
|
||||
@include reset-color();
|
||||
@include reset-typography();
|
||||
@include font-size(regular);
|
||||
line-height: 1.375; // =22px when font-size is 16px.
|
||||
position: absolute;
|
||||
transform: translateY(0.75em);
|
||||
left: 0;
|
||||
top: 0;
|
||||
transform-origin: top left;
|
||||
color: $gray-700;
|
||||
transition: transform 200ms ease;
|
||||
margin: 0 0 0 #{$gap + 1px};
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: calc(100% - #{$gap + $gap-smaller});
|
||||
cursor: text;
|
||||
|
||||
@media screen and (prefers-reduced-motion: reduce) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.wc-inline-card-element {
|
||||
label {
|
||||
// $gap is the padding of the input box, 1.5em the width of the card
|
||||
// icon and $gap-smaller the space between the card
|
||||
// icon and the label.
|
||||
margin-left: calc(#{$gap + $gap-smaller} + 1.5em);
|
||||
}
|
||||
.wc-block-gateway-input.focused.empty,
|
||||
.wc-block-gateway-input:not(.empty) {
|
||||
+ label {
|
||||
margin-left: $gap;
|
||||
transform: translateY(#{$gap-smallest}) scale(0.75);
|
||||
}
|
||||
}
|
||||
+ .wc-block-components-validation-error {
|
||||
position: static;
|
||||
margin-top: -$gap-large;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-gateway-input.focused.empty,
|
||||
.wc-block-gateway-input:not(.empty) {
|
||||
padding: em($gap-large) 0 em($gap-smallest) $gap;
|
||||
+ label {
|
||||
transform: translateY(#{$gap-smallest}) scale(0.75);
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-gateway-input.has-error {
|
||||
border-color: $alert-red;
|
||||
&:focus {
|
||||
outline-color: $alert-red;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-gateway-input.has-error + label {
|
||||
color: $alert-red;
|
||||
}
|
||||
}
|
||||
|
||||
// These elements have available space below, so we can display errors with a
|
||||
// larger line height.
|
||||
.is-medium,
|
||||
.is-large {
|
||||
.wc-card-expiry-element,
|
||||
.wc-card-cvc-element {
|
||||
.wc-block-components-validation-error > p {
|
||||
line-height: 16px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.is-mobile,
|
||||
.is-small {
|
||||
.wc-card-expiry-element,
|
||||
.wc-card-cvc-element {
|
||||
.wc-block-components-validation-error > p {
|
||||
min-height: 28px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-components-checkout-payment-methods * {
|
||||
pointer-events: all; // Overrides parent disabled component in editor context
|
||||
}
|
||||
|
||||
.is-mobile,
|
||||
.is-small {
|
||||
.wc-block-card-elements {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wc-block-gateway-container.wc-card-number-element {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.wc-block-gateway-container.wc-card-expiry-element {
|
||||
flex-basis: calc(50% - #{$gap-smaller});
|
||||
margin-left: 0;
|
||||
margin-right: $gap-smaller;
|
||||
}
|
||||
|
||||
.wc-block-gateway-container.wc-card-cvc-element {
|
||||
flex-basis: calc(50% - #{$gap-smaller});
|
||||
margin-left: $gap-smaller;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-checkout__payment-method {
|
||||
.wc-block-components-radio-control__option {
|
||||
padding-left: 56px;
|
||||
|
||||
&::after {
|
||||
content: none;
|
||||
}
|
||||
|
||||
.wc-block-components-radio-control__input {
|
||||
left: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
// We need to add the first-child and last-child pseudoclasses for specificity.
|
||||
.wc-block-components-radio-control__option,
|
||||
.wc-block-components-radio-control__option:first-child,
|
||||
.wc-block-components-radio-control__option:last-child {
|
||||
margin: 0;
|
||||
padding-bottom: em($gap);
|
||||
padding-top: em($gap);
|
||||
}
|
||||
|
||||
.wc-block-components-radio-control__option-checked {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.wc-block-components-radio-control-accordion-option,
|
||||
.wc-block-components-radio-control__option {
|
||||
@include with-translucent-border(1px 1px 0 1px);
|
||||
}
|
||||
|
||||
.wc-block-components-radio-control-accordion-option:first-child::after {
|
||||
border-top-left-radius: $universal-border-radius;
|
||||
border-top-right-radius: $universal-border-radius;
|
||||
}
|
||||
|
||||
.wc-block-components-radio-control-accordion-option:last-child::after {
|
||||
border-bottom-left-radius: $universal-border-radius;
|
||||
border-bottom-right-radius: $universal-border-radius;
|
||||
}
|
||||
|
||||
.wc-block-components-radio-control__option:last-child::after,
|
||||
.wc-block-components-radio-control-accordion-option:last-child::after {
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
.wc-block-components-radio-control-accordion-option {
|
||||
.wc-block-components-radio-control__option::after {
|
||||
border-width: 0;
|
||||
}
|
||||
.wc-block-components-radio-control__label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.wc-block-components-radio-control__label img {
|
||||
height: 24px;
|
||||
max-height: 24px;
|
||||
object-fit: contain;
|
||||
object-position: left;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-components-radio-control.disable-radio-control {
|
||||
.wc-block-components-radio-control__option {
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.wc-block-components-radio-control__input {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-components-checkout-step__description-payments-aligned {
|
||||
padding-top: 14px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.wc-block-components-radio-control-accordion-content {
|
||||
padding: 0 $gap em($gap) $gap;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-checkout__order-notes {
|
||||
.wc-block-components-checkout-step__content {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import { previewCart } from '@woocommerce/resource-previews';
|
||||
import * as wpDataFunctions from '@wordpress/data';
|
||||
import { CART_STORE_KEY, PAYMENT_STORE_KEY } from '@woocommerce/block-data';
|
||||
import { default as fetchMock } from 'jest-fetch-mock';
|
||||
import {
|
||||
registerPaymentMethod,
|
||||
__experimentalDeRegisterPaymentMethod,
|
||||
} from '@woocommerce/blocks-registry';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { dispatch } from '@wordpress/data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import PaymentMethods from '../payment-methods';
|
||||
|
||||
jest.mock( '../saved-payment-method-options', () => ( { onChange } ) => {
|
||||
return (
|
||||
<>
|
||||
<span>Saved payment method options</span>
|
||||
<button onClick={ () => onChange( '0' ) }>Select saved</button>
|
||||
</>
|
||||
);
|
||||
} );
|
||||
|
||||
jest.mock( '@woocommerce/blocks-components', () => {
|
||||
const originalModule = jest.requireActual(
|
||||
'@woocommerce/blocks-components'
|
||||
);
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
...originalModule,
|
||||
RadioControlAccordion: ( { onChange } ) => (
|
||||
<>
|
||||
<span>Payment method options</span>
|
||||
<button onClick={ () => onChange( 'credit-card' ) }>
|
||||
Select new payment
|
||||
</button>
|
||||
</>
|
||||
),
|
||||
};
|
||||
} );
|
||||
|
||||
const originalSelect = jest.requireActual( '@wordpress/data' ).select;
|
||||
const selectMock = jest
|
||||
.spyOn( wpDataFunctions, 'select' )
|
||||
.mockImplementation( ( storeName ) => {
|
||||
const originalStore = originalSelect( storeName );
|
||||
if ( storeName === PAYMENT_STORE_KEY ) {
|
||||
return {
|
||||
...originalStore,
|
||||
getState: () => {
|
||||
const originalState = originalStore.getState();
|
||||
return {
|
||||
...originalState,
|
||||
savedPaymentMethods: {},
|
||||
availablePaymentMethods: {},
|
||||
paymentMethodsInitialized: true,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
return originalStore;
|
||||
} );
|
||||
|
||||
const registerMockPaymentMethods = () => {
|
||||
[ 'cod', 'credit-card' ].forEach( ( name ) => {
|
||||
registerPaymentMethod( {
|
||||
name,
|
||||
label: name,
|
||||
content: <div>A payment method</div>,
|
||||
edit: <div>A payment method</div>,
|
||||
icons: null,
|
||||
canMakePayment: () => true,
|
||||
supports: {
|
||||
showSavedCards: true,
|
||||
showSaveOption: true,
|
||||
features: [ 'products' ],
|
||||
},
|
||||
ariaLabel: name,
|
||||
} );
|
||||
} );
|
||||
dispatch( PAYMENT_STORE_KEY ).__internalUpdateAvailablePaymentMethods();
|
||||
};
|
||||
|
||||
const resetMockPaymentMethods = () => {
|
||||
[ 'cod', 'credit-card' ].forEach( ( name ) => {
|
||||
__experimentalDeRegisterPaymentMethod( name );
|
||||
} );
|
||||
};
|
||||
|
||||
describe( 'PaymentMethods', () => {
|
||||
beforeEach( () => {
|
||||
fetchMock.mockResponse( ( req ) => {
|
||||
if ( req.url.match( /wc\/store\/v1\/cart/ ) ) {
|
||||
return Promise.resolve( JSON.stringify( previewCart ) );
|
||||
}
|
||||
return Promise.resolve( '' );
|
||||
} );
|
||||
// need to clear the store resolution state between tests.
|
||||
wpDataFunctions
|
||||
.dispatch( CART_STORE_KEY )
|
||||
.invalidateResolutionForStore();
|
||||
wpDataFunctions.dispatch( CART_STORE_KEY ).receiveCart( {
|
||||
...previewCart,
|
||||
payment_methods: [ 'cod', 'credit-card' ],
|
||||
} );
|
||||
} );
|
||||
|
||||
afterEach( () => {
|
||||
fetchMock.resetMocks();
|
||||
} );
|
||||
|
||||
test( 'should show no payment methods component when there are no payment methods', async () => {
|
||||
render( <PaymentMethods /> );
|
||||
|
||||
await waitFor( () => {
|
||||
const noPaymentMethods = screen.queryAllByText(
|
||||
/no payment methods available/
|
||||
);
|
||||
// We might get more than one match because the `speak()` function
|
||||
// creates an extra `div` with the notice contents used for a11y.
|
||||
expect( noPaymentMethods.length ).toBeGreaterThanOrEqual( 1 );
|
||||
|
||||
// Reset the mock back to how it was because we don't need it anymore after this test.
|
||||
selectMock.mockRestore();
|
||||
} );
|
||||
} );
|
||||
|
||||
test( 'selecting new payment method', async () => {
|
||||
const ShowActivePaymentMethod = () => {
|
||||
const { activePaymentMethod, activeSavedToken } =
|
||||
wpDataFunctions.useSelect( ( select ) => {
|
||||
const store = select( PAYMENT_STORE_KEY );
|
||||
return {
|
||||
activePaymentMethod: store.getActivePaymentMethod(),
|
||||
activeSavedToken: store.getActiveSavedToken(),
|
||||
};
|
||||
} );
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
{ 'Active Payment Method: ' + activePaymentMethod }
|
||||
</div>
|
||||
<div>{ 'Active Saved Token: ' + activeSavedToken }</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
act( () => {
|
||||
registerMockPaymentMethods();
|
||||
} );
|
||||
// Wait for the payment methods to finish loading before rendering.
|
||||
await waitFor( () => {
|
||||
expect(
|
||||
wpDataFunctions
|
||||
.select( PAYMENT_STORE_KEY )
|
||||
.getActivePaymentMethod()
|
||||
).toBe( 'cod' );
|
||||
} );
|
||||
|
||||
render(
|
||||
<>
|
||||
<PaymentMethods />
|
||||
<ShowActivePaymentMethod />
|
||||
</>
|
||||
);
|
||||
|
||||
await waitFor( () => {
|
||||
const savedPaymentMethodOptions = screen.queryByText(
|
||||
/Saved payment method options/
|
||||
);
|
||||
expect( savedPaymentMethodOptions ).not.toBeNull();
|
||||
} );
|
||||
|
||||
await waitFor( () => {
|
||||
const paymentMethodOptions = screen.queryByText(
|
||||
/Payment method options/
|
||||
);
|
||||
expect( paymentMethodOptions ).not.toBeNull();
|
||||
} );
|
||||
|
||||
await waitFor( () => {
|
||||
const savedToken = screen.queryByText(
|
||||
/Active Payment Method: credit-card/
|
||||
);
|
||||
expect( savedToken ).toBeNull();
|
||||
} );
|
||||
|
||||
userEvent.click( screen.getByText( 'Select new payment' ) );
|
||||
|
||||
await waitFor( () => {
|
||||
const activePaymentMethod = screen.queryByText(
|
||||
/Active Payment Method: credit-card/
|
||||
);
|
||||
expect( activePaymentMethod ).not.toBeNull();
|
||||
} );
|
||||
|
||||
act( () => resetMockPaymentMethods() );
|
||||
} );
|
||||
} );
|
||||
@@ -1,138 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { registerPaymentMethod } from '@woocommerce/blocks-registry';
|
||||
import * as wpData from '@wordpress/data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import SavedPaymentMethodOptions from '../saved-payment-method-options';
|
||||
|
||||
jest.mock( '@wordpress/data', () => ( {
|
||||
__esModule: true,
|
||||
...jest.requireActual( '@wordpress/data' ),
|
||||
useSelect: jest.fn(),
|
||||
} ) );
|
||||
|
||||
const mockedUseSelect = wpData.useSelect as jest.Mock;
|
||||
// Mock use select so we can override it when wc/store/checkout is accessed, but return the original select function if any other store is accessed.
|
||||
mockedUseSelect.mockImplementation(
|
||||
jest.fn().mockImplementation( ( passedMapSelect ) => {
|
||||
const mockedSelect = jest.fn().mockImplementation( ( storeName ) => {
|
||||
if ( storeName === 'wc/store/payment' ) {
|
||||
return {
|
||||
...jest
|
||||
.requireActual( '@wordpress/data' )
|
||||
.select( storeName ),
|
||||
getActiveSavedToken: () => 1,
|
||||
getSavedPaymentMethods: () => {
|
||||
return {
|
||||
cc: [
|
||||
{
|
||||
tokenId: 1,
|
||||
expires: '1/2099',
|
||||
method: {
|
||||
brand: 'Visa',
|
||||
gateway:
|
||||
'can-pay-true-test-payment-method',
|
||||
last4: '1234',
|
||||
},
|
||||
},
|
||||
{
|
||||
tokenId: 2,
|
||||
expires: '1/2099',
|
||||
method: {
|
||||
brand: 'Visa',
|
||||
gateway:
|
||||
'can-pay-true-test-payment-method',
|
||||
last4: '2345',
|
||||
},
|
||||
},
|
||||
{
|
||||
tokenId: 3,
|
||||
expires: '1/2099',
|
||||
method: {
|
||||
brand: 'Visa',
|
||||
gateway:
|
||||
'can-pay-true-first-false-second-test-payment-method',
|
||||
last4: '3456',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
return jest.requireActual( '@wordpress/data' ).select( storeName );
|
||||
} );
|
||||
return passedMapSelect( mockedSelect, {
|
||||
dispatch: jest.requireActual( '@wordpress/data' ).dispatch,
|
||||
} );
|
||||
} )
|
||||
);
|
||||
|
||||
describe( 'SavedPaymentMethodOptions', () => {
|
||||
it( 'renders saved methods when a registered method exists', () => {
|
||||
registerPaymentMethod( {
|
||||
name: 'can-pay-true-test-payment-method',
|
||||
label: 'Can Pay True Test Payment Method',
|
||||
edit: <div>edit</div>,
|
||||
ariaLabel: 'Can Pay True Test Payment Method',
|
||||
canMakePayment: () => true,
|
||||
content: <div>content</div>,
|
||||
supports: {
|
||||
showSavedCards: true,
|
||||
showSaveOption: true,
|
||||
features: [ 'products' ],
|
||||
},
|
||||
} );
|
||||
render( <SavedPaymentMethodOptions /> );
|
||||
|
||||
// First saved token for can-pay-true-test-payment-method.
|
||||
expect(
|
||||
screen.getByText( 'Visa ending in 1234 (expires 1/2099)' )
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Second saved token for can-pay-true-test-payment-method.
|
||||
expect(
|
||||
screen.getByText( 'Visa ending in 2345 (expires 1/2099)' )
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Third saved token for can-pay-false-test-payment-method - this should not show because the method is not registered.
|
||||
expect(
|
||||
screen.queryByText( 'Visa ending in 3456 (expires 1/2099)' )
|
||||
).not.toBeInTheDocument();
|
||||
} );
|
||||
it( "does not show saved methods when the method's canPay function returns false", () => {
|
||||
registerPaymentMethod( {
|
||||
name: 'can-pay-true-first-false-second-test-payment-method',
|
||||
label: 'Can Pay True First False Second Test Payment Method',
|
||||
edit: <div>edit</div>,
|
||||
ariaLabel: 'Can Pay True First False Second Test Payment Method',
|
||||
// This mock will return true the first time it runs, then false on subsequent calls.
|
||||
canMakePayment: jest
|
||||
.fn()
|
||||
.mockReturnValueOnce( true )
|
||||
.mockReturnValue( false ),
|
||||
content: <div>content</div>,
|
||||
supports: {
|
||||
showSavedCards: true,
|
||||
showSaveOption: true,
|
||||
features: [ 'products' ],
|
||||
},
|
||||
} );
|
||||
const { rerender } = render( <SavedPaymentMethodOptions /> );
|
||||
// Saved token for can-pay-true-first-false-second-test-payment-method - this should show because canPay is true on first call.
|
||||
expect(
|
||||
screen.queryByText( 'Visa ending in 3456 (expires 1/2099)' )
|
||||
).toBeInTheDocument();
|
||||
rerender( <SavedPaymentMethodOptions /> );
|
||||
|
||||
// Saved token for can-pay-true-first-false-second-test-payment-method - this should not show because canPay is false on subsequent calls.
|
||||
expect(
|
||||
screen.queryByText( 'Visa ending in 3456 (expires 1/2099)' )
|
||||
).not.toBeInTheDocument();
|
||||
} );
|
||||
} );
|
||||
@@ -1,163 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { createHigherOrderComponent } from '@wordpress/compose';
|
||||
import {
|
||||
InspectorControls,
|
||||
store as blockEditorStore,
|
||||
} from '@wordpress/block-editor';
|
||||
import { addFilter, hasFilter } from '@wordpress/hooks';
|
||||
import type { StoreDescriptor } from '@wordpress/data';
|
||||
import { CartCheckoutSidebarCompatibilityNotice } from '@woocommerce/editor-components/sidebar-compatibility-notice';
|
||||
import { NoPaymentMethodsNotice } from '@woocommerce/editor-components/no-payment-methods-notice';
|
||||
import { PAYMENT_STORE_KEY } from '@woocommerce/block-data';
|
||||
import { DefaultNotice } from '@woocommerce/editor-components/default-notice';
|
||||
import { IncompatibleExtensionsNotice } from '@woocommerce/editor-components/incompatible-extension-notice';
|
||||
import { useSelect } from '@wordpress/data';
|
||||
import { CartCheckoutFeedbackPrompt } from '@woocommerce/editor-components/feedback-prompt';
|
||||
import { useState } from '@wordpress/element';
|
||||
|
||||
declare module '@wordpress/editor' {
|
||||
let store: StoreDescriptor;
|
||||
}
|
||||
|
||||
declare module '@wordpress/core-data' {
|
||||
let store: StoreDescriptor;
|
||||
}
|
||||
|
||||
declare module '@wordpress/block-editor' {
|
||||
let store: StoreDescriptor;
|
||||
}
|
||||
|
||||
const withSidebarNotices = createHigherOrderComponent(
|
||||
( BlockEdit ) => ( props ) => {
|
||||
const {
|
||||
clientId,
|
||||
name: blockName,
|
||||
isSelected: isBlockSelected,
|
||||
} = props;
|
||||
|
||||
const [
|
||||
isIncompatibleExtensionsNoticeDismissed,
|
||||
setIsIncompatibleExtensionsNoticeDismissed,
|
||||
] = useState( true );
|
||||
|
||||
const toggleIncompatibleExtensionsNoticeDismissedStatus = (
|
||||
isDismissed: boolean
|
||||
) => {
|
||||
setIsIncompatibleExtensionsNoticeDismissed( isDismissed );
|
||||
};
|
||||
|
||||
const {
|
||||
isCart,
|
||||
isCheckout,
|
||||
isPaymentMethodsBlock,
|
||||
hasPaymentMethods,
|
||||
parentId,
|
||||
} = useSelect( ( select ) => {
|
||||
const { getBlockParentsByBlockName, getBlockName } =
|
||||
select( blockEditorStore );
|
||||
|
||||
const parents = getBlockParentsByBlockName( clientId, [
|
||||
'woocommerce/cart',
|
||||
'woocommerce/checkout',
|
||||
] ).reduce(
|
||||
(
|
||||
accumulator: Record< string, string >,
|
||||
parentClientId: string
|
||||
) => {
|
||||
const parentName = getBlockName( parentClientId );
|
||||
accumulator[ parentName ] = parentClientId;
|
||||
return accumulator;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
const currentBlockName = getBlockName( clientId );
|
||||
const parentBlockIsCart =
|
||||
Object.keys( parents ).includes( 'woocommerce/cart' );
|
||||
const parentBlockIsCheckout = Object.keys( parents ).includes(
|
||||
'woocommerce/checkout'
|
||||
);
|
||||
const currentBlockIsCart =
|
||||
currentBlockName === 'woocommerce/cart' || parentBlockIsCart;
|
||||
const currentBlockIsCheckout =
|
||||
currentBlockName === 'woocommerce/checkout' ||
|
||||
parentBlockIsCheckout;
|
||||
const targetParentBlock = currentBlockIsCart
|
||||
? 'woocommerce/cart'
|
||||
: 'woocommerce/checkout';
|
||||
|
||||
return {
|
||||
isCart: currentBlockIsCart,
|
||||
isCheckout: currentBlockIsCheckout,
|
||||
parentId:
|
||||
currentBlockName === targetParentBlock
|
||||
? clientId
|
||||
: parents[ targetParentBlock ],
|
||||
isPaymentMethodsBlock:
|
||||
currentBlockName === 'woocommerce/checkout-payment-block',
|
||||
hasPaymentMethods:
|
||||
select( PAYMENT_STORE_KEY ).paymentMethodsInitialized() &&
|
||||
Object.keys(
|
||||
select( PAYMENT_STORE_KEY ).getAvailablePaymentMethods()
|
||||
).length > 0,
|
||||
};
|
||||
} );
|
||||
|
||||
// Show sidebar notices only when a WooCommerce block is selected.
|
||||
if (
|
||||
! blockName.startsWith( 'woocommerce/' ) ||
|
||||
! isBlockSelected ||
|
||||
! ( isCart || isCheckout )
|
||||
) {
|
||||
return <BlockEdit key="edit" { ...props } />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<InspectorControls>
|
||||
<IncompatibleExtensionsNotice
|
||||
toggleDismissedStatus={
|
||||
toggleIncompatibleExtensionsNoticeDismissedStatus
|
||||
}
|
||||
block={
|
||||
isCart ? 'woocommerce/cart' : 'woocommerce/checkout'
|
||||
}
|
||||
clientId={ parentId }
|
||||
/>
|
||||
|
||||
<DefaultNotice block={ isCheckout ? 'checkout' : 'cart' } />
|
||||
|
||||
{ isIncompatibleExtensionsNoticeDismissed ? (
|
||||
<CartCheckoutSidebarCompatibilityNotice
|
||||
block={ isCheckout ? 'checkout' : 'cart' }
|
||||
/>
|
||||
) : null }
|
||||
|
||||
{ isPaymentMethodsBlock && ! hasPaymentMethods && (
|
||||
<NoPaymentMethodsNotice />
|
||||
) }
|
||||
|
||||
<CartCheckoutFeedbackPrompt />
|
||||
</InspectorControls>
|
||||
<BlockEdit key="edit" { ...props } />
|
||||
</>
|
||||
);
|
||||
},
|
||||
'withSidebarNotices'
|
||||
);
|
||||
|
||||
if (
|
||||
! hasFilter(
|
||||
'editor.BlockEdit',
|
||||
'woocommerce/add/sidebar-compatibility-notice'
|
||||
)
|
||||
) {
|
||||
addFilter(
|
||||
'editor.BlockEdit',
|
||||
'woocommerce/add/sidebar-compatibility-notice',
|
||||
withSidebarNotices,
|
||||
11
|
||||
);
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useRef, useEffect } from '@wordpress/element';
|
||||
import { useRegistry, dispatch } from '@wordpress/data';
|
||||
import {
|
||||
createBlock,
|
||||
getBlockType,
|
||||
createBlocksFromInnerBlocksTemplate,
|
||||
TemplateArray,
|
||||
} from '@wordpress/blocks';
|
||||
import { useEditorContext } from '@woocommerce/base-context';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { getMissingBlocks, findBlockPosition } from './utils';
|
||||
|
||||
/**
|
||||
* Hook to ensure FORCED blocks are rendered in the correct place.
|
||||
*/
|
||||
export const useForcedLayout = ( {
|
||||
clientId,
|
||||
registeredBlocks,
|
||||
defaultTemplate = [],
|
||||
}: {
|
||||
// Client ID of the parent block.
|
||||
clientId: string;
|
||||
// An array of registered blocks that may be forced in this particular layout.
|
||||
registeredBlocks: Array< string >;
|
||||
// The default template for the inner blocks in this layout.
|
||||
defaultTemplate: TemplateArray;
|
||||
} ) => {
|
||||
const currentRegisteredBlocks = useRef( registeredBlocks );
|
||||
const currentDefaultTemplate = useRef( defaultTemplate );
|
||||
const registry = useRegistry();
|
||||
const { isPreview } = useEditorContext();
|
||||
|
||||
useEffect( () => {
|
||||
let templateSynced = false;
|
||||
|
||||
if ( isPreview ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { replaceInnerBlocks } = dispatch( 'core/block-editor' );
|
||||
|
||||
return registry.subscribe( () => {
|
||||
const currentBlock = registry
|
||||
.select( 'core/block-editor' )
|
||||
.getBlock( clientId );
|
||||
|
||||
// If the block is removed we shouldn't reinsert its inner blocks.
|
||||
if ( ! currentBlock ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const innerBlocks = registry
|
||||
.select( 'core/block-editor' )
|
||||
.getBlocks( clientId );
|
||||
|
||||
// If there are NO inner blocks, sync with the given template.
|
||||
if (
|
||||
innerBlocks.length === 0 &&
|
||||
currentDefaultTemplate.current.length > 0 &&
|
||||
! templateSynced
|
||||
) {
|
||||
const nextBlocks = createBlocksFromInnerBlocksTemplate(
|
||||
currentDefaultTemplate.current
|
||||
);
|
||||
if ( nextBlocks.length !== 0 ) {
|
||||
templateSynced = true;
|
||||
replaceInnerBlocks( clientId, nextBlocks );
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const registeredBlockTypes = currentRegisteredBlocks.current.map(
|
||||
( blockName: string ) => getBlockType( blockName )
|
||||
);
|
||||
|
||||
const missingBlocks = getMissingBlocks(
|
||||
innerBlocks,
|
||||
registeredBlockTypes
|
||||
);
|
||||
|
||||
if ( missingBlocks.length === 0 ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Initially set as -1, so we can skip checking the position multiple times. Later on in the map callback,
|
||||
// we check where the forced blocks should be inserted. This gets set to >= 0 if we find a missing block,
|
||||
// so we know we can skip calculating it.
|
||||
let insertAtPosition = -1;
|
||||
const blockConfig = missingBlocks.map( ( block ) => {
|
||||
const defaultTemplatePosition =
|
||||
currentDefaultTemplate.current.findIndex(
|
||||
( [ blockName ] ) => blockName === block.name
|
||||
);
|
||||
const createdBlock = createBlock( block.name );
|
||||
|
||||
// As mentioned above, if this is not -1, this is the first time we're calculating the position, if it's
|
||||
// already been calculated we can skip doing so.
|
||||
if ( insertAtPosition === -1 ) {
|
||||
insertAtPosition = findBlockPosition( {
|
||||
defaultTemplatePosition,
|
||||
innerBlocks,
|
||||
currentDefaultTemplate,
|
||||
} );
|
||||
}
|
||||
|
||||
return createdBlock;
|
||||
} );
|
||||
|
||||
registry.batch( () => {
|
||||
registry
|
||||
.dispatch( 'core/block-editor' )
|
||||
.insertBlocks( blockConfig, insertAtPosition, clientId );
|
||||
} );
|
||||
}, 'core/block-editor' );
|
||||
}, [ clientId, isPreview, registry ] );
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import type { Block } from '@wordpress/blocks';
|
||||
|
||||
export interface LockableBlock extends Block {
|
||||
attributes: {
|
||||
lock?: {
|
||||
type: 'object';
|
||||
remove?: boolean;
|
||||
move: boolean;
|
||||
default?: {
|
||||
remove?: boolean;
|
||||
move?: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import type { BlockInstance, TemplateArray } from '@wordpress/blocks';
|
||||
import type { MutableRefObject } from 'react';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { LockableBlock } from './types';
|
||||
|
||||
export const isBlockLocked = ( {
|
||||
attributes,
|
||||
}: {
|
||||
attributes: LockableBlock[ 'attributes' ];
|
||||
} ) => Boolean( attributes.lock?.remove || attributes.lock?.default?.remove );
|
||||
|
||||
/**
|
||||
* This hook is used to determine which blocks are missing from a block. Given the list of inner blocks of a block, we
|
||||
* can check for any registered blocks that:
|
||||
* a) Are locked,
|
||||
* b) Have the parent set as the current block, and
|
||||
* c) Are not present in the list of inner blocks.
|
||||
*/
|
||||
export const getMissingBlocks = (
|
||||
innerBlocks: BlockInstance[],
|
||||
registeredBlockTypes: ( LockableBlock | undefined )[]
|
||||
) => {
|
||||
const lockedBlockTypes = registeredBlockTypes.filter(
|
||||
( block: LockableBlock | undefined ) => block && isBlockLocked( block )
|
||||
);
|
||||
const missingBlocks: LockableBlock[] = [];
|
||||
lockedBlockTypes.forEach( ( lockedBlock ) => {
|
||||
if ( typeof lockedBlock === 'undefined' ) {
|
||||
return;
|
||||
}
|
||||
const existingBlock = innerBlocks.find(
|
||||
( block ) => block.name === lockedBlock.name
|
||||
);
|
||||
|
||||
if ( ! existingBlock ) {
|
||||
missingBlocks.push( lockedBlock );
|
||||
}
|
||||
} );
|
||||
return missingBlocks;
|
||||
};
|
||||
|
||||
/**
|
||||
* This hook is used to determine the position that a missing block should be inserted at.
|
||||
*
|
||||
* @return The index to insert the missing block at.
|
||||
*/
|
||||
export const findBlockPosition = ( {
|
||||
defaultTemplatePosition,
|
||||
innerBlocks,
|
||||
currentDefaultTemplate,
|
||||
}: {
|
||||
defaultTemplatePosition: number;
|
||||
innerBlocks: BlockInstance[];
|
||||
currentDefaultTemplate: MutableRefObject< TemplateArray >;
|
||||
} ) => {
|
||||
switch ( defaultTemplatePosition ) {
|
||||
case -1:
|
||||
// The block is not part of the default template, so we append it to the current layout.
|
||||
return innerBlocks.length;
|
||||
// defaultTemplatePosition defaults to 0, so if this happens we can just return, this is because the block was
|
||||
// the first block in the default layout, so we can prepend it to the current layout.
|
||||
case 0:
|
||||
return 0;
|
||||
default:
|
||||
// The new layout may have extra blocks compared to the default template, so rather than insert
|
||||
// at the default position, we should append it after another default block.
|
||||
const adjacentBlock =
|
||||
currentDefaultTemplate.current[ defaultTemplatePosition - 1 ];
|
||||
const position = innerBlocks.findIndex(
|
||||
( { name: blockName } ) => blockName === adjacentBlock[ 0 ]
|
||||
);
|
||||
return position === -1 ? defaultTemplatePosition : position + 1;
|
||||
}
|
||||
};
|
||||
@@ -1,54 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useSelect } from '@wordpress/data';
|
||||
import { addFilter, hasFilter } from '@wordpress/hooks';
|
||||
import type { EditorBlock } from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import Switcher from './switcher';
|
||||
import { findParentBlockEditorViews } from './utils';
|
||||
|
||||
const withViewSwitcher =
|
||||
< T extends EditorBlock< T > >( BlockEdit: React.ElementType ) =>
|
||||
( props: Record< string, unknown > ) => {
|
||||
const { clientId } = props as { clientId: string };
|
||||
const { views, currentView, viewClientId } = useSelect( ( select ) => {
|
||||
const blockAttributes =
|
||||
select( 'core/block-editor' ).getBlockAttributes( clientId );
|
||||
|
||||
return blockAttributes?.editorViews
|
||||
? {
|
||||
views: blockAttributes.editorViews,
|
||||
currentView: blockAttributes.currentView,
|
||||
viewClientId: clientId,
|
||||
}
|
||||
: findParentBlockEditorViews( clientId );
|
||||
} );
|
||||
|
||||
if ( views.length === 0 ) {
|
||||
return <BlockEdit { ...props } />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Switcher
|
||||
currentView={ currentView }
|
||||
views={ views }
|
||||
clientId={ viewClientId }
|
||||
/>
|
||||
<BlockEdit { ...props } />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
if ( ! hasFilter( 'editor.BlockEdit', 'woocommerce/with-view-switcher' ) ) {
|
||||
addFilter(
|
||||
'editor.BlockEdit',
|
||||
'woocommerce/with-view-switcher',
|
||||
withViewSwitcher,
|
||||
11
|
||||
);
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { useLayoutEffect } from '@wordpress/element';
|
||||
import { useSelect } from '@wordpress/data';
|
||||
import { ToolbarGroup, ToolbarDropdownMenu } from '@wordpress/components';
|
||||
import { BlockControls } from '@wordpress/block-editor';
|
||||
import { Icon } from '@wordpress/icons';
|
||||
import { eye } from '@woocommerce/icons';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import type { View } from './types';
|
||||
import { getView, selectView } from './utils';
|
||||
|
||||
export const Switcher = ( {
|
||||
currentView,
|
||||
views,
|
||||
clientId,
|
||||
}: {
|
||||
currentView: string;
|
||||
views: View[];
|
||||
clientId: string;
|
||||
} ): JSX.Element | null => {
|
||||
const {
|
||||
getBlockName,
|
||||
getSelectedBlockClientId,
|
||||
getBlockParentsByBlockName,
|
||||
} = useSelect( ( select ) => {
|
||||
const blockEditor = select( 'core/block-editor' );
|
||||
return {
|
||||
getBlockName: blockEditor.getBlockName,
|
||||
getSelectedBlockClientId: blockEditor.getSelectedBlockClientId,
|
||||
getBlockParentsByBlockName: blockEditor.getBlockParentsByBlockName,
|
||||
};
|
||||
}, [] );
|
||||
const selectedBlockClientId = getSelectedBlockClientId();
|
||||
const currentViewObject = getView( currentView, views ) || views[ 0 ];
|
||||
const currentViewLabel = currentViewObject.label;
|
||||
|
||||
useLayoutEffect( () => {
|
||||
const selectedBlock = selectedBlockClientId
|
||||
? getBlockName( selectedBlockClientId )
|
||||
: null;
|
||||
|
||||
// If there is no selected block, or the selected block is the current view, do nothing.
|
||||
if ( ! selectedBlock || currentView === selectedBlock ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewNames = views.map( ( view ) => view.view );
|
||||
|
||||
if ( viewNames.includes( selectedBlock ) ) {
|
||||
selectView( clientId, selectedBlock );
|
||||
return;
|
||||
}
|
||||
|
||||
// Look at the parent blocks to see if any of them are a view we can select.
|
||||
const parentBlockClientIds = getBlockParentsByBlockName(
|
||||
selectedBlockClientId,
|
||||
viewNames
|
||||
);
|
||||
|
||||
const parentBlock =
|
||||
parentBlockClientIds.length === 1
|
||||
? getBlockName( parentBlockClientIds[ 0 ] )
|
||||
: null;
|
||||
|
||||
// If there is no parent block, or the parent block is the current view, do nothing.
|
||||
if ( ! parentBlock || currentView === parentBlock ) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectView( clientId, parentBlock, false );
|
||||
}, [
|
||||
clientId,
|
||||
currentView,
|
||||
getBlockName,
|
||||
getBlockParentsByBlockName,
|
||||
selectedBlockClientId,
|
||||
views,
|
||||
] );
|
||||
|
||||
return (
|
||||
<BlockControls>
|
||||
<ToolbarGroup>
|
||||
<ToolbarDropdownMenu
|
||||
label={ __(
|
||||
'Switch view',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
text={ currentViewLabel }
|
||||
icon={
|
||||
<Icon icon={ eye } style={ { marginRight: '8px' } } />
|
||||
}
|
||||
controls={ views.map( ( view ) => ( {
|
||||
...view,
|
||||
title: (
|
||||
<span style={ { marginLeft: '8px' } }>
|
||||
{ view.label }
|
||||
</span>
|
||||
),
|
||||
isActive: view.view === currentView,
|
||||
onClick: () => {
|
||||
selectView( clientId, view.view );
|
||||
},
|
||||
} ) ) }
|
||||
/>
|
||||
</ToolbarGroup>
|
||||
</BlockControls>
|
||||
);
|
||||
};
|
||||
|
||||
export default Switcher;
|
||||
@@ -1,5 +0,0 @@
|
||||
export interface View {
|
||||
view: string;
|
||||
label: string;
|
||||
icon: string | JSX.Element;
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { select, dispatch } from '@wordpress/data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import type { View } from './types';
|
||||
|
||||
export const getView = (
|
||||
viewName: string,
|
||||
views: View[]
|
||||
): View | undefined => {
|
||||
return views.find( ( view ) => view.view === viewName );
|
||||
};
|
||||
|
||||
export const selectView = (
|
||||
clientId: string,
|
||||
viewName: string,
|
||||
selectParent = true
|
||||
) => {
|
||||
const { updateBlockAttributes, selectBlock } =
|
||||
dispatch( 'core/block-editor' );
|
||||
updateBlockAttributes( clientId, {
|
||||
currentView: viewName,
|
||||
} );
|
||||
if ( selectParent ) {
|
||||
selectBlock(
|
||||
select( 'core/block-editor' )
|
||||
.getBlock( clientId )
|
||||
?.innerBlocks.find(
|
||||
( block: { name: string } ) => block.name === viewName
|
||||
)?.clientId || clientId
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const defaultView = {
|
||||
views: [],
|
||||
currentView: '',
|
||||
viewClientId: '',
|
||||
};
|
||||
|
||||
export const findParentBlockEditorViews = (
|
||||
clientId: string,
|
||||
maxDepth = 10,
|
||||
currentDepth = 0
|
||||
): {
|
||||
views: View[];
|
||||
currentView: string;
|
||||
viewClientId: string;
|
||||
} => {
|
||||
const depth = currentDepth + 1;
|
||||
|
||||
if ( depth > maxDepth ) {
|
||||
return defaultView;
|
||||
}
|
||||
|
||||
const { getBlockAttributes, getBlockRootClientId } =
|
||||
select( 'core/block-editor' );
|
||||
const rootId = getBlockRootClientId( clientId );
|
||||
|
||||
if ( rootId === null || rootId === '' ) {
|
||||
return defaultView;
|
||||
}
|
||||
|
||||
const rootAttributes = getBlockAttributes( rootId );
|
||||
|
||||
if ( ! rootAttributes ) {
|
||||
return defaultView;
|
||||
}
|
||||
|
||||
if ( rootAttributes.editorViews !== undefined ) {
|
||||
return {
|
||||
views: rootAttributes.editorViews,
|
||||
currentView:
|
||||
rootAttributes.currentView ||
|
||||
rootAttributes.editorViews[ 0 ].view,
|
||||
viewClientId: rootId,
|
||||
};
|
||||
}
|
||||
|
||||
return findParentBlockEditorViews( rootId, maxDepth, depth );
|
||||
};
|
||||
@@ -1,56 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { getSetting } from '@woocommerce/settings';
|
||||
import { Icon } from '@wordpress/icons';
|
||||
import { filledCart, removeCart } from '@woocommerce/icons';
|
||||
|
||||
export const blockName = 'woocommerce/cart';
|
||||
export const blockAttributes = {
|
||||
isPreview: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
currentView: {
|
||||
type: 'string',
|
||||
default: 'woocommerce/filled-cart-block',
|
||||
source: 'readonly', // custom source to prevent saving to post content
|
||||
},
|
||||
editorViews: {
|
||||
type: 'object',
|
||||
default: [
|
||||
{
|
||||
view: 'woocommerce/filled-cart-block',
|
||||
label: __( 'Filled Cart', 'woocommerce' ),
|
||||
icon: <Icon icon={ filledCart } />,
|
||||
},
|
||||
{
|
||||
view: 'woocommerce/empty-cart-block',
|
||||
label: __( 'Empty Cart', 'woocommerce' ),
|
||||
icon: <Icon icon={ removeCart } />,
|
||||
},
|
||||
],
|
||||
},
|
||||
hasDarkControls: {
|
||||
type: 'boolean',
|
||||
default: getSetting( 'hasDarkEditorStyleSupport', false ),
|
||||
},
|
||||
// Deprecated - here for v1 migration support
|
||||
isShippingCalculatorEnabled: {
|
||||
type: 'boolean',
|
||||
default: getSetting( 'isShippingCalculatorEnabled', true ),
|
||||
},
|
||||
checkoutPageId: {
|
||||
type: 'number',
|
||||
default: 0,
|
||||
},
|
||||
showRateAfterTaxName: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
},
|
||||
align: {
|
||||
type: 'string',
|
||||
default: 'wide',
|
||||
},
|
||||
};
|
||||
@@ -1,98 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { useStoreCart } from '@woocommerce/base-context/hooks';
|
||||
import { useEffect } from '@wordpress/element';
|
||||
import LoadingMask from '@woocommerce/base-components/loading-mask';
|
||||
import { CURRENT_USER_IS_ADMIN } from '@woocommerce/settings';
|
||||
import BlockErrorBoundary from '@woocommerce/base-components/block-error-boundary';
|
||||
import { translateJQueryEventToNative } from '@woocommerce/base-utils';
|
||||
import withScrollToTop from '@woocommerce/base-hocs/with-scroll-to-top';
|
||||
import {
|
||||
CartEventsProvider,
|
||||
CartProvider,
|
||||
noticeContexts,
|
||||
} from '@woocommerce/base-context';
|
||||
import { SlotFillProvider } from '@woocommerce/blocks-checkout';
|
||||
import { StoreNoticesContainer } from '@woocommerce/blocks-components';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { CartBlockContext } from './context';
|
||||
import './style.scss';
|
||||
|
||||
const reloadPage = () => void window.location.reload( true );
|
||||
|
||||
const Cart = ( { children, attributes = {} } ) => {
|
||||
const { cartIsLoading } = useStoreCart();
|
||||
const { hasDarkControls } = attributes;
|
||||
|
||||
return (
|
||||
<LoadingMask showSpinner={ true } isLoading={ cartIsLoading }>
|
||||
<CartBlockContext.Provider
|
||||
value={ {
|
||||
hasDarkControls,
|
||||
} }
|
||||
>
|
||||
{ children }
|
||||
</CartBlockContext.Provider>
|
||||
</LoadingMask>
|
||||
);
|
||||
};
|
||||
|
||||
const ScrollOnError = ( { scrollToTop } ) => {
|
||||
useEffect( () => {
|
||||
// Make it so we can read jQuery events triggered by WC Core elements.
|
||||
const removeJQueryAddedToCartEvent = translateJQueryEventToNative(
|
||||
'added_to_cart',
|
||||
'wc-blocks_added_to_cart'
|
||||
);
|
||||
|
||||
document.body.addEventListener(
|
||||
'wc-blocks_added_to_cart',
|
||||
scrollToTop
|
||||
);
|
||||
|
||||
return () => {
|
||||
removeJQueryAddedToCartEvent();
|
||||
|
||||
document.body.removeEventListener(
|
||||
'wc-blocks_added_to_cart',
|
||||
scrollToTop
|
||||
);
|
||||
};
|
||||
}, [ scrollToTop ] );
|
||||
|
||||
return null;
|
||||
};
|
||||
const Block = ( { attributes, children, scrollToTop } ) => (
|
||||
<BlockErrorBoundary
|
||||
header={ __(
|
||||
'Something went wrong. Please contact us for assistance.',
|
||||
'woocommerce'
|
||||
) }
|
||||
text={ __(
|
||||
'The cart has encountered an unexpected error. If the error persists, please get in touch with us for help.',
|
||||
'woocommerce'
|
||||
) }
|
||||
button={
|
||||
<button className="wc-block-button" onClick={ reloadPage }>
|
||||
{ __( 'Reload the page', 'woocommerce' ) }
|
||||
</button>
|
||||
}
|
||||
showErrorMessage={ CURRENT_USER_IS_ADMIN }
|
||||
>
|
||||
<StoreNoticesContainer context={ noticeContexts.CART } />
|
||||
<SlotFillProvider>
|
||||
<CartProvider>
|
||||
<CartEventsProvider>
|
||||
<Cart attributes={ attributes }>{ children }</Cart>
|
||||
<ScrollOnError scrollToTop={ scrollToTop } />
|
||||
</CartEventsProvider>
|
||||
</CartProvider>
|
||||
</SlotFillProvider>
|
||||
</BlockErrorBoundary>
|
||||
);
|
||||
export default withScrollToTop( Block );
|
||||
@@ -1,69 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import {
|
||||
InnerBlockLayoutContextProvider,
|
||||
ProductDataContextProvider,
|
||||
} from '@woocommerce/shared-context';
|
||||
import { ProductResponseItem } from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { Block as ProductImage } from '../../../atomic/blocks/product-elements/image/block';
|
||||
import { Block as ProductName } from '../../../atomic/blocks/product-elements/title/block';
|
||||
import { Block as ProductRating } from '../../../atomic/blocks/product-elements/rating/block';
|
||||
import { Block as ProductSaleBadge } from '../../../atomic/blocks/product-elements/sale-badge/block';
|
||||
import { Block as ProductPrice } from '../../../atomic/blocks/product-elements/price/block';
|
||||
import { Block as ProductButton } from '../../../atomic/blocks/product-elements/button/block';
|
||||
import { ImageSizing } from '../../../atomic/blocks/product-elements/image/types';
|
||||
|
||||
interface CrossSellsProductProps {
|
||||
product: ProductResponseItem;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const CartCrossSellsProduct = ( {
|
||||
product,
|
||||
}: CrossSellsProductProps ): JSX.Element => {
|
||||
return (
|
||||
<div className="cross-sells-product">
|
||||
<InnerBlockLayoutContextProvider
|
||||
parentName={ 'woocommerce/cart-cross-sells-block' }
|
||||
parentClassName={ 'wp-block-cart-cross-sells-product' }
|
||||
>
|
||||
<ProductDataContextProvider
|
||||
// Setting isLoading to false, given this parameter is required.
|
||||
isLoading={ false }
|
||||
product={ product }
|
||||
>
|
||||
<div>
|
||||
<ProductImage
|
||||
className={ '' }
|
||||
showSaleBadge={ false }
|
||||
productId={ product.id }
|
||||
showProductLink={ false }
|
||||
saleBadgeAlign={ 'left' }
|
||||
imageSizing={ ImageSizing.SINGLE }
|
||||
isDescendentOfQueryLoop={ false }
|
||||
/>
|
||||
<ProductName
|
||||
align={ '' }
|
||||
headingLevel={ 3 }
|
||||
showProductLink={ true }
|
||||
/>
|
||||
<ProductRating />
|
||||
<ProductSaleBadge
|
||||
productId={ product.id }
|
||||
align={ 'left' }
|
||||
/>
|
||||
<ProductPrice />
|
||||
</div>
|
||||
<ProductButton />
|
||||
</ProductDataContextProvider>
|
||||
</InnerBlockLayoutContextProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CartCrossSellsProduct;
|
||||
@@ -1,37 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { ProductResponseItem } from '@woocommerce/types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import CartCrossSellsProduct from './cart-cross-sells-product';
|
||||
|
||||
interface CrossSellsProductListProps {
|
||||
products: ProductResponseItem[];
|
||||
className?: string | undefined;
|
||||
columns: number;
|
||||
}
|
||||
|
||||
const CartCrossSellsProductList = ( {
|
||||
products,
|
||||
columns,
|
||||
}: CrossSellsProductListProps ): JSX.Element => {
|
||||
const crossSellsProducts = products.map( ( product, i ) => {
|
||||
if ( i >= columns ) return null;
|
||||
|
||||
return (
|
||||
<CartCrossSellsProduct
|
||||
// Setting isLoading to false, given this parameter is required.
|
||||
isLoading={ false }
|
||||
product={ product }
|
||||
key={ product.id }
|
||||
/>
|
||||
);
|
||||
} );
|
||||
|
||||
return <div>{ crossSellsProducts }</div>;
|
||||
};
|
||||
|
||||
export default CartCrossSellsProductList;
|
||||
@@ -1,19 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { createContext, useContext } from '@wordpress/element';
|
||||
|
||||
/**
|
||||
* Context consumed by inner blocks.
|
||||
*/
|
||||
export type CartBlockContextProps = {
|
||||
hasDarkControls: boolean;
|
||||
};
|
||||
|
||||
export const CartBlockContext = createContext< CartBlockContextProps >( {
|
||||
hasDarkControls: false,
|
||||
} );
|
||||
|
||||
export const useCartBlockContext = (): CartBlockContextProps => {
|
||||
return useContext( CartBlockContext );
|
||||
};
|
||||
@@ -1,130 +0,0 @@
|
||||
/* tslint:disable */
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import classnames from 'classnames';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import {
|
||||
useBlockProps,
|
||||
InnerBlocks,
|
||||
InspectorControls,
|
||||
} from '@wordpress/block-editor';
|
||||
import BlockErrorBoundary from '@woocommerce/base-components/block-error-boundary';
|
||||
import { EditorProvider, CartProvider } from '@woocommerce/base-context';
|
||||
import { previewCart } from '@woocommerce/resource-previews';
|
||||
import { SlotFillProvider } from '@woocommerce/blocks-checkout';
|
||||
import { useEffect, useRef } from '@wordpress/element';
|
||||
import { getQueryArg } from '@wordpress/url';
|
||||
import { dispatch, select } from '@wordpress/data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './inner-blocks';
|
||||
import './editor.scss';
|
||||
import {
|
||||
addClassToBody,
|
||||
useBlockPropsWithLocking,
|
||||
BlockSettings,
|
||||
} from '../cart-checkout-shared';
|
||||
import '../cart-checkout-shared/sidebar-notices';
|
||||
import '../cart-checkout-shared/view-switcher';
|
||||
import { CartBlockContext } from './context';
|
||||
|
||||
// This is adds a class to body to signal if the selected block is locked
|
||||
addClassToBody();
|
||||
|
||||
// Array of allowed block names.
|
||||
const ALLOWED_BLOCKS = [
|
||||
'woocommerce/filled-cart-block',
|
||||
'woocommerce/empty-cart-block',
|
||||
];
|
||||
|
||||
export const Edit = ( { clientId, className, attributes, setAttributes } ) => {
|
||||
const { hasDarkControls, currentView, isPreview = false } = attributes;
|
||||
const defaultTemplate = [
|
||||
[ 'woocommerce/filled-cart-block', {}, [] ],
|
||||
[ 'woocommerce/empty-cart-block', {}, [] ],
|
||||
];
|
||||
const blockProps = useBlockPropsWithLocking( {
|
||||
className: classnames( className, 'wp-block-woocommerce-cart', {
|
||||
'is-editor-preview': isPreview,
|
||||
} ),
|
||||
} );
|
||||
|
||||
// This focuses on the block when a certain query param is found. This is used on the link from the task list.
|
||||
const focus = useRef( getQueryArg( window.location.href, 'focus' ) );
|
||||
|
||||
useEffect( () => {
|
||||
if (
|
||||
focus.current === 'cart' &&
|
||||
! select( 'core/block-editor' ).hasSelectedBlock()
|
||||
) {
|
||||
dispatch( 'core/block-editor' ).selectBlock( clientId );
|
||||
dispatch( 'core/interface' ).enableComplementaryArea(
|
||||
'core/edit-site',
|
||||
'edit-site/block-inspector'
|
||||
);
|
||||
}
|
||||
}, [ clientId ] );
|
||||
|
||||
return (
|
||||
<div { ...blockProps }>
|
||||
<InspectorControls>
|
||||
<BlockSettings
|
||||
attributes={ attributes }
|
||||
setAttributes={ setAttributes }
|
||||
/>
|
||||
</InspectorControls>
|
||||
<BlockErrorBoundary
|
||||
header={ __(
|
||||
'Cart Block Error',
|
||||
'woocommerce'
|
||||
) }
|
||||
text={ __(
|
||||
'There was an error whilst rendering the cart block. If this problem continues, try re-creating the block.',
|
||||
'woocommerce'
|
||||
) }
|
||||
showErrorMessage={ true }
|
||||
errorMessagePrefix={ __(
|
||||
'Error message:',
|
||||
'woocommerce'
|
||||
) }
|
||||
>
|
||||
<EditorProvider
|
||||
previewData={ { previewCart } }
|
||||
currentView={ currentView }
|
||||
isPreview={ isPreview }
|
||||
>
|
||||
<CartBlockContext.Provider
|
||||
value={ {
|
||||
hasDarkControls,
|
||||
} }
|
||||
>
|
||||
<SlotFillProvider>
|
||||
<CartProvider>
|
||||
<InnerBlocks
|
||||
allowedBlocks={ ALLOWED_BLOCKS }
|
||||
template={ defaultTemplate }
|
||||
templateLock="insert"
|
||||
/>
|
||||
</CartProvider>
|
||||
</SlotFillProvider>
|
||||
</CartBlockContext.Provider>
|
||||
</EditorProvider>
|
||||
</BlockErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Save = () => {
|
||||
return (
|
||||
<div
|
||||
{ ...useBlockProps.save( {
|
||||
className: 'is-loading',
|
||||
} ) }
|
||||
>
|
||||
<InnerBlocks.Content />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,37 +0,0 @@
|
||||
body.wc-lock-selected-block--move {
|
||||
.block-editor-block-mover__move-button-container,
|
||||
.block-editor-block-mover {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
body.wc-lock-selected-block--remove {
|
||||
.block-editor-block-settings-menu__popover {
|
||||
.components-menu-group:last-child {
|
||||
display: none;
|
||||
}
|
||||
.components-menu-group:nth-last-child(2) {
|
||||
margin-bottom: -12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wp-block-woocommerce-cart-items-block,
|
||||
.wp-block-woocommerce-cart-totals-block,
|
||||
.wp-block-woocommerce-empty-cart-block {
|
||||
// Temporary fix after the appender button was positioned absolute
|
||||
// See https://github.com/woocommerce/woocommerce-gutenberg-products-block/issues/5742#issuecomment-1032804168
|
||||
.block-list-appender {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
.wp-block-woocommerce-cart-order-summary-block {
|
||||
.block-editor-block-list__layout > div {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.wc-block-components-totals-wrapper {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { getValidBlockAttributes } from '@woocommerce/base-utils';
|
||||
import { Children, cloneElement, isValidElement } from '@wordpress/element';
|
||||
import { useStoreCart } from '@woocommerce/base-context';
|
||||
import { getRegisteredBlockComponents } from '@woocommerce/blocks-registry';
|
||||
|
||||
import { renderParentBlock } from '@woocommerce/atomic-utils';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import './inner-blocks/register-components';
|
||||
import Block from './block';
|
||||
import { blockName, blockAttributes } from './attributes';
|
||||
|
||||
const getProps = ( el ) => {
|
||||
return {
|
||||
attributes: getValidBlockAttributes(
|
||||
blockAttributes,
|
||||
!! el ? el.dataset : {}
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const Wrapper = ( { children } ) => {
|
||||
// we need to pluck out receiveCart.
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { extensions, receiveCart, ...cart } = useStoreCart();
|
||||
return Children.map( children, ( child ) => {
|
||||
if ( isValidElement( child ) ) {
|
||||
const componentProps = {
|
||||
extensions,
|
||||
cart,
|
||||
};
|
||||
return cloneElement( child, componentProps );
|
||||
}
|
||||
return child;
|
||||
} );
|
||||
};
|
||||
|
||||
renderParentBlock( {
|
||||
Block,
|
||||
blockName,
|
||||
selector: '.wp-block-woocommerce-cart',
|
||||
getProps,
|
||||
blockMap: getRegisteredBlockComponents( blockName ),
|
||||
blockWrapper: Wrapper,
|
||||
} );
|
||||
@@ -1,131 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import classnames from 'classnames';
|
||||
import { InnerBlocks } from '@wordpress/block-editor';
|
||||
import { cart } from '@woocommerce/icons';
|
||||
import { Icon } from '@wordpress/icons';
|
||||
import { registerBlockType, createBlock } from '@wordpress/blocks';
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { Edit, Save } from './edit';
|
||||
import './style.scss';
|
||||
import { blockName, blockAttributes } from './attributes';
|
||||
import './inner-blocks';
|
||||
|
||||
/**
|
||||
* Register and run the Cart block.
|
||||
*/
|
||||
const settings = {
|
||||
title: __( 'Cart', 'woocommerce' ),
|
||||
icon: {
|
||||
src: (
|
||||
<Icon
|
||||
icon={ cart }
|
||||
className="wc-block-editor-components-block-icon"
|
||||
/>
|
||||
),
|
||||
},
|
||||
category: 'woocommerce',
|
||||
keywords: [ __( 'WooCommerce', 'woocommerce' ) ],
|
||||
description: __( 'Shopping cart.', 'woocommerce' ),
|
||||
supports: {
|
||||
align: [ 'wide' ],
|
||||
html: false,
|
||||
multiple: false,
|
||||
},
|
||||
example: {
|
||||
attributes: {
|
||||
isPreview: true,
|
||||
},
|
||||
viewportWidth: 800,
|
||||
},
|
||||
attributes: blockAttributes,
|
||||
edit: Edit,
|
||||
save: Save,
|
||||
transforms: {
|
||||
to: [
|
||||
{
|
||||
type: 'block',
|
||||
blocks: [ 'woocommerce/classic-shortcode' ],
|
||||
transform: ( attributes ) => {
|
||||
return createBlock(
|
||||
'woocommerce/classic-shortcode',
|
||||
{
|
||||
shortcode: 'cart',
|
||||
align: attributes.align,
|
||||
},
|
||||
[]
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
// Migrates v1 to v2 checkout.
|
||||
deprecated: [
|
||||
{
|
||||
attributes: blockAttributes,
|
||||
save: ( { attributes } ) => {
|
||||
return (
|
||||
<div
|
||||
className={ classnames(
|
||||
'is-loading',
|
||||
attributes.className
|
||||
) }
|
||||
>
|
||||
<InnerBlocks.Content />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
migrate: ( attributes, innerBlocks ) => {
|
||||
const { checkoutPageId, align } = attributes;
|
||||
return [
|
||||
attributes,
|
||||
[
|
||||
createBlock(
|
||||
'woocommerce/filled-cart-block',
|
||||
{ align },
|
||||
[
|
||||
createBlock( 'woocommerce/cart-items-block' ),
|
||||
createBlock(
|
||||
'woocommerce/cart-totals-block',
|
||||
{},
|
||||
[
|
||||
createBlock(
|
||||
'woocommerce/cart-order-summary-block',
|
||||
{}
|
||||
),
|
||||
createBlock(
|
||||
'woocommerce/cart-express-payment-block'
|
||||
),
|
||||
createBlock(
|
||||
'woocommerce/proceed-to-checkout-block',
|
||||
{ checkoutPageId }
|
||||
),
|
||||
createBlock(
|
||||
'woocommerce/cart-accepted-payment-methods-block'
|
||||
),
|
||||
]
|
||||
),
|
||||
]
|
||||
),
|
||||
createBlock(
|
||||
'woocommerce/empty-cart-block',
|
||||
{ align },
|
||||
innerBlocks
|
||||
),
|
||||
],
|
||||
];
|
||||
},
|
||||
isEligible: ( _, innerBlocks ) => {
|
||||
return ! innerBlocks.find(
|
||||
( block ) => block.name === 'woocommerce/filled-cart-block'
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
registerBlockType( blockName, settings );
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"name": "woocommerce/cart-accepted-payment-methods-block",
|
||||
"version": "1.0.0",
|
||||
"title": "Accepted Payment Methods",
|
||||
"description": "Display accepted payment methods.",
|
||||
"category": "woocommerce",
|
||||
"supports": {
|
||||
"align": false,
|
||||
"html": false,
|
||||
"multiple": false,
|
||||
"reusable": false,
|
||||
"inserter": true
|
||||
},
|
||||
"parent": [ "woocommerce/cart-totals-block" ],
|
||||
"textdomain": "woocommerce",
|
||||
"$schema": "https://schemas.wp.org/trunk/block.json",
|
||||
"apiVersion": 2
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { PaymentMethodIcons } from '@woocommerce/base-components/cart-checkout';
|
||||
import { usePaymentMethods } from '@woocommerce/base-context/hooks';
|
||||
import { getIconsFromPaymentMethods } from '@woocommerce/base-utils';
|
||||
|
||||
const Block = ( { className }: { className: string } ): JSX.Element => {
|
||||
const { paymentMethods } = usePaymentMethods();
|
||||
|
||||
return (
|
||||
<PaymentMethodIcons
|
||||
className={ className }
|
||||
icons={ getIconsFromPaymentMethods( paymentMethods ) }
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Block;
|
||||
@@ -1,27 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useBlockProps } from '@wordpress/block-editor';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import Block from './block';
|
||||
|
||||
export const Edit = ( {
|
||||
attributes,
|
||||
}: {
|
||||
attributes: { className: string };
|
||||
} ): JSX.Element => {
|
||||
const { className } = attributes;
|
||||
const blockProps = useBlockProps();
|
||||
return (
|
||||
<div { ...blockProps }>
|
||||
<Block className={ className } />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Save = (): JSX.Element => {
|
||||
return <div { ...useBlockProps.save() } />;
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import Block from './block';
|
||||
|
||||
export default Block;
|
||||
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { registerBlockType } from '@wordpress/blocks';
|
||||
import { Icon, payment } from '@wordpress/icons';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { Edit, Save } from './edit';
|
||||
|
||||
registerBlockType( 'woocommerce/cart-accepted-payment-methods-block', {
|
||||
icon: {
|
||||
src: (
|
||||
<Icon
|
||||
icon={ payment }
|
||||
className="wc-block-editor-components-block-icon"
|
||||
/>
|
||||
),
|
||||
},
|
||||
edit: Edit,
|
||||
save: Save,
|
||||
} );
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"name": "woocommerce/cart-cross-sells-block",
|
||||
"version": "1.0.0",
|
||||
"title": "Cart Cross-Sells",
|
||||
"description": "Shows the Cross-Sells block.",
|
||||
"category": "woocommerce",
|
||||
"supports": {
|
||||
"align": false,
|
||||
"html": false,
|
||||
"multiple": false,
|
||||
"reusable": false,
|
||||
"inserter": true
|
||||
},
|
||||
"parent": [ "woocommerce/cart-items-block" ],
|
||||
"textdomain": "woocommerce",
|
||||
"$schema": "https://schemas.wp.org/trunk/block.json",
|
||||
"apiVersion": 2
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import type { TemplateArray } from '@wordpress/blocks';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { useBlockProps, InnerBlocks } from '@wordpress/block-editor';
|
||||
|
||||
export const Edit = (): JSX.Element => {
|
||||
const blockProps = useBlockProps( {
|
||||
className: 'wc-block-cart__cross-sells',
|
||||
} );
|
||||
const defaultTemplate = [
|
||||
[
|
||||
'core/heading',
|
||||
{
|
||||
content: __(
|
||||
'You may be interested in…',
|
||||
'woo-gutenberg-products-block'
|
||||
),
|
||||
level: 2,
|
||||
fontSize: 'large',
|
||||
},
|
||||
[],
|
||||
],
|
||||
[ 'woocommerce/cart-cross-sells-products-block', {}, [] ],
|
||||
] as TemplateArray;
|
||||
|
||||
return (
|
||||
<div { ...blockProps }>
|
||||
<InnerBlocks template={ defaultTemplate } templateLock={ false } />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Save = (): JSX.Element => {
|
||||
return (
|
||||
<div { ...useBlockProps.save() }>
|
||||
<InnerBlocks.Content />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useStoreCart } from '@woocommerce/base-context/hooks';
|
||||
|
||||
interface Props {
|
||||
children?: JSX.Element | JSX.Element[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const FrontendBlock = ( {
|
||||
children,
|
||||
className = '',
|
||||
}: Props ): JSX.Element | null => {
|
||||
const { crossSellsProducts, cartIsLoading } = useStoreCart();
|
||||
|
||||
if ( cartIsLoading || crossSellsProducts.length < 1 ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className={ className }>{ children }</div>;
|
||||
};
|
||||
|
||||
export default FrontendBlock;
|
||||
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { Icon, column } from '@wordpress/icons';
|
||||
import { registerBlockType } from '@wordpress/blocks';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { Edit, Save } from './edit';
|
||||
|
||||
registerBlockType( 'woocommerce/cart-cross-sells-block', {
|
||||
icon: {
|
||||
src: (
|
||||
<Icon
|
||||
icon={ column }
|
||||
className="wc-block-editor-components-block-icon"
|
||||
/>
|
||||
),
|
||||
},
|
||||
edit: Edit,
|
||||
save: Save,
|
||||
} );
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"name": "woocommerce/cart-cross-sells-products-block",
|
||||
"version": "1.0.0",
|
||||
"title": "Cart Cross-Sells Products",
|
||||
"description": "Shows the Cross-Sells products.",
|
||||
"category": "woocommerce",
|
||||
"supports": {
|
||||
"align": false,
|
||||
"html": false,
|
||||
"multiple": false,
|
||||
"reusable": false,
|
||||
"inserter": false,
|
||||
"lock": false
|
||||
},
|
||||
"attributes": {
|
||||
"columns": {
|
||||
"type": "number",
|
||||
"default": 3
|
||||
},
|
||||
"lock": {
|
||||
"type": "object",
|
||||
"default": {
|
||||
"remove": true,
|
||||
"move": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"parent": [ "woocommerce/cart-cross-sells-block" ],
|
||||
"textdomain": "woocommerce",
|
||||
"$schema": "https://schemas.wp.org/trunk/block.json",
|
||||
"apiVersion": 2
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useStoreCart } from '@woocommerce/base-context/hooks';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import CartCrossSellsProductList from '../../cart-cross-sells-product-list';
|
||||
import metadata from './block.json';
|
||||
|
||||
interface BlockProps {
|
||||
className?: string | undefined;
|
||||
columns: number;
|
||||
}
|
||||
|
||||
const Block = ( { className, columns }: BlockProps ): JSX.Element => {
|
||||
const { crossSellsProducts } = useStoreCart();
|
||||
|
||||
if ( typeof columns === 'undefined' ) {
|
||||
columns = metadata.attributes.columns.default;
|
||||
}
|
||||
|
||||
return (
|
||||
<CartCrossSellsProductList
|
||||
className={ className }
|
||||
columns={ columns }
|
||||
products={ crossSellsProducts }
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Block;
|
||||
@@ -1,59 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { PanelBody, RangeControl } from '@wordpress/components';
|
||||
import { useBlockProps, InspectorControls } from '@wordpress/block-editor';
|
||||
import { getSetting } from '@woocommerce/settings';
|
||||
import Noninteractive from '@woocommerce/base-components/noninteractive';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import Block from './block';
|
||||
import './editor.scss';
|
||||
|
||||
interface Attributes {
|
||||
className?: string;
|
||||
columns: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
attributes: Attributes;
|
||||
setAttributes: ( attributes: Record< string, unknown > ) => void;
|
||||
}
|
||||
|
||||
export const Edit = ( { attributes, setAttributes }: Props ): JSX.Element => {
|
||||
const { className, columns } = attributes;
|
||||
const blockProps = useBlockProps();
|
||||
|
||||
return (
|
||||
<div { ...blockProps }>
|
||||
<InspectorControls>
|
||||
<PanelBody
|
||||
title={ __( 'Settings', 'woo-gutenberg-products-block' ) }
|
||||
>
|
||||
<RangeControl
|
||||
label={ __(
|
||||
'Cross-Sells products to show',
|
||||
'woo-gutenberg-products-block'
|
||||
) }
|
||||
value={ columns }
|
||||
onChange={ ( value ) =>
|
||||
setAttributes( { columns: value } )
|
||||
}
|
||||
min={ getSetting( 'minColumns', 1 ) }
|
||||
max={ getSetting( 'maxColumns', 6 ) }
|
||||
/>
|
||||
</PanelBody>
|
||||
</InspectorControls>
|
||||
<Noninteractive>
|
||||
<Block columns={ columns } className={ className } />
|
||||
</Noninteractive>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Save = (): JSX.Element => {
|
||||
return <div { ...useBlockProps.save() } />;
|
||||
};
|
||||
@@ -1,38 +0,0 @@
|
||||
.wp-block-woocommerce-cart-cross-sells-products-block {
|
||||
|
||||
.cross-sells-product {
|
||||
display: inline-block;
|
||||
margin-bottom: 2em;
|
||||
padding-right: 5%;
|
||||
text-align: center;
|
||||
vertical-align: top;
|
||||
width: 30%;
|
||||
|
||||
&:nth-child(3n + 3) {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
div {
|
||||
.wc-block-components-product-name {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.wc-block-components-product-price {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-components-product-add-to-cart-button:not(.is-link) {
|
||||
background-color: #eee;
|
||||
color: #333;
|
||||
margin-top: 1em;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
background-color: #d5d5d5;
|
||||
border-color: #d5d5d5;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import Block from './block';
|
||||
|
||||
export default Block;
|
||||
@@ -1,24 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { Icon, column } from '@wordpress/icons';
|
||||
import { registerBlockType } from '@wordpress/blocks';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { Edit, Save } from './edit';
|
||||
import './style.scss';
|
||||
|
||||
registerBlockType( 'woocommerce/cart-cross-sells-products-block', {
|
||||
icon: {
|
||||
src: (
|
||||
<Icon
|
||||
icon={ column }
|
||||
className="wc-block-editor-components-block-icon"
|
||||
/>
|
||||
),
|
||||
},
|
||||
edit: Edit,
|
||||
save: Save,
|
||||
} );
|
||||
@@ -1,72 +0,0 @@
|
||||
.wp-block-woocommerce-cart {
|
||||
|
||||
&.is-loading .wp-block-woocommerce-cart-cross-sells-block {
|
||||
@include placeholder();
|
||||
margin-top: 2em;
|
||||
min-height: 15em;
|
||||
|
||||
h3 {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.wp-block-woocommerce-cart-cross-sells-block {
|
||||
|
||||
.cross-sells-product {
|
||||
display: inline-block;
|
||||
box-sizing: content-box;
|
||||
margin-bottom: 2em;
|
||||
padding-right: 5%;
|
||||
text-align: center;
|
||||
vertical-align: top;
|
||||
width: 30%;
|
||||
|
||||
&:nth-child(3n + 3) {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
div {
|
||||
.wc-block-components-product-name {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.wc-block-components-product-price {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.wc-block-components-product-button__button {
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
.wc-block-components-product-add-to-cart {
|
||||
justify-content: center;
|
||||
|
||||
.wc-block-components-product-add-to-cart-button:not(.is-link) {
|
||||
background-color: #eee;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
margin-top: 1em;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
background-color: #d5d5d5;
|
||||
border-color: #d5d5d5;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include breakpoint("<480px") {
|
||||
.wp-block-woocommerce-cart {
|
||||
.wp-block-woocommerce-cart-cross-sells-block {
|
||||
.cross-sells-product {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"name": "woocommerce/cart-express-payment-block",
|
||||
"version": "1.0.0",
|
||||
"title": "Express Checkout",
|
||||
"description": "Allow customers to breeze through with quick payment options.",
|
||||
"category": "woocommerce",
|
||||
"supports": {
|
||||
"align": false,
|
||||
"html": false,
|
||||
"multiple": false,
|
||||
"reusable": false,
|
||||
"inserter": false,
|
||||
"lock": false
|
||||
},
|
||||
"attributes": {
|
||||
"lock": {
|
||||
"type": "object",
|
||||
"default": {
|
||||
"remove": true,
|
||||
"move": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"parent": [ "woocommerce/cart-totals-block" ],
|
||||
"textdomain": "woocommerce",
|
||||
"$schema": "https://schemas.wp.org/trunk/block.json",
|
||||
"apiVersion": 2
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useStoreCart } from '@woocommerce/base-context/hooks';
|
||||
import classnames from 'classnames';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { CartExpressPayment } from '../../../cart-checkout-shared/payment-methods';
|
||||
|
||||
const Block = ( { className }: { className: string } ): JSX.Element | null => {
|
||||
const { cartNeedsPayment } = useStoreCart();
|
||||
|
||||
if ( ! cartNeedsPayment ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={ classnames(
|
||||
'wc-block-cart__payment-options',
|
||||
className
|
||||
) }
|
||||
>
|
||||
<CartExpressPayment />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Block;
|
||||
@@ -1,42 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useBlockProps } from '@wordpress/block-editor';
|
||||
import { useExpressPaymentMethods } from '@woocommerce/base-context/hooks';
|
||||
import classnames from 'classnames';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import Block from './block';
|
||||
import './editor.scss';
|
||||
|
||||
export const Edit = ( {
|
||||
attributes,
|
||||
}: {
|
||||
attributes: { className: string };
|
||||
} ): JSX.Element | null => {
|
||||
const { paymentMethods, isInitialized } = useExpressPaymentMethods();
|
||||
const hasExpressPaymentMethods = Object.keys( paymentMethods ).length > 0;
|
||||
const blockProps = useBlockProps( {
|
||||
className: classnames( {
|
||||
'wp-block-woocommerce-cart-express-payment-block--has-express-payment-methods':
|
||||
hasExpressPaymentMethods,
|
||||
} ),
|
||||
} );
|
||||
const { className } = attributes;
|
||||
|
||||
if ( ! isInitialized || ! hasExpressPaymentMethods ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div { ...blockProps }>
|
||||
<Block className={ className } />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Save = (): JSX.Element => {
|
||||
return <div { ...useBlockProps.save() } />;
|
||||
};
|
||||
@@ -1,34 +0,0 @@
|
||||
// Adjust padding and margins in the editor to improve selected block outlines.
|
||||
.wp-block-woocommerce-cart-express-payment-block {
|
||||
.components-placeholder__label svg {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.wc-block-cart__payment-options {
|
||||
padding: 0;
|
||||
|
||||
.wc-block-components-express-payment-continue-rule {
|
||||
margin-bottom: -12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wp-block-woocommerce-proceed-to-checkout-block {
|
||||
margin-bottom: 28px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.wp-block-woocommerce-checkout-express-payment-block-placeholder {
|
||||
* {
|
||||
pointer-events: all; // Overrides parent disabled component in editor context
|
||||
}
|
||||
|
||||
.wp-block-woocommerce-cart-express-payment-block &,
|
||||
.wp-block-woocommerce-checkout-express-payment-block-placeholder__description {
|
||||
margin: 0 0 1em;
|
||||
}
|
||||
|
||||
.wp-block-woocommerce-checkout-express-payment-block-placeholder__description {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import Block from './block';
|
||||
|
||||
export default Block;
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { Icon } from '@wordpress/icons';
|
||||
import { registerBlockType } from '@wordpress/blocks';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { Edit, Save } from './edit';
|
||||
import expressIcon from '../../../cart-checkout-shared/icon';
|
||||
|
||||
registerBlockType( 'woocommerce/cart-express-payment-block', {
|
||||
icon: {
|
||||
src: (
|
||||
<Icon
|
||||
style={ { fill: 'none' } } // this is needed for this particular svg
|
||||
icon={ expressIcon }
|
||||
className="wc-block-editor-components-block-icon"
|
||||
/>
|
||||
),
|
||||
},
|
||||
edit: Edit,
|
||||
save: Save,
|
||||
} );
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"name": "woocommerce/cart-items-block",
|
||||
"version": "1.0.0",
|
||||
"title": "Cart Items",
|
||||
"description": "Column containing cart items.",
|
||||
"category": "woocommerce",
|
||||
"supports": {
|
||||
"align": false,
|
||||
"html": false,
|
||||
"multiple": false,
|
||||
"reusable": false,
|
||||
"inserter": false,
|
||||
"lock": false
|
||||
},
|
||||
"attributes": {
|
||||
"lock": {
|
||||
"type": "object",
|
||||
"default": {
|
||||
"remove": true,
|
||||
"move": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"parent": [ "woocommerce/filled-cart-block" ],
|
||||
"textdomain": "woocommerce",
|
||||
"$schema": "https://schemas.wp.org/trunk/block.json",
|
||||
"apiVersion": 2
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useBlockProps, InnerBlocks } from '@wordpress/block-editor';
|
||||
import { Main } from '@woocommerce/base-components/sidebar-layout';
|
||||
import { innerBlockAreas } from '@woocommerce/blocks-checkout';
|
||||
import type { TemplateArray } from '@wordpress/blocks';
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import {
|
||||
useForcedLayout,
|
||||
getAllowedBlocks,
|
||||
} from '../../../cart-checkout-shared';
|
||||
|
||||
interface Props {
|
||||
clientId: string;
|
||||
}
|
||||
|
||||
export const Edit = ( { clientId }: Props ): JSX.Element => {
|
||||
const blockProps = useBlockProps( { className: 'wc-block-cart__main' } );
|
||||
const allowedBlocks = getAllowedBlocks( innerBlockAreas.CART_ITEMS );
|
||||
const defaultTemplate = [
|
||||
[ 'woocommerce/cart-line-items-block', {}, [] ],
|
||||
[ 'woocommerce/cart-cross-sells-block', {}, [] ],
|
||||
] as unknown as TemplateArray;
|
||||
|
||||
useForcedLayout( {
|
||||
clientId,
|
||||
registeredBlocks: allowedBlocks,
|
||||
defaultTemplate,
|
||||
} );
|
||||
return (
|
||||
<Main { ...blockProps }>
|
||||
<InnerBlocks
|
||||
allowedBlocks={ allowedBlocks }
|
||||
template={ defaultTemplate }
|
||||
templateLock={ false }
|
||||
renderAppender={ InnerBlocks.ButtonBlockAppender }
|
||||
/>
|
||||
</Main>
|
||||
);
|
||||
};
|
||||
|
||||
export const Save = (): JSX.Element => {
|
||||
return (
|
||||
<div { ...useBlockProps.save() }>
|
||||
<InnerBlocks.Content />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { Main } from '@woocommerce/base-components/sidebar-layout';
|
||||
import classnames from 'classnames';
|
||||
|
||||
const FrontendBlock = ( {
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: JSX.Element;
|
||||
className: string;
|
||||
} ): JSX.Element => {
|
||||
return (
|
||||
<Main className={ classnames( 'wc-block-cart__main', className ) }>
|
||||
{ children }
|
||||
</Main>
|
||||
);
|
||||
};
|
||||
|
||||
export default FrontendBlock;
|
||||
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { Icon, column } from '@wordpress/icons';
|
||||
import { registerBlockType } from '@wordpress/blocks';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { Edit, Save } from './edit';
|
||||
|
||||
registerBlockType( 'woocommerce/cart-items-block', {
|
||||
icon: {
|
||||
src: (
|
||||
<Icon
|
||||
icon={ column }
|
||||
className="wc-block-editor-components-block-icon"
|
||||
/>
|
||||
),
|
||||
},
|
||||
edit: Edit,
|
||||
save: Save,
|
||||
} );
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"name": "woocommerce/cart-line-items-block",
|
||||
"version": "1.0.0",
|
||||
"title": "Cart Line Items",
|
||||
"description": "Block containing current line items in Cart.",
|
||||
"category": "woocommerce",
|
||||
"supports": {
|
||||
"align": false,
|
||||
"html": false,
|
||||
"multiple": false,
|
||||
"reusable": false,
|
||||
"inserter": false,
|
||||
"lock": false
|
||||
},
|
||||
"attributes": {
|
||||
"lock": {
|
||||
"type": "object",
|
||||
"default": {
|
||||
"remove": true,
|
||||
"move": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"parent": [ "woocommerce/cart-items-block" ],
|
||||
"textdomain": "woocommerce",
|
||||
"$schema": "https://schemas.wp.org/trunk/block.json",
|
||||
"apiVersion": 2
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useStoreCart } from '@woocommerce/base-context/hooks';
|
||||
import { CartLineItemsTable } from '@woocommerce/base-components/cart-checkout';
|
||||
|
||||
const Block = ( { className }: { className: string } ): JSX.Element => {
|
||||
const { cartItems, cartIsLoading } = useStoreCart();
|
||||
return (
|
||||
<CartLineItemsTable
|
||||
className={ className }
|
||||
lineItems={ cartItems }
|
||||
isLoading={ cartIsLoading }
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Block;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user