first commit

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

View File

@@ -0,0 +1,149 @@
/**
* 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,
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( {
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(
'is_rendering_php_template',
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;

View File

@@ -0,0 +1,11 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
export const blockAttributes = {
heading: {
type: 'string',
default: __( 'Active filters', 'woo-gutenberg-products-block' ),
},
};

View File

@@ -0,0 +1,26 @@
/**
* External dependencies
*/
import { useColorProps } 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 colorProps = useColorProps( props );
return (
<div
className={ isString( props.className ) ? props.className : '' }
style={ { ...colorProps.style } }
>
<Block isEditor={ false } attributes={ parseAttributes( props ) } />
</div>
);
};
export default BlockWrapper;

View File

@@ -0,0 +1,31 @@
{
"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"
}

View File

@@ -0,0 +1,427 @@
/**
* 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 PropTypes from 'prop-types';
import Label from '@woocommerce/base-components/label';
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';
/**
* Internal dependencies
*/
import './style.scss';
import { getAttributeFromTaxonomy } from '../../utils/attributes';
import {
formatPriceRange,
renderRemovableListItem,
removeArgsFromFilterUrl,
cleanFilterUrl,
maybeUrlContainsFilters,
urlContainsAttributeFilter,
} from './utils';
import ActiveAttributeFilters from './active-attribute-filters';
import FilterPlaceholders from './filter-placeholders';
import { Attributes } from './types';
import { useSetWraperVisibility } from '../filter-wrapper/context';
/**
* Component displaying active filters.
*
* @param {Object} props Incoming props for the component.
* @param {Object} props.attributes Incoming attributes for the block.
* @param {boolean} props.isEditor Whether or not in the editor context.
*/
const ActiveFiltersBlock = ( {
attributes: blockAttributes,
isEditor = false,
}: {
attributes: Attributes;
isEditor?: boolean;
} ) => {
const setWrapperVisibility = useSetWraperVisibility();
const isMounted = useIsMounted();
const componentHasMounted = isMounted();
const filteringForPhpTemplate = getSettingWithCoercion(
'is_rendering_php_template',
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 = 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(
'has_filterable_products',
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>
</>
);
};
ActiveFiltersBlock.propTypes = {
/**
* The attributes for this block.
*/
attributes: PropTypes.object.isRequired,
/**
* Whether it's in the editor or frontend display.
*/
isEditor: PropTypes.bool,
};
export default ActiveFiltersBlock;

View File

@@ -0,0 +1,105 @@
/**
* 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 );

View File

@@ -0,0 +1,9 @@
.wc-block-active-filters {
.wc-block-active-filters__list-item-name {
margin-top: 9px;
}
}
.wc-block-active-filter__style-toggle {
width: 100%;
}

View File

@@ -0,0 +1,30 @@
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;

View File

@@ -0,0 +1,23 @@
/**
* 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,
} );

View File

@@ -0,0 +1,55 @@
/**
* 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';
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, 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>
);
},
} );

View File

@@ -0,0 +1,197 @@
.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;
padding: 0;
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);
}
}
}

View File

@@ -0,0 +1,6 @@
export interface Attributes {
heading: string;
headingLevel: number;
displayStyle: string;
className?: string;
}

View File

@@ -0,0 +1,298 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { formatPrice } from '@woocommerce/price-format';
import { RemovableChip } from '@woocommerce/base-components/chip';
import Label from '@woocommerce/base-components/label';
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 }
&nbsp;
{ 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;
};
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,
};
};

View File

@@ -0,0 +1,11 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
export const blockAttributes = {
heading: {
type: 'string',
default: __( 'Filter by attribute', 'woo-gutenberg-products-block' ),
},
};

View File

@@ -0,0 +1,26 @@
/**
* External dependencies
*/
import { useColorProps } 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 colorProps = useColorProps( props );
return (
<div
className={ isString( props.className ) ? props.className : '' }
style={ { ...colorProps.style } }
>
<Block isEditor={ false } attributes={ parseAttributes( props ) } />
</div>
);
};
export default BlockWrapper;

