Add order created|cancelled|completed triggers

[MAILPOET-5661]
This commit is contained in:
David Remer
2023-10-26 09:08:24 +03:00
committed by Aschepikov
parent d4ea49d09a
commit 1d885ea238
19 changed files with 506 additions and 2 deletions

View File

@@ -1,5 +1,8 @@
import { registerStepType } from '../../editor/store';
import { step as OrderStatusChanged } from './steps/order-status-changed';
import { step as OrderCompletedTrigger } from './steps/order-completed';
import { step as OrderCancelledTrigger } from './steps/order-cancelled';
import { step as OrderCreatedTrigger } from './steps/order-created';
import { step as AbandonedCartTrigger } from './steps/abandoned-cart';
import { MailPoet } from '../../../mailpoet';
import { step as BuysAProductTrigger } from './steps/buys-a-product';
@@ -12,6 +15,9 @@ export const initialize = (): void => {
return;
}
registerStepType(OrderStatusChanged);
registerStepType(OrderCompletedTrigger);
registerStepType(OrderCancelledTrigger);
registerStepType(OrderCreatedTrigger);
registerStepType(AbandonedCartTrigger);
registerStepType(BuysAProductTrigger);
registerStepType(BuysFromACategory);

View File

@@ -0,0 +1,36 @@
export function Icon(): JSX.Element {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M14.884 5.12L16.932 6.142L17.557 5.892L18 5.716V12.724H19.5V3.5L17 4.5L15 3.5L12 4.5L9 3.5L7 4.5L4.5 3.5V20.5L7 19.5L9 20.5L12 19.5V17.919L9.116 18.88L7.671 18.158L7.068 17.858L6 18.285V5.715L6.443 5.893L7.068 6.143L9.115 5.12L12 6.081L14.884 5.12Z"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M16 11.25V12.724H12V12.75H8V11.25H16Z"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12 14.25H8V15.75H12V14.25Z"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M16 9.75V8.25H8V9.75H16Z"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M15.5355 17.6564L13.7678 19.4241L14.8284 20.4848L16.5962 18.717L18.364 20.4848L19.4246 19.4241L17.6569 17.6564L19.4246 15.8886L18.364 14.8279L16.5962 16.5957L14.8284 14.8279L13.7678 15.8886L15.5355 17.6564Z"
/>
</svg>
);
}

View File

@@ -0,0 +1,18 @@
import { __, _x } from '@wordpress/i18n';
import { StepType } from '../../../../editor/store';
import { Icon } from './icon';
const keywords = [__('woocommerce', 'mailpoet'), __('order', 'mailpoet')];
export const step: StepType = {
key: 'woocommerce:order-cancelled',
group: 'triggers',
title: () => __('Order cancelled', 'mailpoet'),
description: () =>
__('Start the automation when an order is cancelled.', 'mailpoet'),
subtitle: () => _x('Trigger', 'noun', 'mailpoet'),
keywords,
foreground: '#2271b1',
background: '#f0f6fc',
icon: () => <Icon />,
edit: () => null,
} as const;

View File

@@ -0,0 +1,36 @@
export function Icon(): JSX.Element {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M14.884 5.12L16.932 6.142L17.557 5.892L18 5.716V12.724H19.5V3.5L17 4.5L15 3.5L12 4.5L9 3.5L7 4.5L4.5 3.5V20.5L7 19.5L9 20.5L11.5639 19.6454V18.0644L9.116 18.88L7.671 18.158L7.068 17.858L6 18.285V5.715L6.443 5.893L7.068 6.143L9.115 5.12L12 6.081L14.884 5.12Z"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M16 11.25V12.724H11.5639V12.75H8V11.25H16Z"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M11.5639 14.25H8V15.75H11.5639V14.25Z"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M16 9.75V8.25H8V9.75H16Z"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M15.1925 18.2365L18.363 15.066L19.5 16.203L15.1936 20.5L12.6553 17.9437L13.7825 16.8165L15.1925 18.2365Z"
/>
</svg>
);
}

View File

@@ -0,0 +1,18 @@
import { __, _x } from '@wordpress/i18n';
import { StepType } from '../../../../editor/store';
import { Icon } from './icon';
const keywords = [__('woocommerce', 'mailpoet'), __('order', 'mailpoet')];
export const step: StepType = {
key: 'woocommerce:order-completed',
group: 'triggers',
title: () => __('Order completed', 'mailpoet'),
description: () =>
__('Start the automation when an order is completed.', 'mailpoet'),
subtitle: () => _x('Trigger', 'noun', 'mailpoet'),
keywords,
foreground: '#2271b1',
background: '#f0f6fc',
icon: () => <Icon />,
edit: () => null,
} as const;

