This commit is contained in:
KhaiNguyen
2020-02-13 10:39:37 +07:00
commit 59401cb805
12867 changed files with 4646216 additions and 0 deletions

View File

@@ -0,0 +1,57 @@
/**
* External dependencies
*/
import { useCollection, useQueryStateByKey } from '@woocommerce/base-hooks';
import { decodeEntities } from '@wordpress/html-entities';
/**
* Internal dependencies
*/
import { renderRemovableListItem } from './utils';
import { removeAttributeFilterBySlug } from '../../utils/attributes-query';
/**
* Component that renders active attribute (terms) filters.
*/
const ActiveAttributeFilters = ( { attributeObject = {}, slugs = [] } ) => {
const { results, isLoading } = useCollection( {
namespace: '/wc/store',
resourceName: 'products/attributes/terms',
resourceValues: [ attributeObject.id ],
} );
const [ productAttributes, setProductAttributes ] = useQueryStateByKey(
'attributes',
[]
);
if ( isLoading ) {
return null;
}
const attributeLabel = attributeObject.label;
return slugs.map( ( slug ) => {
const termObject = results.find( ( term ) => {
return term.slug === slug;
} );
return (
termObject &&
renderRemovableListItem(
attributeLabel,
decodeEntities( termObject.name || slug ),
() => {
removeAttributeFilterBySlug(
productAttributes,
setProductAttributes,
attributeObject,
slug
);
}
)
);
} );
};
export default ActiveAttributeFilters;

View File

@@ -0,0 +1,130 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useQueryStateByKey } from '@woocommerce/base-hooks';
import { useMemo, Fragment } from '@wordpress/element';
import classnames from 'classnames';
import PropTypes from 'prop-types';
/**
* Internal dependencies
*/
import './style.scss';
import { getAttributeFromTaxonomy } from '../../utils/attributes';
import { formatPriceRange, renderRemovableListItem } from './utils';
import ActiveAttributeFilters from './active-attribute-filters';
/**
* Component displaying active filters.
*/
const ActiveFiltersBlock = ( {
attributes: blockAttributes,
isEditor = false,
} ) => {
const [ productAttributes, setProductAttributes ] = useQueryStateByKey(
'attributes',
[]
);
const [ minPrice, setMinPrice ] = useQueryStateByKey( 'min_price' );
const [ maxPrice, setMaxPrice ] = useQueryStateByKey( 'max_price' );
const activePriceFilters = useMemo( () => {
if ( ! Number.isFinite( minPrice ) && ! Number.isFinite( maxPrice ) ) {
return null;
}
return renderRemovableListItem(
__( 'Price', 'woocommerce' ),
formatPriceRange( minPrice, maxPrice ),
() => {
setMinPrice( null );
setMaxPrice( null );
}
);
}, [ minPrice, maxPrice, formatPriceRange ] );
const activeAttributeFilters = useMemo( () => {
return productAttributes.map( ( attribute ) => {
const attributeObject = getAttributeFromTaxonomy(
attribute.attribute
);
return (
<ActiveAttributeFilters
attributeObject={ attributeObject }
slugs={ attribute.slug }
key={ attribute.attribute }
/>
);
} );
}, [ productAttributes ] );
const hasFilters = () => {
return (
productAttributes.length > 0 ||
Number.isFinite( minPrice ) ||
Number.isFinite( maxPrice )
);
};
if ( ! hasFilters() && ! isEditor ) {
return null;
}
const TagName = `h${ blockAttributes.headingLevel }`;
const listClasses = classnames( 'wc-block-active-filters-list', {
'wc-block-active-filters-list--chips':
blockAttributes.displayStyle === 'chips',
} );
return (
<Fragment>
{ ! isEditor && blockAttributes.heading && (
<TagName>{ blockAttributes.heading }</TagName>
) }
<div className="wc-block-active-filters">
<ul className={ listClasses }>
{ isEditor ? (
<Fragment>
{ renderRemovableListItem(
__( 'Size', 'woocommerce' ),
__( 'Small', 'woocommerce' )
) }
{ renderRemovableListItem(
__( 'Color', 'woocommerce' ),
__( 'Blue', 'woocommerce' )
) }
</Fragment>
) : (
<Fragment>
{ activePriceFilters }
{ activeAttributeFilters }
</Fragment>
) }
</ul>
<button
className="wc-block-active-filters__clear-all"
onClick={ () => {
setMinPrice( null );
setMaxPrice( null );
setProductAttributes( [] );
} }
>
{ __( 'Clear All', 'woocommerce' ) }
</button>
</div>
</Fragment>
);
};
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,92 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { InspectorControls } from '@wordpress/block-editor';
import { Disabled, PanelBody, withSpokenMessages } from '@wordpress/components';
import HeadingToolbar from '@woocommerce/block-components/heading-toolbar';
import BlockTitle from '@woocommerce/block-components/block-title';
/**
* Internal dependencies
*/
import Block from './block.js';
import ToggleButtonControl from '../../components/toggle-button-control';
const Edit = ( { attributes, setAttributes } ) => {
const { className, displayStyle, heading, headingLevel } = attributes;
const getInspectorControls = () => {
return (
<InspectorControls key="inspector">
<PanelBody
title={ __(
'Block Settings',
'woocommerce'
) }
>
<ToggleButtonControl
label={ __(
'Display Style',
'woocommerce'
) }
value={ displayStyle }
options={ [
{
label: __(
'List',
'woocommerce'
),
value: 'list',
},
{
/* translators: "Chips" is a tag-like display style for chosen attributes. */
label: __(
'Chips',
'woocommerce'
),
value: 'chips',
},
] }
onChange={ ( value ) =>
setAttributes( {
displayStyle: value,
} )
}
/>
<p>
{ __(
'Heading Level',
'woocommerce'
) }
</p>
<HeadingToolbar
isCollapsed={ false }
minLevel={ 2 }
maxLevel={ 7 }
selectedLevel={ headingLevel }
onChange={ ( newLevel ) =>
setAttributes( { headingLevel: newLevel } )
}
/>
</PanelBody>
</InspectorControls>
);
};
return (
<div className={ className }>
{ getInspectorControls() }
<BlockTitle
headingLevel={ headingLevel }
heading={ heading }
onChange={ ( value ) => setAttributes( { heading: value } ) }
/>
<Disabled>
<Block attributes={ attributes } isEditor={ true } />
</Disabled>
</div>
);
};
export default withSpokenMessages( Edit );

View File

@@ -0,0 +1,26 @@
/**
* External dependencies
*/
import { withRestApiHydration } from '@woocommerce/block-hocs';
/**
* Internal dependencies
*/
import Block from './block.js';
import renderFrontend from '../../utils/render-frontend.js';
const getProps = ( el ) => {
return {
attributes: {
displayStyle: el.dataset.displayStyle,
heading: el.dataset.heading,
headingLevel: el.dataset.headingLevel || 3,
},
};
};
renderFrontend(
'.wp-block-woocommerce-active-filters',
withRestApiHydration( Block ),
getProps
);

View File

@@ -0,0 +1,69 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { registerBlockType } from '@wordpress/blocks';
import Gridicon from 'gridicons';
import classNames from 'classnames';
/**
* Internal dependencies
*/
import edit from './edit.js';
registerBlockType( 'woocommerce/active-filters', {
title: __( 'Active Product Filters', 'woocommerce' ),
icon: {
src: <Gridicon icon="list-checkmark" />,
foreground: '#96588a',
},
category: 'woocommerce',
keywords: [ __( 'WooCommerce', 'woocommerce' ) ],
description: __(
'Display a list of active product filters.',
'woocommerce'
),
supports: {
multiple: false,
},
example: {
attributes: {},
},
attributes: {
displayStyle: {
type: 'string',
default: 'list',
},
heading: {
type: 'string',
default: __( 'Active filters', 'woocommerce' ),
},
headingLevel: {
type: 'number',
default: 3,
},
},
edit,
/**
* Save the props to post content.
*/
save( { attributes } ) {
const { className, displayStyle, heading, headingLevel } = attributes;
const data = {
'data-display-style': displayStyle,
'data-heading': heading,
'data-heading-level': headingLevel,
};
return (
<div
className={ classNames( 'is-loading', className ) }
{ ...data }
>
<span
aria-hidden
className="wc-block-active-product-filters__placeholder"
/>
</div>
);
},
} );

View File

@@ -0,0 +1,90 @@
.wc-block-active-filters {
margin: 0 0 $gap;
overflow: hidden;
.wc-block-active-filters__clear-all {
float: right;
background: transparent none;
border: none;
padding: 0;
text-decoration: underline;
cursor: pointer;
font-size: 1em;
&:hover {
background: transparent none;
}
}
.wc-block-active-filters-list {
margin: 0 0 $gap-smallest;
list-style: none outside;
clear: both;
li {
margin: 0 0 $gap-smallest;
padding: 0 16px 0 0;
list-style: none outside;
clear: both;
position: relative;
}
button {
background: transparent;
border: 0;
appearance: none;
height: 0;
padding: 16px 0 0 0;
width: 16px;
overflow: hidden;
position: absolute;
right: 0;
top: 50%;
margin: -8px 0 0 0;
&::before {
width: 16px;
height: 16px;
background: transparent url("data:image/svg+xml,%3Csvg viewBox='0 0 18 18' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='9' cy='9' r='9' fill='%2324292d'/%3E%3Crect x='4.5' y='6.8866' width='3.375' height='9.9466' transform='rotate(-45 4.5 6.8866)' fill='white'/%3E%3Crect x='11.5334' y='4.5' width='3.375' height='9.9466' transform='rotate(45 11.5334 4.5)' fill='white'/%3E%3C/svg%3E%0A") center center no-repeat; /* stylelint-disable-line */
display: block;
content: "";
position: absolute;
top: 0;
}
}
&.wc-block-active-filters-list--chips {
li {
display: inline-block;
background: #c4c4c4;
border-radius: 4px;
padding: 4px 8px;
margin: 0 6px 6px 0;
color: #24292d;
.wc-block-active-filters-list-item__type {
display: none;
}
}
button {
float: none;
vertical-align: middle;
margin: -2px 0 0 9px;
height: 0;
padding: 12px 0 0 0;
width: 12px;
overflow: hidden;
position: relative;
&::before {
width: 12px;
height: 12px;
background: transparent url("data:image/svg+xml,%3Csvg width='12' viewBox='0 0 9 9' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='7.03329' width='2' height='9.9466' transform='rotate(45 7.03329 0)' fill='%2324292d'/%3E%3Crect x='8.4476' y='7.07104' width='2' height='9.9466' transform='rotate(135 8.4476 7.07104)' fill='%2324292d'/%3E%3C/svg%3E%0A") center center no-repeat; /* stylelint-disable-line */
display: block;
content: "";
position: absolute;
top: 0;
}
}
}
}
}

View File

@@ -0,0 +1,64 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { formatPrice } from '@woocommerce/base-utils';
/**
* 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, maxPrice ) => {
if ( Number.isFinite( minPrice ) && Number.isFinite( maxPrice ) ) {
return sprintf(
/* translators: %s min price, %s max price */
__( 'Between %s and %s', 'woocommerce' ),
formatPrice( minPrice ),
formatPrice( maxPrice )
);
}
if ( Number.isFinite( minPrice ) ) {
return sprintf(
/* translators: %s min price */
__( 'From %s', 'woocommerce' ),
formatPrice( minPrice )
);
}
return sprintf(
/* translators: %s max price */
__( 'Up to %s', 'woocommerce' ),
formatPrice( maxPrice )
);
};
/**
* Render a removable item in the active filters block list.
* @param {string} type Type string.
* @param {string} name Name string.
* @param {Function} removeCallback Callback to remove item.
*/
export const renderRemovableListItem = (
type,
name,
removeCallback = () => {}
) => {
return (
<li
className="wc-block-active-filters-list-item"
key={ type + ':' + name }
>
<span className="wc-block-active-filters-list-item__type">
{ type + ': ' }
</span>
<strong className="wc-block-active-filters-list-item__name">
{ name }
</strong>
<button onClick={ removeCallback }>
{ __( 'Remove', 'woocommerce' ) }
</button>
</li>
);
};

View File

