Skip to content
This repository has been archived by the owner on Feb 23, 2024. It is now read-only.

Implement Hand-Picked Products block #7925

Merged
merged 10 commits into from
May 8, 2023
11 changes: 2 additions & 9 deletions assets/js/blocks/product-query/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* External dependencies
*/
import { getSetting } from '@woocommerce/settings';
import { objectOmit } from '@woocommerce/utils';
import type { InnerBlockTemplate } from '@wordpress/blocks';

/**
Expand All @@ -12,15 +13,6 @@ import { VARIATION_NAME as PRODUCT_TITLE_ID } from './variations/elements/produc
import { VARIATION_NAME as PRODUCT_TEMPLATE_ID } from './variations/elements/product-template';
import { ImageSizing } from '../../atomic/blocks/product-elements/image/types';

/**
* Returns an object without a key.
*/
function objectOmit< T, K extends keyof T >( obj: T, key: K ) {
const { [ key ]: omit, ...rest } = obj;

return rest;
}

Comment on lines -15 to -23
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great! Thanks for moving this to utils 🙌🏻

export const EDIT_ATTRIBUTES_URL =
'/wp-admin/edit.php?post_type=product&page=product_attributes';

Expand All @@ -31,6 +23,7 @@ export const DEFAULT_CORE_ALLOWED_CONTROLS = [ 'taxQuery', 'search' ];
export const ALL_PRODUCT_QUERY_CONTROLS = [
'attributes',
'presets',
'productSelector',
'onSale',
'stockStatus',
'wooInherit',
Expand Down
4 changes: 3 additions & 1 deletion assets/js/blocks/product-query/inspector-controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,9 @@ import {
QUERY_LOOP_ID,
STOCK_STATUS_OPTIONS,
} from './constants';
import { PopularPresets } from './inspector-controls/popular-presets';
import { AttributesFilter } from './inspector-controls/attributes-filter';
import { PopularPresets } from './inspector-controls/popular-presets';
import { ProductSelector } from './inspector-controls/product-selector';

import './editor.scss';

Expand Down Expand Up @@ -168,6 +169,7 @@ export const TOOLS_PANEL_CONTROLS = {
</ToolsPanelItem>
);
},
productSelector: ProductSelector,
stockStatus: ( props: ProductQueryBlock ) => {
const { query } = props.attributes;

Expand Down
103 changes: 103 additions & 0 deletions assets/js/blocks/product-query/inspector-controls/product-selector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* External dependencies
*/
import { getProducts } from '@woocommerce/editor-components/utils';
import { ProductResponseItem } from '@woocommerce/types';
import { objectOmit } from '@woocommerce/utils';
import { useState, useEffect } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import {
FormTokenField,
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalToolsPanelItem as ToolsPanelItem,
} from '@wordpress/components';

/**
* Internal dependencies
*/
import { ProductQueryBlock } from '../types';
import { setQueryAttribute } from '../utils';

export const ProductSelector = ( props: ProductQueryBlock ) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am nitpicking here, but how do you feel about restructuring the attributes property from the props object to simplify property access:

Suggested change
export const ProductSelector = ( props: ProductQueryBlock ) => {
export const ProductSelector = ({ attributes }: ProductQueryBlock) => {

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the suggestion! Normally, I'd be up for it, but in this case you can see that we use props as a whole twice on L76 and L85.

const { query } = props.attributes;

const [ productsList, setProductsList ] = useState< ProductResponseItem[] >(
[]
);

useEffect( () => {
getProducts( { selected: [] } ).then( ( results ) => {
setProductsList( results as ProductResponseItem[] );
} );
}, [] );
Comment on lines +22 to +30
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it's needed here but just wanted to share my thoughts. Maybe we can create a custom hook to fetch and manage the productsList state to separate concerns and make the component more modular:

const useProductsList = (): ProductResponseItem[] => {
  const [productsList, setProductsList] = useState<ProductResponseItem[]>([]);

  useEffect(() => {
    getProducts({ selected: [] }).then((results) => {
      setProductsList(results as ProductResponseItem[]);
    });
  }, []);

  return productsList;
};

And use it in the component like this:

const productsList = useProductsList();


return (
<ToolsPanelItem
label={ __(
'Hand-picked Products',
'woo-gutenberg-products-block'
) }
hasValue={ () => query.include?.length }
>
<FormTokenField
disabled={ ! productsList.length }
displayTransform={ ( token: string ) =>
Number.isNaN( Number( token ) )
? token
: productsList.find(
( product ) => product.id === Number( token )
)?.name || ''
}
label={ __(
'Pick some products',
'woo-gutenberg-products-block'
) }
onChange={ ( values ) => {
const ids = values
.map(
( nameOrId ) =>
productsList.find(
( product ) =>
product.name === nameOrId ||
product.id === Number( nameOrId )
)?.id
)
.filter( Boolean )
.map( String );

if ( ! ids.length && props.attributes.query.include ) {
const prunedQuery = objectOmit(
props.attributes.query,
'include'
);

setQueryAttribute(
{
...props,
attributes: {
...props.attributes,
query: prunedQuery,
},
},
{}
);
} else {
setQueryAttribute( props, {
include: ids,
} );
}
} }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can extract the logic of the onChange callback into a separate function to make the component more readable and maintainable?

const handleTokensChange = (values: string[]) => {
  ...
};

...
<FormTokenField
  ...
  onChange={handleTokensChange}
  ...
/>

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea good call.

suggestions={ productsList.map( ( product ) => product.name ) }
validateInput={ ( value: string ) =>
productsList.find( ( product ) => product.name === value )
}
value={
! productsList.length
? [ __( 'Loading…', 'woo-gutenberg-products-block' ) ]
: query?.include || []
}
__experimentalExpandOnFocus={ true }
/>
</ToolsPanelItem>
);
};
1 change: 1 addition & 0 deletions assets/js/blocks/product-query/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export interface QueryBlockAttributes {
export interface QueryBlockQuery {
author?: string;
exclude?: string[];
include?: string[];
inherit: boolean;
offset?: number;
order: 'asc' | 'desc';
Expand Down
1 change: 1 addition & 0 deletions assets/js/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export * from './attributes-query';
export * from './attributes';
export * from './filters';
export * from './notices';
export * from './object-operations';
export * from './products';
export * from './shared-attributes';
export * from './sanitize-html';
8 changes: 8 additions & 0 deletions assets/js/utils/object-operations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* Returns an object without a key.
*/
export function objectOmit< T, K extends keyof T >( obj: T, key: K ) {
const { [ key ]: omit, ...rest } = obj;

return rest;
}
32 changes: 27 additions & 5 deletions src/BlockTypes/ProductQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -165,18 +165,21 @@ public function build_query( $query ) {
}

$common_query_values = array(
'post_type' => 'product',
'post__in' => array(),
'post_status' => 'publish',
'meta_query' => array(),
'posts_per_page' => $query['posts_per_page'],
'orderby' => $query['orderby'],
'order' => $query['order'],
'offset' => $query['offset'],
'meta_query' => array(),
'post__in' => array(),
'post_status' => 'publish',
'post_type' => 'product',
'tax_query' => array(),
);

return $this->merge_queries(
$handpicked_products = isset( $parsed_block['attrs']['query']['include'] ) ?
$parsed_block['attrs']['query']['include'] : $common_query_values['post__in'];

$merged_query = $this->merge_queries(
$common_query_values,
$this->get_global_query( $parsed_block ),
$this->get_custom_orderby_query( $query['orderby'] ),
Expand All @@ -185,6 +188,8 @@ public function build_query( $query ) {
$this->get_filter_by_taxonomies_query( $query ),
$this->get_filter_by_keyword_query( $query )
);

return $this->filter_query_to_only_include_ids( $merged_query, $handpicked_products );
}

/**
Expand Down Expand Up @@ -307,6 +312,23 @@ private function get_custom_orderby_query( $orderby ) {
);
}

/**
* Apply the query only to a subset of products
*
* @param array $query The query.
* @param array $ids Array of selected product ids.
*
* @return array
*/
private function filter_query_to_only_include_ids( $query, $ids ) {
if ( ! empty( $ids ) ) {
$query['post__in'] = empty( $query['post__in'] ) ?
$ids : array_intersect( $ids, $query['post__in'] );
}

return $query;
}

/**
* Return the `tax_query` for the requested attributes
*
Expand Down