View File

@@ -0,0 +1,58 @@
{
"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": true
},
"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"
}

View File

@@ -0,0 +1,700 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import {
usePrevious,
useShallowEqual,
useBorderProps,
} 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,
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 { difference } from 'lodash';
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(
'has_filterable_products',
false,
isBoolean
);
const filteringForPhpTemplate = getSettingWithCoercion(
'is_rendering_php_template',
false,
isBoolean
);
const pageUrl = getSettingWithCoercion(
'page_url',
window.location.href,
isString
);
const productIds = isEditor
? []
: getSettingWithCoercion( 'product_ids', [], Array.isArray );
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 borderProps = useBorderProps( blockAttributes );
const [ queryState ] = useQueryStateByContext();
const [ productAttributesQuery, setProductAttributesQuery ] =
useQueryStateByKey( 'attributes', [] );
const { results: attributeTerms, isLoading: attributeTermsLoading } =
useCollection( {
namespace: '/wc/store/v1',
resourceName: 'products/attributes/terms',
resourceValues: [ attributeObject?.id || 0 ],
shouldSelect: blockAttributes.attributeId > 0,
} );
const filterAvailableTerms =
blockAttributes.displayStyle !== 'dropdown' &&
blockAttributes.queryType === 'and';
const { results: filteredCounts, isLoading: filteredCountsLoading } =
useCollectionData( {
queryAttribute: {
taxonomy: attributeObject?.taxonomy || '',
queryType: blockAttributes.queryType,
},
queryState: {
...queryState,
attributes: filterAvailableTerms ? queryState.attributes : null,
},
productIds,
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( borderProps.className, {
'single-selection': ! multiple,
'is-loading': isLoading,
} ) }
style={ {
...borderProps.style,
borderStyle: 'none',
} }
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 = difference( tokens, checked );
if ( added.length === 1 ) {
return onChange( added[ 0 ] );
}
const removed = difference( checked, tokens );
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;

View File

@@ -0,0 +1,47 @@
/**
* External dependencies
*/
import CheckboxList from '@woocommerce/base-components/checkbox-list';
/**
* 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;

View File

@@ -0,0 +1,452 @@
/**
* External dependencies
*/
import { sortBy } from 'lodash';
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,
className,
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 = sortBy(
ATTRIBUTES.map( ( item ) => {
return {
id: parseInt( item.attribute_id, 10 ),
name: item.attribute_label,
};
} ),
'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'
) }
>
{ __( '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/"
>
{ __( '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(
className,
'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 );

View File

@@ -0,0 +1,53 @@
.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;
}

View File

@@ -0,0 +1,23 @@
/**
* 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,
} );

View File

@@ -0,0 +1,85 @@
/**
* External dependencies
*/
import { registerBlockType } from '@wordpress/blocks';
import { useBlockProps } from '@wordpress/block-editor';
import { isFeaturePluginBuild } from '@woocommerce/block-settings';
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';
registerBlockType( metadata, {
icon: {
src: (
<Icon
icon={ category }
className="wc-block-editor-components-block-icon"
/>
),
},
supports: {
...metadata.supports,
...( isFeaturePluginBuild() && {
__experimentalBorder: {
radius: false,
color: true,
width: false,
},
} ),
},
attributes: {
...metadata.attributes,
...blockAttributes,
},
edit,
// Save the props to post content.
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>
);
},
} );

View File

@@ -0,0 +1,41 @@
/**
* 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: '',
};

View File

@@ -0,0 +1,216 @@
@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;
}

View File

@@ -0,0 +1,191 @@
/**
* 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();
} );
} );
} );

View File

@@ -0,0 +1,33 @@
/**
* 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;

View File

@@ -0,0 +1,148 @@
/**
* 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 !== 'false',
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,
};
};

View File

@@ -0,0 +1,36 @@
{
"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"
}

View File

@@ -0,0 +1,29 @@
/**
* 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;

View File

@@ -0,0 +1,49 @@
/**
* External dependencies
*/
import { registerBlockType } from '@wordpress/blocks';
import { isFeaturePluginBuild } from '@woocommerce/block-settings';
import { Icon, queryPagination } from '@wordpress/icons';
/**
* Internal dependencies
*/
import metadata from './block.json';
import edit from './edit';
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={ queryPagination }
className="wc-block-editor-components-block-icon"
/>
),
},
attributes: {
...metadata.attributes,
},
supports: {
...featurePluginSupport,
},
edit,
save() {
return null;
},
} );