@@ -0,0 +1,254 @@
/**
* External dependencies
*/
import {
useCollection,
useQueryStateByKey,
useQueryStateByContext,
useCollectionData,
} from '@woocommerce/base-hooks';
import {
useCallback,
Fragment,
useEffect,
useState,
useMemo,
} from '@wordpress/element';
import CheckboxList from '@woocommerce/base-components/checkbox-list';
import { decodeEntities } from '@wordpress/html-entities';
/**
* Internal dependencies
*/
import './style.scss';
import { getAttributeFromID } from '../../utils/attributes';
import { updateAttributeFilter } from '../../utils/attributes-query';
/**
* Component displaying an attribute filter.
*/
const AttributeFilterBlock = ( {
attributes: blockAttributes,
isEditor = false,
} ) => {
/**
* Get the label for an attribute term filter.
*/
const getLabel = useCallback(
( name, count ) => {
return (
<Fragment>
{ decodeEntities( name ) }
{ blockAttributes.showCounts && count !== null && (
<span className="wc-block-attribute-filter-list-count">
{ count }
</span>
) }
</Fragment>
);
},
[ blockAttributes ]
);
const attributeObject =
blockAttributes.isPreview && ! blockAttributes.attributeId
? {
id: 0,
name: 'preview',
taxonomy: 'preview',
label: 'Preview',
}
: getAttributeFromID( blockAttributes.attributeId );
const [ displayedOptions, setDisplayedOptions ] = useState(
blockAttributes.isPreview && ! blockAttributes.attributeId
? [
{
key: 'preview-1',
label: getLabel( 'Blue', 3 ),
},
{
key: 'preview-2',
label: getLabel( 'Green', 3 ),
},
{
key: 'preview-3',
label: getLabel( 'Red', 2 ),
},
]
: []
);
const [ queryState ] = useQueryStateByContext();
const [
productAttributesQuery,
setProductAttributesQuery,
] = useQueryStateByKey( 'attributes', [] );
const checked = useMemo( () => {
return productAttributesQuery
.filter(
( attribute ) =>
attribute.attribute === attributeObject.taxonomy
)
.flatMap( ( attribute ) => attribute.slug );
}, [ productAttributesQuery, attributeObject ] );
const {
results: attributeTerms,
isLoading: attributeTermsLoading,
} = useCollection( {
namespace: '/wc/store',
resourceName: 'products/attributes/terms',
resourceValues: [ attributeObject.id ],
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,
},
} );
/**
* Get count data about a given term by ID.
*/
const getFilteredTerm = useCallback(
( id ) => {
if ( ! 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( () => {
if ( attributeTermsLoading || filteredCountsLoading ) {
return;
}
const newOptions = [];
attributeTerms.forEach( ( term ) => {
const filteredTerm = getFilteredTerm( term.id );
const isChecked = checked.includes( term.slug );
const count = filteredTerm ? filteredTerm.count : null;
// If there is no match this term doesn't match the current product collection - only render if checked.
if ( ! filteredTerm && ! isChecked ) {
return;
}
newOptions.push( {
key: term.slug,
label: getLabel( term.name, count ),
} );
} );
setDisplayedOptions( newOptions );
}, [
attributeTerms,
attributeTermsLoading,
filteredCountsLoading,
getFilteredTerm,
getLabel,
checked,
] );
/**
* Returns an array of term objects that have been chosen via the checkboxes.
*/
const getSelectedTerms = useCallback(
( newChecked ) => {
return attributeTerms.reduce( ( acc, term ) => {
if ( newChecked.includes( term.slug ) ) {
acc.push( term );
}
return acc;
}, [] );
},
[ attributeTerms ]
);
/**
* When a checkbox in the list changes, update state.
*/
const onChange = useCallback(
( event ) => {
const isChecked = event.target.checked;
const checkedValue = event.target.value;
const newChecked = checked.filter(
( value ) => value !== checkedValue
);
if ( isChecked ) {
newChecked.push( checkedValue );
newChecked.sort();
}
const newSelectedTerms = getSelectedTerms( newChecked );
updateAttributeFilter(
productAttributesQuery,
setProductAttributesQuery,
attributeObject,
newSelectedTerms,
blockAttributes.queryType === 'or' ? 'in' : 'and'
);
},
[
attributeTerms,
checked,
productAttributesQuery,
setProductAttributesQuery,
attributeObject,
blockAttributes,
]
);
if ( displayedOptions.length === 0 && ! attributeTermsLoading ) {
return null;
}
const TagName = `h${ blockAttributes.headingLevel }`;
return (
<Fragment>
{ ! isEditor && blockAttributes.heading && (
<TagName>{ blockAttributes.heading }</TagName>
) }
<div className="wc-block-attribute-filter">
<CheckboxList
className={ 'wc-block-attribute-filter-list' }
options={ displayedOptions }
checked={ checked }
onChange={ onChange }
isLoading={
! blockAttributes.isPreview && attributeTermsLoading
}
isDisabled={
! blockAttributes.isPreview && filteredCountsLoading
}
/>
</div>
</Fragment>
);
};
export default AttributeFilterBlock;

View File

@@ -0,0 +1,354 @@
/**
* External dependencies
*/
import { __, sprintf, _n } from '@wordpress/i18n';
import { Fragment, useState, useCallback } from '@wordpress/element';
import { InspectorControls, BlockControls } from '@wordpress/block-editor';
import {
Placeholder,
Disabled,
PanelBody,
ToggleControl,
Button,
Toolbar,
withSpokenMessages,
} from '@wordpress/components';
import Gridicon from 'gridicons';
import { SearchListControl } from '@woocommerce/components';
import { mapValues, toArray, sortBy, find } from 'lodash';
import { ATTRIBUTES } from '@woocommerce/block-settings';
import { getAdminLink } from '@woocommerce/settings';
import HeadingToolbar from '@woocommerce/block-components/heading-toolbar';
import BlockTitle from '@woocommerce/block-components/block-title';
/**
* Internal dependencies
*/
import Block from './block.js';
import './editor.scss';
import { IconExternal } from '../../components/icons';
import ToggleButtonControl from '../../components/toggle-button-control';
const Edit = ( { attributes, setAttributes, debouncedSpeak } ) => {
const {
attributeId,
className,
heading,
headingLevel,
isPreview,
queryType,
showCounts,
} = attributes;
const [ isEditing, setIsEditing ] = useState(
! attributeId && ! isPreview
);
const getBlockControls = () => {
return (
<BlockControls>
<Toolbar
controls={ [
{
icon: 'edit',
title: __( 'Edit', 'woocommerce' ),
onClick: () => setIsEditing( ! isEditing ),
isActive: isEditing,
},
] }
/>
</BlockControls>
);
};
const getInspectorControls = () => {
return (
<InspectorControls key="inspector">
<PanelBody
title={ __( 'Content', 'woocommerce' ) }
>
<ToggleControl
label={ __(
'Product count',
'woocommerce'
) }
help={
showCounts
? __(
'Product counts are visible.',
'woocommerce'
)
: __(
'Product counts are hidden.',
'woocommerce'
)
}
checked={ showCounts }
onChange={ () =>
setAttributes( {
showCounts: ! showCounts,
} )
}
/>
<p>
{ __(
'Heading Level',
'woocommerce'
) }
</p>
<HeadingToolbar
isCollapsed={ false }
minLevel={ 2 }
maxLevel={ 7 }
selectedLevel={ headingLevel }
onChange={ ( newLevel ) =>
setAttributes( { headingLevel: newLevel } )
}
/>
</PanelBody>
<PanelBody
title={ __(
'Block Settings',
'woocommerce'
) }
>
<ToggleButtonControl
label={ __(
'Query Type',
'woocommerce'
) }
help={
queryType === 'and'
? __(
'Products that have all of the selected attributes will be shown.',
'woocommerce'
)
: __(
'Products that have any of the selected attributes will be shown.',
'woocommerce'
)
}
value={ queryType }
options={ [
{
label: __(
'And',
'woocommerce'
),
value: 'and',
},
{
label: __(
'Or',
'woocommerce'
),
value: 'or',
},
] }
onChange={ ( value ) =>
setAttributes( {
queryType: value,
} )
}
/>
</PanelBody>
<PanelBody
title={ __(
'Filter Products by Attribute',
'woocommerce'
) }
initialOpen={ false }
>
{ renderAttributeControl() }
</PanelBody>
</InspectorControls>
);
};
const noAttributesPlaceholder = () => (
<Placeholder
className="wc-block-attribute-filter"
icon={ <Gridicon icon="menus" /> }
label={ __(
'Filter Products by Attribute',
'woocommerce'
) }
instructions={ __(
'Display a list of filters based on a chosen attribute.',
'woocommerce'
) }
>
<p>
{ __(
"Attributes are needed for filtering your products. You haven't created any attributes yet.",
'woocommerce'
) }
</p>
<Button
className="wc-block-attribute-filter__add_attribute_button"
isDefault
isLarge
href={ getAdminLink(
'edit.php?post_type=product&page=product_attributes'
) }
>
{ __( 'Add new attribute', 'woocommerce' ) +
' ' }
<IconExternal />
</Button>
<Button
className="wc-block-attribute-filter__read_more_button"
isTertiary
href="https://docs.woocommerce.com/document/managing-product-taxonomies/"
>
{ __( 'Learn more', 'woocommerce' ) }
</Button>
</Placeholder>
);
const onDone = useCallback( () => {
setIsEditing( false );
debouncedSpeak(
__(
'Showing attribute filter block preview.',
'woocommerce'
)
);
}, [] );
const onChange = useCallback(
( selected ) => {
if ( ! selected || ! selected.length ) {
return;
}
const selectedId = selected[ 0 ].id;
const productAttribute = find( ATTRIBUTES, [
'attribute_id',
selectedId.toString(),
] );
if ( ! productAttribute || attributeId === selectedId ) {
return;
}
const attributeName = productAttribute.attribute_label;
setAttributes( {
attributeId: selectedId,
heading: sprintf(
// Translators: %s attribute name.
__( 'Filter by %s', 'woocommerce' ),
attributeName
),
} );
},
[ attributeId ]
);
const renderAttributeControl = () => {
const messages = {
clear: __(
'Clear selected attribute',
'woocommerce'
),
list: __( 'Product Attributes', 'woocommerce' ),
noItems: __(
"Your store doesn't have any product attributes.",
'woocommerce'
),
search: __(
'Search for a product attribute:',
'woocommerce'
),
selected: ( n ) =>
sprintf(
_n(
'%d attribute selected',
'%d attributes selected',
n,
'woocommerce'
),
n
),
updated: __(
'Product attribute search results updated.',
'woocommerce'
),
};
const list = sortBy(
toArray(
mapValues( ATTRIBUTES, ( 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
/>
);
};
const renderEditMode = () => {
return (
<Placeholder
className="wc-block-attribute-filter"
icon={ <Gridicon icon="menus" /> }
label={ __(
'Filter Products by Attribute',
'woocommerce'
) }
instructions={ __(
'Display a list of filters based on a chosen attribute.',
'woocommerce'
) }
>
<div className="wc-block-attribute-filter__selection">
{ renderAttributeControl() }
<Button isDefault onClick={ onDone }>
{ __( 'Done', 'woocommerce' ) }
</Button>
</div>
</Placeholder>
);
};
return Object.keys( ATTRIBUTES ).length === 0 ? (
noAttributesPlaceholder()
) : (
<Fragment>
{ getBlockControls() }
{ getInspectorControls() }
{ isEditing ? (
renderEditMode()
) : (
<div className={ className }>
<BlockTitle
headingLevel={ headingLevel }
heading={ heading }
onChange={ ( value ) =>
setAttributes( { heading: value } )
}
/>
<Disabled>
<Block attributes={ attributes } isEditor />
</Disabled>
</div>
) }
</Fragment>
);
};
export default withSpokenMessages( Edit );

View File

@@ -0,0 +1,42 @@
.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 */
p {
font-size: 14px;
}
}
.woocommerce-search-list__search {
border-top: 0;
margin-top: 0;
padding-top: 0;
}
.wc-block-attribute-filter__add_attribute_button {
margin: 0 0 1em;
line-height: 24px;
vertical-align: middle;
height: auto;
font-size: 14px;
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;
}
}

View File

@@ -0,0 +1,28 @@
/**
* External dependencies
*/
import { withRestApiHydration } from '@woocommerce/block-hocs';
/**
* Internal dependencies
*/
import Block from './block.js';
import renderFrontend from '../../utils/render-frontend.js';
const getProps = ( el ) => {
return {
attributes: {
attributeId: parseInt( el.dataset.attributeId || 0, 10 ),
showCounts: el.dataset.showCounts === 'true',
queryType: el.dataset.queryType,
heading: el.dataset.heading,
headingLevel: el.dataset.headingLevel || 3,
},
};
};
renderFrontend(
'.wp-block-woocommerce-attribute-filter',
withRestApiHydration( Block ),
getProps
);

View File

@@ -0,0 +1,96 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { registerBlockType } from '@wordpress/blocks';
import Gridicon from 'gridicons';
import classNames from 'classnames';
/**
* Internal dependencies
*/
import edit from './edit.js';
registerBlockType( 'woocommerce/attribute-filter', {
title: __( 'Filter Products by Attribute', 'woocommerce' ),
icon: {
src: <Gridicon icon="menus" />,
foreground: '#96588a',
},
category: 'woocommerce',
keywords: [ __( 'WooCommerce', 'woocommerce' ) ],
description: __(
'Display a list of filters based on a chosen product attribute.',
'woocommerce'
),
supports: {},
example: {
attributes: {
isPreview: true,
},
},
attributes: {
attributeId: {
type: 'number',
default: 0,
},
showCounts: {
type: 'boolean',
default: true,
},
queryType: {
type: 'string',
default: 'or',
},
heading: {
type: 'string',
default: __(
'Filter by attribute',
'woocommerce'
),
},
headingLevel: {
type: 'number',
default: 3,
},
/**
* Are we previewing?
*/
isPreview: {
type: 'boolean',
default: false,
},
},
edit,
/**
* Save the props to post content.
*/
save( { attributes } ) {
const {
className,
showCounts,
queryType,
attributeId,
heading,
headingLevel,
} = attributes;
const data = {
'data-attribute-id': attributeId,
'data-show-counts': showCounts,
'data-query-type': queryType,
'data-heading': heading,
'data-heading-level': headingLevel,
};
return (
<div
className={ classNames( 'is-loading', className ) }
{ ...data }
>
<span
aria-hidden
className="wc-block-product-attribute-filter__placeholder"
/>
</div>
);
},
} );

View File

@@ -0,0 +1,24 @@
.wc-block-attribute-filter {
.wc-block-attribute-filter-list {
margin: 0 0 $gap;
li {
text-decoration: underline;
label,
input {
cursor: pointer;
}
}
.wc-block-attribute-filter-list-count {
float: right;
}
.wc-block-attribute-filter-list-count::before {
content: " (";
}
.wc-block-attribute-filter-list-count::after {
content: ")";
}
}
}

View File

@@ -0,0 +1,410 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import {
AlignmentToolbar,
BlockControls,
InnerBlocks,
InspectorControls,
MediaUpload,
MediaUploadCheck,
PanelColorSettings,
withColors,
RichText,
} from '@wordpress/editor';
import {
Button,
FocalPointPicker,
IconButton,
PanelBody,
Placeholder,
RangeControl,
ResizableBox,
Spinner,
ToggleControl,
Toolbar,
withSpokenMessages,
} from '@wordpress/components';
import classnames from 'classnames';
import { Fragment } from '@wordpress/element';
import { compose } from '@wordpress/compose';
import PropTypes from 'prop-types';
import { MIN_HEIGHT } from '@woocommerce/block-settings';
import { IconFolderStar } from '@woocommerce/block-components/icons';
import ProductCategoryControl from '@woocommerce/block-components/product-category-control';
import ErrorPlaceholder from '@woocommerce/block-components/error-placeholder';
/**
* Internal dependencies
*/
import {
dimRatioToClass,
getBackgroundImageStyles,
getCategoryImageId,
getCategoryImageSrc,
} from './utils';
import { withCategory } from '../../hocs';
/**
* Component to handle edit mode of "Featured Category".
*/
const FeaturedCategory = ( {
attributes,
isSelected,
setAttributes,
error,
getCategory,
isLoading,
category,
overlayColor,
setOverlayColor,
debouncedSpeak,
} ) => {
const renderApiError = () => (
<ErrorPlaceholder
className="wc-block-featured-category-error"
error={ error }
isLoading={ isLoading }
onRetry={ getCategory }
/>
);
const getBlockControls = () => {
const { contentAlign } = attributes;
const mediaId = attributes.mediaId || getCategoryImageId( category );
return (
<BlockControls>
<AlignmentToolbar
value={ contentAlign }
onChange={ ( nextAlign ) => {
setAttributes( { contentAlign: nextAlign } );
} }
/>
<MediaUploadCheck>
<Toolbar>
<MediaUpload
onSelect={ ( media ) => {
setAttributes( {
mediaId: media.id,
mediaSrc: media.url,
} );
} }
allowedTypes={ [ 'image' ] }
value={ mediaId }
render={ ( { open } ) => (
<IconButton
className="components-toolbar__control"
label={ __( 'Edit media' ) }
icon="format-image"
onClick={ open }
disabled={ ! category }
/>
) }
/>
</Toolbar>
</MediaUploadCheck>
</BlockControls>
);
};
const getInspectorControls = () => {
const url = attributes.mediaSrc || getCategoryImageSrc( category );
const { focalPoint = { x: 0.5, y: 0.5 } } = attributes;
// FocalPointPicker was introduced in Gutenberg 5.0 (WordPress 5.2),
// so we need to check if it exists before using it.
const focalPointPickerExists = typeof FocalPointPicker === 'function';
return (
<InspectorControls key="inspector">
<PanelBody
title={ __( 'Content', 'woocommerce' ) }
>
<ToggleControl
label={ __(
'Show description',
'woocommerce'
) }
checked={ attributes.showDesc }
onChange={ () =>
setAttributes( { showDesc: ! attributes.showDesc } )
}
/>
</PanelBody>
<PanelColorSettings
title={ __( 'Overlay', 'woocommerce' ) }
colorSettings={ [
{
value: overlayColor.color,
onChange: setOverlayColor,
label: __(
'Overlay Color',
'woocommerce'
),
},
] }
>
{ !! url && (
<Fragment>
<RangeControl
label={ __(
'Background Opacity',
'woocommerce'
) }
value={ attributes.dimRatio }
onChange={ ( ratio ) =>
setAttributes( { dimRatio: ratio } )
}
min={ 0 }
max={ 100 }
step={ 10 }
/>
{ focalPointPickerExists && (
<FocalPointPicker
label={ __( 'Focal Point Picker' ) }
url={ url }
value={ focalPoint }
onChange={ ( value ) =>
setAttributes( { focalPoint: value } )
}
/>
) }
</Fragment>
) }
</PanelColorSettings>
</InspectorControls>
);
};
const renderEditMode = () => {
const onDone = () => {
setAttributes( { editMode: false } );
debouncedSpeak(
__(
'Showing Featured Product block preview.',
'woocommerce'
)
);
};
return (
<Placeholder
icon={ <IconFolderStar /> }
label={ __(
'Featured Category',
'woocommerce'
) }
className="wc-block-featured-category"
>
{ __(
'Visually highlight a product category and encourage prompt action.',
'woocommerce'
) }
<div className="wc-block-featured-category__selection">
<ProductCategoryControl
selected={ [ attributes.categoryId ] }
onChange={ ( value = [] ) => {
const id = value[ 0 ] ? value[ 0 ].id : 0;
setAttributes( {
categoryId: id,
mediaId: 0,
mediaSrc: '',
} );
} }
isSingle
/>
<Button isDefault onClick={ onDone }>
{ __( 'Done', 'woocommerce' ) }
</Button>
</div>
</Placeholder>
);
};
const renderButton = () => {
const buttonClasses = classnames(
'wp-block-button__link',
'is-style-fill'
);
const buttonStyle = {
backgroundColor: 'vivid-green-cyan',
borderRadius: '5px',
};
const wrapperStyle = {
width: '100%',
};
return attributes.categoryId === 'preview' ? (
<div className="wp-block-button aligncenter" style={ wrapperStyle }>
<RichText.Content
tagName="a"
className={ buttonClasses }
href={ category.permalink }
title={ attributes.linkText }
style={ buttonStyle }
value={ attributes.linkText }
target={ category.permalink }
/>
</div>
) : (
<InnerBlocks
template={ [
[
'core/button',
{
text: __(
'Shop now',
'woocommerce'
),
url: category.permalink,
align: 'center',
},
],
] }
templateLock="all"
/>
);
};
const renderCategory = () => {
const {
className,
contentAlign,
dimRatio,
focalPoint,
height,
showDesc,
} = attributes;
const classes = classnames(
'wc-block-featured-category',
{
'is-selected': isSelected && attributes.productId !== 'preview',
'is-loading': ! category && isLoading,
'is-not-found': ! category && ! isLoading,
'has-background-dim': dimRatio !== 0,
},
dimRatioToClass( dimRatio ),
contentAlign !== 'center' && `has-${ contentAlign }-content`,
className
);
const mediaSrc = attributes.mediaSrc || getCategoryImageSrc( category );
const style = !! category ? getBackgroundImageStyles( mediaSrc ) : {};
if ( overlayColor.color ) {
style.backgroundColor = overlayColor.color;
}
if ( focalPoint ) {
const bgPosX = focalPoint.x * 100;
const bgPosY = focalPoint.y * 100;
style.backgroundPosition = `${ bgPosX }% ${ bgPosY }%`;
}
const onResizeStop = ( event, direction, elt ) => {
setAttributes( { height: parseInt( elt.style.height ) } );
};
return (
<ResizableBox
className={ classes }
size={ { height } }
minHeight={ MIN_HEIGHT }
enable={ { bottom: true } }
onResizeStop={ onResizeStop }
style={ style }
>
<div className="wc-block-featured-category__wrapper">
<h2
className="wc-block-featured-category__title"
dangerouslySetInnerHTML={ {
__html: category.name,
} }
/>
{ showDesc && (
<div
className="wc-block-featured-category__description"
dangerouslySetInnerHTML={ {
__html: category.description,
} }
/>
) }
<div className="wc-block-featured-category__link">
{ renderButton() }
</div>
</div>
</ResizableBox>
);
};
const renderNoCategory = () => (
<Placeholder
className="wc-block-featured-category"
icon={ <IconFolderStar /> }
label={ __( 'Featured Category', 'woocommerce' ) }
>
{ isLoading ? (
<Spinner />
) : (
__(
'No product category is selected.',
'woocommerce'
)
) }
</Placeholder>
);
const { editMode } = attributes;
if ( error ) {
return renderApiError();
}
if ( editMode ) {
return renderEditMode();
}
return (
<Fragment>
{ getBlockControls() }
{ getInspectorControls() }
{ category ? renderCategory() : renderNoCategory() }
</Fragment>
);
};
FeaturedCategory.propTypes = {
/**
* The attributes for this block.
*/
attributes: PropTypes.object.isRequired,
/**
* Whether this block is currently active.
*/
isSelected: PropTypes.bool.isRequired,
/**
* The register block name.
*/
name: PropTypes.string.isRequired,
/**
* A callback to update attributes.
*/
setAttributes: PropTypes.func.isRequired,
// from withCategory
error: PropTypes.object,
getCategory: PropTypes.func,
isLoading: PropTypes.bool,
category: PropTypes.shape( {
name: PropTypes.node,
description: PropTypes.node,
permalink: PropTypes.string,
} ),
// from withColors
overlayColor: PropTypes.object,
setOverlayColor: PropTypes.func.isRequired,
// from withSpokenMessages
debouncedSpeak: PropTypes.func.isRequired,
};
export default compose( [
withCategory,
withColors( { overlayColor: 'background-color' } ),
withSpokenMessages,
] )( FeaturedCategory );

View File

@@ -0,0 +1,18 @@
.wc-block-featured-category {
&.components-placeholder {
// Reset the background for the placeholders.
background-color: rgba(139, 139, 150, 0.1);
}
.components-resizable-box__handle {
z-index: 10;
}
.components-placeholder__label svg {
fill: currentColor;
margin-right: 1ch;
}
}
.wc-block-featured-category__selection {
width: 100%;
}

View File

@@ -0,0 +1,18 @@
/**
* External dependencies
*/
import { DEFAULT_HEIGHT } from '@woocommerce/block-settings';
import { previewCategories } from '@woocommerce/resource-previews';
export const example = {
attributes: {
contentAlign: 'center',
dimRatio: 50,
editMode: false,
height: DEFAULT_HEIGHT,
mediaSrc: '',
showDesc: true,
categoryId: 'preview',
previewCategory: previewCategories[ 0 ],
},
};

View File

@@ -0,0 +1,153 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { InnerBlocks } from '@wordpress/editor';
import { registerBlockType } from '@wordpress/blocks';
import { DEFAULT_HEIGHT } from '@woocommerce/block-settings';
import { IconFolderStar } from '@woocommerce/block-components/icons';
/**
* Internal dependencies
*/
import './style.scss';
import './editor.scss';
import Block from './block';
import { example } from './example';
/**
* Register and run the "Featured Category" block.
*/
registerBlockType( 'woocommerce/featured-category', {
title: __( 'Featured Category', 'woocommerce' ),
icon: {
src: <IconFolderStar />,
foreground: '#96588a',
},
category: 'woocommerce',
keywords: [ __( 'WooCommerce', 'woocommerce' ) ],
description: __(
'Visually highlight a product category and encourage prompt action.',
'woocommerce'
),
supports: {
align: [ 'wide', 'full' ],
html: false,
},
example,
attributes: {
/**
* Alignment of content inside block.
*/
contentAlign: {
type: 'string',
default: 'center',
},
/**
* Percentage opacity of overlay.
*/
dimRatio: {
type: 'number',
default: 50,
},
/**
* Toggle for edit mode in the block preview.
*/
editMode: {
type: 'boolean',
default: true,
},
/**
* Focus point for the background image
*/
focalPoint: {
type: 'object',
},
/**
* A fixed height for the block.
*/
height: {
type: 'number',
default: DEFAULT_HEIGHT,
},
/**
* ID for a custom image, overriding the product's featured image.
*/
mediaId: {
type: 'number',
default: 0,
},
/**
* URL for a custom image, overriding the product's featured image.
*/
mediaSrc: {
type: 'string',
default: '',
},
/**
* The overlay color, from the color list.
*/
overlayColor: {
type: 'string',
},
/**
* The overlay color, if a custom color value.
*/
customOverlayColor: {
type: 'string',
},
/**
* Text for the category link.
*/
linkText: {
type: 'string',
default: __( 'Shop now', 'woocommerce' ),
},
/**
* The category ID to display.
*/
categoryId: {
type: 'number',
},
/**
* Show the category description.
*/
showDesc: {
type: 'boolean',
default: true,
},
/**
* Category preview.
*/
previewCategory: {
type: 'object',
default: null,
},
},
/**
* Renders and manages the block.
*/
edit( props ) {
return <Block { ...props } />;
},
/**
* Block content is rendered in PHP, not via save function.
*/
save() {
return <InnerBlocks.Content />;
},
} );

View File

@@ -0,0 +1,130 @@
.wc-block-featured-category {
position: relative;
background-color: $black;
background-size: cover;
background-position: center center;
width: 100%;
margin: 0 0 1.5em 0;
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
align-content: center;
.wc-block-featured-category__wrapper {
overflow: hidden;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
align-content: center;
}
&.has-left-content {
justify-content: flex-start;
.wc-block-featured-category__title,
.wc-block-featured-category__description,
.wc-block-featured-category__price {
margin-left: 0;
text-align: left;
}
}
&.has-right-content {
justify-content: flex-end;
.wc-block-featured-category__title,
.wc-block-featured-category__description,
.wc-block-featured-category__price {
margin-right: 0;
text-align: right;
}
}
.wc-block-featured-category__title,
.wc-block-featured-category__description,
.wc-block-featured-category__price {
color: $white;
line-height: 1.25;
margin-bottom: 0;
text-align: center;
a,
a:hover,
a:focus,
a:active {
color: $white;
}
}
.wc-block-featured-category__title,
.wc-block-featured-category__description,
.wc-block-featured-category__price,
.wc-block-featured-category__link {
width: 100%;
padding: 0 48px 16px 48px;
z-index: 1;
}
.wc-block-featured-category__title {
margin-top: 0;
&::before {
display: none;
}
}
.wc-block-featured-category__description {
p {
margin: 0;
}
}
&.has-background-dim::before {
content: "";
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
background-color: inherit;
opacity: 0.5;
z-index: 1;
}
@for $i from 1 through 10 {
&.has-background-dim.has-background-dim-#{ $i * 10 }::before {
opacity: $i * 0.1;
}
}
// Apply max-width to floated items that have no intrinsic width
&.alignleft,
&.alignright {
max-width: $content-width / 2;
width: 100%;
}
// Using flexbox without an assigned height property breaks vertical center alignment in IE11.
// Appending an empty ::after element tricks IE11 into giving the cover image an implicit height, which sidesteps this issue.
&::after {
display: block;
content: "";
font-size: 0;
min-height: inherit;
// IE doesn't support flex so omit that.
@supports (position: sticky) {
content: none;
}
}
// Aligned cover blocks should not use our global alignment rules
&.aligncenter,
&.alignleft,
&.alignright {
display: flex;
}
}

View File

@@ -0,0 +1,62 @@
/**
* External dependencies
*/
import { isObject } from 'lodash';
/**
* Get the src from a category object, unless null (no image).
*
* @param {object|null} category A product category object from the API.
* @return {string} The src of the category image.
*/
function getCategoryImageSrc( category ) {
if ( category && isObject( category.image ) ) {
return category.image.src;
}
return '';
}
/**
* Get the attachment ID from a category object, unless null (no image).
*
* @param {object|null} category A product category object from the API.
* @return {number} The id of the category image.
*/
function getCategoryImageId( category ) {
if ( category && isObject( category.image ) ) {
return category.image.id;
}
return 0;
}
/**
* Generate a style object given either a product category image from the API or URL to an image.
*
* @param {string} url An image URL.
* @return {Object} A style object with a backgroundImage set (if a valid image is provided).
*/
function getBackgroundImageStyles( url ) {
if ( url ) {
return { backgroundImage: `url(${ url })` };
}
return {};
}
/**
* Convert the selected ratio to the correct background class.
*
* @param {number} ratio Selected opacity from 0 to 100.
* @return {string} The class name, if applicable (not used for ratio 0 or 50).
*/
function dimRatioToClass( ratio ) {
return ratio === 0 || ratio === 50
? null
: `has-background-dim-${ 10 * Math.round( ratio / 10 ) }`;
}
export {
getCategoryImageSrc,
getCategoryImageId,
getBackgroundImageStyles,
dimRatioToClass,
};

View File

@@ -0,0 +1,454 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import {
AlignmentToolbar,
BlockControls,
InnerBlocks,
InspectorControls,
MediaUpload,
MediaUploadCheck,
PanelColorSettings,
withColors,
RichText,
} from '@wordpress/editor';
import {
Button,
FocalPointPicker,
IconButton,
PanelBody,
Placeholder,
RangeControl,
ResizableBox,
Spinner,
ToggleControl,
Toolbar,
withSpokenMessages,
} from '@wordpress/components';
import classnames from 'classnames';
import { Fragment } from '@wordpress/element';
import { compose } from '@wordpress/compose';
import { isEmpty } from 'lodash';
import PropTypes from 'prop-types';
import { MIN_HEIGHT } from '@woocommerce/block-settings';
import ProductControl from '@woocommerce/block-components/product-control';
import ErrorPlaceholder from '@woocommerce/block-components/error-placeholder';
import { withProduct } from '@woocommerce/block-hocs';
/**
* Internal dependencies
*/
import { dimRatioToClass, getBackgroundImageStyles } from './utils';
import {
getImageSrcFromProduct,
getImageIdFromProduct,
} from '../../utils/products';
/**
* Component to handle edit mode of "Featured Product".
*/
const FeaturedProduct = ( {
attributes,
debouncedSpeak,
error,
getProduct,
isLoading,
isSelected,
overlayColor,
product,
setAttributes,
setOverlayColor,
} ) => {
const renderApiError = () => (
<ErrorPlaceholder
className="wc-block-featured-product-error"
error={ error }
isLoading={ isLoading }
onRetry={ getProduct }
/>
);
const renderEditMode = () => {
const onDone = () => {
setAttributes( { editMode: false } );
debouncedSpeak(
__(
'Showing Featured Product block preview.',
'woocommerce'
)
);
};
return (
<Fragment>
{ getBlockControls() }
<Placeholder
icon="star-filled"
label={ __(
'Featured Product',
'woocommerce'
) }
className="wc-block-featured-product"
>
{ __(
'Visually highlight a product or variation and encourage prompt action',
'woocommerce'
) }
<div className="wc-block-featured-product__selection">
<ProductControl
selected={ attributes.productId || 0 }
showVariations
onChange={ ( value = [] ) => {
const id = value[ 0 ] ? value[ 0 ].id : 0;
setAttributes( {
productId: id,
mediaId: 0,
mediaSrc: '',
} );
} }
/>
<Button isDefault onClick={ onDone }>
{ __( 'Done', 'woocommerce' ) }
</Button>
</div>
</Placeholder>
</Fragment>
);
};
const getBlockControls = () => {
const { contentAlign, editMode } = attributes;
const mediaId = attributes.mediaId || getImageIdFromProduct( product );
return (
<BlockControls>
<AlignmentToolbar
value={ contentAlign }
onChange={ ( nextAlign ) => {
setAttributes( { contentAlign: nextAlign } );
} }
/>
<MediaUploadCheck>
<Toolbar>
<MediaUpload
onSelect={ ( media ) => {
setAttributes( {
mediaId: media.id,
mediaSrc: media.url,
} );
} }
allowedTypes={ [ 'image' ] }
value={ mediaId }
render={ ( { open } ) => (
<IconButton
className="components-toolbar__control"
label={ __( 'Edit media' ) }
icon="format-image"
onClick={ open }
disabled={ ! product }
/>
) }
/>
</Toolbar>
</MediaUploadCheck>
<Toolbar
controls={ [
{
icon: 'edit',
title: __( 'Edit' ),
onClick: () =>
setAttributes( { editMode: ! editMode } ),
isActive: editMode,
},
] }
/>
</BlockControls>
);
};
const getInspectorControls = () => {
const url = attributes.mediaSrc || getImageSrcFromProduct( product );
const { focalPoint = { x: 0.5, y: 0.5 } } = attributes;
// FocalPointPicker was introduced in Gutenberg 5.0 (WordPress 5.2),
// so we need to check if it exists before using it.
const focalPointPickerExists = typeof FocalPointPicker === 'function';
return (
<InspectorControls key="inspector">
<PanelBody
title={ __( 'Content', 'woocommerce' ) }
>
<ToggleControl
label={ __(
'Show description',
'woocommerce'
) }
checked={ attributes.showDesc }
onChange={
// prettier-ignore
() => setAttributes( { showDesc: ! attributes.showDesc } )
}
/>
<ToggleControl
label={ __(
'Show price',
'woocommerce'
) }
checked={ attributes.showPrice }
onChange={
// prettier-ignore
() => setAttributes( { showPrice: ! attributes.showPrice } )
}
/>
</PanelBody>
<PanelColorSettings
title={ __( 'Overlay', 'woocommerce' ) }
colorSettings={ [
{
value: overlayColor.color,
onChange: setOverlayColor,
label: __(
'Overlay Color',
'woocommerce'
),
},
] }
>
{ !! url && (
<Fragment>
<RangeControl
label={ __(
'Background Opacity',
'woocommerce'
) }
value={ attributes.dimRatio }
onChange={ ( ratio ) =>
setAttributes( { dimRatio: ratio } )
}
min={ 0 }
max={ 100 }
step={ 10 }
/>
{ focalPointPickerExists && (
<FocalPointPicker
label={ __( 'Focal Point Picker' ) }
url={ url }
value={ focalPoint }
onChange={ ( value ) =>
setAttributes( { focalPoint: value } )
}
/>
) }
</Fragment>
) }
</PanelColorSettings>
</InspectorControls>
);
};
const renderProduct = () => {
const {
className,
contentAlign,
dimRatio,
focalPoint,
height,
showDesc,
showPrice,
} = attributes;
const classes = classnames(
'wc-block-featured-product',
{
'is-selected': isSelected && attributes.productId !== 'preview',
'is-loading': ! product && isLoading,
'is-not-found': ! product && ! isLoading,
'has-background-dim': dimRatio !== 0,
},
dimRatioToClass( dimRatio ),
contentAlign !== 'center' && `has-${ contentAlign }-content`,
className
);
const style = getBackgroundImageStyles(
attributes.mediaSrc || product
);
if ( overlayColor.color ) {
style.backgroundColor = overlayColor.color;
}
if ( focalPoint ) {
const bgPosX = focalPoint.x * 100;
const bgPosY = focalPoint.y * 100;
style.backgroundPosition = `${ bgPosX }% ${ bgPosY }%`;
}
const onResizeStop = ( event, direction, elt ) => {
setAttributes( { height: parseInt( elt.style.height ) } );
};
return (
<ResizableBox
className={ classes }
size={ { height } }
minHeight={ MIN_HEIGHT }
enable={ { bottom: true } }
onResizeStop={ onResizeStop }
style={ style }
>
<div className="wc-block-featured-product__wrapper">
<h2
className="wc-block-featured-product__title"
dangerouslySetInnerHTML={ {
__html: product.name,
} }
/>
{ ! isEmpty( product.variation ) && (
<h3
className="wc-block-featured-product__variation"
dangerouslySetInnerHTML={ {
__html: product.variation,
} }
/>
) }
{ showDesc && (
<div
className="wc-block-featured-product__description"
dangerouslySetInnerHTML={ {
__html: product.description,
} }
/>
) }
{ showPrice && (
<div
className="wc-block-featured-product__price"
dangerouslySetInnerHTML={ {
__html: product.price_html,
} }
/>
) }
<div className="wc-block-featured-product__link">
{ renderButton() }
</div>
</div>
</ResizableBox>
);
};
const renderButton = () => {
const buttonClasses = classnames(
'wp-block-button__link',
'is-style-fill'
);
const buttonStyle = {
backgroundColor: 'vivid-green-cyan',
borderRadius: '5px',
};
const wrapperStyle = {
width: '100%',
};
return attributes.productId === 'preview' ? (
<div className="wp-block-button aligncenter" style={ wrapperStyle }>
<RichText.Content
tagName="a"
className={ buttonClasses }
href={ product.url }
title={ attributes.linkText }
style={ buttonStyle }
value={ attributes.linkText }
target={ product.url }
/>
</div>
) : (
<InnerBlocks
template={ [
[
'core/button',
{
text: __(
'Shop now',
'woocommerce'
),
url: product.permalink,
align: 'center',
},
],
] }
templateLock="all"
/>
);
};
const renderNoProduct = () => (
<Placeholder
className="wc-block-featured-product"
icon="star-filled"
label={ __( 'Featured Product', 'woocommerce' ) }
>
{ isLoading ? (
<Spinner />
) : (
__( 'No product is selected.', 'woocommerce' )
) }
</Placeholder>
);
const { editMode } = attributes;
if ( error ) {
return renderApiError();
}
if ( editMode ) {
return renderEditMode();
}
return (
<Fragment>
{ getBlockControls() }
{ getInspectorControls() }
{ product ? renderProduct() : renderNoProduct() }
</Fragment>
);
};
FeaturedProduct.propTypes = {
/**
* The attributes for this block.
*/
attributes: PropTypes.object.isRequired,
/**
* Whether this block is currently active.
*/
isSelected: PropTypes.bool.isRequired,
/**
* The register block name.
*/
name: PropTypes.string.isRequired,
/**
* A callback to update attributes.
*/
setAttributes: PropTypes.func.isRequired,
// from withProduct
error: PropTypes.object,
getProduct: PropTypes.func,
isLoading: PropTypes.bool,
product: PropTypes.shape( {
name: PropTypes.node,
variation: PropTypes.node,
description: PropTypes.node,
price_html: PropTypes.node,
permalink: PropTypes.string,
} ),
// from withColors
overlayColor: PropTypes.object,
setOverlayColor: PropTypes.func.isRequired,
// from withSpokenMessages
debouncedSpeak: PropTypes.func.isRequired,
};
export default compose( [
withProduct,
withColors( { overlayColor: 'background-color' } ),
withSpokenMessages,
] )( FeaturedProduct );

View File

@@ -0,0 +1,17 @@
.wc-block-featured-product {
&.components-placeholder {
// Reset the background for the placeholders.
background-color: rgba(139, 139, 150, 0.1);
}
.components-resizable-box__handle {
z-index: 10;
}
}
.wc-block-featured-product__message {
margin-bottom: 16px;
}
.wc-block-featured-product__selection {
width: 100%;
}

View File

@@ -0,0 +1,18 @@
/**
* External dependencies
*/
import { DEFAULT_HEIGHT } from '@woocommerce/block-settings';
import { previewProducts } from '@woocommerce/resource-previews';
export const example = {
attributes: {
contentAlign: 'center',
dimRatio: 50,
editMode: false,
height: DEFAULT_HEIGHT,
mediaSrc: '',
showDesc: true,
productId: 'preview',
previewProduct: previewProducts[ 0 ],
},
};

View File

@@ -0,0 +1,160 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { InnerBlocks } from '@wordpress/editor';
import { registerBlockType } from '@wordpress/blocks';
import { DEFAULT_HEIGHT } from '@woocommerce/block-settings';
/**
* Internal dependencies
*/
import './style.scss';
import './editor.scss';
import { example } from './example';
import Block from './block';
/**
* Register and run the "Featured Product" block.
*/
registerBlockType( 'woocommerce/featured-product', {
title: __( 'Featured Product', 'woocommerce' ),
icon: {
src: 'star-filled',
foreground: '#96588a',
},
category: 'woocommerce',
keywords: [ __( 'WooCommerce', 'woocommerce' ) ],
description: __(
'Visually highlight a product or variation and encourage prompt action.',
'woocommerce'
),
supports: {
align: [ 'wide', 'full' ],
html: false,
},
example,
attributes: {
/**
* Alignment of content inside block.
*/
contentAlign: {
type: 'string',
default: 'center',
},
/**
* Percentage opacity of overlay.
*/
dimRatio: {
type: 'number',
default: 50,
},
/**
* Toggle for edit mode in the block preview.
*/
editMode: {
type: 'boolean',
default: true,
},
/**
* Focus point for the background image
*/
focalPoint: {
type: 'object',
},
/**
* A fixed height for the block.
*/
height: {
type: 'number',
default: DEFAULT_HEIGHT,
},
/**
* ID for a custom image, overriding the product's featured image.
*/
mediaId: {
type: 'number',
default: 0,
},
/**
* URL for a custom image, overriding the product's featured image.
*/
mediaSrc: {
type: 'string',
default: '',
},
/**
* The overlay color, from the color list.
*/
overlayColor: {
type: 'string',
},
/**
* The overlay color, if a custom color value.
*/
customOverlayColor: {
type: 'string',
},
/**
* Text for the product link.
*/
linkText: {
type: 'string',
default: __( 'Shop now', 'woocommerce' ),
},
/**
* The product ID to display.
*/
productId: {
type: 'number',
},
/**
* Show the product description.
*/
showDesc: {
type: 'boolean',
default: true,
},
/**
* Show the product price.
*/
showPrice: {
type: 'boolean',
default: true,
},
/**
* Product preview.
*/
previewProduct: {
type: 'object',
default: null,
},
},
/**
* Renders and manages the block.
*/
edit( props ) {
return <Block { ...props } />;
},
/**
* Block content is rendered in PHP, not via save function.
*/
save() {
return <InnerBlocks.Content />;
},
} );

View File

@@ -0,0 +1,142 @@
.wc-block-featured-product {
position: relative;
background-color: $black;
background-size: cover;
background-position: center center;
width: 100%;
margin: 0 0 1.5em 0;
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
align-content: center;
.wc-block-featured-product__wrapper {
overflow: hidden;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
align-content: center;
}
&.has-left-content {
justify-content: flex-start;
.wc-block-featured-product__title,
.wc-block-featured-product__variation,
.wc-block-featured-product__description,
.wc-block-featured-product__price {
margin-left: 0;
text-align: left;
}
}
&.has-right-content {
justify-content: flex-end;
.wc-block-featured-product__title,
.wc-block-featured-product__variation,
.wc-block-featured-product__description,
.wc-block-featured-product__price {
margin-right: 0;
text-align: right;
}
}
.wc-block-featured-product__title,
.wc-block-featured-product__variation,
.wc-block-featured-product__description,
.wc-block-featured-product__price {
color: $white;
line-height: 1.25;
margin-bottom: 0;
text-align: center;
a,
a:hover,
a:focus,
a:active {
color: $white;
}
}
.wc-block-featured-product__title,
.wc-block-featured-product__variation,
.wc-block-featured-product__description,
.wc-block-featured-product__price,
.wc-block-featured-product__link {
width: 100%;
padding: 16px 48px 0 48px;
z-index: 1;
}
.wc-block-featured-product__title,
.wc-block-featured-product__variation {
margin-top: 0;
border: 0;
&::before {
display: none;
}
}
.wc-block-featured-product__variation {
font-style: italic;
padding-top: 0;
}
.wc-block-featured-product__description {
p {
margin: 0;
line-height: 1.5em;
}
}
&.has-background-dim::before {
content: "";
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
background-color: inherit;
opacity: 0.5;
z-index: 1;
}
@for $i from 1 through 10 {
&.has-background-dim.has-background-dim-#{ $i * 10 }::before {
opacity: $i * 0.1;
}
}
// Apply max-width to floated items that have no intrinsic width
&.alignleft,
&.alignright {
max-width: $content-width / 2;
width: 100%;
}
// Using flexbox without an assigned height property breaks vertical center alignment in IE11.
// Appending an empty ::after element tricks IE11 into giving the cover image an implicit height, which sidesteps this issue.
&::after {
display: block;
content: "";
font-size: 0;
min-height: inherit;
// IE doesn't support flex so omit that.
@supports (position: sticky) {
content: none;
}
}
// Aligned cover blocks should not use our global alignment rules
&.aligncenter,
&.alignleft,
&.alignright {
display: flex;
}
}

View File

@@ -0,0 +1,40 @@
/**
* External dependencies
*/
import { isObject } from 'lodash';
/**
* Internal dependencies
*/
import { getImageSrcFromProduct } from '../../utils/products';
/**
* Generate a style object given either a product object or URL to an image.
*
* @param {object|string} url A product object as returned from the API, or an image URL.
* @return {Object} A style object with a backgroundImage set (if a valid image is provided).
*/
function getBackgroundImageStyles( url ) {
// If `url` is an object, it's actually a product.
if ( isObject( url ) ) {
url = getImageSrcFromProduct( url );
}
if ( url ) {
return { backgroundImage: `url(${ url })` };
}
return {};
}
/**
* Convert the selected ratio to the correct background class.
*
* @param {number} ratio Selected opacity from 0 to 100.
* @return {string} The class name, if applicable (not used for ratio 0 or 50).
*/
function dimRatioToClass( ratio ) {
return ratio === 0 || ratio === 50
? null
: `has-background-dim-${ 10 * Math.round( ratio / 10 ) }`;
}
export { getBackgroundImageStyles, dimRatioToClass };

View File

@@ -0,0 +1,215 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import {
BlockControls,
InspectorControls,
ServerSideRender,
} from '@wordpress/editor';
import {
Button,
Disabled,
PanelBody,
Placeholder,
RangeControl,
Toolbar,
withSpokenMessages,
ToggleControl,
} from '@wordpress/components';
import { Component, Fragment } from '@wordpress/element';
import PropTypes from 'prop-types';
import { MAX_COLUMNS, MIN_COLUMNS } from '@woocommerce/block-settings';
import GridContentControl from '@woocommerce/block-components/grid-content-control';
import { IconWidgets } from '@woocommerce/block-components/icons';
import ProductsControl from '@woocommerce/block-components/products-control';
import ProductOrderbyControl from '@woocommerce/block-components/product-orderby-control';
import { gridBlockPreview } from '@woocommerce/resource-previews';
/**
* Component to handle edit mode of "Hand-picked Products".
*/
class ProductsBlock extends Component {
getInspectorControls() {
const { attributes, setAttributes } = this.props;
const {
columns,
contentVisibility,
orderby,
alignButtons,
} = attributes;
return (
<InspectorControls key="inspector">
<PanelBody
title={ __( 'Layout', 'woocommerce' ) }
initialOpen
>
<RangeControl
label={ __(
'Columns',
'woocommerce'
) }
value={ columns }
onChange={ ( value ) =>
setAttributes( { columns: value } )
}
min={ MIN_COLUMNS }
max={ MAX_COLUMNS }
/>
<ToggleControl
label={ __(
'Align Buttons',
'woocommerce'
) }
help={
alignButtons
? __(
'Buttons are aligned vertically.',
'woocommerce'
)
: __(
'Buttons follow content.',
'woocommerce'
)
}
checked={ alignButtons }
onChange={ () =>
setAttributes( { alignButtons: ! alignButtons } )
}
/>
</PanelBody>
<PanelBody
title={ __( 'Content', 'woocommerce' ) }
initialOpen
>
<GridContentControl
settings={ contentVisibility }
onChange={ ( value ) =>
setAttributes( { contentVisibility: value } )
}
/>
</PanelBody>
<PanelBody
title={ __( 'Order By', 'woocommerce' ) }
initialOpen={ false }
>
<ProductOrderbyControl
setAttributes={ setAttributes }
value={ orderby }
/>
</PanelBody>
<PanelBody
title={ __( 'Products', 'woocommerce' ) }
initialOpen={ false }
>
<ProductsControl
selected={ attributes.products }
onChange={ ( value = [] ) => {
const ids = value.map( ( { id } ) => id );
setAttributes( { products: ids } );
} }
/>
</PanelBody>
</InspectorControls>
);
}
renderEditMode() {
const { attributes, debouncedSpeak, setAttributes } = this.props;
const onDone = () => {
setAttributes( { editMode: false } );
debouncedSpeak(
__(
'Showing Hand-picked Products block preview.',
'woocommerce'
)
);
};
return (
<Placeholder
icon={ <IconWidgets /> }
label={ __(
'Hand-picked Products',
'woocommerce'
) }
className="wc-block-products-grid wc-block-handpicked-products"
>
{ __(
'Display a selection of hand-picked products in a grid.',
'woocommerce'
) }
<div className="wc-block-handpicked-products__selection">
<ProductsControl
selected={ attributes.products }
onChange={ ( value = [] ) => {
const ids = value.map( ( { id } ) => id );
setAttributes( { products: ids } );
} }
/>
<Button isDefault onClick={ onDone }>
{ __( 'Done', 'woocommerce' ) }
</Button>
</div>
</Placeholder>
);
}
render() {
const { attributes, name, setAttributes } = this.props;
const { editMode } = attributes;
if ( attributes.isPreview ) {
return gridBlockPreview;
}
return (
<Fragment>
<BlockControls>
<Toolbar
controls={ [
{
icon: 'edit',
title: __( 'Edit' ),
onClick: () =>
setAttributes( { editMode: ! editMode } ),
isActive: editMode,
},
] }
/>
</BlockControls>
{ this.getInspectorControls() }
{ editMode ? (
this.renderEditMode()
) : (
<Disabled>
<ServerSideRender
block={ name }
attributes={ attributes }
/>
</Disabled>
) }
</Fragment>
);
}
}
ProductsBlock.propTypes = {
/**
* The attributes for this block
*/
attributes: PropTypes.object.isRequired,
/**
* The register block name.
*/
name: PropTypes.string.isRequired,
/**
* A callback to update attributes
*/
setAttributes: PropTypes.func.isRequired,
// from withSpokenMessages
debouncedSpeak: PropTypes.func.isRequired,
};
export default withSpokenMessages( ProductsBlock );

View File

@@ -0,0 +1,3 @@
.wc-block-handpicked-products__selection {
width: 100%;
}

View File

@@ -0,0 +1,156 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { registerBlockType } from '@wordpress/blocks';
import { DEFAULT_COLUMNS } from '@woocommerce/block-settings';
import { IconWidgets } from '@woocommerce/block-components/icons';
/**
* Internal dependencies
*/
import './editor.scss';
import Block from './block';
import { deprecatedConvertToShortcode } from '../../utils/deprecations';
registerBlockType( 'woocommerce/handpicked-products', {
title: __( 'Hand-picked Products', 'woocommerce' ),
icon: {
src: <IconWidgets />,
foreground: '#96588a',
},
category: 'woocommerce',
keywords: [ __( 'WooCommerce', 'woocommerce' ) ],
description: __(
'Display a selection of hand-picked products in a grid.',
'woocommerce'
),
supports: {
align: [ 'wide', 'full' ],
html: false,
},
example: {
attributes: {
isPreview: true,
},
},
attributes: {
/**
* Alignment of product grid
*/
align: {
type: 'string',
},
/**
* Number of columns.
*/
columns: {
type: 'number',
default: DEFAULT_COLUMNS,
},
/**
* Toggle for edit mode in the block preview.
*/
editMode: {
type: 'boolean',
default: true,
},
/**
* Content visibility setting
*/
contentVisibility: {
type: 'object',
default: {
title: true,
price: true,
rating: true,
button: true,
},
},
/**
* How to order the products: 'date', 'popularity', 'price_asc', 'price_desc' 'rating', 'title'.
*/
orderby: {
type: 'string',
default: 'date',
},
/**
* The list of product IDs to display
*/
products: {
type: 'array',
default: [],
},
/**
* How to align cart buttons.
*/
alignButtons: {
type: 'boolean',
default: false,
},
/**
* Are we previewing?
*/
isPreview: {
type: 'boolean',
default: false,
},
},
deprecated: [
{
// Deprecate shortcode save method in favor of dynamic rendering.
attributes: {
align: {
type: 'string',
},
columns: {
type: 'number',
default: DEFAULT_COLUMNS,
},
editMode: {
type: 'boolean',
default: true,
},
contentVisibility: {
type: 'object',
default: {
title: true,
price: true,
rating: true,
button: true,
},
},
orderby: {
type: 'string',
default: 'date',
},
products: {
type: 'array',
default: [],
},
},
save: deprecatedConvertToShortcode(
'woocommerce/handpicked-products'
),
},
],
/**
* Renders and manages the block.
*/
edit( props ) {
return <Block { ...props } />;
},
save() {
return null;
},
} );

View File

@@ -0,0 +1,136 @@
/**
* External dependencies
*/
import {
useQueryStateByKey,
useQueryStateByContext,
useCollectionData,
} from '@woocommerce/base-hooks';
import { Fragment, useCallback, useState, useEffect } from '@wordpress/element';
import PriceSlider from '@woocommerce/base-components/price-slider';
import { CURRENCY } from '@woocommerce/settings';
import { useDebouncedCallback } from 'use-debounce';
import PropTypes from 'prop-types';
/**
* Internal dependencies
*/
import usePriceConstraints from './use-price-constraints.js';
/**
* Component displaying a price filter.
*/
const PriceFilterBlock = ( { attributes, isEditor = false } ) => {
const [ minPriceQuery, setMinPriceQuery ] = useQueryStateByKey(
'min_price'
);
const [ maxPriceQuery, setMaxPriceQuery ] = useQueryStateByKey(
'max_price'
);
const [ queryState ] = useQueryStateByContext();
const { results, isLoading } = useCollectionData( {
queryPrices: true,
queryState,
} );
const [ minPrice, setMinPrice ] = useState();
const [ maxPrice, setMaxPrice ] = useState();
const { minConstraint, maxConstraint } = usePriceConstraints( {
minPrice: results.min_price,
maxPrice: results.max_price,
} );
// Updates the query after a short delay.
const [ debouncedUpdateQuery ] = useDebouncedCallback( () => {
onSubmit();
}, 500 );
// Updates the query based on slider values.
const onSubmit = useCallback( () => {
setMinPriceQuery( minPrice === minConstraint ? undefined : minPrice );
setMaxPriceQuery( maxPrice === maxConstraint ? undefined : maxPrice );
}, [ minPrice, maxPrice, minConstraint, maxConstraint ] );
// Callback when slider or input fields are changed.
const onChange = useCallback(
( prices ) => {
if ( prices[ 0 ] !== minPrice ) {
setMinPrice( prices[ 0 ] );
}
if ( prices[ 1 ] !== maxPrice ) {
setMaxPrice( prices[ 1 ] );
}
},
[ minConstraint, maxConstraint, minPrice, maxPrice ]
);
// Track price STATE changes - if state changes, update the query.
useEffect( () => {
if ( ! attributes.showFilterButton ) {
debouncedUpdateQuery();
}
}, [ minPrice, maxPrice, attributes.showFilterButton ] );
// Track PRICE QUERY changes so the slider reflects current filters.
useEffect( () => {
if ( minPriceQuery !== minPrice ) {
setMinPrice(
Number.isFinite( minPriceQuery ) ? minPriceQuery : minConstraint
);
}
if ( maxPriceQuery !== maxPrice ) {
setMaxPrice(
Number.isFinite( maxPriceQuery ) ? maxPriceQuery : maxConstraint
);
}
}, [ minPriceQuery, maxPriceQuery, minConstraint, maxConstraint ] );
if (
! isLoading &&
( minConstraint === null ||
maxConstraint === null ||
minConstraint === maxConstraint )
) {
return null;
}
const TagName = `h${ attributes.headingLevel }`;
return (
<Fragment>
{ ! isEditor && attributes.heading && (
<TagName>{ attributes.heading }</TagName>
) }
<div className="wc-block-price-slider">
<PriceSlider
minConstraint={ minConstraint }
maxConstraint={ maxConstraint }
minPrice={ minPrice }
maxPrice={ maxPrice }
step={ 10 }
currencySymbol={ CURRENCY.symbol }
priceFormat={ CURRENCY.priceFormat }
showInputFields={ attributes.showInputFields }
showFilterButton={ attributes.showFilterButton }
onChange={ onChange }
onSubmit={ onSubmit }
isLoading={ isLoading }
/>
</div>
</Fragment>
);
};
PriceFilterBlock.propTypes = {
/**
* The attributes for this block.
*/
attributes: PropTypes.object.isRequired,
/**
* Whether it's in the editor or frontend display.
*/
isEditor: PropTypes.bool,
};
export default PriceFilterBlock;

View File

@@ -0,0 +1,2 @@
export const ROUND_UP = 'ROUND_UP';
export const ROUND_DOWN = 'ROUND_DOWN';

View File

@@ -0,0 +1,176 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Fragment } from '@wordpress/element';
import { InspectorControls } from '@wordpress/block-editor';
import {
Placeholder,
Disabled,
PanelBody,
ToggleControl,
Button,
} from '@wordpress/components';
import { PRODUCT_COUNT } from '@woocommerce/block-settings';
import { getAdminLink } from '@woocommerce/settings';
import HeadingToolbar from '@woocommerce/block-components/heading-toolbar';
import BlockTitle from '@woocommerce/block-components/block-title';
/**
* Internal dependencies
*/
import Block from './block.js';
import './editor.scss';
import { IconMoney, IconExternal } from '../../components/icons';
import ToggleButtonControl from '../../components/toggle-button-control';
export default function( { attributes, setAttributes } ) {
const {
className,
heading,
headingLevel,
showInputFields,
showFilterButton,
} = attributes;
const getInspectorControls = () => {
return (
<InspectorControls key="inspector">
<PanelBody
title={ __(
'Block Settings',
'woocommerce'
) }
>
<ToggleButtonControl
label={ __(
'Price Range',
'woocommerce'
) }
value={ showInputFields ? 'editable' : 'text' }
options={ [
{
label: __(
'Editable',
'woocommerce'
),
value: 'editable',
},
{
label: __(
'Text',
'woocommerce'
),
value: 'text',
},
] }
onChange={ ( value ) =>
setAttributes( {
showInputFields: value === 'editable',
} )
}
/>
<ToggleControl
label={ __(
'Filter button',
'woocommerce'
) }
help={
showFilterButton
? __(
'Results will only update when the button is pressed.',
'woocommerce'
)
: __(
'Results will update when the slider is moved.',
'woocommerce'
)
}
checked={ showFilterButton }
onChange={ () =>
setAttributes( {
showFilterButton: ! showFilterButton,
} )
}
/>
<p>
{ __(
'Heading Level',
'woocommerce'
) }
</p>
<HeadingToolbar
isCollapsed={ false }
minLevel={ 2 }
maxLevel={ 7 }
selectedLevel={ headingLevel }
onChange={ ( newLevel ) =>
setAttributes( { headingLevel: newLevel } )
}
/>
</PanelBody>
</InspectorControls>
);
};
const noProductsPlaceholder = () => (
<Placeholder
className="wc-block-price-slider"
icon={ <IconMoney /> }
label={ __(
'Filter Products by Price',
'woocommerce'
) }
instructions={ __(
'Display a slider to filter products in your store by price.',
'woocommerce'
) }
>
<p>
{ __(
"Products with prices are needed for filtering by price. You haven't created any products yet.",
'woocommerce'
) }
</p>
<Button
className="wc-block-price-slider__add_product_button"
isDefault
isLarge
href={ getAdminLink( 'post-new.php?post_type=product' ) }
>
{ __( 'Add new product', 'woocommerce' ) +
' ' }
<IconExternal />
</Button>
<Button
className="wc-block-price-slider__read_more_button"
isTertiary
href="https://docs.woocommerce.com/document/managing-products/"
>
{ __( 'Learn more', 'woocommerce' ) }
</Button>
</Placeholder>
);
return (
<Fragment>
{ PRODUCT_COUNT === 0 ? (
noProductsPlaceholder()
) : (
<div className={ className }>
{ getInspectorControls() }
<BlockTitle
headingLevel={ headingLevel }
heading={ heading }
onChange={ ( value ) =>
setAttributes( { heading: value } )
}
/>
<Disabled>
<Block attributes={ attributes } isEditor={ true } />
</Disabled>
</div>
) }
</Fragment>
);
}

