khaihihi
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 );
|
||||
@@ -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
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
} );
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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 );
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
} );
|
||||
@@ -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: ")";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 );
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -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 ],
|
||||
},
|
||||
};
|
||||
@@ -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 />;
|
||||
},
|
||||
} );
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 );
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -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 ],
|
||||
},
|
||||
};
|
||||
@@ -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 />;
|
||||
},
|
||||
} );
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 );
|
||||
@@ -0,0 +1,3 @@
|
||||
.wc-block-handpicked-products__selection {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
} );
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
export const ROUND_UP = 'ROUND_UP';
|
||||
export const ROUND_DOWN = 'ROUND_DOWN';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
} );
|
||||
@@ -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 );
|
||||
} );
|
||||
} );
|
||||
@@ -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 ),
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
},
|
||||
} );
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
} );
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 );
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
} );
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
},
|
||||
} );
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
},
|
||||
} );
|
||||
@@ -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 );
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
} );
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 );
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
} );
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
},
|
||||
} );
|
||||
@@ -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 );
|
||||
@@ -0,0 +1,3 @@
|
||||
.wc-block-products-by-attribute__selection {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
} );
|
||||
@@ -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;
|
||||
@@ -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 );
|
||||
@@ -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
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
} );
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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 )
|
||||
: [],
|
||||
},
|
||||
];
|
||||
} );
|
||||
};
|
||||
@@ -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 } ) }
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
} );
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 );
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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 );
|
||||
@@ -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;
|
||||
@@ -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 );
|
||||
@@ -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 );
|
||||
@@ -0,0 +1,3 @@
|
||||
.wc-block-reviews-by-category__selection {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -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,
|
||||
} );
|
||||
@@ -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;
|
||||
@@ -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 );
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
} );
|
||||
@@ -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 );
|
||||
@@ -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 }
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
} );
|
||||
};
|
||||
Reference in New Issue
Block a user