File: /var/www/vhosts/uyarreklam.com.tr/httpdocs/atomic.tar
blocks/component-init.js 0000644 00000005720 15154703035 0011331 0 ustar 00 /**
* External dependencies
*/
import { registerBlockComponent } from '@woocommerce/blocks-registry';
import { lazy } from '@wordpress/element';
import { WC_BLOCKS_BUILD_URL } from '@woocommerce/block-settings';
// Modify webpack publicPath at runtime based on location of WordPress Plugin.
// eslint-disable-next-line no-undef,camelcase
__webpack_public_path__ = WC_BLOCKS_BUILD_URL;
registerBlockComponent( {
blockName: 'woocommerce/product-price',
component: lazy( () =>
import(
/* webpackChunkName: "product-price" */ './product-elements/price/block'
)
),
} );
registerBlockComponent( {
blockName: 'woocommerce/product-image',
component: lazy( () =>
import(
/* webpackChunkName: "product-image" */ './product-elements/image/frontend'
)
),
} );
registerBlockComponent( {
blockName: 'woocommerce/product-title',
component: lazy( () =>
import(
/* webpackChunkName: "product-title" */ './product-elements/title/frontend'
)
),
} );
registerBlockComponent( {
blockName: 'woocommerce/product-rating',
component: lazy( () =>
import(
/* webpackChunkName: "product-rating" */ './product-elements/rating/block'
)
),
} );
registerBlockComponent( {
blockName: 'woocommerce/product-rating-stars',
component: lazy( () =>
import(
/* webpackChunkName: "product-rating-stars" */ './product-elements/rating-stars/block'
)
),
} );
registerBlockComponent( {
blockName: 'woocommerce/product-rating-counter',
component: lazy( () =>
import(
/* webpackChunkName: "product-rating-counter" */ './product-elements/rating-counter/block'
)
),
} );
registerBlockComponent( {
blockName: 'woocommerce/product-average-rating',
component: lazy( () =>
import(
/* webpackChunkName: "product-average-rating" */ './product-elements/average-rating/block'
)
),
} );
registerBlockComponent( {
blockName: 'woocommerce/product-button',
component: lazy( () =>
import(
/* webpackChunkName: "product-button" */ './product-elements/button/block'
)
),
} );
registerBlockComponent( {
blockName: 'woocommerce/product-summary',
component: lazy( () =>
import(
/* webpackChunkName: "product-summary" */ './product-elements/summary/block'
)
),
} );
registerBlockComponent( {
blockName: 'woocommerce/product-sale-badge',
component: lazy( () =>
import(
/* webpackChunkName: "product-sale-badge" */ './product-elements/sale-badge/block'
)
),
} );
registerBlockComponent( {
blockName: 'woocommerce/product-sku',
component: lazy( () =>
import(
/* webpackChunkName: "product-sku" */ './product-elements/sku/block'
)
),
} );
registerBlockComponent( {
blockName: 'woocommerce/product-stock-indicator',
component: lazy( () =>
import(
/* webpackChunkName: "product-stock-indicator" */ './product-elements/stock-indicator/block'
)
),
} );
registerBlockComponent( {
blockName: 'woocommerce/product-add-to-cart',
component: lazy( () =>
import(
/* webpackChunkName: "product-add-to-cart" */ './product-elements/add-to-cart/frontend'
)
),
} );
blocks/index.js 0000644 00000001453 15154703035 0007474 0 ustar 00 /**
* Internal dependencies
*/
import './product-elements/title';
import './product-elements/price';
import './product-elements/image';
import './product-elements/rating';
import './product-elements/rating-stars';
import './product-elements/rating-counter';
import './product-elements/average-rating';
import './product-elements/button';
import './product-elements/summary';
import './product-elements/sale-badge';
import './product-elements/sku';
import './product-elements/stock-indicator';
import './product-elements/add-to-cart';
import './product-elements/add-to-cart-form';
import './product-elements/product-image-gallery';
import './product-elements/product-details';
import './product-elements/product-reviews';
import './product-elements/related-products';
import './product-elements/product-meta';
blocks/product-elements/add-to-cart/attributes.ts 0000644 00000000265 15154703035 0016156 0 ustar 00 export const blockAttributes = {
showFormElements: {
type: 'boolean',
default: false,
},
productId: {
type: 'number',
default: 0,
},
};
export default blockAttributes;
blocks/product-elements/add-to-cart/block.tsx 0000644 00000003633 15154703035 0015254 0 ustar 00 /**
* External dependencies
*/
import classnames from 'classnames';
import {
AddToCartFormContextProvider,
useAddToCartFormContext,
} from '@woocommerce/base-context';
import { useProductDataContext } from '@woocommerce/shared-context';
import { isEmpty } from '@woocommerce/types';
import { withProductDataContext } from '@woocommerce/shared-hocs';
/**
* Internal dependencies
*/
import './style.scss';
import { AddToCartButton } from './shared';
import {
SimpleProductForm,
VariableProductForm,
ExternalProductForm,
GroupedProductForm,
} from './product-types';
interface Props {
/**
* CSS Class name for the component.
*/
className?: string;
/**
* Whether or not to show form elements.
*/
showFormElements?: boolean;
}
/**
* Renders the add to cart form using useAddToCartFormContext.
*/
const AddToCartForm = () => {
const { showFormElements, productType } = useAddToCartFormContext();
if ( showFormElements ) {
if ( productType === 'variable' ) {
return <VariableProductForm />;
}
if ( productType === 'grouped' ) {
return <GroupedProductForm />;
}
if ( productType === 'external' ) {
return <ExternalProductForm />;
}
if ( productType === 'simple' || productType === 'variation' ) {
return <SimpleProductForm />;
}
return null;
}
return <AddToCartButton />;
};
/**
* Product Add to Form Block Component.
*/
const Block = ( { className, showFormElements }: Props ) => {
const { product } = useProductDataContext();
const componentClass = classnames(
className,
'wc-block-components-product-add-to-cart',
{
'wc-block-components-product-add-to-cart--placeholder':
isEmpty( product ),
}
);
return (
<AddToCartFormContextProvider
product={ product }
showFormElements={ showFormElements }
>
<div className={ componentClass }>
<AddToCartForm />
</div>
</AddToCartFormContextProvider>
);
};
export default withProductDataContext( Block );
blocks/product-elements/add-to-cart/constants.tsx 0000644 00000000766 15154703035 0016202 0 ustar 00 /**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { cart } from '@woocommerce/icons';
import { Icon } from '@wordpress/icons';
export const BLOCK_TITLE = __( 'Add to Cart', 'woo-gutenberg-products-block' );
export const BLOCK_ICON = (
<Icon icon={ cart } className="wc-block-editor-components-block-icon" />
);
export const BLOCK_DESCRIPTION = __(
'Displays an add to cart button. Optionally displays other add to cart form elements.',
'woo-gutenberg-products-block'
);
blocks/product-elements/add-to-cart/edit.tsx 0000644 00000004642 15154703035 0015110 0 ustar 00 /**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import EditProductLink from '@woocommerce/editor-components/edit-product-link';
import { useProductDataContext } from '@woocommerce/shared-context';
import classnames from 'classnames';
import {
Disabled,
PanelBody,
ToggleControl,
Notice,
} from '@wordpress/components';
import { InspectorControls } from '@wordpress/block-editor';
import { productSupportsAddToCartForm } from '@woocommerce/base-utils';
/**
* Internal dependencies
*/
import './style.scss';
import Block from './block';
import withProductSelector from '../shared/with-product-selector';
import { BLOCK_TITLE, BLOCK_ICON } from './constants';
interface EditProps {
attributes: {
className: string;
showFormElements: boolean;
};
setAttributes: ( attributes: { showFormElements: boolean } ) => void;
}
const Edit = ( { attributes, setAttributes }: EditProps ) => {
const { product } = useProductDataContext();
const { className, showFormElements } = attributes;
return (
<div
className={ classnames(
className,
'wc-block-components-product-add-to-cart'
) }
>
<EditProductLink productId={ product.id } />
<InspectorControls>
<PanelBody
title={ __( 'Layout', 'woo-gutenberg-products-block' ) }
>
{ productSupportsAddToCartForm( product ) ? (
<ToggleControl
label={ __(
'Display form elements',
'woo-gutenberg-products-block'
) }
help={ __(
'Depending on product type, allow customers to select a quantity, variations etc.',
'woo-gutenberg-products-block'
) }
checked={ showFormElements }
onChange={ () =>
setAttributes( {
showFormElements: ! showFormElements,
} )
}
/>
) : (
<Notice
className="wc-block-components-product-add-to-cart-notice"
isDismissible={ false }
status="info"
>
{ __(
'This product does not support the block based add to cart form. A link to the product page will be shown instead.',
'woo-gutenberg-products-block'
) }
</Notice>
) }
</PanelBody>
</InspectorControls>
<Disabled>
<Block { ...attributes } />
</Disabled>
</div>
);
};
export default withProductSelector( {
icon: BLOCK_ICON,
label: BLOCK_TITLE,
description: __(
'Choose a product to display its add to cart form.',
'woo-gutenberg-products-block'
),
} )( Edit );
blocks/product-elements/add-to-cart/frontend.ts 0000644 00000000411 15154703035 0015600 0 ustar 00 /**
* External dependencies
*/
import { withFilteredAttributes } from '@woocommerce/shared-hocs';
/**
* Internal dependencies
*/
import Block from './block';
import attributes from './attributes';
export default withFilteredAttributes( attributes )( Block );
blocks/product-elements/add-to-cart/index.ts 0000644 00000001064 15154703035 0015075 0 ustar 00 /**
* External dependencies
*/
import { registerExperimentalBlockType } from '@woocommerce/block-settings';
/**
* Internal dependencies
*/
import sharedConfig from '../shared/config';
import edit from './edit';
import attributes from './attributes';
import {
BLOCK_TITLE as title,
BLOCK_ICON as icon,
BLOCK_DESCRIPTION as description,
} from './constants';
const blockConfig = {
title,
description,
icon: { src: icon },
edit,
attributes,
};
registerExperimentalBlockType( 'woocommerce/product-add-to-cart', {
...sharedConfig,
...blockConfig,
} );
blocks/product-elements/add-to-cart/product-types/external.tsx 0000644 00000000336 15154703035 0020623 0 ustar 00 /**
* Internal dependencies
*/
import AddToCartButton from '../shared/add-to-cart-button';
/**
* External Product Add To Cart Form
*/
const External = () => {
return <AddToCartButton />;
};
export default External;
blocks/product-elements/add-to-cart/product-types/grouped.tsx 0000644 00000000245 15154703035 0020445 0 ustar 00 /**
* Grouped Product Add To Cart Form
*/
const Grouped = () => (
<p>This is a placeholder for the grouped products form element.</p>
);
export default Grouped;
blocks/product-elements/add-to-cart/product-types/index.ts 0000644 00000000364 15154703035 0017721 0 ustar 00 export { default as SimpleProductForm } from './simple';
export { default as VariableProductForm } from './variable/index';
export { default as ExternalProductForm } from './external';
export { default as GroupedProductForm } from './grouped';
blocks/product-elements/add-to-cart/product-types/simple.tsx 0000644 00000002106 15154703035 0020267 0 ustar 00 /**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useAddToCartFormContext } from '@woocommerce/base-context';
/**
* Internal dependencies
*/
import { AddToCartButton, QuantityInput, ProductUnavailable } from '../shared';
/**
* Simple Product Add To Cart Form
*/
const Simple = () => {
// @todo Add types for `useAddToCartFormContext`
const {
product,
quantity,
minQuantity,
maxQuantity,
multipleOf,
dispatchActions,
isDisabled,
} = useAddToCartFormContext();
if ( product.id && ! product.is_purchasable ) {
return <ProductUnavailable />;
}
if ( product.id && ! product.is_in_stock ) {
return (
<ProductUnavailable
reason={ __(
'This product is currently out of stock and cannot be purchased.',
'woo-gutenberg-products-block'
) }
/>
);
}
return (
<>
<QuantityInput
value={ quantity }
min={ minQuantity }
max={ maxQuantity }
step={ multipleOf }
disabled={ isDisabled }
onChange={ dispatchActions.setQuantity }
/>
<AddToCartButton />
</>
);
};
export default Simple;
blocks/product-elements/add-to-cart/product-types/variable/index.tsx 0000644 00000002347 15154703035 0021701 0 ustar 00 /**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useAddToCartFormContext } from '@woocommerce/base-context';
/**
* Internal dependencies
*/
import {
AddToCartButton,
QuantityInput,
ProductUnavailable,
} from '../../shared';
import VariationAttributes from './variation-attributes';
/**
* Variable Product Add To Cart Form
*/
const Variable = () => {
// @todo Add types for `useAddToCartFormContext`
const {
product,
quantity,
minQuantity,
maxQuantity,
multipleOf,
dispatchActions,
isDisabled,
} = useAddToCartFormContext();
if ( product.id && ! product.is_purchasable ) {
return <ProductUnavailable />;
}
if ( product.id && ! product.is_in_stock ) {
return (
<ProductUnavailable
reason={ __(
'This product is currently out of stock and cannot be purchased.',
'woo-gutenberg-products-block'
) }
/>
);
}
return (
<>
<VariationAttributes
product={ product }
dispatchers={ dispatchActions }
/>
<QuantityInput
value={ quantity }
min={ minQuantity }
max={ maxQuantity }
step={ multipleOf }
disabled={ isDisabled }
onChange={ dispatchActions.setQuantity }
/>
<AddToCartButton />
</>
);
};
export default Variable;
blocks/product-elements/add-to-cart/product-types/variable/types.ts 0000644 00000000427 15154703035 0021543 0 ustar 00 /**
* External dependencies
*/
import { Dictionary } from '@woocommerce/types';
export type AttributesMap = Record<
string,
{ id: number; attributes: Dictionary }
>;
export interface VariationParam {
id: number;
variation: {
attribute: string;
value: string;
}[];
}
blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/attribute-picker.tsx0000644 00000006726 15154703035 0030235 0 ustar 00 /**
* External dependencies
*/
import { useState, useEffect, useMemo } from '@wordpress/element';
import { useShallowEqual } from '@woocommerce/base-hooks';
import type { SelectControl } from '@wordpress/components';
import { Dictionary, ProductResponseAttributeItem } from '@woocommerce/types';
/**
* Internal dependencies
*/
import AttributeSelectControl from './attribute-select-control';
import {
getVariationMatchingSelectedAttributes,
getActiveSelectControlOptions,
getDefaultAttributes,
} from './utils';
import { AttributesMap, VariationParam } from '../types';
interface Props {
attributes: Record< string, ProductResponseAttributeItem >;
setRequestParams: ( param: VariationParam ) => void;
variationAttributes: AttributesMap;
}
/**
* AttributePicker component.
*/
const AttributePicker = ( {
attributes,
variationAttributes,
setRequestParams,
}: Props ) => {
const currentAttributes = useShallowEqual( attributes );
const currentVariationAttributes = useShallowEqual( variationAttributes );
const [ variationId, setVariationId ] = useState( 0 );
const [ selectedAttributes, setSelectedAttributes ] =
useState< Dictionary >( {} );
const [ hasSetDefaults, setHasSetDefaults ] = useState( false );
// Get options for each attribute picker.
const filteredAttributeOptions = useMemo( () => {
return getActiveSelectControlOptions(
currentAttributes,
currentVariationAttributes,
selectedAttributes
);
}, [ selectedAttributes, currentAttributes, currentVariationAttributes ] );
// Set default attributes as selected.
useEffect( () => {
if ( ! hasSetDefaults ) {
const defaultAttributes = getDefaultAttributes( attributes );
if ( defaultAttributes ) {
setSelectedAttributes( {
...defaultAttributes,
} );
}
setHasSetDefaults( true );
}
}, [ selectedAttributes, attributes, hasSetDefaults ] );
// Select variations when selections are change.
useEffect( () => {
const hasSelectedAllAttributes =
Object.values( selectedAttributes ).filter(
( selected ) => selected !== ''
).length === Object.keys( currentAttributes ).length;
if ( hasSelectedAllAttributes ) {
setVariationId(
getVariationMatchingSelectedAttributes(
currentAttributes,
currentVariationAttributes,
selectedAttributes
)
);
} else if ( variationId > 0 ) {
// Unset variation when form is incomplete.
setVariationId( 0 );
}
}, [
selectedAttributes,
variationId,
currentAttributes,
currentVariationAttributes,
] );
// Set requests params as variation ID and data changes.
useEffect( () => {
setRequestParams( {
id: variationId,
variation: Object.keys( selectedAttributes ).map(
( attributeName ) => {
return {
attribute: attributeName,
value: selectedAttributes[ attributeName ],
};
}
),
} );
}, [ setRequestParams, variationId, selectedAttributes ] );
return (
<div className="wc-block-components-product-add-to-cart-attribute-picker">
{ Object.keys( currentAttributes ).map( ( attributeName ) => (
<AttributeSelectControl
key={ attributeName }
attributeName={ attributeName }
options={
filteredAttributeOptions[ attributeName ].filter(
Boolean
) as SelectControl.Option[]
}
value={ selectedAttributes[ attributeName ] }
onChange={ ( selected ) => {
setSelectedAttributes( {
...selectedAttributes,
[ attributeName ]: selected,
} );
} }
/>
) ) }
</div>
);
};
export default AttributePicker;
add-to-cart/product-types/variable/variation-attributes/attribute-select-control.tsx 0000644 00000004534 15154703035 0031631 0 ustar 00 blocks/product-elements /**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { decodeEntities } from '@wordpress/html-entities';
import { SelectControl } from 'wordpress-components';
import type { SelectControl as SelectControlType } from '@wordpress/components';
import { useEffect } from '@wordpress/element';
import classnames from 'classnames';
import { ValidationInputError } from '@woocommerce/blocks-checkout';
import { VALIDATION_STORE_KEY } from '@woocommerce/block-data';
import { useDispatch, useSelect } from '@wordpress/data';
interface Props extends SelectControlType.Props< string > {
attributeName: string;
errorMessage?: string;
}
// Default option for select boxes.
const selectAnOption = {
value: '',
label: __( 'Select an option', 'woo-gutenberg-products-block' ),
};
/**
* VariationAttributeSelect component.
*/
const AttributeSelectControl = ( {
attributeName,
options = [],
value = '',
onChange = () => void 0,
errorMessage = __(
'Please select a value.',
'woo-gutenberg-products-block'
),
}: Props ) => {
const errorId = attributeName;
const { setValidationErrors, clearValidationError } =
useDispatch( VALIDATION_STORE_KEY );
const { error } = useSelect( ( select ) => {
const store = select( VALIDATION_STORE_KEY );
return {
error: store.getValidationError( errorId ) || {},
};
} );
useEffect( () => {
if ( value ) {
clearValidationError( errorId );
} else {
setValidationErrors( {
[ errorId ]: {
message: errorMessage,
hidden: true,
},
} );
}
}, [
value,
errorId,
errorMessage,
clearValidationError,
setValidationErrors,
] );
// Remove validation errors when unmounted.
useEffect(
() => () => void clearValidationError( errorId ),
[ errorId, clearValidationError ]
);
return (
<div className="wc-block-components-product-add-to-cart-attribute-picker__container">
<SelectControl
label={ decodeEntities( attributeName ) }
value={ value || '' }
options={ [ selectAnOption, ...options ] }
onChange={ onChange }
required={ true }
className={ classnames(
'wc-block-components-product-add-to-cart-attribute-picker__select',
{
'has-error': error?.message && ! error?.hidden,
}
) }
/>
<ValidationInputError
propertyName={ errorId }
elementId={ errorId }
/>
</div>
);
};
export default AttributeSelectControl;
blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/index.tsx 0000644 00000001652 15154703035 0026057 0 ustar 00 /**
* External dependencies
*/
import { ProductResponseItem } from '@woocommerce/types';
/**
* Internal dependencies
*/
import './style.scss';
import AttributePicker from './attribute-picker';
import { getAttributes, getVariationAttributes } from './utils';
interface Props {
dispatchers: { setRequestParams: () => void };
product: ProductResponseItem;
}
/**
* VariationAttributes component.
*/
const VariationAttributes = ( { dispatchers, product }: Props ) => {
const attributes = getAttributes( product.attributes );
const variationAttributes = getVariationAttributes( product.variations );
if (
Object.keys( attributes ).length === 0 ||
Object.keys( variationAttributes ).length === 0
) {
return null;
}
return (
<AttributePicker
attributes={ attributes }
variationAttributes={ variationAttributes }
setRequestParams={ dispatchers.setRequestParams }
/>
);
};
export default VariationAttributes;
blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/style.scss 0000644 00000001067 15154703035 0026245 0 ustar 00 .wc-block-components-product-add-to-cart-attribute-picker {
margin: 0;
flex-basis: 100%;
label {
display: block;
@include font-size(regular);
}
.wc-block-components-product-add-to-cart-attribute-picker__container {
position: relative;
}
.wc-block-components-product-add-to-cart-attribute-picker__select {
margin: 0 0 em($gap-small) 0;
select {
min-width: 60%;
min-height: 1.75em;
}
&.has-error {
margin-bottom: $gap-large;
select {
border-color: $alert-red;
&:focus {
outline-color: $alert-red;
}
}
}
}
}
blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/test/index.ts 0000644 00000022660 15154703035 0026650 0 ustar 00 /**
* External dependencies
*/
import { ProductResponseAttributeItem } from '@woocommerce/types';
/**
* Internal dependencies
*/
import {
getAttributes,
getVariationAttributes,
getVariationsMatchingSelectedAttributes,
getVariationMatchingSelectedAttributes,
getActiveSelectControlOptions,
getDefaultAttributes,
} from '../utils';
const rawAttributeData: ProductResponseAttributeItem[] = [
{
id: 1,
name: 'Color',
taxonomy: 'pa_color',
has_variations: true,
terms: [
{
id: 22,
name: 'Blue',
slug: 'blue',
default: true,
},
{
id: 23,
name: 'Green',
slug: 'green',
default: false,
},
{
id: 24,
name: 'Red',
slug: 'red',
default: false,
},
],
},
{
id: 0,
name: 'Logo',
taxonomy: 'pa_logo',
has_variations: true,
terms: [
{
id: 0,
name: 'Yes',
slug: 'Yes',
default: true,
},
{
id: 0,
name: 'No',
slug: 'No',
default: false,
},
],
},
{
id: 0,
name: 'Non-variable attribute',
taxonomy: 'pa_non-variable-attribute',
has_variations: false,
terms: [
{
id: 0,
name: 'Test',
slug: 'Test',
default: false,
},
{
id: 0,
name: 'Test 2',
slug: 'Test 2',
default: false,
},
],
},
];
const rawVariations = [
{
id: 35,
attributes: [
{
name: 'Color',
value: 'blue',
},
{
name: 'Logo',
value: 'Yes',
},
],
},
{
id: 28,
attributes: [
{
name: 'Color',
value: 'red',
},
{
name: 'Logo',
value: 'No',
},
],
},
{
id: 29,
attributes: [
{
name: 'Color',
value: 'green',
},
{
name: 'Logo',
value: 'No',
},
],
},
{
id: 30,
attributes: [
{
name: 'Color',
value: 'blue',
},
{
name: 'Logo',
value: 'No',
},
],
},
];
const formattedAttributes = {
Color: {
id: 1,
name: 'Color',
taxonomy: 'pa_color',
has_variations: true,
terms: [
{
id: 22,
name: 'Blue',
slug: 'blue',
default: true,
},
{
id: 23,
name: 'Green',
slug: 'green',
default: false,
},
{
id: 24,
name: 'Red',
slug: 'red',
default: false,
},
],
},
Size: {
id: 2,
name: 'Size',
taxonomy: 'pa_size',
has_variations: true,
terms: [
{
id: 25,
name: 'Large',
slug: 'large',
default: false,
},
{
id: 26,
name: 'Medium',
slug: 'medium',
default: true,
},
{
id: 27,
name: 'Small',
slug: 'small',
default: false,
},
],
},
};
describe( 'Testing utils', () => {
describe( 'Testing getAttributes()', () => {
it( 'returns empty object if there are no attributes', () => {
const attributes = getAttributes( null );
expect( attributes ).toStrictEqual( {} );
} );
it( 'returns list of attributes when given valid data', () => {
const attributes = getAttributes( rawAttributeData );
expect( attributes ).toStrictEqual( {
Color: {
id: 1,
name: 'Color',
taxonomy: 'pa_color',
has_variations: true,
terms: [
{
id: 22,
name: 'Blue',
slug: 'blue',
default: true,
},
{
id: 23,
name: 'Green',
slug: 'green',
default: false,
},
{
id: 24,
name: 'Red',
slug: 'red',
default: false,
},
],
},
Logo: {
id: 0,
name: 'Logo',
taxonomy: 'pa_logo',
has_variations: true,
terms: [
{
id: 0,
name: 'Yes',
slug: 'Yes',
default: true,
},
{
id: 0,
name: 'No',
slug: 'No',
default: false,
},
],
},
} );
} );
} );
describe( 'Testing getVariationAttributes()', () => {
it( 'returns empty object if there are no variations', () => {
const variationAttributes = getVariationAttributes( null );
expect( variationAttributes ).toStrictEqual( {} );
} );
it( 'returns list of attribute names and value pairs when given valid data', () => {
const variationAttributes = getVariationAttributes( rawVariations );
expect( variationAttributes ).toStrictEqual( {
'id:35': {
id: 35,
attributes: {
Color: 'blue',
Logo: 'Yes',
},
},
'id:28': {
id: 28,
attributes: {
Color: 'red',
Logo: 'No',
},
},
'id:29': {
id: 29,
attributes: {
Color: 'green',
Logo: 'No',
},
},
'id:30': {
id: 30,
attributes: {
Color: 'blue',
Logo: 'No',
},
},
} );
} );
} );
describe( 'Testing getVariationsMatchingSelectedAttributes()', () => {
const attributes = getAttributes( rawAttributeData );
const variationAttributes = getVariationAttributes( rawVariations );
it( 'returns all variations, in the correct order, if no selections have been made yet', () => {
const selectedAttributes = {};
const matches = getVariationsMatchingSelectedAttributes(
attributes,
variationAttributes,
selectedAttributes
);
expect( matches ).toStrictEqual( [ 35, 28, 29, 30 ] );
} );
it( 'returns correct subset of variations after a selection', () => {
const selectedAttributes = {
Color: 'blue',
};
const matches = getVariationsMatchingSelectedAttributes(
attributes,
variationAttributes,
selectedAttributes
);
expect( matches ).toStrictEqual( [ 35, 30 ] );
} );
it( 'returns correct subset of variations after all selections', () => {
const selectedAttributes = {
Color: 'blue',
Logo: 'No',
};
const matches = getVariationsMatchingSelectedAttributes(
attributes,
variationAttributes,
selectedAttributes
);
expect( matches ).toStrictEqual( [ 30 ] );
} );
it( 'returns no results if selection does not match or is invalid', () => {
const selectedAttributes = {
Color: 'brown',
};
const matches = getVariationsMatchingSelectedAttributes(
attributes,
variationAttributes,
selectedAttributes
);
expect( matches ).toStrictEqual( [] );
} );
} );
describe( 'Testing getVariationMatchingSelectedAttributes()', () => {
const attributes = getAttributes( rawAttributeData );
const variationAttributes = getVariationAttributes( rawVariations );
it( 'returns first match if no selections have been made yet', () => {
const selectedAttributes = {};
const matches = getVariationMatchingSelectedAttributes(
attributes,
variationAttributes,
selectedAttributes
);
expect( matches ).toStrictEqual( 35 );
} );
it( 'returns first match after single selection', () => {
const selectedAttributes = {
Color: 'blue',
};
const matches = getVariationMatchingSelectedAttributes(
attributes,
variationAttributes,
selectedAttributes
);
expect( matches ).toStrictEqual( 35 );
} );
it( 'returns correct match after all selections', () => {
const selectedAttributes = {
Color: 'blue',
Logo: 'No',
};
const matches = getVariationMatchingSelectedAttributes(
attributes,
variationAttributes,
selectedAttributes
);
expect( matches ).toStrictEqual( 30 );
} );
it( 'returns no match if invalid', () => {
const selectedAttributes = {
Color: 'brown',
};
const matches = getVariationMatchingSelectedAttributes(
attributes,
variationAttributes,
selectedAttributes
);
expect( matches ).toStrictEqual( 0 );
} );
} );
describe( 'Testing getActiveSelectControlOptions()', () => {
const attributes = getAttributes( rawAttributeData );
const variationAttributes = getVariationAttributes( rawVariations );
it( 'returns all possible options if no selections have been made yet', () => {
const selectedAttributes = {};
const controlOptions = getActiveSelectControlOptions(
attributes,
variationAttributes,
selectedAttributes
);
expect( controlOptions ).toStrictEqual( {
Color: [
{
value: 'blue',
label: 'Blue',
},
{
value: 'green',
label: 'Green',
},
{
value: 'red',
label: 'Red',
},
],
Logo: [
{
value: 'Yes',
label: 'Yes',
},
{
value: 'No',
label: 'No',
},
],
} );
} );
it( 'returns only valid options if color is selected', () => {
const selectedAttributes = {
Color: 'green',
};
const controlOptions = getActiveSelectControlOptions(
attributes,
variationAttributes,
selectedAttributes
);
expect( controlOptions ).toStrictEqual( {
Color: [
{
value: 'blue',
label: 'Blue',
},
{
value: 'green',
label: 'Green',
},
{
value: 'red',
label: 'Red',
},
],
Logo: [
{
value: 'No',
label: 'No',
},
],
} );
} );
} );
describe( 'Testing getDefaultAttributes()', () => {
const defaultAttributes = getDefaultAttributes( formattedAttributes );
it( 'should return default attributes in the format that is ready for setting state', () => {
expect( defaultAttributes ).toStrictEqual( {
Color: 'blue',
Size: 'medium',
} );
} );
it( 'should return an empty object if given unexpected values', () => {
// @ts-expect-error Expected TS Error as we are checking how the function does with *unexpected values*.
expect( getDefaultAttributes( [] ) ).toStrictEqual( {} );
// @ts-expect-error Ditto above.
expect( getDefaultAttributes( null ) ).toStrictEqual( {} );
// @ts-expect-error Ditto above.
expect( getDefaultAttributes( undefined ) ).toStrictEqual( {} );
} );
} );
} );
blocks/product-elements/add-to-cart/product-types/variable/variation-attributes/utils.ts 0000644 00000017213 15154703035 0025720 0 ustar 00 /**
* External dependencies
*/
import { decodeEntities } from '@wordpress/html-entities';
import {
Dictionary,
isObject,
ProductResponseAttributeItem,
ProductResponseTermItem,
ProductResponseVariationsItem,
} from '@woocommerce/types';
import { keyBy } from '@woocommerce/base-utils';
/**
* Internal dependencies
*/
import { AttributesMap } from '../types';
/**
* Key an array of attributes by name,
*/
export const getAttributes = (
attributes?: ProductResponseAttributeItem[] | null
) => {
return attributes
? keyBy(
Object.values( attributes ).filter(
( { has_variations: hasVariations } ) => hasVariations
),
'name'
)
: {};
};
/**
* Format variations from the API into a map of just the attribute names and values.
*
* Note, each item is keyed by the variation ID with an id: prefix. This is to prevent the object
* being reordered when iterated.
*/
export const getVariationAttributes = (
/**
* List of Variation objects and attributes keyed by variation ID.
*/
variations?: ProductResponseVariationsItem[] | null
) => {
if ( ! variations ) {
return {};
}
const attributesMap: AttributesMap = {};
variations.forEach( ( { id, attributes } ) => {
attributesMap[ `id:${ id }` ] = {
id,
attributes: attributes.reduce( ( acc, { name, value } ) => {
acc[ name ] = value;
return acc;
}, {} as Dictionary ),
};
} );
return attributesMap;
};
/**
* Given a list of variations and a list of attribute values, return variations which match.
*
* Allows an attribute to be excluded by name. This is used to filter displayed options for
* individual attribute selects.
*
* @return List of matching variation IDs.
*/
export const getVariationsMatchingSelectedAttributes = (
/**
* List of attribute names and terms.
*
* As returned from {@link getAttributes()}.
*/
attributes: Record< string, ProductResponseAttributeItem >,
/**
* Attributes for each variation keyed by variation ID.
*
* As returned from {@link getVariationAttributes()}.
*/
variationAttributes: AttributesMap,
/**
* Attribute Name Value pairs of current selections by the user.
*/
selectedAttributes: Record< string, string | null >
) => {
const variationIds = Object.values( variationAttributes ).map(
( { id } ) => id
);
// If nothing is selected yet, just return all variations.
if (
Object.values( selectedAttributes ).every( ( value ) => value === '' )
) {
return variationIds;
}
const attributeNames = Object.keys( attributes );
return variationIds.filter( ( variationId ) =>
attributeNames.every( ( attributeName ) => {
const selectedAttribute = selectedAttributes[ attributeName ] || '';
const variationAttribute =
variationAttributes[ 'id:' + variationId ].attributes[
attributeName
];
// If there is no selected attribute, consider this a match.
if ( selectedAttribute === '' ) {
return true;
}
// If the variation attributes for this attribute are set to null, it matches all values.
if ( variationAttribute === null ) {
return true;
}
// Otherwise, only match if the selected values are the same.
return variationAttribute === selectedAttribute;
} )
);
};
/**
* Given a list of variations and a list of attribute values, returns the first matched variation ID.
*
* @return Variation ID.
*/
export const getVariationMatchingSelectedAttributes = (
/**
* List of attribute names and terms.
*
* As returned from {@link getAttributes()}.
*/
attributes: Record< string, ProductResponseAttributeItem >,
/**
* Attributes for each variation keyed by variation ID.
*
* As returned from {@link getVariationAttributes()}.
*/
variationAttributes: AttributesMap,
/**
* Attribute Name Value pairs of current selections by the user.
*/
selectedAttributes: Dictionary
) => {
const matchingVariationIds = getVariationsMatchingSelectedAttributes(
attributes,
variationAttributes,
selectedAttributes
);
return matchingVariationIds[ 0 ] || 0;
};
/**
* Given a list of terms, filter them and return valid options for the select boxes.
*
* @see getActiveSelectControlOptions
*
* @return Value/Label pairs of select box options.
*/
const getValidSelectControlOptions = (
/**
* List of attribute term objects.
*/
attributeTerms: ProductResponseTermItem[],
/**
* Valid values if selections have been made already.
*/
validAttributeTerms: Array< string | null > | null = null
) => {
return Object.values( attributeTerms )
.map( ( { name, slug } ) => {
if (
validAttributeTerms === null ||
validAttributeTerms.includes( null ) ||
validAttributeTerms.includes( slug )
) {
return {
value: slug,
label: decodeEntities( name ),
};
}
return null;
} )
.filter( Boolean );
};
/**
* Given a list of terms, filter them and return active options for the select boxes. This factors in
* which options should be hidden due to current selections.
*
* @return Select box options.
*/
export const getActiveSelectControlOptions = (
/**
* List of attribute names and terms.
*
* As returned from {@link getAttributes()}.
*/
attributes: Record< string, ProductResponseAttributeItem >,
/**
* Attributes for each variation keyed by variation ID.
*
* As returned from {@link getVariationAttributes()}.
*/
variationAttributes: AttributesMap,
/**
* Attribute Name Value pairs of current selections by the user.
*/
selectedAttributes: Dictionary
) => {
const options: Record<
string,
Array< { label: string; value: string } | null >
> = {};
const attributeNames = Object.keys( attributes );
const hasSelectedAttributes =
Object.values( selectedAttributes ).filter( Boolean ).length > 0;
attributeNames.forEach( ( attributeName ) => {
const currentAttribute = attributes[ attributeName ];
const selectedAttributesExcludingCurrentAttribute = {
...selectedAttributes,
[ attributeName ]: null,
};
// This finds matching variations for selected attributes apart from this one. This will be
// used to get valid attribute terms of the current attribute narrowed down by those matching
// variation IDs. For example, if I had Large Blue Shirts and Medium Red Shirts, I want to only
// show Red shirts if Medium is selected.
const matchingVariationIds = hasSelectedAttributes
? getVariationsMatchingSelectedAttributes(
attributes,
variationAttributes,
selectedAttributesExcludingCurrentAttribute
)
: null;
// Uses the above matching variation IDs to get the attributes from just those variations.
const validAttributeTerms =
matchingVariationIds !== null
? matchingVariationIds.map(
( varId ) =>
variationAttributes[ 'id:' + varId ].attributes[
attributeName
]
)
: null;
// Intersects attributes with valid attributes.
options[ attributeName ] = getValidSelectControlOptions(
currentAttribute.terms,
validAttributeTerms
);
} );
return options;
};
/**
* Return the default values of the given attributes in a format ready to be set in state.
*
* @return Default attributes.
*/
export const getDefaultAttributes = (
/**
* List of attribute names and terms.
*
* As returned from {@link getAttributes()}.
*/
attributes: Record< string, ProductResponseAttributeItem >
) => {
if ( ! isObject( attributes ) ) {
return {};
}
const attributeNames = Object.keys( attributes );
if ( attributeNames.length === 0 ) {
return {};
}
const attributesEntries = Object.values( attributes );
return attributesEntries.reduce( ( acc, curr ) => {
const defaultValues = curr.terms.filter( ( term ) => term.default );
if ( defaultValues.length > 0 ) {
acc[ curr.name ] = defaultValues[ 0 ]?.slug;
}
return acc;
}, {} as Dictionary );
};
blocks/product-elements/add-to-cart/shared/add-to-cart-button.tsx 0000644 00000010204 15154703035 0021030 0 ustar 00 /**
* External dependencies
*/
import { __, _n, sprintf } from '@wordpress/i18n';
import Button, { ButtonProps } from '@woocommerce/base-components/button';
import { Icon, check } from '@wordpress/icons';
import { useState, useEffect } from '@wordpress/element';
import { useAddToCartFormContext } from '@woocommerce/base-context';
import {
useStoreEvents,
useStoreAddToCart,
} from '@woocommerce/base-context/hooks';
import { useInnerBlockLayoutContext } from '@woocommerce/shared-context';
type LinkProps = Pick< ButtonProps, 'className' | 'href' | 'onClick' | 'text' >;
interface ButtonComponentProps
extends Pick< ButtonProps, 'className' | 'onClick' > {
/**
* Whether the button is disabled or not.
*/
isDisabled: boolean;
/**
* Whether processing is done.
*/
isDone: boolean;
/**
* Whether processing action is occurring.
*/
isProcessing: ButtonProps[ 'showSpinner' ];
/**
* Quantity of said item currently in the cart.
*/
quantityInCart: number;
}
/**
* Button component for non-purchasable products.
*/
const LinkComponent = ( { className, href, text, onClick }: LinkProps ) => {
return (
<Button
className={ className }
href={ href }
onClick={ onClick }
rel="nofollow"
>
{ text }
</Button>
);
};
/**
* Button for purchasable products.
*/
const ButtonComponent = ( {
className,
quantityInCart,
isProcessing,
isDisabled,
isDone,
onClick,
}: ButtonComponentProps ) => {
return (
<Button
className={ className }
disabled={ isDisabled }
showSpinner={ isProcessing }
onClick={ onClick }
>
{ isDone && quantityInCart > 0
? sprintf(
/* translators: %s number of products in cart. */
_n(
'%d in cart',
'%d in cart',
quantityInCart,
'woo-gutenberg-products-block'
),
quantityInCart
)
: __( 'Add to cart', 'woo-gutenberg-products-block' ) }
{ !! isDone && <Icon icon={ check } /> }
</Button>
);
};
/**
* Add to Cart Form Button Component.
*/
const AddToCartButton = () => {
// @todo Add types for `useAddToCartFormContext`
const {
showFormElements,
productIsPurchasable,
productHasOptions,
product,
productType,
isDisabled,
isProcessing,
eventRegistration,
hasError,
dispatchActions,
} = useAddToCartFormContext();
const { parentName } = useInnerBlockLayoutContext();
const { dispatchStoreEvent } = useStoreEvents();
const { cartQuantity } = useStoreAddToCart( product.id || 0 );
const [ addedToCart, setAddedToCart ] = useState( false );
const addToCartButtonData = product.add_to_cart || {
url: '',
text: '',
};
// Subscribe to emitter for after processing.
useEffect( () => {
const onSuccess = () => {
if ( ! hasError ) {
setAddedToCart( true );
}
return true;
};
const unsubscribeProcessing =
eventRegistration.onAddToCartAfterProcessingWithSuccess(
onSuccess,
0
);
return () => {
unsubscribeProcessing();
};
}, [ eventRegistration, hasError ] );
/**
* We can show a real button if we are:
*
* a) Showing a full add to cart form.
* b) The product doesn't have options and can therefore be added directly to the cart.
* c) The product is purchasable.
*
* Otherwise we show a link instead.
*/
const showButton =
( showFormElements ||
( ! productHasOptions && productType === 'simple' ) ) &&
productIsPurchasable;
return showButton ? (
<ButtonComponent
className="wc-block-components-product-add-to-cart-button"
quantityInCart={ cartQuantity }
isDisabled={ isDisabled }
isProcessing={ isProcessing }
isDone={ addedToCart }
onClick={ () => {
dispatchActions.submitForm(
`woocommerce/single-product/${ product?.id || 0 }`
);
dispatchStoreEvent( 'cart-add-item', {
product,
listName: parentName,
} );
} }
/>
) : (
<LinkComponent
className="wc-block-components-product-add-to-cart-button"
href={ addToCartButtonData.url }
text={
addToCartButtonData.text ||
__( 'View Product', 'woo-gutenberg-products-block' )
}
onClick={ () => {
dispatchStoreEvent( 'product-view-link', {
product,
listName: parentName,
} );
} }
/>
);
};
export default AddToCartButton;
blocks/product-elements/add-to-cart/shared/index.ts 0000644 00000000307 15154703035 0016342 0 ustar 00 export { default as AddToCartButton } from './add-to-cart-button';
export { default as QuantityInput } from './quantity-input';
export { default as ProductUnavailable } from './product-unavailable';
blocks/product-elements/add-to-cart/shared/product-unavailable.tsx 0000644 00000000545 15154703035 0021370 0 ustar 00 /**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
const ProductUnavailable = ( {
reason = __(
'Sorry, this product cannot be purchased.',
'woo-gutenberg-products-block'
),
} ) => {
return (
<div className="wc-block-components-product-add-to-cart-unavailable">
{ reason }
</div>
);
};
export default ProductUnavailable;
blocks/product-elements/add-to-cart/shared/quantity-input.tsx 0000644 00000004075 15154703035 0020444 0 ustar 00 /**
* External dependencies
*/
import { useDebouncedCallback } from 'use-debounce';
type JSXInputProps = JSX.IntrinsicElements[ 'input' ];
interface QuantityInputProps extends Omit< JSXInputProps, 'onChange' > {
max: number;
min: number;
onChange: ( val: number | string ) => void;
step: number;
}
/**
* Quantity Input Component.
*/
const QuantityInput = ( {
disabled,
min,
max,
step = 1,
value,
onChange,
}: QuantityInputProps ) => {
const hasMaximum = typeof max !== 'undefined';
/**
* The goal of this function is to normalize what was inserted,
* but after the customer has stopped typing.
*
* It's important to wait before normalizing or we end up with
* a frustrating experience, for example, if the minimum is 2 and
* the customer is trying to type "10", premature normalizing would
* always kick in at "1" and turn that into 2.
*
* Copied from <QuantitySelector>
*/
const normalizeQuantity = useDebouncedCallback< ( val: number ) => void >(
( initialValue ) => {
// We copy the starting value.
let newValue = initialValue;
// We check if we have a maximum value, and select the lowest between what was inserted and the maximum.
if ( hasMaximum ) {
newValue = Math.min(
newValue,
// the maximum possible value in step increments.
Math.floor( max / step ) * step
);
}
// Select the biggest between what's inserted, the the minimum value in steps.
newValue = Math.max( newValue, Math.ceil( min / step ) * step );
// We round off the value to our steps.
newValue = Math.floor( newValue / step ) * step;
// Only commit if the value has changed
if ( newValue !== initialValue ) {
onChange?.( newValue );
}
},
300
);
return (
<input
className="wc-block-components-product-add-to-cart-quantity"
type="number"
value={ value }
min={ min }
max={ max }
step={ step }
hidden={ max === 1 }
disabled={ disabled }
onChange={ ( e ) => {
onChange?.( e.target.value );
normalizeQuantity( Number( e.target.value ) );
} }
/>
);
};
export default QuantityInput;
blocks/product-elements/add-to-cart/style.scss 0000644 00000002055 15154703035 0015454 0 ustar 00 .wc-block-components-product-add-to-cart {
margin: 0;
display: flex;
flex-wrap: wrap;
.wc-block-components-product-add-to-cart-button {
margin: 0 0 em($gap-small) 0;
.wc-block-components-button__text {
display: block;
> svg {
fill: currentColor;
vertical-align: top;
width: 1.5em;
height: 1.5em;
margin: -0.25em 0 -0.25em 0.5em;
}
}
}
.wc-block-components-product-add-to-cart-quantity {
margin: 0 1em em($gap-small) 0;
flex-basis: 5em;
padding: 0.618em;
background: $white;
border: 1px solid #ccc;
border-radius: 2px;
color: #43454b;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.125);
text-align: center;
}
}
.is-loading .wc-block-components-product-add-to-cart,
.wc-block-components-product-add-to-cart--placeholder {
.wc-block-components-product-add-to-cart-quantity,
.wc-block-components-product-add-to-cart-button {
@include placeholder();
}
}
.wc-block-grid .wc-block-components-product-add-to-cart {
justify-content: center;
}
.wc-block-components-product-add-to-cart-notice {
margin: 0;
}
blocks/product-elements/add-to-cart-form/block.json 0000644 00000001072 15154703035 0016343 0 ustar 00 {
"name": "woocommerce/add-to-cart-form",
"version": "1.0.0",
"title": "Add to Cart with Options",
"description": "Display a button so the customer can add a product to their cart. Options will also be displayed depending on product type. e.g. quantity, variation.",
"attributes": {
"isDescendentOfSingleProductBlock": {
"type": "boolean",
"default": false
}
},
"category": "woocommerce",
"keywords": [ "WooCommerce" ],
"usesContext": ["postId"],
"textdomain": "woocommerce",
"apiVersion": 2,
"$schema": "https://schemas.wp.org/trunk/block.json"
}
blocks/product-elements/add-to-cart-form/edit.tsx 0000644 00000003455 15154703035 0016052 0 ustar 00 /**
* External dependencies
*/
import { useEffect } from '@wordpress/element';
import { useBlockProps } from '@wordpress/block-editor';
import { __ } from '@wordpress/i18n';
import { Button, Disabled, Tooltip } from '@wordpress/components';
import { Skeleton } from '@woocommerce/base-components/skeleton';
import { BlockEditProps } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import './editor.scss';
import { useIsDescendentOfSingleProductBlock } from '../shared/use-is-descendent-of-single-product-block';
export interface Attributes {
className?: string;
isDescendentOfSingleProductBlock: boolean;
}
const Edit = ( props: BlockEditProps< Attributes > ) => {
const { setAttributes } = props;
const blockProps = useBlockProps( {
className: 'wc-block-add-to-cart-form',
} );
const { isDescendentOfSingleProductBlock } =
useIsDescendentOfSingleProductBlock( {
blockClientId: blockProps?.id,
} );
useEffect( () => {
setAttributes( {
isDescendentOfSingleProductBlock,
} );
}, [ setAttributes, isDescendentOfSingleProductBlock ] );
return (
<div { ...blockProps }>
<Tooltip
text="Customer will see product add-to-cart options in this space, dependend on the product type. "
position="bottom right"
>
<div className="wc-block-editor-container">
<Skeleton numberOfLines={ 3 } />
<Disabled>
<input
type={ 'number' }
value={ '1' }
className={
'wc-block-editor-add-to-cart-form__quantity'
}
readOnly
/>
<Button
variant={ 'primary' }
className={
'wc-block-editor-add-to-cart-form__button'
}
>
{ __(
'Add to cart',
'woo-gutenberg-products-block'
) }
</Button>
</Disabled>
</div>
</Tooltip>
</div>
);
};
export default Edit;
blocks/product-elements/add-to-cart-form/editor.scss 0000644 00000001131 15154703035 0016535 0 ustar 00 .wc-block-editor-add-to-cart-form {
display: flex;
flex-direction: column;
row-gap: $default-block-margin;
}
input.wc-block-editor-add-to-cart-form__quantity[type="number"] {
max-width: 50px;
min-height: 23px;
float: left;
padding: 6px 6px 6px 12px;
margin-right: 10px;
font-size: 13px;
height: inherit;
}
input[type="number"]::-webkit-inner-spin-button {
opacity: 1;
}
button.components-button.wc-block-add-to-cart-form__button {
float: left;
padding: 20px 30px;
border-radius: 0;
}
.wc-block-editor-container {
cursor: help;
gap: 10px;
display: flex;
flex-direction: column;
}
blocks/product-elements/add-to-cart-form/index.tsx 0000644 00000001245 15154703035 0016227 0 ustar 00 /**
* External dependencies
*/
import { registerBlockSingleProductTemplate } from '@woocommerce/atomic-utils';
import { Icon, button } from '@wordpress/icons';
/**
* Internal dependencies
*/
import metadata from './block.json';
import edit from './edit';
import './style.scss';
import './editor.scss';
const blockSettings = {
edit,
icon: {
src: (
<Icon
icon={ button }
className="wc-block-editor-components-block-icon"
/>
),
},
ancestor: [ 'woocommerce/single-product' ],
save() {
return null;
},
};
registerBlockSingleProductTemplate( {
blockName: metadata.name,
blockMetadata: metadata,
blockSettings,
isAvailableOnPostEditor: true,
} );
blocks/product-elements/add-to-cart-form/style.scss 0000644 00000000667 15154703035 0016424 0 ustar 00 .wp-block-add-to-cart-form {
width: unset;
/**
* This is a base style for the input text element in WooCommerce that prevents inputs from appearing too small.
*
* @link https://github.com/woocommerce/woocommerce/blob/95ca53675f2817753d484583c96ca9ab9f725172/plugins/woocommerce/client/legacy/css/woocommerce-blocktheme.scss#L203-L206
*/
.input-text {
font-size: var(--wp--preset--font-size--small);
padding: 0.9rem 1.1rem;
}
}
blocks/product-elements/average-rating/block.json 0000644 00000000675 15154703035 0016207 0 ustar 00 {
"name": "woocommerce/product-average-rating",
"version": "1.0.0",
"title": "Product Average Rating (Beta)",
"description": "Display the average rating of a product",
"attributes": {
"textAlign": {
"type": "string"
}
},
"category": "woocommerce",
"keywords": [ "WooCommerce" ],
"ancestor": [ "woocommerce/single-product" ],
"textdomain": "woocommerce",
"apiVersion": 2,
"$schema": "https://schemas.wp.org/trunk/block.json"
}
blocks/product-elements/average-rating/block.tsx 0000644 00000001763 15154703035 0016053 0 ustar 00 /**
* External dependencies
*/
import classnames from 'classnames';
import { useProductDataContext } from '@woocommerce/shared-context';
import { useStyleProps } from '@woocommerce/base-hooks';
import { __ } from '@wordpress/i18n';
import { withProductDataContext } from '@woocommerce/shared-hocs';
type ProductAverageRatingProps = {
className?: string;
textAlign?: string;
};
export const Block = ( props: ProductAverageRatingProps ): JSX.Element => {
const { textAlign } = props;
const styleProps = useStyleProps( props );
const { product } = useProductDataContext();
const className = classnames(
styleProps.className,
'wc-block-components-product-average-rating',
{
[ `has-text-align-${ textAlign }` ]: textAlign,
}
);
return (
<div className={ className } style={ styleProps.style }>
{ Number( product.average_rating ) > 0
? product.average_rating
: __( 'No ratings', 'woo-gutenberg-products-block' ) }
</div>
);
};
export default withProductDataContext( Block );
blocks/product-elements/average-rating/edit.tsx 0000644 00000001524 15154703035 0015701 0 ustar 00 /**
* External dependencies
*/
import {
AlignmentToolbar,
BlockControls,
useBlockProps,
} from '@wordpress/block-editor';
import type { BlockEditProps } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import Block from './block';
export interface BlockAttributes {
textAlign: string;
}
const Edit = ( props: BlockEditProps< BlockAttributes > ): JSX.Element => {
const { attributes, setAttributes } = props;
const blockProps = useBlockProps( {
className: 'wp-block-woocommerce-product-average-rating',
} );
return (
<>
<BlockControls>
<AlignmentToolbar
value={ attributes.textAlign }
onChange={ ( newAlign ) => {
setAttributes( { textAlign: newAlign || '' } );
} }
/>
</BlockControls>
<div { ...blockProps }>
<Block { ...attributes } />
</div>
</>
);
};
export default Edit;
blocks/product-elements/average-rating/index.tsx 0000644 00000000700 15154703036 0016057 0 ustar 00 /**
* External dependencies
*/
import { registerBlockType } from '@wordpress/blocks';
import { Icon, starHalf } from '@wordpress/icons';
/**
* Internal dependencies
*/
import metadata from './block.json';
import edit from './edit';
import { supports } from './support';
registerBlockType( metadata, {
icon: {
src: (
<Icon
icon={ starHalf }
className="wc-block-editor-components-block-icon"
/>
),
},
supports,
edit,
} );
blocks/product-elements/average-rating/support.ts 0000644 00000001130 15154703036 0016272 0 ustar 00 /* eslint-disable @wordpress/no-unsafe-wp-apis */
/**
* External dependencies
*/
import { isFeaturePluginBuild } from '@woocommerce/block-settings';
export const supports = {
...( isFeaturePluginBuild() && {
color: {
text: true,
background: true,
__experimentalSkipSerialization: true,
},
spacing: {
margin: true,
padding: true,
__experimentalSkipSerialization: true,
},
typography: {
fontSize: true,
__experimentalFontWeight: true,
__experimentalSkipSerialization: true,
},
__experimentalSelector: '.wc-block-components-product-average-rating',
} ),
};
blocks/product-elements/button/block.json 0000644 00000002463 15154703036 0014624 0 ustar 00 {
"name": "woocommerce/product-button",
"version": "1.0.0",
"title": "Add to Cart Button",
"description": "Display a call to action button which either adds the product to the cart, or links to the product page.",
"category": "woocommerce",
"keywords": [ "WooCommerce" ],
"usesContext": [ "query", "queryId", "postId" ],
"textdomain": "woocommerce",
"attributes": {
"productId": {
"type": "number",
"default": 0
},
"textAlign": {
"type": "string",
"default": ""
},
"width": {
"type": "number"
},
"isDescendentOfSingleProductBlock": {
"type": "boolean",
"default": false
},
"isDescendentOfQueryLoop": {
"type": "boolean",
"default": false
}
},
"supports": {
"align": [ "wide", "full" ],
"color": {
"background": false,
"link": true
},
"interactivity": true,
"html": false,
"typography": {
"fontSize": true,
"lineHeight": true
}
},
"ancestor": [
"woocommerce/all-products",
"woocommerce/single-product",
"core/post-template",
"woocommerce/product-template"
],
"styles": [
{
"name": "fill",
"label": "Fill",
"isDefault": true
},
{
"name": "outline",
"label": "Outline"
}
],
"viewScript": [
"wc-product-button-interactivity-frontend"
],
"apiVersion": 2,
"$schema": "https://schemas.wp.org/trunk/block.json"
}
blocks/product-elements/button/block.tsx 0000644 00000010154 15154703036 0014465 0 ustar 00 /**
* External dependencies
*/
import classnames from 'classnames';
import { __, _n, sprintf } from '@wordpress/i18n';
import {
useStoreEvents,
useStoreAddToCart,
} from '@woocommerce/base-context/hooks';
import { useStyleProps } from '@woocommerce/base-hooks';
import { decodeEntities } from '@wordpress/html-entities';
import { CART_URL } from '@woocommerce/block-settings';
import { getSetting } from '@woocommerce/settings';
import {
useInnerBlockLayoutContext,
useProductDataContext,
} from '@woocommerce/shared-context';
import { withProductDataContext } from '@woocommerce/shared-hocs';
/**
* Internal dependencies
*/
import './style.scss';
import type {
BlockAttributes,
AddToCartButtonAttributes,
AddToCartButtonPlaceholderAttributes,
} from './types';
const AddToCartButton = ( {
product,
className,
style,
}: AddToCartButtonAttributes ): JSX.Element => {
const {
id,
permalink,
add_to_cart: productCartDetails,
has_options: hasOptions,
is_purchasable: isPurchasable,
is_in_stock: isInStock,
} = product;
const { dispatchStoreEvent } = useStoreEvents();
const { cartQuantity, addingToCart, addToCart } = useStoreAddToCart( id );
const addedToCart = Number.isFinite( cartQuantity ) && cartQuantity > 0;
const allowAddToCart = ! hasOptions && isPurchasable && isInStock;
const buttonAriaLabel = decodeEntities(
productCartDetails?.description || ''
);
const buttonText = addedToCart
? sprintf(
/* translators: %s number of products in cart. */
_n(
'%d in cart',
'%d in cart',
cartQuantity,
'woo-gutenberg-products-block'
),
cartQuantity
)
: decodeEntities(
productCartDetails?.text ||
__( 'Add to cart', 'woo-gutenberg-products-block' )
);
const ButtonTag = allowAddToCart ? 'button' : 'a';
const buttonProps = {} as HTMLAnchorElement & { onClick: () => void };
if ( ! allowAddToCart ) {
buttonProps.href = permalink;
buttonProps.rel = 'nofollow';
buttonProps.onClick = () => {
dispatchStoreEvent( 'product-view-link', {
product,
} );
};
} else {
buttonProps.onClick = async () => {
await addToCart();
dispatchStoreEvent( 'cart-add-item', {
product,
} );
// redirect to cart if the setting to redirect to the cart page
// on cart add item is enabled
const { cartRedirectAfterAdd }: { cartRedirectAfterAdd: boolean } =
getSetting( 'productsSettings' );
if ( cartRedirectAfterAdd ) {
window.location.href = CART_URL;
}
};
}
return (
<ButtonTag
{ ...buttonProps }
aria-label={ buttonAriaLabel }
disabled={ addingToCart }
className={ classnames(
className,
'wp-block-button__link',
'wp-element-button',
'add_to_cart_button',
'wc-block-components-product-button__button',
{
loading: addingToCart,
added: addedToCart,
}
) }
style={ style }
>
{ buttonText }
</ButtonTag>
);
};
const AddToCartButtonPlaceholder = ( {
className,
style,
}: AddToCartButtonPlaceholderAttributes ): JSX.Element => {
return (
<button
className={ classnames(
'wp-block-button__link',
'wp-element-button',
'add_to_cart_button',
'wc-block-components-product-button__button',
'wc-block-components-product-button__button--placeholder',
className
) }
style={ style }
disabled={ true }
/>
);
};
export const Block = ( props: BlockAttributes ): JSX.Element => {
const { className, textAlign } = props;
const styleProps = useStyleProps( props );
const { parentClassName } = useInnerBlockLayoutContext();
const { product } = useProductDataContext();
return (
<div
className={ classnames(
className,
'wp-block-button',
'wc-block-components-product-button',
{
[ `${ parentClassName }__product-add-to-cart` ]:
parentClassName,
[ `align-${ textAlign }` ]: textAlign,
}
) }
>
{ product.id ? (
<AddToCartButton
product={ product }
style={ styleProps.style }
className={ styleProps.className }
/>
) : (
<AddToCartButtonPlaceholder
style={ styleProps.style }
className={ styleProps.className }
/>
) }
</div>
);
};
export default withProductDataContext( Block );
blocks/product-elements/button/edit.tsx 0000644 00000005177 15154703036 0014331 0 ustar 00 /**
* External dependencies
*/
import classnames from 'classnames';
import {
Disabled,
Button,
ButtonGroup,
PanelBody,
} from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import {
AlignmentToolbar,
BlockControls,
useBlockProps,
InspectorControls,
} from '@wordpress/block-editor';
import type { BlockEditProps } from '@wordpress/blocks';
import { useEffect } from '@wordpress/element';
import { ProductQueryContext as Context } from '@woocommerce/blocks/product-query/types';
/**
* Internal dependencies
*/
import Block from './block';
import { BlockAttributes } from './types';
function WidthPanel( {
selectedWidth,
setAttributes,
}: {
selectedWidth: number | undefined;
setAttributes: ( attributes: BlockAttributes ) => void;
} ) {
function handleChange( newWidth: number ) {
// Check if we are toggling the width off
const width = selectedWidth === newWidth ? undefined : newWidth;
// Update attributes.
setAttributes( { width } );
}
return (
<PanelBody
title={ __( 'Width settings', 'woo-gutenberg-products-block' ) }
>
<ButtonGroup
aria-label={ __(
'Button width',
'woo-gutenberg-products-block'
) }
>
{ [ 25, 50, 75, 100 ].map( ( widthValue ) => {
return (
<Button
key={ widthValue }
isSmall
variant={
widthValue === selectedWidth
? 'primary'
: undefined
}
onClick={ () => handleChange( widthValue ) }
>
{ widthValue }%
</Button>
);
} ) }
</ButtonGroup>
</PanelBody>
);
}
const Edit = ( {
attributes,
setAttributes,
context,
}: BlockEditProps< BlockAttributes > & {
context?: Context | undefined;
} ): JSX.Element => {
const blockProps = useBlockProps();
const isDescendentOfQueryLoop = Number.isFinite( context?.queryId );
const { width } = attributes;
useEffect(
() => setAttributes( { isDescendentOfQueryLoop } ),
[ setAttributes, isDescendentOfQueryLoop ]
);
return (
<>
<BlockControls>
{ isDescendentOfQueryLoop && (
<AlignmentToolbar
value={ attributes.textAlign }
onChange={ ( newAlign ) => {
setAttributes( { textAlign: newAlign || '' } );
} }
/>
) }
</BlockControls>
<InspectorControls>
<WidthPanel
selectedWidth={ width }
setAttributes={ setAttributes }
/>
</InspectorControls>
<div { ...blockProps }>
<Disabled>
<Block
{ ...{ ...attributes, ...context } }
className={ classnames( attributes.className, {
[ `has-custom-width wp-block-button__width-${ width }` ]:
width,
} ) }
/>
</Disabled>
</div>
</>
);
};
export default Edit;
blocks/product-elements/button/frontend.tsx 0000644 00000021265 15154703036 0015217 0 ustar 00 /* eslint-disable @typescript-eslint/no-explicit-any */
/**
* External dependencies
*/
import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data';
import { store as interactivityStore } from '@woocommerce/interactivity';
import { dispatch, select, subscribe } from '@wordpress/data';
import { Cart } from '@woocommerce/type-defs/cart';
import { createRoot } from '@wordpress/element';
import NoticeBanner from '@woocommerce/base-components/notice-banner';
type Context = {
woocommerce: {
isLoading: boolean;
addToCartText: string;
productId: number;
displayViewCart: boolean;
quantityToAdd: number;
temporaryNumberOfItems: number;
animationStatus: AnimationStatus;
};
};
enum AnimationStatus {
IDLE = 'IDLE',
SLIDE_OUT = 'SLIDE-OUT',
SLIDE_IN = 'SLIDE-IN',
}
type State = {
woocommerce: {
cart: Cart | undefined;
inTheCartText: string;
};
};
type Store = {
state: State;
context: Context;
selectors: any;
ref: HTMLElement;
};
const storeNoticeClass = '.wc-block-store-notices';
const createNoticeContainer = () => {
const noticeContainer = document.createElement( 'div' );
noticeContainer.classList.add( storeNoticeClass.replace( '.', '' ) );
return noticeContainer;
};
const injectNotice = ( domNode: Element, errorMessage: string ) => {
const root = createRoot( domNode );
root.render(
<NoticeBanner status="error" onRemove={ () => root.unmount() }>
{ errorMessage }
</NoticeBanner>
);
domNode?.scrollIntoView( {
behavior: 'smooth',
inline: 'nearest',
} );
};
const getProductById = ( cartState: Cart | undefined, productId: number ) => {
return cartState?.items.find( ( item ) => item.id === productId );
};
const getTextButton = ( {
addToCartText,
inTheCartText,
numberOfItems,
}: {
addToCartText: string;
inTheCartText: string;
numberOfItems: number;
} ) => {
if ( numberOfItems === 0 ) {
return addToCartText;
}
return inTheCartText.replace( '###', numberOfItems.toString() );
};
const productButtonSelectors = {
woocommerce: {
addToCartText: ( store: Store ) => {
const { context, state, selectors } = store;
// We use the temporary number of items when there's no animation, or the
// second part of the animation hasn't started.
if (
context.woocommerce.animationStatus === AnimationStatus.IDLE ||
context.woocommerce.animationStatus ===
AnimationStatus.SLIDE_OUT
) {
return getTextButton( {
addToCartText: context.woocommerce.addToCartText,
inTheCartText: state.woocommerce.inTheCartText,
numberOfItems: context.woocommerce.temporaryNumberOfItems,
} );
}
return getTextButton( {
addToCartText: context.woocommerce.addToCartText,
inTheCartText: state.woocommerce.inTheCartText,
numberOfItems:
selectors.woocommerce.numberOfItemsInTheCart( store ),
} );
},
displayViewCart: ( store: Store ) => {
const { context, selectors } = store;
if ( ! context.woocommerce.displayViewCart ) return false;
if ( ! selectors.woocommerce.hasCartLoaded( store ) ) {
return context.woocommerce.temporaryNumberOfItems > 0;
}
return selectors.woocommerce.numberOfItemsInTheCart( store ) > 0;
},
hasCartLoaded: ( { state }: { state: State } ) => {
return state.woocommerce.cart !== undefined;
},
numberOfItemsInTheCart: ( { state, context }: Store ) => {
const product = getProductById(
state.woocommerce.cart,
context.woocommerce.productId
);
return product?.quantity || 0;
},
slideOutAnimation: ( { context }: Store ) =>
context.woocommerce.animationStatus === AnimationStatus.SLIDE_OUT,
slideInAnimation: ( { context }: Store ) =>
context.woocommerce.animationStatus === AnimationStatus.SLIDE_IN,
},
};
interactivityStore(
// @ts-expect-error: Store function isn't typed.
{
selectors: productButtonSelectors,
actions: {
woocommerce: {
addToCart: async ( store: Store ) => {
const { context, selectors, ref } = store;
if ( ! ref.classList.contains( 'ajax_add_to_cart' ) ) {
return;
}
context.woocommerce.isLoading = true;
// Allow 3rd parties to validate and quit early.
// https://github.com/woocommerce/woocommerce/blob/154dd236499d8a440edf3cde712511b56baa8e45/plugins/woocommerce/client/legacy/js/frontend/add-to-cart.js/#L74-L77
const event = new CustomEvent(
'should_send_ajax_request.adding_to_cart',
{ detail: [ ref ], cancelable: true }
);
const shouldSendRequest =
document.body.dispatchEvent( event );
if ( shouldSendRequest === false ) {
const ajaxNotSentEvent = new CustomEvent(
'ajax_request_not_sent.adding_to_cart',
{ detail: [ false, false, ref ] }
);
document.body.dispatchEvent( ajaxNotSentEvent );
return true;
}
try {
await dispatch( storeKey ).addItemToCart(
context.woocommerce.productId,
context.woocommerce.quantityToAdd
);
// After the cart has been updated, sync the temporary number of
// items again.
context.woocommerce.temporaryNumberOfItems =
selectors.woocommerce.numberOfItemsInTheCart(
store
);
} catch ( error ) {
const storeNoticeBlock =
document.querySelector( storeNoticeClass );
if ( ! storeNoticeBlock ) {
document
.querySelector( '.entry-content' )
?.prepend( createNoticeContainer() );
}
const domNode =
storeNoticeBlock ??
document.querySelector( storeNoticeClass );
if ( domNode ) {
injectNotice( domNode, error.message );
}
// We don't care about errors blocking execution, but will
// console.error for troubleshooting.
// eslint-disable-next-line no-console
console.error( error );
} finally {
context.woocommerce.displayViewCart = true;
context.woocommerce.isLoading = false;
}
},
handleAnimationEnd: (
store: Store & { event: AnimationEvent }
) => {
const { event, context, selectors } = store;
if ( event.animationName === 'slideOut' ) {
// When the first part of the animation (slide-out) ends, we move
// to the second part (slide-in).
context.woocommerce.animationStatus =
AnimationStatus.SLIDE_IN;
} else if ( event.animationName === 'slideIn' ) {
// When the second part of the animation ends, we update the
// temporary number of items to sync it with the cart and reset the
// animation status so it can be triggered again.
context.woocommerce.temporaryNumberOfItems =
selectors.woocommerce.numberOfItemsInTheCart(
store
);
context.woocommerce.animationStatus =
AnimationStatus.IDLE;
}
},
},
},
init: {
woocommerce: {
syncTemporaryNumberOfItemsOnLoad: ( store: Store ) => {
const { selectors, context } = store;
// If the cart has loaded when we instantiate this element, we sync
// the temporary number of items with the number of items in the cart
// to avoid triggering the animation. We do this only once, but we
// use useLayoutEffect to avoid the useEffect flickering.
if ( selectors.woocommerce.hasCartLoaded( store ) ) {
context.woocommerce.temporaryNumberOfItems =
selectors.woocommerce.numberOfItemsInTheCart(
store
);
}
},
},
},
effects: {
woocommerce: {
startAnimation: ( store: Store ) => {
const { context, selectors } = store;
// We start the animation if the cart has loaded, the temporary number
// of items is out of sync with the number of items in the cart, the
// button is not loading (because that means the user started the
// interaction) and the animation hasn't started yet.
if (
selectors.woocommerce.hasCartLoaded( store ) &&
context.woocommerce.temporaryNumberOfItems !==
selectors.woocommerce.numberOfItemsInTheCart(
store
) &&
! context.woocommerce.isLoading &&
context.woocommerce.animationStatus ===
AnimationStatus.IDLE
) {
context.woocommerce.animationStatus =
AnimationStatus.SLIDE_OUT;
}
},
},
},
},
{
afterLoad: ( store: Store ) => {
const { state, selectors } = store;
// Subscribe to changes in Cart data.
subscribe( () => {
const cartData = select( storeKey ).getCartData();
const isResolutionFinished =
select( storeKey ).hasFinishedResolution( 'getCartData' );
if ( isResolutionFinished ) {
state.woocommerce.cart = cartData;
}
}, storeKey );
// This selector triggers a fetch of the Cart data. It is done in a
// `requestIdleCallback` to avoid potential performance issues.
requestIdleCallback( () => {
if ( ! selectors.woocommerce.hasCartLoaded( store ) ) {
select( storeKey ).getCartData();
}
} );
},
}
);
blocks/product-elements/button/index.tsx 0000644 00000003503 15154703036 0014502 0 ustar 00 /**
* External dependencies
*/
import { Icon, button } from '@wordpress/icons';
import { registerBlockType } from '@wordpress/blocks';
import { isFeaturePluginBuild } from '@woocommerce/block-settings';
import { __experimentalGetSpacingClassesAndStyles } from '@wordpress/block-editor';
/**
* Internal dependencies
*/
import edit from './edit';
import save from './save';
import metadata from './block.json';
const featurePluginSupport = {
...metadata.supports,
...( isFeaturePluginBuild() && {
color: {
text: true,
background: true,
link: false,
__experimentalSkipSerialization: true,
},
__experimentalBorder: {
radius: true,
__experimentalSkipSerialization: true,
},
...( typeof __experimentalGetSpacingClassesAndStyles === 'function' && {
spacing: {
margin: true,
padding: true,
__experimentalSkipSerialization: true,
},
} ),
typography: {
fontSize: true,
lineHeight: true,
__experimentalFontWeight: true,
__experimentalFontFamily: true,
__experimentalFontStyle: true,
__experimentalTextTransform: true,
__experimentalTextDecoration: true,
__experimentalLetterSpacing: true,
__experimentalDefaultControls: {
fontSize: true,
},
},
__experimentalSelector:
'.wp-block-button.wc-block-components-product-button .wc-block-components-product-button__button',
} ),
...( typeof __experimentalGetSpacingClassesAndStyles === 'function' &&
! isFeaturePluginBuild() && {
spacing: {
margin: true,
},
} ),
};
// @ts-expect-error: `metadata` currently does not have a type definition in WordPress core
registerBlockType( metadata, {
icon: {
src: (
<Icon
icon={ button }
className="wc-block-editor-components-block-icon"
/>
),
},
attributes: {
...metadata.attributes,
},
supports: {
...featurePluginSupport,
},
edit,
save,
} );
blocks/product-elements/button/save.tsx 0000644 00000001270 15154703036 0014330 0 ustar 00 /**
* External dependencies
*/
import { useBlockProps } from '@wordpress/block-editor';
import classnames from 'classnames';
/**
* Internal dependencies
*/
import { BlockAttributes } from './types';
type Props = {
attributes: BlockAttributes;
};
const Save = ( { attributes }: Props ): JSX.Element | null => {
if (
attributes.isDescendentOfQueryLoop ||
attributes.isDescendentOfSingleProductBlock
) {
return null;
}
return (
<div
{ ...useBlockProps.save( {
className: classnames( 'is-loading', attributes.className, {
[ `has-custom-width wp-block-button__width-${ attributes.width }` ]:
attributes.width,
} ),
} ) }
/>
);
};
export default Save;
blocks/product-elements/button/style.scss 0000644 00000005215 15154703036 0014672 0 ustar 00 .wp-block-button.wc-block-components-product-button {
word-break: break-word;
white-space: normal;
display: flex;
justify-content: center;
flex-direction: column;
align-items: center;
gap: $gap-small;
.wp-block-button__link {
word-break: break-word;
white-space: normal;
display: inline-flex;
justify-content: center;
text-align: center;
// Set button font size and padding so it inherits from parent.
padding: 0.5em 1em;
font-size: 1em;
&.loading {
opacity: 0.25;
}
&.loading::after {
font-family: WooCommerce; /* stylelint-disable-line */
content: "\e031";
animation: spin 2s linear infinite;
margin-left: 0.5em;
display: inline-block;
width: auto;
height: auto;
}
}
a[hidden] {
display: none;
}
@keyframes slideOut {
from {
transform: translateY(0);
}
to {
transform: translateY(-100%);
}
}
@keyframes slideIn {
from {
transform: translateY(90%);
opacity: 0;
}
to {
transform: translate(0);
opacity: 1;
}
}
&.align-left {
align-items: flex-start;
}
&.align-right {
align-items: flex-end;
}
.wc-block-components-product-button__button {
border-style: none;
display: inline-flex;
justify-content: center;
white-space: normal;
word-break: break-word;
width: 150px;
overflow: hidden;
span {
&.wc-block-slide-out {
animation: slideOut 0.1s linear 1 normal forwards;
}
&.wc-block-slide-in {
animation: slideIn 0.1s linear 1 normal;
}
}
}
.wc-block-components-product-button__button--placeholder {
@include placeholder();
min-width: 8em;
min-height: 3em;
}
.wc-block-all-products & {
margin-bottom: $gap-small;
}
}
.is-loading .wc-block-components-product-button > .wc-block-components-product-button__button {
@include placeholder();
min-width: 8em;
min-height: 3em;
}
.theme-twentytwentyone {
// Prevent buttons appearing disabled in the editor.
.editor-styles-wrapper .wc-block-components-product-button .wp-block-button__link {
background-color: var(--button--color-background);
color: var(--button--color-text);
border-color: var(--button--color-background);
}
}
// Style: Fill & Outline
.wp-block-button.is-style-outline {
.wp-block-button__link {
border: 2px solid currentColor;
&:not(.has-text-color) {
color: currentColor;
}
&:not(.has-background) {
background-color: transparent;
background-image: none;
}
}
}
// Width setting
.wp-block-button {
&.has-custom-width {
.wp-block-button__link {
box-sizing: border-box;
}
}
@for $i from 1 through 4 {
&.wp-block-button__width-#{$i * 25} {
.wp-block-button__link {
width: $i * 25%; // 25%, 50%, 75%, 100%
}
}
}
}
blocks/product-elements/button/types.ts 0000644 00000001441 15154703036 0014346 0 ustar 00 interface WithClass {
className: string;
}
interface WithStyle {
style: Record< string, unknown >;
}
export interface BlockAttributes {
className?: string | undefined;
textAlign?: string | undefined;
isDescendentOfQueryLoop?: boolean | undefined;
isDescendentOfSingleProductBlock?: boolean | undefined;
width?: number | undefined;
}
export interface AddToCartButtonPlaceholderAttributes {
className: string;
style: React.CSSProperties;
}
export interface AddToCartButtonAttributes
extends AddToCartButtonPlaceholderAttributes {
product: {
id: number;
permalink: string;
add_to_cart: {
url: string;
description: string;
text: string;
};
has_options: boolean;
is_purchasable: boolean;
is_in_stock: boolean;
};
textAlign?: ( WithClass & WithStyle ) | undefined;
}
blocks/product-elements/image/attributes.ts 0000644 00000001455 15154703036 0015144 0 ustar 00 /**
* External dependencies
*/
import type { BlockAttributes } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import { ImageSizing } from './types';
export const blockAttributes: BlockAttributes = {
showProductLink: {
type: 'boolean',
default: true,
},
showSaleBadge: {
type: 'boolean',
default: true,
},
saleBadgeAlign: {
type: 'string',
default: 'right',
},
imageSizing: {
type: 'string',
default: ImageSizing.SINGLE,
},
productId: {
type: 'number',
default: 0,
},
isDescendentOfQueryLoop: {
type: 'boolean',
default: false,
},
isDescendentOfSingleProductBlock: {
type: 'boolean',
default: false,
},
width: {
type: 'string',
},
height: {
type: 'string',
},
scale: {
type: 'string',
default: 'cover',
},
};
export default blockAttributes;
blocks/product-elements/image/block.tsx 0000644 00000010040 15154703036 0014226 0 ustar 00 /**
* External dependencies
*/
import { Fragment } from '@wordpress/element';
import { __, sprintf } from '@wordpress/i18n';
import classnames from 'classnames';
import { PLACEHOLDER_IMG_SRC } from '@woocommerce/settings';
import {
useInnerBlockLayoutContext,
useProductDataContext,
} from '@woocommerce/shared-context';
import { useStyleProps } from '@woocommerce/base-hooks';
import { withProductDataContext } from '@woocommerce/shared-hocs';
import { useStoreEvents } from '@woocommerce/base-context/hooks';
import type { HTMLAttributes } from 'react';
/**
* Internal dependencies
*/
import ProductSaleBadge from '../sale-badge/block';
import './style.scss';
import { BlockAttributes, ImageSizing } from './types';
const ImagePlaceholder = ( props ): JSX.Element => {
return (
<img
{ ...props }
src={ PLACEHOLDER_IMG_SRC }
alt=""
width={ undefined }
height={ undefined }
/>
);
};
interface ImageProps {
image?: null | {
alt?: string | undefined;
id: number;
name: string;
sizes?: string | undefined;
src?: string | undefined;
srcset?: string | undefined;
thumbnail?: string | undefined;
};
loaded: boolean;
showFullSize: boolean;
fallbackAlt: string;
scale: string;
width?: string | undefined;
height?: string | undefined;
}
const Image = ( {
image,
loaded,
showFullSize,
fallbackAlt,
width,
scale,
height,
}: ImageProps ): JSX.Element => {
const { thumbnail, src, srcset, sizes, alt } = image || {};
const imageProps = {
alt: alt || fallbackAlt,
hidden: ! loaded,
src: thumbnail,
...( showFullSize && { src, srcSet: srcset, sizes } ),
};
const imageStyles: Record< string, string | undefined > = {
height,
width,
objectFit: scale,
};
return (
<>
{ imageProps.src && (
/* eslint-disable-next-line jsx-a11y/alt-text */
<img
style={ imageStyles }
data-testid="product-image"
{ ...imageProps }
/>
) }
{ ! image && <ImagePlaceholder style={ imageStyles } /> }
</>
);
};
type Props = BlockAttributes & HTMLAttributes< HTMLDivElement >;
export const Block = ( props: Props ): JSX.Element | null => {
const {
className,
imageSizing = ImageSizing.SINGLE,
showProductLink = true,
showSaleBadge,
saleBadgeAlign = 'right',
height,
width,
scale,
...restProps
} = props;
const styleProps = useStyleProps( props );
const { parentClassName } = useInnerBlockLayoutContext();
const { product, isLoading } = useProductDataContext();
const { dispatchStoreEvent } = useStoreEvents();
if ( ! product.id ) {
return (
<div
className={ classnames(
className,
'wc-block-components-product-image',
{
[ `${ parentClassName }__product-image` ]:
parentClassName,
},
styleProps.className
) }
style={ styleProps.style }
>
<ImagePlaceholder />
</div>
);
}
const hasProductImages = !! product.images.length;
const image = hasProductImages ? product.images[ 0 ] : null;
const ParentComponent = showProductLink ? 'a' : Fragment;
const anchorLabel = sprintf(
/* translators: %s is referring to the product name */
__( 'Link to %s', 'woo-gutenberg-products-block' ),
product.name
);
const anchorProps = {
href: product.permalink,
...( ! hasProductImages && { 'aria-label': anchorLabel } ),
onClick: () => {
dispatchStoreEvent( 'product-view-link', {
product,
} );
},
};
return (
<div
className={ classnames(
className,
'wc-block-components-product-image',
{
[ `${ parentClassName }__product-image` ]: parentClassName,
},
styleProps.className
) }
style={ styleProps.style }
>
<ParentComponent { ...( showProductLink && anchorProps ) }>
{ !! showSaleBadge && (
<ProductSaleBadge
align={ saleBadgeAlign }
{ ...restProps }
/>
) }
<Image
fallbackAlt={ product.name }
image={ image }
loaded={ ! isLoading }
showFullSize={ imageSizing !== ImageSizing.THUMBNAIL }
width={ width }
height={ height }
scale={ scale }
/>
</ParentComponent>
</div>
);
};
export default withProductDataContext( Block );
blocks/product-elements/image/constants.tsx 0000644 00000000677 15154703036 0015167 0 ustar 00 /**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { image, Icon } from '@wordpress/icons';
export const BLOCK_TITLE: string = __(
'Product Image',
'woo-gutenberg-products-block'
);
export const BLOCK_ICON: JSX.Element = (
<Icon icon={ image } className="wc-block-editor-components-block-icon" />
);
export const BLOCK_DESCRIPTION: string = __(
'Display the main product image.',
'woo-gutenberg-products-block'
);
blocks/product-elements/image/edit.tsx 0000644 00000012554 15154703036 0014075 0 ustar 00 /**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { InspectorControls, useBlockProps } from '@wordpress/block-editor';
import { createInterpolateElement, useEffect } from '@wordpress/element';
import { getAdminLink, getSettingWithCoercion } from '@woocommerce/settings';
import { isBoolean } from '@woocommerce/types';
import type { BlockEditProps } from '@wordpress/blocks';
import { ProductQueryContext as Context } from '@woocommerce/blocks/product-query/types';
import {
Disabled,
PanelBody,
ToggleControl,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - Ignoring because `__experimentalToggleGroupControl` is not yet in the type definitions.
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalToggleGroupControl as ToggleGroupControl,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - Ignoring because `__experimentalToggleGroupControl` is not yet in the type definitions.
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalToggleGroupControlOption as ToggleGroupControlOption,
} from '@wordpress/components';
/**
* Internal dependencies
*/
import Block from './block';
import withProductSelector from '../shared/with-product-selector';
import {
BLOCK_TITLE as label,
BLOCK_ICON as icon,
BLOCK_DESCRIPTION as description,
} from './constants';
import { BlockAttributes, ImageSizing } from './types';
import { ImageSizeSettings } from './image-size-settings';
type SaleBadgeAlignProps = 'left' | 'center' | 'right';
const Edit = ( {
attributes,
setAttributes,
context,
}: BlockEditProps< BlockAttributes > & { context: Context } ): JSX.Element => {
const {
showProductLink,
imageSizing,
showSaleBadge,
saleBadgeAlign,
width,
height,
scale,
} = attributes;
const blockProps = useBlockProps( { style: { width, height } } );
const isDescendentOfQueryLoop = Number.isFinite( context.queryId );
const isBlockThemeEnabled = getSettingWithCoercion(
'isBlockThemeEnabled',
false,
isBoolean
);
useEffect(
() => setAttributes( { isDescendentOfQueryLoop } ),
[ setAttributes, isDescendentOfQueryLoop ]
);
return (
<div { ...blockProps }>
<InspectorControls>
<ImageSizeSettings
scale={ scale }
width={ width }
height={ height }
setAttributes={ setAttributes }
/>
<PanelBody
title={ __( 'Content', 'woo-gutenberg-products-block' ) }
>
<ToggleControl
label={ __(
'Link to Product Page',
'woo-gutenberg-products-block'
) }
help={ __(
'Links the image to the single product listing.',
'woo-gutenberg-products-block'
) }
checked={ showProductLink }
onChange={ () =>
setAttributes( {
showProductLink: ! showProductLink,
} )
}
/>
<ToggleControl
label={ __(
'Show On-Sale Badge',
'woo-gutenberg-products-block'
) }
help={ __(
'Display a “sale” badge if the product is on-sale.',
'woo-gutenberg-products-block'
) }
checked={ showSaleBadge }
onChange={ () =>
setAttributes( {
showSaleBadge: ! showSaleBadge,
} )
}
/>
{ showSaleBadge && (
<ToggleGroupControl
label={ __(
'Sale Badge Alignment',
'woo-gutenberg-products-block'
) }
value={ saleBadgeAlign }
onChange={ ( value: SaleBadgeAlignProps ) =>
setAttributes( { saleBadgeAlign: value } )
}
>
<ToggleGroupControlOption
value="left"
label={ __(
'Left',
'woo-gutenberg-products-block'
) }
/>
<ToggleGroupControlOption
value="center"
label={ __(
'Center',
'woo-gutenberg-products-block'
) }
/>
<ToggleGroupControlOption
value="right"
label={ __(
'Right',
'woo-gutenberg-products-block'
) }
/>
</ToggleGroupControl>
) }
{ ! isBlockThemeEnabled && (
<ToggleGroupControl
label={ __(
'Image Sizing',
'woo-gutenberg-products-block'
) }
help={ createInterpolateElement(
__(
'Product image cropping can be modified in the <a>Customizer</a>.',
'woo-gutenberg-products-block'
),
{
a: (
// eslint-disable-next-line jsx-a11y/anchor-has-content
<a
href={ `${ getAdminLink(
'customize.php'
) }?autofocus[panel]=woocommerce&autofocus[section]=woocommerce_product_images` }
target="_blank"
rel="noopener noreferrer"
/>
),
}
) }
value={ imageSizing }
onChange={ ( value: ImageSizing ) =>
setAttributes( { imageSizing: value } )
}
>
<ToggleGroupControlOption
value={ ImageSizing.SINGLE }
label={ __(
'Full Size',
'woo-gutenberg-products-block'
) }
/>
<ToggleGroupControlOption
value={ ImageSizing.THUMBNAIL }
label={ __(
'Cropped',
'woo-gutenberg-products-block'
) }
/>
</ToggleGroupControl>
) }
</PanelBody>
</InspectorControls>
<Disabled>
<Block { ...{ ...attributes, ...context } } />
</Disabled>
</div>
);
};
export default withProductSelector( { icon, label, description } )( Edit );
blocks/product-elements/image/frontend.ts 0000644 00000000411 15154703036 0014564 0 ustar 00 /**
* External dependencies
*/
import { withFilteredAttributes } from '@woocommerce/shared-hocs';
/**
* Internal dependencies
*/
import Block from './block';
import attributes from './attributes';
export default withFilteredAttributes( attributes )( Block );
blocks/product-elements/image/image-size-settings.tsx 0000644 00000006337 15154703036 0017042 0 ustar 00 /**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { BlockAttributes } from '@wordpress/blocks';
import {
// @ts-expect-error Using experimental features
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalToggleGroupControl as ToggleGroupControl,
// @ts-expect-error Using experimental features
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalToggleGroupControlOption as ToggleGroupControlOption,
// @ts-expect-error Using experimental features
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalToolsPanel as ToolsPanel,
// @ts-expect-error Using experimental features
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalToolsPanelItem as ToolsPanelItem,
// @ts-expect-error Using experimental features
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalUnitControl as UnitControl,
} from '@wordpress/components';
interface ImageSizeSettingProps {
scale: string;
width: string | undefined;
height: string | undefined;
setAttributes: ( attrs: BlockAttributes ) => void;
}
const scaleHelp: Record< string, string > = {
cover: __(
'Image is scaled and cropped to fill the entire space without being distorted.',
'woo-gutenberg-products-block'
),
contain: __(
'Image is scaled to fill the space without clipping nor distorting.',
'woo-gutenberg-products-block'
),
fill: __(
'Image will be stretched and distorted to completely fill the space.',
'woo-gutenberg-products-block'
),
};
export const ImageSizeSettings = ( {
scale,
width,
height,
setAttributes,
}: ImageSizeSettingProps ) => {
return (
<ToolsPanel
className="wc-block-product-image__tools-panel"
label={ __( 'Image size', 'woo-gutenberg-products-block' ) }
>
<UnitControl
label={ __( 'Height', 'woo-gutenberg-products-block' ) }
onChange={ ( value: string ) => {
setAttributes( { height: value } );
} }
value={ height }
units={ [
{
value: 'px',
label: 'px',
},
] }
/>
<UnitControl
label={ __( 'Width', 'woo-gutenberg-products-block' ) }
onChange={ ( value: string ) => {
setAttributes( { width: value } );
} }
value={ width }
units={ [
{
value: 'px',
label: 'px',
},
] }
/>
{ height && (
<ToolsPanelItem
hasValue={ () => true }
label={ __( 'Scale', 'woo-gutenberg-products-block' ) }
>
<ToggleGroupControl
label={ __( 'Scale', 'woo-gutenberg-products-block' ) }
value={ scale }
help={ scaleHelp[ scale ] }
onChange={ ( value: string ) =>
setAttributes( {
scale: value,
} )
}
isBlock
>
<>
<ToggleGroupControlOption
value="cover"
label={ __(
'Cover',
'woo-gutenberg-products-block'
) }
/>
<ToggleGroupControlOption
value="contain"
label={ __(
'Contain',
'woo-gutenberg-products-block'
) }
/>
<ToggleGroupControlOption
value="fill"
label={ __(
'Fill',
'woo-gutenberg-products-block'
) }
/>
</>
</ToggleGroupControl>
</ToolsPanelItem>
) }
</ToolsPanel>
);
};
blocks/product-elements/image/index.ts 0000644 00000001706 15154703036 0014064 0 ustar 00 /**
* External dependencies
*/
import { registerBlockType } from '@wordpress/blocks';
import type { BlockConfiguration } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import edit from './edit';
import { supports } from './supports';
import attributes from './attributes';
import sharedConfig from '../shared/config';
import {
BLOCK_TITLE as title,
BLOCK_ICON as icon,
BLOCK_DESCRIPTION as description,
} from './constants';
const blockConfig: BlockConfiguration = {
...sharedConfig,
apiVersion: 2,
name: 'woocommerce/product-image',
title,
icon: { src: icon },
keywords: [ 'WooCommerce' ],
description,
usesContext: [ 'query', 'queryId', 'postId' ],
ancestor: [
'woocommerce/all-products',
'woocommerce/single-product',
'core/post-template',
'woocommerce/product-template',
],
textdomain: 'woo-gutenberg-products-block',
attributes,
supports,
edit,
};
registerBlockType( 'woocommerce/product-image', { ...blockConfig } );
blocks/product-elements/image/style.scss 0000644 00000002350 15154703036 0014436 0 ustar 00 .editor-styles-wrapper .wc-block-grid__products .wc-block-grid__product .wc-block-components-product-image,
.wc-block-components-product-image {
text-decoration: none;
display: block;
position: relative;
a {
border-radius: inherit;
text-decoration: none;
border: 0;
outline: 0;
box-shadow: none;
}
img {
border-radius: inherit;
vertical-align: middle;
width: 100%;
height: auto;
&[hidden] {
display: none;
}
}
img[alt=""] {
border: 1px solid $image-placeholder-border-color;
}
.wc-block-components-product-sale-badge {
&--align-left {
position: absolute;
left: $gap-smaller * 0.5;
top: $gap-smaller * 0.5;
right: auto;
margin: 0;
}
&--align-center {
position: absolute;
top: $gap-smaller * 0.5;
left: 50%;
right: auto;
transform: translateX(-50%);
margin: 0;
}
&--align-right {
position: absolute;
right: $gap-smaller * 0.5;
top: $gap-smaller * 0.5;
left: auto;
margin: 0;
}
}
}
.is-loading .wc-block-components-product-image {
@include placeholder($include-border-radius: false);
width: auto;
}
.wc-block-components-product-image {
margin: 0 0 $gap-small;
}
.wc-block-product-image__tools-panel .components-input-control {
margin-bottom: 8px;
}
blocks/product-elements/image/supports.ts 0000644 00000001350 15154703036 0014647 0 ustar 00 /* eslint-disable @wordpress/no-unsafe-wp-apis */
/**
* External dependencies
*/
import { isFeaturePluginBuild } from '@woocommerce/block-settings';
import { __experimentalGetSpacingClassesAndStyles as getSpacingClassesAndStyles } from '@wordpress/block-editor';
/**
* Internal dependencies
*/
export const supports = {
html: false,
...( isFeaturePluginBuild() && {
__experimentalBorder: {
radius: true,
__experimentalSkipSerialization: true,
},
typography: {
fontSize: true,
__experimentalSkipSerialization: true,
},
...( typeof getSpacingClassesAndStyles === 'function' && {
spacing: {
margin: true,
padding: true,
},
} ),
__experimentalSelector: '.wc-block-components-product-image',
} ),
};
blocks/product-elements/image/test/block.test.tsx 0000644 00000016430 15154703036 0016174 0 ustar 00 /**
* External dependencies
*/
import { render, fireEvent } from '@testing-library/react';
import { ProductDataContextProvider } from '@woocommerce/shared-context';
import { ProductResponseItem } from '@woocommerce/types';
/**
* Internal dependencies
*/
import { Block } from '../block';
import { ImageSizing } from '../types';
jest.mock( '@woocommerce/base-hooks', () => ( {
__esModule: true,
useStyleProps: jest.fn( () => ( {
className: '',
style: {},
} ) ),
} ) );
const productWithoutImages: ProductResponseItem = {
name: 'Test product',
id: 1,
permalink: 'http://test.com/product/test-product/',
images: [],
parent: 0,
type: '',
variation: '',
sku: '',
short_description: '',
description: '',
on_sale: false,
prices: {
currency_code: 'USD',
currency_symbol: '',
currency_minor_unit: 0,
currency_decimal_separator: '',
currency_thousand_separator: '',
currency_prefix: '',
currency_suffix: '',
price: '',
regular_price: '',
sale_price: '',
price_range: null,
},
price_html: '',
average_rating: '',
review_count: 0,
categories: [],
tags: [],
attributes: [],
variations: [],
has_options: false,
is_purchasable: false,
is_in_stock: false,
is_on_backorder: false,
low_stock_remaining: null,
sold_individually: false,
add_to_cart: {
text: '',
description: '',
url: '',
minimum: 0,
maximum: 0,
multiple_of: 0,
},
};
const productWithImages: ProductResponseItem = {
name: 'Test product',
id: 1,
permalink: 'http://test.com/product/test-product/',
images: [
{
id: 56,
src: 'logo-1.jpg',
thumbnail: 'logo-1-324x324.jpg',
srcset: 'logo-1.jpg 800w, logo-1-300x300.jpg 300w, logo-1-150x150.jpg 150w, logo-1-768x767.jpg 768w, logo-1-324x324.jpg 324w, logo-1-416x415.jpg 416w, logo-1-100x100.jpg 100w',
sizes: '(max-width: 800px) 100vw, 800px',
name: 'logo-1.jpg',
alt: '',
},
{
id: 55,
src: 'beanie-with-logo-1.jpg',
thumbnail: 'beanie-with-logo-1-324x324.jpg',
srcset: 'beanie-with-logo-1.jpg 800w, beanie-with-logo-1-300x300.jpg 300w, beanie-with-logo-1-150x150.jpg 150w, beanie-with-logo-1-768x768.jpg 768w, beanie-with-logo-1-324x324.jpg 324w, beanie-with-logo-1-416x416.jpg 416w, beanie-with-logo-1-100x100.jpg 100w',
sizes: '(max-width: 800px) 100vw, 800px',
name: 'beanie-with-logo-1.jpg',
alt: '',
},
],
parent: 0,
type: '',
variation: '',
sku: '',
short_description: '',
description: '',
on_sale: false,
prices: {
currency_code: 'USD',
currency_symbol: '',
currency_minor_unit: 0,
currency_decimal_separator: '',
currency_thousand_separator: '',
currency_prefix: '',
currency_suffix: '',
price: '',
regular_price: '',
sale_price: '',
price_range: null,
},
price_html: '',
average_rating: '',
review_count: 0,
categories: [],
tags: [],
attributes: [],
variations: [],
has_options: false,
is_purchasable: false,
is_in_stock: false,
is_on_backorder: false,
low_stock_remaining: null,
sold_individually: false,
add_to_cart: {
text: '',
description: '',
url: '',
minimum: 0,
maximum: 0,
multiple_of: 0,
},
};
describe( 'Product Image Block', () => {
describe( 'with product link', () => {
test( 'should render an anchor with the product image', () => {
const component = render(
<ProductDataContextProvider
product={ productWithImages }
isLoading={ false }
>
<Block
showProductLink={ true }
productId={ productWithImages.id }
showSaleBadge={ false }
saleBadgeAlign={ 'left' }
imageSizing={ ImageSizing.SINGLE }
isDescendentOfQueryLoop={ false }
/>
</ProductDataContextProvider>
);
// use testId as alt is added after image is loaded
const image = component.getByTestId( 'product-image' );
fireEvent.load( image );
const productImage = component.getByAltText(
productWithImages.name
);
expect( productImage.getAttribute( 'src' ) ).toBe(
productWithImages.images[ 0 ].src
);
const anchor = productImage.closest( 'a' );
expect( anchor?.getAttribute( 'href' ) ).toBe(
productWithImages.permalink
);
} );
test( 'should render an anchor with the placeholder image', () => {
const component = render(
<ProductDataContextProvider
product={ productWithoutImages }
isLoading={ false }
>
<Block
showProductLink={ true }
productId={ productWithoutImages.id }
showSaleBadge={ false }
saleBadgeAlign={ 'left' }
imageSizing={ ImageSizing.SINGLE }
isDescendentOfQueryLoop={ false }
/>
</ProductDataContextProvider>
);
const placeholderImage = component.getByAltText( '' );
expect( placeholderImage.getAttribute( 'src' ) ).toBe(
'placeholder.jpg'
);
const anchor = placeholderImage.closest( 'a' );
expect( anchor?.getAttribute( 'href' ) ).toBe(
productWithoutImages.permalink
);
expect( anchor?.getAttribute( 'aria-label' ) ).toBe(
`Link to ${ productWithoutImages.name }`
);
} );
} );
describe( 'without product link', () => {
test( 'should render the product image without an anchor wrapper', () => {
const component = render(
<ProductDataContextProvider
product={ productWithImages }
isLoading={ false }
>
<Block
showProductLink={ false }
productId={ productWithImages.id }
showSaleBadge={ false }
saleBadgeAlign={ 'left' }
imageSizing={ ImageSizing.SINGLE }
isDescendentOfQueryLoop={ false }
/>
</ProductDataContextProvider>
);
const image = component.getByTestId( 'product-image' );
fireEvent.load( image );
const productImage = component.getByAltText(
productWithImages.name
);
expect( productImage.getAttribute( 'src' ) ).toBe(
productWithImages.images[ 0 ].src
);
const anchor = productImage.closest( 'a' );
expect( anchor ).toBe( null );
} );
test( 'should render the placeholder image without an anchor wrapper', () => {
const component = render(
<ProductDataContextProvider
product={ productWithoutImages }
isLoading={ false }
>
<Block
showProductLink={ false }
productId={ productWithoutImages.id }
showSaleBadge={ false }
saleBadgeAlign={ 'left' }
imageSizing={ ImageSizing.SINGLE }
isDescendentOfQueryLoop={ false }
/>
</ProductDataContextProvider>
);
const placeholderImage = component.getByAltText( '' );
expect( placeholderImage.getAttribute( 'src' ) ).toBe(
'placeholder.jpg'
);
const anchor = placeholderImage.closest( 'a' );
expect( anchor ).toBe( null );
} );
} );
describe( 'without image', () => {
test( 'should render the placeholder with no inline width or height attributes', () => {
const component = render(
<ProductDataContextProvider
product={ productWithoutImages }
isLoading={ false }
>
<Block
showProductLink={ true }
productId={ productWithoutImages.id }
showSaleBadge={ false }
saleBadgeAlign={ 'left' }
imageSizing={ ImageSizing.SINGLE }
isDescendentOfQueryLoop={ false }
/>
</ProductDataContextProvider>
);
const placeholderImage = component.getByAltText( '' );
expect( placeholderImage.getAttribute( 'src' ) ).toBe(
'placeholder.jpg'
);
expect( placeholderImage.getAttribute( 'width' ) ).toBe( null );
expect( placeholderImage.getAttribute( 'height' ) ).toBe( null );
} );
} );
} );
blocks/product-elements/image/types.ts 0000644 00000001405 15154703036 0014115 0 ustar 00 export enum ImageSizing {
SINGLE = 'single',
THUMBNAIL = 'thumbnail',
}
export interface BlockAttributes {
// The product ID.
productId: number;
// CSS Class name for the component.
className?: string | undefined;
// Whether or not to display a link to the product page.
showProductLink: boolean;
// Whether or not to display the on sale badge.
showSaleBadge: boolean;
// How should the sale badge be aligned if displayed.
saleBadgeAlign: 'left' | 'center' | 'right';
// Size of image to use.
imageSizing: ImageSizing;
// Whether or not be a children of Query Loop Block.
isDescendentOfQueryLoop: boolean;
// Height of the image.
height?: string;
// Width of the image.
width?: string;
// Image scaling method.
scale: 'cover' | 'contain' | 'fill';
}
blocks/product-elements/price/attributes.ts 0000644 00000000766 15154703036 0015170 0 ustar 00 /**
* External dependencies
*/
import { BlockAttributes } from '@wordpress/blocks';
export const blockAttributes: BlockAttributes = {
productId: {
type: 'number',
default: 0,
},
isDescendentOfQueryLoop: {
type: 'boolean',
default: false,
},
textAlign: {
type: 'string',
default: '',
},
isDescendentOfSingleProductTemplate: {
type: 'boolean',
default: false,
},
isDescendentOfSingleProductBlock: {
type: 'boolean',
default: false,
},
};
export default blockAttributes;
blocks/product-elements/price/block.tsx 0000644 00000007747 15154703036 0014272 0 ustar 00 /**
* External dependencies
*/
import classnames from 'classnames';
import ProductPrice from '@woocommerce/base-components/product-price';
import { getCurrencyFromPriceResponse } from '@woocommerce/price-format';
import {
useInnerBlockLayoutContext,
useProductDataContext,
} from '@woocommerce/shared-context';
import { useStyleProps } from '@woocommerce/base-hooks';
import { withProductDataContext } from '@woocommerce/shared-hocs';
import { CurrencyCode } from '@woocommerce/type-defs/currency';
import type { HTMLAttributes } from 'react';
/**
* Internal dependencies
*/
import type { BlockAttributes } from './types';
type Props = BlockAttributes & HTMLAttributes< HTMLDivElement >;
interface PriceProps {
currency_code: CurrencyCode;
currency_symbol: string;
currency_minor_unit: number;
currency_decimal_separator: string;
currency_thousand_separator: string;
currency_prefix: string;
currency_suffix: string;
price: string;
regular_price: string;
sale_price: string;
price_range: null | { min_amount: string; max_amount: string };
}
export const Block = ( props: Props ): JSX.Element | null => {
const { className, textAlign, isDescendentOfSingleProductTemplate } = props;
const styleProps = useStyleProps( props );
const { parentName, parentClassName } = useInnerBlockLayoutContext();
const { product } = useProductDataContext();
const isDescendentOfAllProductsBlock =
parentName === 'woocommerce/all-products';
const wrapperClassName = classnames(
'wc-block-components-product-price',
className,
styleProps.className,
{
[ `${ parentClassName }__product-price` ]: parentClassName,
}
);
if ( ! product.id && ! isDescendentOfSingleProductTemplate ) {
const productPriceComponent = (
<ProductPrice align={ textAlign } className={ wrapperClassName } />
);
if ( isDescendentOfAllProductsBlock ) {
return (
<div className="wp-block-woocommerce-product-price">
{ productPriceComponent }
</div>
);
}
return productPriceComponent;
}
const prices: PriceProps = product.prices;
const currency = isDescendentOfSingleProductTemplate
? getCurrencyFromPriceResponse()
: getCurrencyFromPriceResponse( prices );
const pricePreview = '5000';
const isOnSale = prices.price !== prices.regular_price;
const priceClassName = classnames( {
[ `${ parentClassName }__product-price__value` ]: parentClassName,
[ `${ parentClassName }__product-price__value--on-sale` ]: isOnSale,
} );
const productPriceComponent = (
<ProductPrice
align={ textAlign }
className={ wrapperClassName }
style={ styleProps.style }
regularPriceStyle={ styleProps.style }
priceStyle={ styleProps.style }
priceClassName={ priceClassName }
currency={ currency }
price={
isDescendentOfSingleProductTemplate
? pricePreview
: prices.price
}
// Range price props
minPrice={ prices?.price_range?.min_amount }
maxPrice={ prices?.price_range?.max_amount }
// This is the regular or original price when the `price` value is a sale price.
regularPrice={
isDescendentOfSingleProductTemplate
? pricePreview
: prices.regular_price
}
regularPriceClassName={ classnames( {
[ `${ parentClassName }__product-price__regular` ]:
parentClassName,
} ) }
/>
);
if ( isDescendentOfAllProductsBlock ) {
return (
<div className="wp-block-woocommerce-product-price">
{ productPriceComponent }
</div>
);
}
return productPriceComponent;
};
export default ( props: Props ) => {
// It is necessary because this block has to support serveral contexts:
// - Inside `All Products Block` -> `withProductDataContext` HOC
// - Inside `Products Block` -> Gutenberg Context
// - Inside `Single Product Template` -> Gutenberg Context
// - Without any parent -> `WithSelector` and `withProductDataContext` HOCs
// For more details, check https://github.com/woocommerce/woocommerce-blocks/pull/8609
if ( props.isDescendentOfSingleProductTemplate ) {
return <Block { ...props } />;
}
return withProductDataContext( Block )( props );
};
blocks/product-elements/price/constants.tsx 0000644 00000000726 15154703036 0015202 0 ustar 00 /**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { currencyDollar, Icon } from '@wordpress/icons';
export const BLOCK_TITLE: string = __(
'Product Price',
'woo-gutenberg-products-block'
);
export const BLOCK_ICON: JSX.Element = (
<Icon
icon={ currencyDollar }
className="wc-block-editor-components-block-icon"
/>
);
export const BLOCK_DESCRIPTION: string = __(
'Display the price of a product.',
'woo-gutenberg-products-block'
);
blocks/product-elements/price/edit.tsx 0000644 00000003665 15154703036 0014120 0 ustar 00 /**
* External dependencies
*/
import {
AlignmentToolbar,
BlockControls,
useBlockProps,
} from '@wordpress/block-editor';
import { useEffect } from '@wordpress/element';
import type { BlockAlignment } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import Block from './block';
import { useIsDescendentOfSingleProductTemplate } from '../shared/use-is-descendent-of-single-product-template';
type UnsupportedAligments = 'wide' | 'full';
type AllowedAlignments = Exclude< BlockAlignment, UnsupportedAligments >;
interface BlockAttributes {
textAlign?: AllowedAlignments;
}
interface Attributes {
textAlign: 'left' | 'center' | 'right';
isDescendentOfSingleProduct: boolean;
isDescendentOfSingleProductBlock: boolean;
productId: number;
}
interface Context {
queryId: number;
}
interface Props {
attributes: Attributes;
setAttributes: (
attributes: Partial< BlockAttributes > & Record< string, unknown >
) => void;
context: Context;
}
const PriceEdit = ( {
attributes,
setAttributes,
context,
}: Props ): JSX.Element => {
const blockProps = useBlockProps();
const blockAttrs = {
...attributes,
...context,
};
const isDescendentOfQueryLoop = Number.isFinite( context.queryId );
let { isDescendentOfSingleProductTemplate } =
useIsDescendentOfSingleProductTemplate( { isDescendentOfQueryLoop } );
if ( isDescendentOfQueryLoop ) {
isDescendentOfSingleProductTemplate = false;
}
useEffect(
() =>
setAttributes( {
isDescendentOfQueryLoop,
isDescendentOfSingleProductTemplate,
} ),
[
isDescendentOfQueryLoop,
isDescendentOfSingleProductTemplate,
setAttributes,
]
);
return (
<>
<BlockControls>
<AlignmentToolbar
value={ attributes.textAlign }
onChange={ ( textAlign: AllowedAlignments ) => {
setAttributes( { textAlign } );
} }
/>
</BlockControls>
<div { ...blockProps }>
<Block { ...blockAttrs } />
</div>
</>
);
};
export default PriceEdit;
blocks/product-elements/price/index.tsx 0000644 00000001254 15154703036 0014272 0 ustar 00 /**
* External dependencies
*/
import { registerBlockType } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import sharedConfig from '../shared/config';
import edit from './edit';
import attributes from './attributes';
import { supports } from './supports';
import {
BLOCK_TITLE as title,
BLOCK_ICON as icon,
BLOCK_DESCRIPTION as description,
} from './constants';
const { ancestor, ...configuration } = sharedConfig;
const blockConfig = {
...configuration,
apiVersion: 2,
title,
description,
usesContext: [ 'query', 'queryId', 'postId' ],
icon: { src: icon },
attributes,
supports,
edit,
};
registerBlockType( 'woocommerce/product-price', blockConfig );
blocks/product-elements/price/supports.ts 0000644 00000001670 15154703036 0014674 0 ustar 00 /**
* External dependencies
*/
import { isFeaturePluginBuild } from '@woocommerce/block-settings';
import { __experimentalGetSpacingClassesAndStyles } from '@wordpress/block-editor';
/**
* Internal dependencies
*/
import sharedConfig from '../shared/config';
export const supports = {
...sharedConfig.supports,
...( isFeaturePluginBuild() && {
color: {
text: true,
background: true,
link: false,
__experimentalSkipSerialization: true,
},
typography: {
fontSize: true,
lineHeight: true,
__experimentalFontFamily: true,
__experimentalFontWeight: true,
__experimentalFontStyle: true,
__experimentalSkipSerialization: true,
__experimentalLetterSpacing: true,
},
__experimentalSelector:
'.wp-block-woocommerce-product-price .wc-block-components-product-price',
} ),
...( typeof __experimentalGetSpacingClassesAndStyles === 'function' && {
spacing: {
margin: true,
padding: true,
},
} ),
};
blocks/product-elements/price/types.ts 0000644 00000000315 15154703036 0014134 0 ustar 00 export interface BlockAttributes {
productId?: number;
className?: string;
textAlign?: 'left' | 'center' | 'right';
isDescendentOfQueryLoop?: boolean;
isDescendentOfSingleProductTemplate?: boolean;
}
blocks/product-elements/product-details/block.json 0000644 00000000647 15154703037 0016417 0 ustar 00 {
"name": "woocommerce/product-details",
"version": "1.0.0",
"icon": "info",
"title": "Product Details",
"description": "Display a product's description, attributes, and reviews.",
"category": "woocommerce",
"keywords": [ "WooCommerce" ],
"supports": {
"align": true,
"spacing": {
"margin": true
}
},
"textdomain": "woocommerce",
"apiVersion": 2,
"$schema": "https://schemas.wp.org/trunk/block.json"
}
blocks/product-elements/product-details/block.tsx 0000644 00000003441 15154703037 0016257 0 ustar 00 /**
* External dependencies
*/
import classnames from 'classnames';
import { __ } from '@wordpress/i18n';
import { useBlockProps } from '@wordpress/block-editor';
interface SingleProductTab {
id: string;
title: string;
active: boolean;
content: string | undefined;
}
const ProductTabTitle = ( {
id,
title,
active,
}: Pick< SingleProductTab, 'id' | 'title' | 'active' > ) => {
return (
<li
className={ classnames( `${ id }_tab`, {
active,
} ) }
id={ `tab-title-${ id }` }
role="tab"
aria-controls={ `tab-${ id }` }
>
<a href={ `#tab-${ id }` }>{ title }</a>
</li>
);
};
const ProductTabContent = ( {
id,
content,
}: Pick< SingleProductTab, 'id' | 'content' > ) => {
return (
<div
className={ `${ id }_tab` }
id={ `tab-title-${ id }` }
role="tab"
aria-controls={ `tab-${ id }` }
>
{ content }
</div>
);
};
export const SingleProductDetails = () => {
const blockProps = useBlockProps();
const productTabs = [
{
id: 'description',
title: 'Description',
active: true,
content: __(
'This block lists description, attributes and reviews for a single product.',
'woo-gutenberg-products-block'
),
},
{
id: 'additional_information',
title: 'Additional Information',
active: false,
},
{ id: 'reviews', title: 'Reviews', active: false },
];
const tabsTitle = productTabs.map( ( { id, title, active } ) => (
<ProductTabTitle
key={ id }
id={ id }
title={ title }
active={ active }
/>
) );
const tabsContent = productTabs.map( ( { id, content } ) => (
<ProductTabContent key={ id } id={ id } content={ content } />
) );
return (
<div { ...blockProps }>
<ul className="wc-tabs tabs" role="tablist">
{ tabsTitle }
</ul>
{ tabsContent }
</div>
);
};
export default SingleProductDetails;
blocks/product-elements/product-details/edit.tsx 0000644 00000001117 15154703037 0016110 0 ustar 00 /**
* External dependencies
*/
import { useBlockProps } from '@wordpress/block-editor';
import { Disabled } from '@wordpress/components';
import type { BlockEditProps } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import Block from './block';
import { Attributes } from './types';
const Edit = ( { attributes }: BlockEditProps< Attributes > ) => {
const { className } = attributes;
const blockProps = useBlockProps( {
className,
} );
return (
<>
<div { ...blockProps }>
<Disabled>
<Block />
</Disabled>
</div>
</>
);
};
export default Edit;
blocks/product-elements/product-details/index.tsx 0000644 00000001311 15154703037 0016266 0 ustar 00 /**
* External dependencies
*/
import { registerBlockSingleProductTemplate } from '@woocommerce/atomic-utils';
import { Icon } from '@wordpress/icons';
import { productDetails } from '@woocommerce/icons';
/**
* Internal dependencies
*/
import metadata from './block.json';
import edit from './edit';
import './style.scss';
registerBlockSingleProductTemplate( {
blockName: metadata.name,
// @ts-expect-error: `metadata` currently does not have a type definition in WordPress core
blockMetadata: metadata,
blockSettings: {
icon: {
src: (
<Icon
icon={ productDetails }
className="wc-block-editor-components-block-icon"
/>
),
},
edit,
},
isAvailableOnPostEditor: false,
} );
blocks/product-elements/product-details/style.scss 0000644 00000001124 15154703037 0016456 0 ustar 00 .wp-block-woocommerce-product-details {
ul.wc-tabs {
list-style: none;
padding: 0 0 0 1em;
margin: 0 0 1.618em;
overflow: hidden;
position: relative;
border-bottom: 1px solid $gray-200;
li {
border: 1px solid $gray-200;
display: inline-block;
position: relative;
z-index: 0;
border-radius: 4px 4px 0 0;
margin: 0;
padding: 0.5em 1em;
a {
display: inline-block;
font-weight: 700;
text-decoration: none;
&:hover {
text-decoration: none;
}
}
&.active {
z-index: 2;
a {
text-shadow: inherit;
}
}
}
}
}
blocks/product-elements/product-details/types.ts 0000644 00000000065 15154703037 0016140 0 ustar 00 export interface Attributes {
className?: string;
}
blocks/product-elements/product-image-gallery/block.json 0000644 00000000674 15154703037 0017511 0 ustar 00 {
"name": "woocommerce/product-image-gallery",
"version": "1.0.0",
"title": "Product Image Gallery",
"icon": "gallery",
"description": "Display a product's images.",
"category": "woocommerce",
"supports": {
"align": true,
"multiple": false
},
"keywords": [ "WooCommerce" ],
"usesContext": [ "postId", "postType", "queryId" ],
"textdomain": "woocommerce",
"apiVersion": 2,
"$schema": "https://schemas.wp.org/trunk/block.json"
}
blocks/product-elements/product-image-gallery/edit.tsx 0000644 00000002666 15154703037 0017214 0 ustar 00 /**
* External dependencies
*/
import { WC_BLOCKS_IMAGE_URL } from '@woocommerce/block-settings';
import { isEmptyObject } from '@woocommerce/types';
import { useBlockProps } from '@wordpress/block-editor';
import { BlockAttributes } from '@wordpress/blocks';
import { Disabled } from '@wordpress/components';
import type { BlockEditProps } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import './editor.scss';
const Placeholder = () => {
return (
<div className="wc-block-editor-product-gallery">
<img
src={ `${ WC_BLOCKS_IMAGE_URL }block-placeholders/product-image-gallery.svg` }
alt="Placeholder"
/>
<div className="wc-block-editor-product-gallery__other-images">
{ [ ...Array( 4 ).keys() ].map( ( index ) => {
return (
<img
key={ index }
src={ `${ WC_BLOCKS_IMAGE_URL }block-placeholders/product-image-gallery.svg` }
alt="Placeholder"
/>
);
} ) }
</div>
</div>
);
};
type Context = {
postId: string;
postType: string;
queryId: string;
};
interface Props extends BlockEditProps< BlockAttributes > {
context: Context;
}
const Edit = ( { context }: Props ) => {
const blockProps = useBlockProps();
if ( isEmptyObject( context ) ) {
return (
<div { ...blockProps }>
<Disabled>
<Placeholder />
</Disabled>
</div>
);
}
// We have work on this case when we will work on the Single Product block.
return <></>;
};
export default Edit;
blocks/product-elements/product-image-gallery/editor.scss 0000644 00000000326 15154703037 0017701 0 ustar 00 .wc-block-editor-product-gallery {
img {
max-width: 500px;
width: 100%;
height: auto;
}
.wc-block-editor-product-gallery__other-images {
img {
width: 100px;
height: 100px;
margin: 5px;
}
}
}
blocks/product-elements/product-image-gallery/index.ts 0000644 00000001154 15154703037 0017175 0 ustar 00 /**
* External dependencies
*/
import { gallery as icon } from '@wordpress/icons';
import { registerBlockSingleProductTemplate } from '@woocommerce/atomic-utils';
/**
* Internal dependencies
*/
import edit from './edit';
import metadata from './block.json';
import './style.scss';
registerBlockSingleProductTemplate( {
blockName: metadata.name,
// @ts-expect-error: `metadata` currently does not have a type definition in WordPress core
blockMetadata: metadata,
blockSettings: {
icon,
// @ts-expect-error `edit` can be extended to include other attributes
edit,
},
isAvailableOnPostEditor: false,
} );
blocks/product-elements/product-image-gallery/style.scss 0000644 00000001445 15154703037 0017556 0 ustar 00 .woocommerce .wp-block-woocommerce-product-image-gallery {
position: relative;
// This is necessary to calculate the correct width of the gallery. https://www.lockedownseo.com/parent-div-100-height-child-floated-elements/#:~:text=Solution%20%232%3A%20Float%20Parent%20Container
clear: both;
max-width: 512px;
span.onsale {
right: unset;
z-index: 1;
left: -1rem;
}
}
// This is necessary to calculate the correct width of the gallery. https://www.lockedownseo.com/parent-div-100-height-child-floated-elements/#:~:text=Solution%20%232%3A%20Float%20Parent%20Container
.woocommerce .wp-block-woocommerce-product-image-gallery::after {
clear: both;
content: "";
display: table;
}
.woocommerce .wp-block-woocommerce-product-image-gallery .woocommerce-product-gallery.images {
width: 100%;
}
blocks/product-elements/product-meta/block.json 0000644 00000000705 15154703037 0015713 0 ustar 00 {
"name": "woocommerce/product-meta",
"version": "1.0.0",
"title": "Product Meta",
"icon": "product",
"description": "Display a product’s SKU, categories, tags, and more.",
"category": "woocommerce",
"supports": {
"align": true,
"reusable": false
},
"keywords": [ "WooCommerce" ],
"usesContext": [ "postId", "postType", "queryId" ],
"textdomain": "woocommerce",
"apiVersion": 2,
"$schema": "https://schemas.wp.org/trunk/block.json"
}
blocks/product-elements/product-meta/edit.tsx 0000644 00000002024 15154703037 0015407 0 ustar 00 /**
* External dependencies
*/
import { InnerBlocks, useBlockProps } from '@wordpress/block-editor';
import { InnerBlockTemplate } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import './editor.scss';
import { useIsDescendentOfSingleProductTemplate } from '../shared/use-is-descendent-of-single-product-template';
const Edit = () => {
const isDescendentOfSingleProductTemplate =
useIsDescendentOfSingleProductTemplate();
const TEMPLATE: InnerBlockTemplate[] = [
[
'core/group',
{ layout: { type: 'flex', flexWrap: 'nowrap' } },
[
[
'woocommerce/product-sku',
{
isDescendentOfSingleProductTemplate,
},
],
[
'core/post-terms',
{
prefix: 'Category: ',
term: 'product_cat',
},
],
[
'core/post-terms',
{
prefix: 'Tags: ',
term: 'product_tag',
},
],
],
],
];
const blockProps = useBlockProps();
return (
<div { ...blockProps }>
<InnerBlocks template={ TEMPLATE } />
</div>
);
};
export default Edit;
blocks/product-elements/product-meta/editor.scss 0000644 00000000133 15154703037 0016104 0 ustar 00 .wc-block-editor-related-products__notice {
margin: 10px auto;
max-width: max-content;
}
blocks/product-elements/product-meta/index.tsx 0000644 00000001374 15154703037 0015600 0 ustar 00 /**
* External dependencies
*/
import { registerBlockSingleProductTemplate } from '@woocommerce/atomic-utils';
import { Icon } from '@wordpress/icons';
import { productMeta } from '@woocommerce/icons';
/**
* Internal dependencies
*/
import edit from './edit';
import save from './save';
import metadata from './block.json';
registerBlockSingleProductTemplate( {
blockName: metadata.name,
// @ts-expect-error: `metadata` currently does not have a type definition in WordPress core
blockMetadata: metadata,
blockSettings: {
edit,
save,
icon: {
src: (
<Icon
icon={ productMeta }
className="wc-block-editor-components-block-icon"
/>
),
},
ancestor: [ 'woocommerce/single-product' ],
},
isAvailableOnPostEditor: true,
} );
blocks/product-elements/product-meta/save.tsx 0000644 00000000560 15154703037 0015423 0 ustar 00 /**
* External dependencies
*/
import { InnerBlocks, useBlockProps } from '@wordpress/block-editor';
const Save = () => {
const blockProps = useBlockProps.save();
return (
<div { ...blockProps }>
{ /* @ts-expect-error: `InnerBlocks.Content` is a component that is typed in WordPress core*/ }
<InnerBlocks.Content />
</div>
);
};
export default Save;
blocks/product-elements/product-reviews/block.json 0000644 00000000636 15154703037 0016454 0 ustar 00 {
"name": "woocommerce/product-reviews",
"version": "1.0.0",
"icon": "admin-comments",
"title": "Product Reviews",
"description": "A block that shows the reviews for a product.",
"category": "woocommerce",
"keywords": [ "WooCommerce" ],
"supports": {},
"attributes": {},
"usesContext": [ "postId" ],
"textdomain": "woocommerce",
"apiVersion": 2,
"$schema": "https://schemas.wp.org/trunk/block.json"
}
blocks/product-elements/product-reviews/block.tsx 0000644 00000003673 15154703037 0016325 0 ustar 00 // We are using anchors as mere placeholders to replicate the front-end look.
/* eslint-disable jsx-a11y/anchor-is-valid */
/**
* External dependencies
*/
import { WC_BLOCKS_IMAGE_URL } from '@woocommerce/block-settings';
import { __ } from '@wordpress/i18n';
import { useBlockProps } from '@wordpress/block-editor';
import { Notice } from '@wordpress/components';
export const ProductReviews = () => {
const blockProps = useBlockProps();
return (
<div { ...blockProps }>
<Notice
className={ 'wc-block-editor-related-products__notice' }
status={ 'info' }
isDismissible={ false }
>
<p>
{ __(
'The products reviews and the form to add a new review will be displayed here according to your theme. The look you see here is not representative of what is going to look like, this is just a placeholder.',
'woo-gutenberg-products-block'
) }
</p>
</Notice>
<h2>
{ __(
'3 reviews for this product',
'woo-gutenberg-products-block'
) }
</h2>
<img
src={ `${ WC_BLOCKS_IMAGE_URL }block-placeholders/product-reviews.svg` }
alt="Placeholder"
/>
<h3>{ __( 'Add a review', 'woo-gutenberg-products-block' ) }</h3>
<div className="wp-block-woocommerce-product-reviews__editor__form-container">
<div className="wp-block-woocommerce-product-reviews__editor__row">
<span>
{ __(
'Your rating *',
'woo-gutenberg-products-block'
) }
</span>
<p className="wp-block-woocommerce-product-reviews__editor__stars"></p>
</div>
<div className="wp-block-woocommerce-product-reviews__editor__row">
<span>
{ __(
'Your review *',
'woo-gutenberg-products-block'
) }
</span>
<textarea />
</div>
<input
type="submit"
className="submit wp-block-button__link wp-element-button"
value={ __( 'Submit', 'woo-gutenberg-products-block' ) }
/>
</div>
</div>
);
};
export default ProductReviews;
blocks/product-elements/product-reviews/edit.tsx 0000644 00000001117 15154703037 0016147 0 ustar 00 /**
* External dependencies
*/
import { useBlockProps } from '@wordpress/block-editor';
import { Disabled } from '@wordpress/components';
import type { BlockEditProps } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import Block from './block';
import { Attributes } from './types';
const Edit = ( { attributes }: BlockEditProps< Attributes > ) => {
const { className } = attributes;
const blockProps = useBlockProps( {
className,
} );
return (
<>
<div { ...blockProps }>
<Disabled>
<Block />
</Disabled>
</div>
</>
);
};
export default Edit;
blocks/product-elements/product-reviews/index.tsx 0000644 00000000747 15154703037 0016341 0 ustar 00 /**
* External dependencies
*/
import { registerBlockSingleProductTemplate } from '@woocommerce/atomic-utils';
/**
* Internal dependencies
*/
import metadata from './block.json';
import edit from './edit';
import './style.scss';
registerBlockSingleProductTemplate( {
blockName: metadata.name,
// @ts-expect-error: `metadata` currently does not have a type definition in WordPress core
blockMetadata: metadata,
blockSettings: {
edit,
},
isAvailableOnPostEditor: false,
} );
blocks/product-elements/product-reviews/style.scss 0000644 00000001521 15154703037 0016516 0 ustar 00 .wp-block-woocommerce-product-reviews {
img {
max-width: 600px;
}
.submit {
margin-top: 2rem;
}
}
.wp-block-woocommerce-product-reviews__editor__row {
align-items: center;
display: flex;
gap: 2rem;
> span {
flex-basis: 20%;
}
textarea,
.wp-block-woocommerce-product-reviews__editor__stars {
flex-grow: 1;
margin-right: 1rem;
}
textarea {
flex-grow: 1;
height: 8rem;
}
}
.wp-block-woocommerce-product-reviews__editor__stars {
display: inline-block;
overflow: hidden;
position: relative;
width: 5.3em;
height: 1.618em;
line-height: 1.618;
font-size: 1em;
/* stylelint-disable-next-line font-family-no-missing-generic-family-keyword */
font-family: star;
font-weight: 400;
&::before {
color: inherit;
content: "SSSSS";
position: absolute;
left: 0;
right: 0;
top: 0;
white-space: nowrap;
}
}
blocks/product-elements/product-reviews/types.ts 0000644 00000000065 15154703037 0016177 0 ustar 00 export interface Attributes {
className?: string;
}
blocks/product-elements/rating/block.json 0000644 00000001452 15154703037 0014573 0 ustar 00 {
"name": "woocommerce/product-rating",
"version": "1.0.0",
"icon": "info",
"title": "Product Rating",
"description": "Display the average rating of a product.",
"attributes": {
"productId": {
"type": "number",
"default": 0
},
"isDescendentOfQueryLoop": {
"type": "boolean",
"default": false
},
"textAlign": {
"type": "string",
"default": ""
},
"isDescendentOfSingleProductBlock": {
"type": "boolean",
"default": false
},
"isDescendentOfSingleProductTemplate": {
"type": "boolean",
"default": false
}
},
"usesContext": [ "query", "queryId", "postId" ],
"category": "woocommerce",
"keywords": [ "WooCommerce" ],
"supports": {
"align": true
},
"textdomain": "woocommerce",
"apiVersion": 2,
"$schema": "https://schemas.wp.org/trunk/block.json"
}
blocks/product-elements/rating/block.tsx 0000644 00000011272 15154703037 0014441 0 ustar 00 /**
* External dependencies
*/
import { __, _n, sprintf } from '@wordpress/i18n';
import classnames from 'classnames';
import {
useInnerBlockLayoutContext,
useProductDataContext,
} from '@woocommerce/shared-context';
import { useStyleProps } from '@woocommerce/base-hooks';
import { withProductDataContext } from '@woocommerce/shared-hocs';
import { isNumber, ProductResponseItem } from '@woocommerce/types';
/**
* Internal dependencies
*/
import './style.scss';
type RatingProps = {
reviews: number;
rating: number;
parentClassName?: string;
};
const getAverageRating = (
product: Omit< ProductResponseItem, 'average_rating' > & {
average_rating: string;
}
) => {
const rating = parseFloat( product.average_rating );
return Number.isFinite( rating ) && rating > 0 ? rating : 0;
};
const getRatingCount = ( product: ProductResponseItem ) => {
const count = isNumber( product.review_count )
? product.review_count
: parseInt( product.review_count, 10 );
return Number.isFinite( count ) && count > 0 ? count : 0;
};
const getStarStyle = ( rating: number ) => ( {
width: ( rating / 5 ) * 100 + '%',
} );
const NoRating = ( { parentClassName }: { parentClassName: string } ) => {
const starStyle = getStarStyle( 0 );
return (
<div
className={ classnames(
'wc-block-components-product-rating__norating-container',
`${ parentClassName }-product-rating__norating-container`
) }
>
<div
className={ 'wc-block-components-product-rating__norating' }
role="img"
>
<span style={ starStyle } />
</div>
<span>{ __( 'No Reviews', 'woo-gutenberg-products-block' ) }</span>
</div>
);
};
const Rating = ( props: RatingProps ): JSX.Element => {
const { rating, reviews, parentClassName } = props;
const starStyle = getStarStyle( rating );
const ratingText = sprintf(
/* translators: %f is referring to the average rating value */
__( 'Rated %f out of 5', 'woo-gutenberg-products-block' ),
rating
);
const ratingHTML = {
__html: sprintf(
/* translators: %1$s is referring to the average rating value, %2$s is referring to the number of ratings */
_n(
'Rated %1$s out of 5 based on %2$s customer rating',
'Rated %1$s out of 5 based on %2$s customer ratings',
reviews,
'woo-gutenberg-products-block'
),
sprintf( '<strong class="rating">%f</strong>', rating ),
sprintf( '<span class="rating">%d</span>', reviews )
),
};
return (
<div
className={ classnames(
'wc-block-components-product-rating__stars',
`${ parentClassName }__product-rating__stars`
) }
role="img"
aria-label={ ratingText }
>
<span style={ starStyle } dangerouslySetInnerHTML={ ratingHTML } />
</div>
);
};
const ReviewsCount = ( props: { reviews: number } ): JSX.Element => {
const { reviews } = props;
const reviewsCount = sprintf(
/* translators: %s is referring to the total of reviews for a product */
_n(
'(%s customer review)',
'(%s customer reviews)',
reviews,
'woo-gutenberg-products-block'
),
reviews
);
return (
<span className="wc-block-components-product-rating__reviews_count">
{ reviewsCount }
</span>
);
};
type ProductRatingProps = {
className?: string;
textAlign?: string;
isDescendentOfSingleProductBlock: boolean;
isDescendentOfQueryLoop: boolean;
postId: number;
productId: number;
shouldDisplayMockedReviewsWhenProductHasNoReviews: boolean;
};
export const Block = ( props: ProductRatingProps ): JSX.Element | undefined => {
const {
textAlign,
isDescendentOfSingleProductBlock,
shouldDisplayMockedReviewsWhenProductHasNoReviews,
} = props;
const styleProps = useStyleProps( props );
const { parentClassName } = useInnerBlockLayoutContext();
const { product } = useProductDataContext();
const rating = getAverageRating( product );
const reviews = getRatingCount( product );
const className = classnames(
styleProps.className,
'wc-block-components-product-rating',
{
[ `${ parentClassName }__product-rating` ]: parentClassName,
[ `has-text-align-${ textAlign }` ]: textAlign,
}
);
const mockedRatings = shouldDisplayMockedReviewsWhenProductHasNoReviews ? (
<NoRating parentClassName={ parentClassName } />
) : null;
const content = reviews ? (
<Rating
rating={ rating }
reviews={ reviews }
parentClassName={ parentClassName }
/>
) : (
mockedRatings
);
if ( reviews || shouldDisplayMockedReviewsWhenProductHasNoReviews ) {
return (
<div className={ className } style={ styleProps.style }>
<div className="wc-block-components-product-rating__container">
{ content }
{ reviews && isDescendentOfSingleProductBlock ? (
<ReviewsCount reviews={ reviews } />
) : null }
</div>
</div>
);
}
};
export default withProductDataContext( Block );
blocks/product-elements/rating/constants.tsx 0000644 00000000726 15154703037 0015365 0 ustar 00 /**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { starEmpty, Icon } from '@wordpress/icons';
export const BLOCK_TITLE: string = __(
'Product Rating',
'woo-gutenberg-products-block'
);
export const BLOCK_ICON: JSX.Element = (
<Icon
icon={ starEmpty }
className="wc-block-editor-components-block-icon"
/>
);
export const BLOCK_DESCRIPTION: string = __(
'Display the average rating of a product.',
'woo-gutenberg-products-block'
);
blocks/product-elements/rating/edit.tsx 0000644 00000004004 15154703037 0014267 0 ustar 00 /**
* External dependencies
*/
import {
AlignmentToolbar,
BlockControls,
useBlockProps,
} from '@wordpress/block-editor';
import type { BlockEditProps } from '@wordpress/blocks';
import { useEffect } from '@wordpress/element';
import { ProductQueryContext as Context } from '@woocommerce/blocks/product-query/types';
/**
* Internal dependencies
*/
import Block from './block';
import { BlockAttributes } from './types';
import './editor.scss';
import { useIsDescendentOfSingleProductBlock } from '../shared/use-is-descendent-of-single-product-block';
import { useIsDescendentOfSingleProductTemplate } from '../shared/use-is-descendent-of-single-product-template';
const Edit = (
props: BlockEditProps< BlockAttributes > & { context: Context }
): JSX.Element => {
const { attributes, setAttributes, context } = props;
const blockProps = useBlockProps( {
className: 'wp-block-woocommerce-product-rating',
} );
const blockAttrs = {
...attributes,
...context,
shouldDisplayMockedReviewsWhenProductHasNoReviews: true,
};
const isDescendentOfQueryLoop = Number.isFinite( context.queryId );
const { isDescendentOfSingleProductBlock } =
useIsDescendentOfSingleProductBlock( {
blockClientId: blockProps?.id,
} );
let { isDescendentOfSingleProductTemplate } =
useIsDescendentOfSingleProductTemplate();
if ( isDescendentOfQueryLoop || isDescendentOfSingleProductBlock ) {
isDescendentOfSingleProductTemplate = false;
}
useEffect( () => {
setAttributes( {
isDescendentOfQueryLoop,
isDescendentOfSingleProductBlock,
isDescendentOfSingleProductTemplate,
} );
}, [
setAttributes,
isDescendentOfQueryLoop,
isDescendentOfSingleProductBlock,
isDescendentOfSingleProductTemplate,
] );
return (
<>
<BlockControls>
<AlignmentToolbar
value={ attributes.textAlign }
onChange={ ( newAlign ) => {
setAttributes( { textAlign: newAlign || '' } );
} }
/>
</BlockControls>
<div { ...blockProps }>
<Block { ...blockAttrs } />
</div>
</>
);
};
export default Edit;
blocks/product-elements/rating/editor.scss 0000644 00000000105 15154703037 0014763 0 ustar 00 .wc-block-components-product-rating__link {
pointer-events: none;
}
blocks/product-elements/rating/index.ts 0000644 00000001463 15154703037 0014267 0 ustar 00 /**
* External dependencies
*/
import type { BlockConfiguration } from '@wordpress/blocks';
import { registerBlockSingleProductTemplate } from '@woocommerce/atomic-utils';
/**
* Internal dependencies
*/
import sharedConfig from '../shared/config';
import edit from './edit';
import { BLOCK_ICON as icon } from './constants';
import metadata from './block.json';
import { supports } from './support';
const blockConfig: BlockConfiguration = {
...sharedConfig,
ancestor: [
'woocommerce/all-products',
'woocommerce/single-product',
'core/post-template',
'woocommerce/product-template',
],
icon: { src: icon },
supports,
edit,
};
registerBlockSingleProductTemplate( {
blockName: 'woocommerce/product-rating',
blockMetadata: metadata,
blockSettings: blockConfig,
isAvailableOnPostEditor: true,
} );
blocks/product-elements/rating/style.scss 0000644 00000000334 15154703037 0014641 0 ustar 00 .wc-block-components-product-rating {
.wc-block-components-product-rating__container {
> * {
vertical-align: middle;
}
}
.wc-block-components-product-rating__stars {
display: inline-block;
margin: 0;
}
}
blocks/product-elements/rating/support.ts 0000644 00000001375 15154703037 0014676 0 ustar 00 /* eslint-disable @wordpress/no-unsafe-wp-apis */
/**
* External dependencies
*/
import { isFeaturePluginBuild } from '@woocommerce/block-settings';
import { __experimentalGetSpacingClassesAndStyles } from '@wordpress/block-editor';
export const supports = {
...( isFeaturePluginBuild() && {
color: {
text: true,
background: false,
link: false,
__experimentalSkipSerialization: true,
},
spacing: {
margin: true,
padding: true,
},
typography: {
fontSize: true,
__experimentalSkipSerialization: true,
},
__experimentalSelector: '.wc-block-components-product-rating',
} ),
...( ! isFeaturePluginBuild() &&
typeof __experimentalGetSpacingClassesAndStyles === 'function' && {
spacing: {
margin: true,
},
} ),
};
blocks/product-elements/rating/types.ts 0000644 00000000313 15154703037 0014315 0 ustar 00 export interface BlockAttributes {
productId: number;
isDescendentOfQueryLoop: boolean;
isDescendentOfSingleProductBlock: boolean;
isDescendentOfSingleProductTemplate: boolean;
textAlign: string;
}
blocks/product-elements/rating-counter/block.json 0000644 00000001525 15154703037 0016251 0 ustar 00 {
"name": "woocommerce/product-rating-counter",
"version": "1.0.0",
"title": "Product Rating Counter",
"description": "Display the review count of a product",
"attributes": {
"productId": {
"type": "number",
"default": 0
},
"isDescendentOfQueryLoop": {
"type": "boolean",
"default": false
},
"textAlign": {
"type": "string",
"default": ""
},
"isDescendentOfSingleProductBlock": {
"type": "boolean",
"default": false
},
"isDescendentOfSingleProductTemplate": {
"type": "boolean",
"default": false
}
},
"usesContext": [ "query", "queryId", "postId" ],
"category": "woocommerce",
"keywords": [ "WooCommerce" ],
"supports": {
"align": true
},
"ancestor": [ "woocommerce/single-product" ],
"textdomain": "woocommerce",
"apiVersion": 2,
"$schema": "https://schemas.wp.org/trunk/block.json"
}
blocks/product-elements/rating-counter/block.tsx 0000644 00000004705 15154703037 0016121 0 ustar 00 /**
* External dependencies
*/
import { __, _n, sprintf } from '@wordpress/i18n';
import classnames from 'classnames';
import {
useInnerBlockLayoutContext,
useProductDataContext,
} from '@woocommerce/shared-context';
import { useStyleProps } from '@woocommerce/base-hooks';
import { withProductDataContext } from '@woocommerce/shared-hocs';
import { isNumber, ProductResponseItem } from '@woocommerce/types';
import { Disabled } from '@wordpress/components';
const getRatingCount = ( product: ProductResponseItem ) => {
const count = isNumber( product.review_count )
? product.review_count
: parseInt( product.review_count, 10 );
return Number.isFinite( count ) && count > 0 ? count : 0;
};
const ReviewsCount = ( props: { reviews: number } ): JSX.Element => {
const { reviews } = props;
const reviewsCount = reviews
? sprintf(
/* translators: %s is referring to the total of reviews for a product */
_n(
'(%s customer review)',
'(%s customer reviews)',
reviews,
'woo-gutenberg-products-block'
),
reviews
)
: __( '(X customer reviews)', 'woo-gutenberg-products-block' );
return (
<span className="wc-block-components-product-rating-counter__reviews_count">
<Disabled>
<a href="/">{ reviewsCount }</a>
</Disabled>
</span>
);
};
type ProductRatingCounterProps = {
className?: string;
textAlign?: string;
isDescendentOfSingleProductBlock: boolean;
isDescendentOfQueryLoop: boolean;
postId: number;
productId: number;
shouldDisplayMockedReviewsWhenProductHasNoReviews: boolean;
};
export const Block = (
props: ProductRatingCounterProps
): JSX.Element | undefined => {
const { textAlign, shouldDisplayMockedReviewsWhenProductHasNoReviews } =
props;
const styleProps = useStyleProps( props );
const { parentClassName } = useInnerBlockLayoutContext();
const { product } = useProductDataContext();
const reviews = getRatingCount( product );
const className = classnames(
styleProps.className,
'wc-block-components-product-rating-counter',
{
[ `${ parentClassName }__product-rating` ]: parentClassName,
[ `has-text-align-${ textAlign }` ]: textAlign,
}
);
if ( reviews || shouldDisplayMockedReviewsWhenProductHasNoReviews ) {
return (
<div className={ className } style={ styleProps.style }>
<div className="wc-block-components-product-rating-counter__container">
<ReviewsCount reviews={ reviews } />
</div>
</div>
);
}
};
export default withProductDataContext( Block );
blocks/product-elements/rating-counter/edit.tsx 0000644 00000003764 15154703040 0015752 0 ustar 00 /**
* External dependencies
*/
import {
AlignmentToolbar,
BlockControls,
useBlockProps,
} from '@wordpress/block-editor';
import type { BlockEditProps } from '@wordpress/blocks';
import { useEffect } from '@wordpress/element';
import { ProductQueryContext as Context } from '@woocommerce/blocks/product-query/types';
/**
* Internal dependencies
*/
import Block from './block';
import { BlockAttributes } from './types';
import { useIsDescendentOfSingleProductBlock } from '../shared/use-is-descendent-of-single-product-block';
import { useIsDescendentOfSingleProductTemplate } from '../shared/use-is-descendent-of-single-product-template';
const Edit = (
props: BlockEditProps< BlockAttributes > & { context: Context }
): JSX.Element => {
const { attributes, setAttributes, context } = props;
const blockProps = useBlockProps( {
className: 'wp-block-woocommerce-product-rating-counter',
} );
const blockAttrs = {
...attributes,
...context,
shouldDisplayMockedReviewsWhenProductHasNoReviews: true,
};
const isDescendentOfQueryLoop = Number.isFinite( context.queryId );
const { isDescendentOfSingleProductBlock } =
useIsDescendentOfSingleProductBlock( {
blockClientId: blockProps?.id,
} );
let { isDescendentOfSingleProductTemplate } =
useIsDescendentOfSingleProductTemplate();
if ( isDescendentOfQueryLoop || isDescendentOfSingleProductBlock ) {
isDescendentOfSingleProductTemplate = false;
}
useEffect( () => {
setAttributes( {
isDescendentOfQueryLoop,
isDescendentOfSingleProductBlock,
isDescendentOfSingleProductTemplate,
} );
}, [
setAttributes,
isDescendentOfQueryLoop,
isDescendentOfSingleProductBlock,
isDescendentOfSingleProductTemplate,
] );
return (
<>
<BlockControls>
<AlignmentToolbar
value={ attributes.textAlign }
onChange={ ( newAlign ) => {
setAttributes( { textAlign: newAlign || '' } );
} }
/>
</BlockControls>
<div { ...blockProps }>
<Block { ...blockAttrs } />
</div>
</>
);
};
export default Edit;
blocks/product-elements/rating-counter/index.tsx 0000644 00000000704 15154703040 0016123 0 ustar 00 /**
* External dependencies
*/
import { registerBlockType } from '@wordpress/blocks';
import { Icon, starFilled } from '@wordpress/icons';
/**
* Internal dependencies
*/
import metadata from './block.json';
import edit from './edit';
import { supports } from './support';
registerBlockType( metadata, {
icon: {
src: (
<Icon
icon={ starFilled }
className="wc-block-editor-components-block-icon"
/>
),
},
supports,
edit,
} );
blocks/product-elements/rating-counter/support.ts 0000644 00000000762 15154703040 0016344 0 ustar 00 /* eslint-disable @wordpress/no-unsafe-wp-apis */
/**
* External dependencies
*/
import { isFeaturePluginBuild } from '@woocommerce/block-settings';
export const supports = {
...( isFeaturePluginBuild() && {
color: {
text: false,
background: false,
link: true,
},
spacing: {
margin: true,
padding: true,
},
typography: {
fontSize: true,
__experimentalSkipSerialization: true,
},
__experimentalSelector: '.wc-block-components-product-rating-counter',
} ),
};
blocks/product-elements/rating-counter/types.ts 0000644 00000000313 15154703040 0015764 0 ustar 00 export interface BlockAttributes {
productId: number;
isDescendentOfQueryLoop: boolean;
isDescendentOfSingleProductBlock: boolean;
isDescendentOfSingleProductTemplate: boolean;
textAlign: string;
}
blocks/product-elements/rating-stars/block.json 0000644 00000001536 15154703040 0015722 0 ustar 00 {
"name": "woocommerce/product-rating-stars",
"version": "1.0.0",
"title": "Product Rating Stars",
"description": "Display the average rating of a product with stars",
"attributes": {
"productId": {
"type": "number",
"default": 0
},
"isDescendentOfQueryLoop": {
"type": "boolean",
"default": false
},
"textAlign": {
"type": "string",
"default": ""
},
"isDescendentOfSingleProductBlock": {
"type": "boolean",
"default": false
},
"isDescendentOfSingleProductTemplate": {
"type": "boolean",
"default": false
}
},
"usesContext": [ "query", "queryId", "postId" ],
"category": "woocommerce",
"keywords": [ "WooCommerce" ],
"supports": {
"align": true
},
"ancestor": [ "woocommerce/single-product" ],
"textdomain": "woocommerce",
"apiVersion": 2,
"$schema": "https://schemas.wp.org/trunk/block.json"
}
blocks/product-elements/rating-stars/block.tsx 0000644 00000010106 15154703040 0015560 0 ustar 00 /**
* External dependencies
*/
import { __, _n, sprintf } from '@wordpress/i18n';
import classnames from 'classnames';
import {
useInnerBlockLayoutContext,
useProductDataContext,
} from '@woocommerce/shared-context';
import { useStyleProps } from '@woocommerce/base-hooks';
import { withProductDataContext } from '@woocommerce/shared-hocs';
import { isNumber, ProductResponseItem } from '@woocommerce/types';
/**
* Internal dependencies
*/
import './style.scss';
type RatingProps = {
reviews: number;
rating: number;
parentClassName?: string;
};
const getAverageRating = (
product: Omit< ProductResponseItem, 'average_rating' > & {
average_rating: string;
}
) => {
const rating = parseFloat( product.average_rating );
return Number.isFinite( rating ) && rating > 0 ? rating : 0;
};
const getRatingCount = ( product: ProductResponseItem ) => {
const count = isNumber( product.review_count )
? product.review_count
: parseInt( product.review_count, 10 );
return Number.isFinite( count ) && count > 0 ? count : 0;
};
const getStarStyle = ( rating: number ) => ( {
width: ( rating / 5 ) * 100 + '%',
} );
const NoRating = ( { parentClassName }: { parentClassName: string } ) => {
const starStyle = getStarStyle( 0 );
return (
<div
className={ classnames(
'wc-block-components-product-rating-stars__norating-container',
`${ parentClassName }-product-rating-stars__norating-container`
) }
>
<div
className={
'wc-block-components-product-rating-stars__norating'
}
role="img"
>
<span style={ starStyle } />
</div>
<span>{ __( 'No Reviews', 'woo-gutenberg-products-block' ) }</span>
</div>
);
};
const Rating = ( props: RatingProps ): JSX.Element => {
const { rating, reviews, parentClassName } = props;
const starStyle = getStarStyle( rating );
const ratingText = sprintf(
/* translators: %f is referring to the average rating value */
__( 'Rated %f out of 5', 'woo-gutenberg-products-block' ),
rating
);
const ratingHTML = {
__html: sprintf(
/* translators: %1$s is referring to the average rating value, %2$s is referring to the number of ratings */
_n(
'Rated %1$s out of 5 based on %2$s customer rating',
'Rated %1$s out of 5 based on %2$s customer ratings',
reviews,
'woo-gutenberg-products-block'
),
sprintf( '<strong class="rating">%f</strong>', rating ),
sprintf( '<span class="rating">%d</span>', reviews )
),
};
return (
<div
className={ classnames(
'wc-block-components-product-rating-stars__stars',
`${ parentClassName }__product-rating-stars__stars`
) }
role="img"
aria-label={ ratingText }
>
<span style={ starStyle } dangerouslySetInnerHTML={ ratingHTML } />
</div>
);
};
interface ProductRatingStarsProps {
className?: string;
textAlign?: string;
isDescendentOfSingleProductBlock: boolean;
isDescendentOfQueryLoop: boolean;
postId: number;
productId: number;
shouldDisplayMockedReviewsWhenProductHasNoReviews: boolean;
}
export const Block = ( props: ProductRatingStarsProps ): JSX.Element | null => {
const { textAlign, shouldDisplayMockedReviewsWhenProductHasNoReviews } =
props;
const styleProps = useStyleProps( props );
const { parentClassName } = useInnerBlockLayoutContext();
const { product } = useProductDataContext();
const rating = getAverageRating( product );
const reviews = getRatingCount( product );
const className = classnames(
styleProps.className,
'wc-block-components-product-rating-stars',
{
[ `${ parentClassName }__product-rating` ]: parentClassName,
[ `has-text-align-${ textAlign }` ]: textAlign,
}
);
const mockedRatings = shouldDisplayMockedReviewsWhenProductHasNoReviews ? (
<NoRating parentClassName={ parentClassName } />
) : null;
const content = reviews ? (
<Rating
rating={ rating }
reviews={ reviews }
parentClassName={ parentClassName }
/>
) : (
mockedRatings
);
return (
<div className={ className } style={ styleProps.style }>
<div className="wc-block-components-product-rating-stars__container">
{ content }
</div>
</div>
);
};
export default withProductDataContext( Block );
blocks/product-elements/rating-stars/edit.tsx 0000644 00000003754 15154703040 0015426 0 ustar 00 /**
* External dependencies
*/
import {
AlignmentToolbar,
BlockControls,
useBlockProps,
} from '@wordpress/block-editor';
import type { BlockEditProps } from '@wordpress/blocks';
import { useEffect } from '@wordpress/element';
import { ProductQueryContext as Context } from '@woocommerce/blocks/product-query/types';
/**
* Internal dependencies
*/
import Block from './block';
import { BlockAttributes } from './types';
import { useIsDescendentOfSingleProductBlock } from '../shared/use-is-descendent-of-single-product-block';
import { useIsDescendentOfSingleProductTemplate } from '../shared/use-is-descendent-of-single-product-template';
const Edit = (
props: BlockEditProps< BlockAttributes > & { context: Context }
): JSX.Element => {
const { attributes, setAttributes, context } = props;
const blockProps = useBlockProps( {
className: 'wp-block-woocommerce-product-rating',
} );
const blockAttrs = {
...attributes,
...context,
shouldDisplayMockedReviewsWhenProductHasNoReviews: true,
};
const isDescendentOfQueryLoop = Number.isFinite( context.queryId );
const { isDescendentOfSingleProductBlock } =
useIsDescendentOfSingleProductBlock( {
blockClientId: blockProps?.id,
} );
let { isDescendentOfSingleProductTemplate } =
useIsDescendentOfSingleProductTemplate();
if ( isDescendentOfQueryLoop || isDescendentOfSingleProductBlock ) {
isDescendentOfSingleProductTemplate = false;
}
useEffect( () => {
setAttributes( {
isDescendentOfQueryLoop,
isDescendentOfSingleProductBlock,
isDescendentOfSingleProductTemplate,
} );
}, [
setAttributes,
isDescendentOfQueryLoop,
isDescendentOfSingleProductBlock,
isDescendentOfSingleProductTemplate,
] );
return (
<>
<BlockControls>
<AlignmentToolbar
value={ attributes.textAlign }
onChange={ ( newAlign ) => {
setAttributes( { textAlign: newAlign || '' } );
} }
/>
</BlockControls>
<div { ...blockProps }>
<Block { ...blockAttrs } />
</div>
</>
);
};
export default Edit;
blocks/product-elements/rating-stars/index.tsx 0000644 00000000704 15154703040 0015600 0 ustar 00 /**
* External dependencies
*/
import { registerBlockType } from '@wordpress/blocks';
import { Icon, starFilled } from '@wordpress/icons';
/**
* Internal dependencies
*/
import metadata from './block.json';
import edit from './edit';
import { supports } from './support';
registerBlockType( metadata, {
icon: {
src: (
<Icon
icon={ starFilled }
className="wc-block-editor-components-block-icon"
/>
),
},
supports,
edit,
} );
blocks/product-elements/rating-stars/style.scss 0000644 00000003614 15154703040 0015771 0 ustar 00 .wc-block-components-product-rating-stars {
display: block;
line-height: 1;
&__stars {
display: inline-block;
overflow: hidden;
position: relative;
width: 5.3em;
height: 1.618em;
line-height: 1.618;
font-size: 1em;
/* stylelint-disable-next-line font-family-no-missing-generic-family-keyword */
font-family: star;
font-weight: 400;
text-align: left;
&::before {
content: "\53\53\53\53\53";
top: 0;
left: 0;
right: 0;
position: absolute;
opacity: 0.5;
color: inherit;
white-space: nowrap;
}
span {
overflow: hidden;
top: 0;
left: 0;
right: 0;
position: absolute;
color: inherit;
padding-top: 1.5em;
}
span::before {
content: "\53\53\53\53\53";
top: 0;
left: 0;
right: 0;
position: absolute;
color: inherit;
white-space: nowrap;
}
}
&__link {
display: inline-block;
height: 1.618em;
width: 100%;
text-align: inherit;
@include font-size(small);
}
.wc-block-all-products & {
margin-top: 0;
margin-bottom: $gap-small;
}
&__norating-container {
display: inline-flex;
flex-direction: row;
align-items: center;
gap: $gap-smaller;
}
&__norating {
display: inline-block;
overflow: hidden;
position: relative;
width: 1.5em;
height: 1.618em;
line-height: 1.618;
font-size: 1em;
/* stylelint-disable-next-line font-family-no-missing-generic-family-keyword */
font-family: star;
font-weight: 400;
-webkit-text-stroke: 2px var(--wp--preset--color--black, #000);
&::before {
content: "\53";
top: 0;
left: 0;
right: 0;
position: absolute;
color: transparent;
white-space: nowrap;
text-align: center;
}
}
}
.wp-block-woocommerce-single-product {
.wc-block-components-product-rating__stars {
margin: 0;
}
}
.wc-block-all-products,
.wp-block-query {
.is-loading {
.wc-block-components-product-rating {
@include placeholder();
width: 7em;
}
}
}
blocks/product-elements/rating-stars/support.ts 0000644 00000001024 15154703040 0016011 0 ustar 00 /* eslint-disable @wordpress/no-unsafe-wp-apis */
/**
* External dependencies
*/
import { isFeaturePluginBuild } from '@woocommerce/block-settings';
export const supports = {
...( isFeaturePluginBuild() && {
color: {
text: true,
background: false,
link: false,
__experimentalSkipSerialization: true,
},
spacing: {
margin: true,
padding: true,
},
typography: {
fontSize: true,
__experimentalSkipSerialization: true,
},
__experimentalSelector: '.wc-block-components-product-rating',
} ),
};
blocks/product-elements/rating-stars/types.ts 0000644 00000000313 15154703040 0015441 0 ustar 00 export interface BlockAttributes {
productId: number;
isDescendentOfQueryLoop: boolean;
isDescendentOfSingleProductBlock: boolean;
isDescendentOfSingleProductTemplate: boolean;
textAlign: string;
}
blocks/product-elements/related-products/block.json 0000644 00000000660 15154703040 0016562 0 ustar 00 {
"name": "woocommerce/related-products",
"version": "1.0.0",
"title": "Related Products",
"icon": "product",
"description": "Display related products.",
"category": "woocommerce",
"supports": {
"align": true,
"reusable": false
},
"keywords": [ "WooCommerce" ],
"usesContext": [ "postId", "postType", "queryId" ],
"textdomain": "woocommerce",
"apiVersion": 2,
"$schema": "https://schemas.wp.org/trunk/block.json"
}
blocks/product-elements/related-products/edit.tsx 0000644 00000002053 15154703040 0016260 0 ustar 00 /**
* External dependencies
*/
import {
BLOCK_ATTRIBUTES,
INNER_BLOCKS_TEMPLATE,
} from '@woocommerce/blocks/product-query/variations';
import {
InnerBlocks,
InspectorControls,
useBlockProps,
} from '@wordpress/block-editor';
import { InnerBlockTemplate } from '@wordpress/blocks';
import { Notice } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import './editor.scss';
const Edit = () => {
const TEMPLATE: InnerBlockTemplate[] = [
[ 'core/query', BLOCK_ATTRIBUTES, INNER_BLOCKS_TEMPLATE ],
];
const blockProps = useBlockProps();
return (
<div { ...blockProps }>
<InspectorControls>
<Notice
className={ 'wc-block-editor-related-products__notice' }
status={ 'warning' }
isDismissible={ false }
>
<p>
{ __(
'These products will vary depending on the main product in the page',
'woo-gutenberg-products-block'
) }
</p>
</Notice>
</InspectorControls>
<InnerBlocks template={ TEMPLATE } />
</div>
);
};
export default Edit;
blocks/product-elements/related-products/editor.scss 0000644 00000000140 15154703040 0016751 0 ustar 00 .wc-block-editor-related-products__notice {
margin: auto $gap $gap;
max-width: max-content;
}
blocks/product-elements/related-products/index.tsx 0000644 00000001053 15154703040 0016441 0 ustar 00 /**
* External dependencies
*/
import { box as icon } from '@wordpress/icons';
import { registerBlockSingleProductTemplate } from '@woocommerce/atomic-utils';
/**
* Internal dependencies
*/
import edit from './edit';
import save from './save';
import metadata from './block.json';
registerBlockSingleProductTemplate( {
blockName: metadata.name,
// @ts-expect-error: `metadata` currently does not have a type definition in WordPress core
blockMetadata: metadata,
blockSettings: {
icon,
edit,
save,
},
isAvailableOnPostEditor: false,
} );
blocks/product-elements/related-products/save.tsx 0000644 00000000560 15154703040 0016272 0 ustar 00 /**
* External dependencies
*/
import { InnerBlocks, useBlockProps } from '@wordpress/block-editor';
const Save = () => {
const blockProps = useBlockProps.save();
return (
<div { ...blockProps }>
{ /* @ts-expect-error: `InnerBlocks.Content` is a component that is typed in WordPress core*/ }
<InnerBlocks.Content />
</div>
);
};
export default Save;
blocks/product-elements/sale-badge/attributes.ts 0000644 00000000572 15154703040 0016040 0 ustar 00 /**
* External dependencies
*/
import type { BlockAttributes } from '@wordpress/blocks';
export const blockAttributes: BlockAttributes = {
productId: {
type: 'number',
default: 0,
},
isDescendentOfQueryLoop: {
type: 'boolean',
default: false,
},
isDescendentOfSingleProductTemplate: {
type: 'boolean',
default: false,
},
};
export default blockAttributes;
blocks/product-elements/sale-badge/block.tsx 0000644 00000003076 15154703040 0015136 0 ustar 00 /**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import classnames from 'classnames';
import Label from '@woocommerce/base-components/label';
import {
useInnerBlockLayoutContext,
useProductDataContext,
} from '@woocommerce/shared-context';
import { useStyleProps } from '@woocommerce/base-hooks';
import { withProductDataContext } from '@woocommerce/shared-hocs';
import type { HTMLAttributes } from 'react';
/**
* Internal dependencies
*/
import './style.scss';
import type { BlockAttributes } from './types';
type Props = BlockAttributes & HTMLAttributes< HTMLDivElement >;
export const Block = ( props: Props ): JSX.Element | null => {
const { className, align } = props;
const styleProps = useStyleProps( props );
const { parentClassName } = useInnerBlockLayoutContext();
const { product } = useProductDataContext();
if (
( ! product.id || ! product.on_sale ) &&
! props.isDescendentOfSingleProductTemplate
) {
return null;
}
const alignClass =
typeof align === 'string'
? `wc-block-components-product-sale-badge--align-${ align }`
: '';
return (
<div
className={ classnames(
'wc-block-components-product-sale-badge',
className,
alignClass,
{
[ `${ parentClassName }__product-onsale` ]: parentClassName,
},
styleProps.className
) }
style={ styleProps.style }
>
<Label
label={ __( 'Sale', 'woo-gutenberg-products-block' ) }
screenReaderLabel={ __(
'Product on sale',
'woo-gutenberg-products-block'
) }
/>
</div>
);
};
export default withProductDataContext( Block );
blocks/product-elements/sale-badge/constants.tsx 0000644 00000000730 15154703040 0016052 0 ustar 00 /**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { percent, Icon } from '@wordpress/icons';
export const BLOCK_TITLE: string = __(
'On-Sale Badge',
'woo-gutenberg-products-block'
);
export const BLOCK_ICON: JSX.Element = (
<Icon icon={ percent } className="wc-block-editor-components-block-icon" />
);
export const BLOCK_DESCRIPTION: string = __(
'Displays an on-sale badge if the product is on-sale.',
'woo-gutenberg-products-block'
);
blocks/product-elements/sale-badge/edit.tsx 0000644 00000002313 15154703040 0014762 0 ustar 00 /**
* External dependencies
*/
import { useBlockProps } from '@wordpress/block-editor';
import type { BlockEditProps } from '@wordpress/blocks';
import { ProductQueryContext as Context } from '@woocommerce/blocks/product-query/types';
import { useEffect } from '@wordpress/element';
/**
* Internal dependencies
*/
import Block from './block';
import type { BlockAttributes } from './types';
import { useIsDescendentOfSingleProductTemplate } from '../shared/use-is-descendent-of-single-product-template';
const Edit = ( {
attributes,
setAttributes,
context,
}: BlockEditProps< BlockAttributes > & { context: Context } ): JSX.Element => {
const blockProps = useBlockProps();
const blockAttrs = {
...attributes,
...context,
};
const isDescendentOfQueryLoop = Number.isFinite( context.queryId );
const { isDescendentOfSingleProductTemplate } =
useIsDescendentOfSingleProductTemplate();
useEffect(
() =>
setAttributes( {
isDescendentOfQueryLoop,
isDescendentOfSingleProductTemplate,
} ),
[
setAttributes,
isDescendentOfQueryLoop,
isDescendentOfSingleProductTemplate,
]
);
return (
<div { ...blockProps }>
<Block { ...blockAttrs } />
</div>
);
};
export default Edit;
blocks/product-elements/sale-badge/index.ts 0000644 00000001573 15154703040 0014763 0 ustar 00 /**
* External dependencies
*/
import { registerBlockType } from '@wordpress/blocks';
import type { BlockConfiguration } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import sharedConfig from '../shared/config';
import attributes from './attributes';
import edit from './edit';
import {
BLOCK_TITLE as title,
BLOCK_ICON as icon,
BLOCK_DESCRIPTION as description,
} from './constants';
import { supports } from './support';
const blockConfig: BlockConfiguration = {
...sharedConfig,
title,
description,
icon: { src: icon },
apiVersion: 2,
supports,
attributes,
edit,
usesContext: [ 'query', 'queryId', 'postId' ],
ancestor: [
'woocommerce/all-products',
'woocommerce/single-product',
'core/post-template',
'woocommerce/product-template',
'woocommerce/product-gallery',
],
};
registerBlockType( 'woocommerce/product-sale-badge', { ...blockConfig } );
blocks/product-elements/sale-badge/style.scss 0000644 00000001210 15154703040 0015325 0 ustar 00 .wp-block-woocommerce-product-sale-badge {
display: flex;
flex-direction: column;
}
.wc-block-components-product-sale-badge {
@include font-size(small);
padding: em($gap-smallest) em($gap-small);
display: inline-block;
width: fit-content;
border: 1px solid #43454b;
border-radius: 3px;
box-sizing: border-box;
color: #43454b;
background: #fff;
text-align: center;
text-transform: uppercase;
font-weight: 600;
z-index: 9;
position: static;
&--align-left {
align-self: auto;
}
&--align-center {
align-self: center;
}
&--align-right {
align-self: flex-end;
}
span {
color: inherit;
background-color: inherit;
}
}
blocks/product-elements/sale-badge/support.ts 0000644 00000002564 15154703040 0015371 0 ustar 00 /* eslint-disable @wordpress/no-unsafe-wp-apis */
/**
* External dependencies
*/
import { isFeaturePluginBuild } from '@woocommerce/block-settings';
import { __experimentalGetSpacingClassesAndStyles } from '@wordpress/block-editor';
export const supports = {
html: false,
align: true,
...( isFeaturePluginBuild() && {
color: {
gradients: true,
background: true,
link: false,
__experimentalSkipSerialization: true,
},
typography: {
fontSize: true,
lineHeight: true,
__experimentalFontFamily: true,
__experimentalFontWeight: true,
__experimentalFontStyle: true,
__experimentalSkipSerialization: true,
__experimentalLetterSpacing: true,
__experimentalTextTransform: true,
__experimentalTextDecoration: true,
},
__experimentalBorder: {
color: true,
radius: true,
width: true,
__experimentalSkipSerialization: true,
},
// @todo: Improve styles support when WordPress 6.4 is released. https://make.wordpress.org/core/2023/07/17/introducing-the-block-selectors-api/
...( typeof __experimentalGetSpacingClassesAndStyles === 'function' && {
spacing: {
margin: true,
padding: true,
},
} ),
__experimentalSelector: '.wc-block-components-product-sale-badge',
} ),
...( typeof __experimentalGetSpacingClassesAndStyles === 'function' &&
! isFeaturePluginBuild() && {
spacing: {
margin: true,
},
} ),
};
blocks/product-elements/sale-badge/types.ts 0000644 00000000276 15154703040 0015017 0 ustar 00 export interface BlockAttributes {
productId: number;
align: 'left' | 'center' | 'right';
isDescendentOfQueryLoop?: boolean | undefined;
isDescendentOfSingleProductTemplate?: boolean;
}
blocks/product-elements/save.js 0000644 00000000604 15154703040 0012606 0 ustar 00 /**
* External dependencies
*/
import classnames from 'classnames';
const save = ( { attributes } ) => {
if (
attributes.isDescendentOfQueryLoop ||
attributes.isDescendentOfSingleProductBlock ||
attributes.isDescendentOfSingleProductTemplate
) {
return null;
}
return (
<div className={ classnames( 'is-loading', attributes.className ) } />
);
};
export default save;
blocks/product-elements/shared/config.tsx 0000644 00000001640 15154703040 0014566 0 ustar 00 /**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Icon, grid } from '@wordpress/icons';
import type { BlockConfiguration } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import save from '../save';
/**
* Holds default config for this collection of blocks.
* attributes and title are omitted here as these are added on an individual block level.
*/
const sharedConfig: Omit< BlockConfiguration, 'attributes' | 'title' > = {
category: 'woocommerce-product-elements',
keywords: [ __( 'WooCommerce', 'woo-gutenberg-products-block' ) ],
icon: {
src: (
<Icon
icon={ grid }
className="wc-block-editor-components-block-icon"
/>
),
},
supports: {
html: false,
},
ancestor: [ 'woocommerce/all-products', 'woocommerce/single-product' ],
save,
deprecated: [
{
attributes: {},
save(): null {
return null;
},
},
],
};
export default sharedConfig;
blocks/product-elements/shared/editor.scss 0000644 00000000330 15154703040 0014737 0 ustar 00 .wc-atomic-blocks-product__selection {
width: 100%;
}
.wc-atomic-blocks-product__edit-card {
padding: 16px;
border-top: 1px solid $gray-200;
.wc-atomic-blocks-product__edit-card-title {
margin: 0 0 $gap;
}
}
blocks/product-elements/shared/product-selector.tsx 0000644 00000003460 15154703041 0016622 0 ustar 00 /**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import ProductControl from '@woocommerce/editor-components/product-control';
import { Placeholder, Button, ToolbarGroup } from '@wordpress/components';
import { BlockControls } from '@wordpress/block-editor';
import TextToolbarButton from '@woocommerce/editor-components/text-toolbar-button';
import { useState } from '@wordpress/element';
export const ProductSelector = ( {
productId,
icon,
label,
description,
setAttributes,
children,
}: {
productId: string;
icon: string;
label: string;
description: string;
setAttributes: ( obj: Record< string, string > ) => void;
children: React.ReactNode;
} ) => {
const [ isEditing, setIsEditing ] = useState( ! productId );
return (
<>
{ isEditing ? (
<Placeholder
icon={ icon || '' }
label={ label || '' }
className="wc-atomic-blocks-product"
>
{ !! description && <div>{ description }</div> }
<div className="wc-atomic-blocks-product__selection">
<ProductControl
selected={ productId || 0 }
showVariations
onChange={ ( value = [] ) => {
setAttributes( {
productId: value[ 0 ] ? value[ 0 ].id : 0,
} );
} }
/>
<Button
isSecondary
disabled={ ! productId }
onClick={ () => {
setIsEditing( false );
} }
>
{ __( 'Done', 'woo-gutenberg-products-block' ) }
</Button>
</div>
</Placeholder>
) : (
<>
<BlockControls>
<ToolbarGroup>
<TextToolbarButton
onClick={ () => setIsEditing( true ) }
>
{ __(
'Switch product…',
'woo-gutenberg-products-block'
) }
</TextToolbarButton>
</ToolbarGroup>
</BlockControls>
{ children }
</>
) }
</>
);
};
blocks/product-elements/shared/use-is-descendent-of-single-product-block.tsx 0000644 00000001361 15154703041 0023270 0 ustar 00 /**
* External dependencies
*/
import { useSelect } from '@wordpress/data';
interface UseIsDescendentOfSingleProductBlockProps {
blockClientId: string;
}
export const useIsDescendentOfSingleProductBlock = ( {
blockClientId,
}: UseIsDescendentOfSingleProductBlockProps ) => {
const { isDescendentOfSingleProductBlock } = useSelect(
( select ) => {
const { getBlockParentsByBlockName } =
select( 'core/block-editor' );
const blockParentBlocksIds = getBlockParentsByBlockName(
blockClientId?.replace( 'block-', '' ),
[ 'woocommerce/single-product' ]
);
return {
isDescendentOfSingleProductBlock:
blockParentBlocksIds.length > 0,
};
},
[ blockClientId ]
);
return { isDescendentOfSingleProductBlock };
};
blocks/product-elements/shared/use-is-descendent-of-single-product-template.tsx 0000644 00000000676 15154703041 0024021 0 ustar 00 /**
* External dependencies
*/
import { useSelect } from '@wordpress/data';
export const useIsDescendentOfSingleProductTemplate = () => {
const isDescendentOfSingleProductTemplate = useSelect( ( select ) => {
const store = select( 'core/edit-site' );
const postId = store?.getEditedPostId< string | undefined >();
return Boolean( postId?.includes( '//single-product' ) );
}, [] );
return { isDescendentOfSingleProductTemplate };
};
blocks/product-elements/shared/with-product-selector.js 0000644 00000004541 15154703041 0017372 0 ustar 00 /**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useState } from '@wordpress/element';
import ProductControl from '@woocommerce/editor-components/product-control';
import { Placeholder, Button, ToolbarGroup } from '@wordpress/components';
import { BlockControls } from '@wordpress/block-editor';
import TextToolbarButton from '@woocommerce/editor-components/text-toolbar-button';
import { useProductDataContext } from '@woocommerce/shared-context';
/**
* Internal dependencies
*/
import './editor.scss';
/**
* This HOC shows a product selection interface if context is not present in the editor.
*
* @param {Object} selectorArgs Options for the selector.
*
*/
const withProductSelector = ( selectorArgs ) => ( OriginalComponent ) => {
return ( props ) => {
const productDataContext = useProductDataContext();
const { attributes, setAttributes } = props;
const { productId } = attributes;
const [ isEditing, setIsEditing ] = useState( ! productId );
if (
productDataContext.hasContext ||
Number.isFinite( props.context?.queryId )
) {
return <OriginalComponent { ...props } />;
}
return (
<>
{ isEditing ? (
<Placeholder
icon={ selectorArgs.icon || '' }
label={ selectorArgs.label || '' }
className="wc-atomic-blocks-product"
>
{ !! selectorArgs.description && (
<div>{ selectorArgs.description }</div>
) }
<div className="wc-atomic-blocks-product__selection">
<ProductControl
selected={ productId || 0 }
showVariations
onChange={ ( value = [] ) => {
setAttributes( {
productId: value[ 0 ]
? value[ 0 ].id
: 0,
} );
} }
/>
<Button
isSecondary
disabled={ ! productId }
onClick={ () => {
setIsEditing( false );
} }
>
{ __( 'Done', 'woocommerce' ) }
</Button>
</div>
</Placeholder>
) : (
<>
<BlockControls>
<ToolbarGroup>
<TextToolbarButton
onClick={ () => setIsEditing( true ) }
>
{ __(
'Switch product…',
'woocommerce'
) }
</TextToolbarButton>
</ToolbarGroup>
</BlockControls>
<OriginalComponent { ...props } />
</>
) }
</>
);
};
};
export default withProductSelector;
blocks/product-elements/sku/attributes.ts 0000644 00000001002 15154703041 0014644 0 ustar 00 /**
* External dependencies
*/
import type { BlockAttributes } from '@wordpress/blocks';
export const blockAttributes: BlockAttributes = {
productId: {
type: 'number',
default: 0,
},
isDescendentOfQueryLoop: {
type: 'boolean',
default: false,
},
isDescendentOfSingleProductTemplate: {
type: 'boolean',
default: false,
},
isDescendantOfAllProducts: {
type: 'boolean',
default: false,
},
showProductSelector: {
type: 'boolean',
default: false,
},
};
export default blockAttributes;
blocks/product-elements/sku/block.tsx 0000644 00000003503 15154703041 0013750 0 ustar 00 /**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import classnames from 'classnames';
import {
useInnerBlockLayoutContext,
useProductDataContext,
} from '@woocommerce/shared-context';
import { withProductDataContext } from '@woocommerce/shared-hocs';
import type { HTMLAttributes } from 'react';
import { useStyleProps } from '@woocommerce/base-hooks';
/**
* Internal dependencies
*/
import './style.scss';
import type { Attributes } from './types';
type Props = Attributes & HTMLAttributes< HTMLDivElement >;
const Preview = ( {
parentClassName,
sku,
className,
style,
}: {
parentClassName: string;
sku: string;
className?: string | undefined;
style?: React.CSSProperties | undefined;
} ) => (
<div
className={ classnames( className, {
[ `${ parentClassName }__product-sku` ]: parentClassName,
} ) }
style={ style }
>
{ __( 'SKU:', 'woo-gutenberg-products-block' ) }{ ' ' }
<strong>{ sku }</strong>
</div>
);
const Block = ( props: Props ): JSX.Element | null => {
const { className } = props;
const styleProps = useStyleProps( props );
const { parentClassName } = useInnerBlockLayoutContext();
const { product } = useProductDataContext();
const sku = product.sku;
if ( props.isDescendentOfSingleProductTemplate ) {
return (
<Preview
parentClassName={ parentClassName }
className={ className }
sku={ 'Product SKU' }
/>
);
}
if ( ! sku ) {
return null;
}
return (
<Preview
className={ className }
parentClassName={ parentClassName }
sku={ sku }
{ ...( props.isDescendantOfAllProducts && {
className: classnames(
className,
'wc-block-components-product-sku wp-block-woocommerce-product-sku',
styleProps.className
),
style: {
...styleProps.style,
},
} ) }
/>
);
};
export default withProductDataContext( Block );
blocks/product-elements/sku/constants.tsx 0000644 00000000744 15154703041 0014676 0 ustar 00 /**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { barcode } from '@woocommerce/icons';
import { Icon } from '@wordpress/icons';
export const BLOCK_TITLE: string = __(
'Product SKU',
'woo-gutenberg-products-block'
);
export const BLOCK_ICON: JSX.Element = (
<Icon icon={ barcode } className="wc-block-editor-components-block-icon" />
);
export const BLOCK_DESCRIPTION: string = __(
'Display the SKU of a product.',
'woo-gutenberg-products-block'
);
blocks/product-elements/sku/edit.tsx 0000644 00000004115 15154703041 0013603 0 ustar 00 /**
* External dependencies
*/
import { useBlockProps } from '@wordpress/block-editor';
import type { BlockEditProps } from '@wordpress/blocks';
import EditProductLink from '@woocommerce/editor-components/edit-product-link';
import { ProductQueryContext as Context } from '@woocommerce/blocks/product-query/types';
import { useEffect } from '@wordpress/element';
/**
* Internal dependencies
*/
import Block from './block';
import type { Attributes } from './types';
import { useIsDescendentOfSingleProductBlock } from '../shared/use-is-descendent-of-single-product-block';
import { useIsDescendentOfSingleProductTemplate } from '../shared/use-is-descendent-of-single-product-template';
const Edit = ( {
attributes,
setAttributes,
context,
}: BlockEditProps< Attributes > & { context: Context } ): JSX.Element => {
const { style, ...blockProps } = useBlockProps( {
className:
'wc-block-components-product-sku wp-block-woocommerce-product-sku',
} );
const blockAttrs = {
...attributes,
...context,
};
const isDescendentOfQueryLoop = Number.isFinite( context.queryId );
const { isDescendentOfSingleProductBlock } =
useIsDescendentOfSingleProductBlock( { blockClientId: blockProps.id } );
let { isDescendentOfSingleProductTemplate } =
useIsDescendentOfSingleProductTemplate();
if ( isDescendentOfQueryLoop ) {
isDescendentOfSingleProductTemplate = false;
}
useEffect(
() =>
setAttributes( {
isDescendentOfQueryLoop,
isDescendentOfSingleProductTemplate,
isDescendentOfSingleProductBlock,
} ),
[
setAttributes,
isDescendentOfQueryLoop,
isDescendentOfSingleProductTemplate,
isDescendentOfSingleProductBlock,
]
);
return (
<>
<EditProductLink />
<div
{ ...blockProps }
/**
* If block is decendant of the All Products block, we don't want to
* apply style here because it will be applied inside Block using
* useColors, useTypography, and useSpacing hooks.
*/
style={
attributes.isDescendantOfAllProducts ? undefined : style
}
>
<Block { ...blockAttrs } />
</div>
</>
);
};
export default Edit;
blocks/product-elements/sku/index.tsx 0000644 00000001651 15154703041 0013767 0 ustar 00 /**
* External dependencies
*/
import { registerBlockType } from '@wordpress/blocks';
import type { BlockConfiguration } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import sharedConfig from '../shared/config';
import attributes from './attributes';
import edit from './edit';
import { supports } from './supports';
import {
BLOCK_TITLE as title,
BLOCK_ICON as icon,
BLOCK_DESCRIPTION as description,
} from './constants';
const { ancestor, ...configuration } = sharedConfig;
const blockConfig: BlockConfiguration = {
...configuration,
apiVersion: 2,
title,
description,
icon: { src: icon },
usesContext: [ 'query', 'queryId', 'postId' ],
attributes,
ancestor: [
'woocommerce/all-products',
'woocommerce/single-product',
'core/post-template',
'woocommerce/product-template',
'woocommerce/product-meta',
],
edit,
supports,
};
registerBlockType( 'woocommerce/product-sku', { ...blockConfig } );
blocks/product-elements/sku/style.scss 0000644 00000000212 15154703041 0014145 0 ustar 00 .wc-block-components-product-sku {
display: block;
text-transform: uppercase;
@include font-size(small);
overflow-wrap: break-word;
}
blocks/product-elements/sku/supports.ts 0000644 00000001656 15154703041 0014374 0 ustar 00 /**
* External dependencies
*/
import { isFeaturePluginBuild } from '@woocommerce/block-settings';
import {
// @ts-expect-error We check if this exists before using it.
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalGetSpacingClassesAndStyles,
} from '@wordpress/block-editor';
/**
* Internal dependencies
*/
import sharedConfig from '../shared/config';
export const supports = {
...sharedConfig.supports,
color: {
text: true,
background: true,
},
typography: {
fontSize: true,
lineHeight: true,
...( isFeaturePluginBuild() && {
__experimentalFontWeight: true,
__experimentalFontFamily: true,
__experimentalFontStyle: true,
__experimentalTextTransform: true,
__experimentalTextDecoration: true,
__experimentalLetterSpacing: true,
} ),
},
...( typeof __experimentalGetSpacingClassesAndStyles === 'function' && {
spacing: {
margin: true,
padding: true,
},
} ),
};
blocks/product-elements/sku/types.ts 0000644 00000000366 15154703041 0013636 0 ustar 00 export interface Attributes {
productId: number;
isDescendentOfQueryLoop: boolean;
isDescendentOfSingleProductTemplate: boolean;
isDescendentOfSingleProductBlock: boolean;
showProductSelector: boolean;
isDescendantOfAllProducts: boolean;
}
blocks/product-elements/stock-indicator/attributes.ts 0000644 00000000560 15154703041 0017147 0 ustar 00 /**
* External dependencies
*/
import type { BlockAttributes } from '@wordpress/blocks';
export const blockAttributes: BlockAttributes = {
productId: {
type: 'number',
default: 0,
},
isDescendentOfQueryLoop: {
type: 'boolean',
default: false,
},
isDescendantOfAllProducts: {
type: 'boolean',
default: false,
},
};
export default blockAttributes;
blocks/product-elements/stock-indicator/block.tsx 0000644 00000006333 15154703041 0016247 0 ustar 00 /**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import classnames from 'classnames';
import {
useInnerBlockLayoutContext,
useProductDataContext,
} from '@woocommerce/shared-context';
import { useStyleProps } from '@woocommerce/base-hooks';
import { withProductDataContext } from '@woocommerce/shared-hocs';
import type { HTMLAttributes } from 'react';
/**
* Internal dependencies
*/
import './style.scss';
import type { BlockAttributes } from './types';
/**
* Get stock text based on stock. For example:
* - In stock
* - Out of stock
* - Available on backorder
* - 2 left in stock
*
* @param stockInfo Object containing stock information.
* @param stockInfo.isInStock Whether product is in stock.
* @param stockInfo.isLowStock Whether product is low in stock.
* @param stockInfo.lowStockAmount Number of items left in stock.
* @param stockInfo.isOnBackorder Whether product is on backorder.
* @return string Stock text.
*/
const getTextBasedOnStock = ( {
isInStock = false,
isLowStock = false,
lowStockAmount = null,
isOnBackorder = false,
}: {
isInStock?: boolean;
isLowStock?: boolean;
lowStockAmount?: number | null;
isOnBackorder?: boolean;
} ): string => {
if ( isLowStock && lowStockAmount !== null ) {
return sprintf(
/* translators: %d stock amount (number of items in stock for product) */
__( '%d left in stock', 'woo-gutenberg-products-block' ),
lowStockAmount
);
} else if ( isOnBackorder ) {
return __( 'Available on backorder', 'woo-gutenberg-products-block' );
} else if ( isInStock ) {
return __( 'In stock', 'woo-gutenberg-products-block' );
}
return __( 'Out of stock', 'woo-gutenberg-products-block' );
};
type Props = BlockAttributes & HTMLAttributes< HTMLDivElement >;
export const Block = ( props: Props ): JSX.Element | null => {
const { className } = props;
const styleProps = useStyleProps( props );
const { parentClassName } = useInnerBlockLayoutContext();
const { product } = useProductDataContext();
if ( ! product.id ) {
return null;
}
const inStock = !! product.is_in_stock;
const lowStock = product.low_stock_remaining;
const isBackordered = product.is_on_backorder;
return (
<div
className={ classnames( className, {
[ `${ parentClassName }__stock-indicator` ]: parentClassName,
'wc-block-components-product-stock-indicator--in-stock':
inStock,
'wc-block-components-product-stock-indicator--out-of-stock':
! inStock,
'wc-block-components-product-stock-indicator--low-stock':
!! lowStock,
'wc-block-components-product-stock-indicator--available-on-backorder':
!! isBackordered,
// When inside All products block
...( props.isDescendantOfAllProducts && {
[ styleProps.className ]: styleProps.className,
'wc-block-components-product-stock-indicator wp-block-woocommerce-product-stock-indicator':
true,
} ),
} ) }
// When inside All products block
{ ...( props.isDescendantOfAllProducts && {
style: styleProps.style,
} ) }
>
{ getTextBasedOnStock( {
isInStock: inStock,
isLowStock: !! lowStock,
lowStockAmount: lowStock,
isOnBackorder: isBackordered,
} ) }
</div>
);
};
export default withProductDataContext( Block );
blocks/product-elements/stock-indicator/constants.tsx 0000644 00000000703 15154703041 0017164 0 ustar 00 /**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { box, Icon } from '@wordpress/icons';
export const BLOCK_TITLE: string = __(
'Product Stock Indicator',
'woo-gutenberg-products-block'
);
export const BLOCK_ICON: JSX.Element = (
<Icon icon={ box } className="wc-block-editor-components-block-icon" />
);
export const BLOCK_DESCRIPTION: string = __(
'Display product stock status.',
'woo-gutenberg-products-block'
);
blocks/product-elements/stock-indicator/edit.tsx 0000644 00000003123 15154703041 0016074 0 ustar 00 /**
* External dependencies
*/
import EditProductLink from '@woocommerce/editor-components/edit-product-link';
import { useBlockProps } from '@wordpress/block-editor';
import type { BlockEditProps } from '@wordpress/blocks';
import { ProductQueryContext as Context } from '@woocommerce/blocks/product-query/types';
import { useEffect } from '@wordpress/element';
/**
* Internal dependencies
*/
import Block from './block';
import withProductSelector from '../shared/with-product-selector';
import {
BLOCK_TITLE as label,
BLOCK_ICON as icon,
BLOCK_DESCRIPTION as description,
} from './constants';
import type { BlockAttributes } from './types';
const Edit = ( {
attributes,
setAttributes,
context,
}: BlockEditProps< BlockAttributes > & { context: Context } ): JSX.Element => {
const { style, ...blockProps } = useBlockProps( {
className: 'wc-block-components-product-stock-indicator',
} );
const blockAttrs = {
...attributes,
...context,
};
const isDescendentOfQueryLoop = Number.isFinite( context.queryId );
useEffect(
() => setAttributes( { isDescendentOfQueryLoop } ),
[ setAttributes, isDescendentOfQueryLoop ]
);
return (
<div
{ ...blockProps }
/**
* If block is decendant of the All Products block, we don't want to
* apply style here because it will be applied inside Block using
* useColors, useTypography, and useSpacing hooks.
*/
style={ attributes.isDescendantOfAllProducts ? undefined : style }
>
<EditProductLink />
<Block { ...blockAttrs } />
</div>
);
};
export default withProductSelector( { icon, label, description } )( Edit );
blocks/product-elements/stock-indicator/index.ts 0000644 00000001543 15154703041 0016072 0 ustar 00 /**
* External dependencies
*/
import { registerBlockType } from '@wordpress/blocks';
import type { BlockConfiguration } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import sharedConfig from '../shared/config';
import attributes from './attributes';
import edit from './edit';
import { supports } from './supports';
import {
BLOCK_TITLE as title,
BLOCK_ICON as icon,
BLOCK_DESCRIPTION as description,
} from './constants';
const blockConfig: BlockConfiguration = {
...sharedConfig,
apiVersion: 2,
title,
description,
icon: { src: icon },
attributes,
supports,
edit,
usesContext: [ 'query', 'queryId', 'postId' ],
ancestor: [
'woocommerce/all-products',
'woocommerce/single-product',
'core/post-template',
'woocommerce/product-template',
],
};
registerBlockType( 'woocommerce/product-stock-indicator', {
...blockConfig,
} );
blocks/product-elements/stock-indicator/style.scss 0000644 00000000216 15154703041 0016444 0 ustar 00 .wc-block-components-product-stock-indicator {
margin-top: 0;
margin-bottom: em($gap-small);
display: block;
@include font-size(small);
}
blocks/product-elements/stock-indicator/supports.ts 0000644 00000001656 15154703041 0016667 0 ustar 00 /**
* External dependencies
*/
import { isFeaturePluginBuild } from '@woocommerce/block-settings';
import {
// @ts-expect-error We check if this exists before using it.
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalGetSpacingClassesAndStyles,
} from '@wordpress/block-editor';
/**
* Internal dependencies
*/
import sharedConfig from '../shared/config';
export const supports = {
...sharedConfig.supports,
color: {
text: true,
background: true,
},
typography: {
fontSize: true,
lineHeight: true,
...( isFeaturePluginBuild() && {
__experimentalFontWeight: true,
__experimentalFontFamily: true,
__experimentalFontStyle: true,
__experimentalTextTransform: true,
__experimentalTextDecoration: true,
__experimentalLetterSpacing: true,
} ),
},
...( typeof __experimentalGetSpacingClassesAndStyles === 'function' && {
spacing: {
margin: true,
padding: true,
},
} ),
};
blocks/product-elements/stock-indicator/types.ts 0000644 00000000201 15154703041 0016115 0 ustar 00 export interface BlockAttributes {
productId: number;
isDescendentOfQueryLoop: boolean;
isDescendantOfAllProducts: boolean;
}
blocks/product-elements/summary/attributes.ts 0000644 00000000344 15154703041 0015547 0 ustar 00 /**
* External dependencies
*/
import type { BlockAttributes } from '@wordpress/blocks';
export const blockAttributes: BlockAttributes = {
productId: {
type: 'number',
default: 0,
},
};
export default blockAttributes;
blocks/product-elements/summary/block.tsx 0000644 00000003167 15154703041 0014651 0 ustar 00 /**
* External dependencies
*/
import classnames from 'classnames';
import Summary from '@woocommerce/base-components/summary';
import { blocksConfig } from '@woocommerce/block-settings';
import {
useInnerBlockLayoutContext,
useProductDataContext,
} from '@woocommerce/shared-context';
import { useStyleProps } from '@woocommerce/base-hooks';
import { withProductDataContext } from '@woocommerce/shared-hocs';
import type { HTMLAttributes } from 'react';
/**
* Internal dependencies
*/
import './style.scss';
import type { BlockAttributes } from './types';
type Props = BlockAttributes & HTMLAttributes< HTMLDivElement >;
const Block = ( props: Props ): JSX.Element | null => {
const { className } = props;
const { parentClassName } = useInnerBlockLayoutContext();
const { product } = useProductDataContext();
const styleProps = useStyleProps( props );
if ( ! product ) {
return (
<div
className={ classnames(
className,
`wc-block-components-product-summary`,
{
[ `${ parentClassName }__product-summary` ]:
parentClassName,
}
) }
/>
);
}
const source = product.short_description
? product.short_description
: product.description;
if ( ! source ) {
return null;
}
return (
<Summary
className={ classnames(
className,
styleProps.className,
`wc-block-components-product-summary`,
{
[ `${ parentClassName }__product-summary` ]:
parentClassName,
}
) }
source={ source }
maxLength={ 150 }
countType={ blocksConfig.wordCountType || 'words' }
style={ styleProps.style }
/>
);
};
export default withProductDataContext( Block );
blocks/product-elements/summary/constants.tsx 0000644 00000000714 15154703041 0015566 0 ustar 00 /**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { page, Icon } from '@wordpress/icons';
export const BLOCK_TITLE: string = __(
'Product Summary',
'woo-gutenberg-products-block'
);
export const BLOCK_ICON: JSX.Element = (
<Icon icon={ page } className="wc-block-editor-components-block-icon" />
);
export const BLOCK_DESCRIPTION: string = __(
'Display a short description about a product.',
'woo-gutenberg-products-block'
);
blocks/product-elements/summary/edit.tsx 0000644 00000002110 15154703041 0014467 0 ustar 00 /**
* External dependencies
*/
import { useBlockProps } from '@wordpress/block-editor';
/**
* Internal dependencies
*/
import Block from './block';
import withProductSelector from '../shared/with-product-selector';
import {
BLOCK_TITLE as label,
BLOCK_ICON as icon,
BLOCK_DESCRIPTION as description,
} from './constants';
import './editor.scss';
import type { BlockAttributes } from './types';
interface Props {
attributes: BlockAttributes;
}
const Edit = ( { attributes }: Props ): JSX.Element => {
const blockProps = useBlockProps();
return (
<div { ...blockProps }>
<Block { ...attributes } />
</div>
);
};
// @todo: Refactor this to remove the HOC 'withProductSelector()' component as users will not see this block in the inserter. Therefore, we can export the Edit component by default. The HOC 'withProductSelector()' component should also be removed from other `product-elements` components. See also https://github.com/woocommerce/woocommerce-blocks/pull/7566#pullrequestreview-1168635469.
export default withProductSelector( { icon, label, description } )( Edit );
blocks/product-elements/summary/editor.scss 0000644 00000000107 15154703041 0015171 0 ustar 00 .wc-block-components-product-summary {
p {
font-size: inherit;
}
}
blocks/product-elements/summary/index.ts 0000644 00000001304 15154703041 0014465 0 ustar 00 /**
* External dependencies
*/
import { registerBlockType } from '@wordpress/blocks';
import type { BlockConfiguration } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import sharedConfig from '../shared/config';
import attributes from './attributes';
import edit from './edit';
import { supports } from './supports';
import {
BLOCK_TITLE as title,
BLOCK_ICON as icon,
BLOCK_DESCRIPTION as description,
} from './constants';
import { Save } from './save';
const blockConfig: BlockConfiguration = {
...sharedConfig,
apiVersion: 2,
title,
description,
icon: { src: icon },
attributes,
supports,
edit,
save: Save,
};
registerBlockType( 'woocommerce/product-summary', blockConfig );
blocks/product-elements/summary/save.tsx 0000644 00000000635 15154703041 0014512 0 ustar 00 /**
* External dependencies
*/
import { useBlockProps } from '@wordpress/block-editor';
import classnames from 'classnames';
type Props = {
attributes: Record< string, unknown > & {
className?: string;
};
};
export const Save = ( { attributes }: Props ): JSX.Element => {
return (
<div
{ ...useBlockProps.save( {
className: classnames( 'is-loading', attributes.className ),
} ) }
/>
);
};
blocks/product-elements/summary/style.scss 0000644 00000000347 15154703041 0015051 0 ustar 00 .wc-block-components-product-summary {
margin-top: 0;
margin-bottom: $gap-small;
}
.is-loading .wc-block-components-product-summary::before {
@include placeholder();
content: ".";
display: block;
width: 100%;
height: 6em;
}
blocks/product-elements/summary/supports.ts 0000644 00000000473 15154703041 0015263 0 ustar 00 /**
* External dependencies
*/
import { isFeaturePluginBuild } from '@woocommerce/block-settings';
export const supports = {
...( isFeaturePluginBuild() && {
color: {
background: false,
},
typography: {
fontSize: true,
},
__experimentalSelector: '.wc-block-components-product-summary',
} ),
};
blocks/product-elements/summary/types.ts 0000644 00000000071 15154703041 0014522 0 ustar 00 export interface BlockAttributes {
productId: number;
}
blocks/product-elements/title/attributes.ts 0000644 00000001051 15154703041 0015167 0 ustar 00 /**
* External dependencies
*/
import type { BlockAttributes } from '@wordpress/blocks';
import { isFeaturePluginBuild } from '@woocommerce/block-settings';
let blockAttributes: BlockAttributes = {
headingLevel: {
type: 'number',
default: 2,
},
showProductLink: {
type: 'boolean',
default: true,
},
linkTarget: {
type: 'string',
},
productId: {
type: 'number',
default: 0,
},
};
if ( isFeaturePluginBuild() ) {
blockAttributes = {
...blockAttributes,
align: {
type: 'string',
},
};
}
export default blockAttributes;
blocks/product-elements/title/block.tsx 0000644 00000006260 15154703041 0014272 0 ustar 00 /**
* External dependencies
*/
import classnames from 'classnames';
import {
useInnerBlockLayoutContext,
useProductDataContext,
} from '@woocommerce/shared-context';
import { isFeaturePluginBuild } from '@woocommerce/block-settings';
import { withProductDataContext } from '@woocommerce/shared-hocs';
import ProductName from '@woocommerce/base-components/product-name';
import { useStoreEvents } from '@woocommerce/base-context/hooks';
import { useStyleProps } from '@woocommerce/base-hooks';
import type { HTMLAttributes } from 'react';
/**
* Internal dependencies
*/
import './style.scss';
import { Attributes } from './types';
type Props = Attributes & HTMLAttributes< HTMLDivElement >;
interface TagNameProps extends HTMLAttributes< HTMLOrSVGElement > {
headingLevel: number;
elementType?: keyof JSX.IntrinsicElements;
}
const TagName = ( {
children,
headingLevel,
elementType:
ElementType = `h${ headingLevel }` as keyof JSX.IntrinsicElements,
...props
}: TagNameProps ): JSX.Element => {
return <ElementType { ...props }>{ children }</ElementType>;
};
/**
* Product Title Block Component.
*
* @param {Object} props Incoming props.
* @param {string} [props.className] CSS Class name for the component.
* @param {number} [props.headingLevel] Heading level (h1, h2, etc.)
* @param {boolean} [props.showProductLink] Whether or not to display a link to the product page.
* @param {string} [props.linkTarget] Specifies where to open the linked URL.
* @param {string} [props.align] Title alignment.
* will be used if this is not provided.
* @return {*} The component.
*/
export const Block = ( props: Props ): JSX.Element => {
const {
className,
headingLevel = 2,
showProductLink = true,
linkTarget,
align,
} = props;
const styleProps = useStyleProps( props );
const { parentClassName } = useInnerBlockLayoutContext();
const { product } = useProductDataContext();
const { dispatchStoreEvent } = useStoreEvents();
if ( ! product.id ) {
return (
<TagName
headingLevel={ headingLevel }
className={ classnames(
className,
styleProps.className,
'wc-block-components-product-title',
{
[ `${ parentClassName }__product-title` ]:
parentClassName,
[ `wc-block-components-product-title--align-${ align }` ]:
align && isFeaturePluginBuild(),
}
) }
style={ isFeaturePluginBuild() ? styleProps.style : {} }
/>
);
}
return (
<TagName
headingLevel={ headingLevel }
className={ classnames(
className,
styleProps.className,
'wc-block-components-product-title',
{
[ `${ parentClassName }__product-title` ]: parentClassName,
[ `wc-block-components-product-title--align-${ align }` ]:
align && isFeaturePluginBuild(),
}
) }
style={ isFeaturePluginBuild() ? styleProps.style : {} }
>
<ProductName
disabled={ ! showProductLink }
name={ product.name }
permalink={ product.permalink }
target={ linkTarget }
onClick={ () => {
dispatchStoreEvent( 'product-view-link', {
product,
} );
} }
/>
</TagName>
);
};
export default withProductDataContext( Block );
blocks/product-elements/title/constants.tsx 0000644 00000000703 15154703041 0015210 0 ustar 00 /**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { heading, Icon } from '@wordpress/icons';
export const BLOCK_TITLE: string = __(
'Product Title',
'woo-gutenberg-products-block'
);
export const BLOCK_ICON: JSX.Element = (
<Icon icon={ heading } className="wc-block-editor-components-block-icon" />
);
export const BLOCK_DESCRIPTION: string = __(
'Display the title of a product.',
'woo-gutenberg-products-block'
);
blocks/product-elements/title/edit.tsx 0000644 00000005150 15154703041 0014122 0 ustar 00 /**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Disabled, PanelBody, ToggleControl } from '@wordpress/components';
import { compose } from '@wordpress/compose';
import {
InspectorControls,
BlockControls,
AlignmentToolbar,
useBlockProps,
} from '@wordpress/block-editor';
import { isFeaturePluginBuild } from '@woocommerce/block-settings';
import HeadingToolbar from '@woocommerce/editor-components/heading-toolbar';
/**
* Internal dependencies
*/
import Block from './block';
import withProductSelector from '../shared/with-product-selector';
import { BLOCK_TITLE, BLOCK_ICON } from './constants';
import { Attributes } from './types';
import './editor.scss';
interface Props {
attributes: Attributes;
setAttributes: ( attributes: Record< string, unknown > ) => void;
}
const TitleEdit = ( { attributes, setAttributes }: Props ): JSX.Element => {
const blockProps = useBlockProps();
const { headingLevel, showProductLink, align, linkTarget } = attributes;
return (
<div { ...blockProps }>
<BlockControls>
<HeadingToolbar
isCollapsed={ true }
minLevel={ 1 }
maxLevel={ 7 }
selectedLevel={ headingLevel }
onChange={ ( newLevel: number ) =>
setAttributes( { headingLevel: newLevel } )
}
/>
{ isFeaturePluginBuild() && (
<AlignmentToolbar
value={ align }
onChange={ ( newAlign ) => {
setAttributes( { align: newAlign } );
} }
/>
) }
</BlockControls>
<InspectorControls>
<PanelBody
title={ __(
'Link settings',
'woo-gutenberg-products-block'
) }
>
<ToggleControl
label={ __(
'Make title a link',
'woo-gutenberg-products-block'
) }
checked={ showProductLink }
onChange={ () =>
setAttributes( {
showProductLink: ! showProductLink,
} )
}
/>
{ showProductLink && (
<>
<ToggleControl
label={ __(
'Open in new tab',
'woo-gutenberg-products-block'
) }
onChange={ ( value ) =>
setAttributes( {
linkTarget: value ? '_blank' : '_self',
} )
}
checked={ linkTarget === '_blank' }
/>
</>
) }
</PanelBody>
</InspectorControls>
<Disabled>
<Block { ...attributes } />
</Disabled>
</div>
);
};
const Title = isFeaturePluginBuild()
? compose( [
withProductSelector( {
icon: BLOCK_ICON,
label: BLOCK_TITLE,
description: __(
'Choose a product to display its title.',
'woo-gutenberg-products-block'
),
} ),
] )( TitleEdit )
: TitleEdit;
export default Title;
blocks/product-elements/title/editor.scss 0000644 00000000466 15154703041 0014625 0 ustar 00 .editor-styles-wrapper .wc-block-components-product-title a.wc-block-components-product-name {
color: inherit;
}
.editor-styles-wrapper .wc-block-components-product-title {
margin-top: 0;
margin-bottom: $gap-small;
line-height: 1.5;
font-weight: 700;
padding: 0;
display: block;
font-size: inherit;
}
blocks/product-elements/title/frontend.ts 0000644 00000000411 15154703041 0014617 0 ustar 00 /**
* External dependencies
*/
import { withFilteredAttributes } from '@woocommerce/shared-hocs';
/**
* Internal dependencies
*/
import Block from './block';
import attributes from './attributes';
export default withFilteredAttributes( attributes )( Block );
blocks/product-elements/title/index.ts 0000644 00000002730 15154703041 0014115 0 ustar 00 /* eslint-disable @wordpress/no-unsafe-wp-apis */
/**
* External dependencies
*/
import { registerBlockType } from '@wordpress/blocks';
import type { BlockConfiguration } from '@wordpress/blocks';
import { isFeaturePluginBuild } from '@woocommerce/block-settings';
import { __experimentalGetSpacingClassesAndStyles } from '@wordpress/block-editor';
/**
* Internal dependencies
*/
import sharedConfig from '../shared/config';
import attributes from './attributes';
import edit from './edit';
import {
BLOCK_TITLE as title,
BLOCK_ICON as icon,
BLOCK_DESCRIPTION as description,
} from './constants';
import { Save } from './save';
const blockConfig: BlockConfiguration = {
...sharedConfig,
apiVersion: 2,
title,
description,
icon: { src: icon },
attributes,
edit,
save: Save,
supports: {
...sharedConfig.supports,
...( isFeaturePluginBuild() && {
typography: {
fontSize: true,
lineHeight: true,
__experimentalFontWeight: true,
__experimentalTextTransform: true,
__experimentalFontFamily: true,
},
color: {
text: true,
background: true,
link: false,
gradients: true,
__experimentalSkipSerialization: true,
},
...( typeof __experimentalGetSpacingClassesAndStyles ===
'function' && {
spacing: {
margin: true,
__experimentalSkipSerialization: true,
},
} ),
__experimentalSelector: '.wc-block-components-product-title',
} ),
},
};
registerBlockType( 'woocommerce/product-title', blockConfig );
blocks/product-elements/title/save.tsx 0000644 00000000635 15154703041 0014136 0 ustar 00 /**
* External dependencies
*/
import { useBlockProps } from '@wordpress/block-editor';
import classnames from 'classnames';
type Props = {
attributes: Record< string, unknown > & {
className?: string;
};
};
export const Save = ( { attributes }: Props ): JSX.Element => {
return (
<div
{ ...useBlockProps.save( {
className: classnames( 'is-loading', attributes.className ),
} ) }
/>
);
};
blocks/product-elements/title/style.scss 0000644 00000001301 15154703041 0014464 0 ustar 00 .wc-block-components-product-title {
margin-top: 0;
margin-bottom: $gap-small;
line-height: 1.5;
font-weight: 700;
padding: 0;
display: block;
font-size: inherit;
a {
color: inherit;
font-size: inherit;
}
}
.is-loading {
.wc-block-components-product-title::before {
@include placeholder();
content: ".";
display: inline-block;
width: 7em;
}
.wc-block-grid .wc-block-components-product-title::before {
width: 10em;
}
}
/*rtl:begin:ignore*/
.wc-block-components-product-title--align-left {
text-align: left;
}
.wc-block-components-product-title--align-center {
text-align: center;
}
.wc-block-components-product-title--align-right {
text-align: right;
}
/*rtl:end:ignore*/
blocks/product-elements/title/test/block.test.js 0000644 00000003467 15154703041 0016033 0 ustar 00 /**
* External dependencies
*/
import { render } from '@testing-library/react';
import { ProductDataContextProvider } from '@woocommerce/shared-context';
/**
* Internal dependencies
*/
import { Block } from '../block';
const product = {
id: 1,
name: 'Test product',
permalink: 'https://test.com/product/test-product/',
};
describe( 'Product Title Block', () => {
describe( 'without product link', () => {
test( 'should render the product title without an anchor wrapper', () => {
const component = render(
<ProductDataContextProvider product={ product }>
<Block showProductLink={ false } />
</ProductDataContextProvider>
);
const productName = component.getByText( product.name );
const anchor = productName.closest( 'a' );
expect( anchor ).toBe( null );
} );
} );
describe( 'with product link', () => {
test( 'should render an anchor with the product title', () => {
const component = render(
<ProductDataContextProvider product={ product }>
<Block showProductLink={ true } />
</ProductDataContextProvider>
);
const productName = component.getByText( product.name );
const anchor = productName.closest( 'a' );
expect( anchor.getAttribute( 'href' ) ).toBe( product.permalink );
expect( anchor.getAttribute( 'target' ) ).toBeNull();
} );
test( 'should render an anchor with the product title and target blank', () => {
const component = render(
<ProductDataContextProvider product={ product }>
<Block showProductLink={ true } linkTarget="_blank" />
</ProductDataContextProvider>
);
const productName = component.getByText( product.name );
const anchor = productName.closest( 'a' );
expect( anchor.getAttribute( 'href' ) ).toBe( product.permalink );
expect( anchor.getAttribute( 'target' ) ).toBe( '_blank' );
} );
} );
} );
blocks/product-elements/title/types.ts 0000644 00000000366 15154703041 0014155 0 ustar 00 export interface Attributes {
headingLevel: number;
showProductLink: boolean;
linkTarget?: string;
align: string;
}
export interface Props {
align: string;
color: string;
customColor: string;
fontSize: string;
customFontSize: string;
}
utils/create-blocks-from-template.js 0000644 00000000720 15154703041 0013531 0 ustar 00 /**
* External dependencies
*/
import { createBlock } from '@wordpress/blocks';
/**
* Creates blocks for a given inner blocks Template.
*
* @param {Array} template Inner Blocks Template.
*/
export const createBlocksFromTemplate = ( template ) => {
return template.map( ( [ name, atts = {}, innerBlocks = [] ] ) => {
const children = innerBlocks
? createBlocksFromTemplate( innerBlocks )
: [];
return createBlock( name, atts, children );
} );
};
utils/get-block-map.ts 0000644 00000001022 15154703041 0010671 0 ustar 00 /**
* External dependencies
*/
import { getRegisteredBlockComponents } from '@woocommerce/blocks-registry';
import type { RegisteredBlockComponent } from '@woocommerce/types';
/**
* Internal dependencies
*/
import '../blocks/component-init';
/**
* Map named Blocks to defined React Components to render on the frontend.
*
* @param {string} blockName Name of the parent block.
*/
export const getBlockMap = (
blockName: string
): Record< string, RegisteredBlockComponent > =>
getRegisteredBlockComponents( blockName );
utils/index.js 0000644 00000000335 15154703041 0007352 0 ustar 00 export * from './get-block-map';
export * from './create-blocks-from-template';
export * from './render-parent-block';
export * from './render-standalone-blocks';
export * from './register-block-single-product-template';
utils/register-block-single-product-template.ts 0000644 00000007632 15154703041 0015746 0 ustar 00 /**
* External dependencies
*/
import { isNumber } from '@woocommerce/types';
import {
BlockAttributes,
BlockConfiguration,
BlockVariation,
getBlockType,
registerBlockType,
registerBlockVariation,
unregisterBlockType,
unregisterBlockVariation,
} from '@wordpress/blocks';
import { subscribe, select } from '@wordpress/data';
// Creating a local cache to prevent multiple registration tries.
const blocksRegistered = new Set();
function parseTemplateId( templateId: string | number | undefined ) {
// With GB 16.3.0 the return type can be a number: https://github.com/WordPress/gutenberg/issues/53230
const parsedTemplateId = isNumber( templateId ) ? undefined : templateId;
return parsedTemplateId?.split( '//' )[ 1 ];
}
export const registerBlockSingleProductTemplate = ( {
blockName,
blockMetadata,
blockSettings,
isVariationBlock = false,
variationName,
isAvailableOnPostEditor,
}: {
blockName: string;
blockMetadata: Partial< BlockConfiguration >;
blockSettings: Partial< BlockConfiguration >;
isAvailableOnPostEditor: boolean;
isVariationBlock?: boolean;
variationName?: string;
} ) => {
let currentTemplateId: string | undefined = '';
subscribe( () => {
const previousTemplateId = currentTemplateId;
const store = select( 'core/edit-site' );
// With GB 16.3.0 the return type can be a number: https://github.com/WordPress/gutenberg/issues/53230
currentTemplateId = parseTemplateId(
store?.getEditedPostId() as string | number | undefined
);
const hasChangedTemplate = previousTemplateId !== currentTemplateId;
const hasTemplateId = Boolean( currentTemplateId );
if ( ! hasChangedTemplate || ! hasTemplateId || ! blockName ) {
return;
}
let isBlockRegistered = Boolean( getBlockType( blockName ) );
/**
* We need to unregister the block each time the user visits or leaves the Single Product template.
*
* The Single Product template is the only template where the `ancestor` property is not needed because it provides the context
* for the product blocks. We need to unregister and re-register the block to remove or add the `ancestor` property depending on which
* location (template, post, page, etc.) the user is in.
*
*/
if (
isBlockRegistered &&
( currentTemplateId?.includes( 'single-product' ) ||
previousTemplateId?.includes( 'single-product' ) )
) {
if ( isVariationBlock && variationName ) {
unregisterBlockVariation( blockName, variationName );
} else {
unregisterBlockType( blockName );
}
isBlockRegistered = false;
}
if ( ! isBlockRegistered ) {
if ( isVariationBlock ) {
registerBlockVariation( blockName, {
...blockSettings,
// @ts-expect-error: `ancestor` key is typed in WordPress core
ancestor: ! currentTemplateId?.includes( 'single-product' )
? blockSettings?.ancestor
: undefined,
} );
} else {
// @ts-expect-error: `registerBlockType` is typed in WordPress core
registerBlockType( blockMetadata, {
...blockSettings,
ancestor: ! currentTemplateId?.includes( 'single-product' )
? blockSettings?.ancestor
: undefined,
} );
}
}
}, 'core/edit-site' );
subscribe( () => {
const isBlockRegistered = Boolean( variationName )
? blocksRegistered.has( variationName )
: blocksRegistered.has( blockName );
// This subscribe callback could be invoked with the core/blocks store
// which would cause infinite registration loops because of the `registerBlockType` call.
// This local cache helps prevent that.
if ( ! isBlockRegistered && isAvailableOnPostEditor ) {
if ( isVariationBlock ) {
blocksRegistered.add( variationName );
registerBlockVariation(
blockName,
blockSettings as BlockVariation< BlockAttributes >
);
} else {
blocksRegistered.add( blockName );
// @ts-expect-error: `registerBlockType` is typed in WordPress core
registerBlockType( blockMetadata, blockSettings );
}
}
}, 'core/edit-post' );
};
utils/render-parent-block.tsx 0000644 00000023057 15154703041 0012311 0 ustar 00 /**
* External dependencies
*/
import { renderFrontend } from '@woocommerce/base-utils';
import { CURRENT_USER_IS_ADMIN } from '@woocommerce/settings';
import {
Fragment,
Suspense,
isValidElement,
cloneElement,
} from '@wordpress/element';
import parse from 'html-react-parser';
import {
getRegisteredBlocks,
hasInnerBlocks,
} from '@woocommerce/blocks-checkout';
import BlockErrorBoundary from '@woocommerce/base-components/block-error-boundary';
/**
* This file contains logic used on the frontend to convert DOM elements (saved by the block editor) to React
* Components. These components are registered using registerBlockComponent() and registerCheckoutBlock() and map 1:1
* to a block by name.
*
* Blocks using this system will have their blockName stored as a data attribute, for example:
* <div data-block-name="woocommerce/product-title"></div>
*
* This block name is then read, and using the map, dynamically converted to a real React Component.
*
* @see registerBlockComponent
* @see registerCheckoutBlock
*/
/**
* Gets a component from the block map for a given block name, or returns null if a component is not registered.
*/
const getBlockComponentFromMap = (
block: string,
blockMap: Record< string, React.ReactNode >
): React.ElementType | null => {
return block && blockMap[ block ]
? ( blockMap[ block ] as React.ElementType )
: null;
};
/**
* Render forced blocks which are missing from the template.
*
* Forced blocks are registered in registerCheckoutBlock. If a block is forced, it will be inserted in the editor
* automatically, however, until that happens they may be missing from the frontend. To fix this, we look up what blocks
* are registered as forced, and then append them here if they are missing.
*
* @see registerCheckoutBlock
*/
const renderForcedBlocks = (
block: string,
blockMap: Record< string, React.ReactNode >,
// Current children from the parent (siblings of the forced block)
blockChildren: NodeListOf< ChildNode > | null,
// Wrapper for inner components.
blockWrapper?: React.ElementType
) => {
if ( ! hasInnerBlocks( block ) ) {
return null;
}
const currentBlocks = blockChildren
? ( Array.from( blockChildren )
.map( ( node: Node ) =>
node instanceof HTMLElement
? node?.dataset.blockName || null
: null
)
.filter( Boolean ) as string[] )
: [];
const forcedBlocks = getRegisteredBlocks( block ).filter(
( { blockName, force } ) =>
force === true && ! currentBlocks.includes( blockName )
);
// This will wrap inner blocks with the provided wrapper. If no wrapper is provided, we default to Fragment.
const InnerBlockComponentWrapper = blockWrapper ? blockWrapper : Fragment;
return (
<>
{ forcedBlocks.map(
(
{ blockName, component },
index: number
): JSX.Element | null => {
const ForcedComponent = component
? component
: getBlockComponentFromMap( blockName, blockMap );
return ForcedComponent ? (
<BlockErrorBoundary
key={ `${ blockName }_blockerror` }
text={ `Unexpected error in: ${ blockName }` }
showErrorBlock={ CURRENT_USER_IS_ADMIN as boolean }
>
<InnerBlockComponentWrapper>
<ForcedComponent
key={ `${ blockName }_forced_${ index }` }
/>
</InnerBlockComponentWrapper>
</BlockErrorBoundary>
) : null;
}
) }
</>
);
};
interface renderInnerBlocksProps {
// Block (parent) being rendered. Used for inner block component mapping.
block: string;
// Map of block names to block components for children.
blockMap: Record< string, React.ReactNode >;
// Wrapper for inner components.
blockWrapper?: React.ElementType | undefined;
// Elements from the DOM being converted to components.
children: HTMLCollection | NodeList;
// Depth within the DOM hierarchy.
depth?: number;
}
/**
* Recursively replace block markup in the DOM with React Components.
*/
const renderInnerBlocks = ( {
// This is the parent block we're working within (see renderParentBlock)
block,
// This is the map of blockNames->components
blockMap,
// Component which inner blocks are wrapped with.
blockWrapper,
// The children from the DOM we're currently iterating over.
children,
// Current depth of the children. Used to ensure keys are unique.
depth = 1,
}: renderInnerBlocksProps ): ( string | JSX.Element | null )[] | null => {
if ( ! children || children.length === 0 ) {
return null;
}
return Array.from( children ).map( ( node: Node, index: number ) => {
/**
* This will grab the blockName from the data- attributes stored in block markup. Without a blockName, we cannot
* convert the HTMLElement to a React component.
*/
const { blockName = '', ...componentProps } = {
...( node instanceof HTMLElement ? node.dataset : {} ),
className: node instanceof Element ? node?.className : '',
};
const componentKey = `${ block }_${ depth }_${ index }`;
const InnerBlockComponent = getBlockComponentFromMap(
blockName,
blockMap
);
/**
* If the component cannot be found, or blockName is missing, return the original element. This also ensures
* that children within the element are processed also, since it may be an element containing block markup.
*
* Note we use childNodes rather than children so that text nodes are also rendered.
*/
if ( ! InnerBlockComponent ) {
const parsedElement = parse(
( node instanceof Element && node?.outerHTML ) ||
node?.textContent ||
''
);
// Returns text nodes without manipulation.
if ( typeof parsedElement === 'string' && !! parsedElement ) {
return parsedElement;
}
// Do not render invalid elements.
if ( ! isValidElement( parsedElement ) ) {
return null;
}
const renderedChildren = node.childNodes.length
? renderInnerBlocks( {
block,
blockMap,
children: node.childNodes,
depth: depth + 1,
blockWrapper,
} )
: undefined;
// We pass props here rather than componentProps to avoid the data attributes being renamed.
return renderedChildren
? cloneElement(
parsedElement,
{
key: componentKey,
...( parsedElement?.props || {} ),
},
renderedChildren
)
: cloneElement( parsedElement, {
key: componentKey,
...( parsedElement?.props || {} ),
} );
}
// This will wrap inner blocks with the provided wrapper. If no wrapper is provided, we default to Fragment.
const InnerBlockComponentWrapper = blockWrapper
? blockWrapper
: Fragment;
return (
<Suspense
key={ `${ block }_${ depth }_${ index }_suspense` }
fallback={ <div className="wc-block-placeholder" /> }
>
{ /* Prevent third party components from breaking the entire checkout */ }
<BlockErrorBoundary
text={ `Unexpected error in: ${ blockName }` }
showErrorBlock={ CURRENT_USER_IS_ADMIN as boolean }
>
<InnerBlockComponentWrapper>
<InnerBlockComponent
key={ componentKey }
{ ...componentProps }
>
{
/**
* Within this Inner Block Component we also need to recursively render it's children. This
* is done here with a depth+1. The same block map and parent is used, but we pass new
* children from this element.
*/
renderInnerBlocks( {
block,
blockMap,
children: node.childNodes,
depth: depth + 1,
blockWrapper,
} )
}
{
/**
* In addition to the inner blocks, we may also need to render FORCED blocks which have not
* yet been added to the inner block template. We do this by comparing the current children
* to the list of registered forced blocks.
*
* @see registerCheckoutBlock
*/
renderForcedBlocks(
blockName,
blockMap,
node.childNodes,
blockWrapper
)
}
</InnerBlockComponent>
</InnerBlockComponentWrapper>
</BlockErrorBoundary>
</Suspense>
);
} );
};
/**
* Render a parent block on the frontend.
*
* This is the main entry point used on the frontend to convert Block Markup (with inner blocks) in the DOM to React
* Components.
*
* This uses renderFrontend(). The difference is, renderFrontend renders a single block, but renderParentBlock() also
* handles inner blocks by recursively running over children from the DOM.
*
* @see renderInnerBlocks
* @see renderFrontend
*/
export const renderParentBlock = ( {
Block,
selector,
blockName,
getProps = () => ( {} ),
blockMap,
blockWrapper,
}: {
// Parent Block Name. Used for inner block component mapping.
blockName: string;
// Map of block names to block components for children.
blockMap: Record< string, React.ReactNode >;
// Wrapper for inner components.
blockWrapper?: React.ElementType;
// React component to use as a replacement.
Block: React.FunctionComponent;
// CSS selector to match the elements to replace.
selector: string;
// Function to generate the props object for the block.
getProps: ( el: Element, i: number ) => Record< string, unknown >;
} ): void => {
/**
* In addition to getProps, we need to render and return the children. This adds children to props.
*/
const getPropsWithChildren = ( element: Element, i: number ) => {
const children = renderInnerBlocks( {
block: blockName,
blockMap,
children: element.children || [],
blockWrapper,
} );
return { ...getProps( element, i ), children };
};
/**
* The only difference between using renderParentBlock and renderFrontend is that here we provide children.
*/
renderFrontend( {
Block,
selector,
getProps: getPropsWithChildren,
} );
};
utils/render-standalone-blocks.js 0000644 00000001022 15154703041 0013115 0 ustar 00 /**
* External dependencies
*/
import { renderFrontend } from '@woocommerce/base-utils';
/**
* Internal dependencies
*/
import { getBlockMap } from './get-block-map';
export const renderStandaloneBlocks = () => {
const blockMap = getBlockMap( '' );
Object.keys( blockMap ).forEach( ( blockName ) => {
const selector = '.wp-block-' + blockName.replace( '/', '-' );
const getProps = ( el ) => {
return el.dataset;
};
renderFrontend( {
Block: blockMap[ blockName ],
selector,
getProps,
} );
} );
};