View File

@@ -0,0 +1,48 @@
.components-disabled .wc-block-price-filter__range-input-wrapper .wc-block-price-filter__range-input {
&::-webkit-slider-thumb {
pointer-events: none;
}
&::-moz-range-thumb {
pointer-events: none;
}
&::-ms-thumb {
pointer-events: none;
}
}
.wc-block-price-slider {
.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 */
p {
font-size: 14px;
}
}
.wc-block-price-slider__add_product_button {
margin: 0 0 1em;
line-height: 24px;
vertical-align: middle;
height: auto;
font-size: 14px;
padding: 0.5em 1em;
svg {
fill: currentColor;
margin-left: 0.5ch;
vertical-align: middle;
}
}
.wc-block-price-slider__read_more_button {
display: block;
margin-bottom: 1em;
}
}

View File

@@ -0,0 +1,25 @@
/**
* External dependencies
*/
import { withRestApiHydration } from '@woocommerce/block-hocs';
/**
* Internal dependencies
*/
import renderFrontend from '../../utils/render-frontend.js';
import Block from './block.js';
const getProps = ( el ) => {
return {
attributes: {
showInputFields: el.dataset.showinputfields === 'true',
showFilterButton: el.dataset.showfilterbutton === 'true',
},
};
};
renderFrontend(
'.wp-block-woocommerce-price-filter',
withRestApiHydration( Block ),
getProps
);