View File

@@ -0,0 +1,9 @@
.woocommerce.wc-block-breadcrumbs {
font-size: inherit;
}
.woocommerce.woocommerce-shop .wc-block-breadcrumbs {
.woocommerce-breadcrumb {
margin: auto;
display: block;
}
}

View File

@@ -0,0 +1,39 @@
/**
* 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>
);
};

View File

@@ -0,0 +1,46 @@
/**
* 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,
] )
);
};

View File

@@ -0,0 +1,177 @@
/**
* 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;
};

View File

@@ -0,0 +1,5 @@
export * from './hacks';
export * from './use-forced-layout';
export * from './editor-utils';
export * from './sidebar-notices';
export * from './block-settings';

View File

@@ -0,0 +1,175 @@
/**
* 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;

View File

@@ -0,0 +1,82 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useExpressPaymentMethods } from '@woocommerce/base-context/hooks';
import { noticeContexts } from '@woocommerce/base-context';
import { StoreNoticesContainer } from '@woocommerce/blocks-checkout';
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;

View File

@@ -0,0 +1,114 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useEditorContext, noticeContexts } from '@woocommerce/base-context';
import { StoreNoticesContainer } from '@woocommerce/blocks-checkout';
import Title from '@woocommerce/base-components/title';
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;

View File

@@ -0,0 +1,2 @@
export { default as CartExpressPayment } from './cart-express-payment.js';
export { default as CheckoutExpressPayment } from './checkout-express-payment.js';

View File

@@ -0,0 +1,153 @@
$border-width: 1px;
$border-radius: 5px;
.wc-block-components-express-payment {
margin: auto;
position: relative;
.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;
box-sizing: border-box;
width: 100%;
padding: 0;
margin: 0;
overflow: hidden;
text-align: center;
> li {
margin: 0;
width: 100%;
> img {
width: 100%;
height: 48px;
}
}
}
@include breakpoint("<782px") {
.wc-block-components-express-payment__event-buttons {
grid-template-columns: 1fr;
}
}
}
.wc-block-components-express-payment--checkout {
/* stylelint-disable-next-line function-calc-no-unspaced-operator */
margin-top: calc($border-radius * 3);
.wc-block-components-express-payment__title-container {
display: flex;
flex-direction: row;
left: 0;
position: absolute;
right: 0;
top: -$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: $border-radius 0 0 0;
content: "";
display: block;
height: $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 $border-radius 0 0;
content: "";
display: block;
height: $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 - $border-radius} $gap-large $gap-large;
&::after {
border-radius: 0 0 $border-radius $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;
}
}

View File

@@ -0,0 +1,4 @@
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';

View File

