* Add attributes to API

* Add component

* API updates to return variation data

* Update to handle updated api responses

* Working picker

* Update tests

* update test

* Use SelectControl

* Add Picker to Form Block

* Code cleanup and splitting

* Inline todos

* Update todos

* Update assets/js/atomic/blocks/product/add-to-cart/product-types/variable/variation-attributes/index.js

Co-authored-by: Albert Juhé Lluveras <contact@albertjuhe.com>

* change classname and move styles within component

* Correct conditional

* Avoid nesting filters

* Remove exclude from getVariationsMatchingSelectedAttributes

* basic select styles

* remove custom select styles

Co-authored-by: Albert Juhé Lluveras <contact@albertjuhe.com>
This commit is contained in:
Mike Jolley 2020-06-19 15:51:09 +01:00 committed by GitHub
parent 1026ff8fd4
commit 98d240d5e6
14 changed files with 572 additions and 42 deletions

View File

@ -12,7 +12,7 @@ import {
QuantityInput,
ProductUnavailable,
} from '../../shared';
import VariationPicker from './variation-picker';
import VariationAttributes from './variation-attributes';
/**
* Variable Product Add To Cart Form
@ -44,7 +44,7 @@ const Variable = () => {
return (
<>
<VariationPicker />
<VariationAttributes product={ product } />
<QuantityInput
value={ quantity }
min={ minQuantity }

View File

@ -0,0 +1,115 @@
/**
* External dependencies
*/
import { useState, useEffect, useMemo } from '@wordpress/element';
/**
* Internal dependencies
*/
import AttributeSelectControl from './attribute-select-control';
import {
getVariationsMatchingSelectedAttributes,
getSelectControlOptions,
} from './utils';
/**
* AttributePicker component.
*
* @param {*} props Component props.
*/
const AttributePicker = ( { attributes, variationAttributes } ) => {
const [ variationId, setVariationId ] = useState( 0 );
// @todo Support default selected attributes in Variation Picker.
const [ selectedAttributes, setSelectedAttributes ] = useState( [] );
const attributeNames = Object.keys( attributes );
const hasSelectedAttributes =
Object.values( selectedAttributes ).filter( Boolean ).length > 0;
const hasSelectedAllAttributes =
Object.values( selectedAttributes ).filter(
( selected ) => selected !== ''
).length === attributeNames.length;
// Gets valid attribute options for the picker taking current selections into account.
const filteredAttributeOptions = useMemo( () => {
const options = [];
attributeNames.forEach( ( attributeName ) => {
const currentAttribute = attributes[ attributeName ];
const attributeNamesExcludingCurrentAttribute = attributeNames.filter(
( name ) => name !== attributeName
);
const matchingVariationIds = hasSelectedAttributes
? getVariationsMatchingSelectedAttributes( {
selectedAttributes,
variationAttributes,
attributeNames: attributeNamesExcludingCurrentAttribute,
} )
: null;
const validAttributeTerms =
matchingVariationIds !== null
? matchingVariationIds.map(
( varId ) =>
variationAttributes[ varId ][ attributeName ]
)
: null;
options[ attributeName ] = getSelectControlOptions(
currentAttribute.terms,
validAttributeTerms
);
} );
return options;
}, [
attributes,
variationAttributes,
attributeNames,
selectedAttributes,
hasSelectedAttributes,
] );
// Select variation when selections change.
useEffect( () => {
if ( ! hasSelectedAllAttributes ) {
setVariationId( 0 );
return;
}
const matchingVariationIds = getVariationsMatchingSelectedAttributes( {
selectedAttributes,
variationAttributes,
attributeNames,
} );
setVariationId( matchingVariationIds[ 0 ] || 0 );
}, [
selectedAttributes,
variationAttributes,
attributeNames,
hasSelectedAllAttributes,
] );
// @todo Hook up Variation Picker with Cart Form.
return (
<div className="wc-block-components-product-add-to-cart-attribute-picker">
{ attributeNames.map( ( attributeName ) => (
<AttributeSelectControl
key={ attributeName }
attributeName={ attributeName }
options={ filteredAttributeOptions[ attributeName ] }
selected={ selectedAttributes[ attributeName ] }
onChange={ ( selected ) => {
setSelectedAttributes( {
...selectedAttributes,
[ attributeName ]: selected,
} );
} }
/>
) ) }
<p>Matched variation ID: { variationId }</p>
</div>
);
};
export default AttributePicker;

