From a4a2ef3b1f8f9c2db548f364afcfe20bd868ceeb Mon Sep 17 00:00:00 2001 From: John Oleksowicz Date: Fri, 16 Feb 2024 11:55:28 -0600 Subject: [PATCH] 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 --- .../woocommerce/purchased-with-attribute.tsx | 180 +++++++++---- .../segments/dynamic/store/initial-state.ts | 1 + .../src/segments/dynamic/store/selectors.ts | 4 + .../assets/js/src/segments/dynamic/types.ts | 10 + .../lib/AdminPages/Pages/DynamicSegments.php | 39 +++ .../DynamicSegments/FilterDataMapper.php | 3 + .../WooCommercePurchasedWithAttribute.php | 130 ++++++++-- mailpoet/tests/_support/IntegrationTester.php | 6 + .../WooCommercePurchasedWithAttributeTest.php | 237 ++++++++++++++++-- mailpoet/views/segments/dynamic.html | 1 + 10 files changed, 521 insertions(+), 90 deletions(-) diff --git a/mailpoet/assets/js/src/segments/dynamic/dynamic-segments-filters/fields/woocommerce/purchased-with-attribute.tsx b/mailpoet/assets/js/src/segments/dynamic/dynamic-segments-filters/fields/woocommerce/purchased-with-attribute.tsx index 61e1cbd4ac..84576be956 100644 --- a/mailpoet/assets/js/src/segments/dynamic/dynamic-segments-filters/fields/woocommerce/purchased-with-attribute.tsx +++ b/mailpoet/assets/js/src/segments/dynamic/dynamic-segments-filters/fields/woocommerce/purchased-with-attribute.tsx @@ -9,6 +9,7 @@ import { AnyValueTypes, FilterProps, SelectOption, + WindowLocalProductAttributes, WindowProductAttributes, WooCommerceFormItem, } from '../../../types'; @@ -18,9 +19,15 @@ export function validatePurchasedWithAttribute( ): boolean { const purchasedProductWithAttributeIsInvalid = !formItems.operator || - formItems.attribute_taxonomy_slug === undefined || - !Array.isArray(formItems.attribute_term_ids) === undefined || - formItems.attribute_term_ids.length === 0; + (formItems.attribute_type === 'taxonomy' && + (formItems.attribute_taxonomy_slug === undefined || + !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; } @@ -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); useEffect(() => { - if (segment.attribute_taxonomy_slug === undefined) { + if ( + segment.attribute_taxonomy_slug === undefined && + segment.attribute_local_name === undefined + ) { productAttributeTermsOptionsRef.current = null; return; } - productAttributeTermsOptionsRef.current = productAttributes[ - segment.attribute_taxonomy_slug - ].terms.map((term) => ({ - value: term.term_id.toString(), - label: term.name, - })); - }, [segment.attribute_taxonomy_slug, productAttributes]); + if (segment.attribute_type === 'taxonomy') { + productAttributeTermsOptionsRef.current = productAttributes[ + segment.attribute_taxonomy_slug + ].terms.map((term) => ({ + value: term.term_id.toString(), + label: term.name, + })); + } 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(() => { if ( @@ -90,23 +137,52 @@ export function PurchasedWithAttributeFields({ dimension="small" key="select-segment-product-attribute" placeholder={__('Search attributes', 'mailpoet')} - options={productAttributesOptions} - value={filter((productAttributeOption) => { - if (segment.attribute_taxonomy_slug === undefined) { - return undefined; - } - return ( - segment.attribute_taxonomy_slug === productAttributeOption.value - ); - }, productAttributesOptions)} + options={combinedOptions} + value={ + segment.attribute_type === 'local' + ? filter((localAttributeOption) => { + if (!segment.attribute_local_name) { + return undefined; + } + return ( + `${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 => { - void updateSegmentFilter( - { - attribute_taxonomy_slug: option.value, - attribute_term_ids: [], - }, - filterIndex, - ); + if (localAttributeValues.includes(option.value)) { + void updateSegmentFilter( + { + attribute_type: 'local', + attribute_local_name: option.value.replace(/@local$/, ''), + 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 && ( @@ -118,26 +194,46 @@ export function PurchasedWithAttributeFields({ options={productAttributeTermsOptionsRef.current} value={filter( (productAttributeTermOption: { value: string; label: string }) => { - if (segment.attribute_term_ids === undefined) { - return undefined; + if (segment.attribute_local_values) { + return ( + segment.attribute_local_values.indexOf( + productAttributeTermOption.value, + ) !== -1 + ); } - return ( - segment.attribute_term_ids.indexOf( - productAttributeTermOption.value, - ) !== -1 - ); + if (segment.attribute_term_ids) { + return ( + segment.attribute_term_ids.indexOf( + productAttributeTermOption.value, + ) !== -1 + ); + } + return undefined; }, productAttributeTermsOptionsRef.current, )} onChange={(options: SelectOption[]): void => { - void updateSegmentFilter( - { - attribute_term_ids: (options || []).map( - (x: SelectOption) => x.value, - ), - }, - filterIndex, - ); + if (segment.attribute_type === 'local') { + void updateSegmentFilter( + { + attribute_term_ids: null, + attribute_local_values: (options || []).map( + (x: SelectOption) => x.value, + ), + }, + filterIndex, + ); + } else { + void updateSegmentFilter( + { + attribute_term_ids: (options || []).map( + (x: SelectOption) => x.value, + ), + attribute_local_values: null, + }, + filterIndex, + ); + } }} /> )} diff --git a/mailpoet/assets/js/src/segments/dynamic/store/initial-state.ts b/mailpoet/assets/js/src/segments/dynamic/store/initial-state.ts index adef9b39dc..583877d566 100644 --- a/mailpoet/assets/js/src/segments/dynamic/store/initial-state.ts +++ b/mailpoet/assets/js/src/segments/dynamic/store/initial-state.ts @@ -28,6 +28,7 @@ export const getInitialState = (): StateType => ({ membershipPlans: window.mailpoet_membership_plans, subscriptionProducts: window.mailpoet_subscription_products, productAttributes: window.mailpoet_product_attributes, + localProductAttributes: window.mailpoet_local_product_attributes, productCategories: window.mailpoet_product_categories, newslettersList: window.mailpoet_newsletters_list, wordpressRoles: window.wordpress_editable_roles_list, diff --git a/mailpoet/assets/js/src/segments/dynamic/store/selectors.ts b/mailpoet/assets/js/src/segments/dynamic/store/selectors.ts index 2cf87dc052..340f96eba6 100644 --- a/mailpoet/assets/js/src/segments/dynamic/store/selectors.ts +++ b/mailpoet/assets/js/src/segments/dynamic/store/selectors.ts @@ -12,6 +12,7 @@ import { Tag, WindowCustomFields, WindowEditableRoles, + WindowLocalProductAttributes, WindowMembershipPlans, WindowNewslettersList, WindowProductAttributes, @@ -34,6 +35,9 @@ export const getWordpressRoles = (state: StateType): WindowEditableRoles => export const getProductAttributes = ( state: StateType, ): WindowProductAttributes => state.productAttributes; +export const getLocalProductAttributes = ( + state: StateType, +): WindowLocalProductAttributes => state.localProductAttributes; export const getProductCategories = ( state: StateType, ): WindowProductCategories => state.productCategories; diff --git a/mailpoet/assets/js/src/segments/dynamic/types.ts b/mailpoet/assets/js/src/segments/dynamic/types.ts index 36dc32c8a0..fb282374ba 100644 --- a/mailpoet/assets/js/src/segments/dynamic/types.ts +++ b/mailpoet/assets/js/src/segments/dynamic/types.ts @@ -152,8 +152,11 @@ export interface WooCommerceFormItem extends FormItem { count?: string; days?: string; coupon_code_ids?: string[]; + attribute_type?: 'local' | 'taxonomy'; attribute_taxonomy_slug?: string; attribute_term_ids?: string[]; + attribute_local_name?: string; + attribute_local_values?: string[]; } export interface AutomationsFormItem extends FormItem { @@ -235,6 +238,11 @@ export type WindowProductAttributes = { terms: []; }[]; +export type WindowLocalProductAttributes = { + name: string; + values: string[]; +}[]; + export type WindowProductCategories = { id: string; name: string; @@ -274,6 +282,7 @@ export interface SegmentFormDataWindow extends Window { mailpoet_membership_plans: WindowMembershipPlans; mailpoet_subscription_products: WindowSubscriptionProducts; mailpoet_product_attributes: WindowProductAttributes; + mailpoet_local_product_attributes: WindowLocalProductAttributes; mailpoet_product_categories: WindowProductCategories; mailpoet_woocommerce_countries: WindowWooCommerceCountries; mailpoet_woocommerce_payment_methods: WooPaymentMethod[]; @@ -295,6 +304,7 @@ export interface StateType { subscriptionProducts: WindowSubscriptionProducts; wordpressRoles: WindowEditableRoles; productAttributes: WindowProductAttributes; + localProductAttributes: WindowLocalProductAttributes; productCategories: WindowProductCategories; newslettersList: WindowNewslettersList; canUseWooMemberships: boolean; diff --git a/mailpoet/lib/AdminPages/Pages/DynamicSegments.php b/mailpoet/lib/AdminPages/Pages/DynamicSegments.php index 7c030c7178..3682bdb276 100644 --- a/mailpoet/lib/AdminPages/Pages/DynamicSegments.php +++ b/mailpoet/lib/AdminPages/Pages/DynamicSegments.php @@ -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(); @@ -204,6 +214,35 @@ class DynamicSegments { $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 { $result = []; foreach ($this->newslettersRepository->getStandardNewsletterList() as $newsletter) { diff --git a/mailpoet/lib/Segments/DynamicSegments/FilterDataMapper.php b/mailpoet/lib/Segments/DynamicSegments/FilterDataMapper.php index 99d72a81de..dcc2c7fbb3 100644 --- a/mailpoet/lib/Segments/DynamicSegments/FilterDataMapper.php +++ b/mailpoet/lib/Segments/DynamicSegments/FilterDataMapper.php @@ -519,6 +519,9 @@ class FilterDataMapper { $filterData['operator'] = $data['operator']; $filterData['attribute_taxonomy_slug'] = $data['attribute_taxonomy_slug']; $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 { throw new InvalidFilterException("Unknown action " . $data['action'], InvalidFilterException::MISSING_ACTION); } diff --git a/mailpoet/lib/Segments/DynamicSegments/Filters/WooCommercePurchasedWithAttribute.php b/mailpoet/lib/Segments/DynamicSegments/Filters/WooCommercePurchasedWithAttribute.php index be67940e87..3b773479bd 100644 --- a/mailpoet/lib/Segments/DynamicSegments/Filters/WooCommercePurchasedWithAttribute.php +++ b/mailpoet/lib/Segments/DynamicSegments/Filters/WooCommercePurchasedWithAttribute.php @@ -12,6 +12,9 @@ use MailPoetVendor\Doctrine\DBAL\Query\QueryBuilder; class WooCommercePurchasedWithAttribute implements Filter { const ACTION = 'purchasedWithAttribute'; + const TYPE_LOCAL = 'local'; + const TYPE_TAXONOMY = 'taxonomy'; + private WooFilterHelper $wooFilterHelper; private FilterHelper $filterHelper; @@ -32,34 +35,24 @@ class WooCommercePurchasedWithAttribute implements Filter { $filterData = $filter->getFilterData(); $this->validateFilterData((array)$filterData->getData()); - $operator = $filterData->getOperator(); - $attributeTaxonomySlug = $filterData->getStringParam('attribute_taxonomy_slug'); - $attributeTermIds = $filterData->getArrayParam('attribute_term_ids'); + $type = $filterData->getStringParam('attribute_type'); - if ($operator === DynamicSegmentFilterData::OPERATOR_ANY) { - $this->applyForAnyOperator($queryBuilder, $attributeTaxonomySlug, $attributeTermIds); - } elseif ($operator === DynamicSegmentFilterData::OPERATOR_ALL) { - $this->applyForAnyOperator($queryBuilder, $attributeTaxonomySlug, $attributeTermIds); - $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)})"); + if ($type === self::TYPE_LOCAL) { + $this->applyForLocalAttribute($queryBuilder, $filterData); + } elseif ($type === self::TYPE_TAXONOMY) { + $this->applyForTaxonomyAttribute($queryBuilder, $filterData); } 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'); $orderStatsAlias = $this->wooFilterHelper->applyOrderStatusFilter($queryBuilder); $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->setParameter($termIdsParam, $attributeTermIds, Connection::PARAM_STR_ARRAY); } @@ -74,7 +67,7 @@ class WooCommercePurchasedWithAttribute implements Filter { 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( $productAlias, $this->filterHelper->getPrefixedTable('wc_product_attributes_lookup'), @@ -86,6 +79,12 @@ class WooCommercePurchasedWithAttribute implements Filter { } public function getLookupData(DynamicSegmentFilterData $filterData): array { + $type = $filterData->getStringParam('attribute_type'); + + if ($type !== self::TYPE_TAXONOMY) { + return []; + } + $slug = $filterData->getStringParam('attribute_taxonomy_slug'); $lookupData = [ @@ -117,12 +116,93 @@ class WooCommercePurchasedWithAttribute implements Filter { ) { throw new InvalidFilterException('Missing operator', InvalidFilterException::MISSING_OPERATOR); } - $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); + $this->validateAttributeData($data); + } + + 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); + } } diff --git a/mailpoet/tests/_support/IntegrationTester.php b/mailpoet/tests/_support/IntegrationTester.php index d03db95d87..d972c5e759 100644 --- a/mailpoet/tests/_support/IntegrationTester.php +++ b/mailpoet/tests/_support/IntegrationTester.php @@ -161,6 +161,12 @@ class IntegrationTester extends \Codeception\Actor { 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(); return $product; } diff --git a/mailpoet/tests/integration/Segments/DynamicSegments/Filters/WooCommercePurchasedWithAttributeTest.php b/mailpoet/tests/integration/Segments/DynamicSegments/Filters/WooCommercePurchasedWithAttributeTest.php index 8aab5f30da..350b976361 100644 --- a/mailpoet/tests/integration/Segments/DynamicSegments/Filters/WooCommercePurchasedWithAttributeTest.php +++ b/mailpoet/tests/integration/Segments/DynamicSegments/Filters/WooCommercePurchasedWithAttributeTest.php @@ -11,14 +11,13 @@ use MailPoet\Segments\DynamicSegments\Filters\WooCommercePurchasedWithAttribute; */ class WooCommercePurchasedWithAttributeTest extends \MailPoetTest { - private WooCommercePurchasedWithAttribute $filter; public function _before(): void { $this->filter = $this->diContainer->get(WooCommercePurchasedWithAttribute::class); } - public function testItWorksWithAnyOperator(): void { + public function testItWorksWithAnyOperatorForTaxonomies(): void { $product1 = $this->tester->createWooCommerceProduct([ 'price' => 20, 'attributes' => [ @@ -41,10 +40,10 @@ class WooCommercePurchasedWithAttributeTest extends \MailPoetTest { $this->createOrder($customer1, [$product1]); $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([ 'price' => 20, 'attributes' => [ @@ -67,10 +66,10 @@ class WooCommercePurchasedWithAttributeTest extends \MailPoetTest { $this->createOrder($customer1, [$product1]); $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([ 'price' => 20, 'attributes' => [ @@ -93,7 +92,88 @@ class WooCommercePurchasedWithAttributeTest extends \MailPoetTest { $this->createOrder($customer1, [$product1, $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 { @@ -117,6 +197,7 @@ class WooCommercePurchasedWithAttributeTest extends \MailPoetTest { 'operator' => 'any', 'attribute_taxonomy_slug' => 'pa_color', 'attribute_term_ids' => [$blueTermId, $redTermId], + 'attribute_type' => 'taxonomy', ]); $lookupData = $this->filter->getLookupData($filterData); @@ -127,32 +208,142 @@ class WooCommercePurchasedWithAttributeTest extends \MailPoetTest { ], $lookupData); } - public function testItValidatesOperator(): void { - $this->expectException(InvalidFilterException::class); - $this->expectExceptionMessage('Missing operator'); - $this->expectExceptionCode(InvalidFilterException::MISSING_OPERATOR); - $this->filter->validateFilterData(['operator' => '', 'attribute_taxonomy_slug' => 'pa_color', 'attribute_term_ids' => ['1']]); + public function testItDoesNotGenerateLookupDataForLocalAttributes(): void { + $redProduct = $this->tester->createWooCommerceProduct([ + 'price' => 20, + 'local_attributes' => [ + '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); - $this->expectExceptionMessage('Missing attribute'); - $this->expectExceptionCode(InvalidFilterException::MISSING_VALUE); - $this->filter->validateFilterData(['operator' => 'any', 'attribute_taxonomy_slug' => '', 'attribute_term_ids' => ['1']]); + /** + * @dataProvider filterDataProvider + */ + public function testItValidatesFilterData(array $data, bool $isValid): void { + if (!$isValid) { + $this->expectException(InvalidFilterException::class); + } + $this->filter->validateFilterData($data); } - public function testItValidatesTerms(): void { - $this->expectException(InvalidFilterException::class); - $this->expectExceptionMessage('Missing attribute terms'); - $this->expectExceptionCode(InvalidFilterException::MISSING_VALUE); - $this->filter->validateFilterData(['operator' => 'any', 'attribute_taxonomy_slug' => 'pa_color', 'attribute_term_ids' => []]); + public function filterDataProvider(): array { + return [ + 'missing 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, [ 'operator' => $operator, 'attribute_taxonomy_slug' => $attributeTaxonomySlug, '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); $this->assertEqualsCanonicalizing($expectedEmails, $emails); diff --git a/mailpoet/views/segments/dynamic.html b/mailpoet/views/segments/dynamic.html index 574ceff8e3..aea73cfe9a 100644 --- a/mailpoet/views/segments/dynamic.html +++ b/mailpoet/views/segments/dynamic.html @@ -10,6 +10,7 @@ var wordpress_editable_roles_list = <%= json_encode(wordpress_editable_roles_list) %>; var mailpoet_newsletters_list = <%= json_encode(newsletters_list) %>; 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_products = <%= json_encode(products) %>; var mailpoet_membership_plans = <%= json_encode(membership_plans) %>;