@@ -0,0 +1,30 @@
/**
* 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;

View File

@@ -0,0 +1,3 @@
.components-notice.wc-block-checkout__no-payment-methods-notice {
margin: 0 0 $gap;
}

View File

@@ -0,0 +1,66 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useEditorContext } from '@woocommerce/base-context';
import { CheckboxControl } from '@woocommerce/blocks-checkout';
import PropTypes from 'prop-types';
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.
*/
const PaymentMethodCard = ( { children, showSaveOption } ) => {
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.',
'woocommerce'
) }
checked={ shouldSavePaymentMethod }
onChange={ () =>
__internalSetShouldSavePaymentMethod(
! shouldSavePaymentMethod
)
}
/>
) }
</PaymentMethodErrorBoundary>
);
};
PaymentMethodCard.propTypes = {
showSaveOption: PropTypes.bool,
children: PropTypes.node,
};
export default PaymentMethodCard;

View File

@@ -0,0 +1,68 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Component } from '@wordpress/element';
import PropTypes from 'prop-types';
import { CURRENT_USER_IS_ADMIN } from '@woocommerce/settings';
import { StoreNoticesContainer } from '@woocommerce/blocks-checkout';
import { noticeContexts } from '@woocommerce/base-context';
class PaymentMethodErrorBoundary extends Component {
state = { errorMessage: '', hasError: false };
static getDerivedStateFromError( error ) {
return {
errorMessage: error.message,
hasError: true,
};
}
render() {
const { hasError, errorMessage } = this.state;
const { isEditor } = this.props;
if ( hasError ) {
let errorText = __(
'We are experiencing difficulties with this payment method. Please contact us for assistance.',
'woocommerce'
);
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.",
'woocommerce'
);
}
}
const notices = [
{
id: '0',
content: errorText,
isDismissible: false,
status: 'error',
},
];
return (
<StoreNoticesContainer
additionalNotices={ notices }
context={ noticeContexts.PAYMENTS }
/>
);
}
return this.props.children;
}
}
PaymentMethodErrorBoundary.propTypes = {
isEditor: PropTypes.bool,
};
PaymentMethodErrorBoundary.defaultProps = {
isEditor: false,
};
export default PaymentMethodErrorBoundary;

View File