View File

@@ -0,0 +1,80 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { registerBlockType } from '@wordpress/blocks';
import classNames from 'classnames';
/**
* Internal dependencies
*/
import edit from './edit.js';
import { IconMoney } from '../../components/icons';
registerBlockType( 'woocommerce/price-filter', {
title: __( 'Filter Products by Price', 'woocommerce' ),
icon: {
src: <IconMoney />,
foreground: '#96588a',
},
category: 'woocommerce',
keywords: [ __( 'WooCommerce', 'woocommerce' ) ],
description: __(
'Display a slider to filter products in your store by price.',
'woocommerce'
),
supports: {
multiple: false,
},
example: {},
attributes: {
showInputFields: {
type: 'boolean',
default: true,
},
showFilterButton: {
type: 'boolean',
default: false,
},
heading: {
type: 'string',
default: __( 'Filter by price', 'woocommerce' ),
},
headingLevel: {
type: 'number',
default: 3,
},
},
edit,
/**
* Save the props to post content.
*/
save( { attributes } ) {
const {
className,
showInputFields,
showFilterButton,
heading,
headingLevel,
} = attributes;
const data = {
'data-showinputfields': showInputFields,
'data-showfilterbutton': showFilterButton,
'data-heading': heading,
'data-heading-level': headingLevel,
};
return (
<div
className={ classNames( 'is-loading', className ) }
{ ...data }
>
<span
aria-hidden
className="wc-block-product-categories__placeholder"
/>
</div>
);
},
} );

View File

@@ -0,0 +1,82 @@
/**
* External dependencies
*/
import TestRenderer from 'react-test-renderer';
/**
* Internal dependencies
*/
import { usePriceConstraint } from '../use-price-constraints';
import { ROUND_UP, ROUND_DOWN } from '../constants';
describe( 'usePriceConstraints', () => {
const TestComponent = ( { price } ) => {
const maxPriceConstraint = usePriceConstraint( price, ROUND_UP );
const minPriceConstraint = usePriceConstraint( price, ROUND_DOWN );
return (
<div
minPriceConstraint={ minPriceConstraint }
maxPriceConstraint={ maxPriceConstraint }
/>
);
};
it( 'max price constraint should be updated when new price is set', () => {
const renderer = TestRenderer.create( <TestComponent price={ 10 } /> );
const container = renderer.root.findByType( 'div' );
expect( container.props.maxPriceConstraint ).toBe( 10 );
renderer.update( <TestComponent price={ 20 } /> );
expect( container.props.maxPriceConstraint ).toBe( 20 );
} );
it( 'min price constraint should be updated when new price is set', () => {
const renderer = TestRenderer.create( <TestComponent price={ 10 } /> );
const container = renderer.root.findByType( 'div' );
expect( container.props.minPriceConstraint ).toBe( 10 );
renderer.update( <TestComponent price={ 20 } /> );
expect( container.props.minPriceConstraint ).toBe( 20 );
} );
it( 'previous price constraint should be preserved when new price is not a infinite number', () => {
const renderer = TestRenderer.create( <TestComponent price={ 10 } /> );
const container = renderer.root.findByType( 'div' );
expect( container.props.maxPriceConstraint ).toBe( 10 );
renderer.update( <TestComponent price={ Infinity } /> );
expect( container.props.maxPriceConstraint ).toBe( 10 );
} );
it( 'max price constraint should be higher if the price is decimal', () => {
const renderer = TestRenderer.create(
<TestComponent price={ 10.99 } />
);
const container = renderer.root.findByType( 'div' );
expect( container.props.maxPriceConstraint ).toBe( 20 );
renderer.update( <TestComponent price={ 19.99 } /> );
expect( container.props.maxPriceConstraint ).toBe( 20 );
} );
it( 'min price constraint should be lower if the price is decimal', () => {
const renderer = TestRenderer.create(
<TestComponent price={ 9.99 } />
);
const container = renderer.root.findByType( 'div' );
expect( container.props.minPriceConstraint ).toBe( 0 );
renderer.update( <TestComponent price={ 19.99 } /> );
expect( container.props.minPriceConstraint ).toBe( 10 );
} );
} );

View File

@@ -0,0 +1,42 @@
/**
* External dependencies
*/
import { usePrevious } from '@woocommerce/base-hooks';
/**
* Internal dependencies
*/
import { ROUND_UP, ROUND_DOWN } from './constants';
/**
* Return the price constraint.
*
* @param {number} price Price in minor unit, e.g. cents.
* @param {ROUND_UP|ROUND_DOWN} direction Rounding flag whether we round up or down.
*/
export const usePriceConstraint = ( price, direction ) => {
let currentConstraint;
if ( direction === ROUND_UP ) {
currentConstraint = isNaN( price )
? null
: Math.ceil( parseFloat( price, 10 ) / 10 ) * 10;
} else if ( direction === ROUND_DOWN ) {
currentConstraint = isNaN( price )
? null
: Math.floor( parseFloat( price, 10 ) / 10 ) * 10;
}
const previousConstraint = usePrevious( currentConstraint, ( val ) =>
Number.isFinite( val )
);
return Number.isFinite( currentConstraint )
? currentConstraint
: previousConstraint;
};
export default ( { minPrice, maxPrice } ) => {
return {
minConstraint: usePriceConstraint( minPrice, ROUND_DOWN ),
maxConstraint: usePriceConstraint( maxPrice, ROUND_UP ),
};
};

View File

@@ -0,0 +1,112 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Component, Fragment } from '@wordpress/element';
import { Disabled, PanelBody } from '@wordpress/components';
import { InspectorControls, ServerSideRender } from '@wordpress/editor';
import PropTypes from 'prop-types';
import GridContentControl from '@woocommerce/block-components/grid-content-control';
import GridLayoutControl from '@woocommerce/block-components/grid-layout-control';
import ProductCategoryControl from '@woocommerce/block-components/product-category-control';
import { gridBlockPreview } from '@woocommerce/resource-previews';
/**
* Component to handle edit mode of "Best Selling Products".
*/
class ProductBestSellersBlock extends Component {
getInspectorControls() {
const { attributes, setAttributes } = this.props;
const {
categories,
catOperator,
columns,
contentVisibility,
rows,
alignButtons,
} = attributes;
return (
<InspectorControls key="inspector">
<PanelBody
title={ __( 'Layout', 'woocommerce' ) }
initialOpen
>
<GridLayoutControl
columns={ columns }
rows={ rows }
alignButtons={ alignButtons }
setAttributes={ setAttributes }
/>
</PanelBody>
<PanelBody
title={ __( 'Content', 'woocommerce' ) }
initialOpen
>
<GridContentControl
settings={ contentVisibility }
onChange={ ( value ) =>
setAttributes( { contentVisibility: value } )
}
/>
</PanelBody>
<PanelBody
title={ __(
'Filter by Product Category',
'woocommerce'
) }
initialOpen={ false }
>
<ProductCategoryControl
selected={ categories }
onChange={ ( value = [] ) => {
const ids = value.map( ( { id } ) => id );
setAttributes( { categories: ids } );
} }
operator={ catOperator }
onOperatorChange={ ( value = 'any' ) =>
setAttributes( { catOperator: value } )
}
/>
</PanelBody>
</InspectorControls>
);
}
render() {
const { attributes, name } = this.props;
if ( attributes.isPreview ) {
return gridBlockPreview;
}
return (
<Fragment>
{ this.getInspectorControls() }
<Disabled>
<ServerSideRender
block={ name }
attributes={ attributes }
/>
</Disabled>
</Fragment>
);
}
}
ProductBestSellersBlock.propTypes = {
/**
* The attributes for this block
*/
attributes: PropTypes.object.isRequired,
/**
* The register block name.
*/
name: PropTypes.string.isRequired,
/**
* A callback to update attributes
*/
setAttributes: PropTypes.func.isRequired,
};
export default ProductBestSellersBlock;

View File

@@ -0,0 +1,80 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { without } from 'lodash';
import Gridicon from 'gridicons';
import { createBlock, registerBlockType } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import Block from './block';
import { deprecatedConvertToShortcode } from '../../utils/deprecations';
import sharedAttributes, {
sharedAttributeBlockTypes,
} from '../../utils/shared-attributes';
registerBlockType( 'woocommerce/product-best-sellers', {
title: __( 'Best Selling Products', 'woocommerce' ),
icon: {
src: <Gridicon icon="stats-up-alt" />,
foreground: '#96588a',
},
category: 'woocommerce',
keywords: [ __( 'WooCommerce', 'woocommerce' ) ],
description: __(
'Display a grid of your all-time best selling products.',
'woocommerce'
),
supports: {
align: [ 'wide', 'full' ],
html: false,
},
example: {
attributes: {
isPreview: true,
},
},
attributes: {
...sharedAttributes,
},
transforms: {
from: [
{
type: 'block',
blocks: without(
sharedAttributeBlockTypes,
'woocommerce/product-best-sellers'
),
transform: ( attributes ) =>
createBlock(
'woocommerce/product-best-sellers',
attributes
),
},
],
},
deprecated: [
{
// Deprecate shortcode save method in favor of dynamic rendering.
attributes: sharedAttributes,
save: deprecatedConvertToShortcode(
'woocommerce/product-best-sellers'
),
},
],
/**
* Renders and manages the block.
*/
edit( props ) {
return <Block { ...props } />;
},
save() {
return null;
},
} );

View File

@@ -0,0 +1,174 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Fragment } from 'react';
import { InspectorControls, ServerSideRender } from '@wordpress/editor';
import PropTypes from 'prop-types';
import { PanelBody, ToggleControl, Placeholder } from '@wordpress/components';
import { IconFolder } from '@woocommerce/block-components/icons';
import ToggleButtonControl from '@woocommerce/block-components/toggle-button-control';
const EmptyPlaceHolder = () => (
<Placeholder
icon={ <IconFolder /> }
label={ __(
'Product Categories List',
'woocommerce'
) }
className="wc-block-product-categories"
>
{ __(
"This block shows product categories for your store. To use it, you'll first need to create a product and assign it to a category.",
'woocommerce'
) }
</Placeholder>
);
/**
* Component displaying the categories as dropdown or list.
*/
const ProductCategoriesBlock = ( { attributes, setAttributes, name } ) => {
const getInspectorControls = () => {
const { hasCount, hasEmpty, isDropdown, isHierarchical } = attributes;
return (
<InspectorControls key="inspector">
<PanelBody
title={ __( 'Content', 'woocommerce' ) }
initialOpen
>
<ToggleControl
label={ __(
'Show product count',
'woocommerce'
) }
help={
hasCount
? __(
'Product count is visible.',
'woocommerce'
)
: __(
'Product count is hidden.',
'woocommerce'
)
}
checked={ hasCount }
onChange={ () =>
setAttributes( { hasCount: ! hasCount } )
}
/>
<ToggleControl
label={ __(
'Show hierarchy',
'woocommerce'
) }
help={
isHierarchical
? __(
'Hierarchy is visible.',
'woocommerce'
)
: __(
'Hierarchy is hidden.',
'woocommerce'
)
}
checked={ isHierarchical }
onChange={ () =>
setAttributes( {
isHierarchical: ! isHierarchical,
} )
}
/>
<ToggleControl
label={ __(
'Show empty categories',
'woocommerce'
) }
help={
hasEmpty
? __(
'Empty categories are visible.',
'woocommerce'
)
: __(
'Empty categories are hidden.',
'woocommerce'
)
}
checked={ hasEmpty }
onChange={ () =>
setAttributes( { hasEmpty: ! hasEmpty } )
}
/>
</PanelBody>
<PanelBody
title={ __(
'List Settings',
'woocommerce'
) }
initialOpen
>
<ToggleButtonControl
label={ __(
'Display style',
'woocommerce'
) }
value={ isDropdown ? 'dropdown' : 'list' }
options={ [
{
label: __(
'List',
'woocommerce'
),
value: 'list',
},
{
label: __(
'Dropdown',
'woocommerce'
),
value: 'dropdown',
},
] }
onChange={ ( value ) =>
setAttributes( {
isDropdown: value === 'dropdown',
} )
}
/>
</PanelBody>
</InspectorControls>
);
};
return (
<Fragment>
{ getInspectorControls() }
<ServerSideRender
block={ name }
attributes={ attributes }
EmptyResponsePlaceholder={ EmptyPlaceHolder }
/>
</Fragment>
);
};
ProductCategoriesBlock.propTypes = {
/**
* The attributes for this block
*/
attributes: PropTypes.object.isRequired,
/**
* The register block name.
*/
name: PropTypes.string.isRequired,
/**
* A callback to update attributes
*/
setAttributes: PropTypes.func.isRequired,
};
export default ProductCategoriesBlock;

View File

@@ -0,0 +1,9 @@
.wc-block-product-categories.wc-block-product-categories ul {
margin-left: 20px;
}
.wc-block-product-categories {
.components-placeholder__label svg {
margin-right: 1ch;
fill: currentColor;
}
}

View File

@@ -0,0 +1,164 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { registerBlockType } from '@wordpress/blocks';
import { IconFolder } from '@woocommerce/block-components/icons';
/**
* Internal dependencies
*/
import './editor.scss';
import './style.scss';
import Block from './block.js';
registerBlockType( 'woocommerce/product-categories', {
title: __( 'Product Categories List', 'woocommerce' ),
icon: {
src: <IconFolder />,
foreground: '#96588a',
},
category: 'woocommerce',
keywords: [ __( 'WooCommerce', 'woocommerce' ) ],
description: __(
'Show your product categories as a list or dropdown.',
'woocommerce'
),
supports: {
align: [ 'wide', 'full' ],
},
example: {
attributes: {
hasCount: true,
},
},
attributes: {
/**
* Whether to show the product count in each category.
*/
hasCount: {
type: 'boolean',
default: true,
},
/**
* Whether to show empty categories in the list.
*/
hasEmpty: {
type: 'boolean',
default: false,
},
/**
* Whether to display product categories as a dropdown (true) or list (false).
*/
isDropdown: {
type: 'boolean',
default: false,
},
/**
* Whether the product categories should display with hierarchy.
*/
isHierarchical: {
type: 'boolean',
default: true,
},
},
deprecated: [
{
// Deprecate HTML save method in favor of dynamic rendering.
attributes: {
hasCount: {
type: 'boolean',
default: true,
source: 'attribute',
selector: 'div',
attribute: 'data-has-count',
},
hasEmpty: {
type: 'boolean',
default: false,
source: 'attribute',
selector: 'div',
attribute: 'data-has-empty',
},
isDropdown: {
type: 'boolean',
default: false,
source: 'attribute',
selector: 'div',
attribute: 'data-is-dropdown',
},
isHierarchical: {
type: 'boolean',
default: true,
source: 'attribute',
selector: 'div',
attribute: 'data-is-hierarchical',
},
},
migrate( attributes ) {
return attributes;
},
save( props ) {
const {
hasCount,
hasEmpty,
isDropdown,
isHierarchical,
} = props.attributes;
const data = {};
if ( hasCount ) {
data[ 'data-has-count' ] = true;
}
if ( hasEmpty ) {
data[ 'data-has-empty' ] = true;
}
if ( isDropdown ) {
data[ 'data-is-dropdown' ] = true;
}
if ( isHierarchical ) {
data[ 'data-is-hierarchical' ] = true;
}
return (
<div className="is-loading" { ...data }>
{ isDropdown ? (
<span
aria-hidden
className="wc-block-product-categories__placeholder"
/>
) : (
<ul aria-hidden>
<li>
<span className="wc-block-product-categories__placeholder" />
</li>
<li>
<span className="wc-block-product-categories__placeholder" />
</li>
<li>
<span className="wc-block-product-categories__placeholder" />
</li>
</ul>
) }
</div>
);
},
},
],
/**
* Renders and manages the block.
*/
edit( props ) {
return <Block { ...props } />;
},
/**
* Save nothing; rendered by server.
*/
save() {
return null;
},
} );

View File

@@ -0,0 +1,81 @@
.wc-block-product-categories {
margin-bottom: 1em;
&.is-dropdown {
display: flex;
}
select {
margin-right: 0.5em;
}
}
.wc-block-product-categories-list-item-count::before {
content: " (";
}
.wc-block-product-categories-list-item-count::after {
content: ")";
}
.wp-block-woocommerce-product-categories.is-loading .wc-block-product-categories__placeholder {
display: inline-block;
height: 1em;
width: 50%;
min-width: 200px;
background: currentColor;
opacity: 0.2;
}
.wc-block-product-categories__button {
display: flex;
align-items: center;
text-decoration: none;
font-size: 13px;
margin: 0;
border: none;
cursor: pointer;
background: none;
padding: 8px;
color: #555d66;
position: relative;
overflow: hidden;
border-radius: 4px;
svg {
fill: currentColor;
outline: none;
.rtl & {
transform: rotate(180deg);
}
}
&:active {
color: currentColor;
}
&:disabled,
&[aria-disabled="true"] {
cursor: default;
opacity: 0.3;
}
&:focus:enabled {
background-color: #fff;
color: #191e23;
box-shadow: inset 0 0 0 1px #6c7781, inset 0 0 0 2px #fff;
outline: 2px solid transparent;
outline-offset: -2px;
}
&:not(:disabled):not([aria-disabled="true"]):hover {
background-color: #fff;
color: #191e23;
box-shadow: inset 0 0 0 1px #e2e4e7, inset 0 0 0 2px #fff, 0 1px 1px rgba(25, 30, 35, 0.2);
}
&:not(:disabled):not([aria-disabled="true"]):active {
outline: none;
background-color: #fff;
color: #191e23;
box-shadow: inset 0 0 0 1px #ccd0d4, inset 0 0 0 2px #fff;
}
&[aria-disabled="true"]:focus,
&:disabled:focus {
box-shadow: none;
}
}

View File

@@ -0,0 +1,302 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import {
BlockControls,
InspectorControls,
ServerSideRender,
} from '@wordpress/editor';
import {
Button,
Disabled,
PanelBody,
Placeholder,
Toolbar,
withSpokenMessages,
} from '@wordpress/components';
import { Component, Fragment } from '@wordpress/element';
import PropTypes from 'prop-types';
import GridContentControl from '@woocommerce/block-components/grid-content-control';
import GridLayoutControl from '@woocommerce/block-components/grid-layout-control';
import ProductCategoryControl from '@woocommerce/block-components/product-category-control';
import ProductOrderbyControl from '@woocommerce/block-components/product-orderby-control';
import { gridBlockPreview } from '@woocommerce/resource-previews';
/**
* Component to handle edit mode of "Products by Category".
*/
class ProductByCategoryBlock extends Component {
static propTypes = {
/**
* The attributes for this block
*/
attributes: PropTypes.object.isRequired,
/**
* The register block name.
*/
name: PropTypes.string.isRequired,
/**
* A callback to update attributes
*/
setAttributes: PropTypes.func.isRequired,
// from withSpokenMessages
debouncedSpeak: PropTypes.func.isRequired,
};
state = {
changedAttributes: {},
isEditing: false,
};
componentDidMount() {
const { attributes } = this.props;
if ( ! attributes.categories.length ) {
// We've removed all selected categories, or no categories have been selected yet.
this.setState( { isEditing: true } );
}
}
startEditing = () => {
this.setState( {
isEditing: true,
changedAttributes: {},
} );
};
stopEditing = () => {
this.setState( {
isEditing: false,
changedAttributes: {},
} );
};
setChangedAttributes = ( attributes ) => {
this.setState( ( prevState ) => {
return {
changedAttributes: {
...prevState.changedAttributes,
...attributes,
},
};
} );
};
save = () => {
const { changedAttributes } = this.state;
const { setAttributes } = this.props;
setAttributes( changedAttributes );
this.stopEditing();
};
getInspectorControls() {
const { attributes, setAttributes } = this.props;
const { isEditing } = this.state;
const {
columns,
catOperator,
contentVisibility,
orderby,
rows,
alignButtons,
} = attributes;
return (
<InspectorControls key="inspector">
<PanelBody
title={ __(
'Product Category',
'woocommerce'
) }
initialOpen={
! attributes.categories.length && ! isEditing
}
>
<ProductCategoryControl
selected={ attributes.categories }
onChange={ ( value = [] ) => {
const ids = value.map( ( { id } ) => id );
const changes = { categories: ids };
// Changes in the sidebar save instantly and overwrite any unsaved changes.
setAttributes( changes );
this.setChangedAttributes( changes );
} }
operator={ catOperator }
onOperatorChange={ ( value = 'any' ) => {
const changes = { catOperator: value };
setAttributes( changes );
this.setChangedAttributes( changes );
} }
/>
</PanelBody>
<PanelBody
title={ __( 'Layout', 'woocommerce' ) }
initialOpen
>
<GridLayoutControl
columns={ columns }
rows={ rows }
alignButtons={ alignButtons }
setAttributes={ setAttributes }
/>
</PanelBody>
<PanelBody
title={ __( 'Content', 'woocommerce' ) }
initialOpen
>
<GridContentControl
settings={ contentVisibility }
onChange={ ( value ) =>
setAttributes( { contentVisibility: value } )
}
/>
</PanelBody>
<PanelBody
title={ __( 'Order By', 'woocommerce' ) }
initialOpen={ false }
>
<ProductOrderbyControl
setAttributes={ setAttributes }
value={ orderby }
/>
</PanelBody>
</InspectorControls>
);
}
renderEditMode() {
const { attributes, debouncedSpeak } = this.props;
const { changedAttributes } = this.state;
const currentAttributes = { ...attributes, ...changedAttributes };
const onDone = () => {
this.save();
debouncedSpeak(
__(
'Showing Products by Category block preview.',
'woocommerce'
)
);
};
const onCancel = () => {
this.stopEditing();
debouncedSpeak(
__(
'Showing Products by Category block preview.',
'woocommerce'
)
);
};
return (
<Placeholder
icon="category"
label={ __(
'Products by Category',
'woocommerce'
) }
className="wc-block-products-grid wc-block-products-category"
>
{ __(
'Display a grid of products from your selected categories.',
'woocommerce'
) }
<div className="wc-block-products-category__selection">
<ProductCategoryControl
selected={ currentAttributes.categories }
onChange={ ( value = [] ) => {
const ids = value.map( ( { id } ) => id );
this.setChangedAttributes( { categories: ids } );
} }
operator={ currentAttributes.catOperator }
onOperatorChange={ ( value = 'any' ) =>
this.setChangedAttributes( { catOperator: value } )
}
/>
<Button isDefault onClick={ onDone }>
{ __( 'Done', 'woocommerce' ) }
</Button>
<Button
className="wc-block-products-category__cancel-button"
isTertiary
onClick={ onCancel }
>
{ __( 'Cancel', 'woocommerce' ) }
</Button>
</div>
</Placeholder>
);
}
renderViewMode() {
const { attributes, name } = this.props;
const hasCategories = attributes.categories.length;
return (
<Disabled>
{ hasCategories ? (
<ServerSideRender
block={ name }
attributes={ attributes }
EmptyResponsePlaceholder={ () => (
<Placeholder
icon="category"
label={ __(
'Products by Category',
'woocommerce'
) }
className="wc-block-products-grid wc-block-products-category"
>
{ __(
'No products were found that matched your selection.',
'woocommerce'
) }
</Placeholder>
) }
/>
) : (
__(
'Select at least one category to display its products.',
'woocommerce'
)
) }
</Disabled>
);
}
render() {
const { isEditing } = this.state;
const { attributes } = this.props;
if ( attributes.isPreview ) {
return gridBlockPreview;
}
return (
<Fragment>
<BlockControls>
<Toolbar
controls={ [
{
icon: 'edit',
title: __( 'Edit' ),
onClick: () =>
isEditing
? this.stopEditing()
: this.startEditing(),
isActive: isEditing,
},
] }
/>
</BlockControls>
{ this.getInspectorControls() }
{ isEditing ? this.renderEditMode() : this.renderViewMode() }
</Fragment>
);
}
}
export default withSpokenMessages( ProductByCategoryBlock );