View File

@ -0,0 +1,36 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { decodeEntities } from '@wordpress/html-entities';
import { SelectControl } from '@wordpress/components';
// Default option for select boxes.
const selectAnOption = {
value: '',
label: __( 'Select an option', 'woo-gutenberg-products-block' ),
};
/**
* VariationAttributeSelect component.
*
* @param {*} props Component props.
*/
const AttributeSelectControl = ( {
attributeName,
options = [],
selected = '',
onChange = () => {},
} ) => {
return (
<SelectControl
className="wc-block-components-product-add-to-cart-attribute-picker__select"
label={ decodeEntities( attributeName ) }
value={ selected || '' }
options={ [ selectAnOption, ...options ] }
onChange={ onChange }
/>
);
};
export default AttributeSelectControl;

View File

@ -0,0 +1,37 @@
/**
* Internal dependencies
*/
import './style.scss';
import AttributePicker from './attribute-picker';
import { getAttributes, getVariationAttributes } from './utils';
/**
* VariationAttributes component.
*
* @param {*} props Component props.
*/
const VariationAttributes = ( { product } ) => {
const {
attributes: productAttributes = {},
variations: productVariations = [],
} = product;
const attributes = getAttributes( productAttributes );
const variationAttributes = getVariationAttributes( productVariations );
if (
Object.keys( attributes ).length === 0 ||
variationAttributes.length === 0
) {
return null;
}
return (
<AttributePicker
attributes={ attributes }
variationAttributes={ variationAttributes }
/>
);
};
export default VariationAttributes;

View File

@ -0,0 +1,18 @@
.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__select {
margin: 0 0 em($gap-small) 0;
select {
min-width: 60%;
min-height: 1.75em;
}
}
}

View File