View File

@@ -0,0 +1,16 @@
export function Icon(): JSX.Element {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M16.33 5.842L16.932 6.142L17.557 5.892L18 5.716V18.285L17.557 18.107L16.932 17.857L16.329 18.158L14.885 18.881L12.475 18.077L12 17.919L11.526 18.077L9.116 18.88L7.671 18.158L7.068 17.858L6.443 18.108L6 18.285V5.715L6.443 5.893L7.068 6.143L7.671 5.842L9.115 5.12L11.525 5.923L12 6.081L12.474 5.923L14.884 5.12L16.33 5.842ZM19.5 3.5L18 4.1L17 4.5L15 3.5L12 4.5L9 3.5L7 4.5L6 4.1L4.5 3.5V20.5L6 19.9L7 19.5L9 20.5L12 19.5L15 20.5L17 19.5L18 19.9L19.5 20.5V3.5ZM16 9.75V8.25H8V9.75H16ZM16 12.75V11.25H8V12.75H16ZM8 15.75V14.25H16V15.75H8Z"
/>
</svg>
);
}

View File

@@ -0,0 +1,22 @@
import { __, _x } from '@wordpress/i18n';
import { StepType } from '../../../../editor/store';
import { Icon } from './icon';
const keywords = [
__('woocommerce', 'mailpoet'),
__('order', 'mailpoet'),
__('new', 'mailpoet'),
];
export const step: StepType = {
key: 'woocommerce:order-created',
group: 'triggers',
title: () => __('Order created', 'mailpoet'),
description: () =>
__('Start the automation when an order is created.', 'mailpoet'),
subtitle: () => _x('Trigger', 'noun', 'mailpoet'),
keywords,
foreground: '#2271b1',
background: '#f0f6fc',
icon: () => <Icon />,
edit: () => null,
} as const;

View File