View File

@@ -0,0 +1,9 @@
.wc-block-products-category__selection {
width: 100%;
}
.wc-block-products-category__cancel-button.is-tertiary {
margin: 1em auto 0;
display: block;
text-align: center;
font-size: 1em;
}

View File

@@ -0,0 +1,109 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { createBlock, registerBlockType } from '@wordpress/blocks';
import { without } from 'lodash';
/**
* Internal dependencies
*/
import './editor.scss';
import Block from './block';
import { deprecatedConvertToShortcode } from '../../utils/deprecations';
import sharedAttributes, {
sharedAttributeBlockTypes,
} from '../../utils/shared-attributes';
/**
* Register and run the "Products by Category" block.
*/
registerBlockType( 'woocommerce/product-category', {
title: __( 'Products by Category', 'woocommerce' ),
icon: {
src: 'category',
foreground: '#96588a',
},
category: 'woocommerce',
keywords: [ __( 'WooCommerce', 'woocommerce' ) ],
description: __(
'Display a grid of products from your selected categories.',
'woocommerce'
),
supports: {
align: [ 'wide', 'full' ],
html: false,
},
example: {
attributes: {
isPreview: true,
},
},
attributes: {
...sharedAttributes,
/**
* Toggle for edit mode in the block preview.
*/
editMode: {
type: 'boolean',
default: true,
},
/**
* How to order the products: 'date', 'popularity', 'price_asc', 'price_desc' 'rating', 'title'.
*/
orderby: {
type: 'string',
default: 'date',
},
},
transforms: {
from: [
{
type: 'block',
blocks: without(
sharedAttributeBlockTypes,
'woocommerce/product-category'
),
transform: ( attributes ) =>
createBlock( 'woocommerce/product-category', {
...attributes,
editMode: false,
} ),
},
],
},
deprecated: [
{
// Deprecate shortcode save method in favor of dynamic rendering.
attributes: {
...sharedAttributes,
editMode: {
type: 'boolean',
default: true,
},
orderby: {
type: 'string',
default: 'date',
},
},
save: deprecatedConvertToShortcode(
'woocommerce/product-category'
),
},
],
/**
* Renders and manages the block.
*/
edit( props ) {
return <Block { ...props } />;
},
save() {
return null;
},
} );

View File

@@ -0,0 +1,112 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Component, Fragment } from '@wordpress/element';
import { Disabled, PanelBody } from '@wordpress/components';
import { InspectorControls, ServerSideRender } from '@wordpress/editor';
import PropTypes from 'prop-types';
import GridContentControl from '@woocommerce/block-components/grid-content-control';
import GridLayoutControl from '@woocommerce/block-components/grid-layout-control';
import ProductCategoryControl from '@woocommerce/block-components/product-category-control';
import { gridBlockPreview } from '@woocommerce/resource-previews';
/**
* Component to handle edit mode of "Newest Products".
*/
class ProductNewestBlock extends Component {
getInspectorControls() {
const { attributes, setAttributes } = this.props;
const {
categories,
catOperator,
columns,
contentVisibility,
rows,
alignButtons,
} = attributes;
return (
<InspectorControls key="inspector">
<PanelBody
title={ __( 'Layout', 'woocommerce' ) }
initialOpen
>
<GridLayoutControl
columns={ columns }
rows={ rows }
alignButtons={ alignButtons }
setAttributes={ setAttributes }
/>
</PanelBody>
<PanelBody
title={ __( 'Content', 'woocommerce' ) }
initialOpen
>
<GridContentControl
settings={ contentVisibility }
onChange={ ( value ) =>
setAttributes( { contentVisibility: value } )
}
/>
</PanelBody>
<PanelBody
title={ __(
'Filter by Product Category',
'woocommerce'
) }
initialOpen={ false }
>
<ProductCategoryControl
selected={ categories }
onChange={ ( value = [] ) => {
const ids = value.map( ( { id } ) => id );
setAttributes( { categories: ids } );
} }
operator={ catOperator }
onOperatorChange={ ( value = 'any' ) =>
setAttributes( { catOperator: value } )
}
/>
</PanelBody>
</InspectorControls>
);
}
render() {
const { attributes, name } = this.props;
if ( attributes.isPreview ) {
return gridBlockPreview;
}
return (
<Fragment>
{ this.getInspectorControls() }
<Disabled>
<ServerSideRender
block={ name }
attributes={ attributes }
/>
</Disabled>
</Fragment>
);
}
}
ProductNewestBlock.propTypes = {
/**
* The attributes for this block
*/
attributes: PropTypes.object.isRequired,
/**
* The register block name.
*/
name: PropTypes.string.isRequired,
/**
* A callback to update attributes
*/
setAttributes: PropTypes.func.isRequired,
};
export default ProductNewestBlock;

View File

@@ -0,0 +1,74 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { createBlock, registerBlockType } from '@wordpress/blocks';
import { without } from 'lodash';
import { IconNewReleases } from '@woocommerce/block-components/icons';
/**
* Internal dependencies
*/
import Block from './block';
import { deprecatedConvertToShortcode } from '../../utils/deprecations';
import sharedAttributes, {
sharedAttributeBlockTypes,
} from '../../utils/shared-attributes';
registerBlockType( 'woocommerce/product-new', {
title: __( 'Newest Products', 'woocommerce' ),
icon: {
src: <IconNewReleases />,
foreground: '#96588a',
},
category: 'woocommerce',
keywords: [ __( 'WooCommerce', 'woocommerce' ) ],
description: __(
'Display a grid of your newest products.',
'woocommerce'
),
supports: {
align: [ 'wide', 'full' ],
html: false,
},
attributes: {
...sharedAttributes,
},
example: {
attributes: {
isPreview: true,
},
},
transforms: {
from: [
{
type: 'block',
blocks: without(
sharedAttributeBlockTypes,
'woocommerce/product-new'
),
transform: ( attributes ) =>
createBlock( 'woocommerce/product-new', attributes ),
},
],
},
deprecated: [
{
// Deprecate shortcode save method in favor of dynamic rendering.
attributes: sharedAttributes,
save: deprecatedConvertToShortcode( 'woocommerce/product-new' ),
},
],
/**
* Renders and manages the block.
*/
edit( props ) {
return <Block { ...props } />;
},
save() {
return null;
},
} );

View File

@@ -0,0 +1,123 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Component, Fragment } from '@wordpress/element';
import { Disabled, PanelBody } from '@wordpress/components';
import { InspectorControls, ServerSideRender } from '@wordpress/editor';
import PropTypes from 'prop-types';
import GridContentControl from '@woocommerce/block-components/grid-content-control';
import GridLayoutControl from '@woocommerce/block-components/grid-layout-control';
import ProductCategoryControl from '@woocommerce/block-components/product-category-control';
import ProductOrderbyControl from '@woocommerce/block-components/product-orderby-control';
import { gridBlockPreview } from '@woocommerce/resource-previews';
/**
* Component to handle edit mode of "On Sale Products".
*/
class ProductOnSaleBlock extends Component {
getInspectorControls() {
const { attributes, setAttributes } = this.props;
const {
categories,
catOperator,
columns,
contentVisibility,
rows,
orderby,
alignButtons,
} = attributes;
return (
<InspectorControls key="inspector">
<PanelBody
title={ __( 'Layout', 'woocommerce' ) }
initialOpen
>
<GridLayoutControl
columns={ columns }
rows={ rows }
alignButtons={ alignButtons }
setAttributes={ setAttributes }
/>
</PanelBody>
<PanelBody
title={ __( 'Content', 'woocommerce' ) }
initialOpen
>
<GridContentControl
settings={ contentVisibility }
onChange={ ( value ) =>
setAttributes( { contentVisibility: value } )
}
/>
</PanelBody>
<PanelBody
title={ __( 'Order By', 'woocommerce' ) }
initialOpen={ false }
>
<ProductOrderbyControl
setAttributes={ setAttributes }
value={ orderby }
/>
</PanelBody>
<PanelBody
title={ __(
'Filter by Product Category',
'woocommerce'
) }
initialOpen={ false }
>
<ProductCategoryControl
selected={ categories }
onChange={ ( value = [] ) => {
const ids = value.map( ( { id } ) => id );
setAttributes( { categories: ids } );
} }
operator={ catOperator }
onOperatorChange={ ( value = 'any' ) =>
setAttributes( { catOperator: value } )
}
/>
</PanelBody>
</InspectorControls>
);
}
render() {
const { attributes, name } = this.props;
if ( attributes.isPreview ) {
return gridBlockPreview;
}
return (
<Fragment>
{ this.getInspectorControls() }
<Disabled>
<ServerSideRender
block={ name }
attributes={ attributes }
/>
</Disabled>
</Fragment>
);
}
}
ProductOnSaleBlock.propTypes = {
/**
* The attributes for this block
*/
attributes: PropTypes.object.isRequired,
/**
* The register block name.
*/
name: PropTypes.string.isRequired,
/**
* A callback to update attributes
*/
setAttributes: PropTypes.func.isRequired,
};
export default ProductOnSaleBlock;

View File

@@ -0,0 +1,88 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { createBlock, registerBlockType } from '@wordpress/blocks';
import { without } from 'lodash';
import { IconProductOnSale } from '@woocommerce/block-components/icons';
/**
* Internal dependencies
*/
import Block from './block';
import { deprecatedConvertToShortcode } from '../../utils/deprecations';
import sharedAttributes, {
sharedAttributeBlockTypes,
} from '../../utils/shared-attributes';
registerBlockType( 'woocommerce/product-on-sale', {
title: __( 'On Sale Products', 'woocommerce' ),
icon: {
src: <IconProductOnSale />,
foreground: '#96588a',
},
category: 'woocommerce',
keywords: [ __( 'WooCommerce', 'woocommerce' ) ],
description: __(
'Display a grid of on sale products.',
'woocommerce'
),
supports: {
align: [ 'wide', 'full' ],
html: false,
},
attributes: {
...sharedAttributes,
/**
* How to order the products: 'date', 'popularity', 'price_asc', 'price_desc' 'rating', 'title'.
*/
orderby: {
type: 'string',
default: 'date',
},
},
example: {
attributes: {
isPreview: true,
},
},
transforms: {
from: [
{
type: 'block',
blocks: without(
sharedAttributeBlockTypes,
'woocommerce/product-on-sale'
),
transform: ( attributes ) =>
createBlock( 'woocommerce/product-on-sale', attributes ),
},
],
},
deprecated: [
{
// Deprecate shortcode save method in favor of dynamic rendering.
attributes: {
...sharedAttributes,
orderby: {
type: 'string',
default: 'date',
},
},
save: deprecatedConvertToShortcode( 'woocommerce/product-on-sale' ),
},
],
/**
* Renders and manages the block.
*/
edit( props ) {
return <Block { ...props } />;
},
save() {
return null;
},
} );

View File

@@ -0,0 +1,182 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import classnames from 'classnames';
import { Component } from '@wordpress/element';
import PropTypes from 'prop-types';
import { withInstanceId, compose } from '@wordpress/compose';
import { PlainText } from '@wordpress/editor';
import { HOME_URL } from '@woocommerce/block-settings';
/**
* Internal dependencies
*/
import './editor.scss';
import './style.scss';
/**
* Component displaying a product search form.
*/
class ProductSearchBlock extends Component {
renderView() {
const {
attributes: {
label,
placeholder,
formId,
className,
hasLabel,
align,
},
} = this.props;
const classes = classnames(
'wc-block-product-search',
align ? 'align' + align : '',
className
);
return (
<div className={ classes }>
<form role="search" method="get" action={ HOME_URL }>
<label
htmlFor={ formId }
className={
hasLabel
? 'wc-block-product-search__label'
: 'wc-block-product-search__label screen-reader-text'
}
>
{ label }
</label>
<div className="wc-block-product-search__fields">
<input
type="search"
id={ formId }
className="wc-block-product-search__field"
placeholder={ placeholder }
name="s"
/>
<input type="hidden" name="post_type" value="product" />
<button
type="submit"
className="wc-block-product-search__button"
label={ __(
'Search',
'woocommerce'
) }
>
<svg
aria-hidden="true"
role="img"
focusable="false"
className="dashicon dashicons-arrow-right-alt2"
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 20 20"
>
<path d="M6 15l5-5-5-5 1-2 7 7-7 7z" />
</svg>
</button>
</div>
</form>
</div>
);
}
renderEdit() {
const { attributes, setAttributes, instanceId } = this.props;
const {
label,
placeholder,
formId,
className,
hasLabel,
align,
} = attributes;
const classes = classnames(
'wc-block-product-search',
align ? 'align' + align : '',
className
);
if ( ! formId ) {
setAttributes( {
formId: `wc-block-product-search-${ instanceId }`,
} );
}
return (
<div className={ classes }>
{ !! hasLabel && (
<PlainText
className="wc-block-product-search__label"
value={ label }
onChange={ ( value ) =>
setAttributes( { label: value } )
}
/>
) }
<div className="wc-block-product-search__fields">
<PlainText
className="wc-block-product-search__field input-control"
value={ placeholder }
onChange={ ( value ) =>
setAttributes( { placeholder: value } )
}
/>
<button
type="submit"
className="wc-block-product-search__button"
label={ __( 'Search', 'woocommerce' ) }
onClick={ ( e ) => e.preventDefault() }
tabIndex="-1"
>
<svg
aria-hidden="true"
role="img"
focusable="false"
className="dashicon dashicons-arrow-right-alt2"
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 20 20"
>
<path d="M6 15l5-5-5-5 1-2 7 7-7 7z" />
</svg>
</button>
</div>
</div>
);
}
render() {
if ( this.props.isEditor ) {
return this.renderEdit();
}
return this.renderView();
}
}
ProductSearchBlock.propTypes = {
/**
* The attributes for this block.
*/
attributes: PropTypes.object.isRequired,
/**
* A unique ID for identifying the label for the select dropdown.
*/
instanceId: PropTypes.number,
/**
* Whether it's in the editor or frontend display.
*/
isEditor: PropTypes.bool,
/**
* A callback to update attributes.
*/
setAttributes: PropTypes.func,
};
export default compose( [ withInstanceId ] )( ProductSearchBlock );

View File

@@ -0,0 +1,10 @@
.wc-block-product-search__field.input-control {
color: #828b96 !important;
}
.wc-block-product-search {
.wc-block-product-search__fields {
.block-editor-rich-text {
flex-grow: 1;
}
}
}

View File

@@ -0,0 +1,131 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { registerBlockType } from '@wordpress/blocks';
import { InspectorControls } from '@wordpress/editor';
import { PanelBody, ToggleControl } from '@wordpress/components';
import { Fragment } from '@wordpress/element';
import { IconProductSearch } from '@woocommerce/block-components/icons';
/**
* Internal dependencies
*/
import './style.scss';
import './editor.scss';
import Block from './block.js';
registerBlockType( 'woocommerce/product-search', {
title: __( 'Product Search', 'woocommerce' ),
icon: {
src: <IconProductSearch />,
foreground: '#96588a',
},
category: 'woocommerce',
keywords: [ __( 'WooCommerce', 'woocommerce' ) ],
description: __(
'Help visitors find your products.',
'woocommerce'
),
supports: {
align: [ 'wide', 'full' ],
},
example: {
attributes: {
hasLabel: true,
},
},
attributes: {
/**
* Whether to show the field label.
*/
hasLabel: {
type: 'boolean',
default: true,
},
/**
* Search field label.
*/
label: {
type: 'string',
default: __( 'Search', 'woocommerce' ),
source: 'text',
selector: 'label',
},
/**
* Search field placeholder.
*/
placeholder: {
type: 'string',
default: __( 'Search products...', 'woocommerce' ),
source: 'attribute',
selector: 'input.wc-block-product-search__field',
attribute: 'placeholder',
},
/**
* Store the instance ID.
*/
formId: {
type: 'string',
default: '',
},
},
/**
* Renders and manages the block.
*/
edit( props ) {
const { attributes, setAttributes } = props;
const { hasLabel } = attributes;
return (
<Fragment>
<InspectorControls key="inspector">
<PanelBody
title={ __(
'Content',
'woocommerce'
) }
initialOpen
>
<ToggleControl
label={ __(
'Show search field label',
'woocommerce'
) }
help={
hasLabel
? __(
'Label is visible.',
'woocommerce'
)
: __(
'Label is hidden.',
'woocommerce'
)
}
checked={ hasLabel }
onChange={ () =>
setAttributes( { hasLabel: ! hasLabel } )
}
/>
</PanelBody>
</InspectorControls>
<Block { ...props } isEditor={ true } />
</Fragment>
);
},
/**
* Save the props to post content.
*/
save( attributes ) {
return (
<div>
<Block { ...attributes } />
</div>
);
},
} );

View File

@@ -0,0 +1,62 @@
.wc-block-product-search {
.wc-block-product-search__fields {
display: flex;
}
.wc-block-product-search__field {
padding: 6px 8px;
line-height: 1.8;
flex-grow: 1;
}
.wc-block-product-search__button {
display: flex;
align-items: center;
text-decoration: none;
font-size: 13px;
margin: 0 0 0 6px;
border: none;
cursor: pointer;
background: none;
padding: 8px;
color: #555d66;
position: relative;
overflow: hidden;
border-radius: 4px;
svg {
fill: currentColor;
outline: none;
.rtl & {
transform: rotate(180deg);
}
}
&:active {
color: currentColor;
}
&:disabled,
&[aria-disabled="true"] {
cursor: default;
opacity: 0.3;
}
&:focus:enabled {
background-color: #fff;
color: #191e23;
box-shadow: inset 0 0 0 1px #6c7781, inset 0 0 0 2px #fff;
outline: 2px solid transparent;
outline-offset: -2px;
}
&:not(:disabled):not([aria-disabled="true"]):hover {
background-color: #fff;
color: #191e23;
box-shadow: inset 0 0 0 1px #e2e4e7, inset 0 0 0 2px #fff, 0 1px 1px rgba(25, 30, 35, 0.2);
}
&:not(:disabled):not([aria-disabled="true"]):active {
outline: none;
background-color: #fff;
color: #191e23;
box-shadow: inset 0 0 0 1px #ccd0d4, inset 0 0 0 2px #fff;
}
&[aria-disabled="true"]:focus,
&:disabled:focus {
box-shadow: none;
}
}
}

View File