@ -0,0 +1,107 @@
/**
* External dependencies
*/
import { keyBy } from 'lodash';
/**
* Key an array of attributes by name,
*
* @param {Object} attributes Attributes array.
*/
export const getAttributes = ( attributes ) => {
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.
*
* @param {Array} variations Variations array.
*/
export const getVariationAttributes = ( variations ) => {
if ( ! variations ) {
return {};
}
const attributesMap = {};
variations.forEach( ( { id, attributes } ) => {
attributesMap[ id ] = attributes.reduce( ( acc, { name, value } ) => {
acc[ name ] = value;
return acc;
}, [] );
} );
return attributesMap;
};
/**
* Given a list of terms, filter them and return options for the select boxes.
*
* @param {Object} attributeTerms List of attribute term objects.
* @param {?Array} validAttributeTerms Valid values if selections have been made already.
* @return {Array} Value/Label pairs of select box options.
*/
export const getSelectControlOptions = (
attributeTerms,
validAttributeTerms = null
) => {
return Object.values( attributeTerms )
.map( ( { name, slug } ) => {
if (
validAttributeTerms === null ||
validAttributeTerms.includes( null ) ||
validAttributeTerms.includes( slug )
) {
return {
value: slug,
label: name,
};
}
return null;
} )
.filter( Boolean );
};
/**
* 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.
*
* @param {Object} props
* @param {Object} props.selectedAttributes List of selected attributes.
* @param {Object} props.variationAttributes List of variations and their attributes.
* @param {Object} props.attributeNames List of all possible attribute names.
* @return {Array} List of matching variation IDs.
*/
export const getVariationsMatchingSelectedAttributes = ( {
selectedAttributes,
variationAttributes,
attributeNames,
} ) => {
return Object.keys( variationAttributes ).filter( ( variationId ) =>
attributeNames.every( ( attributeName ) => {
const selectedAttribute = selectedAttributes[ attributeName ] || '';
const variationAttribute =
variationAttributes[ variationId ][ 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;
} )
);
};

View File

@ -1,14 +0,0 @@
/**
* External dependencies
*/
import { Placeholder } from '@wordpress/components';
const VariationPicker = () => {
return (
<Placeholder className="wc-block-components-product-add-to-cart-variation-picker">
This is a placeholder for the variation picker form element.
</Placeholder>
);
};
export default VariationPicker;

View File

@ -4,14 +4,6 @@
display: flex;
flex-wrap: wrap;
.wc-block-components-product-add-to-cart-variation-picker,
.wc-block-components-product-add-to-cart-group-list {
margin: 0 0 em($gap-small) 0;
flex-basis: 100%;
border: 1px solid #000;
padding: 2em;
}
.wc-block-components-product-add-to-cart-button {
margin: 0 0 em($gap-small) 0;

View File

@ -19,10 +19,13 @@ jest.mock( '../../base/utils/errors', () => ( {
} ) );
const mockProducts = [
{ id: 1, name: 'Hoodie', variations: [ 3, 4 ] },
{ id: 1, name: 'Hoodie', variations: [ { id: 3 }, { id: 4 } ] },
{ id: 2, name: 'Backpack' },
];
const mockVariations = [ { id: 3, name: 'Blue' }, { id: 4, name: 'Red' } ];
const mockVariations = [
{ id: 3, name: 'Blue' },
{ id: 4, name: 'Red' },
];
const TestComponent = withProductVariations( ( props ) => {
return (
<div

View File

@ -88,7 +88,7 @@ const withAttributes = ( OriginalComponent ) => {
newTerms = newTerms.map( ( term ) => ( {
...term,
parent: expandedAttribute,
attr_slug: attributeData.slug,
attr_slug: attributeData.taxonomy,
} ) );
setTermsList( {

View File

@ -121,7 +121,8 @@ const withProductVariations = createHigherOrderComponent(
const { products } = this.props;
const parentProduct = products.filter(
( p ) =>
p.variations && p.variations.includes( variationId )
p.variations &&
p.variations.find( ( { id } ) => id === variationId )
);
return parentProduct[ 0 ].id;
}

View File

@ -41,8 +41,8 @@ class ProductAttributeSchema extends AbstractSchema {
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'slug' => array(
'description' => __( 'String based identifier for the attribute, and its WordPress taxonomy.', 'woo-gutenberg-products-block' ),
'taxonomy' => array(
'description' => __( 'The attribute taxonomy name.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
@ -84,7 +84,7 @@ class ProductAttributeSchema extends AbstractSchema {
return [
'id' => (int) $attribute->id,
'name' => $this->prepare_html_response( $attribute->name ),
'slug' => $attribute->slug,
'taxonomy' => $attribute->slug,
'type' => $attribute->type,
'order' => $attribute->order_by,
'has_archives' => $attribute->has_archives,

View File

@ -177,12 +177,6 @@ class ProductSchema extends AbstractSchema {
'properties' => $this->image_attachment_schema->get_properties(),
],
],
'variations' => [
'description' => __( 'List of variation IDs, if applicable.', 'woo-gutenberg-products-block' ),
'type' => 'array',
'context' => [ 'view', 'edit' ],
'items' => 'number',
],
'categories' => [
'description' => __( 'List of categories, if applicable.', 'woo-gutenberg-products-block' ),
'type' => 'array',
@ -251,8 +245,108 @@ class ProductSchema extends AbstractSchema {
],
],
],
'attributes' => [
'description' => __( 'List of attributes assigned to the product/variation that are visible or used for variations.', 'woo-gutenberg-products-block' ),
'type' => 'array',
'context' => [ 'view', 'edit' ],
'items' => [
'type' => 'object',
'properties' => [
'id' => [
'description' => __( 'The attribute ID, or 0 if the attribute is not taxonomy based.', 'woo-gutenberg-products-block' ),
'type' => 'integer',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'name' => [
'description' => __( 'The attribute name.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'taxonomy' => [
'description' => __( 'The attribute taxonomy, or null if the attribute is not taxonomy based.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'has_variations' => [
'description' => __( 'True if this attribute is used by product variations.', 'woo-gutenberg-products-block' ),
'type' => 'boolean',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'terms' => [
'description' => __( 'List of assigned attribute terms.', 'woo-gutenberg-products-block' ),
'type' => 'array',
'context' => [ 'view', 'edit' ],
'items' => [
'type' => 'object',
'properties' => [
'id' => [
'description' => __( 'The term ID, or 0 if the attribute is not a global attribute.', 'woo-gutenberg-products-block' ),
'type' => 'integer',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'name' => [
'description' => __( 'The term name.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'slug' => [
'description' => __( 'The term slug.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
],
],
],
],
],
],
'variations' => [
'description' => __( 'List of variation IDs, if applicable.', 'woo-gutenberg-products-block' ),
'type' => 'array',
'context' => [ 'view', 'edit' ],
'items' => [
'type' => 'object',
'properties' => [
'id' => [
'description' => __( 'The attribute ID, or 0 if the attribute is not taxonomy based.', 'woo-gutenberg-products-block' ),
'type' => 'integer',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'attributes' => [
'description' => __( 'List of variation attributes.', 'woo-gutenberg-products-block' ),
'type' => 'array',
'context' => [ 'view', 'edit' ],
'items' => [
'type' => 'object',
'properties' => [
'name' => [
'description' => __( 'The attribute name.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
'value' => [
'description' => __( 'The assigned attribute.', 'woo-gutenberg-products-block' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
],
],
],
],
],
],
'has_options' => [
'description' => __( 'Does the product have options?', 'woo-gutenberg-products-block' ),
'description' => __( 'Does the product have additional options before it can be added to the cart?', 'woo-gutenberg-products-block' ),
'type' => 'boolean',
'context' => [ 'view', 'edit' ],
'readonly' => true,
@ -345,9 +439,10 @@ class ProductSchema extends AbstractSchema {
'average_rating' => $product->get_average_rating(),
'review_count' => $product->get_review_count(),
'images' => $this->get_images( $product ),
'variations' => $product->is_type( 'variable' ) ? $product->get_visible_children() : [],
'categories' => $this->get_term_list( $product, 'product_cat' ),
'tags' => $this->get_term_list( $product, 'product_tag' ),
'attributes' => $this->get_attributes( $product ),
'variations' => $this->get_variations( $product ),
'has_options' => $product->has_options(),
'is_purchasable' => $product->is_purchasable(),
'is_in_stock' => $product->is_in_stock(),
@ -424,6 +519,146 @@ class ProductSchema extends AbstractSchema {
return apply_filters( 'woocommerce_store_api_product_quantity_limit', max( min( array_filter( $limits ) ), 1 ), $product );
}
/**
* Returns true if the given attribute is valid.
*
* @param mixed $attribute Object or variable to check.
* @return boolean
*/
protected function filter_valid_attribute( $attribute ) {
return is_a( $attribute, '\WC_Product_Attribute' );
}
/**
* Returns true if the given attribute is valid and used for variations.
*
* @param mixed $attribute Object or variable to check.
* @return boolean
*/
protected function filter_variation_attribute( $attribute ) {
return $this->filter_valid_attribute( $attribute ) && $attribute->get_variation();
}
/**
* Get variation IDs and attributes from the DB.
*
* @param \WC_Product $product Product instance.
* @returns array
*/
protected function get_variations( \WC_Product $product ) {
if ( ! $product->is_type( 'variable' ) ) {
return [];
}
global $wpdb;
$variation_ids = $product->get_visible_children();
$attributes = array_filter( $product->get_attributes(), [ $this, 'filter_variation_attribute' ] );
$default_variation_meta_data = array_reduce(
$attributes,
function( $defaults, $attribute ) {
$meta_key = wc_variation_attribute_name( $attribute->get_name() );
$defaults[ $meta_key ] = [
'name' => wc_attribute_label( $attribute->get_name(), $product ),
'value' => null,
];
return $defaults;
},
[]
);
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
$variation_meta_data = $wpdb->get_results(
"
SELECT post_id as variation_id, meta_key as attribute_key, meta_value as attribute_value
FROM {$wpdb->postmeta}
WHERE post_id IN (" . implode( ',', array_map( 'esc_sql', $variation_ids ) ) . ")
AND meta_key IN ('" . implode( "','", array_map( 'esc_sql', array_keys( $default_variation_meta_data ) ) ) . "')
"
);
// phpcs:enable
$attributes_by_variation = array_reduce(
$variation_meta_data,
function( $values, $data ) {
$values[ $data->variation_id ][ $data->attribute_key ] = $data->attribute_value;
return $values;
},
array_fill_keys( $variation_ids, [] )
);
$variations = [];
foreach ( $variation_ids as $variation_id ) {
$attribute_data = $default_variation_meta_data;
foreach ( $attributes_by_variation[ $variation_id ] as $meta_key => $meta_value ) {
if ( '' !== $meta_value ) {
$attribute_data[ $meta_key ]['value'] = $meta_value;
}
}
$variations[] = (object) [
'id' => $variation_id,
'attributes' => array_values( $attribute_data ),
];
}
return $variations;
}
/**
* Get list of product attributes and attribute terms.
*
* @param \WC_Product $product Product instance.
* @return array
*/
protected function get_attributes( \WC_Product $product ) {
$attributes = array_filter( $product->get_attributes(), [ $this, 'filter_valid_attribute' ] );
$return = [];
foreach ( $attributes as $attribute_slug => $attribute ) {
// Only visible and variation attributes will be exposed by this API.
if ( ! $attribute->get_visible() || ! $attribute->get_variation() ) {
continue;
}
$return[] = (object) [
'id' => $attribute->get_id(),
'name' => wc_attribute_label( $attribute->get_name(), $product ),
'taxonomy' => $attribute->is_taxonomy() ? $attribute->get_name() : null,
'has_variations' => true === $attribute->get_variation(),
'terms' => $attribute->is_taxonomy() ? array_map( [ $this, 'prepare_product_attribute_taxonomy_value' ], $attribute->get_terms() ) : array_map( [ $this, 'prepare_product_attribute_value' ], $attribute->get_options() ),
];
}
return $return;
}
/**
* Prepare an attribute term for the response.
*
* @param \WP_Term $term Term object.
* @return object
*/
protected function prepare_product_attribute_taxonomy_value( \WP_Term $term ) {
return $this->prepare_product_attribute_value( $term->name, $term->term_id, $term->slug );
}
/**
* Prepare an attribute term for the response.
*
* @param string $name Attribute term name.
* @param int $id Attribute term ID.
* @param string $slug Attribute term slug.
* @return object
*/
protected function prepare_product_attribute_value( $name, $id = 0, $slug = '' ) {
return (object) [
'id' => (int) $id,
'name' => $name,
'slug' => $slug ? $slug : $name,
];
}
/**
* Get an array of pricing data.
*

View File

@ -51,7 +51,7 @@ class ProductAttributes extends TestCase {
$this->assertEquals( 200, $response->get_status() );
$this->assertEquals( $this->attributes[0]->id, $data['id'] );
$this->assertEquals( $this->attributes[0]->name, $data['name'] );
$this->assertEquals( $this->attributes[0]->slug, $data['slug'] );
$this->assertEquals( $this->attributes[0]->slug, $data['taxonomy'] );
$this->assertEquals( $this->attributes[0]->type, $data['type'] );
$this->assertEquals( $this->attributes[0]->order_by, $data['order'] );
$this->assertEquals( $this->attributes[0]->has_archives, $data['has_archives'] );
@ -68,7 +68,7 @@ class ProductAttributes extends TestCase {
$this->assertEquals( 2, count( $data ) );
$this->assertArrayHasKey( 'id', $data[0] );
$this->assertArrayHasKey( 'name', $data[0] );
$this->assertArrayHasKey( 'slug', $data[0] );
$this->assertArrayHasKey( 'taxonomy', $data[0] );
$this->assertArrayHasKey( 'type', $data[0] );
$this->assertArrayHasKey( 'order', $data[0] );
$this->assertArrayHasKey( 'has_archives', $data[0] );
@ -85,7 +85,7 @@ class ProductAttributes extends TestCase {
$this->assertArrayHasKey( 'id', $data );
$this->assertArrayHasKey( 'name', $data );
$this->assertArrayHasKey( 'slug', $data );
$this->assertArrayHasKey( 'taxonomy', $data );
$this->assertArrayHasKey( 'type', $data );
$this->assertArrayHasKey( 'order', $data );
$this->assertArrayHasKey( 'has_archives', $data );