From 49710c1a1c15b46a2342c2eb9e77a41fa8cb95e6 Mon Sep 17 00:00:00 2001 From: Adriaan Zonnenberg Date: Thu, 16 Jul 2026 15:37:37 +0200 Subject: [PATCH 01/18] Add AJAX endpoints to report and create order shipments --- lib/Modules/Orders/CreateShipments.php | 198 ++++++++++++++++++++ lib/Plugin.php | 2 + tests/CreateShipmentsTest.php | 182 ++++++++++++++++++ tests/ShipmentsStatusTest.php | 248 +++++++++++++++++++++++++ tests/doubles.php | 111 ++++++++++- 5 files changed, 736 insertions(+), 5 deletions(-) create mode 100644 lib/Modules/Orders/CreateShipments.php create mode 100644 tests/CreateShipmentsTest.php create mode 100644 tests/ShipmentsStatusTest.php diff --git a/lib/Modules/Orders/CreateShipments.php b/lib/Modules/Orders/CreateShipments.php new file mode 100644 index 0000000..77d3f6e --- /dev/null +++ b/lib/Modules/Orders/CreateShipments.php @@ -0,0 +1,198 @@ + __('Nonce verification failed', 'sendy')], 403); + } + + if (! current_user_can('manage_woocommerce') || ! current_user_can('edit_shop_orders')) { + wp_send_json(['message' => __('You do not have sufficient permissions to access this page.', 'sendy')], 403); + } + + $created = []; + $failed = []; + + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- intval() sanitizes each id + foreach (array_map('intval', (array) ($_POST['order_ids'] ?? [])) as $orderId) { + $order = wc_get_order($orderId); + + if (! $order) { + $failed[] = $orderId; + + continue; + } + + if ($order->meta_exists('_sendy_shipment_id')) { + continue; + } + + try { + if (get_option('sendy_processing_method') === ProcessingMethod::WooCommerce) { + $this->create_shipment_from_order( + $order, + sanitize_key($_POST['preference_id'] ?? ''), + sanitize_key($_POST['shop_id'] ?? ''), + (int) sanitize_key($_POST['amount'] ?? '1'), + ); + } else { + $this->create_shipment_with_smart_rules( + $order, + false, + sanitize_key($_POST['shop_id'] ?? ''), + ); + } + } catch (SendyException $exception) { + // The creation methods flash API validation errors themselves; + // this guards the calls they make outside their own try/catch, + // such as fetching the shop list for smart rules. + // translators: %1$s contains the ID of the order, %2$s the error message + sendy_flash_admin_notice('error', sprintf( + __('Error while creating shipment for order #%1$s: %2$s', 'sendy'), + $order->get_id(), + $exception->getMessage(), + )); + } + + if ($order->meta_exists('_sendy_shipment_id')) { + $created[] = $orderId; + } else { + $failed[] = $orderId; + } + } + + if ($created !== []) { + update_option('sendy_previously_used_shop_id', sanitize_key($_POST['shop_id'] ?? '')); + + if (get_option('sendy_processing_method') === ProcessingMethod::WooCommerce) { + update_option('sendy_previously_used_preference_id', sanitize_key($_POST['preference_id'] ?? '')); + update_option('sendy_previously_used_amount', sanitize_key($_POST['amount'] ?? '')); + } + } + + wp_send_json(['created' => $created, 'failed' => $failed]); + } + + /** + * Report per order whether its labels can be printed + * + * Returns a map of order id to one of: `none` (no shipment yet), + * `pending` (shipment exists but the label is still being generated), + * `ready` (labels can be printed), or `failed` (generation failed or the + * shipment was cancelled). The orders list print flow uses this to decide + * between printing directly, creating a shipment first, or polling until + * the Sendy portal has generated the label. + */ + public function handle_shipments_status(): void + { + if (! check_ajax_referer('sendy_print_labels', 'nonce', false)) { + wp_send_json(['message' => __('Nonce verification failed', 'sendy')], 403); + } + + if (! current_user_can('manage_woocommerce') || ! current_user_can('edit_shop_orders')) { + wp_send_json(['message' => __('You do not have sufficient permissions to access this page.', 'sendy')], 403); + } + + $statuses = []; + + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- boolean flag, nothing to sanitize + $flashPending = ! empty($_POST['flash_pending']); + + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- intval() sanitizes each id + foreach (array_map('intval', (array) ($_POST['order_ids'] ?? [])) as $orderId) { + $statuses[$orderId] = $this->shipment_status(wc_get_order($orderId)); + + if ($flashPending && $statuses[$orderId] === 'pending') { + // translators: %s contains the ID of the order + sendy_flash_admin_notice('warning', sprintf( + __('The labels for order #%s are still being generated. Please try again in a moment.', 'sendy'), + $orderId, + )); + } + } + + wp_send_json(['statuses' => $statuses]); + } + + /** + * Determine the print status of a single order's shipment + * + * Orders whose packages are already stored do not hit the API. When the + * API reports the shipment as generated, the packages are persisted so + * the track and trace column and the order meta box pick them up. + * + * @param \WC_Order|false $order + */ + public function shipment_status($order): string + { + if (! $order || ! $order->meta_exists('_sendy_shipment_id')) { + return 'none'; + } + + $packages = $order->get_meta('_sendy_packages'); + + if (is_array($packages) && $packages !== []) { + return 'ready'; + } + + try { + $shipment = (new Shipments())->get($order->get_meta('_sendy_shipment_id')); + } catch (SendyException $exception) { + // translators: %1$s contains the ID of the order, %2$s the error message + sendy_flash_admin_notice('error', sprintf( + __('Error while checking the shipment for order #%1$s: %2$s', 'sendy'), + $order->get_id(), + $exception->getMessage(), + )); + + return 'failed'; + } + + $status = $shipment['status'] ?? ''; + + if ($status === 'generated') { + $order->update_meta_data('_sendy_packages', $shipment['packages'] ?? []); + $order->save(); + + return 'ready'; + } + + if (in_array($status, ['new', 'pending'], true)) { + return 'pending'; + } + + // translators: %1$s contains the ID of the order, %2$s the status of the shipment + sendy_flash_admin_notice('error', sprintf( + __('The labels for order #%1$s cannot be printed (shipment status: %2$s)', 'sendy'), + $order->get_id(), + $status !== '' ? $status : 'unknown', + )); + + return 'failed'; + } +} diff --git a/lib/Plugin.php b/lib/Plugin.php index aa0237b..5296ae4 100644 --- a/lib/Plugin.php +++ b/lib/Plugin.php @@ -7,6 +7,7 @@ use Sendy\WooCommerce\Modules\Checkout; use Sendy\WooCommerce\Modules\OAuth; use Sendy\WooCommerce\Modules\Orders\BulkActions; +use Sendy\WooCommerce\Modules\Orders\CreateShipments; use Sendy\WooCommerce\Modules\Orders\PrintLabels; use Sendy\WooCommerce\Modules\Orders\ProcessInBackground; use Sendy\WooCommerce\Modules\Orders\OrderList; @@ -119,6 +120,7 @@ public function initialize_modules(): void $this->modules['orders_list'] = new OrderList(); $this->modules['orders_single'] = new Single(); $this->modules['orders_print_labels'] = new PrintLabels(); + $this->modules['orders_create_shipments'] = new CreateShipments(); $this->modules['checkout'] = new Checkout(); $this->modules['webhooks'] = new Webhooks(); $this->modules['orders_sendy'] = new ProcessInBackground(); diff --git a/tests/CreateShipmentsTest.php b/tests/CreateShipmentsTest.php new file mode 100644 index 0000000..77a8818 --- /dev/null +++ b/tests/CreateShipmentsTest.php @@ -0,0 +1,182 @@ +module = new CreateShipments(); + + $user = self::factory()->user->create(['role' => 'administrator']); + wp_set_current_user($user); + wp_get_current_user()->add_cap('manage_woocommerce'); + wp_get_current_user()->add_cap('edit_shop_orders'); + + update_option('sendy_processing_method', ProcessingMethod::WooCommerce); + delete_option('sendy_flash_admin_messages'); + } + + public function tearDown(): void + { + $this->set_api_connection(null); + + parent::tearDown(); + } + + public function test_shipments_are_created_through_the_preference_for_orders_without_one(): void + { + $orderWithoutShipment = new Sendy_Fake_Order(1); + new Sendy_Fake_Order(2, ['_sendy_shipment_id' => 'existing-shipment']); + + $packages = [['package_number' => '3S1', 'tracking_url' => 'https://example.com']]; + + $transport = $this->fake_transport(new Response(200, [], json_encode([ + 'data' => ['uuid' => 'new-shipment', 'packages' => $packages], + ]))); + + $response = $this->dispatch(['order_ids' => [1, 2]]); + + $this->assertSame([1], $response['created']); + $this->assertSame([], $response['failed']); + + $this->assertSame('new-shipment', $orderWithoutShipment->get_meta('_sendy_shipment_id')); + $this->assertSame($packages, $orderWithoutShipment->get_meta('_sendy_packages')); + $this->assertTrue($orderWithoutShipment->was_saved()); + + $this->assertCount(1, $transport->requests, 'Orders that already have a shipment must not trigger an API call'); + $this->assertStringContainsString('/shipments/preference', $transport->lastRequest->getUrl()); + + $this->assertSame('shop-1', get_option('sendy_previously_used_shop_id')); + $this->assertSame('pref-1', get_option('sendy_previously_used_preference_id')); + $this->assertSame('2', get_option('sendy_previously_used_amount')); + } + + public function test_shipments_are_created_with_smart_rules_for_the_sendy_processing_method(): void + { + update_option('sendy_processing_method', ProcessingMethod::Sendy); + set_transient('sendy_shops', ['shop-1' => 'Shop']); + + $order = new Sendy_Fake_Order(1); + + $transport = $this->fake_transport(new Response(200, [], json_encode([ + 'data' => ['uuid' => 'smart-shipment'], + ]))); + + $response = $this->dispatch(['order_ids' => [1]]); + + $this->assertSame([1], $response['created']); + $this->assertSame('smart-shipment', $order->get_meta('_sendy_shipment_id')); + $this->assertStringContainsString('/shipments/smart-rule', $transport->lastRequest->getUrl()); + } + + public function test_orders_whose_creation_fails_are_reported_as_failed(): void + { + $order = new Sendy_Fake_Order(1); + + $this->fake_transport(new Response(422, [], json_encode([ + 'message' => 'The given data was invalid', + 'errors' => ['postal_code' => ['The postal code is invalid']], + ]))); + + $response = $this->dispatch(['order_ids' => [1, 99]]); + + $this->assertSame([], $response['created']); + $this->assertSame([1, 99], $response['failed'], 'Unresolvable orders count as failed'); + + $this->assertFalse($order->meta_exists('_sendy_shipment_id')); + + $messages = get_option('sendy_flash_admin_messages'); + $this->assertSame('error', $messages[0]['type']); + $this->assertStringContainsString('order #1', $messages[0]['message']); + } + + public function test_an_invalid_nonce_is_rejected(): void + { + $response = $this->dispatch(['order_ids' => [1], 'nonce' => 'invalid']); + + $this->assertSame('Nonce verification failed', $response['message']); + } + + public function test_a_user_without_the_required_capabilities_is_rejected(): void + { + wp_set_current_user(self::factory()->user->create(['role' => 'editor'])); + + $response = $this->dispatch(['order_ids' => [1]]); + + $this->assertSame('You do not have sufficient permissions to access this page.', $response['message']); + } + + /** + * Fire the AJAX endpoint and return the decoded JSON response. + * + * @param array $post + * @return array + */ + private function dispatch(array $post): array + { + $_POST = array_merge([ + 'action' => 'sendy_create_shipments', + 'nonce' => wp_create_nonce('sendy_bulk_modal'), + 'shop_id' => 'shop-1', + 'preference_id' => 'pref-1', + 'amount' => '2', + ], $post); + + try { + $this->_handleAjax('sendy_create_shipments'); + $this->fail('The AJAX handler was expected to send a JSON response and die'); + } catch (WPAjaxDieContinueException $exception) { + // wp_send_json() ends in an empty wp_die(). + } + + return json_decode($this->_last_response, true); + } + + /** + * Point ApiClientFactory's memoized connection at canned HTTP responses. + */ + private function fake_transport(Response ...$responses): Sendy_Fake_Transport + { + $transport = new Sendy_Fake_Transport(...$responses); + + update_option('sendy_access_token', 'access-token', false); + update_option('sendy_refresh_token', 'refresh-token', false); + update_option('sendy_token_expires', time() + 3600, false); + + $this->set_api_connection( + (new Connection()) + ->setTransport($transport) + ->setAccessToken('access-token') + ->setRefreshToken('refresh-token') + ->setTokenExpires(time() + 3600) + ); + + return $transport; + } + + private function set_api_connection(?Connection $connection): void + { + $property = new ReflectionProperty(ApiClientFactory::class, 'connection'); + $property->setAccessible(true); + $property->setValue(null, $connection); + } +} diff --git a/tests/ShipmentsStatusTest.php b/tests/ShipmentsStatusTest.php new file mode 100644 index 0000000..9c7ad01 --- /dev/null +++ b/tests/ShipmentsStatusTest.php @@ -0,0 +1,248 @@ +module = new CreateShipments(); + + $user = self::factory()->user->create(['role' => 'administrator']); + wp_set_current_user($user); + wp_get_current_user()->add_cap('manage_woocommerce'); + wp_get_current_user()->add_cap('edit_shop_orders'); + + delete_option('sendy_flash_admin_messages'); + } + + public function tearDown(): void + { + $this->set_api_connection(null); + + parent::tearDown(); + } + + public function test_orders_without_a_shipment_or_that_do_not_resolve_are_reported_as_none(): void + { + new Sendy_Fake_Order(1); + + $response = $this->dispatch(['order_ids' => [1, 2]]); + + $this->assertSame(['1' => 'none', '2' => 'none'], $this->stringKeys($response['statuses'])); + } + + public function test_orders_with_packages_meta_are_ready_without_touching_the_api(): void + { + new Sendy_Fake_Order(1, [ + '_sendy_shipment_id' => 'shipment-1', + '_sendy_packages' => [['package_number' => '3S1', 'tracking_url' => 'https://example.com']], + ]); + + $transport = $this->fake_transport(new Response(200, [], json_encode([]))); + + $response = $this->dispatch(['order_ids' => [1]]); + + $this->assertSame(['1' => 'ready'], $this->stringKeys($response['statuses'])); + $this->assertNull($transport->lastRequest, 'The API must not be called when the packages meta is present'); + } + + public function test_a_generated_shipment_is_ready_and_its_packages_are_persisted(): void + { + $order = new Sendy_Fake_Order(1, ['_sendy_shipment_id' => 'shipment-1']); + + $packages = [['package_number' => '3S1', 'tracking_url' => 'https://example.com']]; + + $transport = $this->fake_transport(new Response(200, [], json_encode([ + 'data' => ['uuid' => 'shipment-1', 'status' => 'generated', 'packages' => $packages], + ]))); + + $response = $this->dispatch(['order_ids' => [1]]); + + $this->assertSame(['1' => 'ready'], $this->stringKeys($response['statuses'])); + $this->assertSame($packages, $order->get_meta('_sendy_packages')); + $this->assertTrue($order->was_saved()); + $this->assertStringContainsString('/shipments/shipment-1', $transport->lastRequest->getUrl()); + } + + public function test_new_and_pending_shipments_are_reported_as_pending(): void + { + new Sendy_Fake_Order(1, ['_sendy_shipment_id' => 'shipment-1']); + new Sendy_Fake_Order(2, ['_sendy_shipment_id' => 'shipment-2']); + + $this->fake_transport( + new Response(200, [], json_encode(['data' => ['uuid' => 'shipment-1', 'status' => 'new', 'packages' => []]])), + new Response(200, [], json_encode(['data' => ['uuid' => 'shipment-2', 'status' => 'pending', 'packages' => []]])), + ); + + $response = $this->dispatch(['order_ids' => [1, 2]]); + + $this->assertSame(['1' => 'pending', '2' => 'pending'], $this->stringKeys($response['statuses'])); + } + + public function test_a_pending_shipment_flashes_no_notice_without_the_flash_pending_flag(): void + { + new Sendy_Fake_Order(1, ['_sendy_shipment_id' => 'shipment-1']); + + $this->fake_transport( + new Response(200, [], json_encode(['data' => ['uuid' => 'shipment-1', 'status' => 'pending', 'packages' => []]])), + ); + + $response = $this->dispatch(['order_ids' => [1]]); + + $this->assertSame(['1' => 'pending'], $this->stringKeys($response['statuses'])); + $this->assertFalse(get_option('sendy_flash_admin_messages')); + } + + public function test_a_pending_shipment_flashes_a_warning_notice_with_the_flash_pending_flag(): void + { + new Sendy_Fake_Order(1, ['_sendy_shipment_id' => 'shipment-1']); + + $this->fake_transport( + new Response(200, [], json_encode(['data' => ['uuid' => 'shipment-1', 'status' => 'pending', 'packages' => []]])), + ); + + $response = $this->dispatch(['order_ids' => [1], 'flash_pending' => '1']); + + $this->assertSame(['1' => 'pending'], $this->stringKeys($response['statuses'])); + + $messages = get_option('sendy_flash_admin_messages'); + $this->assertSame('warning', $messages[0]['type']); + $this->assertStringContainsString('order #1', $messages[0]['message']); + } + + public function test_a_failed_shipment_is_reported_as_failed_with_a_flash_notice(): void + { + new Sendy_Fake_Order(1, ['_sendy_shipment_id' => 'shipment-1']); + + $this->fake_transport(new Response(200, [], json_encode([ + 'data' => ['uuid' => 'shipment-1', 'status' => 'failed', 'packages' => []], + ]))); + + $response = $this->dispatch(['order_ids' => [1]]); + + $this->assertSame(['1' => 'failed'], $this->stringKeys($response['statuses'])); + + $messages = get_option('sendy_flash_admin_messages'); + $this->assertSame('error', $messages[0]['type']); + $this->assertStringContainsString('order #1', $messages[0]['message']); + $this->assertStringContainsString('failed', $messages[0]['message']); + } + + public function test_an_api_error_is_reported_as_failed_with_a_flash_notice(): void + { + new Sendy_Fake_Order(1, ['_sendy_shipment_id' => 'shipment-1']); + + $this->fake_transport(new Response(500, [], json_encode(['message' => 'Whoops']))); + + $response = $this->dispatch(['order_ids' => [1]]); + + $this->assertSame(['1' => 'failed'], $this->stringKeys($response['statuses'])); + + $messages = get_option('sendy_flash_admin_messages'); + $this->assertSame('error', $messages[0]['type']); + } + + public function test_an_invalid_nonce_is_rejected(): void + { + $response = $this->dispatch(['order_ids' => [1], 'nonce' => 'invalid']); + + $this->assertSame('Nonce verification failed', $response['message']); + } + + public function test_a_user_without_the_required_capabilities_is_rejected(): void + { + wp_set_current_user(self::factory()->user->create(['role' => 'editor'])); + + $response = $this->dispatch(['order_ids' => [1]]); + + $this->assertSame('You do not have sufficient permissions to access this page.', $response['message']); + } + + /** + * Fire the AJAX endpoint and return the decoded JSON response. + * + * @param array $post + * @return array + */ + private function dispatch(array $post): array + { + $_POST = array_merge([ + 'action' => 'sendy_shipments_status', + 'nonce' => wp_create_nonce('sendy_print_labels'), + ], $post); + + try { + $this->_handleAjax('sendy_shipments_status'); + $this->fail('The AJAX handler was expected to send a JSON response and die'); + } catch (WPAjaxDieContinueException $exception) { + // wp_send_json() ends in an empty wp_die(). + } + + return json_decode($this->_last_response, true); + } + + /** + * json_decode() turns numeric JSON object keys into PHP int keys; convert + * them back so assertions can use one canonical shape. + * + * @param array $statuses + * @return array + */ + private function stringKeys(array $statuses): array + { + $result = []; + + foreach ($statuses as $key => $value) { + $result[(string) $key] = $value; + } + + return $result; + } + + /** + * Point ApiClientFactory's memoized connection at canned HTTP responses. + */ + private function fake_transport(Response ...$responses): Sendy_Fake_Transport + { + $transport = new Sendy_Fake_Transport(...$responses); + + update_option('sendy_access_token', 'access-token', false); + update_option('sendy_refresh_token', 'refresh-token', false); + update_option('sendy_token_expires', time() + 3600, false); + + $this->set_api_connection( + (new Connection()) + ->setTransport($transport) + ->setAccessToken('access-token') + ->setRefreshToken('refresh-token') + ->setTokenExpires(time() + 3600) + ); + + return $transport; + } + + private function set_api_connection(?Connection $connection): void + { + $property = new ReflectionProperty(ApiClientFactory::class, 'connection'); + $property->setAccessible(true); + $property->setValue(null, $connection); + } +} diff --git a/tests/doubles.php b/tests/doubles.php index 2c9b3ee..5372c87 100644 --- a/tests/doubles.php +++ b/tests/doubles.php @@ -18,7 +18,16 @@ function wc_get_order($order_id) } } -class Sendy_Fake_Order +/** + * Minimal WC_Order stub for type hinting in the test environment. + * WooCommerce is not loaded in the test environment, so we provide + * a base class that the fake order can extend to satisfy type hints. + */ +class WC_Order +{ +} + +class Sendy_Fake_Order extends WC_Order { /** @var array */ private static array $orders = []; @@ -68,6 +77,81 @@ public function get_meta(string $key) return $this->meta[$key] ?? ''; } + public function update_meta_data(string $key, $value): void + { + $this->meta[$key] = $value; + } + + public function get_order_number(): string + { + return (string) $this->id; + } + + public function get_date_created(): DateTimeImmutable + { + return new DateTimeImmutable('2026-01-01T10:00:00+00:00'); + } + + public function get_shipping_first_name(): string + { + return 'John'; + } + + public function get_shipping_last_name(): string + { + return 'Doe'; + } + + public function get_shipping_company(): string + { + return ''; + } + + public function get_shipping_country(): string + { + return 'NL'; + } + + public function get_shipping_address_1(): string + { + return 'Teststraat 12'; + } + + public function get_shipping_address_2(): string + { + return ''; + } + + public function get_shipping_postcode(): string + { + return '1234 AB'; + } + + public function get_shipping_city(): string + { + return 'Amsterdam'; + } + + public function get_shipping_phone(): string + { + return ''; + } + + public function get_billing_email(): string + { + return 'john@example.com'; + } + + public function get_items(): array + { + return []; + } + + public function get_shipping_methods(): array + { + return [new Sendy_Fake_Shipping_Method()]; + } + public function set_status(string $status, string $note = ''): void { $this->status = $status; @@ -95,22 +179,39 @@ public function was_saved(): bool } } +class Sendy_Fake_Shipping_Method +{ + public function get_instance_id(): int + { + return 1; + } +} + class Sendy_Fake_Transport implements TransportInterface { - private Response $response; + /** @var Response[] */ + private array $responses; public ?Request $lastRequest = null; - public function __construct(Response $response) + /** @var Request[] */ + public array $requests = []; + + public function __construct(Response ...$responses) { - $this->response = $response; + $this->responses = $responses; } public function send(Request $request): Response { $this->lastRequest = $request; + $this->requests[] = $request; + + if (count($this->responses) > 1) { + return array_shift($this->responses); + } - return $this->response; + return $this->responses[0]; } public function getUserAgent(): string From cf3ed1ea235b448b034476f1cdbe8f4b86cdab4f Mon Sep 17 00:00:00 2001 From: Adriaan Zonnenberg Date: Thu, 16 Jul 2026 15:38:03 +0200 Subject: [PATCH 02/18] Add print-label buttons to the orders list --- lib/Modules/Orders/BulkActions.php | 7 ++++ lib/Modules/Orders/RowActions.php | 42 ++++++++++++++++++++++++ lib/Plugin.php | 2 ++ resources/css/order-actions.css | 5 +++ tests/RowActionsTest.php | 52 ++++++++++++++++++++++++++++++ 5 files changed, 108 insertions(+) create mode 100644 lib/Modules/Orders/RowActions.php create mode 100644 resources/css/order-actions.css create mode 100644 tests/RowActionsTest.php diff --git a/lib/Modules/Orders/BulkActions.php b/lib/Modules/Orders/BulkActions.php index d84eebc..65c531c 100644 --- a/lib/Modules/Orders/BulkActions.php +++ b/lib/Modules/Orders/BulkActions.php @@ -128,6 +128,13 @@ public function enqueue_assets(): void [], Plugin::VERSION, ); + + wp_enqueue_style( + 'sendy-order-actions', + SENDY_WC_PLUGIN_DIR_URL . '/resources/css/order-actions.css', + [], + Plugin::VERSION, + ); } } diff --git a/lib/Modules/Orders/RowActions.php b/lib/Modules/Orders/RowActions.php new file mode 100644 index 0000000..32c2dd8 --- /dev/null +++ b/lib/Modules/Orders/RowActions.php @@ -0,0 +1,42 @@ +> $actions + * @param \WC_Order $order + * @return array> + */ + public function add_print_label_action(array $actions, $order): array + { + if ($order->meta_exists('_sendy_shipment_id')) { + $actions['sendy_print_label'] = [ + 'url' => '#', + 'name' => __('Sendy - Print label', 'sendy'), + 'action' => 'sendy_print_label', + ]; + } else { + $actions['sendy_create_shipment_print_label'] = [ + 'url' => '#', + 'name' => __('Sendy - Create shipment and print label', 'sendy'), + 'action' => 'sendy_create_shipment_print_label', + ]; + } + + return $actions; + } +} diff --git a/lib/Plugin.php b/lib/Plugin.php index 5296ae4..79832e5 100644 --- a/lib/Plugin.php +++ b/lib/Plugin.php @@ -9,6 +9,7 @@ use Sendy\WooCommerce\Modules\Orders\BulkActions; use Sendy\WooCommerce\Modules\Orders\CreateShipments; use Sendy\WooCommerce\Modules\Orders\PrintLabels; +use Sendy\WooCommerce\Modules\Orders\RowActions; use Sendy\WooCommerce\Modules\Orders\ProcessInBackground; use Sendy\WooCommerce\Modules\Orders\OrderList; use Sendy\WooCommerce\Modules\Orders\Single; @@ -121,6 +122,7 @@ public function initialize_modules(): void $this->modules['orders_single'] = new Single(); $this->modules['orders_print_labels'] = new PrintLabels(); $this->modules['orders_create_shipments'] = new CreateShipments(); + $this->modules['orders_row_actions'] = new RowActions(); $this->modules['checkout'] = new Checkout(); $this->modules['webhooks'] = new Webhooks(); $this->modules['orders_sendy'] = new ProcessInBackground(); diff --git a/resources/css/order-actions.css b/resources/css/order-actions.css new file mode 100644 index 0000000..c7056fe --- /dev/null +++ b/resources/css/order-actions.css @@ -0,0 +1,5 @@ +.column-wc_actions a.sendy_print_label::after, +.column-wc_actions a.sendy_create_shipment_print_label::after { + font-family: Dashicons; + content: '\f193'; +} diff --git a/tests/RowActionsTest.php b/tests/RowActionsTest.php new file mode 100644 index 0000000..8fd9bf9 --- /dev/null +++ b/tests/RowActionsTest.php @@ -0,0 +1,52 @@ + 'shipment-1']); + + $actions = (new RowActions())->add_print_label_action(['complete' => ['action' => 'complete']], $order); + + $this->assertArrayHasKey('complete', $actions, 'Existing actions must be preserved'); + $this->assertSame([ + 'url' => '#', + 'name' => 'Sendy - Print label', + 'action' => 'sendy_print_label', + ], $actions['sendy_print_label']); + $this->assertArrayNotHasKey('sendy_create_shipment_print_label', $actions); + } + + public function test_orders_without_a_shipment_get_a_create_and_print_action(): void + { + $order = new Sendy_Fake_Order(1); + + $actions = (new RowActions())->add_print_label_action([], $order); + + $this->assertSame([ + 'url' => '#', + 'name' => 'Sendy - Create shipment and print label', + 'action' => 'sendy_create_shipment_print_label', + ], $actions['sendy_create_shipment_print_label']); + $this->assertArrayNotHasKey('sendy_print_label', $actions); + } + + public function test_the_filter_is_registered(): void + { + $module = new RowActions(); + + $this->assertSame(10, has_filter('woocommerce_admin_order_actions', [$module, 'add_print_label_action'])); + } +} From fb97dd2bfc61eff9e2bda0fdab1b52b04dcc5e9d Mon Sep 17 00:00:00 2001 From: Adriaan Zonnenberg Date: Thu, 16 Jul 2026 15:38:24 +0200 Subject: [PATCH 03/18] Add the label create-and-print flow to the orders list --- lib/Modules/Orders/BulkActions.php | 15 +- resources/js/admin-order-bulk.js | 12 +- resources/js/orders-list-print-flow.js | 281 +++++++++++++++++++++++++ 3 files changed, 303 insertions(+), 5 deletions(-) create mode 100644 resources/js/orders-list-print-flow.js diff --git a/lib/Modules/Orders/BulkActions.php b/lib/Modules/Orders/BulkActions.php index 65c531c..673bdaa 100644 --- a/lib/Modules/Orders/BulkActions.php +++ b/lib/Modules/Orders/BulkActions.php @@ -114,10 +114,23 @@ public function enqueue_assets(): void true, ); + wp_register_script( + 'sendy-orders-list-print-flow', + SENDY_WC_PLUGIN_DIR_URL . '/resources/js/orders-list-print-flow.js', + ['jquery', 'thickbox', 'sendy-print-labels'], + Plugin::VERSION, + true, + ); + + wp_localize_script('sendy-orders-list-print-flow', 'sendyPrintFlowL10n', [ + 'modalTitle' => __('Sendy - Print labels', 'sendy'), + 'modalButtonText' => __('Create shipments and print labels', 'sendy'), + ]); + wp_enqueue_script( 'sendy-admin-order-bulk', SENDY_WC_PLUGIN_DIR_URL . '/resources/js/admin-order-bulk.js', - ['jquery', 'jquery-blockui', 'sendy-print-labels'], + ['jquery', 'jquery-blockui', 'sendy-orders-list-print-flow'], Plugin::VERSION, true, ); diff --git a/resources/js/admin-order-bulk.js b/resources/js/admin-order-bulk.js index 82ab04d..e3d59ef 100644 --- a/resources/js/admin-order-bulk.js +++ b/resources/js/admin-order-bulk.js @@ -24,8 +24,9 @@ }, /** - * Handle the print labels bulk action client-side so the labels can - * be sent to the print app + * Handle the print labels bulk action client-side so shipments can + * be created for orders that have none and the labels can be sent + * to the print app * * @param {Event} event */ @@ -58,7 +59,9 @@ overlayCSS: { background: '#fff', opacity: 0.6 }, } ); - window.sendyPrintLabels( orderIds ).finally( () => form.unblock() ); + window + .sendyOrdersListPrintFlow( orderIds ) + .finally( () => form.unblock() ); }, /** @@ -86,7 +89,8 @@ thickboxWindow .find( '#sendy-create-shipments-button' ) - .on( 'click', function ( event ) { + .off( 'click.sendyBulk click.sendyPrintFlow' ) + .on( 'click.sendyBulk', function ( event ) { event.preventDefault(); let sendyFieldsContainer = bulkActionsForm diff --git a/resources/js/orders-list-print-flow.js b/resources/js/orders-list-print-flow.js new file mode 100644 index 0000000..cfb95a0 --- /dev/null +++ b/resources/js/orders-list-print-flow.js @@ -0,0 +1,281 @@ +/** + * Create-and-print flow for the orders list. + * + * Checks which orders already have a Sendy shipment, creates shipments for + * the ones that do not (asking for shop/preference/amount through the same + * modal as the "Create shipments" bulk action), waits until the Sendy portal + * has generated the labels, and then prints them through sendyPrintLabels() + * from print-labels.js. Used by both the per-order button in the actions + * column and the "Sendy - Print labels" bulk action. + */ +( function () { + 'use strict'; + + const POLL_INTERVAL = 3000; + const POLL_TIMEOUT = 60000; + + const sleep = ( milliseconds ) => + new Promise( ( resolve ) => setTimeout( resolve, milliseconds ) ); + + /** + * Fetch the shipment status per order: none|pending|ready|failed. + * + * @param {Array} orderIds + * @param {boolean} flagPending When true, asks the server to + * flash a notice for orders + * that come back pending. + * @return {Promise>} + */ + const fetchStatuses = async ( orderIds, flagPending = false ) => { + const body = new URLSearchParams(); + body.append( 'action', 'sendy_shipments_status' ); + body.append( + 'nonce', + document.getElementById( 'sendy-print-labels-nonce' ).value + ); + if ( flagPending ) { + body.append( 'flash_pending', '1' ); + } + for ( const orderId of orderIds ) { + body.append( 'order_ids[]', orderId ); + } + + const response = await fetch( ajaxurl, { method: 'POST', body } ); + if ( ! response.ok ) { + throw new Error( 'Failed to fetch the shipment statuses' ); + } + + return ( await response.json() ).statuses; + }; + + /** + * Create shipments for the given orders. + * + * @param {Array} orderIds + * @param {Object} fields The values collected from the modal + * @return {Promise<{created: number[], failed: number[]}>} + */ + const createShipments = async ( orderIds, fields ) => { + const body = new URLSearchParams(); + body.append( 'action', 'sendy_create_shipments' ); + body.append( 'nonce', fields.nonce ); + body.append( 'shop_id', fields.shopId ); + body.append( 'preference_id', fields.preferenceId ); + body.append( 'amount', fields.amount ); + for ( const orderId of orderIds ) { + body.append( 'order_ids[]', orderId ); + } + + const response = await fetch( ajaxurl, { method: 'POST', body } ); + if ( ! response.ok ) { + throw new Error( 'Failed to create the shipments' ); + } + + return response.json(); + }; + + /** + * Ask for the shop/preference/amount through the same thickbox modal as + * the "Create shipments" bulk action. Resolves with the chosen values, + * or null when the modal is dismissed. + * + * @return {Promise} + */ + const openCreateShipmentsModal = () => + new Promise( ( resolve ) => { + if ( ! document.getElementById( 'sendy-create-shipments-modal' ) ) { + // The modal is replaced by a connection error notice when + // the Sendy API is unreachable. + resolve( null ); + return; + } + + tb_show( + '', + '/?TB_inline=true&width=420&height=320&inlineId=sendy-create-shipments-modal' + ); + + const thickboxWindow = jQuery( '#TB_window' ); + const button = thickboxWindow.find( + '#sendy-create-shipments-button' + ); + const originalButtonText = button.text(); + + thickboxWindow + .find( '#TB_ajaxWindowTitle' ) + .text( sendyPrintFlowL10n.modalTitle ); + button.text( sendyPrintFlowL10n.modalButtonText ); + + // Thickbox moves the inline content back into the page when the + // modal closes; undo our changes so the "Create shipments" bulk + // action shows its own button text, and treat a dismissal as a + // cancel (a no-op when the confirm handler already resolved). + jQuery( 'body' ).one( 'thickbox:removed', function () { + button + .text( originalButtonText ) + .prop( 'disabled', false ) + .off( 'click.sendyPrintFlow' ); + resolve( null ); + } ); + + button + .off( 'click.sendyPrintFlow click.sendyBulk' ) + .on( 'click.sendyPrintFlow', function ( event ) { + event.preventDefault(); + + jQuery( this ).prop( 'disabled', true ); + + resolve( { + shopId: thickboxWindow + .find( '#sendy_shop_id :selected' ) + .val(), + preferenceId: + thickboxWindow + .find( '#sendy_preference_id :selected' ) + .val() ?? '', + amount: + thickboxWindow.find( '#sendy_amount' ).val() ?? '', + nonce: thickboxWindow + .find( '#sendy_bulk_modal_nonce' ) + .val(), + } ); + + tb_remove(); + } ); + } ); + + /** + * Poll until the labels for the given orders are available and return + * the ids of the orders whose labels can be printed. Orders that end up + * without a shipment or with a failed shipment are dropped; orders still + * pending when the timeout passes are dropped too. + * + * @param {Array} orderIds + * @return {Promise} + */ + const waitUntilReady = async ( orderIds ) => { + const deadline = Date.now() + POLL_TIMEOUT; + let pending = orderIds.map( String ); + const ready = []; + + while ( pending.length ) { + const statuses = await fetchStatuses( pending ); + + pending = []; + + for ( const [ orderId, status ] of Object.entries( statuses ) ) { + if ( status === 'ready' ) { + ready.push( orderId ); + } else if ( status === 'pending' ) { + pending.push( orderId ); + } + } + + if ( ! pending.length || Date.now() >= deadline ) { + break; + } + + await sleep( POLL_INTERVAL ); + } + + if ( pending.length ) { + await fetchStatuses( pending, true ).catch( () => {} ); + } + + return ready; + }; + + /** + * Print the labels of the given orders, creating shipments first for the + * orders that have none. + * + * @param {Array} orderIds + */ + const runPrintFlow = async ( orderIds ) => { + const statuses = await fetchStatuses( orderIds ); + const withoutShipment = orderIds.filter( + ( orderId ) => statuses[ orderId ] === 'none' + ); + // Orders that are already in a terminal failed state have flashed a + // notice; drop them instead of re-checking (and re-flashing) later. + const printable = orderIds.filter( + ( orderId ) => statuses[ orderId ] !== 'failed' + ); + + let createdShipments = false; + + if ( withoutShipment.length ) { + const fields = await openCreateShipmentsModal(); + + if ( ! fields ) { + return; + } + + const result = await createShipments( withoutShipment, fields ); + createdShipments = result.created.length > 0; + + if ( + ! result.created.length && + withoutShipment.length === orderIds.length + ) { + // Nothing can be printed; reload to show the flash notices + // explaining what went wrong. + window.location.reload(); + return; + } + } + + const ready = await waitUntilReady( printable ); + + if ( ready.length ) { + await window.sendyPrintLabels( ready ); + } + + if ( createdShipments || ready.length !== orderIds.length ) { + // Show the new shipment data and any flash notices for the + // orders that could not be printed. + window.location.reload(); + } + }; + + /** + * Print the labels of the given orders, creating shipments first for the + * orders that have none. The returned promise never rejects, so callers + * can chain finally() without handling errors themselves. + * + * @param {Array} orderIds + * @return {Promise} + */ + window.sendyOrdersListPrintFlow = ( orderIds ) => + runPrintFlow( orderIds ).catch( ( error ) => { + console.error( 'Sendy: printing labels failed', error ); + } ); + + // The per-order print buttons in the actions column. The order id comes + // from the row's bulk-selection checkbox (id[] on the HPOS table, post[] + // on the legacy table). + jQuery( document ).on( + 'click', + '.wc-action-button-sendy_print_label, .wc-action-button-sendy_create_shipment_print_label', + function ( event ) { + event.preventDefault(); + + const button = jQuery( this ); + + if ( button.hasClass( 'disabled' ) ) { + return; + } + + const orderId = button + .closest( 'tr' ) + .find( 'input[name="id[]"], input[name="post[]"]' ) + .val(); + + button.addClass( 'disabled' ); + + window + .sendyOrdersListPrintFlow( [ orderId ] ) + .finally( () => button.removeClass( 'disabled' ) ); + } + ); +} )(); From 3a5f40488096137f56ae6c954b4d92c55eff506b Mon Sep 17 00:00:00 2001 From: Adriaan Zonnenberg Date: Tue, 28 Jul 2026 12:16:07 +0200 Subject: [PATCH 04/18] Set minimum WordPress version to 6.2 Matches the minimum supported WordPress version of WooCommerce 8.2. --- sendy.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sendy.php b/sendy.php index a61d23b..cb22e45 100644 --- a/sendy.php +++ b/sendy.php @@ -10,7 +10,7 @@ * License: MIT * Text Domain: sendy * Domain Path: /languages - * Requires at least: 5.2 + * Requires at least: 6.2 * Tested up to: 7.0 * Requires PHP: 7.4 * Requires Plugins: woocommerce From 9ac8e57a17c047a2cf365038ab898da1ee4b5146 Mon Sep 17 00:00:00 2001 From: Adriaan Zonnenberg Date: Tue, 28 Jul 2026 12:18:40 +0200 Subject: [PATCH 05/18] Invalidate asset cache in development --- docker/provision.sh | 5 +++++ lib/Modules/Admin/Settings.php | 2 +- lib/Modules/Checkout.php | 2 +- lib/Modules/Orders/BulkActions.php | 10 +++++----- lib/Modules/Orders/Single.php | 4 ++-- lib/Plugin.php | 19 +++++++++++++++++++ lib/Utils/BlocksIntegration.php | 2 +- tests/AssetVersionTest.php | 29 +++++++++++++++++++++++++++++ tests/bootstrap.php | 4 ++++ 9 files changed, 67 insertions(+), 10 deletions(-) create mode 100644 tests/AssetVersionTest.php diff --git a/docker/provision.sh b/docker/provision.sh index 9f5eae2..9cd04e1 100644 --- a/docker/provision.sh +++ b/docker/provision.sh @@ -27,6 +27,11 @@ if [ "${WP_VERSION:-latest}" = "latest" ]; then wp core update-db fi +echo "==> Enabling SCRIPT_DEBUG" +# Version local plugin assets by file mtime (see Plugin::asset_version) so the +# browser never serves stale JS/CSS while developing on an unchanged version. +wp config set SCRIPT_DEBUG true --raw + echo "==> Installing locale $locale" wp language core install "$locale" --activate \ || echo "Warning: could not install core locale $locale" >&2 diff --git a/lib/Modules/Admin/Settings.php b/lib/Modules/Admin/Settings.php index 2c7744c..c28e167 100644 --- a/lib/Modules/Admin/Settings.php +++ b/lib/Modules/Admin/Settings.php @@ -32,7 +32,7 @@ public function enqueue_assets(): void 'sendy-admin-settings', SENDY_WC_PLUGIN_DIR_URL . '/resources/js/admin-settings.js', [], - Plugin::VERSION, + Plugin::asset_version('/resources/js/admin-settings.js'), true, ); } diff --git a/lib/Modules/Checkout.php b/lib/Modules/Checkout.php index b1f5fa2..e4a0b25 100644 --- a/lib/Modules/Checkout.php +++ b/lib/Modules/Checkout.php @@ -32,7 +32,7 @@ public function enqueue_assets(): void if (is_checkout()) { wp_enqueue_script('wp-util'); wp_enqueue_script('sendy-api', 'https://app.sendy.nl/embed/api.js', [], Plugin::VERSION, ['in_footer' => true]); - wp_enqueue_script('sendy-checkout', SENDY_WC_PLUGIN_DIR_URL . '/resources/js/checkout.js', ['jquery', 'sendy-api'], Plugin::VERSION, ['in_footer' => true]); + wp_enqueue_script('sendy-checkout', SENDY_WC_PLUGIN_DIR_URL . '/resources/js/checkout.js', ['jquery', 'sendy-api'], Plugin::asset_version('/resources/js/checkout.js'), ['in_footer' => true]); } } diff --git a/lib/Modules/Orders/BulkActions.php b/lib/Modules/Orders/BulkActions.php index 673bdaa..d567d44 100644 --- a/lib/Modules/Orders/BulkActions.php +++ b/lib/Modules/Orders/BulkActions.php @@ -110,7 +110,7 @@ public function enqueue_assets(): void 'sendy-print-labels', SENDY_WC_PLUGIN_DIR_URL . '/resources/js/print-labels.js', [], - Plugin::VERSION, + Plugin::asset_version('/resources/js/print-labels.js'), true, ); @@ -118,7 +118,7 @@ public function enqueue_assets(): void 'sendy-orders-list-print-flow', SENDY_WC_PLUGIN_DIR_URL . '/resources/js/orders-list-print-flow.js', ['jquery', 'thickbox', 'sendy-print-labels'], - Plugin::VERSION, + Plugin::asset_version('/resources/js/orders-list-print-flow.js'), true, ); @@ -131,7 +131,7 @@ public function enqueue_assets(): void 'sendy-admin-order-bulk', SENDY_WC_PLUGIN_DIR_URL . '/resources/js/admin-order-bulk.js', ['jquery', 'jquery-blockui', 'sendy-orders-list-print-flow'], - Plugin::VERSION, + Plugin::asset_version('/resources/js/admin-order-bulk.js'), true, ); @@ -139,14 +139,14 @@ public function enqueue_assets(): void 'sendy-admin-order-bulk', SENDY_WC_PLUGIN_DIR_URL . '/resources/css/modal.css', [], - Plugin::VERSION, + Plugin::asset_version('/resources/css/modal.css'), ); wp_enqueue_style( 'sendy-order-actions', SENDY_WC_PLUGIN_DIR_URL . '/resources/css/order-actions.css', [], - Plugin::VERSION, + Plugin::asset_version('/resources/css/order-actions.css'), ); } } diff --git a/lib/Modules/Orders/Single.php b/lib/Modules/Orders/Single.php index 3361b96..a90283f 100644 --- a/lib/Modules/Orders/Single.php +++ b/lib/Modules/Orders/Single.php @@ -93,7 +93,7 @@ public function enqueue_assets(): void 'sendy-print-labels', SENDY_WC_PLUGIN_DIR_URL . '/resources/js/print-labels.js', [], - Plugin::VERSION, + Plugin::asset_version('/resources/js/print-labels.js'), true, ); @@ -101,7 +101,7 @@ public function enqueue_assets(): void 'sendy-admin-order-single', SENDY_WC_PLUGIN_DIR_URL . '/resources/js/admin-order-single.js', ['sendy-print-labels'], - Plugin::VERSION, + Plugin::asset_version('/resources/js/admin-order-single.js'), true, ); } diff --git a/lib/Plugin.php b/lib/Plugin.php index 79832e5..8390dd3 100644 --- a/lib/Plugin.php +++ b/lib/Plugin.php @@ -40,6 +40,25 @@ public static function instance(): Plugin return self::$instance ??= new self(); } + /** + * Version string for cache-busting a local plugin asset + * + * Releases invalidate browser caches through the plugin version, but that + * version does not change between development edits; with SCRIPT_DEBUG + * enabled the file's modification time is used instead so the browser + * cannot serve a stale copy. + */ + public static function asset_version(string $path): string + { + $file = dirname(SENDY_WC_PLUGIN_FILE) . $path; + + if (defined('SCRIPT_DEBUG') && SCRIPT_DEBUG && file_exists($file)) { + return (string) filemtime($file); + } + + return self::VERSION; + } + public function initialize_plugin(): void { $this->set_default_values_for_settings(); diff --git a/lib/Utils/BlocksIntegration.php b/lib/Utils/BlocksIntegration.php index fe8d7d5..5aa6c3c 100644 --- a/lib/Utils/BlocksIntegration.php +++ b/lib/Utils/BlocksIntegration.php @@ -51,7 +51,7 @@ private function register_block_frontend_scripts() 'sendy-checkout-block-frontend-styling', SENDY_WC_PLUGIN_DIR_URL . '/resources/css/frontend.css', [], - Plugin::VERSION, + Plugin::asset_version('/resources/css/frontend.css'), ); wp_register_script( diff --git a/tests/AssetVersionTest.php b/tests/AssetVersionTest.php new file mode 100644 index 0000000..44ea76c --- /dev/null +++ b/tests/AssetVersionTest.php @@ -0,0 +1,29 @@ +assertSame(Plugin::VERSION, Plugin::asset_version('/resources/js/does-not-exist.js')); + } + + public function test_existing_files_are_versioned_by_mtime_only_with_script_debug(): void + { + $path = '/resources/js/print-labels.js'; + + $expected = defined('SCRIPT_DEBUG') && SCRIPT_DEBUG + ? (string) filemtime(dirname(SENDY_WC_PLUGIN_FILE) . $path) + : Plugin::VERSION; + + $this->assertSame($expected, Plugin::asset_version($path)); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 8bc77ac..485008e 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -10,6 +10,10 @@ require_once dirname(__DIR__) . '/vendor/autoload.php'; +if (! defined('SENDY_WC_PLUGIN_FILE')) { + define('SENDY_WC_PLUGIN_FILE', dirname(__DIR__) . '/sendy.php'); +} + $_tests_dir = getenv('WP_TESTS_DIR'); if (! $_tests_dir) { From 0f6787aaa01e1a19527cf6f97161ae3b726ed38c Mon Sep 17 00:00:00 2001 From: Adriaan Zonnenberg Date: Tue, 28 Jul 2026 13:07:38 +0200 Subject: [PATCH 06/18] Improve loading state --- resources/css/order-actions.css | 28 ++++++++++++++++++++++++-- resources/js/orders-list-print-flow.js | 8 ++++++-- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/resources/css/order-actions.css b/resources/css/order-actions.css index c7056fe..238bafc 100644 --- a/resources/css/order-actions.css +++ b/resources/css/order-actions.css @@ -1,5 +1,29 @@ .column-wc_actions a.sendy_print_label::after, .column-wc_actions a.sendy_create_shipment_print_label::after { - font-family: Dashicons; - content: '\f193'; + font-family: Dashicons; + content: "\f193"; +} + +/* Busy state while the create-and-print flow runs (up to a minute). */ +.column-wc_actions a.sendy_print_label.disabled::after, +.column-wc_actions a.sendy_create_shipment_print_label.disabled::after { + content: "\f463"; + opacity: 0.6; +} + +@media (prefers-reduced-motion: no-preference) { + .column-wc_actions a.sendy_print_label.disabled::after, + .column-wc_actions a.sendy_create_shipment_print_label.disabled::after { + animation: sendy-action-busy 1.5s linear infinite; + } +} + +@keyframes sendy-action-busy { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } } diff --git a/resources/js/orders-list-print-flow.js b/resources/js/orders-list-print-flow.js index cfb95a0..738a38a 100644 --- a/resources/js/orders-list-print-flow.js +++ b/resources/js/orders-list-print-flow.js @@ -271,11 +271,15 @@ .find( 'input[name="id[]"], input[name="post[]"]' ) .val(); - button.addClass( 'disabled' ); + button.addClass( 'disabled' ).attr( 'aria-disabled', 'true' ); window .sendyOrdersListPrintFlow( [ orderId ] ) - .finally( () => button.removeClass( 'disabled' ) ); + .finally( () => + button + .removeClass( 'disabled' ) + .removeAttr( 'aria-disabled' ) + ); } ); } )(); From 51e6470493dafea5b555b00118915ac904b76a30 Mon Sep 17 00:00:00 2001 From: Adriaan Zonnenberg Date: Tue, 28 Jul 2026 14:17:47 +0200 Subject: [PATCH 07/18] Simplify --- lib/Modules/Orders/BulkActions.php | 28 ++---- lib/Modules/Orders/CreateShipments.php | 53 +++++----- lib/Modules/Orders/OrdersModule.php | 26 ++++- lib/Modules/Orders/Single.php | 21 ++-- resources/js/orders-list-print-flow.js | 112 +++++++++++---------- tests/CreateShipmentsTest.php | 38 ++----- tests/ShipmentsStatusTest.php | 31 +----- tests/SingleCreateShipmentTest.php | 133 +++++++++++++++++++++++++ tests/doubles.php | 37 +++++++ 9 files changed, 307 insertions(+), 172 deletions(-) create mode 100644 tests/SingleCreateShipmentTest.php diff --git a/lib/Modules/Orders/BulkActions.php b/lib/Modules/Orders/BulkActions.php index d567d44..95cd5a4 100644 --- a/lib/Modules/Orders/BulkActions.php +++ b/lib/Modules/Orders/BulkActions.php @@ -55,27 +55,21 @@ public function handle_bulk_action_create_shipments(string $redirect, string $ac } foreach ($objectIds as $id) { - if (get_option('sendy_processing_method') === ProcessingMethod::WooCommerce) { - $this->create_shipment_from_order( - wc_get_order($id), - sanitize_key($_REQUEST['sendy_preference_id'] ?? ''), - sanitize_key($_REQUEST['sendy_shop_id'] ?? ''), - sanitize_key($_REQUEST['sendy_amount'] ?? ''), - ); - - update_option('sendy_previously_used_preference_id', sanitize_key($_REQUEST['sendy_preference_id'] ?? '')); - update_option('sendy_previously_used_amount', sanitize_key($_REQUEST['sendy_amount'] ?? '')); - } else { - $this->create_shipment_with_smart_rules( - wc_get_order($id), - false, - sanitize_key($_REQUEST['sendy_shop_id'] ?? ''), - ); - } + $this->create_shipment( + wc_get_order($id), + sanitize_key($_REQUEST['sendy_shop_id'] ?? ''), + sanitize_key($_REQUEST['sendy_preference_id'] ?? ''), + (int) sanitize_key($_REQUEST['sendy_amount'] ?? ''), + ); } update_option('sendy_previously_used_shop_id', sanitize_key($_REQUEST['sendy_shop_id'] ?? '')); + if (get_option('sendy_processing_method') === ProcessingMethod::WooCommerce) { + update_option('sendy_previously_used_preference_id', sanitize_key($_REQUEST['sendy_preference_id'] ?? '')); + update_option('sendy_previously_used_amount', sanitize_key($_REQUEST['sendy_amount'] ?? '')); + } + return $redirect; } diff --git a/lib/Modules/Orders/CreateShipments.php b/lib/Modules/Orders/CreateShipments.php index 77d3f6e..a8fe9b9 100644 --- a/lib/Modules/Orders/CreateShipments.php +++ b/lib/Modules/Orders/CreateShipments.php @@ -27,13 +27,11 @@ public function __construct() */ public function handle_create_shipments(): void { - if (! check_ajax_referer('sendy_bulk_modal', 'nonce', false)) { - wp_send_json(['message' => __('Nonce verification failed', 'sendy')], 403); - } + $this->verify_ajax_request('sendy_bulk_modal'); - if (! current_user_can('manage_woocommerce') || ! current_user_can('edit_shop_orders')) { - wp_send_json(['message' => __('You do not have sufficient permissions to access this page.', 'sendy')], 403); - } + $shopId = sanitize_key($_POST['shop_id'] ?? ''); + $preferenceId = sanitize_key($_POST['preference_id'] ?? ''); + $amount = sanitize_key($_POST['amount'] ?? '1'); $created = []; $failed = []; @@ -53,20 +51,7 @@ public function handle_create_shipments(): void } try { - if (get_option('sendy_processing_method') === ProcessingMethod::WooCommerce) { - $this->create_shipment_from_order( - $order, - sanitize_key($_POST['preference_id'] ?? ''), - sanitize_key($_POST['shop_id'] ?? ''), - (int) sanitize_key($_POST['amount'] ?? '1'), - ); - } else { - $this->create_shipment_with_smart_rules( - $order, - false, - sanitize_key($_POST['shop_id'] ?? ''), - ); - } + $this->create_shipment($order, $shopId, $preferenceId, (int) $amount); } catch (SendyException $exception) { // The creation methods flash API validation errors themselves; // this guards the calls they make outside their own try/catch, @@ -87,11 +72,11 @@ public function handle_create_shipments(): void } if ($created !== []) { - update_option('sendy_previously_used_shop_id', sanitize_key($_POST['shop_id'] ?? '')); + update_option('sendy_previously_used_shop_id', $shopId); if (get_option('sendy_processing_method') === ProcessingMethod::WooCommerce) { - update_option('sendy_previously_used_preference_id', sanitize_key($_POST['preference_id'] ?? '')); - update_option('sendy_previously_used_amount', sanitize_key($_POST['amount'] ?? '')); + update_option('sendy_previously_used_preference_id', $preferenceId); + update_option('sendy_previously_used_amount', $amount); } } @@ -110,13 +95,7 @@ public function handle_create_shipments(): void */ public function handle_shipments_status(): void { - if (! check_ajax_referer('sendy_print_labels', 'nonce', false)) { - wp_send_json(['message' => __('Nonce verification failed', 'sendy')], 403); - } - - if (! current_user_can('manage_woocommerce') || ! current_user_can('edit_shop_orders')) { - wp_send_json(['message' => __('You do not have sufficient permissions to access this page.', 'sendy')], 403); - } + $this->verify_ajax_request('sendy_print_labels'); $statuses = []; @@ -139,6 +118,20 @@ public function handle_shipments_status(): void wp_send_json(['statuses' => $statuses]); } + /** + * Send a 403 JSON response when the nonce or the capabilities do not check out + */ + private function verify_ajax_request(string $nonceAction): void + { + if (! check_ajax_referer($nonceAction, 'nonce', false)) { + wp_send_json(['message' => __('Nonce verification failed', 'sendy')], 403); + } + + if (! current_user_can('manage_woocommerce') || ! current_user_can('edit_shop_orders')) { + wp_send_json(['message' => __('You do not have sufficient permissions to access this page.', 'sendy')], 403); + } + } + /** * Determine the print status of a single order's shipment * diff --git a/lib/Modules/Orders/OrdersModule.php b/lib/Modules/Orders/OrdersModule.php index f6526f2..2a29507 100644 --- a/lib/Modules/Orders/OrdersModule.php +++ b/lib/Modules/Orders/OrdersModule.php @@ -5,6 +5,7 @@ use GuzzleHttp\Exception\GuzzleException; use Sendy\Api\ApiException; use Sendy\WooCommerce\ApiClientFactory; +use Sendy\WooCommerce\Enums\ProcessingMethod; use Sendy\WooCommerce\Repositories\Shops; abstract class OrdersModule @@ -37,14 +38,35 @@ protected function is_pickup_point_delivery(\WC_Order $order): bool } /** - * Create the order in the Sendy API + * Create a shipment for the order through the configured processing method + * + * The WooCommerce processing method creates the shipment from the given + * preference; the Sendy processing method leaves the choice of carrier to + * the smart rules configured in the portal. * + * @param string $shopId The UUID of the selected shop * @param string $preferenceId The UUID of the selected shipping preference + * @param int $amount The amount of packages the shipment should contain + * @throws GuzzleException + */ + protected function create_shipment(\WC_Order $order, string $shopId, string $preferenceId, int $amount): void + { + if (get_option('sendy_processing_method') === ProcessingMethod::WooCommerce) { + $this->create_shipment_from_order($order, $shopId, $preferenceId, $amount); + } else { + $this->create_shipment_with_smart_rules($order, false, $shopId); + } + } + + /** + * Create the order in the Sendy API + * * @param string $shopId The UUID of the selected shop + * @param string $preferenceId The UUID of the selected shipping preference * @param int $amount The amount of packages the shipment should contain * @throws GuzzleException */ - protected function create_shipment_from_order(\WC_Order $order, string $preferenceId, string $shopId, int $amount): void + protected function create_shipment_from_order(\WC_Order $order, string $shopId, string $preferenceId, int $amount): void { if ($order->meta_exists('_sendy_shipment_id')) { // translators: %s The ID of the order diff --git a/lib/Modules/Orders/Single.php b/lib/Modules/Orders/Single.php index a90283f..c37a6d5 100644 --- a/lib/Modules/Orders/Single.php +++ b/lib/Modules/Orders/Single.php @@ -4,7 +4,6 @@ use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController; use Sendy\Api\Exceptions\SendyException; -use Sendy\WooCommerce\Enums\ProcessingMethod; use Sendy\WooCommerce\Plugin; use Sendy\WooCommerce\Repositories\Preferences; use Sendy\WooCommerce\Repositories\Shops; @@ -126,20 +125,12 @@ public function handle_create_shipment_from_form(): void if (! empty($_REQUEST['order_id'])) { $order = wc_get_order(sanitize_key($_REQUEST['order_id'])); - if (get_option('sendy_processing_method') === ProcessingMethod::WooCommerce) { - $this->create_shipment_from_order( - $order, - sanitize_key($_REQUEST['preference_id'] ?? ''), - sanitize_key($_REQUEST['shop_id'] ?? ''), - sanitize_key($_REQUEST['amount'] ?? ''), - ); - } else { - $this->create_shipment_with_smart_rules( - $order, - false, - sanitize_key($_REQUEST['shop_id'] ?? ''), - ); - } + $this->create_shipment( + $order, + sanitize_key($_REQUEST['shop_id'] ?? ''), + sanitize_key($_REQUEST['preference_id'] ?? ''), + (int) sanitize_key($_REQUEST['amount'] ?? '1'), + ); wp_send_json_success(); } diff --git a/resources/js/orders-list-print-flow.js b/resources/js/orders-list-print-flow.js index 738a38a..502bf67 100644 --- a/resources/js/orders-list-print-flow.js +++ b/resources/js/orders-list-print-flow.js @@ -17,6 +17,27 @@ const sleep = ( milliseconds ) => new Promise( ( resolve ) => setTimeout( resolve, milliseconds ) ); + /** + * POST to admin-ajax for the given orders and decode the JSON response. + * + * @param {Object} params Form fields, including action. + * @param {Array} orderIds + * @return {Promise} + */ + const ajaxPost = async ( params, orderIds ) => { + const body = new URLSearchParams( params ); + for ( const orderId of orderIds ) { + body.append( 'order_ids[]', orderId ); + } + + const response = await fetch( ajaxurl, { method: 'POST', body } ); + if ( ! response.ok ) { + throw new Error( `The ${ params.action } request failed` ); + } + + return response.json(); + }; + /** * Fetch the shipment status per order: none|pending|ready|failed. * @@ -27,25 +48,16 @@ * @return {Promise>} */ const fetchStatuses = async ( orderIds, flagPending = false ) => { - const body = new URLSearchParams(); - body.append( 'action', 'sendy_shipments_status' ); - body.append( - 'nonce', - document.getElementById( 'sendy-print-labels-nonce' ).value - ); + const params = { + action: 'sendy_shipments_status', + nonce: document.getElementById( 'sendy-print-labels-nonce' ) + .value, + }; if ( flagPending ) { - body.append( 'flash_pending', '1' ); - } - for ( const orderId of orderIds ) { - body.append( 'order_ids[]', orderId ); - } - - const response = await fetch( ajaxurl, { method: 'POST', body } ); - if ( ! response.ok ) { - throw new Error( 'Failed to fetch the shipment statuses' ); + params.flash_pending = '1'; } - return ( await response.json() ).statuses; + return ( await ajaxPost( params, orderIds ) ).statuses; }; /** @@ -55,24 +67,17 @@ * @param {Object} fields The values collected from the modal * @return {Promise<{created: number[], failed: number[]}>} */ - const createShipments = async ( orderIds, fields ) => { - const body = new URLSearchParams(); - body.append( 'action', 'sendy_create_shipments' ); - body.append( 'nonce', fields.nonce ); - body.append( 'shop_id', fields.shopId ); - body.append( 'preference_id', fields.preferenceId ); - body.append( 'amount', fields.amount ); - for ( const orderId of orderIds ) { - body.append( 'order_ids[]', orderId ); - } - - const response = await fetch( ajaxurl, { method: 'POST', body } ); - if ( ! response.ok ) { - throw new Error( 'Failed to create the shipments' ); - } - - return response.json(); - }; + const createShipments = ( orderIds, fields ) => + ajaxPost( + { + action: 'sendy_create_shipments', + nonce: fields.nonce, + shop_id: fields.shopId, + preference_id: fields.preferenceId, + amount: fields.amount, + }, + orderIds + ); /** * Ask for the shop/preference/amount through the same thickbox modal as @@ -159,7 +164,10 @@ const ready = []; while ( pending.length ) { - const statuses = await fetchStatuses( pending ); + // On the last poll before the deadline, have the server flash a + // notice for the orders that are still pending afterwards. + const isLastPoll = Date.now() + POLL_INTERVAL > deadline; + const statuses = await fetchStatuses( pending, isLastPoll ); pending = []; @@ -171,17 +179,13 @@ } } - if ( ! pending.length || Date.now() >= deadline ) { + if ( ! pending.length || isLastPoll ) { break; } await sleep( POLL_INTERVAL ); } - if ( pending.length ) { - await fetchStatuses( pending, true ).catch( () => {} ); - } - return ready; }; @@ -192,15 +196,19 @@ * @param {Array} orderIds */ const runPrintFlow = async ( orderIds ) => { - const statuses = await fetchStatuses( orderIds ); - const withoutShipment = orderIds.filter( - ( orderId ) => statuses[ orderId ] === 'none' - ); - // Orders that are already in a terminal failed state have flashed a - // notice; drop them instead of re-checking (and re-flashing) later. - const printable = orderIds.filter( - ( orderId ) => statuses[ orderId ] !== 'failed' - ); + const statuses = await fetchStatuses( orderIds.map( String ) ); + const byStatus = ( wanted ) => + Object.keys( statuses ).filter( + ( orderId ) => statuses[ orderId ] === wanted + ); + + const withoutShipment = byStatus( 'none' ); + const ready = byStatus( 'ready' ); + // Orders whose labels still need waiting on: the ones already being + // generated plus the shipments created below. Orders in a terminal + // failed state have flashed a notice; drop them instead of + // re-checking (and re-flashing) later. + const pending = byStatus( 'pending' ); let createdShipments = false; @@ -223,9 +231,13 @@ window.location.reload(); return; } + + pending.push( ...result.created.map( String ) ); } - const ready = await waitUntilReady( printable ); + if ( pending.length ) { + ready.push( ...( await waitUntilReady( pending ) ) ); + } if ( ready.length ) { await window.sendyPrintLabels( ready ); diff --git a/tests/CreateShipmentsTest.php b/tests/CreateShipmentsTest.php index 77a8818..e150889 100644 --- a/tests/CreateShipmentsTest.php +++ b/tests/CreateShipmentsTest.php @@ -16,6 +16,8 @@ */ class CreateShipmentsTest extends WP_Ajax_UnitTestCase { + use Sendy_Fakes_Api_Connection; + private CreateShipments $module; public function setUp(): void @@ -65,6 +67,13 @@ public function test_shipments_are_created_through_the_preference_for_orders_wit $this->assertCount(1, $transport->requests, 'Orders that already have a shipment must not trigger an API call'); $this->assertStringContainsString('/shipments/preference', $transport->lastRequest->getUrl()); + // The shop and the preference are both UUID strings, so swapping them + // anywhere along the call chain would still type check. + $payload = json_decode($transport->lastRequest->getBody(), true); + $this->assertSame('shop-1', $payload['shop_id']); + $this->assertSame('pref-1', $payload['preference_id']); + $this->assertSame(2, $payload['amount']); + $this->assertSame('shop-1', get_option('sendy_previously_used_shop_id')); $this->assertSame('pref-1', get_option('sendy_previously_used_preference_id')); $this->assertSame('2', get_option('sendy_previously_used_amount')); @@ -150,33 +159,4 @@ private function dispatch(array $post): array return json_decode($this->_last_response, true); } - - /** - * Point ApiClientFactory's memoized connection at canned HTTP responses. - */ - private function fake_transport(Response ...$responses): Sendy_Fake_Transport - { - $transport = new Sendy_Fake_Transport(...$responses); - - update_option('sendy_access_token', 'access-token', false); - update_option('sendy_refresh_token', 'refresh-token', false); - update_option('sendy_token_expires', time() + 3600, false); - - $this->set_api_connection( - (new Connection()) - ->setTransport($transport) - ->setAccessToken('access-token') - ->setRefreshToken('refresh-token') - ->setTokenExpires(time() + 3600) - ); - - return $transport; - } - - private function set_api_connection(?Connection $connection): void - { - $property = new ReflectionProperty(ApiClientFactory::class, 'connection'); - $property->setAccessible(true); - $property->setValue(null, $connection); - } } diff --git a/tests/ShipmentsStatusTest.php b/tests/ShipmentsStatusTest.php index 9c7ad01..e9cb514 100644 --- a/tests/ShipmentsStatusTest.php +++ b/tests/ShipmentsStatusTest.php @@ -15,6 +15,8 @@ */ class ShipmentsStatusTest extends WP_Ajax_UnitTestCase { + use Sendy_Fakes_Api_Connection; + private CreateShipments $module; public function setUp(): void @@ -216,33 +218,4 @@ private function stringKeys(array $statuses): array return $result; } - - /** - * Point ApiClientFactory's memoized connection at canned HTTP responses. - */ - private function fake_transport(Response ...$responses): Sendy_Fake_Transport - { - $transport = new Sendy_Fake_Transport(...$responses); - - update_option('sendy_access_token', 'access-token', false); - update_option('sendy_refresh_token', 'refresh-token', false); - update_option('sendy_token_expires', time() + 3600, false); - - $this->set_api_connection( - (new Connection()) - ->setTransport($transport) - ->setAccessToken('access-token') - ->setRefreshToken('refresh-token') - ->setTokenExpires(time() + 3600) - ); - - return $transport; - } - - private function set_api_connection(?Connection $connection): void - { - $property = new ReflectionProperty(ApiClientFactory::class, 'connection'); - $property->setAccessible(true); - $property->setValue(null, $connection); - } } diff --git a/tests/SingleCreateShipmentTest.php b/tests/SingleCreateShipmentTest.php new file mode 100644 index 0000000..f50a8b0 --- /dev/null +++ b/tests/SingleCreateShipmentTest.php @@ -0,0 +1,133 @@ +user->create(['role' => 'administrator']); + wp_set_current_user($user); + wp_get_current_user()->add_cap('manage_woocommerce'); + + update_option('sendy_processing_method', ProcessingMethod::WooCommerce); + delete_option('sendy_flash_admin_messages'); + } + + public function tearDown(): void + { + $this->set_api_connection(null); + + parent::tearDown(); + } + + public function test_the_shipment_is_created_from_the_chosen_preference(): void + { + $order = new Sendy_Fake_Order(1); + + $transport = $this->fake_transport(new Response(200, [], json_encode([ + 'data' => ['uuid' => 'new-shipment', 'packages' => []], + ]))); + + $this->assertStringStartsWith('{"success":true}', $this->dispatch(['order_id' => '1'])); + + $this->assertSame('new-shipment', $order->get_meta('_sendy_shipment_id')); + $this->assertStringContainsString('/shipments/preference', $transport->lastRequest->getUrl()); + + // The shop and the preference are both UUID strings, so swapping them + // anywhere along the call chain would still type check. + $payload = json_decode($transport->lastRequest->getBody(), true); + $this->assertSame('shop-1', $payload['shop_id']); + $this->assertSame('pref-1', $payload['preference_id']); + $this->assertSame(3, $payload['amount']); + } + + public function test_the_shipment_is_created_with_smart_rules_for_the_sendy_processing_method(): void + { + update_option('sendy_processing_method', ProcessingMethod::Sendy); + set_transient('sendy_shops', ['shop-1' => 'Shop']); + + $order = new Sendy_Fake_Order(1); + + $transport = $this->fake_transport(new Response(200, [], json_encode([ + 'data' => ['uuid' => 'smart-shipment'], + ]))); + + $this->assertStringStartsWith('{"success":true}', $this->dispatch(['order_id' => '1'])); + + $this->assertSame('smart-shipment', $order->get_meta('_sendy_shipment_id')); + $this->assertStringContainsString('/shipments/smart-rule', $transport->lastRequest->getUrl()); + $this->assertSame('shop-1', json_decode($transport->lastRequest->getBody(), true)['shop_id']); + } + + public function test_a_failing_creation_reports_the_api_error(): void + { + $order = new Sendy_Fake_Order(1); + + $this->fake_transport(new Response(422, [], json_encode([ + 'message' => 'The given data was invalid', + 'errors' => ['postal_code' => ['The postal code is invalid']], + ]))); + + $this->dispatch(['order_id' => '1']); + + $this->assertFalse($order->meta_exists('_sendy_shipment_id')); + + $messages = get_option('sendy_flash_admin_messages'); + $this->assertSame('error', $messages[0]['type']); + $this->assertStringContainsString('The postal code is invalid', $messages[0]['message']); + } + + /** + * Dispatch the AJAX action and return the response the handler sent first. + * + * The handler wraps its work in catch (\Exception), which in the test + * environment also catches the exception the wp_die() inside + * wp_send_json_success() throws — so a second, spurious error envelope is + * appended to the output. In production wp_die() exits instead. + */ + private function dispatch(array $request): string + { + $_POST = array_merge([ + 'action' => 'sendy_order_single_save_form', + 'nonce' => wp_create_nonce('sendy_create_shipment'), + 'shop_id' => 'shop-1', + 'preference_id' => 'pref-1', + 'amount' => '3', + ], $request); + + $outputBufferLevel = ob_get_level(); + + try { + $this->_handleAjax('sendy_order_single_save_form'); + $this->fail('The AJAX handler was expected to send a JSON response and die'); + } catch (WPAjaxDieContinueException $exception) { + // wp_send_json() ends in an empty wp_die(). + } + + // Responding twice also closes one output buffer more than + // _handleAjax() opened; restore the level PHPUnit expects. + while (ob_get_level() < $outputBufferLevel) { + ob_start(); + } + + return $this->_last_response; + } +} diff --git a/tests/doubles.php b/tests/doubles.php index 5372c87..ed3043d 100644 --- a/tests/doubles.php +++ b/tests/doubles.php @@ -7,9 +7,11 @@ * minimal wc_get_order() backed by a registry of fake orders stands in for it. */ +use Sendy\Api\Connection; use Sendy\Api\Http\Request; use Sendy\Api\Http\Response; use Sendy\Api\Http\Transport\TransportInterface; +use Sendy\WooCommerce\ApiClientFactory; if (! function_exists('wc_get_order')) { function wc_get_order($order_id) @@ -219,3 +221,38 @@ public function getUserAgent(): string return 'PHPUnit'; } } + +/** + * Points ApiClientFactory's memoized connection at canned HTTP responses. + * + * Call set_api_connection(null) in tearDown() so the connection cannot leak + * into other tests. + */ +trait Sendy_Fakes_Api_Connection +{ + private function fake_transport(Response ...$responses): Sendy_Fake_Transport + { + $transport = new Sendy_Fake_Transport(...$responses); + + update_option('sendy_access_token', 'access-token', false); + update_option('sendy_refresh_token', 'refresh-token', false); + update_option('sendy_token_expires', time() + 3600, false); + + $this->set_api_connection( + (new Connection()) + ->setTransport($transport) + ->setAccessToken('access-token') + ->setRefreshToken('refresh-token') + ->setTokenExpires(time() + 3600) + ); + + return $transport; + } + + private function set_api_connection(?Connection $connection): void + { + $property = new ReflectionProperty(ApiClientFactory::class, 'connection'); + $property->setAccessible(true); + $property->setValue(null, $connection); + } +} From 677ed5e9e18c347334b5ec8ad99cd7c136311cf8 Mon Sep 17 00:00:00 2001 From: Adriaan Zonnenberg Date: Tue, 28 Jul 2026 15:20:23 +0200 Subject: [PATCH 08/18] Respect processing method setting when printing labels --- lib/Modules/Orders/BulkActions.php | 4 ++ lib/Modules/Orders/CreateShipments.php | 89 ++++++++++++++++++++---- lib/Modules/Orders/OrdersModule.php | 13 ++++ lib/Modules/Orders/RowActions.php | 6 +- resources/js/orders-list-print-flow.js | 96 ++++++++++++++++++++------ tests/CreateShipmentsTest.php | 13 ++-- tests/PrintFlowAssetsTest.php | 53 ++++++++++++++ tests/RowActionsTest.php | 15 ++++ tests/ShipmentsStatusTest.php | 74 +++++++++++++++++++- tests/bootstrap.php | 6 ++ 10 files changed, 323 insertions(+), 46 deletions(-) create mode 100644 tests/PrintFlowAssetsTest.php diff --git a/lib/Modules/Orders/BulkActions.php b/lib/Modules/Orders/BulkActions.php index 95cd5a4..50dc6a9 100644 --- a/lib/Modules/Orders/BulkActions.php +++ b/lib/Modules/Orders/BulkActions.php @@ -121,6 +121,10 @@ public function enqueue_assets(): void 'modalButtonText' => __('Create shipments and print labels', 'sendy'), ]); + wp_localize_script('sendy-orders-list-print-flow', 'sendyPrintFlowConfig', [ + 'printExistingOnly' => $this->print_existing_labels_only(), + ]); + wp_enqueue_script( 'sendy-admin-order-bulk', SENDY_WC_PLUGIN_DIR_URL . '/resources/js/admin-order-bulk.js', diff --git a/lib/Modules/Orders/CreateShipments.php b/lib/Modules/Orders/CreateShipments.php index a8fe9b9..87377c5 100644 --- a/lib/Modules/Orders/CreateShipments.php +++ b/lib/Modules/Orders/CreateShipments.php @@ -3,7 +3,6 @@ namespace Sendy\WooCommerce\Modules\Orders; use Sendy\Api\Exceptions\SendyException; -use Sendy\WooCommerce\Enums\ProcessingMethod; use Sendy\WooCommerce\Repositories\Shipments; class CreateShipments extends OrdersModule @@ -29,6 +28,14 @@ public function handle_create_shipments(): void { $this->verify_ajax_request('sendy_bulk_modal'); + if ($this->print_existing_labels_only()) { + // The print flow does not offer to create shipments for this + // processing method; refuse rather than trust the client, so a + // browser running a cached older copy of the script cannot add + // shipments the portal would have created itself. + wp_send_json(['message' => __('Shipments for this processing method are created in Sendy', 'sendy')], 400); + } + $shopId = sanitize_key($_POST['shop_id'] ?? ''); $preferenceId = sanitize_key($_POST['preference_id'] ?? ''); $amount = sanitize_key($_POST['amount'] ?? '1'); @@ -72,12 +79,11 @@ public function handle_create_shipments(): void } if ($created !== []) { + // Only the WooCommerce processing method reaches this point, so + // the preference and the amount were used to create the shipments. update_option('sendy_previously_used_shop_id', $shopId); - - if (get_option('sendy_processing_method') === ProcessingMethod::WooCommerce) { - update_option('sendy_previously_used_preference_id', $preferenceId); - update_option('sendy_previously_used_amount', $amount); - } + update_option('sendy_previously_used_preference_id', $preferenceId); + update_option('sendy_previously_used_amount', $amount); } wp_send_json(['created' => $created, 'failed' => $failed]); @@ -92,6 +98,9 @@ public function handle_create_shipments(): void * shipment was cancelled). The orders list print flow uses this to decide * between printing directly, creating a shipment first, or polling until * the Sendy portal has generated the label. + * + * With `flash_unprintable` set, the orders that have no label to print are + * explained in a single admin notice: the flow is done waiting for them. */ public function handle_shipments_status(): void { @@ -100,24 +109,76 @@ public function handle_shipments_status(): void $statuses = []; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- boolean flag, nothing to sanitize - $flashPending = ! empty($_POST['flash_pending']); + $flashUnprintable = ! empty($_POST['flash_unprintable']); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- intval() sanitizes each id foreach (array_map('intval', (array) ($_POST['order_ids'] ?? [])) as $orderId) { $statuses[$orderId] = $this->shipment_status(wc_get_order($orderId)); + } - if ($flashPending && $statuses[$orderId] === 'pending') { - // translators: %s contains the ID of the order - sendy_flash_admin_notice('warning', sprintf( - __('The labels for order #%s are still being generated. Please try again in a moment.', 'sendy'), - $orderId, - )); - } + if ($flashUnprintable) { + $this->flash_unprintable_notice($this->unprintable_orders($statuses)); } wp_send_json(['statuses' => $statuses]); } + /** + * The orders that have no label to print + * + * Orders with a failed shipment have flashed their own explanation + * already, and an order without a shipment is only worth mentioning when + * this flow is not the one that creates it. + * + * @param array $statuses + * @return int[] + */ + private function unprintable_orders(array $statuses): array + { + $unprintable = $this->print_existing_labels_only() ? ['none', 'pending'] : ['pending']; + + return array_keys(array_filter($statuses, function (string $status) use ($unprintable): bool { + return in_array($status, $unprintable, true); + })); + } + + /** + * Explain in one notice which orders have no label to print + * + * The Sendy processing method generates the labels in the portal, so there + * is nothing for the shop to wait for; the WooCommerce processing method + * generates them while the flow polls, so a shipment that is still pending + * when the flow gives up may well be ready shortly after. Printing a whole + * page of orders can leave many of them unprintable, so they are counted + * rather than named. + * + * @param int[] $orderIds + */ + private function flash_unprintable_notice(array $orderIds): void + { + if ($orderIds === []) { + return; + } + + $count = count($orderIds); + + if ($this->print_existing_labels_only()) { + $message = $count > 1 + // translators: %s contains the number of orders + ? __('There is no label to print for %s orders yet. Process the shipments in Sendy first.', 'sendy') + // translators: %s contains the ID of the order + : __('There is no label to print for order #%s yet. Process the shipment in Sendy first.', 'sendy'); + } else { + $message = $count > 1 + // translators: %s contains the number of orders + ? __('The labels for %s orders are still being generated. Please try again in a moment.', 'sendy') + // translators: %s contains the ID of the order + : __('The labels for order #%s are still being generated. Please try again in a moment.', 'sendy'); + } + + sendy_flash_admin_notice('warning', sprintf($message, $count > 1 ? $count : reset($orderIds))); + } + /** * Send a 403 JSON response when the nonce or the capabilities do not check out */ diff --git a/lib/Modules/Orders/OrdersModule.php b/lib/Modules/Orders/OrdersModule.php index 2a29507..d520cc0 100644 --- a/lib/Modules/Orders/OrdersModule.php +++ b/lib/Modules/Orders/OrdersModule.php @@ -37,6 +37,19 @@ protected function is_pickup_point_delivery(\WC_Order $order): bool return $order->meta_exists('_sendy_pickup_point_id'); } + /** + * Whether the orders list can only print labels that already exist + * + * The Sendy processing method has the portal generate the labels, through + * smart rules, for the shipments the plugin creates in the background. A + * shipment created from the orders list would therefore not yield a label + * to wait for, so the print flow prints what is already there instead. + */ + protected function print_existing_labels_only(): bool + { + return get_option('sendy_processing_method') !== ProcessingMethod::WooCommerce; + } + /** * Create a shipment for the order through the configured processing method * diff --git a/lib/Modules/Orders/RowActions.php b/lib/Modules/Orders/RowActions.php index 32c2dd8..16070a2 100644 --- a/lib/Modules/Orders/RowActions.php +++ b/lib/Modules/Orders/RowActions.php @@ -15,7 +15,9 @@ public function __construct() * Add a print label button to the actions column * * The URL is a no-op; orders-list-print-flow.js intercepts the click and, - * for orders without a shipment, creates one first before printing. + * for orders without a shipment, creates one first before printing. The + * Sendy processing method never creates from here, so the button always + * offers to print in that mode. * * @param array> $actions * @param \WC_Order $order @@ -23,7 +25,7 @@ public function __construct() */ public function add_print_label_action(array $actions, $order): array { - if ($order->meta_exists('_sendy_shipment_id')) { + if ($order->meta_exists('_sendy_shipment_id') || $this->print_existing_labels_only()) { $actions['sendy_print_label'] = [ 'url' => '#', 'name' => __('Sendy - Print label', 'sendy'), diff --git a/resources/js/orders-list-print-flow.js b/resources/js/orders-list-print-flow.js index 502bf67..ad8cdcd 100644 --- a/resources/js/orders-list-print-flow.js +++ b/resources/js/orders-list-print-flow.js @@ -7,6 +7,11 @@ * has generated the labels, and then prints them through sendyPrintLabels() * from print-labels.js. Used by both the per-order button in the actions * column and the "Sendy - Print labels" bulk action. + * + * The Sendy processing method has the portal generate the labels for the + * shipments the plugin creates in the background, so there is nothing to + * create or wait for here: that mode prints the labels that already exist and + * leaves a notice for the orders that have none. */ ( function () { 'use strict'; @@ -42,19 +47,18 @@ * Fetch the shipment status per order: none|pending|ready|failed. * * @param {Array} orderIds - * @param {boolean} flagPending When true, asks the server to - * flash a notice for orders - * that come back pending. + * @param {boolean} flagUnprintable When true, asks the server + * to flash a notice for the + * orders it cannot print. * @return {Promise>} */ - const fetchStatuses = async ( orderIds, flagPending = false ) => { + const fetchStatuses = async ( orderIds, flagUnprintable = false ) => { const params = { action: 'sendy_shipments_status', - nonce: document.getElementById( 'sendy-print-labels-nonce' ) - .value, + nonce: document.getElementById( 'sendy-print-labels-nonce' ).value, }; - if ( flagPending ) { - params.flash_pending = '1'; + if ( flagUnprintable ) { + params.flash_unprintable = '1'; } return ( await ajaxPost( params, orderIds ) ).statuses; @@ -189,26 +193,73 @@ return ready; }; + /** + * The ids from a status map that have the wanted status. + * + * @param {Object} statuses + * @param {string} wanted + * @return {string[]} + */ + const idsWithStatus = ( statuses, wanted ) => + Object.keys( statuses ).filter( + ( orderId ) => statuses[ orderId ] === wanted + ); + + /** + * Print the labels of the orders that have one, if any. Asking for the + * labels of nothing fails the request and flashes a notice. + * + * @param {string[]} ready + */ + const printReady = async ( ready ) => { + if ( ready.length ) { + await window.sendyPrintLabels( ready ); + } + }; + + /** + * Print the labels that already exist and explain the orders that have + * none, without creating shipments or waiting for labels. + * + * Used for the Sendy processing method, where the portal generates the + * labels for the shipments the plugin creates in the background. + * + * @param {Array} orderIds + */ + const printExistingLabels = async ( orderIds ) => { + const statuses = await fetchStatuses( orderIds.map( String ), true ); + const ready = idsWithStatus( statuses, 'ready' ); + + await printReady( ready ); + + if ( ready.length !== orderIds.length ) { + // Show the notices for the orders without a label. + window.location.reload(); + } + }; + /** * Print the labels of the given orders, creating shipments first for the - * orders that have none. + * orders that have none — unless the processing method leaves creating and + * generating to the Sendy portal, in which case only the labels that + * already exist are printed. * * @param {Array} orderIds */ const runPrintFlow = async ( orderIds ) => { + if ( sendyPrintFlowConfig.printExistingOnly ) { + return printExistingLabels( orderIds ); + } + const statuses = await fetchStatuses( orderIds.map( String ) ); - const byStatus = ( wanted ) => - Object.keys( statuses ).filter( - ( orderId ) => statuses[ orderId ] === wanted - ); - const withoutShipment = byStatus( 'none' ); - const ready = byStatus( 'ready' ); + const withoutShipment = idsWithStatus( statuses, 'none' ); + const ready = idsWithStatus( statuses, 'ready' ); // Orders whose labels still need waiting on: the ones already being // generated plus the shipments created below. Orders in a terminal // failed state have flashed a notice; drop them instead of // re-checking (and re-flashing) later. - const pending = byStatus( 'pending' ); + const pending = idsWithStatus( statuses, 'pending' ); let createdShipments = false; @@ -239,21 +290,20 @@ ready.push( ...( await waitUntilReady( pending ) ) ); } - if ( ready.length ) { - await window.sendyPrintLabels( ready ); - } + await printReady( ready ); if ( createdShipments || ready.length !== orderIds.length ) { - // Show the new shipment data and any flash notices for the - // orders that could not be printed. + // Show the new shipment data and any notices for the orders that + // could not be printed. window.location.reload(); } }; /** * Print the labels of the given orders, creating shipments first for the - * orders that have none. The returned promise never rejects, so callers - * can chain finally() without handling errors themselves. + * orders that have none where the processing method allows it. The returned + * promise never rejects, so callers can chain finally() without handling + * errors themselves. * * @param {Array} orderIds * @return {Promise} diff --git a/tests/CreateShipmentsTest.php b/tests/CreateShipmentsTest.php index e150889..267016c 100644 --- a/tests/CreateShipmentsTest.php +++ b/tests/CreateShipmentsTest.php @@ -79,8 +79,12 @@ public function test_shipments_are_created_through_the_preference_for_orders_wit $this->assertSame('2', get_option('sendy_previously_used_amount')); } - public function test_shipments_are_created_with_smart_rules_for_the_sendy_processing_method(): void + public function test_the_sendy_processing_method_is_refused(): void { + // The portal creates and processes the shipments for this method, so + // the print flow never asks this endpoint to create one. Refusing + // rather than trusting the client keeps a browser running a cached + // older copy of the script from adding shipments anyway. update_option('sendy_processing_method', ProcessingMethod::Sendy); set_transient('sendy_shops', ['shop-1' => 'Shop']); @@ -92,9 +96,10 @@ public function test_shipments_are_created_with_smart_rules_for_the_sendy_proces $response = $this->dispatch(['order_ids' => [1]]); - $this->assertSame([1], $response['created']); - $this->assertSame('smart-shipment', $order->get_meta('_sendy_shipment_id')); - $this->assertStringContainsString('/shipments/smart-rule', $transport->lastRequest->getUrl()); + $this->assertArrayNotHasKey('created', $response); + $this->assertStringContainsString('created in Sendy', $response['message']); + $this->assertSame([], $transport->requests, 'No shipment may be created'); + $this->assertFalse($order->meta_exists('_sendy_shipment_id')); } public function test_orders_whose_creation_fails_are_reported_as_failed(): void diff --git a/tests/PrintFlowAssetsTest.php b/tests/PrintFlowAssetsTest.php new file mode 100644 index 0000000..7b39f8f --- /dev/null +++ b/tests/PrintFlowAssetsTest.php @@ -0,0 +1,53 @@ +assertStringContainsString( + 'var sendyPrintFlowConfig = {"printExistingOnly":""}', + $this->localizedData(), + ); + } + + public function test_the_sendy_processing_method_only_prints_existing_labels(): void + { + update_option('sendy_processing_method', ProcessingMethod::Sendy); + + $this->assertStringContainsString( + 'var sendyPrintFlowConfig = {"printExistingOnly":"1"}', + $this->localizedData(), + ); + } + + private function localizedData(): string + { + $module = new BulkActions(); + $module->enqueue_assets(); + + $data = wp_scripts()->get_data('sendy-orders-list-print-flow', 'data'); + + $this->assertIsString($data, 'The print flow script must receive localized data'); + + return $data; + } +} diff --git a/tests/RowActionsTest.php b/tests/RowActionsTest.php index 8fd9bf9..0abf928 100644 --- a/tests/RowActionsTest.php +++ b/tests/RowActionsTest.php @@ -1,5 +1,6 @@ assertArrayNotHasKey('sendy_print_label', $actions); } + public function test_the_sendy_processing_method_only_offers_to_print(): void + { + // The portal creates and processes the shipments for this method, so + // the button must not promise a create-and-print it will not do. + update_option('sendy_processing_method', ProcessingMethod::Sendy); + + $actions = (new RowActions())->add_print_label_action([], new Sendy_Fake_Order(1)); + + $this->assertSame('sendy_print_label', $actions['sendy_print_label']['action']); + $this->assertArrayNotHasKey('sendy_create_shipment_print_label', $actions); + } + public function test_the_filter_is_registered(): void { $module = new RowActions(); diff --git a/tests/ShipmentsStatusTest.php b/tests/ShipmentsStatusTest.php index e9cb514..9058ab9 100644 --- a/tests/ShipmentsStatusTest.php +++ b/tests/ShipmentsStatusTest.php @@ -3,6 +3,7 @@ use Sendy\Api\Connection; use Sendy\Api\Http\Response; use Sendy\WooCommerce\ApiClientFactory; +use Sendy\WooCommerce\Enums\ProcessingMethod; use Sendy\WooCommerce\Modules\Orders\CreateShipments; /** @@ -32,6 +33,7 @@ public function setUp(): void wp_get_current_user()->add_cap('manage_woocommerce'); wp_get_current_user()->add_cap('edit_shop_orders'); + update_option('sendy_processing_method', ProcessingMethod::WooCommerce); delete_option('sendy_flash_admin_messages'); } @@ -99,7 +101,7 @@ public function test_new_and_pending_shipments_are_reported_as_pending(): void $this->assertSame(['1' => 'pending', '2' => 'pending'], $this->stringKeys($response['statuses'])); } - public function test_a_pending_shipment_flashes_no_notice_without_the_flash_pending_flag(): void + public function test_a_pending_shipment_flashes_no_notice_without_the_flash_unprintable_flag(): void { new Sendy_Fake_Order(1, ['_sendy_shipment_id' => 'shipment-1']); @@ -113,7 +115,7 @@ public function test_a_pending_shipment_flashes_no_notice_without_the_flash_pend $this->assertFalse(get_option('sendy_flash_admin_messages')); } - public function test_a_pending_shipment_flashes_a_warning_notice_with_the_flash_pending_flag(): void + public function test_a_pending_shipment_flashes_a_warning_notice_with_the_flash_unprintable_flag(): void { new Sendy_Fake_Order(1, ['_sendy_shipment_id' => 'shipment-1']); @@ -121,15 +123,81 @@ public function test_a_pending_shipment_flashes_a_warning_notice_with_the_flash_ new Response(200, [], json_encode(['data' => ['uuid' => 'shipment-1', 'status' => 'pending', 'packages' => []]])), ); - $response = $this->dispatch(['order_ids' => [1], 'flash_pending' => '1']); + $response = $this->dispatch(['order_ids' => [1], 'flash_unprintable' => '1']); $this->assertSame(['1' => 'pending'], $this->stringKeys($response['statuses'])); $messages = get_option('sendy_flash_admin_messages'); $this->assertSame('warning', $messages[0]['type']); + $this->assertStringContainsString('still being generated', $messages[0]['message']); $this->assertStringContainsString('order #1', $messages[0]['message']); } + public function test_the_sendy_processing_method_explains_that_the_shipment_needs_processing(): void + { + update_option('sendy_processing_method', ProcessingMethod::Sendy); + + new Sendy_Fake_Order(1, ['_sendy_shipment_id' => 'shipment-1']); + + $this->fake_transport( + new Response(200, [], json_encode(['data' => ['uuid' => 'shipment-1', 'status' => 'new', 'packages' => []]])), + ); + + $response = $this->dispatch(['order_ids' => [1], 'flash_unprintable' => '1']); + + $this->assertSame(['1' => 'pending'], $this->stringKeys($response['statuses'])); + + // Waiting is pointless in this mode: the portal generates the label. + $messages = get_option('sendy_flash_admin_messages'); + $this->assertCount(1, $messages); + $this->assertSame('warning', $messages[0]['type']); + $this->assertStringContainsString('order #1', $messages[0]['message']); + $this->assertStringContainsString('Process the shipment in Sendy', $messages[0]['message']); + $this->assertStringNotContainsString('try again in a moment', $messages[0]['message']); + } + + public function test_several_unprintable_orders_are_counted_in_one_notice(): void + { + // A whole page of orders can be unprintable; naming each one would bury + // the page in notices. + update_option('sendy_processing_method', ProcessingMethod::Sendy); + + new Sendy_Fake_Order(1, ['_sendy_shipment_id' => 'shipment-1']); + new Sendy_Fake_Order(2); + new Sendy_Fake_Order(3, [ + '_sendy_shipment_id' => 'shipment-3', + '_sendy_packages' => [['package_number' => '3S1']], + ]); + + $this->fake_transport( + new Response(200, [], json_encode(['data' => ['uuid' => 'shipment-1', 'status' => 'new', 'packages' => []]])), + ); + + $this->dispatch(['order_ids' => [1, 2, 3], 'flash_unprintable' => '1']); + + $messages = get_option('sendy_flash_admin_messages'); + $this->assertCount(1, $messages); + $this->assertStringContainsString('for 2 orders', $messages[0]['message'], 'The printable order is not counted'); + $this->assertStringContainsString('Process the shipments in Sendy', $messages[0]['message']); + } + + public function test_several_pending_orders_are_counted_in_one_notice(): void + { + new Sendy_Fake_Order(1, ['_sendy_shipment_id' => 'shipment-1']); + new Sendy_Fake_Order(2, ['_sendy_shipment_id' => 'shipment-2']); + + $this->fake_transport( + new Response(200, [], json_encode(['data' => ['uuid' => 'shipment-1', 'status' => 'pending', 'packages' => []]])), + new Response(200, [], json_encode(['data' => ['uuid' => 'shipment-2', 'status' => 'pending', 'packages' => []]])), + ); + + $this->dispatch(['order_ids' => [1, 2], 'flash_unprintable' => '1']); + + $messages = get_option('sendy_flash_admin_messages'); + $this->assertCount(1, $messages); + $this->assertStringContainsString('labels for 2 orders are still being generated', $messages[0]['message']); + } + public function test_a_failed_shipment_is_reported_as_failed_with_a_flash_notice(): void { new Sendy_Fake_Order(1, ['_sendy_shipment_id' => 'shipment-1']); diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 485008e..f65de3b 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -10,10 +10,16 @@ require_once dirname(__DIR__) . '/vendor/autoload.php'; +// The plugin is not loaded as a WordPress plugin here, so Plugin::define_constants() +// never runs; define the ones the tested code reads. if (! defined('SENDY_WC_PLUGIN_FILE')) { define('SENDY_WC_PLUGIN_FILE', dirname(__DIR__) . '/sendy.php'); } +if (! defined('SENDY_WC_PLUGIN_DIR_URL')) { + define('SENDY_WC_PLUGIN_DIR_URL', 'https://example.com/wp-content/plugins/sendy'); +} + $_tests_dir = getenv('WP_TESTS_DIR'); if (! $_tests_dir) { From 66bb620f5921098cdf3e0fd4a91a777fcd43b2db Mon Sep 17 00:00:00 2001 From: Adriaan Zonnenberg Date: Wed, 29 Jul 2026 10:19:52 +0200 Subject: [PATCH 09/18] Update translations --- languages/sendy-nl_NL.mo | Bin 6989 -> 8402 bytes languages/sendy-nl_NL.po | 100 ++++++++++++++++----- languages/sendy.pot | 116 ++++++++++++++++++------- lib/Modules/Orders/CreateShipments.php | 4 +- 4 files changed, 166 insertions(+), 54 deletions(-) diff --git a/languages/sendy-nl_NL.mo b/languages/sendy-nl_NL.mo index c4d3304d918bca91f37f55fd9fa89a0a4f3e280a..0133034f53421198e46c8af03a362eae3333e1b8 100644 GIT binary patch delta 2554 zcmbW%ZEO@p7{KuXPg`scu!Y)J8Kjj$V~J48Tc|l+6s3)p7Ew&VZExruZFlSLmeOjx zXrkZn%ccYp^n*3N0MY0rVl)~PMT`lFHX$aWC?80TO%xMr41Cc4bGuiPDn=#S{mty& zJTuQTvz%=nPh>8X#6}hW9_Fu_zi(zsPpM-!UUL)87wA{w_gI5haS>MBq*Mv6!(wd3 zQhWsGVm~g$C$R!w#sd5h7bulcpN0)}9$R>E8CT&7ezg|6aULGS`FIi+;|C}U_zvUv z2d=?*VK{y(`t*l!7yg7B@SZv0xMy%7^Q)I=$V%Tundl5kz_0KQyo6m?d~+Ds!DaNv zkU7;^B(^$-x8jA!-%v81TNGBV9vkRy#(H!x&iv{Kjoa`QS2+H0DD0}!8O5oEd znS34fe?`f34rP<)i*X%pK&jv$%J^q7hDT8<_9DtWpJ6**oJ;+;&{)MNd2kRD_&GLV zF&o~23EY9BC=>jEvX{R{Hc^(_=?|b}93Yo^om*;_Qtx9k{aRKfm3$PX;s;8pznuC} z9uP~tjcm927-gcfNV4i%loC#&RHTd>+p8K-CTvD|-pZ{FExa4wMEQDtMv1?YAB&r@ z7CSRE7SR}vUL3{y=^w{!IE4YWlqqH7IJRSg`Y*u%C8JZw9BLd{qnbdez*Us5Do)+^ zU@OYPPM}meGf6`-zLSj<*PzA(R$&)%si(NfL`QHHX0ZV$kaML5Z zohXOcL5imip~M}F-oJ-3-#M(1@Bc>{R7u4cwHB+91FCjLCQ(kYhwJemO6jt=9jCAZ z6PzgNkK$&$h!U@k{oRFoa3{Wl62DmUzK%u}4aukzWu*bi%8#S$;YT`qCDt^1o@FQX zfSa~`-Dh=~zM!gLV^PX<_onS0?WaueSYcT(SkROmEVvSzuV?;xSMm1X`Qj&5_Z!>y z^ghRBOxh!$(``IYV)Pq7-4FR8n$*3;dV zZ96^z!*p3ky_ZUx0U4-0-|`2&^}23lt+(uYQU3qBf`Rz(+LWON6vuYLC2HS^Dlc`r zmi(~sNi(cqZVM7crMvaMwCjZl{!^5E!k(TPMU!lpYrgzZ$m~Sik5w6}XxaT%FAc6} zS8@j2_@2b^1ub)(0$GCr$81$==|TlI_8Xf_S#3bknRG$RWFTK`K`H*HBT- z?ch}Ty@k`A%jF|AyEzGLv4nLfL(+^GJ!IV8q4fR{6P&E54)#}!GP3`m9Li^klsPtmS|~4MUi_<_-Ul; z!vhkfu1CT}=g-S^f_nKd|6zeaTjlDYrSiVaRyRB=k{YYZZHRXIwVyw~QNgCG(WoCX mu2ha1<`>97>frg)pIoE*mvI}zPmyoO`@6t1!v@b)p7|5yrxT{iCWz3Wkoc$2 zgCJ^=Bo@x55L_fh5GE;9WCn$c!i5S!Q3ic~%^wnHKKI=D|IfMi+&f!0T^ab4B0cv5B z7*BnhW}rk1Sb$4-43pM}68o@-cm&C5bI2n$k7>B*x`x_ea&)LurC36|54WNZRp$<7 z;V8yX-(E3L>)v4-E}~jp9b=Y=ZLVFY1b(D-*6+^W#xmkD)DAymG5)}9m`ifHZ>_iy z52KE-7XwN($UrNQ$oV8x!gO0R~5z*o}`+w|yCT*UGrsvL3VXpd0sL1Mwxij<4|qcG8{} zoW(rMr;0k+fGT)|FLls|l(5U{zi$04?7$DGh3sURYNa2w!wJ{tm_s~+>9~SAy4ciE zrwXu~xDHFuhn$|>#X=lIu7$mI=L26DXywbOR(G&m74##UvWv)?Y#4dO9-_|r8ET>P zs0E}mS&Q3I8#;r!bmOSJPf(X`4wYv$G#{`i-d2G;RH7=r$Z4&}HL&BZe$=HKz-qjT z>d;H9$7rflVH4`Tem5RQEocT+*h|}Jnr*>EWvodL-i)XScUB^co!?QJ!IM$HJkGVa za_4fV-tngP2G6H9hXvoHb$gu7j2+J7j2@>g^PaPq Kbget($order->get_meta('_sendy_shipment_id')); } catch (SendyException $exception) { - // translators: %1$s contains the ID of the order, %2$s the error message sendy_flash_admin_notice('error', sprintf( + // translators: %1$s contains the ID of the order, %2$s the error message __('Error while checking the shipment for order #%1$s: %2$s', 'sendy'), $order->get_id(), $exception->getMessage(), @@ -240,8 +240,8 @@ public function shipment_status($order): string return 'pending'; } - // translators: %1$s contains the ID of the order, %2$s the status of the shipment sendy_flash_admin_notice('error', sprintf( + // translators: %1$s contains the ID of the order, %2$s the status of the shipment __('The labels for order #%1$s cannot be printed (shipment status: %2$s)', 'sendy'), $order->get_id(), $status !== '' ? $status : 'unknown', From d5ded53d000a7b5c787ab878fbadd188f55bc272 Mon Sep 17 00:00:00 2001 From: Adriaan Zonnenberg Date: Wed, 29 Jul 2026 15:06:25 +0200 Subject: [PATCH 10/18] Simplify --- lib/Modules/Orders/BulkActions.php | 23 ++--- lib/Modules/Orders/CreateShipments.php | 41 ++------- lib/Modules/Orders/OrdersModule.php | 43 +++++++++- lib/Modules/Orders/PrintLabels.php | 14 +--- lib/Modules/Orders/RowActions.php | 31 ++++--- resources/css/order-actions.css | 9 +- resources/js/admin-order-bulk.js | 112 ++++++++----------------- resources/js/orders-list-print-flow.js | 70 +++++++++------- tests/CreateShipmentsTest.php | 47 ++--------- tests/PrintFlowAssetsTest.php | 10 +-- tests/PrintLabelsTest.php | 74 +++------------- tests/RowActionsTest.php | 13 ++- tests/Sendy_Ajax_TestCase.php | 59 +++++++++++++ tests/ShipmentsStatusTest.php | 39 +-------- tests/SingleCreateShipmentTest.php | 20 +---- tests/bootstrap.php | 1 + tests/doubles.php | 4 +- 17 files changed, 242 insertions(+), 368 deletions(-) create mode 100644 tests/Sendy_Ajax_TestCase.php diff --git a/lib/Modules/Orders/BulkActions.php b/lib/Modules/Orders/BulkActions.php index 50dc6a9..cc981ec 100644 --- a/lib/Modules/Orders/BulkActions.php +++ b/lib/Modules/Orders/BulkActions.php @@ -54,21 +54,15 @@ public function handle_bulk_action_create_shipments(string $redirect, string $ac wp_die('Nonce verification failed'); } + $shopId = sanitize_key($_REQUEST['sendy_shop_id'] ?? ''); + $preferenceId = sanitize_key($_REQUEST['sendy_preference_id'] ?? ''); + $amount = sanitize_key($_REQUEST['sendy_amount'] ?? ''); + foreach ($objectIds as $id) { - $this->create_shipment( - wc_get_order($id), - sanitize_key($_REQUEST['sendy_shop_id'] ?? ''), - sanitize_key($_REQUEST['sendy_preference_id'] ?? ''), - (int) sanitize_key($_REQUEST['sendy_amount'] ?? ''), - ); + $this->create_shipment(wc_get_order($id), $shopId, $preferenceId, (int) $amount); } - update_option('sendy_previously_used_shop_id', sanitize_key($_REQUEST['sendy_shop_id'] ?? '')); - - if (get_option('sendy_processing_method') === ProcessingMethod::WooCommerce) { - update_option('sendy_previously_used_preference_id', sanitize_key($_REQUEST['sendy_preference_id'] ?? '')); - update_option('sendy_previously_used_amount', sanitize_key($_REQUEST['sendy_amount'] ?? '')); - } + $this->remember_previously_used($shopId, $preferenceId, $amount); return $redirect; } @@ -116,12 +110,9 @@ public function enqueue_assets(): void true, ); - wp_localize_script('sendy-orders-list-print-flow', 'sendyPrintFlowL10n', [ + wp_localize_script('sendy-orders-list-print-flow', 'sendyPrintFlow', [ 'modalTitle' => __('Sendy - Print labels', 'sendy'), 'modalButtonText' => __('Create shipments and print labels', 'sendy'), - ]); - - wp_localize_script('sendy-orders-list-print-flow', 'sendyPrintFlowConfig', [ 'printExistingOnly' => $this->print_existing_labels_only(), ]); diff --git a/lib/Modules/Orders/CreateShipments.php b/lib/Modules/Orders/CreateShipments.php index a801acc..b30e007 100644 --- a/lib/Modules/Orders/CreateShipments.php +++ b/lib/Modules/Orders/CreateShipments.php @@ -20,9 +20,9 @@ public function __construct() * preference and amount (the same fields as the "Create shipments" bulk * action) and this endpoint performs the creation over AJAX so the flow * can wait for the labels and print them afterwards. Orders that already - * have a shipment are skipped and appear in neither result list. Errors - * per order are flashed as admin notices by the inherited creation - * methods; the response only reports which orders now have a shipment. + * have a shipment are skipped. Errors per order are flashed as admin + * notices by the inherited creation methods; the response only reports + * which orders now have a shipment. */ public function handle_create_shipments(): void { @@ -41,19 +41,12 @@ public function handle_create_shipments(): void $amount = sanitize_key($_POST['amount'] ?? '1'); $created = []; - $failed = []; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- intval() sanitizes each id foreach (array_map('intval', (array) ($_POST['order_ids'] ?? [])) as $orderId) { $order = wc_get_order($orderId); - if (! $order) { - $failed[] = $orderId; - - continue; - } - - if ($order->meta_exists('_sendy_shipment_id')) { + if (! $order || $order->meta_exists('_sendy_shipment_id')) { continue; } @@ -73,20 +66,14 @@ public function handle_create_shipments(): void if ($order->meta_exists('_sendy_shipment_id')) { $created[] = $orderId; - } else { - $failed[] = $orderId; } } if ($created !== []) { - // Only the WooCommerce processing method reaches this point, so - // the preference and the amount were used to create the shipments. - update_option('sendy_previously_used_shop_id', $shopId); - update_option('sendy_previously_used_preference_id', $preferenceId); - update_option('sendy_previously_used_amount', $amount); + $this->remember_previously_used($shopId, $preferenceId, $amount); } - wp_send_json(['created' => $created, 'failed' => $failed]); + wp_send_json(['created' => $created]); } /** @@ -179,20 +166,6 @@ private function flash_unprintable_notice(array $orderIds): void sendy_flash_admin_notice('warning', sprintf($message, $count > 1 ? $count : reset($orderIds))); } - /** - * Send a 403 JSON response when the nonce or the capabilities do not check out - */ - private function verify_ajax_request(string $nonceAction): void - { - if (! check_ajax_referer($nonceAction, 'nonce', false)) { - wp_send_json(['message' => __('Nonce verification failed', 'sendy')], 403); - } - - if (! current_user_can('manage_woocommerce') || ! current_user_can('edit_shop_orders')) { - wp_send_json(['message' => __('You do not have sufficient permissions to access this page.', 'sendy')], 403); - } - } - /** * Determine the print status of a single order's shipment * @@ -202,7 +175,7 @@ private function verify_ajax_request(string $nonceAction): void * * @param \WC_Order|false $order */ - public function shipment_status($order): string + private function shipment_status($order): string { if (! $order || ! $order->meta_exists('_sendy_shipment_id')) { return 'none'; diff --git a/lib/Modules/Orders/OrdersModule.php b/lib/Modules/Orders/OrdersModule.php index d520cc0..c680215 100644 --- a/lib/Modules/Orders/OrdersModule.php +++ b/lib/Modules/Orders/OrdersModule.php @@ -64,11 +64,50 @@ protected function print_existing_labels_only(): bool */ protected function create_shipment(\WC_Order $order, string $shopId, string $preferenceId, int $amount): void { - if (get_option('sendy_processing_method') === ProcessingMethod::WooCommerce) { + if ($this->print_existing_labels_only()) { + $this->create_shipment_with_smart_rules($order, false, $shopId); + } else { $this->create_shipment_from_order($order, $shopId, $preferenceId, $amount); + } + } + + /** + * Remember the create-shipments modal values as the defaults for next time + * + * Only the WooCommerce processing method uses the preference and the + * amount, so only that method remembers them. + */ + protected function remember_previously_used(string $shopId, string $preferenceId, string $amount): void + { + update_option('sendy_previously_used_shop_id', $shopId); + + if (! $this->print_existing_labels_only()) { + update_option('sendy_previously_used_preference_id', $preferenceId); + update_option('sendy_previously_used_amount', $amount); + } + } + + /** + * Send a 403 JSON response when the nonce or the capabilities do not check out + * + * @param bool $flashErrors Also flash the failure as an admin notice, for + * callers whose JS reloads the page on an error + */ + protected function verify_ajax_request(string $nonceAction, bool $flashErrors = false): void + { + if (! check_ajax_referer($nonceAction, 'nonce', false)) { + $message = __('Nonce verification failed', 'sendy'); + } elseif (! current_user_can('manage_woocommerce') || ! current_user_can('edit_shop_orders')) { + $message = __('You do not have sufficient permissions to access this page.', 'sendy'); } else { - $this->create_shipment_with_smart_rules($order, false, $shopId); + return; } + + if ($flashErrors) { + sendy_flash_admin_notice('error', $message); + } + + wp_send_json(['message' => $message], 403); } /** diff --git a/lib/Modules/Orders/PrintLabels.php b/lib/Modules/Orders/PrintLabels.php index dafe561..e7395b2 100644 --- a/lib/Modules/Orders/PrintLabels.php +++ b/lib/Modules/Orders/PrintLabels.php @@ -23,19 +23,7 @@ public function __construct() */ public function handle_print_labels(): void { - if (! check_ajax_referer('sendy_print_labels', 'nonce', false)) { - $message = __('Nonce verification failed', 'sendy'); - - sendy_flash_admin_notice('error', $message); - wp_send_json(['message' => $message], 403); - } - - if (! current_user_can('manage_woocommerce') || ! current_user_can('edit_shop_orders')) { - $message = __('You do not have sufficient permissions to access this page.', 'sendy'); - - sendy_flash_admin_notice('error', $message); - wp_send_json(['message' => $message], 403); - } + $this->verify_ajax_request('sendy_print_labels', true); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- intval() sanitizes each id $orders = $this->orders_with_shipment(array_map('intval', (array) ($_POST['order_ids'] ?? []))); diff --git a/lib/Modules/Orders/RowActions.php b/lib/Modules/Orders/RowActions.php index 16070a2..317dd97 100644 --- a/lib/Modules/Orders/RowActions.php +++ b/lib/Modules/Orders/RowActions.php @@ -14,10 +14,11 @@ public function __construct() /** * Add a print label button to the actions column * - * The URL is a no-op; orders-list-print-flow.js intercepts the click and, - * for orders without a shipment, creates one first before printing. The - * Sendy processing method never creates from here, so the button always - * offers to print in that mode. + * The URL carries the order id for orders-list-print-flow.js, which + * intercepts the click and, for orders without a shipment, creates one + * first before printing. The tooltip promises the creation only when the + * flow would perform it: the Sendy processing method never creates from + * here, so the button only offers to print in that mode. * * @param array> $actions * @param \WC_Order $order @@ -25,19 +26,15 @@ public function __construct() */ public function add_print_label_action(array $actions, $order): array { - if ($order->meta_exists('_sendy_shipment_id') || $this->print_existing_labels_only()) { - $actions['sendy_print_label'] = [ - 'url' => '#', - 'name' => __('Sendy - Print label', 'sendy'), - 'action' => 'sendy_print_label', - ]; - } else { - $actions['sendy_create_shipment_print_label'] = [ - 'url' => '#', - 'name' => __('Sendy - Create shipment and print label', 'sendy'), - 'action' => 'sendy_create_shipment_print_label', - ]; - } + $createsShipmentFirst = ! $order->meta_exists('_sendy_shipment_id') && ! $this->print_existing_labels_only(); + + $actions['sendy_print_label'] = [ + 'url' => '#sendy-print-label-' . $order->get_id(), + 'name' => $createsShipmentFirst + ? __('Sendy - Create shipment and print label', 'sendy') + : __('Sendy - Print label', 'sendy'), + 'action' => 'sendy_print_label', + ]; return $actions; } diff --git a/resources/css/order-actions.css b/resources/css/order-actions.css index 238bafc..b1d6814 100644 --- a/resources/css/order-actions.css +++ b/resources/css/order-actions.css @@ -1,19 +1,16 @@ -.column-wc_actions a.sendy_print_label::after, -.column-wc_actions a.sendy_create_shipment_print_label::after { +.column-wc_actions a.sendy_print_label::after { font-family: Dashicons; content: "\f193"; } /* Busy state while the create-and-print flow runs (up to a minute). */ -.column-wc_actions a.sendy_print_label.disabled::after, -.column-wc_actions a.sendy_create_shipment_print_label.disabled::after { +.column-wc_actions a.sendy_print_label.disabled::after { content: "\f463"; opacity: 0.6; } @media (prefers-reduced-motion: no-preference) { - .column-wc_actions a.sendy_print_label.disabled::after, - .column-wc_actions a.sendy_create_shipment_print_label.disabled::after { + .column-wc_actions a.sendy_print_label.disabled::after { animation: sendy-action-busy 1.5s linear infinite; } } diff --git a/resources/js/admin-order-bulk.js b/resources/js/admin-order-bulk.js index e3d59ef..6782efd 100644 --- a/resources/js/admin-order-bulk.js +++ b/resources/js/admin-order-bulk.js @@ -65,91 +65,51 @@ }, /** + * Ask for the shop/preference/amount through the shared + * create-shipments modal and submit the bulk action form with the + * chosen values. * * @param {Event} event */ toggleCreateShipmentsModal: function ( event ) { event.preventDefault(); - let value = $( this ).val(), - bulkActionsForm = $( this ).parents( - '#wc-orders-filter, #posts-filter' - ), - title = $( ':selected', this ).text(); - - if ( value === 'sendy_create_shipments' ) { - tb_show( - '', - '/?TB_inline=true&width=420&height=320&inlineId=sendy-create-shipments-modal' - ); - - let thickboxWindow = $( '#TB_window' ); - - thickboxWindow.find( '#TB_ajaxWindowTitle' ).text( title ); - - thickboxWindow - .find( '#sendy-create-shipments-button' ) - .off( 'click.sendyBulk click.sendyPrintFlow' ) - .on( 'click.sendyBulk', function ( event ) { - event.preventDefault(); - - let sendyFieldsContainer = bulkActionsForm - .append( '
' ) - .hide(); - - let preferenceId = thickboxWindow - .find( '#sendy_preference_id' ) - .find( ':selected' ) - .val(); - $( sendyFieldsContainer ).append( - $( '', { - type: 'hidden', - name: 'sendy_preference_id', - value: preferenceId, - } ) - ); - - if ( thickboxWindow.find( '#sendy_shop_id' ) ) { - let shopId = thickboxWindow - .find( '#sendy_shop_id' ) - .find( ':selected' ) - .val(); - $( sendyFieldsContainer ).append( - $( '', { - type: 'hidden', - name: 'sendy_shop_id', - value: shopId, - } ) + if ( $( this ).val() !== 'sendy_create_shipments' ) { + return; + } + + const bulkActionsForm = $( this ).parents( + '#wc-orders-filter, #posts-filter' + ); + const title = $( ':selected', this ).text(); + + window + .sendyOpenCreateShipmentsModal( { title } ) + .then( function ( fields ) { + if ( ! fields ) { + return; + } + + const container = $( '
' ) + .hide() + .appendTo( bulkActionsForm ); + + $.each( + { + sendy_shop_id: fields.shopId, + sendy_preference_id: fields.preferenceId, + sendy_amount: fields.amount, + sendy_bulk_modal_nonce: fields.nonce, + }, + function ( name, value ) { + container.append( + $( '', { type: 'hidden', name, value } ) ); } + ); - let nonce = thickboxWindow - .find( '#sendy_bulk_modal_nonce' ) - .val(); - $( sendyFieldsContainer ).append( - $( '', { - type: 'hidden', - name: 'sendy_bulk_modal_nonce', - value: nonce, - } ) - ); - - let amount = thickboxWindow - .find( '#sendy_amount' ) - .val(); - $( sendyFieldsContainer ).append( - $( '', { - type: 'hidden', - name: 'sendy_amount', - value: amount, - } ) - ); - - $( this ).prop( 'disabled', true ); - - bulkActionsForm.submit(); - } ); - } + bulkActionsForm.submit(); + } ); }, disableSubmitButton: function () { diff --git a/resources/js/orders-list-print-flow.js b/resources/js/orders-list-print-flow.js index ad8cdcd..ddf0c45 100644 --- a/resources/js/orders-list-print-flow.js +++ b/resources/js/orders-list-print-flow.js @@ -69,7 +69,7 @@ * * @param {Array} orderIds * @param {Object} fields The values collected from the modal - * @return {Promise<{created: number[], failed: number[]}>} + * @return {Promise<{created: number[]}>} */ const createShipments = ( orderIds, fields ) => ajaxPost( @@ -84,13 +84,18 @@ ); /** - * Ask for the shop/preference/amount through the same thickbox modal as - * the "Create shipments" bulk action. Resolves with the chosen values, - * or null when the modal is dismissed. + * Ask for the shop/preference/amount through the create-shipments + * thickbox modal. Resolves with the chosen values, or null when the + * modal is dismissed. This is the modal's only owner: admin-order-bulk.js + * calls it too for the "Create shipments" bulk action. * + * @param {Object} options + * @param {string} options.title The thickbox window title. + * @param {string} [options.buttonText] Replaces the confirm button text + * until the modal closes. * @return {Promise} */ - const openCreateShipmentsModal = () => + window.sendyOpenCreateShipmentsModal = ( { title, buttonText } ) => new Promise( ( resolve ) => { if ( ! document.getElementById( 'sendy-create-shipments-modal' ) ) { // The modal is replaced by a connection error notice when @@ -110,34 +115,36 @@ ); const originalButtonText = button.text(); - thickboxWindow - .find( '#TB_ajaxWindowTitle' ) - .text( sendyPrintFlowL10n.modalTitle ); - button.text( sendyPrintFlowL10n.modalButtonText ); + thickboxWindow.find( '#TB_ajaxWindowTitle' ).text( title ); + + if ( buttonText ) { + button.text( buttonText ); + } // Thickbox moves the inline content back into the page when the - // modal closes; undo our changes so the "Create shipments" bulk - // action shows its own button text, and treat a dismissal as a - // cancel (a no-op when the confirm handler already resolved). + // modal closes; undo our changes for the next opening, and treat + // a dismissal as a cancel (a no-op when the confirm handler + // already resolved). jQuery( 'body' ).one( 'thickbox:removed', function () { button .text( originalButtonText ) .prop( 'disabled', false ) - .off( 'click.sendyPrintFlow' ); + .off( 'click.sendyModal' ); resolve( null ); } ); button - .off( 'click.sendyPrintFlow click.sendyBulk' ) - .on( 'click.sendyPrintFlow', function ( event ) { + .off( 'click.sendyModal' ) + .on( 'click.sendyModal', function ( event ) { event.preventDefault(); jQuery( this ).prop( 'disabled', true ); resolve( { - shopId: thickboxWindow - .find( '#sendy_shop_id :selected' ) - .val(), + shopId: + thickboxWindow + .find( '#sendy_shop_id :selected' ) + .val() ?? '', preferenceId: thickboxWindow .find( '#sendy_preference_id :selected' ) @@ -164,7 +171,7 @@ */ const waitUntilReady = async ( orderIds ) => { const deadline = Date.now() + POLL_TIMEOUT; - let pending = orderIds.map( String ); + let pending = orderIds; const ready = []; while ( pending.length ) { @@ -227,7 +234,7 @@ * @param {Array} orderIds */ const printExistingLabels = async ( orderIds ) => { - const statuses = await fetchStatuses( orderIds.map( String ), true ); + const statuses = await fetchStatuses( orderIds, true ); const ready = idsWithStatus( statuses, 'ready' ); await printReady( ready ); @@ -247,11 +254,11 @@ * @param {Array} orderIds */ const runPrintFlow = async ( orderIds ) => { - if ( sendyPrintFlowConfig.printExistingOnly ) { + if ( sendyPrintFlow.printExistingOnly ) { return printExistingLabels( orderIds ); } - const statuses = await fetchStatuses( orderIds.map( String ) ); + const statuses = await fetchStatuses( orderIds ); const withoutShipment = idsWithStatus( statuses, 'none' ); const ready = idsWithStatus( statuses, 'ready' ); @@ -264,7 +271,10 @@ let createdShipments = false; if ( withoutShipment.length ) { - const fields = await openCreateShipmentsModal(); + const fields = await window.sendyOpenCreateShipmentsModal( { + title: sendyPrintFlow.modalTitle, + buttonText: sendyPrintFlow.modalButtonText, + } ); if ( ! fields ) { return; @@ -309,16 +319,15 @@ * @return {Promise} */ window.sendyOrdersListPrintFlow = ( orderIds ) => - runPrintFlow( orderIds ).catch( ( error ) => { + runPrintFlow( orderIds.map( String ) ).catch( ( error ) => { console.error( 'Sendy: printing labels failed', error ); } ); - // The per-order print buttons in the actions column. The order id comes - // from the row's bulk-selection checkbox (id[] on the HPOS table, post[] - // on the legacy table). + // The per-order print buttons in the actions column. The href carries + // the id of the order the button was rendered for. jQuery( document ).on( 'click', - '.wc-action-button-sendy_print_label, .wc-action-button-sendy_create_shipment_print_label', + '.wc-action-button-sendy_print_label', function ( event ) { event.preventDefault(); @@ -329,9 +338,8 @@ } const orderId = button - .closest( 'tr' ) - .find( 'input[name="id[]"], input[name="post[]"]' ) - .val(); + .attr( 'href' ) + .replace( '#sendy-print-label-', '' ); button.addClass( 'disabled' ).attr( 'aria-disabled', 'true' ); diff --git a/tests/CreateShipmentsTest.php b/tests/CreateShipmentsTest.php index 267016c..1346850 100644 --- a/tests/CreateShipmentsTest.php +++ b/tests/CreateShipmentsTest.php @@ -14,34 +14,13 @@ * rules (Sendy processing method) — and reports which orders now have a * shipment so the orders list print flow knows what it can print. */ -class CreateShipmentsTest extends WP_Ajax_UnitTestCase +class CreateShipmentsTest extends Sendy_Ajax_TestCase { - use Sendy_Fakes_Api_Connection; - - private CreateShipments $module; - public function setUp(): void { parent::setUp(); - Sendy_Fake_Order::reset(); - - $this->module = new CreateShipments(); - - $user = self::factory()->user->create(['role' => 'administrator']); - wp_set_current_user($user); - wp_get_current_user()->add_cap('manage_woocommerce'); - wp_get_current_user()->add_cap('edit_shop_orders'); - - update_option('sendy_processing_method', ProcessingMethod::WooCommerce); - delete_option('sendy_flash_admin_messages'); - } - - public function tearDown(): void - { - $this->set_api_connection(null); - - parent::tearDown(); + new CreateShipments(); } public function test_shipments_are_created_through_the_preference_for_orders_without_one(): void @@ -57,8 +36,7 @@ public function test_shipments_are_created_through_the_preference_for_orders_wit $response = $this->dispatch(['order_ids' => [1, 2]]); - $this->assertSame([1], $response['created']); - $this->assertSame([], $response['failed']); + $this->assertSame(['created' => [1]], $response, 'Only the order without a shipment is reported as created'); $this->assertSame('new-shipment', $orderWithoutShipment->get_meta('_sendy_shipment_id')); $this->assertSame($packages, $orderWithoutShipment->get_meta('_sendy_packages')); @@ -102,7 +80,7 @@ public function test_the_sendy_processing_method_is_refused(): void $this->assertFalse($order->meta_exists('_sendy_shipment_id')); } - public function test_orders_whose_creation_fails_are_reported_as_failed(): void + public function test_orders_whose_creation_fails_are_not_reported_as_created(): void { $order = new Sendy_Fake_Order(1); @@ -113,8 +91,7 @@ public function test_orders_whose_creation_fails_are_reported_as_failed(): void $response = $this->dispatch(['order_ids' => [1, 99]]); - $this->assertSame([], $response['created']); - $this->assertSame([1, 99], $response['failed'], 'Unresolvable orders count as failed'); + $this->assertSame([], $response['created'], 'Neither the failed nor the unresolvable order was created'); $this->assertFalse($order->meta_exists('_sendy_shipment_id')); @@ -147,21 +124,11 @@ public function test_a_user_without_the_required_capabilities_is_rejected(): voi */ private function dispatch(array $post): array { - $_POST = array_merge([ - 'action' => 'sendy_create_shipments', + return $this->dispatch_ajax('sendy_create_shipments', array_merge([ 'nonce' => wp_create_nonce('sendy_bulk_modal'), 'shop_id' => 'shop-1', 'preference_id' => 'pref-1', 'amount' => '2', - ], $post); - - try { - $this->_handleAjax('sendy_create_shipments'); - $this->fail('The AJAX handler was expected to send a JSON response and die'); - } catch (WPAjaxDieContinueException $exception) { - // wp_send_json() ends in an empty wp_die(). - } - - return json_decode($this->_last_response, true); + ], $post)); } } diff --git a/tests/PrintFlowAssetsTest.php b/tests/PrintFlowAssetsTest.php index 7b39f8f..92749cd 100644 --- a/tests/PrintFlowAssetsTest.php +++ b/tests/PrintFlowAssetsTest.php @@ -23,10 +23,10 @@ public function test_the_woocommerce_processing_method_creates_and_prints(): voi { update_option('sendy_processing_method', ProcessingMethod::WooCommerce); - $this->assertStringContainsString( - 'var sendyPrintFlowConfig = {"printExistingOnly":""}', - $this->localizedData(), - ); + $data = $this->localizedData(); + + $this->assertStringContainsString('var sendyPrintFlow = ', $data); + $this->assertStringContainsString('"printExistingOnly":""', $data); } public function test_the_sendy_processing_method_only_prints_existing_labels(): void @@ -34,7 +34,7 @@ public function test_the_sendy_processing_method_only_prints_existing_labels(): update_option('sendy_processing_method', ProcessingMethod::Sendy); $this->assertStringContainsString( - 'var sendyPrintFlowConfig = {"printExistingOnly":"1"}', + '"printExistingOnly":"1"', $this->localizedData(), ); } diff --git a/tests/PrintLabelsTest.php b/tests/PrintLabelsTest.php index bc8402f..9103868 100644 --- a/tests/PrintLabelsTest.php +++ b/tests/PrintLabelsTest.php @@ -14,7 +14,7 @@ * print app needs, and marks orders as completed server-side after a * successful fetch when the setting asks for it. */ -class PrintLabelsTest extends WP_Ajax_UnitTestCase +class PrintLabelsTest extends Sendy_Ajax_TestCase { private PrintLabels $module; @@ -22,23 +22,7 @@ public function setUp(): void { parent::setUp(); - Sendy_Fake_Order::reset(); - $this->module = new PrintLabels(); - - $user = self::factory()->user->create(['role' => 'administrator']); - wp_set_current_user($user); - wp_get_current_user()->add_cap('manage_woocommerce'); - wp_get_current_user()->add_cap('edit_shop_orders'); - - delete_option('sendy_flash_admin_messages'); - } - - public function tearDown(): void - { - $this->set_api_connection(null); - - parent::tearDown(); } public function test_only_orders_with_a_shipment_contribute_their_shipment_id(): void @@ -101,7 +85,11 @@ public function test_successful_fetch_returns_the_api_response_unwrapped_with_a_ $orderWithShipment = new Sendy_Fake_Order(1, ['_sendy_shipment_id' => 'shipment-1']); $orderWithoutShipment = new Sendy_Fake_Order(2); - $transport = $this->fake_transport(200, ['x-sendy-token' => ['print-app-token']], ['labels' => 'BASE64PDF']); + $transport = $this->fake_transport(new Response( + 200, + ['x-sendy-token' => ['print-app-token']], + json_encode(['labels' => 'BASE64PDF']), + )); $response = $this->dispatch(['order_ids' => [1, 2]]); @@ -122,7 +110,7 @@ public function test_no_reload_is_requested_when_nothing_changed_server_side(): $order = new Sendy_Fake_Order(1, ['_sendy_shipment_id' => 'shipment-1']); - $this->fake_transport(200, [], ['labels' => 'BASE64PDF']); + $this->fake_transport(new Response(200, [], json_encode(['labels' => 'BASE64PDF']))); $response = $this->dispatch(['order_ids' => [1]]); @@ -136,7 +124,7 @@ public function test_api_failure_yields_a_502_with_a_flash_notice(): void $order = new Sendy_Fake_Order(1, ['_sendy_shipment_id' => 'shipment-1']); - $this->fake_transport(500, [], ['message' => 'Whoops']); + $this->fake_transport(new Response(500, [], json_encode(['message' => 'Whoops']))); $response = $this->dispatch(['order_ids' => [1]]); @@ -187,50 +175,8 @@ public function test_a_user_without_the_required_capabilities_is_rejected(): voi */ private function dispatch(array $post): array { - $_POST = array_merge([ - 'action' => 'sendy_print_labels', + return $this->dispatch_ajax('sendy_print_labels', array_merge([ 'nonce' => wp_create_nonce('sendy_print_labels'), - ], $post); - - - try { - $this->_handleAjax('sendy_print_labels'); - $this->fail('The AJAX handler was expected to send a JSON response and die'); - } catch (WPAjaxDieContinueException $exception) { - // wp_send_json() ends in an empty wp_die(), which the AJAX test - // case converts into this exception after buffering the output. - } - - return json_decode($this->_last_response, true); - } - - /** - * Point ApiClientFactory's memoized connection at a canned HTTP response. - */ - private function fake_transport(int $statusCode, array $headers, array $body): Sendy_Fake_Transport - { - $transport = new Sendy_Fake_Transport(new Response($statusCode, $headers, json_encode($body))); - - // buildConnectionUsingTokens() refuses to run with empty token options. - update_option('sendy_access_token', 'access-token', false); - update_option('sendy_refresh_token', 'refresh-token', false); - update_option('sendy_token_expires', time() + 3600, false); - - $this->set_api_connection( - (new Connection()) - ->setTransport($transport) - ->setAccessToken('access-token') - ->setRefreshToken('refresh-token') - ->setTokenExpires(time() + 3600) - ); - - return $transport; - } - - private function set_api_connection(?Connection $connection): void - { - $property = new ReflectionProperty(ApiClientFactory::class, 'connection'); - $property->setAccessible(true); - $property->setValue(null, $connection); + ], $post)); } } diff --git a/tests/RowActionsTest.php b/tests/RowActionsTest.php index 0abf928..6d9ca5a 100644 --- a/tests/RowActionsTest.php +++ b/tests/RowActionsTest.php @@ -25,11 +25,10 @@ public function test_orders_with_a_shipment_get_a_print_label_action(): void $this->assertArrayHasKey('complete', $actions, 'Existing actions must be preserved'); $this->assertSame([ - 'url' => '#', + 'url' => '#sendy-print-label-1', 'name' => 'Sendy - Print label', 'action' => 'sendy_print_label', ], $actions['sendy_print_label']); - $this->assertArrayNotHasKey('sendy_create_shipment_print_label', $actions); } public function test_orders_without_a_shipment_get_a_create_and_print_action(): void @@ -39,11 +38,10 @@ public function test_orders_without_a_shipment_get_a_create_and_print_action(): $actions = (new RowActions())->add_print_label_action([], $order); $this->assertSame([ - 'url' => '#', + 'url' => '#sendy-print-label-1', 'name' => 'Sendy - Create shipment and print label', - 'action' => 'sendy_create_shipment_print_label', - ], $actions['sendy_create_shipment_print_label']); - $this->assertArrayNotHasKey('sendy_print_label', $actions); + 'action' => 'sendy_print_label', + ], $actions['sendy_print_label']); } public function test_the_sendy_processing_method_only_offers_to_print(): void @@ -54,8 +52,7 @@ public function test_the_sendy_processing_method_only_offers_to_print(): void $actions = (new RowActions())->add_print_label_action([], new Sendy_Fake_Order(1)); - $this->assertSame('sendy_print_label', $actions['sendy_print_label']['action']); - $this->assertArrayNotHasKey('sendy_create_shipment_print_label', $actions); + $this->assertSame('Sendy - Print label', $actions['sendy_print_label']['name']); } public function test_the_filter_is_registered(): void diff --git a/tests/Sendy_Ajax_TestCase.php b/tests/Sendy_Ajax_TestCase.php new file mode 100644 index 0000000..0e1a1b1 --- /dev/null +++ b/tests/Sendy_Ajax_TestCase.php @@ -0,0 +1,59 @@ +user->create(['role' => 'administrator']); + wp_set_current_user($user); + wp_get_current_user()->add_cap('manage_woocommerce'); + wp_get_current_user()->add_cap('edit_shop_orders'); + + update_option('sendy_processing_method', ProcessingMethod::WooCommerce); + delete_option('sendy_flash_admin_messages'); + } + + public function tearDown(): void + { + $this->set_api_connection(null); + + parent::tearDown(); + } + + /** + * Fire the AJAX endpoint and return the decoded JSON response. + * + * @param array $post + * @return array + */ + protected function dispatch_ajax(string $action, array $post): array + { + $_POST = array_merge(['action' => $action], $post); + + try { + $this->_handleAjax($action); + $this->fail('The AJAX handler was expected to send a JSON response and die'); + } catch (WPAjaxDieContinueException $exception) { + // wp_send_json() ends in an empty wp_die(), which the AJAX test + // case converts into this exception after buffering the output. + } + + return json_decode($this->_last_response, true); + } +} diff --git a/tests/ShipmentsStatusTest.php b/tests/ShipmentsStatusTest.php index 9058ab9..a87e9a4 100644 --- a/tests/ShipmentsStatusTest.php +++ b/tests/ShipmentsStatusTest.php @@ -14,34 +14,13 @@ * shipment first, or polling until the portal has generated the label), and * persists the packages meta once a shipment reaches the generated status. */ -class ShipmentsStatusTest extends WP_Ajax_UnitTestCase +class ShipmentsStatusTest extends Sendy_Ajax_TestCase { - use Sendy_Fakes_Api_Connection; - - private CreateShipments $module; - public function setUp(): void { parent::setUp(); - Sendy_Fake_Order::reset(); - - $this->module = new CreateShipments(); - - $user = self::factory()->user->create(['role' => 'administrator']); - wp_set_current_user($user); - wp_get_current_user()->add_cap('manage_woocommerce'); - wp_get_current_user()->add_cap('edit_shop_orders'); - - update_option('sendy_processing_method', ProcessingMethod::WooCommerce); - delete_option('sendy_flash_admin_messages'); - } - - public function tearDown(): void - { - $this->set_api_connection(null); - - parent::tearDown(); + new CreateShipments(); } public function test_orders_without_a_shipment_or_that_do_not_resolve_are_reported_as_none(): void @@ -254,19 +233,9 @@ public function test_a_user_without_the_required_capabilities_is_rejected(): voi */ private function dispatch(array $post): array { - $_POST = array_merge([ - 'action' => 'sendy_shipments_status', + return $this->dispatch_ajax('sendy_shipments_status', array_merge([ 'nonce' => wp_create_nonce('sendy_print_labels'), - ], $post); - - try { - $this->_handleAjax('sendy_shipments_status'); - $this->fail('The AJAX handler was expected to send a JSON response and die'); - } catch (WPAjaxDieContinueException $exception) { - // wp_send_json() ends in an empty wp_die(). - } - - return json_decode($this->_last_response, true); + ], $post)); } /** diff --git a/tests/SingleCreateShipmentTest.php b/tests/SingleCreateShipmentTest.php index f50a8b0..2e2466f 100644 --- a/tests/SingleCreateShipmentTest.php +++ b/tests/SingleCreateShipmentTest.php @@ -11,31 +11,13 @@ * single order, through a preference (WooCommerce processing method) or smart * rules (Sendy processing method). */ -class SingleCreateShipmentTest extends WP_Ajax_UnitTestCase +class SingleCreateShipmentTest extends Sendy_Ajax_TestCase { - use Sendy_Fakes_Api_Connection; - public function setUp(): void { parent::setUp(); - Sendy_Fake_Order::reset(); - new Single(); - - $user = self::factory()->user->create(['role' => 'administrator']); - wp_set_current_user($user); - wp_get_current_user()->add_cap('manage_woocommerce'); - - update_option('sendy_processing_method', ProcessingMethod::WooCommerce); - delete_option('sendy_flash_admin_messages'); - } - - public function tearDown(): void - { - $this->set_api_connection(null); - - parent::tearDown(); } public function test_the_shipment_is_created_from_the_chosen_preference(): void diff --git a/tests/bootstrap.php b/tests/bootstrap.php index f65de3b..cbcf600 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -43,3 +43,4 @@ // in its procedural helpers and the test doubles the tests rely on. require_once dirname(__DIR__) . '/lib/helpers.php'; require_once __DIR__ . '/doubles.php'; +require_once __DIR__ . '/Sendy_Ajax_TestCase.php'; diff --git a/tests/doubles.php b/tests/doubles.php index ed3043d..83d73e3 100644 --- a/tests/doubles.php +++ b/tests/doubles.php @@ -230,7 +230,7 @@ public function getUserAgent(): string */ trait Sendy_Fakes_Api_Connection { - private function fake_transport(Response ...$responses): Sendy_Fake_Transport + protected function fake_transport(Response ...$responses): Sendy_Fake_Transport { $transport = new Sendy_Fake_Transport(...$responses); @@ -249,7 +249,7 @@ private function fake_transport(Response ...$responses): Sendy_Fake_Transport return $transport; } - private function set_api_connection(?Connection $connection): void + protected function set_api_connection(?Connection $connection): void { $property = new ReflectionProperty(ApiClientFactory::class, 'connection'); $property->setAccessible(true); From 4c54482abb5617a1c3f4f31b26e9f2e24961b279 Mon Sep 17 00:00:00 2001 From: Adriaan Zonnenberg Date: Wed, 29 Jul 2026 18:13:28 +0200 Subject: [PATCH 11/18] Add a singular label When creating a shipping label for a single order, the label in the modal now reads singular. --- languages/sendy-nl_NL.mo | Bin 8402 -> 8484 bytes languages/sendy-nl_NL.po | 65 ++++++++++---------- languages/sendy.pot | 81 +++++++++++++------------ lib/Modules/Orders/BulkActions.php | 2 + resources/js/orders-list-print-flow.js | 9 ++- 5 files changed, 85 insertions(+), 72 deletions(-) diff --git a/languages/sendy-nl_NL.mo b/languages/sendy-nl_NL.mo index 0133034f53421198e46c8af03a362eae3333e1b8..f15c934dadb02b7c013ef646b4b20f5a132dc026 100644 GIT binary patch delta 1493 zcmYM!OGs2v9LMp$;^eCtb!xfY;SUWKaQ zZY;r0EF!=4(@>&kn1d6@Wi#AV(im<{=tB+AhH2=y_MpCh9Vw~Zcl)2>D&jY&1t#;- zN(^8rZbwJk+)bkp&toRuLGAGfYA-*a2A)B!B#AO94x&~XLiIOb2#=yFcoX&ghZu*? zQ573Q<@x2K{tYzJczYurM8&tT2LIq{tYpI*@dWO`QRIj1k!u^~6JJBEcm%oZ zEw>{vX4ANqcqg+`B`>8@f0aB+hfe<}>TrETw%>lB63rotH!sUp3A0cY*u;%(w|%ID zhu!{etS9clYMeyQsS^kR^XDCXizw|^S963^mhtXpgr!7JE-LF(Ut z5nO>&sFfvBUvgRynY)Ehm8eJk$_`))I#+3EhI6QrhuKK2+;P2#dBhRS!eQjHSKO57 zE0&>$paet6d9y>PGj$SmnETxRC~D$E$mLi*qbgw-^^c?uHS@j5BJDUbH@kqEXg_KK zlNiR|s1=p7e>%)vs6*C`B(NyzFh4FP?hs=khWnRHe$q$KjQ}CGWJ#Doxx_jhfQl}B;?H}XhFSj4687Tn&}j3 l=Ci0h^aqCeJb~CqyEkoUFn+)rsm=CB&SsX-Fa+&rC${AN^?6}V7I9Br!bHxoa ztCd^I4(%Q;Fekc`N*M=g{fD?x`zto_ zCYSR8SFpuPxx2kAEfH)H%;jF@-j zbj8pU{CGAVZNa~!i}6@nH%Ore8f?1W>NhY zvCLf2_mm?>X^wG^S%FFBud1N#+u6&U*f-2dm&OIT;+Je>x|*#lavpck75m*X(J(t% zVF&Ngxr#;)^DMQ}d5w+C{yyf!2k45cZkcDC{%@3GLXb0`E}O;0e40~qF=x7oa>PNd z=a#tME-ua4U;WRJMoM!#m9J7Te!#+|GV}#~i=D)$_%Id4gO~A9JQ<=FCT! Xd-zjpWn^mGl*;(D;X>t$rqayEyV;Y4 diff --git a/languages/sendy-nl_NL.po b/languages/sendy-nl_NL.po index 6b7b08c..5bef296 100644 --- a/languages/sendy-nl_NL.po +++ b/languages/sendy-nl_NL.po @@ -37,7 +37,7 @@ msgstr "Een plug-in die je WooCommerce shop verbindt met Sendy" msgid "https://sendy.nl/" msgstr "https://sendy.nl/" -#: lib/Modules/Orders/BulkActions.php:204 +#: lib/Modules/Orders/BulkActions.php:197 #: resources/views/admin/meta_box/single.php:18 msgid "Shop" msgstr "Shop" @@ -46,7 +46,7 @@ msgstr "Shop" msgid "Shipping preference" msgstr "Verzendprofiel" -#: lib/Modules/Orders/BulkActions.php:223 +#: lib/Modules/Orders/BulkActions.php:216 #: resources/views/admin/meta_box/single.php:38 msgid "Amount of packages" msgstr "Aantal pakketten" @@ -210,72 +210,70 @@ msgid "Sendy - Create shipments" msgstr "Sendy - Zendingen aanmaken" #: lib/Modules/Orders/BulkActions.php:39 -#: lib/Modules/Orders/BulkActions.php:120 +#: lib/Modules/Orders/BulkActions.php:114 msgid "Sendy - Print labels" msgstr "Sendy - Labels printen" -#: lib/Modules/Orders/PrintLabels.php:121 +#: lib/Modules/Orders/PrintLabels.php:109 msgid "Sendy: Label printed" msgstr "Sendy: Label geprint" -#: lib/Modules/Orders/BulkActions.php:205 +#: lib/Modules/Orders/BulkActions.php:198 msgid "The shipments will be created with the selected shop" msgstr "De zendingen worden aangemaakt met de geselecteerde shop" -#: lib/Modules/Orders/BulkActions.php:214 +#: lib/Modules/Orders/BulkActions.php:207 msgid "Select preference" msgstr "Selecteer verzendprofiel" -#: lib/Modules/Orders/BulkActions.php:215 +#: lib/Modules/Orders/BulkActions.php:208 msgid "The shipments will be created with the preference you select here" msgstr "De zendingen worden aangemaakt met de geselecteerde verzendvoorkeur" #. translators: %s The ID of the order -#: lib/Modules/Orders/OrdersModule.php:86 +#: lib/Modules/Orders/OrdersModule.php:125 #, php-format msgid "Order #%s already has a shipment created" msgstr "Voor order #%s is al een zending aangemaakt" -#: lib/Modules/Orders/OrdersModule.php:119 +#: lib/Modules/Orders/OrdersModule.php:158 #: lib/Modules/Webhooks.php:216 msgid "Sendy: Shipment created" msgstr "Sendy: Zending aangemaakt" #. translators: %1$s should contain the ID of the order and %2$s the error -#: lib/Modules/Orders/CreateShipments.php:68 -#: lib/Modules/Orders/OrdersModule.php:258 -#: lib/Modules/Orders/OrdersModule.php:273 +#: lib/Modules/Orders/CreateShipments.php:61 +#: lib/Modules/Orders/OrdersModule.php:297 +#: lib/Modules/Orders/OrdersModule.php:312 #, php-format msgid "Error while creating shipment for order #%1$s: %2$s" msgstr "Er ging iets mis bij het aanmaken van een zending voor order #%1$s: %2$s" #. translators: %s should contain the ID of the order -#: lib/Modules/Orders/OrdersModule.php:264 +#: lib/Modules/Orders/OrdersModule.php:303 #, php-format msgid "Error while creating shipment for order #%s: Authentication failed. Check the settings page to reconnect with Sendy." msgstr "Er ging iets mis bij het aanmaken van een zending voor order #%s: Authenticatie mislukt. Ga naar de instellingen om opnieuw te verbinden met Sendy." #. translators: %s should contain the ID of the order -#: lib/Modules/Orders/OrdersModule.php:276 +#: lib/Modules/Orders/OrdersModule.php:315 #, php-format msgid "Error while creating shipment for order #%s: Too many requests. Please try again later." msgstr "Er ging iets mis bij het aanmaken van een zending voor order #%s: Te veel requests. Probeer het later opnieuw." #. translators: %s should contain the ID of the order -#: lib/Modules/Orders/OrdersModule.php:279 +#: lib/Modules/Orders/OrdersModule.php:318 #, php-format msgid "Error while creating shipment for order #%s: Unknown error." msgstr "Er ging iets mis bij het aanmaken van een zending voor order #%s: Onbekende fout." -#: lib/Modules/Orders/CreateShipments.php:188 -#: lib/Modules/Orders/PrintLabels.php:27 +#: lib/Modules/Orders/OrdersModule.php:99 #: lib/Modules/Orders/Single.php:122 msgid "Nonce verification failed" msgstr "Nonce verificatie mislukt" #: lib/Modules/Admin/Settings.php:257 -#: lib/Modules/Orders/CreateShipments.php:192 -#: lib/Modules/Orders/PrintLabels.php:34 +#: lib/Modules/Orders/OrdersModule.php:101 #: lib/Modules/Orders/Single.php:118 msgid "You do not have sufficient permissions to access this page." msgstr "Je hebt niet de juiste rechten om de pagina te bekijken." @@ -350,21 +348,21 @@ msgstr "Kan geen verbinding maken met Sendy. Probeer het later opnieuw." msgid "Nonce verification failed." msgstr "Nonce verificatie mislukt." -#: lib/Modules/Orders/BulkActions.php:89 +#: lib/Modules/Orders/BulkActions.php:83 msgid "Printing labels requires JavaScript. Enable JavaScript in your browser and try again." msgstr "Voor het printen van labels is JavaScript vereist. Schakel JavaScript in je browser in en probeer het opnieuw." -#: lib/Modules/Orders/PrintLabels.php:44 +#: lib/Modules/Orders/PrintLabels.php:32 msgid "None of the selected orders have any labels" msgstr "Geen van de geselecteerde bestellingen heeft labels" #. translators: %s contains the error message -#: lib/Modules/Orders/PrintLabels.php:60 +#: lib/Modules/Orders/PrintLabels.php:48 #, php-format msgid "Error while fetching labels: %s" msgstr "Fout bij het ophalen van labels: %s" -#: lib/Modules/Orders/BulkActions.php:121 +#: lib/Modules/Orders/BulkActions.php:116 msgid "Create shipments and print labels" msgstr "Zendingen aanmaken en labels printen" @@ -373,45 +371,50 @@ msgid "Shipments for this processing method are created in Sendy" msgstr "Zendingen voor deze werkwijze worden aangemaakt in Sendy" #. translators: %s contains the number of orders -#: lib/Modules/Orders/CreateShipments.php:168 +#: lib/Modules/Orders/CreateShipments.php:155 #, php-format msgid "There is no label to print for %s orders yet. Process the shipments in Sendy first." msgstr "Er is nog geen label om te printen voor %s orders. Verwerk de zendingen eerst in Sendy." #. translators: %s contains the ID of the order -#: lib/Modules/Orders/CreateShipments.php:170 +#: lib/Modules/Orders/CreateShipments.php:157 #, php-format msgid "There is no label to print for order #%s yet. Process the shipment in Sendy first." msgstr "Er is nog geen label om te printen voor order #%s. Verwerk de zending eerst in Sendy." #. translators: %s contains the number of orders -#: lib/Modules/Orders/CreateShipments.php:174 +#: lib/Modules/Orders/CreateShipments.php:161 #, php-format msgid "The labels for %s orders are still being generated. Please try again in a moment." msgstr "De labels voor %s orders worden nog gegenereerd. Probeer het later opnieuw." #. translators: %s contains the ID of the order -#: lib/Modules/Orders/CreateShipments.php:176 +#: lib/Modules/Orders/CreateShipments.php:163 #, php-format msgid "The labels for order #%s are still being generated. Please try again in a moment." msgstr "De labels voor order #%s worden nog gegenereerd. Probeer het later opnieuw." #. translators: %1$s contains the ID of the order, %2$s the error message -#: lib/Modules/Orders/CreateShipments.php:222 +#: lib/Modules/Orders/CreateShipments.php:195 #, php-format msgid "Error while checking the shipment for order #%1$s: %2$s" msgstr "Er ging iets mis bij het controleren van de zending voor order #%1$s: %2$s" #. translators: %1$s contains the ID of the order, %2$s the status of the shipment -#: lib/Modules/Orders/CreateShipments.php:245 +#: lib/Modules/Orders/CreateShipments.php:218 #, php-format msgid "The labels for order #%1$s cannot be printed (shipment status: %2$s)" msgstr "De labels voor order #%1$s kunnen niet worden geprint (status van de zending: %2$s)" -#: lib/Modules/Orders/RowActions.php:31 +#: lib/Modules/Orders/BulkActions.php:115 +#: lib/Modules/Orders/RowActions.php:35 msgid "Sendy - Print label" msgstr "Sendy - Label printen" -#: lib/Modules/Orders/RowActions.php:37 +#: lib/Modules/Orders/RowActions.php:34 msgid "Sendy - Create shipment and print label" msgstr "Sendy - Zending aanmaken en label printen" + +#: lib/Modules/Orders/BulkActions.php:117 +msgid "Create shipment and print label" +msgstr "Zending aanmaken en label printen" diff --git a/languages/sendy.pot b/languages/sendy.pot index 62f3210..8f1c69b 100644 --- a/languages/sendy.pot +++ b/languages/sendy.pot @@ -9,7 +9,7 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"POT-Creation-Date: 2026-07-29T08:18:55+00:00\n" +"POT-Creation-Date: 2026-07-29T16:06:46+00:00\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "X-Generator: WP-CLI 2.12.0\n" "X-Domain: sendy\n" @@ -95,8 +95,7 @@ msgid "After the shipment is delivered" msgstr "" #: lib/Modules/Admin/Settings.php:257 -#: lib/Modules/Orders/CreateShipments.php:192 -#: lib/Modules/Orders/PrintLabels.php:34 +#: lib/Modules/Orders/OrdersModule.php:101 #: lib/Modules/Orders/Single.php:118 msgid "You do not have sufficient permissions to access this page." msgstr "" @@ -124,36 +123,45 @@ msgid "Sendy - Create shipments" msgstr "" #: lib/Modules/Orders/BulkActions.php:39 -#: lib/Modules/Orders/BulkActions.php:120 +#: lib/Modules/Orders/BulkActions.php:114 msgid "Sendy - Print labels" msgstr "" -#: lib/Modules/Orders/BulkActions.php:89 +#: lib/Modules/Orders/BulkActions.php:83 msgid "Printing labels requires JavaScript. Enable JavaScript in your browser and try again." msgstr "" -#: lib/Modules/Orders/BulkActions.php:121 +#: lib/Modules/Orders/BulkActions.php:115 +#: lib/Modules/Orders/RowActions.php:35 +msgid "Sendy - Print label" +msgstr "" + +#: lib/Modules/Orders/BulkActions.php:116 msgid "Create shipments and print labels" msgstr "" -#: lib/Modules/Orders/BulkActions.php:204 +#: lib/Modules/Orders/BulkActions.php:117 +msgid "Create shipment and print label" +msgstr "" + +#: lib/Modules/Orders/BulkActions.php:197 #: resources/views/admin/meta_box/single.php:18 msgid "Shop" msgstr "" -#: lib/Modules/Orders/BulkActions.php:205 +#: lib/Modules/Orders/BulkActions.php:198 msgid "The shipments will be created with the selected shop" msgstr "" -#: lib/Modules/Orders/BulkActions.php:214 +#: lib/Modules/Orders/BulkActions.php:207 msgid "Select preference" msgstr "" -#: lib/Modules/Orders/BulkActions.php:215 +#: lib/Modules/Orders/BulkActions.php:208 msgid "The shipments will be created with the preference you select here" msgstr "" -#: lib/Modules/Orders/BulkActions.php:223 +#: lib/Modules/Orders/BulkActions.php:216 #: resources/views/admin/meta_box/single.php:38 msgid "Amount of packages" msgstr "" @@ -163,51 +171,45 @@ msgid "Shipments for this processing method are created in Sendy" msgstr "" #. translators: %1$s should contain the ID of the order and %2$s the error -#: lib/Modules/Orders/CreateShipments.php:68 -#: lib/Modules/Orders/OrdersModule.php:258 -#: lib/Modules/Orders/OrdersModule.php:273 +#: lib/Modules/Orders/CreateShipments.php:61 +#: lib/Modules/Orders/OrdersModule.php:297 +#: lib/Modules/Orders/OrdersModule.php:312 #, php-format msgid "Error while creating shipment for order #%1$s: %2$s" msgstr "" #. translators: %s contains the number of orders -#: lib/Modules/Orders/CreateShipments.php:168 +#: lib/Modules/Orders/CreateShipments.php:155 #, php-format msgid "There is no label to print for %s orders yet. Process the shipments in Sendy first." msgstr "" #. translators: %s contains the ID of the order -#: lib/Modules/Orders/CreateShipments.php:170 +#: lib/Modules/Orders/CreateShipments.php:157 #, php-format msgid "There is no label to print for order #%s yet. Process the shipment in Sendy first." msgstr "" #. translators: %s contains the number of orders -#: lib/Modules/Orders/CreateShipments.php:174 +#: lib/Modules/Orders/CreateShipments.php:161 #, php-format msgid "The labels for %s orders are still being generated. Please try again in a moment." msgstr "" #. translators: %s contains the ID of the order -#: lib/Modules/Orders/CreateShipments.php:176 +#: lib/Modules/Orders/CreateShipments.php:163 #, php-format msgid "The labels for order #%s are still being generated. Please try again in a moment." msgstr "" -#: lib/Modules/Orders/CreateShipments.php:188 -#: lib/Modules/Orders/PrintLabels.php:27 -#: lib/Modules/Orders/Single.php:122 -msgid "Nonce verification failed" -msgstr "" - #. translators: %1$s contains the ID of the order, %2$s the error message -#: lib/Modules/Orders/CreateShipments.php:222 +#: lib/Modules/Orders/CreateShipments.php:195 #, php-format msgid "Error while checking the shipment for order #%1$s: %2$s" msgstr "" #. translators: %1$s contains the ID of the order, %2$s the status of the shipment -#: lib/Modules/Orders/CreateShipments.php:245 +#: lib/Modules/Orders/CreateShipments.php:218 #, php-format msgid "The labels for order #%1$s cannot be printed (shipment status: %2$s)" msgstr "" @@ -222,54 +224,55 @@ msgstr "" msgid "Track and trace" msgstr "" +#: lib/Modules/Orders/OrdersModule.php:99 +#: lib/Modules/Orders/Single.php:122 +msgid "Nonce verification failed" +msgstr "" + #. translators: %s The ID of the order -#: lib/Modules/Orders/OrdersModule.php:86 +#: lib/Modules/Orders/OrdersModule.php:125 #, php-format msgid "Order #%s already has a shipment created" msgstr "" -#: lib/Modules/Orders/OrdersModule.php:119 +#: lib/Modules/Orders/OrdersModule.php:158 #: lib/Modules/Webhooks.php:216 msgid "Sendy: Shipment created" msgstr "" #. translators: %s should contain the ID of the order -#: lib/Modules/Orders/OrdersModule.php:264 +#: lib/Modules/Orders/OrdersModule.php:303 #, php-format msgid "Error while creating shipment for order #%s: Authentication failed. Check the settings page to reconnect with Sendy." msgstr "" #. translators: %s should contain the ID of the order -#: lib/Modules/Orders/OrdersModule.php:276 +#: lib/Modules/Orders/OrdersModule.php:315 #, php-format msgid "Error while creating shipment for order #%s: Too many requests. Please try again later." msgstr "" #. translators: %s should contain the ID of the order -#: lib/Modules/Orders/OrdersModule.php:279 +#: lib/Modules/Orders/OrdersModule.php:318 #, php-format msgid "Error while creating shipment for order #%s: Unknown error." msgstr "" -#: lib/Modules/Orders/PrintLabels.php:44 +#: lib/Modules/Orders/PrintLabels.php:32 msgid "None of the selected orders have any labels" msgstr "" #. translators: %s contains the error message -#: lib/Modules/Orders/PrintLabels.php:60 +#: lib/Modules/Orders/PrintLabels.php:48 #, php-format msgid "Error while fetching labels: %s" msgstr "" -#: lib/Modules/Orders/PrintLabels.php:121 +#: lib/Modules/Orders/PrintLabels.php:109 msgid "Sendy: Label printed" msgstr "" -#: lib/Modules/Orders/RowActions.php:31 -msgid "Sendy - Print label" -msgstr "" - -#: lib/Modules/Orders/RowActions.php:37 +#: lib/Modules/Orders/RowActions.php:34 msgid "Sendy - Create shipment and print label" msgstr "" diff --git a/lib/Modules/Orders/BulkActions.php b/lib/Modules/Orders/BulkActions.php index cc981ec..b57fc11 100644 --- a/lib/Modules/Orders/BulkActions.php +++ b/lib/Modules/Orders/BulkActions.php @@ -112,7 +112,9 @@ public function enqueue_assets(): void wp_localize_script('sendy-orders-list-print-flow', 'sendyPrintFlow', [ 'modalTitle' => __('Sendy - Print labels', 'sendy'), + 'modalTitleSingular' => __('Sendy - Print label', 'sendy'), 'modalButtonText' => __('Create shipments and print labels', 'sendy'), + 'modalButtonTextSingular' => __('Create shipment and print label', 'sendy'), 'printExistingOnly' => $this->print_existing_labels_only(), ]); diff --git a/resources/js/orders-list-print-flow.js b/resources/js/orders-list-print-flow.js index ddf0c45..9a657eb 100644 --- a/resources/js/orders-list-print-flow.js +++ b/resources/js/orders-list-print-flow.js @@ -271,9 +271,14 @@ let createdShipments = false; if ( withoutShipment.length ) { + const single = orderIds.length === 1; const fields = await window.sendyOpenCreateShipmentsModal( { - title: sendyPrintFlow.modalTitle, - buttonText: sendyPrintFlow.modalButtonText, + title: single + ? sendyPrintFlow.modalTitleSingular + : sendyPrintFlow.modalTitle, + buttonText: single + ? sendyPrintFlow.modalButtonTextSingular + : sendyPrintFlow.modalButtonText, } ); if ( ! fields ) { From 1b115b4bce99ebef195a6d3cc41b0222a8535f16 Mon Sep 17 00:00:00 2001 From: Adriaan Zonnenberg Date: Fri, 7 Aug 2026 17:52:36 +0200 Subject: [PATCH 12/18] Harden the create shipments bulk action --- lib/Modules/Orders/BulkActions.php | 11 +++-- lib/Modules/Orders/OrdersModule.php | 35 ++++++++++----- tests/BulkActionsTest.php | 66 +++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 15 deletions(-) create mode 100644 tests/BulkActionsTest.php diff --git a/lib/Modules/Orders/BulkActions.php b/lib/Modules/Orders/BulkActions.php index b57fc11..a23eb7c 100644 --- a/lib/Modules/Orders/BulkActions.php +++ b/lib/Modules/Orders/BulkActions.php @@ -56,10 +56,14 @@ public function handle_bulk_action_create_shipments(string $redirect, string $ac $shopId = sanitize_key($_REQUEST['sendy_shop_id'] ?? ''); $preferenceId = sanitize_key($_REQUEST['sendy_preference_id'] ?? ''); - $amount = sanitize_key($_REQUEST['sendy_amount'] ?? ''); + $amount = sanitize_key($_REQUEST['sendy_amount'] ?? '1'); foreach ($objectIds as $id) { - $this->create_shipment(wc_get_order($id), $shopId, $preferenceId, (int) $amount); + $order = wc_get_order($id); + + if ($order) { + $this->create_shipment_flashing_errors($order, $shopId, $preferenceId, (int) $amount); + } } $this->remember_previously_used($shopId, $preferenceId, $amount); @@ -70,8 +74,7 @@ public function handle_bulk_action_create_shipments(string $redirect, string $ac /** * Handle the print labels bulk action * - * The real work happens through the sendy_print_labels AJAX endpoint; - * print-labels.js intercepts the form submission. Reaching this handler + * print-labels.js intercepts the form submission; reaching this handler * means JavaScript was disabled or bypassed. */ public function handle_bulk_action_print_labels(string $redirect, string $action, array $objectIds): string diff --git a/lib/Modules/Orders/OrdersModule.php b/lib/Modules/Orders/OrdersModule.php index c680215..db4a846 100644 --- a/lib/Modules/Orders/OrdersModule.php +++ b/lib/Modules/Orders/OrdersModule.php @@ -4,6 +4,7 @@ use GuzzleHttp\Exception\GuzzleException; use Sendy\Api\ApiException; +use Sendy\Api\Exceptions\SendyException; use Sendy\WooCommerce\ApiClientFactory; use Sendy\WooCommerce\Enums\ProcessingMethod; use Sendy\WooCommerce\Repositories\Shops; @@ -40,10 +41,8 @@ protected function is_pickup_point_delivery(\WC_Order $order): bool /** * Whether the orders list can only print labels that already exist * - * The Sendy processing method has the portal generate the labels, through - * smart rules, for the shipments the plugin creates in the background. A - * shipment created from the orders list would therefore not yield a label - * to wait for, so the print flow prints what is already there instead. + * With the Sendy processing method the portal creates and generates the + * shipments itself, so the orders list never has a new label to wait for. */ protected function print_existing_labels_only(): bool { @@ -53,10 +52,6 @@ protected function print_existing_labels_only(): bool /** * Create a shipment for the order through the configured processing method * - * The WooCommerce processing method creates the shipment from the given - * preference; the Sendy processing method leaves the choice of carrier to - * the smart rules configured in the portal. - * * @param string $shopId The UUID of the selected shop * @param string $preferenceId The UUID of the selected shipping preference * @param int $amount The amount of packages the shipment should contain @@ -72,15 +67,33 @@ protected function create_shipment(\WC_Order $order, string $shopId, string $pre } /** - * Remember the create-shipments modal values as the defaults for next time + * Create a shipment and flash any failure as an admin notice * - * Only the WooCommerce processing method uses the preference and the - * amount, so only that method remembers them. + * The creation methods flash API errors themselves; this also catches what + * they throw outside that, so one failing order does not abort the batch. + */ + protected function create_shipment_flashing_errors(\WC_Order $order, string $shopId, string $preferenceId, int $amount): void + { + try { + $this->create_shipment($order, $shopId, $preferenceId, $amount); + } catch (SendyException $exception) { + // translators: %1$s contains the ID of the order, %2$s the error message + sendy_flash_admin_notice('error', sprintf( + __('Error while creating shipment for order #%1$s: %2$s', 'sendy'), + $order->get_id(), + $exception->getMessage(), + )); + } + } + + /** + * Remember the create-shipments modal values as the defaults for next time */ protected function remember_previously_used(string $shopId, string $preferenceId, string $amount): void { update_option('sendy_previously_used_shop_id', $shopId); + // Only the WooCommerce processing method uses a preference and amount. if (! $this->print_existing_labels_only()) { update_option('sendy_previously_used_preference_id', $preferenceId); update_option('sendy_previously_used_amount', $amount); diff --git a/tests/BulkActionsTest.php b/tests/BulkActionsTest.php new file mode 100644 index 0000000..1b21c0f --- /dev/null +++ b/tests/BulkActionsTest.php @@ -0,0 +1,66 @@ +fake_transport(new Response(200, [], json_encode([ + 'data' => ['uuid' => 'new-shipment', 'packages' => []], + ]))); + + $redirect = $this->handle([99, 1]); + + $this->assertSame('redirect', $redirect); + $this->assertSame('new-shipment', $order->get_meta('_sendy_shipment_id')); + } + + public function test_a_failing_order_does_not_abort_the_rest_of_the_batch(): void + { + update_option('sendy_processing_method', ProcessingMethod::Sendy); + + new Sendy_Fake_Order(1); + new Sendy_Fake_Order(2); + + // Without the sendy_shops transient every order fetches the shop + // list, a call that create_shipment() does not guard itself. + $transport = $this->fake_transport( + new Response(500, [], json_encode(['message' => 'Whoops'])), + new Response(500, [], json_encode(['message' => 'Whoops'])), + ); + + $this->handle([1, 2]); + + $this->assertCount(2, $transport->requests, 'The second order must still be attempted'); + + $messages = get_option('sendy_flash_admin_messages'); + $this->assertCount(2, $messages); + $this->assertStringContainsString('order #1', $messages[0]['message']); + $this->assertStringContainsString('order #2', $messages[1]['message']); + } + + private function handle(array $orderIds): string + { + $_REQUEST['sendy_bulk_modal_nonce'] = wp_create_nonce('sendy_bulk_modal'); + $_REQUEST['sendy_shop_id'] = 'shop-1'; + $_REQUEST['sendy_preference_id'] = 'pref-1'; + $_REQUEST['sendy_amount'] = '2'; + + return (new BulkActions())->handle_bulk_action_create_shipments('redirect', 'sendy_create_shipments', $orderIds); + } +} From 74617f54ba471fa9a8ba43f03d4fcb8c42341305 Mon Sep 17 00:00:00 2001 From: Adriaan Zonnenberg Date: Fri, 7 Aug 2026 17:53:05 +0200 Subject: [PATCH 13/18] Report print flow errors to the user --- lib/Modules/Orders/CreateShipments.php | 119 ++++++++++++------------- resources/js/orders-list-print-flow.js | 47 +++++----- tests/CreateShipmentsTest.php | 22 +++++ tests/ShipmentsStatusTest.php | 17 ++++ 4 files changed, 119 insertions(+), 86 deletions(-) diff --git a/lib/Modules/Orders/CreateShipments.php b/lib/Modules/Orders/CreateShipments.php index b30e007..6e3e61b 100644 --- a/lib/Modules/Orders/CreateShipments.php +++ b/lib/Modules/Orders/CreateShipments.php @@ -15,25 +15,18 @@ public function __construct() /** * Create shipments for the selected orders that do not have one yet - * - * Used by the orders list print flow: the modal collects the shop, - * preference and amount (the same fields as the "Create shipments" bulk - * action) and this endpoint performs the creation over AJAX so the flow - * can wait for the labels and print them afterwards. Orders that already - * have a shipment are skipped. Errors per order are flashed as admin - * notices by the inherited creation methods; the response only reports - * which orders now have a shipment. */ public function handle_create_shipments(): void { - $this->verify_ajax_request('sendy_bulk_modal'); + $this->verify_ajax_request('sendy_bulk_modal', true); if ($this->print_existing_labels_only()) { - // The print flow does not offer to create shipments for this - // processing method; refuse rather than trust the client, so a - // browser running a cached older copy of the script cannot add - // shipments the portal would have created itself. - wp_send_json(['message' => __('Shipments for this processing method are created in Sendy', 'sendy')], 400); + // Refuse rather than trust the client: a cached older copy of the + // script could otherwise create shipments the portal creates itself. + $message = __('Shipments for this processing method are created in Sendy', 'sendy'); + + sendy_flash_admin_notice('error', $message); + wp_send_json(['message' => $message], 400); } $shopId = sanitize_key($_POST['shop_id'] ?? ''); @@ -42,31 +35,22 @@ public function handle_create_shipments(): void $created = []; - // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- intval() sanitizes each id - foreach (array_map('intval', (array) ($_POST['order_ids'] ?? [])) as $orderId) { - $order = wc_get_order($orderId); + try { + foreach ($this->requested_order_ids() as $orderId) { + $order = wc_get_order($orderId); - if (! $order || $order->meta_exists('_sendy_shipment_id')) { - continue; - } + if (! $order || $order->meta_exists('_sendy_shipment_id')) { + continue; + } - try { - $this->create_shipment($order, $shopId, $preferenceId, (int) $amount); - } catch (SendyException $exception) { - // The creation methods flash API validation errors themselves; - // this guards the calls they make outside their own try/catch, - // such as fetching the shop list for smart rules. - // translators: %1$s contains the ID of the order, %2$s the error message - sendy_flash_admin_notice('error', sprintf( - __('Error while creating shipment for order #%1$s: %2$s', 'sendy'), - $order->get_id(), - $exception->getMessage(), - )); - } + $this->create_shipment_flashing_errors($order, $shopId, $preferenceId, (int) $amount); - if ($order->meta_exists('_sendy_shipment_id')) { - $created[] = $orderId; + if ($order->meta_exists('_sendy_shipment_id')) { + $created[] = $orderId; + } } + } catch (\RuntimeException $exception) { + $this->send_not_connected_error(); } if ($created !== []) { @@ -77,30 +61,24 @@ public function handle_create_shipments(): void } /** - * Report per order whether its labels can be printed - * - * Returns a map of order id to one of: `none` (no shipment yet), - * `pending` (shipment exists but the label is still being generated), - * `ready` (labels can be printed), or `failed` (generation failed or the - * shipment was cancelled). The orders list print flow uses this to decide - * between printing directly, creating a shipment first, or polling until - * the Sendy portal has generated the label. - * - * With `flash_unprintable` set, the orders that have no label to print are - * explained in a single admin notice: the flow is done waiting for them. + * Report per order whether its labels can be printed: none, pending, + * ready or failed */ public function handle_shipments_status(): void { - $this->verify_ajax_request('sendy_print_labels'); + $this->verify_ajax_request('sendy_print_labels', true); $statuses = []; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- boolean flag, nothing to sanitize $flashUnprintable = ! empty($_POST['flash_unprintable']); - // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- intval() sanitizes each id - foreach (array_map('intval', (array) ($_POST['order_ids'] ?? [])) as $orderId) { - $statuses[$orderId] = $this->shipment_status(wc_get_order($orderId)); + try { + foreach ($this->requested_order_ids() as $orderId) { + $statuses[$orderId] = $this->shipment_status(wc_get_order($orderId)); + } + } catch (\RuntimeException $exception) { + $this->send_not_connected_error(); } if ($flashUnprintable) { @@ -110,12 +88,34 @@ public function handle_shipments_status(): void wp_send_json(['statuses' => $statuses]); } + /** + * @return int[] + */ + private function requested_order_ids(): array + { + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- intval() sanitizes each id + return array_map('intval', (array) ($_POST['order_ids'] ?? [])); + } + + /** + * Report a broken connection as JSON + * + * An access token registers these endpoints, but the other token options + * can still be missing; building the API connection then throws. + */ + private function send_not_connected_error(): void + { + $message = __('The plugin is not connected to Sendy. Check the settings page to reconnect.', 'sendy'); + + sendy_flash_admin_notice('error', $message); + wp_send_json(['message' => $message], 403); + } + /** * The orders that have no label to print * - * Orders with a failed shipment have flashed their own explanation - * already, and an order without a shipment is only worth mentioning when - * this flow is not the one that creates it. + * Failed shipments have flashed their own explanation, and orders without + * a shipment only matter when this flow is not the one creating them. * * @param array $statuses * @return int[] @@ -132,12 +132,8 @@ private function unprintable_orders(array $statuses): array /** * Explain in one notice which orders have no label to print * - * The Sendy processing method generates the labels in the portal, so there - * is nothing for the shop to wait for; the WooCommerce processing method - * generates them while the flow polls, so a shipment that is still pending - * when the flow gives up may well be ready shortly after. Printing a whole - * page of orders can leave many of them unprintable, so they are counted - * rather than named. + * Printing a whole page of orders can leave many of them unprintable, so + * they are counted rather than named. * * @param int[] $orderIds */ @@ -169,9 +165,8 @@ private function flash_unprintable_notice(array $orderIds): void /** * Determine the print status of a single order's shipment * - * Orders whose packages are already stored do not hit the API. When the - * API reports the shipment as generated, the packages are persisted so - * the track and trace column and the order meta box pick them up. + * Persists the packages once the shipment is generated, so later checks + * skip the API. * * @param \WC_Order|false $order */ diff --git a/resources/js/orders-list-print-flow.js b/resources/js/orders-list-print-flow.js index 9a657eb..d59952d 100644 --- a/resources/js/orders-list-print-flow.js +++ b/resources/js/orders-list-print-flow.js @@ -7,11 +7,6 @@ * has generated the labels, and then prints them through sendyPrintLabels() * from print-labels.js. Used by both the per-order button in the actions * column and the "Sendy - Print labels" bulk action. - * - * The Sendy processing method has the portal generate the labels for the - * shipments the plugin creates in the background, so there is nothing to - * create or wait for here: that mode prints the labels that already exist and - * leaves a notice for the orders that have none. */ ( function () { 'use strict'; @@ -22,12 +17,17 @@ const sleep = ( milliseconds ) => new Promise( ( resolve ) => setTimeout( resolve, milliseconds ) ); + // The server flashes a notice explaining a failed request; the flow + // reloads to show it. + class AjaxError extends Error {} + /** * POST to admin-ajax for the given orders and decode the JSON response. * * @param {Object} params Form fields, including action. * @param {Array} orderIds * @return {Promise} + * @throws {AjaxError} */ const ajaxPost = async ( params, orderIds ) => { const body = new URLSearchParams( params ); @@ -36,11 +36,15 @@ } const response = await fetch( ajaxurl, { method: 'POST', body } ); + const data = await response.json().catch( () => null ); + if ( ! response.ok ) { - throw new Error( `The ${ params.action } request failed` ); + throw new AjaxError( + data?.message ?? `The ${ params.action } request failed` + ); } - return response.json(); + return data; }; /** @@ -226,10 +230,8 @@ /** * Print the labels that already exist and explain the orders that have - * none, without creating shipments or waiting for labels. - * - * Used for the Sendy processing method, where the portal generates the - * labels for the shipments the plugin creates in the background. + * none, without creating shipments or waiting for labels. Used for the + * Sendy processing method, where the portal generates the labels itself. * * @param {Array} orderIds */ @@ -247,9 +249,7 @@ /** * Print the labels of the given orders, creating shipments first for the - * orders that have none — unless the processing method leaves creating and - * generating to the Sendy portal, in which case only the labels that - * already exist are printed. + * orders that have none where the processing method allows it. * * @param {Array} orderIds */ @@ -262,10 +262,7 @@ const withoutShipment = idsWithStatus( statuses, 'none' ); const ready = idsWithStatus( statuses, 'ready' ); - // Orders whose labels still need waiting on: the ones already being - // generated plus the shipments created below. Orders in a terminal - // failed state have flashed a notice; drop them instead of - // re-checking (and re-flashing) later. + // Failed orders have flashed a notice; re-checking would re-flash it. const pending = idsWithStatus( statuses, 'pending' ); let createdShipments = false; @@ -292,8 +289,7 @@ ! result.created.length && withoutShipment.length === orderIds.length ) { - // Nothing can be printed; reload to show the flash notices - // explaining what went wrong. + // Nothing printable; reload to show the flash notices. window.location.reload(); return; } @@ -315,10 +311,9 @@ }; /** - * Print the labels of the given orders, creating shipments first for the - * orders that have none where the processing method allows it. The returned - * promise never rejects, so callers can chain finally() without handling - * errors themselves. + * Print the labels of the given orders, creating shipments first where + * the processing method allows it. The returned promise never rejects, + * so callers can chain finally() without handling errors themselves. * * @param {Array} orderIds * @return {Promise} @@ -326,6 +321,10 @@ window.sendyOrdersListPrintFlow = ( orderIds ) => runPrintFlow( orderIds.map( String ) ).catch( ( error ) => { console.error( 'Sendy: printing labels failed', error ); + + if ( error instanceof AjaxError ) { + window.location.reload(); + } } ); // The per-order print buttons in the actions column. The href carries diff --git a/tests/CreateShipmentsTest.php b/tests/CreateShipmentsTest.php index 1346850..df2f952 100644 --- a/tests/CreateShipmentsTest.php +++ b/tests/CreateShipmentsTest.php @@ -78,6 +78,28 @@ public function test_the_sendy_processing_method_is_refused(): void $this->assertStringContainsString('created in Sendy', $response['message']); $this->assertSame([], $transport->requests, 'No shipment may be created'); $this->assertFalse($order->meta_exists('_sendy_shipment_id')); + + // The print flow reloads on an error response, so the refusal must + // also be flashed to be seen. + $messages = get_option('sendy_flash_admin_messages'); + $this->assertSame('error', $messages[0]['type']); + $this->assertStringContainsString('created in Sendy', $messages[0]['message']); + } + + public function test_a_broken_connection_is_reported_as_an_error_response(): void + { + $order = new Sendy_Fake_Order(1); + + // The endpoint only exists when an access token is stored, but the + // other token options can be missing after a failed authentication. + update_option('sendy_access_token', 'access-token', false); + delete_option('sendy_refresh_token'); + + $response = $this->dispatch(['order_ids' => [1]]); + + $this->assertSame('The plugin is not connected to Sendy. Check the settings page to reconnect.', $response['message']); + $this->assertArrayNotHasKey('created', $response); + $this->assertFalse($order->meta_exists('_sendy_shipment_id')); } public function test_orders_whose_creation_fails_are_not_reported_as_created(): void diff --git a/tests/ShipmentsStatusTest.php b/tests/ShipmentsStatusTest.php index a87e9a4..127483f 100644 --- a/tests/ShipmentsStatusTest.php +++ b/tests/ShipmentsStatusTest.php @@ -209,6 +209,23 @@ public function test_an_api_error_is_reported_as_failed_with_a_flash_notice(): v $this->assertSame('error', $messages[0]['type']); } + public function test_a_broken_connection_is_reported_as_an_error_response(): void + { + new Sendy_Fake_Order(1, ['_sendy_shipment_id' => 'shipment-1']); + + // The endpoint only exists when an access token is stored, but the + // other token options can be missing after a failed authentication. + update_option('sendy_access_token', 'access-token', false); + delete_option('sendy_refresh_token'); + + $response = $this->dispatch(['order_ids' => [1]]); + + $this->assertSame('The plugin is not connected to Sendy. Check the settings page to reconnect.', $response['message']); + + $messages = get_option('sendy_flash_admin_messages'); + $this->assertSame('error', $messages[0]['type']); + } + public function test_an_invalid_nonce_is_rejected(): void { $response = $this->dispatch(['order_ids' => [1], 'nonce' => 'invalid']); From 51438a64cc1c408f49e5d365b80deb81b9da1280 Mon Sep 17 00:00:00 2001 From: Adriaan Zonnenberg Date: Fri, 7 Aug 2026 17:53:21 +0200 Subject: [PATCH 14/18] Move the success response out of the error recovery --- lib/Modules/Orders/Single.php | 24 +++++++++++--------- tests/SingleCreateShipmentTest.php | 36 +++++++----------------------- 2 files changed, 22 insertions(+), 38 deletions(-) diff --git a/lib/Modules/Orders/Single.php b/lib/Modules/Orders/Single.php index c37a6d5..998e591 100644 --- a/lib/Modules/Orders/Single.php +++ b/lib/Modules/Orders/Single.php @@ -122,21 +122,25 @@ public function handle_create_shipment_from_form(): void throw new \Exception(esc_html__('Nonce verification failed', 'sendy')); } - if (! empty($_REQUEST['order_id'])) { - $order = wc_get_order(sanitize_key($_REQUEST['order_id'])); + if (empty($_REQUEST['order_id'])) { + return; + } - $this->create_shipment( - $order, - sanitize_key($_REQUEST['shop_id'] ?? ''), - sanitize_key($_REQUEST['preference_id'] ?? ''), - (int) sanitize_key($_REQUEST['amount'] ?? '1'), - ); + $order = wc_get_order(sanitize_key($_REQUEST['order_id'])); - wp_send_json_success(); - } + $this->create_shipment( + $order, + sanitize_key($_REQUEST['shop_id'] ?? ''), + sanitize_key($_REQUEST['preference_id'] ?? ''), + (int) sanitize_key($_REQUEST['amount'] ?? '1'), + ); } catch (\Exception $e) { wp_send_json_error(['message' => $e->getMessage()]); } + + // Outside the try: wp_send_json_success() ends in wp_die(), which + // throws in tests and would be caught above as a failure. + wp_send_json_success(); } /** diff --git a/tests/SingleCreateShipmentTest.php b/tests/SingleCreateShipmentTest.php index 2e2466f..5290864 100644 --- a/tests/SingleCreateShipmentTest.php +++ b/tests/SingleCreateShipmentTest.php @@ -28,7 +28,7 @@ public function test_the_shipment_is_created_from_the_chosen_preference(): void 'data' => ['uuid' => 'new-shipment', 'packages' => []], ]))); - $this->assertStringStartsWith('{"success":true}', $this->dispatch(['order_id' => '1'])); + $this->assertSame(['success' => true], $this->dispatch(['order_id' => '1'])); $this->assertSame('new-shipment', $order->get_meta('_sendy_shipment_id')); $this->assertStringContainsString('/shipments/preference', $transport->lastRequest->getUrl()); @@ -52,7 +52,7 @@ public function test_the_shipment_is_created_with_smart_rules_for_the_sendy_proc 'data' => ['uuid' => 'smart-shipment'], ]))); - $this->assertStringStartsWith('{"success":true}', $this->dispatch(['order_id' => '1'])); + $this->assertSame(['success' => true], $this->dispatch(['order_id' => '1'])); $this->assertSame('smart-shipment', $order->get_meta('_sendy_shipment_id')); $this->assertStringContainsString('/shipments/smart-rule', $transport->lastRequest->getUrl()); @@ -78,38 +78,18 @@ public function test_a_failing_creation_reports_the_api_error(): void } /** - * Dispatch the AJAX action and return the response the handler sent first. + * Fire the AJAX endpoint and return the decoded JSON response. * - * The handler wraps its work in catch (\Exception), which in the test - * environment also catches the exception the wp_die() inside - * wp_send_json_success() throws — so a second, spurious error envelope is - * appended to the output. In production wp_die() exits instead. + * @param array $request + * @return array */ - private function dispatch(array $request): string + private function dispatch(array $request): array { - $_POST = array_merge([ - 'action' => 'sendy_order_single_save_form', + return $this->dispatch_ajax('sendy_order_single_save_form', array_merge([ 'nonce' => wp_create_nonce('sendy_create_shipment'), 'shop_id' => 'shop-1', 'preference_id' => 'pref-1', 'amount' => '3', - ], $request); - - $outputBufferLevel = ob_get_level(); - - try { - $this->_handleAjax('sendy_order_single_save_form'); - $this->fail('The AJAX handler was expected to send a JSON response and die'); - } catch (WPAjaxDieContinueException $exception) { - // wp_send_json() ends in an empty wp_die(). - } - - // Responding twice also closes one output buffer more than - // _handleAjax() opened; restore the level PHPUnit expects. - while (ob_get_level() < $outputBufferLevel) { - ob_start(); - } - - return $this->_last_response; + ], $request)); } } From d2406a9f50f9aeab75a86de35a91793416074334 Mon Sep 17 00:00:00 2001 From: Adriaan Zonnenberg Date: Fri, 7 Aug 2026 17:54:00 +0200 Subject: [PATCH 15/18] Fail loudly when the fake transport runs out of responses --- tests/doubles.php | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/tests/doubles.php b/tests/doubles.php index 83d73e3..47afef7 100644 --- a/tests/doubles.php +++ b/tests/doubles.php @@ -20,13 +20,15 @@ function wc_get_order($order_id) } } -/** - * Minimal WC_Order stub for type hinting in the test environment. - * WooCommerce is not loaded in the test environment, so we provide - * a base class that the fake order can extend to satisfy type hints. - */ -class WC_Order -{ +if (! class_exists('WC_Order')) { + /** + * Minimal WC_Order stub for type hinting in the test environment. + * WooCommerce is not loaded in the test environment, so we provide + * a base class that the fake order can extend to satisfy type hints. + */ + class WC_Order + { + } } class Sendy_Fake_Order extends WC_Order @@ -209,11 +211,13 @@ public function send(Request $request): Response $this->lastRequest = $request; $this->requests[] = $request; - if (count($this->responses) > 1) { - return array_shift($this->responses); + if ($this->responses === []) { + throw new LogicException('The fake transport received a request but no response was queued.'); } - return $this->responses[0]; + // The last queued response repeats, so polling tests do not need to + // queue one response per call. + return count($this->responses) > 1 ? array_shift($this->responses) : $this->responses[0]; } public function getUserAgent(): string From 0f51c4fc388605d2f04686358d3c7060d7290534 Mon Sep 17 00:00:00 2001 From: Adriaan Zonnenberg Date: Fri, 7 Aug 2026 17:54:12 +0200 Subject: [PATCH 16/18] Trim comments --- lib/Modules/Orders/RowActions.php | 7 ++----- lib/Plugin.php | 7 +++---- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/lib/Modules/Orders/RowActions.php b/lib/Modules/Orders/RowActions.php index 317dd97..149777f 100644 --- a/lib/Modules/Orders/RowActions.php +++ b/lib/Modules/Orders/RowActions.php @@ -14,11 +14,8 @@ public function __construct() /** * Add a print label button to the actions column * - * The URL carries the order id for orders-list-print-flow.js, which - * intercepts the click and, for orders without a shipment, creates one - * first before printing. The tooltip promises the creation only when the - * flow would perform it: the Sendy processing method never creates from - * here, so the button only offers to print in that mode. + * The URL fragment carries the order id for orders-list-print-flow.js; the + * tooltip only promises to create a shipment when the flow actually will. * * @param array> $actions * @param \WC_Order $order diff --git a/lib/Plugin.php b/lib/Plugin.php index 8390dd3..b5b64fd 100644 --- a/lib/Plugin.php +++ b/lib/Plugin.php @@ -43,10 +43,9 @@ public static function instance(): Plugin /** * Version string for cache-busting a local plugin asset * - * Releases invalidate browser caches through the plugin version, but that - * version does not change between development edits; with SCRIPT_DEBUG - * enabled the file's modification time is used instead so the browser - * cannot serve a stale copy. + * Releases invalidate browser caches through the plugin version; with + * SCRIPT_DEBUG enabled the file's modification time is used instead, so + * development edits are never served stale. */ public static function asset_version(string $path): string { From e643295b09c97d72925fa228ce07f14cb8bd3ddf Mon Sep 17 00:00:00 2001 From: Adriaan Zonnenberg Date: Fri, 7 Aug 2026 17:54:28 +0200 Subject: [PATCH 17/18] Update translations --- languages/sendy-nl_NL.mo | Bin 8484 -> 8664 bytes languages/sendy-nl_NL.po | 58 ++++++++++++++++++---------------- languages/sendy.pot | 66 +++++++++++++++++++++------------------ 3 files changed, 66 insertions(+), 58 deletions(-) diff --git a/languages/sendy-nl_NL.mo b/languages/sendy-nl_NL.mo index f15c934dadb02b7c013ef646b4b20f5a132dc026..cce3ea1a726727ed6b125f4c37462dc9d142505c 100644 GIT binary patch delta 1578 zcmYk+TS!z<6vpx8q-mBpYTCte&RA(>6lND|$WWuZiOEz-1eQYzHAYTF1WjP|R0N5j z&`a5iPfF6=qNIB03I$;VJ{V36^DglWb&WTh1=;Asm6(n-I0^S-1|Gx7cnCEW0Gw+Jh-7+WwE`%n`;Lobe?AAh_3z9h30;x)JeD^TN{#t7cR9k^(MSpd(V zkNK^iMkXCYs1<)lO_-P*8*nP-5zoavxCJ$EH_pfR$QmqVq8W2oCQij1*X5|4Z$)LW z0SmAN=P|!^)6mKXFb6*(A4?#gN@*IGAm*S3h+rC?cfExAeJ_$yd*JrJ!6n3>P#etV zp#@loi*Xl5bNRM_h0~Iet?R2BtUxzD+kE1ep4fXp+ z7>{pJ85=~+laxySt7!OndM!4i;+wb`lR4;8+>T4J9joy;ZEFuo!E*a z7{-&FLlutVY%C?e+E@sg+fJg6q79XaF4Wr=y+dO+jUT8L)^NZ|c?W9e?_58iLp+Mv z=w)=8wh%SZIxa<6hXvS%)SW#*)z%ACQGav$Jq*Iaqn1j8K0AS$umd?B>qM>mIDsy$H;_bw09ChPzU+f>yc`T=Y7>iZ)1y1ZnJqC&U z@b3f^h9>r73D%-A(Sh1Q59%x*_jV-&lbwV65ASOXJB`gwxGCc7Z3>4&dn2KGC(`6p zhr;#8T0bY{#Vu+Lc<1%{y_u=G|2^Lria19?hxRmu>qBAZ08#80!&!yBg}$$Ge*iNr BuA~3} delta 1488 zcmYM!TS!z<6vpvoIC;zQmStJasDoNUQc61IC~0IjAI!{d7??#78D-gva-fGG>LD3? ziO8UuE*Ml}K~N7NMi-DM5Q9jHq)37wqzelDzdVqfIlsO3IkVT^YwvNi_G*puK0UVA z_}jr}5uZ=96q)sV{#!%yHt|CIj*BsAwwVu?U^;HVIk+1$@F04z8w>FqF2Dhd!(p6j z=Ge$g!zQqb2YOlx)mVWYn1cPNgpV*4-(fC(bNf@`&60^rupC3EaZccIyot@2lVG+Q zJJCmeyGJ9Fj@PIee?cYui5f62(X0S7u@x&(1NY$)e1puvrjZ=xNiv&{Ue^ND%BxTn z+>XWAfkouEJ{n5&40CVaUVV=+NmOLLIJ;$oAV0RHA8Q@s`Z8Rl;mk1=ew6+ie#r z;eNNj3+st{uo}luzak$mY5Z2y;q7qTj$RDV5kVimbo(c8Bk>fj$GUlDVLXrR7@+Axq7GRXlE5OU!~6s(qK%=(o&5LQVYWO~!a~%*m8es_4J)u6Ik0x! z^#STM58*0&fvTK`gR}|juo1&<{26PBOW9YAcM_ZN4mOq1i1*JVXhFSj7^^UXn&~)d h=2NIWOpiYFG$n>FrshYxQ!`V-%lylu+5X2de*u25ljHyZ diff --git a/languages/sendy-nl_NL.po b/languages/sendy-nl_NL.po index 5bef296..d669ecf 100644 --- a/languages/sendy-nl_NL.po +++ b/languages/sendy-nl_NL.po @@ -37,7 +37,7 @@ msgstr "Een plug-in die je WooCommerce shop verbindt met Sendy" msgid "https://sendy.nl/" msgstr "https://sendy.nl/" -#: lib/Modules/Orders/BulkActions.php:197 +#: lib/Modules/Orders/BulkActions.php:201 #: resources/views/admin/meta_box/single.php:18 msgid "Shop" msgstr "Shop" @@ -46,7 +46,7 @@ msgstr "Shop" msgid "Shipping preference" msgstr "Verzendprofiel" -#: lib/Modules/Orders/BulkActions.php:216 +#: lib/Modules/Orders/BulkActions.php:220 #: resources/views/admin/meta_box/single.php:38 msgid "Amount of packages" msgstr "Aantal pakketten" @@ -210,7 +210,7 @@ msgid "Sendy - Create shipments" msgstr "Sendy - Zendingen aanmaken" #: lib/Modules/Orders/BulkActions.php:39 -#: lib/Modules/Orders/BulkActions.php:114 +#: lib/Modules/Orders/BulkActions.php:118 msgid "Sendy - Print labels" msgstr "Sendy - Labels printen" @@ -218,62 +218,62 @@ msgstr "Sendy - Labels printen" msgid "Sendy: Label printed" msgstr "Sendy: Label geprint" -#: lib/Modules/Orders/BulkActions.php:198 +#: lib/Modules/Orders/BulkActions.php:202 msgid "The shipments will be created with the selected shop" msgstr "De zendingen worden aangemaakt met de geselecteerde shop" -#: lib/Modules/Orders/BulkActions.php:207 +#: lib/Modules/Orders/BulkActions.php:211 msgid "Select preference" msgstr "Selecteer verzendprofiel" -#: lib/Modules/Orders/BulkActions.php:208 +#: lib/Modules/Orders/BulkActions.php:212 msgid "The shipments will be created with the preference you select here" msgstr "De zendingen worden aangemaakt met de geselecteerde verzendvoorkeur" #. translators: %s The ID of the order -#: lib/Modules/Orders/OrdersModule.php:125 +#: lib/Modules/Orders/OrdersModule.php:148 #, php-format msgid "Order #%s already has a shipment created" msgstr "Voor order #%s is al een zending aangemaakt" -#: lib/Modules/Orders/OrdersModule.php:158 +#: lib/Modules/Orders/OrdersModule.php:181 #: lib/Modules/Webhooks.php:216 msgid "Sendy: Shipment created" msgstr "Sendy: Zending aangemaakt" #. translators: %1$s should contain the ID of the order and %2$s the error -#: lib/Modules/Orders/CreateShipments.php:61 -#: lib/Modules/Orders/OrdersModule.php:297 -#: lib/Modules/Orders/OrdersModule.php:312 +#: lib/Modules/Orders/OrdersModule.php:90 +#: lib/Modules/Orders/OrdersModule.php:320 +#: lib/Modules/Orders/OrdersModule.php:335 #, php-format msgid "Error while creating shipment for order #%1$s: %2$s" msgstr "Er ging iets mis bij het aanmaken van een zending voor order #%1$s: %2$s" #. translators: %s should contain the ID of the order -#: lib/Modules/Orders/OrdersModule.php:303 +#: lib/Modules/Orders/OrdersModule.php:326 #, php-format msgid "Error while creating shipment for order #%s: Authentication failed. Check the settings page to reconnect with Sendy." msgstr "Er ging iets mis bij het aanmaken van een zending voor order #%s: Authenticatie mislukt. Ga naar de instellingen om opnieuw te verbinden met Sendy." #. translators: %s should contain the ID of the order -#: lib/Modules/Orders/OrdersModule.php:315 +#: lib/Modules/Orders/OrdersModule.php:338 #, php-format msgid "Error while creating shipment for order #%s: Too many requests. Please try again later." msgstr "Er ging iets mis bij het aanmaken van een zending voor order #%s: Te veel requests. Probeer het later opnieuw." #. translators: %s should contain the ID of the order -#: lib/Modules/Orders/OrdersModule.php:318 +#: lib/Modules/Orders/OrdersModule.php:341 #, php-format msgid "Error while creating shipment for order #%s: Unknown error." msgstr "Er ging iets mis bij het aanmaken van een zending voor order #%s: Onbekende fout." -#: lib/Modules/Orders/OrdersModule.php:99 +#: lib/Modules/Orders/OrdersModule.php:122 #: lib/Modules/Orders/Single.php:122 msgid "Nonce verification failed" msgstr "Nonce verificatie mislukt" #: lib/Modules/Admin/Settings.php:257 -#: lib/Modules/Orders/OrdersModule.php:101 +#: lib/Modules/Orders/OrdersModule.php:124 #: lib/Modules/Orders/Single.php:118 msgid "You do not have sufficient permissions to access this page." msgstr "Je hebt niet de juiste rechten om de pagina te bekijken." @@ -348,7 +348,7 @@ msgstr "Kan geen verbinding maken met Sendy. Probeer het later opnieuw." msgid "Nonce verification failed." msgstr "Nonce verificatie mislukt." -#: lib/Modules/Orders/BulkActions.php:83 +#: lib/Modules/Orders/BulkActions.php:87 msgid "Printing labels requires JavaScript. Enable JavaScript in your browser and try again." msgstr "Voor het printen van labels is JavaScript vereist. Schakel JavaScript in je browser in en probeer het opnieuw." @@ -362,51 +362,51 @@ msgstr "Geen van de geselecteerde bestellingen heeft labels" msgid "Error while fetching labels: %s" msgstr "Fout bij het ophalen van labels: %s" -#: lib/Modules/Orders/BulkActions.php:116 +#: lib/Modules/Orders/BulkActions.php:120 msgid "Create shipments and print labels" msgstr "Zendingen aanmaken en labels printen" -#: lib/Modules/Orders/CreateShipments.php:36 +#: lib/Modules/Orders/CreateShipments.php:30 msgid "Shipments for this processing method are created in Sendy" msgstr "Zendingen voor deze werkwijze worden aangemaakt in Sendy" #. translators: %s contains the number of orders -#: lib/Modules/Orders/CreateShipments.php:155 +#: lib/Modules/Orders/CreateShipments.php:165 #, php-format msgid "There is no label to print for %s orders yet. Process the shipments in Sendy first." msgstr "Er is nog geen label om te printen voor %s orders. Verwerk de zendingen eerst in Sendy." #. translators: %s contains the ID of the order -#: lib/Modules/Orders/CreateShipments.php:157 +#: lib/Modules/Orders/CreateShipments.php:167 #, php-format msgid "There is no label to print for order #%s yet. Process the shipment in Sendy first." msgstr "Er is nog geen label om te printen voor order #%s. Verwerk de zending eerst in Sendy." #. translators: %s contains the number of orders -#: lib/Modules/Orders/CreateShipments.php:161 +#: lib/Modules/Orders/CreateShipments.php:171 #, php-format msgid "The labels for %s orders are still being generated. Please try again in a moment." msgstr "De labels voor %s orders worden nog gegenereerd. Probeer het later opnieuw." #. translators: %s contains the ID of the order -#: lib/Modules/Orders/CreateShipments.php:163 +#: lib/Modules/Orders/CreateShipments.php:173 #, php-format msgid "The labels for order #%s are still being generated. Please try again in a moment." msgstr "De labels voor order #%s worden nog gegenereerd. Probeer het later opnieuw." #. translators: %1$s contains the ID of the order, %2$s the error message -#: lib/Modules/Orders/CreateShipments.php:195 +#: lib/Modules/Orders/CreateShipments.php:205 #, php-format msgid "Error while checking the shipment for order #%1$s: %2$s" msgstr "Er ging iets mis bij het controleren van de zending voor order #%1$s: %2$s" #. translators: %1$s contains the ID of the order, %2$s the status of the shipment -#: lib/Modules/Orders/CreateShipments.php:218 +#: lib/Modules/Orders/CreateShipments.php:228 #, php-format msgid "The labels for order #%1$s cannot be printed (shipment status: %2$s)" msgstr "De labels voor order #%1$s kunnen niet worden geprint (status van de zending: %2$s)" -#: lib/Modules/Orders/BulkActions.php:115 +#: lib/Modules/Orders/BulkActions.php:119 #: lib/Modules/Orders/RowActions.php:35 msgid "Sendy - Print label" msgstr "Sendy - Label printen" @@ -415,6 +415,10 @@ msgstr "Sendy - Label printen" msgid "Sendy - Create shipment and print label" msgstr "Sendy - Zending aanmaken en label printen" -#: lib/Modules/Orders/BulkActions.php:117 +#: lib/Modules/Orders/BulkActions.php:121 msgid "Create shipment and print label" msgstr "Zending aanmaken en label printen" + +#: lib/Modules/Orders/CreateShipments.php:118 +msgid "The plugin is not connected to Sendy. Check the settings page to reconnect." +msgstr "De plugin is niet verbonden met Sendy. Ga naar de instellingen om opnieuw te verbinden." diff --git a/languages/sendy.pot b/languages/sendy.pot index 8f1c69b..77dac89 100644 --- a/languages/sendy.pot +++ b/languages/sendy.pot @@ -9,7 +9,7 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"POT-Creation-Date: 2026-07-29T16:06:46+00:00\n" +"POT-Creation-Date: 2026-08-07T15:00:08+00:00\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "X-Generator: WP-CLI 2.12.0\n" "X-Domain: sendy\n" @@ -95,7 +95,7 @@ msgid "After the shipment is delivered" msgstr "" #: lib/Modules/Admin/Settings.php:257 -#: lib/Modules/Orders/OrdersModule.php:101 +#: lib/Modules/Orders/OrdersModule.php:124 #: lib/Modules/Orders/Single.php:118 msgid "You do not have sufficient permissions to access this page." msgstr "" @@ -123,93 +123,89 @@ msgid "Sendy - Create shipments" msgstr "" #: lib/Modules/Orders/BulkActions.php:39 -#: lib/Modules/Orders/BulkActions.php:114 +#: lib/Modules/Orders/BulkActions.php:118 msgid "Sendy - Print labels" msgstr "" -#: lib/Modules/Orders/BulkActions.php:83 +#: lib/Modules/Orders/BulkActions.php:87 msgid "Printing labels requires JavaScript. Enable JavaScript in your browser and try again." msgstr "" -#: lib/Modules/Orders/BulkActions.php:115 +#: lib/Modules/Orders/BulkActions.php:119 #: lib/Modules/Orders/RowActions.php:35 msgid "Sendy - Print label" msgstr "" -#: lib/Modules/Orders/BulkActions.php:116 +#: lib/Modules/Orders/BulkActions.php:120 msgid "Create shipments and print labels" msgstr "" -#: lib/Modules/Orders/BulkActions.php:117 +#: lib/Modules/Orders/BulkActions.php:121 msgid "Create shipment and print label" msgstr "" -#: lib/Modules/Orders/BulkActions.php:197 +#: lib/Modules/Orders/BulkActions.php:201 #: resources/views/admin/meta_box/single.php:18 msgid "Shop" msgstr "" -#: lib/Modules/Orders/BulkActions.php:198 +#: lib/Modules/Orders/BulkActions.php:202 msgid "The shipments will be created with the selected shop" msgstr "" -#: lib/Modules/Orders/BulkActions.php:207 +#: lib/Modules/Orders/BulkActions.php:211 msgid "Select preference" msgstr "" -#: lib/Modules/Orders/BulkActions.php:208 +#: lib/Modules/Orders/BulkActions.php:212 msgid "The shipments will be created with the preference you select here" msgstr "" -#: lib/Modules/Orders/BulkActions.php:216 +#: lib/Modules/Orders/BulkActions.php:220 #: resources/views/admin/meta_box/single.php:38 msgid "Amount of packages" msgstr "" -#: lib/Modules/Orders/CreateShipments.php:36 +#: lib/Modules/Orders/CreateShipments.php:30 msgid "Shipments for this processing method are created in Sendy" msgstr "" -#. translators: %1$s should contain the ID of the order and %2$s the error -#: lib/Modules/Orders/CreateShipments.php:61 -#: lib/Modules/Orders/OrdersModule.php:297 -#: lib/Modules/Orders/OrdersModule.php:312 -#, php-format -msgid "Error while creating shipment for order #%1$s: %2$s" +#: lib/Modules/Orders/CreateShipments.php:118 +msgid "The plugin is not connected to Sendy. Check the settings page to reconnect." msgstr "" #. translators: %s contains the number of orders -#: lib/Modules/Orders/CreateShipments.php:155 +#: lib/Modules/Orders/CreateShipments.php:165 #, php-format msgid "There is no label to print for %s orders yet. Process the shipments in Sendy first." msgstr "" #. translators: %s contains the ID of the order -#: lib/Modules/Orders/CreateShipments.php:157 +#: lib/Modules/Orders/CreateShipments.php:167 #, php-format msgid "There is no label to print for order #%s yet. Process the shipment in Sendy first." msgstr "" #. translators: %s contains the number of orders -#: lib/Modules/Orders/CreateShipments.php:161 +#: lib/Modules/Orders/CreateShipments.php:171 #, php-format msgid "The labels for %s orders are still being generated. Please try again in a moment." msgstr "" #. translators: %s contains the ID of the order -#: lib/Modules/Orders/CreateShipments.php:163 +#: lib/Modules/Orders/CreateShipments.php:173 #, php-format msgid "The labels for order #%s are still being generated. Please try again in a moment." msgstr "" #. translators: %1$s contains the ID of the order, %2$s the error message -#: lib/Modules/Orders/CreateShipments.php:195 +#: lib/Modules/Orders/CreateShipments.php:205 #, php-format msgid "Error while checking the shipment for order #%1$s: %2$s" msgstr "" #. translators: %1$s contains the ID of the order, %2$s the status of the shipment -#: lib/Modules/Orders/CreateShipments.php:218 +#: lib/Modules/Orders/CreateShipments.php:228 #, php-format msgid "The labels for order #%1$s cannot be printed (shipment status: %2$s)" msgstr "" @@ -224,36 +220,44 @@ msgstr "" msgid "Track and trace" msgstr "" -#: lib/Modules/Orders/OrdersModule.php:99 +#. translators: %1$s should contain the ID of the order and %2$s the error +#: lib/Modules/Orders/OrdersModule.php:90 +#: lib/Modules/Orders/OrdersModule.php:320 +#: lib/Modules/Orders/OrdersModule.php:335 +#, php-format +msgid "Error while creating shipment for order #%1$s: %2$s" +msgstr "" + +#: lib/Modules/Orders/OrdersModule.php:122 #: lib/Modules/Orders/Single.php:122 msgid "Nonce verification failed" msgstr "" #. translators: %s The ID of the order -#: lib/Modules/Orders/OrdersModule.php:125 +#: lib/Modules/Orders/OrdersModule.php:148 #, php-format msgid "Order #%s already has a shipment created" msgstr "" -#: lib/Modules/Orders/OrdersModule.php:158 +#: lib/Modules/Orders/OrdersModule.php:181 #: lib/Modules/Webhooks.php:216 msgid "Sendy: Shipment created" msgstr "" #. translators: %s should contain the ID of the order -#: lib/Modules/Orders/OrdersModule.php:303 +#: lib/Modules/Orders/OrdersModule.php:326 #, php-format msgid "Error while creating shipment for order #%s: Authentication failed. Check the settings page to reconnect with Sendy." msgstr "" #. translators: %s should contain the ID of the order -#: lib/Modules/Orders/OrdersModule.php:315 +#: lib/Modules/Orders/OrdersModule.php:338 #, php-format msgid "Error while creating shipment for order #%s: Too many requests. Please try again later." msgstr "" #. translators: %s should contain the ID of the order -#: lib/Modules/Orders/OrdersModule.php:318 +#: lib/Modules/Orders/OrdersModule.php:341 #, php-format msgid "Error while creating shipment for order #%s: Unknown error." msgstr "" From f2a11f87d74a4ffee82b57a12833a13451342d3d Mon Sep 17 00:00:00 2001 From: Adriaan Zonnenberg Date: Fri, 7 Aug 2026 18:26:49 +0200 Subject: [PATCH 18/18] 3.5.0 --- lib/Plugin.php | 2 +- readme.txt | 9 ++++++--- sendy.php | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/lib/Plugin.php b/lib/Plugin.php index b5b64fd..dc7fa50 100644 --- a/lib/Plugin.php +++ b/lib/Plugin.php @@ -21,7 +21,7 @@ class Plugin { - public const VERSION = '3.4.7'; + public const VERSION = '3.5.0'; public const SETTINGS_ID = 'sendy'; diff --git a/readme.txt b/readme.txt index a1d77bc..c0683f3 100644 --- a/readme.txt +++ b/readme.txt @@ -2,8 +2,8 @@ Plugin Name: Sendy Plugin URI: https://app.sendy.nl/ Description: A WooCommerce plugin that connects your site to the Sendy platform -Version: 3.4.7 -Stable tag: 3.4.7 +Version: 3.5.0 +Stable tag: 3.5.0 License: MIT Author: Sendy Author URI: https://sendy.nl/ @@ -52,7 +52,10 @@ Hierop zijn onze [algemene voorwaarden](https://sendy.nl/algemene-voorwaarden/) == Changelog == -= Unreleased = += 3.5.0 = +* Add print buttons to the orders list to create shipments and print labels in one click +* Print labels directly using the Sendy print app +* Set the minimum required WordPress version to 6.2 * Clean up plugin options on uninstall, so a reinstall starts with fresh credentials = 3.4.7 = diff --git a/sendy.php b/sendy.php index cb22e45..d1feae2 100644 --- a/sendy.php +++ b/sendy.php @@ -4,7 +4,7 @@ * Plugin Name: Sendy * Plugin URI: https://app.sendy.nl/ * Description: A WooCommerce plugin that connects your site to the Sendy platform - * Version: 3.4.7 + * Version: 3.5.0 * Author: Sendy * Author URI: https://sendy.nl/ * License: MIT