@@ -0,0 +1,323 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import {
BlockControls,
InspectorControls,
ServerSideRender,
} from '@wordpress/editor';
import {
Button,
Disabled,
PanelBody,
Placeholder,
Toolbar,
withSpokenMessages,
} from '@wordpress/components';
import { Component, Fragment } from '@wordpress/element';
import PropTypes from 'prop-types';
import { HAS_TAGS } from '@woocommerce/block-settings';
import GridContentControl from '@woocommerce/block-components/grid-content-control';
import GridLayoutControl from '@woocommerce/block-components/grid-layout-control';
import ProductTagControl from '@woocommerce/block-components/product-tag-control';
import ProductOrderbyControl from '@woocommerce/block-components/product-orderby-control';
import { IconProductTag } from '@woocommerce/block-components/icons';
import { gridBlockPreview } from '@woocommerce/resource-previews';
/**
* Component to handle edit mode of "Products by Tag".
*/
class ProductsByTagBlock extends Component {
constructor() {
super( ...arguments );
this.state = {
changedAttributes: {},
isEditing: false,
};
this.startEditing = this.startEditing.bind( this );
this.stopEditing = this.stopEditing.bind( this );
this.setChangedAttributes = this.setChangedAttributes.bind( this );
this.save = this.save.bind( this );
}
componentDidMount() {
const { attributes } = this.props;
if ( ! attributes.tags.length ) {
// We've removed all selected categories, or no categories have been selected yet.
this.setState( { isEditing: true } );
}
}
startEditing() {
this.setState( {
isEditing: true,
changedAttributes: {},
} );
}
stopEditing() {
this.setState( {
isEditing: false,
changedAttributes: {},
} );
}
setChangedAttributes( attributes ) {
this.setState( ( prevState ) => {
return {
changedAttributes: {
...prevState.changedAttributes,
...attributes,
},
};
} );
}
save() {
const { changedAttributes } = this.state;
const { setAttributes } = this.props;
setAttributes( changedAttributes );
this.stopEditing();
}
getInspectorControls() {
const { attributes, setAttributes } = this.props;
const { isEditing } = this.state;
const {
columns,
tagOperator,
contentVisibility,
orderby,
rows,
alignButtons,
} = attributes;
return (
<InspectorControls key="inspector">
<PanelBody
title={ __(
'Product Tag',
'woocommerce'
) }
initialOpen={ ! attributes.tags.length && ! isEditing }
>
<ProductTagControl
selected={ attributes.tags }
onChange={ ( value = [] ) => {
const ids = value.map( ( { id } ) => id );
setAttributes( { tags: ids } );
} }
operator={ tagOperator }
onOperatorChange={ ( value = 'any' ) =>
setAttributes( { tagOperator: value } )
}
/>
</PanelBody>
<PanelBody
title={ __( 'Layout', 'woocommerce' ) }
initialOpen
>
<GridLayoutControl
columns={ columns }
rows={ rows }
alignButtons={ alignButtons }
setAttributes={ setAttributes }
/>
</PanelBody>
<PanelBody
title={ __( 'Content', 'woocommerce' ) }
initialOpen
>
<GridContentControl
settings={ contentVisibility }
onChange={ ( value ) =>
setAttributes( { contentVisibility: value } )
}
/>
</PanelBody>
<PanelBody
title={ __( 'Order By', 'woocommerce' ) }
initialOpen={ false }
>
<ProductOrderbyControl
setAttributes={ setAttributes }
value={ orderby }
/>
</PanelBody>
</InspectorControls>
);
}
renderEditMode() {
const { attributes, debouncedSpeak } = this.props;
const { changedAttributes } = this.state;
const currentAttributes = { ...attributes, ...changedAttributes };
const onDone = () => {
this.save();
debouncedSpeak(
__(
'Showing Products by Tag block preview.',
'woocommerce'
)
);
};
const onCancel = () => {
this.stopEditing();
debouncedSpeak(
__(
'Showing Products by Tag block preview.',
'woocommerce'
)
);
};
return (
<Placeholder
icon={ <IconProductTag className="block-editor-block-icon" /> }
label={ __(
'Products by Tag',
'woocommerce'
) }
className="wc-block-products-grid wc-block-product-tag"
>
{ __(
'Display a grid of products from your selected tags.',
'woocommerce'
) }
<div className="wc-block-product-tag__selection">
<ProductTagControl
selected={ currentAttributes.tags }
onChange={ ( value = [] ) => {
const ids = value.map( ( { id } ) => id );
this.setChangedAttributes( { tags: ids } );
} }
operator={ currentAttributes.tagOperator }
onOperatorChange={ ( value = 'any' ) =>
this.setChangedAttributes( { tagOperator: value } )
}
/>
<Button isDefault onClick={ onDone }>
{ __( 'Done', 'woocommerce' ) }
</Button>
<Button
className="wc-block-product-tag__cancel-button"
isTertiary
onClick={ onCancel }
>
{ __( 'Cancel', 'woocommerce' ) }
</Button>
</div>
</Placeholder>
);
}
renderViewMode() {
const { attributes, name } = this.props;
const selectedTags = attributes.tags.length;
return (
<Disabled>
{ selectedTags ? (
<ServerSideRender
block={ name }
attributes={ attributes }
/>
) : (
<Placeholder
icon={
<IconProductTag className="block-editor-block-icon" />
}
label={ __(
'Products by Tag',
'woocommerce'
) }
className="wc-block-products-grid wc-block-product-tag"
>
{ __(
'This block displays products from selected tags. Select at least one tag to display its products.',
'woocommerce'
) }
</Placeholder>
) }
</Disabled>
);
}
render() {
const { isEditing } = this.state;
const { attributes } = this.props;
if ( attributes.isPreview ) {
return gridBlockPreview;
}
return (
<Fragment>
{ HAS_TAGS ? (
<Fragment>
<BlockControls>
<Toolbar
controls={ [
{
icon: 'edit',
title: __( 'Edit' ),
onClick: () =>
isEditing
? this.stopEditing()
: this.startEditing(),
isActive: isEditing,
},
] }
/>
</BlockControls>
{ this.getInspectorControls() }
{ isEditing
? this.renderEditMode()
: this.renderViewMode() }
</Fragment>
) : (
<Placeholder
icon={
<IconProductTag className="block-editor-block-icon" />
}
label={ __(
'Products by Tag',
'woocommerce'
) }
className="wc-block-products-grid wc-block-product-tag"
>
{ __(
"This block displays products from selected tags. In order to preview this you'll first need to create a product and assign it some tags.",
'woocommerce'
) }
</Placeholder>
) }
</Fragment>
);
}
}
ProductsByTagBlock.propTypes = {
/**
* The attributes for this block
*/
attributes: PropTypes.object.isRequired,
/**
* The register block name.
*/
name: PropTypes.string.isRequired,
/**
* A callback to update attributes
*/
setAttributes: PropTypes.func.isRequired,
/**
* From withSpokenMessages
*/
debouncedSpeak: PropTypes.func.isRequired,
};
export default withSpokenMessages( ProductsByTagBlock );

View File

@@ -0,0 +1,9 @@
.wc-block-product-tag__selection {
width: 100%;
}
.wc-block-product-tag__cancel-button.is-tertiary {
margin: 1em auto 0;
display: block;
text-align: center;
font-size: 1em;
}

View File

@@ -0,0 +1,120 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { registerBlockType } from '@wordpress/blocks';
import { DEFAULT_COLUMNS, DEFAULT_ROWS } from '@woocommerce/block-settings';
import { IconProductTag } from '@woocommerce/block-components/icons';
/**
* Internal dependencies
*/
import './editor.scss';
import Block from './block';
/**
* Register and run the "Products by Tag" block.
*/
registerBlockType( 'woocommerce/product-tag', {
title: __( 'Products by Tag', 'woocommerce' ),
icon: {
src: <IconProductTag />,
foreground: '#96588a',
},
category: 'woocommerce',
keywords: [ __( 'WooCommerce', 'woocommerce' ) ],
description: __(
'Display a grid of products from your selected tags.',
'woocommerce'
),
supports: {
align: [ 'wide', 'full' ],
html: false,
},
example: {
attributes: {
isPreview: true,
},
},
attributes: {
/**
* Number of columns.
*/
columns: {
type: 'number',
default: DEFAULT_COLUMNS,
},
/**
* Number of rows.
*/
rows: {
type: 'number',
default: DEFAULT_ROWS,
},
/**
* How to align cart buttons.
*/
alignButtons: {
type: 'boolean',
default: false,
},
/**
* Content visibility setting
*/
contentVisibility: {
type: 'object',
default: {
title: true,
price: true,
rating: true,
button: true,
},
},
/**
* Product tags, used to display only products with the given tags.
*/
tags: {
type: 'array',
default: [],
},
/**
* Product tags operator, used to restrict to products in all or any selected tags.
*/
tagOperator: {
type: 'string',
default: 'any',
},
/**
* How to order the products: 'date', 'popularity', 'price_asc', 'price_desc' 'rating', 'title'.
*/
orderby: {
type: 'string',
default: 'date',
},
/**
* Are we previewing?
*/
isPreview: {
type: 'boolean',
default: false,
},
},
/**
* Renders and manages the block.
*/
edit( props ) {
return <Block { ...props } />;
},
save() {
return null;
},
} );

View File

@@ -0,0 +1,112 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Component, Fragment } from '@wordpress/element';
import { Disabled, PanelBody } from '@wordpress/components';
import { InspectorControls, ServerSideRender } from '@wordpress/editor';
import PropTypes from 'prop-types';
import GridContentControl from '@woocommerce/block-components/grid-content-control';
import GridLayoutControl from '@woocommerce/block-components/grid-layout-control';
import ProductCategoryControl from '@woocommerce/block-components/product-category-control';
import { gridBlockPreview } from '@woocommerce/resource-previews';
/**
* Component to handle edit mode of "Top Rated Products".
*/
class ProductTopRatedBlock extends Component {
getInspectorControls() {
const { attributes, setAttributes } = this.props;
const {
categories,
catOperator,
columns,
contentVisibility,
rows,
alignButtons,
} = attributes;
return (
<InspectorControls key="inspector">
<PanelBody
title={ __( 'Layout', 'woocommerce' ) }
initialOpen
>
<GridLayoutControl
columns={ columns }
rows={ rows }
alignButtons={ alignButtons }
setAttributes={ setAttributes }
/>
</PanelBody>
<PanelBody
title={ __( 'Content', 'woocommerce' ) }
initialOpen
>
<GridContentControl
settings={ contentVisibility }
onChange={ ( value ) =>
setAttributes( { contentVisibility: value } )
}
/>
</PanelBody>
<PanelBody
title={ __(
'Filter by Product Category',
'woocommerce'
) }
initialOpen={ false }
>
<ProductCategoryControl
selected={ categories }
onChange={ ( value = [] ) => {
const ids = value.map( ( { id } ) => id );
setAttributes( { categories: ids } );
} }
operator={ catOperator }
onOperatorChange={ ( value = 'any' ) =>
setAttributes( { catOperator: value } )
}
/>
</PanelBody>
</InspectorControls>
);
}
render() {
const { name, attributes } = this.props;
if ( attributes.isPreview ) {
return gridBlockPreview;
}
return (
<Fragment>
{ this.getInspectorControls() }
<Disabled>
<ServerSideRender
block={ name }
attributes={ attributes }
/>
</Disabled>
</Fragment>
);
}
}
ProductTopRatedBlock.propTypes = {
/**
* The attributes for this block
*/
attributes: PropTypes.object.isRequired,
/**
* The register block name.
*/
name: PropTypes.string.isRequired,
/**
* A callback to update attributes
*/
setAttributes: PropTypes.func.isRequired,
};
export default ProductTopRatedBlock;

View File

@@ -0,0 +1,74 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { createBlock, registerBlockType } from '@wordpress/blocks';
import Gridicon from 'gridicons';
import { without } from 'lodash';
/**
* Internal dependencies
*/
import Block from './block';
import { deprecatedConvertToShortcode } from '../../utils/deprecations';
import sharedAttributes, {
sharedAttributeBlockTypes,
} from '../../utils/shared-attributes';
const blockTypeName = 'woocommerce/product-top-rated';
registerBlockType( blockTypeName, {
title: __( 'Top Rated Products', 'woocommerce' ),
icon: {
src: <Gridicon icon="trophy" />,
foreground: '#96588a',
},
category: 'woocommerce',
keywords: [ __( 'WooCommerce', 'woocommerce' ) ],
description: __(
'Display a grid of your top rated products.',
'woocommerce'
),
supports: {
align: [ 'wide', 'full' ],
html: false,
},
example: {
attributes: {
isPreview: true,
},
},
attributes: {
...sharedAttributes,
},
transforms: {
from: [
{
type: 'block',
blocks: without( sharedAttributeBlockTypes, blockTypeName ),
transform: ( attributes ) =>
createBlock( 'woocommerce/product-top-rated', attributes ),
},
],
},
deprecated: [
{
// Deprecate shortcode save method in favor of dynamic rendering.
attributes: sharedAttributes,
save: deprecatedConvertToShortcode( blockTypeName ),
},
],
/**
* Renders and manages the block.
*/
edit( props ) {
return <Block { ...props } />;
},
save() {
return null;
},
} );

View File

@@ -0,0 +1,215 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import {
BlockControls,
InspectorControls,
ServerSideRender,
} from '@wordpress/editor';
import {
Button,
Disabled,
PanelBody,
Placeholder,
Toolbar,
withSpokenMessages,
} from '@wordpress/components';
import { Component, Fragment } from '@wordpress/element';
import Gridicon from 'gridicons';
import PropTypes from 'prop-types';
import GridContentControl from '@woocommerce/block-components/grid-content-control';
import GridLayoutControl from '@woocommerce/block-components/grid-layout-control';
import ProductAttributeTermControl from '@woocommerce/block-components/product-attribute-term-control';
import ProductOrderbyControl from '@woocommerce/block-components/product-orderby-control';
import { gridBlockPreview } from '@woocommerce/resource-previews';
/**
* Component to handle edit mode of "Products by Attribute".
*/
class ProductsByAttributeBlock extends Component {
getInspectorControls() {
const { setAttributes } = this.props;
const {
attributes,
attrOperator,
columns,
contentVisibility,
orderby,
rows,
alignButtons,
} = this.props.attributes;
return (
<InspectorControls key="inspector">
<PanelBody
title={ __( 'Layout', 'woocommerce' ) }
initialOpen
>
<GridLayoutControl
columns={ columns }
rows={ rows }
alignButtons={ alignButtons }
setAttributes={ setAttributes }
/>
</PanelBody>
<PanelBody
title={ __( 'Content', 'woocommerce' ) }
initialOpen
>
<GridContentControl
settings={ contentVisibility }
onChange={ ( value ) =>
setAttributes( { contentVisibility: value } )
}
/>
</PanelBody>
<PanelBody
title={ __(
'Filter by Product Attribute',
'woocommerce'
) }
initialOpen={ false }
>
<ProductAttributeTermControl
selected={ attributes }
onChange={ ( value = [] ) => {
/* eslint-disable camelcase */
const result = value.map(
( { id, attr_slug } ) => ( {
id,
attr_slug,
} )
);
/* eslint-enable camelcase */
setAttributes( { attributes: result } );
} }
operator={ attrOperator }
onOperatorChange={ ( value = 'any' ) =>
setAttributes( { attrOperator: value } )
}
/>
</PanelBody>
<PanelBody
title={ __( 'Order By', 'woocommerce' ) }
initialOpen={ false }
>
<ProductOrderbyControl
setAttributes={ setAttributes }
value={ orderby }
/>
</PanelBody>
</InspectorControls>
);
}
renderEditMode() {
const { debouncedSpeak, setAttributes } = this.props;
const blockAttributes = this.props.attributes;
const onDone = () => {
setAttributes( { editMode: false } );
debouncedSpeak(
__(
'Showing Products by Attribute block preview.',
'woocommerce'
)
);
};
return (
<Placeholder
icon={ <Gridicon icon="custom-post-type" /> }
label={ __(
'Products by Attribute',
'woocommerce'
) }
className="wc-block-products-grid wc-block-products-by-attribute"
>
{ __(
'Display a grid of products from your selected attributes.',
'woocommerce'
) }
<div className="wc-block-products-by-attribute__selection">
<ProductAttributeTermControl
selected={ blockAttributes.attributes }
onChange={ ( value = [] ) => {
/* eslint-disable camelcase */
const result = value.map(
( { id, attr_slug } ) => ( {
id,
attr_slug,
} )
);
/* eslint-enable camelcase */
setAttributes( { attributes: result } );
} }
operator={ blockAttributes.attrOperator }
onOperatorChange={ ( value = 'any' ) =>
setAttributes( { attrOperator: value } )
}
/>
<Button isDefault onClick={ onDone }>
{ __( 'Done', 'woocommerce' ) }
</Button>
</div>
</Placeholder>
);
}
render() {
const { attributes, name, setAttributes } = this.props;
const { editMode } = attributes;
if ( attributes.isPreview ) {
return gridBlockPreview;
}
return (
<Fragment>
<BlockControls>
<Toolbar
controls={ [
{
icon: 'edit',
title: __( 'Edit' ),
onClick: () =>
setAttributes( { editMode: ! editMode } ),
isActive: editMode,
},
] }
/>
</BlockControls>
{ this.getInspectorControls() }
{ editMode ? (
this.renderEditMode()
) : (
<Disabled>
<ServerSideRender
block={ name }
attributes={ attributes }
/>
</Disabled>
) }
</Fragment>
);
}
}
ProductsByAttributeBlock.propTypes = {
/**
* The attributes for this block
*/
attributes: PropTypes.object.isRequired,
/**
* The register block name.
*/
name: PropTypes.string.isRequired,
/**
* A callback to update attributes
*/
setAttributes: PropTypes.func.isRequired,
// from withSpokenMessages
debouncedSpeak: PropTypes.func.isRequired,
};
export default withSpokenMessages( ProductsByAttributeBlock );

View File

@@ -0,0 +1,3 @@
.wc-block-products-by-attribute__selection {
width: 100%;
}

View File

@@ -0,0 +1,170 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import Gridicon from 'gridicons';
import { registerBlockType } from '@wordpress/blocks';
import { DEFAULT_COLUMNS, DEFAULT_ROWS } from '@woocommerce/block-settings';
/**
* Internal dependencies
*/
import './editor.scss';
import Block from './block';
import { deprecatedConvertToShortcode } from '../../utils/deprecations';
const blockTypeName = 'woocommerce/products-by-attribute';
registerBlockType( blockTypeName, {
title: __( 'Products by Attribute', 'woocommerce' ),
icon: {
src: <Gridicon icon="custom-post-type" />,
foreground: '#96588a',
},
category: 'woocommerce',
keywords: [ __( 'WooCommerce', 'woocommerce' ) ],
description: __(
'Display a grid of products from your selected attributes.',
'woocommerce'
),
supports: {
align: [ 'wide', 'full' ],
html: false,
},
example: {
attributes: {
isPreview: true,
},
},
attributes: {
/**
* Product attributes, used to display only products with the given attributes.
*/
attributes: {
type: 'array',
default: [],
},
/**
* Product attribute operator, used to restrict to products in all or any selected attributes.
*/
attrOperator: {
type: 'string',
default: 'any',
},
/**
* Number of columns.
*/
columns: {
type: 'number',
default: DEFAULT_COLUMNS,
},
/**
* Toggle for edit mode in the block preview.
*/
editMode: {
type: 'boolean',
default: true,
},
/**
* Content visibility setting
*/
contentVisibility: {
type: 'object',
default: {
title: true,
price: true,
rating: true,
button: true,
},
},
/**
* How to order the products: 'date', 'popularity', 'price_asc', 'price_desc' 'rating', 'title'.
*/
orderby: {
type: 'string',
default: 'date',
},
/**
* Number of rows.
*/
rows: {
type: 'number',
default: DEFAULT_ROWS,
},
/**
* How to align cart buttons.
*/
alignButtons: {
type: 'boolean',
default: false,
},
/**
* Are we previewing?
*/
isPreview: {
type: 'boolean',
default: false,
},
},
deprecated: [
{
// Deprecate shortcode save method in favor of dynamic rendering.
attributes: {
attributes: {
type: 'array',
default: [],
},
attrOperator: {
type: 'string',
default: 'any',
},
columns: {
type: 'number',
default: DEFAULT_COLUMNS,
},
editMode: {
type: 'boolean',
default: true,
},
contentVisibility: {
type: 'object',
default: {
title: true,
price: true,
rating: true,
button: true,
},
},
orderby: {
type: 'string',
default: 'date',
},
rows: {
type: 'number',
default: DEFAULT_ROWS,
},
},
save: deprecatedConvertToShortcode( blockTypeName ),
},
],
/**
* Renders and manages the block.
*/
edit( props ) {
return <Block { ...props } />;
},
save() {
return null;
},
} );

View File

@@ -0,0 +1,54 @@
/**
* External dependencies
*/
import { Component } from 'react';
import PropTypes from 'prop-types';
import ProductListContainer from '@woocommerce/base-components/product-list/container';
import { InnerBlockConfigurationProvider } from '@woocommerce/base-context/inner-block-configuration-context';
import { ProductLayoutContextProvider } from '@woocommerce/base-context/product-layout-context';
import { gridBlockPreview } from '@woocommerce/resource-previews';
const layoutContextConfig = {
layoutStyleClassPrefix: 'wc-block-grid',
};
const parentBlockConfig = { parentName: 'woocommerce/all-products' };
/**
* The All Products Block. @todo
*/
class Block extends Component {
static propTypes = {
/**
* The attributes for this block.
*/
attributes: PropTypes.object.isRequired,
};
render() {
const { attributes, urlParameterSuffix } = this.props;
if ( attributes.isPreview ) {
return gridBlockPreview;
}
/**
* Todo classes
*
* wp-block-{$this->block_name},
* wc-block-{$this->block_name},
*/
return (
<InnerBlockConfigurationProvider value={ parentBlockConfig }>
<ProductLayoutContextProvider value={ layoutContextConfig }>
<ProductListContainer
attributes={ attributes }
urlParameterSuffix={ urlParameterSuffix }
/>
</ProductLayoutContextProvider>
</InnerBlockConfigurationProvider>
);
}
}
export default Block;

View File

@@ -0,0 +1,316 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { createBlock } from '@wordpress/blocks';
import {
BlockControls,
InnerBlocks,
InspectorControls,
} from '@wordpress/block-editor';
import { withDispatch, withSelect } from '@wordpress/data';
import {
PanelBody,
withSpokenMessages,
Placeholder,
Button,
IconButton,
Toolbar,
Disabled,
Tip,
} from '@wordpress/components';
import { Component } from '@wordpress/element';
import { compose } from '@wordpress/compose';
import PropTypes from 'prop-types';
import Gridicon from 'gridicons';
import GridLayoutControl from '@woocommerce/block-components/grid-layout-control';
import { HAS_PRODUCTS } from '@woocommerce/block-settings';
import { InnerBlockConfigurationProvider } from '@woocommerce/base-context/inner-block-configuration-context';
import { ProductLayoutContextProvider } from '@woocommerce/base-context/product-layout-context';
/**
* Internal dependencies
*/
import {
renderHiddenContentPlaceholder,
renderNoProductsPlaceholder,
getBlockClassName,
} from '../utils';
import {
DEFAULT_PRODUCT_LIST_LAYOUT,
getBlockMap,
getProductLayoutConfig,
} from '../base-utils';
import { getSharedContentControls, getSharedListControls } from '../edit';
import Block from './block';
const layoutContextConfig = {
layoutStyleClassPrefix: 'wc-block-grid',
};
const parentBlockConfig = { parentName: 'woocommerce/all-products' };
/**
* Component to handle edit mode of "All Products".
*/
class Editor extends Component {
static propTypes = {
/**
* The attributes for this block.
*/
attributes: PropTypes.object.isRequired,
/**
* A callback to update attributes.
*/
setAttributes: PropTypes.func.isRequired,
/**
* From withSpokenMessages.
*/
debouncedSpeak: PropTypes.func.isRequired,
};
state = {
isEditing: false,
innerBlocks: [],
};
blockMap = getBlockMap( 'woocommerce/all-products' );
componentDidMount = () => {
const { block } = this.props;
this.setState( { innerBlocks: block.innerBlocks } );
};
getTitle = () => {
return __( 'All Products', 'woocommerce' );
};
getIcon = () => {
return <Gridicon icon="grid" />;
};
togglePreview = () => {
const { debouncedSpeak } = this.props;
this.setState( { isEditing: ! this.state.isEditing } );
if ( ! this.state.isEditing ) {
debouncedSpeak(
__(
'Showing All Products block preview.',
'woocommerce'
)
);
}
};
getInspectorControls = () => {
const { attributes, setAttributes } = this.props;
const { columns, rows, alignButtons } = attributes;
return (
<InspectorControls key="inspector">
<PanelBody
title={ __(
'Layout Settings',
'woocommerce'
) }
initialOpen
>
<GridLayoutControl
columns={ columns }
rows={ rows }
alignButtons={ alignButtons }
setAttributes={ setAttributes }
/>
</PanelBody>
<PanelBody
title={ __(
'Content Settings',
'woocommerce'
) }
>
{ getSharedContentControls( attributes, setAttributes ) }
{ getSharedListControls( attributes, setAttributes ) }
</PanelBody>
</InspectorControls>
);
};
getBlockControls = () => {
const { isEditing } = this.state;
return (
<BlockControls>
<Toolbar
controls={ [
{
icon: 'edit',
title: __( 'Edit', 'woocommerce' ),
onClick: () => this.togglePreview(),
isActive: isEditing,
},
] }
/>
</BlockControls>
);
};
renderEditMode = () => {
const onDone = () => {
const { block, setAttributes } = this.props;
setAttributes( {
layoutConfig: getProductLayoutConfig( block.innerBlocks ),
} );
this.setState( { innerBlocks: block.innerBlocks } );
this.togglePreview();
};
const onCancel = () => {
const { block, replaceInnerBlocks } = this.props;
const { innerBlocks } = this.state;
replaceInnerBlocks( block.clientId, innerBlocks, false );
this.togglePreview();
};
const onReset = () => {
const { block, replaceInnerBlocks } = this.props;
const newBlocks = [];
DEFAULT_PRODUCT_LIST_LAYOUT.map( ( [ name, attributes ] ) => {
newBlocks.push( createBlock( name, attributes ) );
return true;
} );
replaceInnerBlocks( block.clientId, newBlocks, false );
this.setState( { innerBlocks: block.innerBlocks } );
};
const InnerBlockProps = {
template: this.props.attributes.layoutConfig,
templateLock: false,
allowedBlocks: Object.keys( this.blockMap ),
};
if ( this.props.attributes.layoutConfig.length !== 0 ) {
InnerBlockProps.renderAppender = false;
}
return (
<Placeholder icon={ this.getIcon() } label={ this.getTitle() }>
{ __(
'Display all products from your store as a grid.',
'woocommerce'
) }
<div className="wc-block-all-products-grid-item-template">
<Tip>
{ __(
'Edit the blocks inside the preview below to change the content displayed for each product within the product grid.',
'woocommerce'
) }
</Tip>
<div className="wc-block-grid has-1-columns">
<ul className="wc-block-grid__products">
<li className="wc-block-grid__product">
<InnerBlocks { ...InnerBlockProps } />
</li>
</ul>
</div>
<div className="wc-block-all-products__actions">
<Button
className="wc-block-all-products__done-button"
isPrimary
isLarge
onClick={ onDone }
>
{ __( 'Done', 'woocommerce' ) }
</Button>
<Button
className="wc-block-all-products__cancel-button"
isTertiary
onClick={ onCancel }
>
{ __( 'Cancel', 'woocommerce' ) }
</Button>
<IconButton
className="wc-block-all-products__reset-button"
icon={ <Gridicon icon="grid" /> }
label={ __(
'Reset layout to default',
'woocommerce'
) }
onClick={ onReset }
>
{ __(
'Reset Layout',
'woocommerce'
) }
</IconButton>
</div>
</div>
</Placeholder>
);
};
renderViewMode = () => {
const { attributes } = this.props;
const { layoutConfig } = attributes;
const hasContent = layoutConfig && layoutConfig.length !== 0;
const blockTitle = this.getTitle();
const blockIcon = this.getIcon();
if ( ! hasContent ) {
return renderHiddenContentPlaceholder( blockTitle, blockIcon );
}
return (
<Disabled>
<Block attributes={ attributes } />
</Disabled>
);
};
render = () => {
const { attributes } = this.props;
const { isEditing } = this.state;
const blockTitle = this.getTitle();
const blockIcon = this.getIcon();
if ( ! HAS_PRODUCTS ) {
return renderNoProductsPlaceholder( blockTitle, blockIcon );
}
return (
<InnerBlockConfigurationProvider value={ parentBlockConfig }>
<ProductLayoutContextProvider value={ layoutContextConfig }>
<div
className={ getBlockClassName(
'wc-block-all-products',
attributes
) }
>
{ this.getBlockControls() }
{ this.getInspectorControls() }
{ isEditing
? this.renderEditMode()
: this.renderViewMode() }
</div>
</ProductLayoutContextProvider>
</InnerBlockConfigurationProvider>
);
};
}
export default compose(
withSpokenMessages,
withSelect( ( select, { clientId } ) => {
const { getBlock } = select( 'core/block-editor' );
return {
block: getBlock( clientId ),
};
} ),
withDispatch( ( dispatch ) => {
const { replaceInnerBlocks } = dispatch( 'core/block-editor' );
return {
replaceInnerBlocks,
};
} )
)( Editor );