@@ -21,6 +21,10 @@ class WordPress {
return add_action($hookName, $callback, $priority, $acceptedArgs);
}
public function removeAction(string $hookName, callable $callback, int $priority = 10): bool {
return remove_action($hookName, $callback, $priority);
}
/** @param mixed ...$arg */
public function doAction(string $hookName, ...$arg): void {
do_action($hookName, ...$arg);

View File

@@ -0,0 +1,28 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\WooCommerce\Triggers\Orders;
use MailPoet\Automation\Engine\Data\StepRunArgs;
use MailPoet\Automation\Integrations\WooCommerce\Payloads\OrderStatusChangePayload;
use MailPoet\Validator\Builder;
use MailPoet\Validator\Schema\ObjectSchema;
class OrderCancelledTrigger extends OrderStatusChangedTrigger {
public function getKey(): string {
return 'woocommerce:order-cancelled';
}
public function getName(): string {
return __('Order cancelled', 'mailpoet');
}
public function isTriggeredBy(StepRunArgs $args): bool {
/** @var OrderStatusChangePayload $orderPayload */
$orderPayload = $args->getSinglePayloadByClass(OrderStatusChangePayload::class);
return $orderPayload->getTo() === 'cancelled';
}
public function getArgsSchema(): ObjectSchema {
return Builder::object();
}
}

View File

@@ -0,0 +1,28 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\WooCommerce\Triggers\Orders;
use MailPoet\Automation\Engine\Data\StepRunArgs;
use MailPoet\Automation\Integrations\WooCommerce\Payloads\OrderStatusChangePayload;
use MailPoet\Validator\Builder;
use MailPoet\Validator\Schema\ObjectSchema;
class OrderCompletedTrigger extends OrderStatusChangedTrigger {
public function getKey(): string {
return 'woocommerce:order-completed';
}
public function getName(): string {
return __('Order completed', 'mailpoet');
}
public function isTriggeredBy(StepRunArgs $args): bool {
/** @var OrderStatusChangePayload $orderPayload */
$orderPayload = $args->getSinglePayloadByClass(OrderStatusChangePayload::class);
return $orderPayload->getTo() === 'completed';
}
public function getArgsSchema(): ObjectSchema {
return Builder::object();
}
}

View File

@@ -0,0 +1,100 @@
<?php declare(strict_types = 1);
namespace MailPoet\Automation\Integrations\WooCommerce\Triggers\Orders;
use MailPoet\Automation\Engine\Data\StepRunArgs;
use MailPoet\Automation\Engine\Data\StepValidationArgs;
use MailPoet\Automation\Engine\Data\Subject;
use MailPoet\Automation\Engine\Hooks;
use MailPoet\Automation\Engine\Integration\Trigger;
use MailPoet\Automation\Engine\WordPress;
use MailPoet\Automation\Integrations\WooCommerce\Subjects\CustomerSubject;
use MailPoet\Automation\Integrations\WooCommerce\Subjects\OrderSubject;
use MailPoet\Validator\Builder;
use MailPoet\Validator\Schema\ObjectSchema;
class OrderCreatedTrigger implements Trigger {
/** @var WordPress */
private $wp;
/** @var int[] */
private $processedOrders = [];
public function __construct(
WordPress $wp
) {
$this->wp = $wp;
}
public function getKey(): string {
return 'woocommerce:order-created';
}
public function getName(): string {
return __('Order created', 'mailpoet');
}
public function registerHooks(): void {
$this->wp->addAction(
'woocommerce_new_order',
[
$this,
'handleCreate',
],
10,
2
);
}
/**
* @param int $orderId
* @param \WC_Order $order
* @return void
*/
public function handleCreate($orderId, $order) {
if (in_array($orderId, $this->processedOrders)) {
return;
}
/**
* Creating an order via wc_create_order() does not yet set crucial information like the customer's email address.
* It just creates the order object and saves it to the database. We need therefore to wait for the order to have at least the billing address stored.
**/
if (!$order->get_billing_email()) {
add_action(
'woocommerce_after_order_object_save',
function($order) use ($orderId) {
if ((int)$orderId !== (int)$order->get_id()) {
return;
}
$this->handleCreate($order->get_id(), $order);
}
);
return;
}
$this->processedOrders[] = $orderId;
$this->wp->doAction(Hooks::TRIGGER, $this, [
new Subject(OrderSubject::KEY, ['order_id' => $order->get_id()]),
new Subject(CustomerSubject::KEY, ['customer_id' => $order->get_customer_id()]),
]);
}
public function isTriggeredBy(StepRunArgs $args): bool {
return true;
}
public function getArgsSchema(): ObjectSchema {
return Builder::object();
}
public function getSubjectKeys(): array {
return [
OrderSubject::KEY,
CustomerSubject::KEY,
];
}
public function validate(StepValidationArgs $args): void {
}
}

View File

@@ -19,10 +19,10 @@ use MailPoet\WP\Functions;
class OrderStatusChangedTrigger implements Trigger {
/** @var Functions */
private $wp;
protected $wp;
/** @var WooCommerce */
private $woocommerce;
protected $woocommerce;
public function __construct(
Functions $wp,

View File

@@ -11,6 +11,9 @@ use MailPoet\Automation\Integrations\WooCommerce\SubjectTransformers\WordPressUs
use MailPoet\Automation\Integrations\WooCommerce\Triggers\AbandonedCart\AbandonedCartTrigger;
use MailPoet\Automation\Integrations\WooCommerce\Triggers\BuysAProductTrigger;
use MailPoet\Automation\Integrations\WooCommerce\Triggers\BuysFromACategoryTrigger;
use MailPoet\Automation\Integrations\WooCommerce\Triggers\Orders\OrderCancelledTrigger;
use MailPoet\Automation\Integrations\WooCommerce\Triggers\Orders\OrderCompletedTrigger;
use MailPoet\Automation\Integrations\WooCommerce\Triggers\Orders\OrderCreatedTrigger;
use MailPoet\Automation\Integrations\WooCommerce\Triggers\Orders\OrderStatusChangedTrigger;
class WooCommerceIntegration {
@@ -18,6 +21,14 @@ class WooCommerceIntegration {
/** @var OrderStatusChangedTrigger */
private $orderStatusChangedTrigger;
/** @var OrderCreatedTrigger */
private $orderCreatedTrigger;
/** @var OrderCompletedTrigger */
private $orderCompletedTrigger;
private $orderCancelledTrigger;
/** @var AbandonedCartTrigger */
private $abandonedCartTrigger;
@@ -50,6 +61,9 @@ class WooCommerceIntegration {
public function __construct(
OrderStatusChangedTrigger $orderStatusChangedTrigger,
OrderCreatedTrigger $orderCreatedTrigger,
OrderCompletedTrigger $orderCompletedTrigger,
OrderCancelledTrigger $orderCancelledTrigger,
AbandonedCartTrigger $abandonedCartTrigger,
BuysAProductTrigger $buysAProductTrigger,
BuysFromACategoryTrigger $buysFromACategoryTrigger,
@@ -62,6 +76,9 @@ class WooCommerceIntegration {
WooCommerce $wooCommerce
) {
$this->orderStatusChangedTrigger = $orderStatusChangedTrigger;
$this->orderCreatedTrigger = $orderCreatedTrigger;
$this->orderCompletedTrigger = $orderCompletedTrigger;
$this->orderCancelledTrigger = $orderCancelledTrigger;
$this->abandonedCartTrigger = $abandonedCartTrigger;
$this->buysAProductTrigger = $buysAProductTrigger;
$this->buysFromACategoryTrigger = $buysFromACategoryTrigger;
@@ -88,6 +105,9 @@ class WooCommerceIntegration {
$registry->addSubject($this->orderStatusChangeSubject);
$registry->addSubject($this->customerSubject);
$registry->addTrigger($this->orderStatusChangedTrigger);
$registry->addTrigger($this->orderCreatedTrigger);
$registry->addTrigger($this->orderCompletedTrigger);
$registry->addTrigger($this->orderCancelledTrigger);
$registry->addTrigger($this->abandonedCartTrigger);
$registry->addTrigger($this->buysAProductTrigger);
$registry->addTrigger($this->buysFromACategoryTrigger);

View File

@@ -208,6 +208,9 @@ class ContainerConfigurator implements IContainerConfigurator {
$container->autowire(\MailPoet\Automation\Integrations\WooCommerce\Fields\TermOptionsBuilder::class)->setPublic(true);
$container->autowire(\MailPoet\Automation\Integrations\WooCommerce\Fields\TermParentsLoader::class)->setPublic(true);
$container->autowire(\MailPoet\Automation\Integrations\WooCommerce\Triggers\Orders\OrderStatusChangedTrigger::class)->setPublic(true)->setShared(false);
$container->autowire(\MailPoet\Automation\Integrations\WooCommerce\Triggers\Orders\OrderCreatedTrigger::class)->setPublic(true)->setShared(false);
$container->autowire(\MailPoet\Automation\Integrations\WooCommerce\Triggers\Orders\OrderCompletedTrigger::class)->setPublic(true)->setShared(false);
$container->autowire(\MailPoet\Automation\Integrations\WooCommerce\Triggers\Orders\OrderCancelledTrigger::class)->setPublic(true)->setShared(false);
$container->autowire(\MailPoet\Automation\Integrations\WooCommerce\Triggers\BuysAProductTrigger::class)->setPublic(true)->setShared(false);
$container->autowire(\MailPoet\Automation\Integrations\WooCommerce\Triggers\BuysFromACategoryTrigger::class)->setPublic(true)->setShared(false);
$container->autowire(\MailPoet\Automation\Integrations\WooCommerce\Subjects\OrderSubject::class)->setPublic(true)->setShared(false);

View File

@@ -0,0 +1,57 @@
<?php declare(strict_types = 1);
namespace MailPoet\Test\Automation\Integrations\MailPoet\Triggers\Orders;
use MailPoet\Automation\Engine\Data\StepRunArgs;
use MailPoet\Automation\Engine\Integration\Trigger;
use MailPoet\Automation\Integrations\WooCommerce\Payloads\OrderStatusChangePayload;
/**
* For testing order triggers that are triggered by a status change to a specific status,
* e.g. OrderCompletedTrigger, OrderCancelledTrigger.
*/
abstract class AbstractOrderSingleStatusTest extends \MailPoetTest {
abstract protected function getTestee(): Trigger;
abstract protected function getTriggerStatus(): string;
/**
* @dataProvider dataTestIsTriggeredBy
*/
public function testIsTriggeredBy(string $from, string $to, bool $expected) {
$statusChangePayload = $this->createMock(OrderStatusChangePayload::class);
$statusChangePayload->method('getFrom')->willReturn($from);
$statusChangePayload->method('getTo')->willReturn($to);
$stepRunArgs = $this->createMock(StepRunArgs::class);
$stepRunArgs->method('getSinglePayloadByClass')->willReturn($statusChangePayload);
$this->assertEquals($expected, $this->getTestee()->isTriggeredBy($stepRunArgs));
}
public function dataTestIsTriggeredBy() {
$statuses = [
'pending',
'processing',
'on-hold',
'completed',
'cancelled',
'refunded',
'failed',
];
$data = [];
foreach ($statuses as $from) {
foreach ($statuses as $to) {
if ($from === $to) {
continue;
}
$data[sprintf('from_%s_to_%s', $from, $to)] = [
'from' => $from,
'to' => $to,
'expected' => $to === $this->getTriggerStatus(),
];
}
}
return $data;
}
}

View File

@@ -0,0 +1,19 @@
<?php declare(strict_types = 1);
namespace MailPoet\Test\Automation\Integrations\MailPoet\Triggers\Orders;
use MailPoet\Automation\Engine\Integration\Trigger;
use MailPoet\Automation\Integrations\WooCommerce\Triggers\Orders\OrderCancelledTrigger;
/**
* @group woo
*/
class OrderCancelledTriggerTest extends AbstractOrderSingleStatusTest {
protected function getTestee(): Trigger {
return $this->diContainer->get(OrderCancelledTrigger::class);
}
protected function getTriggerStatus(): string {
return 'cancelled';
}
}

View File

@@ -0,0 +1,19 @@
<?php declare(strict_types = 1);
namespace MailPoet\Test\Automation\Integrations\MailPoet\Triggers\Orders;
use MailPoet\Automation\Engine\Integration\Trigger;
use MailPoet\Automation\Integrations\WooCommerce\Triggers\Orders\OrderCompletedTrigger;
/**
* @group woo
*/
class OrderCompletedTriggerTest extends AbstractOrderSingleStatusTest {
protected function getTestee(): Trigger {
return $this->diContainer->get(OrderCompletedTrigger::class);
}
protected function getTriggerStatus(): string {
return 'completed';
}
}

View File

@@ -0,0 +1,71 @@
<?php declare(strict_types = 1);
namespace MailPoet\Test\Automation\Integrations\MailPoet\Triggers\Orders;
use MailPoet\Automation\Engine\Data\AutomationRun;
use MailPoet\Automation\Engine\Data\Step;
use MailPoet\Automation\Engine\Storage\AutomationRunStorage;
use MailPoet\Automation\Integrations\WooCommerce\Subjects\OrderSubject;
use MailPoet\Automation\Integrations\WooCommerce\Triggers\Orders\OrderCreatedTrigger;
use MailPoet\Test\DataFactories\Automation as AutomationFactory;
/**
* @group woo
*/
class OrderCreatedTriggerTest extends \MailPoetTest {
/** @var OrderCreatedTrigger */
private $testee;
/** @var AutomationRunStorage */
private $runStorage;
public function _before() {
$this->testee = $this->diContainer->get(OrderCreatedTrigger::class);
$this->runStorage = $this->diContainer->get(AutomationRunStorage::class);
}
public function testCreatesRunWhenANewOrderIsCreated() {
$orderCreatedTriggerStep = new Step(
uniqid(),
Step::TYPE_TRIGGER,
$this->testee->getKey(),
[],
[]
);
$automation = (new AutomationFactory())
->withStep($orderCreatedTriggerStep)
->withDelayAction()
->withStatusActive()
->create();
$this->testee->registerHooks();
$this->assertEmpty($this->runStorage->getAutomationRunsForAutomation($automation));
$order = wc_create_order(['customer_id' => 1]);
$this->assertInstanceOf(\WC_Order::class, $order);
$runs = $this->runStorage->getAutomationRunsForAutomation($automation);
$this->assertCount(1, $runs);
/** @var AutomationRun $run */
$run = current($runs);
$subject = $run->getSubjects(OrderSubject::KEY)[0];
$this->assertEquals($order->get_id(), $subject->getArgs()['order_id']);
// Test with no email address available yet.
$order = wc_create_order();
$this->assertInstanceOf(\WC_Order::class, $order);
$runs = $this->runStorage->getAutomationRunsForAutomation($automation);
$this->assertCount(1, $runs);
$order->set_billing_email('someone@example.com');
$order->save();
$runs = $this->runStorage->getAutomationRunsForAutomation($automation);
$this->assertCount(2, $runs);
$order->set_status('failed');
$order->save();
$runs = $this->runStorage->getAutomationRunsForAutomation($automation);
$this->assertCount(2, $runs);
}
}

View File

@@ -6,6 +6,9 @@ use MailPoet\Automation\Engine\Data\StepRunArgs;
use MailPoet\Automation\Integrations\WooCommerce\Payloads\OrderStatusChangePayload;
use MailPoet\Automation\Integrations\WooCommerce\Triggers\Orders\OrderStatusChangedTrigger;
/**
* @group woo
*/
class OrderStatusChangeTriggerTest extends \MailPoetTest {
/**
* @dataProvider dataTestIsTriggeredBy