@@ -0,0 +1,108 @@
/**
* 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/base-components/radio-control-accordion';
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;

View File

@@ -0,0 +1,68 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import Label from '@woocommerce/base-components/label';
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';
/**
* 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;

View File

@@ -0,0 +1,151 @@
/**
* External dependencies
*/
import { useMemo, cloneElement } from '@wordpress/element';
import { __, sprintf } from '@wordpress/i18n';
import { noticeContexts } from '@woocommerce/base-context';
import RadioControl from '@woocommerce/base-components/radio-control';
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';
/**
* @typedef {import('@woocommerce/type-defs/contexts').CustomerPaymentMethod} CustomerPaymentMethod
* @typedef {import('@woocommerce/type-defs/contexts').PaymentStatusDispatch} PaymentStatusDispatch
*/
/**
* Returns the option object for a cc or echeck saved payment method token.
*
* @param {CustomerPaymentMethod} savedPaymentMethod
* @return {string} label
*/
const getCcOrEcheckLabel = ( { method, expires } ) => {
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)',
'woocommerce'
),
method.brand,
method.last4,
expires
);
};
/**
* Returns the option object for any non specific saved payment method.
*
* @param {CustomerPaymentMethod} savedPaymentMethod
* @return {string} label
*/
const getDefaultLabel = ( { method } ) => {
/* 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', 'woocommerce' ),
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', 'woocommerce' ),
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 paymentMethods = getPaymentMethods();
const paymentMethodInterface = usePaymentMethodInterface();
const { removeNotice } = useDispatch( 'core/notices' );
const { dispatchCheckoutEvent } = useStoreEvents();
const options = useMemo( () => {
const types = Object.keys( savedPaymentMethods );
return types
.flatMap( ( type ) => {
const typeMethods = savedPaymentMethods[ type ];
return typeMethods.map( ( paymentMethod ) => {
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 ) => {
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,
}
);
},
};
} );
} )
.filter( Boolean );
}, [
savedPaymentMethods,
__internalSetActivePaymentMethod,
removeNotice,
dispatchCheckoutEvent,
] );
const savedPaymentMethodHandler =
!! activeSavedToken &&
paymentMethods[ activePaymentMethod ] &&
paymentMethods[ activePaymentMethod ]?.savedTokenComponent
? cloneElement(
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;

View File

@@ -0,0 +1,254 @@
.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: 4px;
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-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__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;
}
}

View File

@@ -0,0 +1,201 @@
/**
* 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/base-components/radio-control-accordion',
() =>
( { 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() );
} );
} );

View File

@@ -0,0 +1,140 @@
/**
* 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 { IncompatiblePaymentGatewaysNotice } from '@woocommerce/editor-components/incompatible-payment-gateways-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 [
isIncompatiblePaymentGatewaysNoticeDismissed,
setIsIncompatiblePaymentGatewaysNoticeDismissed,
] = useState( true );
const toggleIncompatiblePaymentGatewaysNoticeDismissedStatus = (
isDismissed: boolean
) => {
setIsIncompatiblePaymentGatewaysNoticeDismissed( isDismissed );
};
const { isCart, isCheckout, isPaymentMethodsBlock, hasPaymentMethods } =
useSelect( ( select ) => {
const { getBlockParentsByBlockName, getBlockName } =
select( blockEditorStore );
const parent = getBlockParentsByBlockName( clientId, [
'woocommerce/cart',
'woocommerce/checkout',
] ).map( getBlockName );
const currentBlockName = getBlockName( clientId );
return {
isCart:
parent.includes( 'woocommerce/cart' ) ||
currentBlockName === 'woocommerce/cart',
isCheckout:
parent.includes( 'woocommerce/checkout' ) ||
currentBlockName === 'woocommerce/checkout',
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>
<IncompatiblePaymentGatewaysNotice
toggleDismissedStatus={
toggleIncompatiblePaymentGatewaysNoticeDismissedStatus
}
block={
isCheckout
? 'woocommerce/checkout'
: 'woocommerce/cart'
}
/>
{ isIncompatiblePaymentGatewaysNoticeDismissed ? (
<>
<DefaultNotice
block={ isCheckout ? 'checkout' : 'cart' }
/>
<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
);
}

View File

@@ -0,0 +1,113 @@
/**
* 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 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 ] );
};

View File

@@ -0,0 +1,18 @@
/**
* 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;
};
};
};
}

View File

@@ -0,0 +1,80 @@
/**
* 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;
}
};

View File

@@ -0,0 +1,54 @@
/**
* 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
);
}

View File

@@ -0,0 +1,116 @@
/**
* 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;

View File

@@ -0,0 +1,5 @@
export interface View {
view: string;
label: string;
icon: string | JSX.Element;
}

View File

@@ -0,0 +1,85 @@
/**
* 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 );
};

View File

@@ -0,0 +1,56 @@
/**
* 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',
},
};

View File

@@ -0,0 +1,94 @@
/**
* 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 { CartProvider, noticeContexts } from '@woocommerce/base-context';
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 {
SlotFillProvider,
StoreNoticesContainer,
} from '@woocommerce/blocks-checkout';
/**
* 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>
<Cart attributes={ attributes }>{ children }</Cart>
<ScrollOnError scrollToTop={ scrollToTop } />
</CartProvider>
</SlotFillProvider>
</BlockErrorBoundary>
);
export default withScrollToTop( Block );

View File

@@ -0,0 +1,73 @@
/**
* 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 AddToCartButton from '../../../atomic/blocks/product-elements/add-to-cart/block';
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={ 'full-size' }
isDescendentOfQueryLoop={ false }
/>
<ProductName
align={ '' }
headingLevel={ 3 }
showProductLink={ true }
/>
<ProductRating />
<ProductSaleBadge
productId={ product.id }
align={ 'left' }
/>
<ProductPrice />
</div>
{ product.is_in_stock ? (
<AddToCartButton />
) : (
<ProductButton />
) }
</ProductDataContextProvider>
</InnerBlockLayoutContextProvider>
</div>
);
};
export default CartCrossSellsProduct;

View File

@@ -0,0 +1,37 @@
/**
* 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;

View File

@@ -0,0 +1,19 @@
/**
* 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 );
};

View File

@@ -0,0 +1,111 @@
/* 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';
/**
* 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 = ( { 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,
} ),
} );
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>
);
};

View File

@@ -0,0 +1,37 @@
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;
}
}

View File

@@ -0,0 +1,50 @@
/**
* 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,
} );

View File

@@ -0,0 +1,113 @@
/**
* 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,
// 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 );

View File

@@ -0,0 +1,18 @@
{
"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
}

View File

@@ -0,0 +1,19 @@
/**
* 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;

View File

@@ -0,0 +1,27 @@
/**
* 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() } />;
};

View File

@@ -0,0 +1,6 @@
/**
* Internal dependencies
*/
import Block from './block';
export default Block;

View File

@@ -0,0 +1,23 @@
/**
* 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,
} );

View File

@@ -0,0 +1,18 @@
{
"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
}

View File

@@ -0,0 +1,41 @@
/**
* 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>
);
};

View File

@@ -0,0 +1,24 @@
/**
* 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;

View File

@@ -0,0 +1,23 @@
/**
* 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,
} );

View File

@@ -0,0 +1,32 @@
{
"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
}

View File

@@ -0,0 +1,33 @@
/**
* 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;

View File

@@ -0,0 +1,59 @@
/**
* 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( 'min_columns', 1 ) }
max={ getSetting( 'max_columns', 6 ) }
/>
</PanelBody>
</InspectorControls>
<Noninteractive>
<Block columns={ columns } className={ className } />
</Noninteractive>
</div>
);
};
export const Save = (): JSX.Element => {
return <div { ...useBlockProps.save() } />;
};

View File

@@ -0,0 +1,38 @@
.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;
}
}
}
}

View File

@@ -0,0 +1,6 @@
/**
* Internal dependencies
*/
import Block from './block';
export default Block;

View File

@@ -0,0 +1,23 @@
/**
* 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-products-block', {
icon: {
src: (
<Icon
icon={ column }
className="wc-block-editor-components-block-icon"
/>
),
},
edit: Edit,
save: Save,
} );

View File

@@ -0,0 +1,72 @@
.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%;
}
}
}
}

View File

@@ -0,0 +1,28 @@
{
"name": "woocommerce/cart-express-payment-block",
"version": "1.0.0",
"title": "Express Checkout",
"description": "Provide an express payment option for your customers.",
"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
}

View File

@@ -0,0 +1,31 @@
/**
* 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;

View File

@@ -0,0 +1,42 @@
/**
* 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() } />;
};

View File

@@ -0,0 +1,29 @@
// 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-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;
}
}

View File

@@ -0,0 +1,6 @@
/**
* Internal dependencies
*/
import Block from './block';
export default Block;

View File

@@ -0,0 +1,23 @@
/**
* External dependencies
*/
import { Icon, payment } from '@wordpress/icons';
import { registerBlockType } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import { Edit, Save } from './edit';
registerBlockType( 'woocommerce/cart-express-payment-block', {
icon: {
src: (
<Icon
icon={ payment }
className="wc-block-editor-components-block-icon"
/>
),
},
edit: Edit,
save: Save,
} );

View File

@@ -0,0 +1,28 @@
{
"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
}

View File

@@ -0,0 +1,51 @@
/**
* 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>
);
};

View File

@@ -0,0 +1,21 @@
/**
* 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;

View File

@@ -0,0 +1,23 @@
/**
* 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,
} );

View File

@@ -0,0 +1,28 @@
{
"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
}

View File

@@ -0,0 +1,18 @@
/**
* 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;

View File

@@ -0,0 +1,31 @@
/**
* External dependencies
*/
import { useBlockProps } from '@wordpress/block-editor';
import Noninteractive from '@woocommerce/base-components/noninteractive';
/**
* Internal dependencies
*/
import Block from './block';
export const Edit = ( {
attributes,
}: {
attributes: { className: string };
} ): JSX.Element => {
const { className } = attributes;
const blockProps = useBlockProps();
return (
<div { ...blockProps }>
<Noninteractive>
<Block className={ className } />
</Noninteractive>
</div>
);
};
export const Save = (): JSX.Element => {
return <div { ...useBlockProps.save() } />;
};

View File

@@ -0,0 +1,6 @@
/**
* Internal dependencies
*/
import Block from './block';
export default Block;

View File

@@ -0,0 +1,23 @@
/**
* External dependencies
*/
import { Icon, column } from '@wordpress/icons';
import { registerBlockType } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import { Edit, Save } from './edit';
registerBlockType( 'woocommerce/cart-line-items-block', {
icon: {
src: (
<Icon
icon={ column }
className="wc-block-editor-components-block-icon"
/>
),
},
edit: Edit,
save: Save,
} );

View File

@@ -0,0 +1,28 @@
{
"name": "woocommerce/cart-order-summary-block",
"version": "1.0.0",
"title": "Order Summary",
"description": "Show customers a summary of their order.",
"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
}

View File

@@ -0,0 +1,74 @@
/**
* External dependencies
*/
import { useBlockProps, InnerBlocks } from '@wordpress/block-editor';
import type { TemplateArray } from '@wordpress/blocks';
import { innerBlockAreas } from '@woocommerce/blocks-checkout';
import { __ } from '@wordpress/i18n';
import { TotalsFooterItem } from '@woocommerce/base-components/cart-checkout';
import { getCurrencyFromPriceResponse } from '@woocommerce/price-format';
import { useStoreCart } from '@woocommerce/base-context/hooks';
/**
* Internal dependencies
*/
import {
useForcedLayout,
getAllowedBlocks,
} from '../../../cart-checkout-shared';
import { OrderMetaSlotFill } from './slotfills';
export const Edit = ( { clientId }: { clientId: string } ): JSX.Element => {
const blockProps = useBlockProps();
const { cartTotals } = useStoreCart();
const totalsCurrency = getCurrencyFromPriceResponse( cartTotals );
const allowedBlocks = getAllowedBlocks(
innerBlockAreas.CART_ORDER_SUMMARY
);
const defaultTemplate = [
[
'woocommerce/cart-order-summary-heading-block',
{
content: __( 'Cart totals', 'woo-gutenberg-products-block' ),
},
[],
],
[ 'woocommerce/cart-order-summary-coupon-form-block', {}, [] ],
[ 'woocommerce/cart-order-summary-subtotal-block', {}, [] ],
[ 'woocommerce/cart-order-summary-fee-block', {}, [] ],
[ 'woocommerce/cart-order-summary-discount-block', {}, [] ],
[ 'woocommerce/cart-order-summary-shipping-block', {}, [] ],
[ 'woocommerce/cart-order-summary-taxes-block', {}, [] ],
] as TemplateArray;
useForcedLayout( {
clientId,
registeredBlocks: allowedBlocks,
defaultTemplate,
} );
return (
<div { ...blockProps }>
<InnerBlocks
allowedBlocks={ allowedBlocks }
template={ defaultTemplate }
/>
<div className="wc-block-components-totals-wrapper">
<TotalsFooterItem
currency={ totalsCurrency }
values={ cartTotals }
/>
</div>
{ /* do I put an totals wrapper here? */ }
<OrderMetaSlotFill />
</div>
);
};
export const Save = (): JSX.Element => {
return (
<div { ...useBlockProps.save() }>
<InnerBlocks.Content />
</div>
);
};

Some files were not shown because too many files have changed in this diff Show More