Add support for local variation attributes

In WooCommerce it's possible to create attributes that are local to a
specific product. When these attributes are used to generate variations,
 they are stored in the postmeta table.

MAILPOET-5467
This commit is contained in:
John Oleksowicz
2024-02-16 11:55:28 -06:00
committed by Aschepikov
parent 24e38d6752
commit a4a2ef3b1f
10 changed files with 521 additions and 90 deletions

View File

@@ -9,6 +9,7 @@ import {
AnyValueTypes, AnyValueTypes,
FilterProps, FilterProps,
SelectOption, SelectOption,
WindowLocalProductAttributes,
WindowProductAttributes, WindowProductAttributes,
WooCommerceFormItem, WooCommerceFormItem,
} from '../../../types'; } from '../../../types';
@@ -18,9 +19,15 @@ export function validatePurchasedWithAttribute(
): boolean { ): boolean {
const purchasedProductWithAttributeIsInvalid = const purchasedProductWithAttributeIsInvalid =
!formItems.operator || !formItems.operator ||
formItems.attribute_taxonomy_slug === undefined || (formItems.attribute_type === 'taxonomy' &&
!Array.isArray(formItems.attribute_term_ids) === undefined || (formItems.attribute_taxonomy_slug === undefined ||
formItems.attribute_term_ids.length === 0; !Array.isArray(formItems.attribute_term_ids) ||
formItems.attribute_term_ids.length === 0)) ||
(formItems.attribute_type === 'local' &&
(!formItems.attribute_local_name ||
formItems.attribute_local_name.length === 0 ||
!Array.isArray(formItems.attribute_local_values) ||
formItems.attribute_local_values.length === 0));
return !purchasedProductWithAttributeIsInvalid; return !purchasedProductWithAttributeIsInvalid;
} }
@@ -46,21 +53,61 @@ export function PurchasedWithAttributeFields({
}), }),
); );
const localProductAttributes: WindowLocalProductAttributes = useSelect(
(select) => select(storeName).getLocalProductAttributes(),
[],
);
const localAttributeOptions = Object.values(localProductAttributes).map(
(attribute) => ({
// Appending @local to avoid conflicts between taxonomy and local attributes with the same name
value: `${attribute.name}@local`,
label: attribute.name,
}),
);
const localAttributeValues = Object.values(localAttributeOptions).map(
(option) => option.value,
);
const combinedOptions = [
...productAttributesOptions,
...localAttributeOptions,
];
const productAttributeTermsOptionsRef = useRef(null); const productAttributeTermsOptionsRef = useRef(null);
useEffect(() => { useEffect(() => {
if (segment.attribute_taxonomy_slug === undefined) { if (
segment.attribute_taxonomy_slug === undefined &&
segment.attribute_local_name === undefined
) {
productAttributeTermsOptionsRef.current = null; productAttributeTermsOptionsRef.current = null;
return; return;
} }
productAttributeTermsOptionsRef.current = productAttributes[ if (segment.attribute_type === 'taxonomy') {
segment.attribute_taxonomy_slug productAttributeTermsOptionsRef.current = productAttributes[
].terms.map((term) => ({ segment.attribute_taxonomy_slug
value: term.term_id.toString(), ].terms.map((term) => ({
label: term.name, value: term.term_id.toString(),
})); label: term.name,
}, [segment.attribute_taxonomy_slug, productAttributes]); }));
} else if (segment.attribute_type === 'local') {
productAttributeTermsOptionsRef.current = localProductAttributes[
segment.attribute_local_name
].values.map((value) => ({
value,
label: value,
}));
}
}, [
segment.attribute_taxonomy_slug,
segment.attribute_type,
segment.attribute_local_name,
productAttributes,
localProductAttributes,
]);
useEffect(() => { useEffect(() => {
if ( if (
@@ -90,23 +137,52 @@ export function PurchasedWithAttributeFields({
dimension="small" dimension="small"
key="select-segment-product-attribute" key="select-segment-product-attribute"
placeholder={__('Search attributes', 'mailpoet')} placeholder={__('Search attributes', 'mailpoet')}
options={productAttributesOptions} options={combinedOptions}
value={filter((productAttributeOption) => { value={
if (segment.attribute_taxonomy_slug === undefined) { segment.attribute_type === 'local'
return undefined; ? filter((localAttributeOption) => {
} if (!segment.attribute_local_name) {
return ( return undefined;
segment.attribute_taxonomy_slug === productAttributeOption.value }
); return (
}, productAttributesOptions)} `${segment.attribute_local_name}@local` ===
localAttributeOption.value
);
}, localAttributeOptions)
: filter((productAttributeOption) => {
if (segment.attribute_taxonomy_slug === undefined) {
return undefined;
}
return (
segment.attribute_taxonomy_slug ===
productAttributeOption.value
);
}, productAttributesOptions)
}
onChange={(option: SelectOption): void => { onChange={(option: SelectOption): void => {
void updateSegmentFilter( if (localAttributeValues.includes(option.value)) {
{ void updateSegmentFilter(
attribute_taxonomy_slug: option.value, {
attribute_term_ids: [], attribute_type: 'local',
}, attribute_local_name: option.value.replace(/@local$/, ''),
filterIndex, attribute_local_values: [],
); attribute_taxonomy_slug: null,
attribute_term_ids: null,
},
filterIndex,
);
} else {
void updateSegmentFilter(
{
attribute_type: 'taxonomy',
attribute_local_name: null,
attribute_local_values: null,
attribute_taxonomy_slug: option.value,
attribute_term_ids: [],
},
filterIndex,
);
}
}} }}
/> />
{productAttributeTermsOptionsRef.current && ( {productAttributeTermsOptionsRef.current && (
@@ -118,26 +194,46 @@ export function PurchasedWithAttributeFields({
options={productAttributeTermsOptionsRef.current} options={productAttributeTermsOptionsRef.current}
value={filter( value={filter(
(productAttributeTermOption: { value: string; label: string }) => { (productAttributeTermOption: { value: string; label: string }) => {
if (segment.attribute_term_ids === undefined) { if (segment.attribute_local_values) {
return undefined; return (
segment.attribute_local_values.indexOf(
productAttributeTermOption.value,
) !== -1
);
} }
return ( if (segment.attribute_term_ids) {
segment.attribute_term_ids.indexOf( return (
productAttributeTermOption.value, segment.attribute_term_ids.indexOf(
) !== -1 productAttributeTermOption.value,
); ) !== -1
);
}
return undefined;
}, },
productAttributeTermsOptionsRef.current, productAttributeTermsOptionsRef.current,
)} )}
onChange={(options: SelectOption[]): void => { onChange={(options: SelectOption[]): void => {
void updateSegmentFilter( if (segment.attribute_type === 'local') {
{ void updateSegmentFilter(
attribute_term_ids: (options || []).map( {
(x: SelectOption) => x.value, attribute_term_ids: null,
), attribute_local_values: (options || []).map(
}, (x: SelectOption) => x.value,
filterIndex, ),
); },
filterIndex,
);
} else {
void updateSegmentFilter(
{
attribute_term_ids: (options || []).map(
(x: SelectOption) => x.value,
),
attribute_local_values: null,
},
filterIndex,
);
}
}} }}
/> />
)} )}

View File

@@ -28,6 +28,7 @@ export const getInitialState = (): StateType => ({
membershipPlans: window.mailpoet_membership_plans, membershipPlans: window.mailpoet_membership_plans,
subscriptionProducts: window.mailpoet_subscription_products, subscriptionProducts: window.mailpoet_subscription_products,
productAttributes: window.mailpoet_product_attributes, productAttributes: window.mailpoet_product_attributes,
localProductAttributes: window.mailpoet_local_product_attributes,
productCategories: window.mailpoet_product_categories, productCategories: window.mailpoet_product_categories,
newslettersList: window.mailpoet_newsletters_list, newslettersList: window.mailpoet_newsletters_list,
wordpressRoles: window.wordpress_editable_roles_list, wordpressRoles: window.wordpress_editable_roles_list,

View File

@@ -12,6 +12,7 @@ import {
Tag, Tag,
WindowCustomFields, WindowCustomFields,
WindowEditableRoles, WindowEditableRoles,
WindowLocalProductAttributes,
WindowMembershipPlans, WindowMembershipPlans,
WindowNewslettersList, WindowNewslettersList,
WindowProductAttributes, WindowProductAttributes,
@@ -34,6 +35,9 @@ export const getWordpressRoles = (state: StateType): WindowEditableRoles =>
export const getProductAttributes = ( export const getProductAttributes = (
state: StateType, state: StateType,
): WindowProductAttributes => state.productAttributes; ): WindowProductAttributes => state.productAttributes;
export const getLocalProductAttributes = (
state: StateType,
): WindowLocalProductAttributes => state.localProductAttributes;
export const getProductCategories = ( export const getProductCategories = (
state: StateType, state: StateType,
): WindowProductCategories => state.productCategories; ): WindowProductCategories => state.productCategories;

View File

@@ -152,8 +152,11 @@ export interface WooCommerceFormItem extends FormItem {
count?: string; count?: string;
days?: string; days?: string;
coupon_code_ids?: string[]; coupon_code_ids?: string[];
attribute_type?: 'local' | 'taxonomy';
attribute_taxonomy_slug?: string; attribute_taxonomy_slug?: string;
attribute_term_ids?: string[]; attribute_term_ids?: string[];
attribute_local_name?: string;
attribute_local_values?: string[];
} }
export interface AutomationsFormItem extends FormItem { export interface AutomationsFormItem extends FormItem {
@@ -235,6 +238,11 @@ export type WindowProductAttributes = {
terms: []; terms: [];
}[]; }[];
export type WindowLocalProductAttributes = {
name: string;
values: string[];
}[];
export type WindowProductCategories = { export type WindowProductCategories = {
id: string; id: string;
name: string; name: string;
@@ -274,6 +282,7 @@ export interface SegmentFormDataWindow extends Window {
mailpoet_membership_plans: WindowMembershipPlans; mailpoet_membership_plans: WindowMembershipPlans;
mailpoet_subscription_products: WindowSubscriptionProducts; mailpoet_subscription_products: WindowSubscriptionProducts;
mailpoet_product_attributes: WindowProductAttributes; mailpoet_product_attributes: WindowProductAttributes;
mailpoet_local_product_attributes: WindowLocalProductAttributes;
mailpoet_product_categories: WindowProductCategories; mailpoet_product_categories: WindowProductCategories;
mailpoet_woocommerce_countries: WindowWooCommerceCountries; mailpoet_woocommerce_countries: WindowWooCommerceCountries;
mailpoet_woocommerce_payment_methods: WooPaymentMethod[]; mailpoet_woocommerce_payment_methods: WooPaymentMethod[];
@@ -295,6 +304,7 @@ export interface StateType {
subscriptionProducts: WindowSubscriptionProducts; subscriptionProducts: WindowSubscriptionProducts;
wordpressRoles: WindowEditableRoles; wordpressRoles: WindowEditableRoles;
productAttributes: WindowProductAttributes; productAttributes: WindowProductAttributes;
localProductAttributes: WindowLocalProductAttributes;
productCategories: WindowProductCategories; productCategories: WindowProductCategories;
newslettersList: WindowNewslettersList; newslettersList: WindowNewslettersList;
canUseWooMemberships: boolean; canUseWooMemberships: boolean;

View File

@@ -148,6 +148,16 @@ class DynamicSegments {
]; ];
} }
} }
// Fetch local attributes used for product variations
$data['local_product_attributes'] = [];
$localAttributes = $this->getLocalAttributesUsedInProductVariations();
foreach ($localAttributes as $localAttribute => $values) {
$data['local_product_attributes'][$localAttribute] = [
'name' => $localAttribute,
'values' => $values,
];
}
} }
$data['product_categories'] = $this->wpPostListLoader->getWooCommerceCategories(); $data['product_categories'] = $this->wpPostListLoader->getWooCommerceCategories();
@@ -204,6 +214,35 @@ class DynamicSegments {
$this->pageRenderer->displayPage('segments/dynamic.html', $data); $this->pageRenderer->displayPage('segments/dynamic.html', $data);
} }
private function getLocalAttributesUsedInProductVariations(): array {
$attributes = [];
if (!$this->woocommerceHelper->isWooCommerceActive()) {
return $attributes;
}
global $wpdb;
$query = "
SELECT DISTINCT pm.meta_key, pm.meta_value
FROM {$wpdb->postmeta} pm
INNER JOIN {$wpdb->posts} p ON pm.post_id = p.ID
WHERE pm.meta_key LIKE 'attribute_%'
AND p.post_type = 'product_variation'
GROUP BY pm.meta_key, pm.meta_value";
$results = $wpdb->get_results($query, ARRAY_A);
foreach ($results as $result) {
$attribute = substr($result['meta_key'], 10);
if (!isset($attributes[$attribute])) {
$attributes[$attribute] = [];
}
$attributes[$attribute][] = $result['meta_value'];
}
return $attributes;
}
private function getNewslettersList(): array { private function getNewslettersList(): array {
$result = []; $result = [];
foreach ($this->newslettersRepository->getStandardNewsletterList() as $newsletter) { foreach ($this->newslettersRepository->getStandardNewsletterList() as $newsletter) {

View File

@@ -519,6 +519,9 @@ class FilterDataMapper {
$filterData['operator'] = $data['operator']; $filterData['operator'] = $data['operator'];
$filterData['attribute_taxonomy_slug'] = $data['attribute_taxonomy_slug']; $filterData['attribute_taxonomy_slug'] = $data['attribute_taxonomy_slug'];
$filterData['attribute_term_ids'] = $data['attribute_term_ids']; $filterData['attribute_term_ids'] = $data['attribute_term_ids'];
$filterData['attribute_type'] = $data['attribute_type'];
$filterData['attribute_local_name'] = $data['attribute_local_name'];
$filterData['attribute_local_values'] = $data['attribute_local_values'];
} else { } else {
throw new InvalidFilterException("Unknown action " . $data['action'], InvalidFilterException::MISSING_ACTION); throw new InvalidFilterException("Unknown action " . $data['action'], InvalidFilterException::MISSING_ACTION);
} }

View File

@@ -12,6 +12,9 @@ use MailPoetVendor\Doctrine\DBAL\Query\QueryBuilder;
class WooCommercePurchasedWithAttribute implements Filter { class WooCommercePurchasedWithAttribute implements Filter {
const ACTION = 'purchasedWithAttribute'; const ACTION = 'purchasedWithAttribute';
const TYPE_LOCAL = 'local';
const TYPE_TAXONOMY = 'taxonomy';
private WooFilterHelper $wooFilterHelper; private WooFilterHelper $wooFilterHelper;
private FilterHelper $filterHelper; private FilterHelper $filterHelper;
@@ -32,34 +35,24 @@ class WooCommercePurchasedWithAttribute implements Filter {
$filterData = $filter->getFilterData(); $filterData = $filter->getFilterData();
$this->validateFilterData((array)$filterData->getData()); $this->validateFilterData((array)$filterData->getData());
$operator = $filterData->getOperator(); $type = $filterData->getStringParam('attribute_type');
$attributeTaxonomySlug = $filterData->getStringParam('attribute_taxonomy_slug');
$attributeTermIds = $filterData->getArrayParam('attribute_term_ids');
if ($operator === DynamicSegmentFilterData::OPERATOR_ANY) { if ($type === self::TYPE_LOCAL) {
$this->applyForAnyOperator($queryBuilder, $attributeTaxonomySlug, $attributeTermIds); $this->applyForLocalAttribute($queryBuilder, $filterData);
} elseif ($operator === DynamicSegmentFilterData::OPERATOR_ALL) { } elseif ($type === self::TYPE_TAXONOMY) {
$this->applyForAnyOperator($queryBuilder, $attributeTaxonomySlug, $attributeTermIds); $this->applyForTaxonomyAttribute($queryBuilder, $filterData);
$countParam = $this->filterHelper->getUniqueParameterName('count');
$queryBuilder
->groupBy('inner_subscriber_id')
->having("COUNT(DISTINCT attribute.term_id) = :$countParam")
->setParameter($countParam, count($attributeTermIds));
} elseif ($operator === DynamicSegmentFilterData::OPERATOR_NONE) {
$subQuery = $this->filterHelper->getNewSubscribersQueryBuilder();
$this->applyForAnyOperator($subQuery, $attributeTaxonomySlug, $attributeTermIds);
$subscribersTable = $this->filterHelper->getSubscribersTable();
$queryBuilder->where("{$subscribersTable}.id NOT IN ({$this->filterHelper->getInterpolatedSQL($subQuery)})");
} }
return $queryBuilder; return $queryBuilder;
} }
private function applyForAnyOperator(QueryBuilder $queryBuilder, string $attributeTaxonomySlug, array $attributeTermIds): void { private function applyForTaxonomyAnyOperator(QueryBuilder $queryBuilder, DynamicSegmentFilterData $filterData): void {
$attributeTaxonomySlug = $filterData->getStringParam('attribute_taxonomy_slug');
$attributeTermIds = $filterData->getArrayParam('attribute_term_ids');
$termIdsParam = $this->filterHelper->getUniqueParameterName('attribute_term_ids'); $termIdsParam = $this->filterHelper->getUniqueParameterName('attribute_term_ids');
$orderStatsAlias = $this->wooFilterHelper->applyOrderStatusFilter($queryBuilder); $orderStatsAlias = $this->wooFilterHelper->applyOrderStatusFilter($queryBuilder);
$productAlias = $this->applyProductJoin($queryBuilder, $orderStatsAlias); $productAlias = $this->applyProductJoin($queryBuilder, $orderStatsAlias);
$attributeAlias = $this->applyAttributeJoin($queryBuilder, $productAlias, $attributeTaxonomySlug); $attributeAlias = $this->applyTaxonomyAttributeJoin($queryBuilder, $productAlias, $attributeTaxonomySlug);
$queryBuilder->andWhere("$attributeAlias.term_id IN (:$termIdsParam)"); $queryBuilder->andWhere("$attributeAlias.term_id IN (:$termIdsParam)");
$queryBuilder->setParameter($termIdsParam, $attributeTermIds, Connection::PARAM_STR_ARRAY); $queryBuilder->setParameter($termIdsParam, $attributeTermIds, Connection::PARAM_STR_ARRAY);
} }
@@ -74,7 +67,7 @@ class WooCommercePurchasedWithAttribute implements Filter {
return $alias; return $alias;
} }
private function applyAttributeJoin(QueryBuilder $queryBuilder, string $productAlias, $taxonomySlug, string $alias = 'attribute'): string { private function applyTaxonomyAttributeJoin(QueryBuilder $queryBuilder, string $productAlias, $taxonomySlug, string $alias = 'attribute'): string {
$queryBuilder->innerJoin( $queryBuilder->innerJoin(
$productAlias, $productAlias,
$this->filterHelper->getPrefixedTable('wc_product_attributes_lookup'), $this->filterHelper->getPrefixedTable('wc_product_attributes_lookup'),
@@ -86,6 +79,12 @@ class WooCommercePurchasedWithAttribute implements Filter {
} }
public function getLookupData(DynamicSegmentFilterData $filterData): array { public function getLookupData(DynamicSegmentFilterData $filterData): array {
$type = $filterData->getStringParam('attribute_type');
if ($type !== self::TYPE_TAXONOMY) {
return [];
}
$slug = $filterData->getStringParam('attribute_taxonomy_slug'); $slug = $filterData->getStringParam('attribute_taxonomy_slug');
$lookupData = [ $lookupData = [
@@ -117,12 +116,93 @@ class WooCommercePurchasedWithAttribute implements Filter {
) { ) {
throw new InvalidFilterException('Missing operator', InvalidFilterException::MISSING_OPERATOR); throw new InvalidFilterException('Missing operator', InvalidFilterException::MISSING_OPERATOR);
} }
$attribute_taxonomy_slug = $data['attribute_taxonomy_slug'] ?? null; $this->validateAttributeData($data);
if (!is_string($attribute_taxonomy_slug) || strlen($attribute_taxonomy_slug) === 0) { }
throw new InvalidFilterException('Missing attribute', InvalidFilterException::MISSING_VALUE);
public function validateAttributeData(array $data): void {
$type = $data['attribute_type'];
if (!in_array($type, [self::TYPE_LOCAL, self::TYPE_TAXONOMY], true)) {
throw new InvalidFilterException('Invalid attribute type', InvalidFilterException::INVALID_TYPE);
} }
if (!isset($data['attribute_term_ids']) || !is_array($data['attribute_term_ids']) || count($data['attribute_term_ids']) === 0) {
throw new InvalidFilterException('Missing attribute terms', InvalidFilterException::MISSING_VALUE); if ($type === self::TYPE_LOCAL) {
$name = $data['attribute_local_name'] ?? null;
if (!is_string($name) || strlen($name) === 0) {
throw new InvalidFilterException('Missing attribute', InvalidFilterException::MISSING_VALUE);
}
$values = $data['attribute_local_values'] ?? [];
if (!is_array($values) || count($values) === 0) {
throw new InvalidFilterException('Missing attribute values', InvalidFilterException::MISSING_VALUE);
}
}
if ($type === self::TYPE_TAXONOMY) {
$attribute_taxonomy_slug = $data['attribute_taxonomy_slug'] ?? null;
if (!is_string($attribute_taxonomy_slug) || strlen($attribute_taxonomy_slug) === 0) {
throw new InvalidFilterException('Missing attribute', InvalidFilterException::MISSING_VALUE);
}
if (!isset($data['attribute_term_ids']) || !is_array($data['attribute_term_ids']) || count($data['attribute_term_ids']) === 0) {
throw new InvalidFilterException('Missing attribute terms', InvalidFilterException::MISSING_VALUE);
}
} }
} }
private function applyForTaxonomyAttribute(QueryBuilder $queryBuilder, DynamicSegmentFilterData $filterData) {
$operator = $filterData->getOperator();
if ($operator === DynamicSegmentFilterData::OPERATOR_ANY) {
$this->applyForTaxonomyAnyOperator($queryBuilder, $filterData);
} elseif ($operator === DynamicSegmentFilterData::OPERATOR_ALL) {
$this->applyForTaxonomyAnyOperator($queryBuilder, $filterData);
$countParam = $this->filterHelper->getUniqueParameterName('count');
$queryBuilder
->groupBy('inner_subscriber_id')
->having("COUNT(DISTINCT attribute.term_id) = :$countParam")
->setParameter($countParam, count($filterData->getArrayParam('attribute_term_ids')));
} elseif ($operator === DynamicSegmentFilterData::OPERATOR_NONE) {
$subQuery = $this->filterHelper->getNewSubscribersQueryBuilder();
$this->applyForTaxonomyAnyOperator($subQuery, $filterData);
$subscribersTable = $this->filterHelper->getSubscribersTable();
$queryBuilder->where("{$subscribersTable}.id NOT IN ({$this->filterHelper->getInterpolatedSQL($subQuery)})");
}
}
private function applyForLocalAttribute(QueryBuilder $queryBuilder, DynamicSegmentFilterData $filterData): void {
$operator = $filterData->getOperator();
if ($operator === DynamicSegmentFilterData::OPERATOR_ANY) {
$this->applyForLocalAnyAttribute($queryBuilder, $filterData);
} elseif ($operator === DynamicSegmentFilterData::OPERATOR_ALL) {
$this->applyForLocalAnyAttribute($queryBuilder, $filterData);
$countParam = $this->filterHelper->getUniqueParameterName('count');
$queryBuilder
->groupBy('inner_subscriber_id')
->having("COUNT(DISTINCT postmeta.meta_value) = :$countParam")
->setParameter($countParam, count($filterData->getArrayParam('attribute_local_values')));
} elseif ($operator === DynamicSegmentFilterData::OPERATOR_NONE) {
$subQuery = $this->filterHelper->getNewSubscribersQueryBuilder();
$this->applyForLocalAnyAttribute($subQuery, $filterData);
$subscribersTable = $this->filterHelper->getSubscribersTable();
$queryBuilder->where("{$subscribersTable}.id NOT IN ({$this->filterHelper->getInterpolatedSQL($subQuery)})");
}
}
private function applyForLocalAnyAttribute(QueryBuilder $queryBuilder, DynamicSegmentFilterData $filterData): void {
$attributeName = $filterData->getStringParam('attribute_local_name');
$attributeValues = $filterData->getArrayParam('attribute_local_values');
$valuesParam = $this->filterHelper->getUniqueParameterName('attribute_values');
$keyParam = $this->filterHelper->getUniqueParameterName('attribute_name');
$orderStatsAlias = $this->wooFilterHelper->applyOrderStatusFilter($queryBuilder);
$productAlias = $this->applyProductJoin($queryBuilder, $orderStatsAlias);
$queryBuilder->innerJoin(
$productAlias,
$this->filterHelper->getPrefixedTable('postmeta'),
'postmeta',
"$productAlias.product_id = postmeta.post_id AND postmeta.meta_key = :$keyParam AND postmeta.meta_value IN (:$valuesParam)"
);
$queryBuilder->setParameter($keyParam, sprintf("attribute_%s", $attributeName));
$queryBuilder->setParameter($valuesParam, $attributeValues, Connection::PARAM_STR_ARRAY);
}
} }

View File

@@ -161,6 +161,12 @@ class IntegrationTester extends \Codeception\Actor {
do_action('woocommerce_run_product_attribute_lookup_update_callback', $product->get_id(), 1); do_action('woocommerce_run_product_attribute_lookup_update_callback', $product->get_id(), 1);
} }
if (isset($data['local_attributes'])) {
foreach ($data['local_attributes'] as $name => $value) {
update_post_meta($product->get_id(), sprintf('attribute_%s', $name), $value);
}
}
$this->wooProductIds[] = $product->get_id(); $this->wooProductIds[] = $product->get_id();
return $product; return $product;
} }

View File

@@ -11,14 +11,13 @@ use MailPoet\Segments\DynamicSegments\Filters\WooCommercePurchasedWithAttribute;
*/ */
class WooCommercePurchasedWithAttributeTest extends \MailPoetTest { class WooCommercePurchasedWithAttributeTest extends \MailPoetTest {
private WooCommercePurchasedWithAttribute $filter; private WooCommercePurchasedWithAttribute $filter;
public function _before(): void { public function _before(): void {
$this->filter = $this->diContainer->get(WooCommercePurchasedWithAttribute::class); $this->filter = $this->diContainer->get(WooCommercePurchasedWithAttribute::class);
} }
public function testItWorksWithAnyOperator(): void { public function testItWorksWithAnyOperatorForTaxonomies(): void {
$product1 = $this->tester->createWooCommerceProduct([ $product1 = $this->tester->createWooCommerceProduct([
'price' => 20, 'price' => 20,
'attributes' => [ 'attributes' => [
@@ -41,10 +40,10 @@ class WooCommercePurchasedWithAttributeTest extends \MailPoetTest {
$this->createOrder($customer1, [$product1]); $this->createOrder($customer1, [$product1]);
$this->createOrder($customer2, [$product2]); $this->createOrder($customer2, [$product2]);
$this->assertFilterReturnsEmails('any', 'pa_color', [$blueTermId, $redTermId], ['customer1@example.com', 'customer2@example.com']); $this->assertFilterReturnsEmailsForTaxonomyAttributes('any', 'pa_color', [$blueTermId, $redTermId], ['customer1@example.com', 'customer2@example.com']);
} }
public function testItWorksWithNoneOperator(): void { public function testItWorksWithNoneOperatorForTaxonomies(): void {
$product1 = $this->tester->createWooCommerceProduct([ $product1 = $this->tester->createWooCommerceProduct([
'price' => 20, 'price' => 20,
'attributes' => [ 'attributes' => [
@@ -67,10 +66,10 @@ class WooCommercePurchasedWithAttributeTest extends \MailPoetTest {
$this->createOrder($customer1, [$product1]); $this->createOrder($customer1, [$product1]);
$this->createOrder($customer2, [$product2]); $this->createOrder($customer2, [$product2]);
$this->assertFilterReturnsEmails('none', 'pa_color', [$blueTermId, $redTermId], ['customer3@example.com']); $this->assertFilterReturnsEmailsForTaxonomyAttributes('none', 'pa_color', [$blueTermId, $redTermId], ['customer3@example.com']);
} }
public function testItWorksWithAllOperator(): void { public function testItWorksWithAllOperatorForTaxonomies(): void {
$product1 = $this->tester->createWooCommerceProduct([ $product1 = $this->tester->createWooCommerceProduct([
'price' => 20, 'price' => 20,
'attributes' => [ 'attributes' => [
@@ -93,7 +92,88 @@ class WooCommercePurchasedWithAttributeTest extends \MailPoetTest {
$this->createOrder($customer1, [$product1, $product2]); $this->createOrder($customer1, [$product1, $product2]);
$this->createOrder($customer2, [$product2]); $this->createOrder($customer2, [$product2]);
$this->assertFilterReturnsEmails('all', 'pa_color', [$blueTermId, $redTermId], ['customer1@example.com']); $this->assertFilterReturnsEmailsForTaxonomyAttributes('all', 'pa_color', [$blueTermId, $redTermId], ['customer1@example.com']);
}
public function testItWorksWithAnyOperatorForLocalAttributes(): void {
$product1 = $this->tester->createWooCommerceProduct([
'price' => 20,
'local_attributes' => [
'color' => 'red',
],
]);
$product2 = $this->tester->createWooCommerceProduct([
'price' => 20,
'local_attributes' => [
'color' => 'blue',
],
]);
$customer1 = $this->tester->createCustomer('customer1@example.com');
$customer2 = $this->tester->createCustomer('customer2@example.com');
$customer3 = $this->tester->createCustomer('customer3@example.com');
$this->createOrder($customer1, [$product1]);
$this->createOrder($customer2, [$product2]);
$this->assertFilterReturnsEmailsForLocalAttributes('any', 'color', ['red', 'blue'], ['customer1@example.com', 'customer2@example.com']);
$this->assertFilterReturnsEmailsForLocalAttributes('any', 'color', ['red'], ['customer1@example.com']);
$this->assertFilterReturnsEmailsForLocalAttributes('any', 'color', ['blue'], ['customer2@example.com']);
}
public function testItWorksWithAllOperatorForLocalAttributes(): void {
$product1 = $this->tester->createWooCommerceProduct([
'price' => 20,
'local_attributes' => [
'color' => 'red',
],
]);
$product2 = $this->tester->createWooCommerceProduct([
'price' => 20,
'local_attributes' => [
'color' => 'blue',
],
]);
$customer1 = $this->tester->createCustomer('customer1@example.com');
$customer2 = $this->tester->createCustomer('customer2@example.com');
$customer3 = $this->tester->createCustomer('customer3@example.com');
$this->createOrder($customer1, [$product1, $product2]);
$this->createOrder($customer2, [$product1]);
$this->createOrder($customer3, [$product2]);
$this->assertFilterReturnsEmailsForLocalAttributes('all', 'color', ['red', 'blue'], ['customer1@example.com']);
$this->assertFilterReturnsEmailsForLocalAttributes('all', 'color', ['red'], ['customer1@example.com', 'customer2@example.com']);
$this->assertFilterReturnsEmailsForLocalAttributes('all', 'color', ['blue'], ['customer1@example.com', 'customer3@example.com']);
}
public function testItWorksWithNoneOperatorForLocalAttributes(): void {
$redProduct = $this->tester->createWooCommerceProduct([
'price' => 20,
'local_attributes' => [
'color' => 'red',
],
]);
$blueProduct = $this->tester->createWooCommerceProduct([
'price' => 20,
'local_attributes' => [
'color' => 'blue',
],
]);
$customer1 = $this->tester->createCustomer('customer1@example.com');
$customer2 = $this->tester->createCustomer('customer2@example.com');
$customer3 = $this->tester->createCustomer('customer3@example.com');
$customer4 = $this->tester->createCustomer('customer4@example.com');
$this->createOrder($customer1, [$redProduct, $blueProduct]);
$this->createOrder($customer2, [$redProduct]);
$this->createOrder($customer3, [$blueProduct]);
$this->assertFilterReturnsEmailsForLocalAttributes('none', 'color', ['red', 'blue'], ['customer4@example.com']);
$this->assertFilterReturnsEmailsForLocalAttributes('none', 'color', ['red'], ['customer3@example.com', 'customer4@example.com']);
$this->assertFilterReturnsEmailsForLocalAttributes('none', 'color', ['blue'], ['customer2@example.com', 'customer4@example.com']);
} }
public function testItRetrievesLookupData(): void { public function testItRetrievesLookupData(): void {
@@ -117,6 +197,7 @@ class WooCommercePurchasedWithAttributeTest extends \MailPoetTest {
'operator' => 'any', 'operator' => 'any',
'attribute_taxonomy_slug' => 'pa_color', 'attribute_taxonomy_slug' => 'pa_color',
'attribute_term_ids' => [$blueTermId, $redTermId], 'attribute_term_ids' => [$blueTermId, $redTermId],
'attribute_type' => 'taxonomy',
]); ]);
$lookupData = $this->filter->getLookupData($filterData); $lookupData = $this->filter->getLookupData($filterData);
@@ -127,32 +208,142 @@ class WooCommercePurchasedWithAttributeTest extends \MailPoetTest {
], $lookupData); ], $lookupData);
} }
public function testItValidatesOperator(): void { public function testItDoesNotGenerateLookupDataForLocalAttributes(): void {
$this->expectException(InvalidFilterException::class); $redProduct = $this->tester->createWooCommerceProduct([
$this->expectExceptionMessage('Missing operator'); 'price' => 20,
$this->expectExceptionCode(InvalidFilterException::MISSING_OPERATOR); 'local_attributes' => [
$this->filter->validateFilterData(['operator' => '', 'attribute_taxonomy_slug' => 'pa_color', 'attribute_term_ids' => ['1']]); 'color' => 'red',
],
]);
$blueProduct = $this->tester->createWooCommerceProduct([
'price' => 20,
'local_attributes' => [
'color' => 'blue',
],
]);
$filterData = new DynamicSegmentFilterData(DynamicSegmentFilterData::TYPE_WOOCOMMERCE, WooCommercePurchasedWithAttribute::ACTION, [
'operator' => 'any',
'attribute_local_name' => 'color',
'attribute_local_values' => ['red', 'blue'],
'attribute_type' => 'local',
]);
$lookupData = $this->filter->getLookupData($filterData);
$this->assertSame([], $lookupData);
} }
public function testItValidatesAttribute(): void { /**
$this->expectException(InvalidFilterException::class); * @dataProvider filterDataProvider
$this->expectExceptionMessage('Missing attribute'); */
$this->expectExceptionCode(InvalidFilterException::MISSING_VALUE); public function testItValidatesFilterData(array $data, bool $isValid): void {
$this->filter->validateFilterData(['operator' => 'any', 'attribute_taxonomy_slug' => '', 'attribute_term_ids' => ['1']]); if (!$isValid) {
$this->expectException(InvalidFilterException::class);
}
$this->filter->validateFilterData($data);
} }
public function testItValidatesTerms(): void { public function filterDataProvider(): array {
$this->expectException(InvalidFilterException::class); return [
$this->expectExceptionMessage('Missing attribute terms'); 'missing term ids' =>
$this->expectExceptionCode(InvalidFilterException::MISSING_VALUE); [
$this->filter->validateFilterData(['operator' => 'any', 'attribute_taxonomy_slug' => 'pa_color', 'attribute_term_ids' => []]); [
'operator' => 'any',
'attribute_type' => 'taxonomy',
'attribute_taxonomy_slug' => 'pa_color',
'attribute_term_ids' => [],
],
false,
],
'missing taxonomy slug' =>
[
[
'operator' => 'any',
'attribute_type' => 'taxonomy',
'attribute_taxonomy_slug' => '',
'attribute_term_ids' => ['1'],
],
false,
],
'valid taxonomy' => [
[
'operator' => 'any',
'attribute_type' => 'taxonomy',
'attribute_taxonomy_slug' => 'pa_something',
'attribute_term_ids' => ['1'],
],
true,
],
'missing operator' =>
[
[
'operator' => '',
'attribute_type' => 'taxonomy',
'attribute_taxonomy_slug' => 'pa_color',
'attribute_term_ids' => ['1'],
],
false,
],
'invalid operator' =>
[
[
'operator' => 'anyyyyy',
'attribute_type' => 'taxonomy',
'attribute_taxonomy_slug' => 'pa_color',
'attribute_term_ids' => ['1'],
],
false,
],
'missing name' =>
[
[
'operator' => 'any',
'attribute_type' => 'local',
'attribute_local_name' => '',
'attribute_local_values' => ['1'],
],
false,
],
'missing values' =>
[
[
'operator' => 'any',
'attribute_type' => 'local',
'attribute_local_name' => 'color',
'attribute_local_values' => [],
],
false,
],
'valid local' =>
[
[
'operator' => 'any',
'attribute_type' => 'local',
'attribute_local_name' => 'color',
'attribute_local_values' => ['red'],
],
true,
],
];
} }
private function assertFilterReturnsEmails(string $operator, string $attributeTaxonomySlug, array $termIds, array $expectedEmails): void { private function assertFilterReturnsEmailsForTaxonomyAttributes(string $operator, string $attributeTaxonomySlug, array $termIds, array $expectedEmails): void {
$filterData = new DynamicSegmentFilterData(DynamicSegmentFilterData::TYPE_WOOCOMMERCE, WooCommercePurchasedWithAttribute::ACTION, [ $filterData = new DynamicSegmentFilterData(DynamicSegmentFilterData::TYPE_WOOCOMMERCE, WooCommercePurchasedWithAttribute::ACTION, [
'operator' => $operator, 'operator' => $operator,
'attribute_taxonomy_slug' => $attributeTaxonomySlug, 'attribute_taxonomy_slug' => $attributeTaxonomySlug,
'attribute_term_ids' => $termIds, 'attribute_term_ids' => $termIds,
'attribute_type' => 'taxonomy',
]);
$emails = $this->tester->getSubscriberEmailsMatchingDynamicFilter($filterData, $this->filter);
$this->assertEqualsCanonicalizing($expectedEmails, $emails);
}
private function assertFilterReturnsEmailsForLocalAttributes(string $operator, string $localAttributeName, array $localAttributeValues, array $expectedEmails): void {
$filterData = new DynamicSegmentFilterData(DynamicSegmentFilterData::TYPE_WOOCOMMERCE, WooCommercePurchasedWithAttribute::ACTION, [
'operator' => $operator,
'attribute_local_name' => $localAttributeName,
'attribute_local_values' => $localAttributeValues,
'attribute_type' => 'local',
]); ]);
$emails = $this->tester->getSubscriberEmailsMatchingDynamicFilter($filterData, $this->filter); $emails = $this->tester->getSubscriberEmailsMatchingDynamicFilter($filterData, $this->filter);
$this->assertEqualsCanonicalizing($expectedEmails, $emails); $this->assertEqualsCanonicalizing($expectedEmails, $emails);

View File

@@ -10,6 +10,7 @@
var wordpress_editable_roles_list = <%= json_encode(wordpress_editable_roles_list) %>; var wordpress_editable_roles_list = <%= json_encode(wordpress_editable_roles_list) %>;
var mailpoet_newsletters_list = <%= json_encode(newsletters_list) %>; var mailpoet_newsletters_list = <%= json_encode(newsletters_list) %>;
var mailpoet_product_attributes = <%= json_encode(product_attributes) %>; var mailpoet_product_attributes = <%= json_encode(product_attributes) %>;
var mailpoet_local_product_attributes = <%= json_encode(local_product_attributes) %>;
var mailpoet_product_categories = <%= json_encode(product_categories) %>; var mailpoet_product_categories = <%= json_encode(product_categories) %>;
var mailpoet_products = <%= json_encode(products) %>; var mailpoet_products = <%= json_encode(products) %>;
var mailpoet_membership_plans = <%= json_encode(membership_plans) %>; var mailpoet_membership_plans = <%= json_encode(membership_plans) %>;