View File

@@ -0,0 +1,20 @@
/**
* External dependencies
*/
import { withRestApiHydration } from '@woocommerce/block-hocs';
/**
* Internal dependencies
*/
import Block from './block';
import renderFrontend from '../../../utils/render-frontend.js';
const getProps = ( el ) => ( {
attributes: JSON.parse( el.dataset.attributes ),
} );
renderFrontend(
'.wp-block-woocommerce-all-products',
withRestApiHydration( Block ),
getProps
);

View File

@@ -0,0 +1,73 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { InnerBlocks } from '@wordpress/block-editor';
import { registerBlockType } from '@wordpress/blocks';
import Gridicon from 'gridicons';
/**
* Internal dependencies
*/
import Editor from './edit';
import sharedAttributes from '../attributes';
import { getBlockClassName } from '../utils.js';
import '../../../atomic/blocks/product';
/**
* Register and run the "All Products" block.
*/
registerBlockType( 'woocommerce/all-products', {
title: __( 'All Products', 'woocommerce' ),
icon: {
src: <Gridicon icon="grid" />,
foreground: '#96588a',
},
category: 'woocommerce',
keywords: [ __( 'WooCommerce', 'woocommerce' ) ],
description: __(
'Display all products from your store as a grid.',
'woocommerce'
),
supports: {
align: [ 'wide', 'full' ],
html: false,
multiple: false,
},
example: {
attributes: {
isPreview: true,
},
},
attributes: {
...sharedAttributes,
},
/**
* Renders and manages the block.
*/
edit( props ) {
return <Editor { ...props } />;
},
/**
* Save the props to post content.
*/
save( { attributes } ) {
const data = {
'data-attributes': JSON.stringify( attributes ),
};
return (
<div
className={ getBlockClassName(
'wc-block-all-products',
attributes
) }
{ ...data }
>
<InnerBlocks.Content />
</div>
);
},
} );

View File

@@ -0,0 +1,69 @@
/**
* External dependencies
*/
import { DEFAULT_COLUMNS, DEFAULT_ROWS } from '@woocommerce/block-settings';
/**
* Internal dependencies
*/
import { DEFAULT_PRODUCT_LIST_LAYOUT } from './base-utils';
export default {
/**
* Number of columns.
*/
columns: {
type: 'number',
default: DEFAULT_COLUMNS,
},
/**
* Number of rows.
*/
rows: {
type: 'number',
default: DEFAULT_ROWS,
},
/**
* How to align cart buttons.
*/
alignButtons: {
type: 'boolean',
default: false,
},
/**
* Content visibility setting
*/
contentVisibility: {
type: 'object',
default: {
orderBy: true,
},
},
/**
* Order to use for the products listing.
*/
orderby: {
type: 'string',
default: 'date',
},
/**
* Layout config.
*/
layoutConfig: {
type: 'array',
default: DEFAULT_PRODUCT_LIST_LAYOUT,
},
/**
* Are we previewing?
*/
isPreview: {
type: 'boolean',
default: false,
},
};

View File

@@ -0,0 +1,65 @@
/**
* External dependencies
*/
import { getRegisteredInnerBlocks } from '@woocommerce/blocks-registry';
import {
ProductTitle,
ProductPrice,
ProductButton,
ProductImage,
ProductRating,
ProductSummary,
ProductSaleBadge,
} from '@woocommerce/atomic-components/product';
/**
* Map blocks names to components.
*
* @param {string} blockName Name of the parent block. Used to get extension children.
*/
export const getBlockMap = ( blockName ) => ( {
'woocommerce/product-price': ProductPrice,
'woocommerce/product-image': ProductImage,
'woocommerce/product-title': ProductTitle,
'woocommerce/product-rating': ProductRating,
'woocommerce/product-button': ProductButton,
'woocommerce/product-summary': ProductSummary,
'woocommerce/product-sale-badge': ProductSaleBadge,
...getRegisteredInnerBlocks( blockName ),
} );
/**
* The default layout built from the default template.
*/
export const DEFAULT_PRODUCT_LIST_LAYOUT = [
[ 'woocommerce/product-image' ],
[ 'woocommerce/product-title' ],
[ 'woocommerce/product-price' ],
[ 'woocommerce/product-rating' ],
[ 'woocommerce/product-button' ],
];
/**
* Converts innerblocks to a list of layout configs.
*
* @param {Object[]} innerBlocks Inner block components.
*/
export const getProductLayoutConfig = ( innerBlocks ) => {
if ( ! innerBlocks || innerBlocks.length === 0 ) {
return [];
}
return innerBlocks.map( ( block ) => {
return [
block.name,
{
...block.attributes,
product: undefined,
children:
block.innerBlocks.length > 0
? getProductLayoutConfig( block.innerBlocks )
: [],
},
];
} );
};

View File

@@ -0,0 +1,82 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { ToggleControl, SelectControl } from '@wordpress/components';
/**
* Internal dependencies
*/
import './editor.scss';
export const getSharedContentControls = ( attributes, setAttributes ) => {
const { contentVisibility } = attributes;
return (
<ToggleControl
label={ __(
'Show Sorting Dropdown',
'woocommerce'
) }
checked={ contentVisibility.orderBy }
onChange={ () =>
setAttributes( {
contentVisibility: {
...contentVisibility,
orderBy: ! contentVisibility.orderBy,
},
} )
}
/>
);
};
export const getSharedListControls = ( attributes, setAttributes ) => {
return (
<SelectControl
label={ __( 'Order Products By', 'woocommerce' ) }
value={ attributes.orderby }
options={ [
{
label: __(
'Newness - newest first',
'woocommerce'
),
value: 'date',
},
{
label: __(
'Price - low to high',
'woocommerce'
),
value: 'price',
},
{
label: __(
'Price - high to low',
'woocommerce'
),
value: 'price-desc',
},
{
label: __(
'Rating - highest first',
'woocommerce'
),
value: 'rating',
},
{
label: __(
'Sales - most first',
'woocommerce'
),
value: 'popularity',
},
{
label: __( 'Menu Order', 'woocommerce' ),
value: 'menu_order',
},
] }
onChange={ ( orderby ) => setAttributes( { orderby } ) }
/>
);
};

View File

@@ -0,0 +1,122 @@
.wc-block-products {
.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 */
p {
font-size: 14px;
}
}
.wc-block-products__add_product_button {
margin: 0 0 1em;
line-height: 24px;
vertical-align: middle;
height: auto;
font-size: 14px;
padding: 0.5em 1em;
svg {
fill: currentColor;
margin-left: 0.5ch;
vertical-align: middle;
}
}
.wc-block-products__read_more_button {
display: block;
margin-bottom: 1em;
}
}
// Edit view for product list.
.wc-block-all-products {
.components-placeholder__fieldset {
max-width: initial;
overflow: hidden;
}
.wc-block-all-products-grid-item-template {
border-top: 1px solid #e2e4e7;
margin-top: 20px;
width: 100%;
overflow: hidden;
text-align: center;
.components-tip {
max-width: 450px;
margin: 20px auto;
text-align: left;
p {
margin: 1em 0;
}
}
.wc-block-all-products__actions {
display: flex;
margin: 20px auto;
padding: 1em 0 0;
align-items: center;
vertical-align: middle;
max-width: 450px;
.wc-block-all-products__done-button {
margin: 0;
order: 3;
line-height: 32px;
height: auto;
}
.wc-block-all-products__cancel-button {
margin: 0 1em 0 auto;
order: 2;
}
.wc-block-all-products__reset-button {
margin: 0;
order: 1;
}
}
.wc-block-grid__products {
margin: 0 auto !important;
text-align: center;
position: relative;
max-width: 450px;
}
.wc-block-grid__product {
padding: 1px 20px;
margin: 0 auto;
background: #fff;
box-shadow: 0 5px 7px -2px rgba(0, 0, 0, 0.2);
position: static;
.wp-block-button__link {
margin-top: 0;
}
&::before,
&::after {
content: "";
background: #e2e4e7;
display: block;
position: absolute;
width: 100%;
top: 20px;
bottom: 20px;
}
&::before {
right: 100%;
margin-right: 30px;
}
&::after {
left: 100%;
margin-left: 30px;
}
}
}
}

View File

@@ -0,0 +1,64 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Button, Placeholder } from '@wordpress/components';
import classNames from 'classnames';
import { adminUrl } from '@woocommerce/settings';
import { IconExternal } from '@woocommerce/block-components/icons';
export const getBlockClassName = ( blockClassName, attributes ) => {
const { className, contentVisibility } = attributes;
return classNames( blockClassName, className, {
'has-image': contentVisibility.image,
'has-title': contentVisibility.title,
'has-rating': contentVisibility.rating,
'has-price': contentVisibility.price,
'has-button': contentVisibility.button,
} );
};
export const renderNoProductsPlaceholder = ( blockTitle, blockIcon ) => (
<Placeholder
className="wc-block-products"
icon={ blockIcon }
label={ blockTitle }
>
<p>
{ __(
"You haven't published any products to list here yet.",
'woocommerce'
) }
</p>
<Button
className="wc-block-products__add_product_button"
isDefault
isLarge
href={ adminUrl + 'post-new.php?post_type=product' }
>
{ __( 'Add new product', 'woocommerce' ) + ' ' }
<IconExternal />
</Button>
<Button
className="wc-block-products__read_more_button"
isTertiary
href="https://docs.woocommerce.com/document/managing-products/"
>
{ __( 'Learn more', 'woocommerce' ) }
</Button>
</Placeholder>
);
export const renderHiddenContentPlaceholder = ( blockTitle, blockIcon ) => (
<Placeholder
className="wc-block-products"
icon={ blockIcon }
label={ blockTitle }
>
{ __(
'The content for this block is hidden due to block settings.',
'woocommerce'
) }
</Placeholder>
);

View File

@@ -0,0 +1,89 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { InspectorControls } from '@wordpress/editor';
import { PanelBody, ToggleControl } from '@wordpress/components';
import { Fragment } from '@wordpress/element';
import PropTypes from 'prop-types';
import { IconAllReviews } from '@woocommerce/block-components/icons';
/**
* Internal dependencies
*/
import EditorContainerBlock from '../editor-container-block.js';
import NoReviewsPlaceholder from './no-reviews-placeholder.js';
import {
getSharedReviewContentControls,
getSharedReviewListControls,
} from '../edit-utils.js';
/**
* Component to handle edit mode of "All Reviews".
*/
const AllReviewsEditor = ( { attributes, setAttributes } ) => {
const getInspectorControls = () => {
return (
<InspectorControls key="inspector">
<PanelBody
title={ __( 'Content', 'woocommerce' ) }
>
<ToggleControl
label={ __(
'Product name',
'woocommerce'
) }
checked={ attributes.showProductName }
onChange={ () =>
setAttributes( {
showProductName: ! attributes.showProductName,
} )
}
/>
{ getSharedReviewContentControls(
attributes,
setAttributes
) }
</PanelBody>
<PanelBody
title={ __(
'List Settings',
'woocommerce'
) }
>
{ getSharedReviewListControls( attributes, setAttributes ) }
</PanelBody>
</InspectorControls>
);
};
return (
<Fragment>
{ getInspectorControls() }
<EditorContainerBlock
attributes={ attributes }
className="wc-block-all-reviews"
icon={ <IconAllReviews className="block-editor-block-icon" /> }
name={ __( 'All Reviews', 'woocommerce' ) }
noReviewsPlaceholder={ NoReviewsPlaceholder }
/>
</Fragment>
);
};
AllReviewsEditor.propTypes = {
/**
* The attributes for this block.
*/
attributes: PropTypes.object.isRequired,
/**
* The register block name.
*/
name: PropTypes.string.isRequired,
/**
* A callback to update attributes.
*/
setAttributes: PropTypes.func.isRequired,
};
export default AllReviewsEditor;

View File

@@ -0,0 +1,61 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { registerBlockType } from '@wordpress/blocks';
import { IconAllReviews } from '@woocommerce/block-components/icons';
/**
* Internal dependencies
*/
import '../editor.scss';
import Editor from './edit';
import sharedAttributes from '../attributes';
import save from '../save.js';
import { example } from '../example';
/**
* Register and run the "All Reviews" block.
*/
registerBlockType( 'woocommerce/all-reviews', {
title: __( 'All Reviews', 'woocommerce' ),
icon: {
src: <IconAllReviews />,
foreground: '#96588a',
},
category: 'woocommerce',
keywords: [ __( 'WooCommerce', 'woocommerce' ) ],
description: __(
'Shows a list of all product reviews.',
'woocommerce'
),
example: {
...example,
attributes: {
...example.attributes,
showProductName: true,
},
},
attributes: {
...sharedAttributes,
/**
* Show the product name.
*/
showProductName: {
type: 'boolean',
default: true,
},
},
/**
* Renders and manages the block.
*/
edit( props ) {
return <Editor { ...props } />;
},
/**
* Save the props to post content.
*/
save,
} );

View File

@@ -0,0 +1,23 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Placeholder } from '@wordpress/components';
import { IconAllReviews } from '@woocommerce/block-components/icons';
const NoCategoryReviewsPlaceholder = () => {
return (
<Placeholder
className="wc-block-all-reviews"
icon={ <IconAllReviews className="block-editor-block-icon" /> }
label={ __( 'All Reviews', 'woocommerce' ) }
>
{ __(
'This block shows a list of all product reviews. Your store does not have any reviews yet, but they will show up here when it does.',
'woocommerce'
) }
</Placeholder>
);
};
export default NoCategoryReviewsPlaceholder;

View File

@@ -0,0 +1,102 @@
export default {
/**
* Toggle for edit mode in the block preview.
*/
editMode: {
type: 'boolean',
default: true,
},
/**
* Whether to display the reviewer or product image.
*/
imageType: {
type: 'string',
default: 'reviewer',
},
/**
* Order to use for the reviews listing.
*/
orderby: {
type: 'string',
default: 'most-recent',
},
/**
* Number of reviews to add when clicking on load more.
*/
reviewsOnLoadMore: {
type: 'number',
default: 10,
},
/**
* Number of reviews to display on page load.
*/
reviewsOnPageLoad: {
type: 'number',
default: 10,
},
/**
* Show the load more button.
*/
showLoadMore: {
type: 'boolean',
default: true,
},
/**
* Show the order by selector.
*/
showOrderby: {
type: 'boolean',
default: true,
},
/**
* Show the review date.
*/
showReviewDate: {
type: 'boolean',
default: true,
},
/**
* Show the reviewer name.
*/
showReviewerName: {
type: 'boolean',
default: true,
},
/**
* Show the review image..
*/
showReviewImage: {
type: 'boolean',
default: true,
},
/**
* Show the product rating.
*/
showReviewRating: {
type: 'boolean',
default: true,
},
/**
* Show the product content.
*/
showReviewContent: {
type: 'boolean',
default: true,
},
previewReviews: {
type: 'array',
default: null,
},
};

View File

@@ -0,0 +1,224 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { Fragment, RawHTML } from '@wordpress/element';
import { escapeHTML } from '@wordpress/escape-html';
import {
Notice,
ToggleControl,
Toolbar,
RangeControl,
SelectControl,
} from '@wordpress/components';
import { BlockControls } from '@wordpress/editor';
import { getAdminLink } from '@woocommerce/settings';
import {
ENABLE_REVIEW_RATING,
SHOW_AVATARS,
} from '@woocommerce/block-settings';
import ToggleButtonControl from '@woocommerce/block-components/toggle-button-control';
export const getBlockControls = ( editMode, setAttributes ) => (
<BlockControls>
<Toolbar
controls={ [
{
icon: 'edit',
title: __( 'Edit', 'woocommerce' ),
onClick: () => setAttributes( { editMode: ! editMode } ),
isActive: editMode,
},
] }
/>
</BlockControls>
);
export const getSharedReviewContentControls = ( attributes, setAttributes ) => {
return (
<Fragment>
<ToggleControl
label={ __( 'Product rating', 'woocommerce' ) }
checked={ attributes.showReviewRating }
onChange={ () =>
setAttributes( {
showReviewRating: ! attributes.showReviewRating,
} )
}
/>
{ attributes.showReviewRating && ! ENABLE_REVIEW_RATING && (
<Notice
className="wc-block-reviews__notice"
isDismissible={ false }
>
<RawHTML>
{ sprintf(
escapeHTML(
/* translators: A notice that links to WooCommerce settings. */
__(
'Product rating is disabled in your %sstore settings%s.',
'woocommerce'
)
),
`<a href="${ getAdminLink(
'admin.php?page=wc-settings&tab=products'
) }" target="_blank">`,
'</a>'
) }
</RawHTML>
</Notice>
) }
<ToggleControl
label={ __( 'Reviewer name', 'woocommerce' ) }
checked={ attributes.showReviewerName }
onChange={ () =>
setAttributes( {
showReviewerName: ! attributes.showReviewerName,
} )
}
/>
<ToggleControl
label={ __( 'Image', 'woocommerce' ) }
checked={ attributes.showReviewImage }
onChange={ () =>
setAttributes( {
showReviewImage: ! attributes.showReviewImage,
} )
}
/>
<ToggleControl
label={ __( 'Review date', 'woocommerce' ) }
checked={ attributes.showReviewDate }
onChange={ () =>
setAttributes( {
showReviewDate: ! attributes.showReviewDate,
} )
}
/>
<ToggleControl
label={ __( 'Review content', 'woocommerce' ) }
checked={ attributes.showReviewContent }
onChange={ () =>
setAttributes( {
showReviewContent: ! attributes.showReviewContent,
} )
}
/>
{ attributes.showReviewImage && (
<Fragment>
<ToggleButtonControl
label={ __(
'Review image',
'woocommerce'
) }
value={ attributes.imageType }
options={ [
{
label: __(
'Reviewer photo',
'woocommerce'
),
value: 'reviewer',
},
{
label: __(
'Product',
'woocommerce'
),
value: 'product',
},
] }
onChange={ ( value ) =>
setAttributes( { imageType: value } )
}
/>
{ attributes.imageType === 'reviewer' && ! SHOW_AVATARS && (
<Notice
className="wc-block-reviews__notice"
isDismissible={ false }
>
<RawHTML>
{ sprintf(
escapeHTML(
/* translators: A notice that links to WordPress settings. */
__(
'Reviewer photo is disabled in your %ssite settings%s.',
'woocommerce'
)
),
`<a href="${ getAdminLink(
'options-discussion.php'
) }" target="_blank">`,
'</a>'
) }
</RawHTML>
</Notice>
) }
</Fragment>
) }
</Fragment>
);
};
export const getSharedReviewListControls = ( attributes, setAttributes ) => {
const minPerPage = 1;
const maxPerPage = 20;
return (
<Fragment>
<ToggleControl
label={ __( 'Order by', 'woocommerce' ) }
checked={ attributes.showOrderby }
onChange={ () =>
setAttributes( { showOrderby: ! attributes.showOrderby } )
}
/>
<SelectControl
label={ __(
'Order Product Reviews by',
'woocommerce'
) }
value={ attributes.orderby }
options={ [
{ label: 'Most recent', value: 'most-recent' },
{ label: 'Highest Rating', value: 'highest-rating' },
{ label: 'Lowest Rating', value: 'lowest-rating' },
] }
onChange={ ( orderby ) => setAttributes( { orderby } ) }
/>
<RangeControl
label={ __(
'Starting Number of Reviews',
'woocommerce'
) }
value={ attributes.reviewsOnPageLoad }
onChange={ ( reviewsOnPageLoad ) =>
setAttributes( { reviewsOnPageLoad } )
}
max={ maxPerPage }
min={ minPerPage }
/>
<ToggleControl
label={ __( 'Load more', 'woocommerce' ) }
checked={ attributes.showLoadMore }
onChange={ () =>
setAttributes( { showLoadMore: ! attributes.showLoadMore } )
}
/>
{ attributes.showLoadMore && (
<RangeControl
label={ __(
'Load More Reviews',
'woocommerce'
) }
value={ attributes.reviewsOnLoadMore }
onChange={ ( reviewsOnLoadMore ) =>
setAttributes( { reviewsOnLoadMore } )
}
max={ maxPerPage }
min={ minPerPage }
/>
) }
</Fragment>
);
};

View File

@@ -0,0 +1,72 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Component } from 'react';
import PropTypes from 'prop-types';
import { Disabled } from '@wordpress/components';
import { ENABLE_REVIEW_RATING } from '@woocommerce/block-settings';
import ErrorPlaceholder from '@woocommerce/block-components/error-placeholder';
import LoadMoreButton from '@woocommerce/base-components/load-more-button';
import ReviewList from '@woocommerce/base-components/review-list';
import ReviewSortSelect from '@woocommerce/base-components/review-sort-select';
import withReviews from '@woocommerce/base-hocs/with-reviews';
/**
* Block rendered in the editor.
*/
class EditorBlock extends Component {
static propTypes = {
/**
* The attributes for this block.
*/
attributes: PropTypes.object.isRequired,
// from withReviews
reviews: PropTypes.array,
totalReviews: PropTypes.number,
};
render() {
const {
attributes,
error,
isLoading,
noReviewsPlaceholder: NoReviewsPlaceholder,
reviews,
totalReviews,
} = this.props;
if ( error ) {
return (
<ErrorPlaceholder
className="wc-block-featured-product-error"
error={ error }
isLoading={ isLoading }
/>
);
}
if ( reviews.length === 0 && ! isLoading ) {
return <NoReviewsPlaceholder attributes={ attributes } />;
}
return (
<Disabled>
{ attributes.showOrderby && ENABLE_REVIEW_RATING && (
<ReviewSortSelect readOnly value={ attributes.orderby } />
) }
<ReviewList attributes={ attributes } reviews={ reviews } />
{ attributes.showLoadMore && totalReviews > reviews.length && (
<LoadMoreButton
screenReaderLabel={ __(
'Load more reviews',
'woocommerce'
) }
/>
) }
</Disabled>
);
}
}
export default withReviews( EditorBlock );

View File

@@ -0,0 +1,84 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Component } from 'react';
import PropTypes from 'prop-types';
import { debounce } from 'lodash';
import { Placeholder } from '@wordpress/components';
/**
* Internal dependencies
*/
import EditorBlock from './editor-block.js';
import { getBlockClassName, getSortArgs } from './utils.js';
/**
* Container of the block rendered in the editor.
*/
class EditorContainerBlock extends Component {
renderHiddenContentPlaceholder() {
const { icon, name } = this.props;
return (
<Placeholder icon={ icon } label={ name }>
{ __(
'The content for this block is hidden due to block settings.',
'woocommerce'
) }
</Placeholder>
);
}
render() {
const { attributes, className, noReviewsPlaceholder } = this.props;
const {
categoryIds,
productId,
reviewsOnPageLoad,
showProductName,
showReviewDate,
showReviewerName,
showReviewContent,
showReviewImage,
showReviewRating,
} = attributes;
const { order, orderby } = getSortArgs( attributes.orderby );
const isAllContentHidden =
! showReviewContent &&
! showReviewRating &&
! showReviewDate &&
! showReviewerName &&
! showReviewImage &&
! showProductName;
if ( isAllContentHidden ) {
return this.renderHiddenContentPlaceholder();
}
return (
<div className={ getBlockClassName( className, attributes ) }>
<EditorBlock
attributes={ attributes }
categoryIds={ categoryIds }
delayFunction={ ( callback ) => debounce( callback, 400 ) }
noReviewsPlaceholder={ noReviewsPlaceholder }
orderby={ orderby }
order={ order }
productId={ productId }
reviewsToDisplay={ reviewsOnPageLoad }
/>
</div>
);
}
}
EditorContainerBlock.propTypes = {
attributes: PropTypes.object.isRequired,
icon: PropTypes.node.isRequired,
name: PropTypes.string.isRequired,
noReviewsPlaceholder: PropTypes.func.isRequired,
className: PropTypes.string,
};
export default EditorContainerBlock;

View File

@@ -0,0 +1,13 @@
.wc-block-reviews__selection {
width: 100%;
}
.components-base-control {
+ .wc-block-reviews__notice {
margin: -$gap 0 $gap;
}
&:nth-last-child(2) + .wc-block-reviews__notice {
margin: -$gap 0 $gap-small;
}
}

View File

@@ -0,0 +1,22 @@
/**
* External dependencies
*/
import { previewReviews } from '@woocommerce/resource-previews';
export const example = {
attributes: {
editMode: false,
imageType: 'reviewer',
orderby: 'most-recent',
reviewsOnLoadMore: 10,
reviewsOnPageLoad: 10,
showLoadMore: true,
showOrderby: true,
showReviewDate: true,
showReviewerName: true,
showReviewImage: true,
showReviewRating: true,
showReviewContent: true,
previewReviews,
},
};

View File

@@ -0,0 +1,64 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Fragment } from 'react';
import PropTypes from 'prop-types';
import { ENABLE_REVIEW_RATING } from '@woocommerce/block-settings';
import LoadMoreButton from '@woocommerce/base-components/load-more-button';
import ReviewSortSelect from '@woocommerce/base-components/review-sort-select';
import ReviewList from '@woocommerce/base-components/review-list';
import withReviews from '@woocommerce/base-hocs/with-reviews';
/**
* Block rendered in the frontend.
*/
const FrontendBlock = ( {
attributes,
onAppendReviews,
onChangeOrderby,
reviews,
totalReviews,
} ) => {
const { orderby } = attributes;
if ( reviews.length === 0 ) {
return null;
}
return (
<Fragment>
{ attributes.showOrderby !== 'false' && ENABLE_REVIEW_RATING && (
<ReviewSortSelect
defaultValue={ orderby }
onChange={ onChangeOrderby }
/>
) }
<ReviewList attributes={ attributes } reviews={ reviews } />
{ attributes.showLoadMore !== 'false' &&
totalReviews > reviews.length && (
<LoadMoreButton
onClick={ onAppendReviews }
screenReaderLabel={ __(
'Load more reviews',
'woocommerce'
) }
/>
) }
</Fragment>
);
};
FrontendBlock.propTypes = {
/**
* The attributes for this block.
*/
attributes: PropTypes.object.isRequired,
onAppendReviews: PropTypes.func,
onChangeArgs: PropTypes.func,
// from withReviewsattributes
reviews: PropTypes.array,
totalReviews: PropTypes.number,
};
export default withReviews( FrontendBlock );

View File

@@ -0,0 +1,109 @@
/**
* External dependencies
*/
import { __, _n, sprintf } from '@wordpress/i18n';
import { speak } from '@wordpress/a11y';
import { Component } from 'react';
import PropTypes from 'prop-types';
/**
* Internal dependencies
*/
import { getSortArgs } from './utils';
import FrontendBlock from './frontend-block';
/**
* Container of the block rendered in the frontend.
*/
class FrontendContainerBlock extends Component {
constructor() {
super( ...arguments );
const { attributes } = this.props;
this.state = {
orderby: attributes.orderby,
reviewsToDisplay: parseInt( attributes.reviewsOnPageLoad, 10 ),
};
this.onAppendReviews = this.onAppendReviews.bind( this );
this.onChangeOrderby = this.onChangeOrderby.bind( this );
}
onAppendReviews() {
const { attributes } = this.props;
const { reviewsToDisplay } = this.state;
this.setState( {
reviewsToDisplay:
reviewsToDisplay + parseInt( attributes.reviewsOnLoadMore, 10 ),
} );
}
onChangeOrderby( event ) {
const { attributes } = this.props;
this.setState( {
orderby: event.target.value,
reviewsToDisplay: parseInt( attributes.reviewsOnPageLoad, 10 ),
} );
}
onReviewsAppended( { newReviews } ) {
speak(
sprintf(
_n(
'%d review loaded.',
'%d reviews loaded.',
newReviews.length,
'woocommerce'
),
newReviews.length
)
);
}
onReviewsReplaced() {
speak( __( 'Reviews list updated.', 'woocommerce' ) );
}
onReviewsLoadError() {
speak(
__(
'There was an error loading the reviews.',
'woocommerce'
)
);
}
render() {
const { attributes } = this.props;
const { categoryIds, productId } = attributes;
const { reviewsToDisplay } = this.state;
const { order, orderby } = getSortArgs( this.state.orderby );
return (
<FrontendBlock
attributes={ attributes }
categoryIds={ categoryIds }
onAppendReviews={ this.onAppendReviews }
onChangeOrderby={ this.onChangeOrderby }
onReviewsAppended={ this.onReviewsAppended }
onReviewsLoadError={ this.onReviewsLoadError }
onReviewsReplaced={ this.onReviewsReplaced }
order={ order }
orderby={ orderby }
productId={ productId }
reviewsToDisplay={ reviewsToDisplay }
/>
);
}
}
FrontendContainerBlock.propTypes = {
/**
* The attributes for this block.
*/
attributes: PropTypes.object.isRequired,
};
export default FrontendContainerBlock;

View File

@@ -0,0 +1,26 @@
/**
* Internal dependencies
*/
import FrontendContainerBlock from './frontend-container-block.js';
import renderFrontend from '../../utils/render-frontend.js';
const selector = `
.wp-block-woocommerce-all-reviews,
.wp-block-woocommerce-reviews-by-product,
.wp-block-woocommerce-reviews-by-category
`;
const getProps = ( el ) => {
return {
attributes: {
showReviewDate: el.classList.contains( 'has-date' ),
showReviewerName: el.classList.contains( 'has-name' ),
showReviewImage: el.classList.contains( 'has-image' ),
showReviewRating: el.classList.contains( 'has-rating' ),
showReviewContent: el.classList.contains( 'has-content' ),
showProductName: el.classList.contains( 'has-product-name' ),
},
};
};
renderFrontend( selector, FrontendContainerBlock, getProps );

View File

@@ -0,0 +1,204 @@
/**
* External dependencies
*/
import { __, _n, sprintf } from '@wordpress/i18n';
import { InspectorControls } from '@wordpress/editor';
import {
Button,
PanelBody,
Placeholder,
ToggleControl,
withSpokenMessages,
} from '@wordpress/components';
import { SearchListItem } from '@woocommerce/components';
import { Fragment } from '@wordpress/element';
import PropTypes from 'prop-types';
import ProductCategoryControl from '@woocommerce/block-components/product-category-control';
import { IconReviewsByCategory } from '@woocommerce/block-components/icons';
/**
* Internal dependencies
*/
import EditorContainerBlock from '../editor-container-block.js';
import NoReviewsPlaceholder from './no-reviews-placeholder.js';
import {
getBlockControls,
getSharedReviewContentControls,
getSharedReviewListControls,
} from '../edit-utils.js';
/**
* Component to handle edit mode of "Reviews by Category".
*/
const ReviewsByCategoryEditor = ( {
attributes,
debouncedSpeak,
setAttributes,
} ) => {
const { editMode, categoryIds } = attributes;
const renderCategoryControlItem = ( args ) => {
const { item, search, depth = 0 } = args;
const classes = [ 'woocommerce-product-categories__item' ];
if ( search.length ) {
classes.push( 'is-searching' );
}
if ( depth === 0 && item.parent !== 0 ) {
classes.push( 'is-skip-level' );
}
const accessibleName = ! item.breadcrumbs.length
? item.name
: `${ item.breadcrumbs.join( ', ' ) }, ${ item.name }`;
return (
<SearchListItem
className={ classes.join( ' ' ) }
{ ...args }
showCount
aria-label={ sprintf(
_n(
'%s, has %d product',
'%s, has %d products',
item.count,
'woocommerce'
),
accessibleName,
item.count
) }
/>
);
};
const getInspectorControls = () => {
return (
<InspectorControls key="inspector">
<PanelBody
title={ __( 'Category', 'woocommerce' ) }
initialOpen={ false }
>
<ProductCategoryControl
selected={ attributes.categoryIds }
onChange={ ( value = [] ) => {
const ids = value.map( ( { id } ) => id );
setAttributes( { categoryIds: ids } );
} }
renderItem={ renderCategoryControlItem }
/>
</PanelBody>
<PanelBody
title={ __( 'Content', 'woocommerce' ) }
>
<ToggleControl
label={ __(
'Product name',
'woocommerce'
) }
checked={ attributes.showProductName }
onChange={ () =>
setAttributes( {
showProductName: ! attributes.showProductName,
} )
}
/>
{ getSharedReviewContentControls(
attributes,
setAttributes
) }
</PanelBody>
<PanelBody
title={ __(
'List Settings',
'woocommerce'
) }
>
{ getSharedReviewListControls( attributes, setAttributes ) }
</PanelBody>
</InspectorControls>
);
};
const renderEditMode = () => {
const onDone = () => {
setAttributes( { editMode: false } );
debouncedSpeak(
__(
'Showing Reviews by Category block preview.',
'woocommerce'
)
);
};
return (
<Placeholder
icon={
<IconReviewsByCategory className="block-editor-block-icon" />
}
label={ __(
'Reviews by Category',
'woocommerce'
) }
>
{ __(
'Show product reviews from specific categories.',
'woocommerce'
) }
<div className="wc-block-reviews__selection">
<ProductCategoryControl
selected={ attributes.categoryIds }
onChange={ ( value = [] ) => {
const ids = value.map( ( { id } ) => id );
setAttributes( { categoryIds: ids } );
} }
showReviewCount={ true }
/>
<Button isDefault onClick={ onDone }>
{ __( 'Done', 'woocommerce' ) }
</Button>
</div>
</Placeholder>
);
};
if ( ! categoryIds || editMode ) {
return renderEditMode();
}
return (
<Fragment>
{ getBlockControls( editMode, setAttributes ) }
{ getInspectorControls() }
<EditorContainerBlock
attributes={ attributes }
className="wc-block-reviews-by-category"
icon={
<IconReviewsByCategory className="block-editor-block-icon" />
}
name={ __(
'Reviews by Category',
'woocommerce'
) }
noReviewsPlaceholder={ NoReviewsPlaceholder }
/>
</Fragment>
);
};
ReviewsByCategoryEditor.propTypes = {
/**
* The attributes for this block.
*/
attributes: PropTypes.object.isRequired,
/**
* The register block name.
*/
name: PropTypes.string.isRequired,
/**
* A callback to update attributes.
*/
setAttributes: PropTypes.func.isRequired,
// from withSpokenMessages
debouncedSpeak: PropTypes.func.isRequired,
};
export default withSpokenMessages( ReviewsByCategoryEditor );

View File

@@ -0,0 +1,3 @@
.wc-block-reviews-by-category__selection {
width: 100%;
}

View File

@@ -0,0 +1,69 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { registerBlockType } from '@wordpress/blocks';
import { IconReviewsByCategory } from '@woocommerce/block-components/icons';
/**
* Internal dependencies
*/
import '../editor.scss';
import Editor from './edit';
import sharedAttributes from '../attributes';
import save from '../save.js';
import { example } from '../example';
/**
* Register and run the "Reviews by category" block.
*/
registerBlockType( 'woocommerce/reviews-by-category', {
title: __( 'Reviews by Category', 'woocommerce' ),
icon: {
src: <IconReviewsByCategory />,
foreground: '#96588a',
},
category: 'woocommerce',
keywords: [ __( 'WooCommerce', 'woocommerce' ) ],
description: __(
'Show product reviews from specific categories.',
'woocommerce'
),
example: {
...example,
attributes: {
...example.attributes,
categoryIds: [ 1 ],
showProductName: true,
},
},
attributes: {
...sharedAttributes,
/**
* The ids of the categories to load reviews for.
*/
categoryIds: {
type: 'array',
default: [],
},
/**
* Show the product name.
*/
showProductName: {
type: 'boolean',
default: true,
},
},
/**
* Renders and manages the block.
*/
edit( props ) {
return <Editor { ...props } />;
},
/**
* Save the props to post content.
*/
save,
} );

View File

@@ -0,0 +1,28 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Placeholder } from '@wordpress/components';
import { IconReviewsByCategory } from '@woocommerce/block-components/icons';
const NoReviewsPlaceholder = () => {
return (
<Placeholder
className="wc-block-reviews-by-category"
icon={
<IconReviewsByCategory className="block-editor-block-icon" />
}
label={ __(
'Reviews by Category',
'woocommerce'
) }
>
{ __(
'This block lists reviews for products from selected categories. The selected categories do not have any reviews yet, but they will show up here when they do.',
'woocommerce'
) }
</Placeholder>
);
};
export default NoReviewsPlaceholder;

View File

@@ -0,0 +1,192 @@
/**
* External dependencies
*/
import { __, _n, sprintf } from '@wordpress/i18n';
import { InspectorControls } from '@wordpress/editor';
import {
Button,
PanelBody,
Placeholder,
withSpokenMessages,
} from '@wordpress/components';
import { SearchListItem } from '@woocommerce/components';
import { Fragment } from '@wordpress/element';
import PropTypes from 'prop-types';
import ProductControl from '@woocommerce/block-components/product-control';
import { IconReviewsByProduct } from '@woocommerce/block-components/icons';
/**
* Internal dependencies
*/
import EditorContainerBlock from '../editor-container-block.js';
import NoReviewsPlaceholder from './no-reviews-placeholder.js';
import {
getBlockControls,
getSharedReviewContentControls,
getSharedReviewListControls,
} from '../edit-utils.js';
/**
* Component to handle edit mode of "Reviews by Product".
*/
const ReviewsByProductEditor = ( {
attributes,
debouncedSpeak,
setAttributes,
} ) => {
const { editMode, productId } = attributes;
const renderProductControlItem = ( args ) => {
const { item = 0 } = args;
return (
<SearchListItem
{ ...args }
countLabel={ sprintf(
_n(
'%d Review',
'%d Reviews',
item.review_count,
'woocommerce'
),
item.review_count
) }
showCount
aria-label={ sprintf(
_n(
'%s, has %d review',
'%s, has %d reviews',
item.review_count,
'woocommerce'
),
item.name,
item.review_count
) }
/>
);
};
const getInspectorControls = () => {
return (
<InspectorControls key="inspector">
<PanelBody
title={ __( 'Product', 'woocommerce' ) }
initialOpen={ false }
>
<ProductControl
selected={ attributes.productId || 0 }
onChange={ ( value = [] ) => {
const id = value[ 0 ] ? value[ 0 ].id : 0;
setAttributes( { productId: id } );
} }
renderItem={ renderProductControlItem }
/>
</PanelBody>
<PanelBody
title={ __( 'Content', 'woocommerce' ) }
>
{ getSharedReviewContentControls(
attributes,
setAttributes
) }
</PanelBody>
<PanelBody
title={ __(
'List Settings',
'woocommerce'
) }
>
{ getSharedReviewListControls( attributes, setAttributes ) }
</PanelBody>
</InspectorControls>
);
};
const renderEditMode = () => {
const onDone = () => {
setAttributes( { editMode: false } );
debouncedSpeak(
__(
'Showing Reviews by Product block preview.',
'woocommerce'
)
);
};
return (
<Placeholder
icon={
<IconReviewsByProduct className="block-editor-block-icon" />
}
label={ __(
'Reviews by Product',
'woocommerce'
) }
>
{ __(
'Show reviews of your product to build trust',
'woocommerce'
) }
<div className="wc-block-reviews__selection">
<ProductControl
selected={ attributes.productId || 0 }
onChange={ ( value = [] ) => {
const id = value[ 0 ] ? value[ 0 ].id : 0;
setAttributes( { productId: id } );
} }
queryArgs={ {
orderby: 'comment_count',
order: 'desc',
} }
renderItem={ renderProductControlItem }
/>
<Button isDefault onClick={ onDone }>
{ __( 'Done', 'woocommerce' ) }
</Button>
</div>
</Placeholder>
);
};
if ( ! productId || editMode ) {
return renderEditMode();
}
return (
<Fragment>
{ getBlockControls( editMode, setAttributes ) }
{ getInspectorControls() }
<EditorContainerBlock
attributes={ attributes }
className="wc-block-all-reviews"
icon={
<IconReviewsByProduct className="block-editor-block-icon" />
}
name={ __(
'Reviews by Product',
'woocommerce'
) }
noReviewsPlaceholder={ NoReviewsPlaceholder }
/>
</Fragment>
);
};
ReviewsByProductEditor.propTypes = {
/**
* The attributes for this block.
*/
attributes: PropTypes.object.isRequired,
/**
* The register block name.
*/
name: PropTypes.string.isRequired,
/**
* A callback to update attributes.
*/
setAttributes: PropTypes.func.isRequired,
// from withSpokenMessages
debouncedSpeak: PropTypes.func.isRequired,
};
export default withSpokenMessages( ReviewsByProductEditor );

View File

@@ -0,0 +1,13 @@
.wc-block-reviews-by-product__selection {
width: 100%;
}
.components-base-control {
+ .wc-block-reviews-by-product__notice {
margin: -$gap 0 $gap;
}
&:nth-last-child(2) + .wc-block-reviews-by-product__notice {
margin: -$gap 0 $gap-small;
}
}

View File

@@ -0,0 +1,60 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { registerBlockType } from '@wordpress/blocks';
import { IconReviewsByProduct } from '@woocommerce/block-components/icons';
/**
* Internal dependencies
*/
import '../editor.scss';
import Editor from './edit';
import sharedAttributes from '../attributes';
import save from '../save.js';
import { example } from '../example';
/**
* Register and run the "Reviews by Product" block.
*/
registerBlockType( 'woocommerce/reviews-by-product', {
title: __( 'Reviews by Product', 'woocommerce' ),
icon: {
src: <IconReviewsByProduct />,
foreground: '#96588a',
},
category: 'woocommerce',
keywords: [ __( 'WooCommerce', 'woocommerce' ) ],
description: __(
'Show reviews of your product to build trust.',
'woocommerce'
),
example: {
...example,
attributes: {
...example.attributes,
productId: 1,
},
},
attributes: {
...sharedAttributes,
/**
* The id of the product to load reviews for.
*/
productId: {
type: 'number',
},
},
/**
* Renders and manages the block.
*/
edit( props ) {
return <Editor { ...props } />;
},
/**
* Save the props to post content.
*/
save,
} );

View File

@@ -0,0 +1,61 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { Placeholder, Spinner } from '@wordpress/components';
import PropTypes from 'prop-types';
import ErrorPlaceholder from '@woocommerce/block-components/error-placeholder';
import { IconReviewsByProduct } from '@woocommerce/block-components/icons';
import { withProduct } from '@woocommerce/block-hocs';
const NoReviewsPlaceholder = ( { error, getProduct, isLoading, product } ) => {
const renderApiError = () => (
<ErrorPlaceholder
className="wc-block-featured-product-error"
error={ error }
isLoading={ isLoading }
onRetry={ getProduct }
/>
);
if ( error ) {
return renderApiError();
}
const content =
! product || isLoading ? (
<Spinner />
) : (
sprintf(
__(
"This block lists reviews for a selected product. %s doesn't have any reviews yet, but they will show up here when it does.",
'woocommerce'
),
product.name
)
);
return (
<Placeholder
className="wc-block-reviews-by-product"
icon={
<IconReviewsByProduct className="block-editor-block-icon" />
}
label={ __( 'Reviews by Product', 'woocommerce' ) }
>
{ content }
</Placeholder>
);
};
NoReviewsPlaceholder.propTypes = {
// from withProduct
error: PropTypes.object,
isLoading: PropTypes.bool,
product: PropTypes.shape( {
name: PropTypes.node,
review_count: PropTypes.number,
} ),
};
export default withProduct( NoReviewsPlaceholder );

View File

@@ -0,0 +1,45 @@
/**
* Internal dependencies
*/
import './editor.scss';
import { getBlockClassName } from './utils.js';
export default ( { attributes } ) => {
const {
categoryIds,
imageType,
orderby,
productId,
reviewsOnPageLoad,
reviewsOnLoadMore,
showLoadMore,
showOrderby,
} = attributes;
const data = {
'data-image-type': imageType,
'data-orderby': orderby,
'data-reviews-on-page-load': reviewsOnPageLoad,
'data-reviews-on-load-more': reviewsOnLoadMore,
'data-show-load-more': showLoadMore,
'data-show-orderby': showOrderby,
};
let className = 'wc-block-all-reviews';
if ( productId ) {
data[ 'data-product-id' ] = productId;
className = 'wc-block-reviews-by-product';
}
if ( Array.isArray( categoryIds ) ) {
data[ 'data-category-ids' ] = categoryIds.join( ',' );
className = 'wc-block-reviews-by-category';
}
return (
<div
className={ getBlockClassName( className, attributes ) }
{ ...data }
/>
);
};

View File

@@ -0,0 +1,68 @@
/**
* External dependencies
*/
import apiFetch from '@wordpress/api-fetch';
import classNames from 'classnames';
import { ENABLE_REVIEW_RATING } from '@woocommerce/block-settings';
export const getSortArgs = ( sortValue ) => {
if ( ENABLE_REVIEW_RATING ) {
if ( sortValue === 'lowest-rating' ) {
return {
order: 'asc',
orderby: 'rating',
};
}
if ( sortValue === 'highest-rating' ) {
return {
order: 'desc',
orderby: 'rating',
};
}
}
return {
order: 'desc',
orderby: 'date_gmt',
};
};
export const getReviews = ( args ) => {
return apiFetch( {
path:
'/wc/blocks/products/reviews?' +
Object.entries( args )
.map( ( arg ) => arg.join( '=' ) )
.join( '&' ),
parse: false,
} ).then( ( response ) => {
return response.json().then( ( reviews ) => {
const totalReviews = parseInt(
response.headers.get( 'x-wp-total' ),
10
);
return { reviews, totalReviews };
} );
} );
};
export const getBlockClassName = ( blockClassName, attributes ) => {
const {
className,
showReviewDate,
showReviewerName,
showReviewContent,
showProductName,
showReviewImage,
showReviewRating,
} = attributes;
return classNames( blockClassName, className, {
'has-image': showReviewImage,
'has-name': showReviewerName,
'has-date': showReviewDate,
'has-rating': showReviewRating,
'has-content': showReviewContent,
'has-product-name': showProductName,
} );
};