From 77247a859050da34159293142960e07058d32ca7 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Thu, 12 Nov 2026 10:25:08 +0100 Subject: [PATCH 01/50] Update packages WooCommerce 11.3.0 is stable on wordpress.org, WP Packages and the stubs, while the root and every plugin still require and declare 11.2. Since the v2.0.0 tag falls after 11.3, the monorepo moves its floor with its locks (X374, A761). The root's and each plugin's locks take WooCommerce 11.3.0, each plugin's take its stubs 11.3.0 and phpstorm-stubs v2026.3. The constraints, each entry's WC requires at least and WC tested up to, the readmes' Installation and Upgrade Notice lines and the changelog fragments that name the floor read 11.3. Assisted-by: Claude Code:claude-opus-5-5 --- composer.json | 2 +- composer.lock | 12 ++--- plugins/internal-comments/composer.json | 4 +- plugins/internal-comments/composer.lock | 47 ++++++++++--------- .../internal-comments/internal-comments.php | 4 +- plugins/internal-comments/readme.txt | 2 +- .../changelog/floors | 2 +- .../composer.json | 4 +- .../composer.lock | 47 ++++++++++--------- .../linked-orders-for-woocommerce.php | 4 +- .../linked-orders-for-woocommerce/readme.txt | 4 +- .../changelog/lock-safeguard | 2 +- .../changelog/requirements | 2 +- .../composer.json | 4 +- .../composer.lock | 47 ++++++++++--------- ...locked-payment-methods-for-woocommerce.php | 4 +- .../readme.txt | 6 +-- 17 files changed, 103 insertions(+), 94 deletions(-) diff --git a/composer.json b/composer.json index a178fd3..44d1a68 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,7 @@ "php": ">=8.5" }, "require-dev": { - "wp-plugin/woocommerce": "^11.2" + "wp-plugin/woocommerce": "^11.3" }, "minimum-stability": "dev", diff --git a/composer.lock b/composer.lock index 8d3fcfc..85327b4 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8e1dd9b99dbc475af9ef210b1b699ce1", + "content-hash": "ad20afc9f8cb972763fac14f9d71336d", "packages": [], "packages-dev": [ { @@ -156,15 +156,15 @@ }, { "name": "wp-plugin/woocommerce", - "version": "11.2.2", + "version": "11.3.0", "source": { "type": "svn", "url": "https://plugins.svn.wordpress.org/woocommerce/", - "reference": "tags/11.2.2" + "reference": "tags/11.3.0" }, "dist": { "type": "zip", - "url": "https://downloads.wordpress.org/plugin/woocommerce.11.2.2.zip" + "url": "https://downloads.wordpress.org/plugin/woocommerce.11.3.0.zip" }, "require": { "composer/installers": "~1.0|~2.0" @@ -183,8 +183,8 @@ "issues": "https://wordpress.org/support/plugin/woocommerce", "source": "https://plugins.svn.wordpress.org/woocommerce" }, - "time": "2026-11-04T21:34:49+00:00", - "published-time": "2026-11-04T21:34:49+00:00" + "time": "2026-11-11T16:56:56+00:00", + "published-time": "2026-11-11T16:56:56+00:00" } ], "aliases": [], diff --git a/plugins/internal-comments/composer.json b/plugins/internal-comments/composer.json index c701b30..4d5915f 100644 --- a/plugins/internal-comments/composer.json +++ b/plugins/internal-comments/composer.json @@ -53,10 +53,10 @@ "johnpbloch/wordpress-core": "^7.1", "laravel/serializable-closure": "^2.2", "php-di/php-di": "^7.1.1", - "php-stubs/woocommerce-stubs": "^11.2", + "php-stubs/woocommerce-stubs": "^11.3", "phpunit/phpunit": "^13", "pronamic/changelog-md-to-wordpress-plugin-readme-txt": "^1.0", - "wp-plugin/woocommerce": "^11.2" + "wp-plugin/woocommerce": "^11.3" }, "minimum-stability": "dev", diff --git a/plugins/internal-comments/composer.lock b/plugins/internal-comments/composer.lock index d1045e6..6d6ded7 100644 --- a/plugins/internal-comments/composer.lock +++ b/plugins/internal-comments/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f8e215ebdff088fe9cea3a80e85eff68", + "content-hash": "378b92d05921ad2eed470adfd2a246c9", "packages": [], "packages-dev": [ { @@ -1014,23 +1014,23 @@ }, { "name": "jetbrains/phpstorm-stubs", - "version": "v2026.2", + "version": "v2026.3", "source": { "type": "git", - "url": "https://github.com/JetBrains/phpstorm-stubs", - "reference": "709e512210784a7c0a677b3a89d35def844a59b9" + "url": "https://github.com/JetBrains/phpstorm-stubs.git", + "reference": "912ae721089fc48534d3a5795997ee8a2802b8cd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/709e512210784a7c0a677b3a89d35def844a59b9", - "reference": "709e512210784a7c0a677b3a89d35def844a59b9", + "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/912ae721089fc48534d3a5795997ee8a2802b8cd", + "reference": "912ae721089fc48534d3a5795997ee8a2802b8cd", "shasum": "" }, "require-dev": { "friendsofphp/php-cs-fixer": "^v3.86", "nikic/php-parser": "^v5.6", - "phpdocumentor/reflection-docblock": "^5.6", - "phpunit/phpunit": "^12.3" + "phpdocumentor/reflection-docblock": "^6.0", + "phpunit/phpunit": "^13.2" }, "type": "library", "autoload": { @@ -1054,8 +1054,11 @@ "stubs", "type" ], - "time": "2026-06-12T13:19:10+00:00", - "published-time": "2026-07-22T11:41:13+00:00" + "support": { + "source": "https://github.com/JetBrains/phpstorm-stubs/tree/v2026.3" + }, + "time": "2026-10-19T10:54:17+00:00", + "published-time": "2026-11-11T13:34:29+00:00" }, { "name": "johnbillion/wp-compat", @@ -1615,16 +1618,16 @@ }, { "name": "php-stubs/woocommerce-stubs", - "version": "v11.2.2", + "version": "v11.3.0", "source": { "type": "git", "url": "https://github.com/php-stubs/woocommerce-stubs.git", - "reference": "d5c232c183c09689d75c177b01bef60e6dd53639" + "reference": "a897f9ad0552482e9d50d3937daab20dfb97265c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-stubs/woocommerce-stubs/zipball/d5c232c183c09689d75c177b01bef60e6dd53639", - "reference": "d5c232c183c09689d75c177b01bef60e6dd53639", + "url": "https://api.github.com/repos/php-stubs/woocommerce-stubs/zipball/a897f9ad0552482e9d50d3937daab20dfb97265c", + "reference": "a897f9ad0552482e9d50d3937daab20dfb97265c", "shasum": "" }, "require": { @@ -1653,10 +1656,10 @@ ], "support": { "issues": "https://github.com/php-stubs/woocommerce-stubs/issues", - "source": "https://github.com/php-stubs/woocommerce-stubs/tree/v11.2.2" + "source": "https://github.com/php-stubs/woocommerce-stubs/tree/v11.3.0" }, - "time": "2026-11-04T21:47:17+00:00", - "published-time": "2026-11-04T21:48:23+00:00" + "time": "2026-11-12T00:45:07+00:00", + "published-time": "2026-11-12T00:47:40+00:00" }, { "name": "php-stubs/wordpress-stubs", @@ -7299,15 +7302,15 @@ }, { "name": "wp-plugin/woocommerce", - "version": "11.2.2", + "version": "11.3.0", "source": { "type": "svn", "url": "https://plugins.svn.wordpress.org/woocommerce/", - "reference": "tags/11.2.2" + "reference": "tags/11.3.0" }, "dist": { "type": "zip", - "url": "https://downloads.wordpress.org/plugin/woocommerce.11.2.2.zip" + "url": "https://downloads.wordpress.org/plugin/woocommerce.11.3.0.zip" }, "require": { "composer/installers": "~1.0|~2.0" @@ -7326,8 +7329,8 @@ "issues": "https://wordpress.org/support/plugin/woocommerce", "source": "https://plugins.svn.wordpress.org/woocommerce" }, - "time": "2026-11-04T21:34:49+00:00", - "published-time": "2026-11-04T21:34:49+00:00" + "time": "2026-11-11T16:56:56+00:00", + "published-time": "2026-11-11T16:56:56+00:00" } ], "aliases": [], diff --git a/plugins/internal-comments/internal-comments.php b/plugins/internal-comments/internal-comments.php index 77b1171..f2632b6 100644 --- a/plugins/internal-comments/internal-comments.php +++ b/plugins/internal-comments/internal-comments.php @@ -8,8 +8,8 @@ * Version: 2.0.0 * Requires at least: 7.1 * Requires PHP: 8.5 - * WC requires at least: 11.2 - * WC tested up to: 11.2 + * WC requires at least: 11.3 + * WC tested up to: 11.3 * Author: Contributors * Author URI: https://github.com/ahegyes * License: GPL-3.0-or-later diff --git a/plugins/internal-comments/readme.txt b/plugins/internal-comments/readme.txt index 251b716..34047b1 100644 --- a/plugins/internal-comments/readme.txt +++ b/plugins/internal-comments/readme.txt @@ -27,7 +27,7 @@ Here is a short overview of the features this plugin offers: == Installation == -There are no prerequisites other than the minimum WordPress and PHP versions. Internal comments on WooCommerce orders need WooCommerce 11.2 or newer. +There are no prerequisites other than the minimum WordPress and PHP versions. Internal comments on WooCommerce orders need WooCommerce 11.3 or newer. ### INSTALL FROM WITHIN WORDPRESS diff --git a/plugins/linked-orders-for-woocommerce/changelog/floors b/plugins/linked-orders-for-woocommerce/changelog/floors index 481e876..69ba145 100644 --- a/plugins/linked-orders-for-woocommerce/changelog/floors +++ b/plugins/linked-orders-for-woocommerce/changelog/floors @@ -1,4 +1,4 @@ Significance: major Type: changed -Requires PHP 8.5, WordPress 7.1 and WooCommerce 11.2, which the v2 framework needs. +Requires PHP 8.5, WordPress 7.1 and WooCommerce 11.3, which the v2 framework needs. diff --git a/plugins/linked-orders-for-woocommerce/composer.json b/plugins/linked-orders-for-woocommerce/composer.json index 68ffba6..207d09d 100644 --- a/plugins/linked-orders-for-woocommerce/composer.json +++ b/plugins/linked-orders-for-woocommerce/composer.json @@ -58,10 +58,10 @@ "johnpbloch/wordpress-core": "^7.1", "laravel/serializable-closure": "^2.2", "php-di/php-di": "^7.1.1", - "php-stubs/woocommerce-stubs": "^11.2", + "php-stubs/woocommerce-stubs": "^11.3", "phpunit/phpunit": "^13", "pronamic/changelog-md-to-wordpress-plugin-readme-txt": "^1.0", - "wp-plugin/woocommerce": "^11.2" + "wp-plugin/woocommerce": "^11.3" }, "minimum-stability": "dev", diff --git a/plugins/linked-orders-for-woocommerce/composer.lock b/plugins/linked-orders-for-woocommerce/composer.lock index 746d5dc..2fba312 100644 --- a/plugins/linked-orders-for-woocommerce/composer.lock +++ b/plugins/linked-orders-for-woocommerce/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e171d9ca6844f3f474ef4b6b71dc8c8c", + "content-hash": "59261e06eda74c0a33cbfc5135b3526a", "packages": [], "packages-dev": [ { @@ -1063,23 +1063,23 @@ }, { "name": "jetbrains/phpstorm-stubs", - "version": "v2026.2", + "version": "v2026.3", "source": { "type": "git", - "url": "https://github.com/JetBrains/phpstorm-stubs", - "reference": "709e512210784a7c0a677b3a89d35def844a59b9" + "url": "https://github.com/JetBrains/phpstorm-stubs.git", + "reference": "912ae721089fc48534d3a5795997ee8a2802b8cd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/709e512210784a7c0a677b3a89d35def844a59b9", - "reference": "709e512210784a7c0a677b3a89d35def844a59b9", + "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/912ae721089fc48534d3a5795997ee8a2802b8cd", + "reference": "912ae721089fc48534d3a5795997ee8a2802b8cd", "shasum": "" }, "require-dev": { "friendsofphp/php-cs-fixer": "^v3.86", "nikic/php-parser": "^v5.6", - "phpdocumentor/reflection-docblock": "^5.6", - "phpunit/phpunit": "^12.3" + "phpdocumentor/reflection-docblock": "^6.0", + "phpunit/phpunit": "^13.2" }, "type": "library", "autoload": { @@ -1103,8 +1103,11 @@ "stubs", "type" ], - "time": "2026-06-12T13:19:10+00:00", - "published-time": "2026-07-22T11:41:13+00:00" + "support": { + "source": "https://github.com/JetBrains/phpstorm-stubs/tree/v2026.3" + }, + "time": "2026-10-19T10:54:17+00:00", + "published-time": "2026-11-11T13:34:29+00:00" }, { "name": "johnbillion/wp-compat", @@ -1664,16 +1667,16 @@ }, { "name": "php-stubs/woocommerce-stubs", - "version": "v11.2.2", + "version": "v11.3.0", "source": { "type": "git", "url": "https://github.com/php-stubs/woocommerce-stubs.git", - "reference": "d5c232c183c09689d75c177b01bef60e6dd53639" + "reference": "a897f9ad0552482e9d50d3937daab20dfb97265c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-stubs/woocommerce-stubs/zipball/d5c232c183c09689d75c177b01bef60e6dd53639", - "reference": "d5c232c183c09689d75c177b01bef60e6dd53639", + "url": "https://api.github.com/repos/php-stubs/woocommerce-stubs/zipball/a897f9ad0552482e9d50d3937daab20dfb97265c", + "reference": "a897f9ad0552482e9d50d3937daab20dfb97265c", "shasum": "" }, "require": { @@ -1702,10 +1705,10 @@ ], "support": { "issues": "https://github.com/php-stubs/woocommerce-stubs/issues", - "source": "https://github.com/php-stubs/woocommerce-stubs/tree/v11.2.2" + "source": "https://github.com/php-stubs/woocommerce-stubs/tree/v11.3.0" }, - "time": "2026-11-04T21:47:17+00:00", - "published-time": "2026-11-04T21:48:23+00:00" + "time": "2026-11-12T00:45:07+00:00", + "published-time": "2026-11-12T00:47:40+00:00" }, { "name": "php-stubs/wordpress-stubs", @@ -7348,15 +7351,15 @@ }, { "name": "wp-plugin/woocommerce", - "version": "11.2.2", + "version": "11.3.0", "source": { "type": "svn", "url": "https://plugins.svn.wordpress.org/woocommerce/", - "reference": "tags/11.2.2" + "reference": "tags/11.3.0" }, "dist": { "type": "zip", - "url": "https://downloads.wordpress.org/plugin/woocommerce.11.2.2.zip" + "url": "https://downloads.wordpress.org/plugin/woocommerce.11.3.0.zip" }, "require": { "composer/installers": "~1.0|~2.0" @@ -7375,8 +7378,8 @@ "issues": "https://wordpress.org/support/plugin/woocommerce", "source": "https://plugins.svn.wordpress.org/woocommerce" }, - "time": "2026-11-04T21:34:49+00:00", - "published-time": "2026-11-04T21:34:49+00:00" + "time": "2026-11-11T16:56:56+00:00", + "published-time": "2026-11-11T16:56:56+00:00" } ], "aliases": [], diff --git a/plugins/linked-orders-for-woocommerce/linked-orders-for-woocommerce.php b/plugins/linked-orders-for-woocommerce/linked-orders-for-woocommerce.php index 8027e58..b131e95 100644 --- a/plugins/linked-orders-for-woocommerce/linked-orders-for-woocommerce.php +++ b/plugins/linked-orders-for-woocommerce/linked-orders-for-woocommerce.php @@ -9,8 +9,8 @@ * Requires at least: 7.1 * Requires PHP: 8.5 * Requires Plugins: woocommerce - * WC requires at least: 11.2 - * WC tested up to: 11.2 + * WC requires at least: 11.3 + * WC tested up to: 11.3 * Author: Contributors * Author URI: https://github.com/ahegyes * License: GPL-3.0-or-later diff --git a/plugins/linked-orders-for-woocommerce/readme.txt b/plugins/linked-orders-for-woocommerce/readme.txt index 2a27ecf..68f1a18 100644 --- a/plugins/linked-orders-for-woocommerce/readme.txt +++ b/plugins/linked-orders-for-woocommerce/readme.txt @@ -22,7 +22,7 @@ Linked orders work whether WooCommerce stores orders in its own order tables or == Installation == -This plugin requires WooCommerce 11.2 or newer to run. If you're running a lower version, please update first. After you made sure that you're running a supported version of WooCommerce, you may install `Linked Orders for WooCommerce` either manually or through your site's plugins page. +This plugin requires WooCommerce 11.3 or newer to run. If you're running a lower version, please update first. After you made sure that you're running a supported version of WooCommerce, you may install `Linked Orders for WooCommerce` either manually or through your site's plugins page. ### INSTALL FROM WITHIN WORDPRESS @@ -68,7 +68,7 @@ You can open a community support ticket here at [wordpress.org](https://wordpres == Upgrade Notice == = 2.0.0 = -Requires PHP 8.5, WordPress 7.1 and WooCommerce 11.2. Hooks move to dws_lowc/ names and their v1 names still fire, deprecated, but eight settings hooks, the v1 framework's notice-markup hooks and its accessors, such as dws_lowc_instance(), are removed. +Requires PHP 8.5, WordPress 7.1 and WooCommerce 11.3. Hooks move to dws_lowc/ names and their v1 names still fire, deprecated, but eight settings hooks, the v1 framework's notice-markup hooks and its accessors, such as dws_lowc_instance(), are removed. == Changelog == diff --git a/plugins/locked-payment-methods-for-woocommerce/changelog/lock-safeguard b/plugins/locked-payment-methods-for-woocommerce/changelog/lock-safeguard index 28e1d88..3f73f1d 100644 --- a/plugins/locked-payment-methods-for-woocommerce/changelog/lock-safeguard +++ b/plugins/locked-payment-methods-for-woocommerce/changelog/lock-safeguard @@ -1,4 +1,4 @@ Significance: major Type: changed -The lock holds whenever the plugin is active, on a runtime below its floors, with WooCommerce below 11.2 and when the plugin fails to boot, until the plugin has booted; only an explicit false from dws_lpmwc/payment_method/should_lock unlocks a payment method, so a callback that returns null, 0 or an empty string no longer does. +The lock holds whenever the plugin is active, on a runtime below its floors, with WooCommerce below 11.3 and when the plugin fails to boot, until the plugin has booted; only an explicit false from dws_lpmwc/payment_method/should_lock unlocks a payment method, so a callback that returns null, 0 or an empty string no longer does. diff --git a/plugins/locked-payment-methods-for-woocommerce/changelog/requirements b/plugins/locked-payment-methods-for-woocommerce/changelog/requirements index d7346a9..64cd7ad 100644 --- a/plugins/locked-payment-methods-for-woocommerce/changelog/requirements +++ b/plugins/locked-payment-methods-for-woocommerce/changelog/requirements @@ -1,4 +1,4 @@ Significance: major Type: changed -Requires PHP 8.5, WordPress 7.1 and WooCommerce 11.2, and declares compatibility with WooCommerce's order tables. +Requires PHP 8.5, WordPress 7.1 and WooCommerce 11.3, and declares compatibility with WooCommerce's order tables. diff --git a/plugins/locked-payment-methods-for-woocommerce/composer.json b/plugins/locked-payment-methods-for-woocommerce/composer.json index f43a836..d72938a 100644 --- a/plugins/locked-payment-methods-for-woocommerce/composer.json +++ b/plugins/locked-payment-methods-for-woocommerce/composer.json @@ -58,11 +58,11 @@ "johnpbloch/wordpress-core": "^7.1", "laravel/serializable-closure": "^2.2", "php-di/php-di": "^7.1.1", - "php-stubs/woocommerce-stubs": "^11.2", + "php-stubs/woocommerce-stubs": "^11.3", "phpunit/phpunit": "^13", "pronamic/changelog-md-to-wordpress-plugin-readme-txt": "^1.0", "wp-plugin/groups": "^4.8", - "wp-plugin/woocommerce": "^11.2" + "wp-plugin/woocommerce": "^11.3" }, "minimum-stability": "dev", diff --git a/plugins/locked-payment-methods-for-woocommerce/composer.lock b/plugins/locked-payment-methods-for-woocommerce/composer.lock index 41f47d3..9c5bdaf 100644 --- a/plugins/locked-payment-methods-for-woocommerce/composer.lock +++ b/plugins/locked-payment-methods-for-woocommerce/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "749695d245d7c277078b9c46424489f2", + "content-hash": "7f24e219df6b1c43d21ca8c80e2efdce", "packages": [], "packages-dev": [ { @@ -1063,23 +1063,23 @@ }, { "name": "jetbrains/phpstorm-stubs", - "version": "v2026.2", + "version": "v2026.3", "source": { "type": "git", - "url": "https://github.com/JetBrains/phpstorm-stubs", - "reference": "709e512210784a7c0a677b3a89d35def844a59b9" + "url": "https://github.com/JetBrains/phpstorm-stubs.git", + "reference": "912ae721089fc48534d3a5795997ee8a2802b8cd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/709e512210784a7c0a677b3a89d35def844a59b9", - "reference": "709e512210784a7c0a677b3a89d35def844a59b9", + "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/912ae721089fc48534d3a5795997ee8a2802b8cd", + "reference": "912ae721089fc48534d3a5795997ee8a2802b8cd", "shasum": "" }, "require-dev": { "friendsofphp/php-cs-fixer": "^v3.86", "nikic/php-parser": "^v5.6", - "phpdocumentor/reflection-docblock": "^5.6", - "phpunit/phpunit": "^12.3" + "phpdocumentor/reflection-docblock": "^6.0", + "phpunit/phpunit": "^13.2" }, "type": "library", "autoload": { @@ -1103,8 +1103,11 @@ "stubs", "type" ], - "time": "2026-06-12T13:19:10+00:00", - "published-time": "2026-07-22T11:41:13+00:00" + "support": { + "source": "https://github.com/JetBrains/phpstorm-stubs/tree/v2026.3" + }, + "time": "2026-10-19T10:54:17+00:00", + "published-time": "2026-11-11T13:34:29+00:00" }, { "name": "johnbillion/wp-compat", @@ -1664,16 +1667,16 @@ }, { "name": "php-stubs/woocommerce-stubs", - "version": "v11.2.2", + "version": "v11.3.0", "source": { "type": "git", "url": "https://github.com/php-stubs/woocommerce-stubs.git", - "reference": "d5c232c183c09689d75c177b01bef60e6dd53639" + "reference": "a897f9ad0552482e9d50d3937daab20dfb97265c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-stubs/woocommerce-stubs/zipball/d5c232c183c09689d75c177b01bef60e6dd53639", - "reference": "d5c232c183c09689d75c177b01bef60e6dd53639", + "url": "https://api.github.com/repos/php-stubs/woocommerce-stubs/zipball/a897f9ad0552482e9d50d3937daab20dfb97265c", + "reference": "a897f9ad0552482e9d50d3937daab20dfb97265c", "shasum": "" }, "require": { @@ -1702,10 +1705,10 @@ ], "support": { "issues": "https://github.com/php-stubs/woocommerce-stubs/issues", - "source": "https://github.com/php-stubs/woocommerce-stubs/tree/v11.2.2" + "source": "https://github.com/php-stubs/woocommerce-stubs/tree/v11.3.0" }, - "time": "2026-11-04T21:47:17+00:00", - "published-time": "2026-11-04T21:48:23+00:00" + "time": "2026-11-12T00:45:07+00:00", + "published-time": "2026-11-12T00:47:40+00:00" }, { "name": "php-stubs/wordpress-stubs", @@ -7380,15 +7383,15 @@ }, { "name": "wp-plugin/woocommerce", - "version": "11.2.2", + "version": "11.3.0", "source": { "type": "svn", "url": "https://plugins.svn.wordpress.org/woocommerce/", - "reference": "tags/11.2.2" + "reference": "tags/11.3.0" }, "dist": { "type": "zip", - "url": "https://downloads.wordpress.org/plugin/woocommerce.11.2.2.zip" + "url": "https://downloads.wordpress.org/plugin/woocommerce.11.3.0.zip" }, "require": { "composer/installers": "~1.0|~2.0" @@ -7407,8 +7410,8 @@ "issues": "https://wordpress.org/support/plugin/woocommerce", "source": "https://plugins.svn.wordpress.org/woocommerce" }, - "time": "2026-11-04T21:34:49+00:00", - "published-time": "2026-11-04T21:34:49+00:00" + "time": "2026-11-11T16:56:56+00:00", + "published-time": "2026-11-11T16:56:56+00:00" } ], "aliases": [], diff --git a/plugins/locked-payment-methods-for-woocommerce/locked-payment-methods-for-woocommerce.php b/plugins/locked-payment-methods-for-woocommerce/locked-payment-methods-for-woocommerce.php index 2924a99..eb3ef4b 100644 --- a/plugins/locked-payment-methods-for-woocommerce/locked-payment-methods-for-woocommerce.php +++ b/plugins/locked-payment-methods-for-woocommerce/locked-payment-methods-for-woocommerce.php @@ -9,8 +9,8 @@ * Requires at least: 7.1 * Requires PHP: 8.5 * Requires Plugins: woocommerce - * WC requires at least: 11.2 - * WC tested up to: 11.2 + * WC requires at least: 11.3 + * WC tested up to: 11.3 * Author: Contributors * Author URI: https://github.com/ahegyes * License: GPL-3.0-or-later diff --git a/plugins/locked-payment-methods-for-woocommerce/readme.txt b/plugins/locked-payment-methods-for-woocommerce/readme.txt index a682b09..8e12b0e 100644 --- a/plugins/locked-payment-methods-for-woocommerce/readme.txt +++ b/plugins/locked-payment-methods-for-woocommerce/readme.txt @@ -30,7 +30,7 @@ Developers can lock and unlock payment methods by their own rules through the pl == Installation == -This plugin requires WooCommerce 11.2 or newer. If you're running a lower version, please update it first. +This plugin requires WooCommerce 11.3 or newer. If you're running a lower version, please update it first. ### INSTALL FROM WITHIN WORDPRESS @@ -46,7 +46,7 @@ This plugin requires WooCommerce 11.2 or newer. If you're running a lower versio ### AFTER ACTIVATION -If WooCommerce 11.2 or newer is active, you will find a section called `Locked Payment Methods` under `WooCommerce` → `Settings` → `Advanced`, which the plugin's `Settings` link on the `Plugins` page also opens. There you will be able to: +If WooCommerce 11.3 or newer is active, you will find a section called `Locked Payment Methods` under `WooCommerce` → `Settings` → `Advanced`, which the plugin's `Settings` link on the `Plugins` page also opens. There you will be able to: 1. Choose which payment methods are locked by default. 1. Choose which ways of unlocking them you use, and which user roles have access to every payment method. @@ -79,7 +79,7 @@ You can open a community support ticket here at [wordpress.org](https://wordpres == Upgrade Notice == = 2.0.0 = -Requires PHP 8.5, WordPress 7.1 and WooCommerce 11.2. Settings move to WooCommerce → Settings → Advanced, and a customer's unlocked payment methods become per site. Only an explicit false unlocks a payment method. Read the changelog before updating. +Requires PHP 8.5, WordPress 7.1 and WooCommerce 11.3. Settings move to WooCommerce → Settings → Advanced, and a customer's unlocked payment methods become per site. Only an explicit false unlocks a payment method. Read the changelog before updating. == Changelog == From 5a2aa9d537aa9c86a5b397302fcfee61248acd00 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Thu, 12 Nov 2026 10:32:04 +0100 Subject: [PATCH 02/50] Test that a creator's existing order survives a refused child WooCommerce's wc_create_order() opens and saves the order an order_id names, and Linked Orders force-deletes whatever the creator returned when its link is refused, so a caller or filter naming the parent, or an order already linked, gets that order permanently deleted (A268). The row passes the parent's and a linked order's ids on every storage and expects a null return with _doing_it_wrong, both orders kept with their links and addresses, and no created-by meta. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/Integration/LinkingTest.php | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/LinkingTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/LinkingTest.php index 709b183..8b0ca40 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/LinkingTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/LinkingTest.php @@ -359,6 +359,45 @@ public function test_a_linked_child_whose_link_is_refused_is_deleted_and_an_erro self::assertSame( array(), dws_lowc_get_order_node( $parent->get_id() )?->get_children() ); } + // WooCommerce's creator opens and saves the order an order_id names, and a refused link deletes what the creator returned. + #[DataProvider( 'storages' )] + public function test_a_linked_child_is_not_created_from_the_parent_or_an_order_already_linked( bool $hpos, bool $sync ): void { + $this->use_storage( $hpos, $sync ); + $parent = $this->create_order(); + $root = $this->create_order(); + $linked = $this->create_order(); + $parent->set_address( array( 'city' => 'London' ), 'billing' ); + $parent->save(); + $linked->set_address( array( 'city' => 'Paris' ), 'billing' ); + $linked->save(); + self::assertTrue( dws_lowc_link_orders( $root->get_id(), $linked->get_id() ) ); + $wrong = array(); + add_filter( 'doing_it_wrong_trigger_error', '__return_false' ); + $record = static function ( string $function_name ) use ( &$wrong ): void { + $wrong[] = $function_name; + }; + add_action( 'doing_it_wrong_run', $record ); + + try { + self::assertNull( dws_lowc_create_linked_child( $parent->get_id(), array( 'order_id' => $parent->get_id() ) ) ); + self::assertNull( dws_lowc_create_linked_child( $parent->get_id(), array( 'order_id' => $linked->get_id() ) ) ); + } finally { + remove_filter( 'doing_it_wrong_trigger_error', '__return_false' ); + remove_action( 'doing_it_wrong_run', $record ); + } + + self::assertSame( array( 'dws_lowc_create_linked_child', 'dws_lowc_create_linked_child' ), $wrong ); + foreach ( array( $parent, $linked ) as $order ) { + $stored = wc_get_order( $order->get_id() ); + self::assertInstanceOf( \WC_Order::class, $stored ); + self::assertSame( '', $stored->get_meta( '_dws_lo_created_by' ) ); + } + + self::assertSame( 'Paris', wc_get_order( $linked->get_id() )->get_billing_city() ); + self::assertSame( array(), dws_lowc_get_order_node( $parent->get_id() )?->get_children() ); + self::assertSame( $root->get_id(), dws_lowc_get_order_node( $linked->get_id() )?->get_parent()?->get_id() ); + } + #[DataProvider( 'storages' )] public function test_a_creator_error_is_returned( bool $hpos, bool $sync ): void { $this->use_storage( $hpos, $sync ); From 22274aa7add11ef50a98056a6ee2e26f29bc41da Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Thu, 12 Nov 2026 10:33:22 +0100 Subject: [PATCH 03/50] Refuse a creator's order that is the parent or already linked dws_lowc_create_linked_child() wrote the parent's addresses onto whatever its creator returned and force-deleted it when the link was refused, so a creator that returned the parent or an order already in a tree, as wc_create_order() does for an order_id, lost that order for good (A268). The check that refuses an order of an unsupported type now also refuses the parent and an order with a parent or children, with its _doing_it_wrong and a null return, before the order is changed. The docblock names the residual X117 accepts: an existing order without links is still deleted when the link gives up on the lock. Assisted-by: Claude Code:claude-opus-5-5 --- .../changelog/existing-order-children | 4 ++++ .../linked-orders-for-woocommerce/includes/post-type.php | 7 ++++--- 2 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 plugins/linked-orders-for-woocommerce/changelog/existing-order-children diff --git a/plugins/linked-orders-for-woocommerce/changelog/existing-order-children b/plugins/linked-orders-for-woocommerce/changelog/existing-order-children new file mode 100644 index 0000000..d0bf6a4 --- /dev/null +++ b/plugins/linked-orders-for-woocommerce/changelog/existing-order-children @@ -0,0 +1,4 @@ +Significance: minor +Type: changed + +dws_lowc_create_linked_child() returns null, leaving the order as it is, when its creator returns the parent or an order that already has a parent or children. diff --git a/plugins/linked-orders-for-woocommerce/includes/post-type.php b/plugins/linked-orders-for-woocommerce/includes/post-type.php index 33d3f45..cba21ad 100644 --- a/plugins/linked-orders-for-woocommerce/includes/post-type.php +++ b/plugins/linked-orders-for-woocommerce/includes/post-type.php @@ -141,7 +141,7 @@ function dws_lowc_get_create_child_url( int $parent_id ): string { /** * Creates an order linked as the child of a supported order, with the parent's customer and addresses, and returns its id. * - * Returns null when the parent is missing or unsupported, or when the creator is not callable or creates no supported order, the creator's error when it fails, and an error when the link is refused, after deleting the child. + * The creator must create a new order, because a refused link deletes what it returned, so the function refuses the parent and any order with a parent or children before it changes them. An existing order without links that a creator returns is still deleted when the link gives up waiting for another request's. Returns null when the parent is missing or unsupported, or when the creator is not callable or returns no such order, the creator's error when it fails, and an error when the link is refused, after deleting the child. * * @api * @@ -201,8 +201,9 @@ function dws_lowc_create_linked_child( int $parent_id, array $args = array() ) { return $child; } - if ( ! $child instanceof WC_Order || true !== dws_lowc_is_supported_order( $child ) ) { - _doing_it_wrong( __FUNCTION__, esc_html__( 'The linked child\'s create_function returned no order of a supported type.', 'linked-orders-for-woocommerce' ), '1.0.0' ); + $node = $child instanceof WC_Order ? dws_lowc_get_order_node( $child ) : null; + if ( ! $child instanceof WC_Order || is_null( $node ) || $parent_id === $child->get_id() || $node->has_parent() || $node->has_children() ) { + _doing_it_wrong( __FUNCTION__, esc_html__( 'The linked child\'s create_function returned no new order of a supported type.', 'linked-orders-for-woocommerce' ), '1.0.0' ); return null; } From 9d7332123001530fad8fd2a0a788860d0e13ec9c Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Thu, 12 Nov 2026 10:33:30 +0100 Subject: [PATCH 04/50] Test that a link whose child is not saved leaves no half link WooCommerce catches what an order save throws, logs it and returns the order's id, so dws_lowc_link_orders() reported success after a child save that wrote nothing, leaving the parent listing a child that names no parent, which a second parent could then claim (A269). The rows make the child's save throw on every storage, through the linking API and through child creation, and expect false or the refusal error, the parent's children unsaved and the created child deleted. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/Integration/LinkingTest.php | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/LinkingTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/LinkingTest.php index 8b0ca40..3c124b9 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/LinkingTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/LinkingTest.php @@ -398,6 +398,53 @@ public function test_a_linked_child_is_not_created_from_the_parent_or_an_order_a self::assertSame( $root->get_id(), dws_lowc_get_order_node( $linked->get_id() )?->get_parent()?->get_id() ); } + // WooCommerce catches what a save throws and logs it, as it does a failed database write, so a link reads the child's parent back before it saves the parent. + #[DataProvider( 'storages' )] + public function test_a_link_is_refused_and_the_parent_left_unsaved_when_the_child_is_not_saved( bool $hpos, bool $sync ): void { + $this->use_storage( $hpos, $sync ); + $parent = $this->create_order(); + $child = $this->create_order(); + $fail = self::fail_saving( $child->get_id() ); + + try { + self::assertFalse( dws_lowc_link_orders( $parent->get_id(), $child->get_id() ) ); + } finally { + remove_action( 'woocommerce_before_order_object_save', $fail ); + } + + self::assertSame( '', wc_get_order( $parent->get_id() )->get_meta( '_dws_lo_children' ) ); + self::assertFalse( dws_lowc_get_order_node( $child->get_id() )?->has_parent() ); + self::assertTrue( dws_lowc_link_orders( $parent->get_id(), $child->get_id() ) ); + } + + #[DataProvider( 'storages' )] + public function test_a_linked_child_that_is_not_saved_is_deleted_and_an_error_returned( bool $hpos, bool $sync ): void { + $this->use_storage( $hpos, $sync ); + $parent = $this->create_order(); + $created = 0; + $fail = null; + $record = static function ( \WC_Order $child ) use ( &$created, &$fail ): void { + $created = $child->get_id(); + $fail = self::fail_saving( $created ); + }; + add_action( 'dws_lowc/linked_order/created', $record ); + + try { + $result = dws_lowc_create_linked_child( $parent->get_id() ); + } finally { + remove_action( 'dws_lowc/linked_order/created', $record ); + if ( $fail instanceof \Closure ) { + remove_action( 'woocommerce_before_order_object_save', $fail ); + } + } + + $this->orders[] = \is_int( $result ) ? $result : $created; + self::assertInstanceOf( \WP_Error::class, $result ); + self::assertSame( 'dws_lowc_link_refused', $result->get_error_code() ); + self::assertFalse( wc_get_order( $created ) ); + self::assertSame( '', wc_get_order( $parent->get_id() )->get_meta( '_dws_lo_children' ) ); + } + #[DataProvider( 'storages' )] public function test_a_creator_error_is_returned( bool $hpos, bool $sync ): void { $this->use_storage( $hpos, $sync ); @@ -420,6 +467,16 @@ protected function use_storage_and_data_caching( bool $hpos, bool $sync, bool $c add_filter( $hook, $this->storage_filters[ $hook ] ); } + protected static function fail_saving( int $order_id ): \Closure { + $fail = static function ( \WC_Order $order ) use ( $order_id ): void { + if ( $order_id === $order->get_id() ) { + throw new \RuntimeException( 'The save failed.' ); + } + }; + add_action( 'woocommerce_before_order_object_save', $fail ); + return $fail; + } + // The other request runs on the row's storage, as the shared tests site stores orders on the order tables. protected function link_in_another_request( bool $hpos, bool $sync, int $parent_id, int $child_id ): void { $php = \sprintf( 'add_filter( "pre_option_woocommerce_custom_orders_table_enabled", fn () => "%s" ); add_filter( "pre_option_woocommerce_custom_orders_table_data_sync_enabled", fn () => "%s" ); echo var_export( dws_lowc_link_orders( %d, %d ), true );', $hpos ? 'yes' : 'no', $sync ? 'yes' : 'no', $parent_id, $child_id ); From 0d2b654491f09fb7965f53c35f806dd58f2c96bc Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Thu, 12 Nov 2026 10:35:03 +0100 Subject: [PATCH 05/50] Save a link's child first and its parent only once it reads back A link saved the parent and then the child and reported success, though WooCommerce logs a save that fails and returns as if it had saved, so a failed child write left a permanent one-sided link (A269, X144). Inside the lock, the child now saves first and its parent is read back from the database with the order-tables data caching off, as the reads before it; when it does not name the parent the link returns false without saving the parent, so child creation deletes the child it made. The forced read moves into one helper both reads share. Assisted-by: Claude Code:claude-opus-5-5 --- .../changelog/failed-link-save | 4 ++ .../docs/ARCHITECTURE.md | 2 +- .../includes/linking.php | 44 ++++++++++++++----- 3 files changed, 37 insertions(+), 13 deletions(-) create mode 100644 plugins/linked-orders-for-woocommerce/changelog/failed-link-save diff --git a/plugins/linked-orders-for-woocommerce/changelog/failed-link-save b/plugins/linked-orders-for-woocommerce/changelog/failed-link-save new file mode 100644 index 0000000..5307b2a --- /dev/null +++ b/plugins/linked-orders-for-woocommerce/changelog/failed-link-save @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +A link whose child order fails to save returns false and leaves the parent as it was, rather than listing a child that names no parent. diff --git a/plugins/linked-orders-for-woocommerce/docs/ARCHITECTURE.md b/plugins/linked-orders-for-woocommerce/docs/ARCHITECTURE.md index 6eb4473..d7800a4 100644 --- a/plugins/linked-orders-for-woocommerce/docs/ARCHITECTURE.md +++ b/plugins/linked-orders-for-woocommerce/docs/ARCHITECTURE.md @@ -35,7 +35,7 @@ The request that activates the plugin does not boot it, because WordPress includ ## Data -- **Order links** are three meta keys on each linked order, as v1 stored them: `_dws_lo_depth`, where a root is 0, `_dws_lo_parent` and `_dws_lo_children`, and `_dws_lo_created_by` on a child created through the plugin. WC CRUD writes them to whichever storage WooCommerce uses. Every link reads and rewrites the parent's `_dws_lo_children`, so `dws_lowc_link_orders()` holds the `dws_lowc_linking` option lock while it links, re-reads both orders' meta from the database under it, with WooCommerce's order-tables data caching off for the reads, and waits up to five seconds for another request's link to finish before it returns false; `dws_lowc_create_linked_child()` then deletes the child it created and returns a `WP_Error`. +- **Order links** are three meta keys on each linked order, as v1 stored them: `_dws_lo_depth`, where a root is 0, `_dws_lo_parent` and `_dws_lo_children`, and `_dws_lo_created_by` on a child created through the plugin. WC CRUD writes them to whichever storage WooCommerce uses. Every link reads and rewrites the parent's `_dws_lo_children`, so `dws_lowc_link_orders()` holds the `dws_lowc_linking` option lock while it links, re-reads both orders' meta from the database under it, with WooCommerce's order-tables data caching off for the reads, and waits up to five seconds for another request's link to finish before it returns false. It saves the child first and the parent only once the child's parent reads back, since WooCommerce logs a failed save and carries on; on either refusal `dws_lowc_create_linked_child()` deletes the child it created and returns a `WP_Error`. - **Options** keep v1's keys, `dws-lowc_general_max-depth`, `dws-lowc_general_autocomplete-descendants` and `dws-lowc_plugin_remove-data-uninstall`, as constants in `includes/options.php`. Code reads them through v1's API in `includes/settings.php`, which validates each stored value, falls back to the key's default, and applies v1's filters and then `dws_lowc/settings/validated_value`. - **Migrations** are the append-only steps `dws_lowc_get_migration_steps()` returns. The framework's runner runs each pending step once per site on `wp_loaded`, behind a lock, and records the revision reached in `dws_lowc_revision`. The first step stores each setting's default where a site, v1 or new, has none. A step converges from any partial state and throws when a write fails, so a failed step runs again on a later request. - **The footprint**, `dws_lowc_get_footprint()`, names every kind of data the plugin stores: the `dws_lowc_` and `dws-lowc_` option prefixes, the `_dws_lo_` order meta on both storages, v1's version and notice rows, user meta, network options, the capabilities, scheduled hooks and Action Scheduler groups. Freemius's `fs_` rows from v1 stay, since they belong to Freemius. diff --git a/plugins/linked-orders-for-woocommerce/includes/linking.php b/plugins/linked-orders-for-woocommerce/includes/linking.php index aea1f13..c1753d0 100644 --- a/plugins/linked-orders-for-woocommerce/includes/linking.php +++ b/plugins/linked-orders-for-woocommerce/includes/linking.php @@ -52,9 +52,9 @@ function dws_lowc_is_root_order( $order ): ?bool { } /** - * Links one order as the child of another, returning false unless the child is another order with neither a parent nor children, or when another request still links orders after five seconds, and null when either order has no node. + * Links one order as the child of another, returning false unless the child is another order with neither a parent nor children, when another request still links orders after five seconds, or when the child's save does not store its parent, and null when either order has no node. * - * A link reads and rewrites the parent's list of children, so one lock serializes links across requests, and each reads both orders' meta from the database once it holds the lock. A link holds it for milliseconds, so the next one waits for it rather than refusing at once. + * A link reads and rewrites the parent's list of children, so one lock serializes links across requests, and each reads both orders' meta from the database once it holds the lock. A link holds it for milliseconds, so the next one waits for it rather than refusing at once. WooCommerce logs a save that fails and returns as if it had saved, so the parent lists the child only once the child's parent reads back from the database. * * @api * @@ -85,22 +85,18 @@ function dws_lowc_link_orders( int $parent_id, int $child_id ): ?bool { } try { - // This request may hold either order as loaded before another request linked it, and WooCommerce's order-tables data caching answers even a forced read from that load, so the reads run with the caching off, and the saves, with it back on, clear what it kept. - $no_data_caching = static fn (): string => 'no'; - add_filter( 'pre_option_woocommerce_hpos_datastore_caching_enabled', $no_data_caching, PHP_INT_MAX ); - try { - $parent->get_order()->read_meta_data( true ); - $child->get_order()->read_meta_data( true ); - } finally { - remove_filter( 'pre_option_woocommerce_hpos_datastore_caching_enabled', $no_data_caching, PHP_INT_MAX ); + dws_lowc_refresh_links( $parent->get_order(), $child->get_order() ); + if ( ! $parent->add_child( $child ) ) { + return false; } - if ( ! $parent->add_child( $child ) ) { + $child->save(); + dws_lowc_refresh_links( $child->get_order() ); + if ( (string) $parent->get_order()->get_id() !== $child->get_order()->get_meta( '_dws_lo_parent' ) ) { return false; } $parent->save(); - $child->save(); } finally { $lock->release( $owner ); } @@ -108,6 +104,30 @@ function dws_lowc_link_orders( int $parent_id, int $child_id ): ?bool { return true; } +/** + * Reads orders' meta again from the database, past WooCommerce's order-tables data caching. + * + * This request may hold an order as loaded before another request changed its links, and the caching answers even a forced read from that load, so the reads run with the caching off, and the saves that follow, with it back on, clear what it kept. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param WC_Order ...$orders The orders. + * + * @return void + */ +function dws_lowc_refresh_links( WC_Order ...$orders ): void { + $no_data_caching = static fn (): string => 'no'; + add_filter( 'pre_option_woocommerce_hpos_datastore_caching_enabled', $no_data_caching, PHP_INT_MAX ); + try { + foreach ( $orders as $order ) { + $order->read_meta_data( true ); + } + } finally { + remove_filter( 'pre_option_woocommerce_hpos_datastore_caching_enabled', $no_data_caching, PHP_INT_MAX ); + } +} + /** * Returns the node at the top of an order's tree, which is the order's own for a root or for a child whose parent was deleted, or null when the order has no node. * From 3573638cee122277023660093acdc1c1759c044e Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Thu, 12 Nov 2026 10:42:03 +0100 Subject: [PATCH 06/50] Test that a tree walk stops at an order it has visited v1 linked orders without refusing a cycle, and a site upgrading from it keeps those links, so dws_lowc_get_root_order() loops forever and dws_lowc_get_orders_tree() recurses until the request dies (A292). The row stores a two-order cycle on every storage and expects both walks to return; it counts the nodes the walk builds through the supported-types filter and throws past a thousand, so an endless walk fails the row instead of hanging the suite. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/Integration/LinkingTest.php | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/LinkingTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/LinkingTest.php index 3c124b9..666c4ae 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/LinkingTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/LinkingTest.php @@ -171,6 +171,32 @@ public function test_a_link_is_refused_for_a_child_with_a_parent_or_children_and self::assertSame( array( $other->get_id() ), dws_lowc_get_orders_tree( $other->get_id() ) ); } + // v1 linked orders without refusing a cycle, and every node a walk builds reads the supported types, so the filter ends an endless walk rather than letting it hang the suite. + #[DataProvider( 'storages' )] + public function test_a_walk_stops_at_an_order_it_has_already_visited( bool $hpos, bool $sync ): void { + $this->use_storage( $hpos, $sync ); + $first = $this->create_order(); + $second = $this->create_order(); + self::store_links( $first, 1, $second->get_id(), array( $second->get_id() ) ); + self::store_links( $second, 1, $first->get_id(), array( $first->get_id() ) ); + $nodes = 0; + $bound = static function ( array $types ) use ( &$nodes ): array { + if ( ++$nodes > 1000 ) { + throw new \RuntimeException( 'The walk did not stop.' ); + } + + return $types; + }; + add_filter( 'dws_lowc/post_type/supported_order_types', $bound ); + + try { + self::assertSame( array( $first->get_id(), $second->get_id() ), dws_lowc_get_orders_tree( $first->get_id() ) ); + self::assertSame( $second->get_id(), dws_lowc_get_root_order( $first->get_id() )?->get_id() ); + } finally { + remove_filter( 'dws_lowc/post_type/supported_order_types', $bound ); + } + } + #[DataProvider( 'storages' )] public function test_an_order_that_is_missing_or_not_supported_has_no_node( bool $hpos, bool $sync ): void { $this->use_storage( $hpos, $sync ); @@ -467,6 +493,14 @@ protected function use_storage_and_data_caching( bool $hpos, bool $sync, bool $c add_filter( $hook, $this->storage_filters[ $hook ] ); } + // Stands in for links v1 stored without the checks a link makes now. + protected static function store_links( \WC_Order $order, int $depth, int $parent_id, array $child_ids ): void { + $order->update_meta_data( '_dws_lo_depth', (string) $depth ); + $order->update_meta_data( '_dws_lo_parent', 0 === $parent_id ? '' : (string) $parent_id ); + $order->update_meta_data( '_dws_lo_children', $child_ids ); + $order->save(); + } + protected static function fail_saving( int $order_id ): \Closure { $fail = static function ( \WC_Order $order ) use ( $order_id ): void { if ( $order_id === $order->get_id() ) { From 57cba3b05e7ace6fb660db1704a8a92e97d0b1b8 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Thu, 12 Nov 2026 10:43:27 +0100 Subject: [PATCH 07/50] Stop each tree walk at an order it has visited dws_lowc_get_root_order() and dws_lowc_get_orders_tree() trusted the stored links, so a cycle v1 stored hung the first and overflowed the second on every screen that shows the tree (A292, X207). The root walk tracks the orders it has passed and stops below a repeat, and the tree walk runs depth first from a stack, listing each order once in the same order as before. The link-cycles changelog line covers the stored loops too. Assisted-by: Claude Code:claude-opus-5-5 --- .../changelog/link-cycles | 2 +- .../includes/linking.php | 31 +++++++++++++++---- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/plugins/linked-orders-for-woocommerce/changelog/link-cycles b/plugins/linked-orders-for-woocommerce/changelog/link-cycles index dd33742..07d77fd 100644 --- a/plugins/linked-orders-for-woocommerce/changelog/link-cycles +++ b/plugins/linked-orders-for-woocommerce/changelog/link-cycles @@ -1,4 +1,4 @@ Significance: patch Type: fixed -Linking an order as the child of itself or of one of its descendants returns false, rather than creating a loop that broke every screen showing the tree. +Linking an order as the child of itself or of one of its descendants returns false, rather than creating a loop that broke every screen showing the tree, and those screens stop at a loop v1 stored. diff --git a/plugins/linked-orders-for-woocommerce/includes/linking.php b/plugins/linked-orders-for-woocommerce/includes/linking.php index c1753d0..a3d72c2 100644 --- a/plugins/linked-orders-for-woocommerce/includes/linking.php +++ b/plugins/linked-orders-for-woocommerce/includes/linking.php @@ -131,6 +131,8 @@ function dws_lowc_refresh_links( WC_Order ...$orders ): void { /** * Returns the node at the top of an order's tree, which is the order's own for a root or for a child whose parent was deleted, or null when the order has no node. * + * The walk stops below an order it has already visited, since v1 stored links without refusing a cycle. + * * @api * * @since 1.0.0 @@ -141,9 +143,16 @@ function dws_lowc_refresh_links( WC_Order ...$orders ): void { * @return DWS_Order_Node|null */ function dws_lowc_get_root_order( $order ): ?DWS_Order_Node { - $node = dws_lowc_get_order_node( $order ); - while ( ! is_null( $node ) && $node->has_parent() ) { - $node = $node->get_parent(); + $node = dws_lowc_get_order_node( $order ); + $visited = array(); + while ( ! is_null( $node ) ) { + $visited[] = $node->get_order()->get_id(); + $parent = $node->get_parent(); + if ( is_null( $parent ) || in_array( $parent->get_order()->get_id(), $visited, true ) ) { + break; + } + + $node = $parent; } return $node; @@ -152,6 +161,8 @@ function dws_lowc_get_root_order( $order ): ?DWS_Order_Node { /** * Returns the ids of an order and its descendants, each child before the next with its own descendants, or null when the order has no node. * + * Each order is listed once, since v1 stored links without refusing a cycle. + * * @api * * @since 1.0.0 @@ -167,9 +178,17 @@ function dws_lowc_get_orders_tree( $order ): ?array { return null; } - $tree = array( $node->get_order()->get_id() ); - foreach ( $node->get_children() as $child ) { - $tree = array_merge( $tree, dws_lowc_get_orders_tree( $child->get_order() ) ?? array() ); + $tree = array(); + $pending = array( $node ); + while ( array() !== $pending ) { + $current = array_pop( $pending ); + $id = $current->get_order()->get_id(); + if ( in_array( $id, $tree, true ) ) { + continue; + } + + $tree[] = $id; + $pending = array_merge( $pending, array_reverse( $current->get_children() ) ); } return $tree; From 120954803cad11319af973cb054f50166a7230b4 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Thu, 12 Nov 2026 10:43:51 +0100 Subject: [PATCH 08/50] Test that an order deleted for good leaves its children as roots The children of a deleted order keep its id as their parent and their stored depth, so they read as their own roots on the tree walks but as children in the depth column, the depth filter and the maximum depth (A290). The row trashes then deletes a parent on every storage and expects the trashed parent to keep its children, then each child stored as a root and each grandchild's depth rewritten below it. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/Integration/LinkingTest.php | 48 +++++++++++++++++-- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/LinkingTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/LinkingTest.php index 666c4ae..8e1623c 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/LinkingTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/LinkingTest.php @@ -2,6 +2,7 @@ namespace DeepWebSolutions\LinkedOrders\Tests\Integration; +use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore; use DeepWebSolutions\LinkedOrders\Scoped\DeepWebSolutions\Framework\Core\Storage\Lock; use DeepWebSolutions\LinkedOrders\Scoped\DeepWebSolutions\Framework\Core\Storage\LockClaim; use DeepWebSolutions\LinkedOrders\Tests\Support\OrderStorageTrait; @@ -220,17 +221,56 @@ public function test_an_order_that_is_missing_or_not_supported_has_no_node( bool self::assertTrue( dws_lowc_is_supported_order( $order->get_id() ) ); } + // The depth column, the depth filter and the maximum depth read the stored depth, so the children of an order deleted for good become roots in their meta, while a trashed order, which can come back, keeps them. #[DataProvider( 'storages' )] - public function test_a_child_whose_parent_was_deleted_is_its_own_root( bool $hpos, bool $sync ): void { + public function test_an_order_deleted_for_good_leaves_its_children_as_roots( bool $hpos, bool $sync ): void { + $this->use_storage( $hpos, $sync ); + $parent = $this->create_order(); + $child = $this->create_order(); + $grandchild = $this->create_order(); + $sibling = $this->create_order(); + self::assertTrue( dws_lowc_link_orders( $parent->get_id(), $child->get_id() ) ); + self::assertTrue( dws_lowc_link_orders( $child->get_id(), $grandchild->get_id() ) ); + self::assertTrue( dws_lowc_link_orders( $parent->get_id(), $sibling->get_id() ) ); + + wc_get_order( $parent->get_id() )->delete(); + self::assertSame( $parent->get_id(), dws_lowc_get_order_node( $child->get_id() )?->get_parent()?->get_id() ); + + wc_get_order( $parent->get_id() )->delete( true ); + + foreach ( array( $child, $sibling ) as $root ) { + $stored = wc_get_order( $root->get_id() ); + self::assertSame( '0', (string) $stored->get_meta( '_dws_lo_depth' ) ); + self::assertSame( '', (string) $stored->get_meta( '_dws_lo_parent' ) ); + self::assertTrue( dws_lowc_is_root_order( $root->get_id() ) ); + } + + self::assertSame( '1', (string) wc_get_order( $grandchild->get_id() )->get_meta( '_dws_lo_depth' ) ); + self::assertSame( $child->get_id(), dws_lowc_get_root_order( $grandchild->get_id() )?->get_id() ); + self::assertSame( array( $child->get_id(), $grandchild->get_id() ), dws_lowc_get_orders_tree( $child->get_id() ) ); + } + + // The posts storage's orders list and WordPress's scheduled trash emptying delete an order through the posts API, which fires no WooCommerce action, while on the order tables the order's post is a placeholder WordPress deletes or a copy WooCommerce keeps. + #[DataProvider( 'storages' )] + public function test_deleting_an_order_post_for_good_roots_its_children_only_on_the_posts_storage( bool $hpos, bool $sync ): void { $this->use_storage( $hpos, $sync ); $parent = $this->create_order(); $child = $this->create_order(); self::assertTrue( dws_lowc_link_orders( $parent->get_id(), $child->get_id() ) ); - $parent->delete( true ); + try { + self::assertSame( ! ( $hpos && $sync ), wp_delete_post( $parent->get_id(), true ) instanceof \WP_Post ); - self::assertSame( $child->get_id(), dws_lowc_get_root_order( $child->get_id() )?->get_id() ); - self::assertFalse( dws_lowc_get_order_node( $child->get_id() )?->has_parent() ); + $stored = wc_get_order( $child->get_id() ); + self::assertSame( $hpos, wc_get_order( $parent->get_id() ) instanceof \WC_Order ); + self::assertSame( $hpos ? '1' : '0', (string) $stored->get_meta( '_dws_lo_depth' ) ); + self::assertSame( $hpos ? (string) $parent->get_id() : '', (string) $stored->get_meta( '_dws_lo_parent' ) ); + } finally { + // Deleted the usual way, an order whose placeholder is gone leaves a deletion still to sync, which the tests site's next start refuses, so its order-tables rows go directly. + if ( $hpos && ! $sync ) { + wc_get_container()->get( OrdersTableDataStore::class )->delete_order_data_from_custom_order_tables( $parent->get_id() ); + } + } } #[DataProvider( 'storages' )] From b2cedaf4acf721ba42a6033f843a96666f260a68 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Thu, 12 Nov 2026 10:46:37 +0100 Subject: [PATCH 09/50] Make the children of an order deleted for good roots of their trees A deleted order's children kept its id as their parent and their stored depth, so the depth column, the depth filter and the maximum depth went on counting them as children of an order that no longer exists (A290, X207). OrderDeletion runs on woocommerce_before_delete_order, which both storages fire only for a deletion for good. Under the linking lock, each child that names the order as its parent is stored as a root and its descendants' depths are rewritten below it; a trashed order keeps its children. The lock's wait moves into one helper that linking shares. Assisted-by: Claude Code:claude-opus-5-5 --- .../linked-orders-for-woocommerce/AGENTS.md | 1 + .../changelog/deleted-parent | 2 +- .../docs/ARCHITECTURE.md | 4 +- .../includes/linking.php | 117 ++++++++++++++++-- .../src/OrderDeletion.php | 68 ++++++++++ .../src/Plugin.php | 1 + 6 files changed, 180 insertions(+), 13 deletions(-) create mode 100644 plugins/linked-orders-for-woocommerce/src/OrderDeletion.php diff --git a/plugins/linked-orders-for-woocommerce/AGENTS.md b/plugins/linked-orders-for-woocommerce/AGENTS.md index 7a67805..ca19c06 100644 --- a/plugins/linked-orders-for-woocommerce/AGENTS.md +++ b/plugins/linked-orders-for-woocommerce/AGENTS.md @@ -17,6 +17,7 @@ src/ the composition root and its components only ├── MetaBox.php the parent, children and add-child button beside an order's editor, on either storage ├── ListTable.php the orders list's depth column, filters and row actions, on either storage ├── Autocompletion.php completes an order's descendants when it completes, where the setting is on +├── OrderDeletion.php makes the children of an order deleted for good the roots of their trees └── Integration/ SequentialOrderNumbersPro, gated on wc_seq_order_number_pro() models/DWS_Order_Node.php v1's global node class on the classmap, reading and saving the _dws_lo_ order meta includes/ hook-free code as dws_lowc_ functions and DWS_LOWC_ constants, loaded in name order, _-prefixed files skipped diff --git a/plugins/linked-orders-for-woocommerce/changelog/deleted-parent b/plugins/linked-orders-for-woocommerce/changelog/deleted-parent index c127ca3..19a9ca0 100644 --- a/plugins/linked-orders-for-woocommerce/changelog/deleted-parent +++ b/plugins/linked-orders-for-woocommerce/changelog/deleted-parent @@ -1,4 +1,4 @@ Significance: patch Type: fixed -A child whose parent order was deleted shows as its own root, rather than failing the screens that show its tree. +Deleting an order for good makes its children the roots of their own trees, rather than leaving them the children of an order that no longer exists. diff --git a/plugins/linked-orders-for-woocommerce/docs/ARCHITECTURE.md b/plugins/linked-orders-for-woocommerce/docs/ARCHITECTURE.md index d7800a4..3df5308 100644 --- a/plugins/linked-orders-for-woocommerce/docs/ARCHITECTURE.md +++ b/plugins/linked-orders-for-woocommerce/docs/ARCHITECTURE.md @@ -28,14 +28,14 @@ The request that activates the plugin does not boot it, because WordPress includ ## Where code lives -- `src/` holds the composition root and its components: final classes that implement the framework's `ComponentInterface` and add their hooks in `register_hooks()`. `Settings` adds the `dws-linked-orders` section to WooCommerce's Advanced tab; `ChildCreation` answers v1's admin-post action; `MetaBox` and `ListTable` add the links to an order's editor and to the orders list, on either order storage, checking the user's capabilities in each callback; `Autocompletion` completes descendants; `Integration\SequentialOrderNumbersPro` loads only beside that plugin. A component takes ungated services in its constructor, such as `AdminNotices`, which `Plugin::get_definitions()` provides. +- `src/` holds the composition root and its components: final classes that implement the framework's `ComponentInterface` and add their hooks in `register_hooks()`. `Settings` adds the `dws-linked-orders` section to WooCommerce's Advanced tab; `ChildCreation` answers v1's admin-post action; `MetaBox` and `ListTable` add the links to an order's editor and to the orders list, on either order storage, checking the user's capabilities in each callback; `Autocompletion` completes descendants; `OrderDeletion` makes the children of an order deleted for good the roots of their own trees; `Integration\SequentialOrderNumbersPro` loads only beside that plugin. A component takes ungated services in its constructor, such as `AdminNotices`, which `Plugin::get_definitions()` provides. - `models/DWS_Order_Node.php` keeps v1's global class on Composer's classmap: a node wraps an order of a supported type, reads its depth, parent and children from the order's meta once, and saves them back through WC CRUD. - `includes/` holds code that registers no hooks and is not part of the tree, as prefixed functions and constants: v1's capabilities (`capabilities.php`), the option keys, defaults and the depth validator (`options.php`), v1's settings, order-type and linking APIs (`settings.php`, `post-type.php`, `linking.php`), v1's deprecated root functions, constants and boot-hook relays (`deprecated.php`), the footprint (`footprint.php`), the migration steps (`migrations.php`), the asset registrars (`assets.php`) and the slug, name and version getters a peer plugin reads (`metadata.php`). Quality's syntax job parses the folder on PHP 7.4, because `safeguards.php` runs before the floor check, and `phpstan.safeguards.neon` analyzes it at PHP 7.4 and the oldest WordPress the bootstrap package runs on. - `assets/src/` holds the orders list's TypeScript and SCSS, which `npm run build` compiles into `assets/build/`; `includes/assets.php` reads the `*.asset.php` file wp-scripts writes beside each build. ## Data -- **Order links** are three meta keys on each linked order, as v1 stored them: `_dws_lo_depth`, where a root is 0, `_dws_lo_parent` and `_dws_lo_children`, and `_dws_lo_created_by` on a child created through the plugin. WC CRUD writes them to whichever storage WooCommerce uses. Every link reads and rewrites the parent's `_dws_lo_children`, so `dws_lowc_link_orders()` holds the `dws_lowc_linking` option lock while it links, re-reads both orders' meta from the database under it, with WooCommerce's order-tables data caching off for the reads, and waits up to five seconds for another request's link to finish before it returns false. It saves the child first and the parent only once the child's parent reads back, since WooCommerce logs a failed save and carries on; on either refusal `dws_lowc_create_linked_child()` deletes the child it created and returns a `WP_Error`. +- **Order links** are three meta keys on each linked order, as v1 stored them: `_dws_lo_depth`, where a root is 0, `_dws_lo_parent` and `_dws_lo_children`, and `_dws_lo_created_by` on a child created through the plugin. WC CRUD writes them to whichever storage WooCommerce uses. Every link reads and rewrites the parent's `_dws_lo_children`, so `dws_lowc_link_orders()` holds the `dws_lowc_linking` option lock while it links, re-reads both orders' meta from the database under it, with WooCommerce's order-tables data caching off for the reads, and waits up to five seconds for another request's link to finish before it returns false. It saves the child first and the parent only once the child's parent reads back, since WooCommerce logs a failed save and carries on; on either refusal `dws_lowc_create_linked_child()` deletes the child it created and returns a `WP_Error`. When an order is deleted for good, its children become the roots of their own trees and their descendants' depths are rewritten, under the same lock. - **Options** keep v1's keys, `dws-lowc_general_max-depth`, `dws-lowc_general_autocomplete-descendants` and `dws-lowc_plugin_remove-data-uninstall`, as constants in `includes/options.php`. Code reads them through v1's API in `includes/settings.php`, which validates each stored value, falls back to the key's default, and applies v1's filters and then `dws_lowc/settings/validated_value`. - **Migrations** are the append-only steps `dws_lowc_get_migration_steps()` returns. The framework's runner runs each pending step once per site on `wp_loaded`, behind a lock, and records the revision reached in `dws_lowc_revision`. The first step stores each setting's default where a site, v1 or new, has none. A step converges from any partial state and throws when a write fails, so a failed step runs again on a later request. - **The footprint**, `dws_lowc_get_footprint()`, names every kind of data the plugin stores: the `dws_lowc_` and `dws-lowc_` option prefixes, the `_dws_lo_` order meta on both storages, v1's version and notice rows, user meta, network options, the capabilities, scheduled hooks and Action Scheduler groups. Freemius's `fs_` rows from v1 stay, since they belong to Freemius. diff --git a/plugins/linked-orders-for-woocommerce/includes/linking.php b/plugins/linked-orders-for-woocommerce/includes/linking.php index a3d72c2..9428b53 100644 --- a/plugins/linked-orders-for-woocommerce/includes/linking.php +++ b/plugins/linked-orders-for-woocommerce/includes/linking.php @@ -54,7 +54,7 @@ function dws_lowc_is_root_order( $order ): ?bool { /** * Links one order as the child of another, returning false unless the child is another order with neither a parent nor children, when another request still links orders after five seconds, or when the child's save does not store its parent, and null when either order has no node. * - * A link reads and rewrites the parent's list of children, so one lock serializes links across requests, and each reads both orders' meta from the database once it holds the lock. A link holds it for milliseconds, so the next one waits for it rather than refusing at once. WooCommerce logs a save that fails and returns as if it had saved, so the parent lists the child only once the child's parent reads back from the database. + * A link reads and rewrites the parent's list of children, so it holds the linking lock and reads both orders' meta from the database under it. WooCommerce logs a save that fails and returns as if it had saved, so the parent lists the child only once the child's parent reads back from the database. * * @api * @@ -73,15 +73,10 @@ function dws_lowc_link_orders( int $parent_id, int $child_id ): ?bool { return null; } - $lock = new Lock( Plugin::PREFIX . '_linking', 30 ); - $owner = wp_generate_uuid4(); - $deadline = microtime( true ) + 5; - while ( LockClaim::Busy === $lock->acquire( $owner ) ) { - if ( microtime( true ) >= $deadline ) { - return false; - } - - usleep( 50000 ); + $owner = wp_generate_uuid4(); + $lock = dws_lowc_acquire_linking_lock( $owner ); + if ( is_null( $lock ) ) { + return false; } try { @@ -104,6 +99,108 @@ function dws_lowc_link_orders( int $parent_id, int $child_id ): ?bool { return true; } +/** + * Makes each child of an order the root of its own tree, as when the order is deleted for good. + * + * The children's and their descendants' stored depths decide the depth column, the depth filter and the maximum depth, so the rewrite holds the linking lock, and runs without it only once another request has held it for five seconds. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param int $order_id The id of the order. + * + * @return void + */ +function dws_lowc_reroot_children( int $order_id ): void { + $node = dws_lowc_get_order_node( $order_id ); + if ( is_null( $node ) || ! $node->has_children() ) { + return; + } + + $owner = wp_generate_uuid4(); + $lock = dws_lowc_acquire_linking_lock( $owner ); + try { + $node = dws_lowc_get_order_node( $order_id ); + if ( is_null( $node ) ) { + return; + } + + dws_lowc_refresh_links( $node->get_order() ); + foreach ( $node->get_children() as $child ) { + $parent = $child->get_parent(); + if ( ! is_null( $parent ) && $order_id === $parent->get_order()->get_id() ) { + dws_lowc_store_depths( $child->get_order()->get_id(), 0 ); + } + } + } finally { + if ( ! is_null( $lock ) ) { + $lock->release( $owner ); + } + } +} + +/** + * Stores an order at a depth, 0 for the root of its tree, and each descendant one level below its parent. + * + * The caller holds the linking lock. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param int $order_id The id of the order. + * @param int $depth The order's depth. + * + * @return void + */ +function dws_lowc_store_depths( int $order_id, int $depth ): void { + $depths = array( $order_id => $depth ); + $pending = array( $order_id ); + while ( array() !== $pending ) { + $id = array_shift( $pending ); + $node = dws_lowc_get_order_node( $id ); + if ( is_null( $node ) ) { + continue; + } + + dws_lowc_refresh_links( $node->get_order() ); + $node->set_depth( $depths[ $id ] ); + $node->save(); + foreach ( $node->get_children() as $child ) { + $child_id = $child->get_order()->get_id(); + if ( ! isset( $depths[ $child_id ] ) ) { + $depths[ $child_id ] = $depths[ $id ] + 1; + $pending[] = $child_id; + } + } + } +} + +/** + * Waits up to five seconds for the lock that serializes changes to links across requests, and returns it once the owner holds it, or null when another request still holds it. + * + * A change holds the lock for milliseconds, so the next one waits for it rather than refusing at once. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param string $owner The token that identifies the holder. + * + * @return Lock|null + */ +function dws_lowc_acquire_linking_lock( string $owner ): ?Lock { + $lock = new Lock( Plugin::PREFIX . '_linking', 30 ); + $deadline = microtime( true ) + 5; + while ( LockClaim::Busy === $lock->acquire( $owner ) ) { + if ( microtime( true ) >= $deadline ) { + return null; + } + + usleep( 50000 ); + } + + return $lock; +} + /** * Reads orders' meta again from the database, past WooCommerce's order-tables data caching. * diff --git a/plugins/linked-orders-for-woocommerce/src/OrderDeletion.php b/plugins/linked-orders-for-woocommerce/src/OrderDeletion.php new file mode 100644 index 0000000..125b3f7 --- /dev/null +++ b/plugins/linked-orders-for-woocommerce/src/OrderDeletion.php @@ -0,0 +1,68 @@ +post_type, dws_lowc_get_supported_order_types(), true ) ) { + dws_lowc_reroot_children( $post_id ); + } + } + + // endregion +} diff --git a/plugins/linked-orders-for-woocommerce/src/Plugin.php b/plugins/linked-orders-for-woocommerce/src/Plugin.php index a3e8941..28496df 100644 --- a/plugins/linked-orders-for-woocommerce/src/Plugin.php +++ b/plugins/linked-orders-for-woocommerce/src/Plugin.php @@ -62,6 +62,7 @@ final class Plugin { MetaBox::class, ListTable::class, Autocompletion::class, + OrderDeletion::class, Integration\SequentialOrderNumbersPro::class, ); From c286e9b8e75a6590eda9b025210edaa0fbcbf73d Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Thu, 12 Nov 2026 10:48:02 +0100 Subject: [PATCH 10/50] Test that an update repairs the links v1 left inconsistent A v1 site keeps the children of orders v1 deleted, which still name their parent and their depth, and the half links v1's lost-update race left, where a child names a parent that does not list it; nothing repairs either (A290, A291). The row stores both on every storage, as v1 did, and runs the second migration step's batches until none remain: the orphan becomes a root with its descendant one level below, the unlisted child joins its parent's children, and the walk's position is gone. The helper that stores v1's links moves to a trait both test classes use. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/Integration/LinkingTest.php | 10 +-- .../tests/Integration/MigrationsTest.php | 85 +++++++++++++++++++ .../tests/Support/StoresLinksTrait.php | 13 +++ 3 files changed, 100 insertions(+), 8 deletions(-) create mode 100644 plugins/linked-orders-for-woocommerce/tests/Support/StoresLinksTrait.php diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/LinkingTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/LinkingTest.php index 8e1623c..9051cc5 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/LinkingTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/LinkingTest.php @@ -7,6 +7,7 @@ use DeepWebSolutions\LinkedOrders\Scoped\DeepWebSolutions\Framework\Core\Storage\LockClaim; use DeepWebSolutions\LinkedOrders\Tests\Support\OrderStorageTrait; use DeepWebSolutions\LinkedOrders\Tests\Support\RunsRequestsTrait; +use DeepWebSolutions\LinkedOrders\Tests\Support\StoresLinksTrait; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\Before; use PHPUnit\Framework\Attributes\CoversNothing; @@ -17,6 +18,7 @@ final class LinkingTest extends TestCase { use OrderStorageTrait; use RunsRequestsTrait; + use StoresLinksTrait; protected int $user = 0; @@ -533,14 +535,6 @@ protected function use_storage_and_data_caching( bool $hpos, bool $sync, bool $c add_filter( $hook, $this->storage_filters[ $hook ] ); } - // Stands in for links v1 stored without the checks a link makes now. - protected static function store_links( \WC_Order $order, int $depth, int $parent_id, array $child_ids ): void { - $order->update_meta_data( '_dws_lo_depth', (string) $depth ); - $order->update_meta_data( '_dws_lo_parent', 0 === $parent_id ? '' : (string) $parent_id ); - $order->update_meta_data( '_dws_lo_children', $child_ids ); - $order->save(); - } - protected static function fail_saving( int $order_id ): \Closure { $fail = static function ( \WC_Order $order ) use ( $order_id ): void { if ( $order_id === $order->get_id() ) { diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/MigrationsTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/MigrationsTest.php index 877dfcf..133ba99 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/MigrationsTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/MigrationsTest.php @@ -3,16 +3,22 @@ namespace DeepWebSolutions\LinkedOrders\Tests\Integration; use DeepWebSolutions\LinkedOrders\Plugin; +use DeepWebSolutions\LinkedOrders\Scoped\DeepWebSolutions\Framework\Core\Migration\BatchedMigrationStepInterface; use DeepWebSolutions\LinkedOrders\Scoped\DeepWebSolutions\Framework\Core\Migration\MigrationRunner; +use DeepWebSolutions\LinkedOrders\Tests\Support\OrderStorageTrait; use DeepWebSolutions\LinkedOrders\Tests\Support\RestoresOptionsTrait; +use DeepWebSolutions\LinkedOrders\Tests\Support\StoresLinksTrait; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RunInSeparateProcess; use PHPUnit\Framework\TestCase; #[CoversNothing] final class MigrationsTest extends TestCase { + use OrderStorageTrait; use RestoresOptionsTrait; + use StoresLinksTrait; protected const array OPTIONS = array( DWS_LOWC_OPTION_MAX_DEPTH, DWS_LOWC_OPTION_AUTOCOMPLETE, DWS_LOWC_OPTION_DELETE_DATA ); @@ -68,4 +74,83 @@ public function test_the_first_step_throws_when_a_default_cannot_be_stored(): vo $wpdb->suppress_errors( $suppressed ); } } + + // v1 deleted an order without touching its children, and could save a parent's children without the child it had just linked. + #[DataProvider( 'storages' )] + public function test_the_second_step_roots_orphans_and_lists_each_child_among_its_parents_children( bool $hpos, bool $sync ): void { + $this->use_storage( $hpos, $sync ); + $deleted = $this->create_order(); + $orphan = $this->create_order(); + $descendant = $this->create_order(); + $parent = $this->create_order(); + $listed = $this->create_order(); + $unlisted = $this->create_order(); + self::store_links( $orphan, 1, $deleted->get_id(), array( $descendant->get_id() ) ); + self::store_links( $descendant, 2, $orphan->get_id(), array() ); + self::store_links( $parent, 0, 0, array( $listed->get_id() ) ); + self::store_links( $listed, 1, $parent->get_id(), array() ); + self::store_links( $unlisted, 1, $parent->get_id(), array() ); + $deleted->delete( true ); + + self::assertInstanceOf( BatchedMigrationStepInterface::class, dws_lowc_get_migration_steps()[1] ?? null ); + $batches = 0; + while ( dws_lowc_repair_links() ) { + self::assertLessThan( 10000, ++$batches ); + } + + self::assertSame( '0', wc_get_order( $orphan->get_id() )->get_meta( '_dws_lo_depth' ) ); + self::assertSame( '', wc_get_order( $orphan->get_id() )->get_meta( '_dws_lo_parent' ) ); + self::assertSame( '1', wc_get_order( $descendant->get_id() )->get_meta( '_dws_lo_depth' ) ); + self::assertSame( array( $listed->get_id(), $unlisted->get_id() ), wc_get_order( $parent->get_id() )->get_meta( '_dws_lo_children' ) ); + self::assertSame( '1', wc_get_order( $unlisted->get_id() )->get_meta( '_dws_lo_depth' ) ); + self::assertSame( (string) $parent->get_id(), wc_get_order( $unlisted->get_id() )->get_meta( '_dws_lo_parent' ) ); + self::assertFalse( get_option( 'dws_lowc_link_repair_page' ) ); + } + + // A batch holds 50 orders, so the orphan after 50 roots waits on the second page. + #[DataProvider( 'storages' )] + public function test_the_second_step_walks_past_its_first_page( bool $hpos, bool $sync ): void { + $this->use_storage( $hpos, $sync ); + for ( $i = 0; $i < 50; $i++ ) { + self::store_links( $this->create_order(), 0, 0, array() ); + } + + $deleted = $this->create_order(); + $orphan = $this->create_order(); + self::store_links( $orphan, 1, $deleted->get_id(), array() ); + $deleted->delete( true ); + + $batches = 0; + while ( dws_lowc_repair_links() ) { + self::assertLessThan( 5, ++$batches ); + } + + self::assertSame( '0', wc_get_order( $orphan->get_id() )->get_meta( '_dws_lo_depth' ) ); + } + + // v1 could lose a child from the children of an order it later orphaned, and a request killed while it rewrites a tree's depths leaves some unwritten. + #[DataProvider( 'storages' )] + public function test_the_second_step_stores_each_child_one_level_below_its_parent( bool $hpos, bool $sync ): void { + $this->use_storage( $hpos, $sync ); + $deleted = $this->create_order(); + $orphan = $this->create_order(); + $lost = $this->create_order(); + $root = $this->create_order(); + $stale = $this->create_order(); + $grandchild = $this->create_order(); + self::store_links( $orphan, 1, $deleted->get_id(), array() ); + self::store_links( $lost, 2, $orphan->get_id(), array() ); + self::store_links( $root, 0, 0, array( $stale->get_id() ) ); + self::store_links( $stale, 3, $root->get_id(), array( $grandchild->get_id() ) ); + self::store_links( $grandchild, 4, $stale->get_id(), array() ); + $deleted->delete( true ); + + $batches = 0; + while ( dws_lowc_repair_links() ) { + self::assertLessThan( 10000, ++$batches ); + } + + self::assertSame( array( $lost->get_id() ), wc_get_order( $orphan->get_id() )->get_meta( '_dws_lo_children' ) ); + self::assertSame( array( '0', '1', '1', '2' ), \array_map( static fn ( \WC_Order $order ): string => (string) wc_get_order( $order->get_id() )->get_meta( '_dws_lo_depth' ), array( $orphan, $lost, $stale, $grandchild ) ) ); + } } diff --git a/plugins/linked-orders-for-woocommerce/tests/Support/StoresLinksTrait.php b/plugins/linked-orders-for-woocommerce/tests/Support/StoresLinksTrait.php new file mode 100644 index 0000000..cdccfca --- /dev/null +++ b/plugins/linked-orders-for-woocommerce/tests/Support/StoresLinksTrait.php @@ -0,0 +1,13 @@ +update_meta_data( '_dws_lo_depth', (string) $depth ); + $order->update_meta_data( '_dws_lo_parent', 0 === $parent_id ? '' : (string) $parent_id ); + $order->update_meta_data( '_dws_lo_children', $child_ids ); + $order->save(); + } +} From 48a696c89c87ed646739b9e517cec64b820e445a Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Thu, 12 Nov 2026 10:50:42 +0100 Subject: [PATCH 11/50] Repair v1's orphaned and half links in a batched migration step A site upgrading from v1 keeps the children of orders v1 deleted, which still name their parent and their depth, and the half links v1's lost-update race left, where a child names a parent that does not list it; the screens then disagree on where such an order stands, and a second parent can claim a half-linked child (A290, A291, X207). The second migration step implements BatchedMigrationStepInterface, so the runner's scheduled hook runs it. Each batch takes the next page of 50 orders that store a parent and, under the linking lock, stores an orphan as a root with its descendants' depths rewritten, or adds a child its parent does not list to the parent's children, reading each write back and throwing when it did not land. A repair keeps every order on its page, so the walk's position is a page number, deleted once the walk ends; a batch that cannot take the lock runs again. Assisted-by: Claude Code:claude-opus-5-5 --- .../changelog/link-repair | 4 + .../docs/ARCHITECTURE.md | 4 +- .../includes/migrations.php | 116 +++++++++++++++++- .../tests/Integration/UninstallTest.php | 13 +- 4 files changed, 129 insertions(+), 8 deletions(-) create mode 100644 plugins/linked-orders-for-woocommerce/changelog/link-repair diff --git a/plugins/linked-orders-for-woocommerce/changelog/link-repair b/plugins/linked-orders-for-woocommerce/changelog/link-repair new file mode 100644 index 0000000..7048730 --- /dev/null +++ b/plugins/linked-orders-for-woocommerce/changelog/link-repair @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +The update to 2.0.0 makes each child whose parent order v1 deleted the root of its own tree, lists each child that its parent's children lost among them again, and stores each child one level below its parent. diff --git a/plugins/linked-orders-for-woocommerce/docs/ARCHITECTURE.md b/plugins/linked-orders-for-woocommerce/docs/ARCHITECTURE.md index 3df5308..5e3798b 100644 --- a/plugins/linked-orders-for-woocommerce/docs/ARCHITECTURE.md +++ b/plugins/linked-orders-for-woocommerce/docs/ARCHITECTURE.md @@ -19,7 +19,7 @@ This document describes how the plugin loads, boots, stores its data and leaves - The scheduler registers its hooks first, so the plugin's recurring jobs keep their schedules while the plugin is blocked. - `WooCommerceRequirement` checks WooCommerce against the `WC requires at least` header; without it, the boot is `blocked`, `dws_lowc/boot/blocked` fires, and users who can activate plugins see why the plugin stays off. -- The migration runner registers, and runs pending steps on `wp_loaded`. +- The migration runner registers, runs pending steps on `wp_loaded`, and runs a batched step's batches from its scheduled hook. - The kernel builds each class in `Plugin::COMPONENTS` through a PHP-DI container and calls its `register_hooks()`; a composite adds its children, and a conditional component loads only when its static `should_load()` returns true. - `CapabilityGrants` gives administrators and shop managers v1's four capabilities on `wp_loaded`, once per change of the declaration or the roles. - Once every component has registered, the boot is `booted` and `dws_lowc/boot/succeeded` fires. A throw anywhere in the composition makes it `failed`: `dws_lowc/boot/failed` fires, the failure is logged, and administrators see a notice, while local and development environments rethrow it. @@ -37,7 +37,7 @@ The request that activates the plugin does not boot it, because WordPress includ - **Order links** are three meta keys on each linked order, as v1 stored them: `_dws_lo_depth`, where a root is 0, `_dws_lo_parent` and `_dws_lo_children`, and `_dws_lo_created_by` on a child created through the plugin. WC CRUD writes them to whichever storage WooCommerce uses. Every link reads and rewrites the parent's `_dws_lo_children`, so `dws_lowc_link_orders()` holds the `dws_lowc_linking` option lock while it links, re-reads both orders' meta from the database under it, with WooCommerce's order-tables data caching off for the reads, and waits up to five seconds for another request's link to finish before it returns false. It saves the child first and the parent only once the child's parent reads back, since WooCommerce logs a failed save and carries on; on either refusal `dws_lowc_create_linked_child()` deletes the child it created and returns a `WP_Error`. When an order is deleted for good, its children become the roots of their own trees and their descendants' depths are rewritten, under the same lock. - **Options** keep v1's keys, `dws-lowc_general_max-depth`, `dws-lowc_general_autocomplete-descendants` and `dws-lowc_plugin_remove-data-uninstall`, as constants in `includes/options.php`. Code reads them through v1's API in `includes/settings.php`, which validates each stored value, falls back to the key's default, and applies v1's filters and then `dws_lowc/settings/validated_value`. -- **Migrations** are the append-only steps `dws_lowc_get_migration_steps()` returns. The framework's runner runs each pending step once per site on `wp_loaded`, behind a lock, and records the revision reached in `dws_lowc_revision`. The first step stores each setting's default where a site, v1 or new, has none. A step converges from any partial state and throws when a write fails, so a failed step runs again on a later request. +- **Migrations** are the append-only steps `dws_lowc_get_migration_steps()` returns. The framework's runner runs each pending step once per site on `wp_loaded`, behind a lock, and records the revision reached in `dws_lowc_revision`. The first step stores each setting's default where a site, v1 or new, has none. The second, batched one walks every order that stores a parent, 50 to a batch, under the linking lock: a child whose parent no longer loads becomes the root of its own tree, a child missing from its parent's children joins them, and a child at another depth than one below its parent moves there with its descendants, repairing what v1's deletions and lost updates left. A step converges from any partial state and throws when a write fails, so a failed step runs again on a later request. - **The footprint**, `dws_lowc_get_footprint()`, names every kind of data the plugin stores: the `dws_lowc_` and `dws-lowc_` option prefixes, the `_dws_lo_` order meta on both storages, v1's version and notice rows, user meta, network options, the capabilities, scheduled hooks and Action Scheduler groups. Freemius's `fs_` rows from v1 stay, since they belong to Freemius. - **Deactivation** unschedules every hook in the footprint's `cron_hooks` and `as_hooks` on the current site. - **Uninstall** runs `uninstall.php`, which calls the framework's uninstall engine over the footprint, with WooCommerce's order meta types, on every site whose `dws-lowc_plugin_remove-data-uninstall` setting consents, as a v1 site's consent carries over; a site that never consented keeps its data. Below the floors or with an incomplete build, a web request deletes the files, keeps the data and logs why, while WP-CLI fails and keeps both for a rerun. diff --git a/plugins/linked-orders-for-woocommerce/includes/migrations.php b/plugins/linked-orders-for-woocommerce/includes/migrations.php index 9172c19..8d3eb17 100644 --- a/plugins/linked-orders-for-woocommerce/includes/migrations.php +++ b/plugins/linked-orders-for-woocommerce/includes/migrations.php @@ -8,6 +8,9 @@ * @package DeepWebSolutions\LinkedOrders */ +use Automattic\WooCommerce\Utilities\OrderUtil; +use DeepWebSolutions\LinkedOrders\Scoped\DeepWebSolutions\Framework\Core\Migration\BatchedMigrationStepInterface; + defined( 'ABSPATH' ) || exit; /** @@ -16,11 +19,27 @@ * @since 2.0.0 * @version 2.0.0 * - * @return list + * @return list<(callable(): void)|BatchedMigrationStepInterface> */ function dws_lowc_get_migration_steps(): array { return array( 'dws_lowc_add_settings', + new class() implements BatchedMigrationStepInterface { + // region INHERITED METHODS + + /** + * {@inheritDoc} + * + * @since 2.0.0 + * @version 2.0.0 + */ + #[\Override] + public function process_batch(): bool { + return dws_lowc_repair_links(); + } + + // endregion + }, ); } @@ -49,3 +68,98 @@ function dws_lowc_add_settings(): void { } } } + +/** + * Repairs the next page of linked orders and returns whether pages remain. + * + * Under the linking lock, a child whose parent no longer loads becomes the root of its own tree, a child its parent does not list joins the parent's children, and a child stored at any other depth than one below its parent moves there with its descendants. + * + * Children of an order v1 deleted still name it, v1's links could lose a child from its parent's list, and a request killed while a tree's depths are rewritten leaves some unwritten. A repair keeps every order on its page, so the pages hold still while the walk runs, and the option that holds its position is deleted once it ends. A batch that waits five seconds for the lock in vain runs again later. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @throws RuntimeException Thrown when a repair does not read back or the walk's position could not be stored. + * + * @return bool + */ +function dws_lowc_repair_links(): bool { + $page = filter_var( get_option( 'dws_lowc_link_repair_page', 1 ), FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE ) ?? 1; + + // The order tables take the parent key only in a meta query, which the posts store refuses, passing a top-level key to WP_Query instead. + $linked = OrderUtil::custom_orders_table_usage_is_enabled() ? array( 'meta_query' => array( array( 'key' => '_dws_lo_parent' ) ) ) : array( 'meta_key' => '_dws_lo_parent' ); // phpcs:ignore WordPress.DB.SlowDBQuery -- A one-time walk of the linked orders. + $ids = wc_get_orders( + $linked + array( + 'type' => dws_lowc_get_supported_order_types(), + 'status' => array_values( get_post_stati() ), + 'limit' => 50, + 'paged' => $page, + 'orderby' => 'ID', + 'order' => 'ASC', + 'return' => 'ids', + ) + ); + + $ids = is_array( $ids ) ? $ids : array(); + $child_ids = static fn ( DWS_Order_Node $node ): array => array_map( static fn ( DWS_Order_Node $child ): int => $child->get_order()->get_id(), $node->get_children() ); + $owner = wp_generate_uuid4(); + $lock = dws_lowc_acquire_linking_lock( $owner ); + if ( is_null( $lock ) ) { + return true; + } + + try { + foreach ( $ids as $id ) { + $node = dws_lowc_get_order_node( $id ); + if ( is_null( $node ) ) { + continue; + } + + dws_lowc_refresh_links( $node->get_order() ); + $order_id = $node->get_order()->get_id(); + $parent = $node->get_parent(); + $depth = 0; + if ( ! is_null( $parent ) ) { + dws_lowc_refresh_links( $parent->get_order() ); + $depth = $parent->get_depth() + 1; + if ( ! in_array( $order_id, $child_ids( $parent ), true ) ) { + $parent->set_children( array( ...$parent->get_children(), $node ) ); + $parent->save(); + $check = dws_lowc_get_order_node( $parent->get_order()->get_id() ); + if ( ! is_null( $check ) ) { + dws_lowc_refresh_links( $check->get_order() ); + } + + if ( is_null( $check ) || ! in_array( $order_id, $child_ids( $check ), true ) ) { + throw new RuntimeException( "The order $order_id could not be listed among its parent's children." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered. + } + } + } + + if ( $depth !== $node->get_depth() ) { + dws_lowc_store_depths( $order_id, $depth ); + $check = dws_lowc_get_order_node( $order_id ); + if ( ! is_null( $check ) ) { + dws_lowc_refresh_links( $check->get_order() ); + } + + if ( is_null( $check ) || $depth !== $check->get_depth() ) { + throw new RuntimeException( "The order $order_id could not be stored at depth $depth." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered. + } + } + } + } finally { + $lock->release( $owner ); + } + + if ( count( $ids ) < 50 ) { + delete_option( 'dws_lowc_link_repair_page' ); + return false; + } + + if ( ! update_option( 'dws_lowc_link_repair_page', $page + 1, false ) ) { + throw new RuntimeException( "The repair of the links could not store its position after page $page." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered. + } + + return true; +} diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/UninstallTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/UninstallTest.php index 7477760..1365364 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/UninstallTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/UninstallTest.php @@ -45,8 +45,8 @@ public function test_an_uninstall_in_a_request_that_skips_the_plugin_removes_its $request = self::request( $php, '--skip-plugins=' . Plugin::SLUG ); - // The other tests share the database, so a request with the plugin stores again what a new site starts with, as the site's next request would. - $restored = self::request( '' ); + // The other tests share the database, so a request with the plugin stores again what a new site starts with, as the site's next request and the runner's scheduled hook would. + $restored = self::request( 'do_action( "' . Plugin::PREFIX . '_revision/migration/run" );' ); wp_cache_flush(); wp_roles()->for_site(); @@ -80,8 +80,9 @@ public function test_a_web_uninstall_whose_engine_fails_ends_with_its_message_an delete_option( Plugin::PREFIX . '_revision' ); wp_cache_flush(); wp_roles()->for_site(); - // The other tests share the database, so the runner stores again what a new site starts with, and the grants act again. + // The other tests share the database, so the runner and its scheduled hook store again what a new site starts with, and the grants act again. dws_lowc_plugin()->migrations?->run(); + do_action( Plugin::PREFIX . '_revision/migration/run' ); do_action( 'wp_loaded' ); } } @@ -125,13 +126,14 @@ public function test_an_uninstall_after_v1_consent_removes_the_links_on_both_sto } } - // The other tests share the database, so the roles get their capabilities back and the runner stores again what a new site starts with. + // The other tests share the database, so the roles get their capabilities back and the runner and its scheduled hook store again what a new site starts with. foreach ( DWS_LOWC_CAPABILITY_GRANTS as $role => $capabilities ) { foreach ( $capabilities as $capability ) { get_role( $role )?->add_cap( $capability ); } } dws_lowc_plugin()->migrations?->run(); + do_action( Plugin::PREFIX . '_revision/migration/run' ); foreach ( $linked as $id => $hpos ) { $storage = static fn (): string => $hpos; add_filter( 'pre_option_woocommerce_custom_orders_table_enabled', $storage ); @@ -175,8 +177,9 @@ public function test_a_consenting_uninstall_removes_a_row_of_every_kind_the_fram ) ); - // The other tests share the database, so the runner stores again what a new site starts with. + // The other tests share the database, so the runner and its scheduled hook store again what a new site starts with. dws_lowc_plugin()->migrations?->run(); + do_action( Plugin::PREFIX . '_revision/migration/run' ); self::assertSame( 'no', get_option( DWS_LOWC_OPTION_DELETE_DATA ) ); } } From bce1061d77a898ec5866f4d7feaa19128c9cdf8f Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Thu, 12 Nov 2026 10:51:36 +0100 Subject: [PATCH 12/50] Test that a child is created only by a user who may edit that order can_create_child() checks the primitive edit_shop_orders, which a user who may edit only their own orders holds, so that user may create a child of an order WooCommerce would not let them open (A293). The row gives a subscriber edit_shop_orders and Linked Orders' own capability on every storage, expects no child of another user's order, then one once edit_others_shop_orders is granted. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/Integration/LinkingTest.php | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/LinkingTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/LinkingTest.php index 9051cc5..56a1af8 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/LinkingTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/LinkingTest.php @@ -295,6 +295,29 @@ public function test_a_node_of_an_unsupported_order_cannot_be_built(): void { new \DWS_Order_Node( \PHP_INT_MAX ); } + // A user who may edit orders may still not edit another user's, which WooCommerce checks before it opens one. + #[DataProvider( 'storages' )] + public function test_a_child_is_created_only_by_a_user_who_may_edit_that_order( bool $hpos, bool $sync ): void { + $this->use_storage( $hpos, $sync ); + $order = $this->create_order(); + $this->user = wp_insert_user( + array( + 'user_login' => 'dws-lowc-linking-editor', + 'user_pass' => wp_generate_password(), + 'role' => 'subscriber', + ) + ); + self::assertIsInt( $this->user ); + $editor = new \WP_User( $this->user ); + $editor->add_cap( 'edit_shop_orders' ); + $editor->add_cap( DWS_LOWC_CAP_CREATE_LINKED_CHILDREN ); + + self::assertFalse( dws_lowc_get_order_node( $order->get_id() )?->can_create_child( $this->user ) ); + + $editor->add_cap( 'edit_others_shop_orders' ); + self::assertTrue( dws_lowc_get_order_node( $order->get_id() )?->can_create_child( $this->user ) ); + } + #[DataProvider( 'storages' )] public function test_a_child_can_be_created_below_the_maximum_depth_by_a_user_who_may_edit_the_order( bool $hpos, bool $sync ): void { $this->use_storage( $hpos, $sync ); From 390d5eccfacd9d73199e1c5bdde5144497ed7a64 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Thu, 12 Nov 2026 10:52:51 +0100 Subject: [PATCH 13/50] Check the order's own edit capability before creating its child can_create_child() asked for the primitive edit_shop_orders, so a user who may edit only their own orders could create a child of another user's order and copy its addresses (A293, X208). It now checks the order type's edit_post capability with the order's id, or manage_woocommerce, as WooCommerce does before it opens an order; Linked Orders' own capability and the v1 filter stay. Assisted-by: Claude Code:claude-opus-5-5 --- .../changelog/child-creation-permission | 4 ++++ .../linked-orders-for-woocommerce/includes/capabilities.php | 2 +- .../linked-orders-for-woocommerce/models/DWS_Order_Node.php | 3 ++- plugins/linked-orders-for-woocommerce/phpcs.dist.xml | 2 +- 4 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 plugins/linked-orders-for-woocommerce/changelog/child-creation-permission diff --git a/plugins/linked-orders-for-woocommerce/changelog/child-creation-permission b/plugins/linked-orders-for-woocommerce/changelog/child-creation-permission new file mode 100644 index 0000000..231d727 --- /dev/null +++ b/plugins/linked-orders-for-woocommerce/changelog/child-creation-permission @@ -0,0 +1,4 @@ +Significance: minor +Type: changed + +Creating a linked child of an order needs permission to edit that order, as WooCommerce asks before it opens one, rather than permission to edit orders at all. diff --git a/plugins/linked-orders-for-woocommerce/includes/capabilities.php b/plugins/linked-orders-for-woocommerce/includes/capabilities.php index e5afcab..efccfb6 100644 --- a/plugins/linked-orders-for-woocommerce/includes/capabilities.php +++ b/plugins/linked-orders-for-woocommerce/includes/capabilities.php @@ -11,7 +11,7 @@ defined( 'ABSPATH' ) || exit; /** - * The capability a user needs, beside edit_shop_orders on the parent order, to create a linked child order. + * The capability a user needs, beside permission to edit the parent order, to create a linked child order. * * @since 2.0.0 * @version 2.0.0 diff --git a/plugins/linked-orders-for-woocommerce/models/DWS_Order_Node.php b/plugins/linked-orders-for-woocommerce/models/DWS_Order_Node.php index c073432..eee5c76 100644 --- a/plugins/linked-orders-for-woocommerce/models/DWS_Order_Node.php +++ b/plugins/linked-orders-for-woocommerce/models/DWS_Order_Node.php @@ -404,7 +404,8 @@ public function get_formatted_name(): string { */ public function can_create_child( ?int $user_id = null ): bool { $user = $user_id ?? get_current_user_id(); - $permission = user_can( $user, DWS_LOWC_CAP_CREATE_LINKED_CHILDREN, $this->order->get_id() ) && user_can( $user, 'edit_shop_orders', $this->order->get_id() ); + $edit = $this->post_type->cap->edit_post; + $permission = user_can( $user, DWS_LOWC_CAP_CREATE_LINKED_CHILDREN, $this->order->get_id() ) && ( ( is_string( $edit ) && user_can( $user, $edit, $this->order->get_id() ) ) || user_can( $user, 'manage_woocommerce' ) ); $depth = dws_lowc_get_max_depth() > $this->get_depth(); $status = $this->order->has_status( dws_lowc_get_valid_statuses_for_new_child( $this->post_type->name, $this->order ) ); diff --git a/plugins/linked-orders-for-woocommerce/phpcs.dist.xml b/plugins/linked-orders-for-woocommerce/phpcs.dist.xml index 2b54bad..d96c3bd 100644 --- a/plugins/linked-orders-for-woocommerce/phpcs.dist.xml +++ b/plugins/linked-orders-for-woocommerce/phpcs.dist.xml @@ -10,7 +10,7 @@ - + From 159ba71a5a6cdc7771084fd5c9eb5f7a0751bd46 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Thu, 12 Nov 2026 10:54:08 +0100 Subject: [PATCH 14/50] Test that rows read their own links and completion passes once Each orders-list row loaded its parent and every child to decide on one action, so a page of linked orders loaded their whole trees, and a row whose related order was deleted lost the action (A294). Completing a tree completes each descendant, which fires the completion hook again, so every descendant walked and saved its own subtree once more (A295). The list row stores a dangling child and a deleted parent on every storage and expects the tree action with one node built per row. The completion row records the orders the filter sees and expects each once, with one note per descendant, while an unrelated tree a callback completes mid-pass still completes its child. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/Integration/AutocompletionTest.php | 31 +++++++++++++++++++ .../tests/Integration/ListTableTest.php | 31 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/AutocompletionTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/AutocompletionTest.php index 5f15d14..a4cdc41 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/AutocompletionTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/AutocompletionTest.php @@ -63,6 +63,37 @@ public function test_the_completed_order_passes_the_filters_before_its_descendan self::assertSame( array( $root->get_id(), $child->get_id() ), \array_slice( $seen, 0, 2 ) ); } + // Completing each descendant fires the completion hook again, so a pass covers each order once, while an order outside the pass that a callback completes still completes its own descendants. + #[DataProvider( 'storages' )] + public function test_each_order_of_a_tree_is_completed_once_and_with_one_note( bool $hpos, bool $sync ): void { + $this->use_storage( $hpos, $sync ); + list( $root, $child, $grandchild ) = $this->create_tree(); + list( $other, $other_child, $other_grandchild ) = $this->create_tree(); + + $seen = array(); + $record = static function ( bool $should, int $order_id ) use ( &$seen, $child, $other ): bool { + $seen[] = $order_id; + if ( $child->get_id() === $order_id ) { + wc_get_order( $other->get_id() )->update_status( 'completed' ); + } + + return $should; + }; + add_filter( 'dws_lowc/autocompletion/should_autocomplete', $record, 10, 2 ); + + try { + $root->update_status( 'completed' ); + } finally { + remove_filter( 'dws_lowc/autocompletion/should_autocomplete', $record ); + } + + self::assertSame( array( $root->get_id(), $child->get_id(), $other->get_id(), $other_child->get_id(), $other_grandchild->get_id(), $grandchild->get_id() ), $seen ); + foreach ( array( $child, $grandchild, $other_child, $other_grandchild ) as $descendant ) { + self::assertSame( 'completed', wc_get_order( $descendant->get_id() )->get_status() ); + self::assertCount( 1, \array_filter( wc_get_order_notes( array( 'order_id' => $descendant->get_id() ) ), static fn ( \stdClass $note ): bool => \str_starts_with( $note->content, 'Child Order autocompleted.' ) ) ); + } + } + #[DataProvider( 'storages' )] public function test_completing_a_child_leaves_its_parent_alone( bool $hpos, bool $sync ): void { $this->use_storage( $hpos, $sync ); diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/ListTableTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/ListTableTest.php index 244f189..411a077 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/ListTableTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/ListTableTest.php @@ -252,6 +252,37 @@ public function test_an_order_row_offers_its_customers_orders_its_tree_and_a_new self::assertSame( 'create-empty-linked-child', $actions['create_empty_linked_child']['action'] ); } + // A list page builds every row, so a row reads only its own order's stored links, and offers its tree even where a related order was deleted. + #[DataProvider( 'storages' )] + public function test_a_row_offers_its_tree_from_its_stored_links_alone( bool $hpos, bool $sync ): void { + $this->use_storage( $hpos, $sync ); + $parent = $this->create_order(); + $orphan = $this->create_order(); + $builds = 0; + $counted = static function ( array $types ) use ( &$builds ): array { + ++$builds; + return $types; + }; + foreach ( array( array( $parent, 0, array( \PHP_INT_MAX ) ), array( $orphan, 1, array() ) ) as list( $order, $depth, $children ) ) { + $order->update_meta_data( '_dws_lo_depth', (string) $depth ); + $order->update_meta_data( '_dws_lo_parent', 0 === $depth ? '' : (string) \PHP_INT_MAX ); + $order->update_meta_data( '_dws_lo_children', $children ); + $order->save(); + } + $this->open_list(); + add_filter( 'dws_lowc/post_type/supported_order_types', $counted ); + + try { + foreach ( array( $parent, $orphan ) as $order ) { + $builds = 0; + self::assertArrayHasKey( 'view_all_linked_orders', apply_filters( 'woocommerce_admin_order_actions', array(), wc_get_order( $order->get_id() ) ) ); + self::assertSame( 1, $builds ); + } + } finally { + remove_filter( 'dws_lowc/post_type/supported_order_types', $counted ); + } + } + #[DataProvider( 'storages' )] public function test_a_guest_row_offers_the_orders_of_its_billing_email_unless_the_list_shows_them( bool $hpos, bool $sync ): void { $this->use_storage( $hpos, $sync ); From a839ea1d29092fa7f3c2bd96a31b6d79b9a3ca84 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Thu, 12 Nov 2026 10:56:43 +0100 Subject: [PATCH 15/50] Read each row's own links and complete each order once per pass Every orders-list row loaded its parent and children to decide on its tree action, so a page loaded whole trees, and a row whose related order was deleted lost the action (A294). Completing an order completed each descendant, whose own completion walked and saved its subtree again (A295, X209). A row now offers its tree when its stored depth is above 0 or it stores child ids, loading no related order, so a dangling id opens a tree of one. Autocompletion keeps the ids of the orders a pass in progress covers and starts no nested pass for them, while an order a callback completes outside the pass still completes its descendants. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/Autocompletion.php | 127 +++++++++++------- .../src/ListTable.php | 4 +- 2 files changed, 78 insertions(+), 53 deletions(-) diff --git a/plugins/linked-orders-for-woocommerce/src/Autocompletion.php b/plugins/linked-orders-for-woocommerce/src/Autocompletion.php index 961a2fa..1eb71a1 100644 --- a/plugins/linked-orders-for-woocommerce/src/Autocompletion.php +++ b/plugins/linked-orders-for-woocommerce/src/Autocompletion.php @@ -13,6 +13,20 @@ * @version 2.0.0 */ final class Autocompletion implements ComponentInterface { + // region FIELDS AND CONSTANTS + + /** + * The ids of the orders a pass in progress covers, whose completion it already handles. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @var array + */ + protected array $covered = array(); + + // endregion + // region INHERITED METHODS /** @@ -33,6 +47,8 @@ public function register_hooks(): void { /** * Sets the order and each of its descendants, the order first, to completed, with a note, unless a filter keeps one or names another status; the order itself, already completed, changes only through a filter. * + * Completing a descendant fires this hook again, so an order a pass in progress covers starts no pass of its own. + * * @since 2.0.0 * @version 2.0.0 * @@ -41,62 +57,69 @@ public function register_hooks(): void { * @return void */ public function complete_descendants( int $order_id ): void { - if ( true !== dws_lowc_get_validated_setting( 'autocomplete-descendants', 'general' ) ) { + if ( isset( $this->covered[ $order_id ] ) || true !== dws_lowc_get_validated_setting( 'autocomplete-descendants', 'general' ) ) { return; } - foreach ( dws_lowc_get_orders_tree( $order_id ) ?? array() as $descendant_id ) { - /** - * Filters whether a descendant of a completed order is completed too. - * - * @since 1.0.0 - * @version 2.0.0 - * @deprecated 2.0.0 Use dws_lowc/autocompletion/should_autocomplete. - * - * @param bool $should_autocomplete Whether the descendant is completed. - * @param int $descendant_id The descendant's id. - */ - $should_autocomplete = apply_filters_deprecated( 'linked_orders_for_woocommerce/autocompletion/should_autocomplete', array( true, $descendant_id ), '2.0.0', 'dws_lowc/autocompletion/should_autocomplete' ); - - /** - * Filters whether a descendant of a completed order is completed too. - * - * @since 2.0.0 - * @version 2.0.0 - * - * @param bool $should_autocomplete Whether the descendant is completed. - * @param int $descendant_id The descendant's id. - */ - $node = true === apply_filters( 'dws_lowc/autocompletion/should_autocomplete', $should_autocomplete, $descendant_id ) ? dws_lowc_get_order_node( $descendant_id ) : null; - if ( \is_null( $node ) ) { - continue; - } + $tree = dws_lowc_get_orders_tree( $order_id ) ?? array(); + $pass = \array_diff_key( \array_fill_keys( $tree, true ), $this->covered ); + try { + $this->covered += $pass; + foreach ( $tree as $descendant_id ) { + /** + * Filters whether a descendant of a completed order is completed too. + * + * @since 1.0.0 + * @version 2.0.0 + * @deprecated 2.0.0 Use dws_lowc/autocompletion/should_autocomplete. + * + * @param bool $should_autocomplete Whether the descendant is completed. + * @param int $descendant_id The descendant's id. + */ + $should_autocomplete = apply_filters_deprecated( 'linked_orders_for_woocommerce/autocompletion/should_autocomplete', array( true, $descendant_id ), '2.0.0', 'dws_lowc/autocompletion/should_autocomplete' ); - /** - * Filters the status a descendant of a completed order is set to. - * - * @since 1.0.0 - * @version 2.0.0 - * @deprecated 2.0.0 Use dws_lowc/autocompletion/completed_status. - * - * @param string $status The status, without its 'wc-' prefix. - * @param \WC_Order $descendant The descendant. - */ - $status = apply_filters_deprecated( 'linked_orders_for_woocommerce/autocompletion/completed_status', array( 'completed', $node->get_order() ), '2.0.0', 'dws_lowc/autocompletion/completed_status' ); - - /** - * Filters the status a descendant of a completed order is set to. - * - * @since 2.0.0 - * @version 2.0.0 - * - * @param string $status The status, without its 'wc-' prefix. - * @param \WC_Order $descendant The descendant. - */ - $status = apply_filters( 'dws_lowc/autocompletion/completed_status', $status, $node->get_order() ); - - /* translators: %s: The order type's singular name. */ - $node->get_order()->update_status( $status, \sprintf( __( 'Child %s autocompleted.', 'linked-orders-for-woocommerce' ), dws_lowc_get_singular_name( $node->get_post_type() ) ) ); + /** + * Filters whether a descendant of a completed order is completed too. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param bool $should_autocomplete Whether the descendant is completed. + * @param int $descendant_id The descendant's id. + */ + $node = true === apply_filters( 'dws_lowc/autocompletion/should_autocomplete', $should_autocomplete, $descendant_id ) ? dws_lowc_get_order_node( $descendant_id ) : null; + if ( \is_null( $node ) ) { + continue; + } + + /** + * Filters the status a descendant of a completed order is set to. + * + * @since 1.0.0 + * @version 2.0.0 + * @deprecated 2.0.0 Use dws_lowc/autocompletion/completed_status. + * + * @param string $status The status, without its 'wc-' prefix. + * @param \WC_Order $descendant The descendant. + */ + $status = apply_filters_deprecated( 'linked_orders_for_woocommerce/autocompletion/completed_status', array( 'completed', $node->get_order() ), '2.0.0', 'dws_lowc/autocompletion/completed_status' ); + + /** + * Filters the status a descendant of a completed order is set to. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param string $status The status, without its 'wc-' prefix. + * @param \WC_Order $descendant The descendant. + */ + $status = apply_filters( 'dws_lowc/autocompletion/completed_status', $status, $node->get_order() ); + + /* translators: %s: The order type's singular name. */ + $node->get_order()->update_status( $status, \sprintf( __( 'Child %s autocompleted.', 'linked-orders-for-woocommerce' ), dws_lowc_get_singular_name( $node->get_post_type() ) ) ); + } + } finally { + $this->covered = \array_diff_key( $this->covered, $pass ); } } diff --git a/plugins/linked-orders-for-woocommerce/src/ListTable.php b/plugins/linked-orders-for-woocommerce/src/ListTable.php index 2fc7890..ce7a06e 100644 --- a/plugins/linked-orders-for-woocommerce/src/ListTable.php +++ b/plugins/linked-orders-for-woocommerce/src/ListTable.php @@ -278,7 +278,9 @@ public function add_row_actions( array $actions, \WC_Order $order ): array { ); } - if ( '' === self::get_query_arg( '_dws_linked_order_id' ) && ( $node->has_parent() || $node->has_children() ) ) { + // A list page builds every row, so the stored links decide, and a tree whose related order was deleted shows as one order. + $children = $order->get_meta( '_dws_lo_children' ); + if ( '' === self::get_query_arg( '_dws_linked_order_id' ) && ( 0 < $node->get_depth() || ( \is_array( $children ) && array() !== $children ) ) ) { $added['view_all_linked_orders'] = array( 'url' => add_query_arg( '_dws_linked_order_id', $order->get_id(), $list ), /* translators: %s: The order type's plural name. */ From 466203dd1ada7ec11533e3ec55b9633c370cf049 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:03:01 +0100 Subject: [PATCH 16/50] Test that new-tab links announce it and the depth filter is named The meta box's order links and the orders list's customer and tree actions open a new tab without saying so, and the list's depth filter has no accessible name (A296, A466, X210, X376, X133). The meta box row expects core's "(opens in a new tab)" in a screen-reader span inside each link; the list rows expect it after the name in each new-tab action's title, which WooCommerce prints as the button's aria-label, and a screen-reader label for the depth filter named after the order type. The end-to-end spec runs axe over the meta box, the orders list's top navigation and the settings form. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/EndToEnd/linked-orders.spec.ts | 13 +++++++++++++ .../tests/Integration/ListTableTest.php | 6 ++++++ .../tests/Integration/MetaBoxTest.php | 6 +++++- 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/plugins/linked-orders-for-woocommerce/tests/EndToEnd/linked-orders.spec.ts b/plugins/linked-orders-for-woocommerce/tests/EndToEnd/linked-orders.spec.ts index b9fe6a6..a0db786 100644 --- a/plugins/linked-orders-for-woocommerce/tests/EndToEnd/linked-orders.spec.ts +++ b/plugins/linked-orders-for-woocommerce/tests/EndToEnd/linked-orders.spec.ts @@ -1,3 +1,4 @@ +import AxeBuilder from '@axe-core/playwright'; import { expect, test } from '@wordpress/e2e-test-utils-playwright'; // The tests environment stores orders in WooCommerce's order tables; the Integration suite covers the posts storage. @@ -41,6 +42,10 @@ test.describe( 'Linked orders', () => { await expect( page.locator( '#dws-linked-orders .dws-linked-orders__parent' ) ).toContainText( `Order #${ parent.id }` ); + const boxResults = await new AxeBuilder( { page } ) + .include( '#dws-linked-orders' ) + .analyze(); + expect( boxResults.violations ).toEqual( [] ); await page.goto( '/wp-admin/admin.php?page=wc-orders' ); await expect( page.locator( 'thead #dws_lo_depth' ) ).toHaveText( @@ -52,6 +57,10 @@ test.describe( 'Linked orders', () => { await expect( page.locator( `#order-${ parent.id } td.column-dws_lo_depth` ) ).toHaveText( 'Root Order' ); + const listResults = await new AxeBuilder( { page } ) + .include( '.tablenav.top' ) + .analyze(); + expect( listResults.violations ).toEqual( [] ); } ); test( 'the plugin row opens the section on the Advanced tab', async ( { @@ -69,5 +78,9 @@ test.describe( 'Linked orders', () => { await expect( page.getByLabel( 'Maximum linked orders depth' ) ).toHaveValue( '1' ); + const results = await new AxeBuilder( { page } ) + .include( '#mainform' ) + .analyze(); + expect( results.violations ).toEqual( [] ); } ); } ); diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/ListTableTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/ListTableTest.php index 411a077..26dddba 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/ListTableTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/ListTableTest.php @@ -108,6 +108,9 @@ public function test_the_depth_filter_lists_every_level_up_to_the_deepest_stored $filters = \Dom\HTMLDocument::createFromString( '' . $this->render_filters() . '', \LIBXML_NOERROR ); + $label = $filters->querySelector( 'label[for="dws_lo_depth_filter_dropdown"]' ); + self::assertSame( 'Order depth', \trim( (string) $label?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. + self::assertContains( 'screen-reader-text', \explode( ' ', (string) $label?->getAttribute( 'class' ) ) ); $options = \iterator_to_array( $filters->querySelectorAll( 'select[name="_dws_lo_depth"] option' ) ); self::assertSame( array( '', '0', '1', '2', '3' ), \array_map( static fn ( \Dom\Element $option ): string => $option->getAttribute( 'value' ) ?? '', $options ) ); self::assertSame( array( 'All Order depths', 'Root Orders', 'Child Orders (level 2)', 'Child Orders (level 3)', 'Child Orders (level 4)' ), \array_map( static fn ( \Dom\Element $option ): string => \trim( $option->textContent ), $options ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. @@ -246,8 +249,11 @@ public function test_an_order_row_offers_its_customers_orders_its_tree_and_a_new self::assertSame( array( 'view_all_customer_orders', 'view_all_linked_orders', 'create_empty_linked_child', 'complete' ), \array_keys( $actions ) ); self::assertSame( add_query_arg( '_customer_user', 1, $list ), $actions['view_all_customer_orders']['url'] ); self::assertSame( 'View all customer Orders', $actions['view_all_customer_orders']['name'] ); + self::assertSame( 'View all customer Orders (opens in a new tab)', $actions['view_all_customer_orders']['title'] ?? null ); self::assertSame( add_query_arg( '_dws_linked_order_id', $root->get_id(), $list ), $actions['view_all_linked_orders']['url'] ); self::assertSame( 'View all linked Orders', $actions['view_all_linked_orders']['name'] ); + self::assertSame( 'View all linked Orders (opens in a new tab)', $actions['view_all_linked_orders']['title'] ?? null ); + self::assertArrayNotHasKey( 'title', $actions['create_empty_linked_child'] ); self::assertSame( 'Create empty linked Order', $actions['create_empty_linked_child']['name'] ); self::assertSame( 'create-empty-linked-child', $actions['create_empty_linked_child']['action'] ); } diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/MetaBoxTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/MetaBoxTest.php index e79f1b4..0a7f472 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/MetaBoxTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/MetaBoxTest.php @@ -98,9 +98,13 @@ public function test_the_box_shows_the_parent_the_children_and_the_button_to_add $parent = $box->querySelector( '.dws-linked-orders__parent a' ); self::assertSame( wc_get_order( $root->get_id() )->get_edit_order_url(), $parent?->getAttribute( 'href' ) ); - self::assertSame( 'Order #' . $root->get_order_number() . ' - Pending payment', \trim( (string) $parent?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. + self::assertSame( 'Order #' . $root->get_order_number() . ' - Pending payment (opens in a new tab)', \preg_replace( '/\s+/', ' ', \trim( (string) $parent?->textContent ) ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. $link = $box->querySelector( '#linked-order-' . $child->get_id() . ' a' ); self::assertSame( wc_get_order( $child->get_id() )->get_edit_order_url(), $link?->getAttribute( 'href' ) ); + foreach ( array( $parent, $link ) as $opens_tab ) { + self::assertSame( '_blank', $opens_tab?->getAttribute( 'target' ) ); + self::assertSame( '(opens in a new tab)', \trim( (string) $opens_tab?->querySelector( '.screen-reader-text' )?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. + } $button = $box->querySelector( 'a.button' ); self::assertSame( 'Add new child Order', \trim( (string) $button?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. \parse_str( (string) wp_parse_url( (string) $button?->getAttribute( 'href' ), \PHP_URL_QUERY ), $query ); From dbdc43740da10ea647d371a42bc6045ac8345111 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:03:58 +0100 Subject: [PATCH 17/50] Announce new-tab links and name the depth filter The meta box's order links and the orders list's customer and tree actions open a new tab without saying so, and the depth filter's select has no accessible name, a WCAG 4.1.2 failure v1 shipped too (A296, A466, X210, X376, X133). Each meta box link carries core's "(opens in a new tab)" in a screen-reader span. WooCommerce escapes a row action's name and prints its title as the aria-label, so the two new-tab actions pass a title of the name and the cue, which also shows as their tooltip. A screen-reader label named after the order type precedes the filter, as core labels its date filter. Assisted-by: Claude Code:claude-opus-5-5 --- .../changelog/accessibility | 4 ++++ .../src/ListTable.php | 15 +++++++++++---- .../linked-orders-for-woocommerce/src/MetaBox.php | 3 +++ 3 files changed, 18 insertions(+), 4 deletions(-) create mode 100644 plugins/linked-orders-for-woocommerce/changelog/accessibility diff --git a/plugins/linked-orders-for-woocommerce/changelog/accessibility b/plugins/linked-orders-for-woocommerce/changelog/accessibility new file mode 100644 index 0000000..cb6b1b7 --- /dev/null +++ b/plugins/linked-orders-for-woocommerce/changelog/accessibility @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Screen readers hear that the order links and the list actions that open a new tab do so, and the orders list names its depth filter. diff --git a/plugins/linked-orders-for-woocommerce/src/ListTable.php b/plugins/linked-orders-for-woocommerce/src/ListTable.php index ce7a06e..9f7c4ef 100644 --- a/plugins/linked-orders-for-woocommerce/src/ListTable.php +++ b/plugins/linked-orders-for-woocommerce/src/ListTable.php @@ -164,6 +164,8 @@ public function render_filters( string $type ): void { $selected = self::get_depth_filter(); $name = dws_lowc_get_singular_name( $post_type ); ?> + + get_order_number() : $pivot ); + } + + foreach ( $lines as $key => $line ) { + ?> + + + + + get_post_type(); - $list = OrderUtil::custom_orders_table_usage_is_enabled() ? admin_url( 'admin.php?page=wc-orders' . ( 'shop_order' === $type->name ? '' : '--' . $type->name ) ) : admin_url( 'edit.php?post_type=' . $type->name ); + $list = dws_lowc_get_list_url( $node->get_post_type() ); $customers = 0 !== $order->get_customer_id() ? array( '_customer_user', (string) $order->get_customer_id() ) : array( '_guest_customer_user', \rawurlencode( $order->get_billing_email() ) ); if ( '' !== $customers[1] && '' === self::get_query_arg( $customers[0] ) ) { - /* translators: %s: The order type's plural name. */ - $name = \sprintf( __( 'View all customer %s', 'linked-orders-for-woocommerce' ), $type->label ); + $name = __( 'View all customer orders', 'linked-orders-for-woocommerce' ); $added['view_all_customer_orders'] = array( 'url' => add_query_arg( $customers[0], $customers[1], $list ), 'name' => $name, @@ -286,8 +303,7 @@ public function add_row_actions( array $actions, \WC_Order $order ): array { // A list page builds every row, so the stored links decide, and a tree whose related order was deleted shows as one order. $children = $order->get_meta( '_dws_lo_children' ); if ( '' === self::get_query_arg( '_dws_linked_order_id' ) && ( 0 < $node->get_depth() || ( \is_array( $children ) && array() !== $children ) ) ) { - /* translators: %s: The order type's plural name. */ - $name = \sprintf( __( 'View all linked %s', 'linked-orders-for-woocommerce' ), $type->label ); + $name = __( 'View all linked orders', 'linked-orders-for-woocommerce' ); $added['view_all_linked_orders'] = array( 'url' => add_query_arg( '_dws_linked_order_id', $order->get_id(), $list ), 'name' => $name, @@ -299,8 +315,7 @@ public function add_row_actions( array $actions, \WC_Order $order ): array { if ( $node->can_create_child() ) { $added['create_empty_linked_child'] = array( 'url' => dws_lowc_get_create_child_url( $order->get_id() ), - /* translators: %s: The order type's singular name. */ - 'name' => \sprintf( __( 'Create empty linked %s', 'linked-orders-for-woocommerce' ), dws_lowc_get_singular_name( $type ) ), + 'name' => __( 'Create empty linked order', 'linked-orders-for-woocommerce' ), 'action' => 'create-empty-linked-child', ); } @@ -462,8 +477,8 @@ protected static function add_meta_clauses( mixed $meta_query, array $clauses ): * @return list|null */ protected static function get_tree_filter( mixed $found ): ?array { - $pivot = \filter_var( self::get_query_arg( '_dws_linked_order_id' ), \FILTER_VALIDATE_INT, array( 'options' => array( 'min_range' => 1 ) ) ); - if ( false === $pivot ) { + $pivot = self::get_tree_pivot(); + if ( \is_null( $pivot ) ) { return null; } @@ -478,6 +493,19 @@ protected static function get_tree_filter( mixed $found ): ?array { return array() === $tree ? array( 0 ) : \array_values( $tree ); } + /** + * Returns the id of the order whose tree the list is filtered by, or null when it is not filtered by a tree. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @return int|null + */ + protected static function get_tree_pivot(): ?int { + $pivot = \filter_var( self::get_query_arg( '_dws_linked_order_id' ), \FILTER_VALIDATE_INT, array( 'options' => array( 'min_range' => 1 ) ) ); + return false === $pivot ? null : $pivot; + } + /** * Returns a list filter from the request, sanitized, or an empty string when it is absent. * diff --git a/plugins/linked-orders-for-woocommerce/src/MetaBox.php b/plugins/linked-orders-for-woocommerce/src/MetaBox.php index 0fb505f..db9de45 100644 --- a/plugins/linked-orders-for-woocommerce/src/MetaBox.php +++ b/plugins/linked-orders-for-woocommerce/src/MetaBox.php @@ -69,12 +69,11 @@ public function render_meta_box( mixed $post_or_order ): void { $parent = $node->get_parent(); $can_create = $node->can_create_child(); - $type_name = dws_lowc_get_singular_name( $node->get_post_type() ); $new_tab = __( '(opens in a new tab)' ); // phpcs:ignore WordPress.WP.I18n.MissingArgDomain -- Core's translation of the cue. if ( ! \is_null( $parent ) ) { ?>
- : + get_formatted_name() ); ?> @@ -89,15 +88,14 @@ public function render_meta_box( mixed $post_or_order ): void {
- - +
- : +
    get_children() as $child ) : ?>
  • @@ -112,14 +110,24 @@ public function render_meta_box( mixed $post_or_order ): void { has_children() ) { + ?> +

    + + + + +

    + - - + 'title', 'id' => "dws-lowc_$group", - 'title' => 'general' === $group ? __( 'General Settings', 'linked-orders-for-woocommerce' ) : __( 'Plugin Settings', 'linked-orders-for-woocommerce' ), + 'title' => 'general' === $group ? __( 'General settings', 'linked-orders-for-woocommerce' ) : __( 'Plugin settings', 'linked-orders-for-woocommerce' ), ), ), $wc_fields, @@ -193,7 +193,7 @@ protected function get_group_fields( string $group ): array { 'step' => '1', ), 'default' => (string) DWS_LOWC_OPTION_DEFAULTS[ DWS_LOWC_OPTION_MAX_DEPTH ], - 'desc_tip' => __( 'The maximum number of linked orders levels. Root orders are considered level 0.', 'linked-orders-for-woocommerce' ), + 'desc' => __( 'How many levels of children an order can have below it, such as 2 for children and grandchildren.', 'linked-orders-for-woocommerce' ), 'validate' => 'dws_lowc_validate_max_depth', ), 'autocomplete-descendants' => array( @@ -209,7 +209,7 @@ protected function get_group_fields( string $group ): array { 'remove-data-uninstall' => array( 'title' => __( 'Remove all data on uninstallation', 'linked-orders-for-woocommerce' ), 'type' => 'checkbox', - 'desc' => __( 'Delete the plugin\'s data when it is uninstalled, so it must be configured again if it is installed later', 'linked-orders-for-woocommerce' ), + 'desc' => __( 'Delete the plugin\'s settings and the links between orders when the plugin is deleted. This cannot be undone.', 'linked-orders-for-woocommerce' ), 'default' => wc_bool_to_string( DWS_LOWC_OPTION_DEFAULTS[ DWS_LOWC_OPTION_DELETE_DATA ] ), ), ); From a7e2d9081c65d28c5451f8ccb273e3e37f916c61 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:16:14 +0100 Subject: [PATCH 20/50] Test the v1 behaviors the port changed without a ruling The port read DWS_LOWC_DEBUG only as a strict true, renamed the actions WooCommerce fires around the two settings groups, dropped the section's value filter for an id no group maps, numbered a linked child after every v1 child-created listener, and joined v1 hook names without v1's byte stripping (A281, A283-A286, X205). The rows define the constant as 'yes' and 1 in a fresh request, count the group actions under v1's _start and _end ids, read two unmapped ids through the section filter, give a v1 listener at priority 20 the number to replace, and add control and high bytes to the hook-name parity rows. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/Integration/DeprecatedApiTest.php | 2 + .../SequentialOrderNumbersProTest.php | 33 +++++++++++++++ .../tests/Integration/PluginBootTest.php | 18 ++++++++ .../tests/Integration/SettingsApiTest.php | 42 +++++++++++++++++++ .../tests/Integration/SettingsTest.php | 10 +++++ 5 files changed, 105 insertions(+) diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/DeprecatedApiTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/DeprecatedApiTest.php index 2356ded..d3b8ea4 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/DeprecatedApiTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/DeprecatedApiTest.php @@ -17,6 +17,8 @@ public static function hook_tags(): array { 'plugin' => array( 'dws_lowc_get_hook_tag', array( 'post_type', 'supported_order_types' ), 'linked_orders_for_woocommerce/post_type/supported_order_types' ), 'plugin-no-extra' => array( 'dws_lowc_get_hook_tag', array( 'created_linked_order' ), 'linked_orders_for_woocommerce/created_linked_order' ), 'plugin-unsafe' => array( 'dws_lowc_get_hook_tag', array( 'Some Name', array( 'A\\B', '' ) ), 'linked_orders_for_woocommerce/some_name/a_b' ), + 'plugin-bytes' => array( 'dws_lowc_get_hook_tag', array( "Caf\xC3\xA9\tName", array( "\x00Extra\x80" ) ), 'linked_orders_for_woocommerce/cafname/extra' ), + 'component-bytes' => array( 'dws_lowc_get_component_hook_tag', array( 'general-settings', "Na\x1Bme\xFF" ), 'linked_orders_for_woocommerce/general_settings/name' ), 'component' => array( 'dws_lowc_get_component_hook_tag', array( 'list-table-output', 'column', 'insert_after' ), 'linked_orders_for_woocommerce/list_table_output/column/insert_after' ), 'component-meta' => array( 'dws_lowc_get_component_hook_tag', array( 'metabox-output', 'after_add_new_child_button' ), 'linked_orders_for_woocommerce/metabox_output/after_add_new_child_button' ), 'component-group' => array( 'dws_lowc_get_component_hook_tag', array( 'general-settings', 'get_group_fields' ), 'linked_orders_for_woocommerce/general_settings/get_group_fields' ), diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/Integration/SequentialOrderNumbersProTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/Integration/SequentialOrderNumbersProTest.php index cb71e22..6de737d 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/Integration/SequentialOrderNumbersProTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/Integration/SequentialOrderNumbersProTest.php @@ -31,4 +31,37 @@ public function test_a_linked_child_is_numbered_before_it_is_saved(): void { self::assertSame( array( $id ), \array_map( static fn ( \WC_Order $order ): int => $order->get_id(), wc_seq_order_number_pro()->numbered ) ); self::assertSame( 'SOS-' . $id, wc_get_order( $id )?->get_meta( '_order_number' ) ); } + + // v1 numbered a child on its own child-created action at priority 10, so a v1 listener after that saw the number and could replace it. + #[RunInSeparateProcess] + public function test_a_v1_listener_after_priority_10_sees_the_number_and_can_replace_it(): void { + require_once \dirname( __DIR__, 2 ) . '/Fixtures/sequential-order-numbers-pro.php'; + new SequentialOrderNumbersPro()->register_hooks(); + wp_set_current_user( 1 ); + add_filter( 'deprecated_hook_trigger_error', '__return_false' ); + $seen = array(); + add_action( + 'linked_orders_for_woocommerce/created_linked_order', + static function ( \WC_Order $child ) use ( &$seen ): void { + $seen[] = array( 5, $child->get_meta( '_order_number' ) ); + }, + 5 + ); + add_action( + 'linked_orders_for_woocommerce/created_linked_order', + static function ( \WC_Order $child ) use ( &$seen ): void { + $seen[] = array( 20, $child->get_meta( '_order_number' ) ); + $child->update_meta_data( '_order_number', 'CUSTOM' ); + }, + 20 + ); + + $id = dws_lowc_create_linked_child( $this->create_order()->get_id() ); + + self::assertIsInt( $id ); + $this->orders[] = $id; + self::assertSame( array( array( 5, '' ), array( 20, 'SOS-' . $id ) ), $seen ); + self::assertSame( 'CUSTOM', wc_get_order( $id )?->get_meta( '_order_number' ) ); + self::assertCount( 1, wc_seq_order_number_pro()->numbered ); + } } diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/PluginBootTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/PluginBootTest.php index 8134734..ba56580 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/PluginBootTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/PluginBootTest.php @@ -7,12 +7,30 @@ use DeepWebSolutions\LinkedOrders\Scoped\DeepWebSolutions\Framework\Core\Migration\MigrationRunner; use DeepWebSolutions\LinkedOrders\Tests\Support\RunsRequestsTrait; use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; #[CoversNothing] final class PluginBootTest extends TestCase { use RunsRequestsTrait; + public static function debug_definitions(): array { + return array( + 'true' => array( 'true', 100 ), + 'yes' => array( "'yes'", 100 ), + 'one' => array( '1', 100 ), + 'no' => array( "'no'", null ), + ); + } + + // v1 read the debug constant as a boolean, so a site that defines it as 'yes' or 1 still logs debug records. + #[DataProvider( 'debug_definitions' )] + public function test_the_debug_constant_reads_as_a_boolean_as_v1_read_it( string $definition, ?int $threshold ): void { + $request = self::request( '$plugin = dws_lowc_plugin(); $logger = new ReflectionProperty( $plugin, "logger" )->getValue( $plugin ); echo wp_json_encode( new ReflectionProperty( $logger, "threshold" )->getValue( $logger ) );', "--exec=define( 'DWS_LOWC_DEBUG', {$definition} );" ); + + self::assertSame( wp_json_encode( $threshold ), $request['last'] ); + } + public function test_the_plugin_boots_every_component_but_the_integration_with_an_absent_plugin(): void { $plugin = dws_lowc_plugin(); diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/SettingsApiTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/SettingsApiTest.php index 3663bf2..3b9ef86 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/SettingsApiTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/SettingsApiTest.php @@ -134,4 +134,46 @@ public function test_a_validated_value_passes_the_v1_filters_of_its_path_then_th self::assertSame( $expected, $seen ); self::assertSame( \array_map( static fn ( string $hook ): array => array( $hook, 'dws_lowc/settings/validated_value' ), $deprecated ), $this->deprecated ); } + + // v1 read every id through the section's filter, so an extension answered for an id no group maps. + public function test_a_section_read_of_an_id_no_group_maps_passes_the_v1_section_filter(): void { + $seen = array(); + $section = static function ( mixed $value, string $field_id ) use ( &$seen ): mixed { + $seen[] = array( $value, $field_id ); + return "extension:{$field_id}"; + }; + add_filter( 'linked_orders_for_woocommerce/settings/get_validated_option_value', $section, 10, 2 ); + + try { + $values = array( dws_lowc_get_validated_setting( 'extension-field' ), dws_lowc_get_validated_setting( 'other/field' ), dws_lowc_get_validated_setting( 'field', 'other' ) ); + } finally { + remove_filter( 'linked_orders_for_woocommerce/settings/get_validated_option_value', $section ); + } + + self::assertSame( array( 'extension:extension-field', 'extension:other/field', null ), $values ); + self::assertSame( array( array( null, 'extension-field' ), array( null, 'other/field' ) ), $seen ); + self::assertSame( \array_fill( 0, 2, array( 'linked_orders_for_woocommerce/settings/get_validated_option_value', 'dws_lowc/settings/validated_value' ) ), $this->deprecated ); + } + + // The v1 filter's deprecation names the v2 filter, so an extension that moves its callback there still answers. + public function test_a_section_read_of_an_id_no_group_maps_passes_the_v2_filter_after_the_v1_one(): void { + $seen = array(); + $v1 = static fn ( mixed $value, string $field_id ): string => "v1:{$field_id}"; + $v2 = static function ( mixed $value, string $field_id, string $group ) use ( &$seen ): string { + $seen[] = array( $value, $field_id, $group ); + return "v2:{$field_id}"; + }; + add_filter( 'linked_orders_for_woocommerce/settings/get_validated_option_value', $v1, 10, 2 ); + add_filter( 'dws_lowc/settings/validated_value', $v2, 10, 3 ); + + try { + $values = array( dws_lowc_get_validated_setting( 'extension-field' ), dws_lowc_get_validated_setting( 'other/field' ), dws_lowc_get_validated_setting( 'other-settings/field' ), dws_lowc_get_validated_setting( 'field', 'other' ) ); + } finally { + remove_filter( 'linked_orders_for_woocommerce/settings/get_validated_option_value', $v1 ); + remove_filter( 'dws_lowc/settings/validated_value', $v2 ); + } + + self::assertSame( array( 'v2:extension-field', 'v2:field', 'v2:field', null ), $values ); + self::assertSame( array( array( 'v1:extension-field', 'extension-field', '' ), array( 'v1:other/field', 'field', 'other' ), array( 'v1:other-settings/field', 'field', 'other' ) ), $seen ); + } } diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/SettingsTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/SettingsTest.php index 63aa39a..ae0a476 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/SettingsTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/SettingsTest.php @@ -83,6 +83,16 @@ public function test_the_maximum_depth_and_the_consent_explain_themselves_beside self::assertStringContainsString( 'Delete the plugin\'s settings and the links between orders when the plugin is deleted. This cannot be undone.', (string) $form->getElementById( DWS_LOWC_OPTION_DELETE_DATA )?->parentElement?->textContent ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. } + // WooCommerce fires an action named after each group's title and end ids, which v1's listeners hook. + public function test_the_groups_keep_v1s_ids_for_the_actions_woocommerce_fires_around_them(): void { + $hooks = array( 'woocommerce_settings_dws-lowc_general_start', 'woocommerce_settings_dws-lowc_general_end_end', 'woocommerce_settings_dws-lowc_plugin_start', 'woocommerce_settings_dws-lowc_plugin_end_end' ); + $before = \array_map( 'did_action', $hooks ); + + $this->render(); + + self::assertSame( array( 1, 1, 1, 1 ), \array_map( static fn ( string $hook, int $count ): int => did_action( $hook ) - $count, $hooks, $before ) ); + } + public function test_the_checkboxes_store_yes_and_no_as_v1s_selects_did(): void { $this->save( array( From 700aa0276c205d292b0d35668605dfa832b288ef Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:17:50 +0100 Subject: [PATCH 21/50] Restore the v1 behaviors the port changed without a ruling DWS_LOWC_DEBUG needed a strict true, the settings groups lost the ids whose actions v1 listeners hook, a section read of an unmapped id skipped the section filter, Sequential Order Numbers Pro numbered a child after every v1 child-created listener, and v1 hook names kept control and high bytes (A281, A283-A286, X205). The constant reads through FILTER_VALIDATE_BOOL; the groups' title and end ids are dws-lowc_{group}_start and _end; a read without a group passes the section filter for an id no group maps; numbering joins v1's action at the default priority once WordPress reports that action running, and leaves it after the v1 pass, since a callback removing its own priority makes WordPress skip the next; the hook-name joiner strips bytes below 32 and above 127 again. A changelog line records that the integration no longer checks v1's 1.18 floor (A282). Assisted-by: Claude Code:claude-opus-5-5 --- .../sequential-order-numbers-version | 4 ++ .../includes/deprecated.php | 4 +- .../includes/settings.php | 63 +++++++++-------- .../Integration/SequentialOrderNumbersPro.php | 69 ++++++++++++++++++- .../src/Plugin.php | 2 +- .../src/Settings.php | 4 +- 6 files changed, 108 insertions(+), 38 deletions(-) create mode 100644 plugins/linked-orders-for-woocommerce/changelog/sequential-order-numbers-version diff --git a/plugins/linked-orders-for-woocommerce/changelog/sequential-order-numbers-version b/plugins/linked-orders-for-woocommerce/changelog/sequential-order-numbers-version new file mode 100644 index 0000000..d3a67ab --- /dev/null +++ b/plugins/linked-orders-for-woocommerce/changelog/sequential-order-numbers-version @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +The Sequential Order Numbers Pro integration loads beside any version of that plugin that defines wc_seq_order_number_pro(), rather than checking for 1.18 or later. diff --git a/plugins/linked-orders-for-woocommerce/includes/deprecated.php b/plugins/linked-orders-for-woocommerce/includes/deprecated.php index d128ba7..ca9558d 100644 --- a/plugins/linked-orders-for-woocommerce/includes/deprecated.php +++ b/plugins/linked-orders-for-woocommerce/includes/deprecated.php @@ -215,7 +215,7 @@ function dws_lowc_get_component_hook_tag( string $component_id, string $name, $e } /** - * Returns a hook name joined and sanitized as v1 did: the non-empty parts joined with slashes, spaces and backslashes as underscores, lowercased. + * Returns a hook name joined and sanitized as v1 did: the non-empty parts joined with slashes, spaces and backslashes as underscores, bytes below 32 and above 127 stripped, lowercased. * * @since 2.0.0 * @version 2.0.0 @@ -227,7 +227,7 @@ function dws_lowc_get_component_hook_tag( string $component_id, string $name, $e * @return string */ function dws_lowc_join_v1_hook_tag( string $root, string $name, $extra ): string { - return strtolower( str_replace( array( ' ', '\\' ), '_', implode( '/', array_filter( array_merge( array( $root, $name ), is_array( $extra ) ? $extra : array( $extra ) ), 'boolval' ) ) ) ); + return strtolower( filter_var( str_replace( array( ' ', '\\' ), '_', implode( '/', array_filter( array_merge( array( $root, $name ), is_array( $extra ) ? $extra : array( $extra ) ), 'boolval' ) ) ), FILTER_UNSAFE_RAW, FILTER_FLAG_STRIP_LOW | FILTER_FLAG_STRIP_HIGH | FILTER_NULL_ON_FAILURE ) ?? '' ); } /** diff --git a/plugins/linked-orders-for-woocommerce/includes/settings.php b/plugins/linked-orders-for-woocommerce/includes/settings.php index 4b83283..b7cc09a 100644 --- a/plugins/linked-orders-for-woocommerce/includes/settings.php +++ b/plugins/linked-orders-for-woocommerce/includes/settings.php @@ -34,13 +34,15 @@ * @return mixed */ function dws_lowc_get_raw_setting( string $field_id, ?string $group = null ) { - $field = dws_lowc_find_setting( $field_id, $group ); - return is_null( $field ) ? null : get_option( $field[2], null ); + $key = dws_lowc_find_setting( $field_id, $group )[2]; + return is_null( $key ) ? null : get_option( $key, null ); } /** * Returns the validated value of a setting, filtered, or null for a group v1 did not have. * + * As in v1, a read without a group is filtered even for an id no group maps, so an extension's callback can answer for it. + * * @api * * @since 1.0.0 @@ -52,28 +54,30 @@ function dws_lowc_get_raw_setting( string $field_id, ?string $group = null ) { * @return mixed */ function dws_lowc_get_validated_setting( string $field_id, ?string $group = null ) { - $field = dws_lowc_find_setting( $field_id, $group ); - if ( is_null( $field ) ) { + list( $group_id, $id, $key ) = dws_lowc_find_setting( $field_id, $group ); + if ( ! is_null( $key ) ) { + $value = dws_lowc_validate_setting( get_option( $key, null ), $id, $group_id ); + + /** + * Filters a setting's validated value. + * + * @since 1.0.0 + * @version 2.0.0 + * @deprecated 2.0.0 Use dws_lowc/settings/validated_value. + * + * @param mixed $value The validated value. + * @param string $field_id The field's v1 id. + */ + $value = apply_filters_deprecated( "linked_orders_for_woocommerce/{$group_id}_settings/get_validated_option_value", array( $value, $id ), '2.0.0', 'dws_lowc/settings/validated_value' ); + } elseif ( is_null( $group ) ) { + $value = null; + } else { return null; } - list( $group_id, $id, $key ) = $field; - $value = dws_lowc_validate_setting( get_option( $key, null ), $id, $group_id ); - - /** - * Filters a setting's validated value. - * - * @since 1.0.0 - * @version 2.0.0 - * @deprecated 2.0.0 Use dws_lowc/settings/validated_value. - * - * @param mixed $value The validated value. - * @param string $field_id The field's v1 id. - */ - $value = apply_filters_deprecated( "linked_orders_for_woocommerce/{$group_id}_settings/get_validated_option_value", array( $value, $id ), '2.0.0', 'dws_lowc/settings/validated_value' ); if ( is_null( $group ) ) { /** - * Filters the validated value of a setting read through the section, after its group's filter. + * Filters the validated value of a setting read through the section, after its group's filter, or null for an id no group maps. * * @since 1.0.0 * @version 2.0.0 @@ -93,7 +97,7 @@ function dws_lowc_get_validated_setting( string $field_id, ?string $group = null * * @param mixed $value The validated value. * @param string $field_id The field's v1 id. - * @param string $group The field's v1 group. + * @param string $group The field's v1 group without its '-settings' suffix, empty when the id names none. */ return apply_filters( 'dws_lowc/settings/validated_value', $value, $id, $group_id ); } @@ -113,8 +117,7 @@ function dws_lowc_get_validated_setting( string $field_id, ?string $group = null * @return mixed */ function dws_lowc_validate_setting( $value, string $field_id, ?string $group = null ) { - $field = dws_lowc_find_setting( $field_id, $group ); - switch ( is_null( $field ) ? null : $field[2] ) { + switch ( dws_lowc_find_setting( $field_id, $group )[2] ) { case DWS_LOWC_OPTION_MAX_DEPTH: return max( filter_var( $value, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE ) ?? 1, 1 ); case DWS_LOWC_OPTION_AUTOCOMPLETE: @@ -138,9 +141,9 @@ function dws_lowc_get_max_depth(): int { } /** - * Returns a setting's group, field id and option, or null for a group v1 did not have. + * Returns a setting's group without its '-settings' suffix, its field id and its option, which is null for a group v1 did not have. * - * As in v1, a read without a group names the group in the field id, such as 'general/max-depth', and a field an extension adds to a group lives in the option WooCommerce saves it under, `dws-lowc_{group}_{field}`. + * As in v1, a read without a group names the group in the field id, such as 'general/max-depth', and a field an extension adds to a group lives in the option WooCommerce saves it under, `dws-lowc_{group}_{field}`. An id read without a group that names none has an empty group. * * @since 2.0.0 * @version 2.0.0 @@ -148,20 +151,16 @@ function dws_lowc_get_max_depth(): int { * @param string $field_id The field's v1 id, prefixed with its group and a slash when no group is given. * @param string|null $group The field's v1 group, with or without its '-settings' suffix. * - * @return array{0: string, 1: string, 2: string}|null + * @return array{0: string, 1: string, 2: string|null} */ -function dws_lowc_find_setting( string $field_id, ?string $group ): ?array { +function dws_lowc_find_setting( string $field_id, ?string $group ): array { if ( is_null( $group ) ) { - if ( false === strpos( $field_id, '/' ) ) { - return null; - } - - list( $group, $field_id ) = explode( '/', $field_id, 2 ); + list( $group, $field_id ) = array_pad( explode( '/', $field_id, 2 ), -2, '' ); } if ( '-settings' === substr( $group, -9 ) ) { $group = substr( $group, 0, -9 ); } - return in_array( $group, DWS_LOWC_SETTINGS_GROUPS, true ) ? array( $group, $field_id, "dws-lowc_{$group}_$field_id" ) : null; + return array( $group, $field_id, in_array( $group, DWS_LOWC_SETTINGS_GROUPS, true ) ? "dws-lowc_{$group}_$field_id" : null ); } diff --git a/plugins/linked-orders-for-woocommerce/src/Integration/SequentialOrderNumbersPro.php b/plugins/linked-orders-for-woocommerce/src/Integration/SequentialOrderNumbersPro.php index dea50d2..fe322f4 100644 --- a/plugins/linked-orders-for-woocommerce/src/Integration/SequentialOrderNumbersPro.php +++ b/plugins/linked-orders-for-woocommerce/src/Integration/SequentialOrderNumbersPro.php @@ -14,6 +14,30 @@ * @version 2.0.0 */ final class SequentialOrderNumbersPro implements ComponentInterface, ConditionalComponentInterface { + // region FIELDS AND CONSTANTS + + /** + * The v1 action a linked child fires on before its v2 action, where v1 numbered it at the default priority. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @var string + */ + protected const string V1_CREATED = 'linked_orders_for_woocommerce/created_linked_order'; + + /** + * The children numbered among v1's listeners, keyed by object id, which the v2 action then leaves alone. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @var array + */ + protected array $numbered = array(); + + // endregion + // region INHERITED METHODS /** @@ -35,6 +59,7 @@ public static function should_load(): bool { */ #[\Override] public function register_hooks(): void { + add_action( 'deprecated_hook_run', array( $this, 'join_v1_listeners' ) ); add_action( 'dws_lowc/linked_order/created', array( $this, 'set_order_number' ) ); } @@ -43,7 +68,42 @@ public function register_hooks(): void { // region HOOKS /** - * Gives a linked child its sequential number before it is saved. + * Numbers the next linked child at the default priority of v1's action when that action is about to run its listeners. + * + * WordPress fires a deprecated action only while it has listeners, so numbering joins it only then, and listening to it always would report the action as used. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param string $hook The deprecated hook about to run. + * + * @return void + */ + public function join_v1_listeners( string $hook ): void { + if ( self::V1_CREATED === $hook ) { + add_action( self::V1_CREATED, array( $this, 'set_v1_order_number' ) ); + } + } + + /** + * Gives a linked child its sequential number among v1's listeners. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param \WC_Order $child The linked child. + * + * @return void + */ + public function set_v1_order_number( \WC_Order $child ): void { + $this->numbered[ \spl_object_id( $child ) ] = true; + wc_seq_order_number_pro()->set_sequential_order_number( $child ); + } + + /** + * Gives a linked child its sequential number before it is saved, unless v1's action already did, and then leaves v1's action. + * + * WordPress skips the next priority of an action whose running callback removes its own, so numbering leaves v1's action only once it has ended. * * @since 2.0.0 * @version 2.0.0 @@ -53,6 +113,13 @@ public function register_hooks(): void { * @return void */ public function set_order_number( \WC_Order $child ): void { + $id = \spl_object_id( $child ); + if ( isset( $this->numbered[ $id ] ) ) { + unset( $this->numbered[ $id ] ); + remove_action( self::V1_CREATED, array( $this, 'set_v1_order_number' ) ); + return; + } + wc_seq_order_number_pro()->set_sequential_order_number( $child ); } diff --git a/plugins/linked-orders-for-woocommerce/src/Plugin.php b/plugins/linked-orders-for-woocommerce/src/Plugin.php index 28496df..ac3bd6d 100644 --- a/plugins/linked-orders-for-woocommerce/src/Plugin.php +++ b/plugins/linked-orders-for-woocommerce/src/Plugin.php @@ -127,7 +127,7 @@ final class Plugin { * @version 2.0.0 */ public function __construct() { - $this->logger = new WooCommerceLogger( self::SLUG, \defined( 'DWS_LOWC_DEBUG' ) && true === \constant( 'DWS_LOWC_DEBUG' ) ? 'debug' : null ); + $this->logger = new WooCommerceLogger( self::SLUG, \defined( 'DWS_LOWC_DEBUG' ) && true === \filter_var( \constant( 'DWS_LOWC_DEBUG' ), \FILTER_VALIDATE_BOOL, \FILTER_NULL_ON_FAILURE ) ? 'debug' : null ); $this->scheduler = new Scheduler( self::PREFIX ); $this->boundary = new PluginBoot( DWS_LOWC_FILE, self::PREFIX, $this->logger ); } diff --git a/plugins/linked-orders-for-woocommerce/src/Settings.php b/plugins/linked-orders-for-woocommerce/src/Settings.php index e5aad87..c4bc52c 100644 --- a/plugins/linked-orders-for-woocommerce/src/Settings.php +++ b/plugins/linked-orders-for-woocommerce/src/Settings.php @@ -89,7 +89,7 @@ public function get_section_fields( string $tab_id, string $section_id ): array array( array( 'type' => 'title', - 'id' => "dws-lowc_$group", + 'id' => "dws-lowc_{$group}_start", 'title' => 'general' === $group ? __( 'General settings', 'linked-orders-for-woocommerce' ) : __( 'Plugin settings', 'linked-orders-for-woocommerce' ), ), ), @@ -97,7 +97,7 @@ public function get_section_fields( string $tab_id, string $section_id ): array array( array( 'type' => 'sectionend', - 'id' => "dws-lowc_$group", + 'id' => "dws-lowc_{$group}_end", ), ) ); From 3e22ec73fa25a20861e97de4d9d547796ef4689a Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:18:22 +0100 Subject: [PATCH 22/50] Test that the plugin row keeps v1's link keys and labels v1 added its Settings and Community support links under the settings and support keys, with the aria-labels "View settings" and "Visit community forums"; the port appends them unkeyed and unlabelled, and its changelog calls them new (A273, X204). The row expects the settings link first under its key with its label, and the support link under its key with its label. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/Integration/SettingsTest.php | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/SettingsTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/SettingsTest.php index ae0a476..7569bd0 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/SettingsTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/SettingsTest.php @@ -163,14 +163,19 @@ public function test_a_groups_fields_pass_its_v1_filter_first_then_its_v2_filter self::assertSame( array( array( $v1_hook, $v2_hook ) ), $this->deprecated ); } - public function test_the_plugin_row_links_to_the_section_and_to_the_support_forum(): void { + // v1 keyed its row links and named them for screen readers, and other plugins find them by key. + public function test_the_plugin_row_links_to_the_section_and_to_the_support_forum_under_v1s_keys(): void { $basename = plugin_basename( DWS_LOWC_FILE ); $links = apply_filters( "plugin_action_links_{$basename}", array( 'deactivate' => 'Deactivate' ), $basename, array(), 'all' ); $meta = apply_filters( 'plugin_row_meta', array(), $basename, array(), 'all' ); - self::assertStringContainsString( esc_url( admin_url( 'admin.php?page=wc-settings&tab=advanced§ion=' . self::SECTION ) ), (string) \reset( $links ) ); - self::assertStringContainsString( 'https://wordpress.org/support/plugin/linked-orders-for-woocommerce/', \implode( '', $meta ) ); + self::assertSame( array( 'settings', 'deactivate' ), \array_keys( $links ) ); + self::assertStringContainsString( esc_url( admin_url( 'admin.php?page=wc-settings&tab=advanced§ion=' . self::SECTION ) ), $links['settings'] ); + self::assertStringContainsString( 'aria-label="View settings"', $links['settings'] ); + self::assertSame( array( 'support' ), \array_keys( $meta ) ); + self::assertStringContainsString( 'https://wordpress.org/support/plugin/linked-orders-for-woocommerce/', $meta['support'] ); + self::assertStringContainsString( 'aria-label="Visit community forums"', $meta['support'] ); self::assertSame( array(), apply_filters( 'plugin_row_meta', array(), 'another-plugin/another-plugin.php', array(), 'all' ) ); } From 1badd8b02fa717b9fd89e20c8fd618f9d2da4393 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:18:34 +0100 Subject: [PATCH 23/50] Key and label the plugin row's links as v1 did The port appended the Settings and Community support links unkeyed and without v1's aria-labels, and its changelog called them new (A273, X204). The settings link leads the action links under v1's settings key with the aria-label "View settings", and the support link joins the row's meta under v1's support key with "Visit community forums". The plugin-row-links fragment goes, since v1 had both links. Assisted-by: Claude Code:claude-opus-5-5 --- .../changelog/plugin-row-links | 4 ---- plugins/linked-orders-for-woocommerce/src/Settings.php | 9 ++++----- 2 files changed, 4 insertions(+), 9 deletions(-) delete mode 100644 plugins/linked-orders-for-woocommerce/changelog/plugin-row-links diff --git a/plugins/linked-orders-for-woocommerce/changelog/plugin-row-links b/plugins/linked-orders-for-woocommerce/changelog/plugin-row-links deleted file mode 100644 index 11ef401..0000000 --- a/plugins/linked-orders-for-woocommerce/changelog/plugin-row-links +++ /dev/null @@ -1,4 +0,0 @@ -Significance: minor -Type: added - -Settings and Community support links on the plugin's row of the Plugins screen. diff --git a/plugins/linked-orders-for-woocommerce/src/Settings.php b/plugins/linked-orders-for-woocommerce/src/Settings.php index c4bc52c..0d709d8 100644 --- a/plugins/linked-orders-for-woocommerce/src/Settings.php +++ b/plugins/linked-orders-for-woocommerce/src/Settings.php @@ -134,7 +134,7 @@ public function register_section( SettingsSectionRegistry $registry ): void { } /** - * Adds the link to the plugin's section to its row on the Plugins screen. + * Adds the link to the plugin's section first in its row on the Plugins screen, under v1's settings key. * * @since 2.0.0 * @version 2.0.0 @@ -144,12 +144,11 @@ public function register_section( SettingsSectionRegistry $registry ): void { * @return string[] */ public function add_settings_link( array $links ): array { - \array_unshift( $links, \sprintf( '%s', esc_url( admin_url( 'admin.php?page=wc-settings&tab=advanced§ion=' . self::SECTION ) ), esc_html__( 'Settings', 'linked-orders-for-woocommerce' ) ) ); - return $links; + return array( 'settings' => \sprintf( '%s', esc_url( admin_url( 'admin.php?page=wc-settings&tab=advanced§ion=' . self::SECTION ) ), esc_attr__( 'View settings', 'linked-orders-for-woocommerce' ), esc_html__( 'Settings', 'linked-orders-for-woocommerce' ) ) ) + $links; } /** - * Adds the link to the plugin's support forum to its row on the Plugins screen. + * Adds the link to the plugin's support forum to its row on the Plugins screen, under v1's support key. * * @since 2.0.0 * @version 2.0.0 @@ -161,7 +160,7 @@ public function add_settings_link( array $links ): array { */ public function add_support_link( array $meta, string $basename ): array { if ( plugin_basename( DWS_LOWC_FILE ) === $basename ) { - $meta[] = \sprintf( '%s', esc_url( 'https://wordpress.org/support/plugin/linked-orders-for-woocommerce/' ), esc_html__( 'Community support', 'linked-orders-for-woocommerce' ) ); + $meta['support'] = \sprintf( '%s', esc_url( 'https://wordpress.org/support/plugin/linked-orders-for-woocommerce/' ), esc_attr__( 'Visit community forums', 'linked-orders-for-woocommerce' ), esc_html__( 'Community support', 'linked-orders-for-woocommerce' ) ); } return $meta; From f4bedb4994dc454be773a35a08125b8dfc67ba69 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:19:10 +0100 Subject: [PATCH 24/50] Test that a v1 field's own id names its option The get_group_fields bridge builds each field's id from its group and v1 key and lets that id override the field's own, so a v1 extension that names its option loses it (A343, X216). The row adds a v1 field with its own id and expects that id among the section's fields, not the one built from its key. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/Integration/SettingsTest.php | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/SettingsTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/SettingsTest.php index 7569bd0..8d74300 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/SettingsTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/SettingsTest.php @@ -163,6 +163,27 @@ public function test_a_groups_fields_pass_its_v1_filter_first_then_its_v2_filter self::assertSame( array( array( $v1_hook, $v2_hook ) ), $this->deprecated ); } + // A v1 field keyed by its v1 id may name its own option, which the field's id gives. + public function test_a_v1_fields_own_id_names_its_option(): void { + $extra = static fn ( array $fields ): array => $fields + array( + 'extra' => array( + 'id' => 'dws_lowc_test_extension_option', + 'type' => 'text', + 'title' => 'Extra', + ), + ); + add_filter( 'linked_orders_for_woocommerce/general_settings/get_group_fields', $extra ); + + try { + $ids = \array_column( new Settings()->get_section_fields( 'advanced', self::SECTION ), 'id' ); + } finally { + remove_filter( 'linked_orders_for_woocommerce/general_settings/get_group_fields', $extra ); + } + + self::assertContains( 'dws_lowc_test_extension_option', $ids ); + self::assertNotContains( 'dws-lowc_general_extra', $ids ); + } + // v1 keyed its row links and named them for screen readers, and other plugins find them by key. public function test_the_plugin_row_links_to_the_section_and_to_the_support_forum_under_v1s_keys(): void { $basename = plugin_basename( DWS_LOWC_FILE ); From d62025884043a3dbba9fe762c0b7efbf151c6987 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:19:19 +0100 Subject: [PATCH 25/50] Keep a v1 field's own id in the get_group_fields bridge The bridge put the id it builds from the group and v1 key ahead of the field, so the union dropped an id the field set itself and stored the field under another option (A343, X216). A field's own string id wins, and the built id applies only to a field without one; the hook-names line says the fields stay keyed by their v1 ids and keep an id they set. Assisted-by: Claude Code:claude-opus-5-5 --- plugins/linked-orders-for-woocommerce/changelog/hook-names | 2 +- plugins/linked-orders-for-woocommerce/includes/settings.php | 2 +- plugins/linked-orders-for-woocommerce/src/Settings.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/linked-orders-for-woocommerce/changelog/hook-names b/plugins/linked-orders-for-woocommerce/changelog/hook-names index 86a7b97..67bb634 100644 --- a/plugins/linked-orders-for-woocommerce/changelog/hook-names +++ b/plugins/linked-orders-for-woocommerce/changelog/hook-names @@ -1,4 +1,4 @@ Significance: minor Type: deprecated -Every hook moves from linked_orders_for_woocommerce/ to dws_lowc/, and each v1 name still fires first, deprecated: post_type/supported_order_types, post_type/valid_statuses_for_new_child, create_linked_order_args (now linked_order/creation_args), created_linked_order (now linked_order/created), node/status_name, node/get_formatted_name (now node/formatted_name), node/can_create_child, autocompletion/should_autocomplete, autocompletion/completed_status, metabox_output/after_add_new_child_button (now meta_box/after_add_new_child_button), list_table_output/column/insert_after (now list_table/preceding_column), list_table_output/column/content (now list_table/column_content), the general and plugin groups' get_group_fields (now settings/general_fields and settings/plugin_fields) and the get_validated_option_value filters (now settings/validated_value). +Every hook moves from linked_orders_for_woocommerce/ to dws_lowc/, and each v1 name still fires first, deprecated: post_type/supported_order_types, post_type/valid_statuses_for_new_child, create_linked_order_args (now linked_order/creation_args), created_linked_order (now linked_order/created), node/status_name, node/get_formatted_name (now node/formatted_name), node/can_create_child, autocompletion/should_autocomplete, autocompletion/completed_status, metabox_output/after_add_new_child_button (now meta_box/after_add_new_child_button), list_table_output/column/insert_after (now list_table/preceding_column), list_table_output/column/content (now list_table/column_content), the general and plugin groups' get_group_fields (now settings/general_fields and settings/plugin_fields), whose fields stay keyed by their v1 ids and keep an id they set, and the get_validated_option_value filters (now settings/validated_value). diff --git a/plugins/linked-orders-for-woocommerce/includes/settings.php b/plugins/linked-orders-for-woocommerce/includes/settings.php index b7cc09a..1da4c7c 100644 --- a/plugins/linked-orders-for-woocommerce/includes/settings.php +++ b/plugins/linked-orders-for-woocommerce/includes/settings.php @@ -143,7 +143,7 @@ function dws_lowc_get_max_depth(): int { /** * Returns a setting's group without its '-settings' suffix, its field id and its option, which is null for a group v1 did not have. * - * As in v1, a read without a group names the group in the field id, such as 'general/max-depth', and a field an extension adds to a group lives in the option WooCommerce saves it under, `dws-lowc_{group}_{field}`. An id read without a group that names none has an empty group. + * As in v1, a read without a group names the group in the field id, such as 'general/max-depth', and a field an extension adds to a group without an id of its own lives in the option WooCommerce saves it under, `dws-lowc_{group}_{field}`. An id read without a group that names none has an empty group. * * @since 2.0.0 * @version 2.0.0 diff --git a/plugins/linked-orders-for-woocommerce/src/Settings.php b/plugins/linked-orders-for-woocommerce/src/Settings.php index 0d709d8..1b44c20 100644 --- a/plugins/linked-orders-for-woocommerce/src/Settings.php +++ b/plugins/linked-orders-for-woocommerce/src/Settings.php @@ -71,7 +71,7 @@ public function get_section_fields( string $tab_id, string $section_id ): array $wc_fields = array(); foreach ( $group_fields as $id => $field ) { - $wc_fields[] = array( 'id' => "dws-lowc_{$group}_$id" ) + $field; + $wc_fields[] = array( 'id' => \is_string( $field['id'] ?? null ) ? $field['id'] : "dws-lowc_{$group}_$id" ) + $field; } /** From b08b0c2c34770a94b856b52ca0b1f65cf24c7fc7 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:21:20 +0100 Subject: [PATCH 26/50] Die through an exception a redirect cannot raise in the nonce row The nonce row expected a RuntimeException, which the redirect's UnexpectedValueException also is, so the row passed with the nonce check removed (A475, X238). The die handler throws an anonymous LogicException, and the row expects it and finds the parent without children; with the check removed, the row now errors on the redirect. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/Integration/ChildCreationTest.php | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/ChildCreationTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/ChildCreationTest.php index f2738b9..96c8cd2 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/ChildCreationTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/ChildCreationTest.php @@ -54,9 +54,10 @@ public function stop_at_redirect( string $location ): never { throw new \UnexpectedValueException( $location ); } + // A death throws no RuntimeException, so a request that redirected instead, through the redirect's exception, fails the row. public function get_die_handler(): callable { return static function ( mixed $message ): never { - throw new \RuntimeException( \is_string( $message ) ? $message : 'wp_die' ); + throw new class( \is_string( $message ) ? $message : 'wp_die' ) extends \LogicException {}; }; } @@ -74,15 +75,18 @@ public function test_a_created_child_opens_in_its_editor( bool $hpos, bool $sync self::assertSame( array(), $this->notices->get_notices( NoticeScope::User, 1 ) ); } - public function test_a_request_without_a_valid_nonce_dies(): void { - $parent_id = (string) $this->create_order()->get_id(); - $_GET['parent_id'] = $parent_id; - $_REQUEST['parent_id'] = $parent_id; + public function test_a_request_without_a_valid_nonce_dies_without_a_child(): void { + $parent = $this->create_order(); + $_GET['parent_id'] = (string) $parent->get_id(); + $_REQUEST['parent_id'] = (string) $parent->get_id(); $_REQUEST['_wpnonce'] = 'invalid'; - $this->expectException( \RuntimeException::class ); - - do_action( self::ACTION ); + try { + do_action( self::ACTION ); + self::fail( 'The request did not die.' ); + } catch ( \LogicException ) { + self::assertSame( array(), dws_lowc_get_order_node( $parent->get_id() )?->get_children() ); + } } public function test_a_missing_parent_returns_to_the_referring_screen_with_a_notice(): void { From 471b34ff2d2b44b121b005b91b9683e6c4aa6187 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:41:21 +0100 Subject: [PATCH 27/50] Give the Integration suite WordPress's admin memory ceiling WordPress, WooCommerce and PHPUnit hold about 103 MB before the first row, and the suite's 304 rows peak at about 127 MB, so under the tests container's 128 MB CLI default a random order could end the run with a memory fatal. The PHPUnit configuration sets memory_limit to 256M, WordPress's own admin ceiling, for the suite and the processes it starts. Assisted-by: Claude Code:claude-opus-5-5 --- plugins/linked-orders-for-woocommerce/phpunit.dist.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/linked-orders-for-woocommerce/phpunit.dist.xml b/plugins/linked-orders-for-woocommerce/phpunit.dist.xml index 62148d3..65c449b 100644 --- a/plugins/linked-orders-for-woocommerce/phpunit.dist.xml +++ b/plugins/linked-orders-for-woocommerce/phpunit.dist.xml @@ -21,6 +21,8 @@ + + From 9bc6d4edd4d7a5870b00e90f1fc49a5d9b9b17cc Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:42:50 +0100 Subject: [PATCH 28/50] Share the test harness each class copied Four classes each created, guarded and deleted a throwaway user, two loaded the admin screen API and a third required it inside a helper, four stashed an option by hand, and AutocompletionTest restored an absent option as an empty string; SettingsTest used setUpBeforeClass(), two providers sat below the rows, DeprecatedApiTest read the plugin header twice, and one recorder was not static (A517, A530, A607). CreatesUsersTrait inserts a user, stops on a failed insert so cleanup cannot delete a user the site had, and deletes its users after the row; LoadsAdminScreensTrait loads the screen and meta box API once per class; the stashing classes use RestoresOptionsTrait, whose snapshot now runs before every other hook. SettingsTest builds its tab in a BeforeClass hook, the providers lead their classes, DeprecatedApiTest reads the header once, and the recorder is static. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/Integration/AutocompletionTest.php | 13 ++-- .../tests/Integration/ChildCreationTest.php | 24 ++----- .../tests/Integration/DeprecatedApiTest.php | 31 ++++----- .../tests/Integration/DeprecatedHooksTest.php | 17 ++--- .../tests/Integration/LinkingTest.php | 65 +++++++------------ .../tests/Integration/ListTableTest.php | 30 ++------- .../tests/Integration/MetaBoxTest.php | 41 +++--------- .../tests/Integration/SettingsApiTest.php | 48 +++++++------- .../tests/Integration/SettingsTest.php | 5 +- .../tests/Support/CreatesUsersTrait.php | 32 +++++++++ .../tests/Support/LoadsAdminScreensTrait.php | 16 +++++ .../tests/Support/RestoresOptionsTrait.php | 3 +- 12 files changed, 145 insertions(+), 180 deletions(-) create mode 100644 plugins/linked-orders-for-woocommerce/tests/Support/CreatesUsersTrait.php create mode 100644 plugins/linked-orders-for-woocommerce/tests/Support/LoadsAdminScreensTrait.php diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/AutocompletionTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/AutocompletionTest.php index 2761f8e..e939208 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/AutocompletionTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/AutocompletionTest.php @@ -4,7 +4,7 @@ use DeepWebSolutions\LinkedOrders\Autocompletion; use DeepWebSolutions\LinkedOrders\Tests\Support\OrderStorageTrait; -use PHPUnit\Framework\Attributes\After; +use DeepWebSolutions\LinkedOrders\Tests\Support\RestoresOptionsTrait; use PHPUnit\Framework\Attributes\Before; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; @@ -13,20 +13,15 @@ #[CoversClass( Autocompletion::class )] final class AutocompletionTest extends TestCase { use OrderStorageTrait; + use RestoresOptionsTrait; - protected mixed $stored = false; + protected const array OPTIONS = array( DWS_LOWC_OPTION_AUTOCOMPLETE ); #[Before] - protected function store_setting(): void { - $this->stored = get_option( DWS_LOWC_OPTION_AUTOCOMPLETE ); + protected function enable_autocompletion(): void { update_option( DWS_LOWC_OPTION_AUTOCOMPLETE, 'yes' ); } - #[After] - protected function restore_setting(): void { - update_option( DWS_LOWC_OPTION_AUTOCOMPLETE, $this->stored ); - } - #[DataProvider( 'storages' )] public function test_completing_an_order_completes_its_descendants_with_a_note( bool $hpos, bool $sync ): void { $this->use_storage( $hpos, $sync ); diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/ChildCreationTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/ChildCreationTest.php index 96c8cd2..8212e18 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/ChildCreationTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/ChildCreationTest.php @@ -8,6 +8,7 @@ use DeepWebSolutions\LinkedOrders\Scoped\DeepWebSolutions\Framework\Core\AdminNotice\Notice; use DeepWebSolutions\LinkedOrders\Scoped\DeepWebSolutions\Framework\Core\AdminNotice\NoticeScope; use DeepWebSolutions\LinkedOrders\Scoped\Psr\Log\NullLogger; +use DeepWebSolutions\LinkedOrders\Tests\Support\CreatesUsersTrait; use DeepWebSolutions\LinkedOrders\Tests\Support\OrderStorageTrait; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\Before; @@ -17,14 +18,13 @@ #[CoversClass( ChildCreation::class )] final class ChildCreationTest extends TestCase { + use CreatesUsersTrait; use OrderStorageTrait; protected const string ACTION = 'admin_post_dws_lowc_create_linked_child'; protected AdminNotices $notices; - protected int $user = 0; - #[Before] protected function prepare_request(): void { wp_set_current_user( 1 ); @@ -42,11 +42,6 @@ protected function end_request(): void { remove_filter( 'wp_die_handler', array( $this, 'get_die_handler' ) ); unset( $_GET['parent_id'], $_REQUEST['parent_id'], $_REQUEST['_wpnonce'], $_SERVER['HTTP_REFERER'] ); wp_set_current_user( 0 ); - if ( 0 !== $this->user ) { - require_once ABSPATH . 'wp-admin/includes/user.php'; - wp_delete_user( $this->user ); - $this->user = 0; - } } // The redirect ends the request with exit, which the test replaces with a throw. @@ -101,21 +96,14 @@ public function test_a_missing_parent_returns_to_the_referring_screen_with_a_not #[DataProvider( 'storages' )] public function test_a_user_who_may_not_create_a_child_returns_to_the_parent_with_a_notice( bool $hpos, bool $sync ): void { $this->use_storage( $hpos, $sync ); - $parent = $this->create_order(); - $this->user = wp_insert_user( - array( - 'user_login' => 'dws-lowc-child-creation-editor', - 'user_pass' => wp_generate_password(), - 'role' => 'editor', - ) - ); - self::assertIsInt( $this->user ); - wp_set_current_user( $this->user ); + $parent = $this->create_order(); + $user = $this->create_user( 'dws-lowc-child-creation-editor', 'editor' ); + wp_set_current_user( $user ); $location = $this->request( (string) $parent->get_id() ); self::assertSame( $parent->get_edit_order_url(), $location ); - self::assertSame( array( 'No linked child can be created for order #' . $parent->get_order_number() . '.' ), \array_map( static fn ( Notice $notice ): string => $notice->message, $this->notices->get_notices( NoticeScope::User, $this->user ) ) ); + self::assertSame( array( 'No linked child can be created for order #' . $parent->get_order_number() . '.' ), \array_map( static fn ( Notice $notice ): string => $notice->message, $this->notices->get_notices( NoticeScope::User, $user ) ) ); self::assertSame( array(), dws_lowc_get_order_node( $parent->get_id() )?->get_children() ); } diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/DeprecatedApiTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/DeprecatedApiTest.php index d3b8ea4..f9ac792 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/DeprecatedApiTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/DeprecatedApiTest.php @@ -3,6 +3,7 @@ namespace DeepWebSolutions\LinkedOrders\Tests\Integration; use DeepWebSolutions\LinkedOrders\Tests\Support\RecordsDeprecationsTrait; +use PHPUnit\Framework\Attributes\BeforeClass; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -11,6 +12,8 @@ final class DeprecatedApiTest extends TestCase { use RecordsDeprecationsTrait; + protected static array $header = array(); + public static function hook_tags(): array { return array( @@ -27,8 +30,9 @@ public static function hook_tags(): array { ); } - public function test_the_v1_getters_answer_from_the_plugin_header(): void { - $header = get_file_data( + #[BeforeClass] + public static function read_the_header(): void { + self::$header = get_file_data( DWS_LOWC_FILE, array( 'Version' => 'Version', @@ -36,12 +40,14 @@ public function test_the_v1_getters_answer_from_the_plugin_header(): void { 'RequiresPHP' => 'Requires PHP', ) ); + } + public function test_the_v1_getters_answer_from_the_plugin_header(): void { self::assertSame( dws_lowc_get_plugin_name(), dws_lowc_name() ); - self::assertSame( $header['Version'], dws_lowc_version() ); + self::assertSame( self::$header['Version'], dws_lowc_version() ); self::assertSame( DWS_LOWC_FILE, dws_lowc_path() ); - self::assertSame( $header['RequiresPHP'], dws_lowc_min_php() ); - self::assertSame( $header['RequiresWP'], dws_lowc_min_wp() ); + self::assertSame( self::$header['RequiresPHP'], dws_lowc_min_php() ); + self::assertSame( self::$header['RequiresWP'], dws_lowc_min_wp() ); self::assertSame( array( array( 'dws_lowc_name', 'dws_lowc_get_plugin_name()' ), @@ -55,20 +61,11 @@ public function test_the_v1_getters_answer_from_the_plugin_header(): void { } public function test_the_v1_constants_hold_the_plugin_header(): void { - $header = get_file_data( - DWS_LOWC_FILE, - array( - 'Version' => 'Version', - 'RequiresWP' => 'Requires at least', - 'RequiresPHP' => 'Requires PHP', - ) - ); - self::assertSame( 'Linked Orders for WooCommerce', DWS_LOWC_NAME ); - self::assertSame( $header['Version'], DWS_LOWC_VERSION ); + self::assertSame( self::$header['Version'], DWS_LOWC_VERSION ); self::assertSame( DWS_LOWC_FILE, DWS_LOWC_PATH ); - self::assertSame( $header['RequiresPHP'], DWS_LOWC_MIN_PHP ); - self::assertSame( $header['RequiresWP'], DWS_LOWC_MIN_WP ); + self::assertSame( self::$header['RequiresPHP'], DWS_LOWC_MIN_PHP ); + self::assertSame( self::$header['RequiresWP'], DWS_LOWC_MIN_WP ); } #[DataProvider( 'hook_tags' )] diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/DeprecatedHooksTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/DeprecatedHooksTest.php index 575d335..81830a3 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/DeprecatedHooksTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/DeprecatedHooksTest.php @@ -2,8 +2,10 @@ namespace DeepWebSolutions\LinkedOrders\Tests\Integration; +use DeepWebSolutions\LinkedOrders\Tests\Support\LoadsAdminScreensTrait; use DeepWebSolutions\LinkedOrders\Tests\Support\OrderStorageTrait; use DeepWebSolutions\LinkedOrders\Tests\Support\RecordsDeprecationsTrait; +use DeepWebSolutions\LinkedOrders\Tests\Support\RestoresOptionsTrait; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\Before; use PHPUnit\Framework\Attributes\CoversNothing; @@ -12,8 +14,12 @@ #[CoversNothing] final class DeprecatedHooksTest extends TestCase { + use LoadsAdminScreensTrait; use OrderStorageTrait; use RecordsDeprecationsTrait; + use RestoresOptionsTrait; + + protected const array OPTIONS = array( DWS_LOWC_OPTION_AUTOCOMPLETE ); public static function renamed_hooks(): array { return array( @@ -92,22 +98,13 @@ protected function check_child_creation(): void { // A lone order's tree is the order alone, so each autocompletion filter runs once. protected function complete_order(): void { $parent = $this->create_order(); - $stored = get_option( DWS_LOWC_OPTION_AUTOCOMPLETE ); update_option( DWS_LOWC_OPTION_AUTOCOMPLETE, 'yes' ); - try { - wc_get_order( $parent->get_id() )->update_status( 'completed' ); - } finally { - update_option( DWS_LOWC_OPTION_AUTOCOMPLETE, $stored ); - } + wc_get_order( $parent->get_id() )->update_status( 'completed' ); } protected function render_meta_box(): void { global $wp_meta_boxes; - require_once WC_ABSPATH . 'includes/admin/wc-admin-functions.php'; - require_once ABSPATH . 'wp-admin/includes/template.php'; - require_once ABSPATH . 'wp-admin/includes/class-wp-screen.php'; - require_once ABSPATH . 'wp-admin/includes/screen.php'; $order = $this->create_order(); $screen = wc_get_page_screen_id( 'shop-order' ); do_action( 'add_meta_boxes', $screen, $order ); diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/LinkingTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/LinkingTest.php index 56a1af8..c0a43b0 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/LinkingTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/LinkingTest.php @@ -5,7 +5,9 @@ use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore; use DeepWebSolutions\LinkedOrders\Scoped\DeepWebSolutions\Framework\Core\Storage\Lock; use DeepWebSolutions\LinkedOrders\Scoped\DeepWebSolutions\Framework\Core\Storage\LockClaim; +use DeepWebSolutions\LinkedOrders\Tests\Support\CreatesUsersTrait; use DeepWebSolutions\LinkedOrders\Tests\Support\OrderStorageTrait; +use DeepWebSolutions\LinkedOrders\Tests\Support\RestoresOptionsTrait; use DeepWebSolutions\LinkedOrders\Tests\Support\RunsRequestsTrait; use DeepWebSolutions\LinkedOrders\Tests\Support\StoresLinksTrait; use PHPUnit\Framework\Attributes\After; @@ -16,11 +18,18 @@ #[CoversNothing] final class LinkingTest extends TestCase { + use CreatesUsersTrait; use OrderStorageTrait; + use RestoresOptionsTrait; use RunsRequestsTrait; use StoresLinksTrait; - protected int $user = 0; + protected const array OPTIONS = array( DWS_LOWC_OPTION_MAX_DEPTH ); + + // WooCommerce's order-tables data caching, off by default, keeps each order's meta for the request. + public static function storages_and_data_caching(): array { + return self::storages() + array( 'hpos-with-data-caching' => array( true, false, true ) ); + } #[Before] protected function log_in(): void { @@ -30,11 +39,6 @@ protected function log_in(): void { #[After] protected function log_out(): void { wp_set_current_user( 0 ); - if ( 0 !== $this->user ) { - require_once ABSPATH . 'wp-admin/includes/user.php'; - wp_delete_user( $this->user ); - $this->user = 0; - } } #[DataProvider( 'storages' )] @@ -299,23 +303,16 @@ public function test_a_node_of_an_unsupported_order_cannot_be_built(): void { #[DataProvider( 'storages' )] public function test_a_child_is_created_only_by_a_user_who_may_edit_that_order( bool $hpos, bool $sync ): void { $this->use_storage( $hpos, $sync ); - $order = $this->create_order(); - $this->user = wp_insert_user( - array( - 'user_login' => 'dws-lowc-linking-editor', - 'user_pass' => wp_generate_password(), - 'role' => 'subscriber', - ) - ); - self::assertIsInt( $this->user ); - $editor = new \WP_User( $this->user ); + $order = $this->create_order(); + $user = $this->create_user( 'dws-lowc-linking-editor', 'subscriber' ); + $editor = new \WP_User( $user ); $editor->add_cap( 'edit_shop_orders' ); $editor->add_cap( DWS_LOWC_CAP_CREATE_LINKED_CHILDREN ); - self::assertFalse( dws_lowc_get_order_node( $order->get_id() )?->can_create_child( $this->user ) ); + self::assertFalse( dws_lowc_get_order_node( $order->get_id() )?->can_create_child( $user ) ); $editor->add_cap( 'edit_others_shop_orders' ); - self::assertTrue( dws_lowc_get_order_node( $order->get_id() )?->can_create_child( $this->user ) ); + self::assertTrue( dws_lowc_get_order_node( $order->get_id() )?->can_create_child( $user ) ); } #[DataProvider( 'storages' )] @@ -324,28 +321,15 @@ public function test_a_child_can_be_created_below_the_maximum_depth_by_a_user_wh $root = $this->create_order(); $child = $this->create_order(); self::assertTrue( dws_lowc_link_orders( $root->get_id(), $child->get_id() ) ); - $stored = get_option( DWS_LOWC_OPTION_MAX_DEPTH ); - try { - update_option( DWS_LOWC_OPTION_MAX_DEPTH, '1' ); - self::assertTrue( dws_lowc_get_order_node( $root->get_id() )?->can_create_child() ); - self::assertFalse( dws_lowc_get_order_node( $child->get_id() )?->can_create_child() ); + update_option( DWS_LOWC_OPTION_MAX_DEPTH, '1' ); + self::assertTrue( dws_lowc_get_order_node( $root->get_id() )?->can_create_child() ); + self::assertFalse( dws_lowc_get_order_node( $child->get_id() )?->can_create_child() ); - update_option( DWS_LOWC_OPTION_MAX_DEPTH, '2' ); - self::assertTrue( dws_lowc_get_order_node( $child->get_id() )?->can_create_child() ); - } finally { - update_option( DWS_LOWC_OPTION_MAX_DEPTH, $stored ); - } + update_option( DWS_LOWC_OPTION_MAX_DEPTH, '2' ); + self::assertTrue( dws_lowc_get_order_node( $child->get_id() )?->can_create_child() ); - $this->user = wp_insert_user( - array( - 'user_login' => 'dws-lowc-linking-customer', - 'user_pass' => wp_generate_password(), - 'role' => 'customer', - ) - ); - self::assertIsInt( $this->user ); - self::assertFalse( dws_lowc_get_order_node( $root->get_id() )?->can_create_child( $this->user ) ); + self::assertFalse( dws_lowc_get_order_node( $root->get_id() )?->can_create_child( $this->create_user( 'dws-lowc-linking-customer', 'customer' ) ) ); } #[DataProvider( 'storages' )] @@ -429,7 +413,7 @@ public function test_a_linked_child_whose_link_is_refused_is_deleted_and_an_erro $this->use_storage( $hpos, $sync ); $parent = $this->create_order(); $created = 0; - $record = function ( \WC_Order $child ) use ( &$created ): void { + $record = static function ( \WC_Order $child ) use ( &$created ): void { $created = $child->get_id(); }; add_action( 'dws_lowc/linked_order/created', $record ); @@ -544,11 +528,6 @@ public function test_a_creator_error_is_returned( bool $hpos, bool $sync ): void self::assertSame( $error, dws_lowc_create_linked_child( $this->create_order()->get_id(), array( 'create_function' => static fn (): \WP_Error => $error ) ) ); } - // WooCommerce's order-tables data caching, off by default, keeps each order's meta for the request. - public static function storages_and_data_caching(): array { - return self::storages() + array( 'hpos-with-data-caching' => array( true, false, true ) ); - } - // The trait keeps the order tables' data caching off, which these rows also need on. protected function use_storage_and_data_caching( bool $hpos, bool $sync, bool $caching ): void { $this->use_storage( $hpos, $sync ); diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/ListTableTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/ListTableTest.php index 0a88b1c..020bd6c 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/ListTableTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/ListTableTest.php @@ -5,16 +5,19 @@ use Automattic\WooCommerce\Utilities\OrderUtil; use DeepWebSolutions\LinkedOrders\ListTable; use DeepWebSolutions\LinkedOrders\Plugin; +use DeepWebSolutions\LinkedOrders\Tests\Support\CreatesUsersTrait; +use DeepWebSolutions\LinkedOrders\Tests\Support\LoadsAdminScreensTrait; use DeepWebSolutions\LinkedOrders\Tests\Support\OrderStorageTrait; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\Before; -use PHPUnit\Framework\Attributes\BeforeClass; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; #[CoversClass( ListTable::class )] final class ListTableTest extends TestCase { + use CreatesUsersTrait; + use LoadsAdminScreensTrait; use OrderStorageTrait; protected const array COLUMNS = array( @@ -24,16 +27,6 @@ final class ListTableTest extends TestCase { 'wc_actions' => 'Actions', ); - protected int $user = 0; - - // WordPress and WooCommerce load these only in admin requests. - #[BeforeClass] - public static function load_screen_api(): void { - require_once WC_ABSPATH . 'includes/admin/wc-admin-functions.php'; - require_once ABSPATH . 'wp-admin/includes/class-wp-screen.php'; - require_once ABSPATH . 'wp-admin/includes/screen.php'; - } - #[Before] protected function log_in(): void { wp_set_current_user( 1 ); @@ -51,11 +44,6 @@ protected function leave_screen(): void { wp_dequeue_script( Plugin::SLUG . '-orders-list-table' ); wp_dequeue_style( Plugin::SLUG . '-orders-list-table-icons' ); wp_set_current_user( 0 ); - if ( 0 !== $this->user ) { - require_once ABSPATH . 'wp-admin/includes/user.php'; - wp_delete_user( $this->user ); - $this->user = 0; - } } #[DataProvider( 'storages' )] @@ -76,15 +64,7 @@ public function test_the_depth_column_follows_the_total_and_names_each_orders_le #[DataProvider( 'storages' )] public function test_a_user_without_the_columns_capability_sees_the_list_unchanged( bool $hpos, bool $sync ): void { $this->use_storage( $hpos, $sync ); - $this->user = wp_insert_user( - array( - 'user_login' => 'dws-lowc-list-table-editor', - 'user_pass' => wp_generate_password(), - 'role' => 'editor', - ) - ); - self::assertIsInt( $this->user ); - wp_set_current_user( $this->user ); + wp_set_current_user( $this->create_user( 'dws-lowc-list-table-editor', 'editor' ) ); $screen = $this->open_list(); $order = $this->create_order(); diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/MetaBoxTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/MetaBoxTest.php index 3f46ddb..f678c7d 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/MetaBoxTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/MetaBoxTest.php @@ -4,28 +4,24 @@ use Automattic\WooCommerce\Utilities\OrderUtil; use DeepWebSolutions\LinkedOrders\MetaBox; +use DeepWebSolutions\LinkedOrders\Tests\Support\CreatesUsersTrait; +use DeepWebSolutions\LinkedOrders\Tests\Support\LoadsAdminScreensTrait; use DeepWebSolutions\LinkedOrders\Tests\Support\OrderStorageTrait; +use DeepWebSolutions\LinkedOrders\Tests\Support\RestoresOptionsTrait; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\Before; -use PHPUnit\Framework\Attributes\BeforeClass; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; #[CoversClass( MetaBox::class )] final class MetaBoxTest extends TestCase { + use CreatesUsersTrait; + use LoadsAdminScreensTrait; use OrderStorageTrait; + use RestoresOptionsTrait; - protected int $user = 0; - - // WordPress and WooCommerce load these only in admin requests. - #[BeforeClass] - public static function load_meta_box_api(): void { - require_once WC_ABSPATH . 'includes/admin/wc-admin-functions.php'; - require_once ABSPATH . 'wp-admin/includes/template.php'; - require_once ABSPATH . 'wp-admin/includes/class-wp-screen.php'; - require_once ABSPATH . 'wp-admin/includes/screen.php'; - } + protected const array OPTIONS = array( DWS_LOWC_OPTION_MAX_DEPTH ); #[Before] protected function log_in(): void { @@ -37,11 +33,6 @@ protected function clear_meta_boxes(): void { global $wp_meta_boxes; $wp_meta_boxes = array(); wp_set_current_user( 0 ); - if ( 0 !== $this->user ) { - require_once ABSPATH . 'wp-admin/includes/user.php'; - wp_delete_user( $this->user ); - $this->user = 0; - } } #[DataProvider( 'storages' )] @@ -57,14 +48,7 @@ public function test_the_box_sits_beside_an_orders_editor_under_its_v1_id( bool #[DataProvider( 'storages' )] public function test_the_box_is_not_added_for_a_user_without_its_capabilities_or_beside_other_editors( bool $hpos, bool $sync ): void { $this->use_storage( $hpos, $sync ); - $this->user = wp_insert_user( - array( - 'user_login' => 'dws-lowc-meta-box-editor', - 'user_pass' => wp_generate_password(), - 'role' => 'editor', - ) - ); - self::assertIsInt( $this->user ); + $user = $this->create_user( 'dws-lowc-meta-box-editor', 'editor' ); $order = $this->create_order(); $post = get_post( wp_insert_post( array( 'post_title' => 'Not an order' ) ) ); self::assertInstanceOf( \WP_Post::class, $post ); @@ -72,7 +56,7 @@ public function test_the_box_is_not_added_for_a_user_without_its_capabilities_or try { self::assertNull( $this->register( $post, 'post' ) ); - wp_set_current_user( $this->user ); + wp_set_current_user( $user ); self::assertNull( $this->register( $order ) ); } finally { wp_delete_post( $post->ID, true ); @@ -82,7 +66,6 @@ public function test_the_box_is_not_added_for_a_user_without_its_capabilities_or #[DataProvider( 'storages' )] public function test_the_box_shows_the_parent_the_children_and_the_button_to_add_a_child( bool $hpos, bool $sync ): void { $this->use_storage( $hpos, $sync ); - $stored = get_option( DWS_LOWC_OPTION_MAX_DEPTH ); update_option( DWS_LOWC_OPTION_MAX_DEPTH, '2' ); $root = $this->create_order(); $order = $this->create_order(); @@ -90,11 +73,7 @@ public function test_the_box_shows_the_parent_the_children_and_the_button_to_add self::assertTrue( dws_lowc_link_orders( $root->get_id(), $order->get_id() ) ); self::assertTrue( dws_lowc_link_orders( $order->get_id(), $child->get_id() ) ); - try { - $box = $this->render( $order->get_id() ); - } finally { - update_option( DWS_LOWC_OPTION_MAX_DEPTH, $stored ); - } + $box = $this->render( $order->get_id() ); $parent = $box->querySelector( '.dws-linked-orders__parent a' ); self::assertSame( wc_get_order( $root->get_id() )->get_edit_order_url(), $parent?->getAttribute( 'href' ) ); diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/SettingsApiTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/SettingsApiTest.php index 3b9ef86..799ea3e 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/SettingsApiTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/SettingsApiTest.php @@ -27,6 +27,30 @@ public static function stored_settings(): array { ); } + public static function paths(): array { + return array( + 'group' => array( + 'max-depth', + 'general', + array( + array( 'group', 2, 'max-depth' ), + array( 'v2', 4, 'max-depth', 'general' ), + ), + array( 'linked_orders_for_woocommerce/general_settings/get_validated_option_value' ), + ), + 'group-in-the-id' => array( + 'general/max-depth', + null, + array( + array( 'group', 2, 'max-depth' ), + array( 'section', 4, 'general/max-depth' ), + array( 'v2', 5, 'max-depth', 'general' ), + ), + array( 'linked_orders_for_woocommerce/general_settings/get_validated_option_value', 'linked_orders_for_woocommerce/settings/get_validated_option_value' ), + ), + ); + } + #[DataProvider( 'stored_settings' )] public function test_a_setting_reads_validated_by_its_v1_field_and_group( string $key, string $stored, string $field_id, ?string $group, int|bool $expected ): void { update_option( $key, $stored ); @@ -73,30 +97,6 @@ public function test_a_value_validates_against_its_fields_routine(): void { self::assertFalse( dws_lowc_validate_setting( 'maybe', 'remove-data-uninstall', 'plugin' ) ); } - public static function paths(): array { - return array( - 'group' => array( - 'max-depth', - 'general', - array( - array( 'group', 2, 'max-depth' ), - array( 'v2', 4, 'max-depth', 'general' ), - ), - array( 'linked_orders_for_woocommerce/general_settings/get_validated_option_value' ), - ), - 'group-in-the-id' => array( - 'general/max-depth', - null, - array( - array( 'group', 2, 'max-depth' ), - array( 'section', 4, 'general/max-depth' ), - array( 'v2', 5, 'max-depth', 'general' ), - ), - array( 'linked_orders_for_woocommerce/general_settings/get_validated_option_value', 'linked_orders_for_woocommerce/settings/get_validated_option_value' ), - ), - ); - } - // v1 fired its section filter only for a read through the section, which names the group in the field id. #[DataProvider( 'paths' )] public function test_a_validated_value_passes_the_v1_filters_of_its_path_then_the_v2_filter( string $field_id, ?string $group, array $expected, array $deprecated ): void { diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/SettingsTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/SettingsTest.php index 8d74300..eacd2af 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/SettingsTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/SettingsTest.php @@ -6,6 +6,7 @@ use DeepWebSolutions\LinkedOrders\Tests\Support\RecordsDeprecationsTrait; use DeepWebSolutions\LinkedOrders\Tests\Support\RestoresOptionsTrait; use PHPUnit\Framework\Attributes\After; +use PHPUnit\Framework\Attributes\BeforeClass; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -21,8 +22,8 @@ final class SettingsTest extends TestCase { protected static \WC_Settings_Page $advanced; - #[\Override] - public static function setUpBeforeClass(): void { + #[BeforeClass] + public static function build_the_advanced_tab(): void { require_once WC_ABSPATH . 'includes/admin/class-wc-admin-settings.php'; // WooCommerce builds its settings pages once per request, so the class shares one build of the Advanced tab. foreach ( \WC_Admin_Settings::get_settings_pages() as $page ) { diff --git a/plugins/linked-orders-for-woocommerce/tests/Support/CreatesUsersTrait.php b/plugins/linked-orders-for-woocommerce/tests/Support/CreatesUsersTrait.php new file mode 100644 index 0000000..4c6b863 --- /dev/null +++ b/plugins/linked-orders-for-woocommerce/tests/Support/CreatesUsersTrait.php @@ -0,0 +1,32 @@ + $login, + 'user_pass' => wp_generate_password(), + 'role' => $role, + ) + ); + self::assertIsInt( $user ); + $this->users[] = $user; + return $user; + } + + #[After] + protected function delete_users(): void { + require_once ABSPATH . 'wp-admin/includes/user.php'; + foreach ( $this->users as $user ) { + wp_delete_user( $user ); + } + $this->users = array(); + } +} diff --git a/plugins/linked-orders-for-woocommerce/tests/Support/LoadsAdminScreensTrait.php b/plugins/linked-orders-for-woocommerce/tests/Support/LoadsAdminScreensTrait.php new file mode 100644 index 0000000..fa340cd --- /dev/null +++ b/plugins/linked-orders-for-woocommerce/tests/Support/LoadsAdminScreensTrait.php @@ -0,0 +1,16 @@ +stored_options[ $key ] = get_option( $key ); From 0c93d30b0a2cd9066b4e3bbe498ca06e7587aac5 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:45:31 +0100 Subject: [PATCH 29/50] Restore the shared site in finally after each destructive row The uninstall and partial-deploy rows restored the shared database after their assertions, so a failing row left the consent, the grants or the migrated settings gone for every later row; the consenting rows re-added grants by hand without refreshing roles; two partial-deploy rows built the same failing include; the lock row repeated the self-link assertion; the temp files kept the template's prefix; and LinkingTest's background request named the scoped Lock as a literal on its own command line (A526-A528, A532, A533, A578). Each destructive row restores in finally: the uninstall rows through one fresh request that migrates, runs the runner's hook and grants again, then refreshes this process's caches and roles; the partial-deploy consent row puts back the consent it found. The two include rows are one request, the lock row loses the repeated line, the temp files take the plugin's prefix, and the background request builds its class name from Lock::class and its command through RunsRequestsTrait::command(). Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/Integration/LinkingTest.php | 5 +- .../tests/Integration/PartialDeployTest.php | 36 ++--- .../tests/Integration/UninstallTest.php | 140 +++++++++--------- .../tests/Support/RunsRequestsTrait.php | 8 +- 4 files changed, 90 insertions(+), 99 deletions(-) diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/LinkingTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/LinkingTest.php index c0a43b0..162ce06 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/LinkingTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/LinkingTest.php @@ -102,8 +102,8 @@ public function test_a_link_waits_for_another_request_to_finish_linking(): void global $wpdb; $parent = $this->create_order(); $child = $this->create_order(); - $php = '$lock = new DeepWebSolutions\\LinkedOrders\\Scoped\\DeepWebSolutions\\Framework\\Core\\Storage\\Lock( "dws_lowc_linking", 30 ); $lock->acquire( "another-request" ); sleep( 1 ); $lock->release( "another-request" );'; - \exec( 'wp --path=' . \escapeshellarg( ABSPATH ) . ' eval ' . \escapeshellarg( $php ) . ' > /dev/null 2>&1 &' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec -- The other request must hold the lock while this one links. + $php = '$lock = new ' . Lock::class . '( "dws_lowc_linking", 30 ); $lock->acquire( "another-request" ); sleep( 1 ); $lock->release( "another-request" );'; + \exec( self::command( $php ) . ' > /dev/null 2>&1 &' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec -- The other request must hold the lock while this one links. $held = static fn (): bool => ! \is_null( $wpdb->get_var( "SELECT option_value FROM {$wpdb->options} WHERE option_name = 'dws_lowc_linking'" ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Watches the other request's lock row past the options cache. $tries = 0; @@ -140,7 +140,6 @@ public function test_a_link_is_refused_once_another_request_has_held_the_lock_fo self::assertGreaterThanOrEqual( 5.0, \microtime( true ) - $started ); self::assertFalse( dws_lowc_get_order_node( $child->get_id() )?->has_parent() ); - self::assertFalse( dws_lowc_link_orders( $child->get_id(), $child->get_id() ) ); self::assertTrue( dws_lowc_link_orders( $parent->get_id(), $child->get_id() ) ); } diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/PartialDeployTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/PartialDeployTest.php index 1682954..1743074 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/PartialDeployTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/PartialDeployTest.php @@ -26,30 +26,17 @@ public function test_a_missing_built_asset_shows_the_build_notice_and_loads_noth self::assertSame( '[false,true]', $request['last'] ); } - public function test_an_include_that_throws_shows_the_build_notice_and_boots_nothing(): void { - $include = \dirname( DWS_LOWC_FILE ) . '/includes/zz-throws.php'; - \file_put_contents( $include, "for_site(); + self::assertSame( 0, $request['status'], $request['output'] ); + self::assertSame( '[false,false]', $request['last'], $request['output'] ); + } finally { + self::restore_a_new_sites_data(); + } - self::assertSame( 0, $request['status'], $request['output'] ); - self::assertSame( '[false,false]', $request['last'], $request['output'] ); - self::assertSame( 0, $restored['status'], $restored['output'] ); self::assertSame( 'no', get_option( DWS_LOWC_OPTION_DELETE_DATA ) ); self::assertTrue( get_role( 'shop_manager' )?->has_cap( DWS_LOWC_CAP_CREATE_LINKED_CHILDREN ) ); } @@ -78,12 +76,7 @@ public function test_a_web_uninstall_whose_engine_fails_ends_with_its_message_an remove_all_filters( 'query' ); delete_option( DWS_LOWC_OPTION_DELETE_DATA ); delete_option( Plugin::PREFIX . '_revision' ); - wp_cache_flush(); - wp_roles()->for_site(); - // The other tests share the database, so the runner and its scheduled hook store again what a new site starts with, and the grants act again. - dws_lowc_plugin()->migrations?->run(); - do_action( Plugin::PREFIX . '_revision/migration/run' ); - do_action( 'wp_loaded' ); + self::restore_a_new_sites_data(); } } @@ -109,38 +102,34 @@ public function test_an_uninstall_after_v1_consent_removes_the_links_on_both_sto remove_filter( 'pre_option_woocommerce_custom_orders_table_data_sync_enabled', '__return_empty_string' ); } - self::assertTrue( uninstall_plugin( plugin_basename( DWS_LOWC_FILE ) ) ); - wp_cache_flush(); - wp_roles()->for_site(); + try { + self::assertTrue( uninstall_plugin( plugin_basename( DWS_LOWC_FILE ) ) ); + wp_cache_flush(); + wp_roles()->for_site(); - $ids = \implode( ',', \array_map( 'intval', \array_keys( $linked ) ) ); - self::assertSame( '0', $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE post_id IN ($ids) AND meta_key LIKE '\\_dws\\_lo\\_%'" ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Reads both storages' rows directly; the ids are cast to integers. - self::assertSame( '0', $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}wc_orders_meta WHERE order_id IN ($ids) AND meta_key LIKE '\\_dws\\_lo\\_%'" ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Reads both storages' rows directly; the ids are cast to integers. - foreach ( array( DWS_LOWC_OPTION_DELETE_DATA, 'linked_orders_for_woocommerce_version', 'linked_orders_for_woocommerce_original_version', '_linked_orders_for_woocommerce_admin_notices' ) as $option ) { - self::assertFalse( get_option( $option ), "The option $option is still stored." ); - } - self::assertSame( '', get_user_meta( self::USER, '_linked_orders_for_woocommerce_admin_notices', true ) ); - foreach ( DWS_LOWC_CAPABILITY_GRANTS as $role => $capabilities ) { - foreach ( $capabilities as $capability ) { - self::assertFalse( get_role( $role )?->has_cap( $capability ), "The $role role still holds $capability." ); + $ids = \implode( ',', \array_map( 'intval', \array_keys( $linked ) ) ); + self::assertSame( '0', $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE post_id IN ($ids) AND meta_key LIKE '\\_dws\\_lo\\_%'" ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Reads both storages' rows directly; the ids are cast to integers. + self::assertSame( '0', $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}wc_orders_meta WHERE order_id IN ($ids) AND meta_key LIKE '\\_dws\\_lo\\_%'" ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Reads both storages' rows directly; the ids are cast to integers. + foreach ( array( DWS_LOWC_OPTION_DELETE_DATA, 'linked_orders_for_woocommerce_version', 'linked_orders_for_woocommerce_original_version', '_linked_orders_for_woocommerce_admin_notices' ) as $option ) { + self::assertFalse( get_option( $option ), "The option $option is still stored." ); } - } - - // The other tests share the database, so the roles get their capabilities back and the runner and its scheduled hook store again what a new site starts with. - foreach ( DWS_LOWC_CAPABILITY_GRANTS as $role => $capabilities ) { - foreach ( $capabilities as $capability ) { - get_role( $role )?->add_cap( $capability ); + self::assertSame( '', get_user_meta( self::USER, '_linked_orders_for_woocommerce_admin_notices', true ) ); + foreach ( DWS_LOWC_CAPABILITY_GRANTS as $role => $capabilities ) { + foreach ( $capabilities as $capability ) { + self::assertFalse( get_role( $role )?->has_cap( $capability ), "The $role role still holds $capability." ); + } + } + } finally { + self::restore_a_new_sites_data(); + foreach ( $linked as $id => $hpos ) { + $storage = static fn (): string => $hpos; + add_filter( 'pre_option_woocommerce_custom_orders_table_enabled', $storage ); + $order = wc_get_order( $id ); + if ( $order instanceof \WC_Order ) { + $order->delete( true ); + } + remove_filter( 'pre_option_woocommerce_custom_orders_table_enabled', $storage ); } - } - dws_lowc_plugin()->migrations?->run(); - do_action( Plugin::PREFIX . '_revision/migration/run' ); - foreach ( $linked as $id => $hpos ) { - $storage = static fn (): string => $hpos; - add_filter( 'pre_option_woocommerce_custom_orders_table_enabled', $storage ); - $order = wc_get_order( $id ); - self::assertInstanceOf( \WC_Order::class, $order ); - $order->delete( true ); - remove_filter( 'pre_option_woocommerce_custom_orders_table_enabled', $storage ); } } @@ -156,30 +145,39 @@ public function test_a_consenting_uninstall_removes_a_row_of_every_kind_the_fram wp_schedule_single_event( \time() + HOUR_IN_SECONDS, "{$prefix}revision/migration/run" ); as_schedule_single_action( \time() + HOUR_IN_SECONDS, "{$prefix}seeded", array(), Plugin::PREFIX ); - self::assertTrue( uninstall_plugin( plugin_basename( DWS_LOWC_FILE ) ) ); - wp_cache_flush(); + try { + self::assertTrue( uninstall_plugin( plugin_basename( DWS_LOWC_FILE ) ) ); + wp_cache_flush(); + + self::assertFalse( get_option( DWS_LOWC_OPTION_DELETE_DATA ) ); + self::assertFalse( get_option( "{$prefix}notices" ) ); + self::assertFalse( get_network_option( null, "{$prefix}network_notices" ) ); + self::assertFalse( get_transient( "{$prefix}boot_failure_seeded" ) ); + self::assertFalse( get_user_option( "{$prefix}hidden_notices", self::USER ) ); + self::assertSame( '', get_user_meta( self::USER, "{$prefix}network_hidden_notices", true ) ); + self::assertFalse( wp_next_scheduled( "{$prefix}revision/migration/run" ) ); + self::assertSame( + array(), + as_get_scheduled_actions( + array( + 'group' => Plugin::PREFIX, + 'status' => 'pending', + ), + 'ids' + ) + ); + } finally { + self::restore_a_new_sites_data(); + } - self::assertFalse( get_option( DWS_LOWC_OPTION_DELETE_DATA ) ); - self::assertFalse( get_option( "{$prefix}notices" ) ); - self::assertFalse( get_network_option( null, "{$prefix}network_notices" ) ); - self::assertFalse( get_transient( "{$prefix}boot_failure_seeded" ) ); - self::assertFalse( get_user_option( "{$prefix}hidden_notices", self::USER ) ); - self::assertSame( '', get_user_meta( self::USER, "{$prefix}network_hidden_notices", true ) ); - self::assertFalse( wp_next_scheduled( "{$prefix}revision/migration/run" ) ); - self::assertSame( - array(), - as_get_scheduled_actions( - array( - 'group' => Plugin::PREFIX, - 'status' => 'pending', - ), - 'ids' - ) - ); - - // The other tests share the database, so the runner and its scheduled hook store again what a new site starts with. - dws_lowc_plugin()->migrations?->run(); - do_action( Plugin::PREFIX . '_revision/migration/run' ); self::assertSame( 'no', get_option( DWS_LOWC_OPTION_DELETE_DATA ) ); } + + // The rows share the database, so a request with the plugin stores again what a new site starts with and grants the capabilities again, as the site's next request and the runner's scheduled hook would, and this process then reads them afresh. + protected static function restore_a_new_sites_data(): void { + $restored = self::request( 'do_action( "' . Plugin::PREFIX . '_revision/migration/run" );' ); + wp_cache_flush(); + wp_roles()->for_site(); + self::assertSame( 0, $restored['status'], $restored['output'] ); + } } diff --git a/plugins/linked-orders-for-woocommerce/tests/Support/RunsRequestsTrait.php b/plugins/linked-orders-for-woocommerce/tests/Support/RunsRequestsTrait.php index 09d3ec9..84c5de5 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Support/RunsRequestsTrait.php +++ b/plugins/linked-orders-for-woocommerce/tests/Support/RunsRequestsTrait.php @@ -9,8 +9,7 @@ protected static function request( string $php, string ...$flags ): array { // A row whose fixture provokes a diagnostic names it, so that diagnostic and what follows it, such as a fatal error's trace, pass, while any output before it fails the row. protected static function request_provoking( string $diagnostic, string $php, string ...$flags ): array { - $arguments = \implode( '', \array_map( static fn ( string $flag ): string => ' ' . \escapeshellarg( $flag ), $flags ) ); - \exec( 'wp --path=' . \escapeshellarg( ABSPATH ) . "$arguments eval " . \escapeshellarg( $php ) . ' 2>&1', $output, $status ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec -- Each row needs a fresh request that loads the plugin. + \exec( self::command( $php, ...$flags ) . ' 2>&1', $output, $status ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec -- Each row needs a fresh request that loads the plugin. $output = \array_values( \array_filter( $output, static fn ( string $line ): bool => '' !== \trim( $line ) ) ); $provoked = '' === $diagnostic ? null : \array_find_key( $output, static fn ( string $line ): bool => \str_contains( $line, $diagnostic ) ); @@ -21,4 +20,9 @@ protected static function request_provoking( string $diagnostic, string $php, st 'last' => (string) \end( $output ), ); } + + protected static function command( string $php, string ...$flags ): string { + $arguments = \implode( '', \array_map( static fn ( string $flag ): string => ' ' . \escapeshellarg( $flag ), $flags ) ); + return 'wp --path=' . \escapeshellarg( ABSPATH ) . "$arguments eval " . \escapeshellarg( $php ); + } } From 780103d20e6f28c38312b33f05fd44c07367e374 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:49:47 +0100 Subject: [PATCH 30/50] Cover other order types, the asset gate and the allowed depth No row linked an order type another plugin registers, which v1 1.2.0 advertised; the asset row ran on one storage, pinned neither its capability gate nor the editor, and its "not loaded" check passed only because the handle could not register twice; the depth row never let the allowed maximum exceed the stored depth; and the autocompletion rows left a second note and the root's status unpinned (A540, A544, A552, A554). A fixture order class and RegistersOrderTypeTrait register a type through wc_register_order_type() and the supported-types filter, and one ListTable and one MetaBox row on every storage expect the column, the cell, the filter's label, the box's title and names, and links to that type's list. The asset row runs on every storage, dequeues and deregisters before each screen, and expects nothing on a posts list, on the posts editor, or for a user without the column. The depth row raises the maximum to 5, and the autocompletion rows expect one note per descendant and the root at the filtered status. Assisted-by: Claude Code:claude-opus-5-5 --- .../Fixtures/class-dws-lowc-test-order.php | 9 ++ .../tests/Integration/AutocompletionTest.php | 3 +- .../tests/Integration/ListTableTest.php | 93 +++++++++++++------ .../tests/Integration/MetaBoxTest.php | 22 ++++- .../tests/Support/RegistersOrderTypeTrait.php | 56 +++++++++++ 5 files changed, 154 insertions(+), 29 deletions(-) create mode 100644 plugins/linked-orders-for-woocommerce/tests/Fixtures/class-dws-lowc-test-order.php create mode 100644 plugins/linked-orders-for-woocommerce/tests/Support/RegistersOrderTypeTrait.php diff --git a/plugins/linked-orders-for-woocommerce/tests/Fixtures/class-dws-lowc-test-order.php b/plugins/linked-orders-for-woocommerce/tests/Fixtures/class-dws-lowc-test-order.php new file mode 100644 index 0000000..9089341 --- /dev/null +++ b/plugins/linked-orders-for-woocommerce/tests/Fixtures/class-dws-lowc-test-order.php @@ -0,0 +1,9 @@ +get_id() ); self::assertSame( 'completed', $stored->get_status() ); - self::assertContains( 'Child order autocompleted. Order status changed from Pending payment to Completed.', \array_map( static fn ( \stdClass $note ): string => $note->content, wc_get_order_notes( array( 'order_id' => $descendant->get_id() ) ) ) ); + self::assertSame( array( 'Child order autocompleted. Order status changed from Pending payment to Completed.' ), \array_values( \array_filter( \array_map( static fn ( \stdClass $note ): string => $note->content, wc_get_order_notes( array( 'order_id' => $descendant->get_id() ) ) ), static fn ( string $content ): bool => \str_starts_with( $content, 'Child order autocompleted.' ) ) ) ); } } @@ -128,6 +128,7 @@ public function test_a_filter_keeps_a_descendant_or_sets_another_status( bool $h remove_filter( 'dws_lowc/autocompletion/completed_status', $status ); } + self::assertSame( 'processing', wc_get_order( $root->get_id() )->get_status() ); self::assertSame( 'pending', wc_get_order( $child->get_id() )->get_status() ); self::assertSame( 'processing', wc_get_order( $grandchild->get_id() )->get_status() ); } diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/ListTableTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/ListTableTest.php index 020bd6c..3bbf788 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/ListTableTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/ListTableTest.php @@ -8,6 +8,8 @@ use DeepWebSolutions\LinkedOrders\Tests\Support\CreatesUsersTrait; use DeepWebSolutions\LinkedOrders\Tests\Support\LoadsAdminScreensTrait; use DeepWebSolutions\LinkedOrders\Tests\Support\OrderStorageTrait; +use DeepWebSolutions\LinkedOrders\Tests\Support\RegistersOrderTypeTrait; +use DeepWebSolutions\LinkedOrders\Tests\Support\RestoresOptionsTrait; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\Before; use PHPUnit\Framework\Attributes\CoversClass; @@ -19,6 +21,10 @@ final class ListTableTest extends TestCase { use CreatesUsersTrait; use LoadsAdminScreensTrait; use OrderStorageTrait; + use RegistersOrderTypeTrait; + use RestoresOptionsTrait; + + protected const array OPTIONS = array( DWS_LOWC_OPTION_MAX_DEPTH ); protected const array COLUMNS = array( 'cb' => '', @@ -35,14 +41,19 @@ protected function log_in(): void { // A screen's column hooks stay for the rest of the process, as they stay for the rest of a request; WooCommerce adds its own only in admin requests, so clearing them removes none of its. #[After] protected function leave_screen(): void { - foreach ( array( 'edit-shop_order', 'woocommerce_page_wc-orders', 'admin_page_wc-orders' ) as $screen ) { - remove_all_filters( "manage_{$screen}_columns" ); - remove_all_actions( "manage_{$screen}_custom_column" ); + foreach ( array( 'shop_order', self::ORDER_TYPE ) as $type ) { + $suffix = 'shop_order' === $type ? '' : "--$type"; + foreach ( array( "edit-$type", "woocommerce_page_wc-orders$suffix", "admin_page_wc-orders$suffix" ) as $screen ) { + remove_all_filters( "manage_{$screen}_columns" ); + remove_all_actions( "manage_{$screen}_custom_column" ); + } + remove_all_actions( "manage_{$type}_posts_custom_column" ); } - remove_all_actions( 'manage_shop_order_posts_custom_column' ); unset( $GLOBALS['current_screen'], $GLOBALS['typenow'], $_GET['_dws_lo_depth'], $_GET['_guest_customer_user'], $_GET['_dws_linked_order_id'], $_GET['_customer_user'] ); wp_dequeue_script( Plugin::SLUG . '-orders-list-table' ); wp_dequeue_style( Plugin::SLUG . '-orders-list-table-icons' ); + wp_deregister_script( Plugin::SLUG . '-orders-list-table' ); + wp_deregister_style( Plugin::SLUG . '-orders-list-table-icons' ); wp_set_current_user( 0 ); } @@ -86,7 +97,7 @@ public function test_the_depth_filter_lists_every_level_up_to_the_deepest_stored $this->open_list(); $_GET['_dws_lo_depth'] = '2'; - $filters = \Dom\HTMLDocument::createFromString( '' . $this->render_filters() . '', \LIBXML_NOERROR ); + $filters = $this->parse_filters(); $label = $filters->querySelector( 'label[for="dws_lo_depth_filter_dropdown"]' ); self::assertSame( 'Order depth', \trim( (string) $label?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. @@ -97,6 +108,9 @@ public function test_the_depth_filter_lists_every_level_up_to_the_deepest_stored self::assertTrue( $options[3]->hasAttribute( 'selected' ) ); self::assertNotNull( $filters->querySelector( 'input[type="hidden"][name="_guest_customer_user"]' ) ); self::assertNotNull( $filters->querySelector( 'input[type="hidden"][name="_dws_linked_order_id"]' ) ); + + update_option( DWS_LOWC_OPTION_MAX_DEPTH, '5' ); + self::assertSame( array( '', '0', '1', '2', '3', '4', '5' ), \array_map( static fn ( \Dom\Element $option ): string => $option->getAttribute( 'value' ) ?? '', \iterator_to_array( $this->parse_filters()->querySelectorAll( 'select[name="_dws_lo_depth"] option' ) ) ) ); } // v1 read the deepest depth from every order of the type, whatever its status: the Trash, which the list's Trash view shows, or a status another plugin registers only as a post status. @@ -310,49 +324,76 @@ public function test_a_guest_row_offers_the_orders_of_its_billing_email_unless_t self::assertArrayNotHasKey( 'view_all_customer_orders', apply_filters( 'woocommerce_admin_order_actions', array(), $guest ) ); } - public function test_the_list_loads_the_script_and_the_icons_only_on_order_lists(): void { - $this->open_list(); - do_action( 'admin_enqueue_scripts', 'edit.php' ); + // The order tables' editor shares its list's screen, so only the posts storage tells the two apart; a request registers the assets once, so each screen starts without them. + #[DataProvider( 'storages' )] + public function test_the_list_loads_the_script_and_the_icons_only_on_order_lists_for_a_user_who_sees_the_column( bool $hpos, bool $sync ): void { + $this->use_storage( $hpos, $sync ); + $enqueued = static function ( string $screen ): bool { + wp_dequeue_script( Plugin::SLUG . '-orders-list-table' ); + wp_dequeue_style( Plugin::SLUG . '-orders-list-table-icons' ); + wp_deregister_script( Plugin::SLUG . '-orders-list-table' ); + wp_deregister_style( Plugin::SLUG . '-orders-list-table-icons' ); + set_current_screen( $screen ); + do_action( 'admin_enqueue_scripts', 'edit.php' ); + return wp_script_is( Plugin::SLUG . '-orders-list-table' ) && wp_style_is( Plugin::SLUG . '-orders-list-table-icons' ); + }; + $list = $this->open_list(); - self::assertTrue( wp_script_is( Plugin::SLUG . '-orders-list-table' ) ); - self::assertTrue( wp_style_is( Plugin::SLUG . '-orders-list-table-icons' ) ); + self::assertTrue( $enqueued( $list ) ); + self::assertFalse( $enqueued( 'edit-post' ) ); + if ( ! $hpos ) { + self::assertFalse( $enqueued( 'shop_order' ) ); + } - wp_dequeue_script( Plugin::SLUG . '-orders-list-table' ); - wp_dequeue_style( Plugin::SLUG . '-orders-list-table-icons' ); - set_current_screen( 'edit-post' ); - do_action( 'admin_enqueue_scripts', 'edit.php' ); + wp_set_current_user( $this->create_user( 'dws-lowc-list-table-assets-editor', 'editor' ) ); + self::assertFalse( $enqueued( $list ) ); + } + + // v1 1.2.0 linked any order type its filter named, such as another plugin's. + #[DataProvider( 'storages' )] + public function test_another_plugins_order_type_gets_the_column_the_filter_and_links_to_its_own_list( bool $hpos, bool $sync ): void { + $this->use_storage( $hpos, $sync ); + $this->register_order_type(); + $root = $this->create_typed_order(); + $child = $this->create_typed_order(); + self::assertTrue( dws_lowc_link_orders( $root->get_id(), $child->get_id() ) ); + $screen = $this->open_list( self::ORDER_TYPE ); + $list = OrderUtil::custom_orders_table_usage_is_enabled() ? admin_url( 'admin.php?page=wc-orders--' . self::ORDER_TYPE ) : admin_url( 'edit.php?post_type=' . self::ORDER_TYPE ); - self::assertFalse( wp_script_is( Plugin::SLUG . '-orders-list-table' ) ); + self::assertSame( 'Depth', apply_filters( "manage_{$screen}_columns", self::COLUMNS )['dws_lo_depth'] ?? null ); + self::assertSame( 'Child order (level 2)', $this->render_cell( $child->get_id(), 'dws_lo_depth', self::ORDER_TYPE ) ); + self::assertSame( 'Test order depth', \trim( (string) $this->parse_filters( self::ORDER_TYPE )->querySelector( 'label[for="dws_lo_depth_filter_dropdown"]' )?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. + self::assertSame( add_query_arg( '_dws_linked_order_id', $root->get_id(), $list ), apply_filters( 'woocommerce_admin_order_actions', array(), wc_get_order( $root->get_id() ) )['view_all_linked_orders']['url'] ?? null ); } - protected function open_list(): string { - $screen = OrderUtil::custom_orders_table_usage_is_enabled() ? wc_get_page_screen_id( 'shop-order' ) : 'edit-shop_order'; + protected function open_list( string $type = 'shop_order' ): string { + $screen = OrderUtil::custom_orders_table_usage_is_enabled() ? wc_get_page_screen_id( $type ) : "edit-$type"; set_current_screen( $screen ); - $GLOBALS['typenow'] = 'shop_order'; + $GLOBALS['typenow'] = $type; return $screen; } - protected function render_cell( int $order_id, string $column = 'dws_lo_depth' ): string { + protected function render_cell( int $order_id, string $column = 'dws_lo_depth', string $type = 'shop_order' ): string { \ob_start(); if ( OrderUtil::custom_orders_table_usage_is_enabled() ) { - do_action( 'manage_' . wc_get_page_screen_id( 'shop-order' ) . '_custom_column', $column, wc_get_order( $order_id ) ); + do_action( 'manage_' . wc_get_page_screen_id( $type ) . '_custom_column', $column, wc_get_order( $order_id ) ); } else { - do_action( 'manage_shop_order_posts_custom_column', $column, $order_id ); + do_action( "manage_{$type}_posts_custom_column", $column, $order_id ); } return \trim( (string) \ob_get_clean() ); } - protected function parse_filters(): \Dom\HTMLDocument { - return \Dom\HTMLDocument::createFromString( '' . $this->render_filters() . '', \LIBXML_NOERROR ); + protected function parse_filters( string $type = 'shop_order' ): \Dom\HTMLDocument { + return \Dom\HTMLDocument::createFromString( '' . $this->render_filters( $type ) . '', \LIBXML_NOERROR ); } - protected function render_filters(): string { + protected function render_filters( string $type = 'shop_order' ): string { \ob_start(); if ( OrderUtil::custom_orders_table_usage_is_enabled() ) { - do_action( 'woocommerce_order_list_table_restrict_manage_orders', 'shop_order', 'top' ); + do_action( 'woocommerce_order_list_table_restrict_manage_orders', $type, 'top' ); } else { - do_action( 'restrict_manage_posts', 'shop_order', 'top' ); + do_action( 'restrict_manage_posts', $type, 'top' ); } return \trim( (string) \ob_get_clean() ); diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/MetaBoxTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/MetaBoxTest.php index f678c7d..72d803a 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/MetaBoxTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/MetaBoxTest.php @@ -7,6 +7,7 @@ use DeepWebSolutions\LinkedOrders\Tests\Support\CreatesUsersTrait; use DeepWebSolutions\LinkedOrders\Tests\Support\LoadsAdminScreensTrait; use DeepWebSolutions\LinkedOrders\Tests\Support\OrderStorageTrait; +use DeepWebSolutions\LinkedOrders\Tests\Support\RegistersOrderTypeTrait; use DeepWebSolutions\LinkedOrders\Tests\Support\RestoresOptionsTrait; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\Before; @@ -19,6 +20,7 @@ final class MetaBoxTest extends TestCase { use CreatesUsersTrait; use LoadsAdminScreensTrait; use OrderStorageTrait; + use RegistersOrderTypeTrait; use RestoresOptionsTrait; protected const array OPTIONS = array( DWS_LOWC_OPTION_MAX_DEPTH ); @@ -110,6 +112,22 @@ public function test_a_childless_order_at_the_maximum_depth_says_no_child_can_be self::assertNull( $this->render( $this->create_order()->get_id() )->querySelector( 'a.dws-linked-orders__tree' ) ); } + // v1 1.2.0 linked any order type its filter named, such as another plugin's. + #[DataProvider( 'storages' )] + public function test_another_plugins_order_type_gets_the_box_with_a_link_to_its_own_list( bool $hpos, bool $sync ): void { + $this->use_storage( $hpos, $sync ); + $this->register_order_type(); + $root = $this->create_typed_order(); + $child = $this->create_typed_order(); + self::assertTrue( dws_lowc_link_orders( $root->get_id(), $child->get_id() ) ); + $list = OrderUtil::custom_orders_table_usage_is_enabled() ? admin_url( 'admin.php?page=wc-orders--' . self::ORDER_TYPE ) : admin_url( 'edit.php?post_type=' . self::ORDER_TYPE ); + + self::assertSame( 'Linked Test orders', $this->register( wc_get_order( $root->get_id() ), wc_get_page_screen_id( self::ORDER_TYPE ) )['title'] ?? null ); + $box = $this->render( $root->get_id(), wc_get_page_screen_id( self::ORDER_TYPE ) ); + self::assertSame( add_query_arg( '_dws_linked_order_id', $root->get_id(), $list ), $box->querySelector( 'a.dws-linked-orders__tree' )?->getAttribute( 'href' ) ); + self::assertStringStartsWith( 'Test order #' . $child->get_order_number(), \trim( (string) $box->querySelector( '#linked-order-' . $child->get_id() . ' a' )?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. + } + // The posts editor passes its post to the box and the order-tables editor its order, so a row passes what its storage's editor does. protected function register( \WC_Order|\WP_Post $post_or_order, ?string $screen = null ): ?array { global $wp_meta_boxes; @@ -122,10 +140,10 @@ protected function register( \WC_Order|\WP_Post $post_or_order, ?string $screen return $wp_meta_boxes[ $screen ]['side']['default']['dws-linked-orders'] ?? null; } - protected function render( int $order_id ): \Dom\HTMLDocument { + protected function render( int $order_id, ?string $screen = null ): \Dom\HTMLDocument { $order = OrderUtil::custom_orders_table_usage_is_enabled() ? wc_get_order( $order_id ) : get_post( $order_id ); self::assertTrue( $order instanceof \WC_Order || $order instanceof \WP_Post ); - $box = $this->register( $order ); + $box = $this->register( $order, $screen ); self::assertIsArray( $box ); \ob_start(); diff --git a/plugins/linked-orders-for-woocommerce/tests/Support/RegistersOrderTypeTrait.php b/plugins/linked-orders-for-woocommerce/tests/Support/RegistersOrderTypeTrait.php new file mode 100644 index 0000000..c5d5ec2 --- /dev/null +++ b/plugins/linked-orders-for-woocommerce/tests/Support/RegistersOrderTypeTrait.php @@ -0,0 +1,56 @@ +meta_caps = $GLOBALS['post_type_meta_caps'] ?? array(); + wc_register_order_type( + self::ORDER_TYPE, + array( + 'labels' => array( + 'name' => 'Test orders', + 'singular_name' => 'Test order', + ), + 'public' => false, + 'show_ui' => true, + 'show_in_menu' => 'woocommerce', + 'capability_type' => 'shop_order', + 'map_meta_cap' => true, + 'class_name' => \DWS_LOWC_Test_Order::class, + ) + ); + add_filter( 'dws_lowc/post_type/supported_order_types', array( $this, 'support_order_type' ) ); + } + + public function support_order_type( array $types ): array { + return \array_merge( $types, array( self::ORDER_TYPE ) ); + } + + protected function create_typed_order(): \WC_Order { + $order = new \DWS_LOWC_Test_Order(); + $order->set_status( 'pending' ); + $order->save(); + $this->orders[] = $order->get_id(); + return $order; + } + + // The type goes after the rows' orders, which load through its class, and unregistering it drops the meta capabilities it shares with WooCommerce's orders from the one map WordPress keeps. + #[After( priority: -1 )] + protected function unregister_order_type(): void { + remove_filter( 'dws_lowc/post_type/supported_order_types', array( $this, 'support_order_type' ) ); + if ( post_type_exists( self::ORDER_TYPE ) ) { + unregister_post_type( self::ORDER_TYPE ); + $GLOBALS['post_type_meta_caps'] = $this->meta_caps; + } + unset( $GLOBALS['wc_order_types'][ self::ORDER_TYPE ] ); + } +} From 7e80911e8a10df6f318b85f02546b16fc1541153 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:50:47 +0100 Subject: [PATCH 31/50] Check that v1 hook listeners hand their values to the v2 names The renamed-hook rows proved only that each v1 literal fires before its v2 name, so a relay that passed the v2 listener the original value, or other arguments, still passed (A545, X247). The v1 listener records its arguments and returns a value of its own, negated, suffixed or extended by type, and each row expects the v2 listener to receive that value and the same further arguments. The list helper loads no admin includes of its own, since the class's trait loads them. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/Integration/DeprecatedHooksTest.php | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/DeprecatedHooksTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/DeprecatedHooksTest.php index 81830a3..fb63492 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/DeprecatedHooksTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/DeprecatedHooksTest.php @@ -51,16 +51,23 @@ protected function reset_the_screen(): void { wp_set_current_user( 0 ); } + // A v1 filter returns a value of its own, so the v2 name receives what the v1 listener made of the value and the same further arguments. #[DataProvider( 'renamed_hooks' )] - public function test_a_renamed_hook_fires_its_v1_literal_then_its_v2_name( string $v1_leaf, string $v2, string $trigger ): void { + public function test_a_renamed_hook_fires_its_v1_literal_then_its_v2_name_with_its_arguments( string $v1_leaf, string $v2, string $trigger ): void { $v1 = "linked_orders_for_woocommerce/$v1_leaf"; $fired = array(); - $spy = static function ( mixed $value = null ) use ( &$fired ): mixed { - $fired[] = current_filter(); - return $value; + $made = static fn ( mixed $value ): mixed => match ( true ) { + \is_bool( $value ) => ! $value, + \is_string( $value ) => $value . ' (v1)', + \is_array( $value ) => $value + array( 'dws_lowc_v1' => 'dws_lowc_v1' ), + default => $value, }; - add_filter( $v1, $spy ); - add_filter( $v2, $spy ); + $spy = static function ( mixed ...$arguments ) use ( &$fired, $made ): mixed { + $fired[] = array( current_filter(), $arguments ); + return $made( $arguments[0] ); + }; + add_filter( $v1, $spy, 10, 9 ); + add_filter( $v2, $spy, 10, 9 ); try { $this->{$trigger}(); @@ -69,7 +76,10 @@ public function test_a_renamed_hook_fires_its_v1_literal_then_its_v2_name( strin remove_filter( $v2, $spy ); } - self::assertSame( array( $v1, $v2 ), $fired ); + self::assertSame( array( $v1, $v2 ), \array_column( $fired, 0 ) ); + list( $v1_arguments, $v2_arguments ) = \array_column( $fired, 1 ); + self::assertSame( $made( $v1_arguments[0] ), $v2_arguments[0] ); + self::assertSame( \array_slice( $v1_arguments, 1 ), \array_slice( $v2_arguments, 1, \count( $v1_arguments ) - 1 ) ); self::assertSame( array( array( $v1, $v2 ) ), $this->deprecated ); } @@ -128,9 +138,6 @@ protected function render_depth_cell(): void { } protected function open_list(): string { - require_once WC_ABSPATH . 'includes/admin/wc-admin-functions.php'; - require_once ABSPATH . 'wp-admin/includes/class-wp-screen.php'; - require_once ABSPATH . 'wp-admin/includes/screen.php'; $screen = wc_get_page_screen_id( 'shop-order' ); set_current_screen( $screen ); return $screen; From fcc055968785d053305ebdbe1c329ee79c4aa9e6 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:51:08 +0100 Subject: [PATCH 32/50] Pin the WooCommerce floor in the readme's prose The readme row compared header fields only, while the WooCommerce floor appears in the Installation section and the 2.0.0 Upgrade Notice as prose, so a raised floor could leave either behind (A553, X248). A Unit row expects "WooCommerce" and the entry's WC requires at least in both places; with the Installation sentence set back to 11.2, it fails. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/Unit/ReadmeTest.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/plugins/linked-orders-for-woocommerce/tests/Unit/ReadmeTest.php b/plugins/linked-orders-for-woocommerce/tests/Unit/ReadmeTest.php index f014d5c..332bf39 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Unit/ReadmeTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Unit/ReadmeTest.php @@ -16,6 +16,15 @@ public function test_the_readme_repeats_the_entry_files_version_and_floors(): vo self::assertSame( $entry['Requires PHP'], $readme['Requires PHP'] ); } + // The floor rises with WooCommerce's releases, and the readme names it in prose a field read cannot reach. + public function test_the_readme_names_the_woocommerce_floor_where_it_installs_and_upgrades(): void { + $readme = (string) \file_get_contents( \dirname( __DIR__, 2 ) . '/readme.txt' ); + $floor = 'WooCommerce ' . self::read_fields( 'linked-orders-for-woocommerce.php' )['WC requires at least']; + + self::assertMatchesRegularExpression( '/== Installation ==\s+[^=]*' . \preg_quote( $floor, '/' ) . '\b/', $readme ); + self::assertMatchesRegularExpression( '/== Upgrade Notice ==\s+= 2\.0\.0 =\s+[^\n]*' . \preg_quote( $floor, '/' ) . '\b/', $readme ); + } + // Plugin Check refuses a Tested up to in the entry, in any case, as WordPress reads headers, so the readme holds the only one. public function test_the_entry_leaves_tested_up_to_to_the_readme(): void { self::assertDoesNotMatchRegularExpression( '/^(?:[ \t]*<\?(?:php)?)?[ \t\/*#@]*Tested up to:/mi', (string) \file_get_contents( \dirname( __DIR__, 2 ) . '/linked-orders-for-woocommerce.php' ) ); From e0e73f6c6ae372700019c6ca69fc143913433a84 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:51:45 +0100 Subject: [PATCH 33/50] Wait for the child's own editor and set the depth the spec reads The child-creation row's URL check also matched the parent's editor, so it could read the parent's id as the child's, and the settings row assumed the site kept the default depth (A564, X250). The row waits for an editor URL whose id is not the parent's, and the settings row stores a depth of 3 through WP-CLI, expects it on the section, and puts back the depth it found; it follows the settings link by its accessible name, "View settings". Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/EndToEnd/linked-orders.spec.ts | 46 ++++++++++++------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/plugins/linked-orders-for-woocommerce/tests/EndToEnd/linked-orders.spec.ts b/plugins/linked-orders-for-woocommerce/tests/EndToEnd/linked-orders.spec.ts index c0ccbae..4e7a465 100644 --- a/plugins/linked-orders-for-woocommerce/tests/EndToEnd/linked-orders.spec.ts +++ b/plugins/linked-orders-for-woocommerce/tests/EndToEnd/linked-orders.spec.ts @@ -1,5 +1,6 @@ import AxeBuilder from '@axe-core/playwright'; import { expect, test } from '@wordpress/e2e-test-utils-playwright'; +import { cli } from './cli'; // The tests environment stores orders in WooCommerce's order tables; the Integration suite covers the posts storage. test.describe( 'Linked orders', () => { @@ -33,7 +34,12 @@ test.describe( 'Linked orders', () => { await expect( box ).toContainText( 'There are no attached children.' ); await box.getByRole( 'link', { name: 'Add new child order' } ).click(); - await expect( page ).toHaveURL( /[?&]action=edit&id=\d+/ ); + // The parent's own editor URL also names an id, so the wait is for another one. + await page.waitForURL( + ( url ) => + 'edit' === url.searchParams.get( 'action' ) && + String( parent.id ) !== url.searchParams.get( 'id' ) + ); const child = Number( new URL( page.url() ).searchParams.get( 'id' ) ?? 0 ); @@ -66,21 +72,29 @@ test.describe( 'Linked orders', () => { test( 'the plugin row opens the section on the Advanced tab', async ( { page, } ) => { - await page.goto( '/wp-admin/plugins.php' ); - await page - .locator( 'tr[data-slug="linked-orders-for-woocommerce"]' ) - .getByRole( 'link', { name: 'Settings' } ) - .click(); + const depth = 'dws-lowc_general_max-depth'; + const stored = cli( 'option', 'get', depth ); + cli( 'option', 'update', depth, '3' ); - await expect( page ).toHaveURL( - /tab=advanced§ion=dws-linked-orders/ - ); - await expect( - page.getByLabel( 'Maximum linked orders depth' ) - ).toHaveValue( '1' ); - const results = await new AxeBuilder( { page } ) - .include( '#mainform' ) - .analyze(); - expect( results.violations ).toEqual( [] ); + try { + await page.goto( '/wp-admin/plugins.php' ); + await page + .locator( 'tr[data-slug="linked-orders-for-woocommerce"]' ) + .getByRole( 'link', { name: 'View settings' } ) + .click(); + + await expect( page ).toHaveURL( + /tab=advanced§ion=dws-linked-orders/ + ); + await expect( + page.getByLabel( 'Maximum linked orders depth' ) + ).toHaveValue( '3' ); + const results = await new AxeBuilder( { page } ) + .include( '#mainform' ) + .analyze(); + expect( results.violations ).toEqual( [] ); + } finally { + cli( 'option', 'update', depth, stored ); + } } ); } ); From 84f4a1c7ee65072281579ea32dc3630b6dc311f1 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:52:01 +0100 Subject: [PATCH 34/50] Keep only the types PHPStan needs in the order-number fixtures The two stand-ins for Sequential Order Numbers Pro carried production docblocks, file headers and unaligned tags, though PHPStan reads them only for the plugin's API and their native types say the rest (A608, X256). Each keeps a comment saying what it stands in for, and the class keeps one aligned @var for its list of orders. Assisted-by: Claude Code:claude-opus-5-5 --- .../class-wc-seq-order-number-pro.php | 21 +++---------------- .../Fixtures/sequential-order-numbers-pro.php | 11 +--------- 2 files changed, 4 insertions(+), 28 deletions(-) diff --git a/plugins/linked-orders-for-woocommerce/tests/Fixtures/class-wc-seq-order-number-pro.php b/plugins/linked-orders-for-woocommerce/tests/Fixtures/class-wc-seq-order-number-pro.php index 98f8d1c..631ed66 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Fixtures/class-wc-seq-order-number-pro.php +++ b/plugins/linked-orders-for-woocommerce/tests/Fixtures/class-wc-seq-order-number-pro.php @@ -1,28 +1,13 @@ + * @var list */ public array $numbered = array(); - /** - * Records an order the plugin would number, and numbers it as the plugin does, on the object, for its next save to store. - * - * @param WC_Order $order The order. - * - * @return void - */ + // Numbers the order on the object, as the plugin does, for its next save to store. public function set_sequential_order_number( WC_Order $order ): void { $this->numbered[] = $order; $order->update_meta_data( '_order_number', 'SOS-' . $order->get_id() ); diff --git a/plugins/linked-orders-for-woocommerce/tests/Fixtures/sequential-order-numbers-pro.php b/plugins/linked-orders-for-woocommerce/tests/Fixtures/sequential-order-numbers-pro.php index a4e3f4b..5b0c747 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Fixtures/sequential-order-numbers-pro.php +++ b/plugins/linked-orders-for-woocommerce/tests/Fixtures/sequential-order-numbers-pro.php @@ -1,17 +1,8 @@ Date: Thu, 12 Nov 2026 12:03:57 +0100 Subject: [PATCH 35/50] Bring Linked Orders' layout and idioms to the ruled forms The list read every filter through one string helper and mixed static and instance helpers; Settings branched on the general group twice through a single-caller helper, and the list computed its deepest level in another; guards hid in ternaries and the autocompletion filter's result went unnamed; translators comments sat above their statements; the node's formatted name sat among its methods; and an arrow function wrapped a plain call (A307-A313, X212). The list reads its two integers and the billing email through the ruled filter_var and is_string idioms and checks the customer and tree keys for presence, and its helpers are instance methods. Settings builds one group-keyed array, and the list computes its deepest level in place. Guards return early, the filter's result is named, the customer pair reads $customer_key and $customer_value, each translators comment sits inside its call, get_formatted_name() joins the getters, and the children map through dws_lowc_get_order_node by name. Assisted-by: Claude Code:claude-opus-5-5 --- .../includes/post-type.php | 6 +- .../models/DWS_Order_Node.php | 136 +++++++------- .../src/Autocompletion.php | 7 +- .../src/ChildCreation.php | 6 +- .../src/ListTable.php | 173 +++++++++--------- .../src/MetaBox.php | 19 +- .../src/Settings.php | 92 +++++----- 7 files changed, 225 insertions(+), 214 deletions(-) diff --git a/plugins/linked-orders-for-woocommerce/includes/post-type.php b/plugins/linked-orders-for-woocommerce/includes/post-type.php index 73ad124..d29911f 100644 --- a/plugins/linked-orders-for-woocommerce/includes/post-type.php +++ b/plugins/linked-orders-for-woocommerce/includes/post-type.php @@ -151,10 +151,8 @@ function dws_lowc_get_list_url( WP_Post_Type $post_type ): string { function dws_lowc_get_child_refusal( DWS_Order_Node $node ): string { $number = $node->get_order()->get_order_number(); return dws_lowc_get_max_depth() <= $node->get_depth() - /* translators: %s: The order number. */ - ? sprintf( __( 'Order #%s is at the maximum linked orders depth, set under WooCommerce > Settings > Advanced > Linked Orders.', 'linked-orders-for-woocommerce' ), $number ) - /* translators: %s: The order number. */ - : sprintf( __( 'No linked child can be created for order #%s.', 'linked-orders-for-woocommerce' ), $number ); + ? sprintf( /* translators: %s: The order number. */ __( 'Order #%s is at the maximum linked orders depth, set under WooCommerce > Settings > Advanced > Linked Orders.', 'linked-orders-for-woocommerce' ), $number ) + : sprintf( /* translators: %s: The order number. */ __( 'No linked child can be created for order #%s.', 'linked-orders-for-woocommerce' ), $number ); } /** diff --git a/plugins/linked-orders-for-woocommerce/models/DWS_Order_Node.php b/plugins/linked-orders-for-woocommerce/models/DWS_Order_Node.php index eee5c76..e95836f 100644 --- a/plugins/linked-orders-for-woocommerce/models/DWS_Order_Node.php +++ b/plugins/linked-orders-for-woocommerce/models/DWS_Order_Node.php @@ -232,7 +232,7 @@ public function has_parent(): bool { */ public function get_children(): array { $this->maybe_read(); - $this->children ??= array_values( array_filter( array_map( static fn ( ?int $id ): ?DWS_Order_Node => dws_lowc_get_order_node( $id ), $this->child_ids ) ) ); + $this->children ??= array_values( array_filter( array_map( 'dws_lowc_get_order_node', $this->child_ids ) ) ); return $this->children; } @@ -248,6 +248,73 @@ public function has_children(): bool { return array() !== $this->get_children(); } + /** + * Returns the order's name as its type, number and status, such as "Order #123 - Processing". + * + * @since 1.0.0 + * @version 2.0.0 + * + * @return string + */ + public function get_formatted_name(): string { + /** + * Filters the status name in an order's formatted name. + * + * @since 1.0.0 + * @version 2.0.0 + * @deprecated 2.0.0 Use dws_lowc/node/status_name. + * + * @param string $status_name The status name. + * @param WP_Post_Type $post_type The post type object of the order's type. + * @param WC_Order $order The order. + * @param DWS_Order_Node $node The order's node. + */ + $status_name = apply_filters_deprecated( 'linked_orders_for_woocommerce/node/status_name', array( wc_get_order_status_name( $this->order->get_status() ), $this->post_type, $this->order, $this ), '2.0.0', 'dws_lowc/node/status_name' ); + + /** + * Filters the status name in an order's formatted name. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param string $status_name The status name. + * @param WP_Post_Type $post_type The post type object of the order's type. + * @param WC_Order $order The order. + * @param DWS_Order_Node $node The order's node. + */ + $status_name = apply_filters( 'dws_lowc/node/status_name', $status_name, $this->post_type, $this->order, $this ); + $name = sprintf( + /* translators: 1: The order type's singular name. 2: The order number. 3: The order status's name. */ + __( '%1$s #%2$s - %3$s', 'linked-orders-for-woocommerce' ), + dws_lowc_get_singular_name( $this->post_type ), + $this->order->get_order_number(), + $status_name + ); + + /** + * Filters an order's formatted name. + * + * @since 1.0.0 + * @version 2.0.0 + * @deprecated 2.0.0 Use dws_lowc/node/formatted_name. + * + * @param string $name The formatted name. + * @param DWS_Order_Node $node The order's node. + */ + $name = apply_filters_deprecated( 'linked_orders_for_woocommerce/node/get_formatted_name', array( $name, $this ), '2.0.0', 'dws_lowc/node/formatted_name' ); + + /** + * Filters an order's formatted name. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param string $name The formatted name. + * @param DWS_Order_Node $node The order's node. + */ + return apply_filters( 'dws_lowc/node/formatted_name', $name, $this ); + } + // endregion // region SETTERS @@ -325,73 +392,6 @@ public function add_child( DWS_Order_Node $child ): bool { return true; } - /** - * Returns the order's name as its type, number and status, such as "Order #123 - Processing". - * - * @since 1.0.0 - * @version 2.0.0 - * - * @return string - */ - public function get_formatted_name(): string { - /** - * Filters the status name in an order's formatted name. - * - * @since 1.0.0 - * @version 2.0.0 - * @deprecated 2.0.0 Use dws_lowc/node/status_name. - * - * @param string $status_name The status name. - * @param WP_Post_Type $post_type The post type object of the order's type. - * @param WC_Order $order The order. - * @param DWS_Order_Node $node The order's node. - */ - $status_name = apply_filters_deprecated( 'linked_orders_for_woocommerce/node/status_name', array( wc_get_order_status_name( $this->order->get_status() ), $this->post_type, $this->order, $this ), '2.0.0', 'dws_lowc/node/status_name' ); - - /** - * Filters the status name in an order's formatted name. - * - * @since 2.0.0 - * @version 2.0.0 - * - * @param string $status_name The status name. - * @param WP_Post_Type $post_type The post type object of the order's type. - * @param WC_Order $order The order. - * @param DWS_Order_Node $node The order's node. - */ - $status_name = apply_filters( 'dws_lowc/node/status_name', $status_name, $this->post_type, $this->order, $this ); - $name = sprintf( - /* translators: 1: The order type's singular name. 2: The order number. 3: The order status's name. */ - __( '%1$s #%2$s - %3$s', 'linked-orders-for-woocommerce' ), - dws_lowc_get_singular_name( $this->post_type ), - $this->order->get_order_number(), - $status_name - ); - - /** - * Filters an order's formatted name. - * - * @since 1.0.0 - * @version 2.0.0 - * @deprecated 2.0.0 Use dws_lowc/node/formatted_name. - * - * @param string $name The formatted name. - * @param DWS_Order_Node $node The order's node. - */ - $name = apply_filters_deprecated( 'linked_orders_for_woocommerce/node/get_formatted_name', array( $name, $this ), '2.0.0', 'dws_lowc/node/formatted_name' ); - - /** - * Filters an order's formatted name. - * - * @since 2.0.0 - * @version 2.0.0 - * - * @param string $name The formatted name. - * @param DWS_Order_Node $node The order's node. - */ - return apply_filters( 'dws_lowc/node/formatted_name', $name, $this ); - } - /** * Returns whether a user may create a child of the order: the user may edit the order and create linked children, the order is above the maximum depth, and its status allows a new child. * diff --git a/plugins/linked-orders-for-woocommerce/src/Autocompletion.php b/plugins/linked-orders-for-woocommerce/src/Autocompletion.php index 496a055..72d050e 100644 --- a/plugins/linked-orders-for-woocommerce/src/Autocompletion.php +++ b/plugins/linked-orders-for-woocommerce/src/Autocompletion.php @@ -87,7 +87,12 @@ public function complete_descendants( int $order_id ): void { * @param bool $should_autocomplete Whether the descendant is completed. * @param int $descendant_id The descendant's id. */ - $node = true === apply_filters( 'dws_lowc/autocompletion/should_autocomplete', $should_autocomplete, $descendant_id ) ? dws_lowc_get_order_node( $descendant_id ) : null; + $should_autocomplete = apply_filters( 'dws_lowc/autocompletion/should_autocomplete', $should_autocomplete, $descendant_id ); + if ( true !== $should_autocomplete ) { + continue; + } + + $node = dws_lowc_get_order_node( $descendant_id ); if ( \is_null( $node ) ) { continue; } diff --git a/plugins/linked-orders-for-woocommerce/src/ChildCreation.php b/plugins/linked-orders-for-woocommerce/src/ChildCreation.php index fe1a5ce..826516f 100644 --- a/plugins/linked-orders-for-woocommerce/src/ChildCreation.php +++ b/plugins/linked-orders-for-woocommerce/src/ChildCreation.php @@ -67,8 +67,7 @@ public function create_linked_child(): never { ) ?? 0; $node = dws_lowc_get_order_node( $parent_id ); if ( \is_null( $node ) ) { - /* translators: %d: The parent order's id. */ - $this->notices->add( new Notice( 'linked-child-missing-parent', esc_html( \sprintf( __( 'There is no order %d to create a linked child for.', 'linked-orders-for-woocommerce' ), $parent_id ) ) ) ); + $this->notices->add( new Notice( 'linked-child-missing-parent', esc_html( \sprintf( /* translators: %d: The parent order's id. */ __( 'There is no order %d to create a linked child for.', 'linked-orders-for-woocommerce' ), $parent_id ) ) ) ); wp_safe_redirect( wp_get_referer() ?: admin_url( 'index.php' ) ); exit; } @@ -84,8 +83,7 @@ public function create_linked_child(): never { } $reason = is_wp_error( $child ) ? $child->get_error_message() : __( 'Its creator returned no order.', 'linked-orders-for-woocommerce' ); - /* translators: 1: The parent order's number. 2: Why the child could not be created. */ - $this->notices->add( new Notice( 'linked-child-failed', esc_html( \sprintf( __( 'The linked child of order #%1$s could not be created: %2$s', 'linked-orders-for-woocommerce' ), $node->get_order()->get_order_number(), $reason ) ) ) ); + $this->notices->add( new Notice( 'linked-child-failed', esc_html( \sprintf( /* translators: 1: The parent order's number. 2: Why the child could not be created. */ __( 'The linked child of order #%1$s could not be created: %2$s', 'linked-orders-for-woocommerce' ), $node->get_order()->get_order_number(), $reason ) ) ) ); } wp_safe_redirect( wp_get_referer() ?: $node->get_order()->get_edit_order_url() ); diff --git a/plugins/linked-orders-for-woocommerce/src/ListTable.php b/plugins/linked-orders-for-woocommerce/src/ListTable.php index 8a715ec..ca27347 100644 --- a/plugins/linked-orders-for-woocommerce/src/ListTable.php +++ b/plugins/linked-orders-for-woocommerce/src/ListTable.php @@ -48,7 +48,7 @@ public function register_hooks(): void { * @return void */ public function add_depth_column( \WP_Screen $screen ): void { - $type = $this->can_see() ? self::get_list_type( $screen ) : null; + $type = $this->can_see() ? $this->get_list_type( $screen ) : null; if ( \is_null( $type ) ) { return; } @@ -68,7 +68,7 @@ public function add_depth_column( \WP_Screen $screen ): void { * @return array */ public function insert_depth_column( array $columns ): array { - $type = self::get_list_type( get_current_screen() ); + $type = $this->get_list_type( get_current_screen() ); /** * Filters the id of the column the depth column follows. @@ -117,8 +117,11 @@ public function render_depth_cell( string $column, mixed $post_or_order ): void } $depth = $node->get_depth(); - /* translators: %d: The order's level, where the root is level 1. */ - $label = 0 === $depth ? _x( 'Root order', 'list table content', 'linked-orders-for-woocommerce' ) : \sprintf( _x( 'Child order (level %d)', 'list table content', 'linked-orders-for-woocommerce' ), $depth + 1 ); + $label = 0 === $depth ? _x( 'Root order', 'list table content', 'linked-orders-for-woocommerce' ) : \sprintf( + /* translators: %d: The order's level, where the root is level 1. */ + _x( 'Child order (level %d)', 'list table content', 'linked-orders-for-woocommerce' ), + $depth + 1 + ); /** * Filters an order's depth label in the orders list. @@ -155,39 +158,55 @@ public function render_depth_cell( string $column, mixed $post_or_order ): void * @return void */ public function render_filters( string $type ): void { - $post_type = $this->can_see() && \in_array( $type, dws_lowc_get_supported_order_types(), true ) ? get_post_type_object( $type ) : null; - if ( \is_null( $post_type ) ) { + $post_type = \in_array( $type, dws_lowc_get_supported_order_types(), true ) ? get_post_type_object( $type ) : null; + if ( ! $this->can_see() || \is_null( $post_type ) ) { return; } - $selected = self::get_depth_filter(); - $name = dws_lowc_get_singular_name( $post_type ); + // The Trash view lists trashed orders, and another plugin's order type may register its statuses only as post statuses, so the deepest stored order is read in every status. + $depth_key = OrderUtil::custom_orders_table_usage_is_enabled() ? array( 'meta_query' => array( array( 'key' => '_dws_lo_depth' ) ) ) : array( 'meta_key' => '_dws_lo_depth' ); // phpcs:ignore WordPress.DB.SlowDBQuery -- One row, ordered by the depth meta. + $deepest = wc_get_orders( + $depth_key + array( + 'type' => $type, + 'status' => \array_values( get_post_stati() ), + 'limit' => 1, + 'return' => 'ids', + 'orderby' => 'meta_value_num', + 'order' => 'DESC', + ) + ); + $deepest = \max( dws_lowc_get_max_depth(), ( \is_array( $deepest ) && array() !== $deepest ? dws_lowc_get_order_node( \reset( $deepest ) )?->get_depth() : null ) ?? 0 ); + $selected = $this->get_depth_filter(); + $email = $this->get_guest_email(); + $pivot = $this->get_tree_pivot(); ?> - - - - + + + get_order_number() : $pivot ); + $order = wc_get_order( $pivot ); + $lines['_dws_linked_order_id'] = \sprintf( + /* translators: %s: The order number. */ + __( 'Showing orders linked to #%s.', 'linked-orders-for-woocommerce' ), + $order instanceof \WC_Order ? $order->get_order_number() : $pivot + ); } foreach ( $lines as $key => $line ) { @@ -216,8 +235,8 @@ public function filter_posts_query( array $query_vars ): array { return $query_vars; } - $clauses = self::get_depth_clauses(); - $email = self::get_query_arg( '_guest_customer_user' ); + $clauses = $this->get_depth_clauses(); + $email = $this->get_guest_email(); if ( '' !== $email ) { $clauses[] = array( 'key' => '_billing_email', @@ -226,9 +245,9 @@ public function filter_posts_query( array $query_vars ): array { } if ( array() !== $clauses ) { - $query_vars['meta_query'] = self::add_meta_clauses( $query_vars['meta_query'] ?? null, $clauses ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- The filters are meta values. + $query_vars['meta_query'] = $this->add_meta_clauses( $query_vars['meta_query'] ?? null, $clauses ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- The filters are meta values. } - $tree = self::get_tree_filter( $query_vars['post__in'] ?? null ); + $tree = $this->get_tree_filter( $query_vars['post__in'] ?? null ); if ( ! \is_null( $tree ) ) { $query_vars['post__in'] = $tree; } @@ -251,17 +270,17 @@ public function filter_orders_query( array $args ): array { return $args; } - $email = self::get_query_arg( '_guest_customer_user' ); + $email = $this->get_guest_email(); if ( '' !== $email ) { $args['billing_email'] = $email; } // WooCommerce counts the list from its cache only when the query holds none but its own arguments, so an unfiltered list gets no meta query. - $clauses = self::get_depth_clauses(); + $clauses = $this->get_depth_clauses(); if ( array() !== $clauses ) { - $args['meta_query'] = self::add_meta_clauses( $args['meta_query'] ?? null, $clauses ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- The filters are meta values. + $args['meta_query'] = $this->add_meta_clauses( $args['meta_query'] ?? null, $clauses ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- The filters are meta values. } - $tree = self::get_tree_filter( $args['id'] ?? null ); + $tree = $this->get_tree_filter( $args['id'] ?? null ); if ( ! \is_null( $tree ) ) { $args['id'] = $tree; } @@ -281,19 +300,24 @@ public function filter_orders_query( array $args ): array { * @return array> */ public function add_row_actions( array $actions, \WC_Order $order ): array { - $node = $this->can_see() ? dws_lowc_get_order_node( $order ) : null; + if ( ! $this->can_see() ) { + return $actions; + } + + $node = dws_lowc_get_order_node( $order ); if ( \is_null( $node ) ) { return $actions; } - $added = array(); - $new_tab = __( '(opens in a new tab)' ); // phpcs:ignore WordPress.WP.I18n.MissingArgDomain -- Core's translation of the cue. - $list = dws_lowc_get_list_url( $node->get_post_type() ); - $customers = 0 !== $order->get_customer_id() ? array( '_customer_user', (string) $order->get_customer_id() ) : array( '_guest_customer_user', \rawurlencode( $order->get_billing_email() ) ); - if ( '' !== $customers[1] && '' === self::get_query_arg( $customers[0] ) ) { + $added = array(); + $new_tab = __( '(opens in a new tab)' ); // phpcs:ignore WordPress.WP.I18n.MissingArgDomain -- Core's translation of the cue. + $list = dws_lowc_get_list_url( $node->get_post_type() ); + $customer_key = 0 !== $order->get_customer_id() ? '_customer_user' : '_guest_customer_user'; + $customer_value = 0 !== $order->get_customer_id() ? (string) $order->get_customer_id() : \rawurlencode( $order->get_billing_email() ); + if ( '' !== $customer_value && ( ! isset( $_GET[ $customer_key ] ) || '' === $_GET[ $customer_key ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- A list filter, which changes nothing. $name = __( 'View all customer orders', 'linked-orders-for-woocommerce' ); $added['view_all_customer_orders'] = array( - 'url' => add_query_arg( $customers[0], $customers[1], $list ), + 'url' => add_query_arg( $customer_key, $customer_value, $list ), 'name' => $name, 'title' => "{$name} {$new_tab}", 'action' => 'view-all-customer-orders', @@ -302,7 +326,7 @@ public function add_row_actions( array $actions, \WC_Order $order ): array { // A list page builds every row, so the stored links decide, and a tree whose related order was deleted shows as one order. $children = $order->get_meta( '_dws_lo_children' ); - if ( '' === self::get_query_arg( '_dws_linked_order_id' ) && ( 0 < $node->get_depth() || ( \is_array( $children ) && array() !== $children ) ) ) { + if ( \is_null( $this->get_tree_pivot() ) && ( 0 < $node->get_depth() || ( \is_array( $children ) && array() !== $children ) ) ) { $name = __( 'View all linked orders', 'linked-orders-for-woocommerce' ); $added['view_all_linked_orders'] = array( 'url' => add_query_arg( '_dws_linked_order_id', $order->get_id(), $list ), @@ -332,7 +356,7 @@ public function add_row_actions( array $actions, \WC_Order $order ): array { * @return void */ public function enqueue_assets(): void { - if ( ! $this->can_see() || \is_null( self::get_list_type( get_current_screen() ) ) ) { + if ( ! $this->can_see() || \is_null( $this->get_list_type( get_current_screen() ) ) ) { return; } @@ -371,7 +395,7 @@ protected function can_see(): bool { * * @return string|null */ - protected static function get_list_type( ?\WP_Screen $screen ): ?string { + protected function get_list_type( ?\WP_Screen $screen ): ?string { foreach ( dws_lowc_get_supported_order_types() as $type ) { if ( ! \is_null( $screen ) && ( OrderUtil::custom_orders_table_usage_is_enabled() ? wc_get_page_screen_id( $type ) : "edit-$type" ) === $screen->id ) { return $type; @@ -381,33 +405,6 @@ protected static function get_list_type( ?\WP_Screen $screen ): ?string { return null; } - /** - * Returns the deepest level the depth filter offers: the maximum depth, or the deepest stored order of the type where one is deeper, in any registered status, since the Trash view lists trashed orders and another plugin's order type may register its statuses only as post statuses. - * - * @since 2.0.0 - * @version 2.0.0 - * - * @param string $type The order type. - * - * @return int - */ - protected static function get_deepest_level( string $type ): int { - // The order tables take the depth key only in a meta query, which the posts store refuses, passing a top-level key to WP_Query instead. - $depth = OrderUtil::custom_orders_table_usage_is_enabled() ? array( 'meta_query' => array( array( 'key' => '_dws_lo_depth' ) ) ) : array( 'meta_key' => '_dws_lo_depth' ); // phpcs:ignore WordPress.DB.SlowDBQuery -- One row, ordered by the depth meta. - $deepest = wc_get_orders( - $depth + array( - 'type' => $type, - 'status' => \array_values( get_post_stati() ), - 'limit' => 1, - 'return' => 'ids', - 'orderby' => 'meta_value_num', - 'order' => 'DESC', - ) - ); - $stored = \is_array( $deepest ) && array() !== $deepest ? dws_lowc_get_order_node( \reset( $deepest ) )?->get_depth() : null; - return \max( dws_lowc_get_max_depth(), $stored ?? 0 ); - } - /** * Returns the depth the list is filtered by, or null when it is not filtered by depth. * @@ -416,9 +413,15 @@ protected static function get_deepest_level( string $type ): int { * * @return int|null */ - protected static function get_depth_filter(): ?int { - $depth = \filter_var( self::get_query_arg( '_dws_lo_depth' ), \FILTER_VALIDATE_INT, array( 'options' => array( 'min_range' => 0 ) ) ); - return false === $depth ? null : $depth; + protected function get_depth_filter(): ?int { + return \filter_var( + wp_unslash( $_GET['_dws_lo_depth'] ?? '' ), // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- A list filter, which changes nothing. + \FILTER_VALIDATE_INT, + array( + 'options' => array( 'min_range' => 0 ), + 'flags' => \FILTER_NULL_ON_FAILURE, + ) + ); } /** @@ -429,8 +432,8 @@ protected static function get_depth_filter(): ?int { * * @return list> */ - protected static function get_depth_clauses(): array { - $depth = self::get_depth_filter(); + protected function get_depth_clauses(): array { + $depth = $this->get_depth_filter(); if ( \is_null( $depth ) ) { return array(); } @@ -462,7 +465,7 @@ protected static function get_depth_clauses(): array { * * @return array */ - protected static function add_meta_clauses( mixed $meta_query, array $clauses ): array { + protected function add_meta_clauses( mixed $meta_query, array $clauses ): array { return \array_merge( array( 'relation' => 'AND' ), \is_array( $meta_query ) && array() !== $meta_query ? array( $meta_query ) : array(), $clauses ); } @@ -476,8 +479,8 @@ protected static function add_meta_clauses( mixed $meta_query, array $clauses ): * * @return list|null */ - protected static function get_tree_filter( mixed $found ): ?array { - $pivot = self::get_tree_pivot(); + protected function get_tree_filter( mixed $found ): ?array { + $pivot = $this->get_tree_pivot(); if ( \is_null( $pivot ) ) { return null; } @@ -501,23 +504,27 @@ protected static function get_tree_filter( mixed $found ): ?array { * * @return int|null */ - protected static function get_tree_pivot(): ?int { - $pivot = \filter_var( self::get_query_arg( '_dws_linked_order_id' ), \FILTER_VALIDATE_INT, array( 'options' => array( 'min_range' => 1 ) ) ); - return false === $pivot ? null : $pivot; + protected function get_tree_pivot(): ?int { + return \filter_var( + wp_unslash( $_GET['_dws_linked_order_id'] ?? '' ), // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- A list filter, which changes nothing. + \FILTER_VALIDATE_INT, + array( + 'options' => array( 'min_range' => 1 ), + 'flags' => \FILTER_NULL_ON_FAILURE, + ) + ); } /** - * Returns a list filter from the request, sanitized, or an empty string when it is absent. + * Returns the billing email the list is filtered by, or an empty string when it is not filtered by one. * * @since 2.0.0 * @version 2.0.0 * - * @param string $key The filter's query key. - * * @return string */ - protected static function get_query_arg( string $key ): string { - return isset( $_GET[ $key ] ) && \is_string( $_GET[ $key ] ) ? sanitize_text_field( wp_unslash( $_GET[ $key ] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- A list filter, which changes nothing. + protected function get_guest_email(): string { + return \is_string( $_GET['_guest_customer_user'] ?? null ) ? sanitize_text_field( wp_unslash( $_GET['_guest_customer_user'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- A list filter, which changes nothing. } // endregion diff --git a/plugins/linked-orders-for-woocommerce/src/MetaBox.php b/plugins/linked-orders-for-woocommerce/src/MetaBox.php index db9de45..2e8e6a9 100644 --- a/plugins/linked-orders-for-woocommerce/src/MetaBox.php +++ b/plugins/linked-orders-for-woocommerce/src/MetaBox.php @@ -42,13 +42,26 @@ public function register_hooks(): void { * @return void */ public function add_meta_box( string $screen_id, mixed $post_or_order ): void { - $node = current_user_can( DWS_LOWC_CAP_SEE_ORDER_LINKS ) && current_user_can( DWS_LOWC_CAP_SEE_METABOX ) ? dws_lowc_get_order_node( $post_or_order ) : null; + if ( ! current_user_can( DWS_LOWC_CAP_SEE_ORDER_LINKS ) || ! current_user_can( DWS_LOWC_CAP_SEE_METABOX ) ) { + return; + } + + $node = dws_lowc_get_order_node( $post_or_order ); if ( \is_null( $node ) ) { return; } - /* translators: %s: The order type's plural name. */ - add_meta_box( 'dws-linked-orders', \sprintf( _x( 'Linked %s', 'metabox heading', 'linked-orders-for-woocommerce' ), $node->get_post_type()->label ), array( $this, 'render_meta_box' ), $screen_id, 'side' ); + add_meta_box( + 'dws-linked-orders', + \sprintf( + /* translators: %s: The order type's plural name. */ + _x( 'Linked %s', 'metabox heading', 'linked-orders-for-woocommerce' ), + $node->get_post_type()->label + ), + array( $this, 'render_meta_box' ), + $screen_id, + 'side' + ); } /** diff --git a/plugins/linked-orders-for-woocommerce/src/Settings.php b/plugins/linked-orders-for-woocommerce/src/Settings.php index 1b44c20..4adca7f 100644 --- a/plugins/linked-orders-for-woocommerce/src/Settings.php +++ b/plugins/linked-orders-for-woocommerce/src/Settings.php @@ -56,8 +56,46 @@ public function register_hooks(): void { */ #[\Override] public function get_section_fields( string $tab_id, string $section_id ): array { + // v1's groups, with checkboxes in place of its yes-or-no selects. + $groups = array( + 'general' => array( + 'title' => __( 'General settings', 'linked-orders-for-woocommerce' ), + 'fields' => array( + 'max-depth' => array( + 'title' => __( 'Maximum linked orders depth', 'linked-orders-for-woocommerce' ), + 'type' => 'number', + 'custom_attributes' => array( + 'min' => '1', + 'max' => '99', + 'step' => '1', + ), + 'default' => (string) DWS_LOWC_OPTION_DEFAULTS[ DWS_LOWC_OPTION_MAX_DEPTH ], + 'desc' => __( 'How many levels of children an order can have below it, such as 2 for children and grandchildren.', 'linked-orders-for-woocommerce' ), + 'validate' => 'dws_lowc_validate_max_depth', + ), + 'autocomplete-descendants' => array( + 'title' => __( 'Autocomplete descendant orders', 'linked-orders-for-woocommerce' ), + 'type' => 'checkbox', + 'desc' => __( 'Set every descendant order to completed when its parent order is completed', 'linked-orders-for-woocommerce' ), + 'default' => wc_bool_to_string( DWS_LOWC_OPTION_DEFAULTS[ DWS_LOWC_OPTION_AUTOCOMPLETE ] ), + ), + ), + ), + 'plugin' => array( + 'title' => __( 'Plugin settings', 'linked-orders-for-woocommerce' ), + 'fields' => array( + 'remove-data-uninstall' => array( + 'title' => __( 'Remove all data on uninstallation', 'linked-orders-for-woocommerce' ), + 'type' => 'checkbox', + 'desc' => __( 'Delete the plugin\'s settings and the links between orders when the plugin is deleted. This cannot be undone.', 'linked-orders-for-woocommerce' ), + 'default' => wc_bool_to_string( DWS_LOWC_OPTION_DEFAULTS[ DWS_LOWC_OPTION_DELETE_DATA ] ), + ), + ), + ), + ); + $fields = array(); - foreach ( DWS_LOWC_SETTINGS_GROUPS as $group ) { + foreach ( $groups as $group => $definition ) { /** * Filters a group's fields, keyed by their v1 ids. * @@ -67,7 +105,7 @@ public function get_section_fields( string $tab_id, string $section_id ): array * * @param array $fields The group's fields. */ - $group_fields = apply_filters_deprecated( "linked_orders_for_woocommerce/{$group}_settings/get_group_fields", array( $this->get_group_fields( $group ) ), '2.0.0', "dws_lowc/settings/{$group}_fields" ); + $group_fields = apply_filters_deprecated( "linked_orders_for_woocommerce/{$group}_settings/get_group_fields", array( $definition['fields'] ), '2.0.0', "dws_lowc/settings/{$group}_fields" ); $wc_fields = array(); foreach ( $group_fields as $id => $field ) { @@ -90,7 +128,7 @@ public function get_section_fields( string $tab_id, string $section_id ): array array( 'type' => 'title', 'id' => "dws-lowc_{$group}_start", - 'title' => 'general' === $group ? __( 'General settings', 'linked-orders-for-woocommerce' ) : __( 'Plugin settings', 'linked-orders-for-woocommerce' ), + 'title' => $definition['title'], ), ), $wc_fields, @@ -167,52 +205,4 @@ public function add_support_link( array $meta, string $basename ): array { } // endregion - - // region HELPERS - - /** - * Returns a group's fields, keyed by their v1 ids, as v1 defined them but for checkboxes in place of yes-or-no selects. - * - * @since 2.0.0 - * @version 2.0.0 - * - * @param string $group The group's v1 id. - * - * @return array - */ - protected function get_group_fields( string $group ): array { - if ( 'general' === $group ) { - return array( - 'max-depth' => array( - 'title' => __( 'Maximum linked orders depth', 'linked-orders-for-woocommerce' ), - 'type' => 'number', - 'custom_attributes' => array( - 'min' => '1', - 'max' => '99', - 'step' => '1', - ), - 'default' => (string) DWS_LOWC_OPTION_DEFAULTS[ DWS_LOWC_OPTION_MAX_DEPTH ], - 'desc' => __( 'How many levels of children an order can have below it, such as 2 for children and grandchildren.', 'linked-orders-for-woocommerce' ), - 'validate' => 'dws_lowc_validate_max_depth', - ), - 'autocomplete-descendants' => array( - 'title' => __( 'Autocomplete descendant orders', 'linked-orders-for-woocommerce' ), - 'type' => 'checkbox', - 'desc' => __( 'Set every descendant order to completed when its parent order is completed', 'linked-orders-for-woocommerce' ), - 'default' => wc_bool_to_string( DWS_LOWC_OPTION_DEFAULTS[ DWS_LOWC_OPTION_AUTOCOMPLETE ] ), - ), - ); - } - - return array( - 'remove-data-uninstall' => array( - 'title' => __( 'Remove all data on uninstallation', 'linked-orders-for-woocommerce' ), - 'type' => 'checkbox', - 'desc' => __( 'Delete the plugin\'s settings and the links between orders when the plugin is deleted. This cannot be undone.', 'linked-orders-for-woocommerce' ), - 'default' => wc_bool_to_string( DWS_LOWC_OPTION_DEFAULTS[ DWS_LOWC_OPTION_DELETE_DATA ] ), - ), - ); - } - - // endregion } From e782b5f09aa28592ae5939bf5689238d8df362a3 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Thu, 12 Nov 2026 12:04:58 +0100 Subject: [PATCH 36/50] Cut Linked Orders' docblock summaries to their roles Summaries narrated their code's branches at twice v1's length, the footprint's return shape named six of its ten keys, and docblocks used semicolons and Markdown backticks, with the settings key pattern written twice (A314-A316, X213). Each long summary names its role, and a second paragraph keeps the one why or the return cases a caller needs; the footprint's shape lists all ten keys in the array's order; the semicolons and backticks go, and the key pattern stays only at dws_lowc_find_setting(). Assisted-by: Claude Code:claude-opus-5-5 --- .../includes/deprecated.php | 4 +++- .../includes/footprint.php | 2 +- .../includes/linking.php | 8 ++++++-- .../includes/settings.php | 8 +++++--- .../models/DWS_Order_Node.php | 12 +++++++++--- .../src/Autocompletion.php | 4 +++- .../src/ChildCreation.php | 4 +++- .../src/Integration/SequentialOrderNumbersPro.php | 4 +++- .../linked-orders-for-woocommerce/src/ListTable.php | 8 ++++++-- 9 files changed, 39 insertions(+), 15 deletions(-) diff --git a/plugins/linked-orders-for-woocommerce/includes/deprecated.php b/plugins/linked-orders-for-woocommerce/includes/deprecated.php index ca9558d..72f08e1 100644 --- a/plugins/linked-orders-for-woocommerce/includes/deprecated.php +++ b/plugins/linked-orders-for-woocommerce/includes/deprecated.php @@ -215,7 +215,9 @@ function dws_lowc_get_component_hook_tag( string $component_id, string $name, $e } /** - * Returns a hook name joined and sanitized as v1 did: the non-empty parts joined with slashes, spaces and backslashes as underscores, bytes below 32 and above 127 stripped, lowercased. + * Returns a hook name joined and sanitized as v1 did. + * + * Its non-empty parts are joined with slashes, spaces and backslashes become underscores, bytes below 32 and above 127 go, and the name is lowercased. * * @since 2.0.0 * @version 2.0.0 diff --git a/plugins/linked-orders-for-woocommerce/includes/footprint.php b/plugins/linked-orders-for-woocommerce/includes/footprint.php index 6e0c0b2..57a4418 100644 --- a/plugins/linked-orders-for-woocommerce/includes/footprint.php +++ b/plugins/linked-orders-for-woocommerce/includes/footprint.php @@ -20,7 +20,7 @@ * @since 2.0.0 * @version 2.0.0 * - * @return array{option_prefixes: list, user_meta_site_prefixes: list, user_meta_global_prefixes: list, network_options: list, cron_hooks: list, as_groups: list} + * @return array{option_prefixes: list, options: list, meta_prefixes: array>, user_meta_site_prefixes: list, user_meta_global_prefixes: list, user_meta_global: list, network_options: list, capabilities: list, cron_hooks: list, as_groups: list} */ function dws_lowc_get_footprint(): array { return array( diff --git a/plugins/linked-orders-for-woocommerce/includes/linking.php b/plugins/linked-orders-for-woocommerce/includes/linking.php index 9428b53..dfc104f 100644 --- a/plugins/linked-orders-for-woocommerce/includes/linking.php +++ b/plugins/linked-orders-for-woocommerce/includes/linking.php @@ -52,7 +52,9 @@ function dws_lowc_is_root_order( $order ): ?bool { } /** - * Links one order as the child of another, returning false unless the child is another order with neither a parent nor children, when another request still links orders after five seconds, or when the child's save does not store its parent, and null when either order has no node. + * Links two orders as parent and child. + * + * It returns false when the child is the parent or has a parent or children, when another request still links orders after five seconds, or when the child's save does not store its parent, and null when either order has no node. * * A link reads and rewrites the parent's list of children, so it holds the linking lock and reads both orders' meta from the database under it. WooCommerce logs a save that fails and returns as if it had saved, so the parent lists the child only once the child's parent reads back from the database. * @@ -226,7 +228,9 @@ function dws_lowc_refresh_links( WC_Order ...$orders ): void { } /** - * Returns the node at the top of an order's tree, which is the order's own for a root or for a child whose parent was deleted, or null when the order has no node. + * Returns the node at the top of an order's tree, or null when the order has no node. + * + * A root is its own top, and so is a child whose parent was deleted. * * The walk stops below an order it has already visited, since v1 stored links without refusing a cycle. * diff --git a/plugins/linked-orders-for-woocommerce/includes/settings.php b/plugins/linked-orders-for-woocommerce/includes/settings.php index 1da4c7c..ff207b9 100644 --- a/plugins/linked-orders-for-woocommerce/includes/settings.php +++ b/plugins/linked-orders-for-woocommerce/includes/settings.php @@ -11,7 +11,7 @@ defined( 'ABSPATH' ) || exit; /** - * The ids of v1's settings groups, in the order the plugin's section shows them; a field of a group lives in the option `dws-lowc_{group}_{field}`. + * The ids of v1's settings groups, in the order the plugin's section shows them. * * @since 2.0.0 * @version 2.0.0 @@ -129,7 +129,9 @@ function dws_lowc_validate_setting( $value, string $field_id, ?string $group = n } /** - * Returns the deepest level a linked order can be created at: the validated setting, at least 1 as stored, or what v1's and v2's filters make it. + * Returns the deepest level a linked order can be created at. + * + * It reads the validated setting, at least 1 as stored, through v1's and v2's filters. * * @since 2.0.0 * @version 2.0.0 @@ -143,7 +145,7 @@ function dws_lowc_get_max_depth(): int { /** * Returns a setting's group without its '-settings' suffix, its field id and its option, which is null for a group v1 did not have. * - * As in v1, a read without a group names the group in the field id, such as 'general/max-depth', and a field an extension adds to a group without an id of its own lives in the option WooCommerce saves it under, `dws-lowc_{group}_{field}`. An id read without a group that names none has an empty group. + * As in v1, a read without a group names the group in the field id, such as 'general/max-depth', and a field an extension adds to a group without an id of its own lives in the option WooCommerce saves it under, dws-lowc_{group}_{field}. An id read without a group that names none has an empty group. * * @since 2.0.0 * @version 2.0.0 diff --git a/plugins/linked-orders-for-woocommerce/models/DWS_Order_Node.php b/plugins/linked-orders-for-woocommerce/models/DWS_Order_Node.php index e95836f..b83ddcf 100644 --- a/plugins/linked-orders-for-woocommerce/models/DWS_Order_Node.php +++ b/plugins/linked-orders-for-woocommerce/models/DWS_Order_Node.php @@ -3,7 +3,9 @@ defined( 'ABSPATH' ) || exit; /** - * Wraps an order of a supported type with its place in a tree of linked orders, which it reads from the order's meta once and saves back to it; it loads its parent's and children's orders only when asked for them. + * Wraps an order of a supported type with its place in a tree of linked orders. + * + * It reads the place from the order's meta once and saves it back, and loads its parent's and children's orders only when asked for them. * * It keeps v1's global name, which other plugins type-hint, and calls to it that name no method of its own reach the order. * @@ -123,7 +125,9 @@ public function __construct( mixed $order ) { } /** - * Returns a property of the order through WooCommerce's legacy getter, which reads every order property, or null when the order has none of that name. + * Returns a property of the order, or null when the order has none of that name. + * + * WooCommerce's legacy getter reads every order property. * * @since 1.0.0 * @version 1.0.0 @@ -393,7 +397,9 @@ public function add_child( DWS_Order_Node $child ): bool { } /** - * Returns whether a user may create a child of the order: the user may edit the order and create linked children, the order is above the maximum depth, and its status allows a new child. + * Returns whether a user may create a child of the order. + * + * The user may edit the order and create linked children, the order is above the maximum depth, and its status allows a new child. * * @since 1.0.0 * @version 2.0.0 diff --git a/plugins/linked-orders-for-woocommerce/src/Autocompletion.php b/plugins/linked-orders-for-woocommerce/src/Autocompletion.php index 72d050e..0e44de6 100644 --- a/plugins/linked-orders-for-woocommerce/src/Autocompletion.php +++ b/plugins/linked-orders-for-woocommerce/src/Autocompletion.php @@ -45,7 +45,9 @@ public function register_hooks(): void { // region HOOKS /** - * Sets the order and each of its descendants, the order first, to completed, with a note, unless a filter keeps one or names another status; the order itself, already completed, changes only through a filter. + * Completes an order's descendants. + * + * Each order of the tree, the completed one first, is set to completed with a note, unless a filter keeps it or names another status. The completed order itself changes only through a filter. * * Completing a descendant fires this hook again, so an order a pass in progress covers starts no pass of its own. * diff --git a/plugins/linked-orders-for-woocommerce/src/ChildCreation.php b/plugins/linked-orders-for-woocommerce/src/ChildCreation.php index 826516f..ddccea6 100644 --- a/plugins/linked-orders-for-woocommerce/src/ChildCreation.php +++ b/plugins/linked-orders-for-woocommerce/src/ChildCreation.php @@ -47,7 +47,9 @@ public function register_hooks(): void { // region HOOKS /** - * Creates a linked child of the requested order and opens it, or returns to the referring screen, or else the parent, with a notice saying why it was not created. + * Creates a linked child of the requested order and opens it. + * + * Otherwise the request returns to the referring screen, or else the parent, with a notice that says why. * * @since 2.0.0 * @version 2.0.0 diff --git a/plugins/linked-orders-for-woocommerce/src/Integration/SequentialOrderNumbersPro.php b/plugins/linked-orders-for-woocommerce/src/Integration/SequentialOrderNumbersPro.php index fe322f4..8008638 100644 --- a/plugins/linked-orders-for-woocommerce/src/Integration/SequentialOrderNumbersPro.php +++ b/plugins/linked-orders-for-woocommerce/src/Integration/SequentialOrderNumbersPro.php @@ -8,7 +8,9 @@ \defined( 'ABSPATH' ) || exit; /** - * Gives a linked child its sequential number from WooCommerce Sequential Order Numbers Pro, which numbers only orders created through checkout or the editor. + * Gives a linked child its sequential number from WooCommerce Sequential Order Numbers Pro. + * + * That plugin numbers only orders created through checkout or the editor. * * @since 2.0.0 * @version 2.0.0 diff --git a/plugins/linked-orders-for-woocommerce/src/ListTable.php b/plugins/linked-orders-for-woocommerce/src/ListTable.php index ca27347..93cb3a7 100644 --- a/plugins/linked-orders-for-woocommerce/src/ListTable.php +++ b/plugins/linked-orders-for-woocommerce/src/ListTable.php @@ -455,7 +455,9 @@ protected function get_depth_clauses(): array { } /** - * Returns a meta query that requires both the query already in place and each of the list's clauses, whatever relation the query in place uses. + * Returns a meta query that requires the query in place and each of the list's clauses. + * + * Joining both under AND holds whatever relation the query in place uses. * * @since 2.0.0 * @version 2.0.0 @@ -470,7 +472,9 @@ protected function add_meta_clauses( mixed $meta_query, array $clauses ): array } /** - * Returns the ids of the tree the list is filtered by, narrowed to the ids a search found, none for an order without a node, or null when the list is not filtered by tree. + * Returns the ids of the tree the list is filtered by, or null when the list is not filtered by tree. + * + * The ids narrow to those a search found, and an order without a node yields none. * * @since 2.0.0 * @version 2.0.0 From 7a7f2c464409a4077f9ad1108ef39d013bab0e9c Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Thu, 12 Nov 2026 12:07:40 +0100 Subject: [PATCH 37/50] Name the link meta keys and the add-child action as constants The readers repeated the option defaults as literals beside DWS_LOWC_OPTION_DEFAULTS, and the four link meta keys and the add-child action and nonce were literals spread across the components, the node and the includes, while Internal Comments and Locked Payment Methods name theirs (A318, A319, X214). The readers fall back to DWS_LOWC_OPTION_DEFAULTS, keeping the validator's floor of 1, and the validator compares its key strictly so PHPStan reads which default applies. DWS_LOWC_META_DEPTH, _PARENT, _CHILDREN and _CREATED_BY live in linking.php, and DWS_LOWC_CREATE_CHILD_ACTION and _NONCE in post-type.php, and every production site uses them; the list's depth query argument keeps its literal, since it names the filter rather than the meta key. Assisted-by: Claude Code:claude-opus-5-5 --- .../includes/linking.php | 42 ++++++++++++++++++- .../includes/migrations.php | 2 +- .../includes/post-type.php | 24 ++++++++++- .../includes/settings.php | 19 +++++---- .../models/DWS_Order_Node.php | 12 +++--- .../src/ChildCreation.php | 4 +- .../src/ListTable.php | 8 ++-- 7 files changed, 86 insertions(+), 25 deletions(-) diff --git a/plugins/linked-orders-for-woocommerce/includes/linking.php b/plugins/linked-orders-for-woocommerce/includes/linking.php index dfc104f..5a5d556 100644 --- a/plugins/linked-orders-for-woocommerce/includes/linking.php +++ b/plugins/linked-orders-for-woocommerce/includes/linking.php @@ -14,6 +14,46 @@ defined( 'ABSPATH' ) || exit; +/** + * The order meta key that holds an order's depth below its root, 0 for a root. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @var string + */ +const DWS_LOWC_META_DEPTH = '_dws_lo_depth'; + +/** + * The order meta key that holds the id of an order's parent, empty for a root. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @var string + */ +const DWS_LOWC_META_PARENT = '_dws_lo_parent'; + +/** + * The order meta key that holds the ids of an order's children. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @var string + */ +const DWS_LOWC_META_CHILDREN = '_dws_lo_children'; + +/** + * The order meta key that holds the id of the user who created a linked child. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @var string + */ +const DWS_LOWC_META_CREATED_BY = '_dws_lo_created_by'; + /** * Returns an order's node, or null when the value names no order of a supported type. * @@ -89,7 +129,7 @@ function dws_lowc_link_orders( int $parent_id, int $child_id ): ?bool { $child->save(); dws_lowc_refresh_links( $child->get_order() ); - if ( (string) $parent->get_order()->get_id() !== $child->get_order()->get_meta( '_dws_lo_parent' ) ) { + if ( (string) $parent->get_order()->get_id() !== $child->get_order()->get_meta( DWS_LOWC_META_PARENT ) ) { return false; } diff --git a/plugins/linked-orders-for-woocommerce/includes/migrations.php b/plugins/linked-orders-for-woocommerce/includes/migrations.php index 8d3eb17..c222b41 100644 --- a/plugins/linked-orders-for-woocommerce/includes/migrations.php +++ b/plugins/linked-orders-for-woocommerce/includes/migrations.php @@ -87,7 +87,7 @@ function dws_lowc_repair_links(): bool { $page = filter_var( get_option( 'dws_lowc_link_repair_page', 1 ), FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE ) ?? 1; // The order tables take the parent key only in a meta query, which the posts store refuses, passing a top-level key to WP_Query instead. - $linked = OrderUtil::custom_orders_table_usage_is_enabled() ? array( 'meta_query' => array( array( 'key' => '_dws_lo_parent' ) ) ) : array( 'meta_key' => '_dws_lo_parent' ); // phpcs:ignore WordPress.DB.SlowDBQuery -- A one-time walk of the linked orders. + $linked = OrderUtil::custom_orders_table_usage_is_enabled() ? array( 'meta_query' => array( array( 'key' => DWS_LOWC_META_PARENT ) ) ) : array( 'meta_key' => DWS_LOWC_META_PARENT ); // phpcs:ignore WordPress.DB.SlowDBQuery -- A one-time walk of the linked orders. $ids = wc_get_orders( $linked + array( 'type' => dws_lowc_get_supported_order_types(), diff --git a/plugins/linked-orders-for-woocommerce/includes/post-type.php b/plugins/linked-orders-for-woocommerce/includes/post-type.php index d29911f..d21a530 100644 --- a/plugins/linked-orders-for-woocommerce/includes/post-type.php +++ b/plugins/linked-orders-for-woocommerce/includes/post-type.php @@ -12,6 +12,26 @@ defined( 'ABSPATH' ) || exit; +/** + * The admin-post action that creates a linked child. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @var string + */ +const DWS_LOWC_CREATE_CHILD_ACTION = 'dws_lowc_create_linked_child'; + +/** + * The nonce action that guards the creation of a linked child. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @var string + */ +const DWS_LOWC_CREATE_CHILD_NONCE = 'dws_create_linked_child'; + /** * Returns the order types whose orders can be linked. * @@ -166,7 +186,7 @@ function dws_lowc_get_child_refusal( DWS_Order_Node $node ): string { * @return string */ function dws_lowc_get_create_child_url( int $parent_id ): string { - return wp_nonce_url( admin_url( 'admin-post.php?action=dws_lowc_create_linked_child&parent_id=' . $parent_id ), 'dws_create_linked_child' ); + return wp_nonce_url( admin_url( 'admin-post.php?action=' . DWS_LOWC_CREATE_CHILD_ACTION . '&parent_id=' . $parent_id ), DWS_LOWC_CREATE_CHILD_NONCE ); } /** @@ -240,7 +260,7 @@ function dws_lowc_create_linked_child( int $parent_id, array $args = array() ) { $child->set_address( $parent->get_address( 'billing' ), 'billing' ); $child->set_address( $parent->get_address( 'shipping' ), 'shipping' ); - $child->add_meta_data( '_dws_lo_created_by', (string) get_current_user_id() ); + $child->add_meta_data( DWS_LOWC_META_CREATED_BY, (string) get_current_user_id() ); /** * Fires when a linked child has been created, before it is saved and linked. diff --git a/plugins/linked-orders-for-woocommerce/includes/settings.php b/plugins/linked-orders-for-woocommerce/includes/settings.php index ff207b9..56d839d 100644 --- a/plugins/linked-orders-for-woocommerce/includes/settings.php +++ b/plugins/linked-orders-for-woocommerce/includes/settings.php @@ -117,15 +117,16 @@ function dws_lowc_get_validated_setting( string $field_id, ?string $group = null * @return mixed */ function dws_lowc_validate_setting( $value, string $field_id, ?string $group = null ) { - switch ( dws_lowc_find_setting( $field_id, $group )[2] ) { - case DWS_LOWC_OPTION_MAX_DEPTH: - return max( filter_var( $value, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE ) ?? 1, 1 ); - case DWS_LOWC_OPTION_AUTOCOMPLETE: - case DWS_LOWC_OPTION_DELETE_DATA: - return filter_var( $value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE ) ?? false; - default: - return $value; + $key = dws_lowc_find_setting( $field_id, $group )[2]; + if ( DWS_LOWC_OPTION_MAX_DEPTH === $key ) { + return max( filter_var( $value, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE ) ?? DWS_LOWC_OPTION_DEFAULTS[ $key ], 1 ); + } + + if ( DWS_LOWC_OPTION_AUTOCOMPLETE === $key || DWS_LOWC_OPTION_DELETE_DATA === $key ) { + return filter_var( $value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE ) ?? DWS_LOWC_OPTION_DEFAULTS[ $key ]; } + + return $value; } /** @@ -139,7 +140,7 @@ function dws_lowc_validate_setting( $value, string $field_id, ?string $group = n * @return int */ function dws_lowc_get_max_depth(): int { - return filter_var( dws_lowc_get_validated_setting( 'max-depth', 'general' ), FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE ) ?? 1; + return filter_var( dws_lowc_get_validated_setting( 'max-depth', 'general' ), FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE ) ?? DWS_LOWC_OPTION_DEFAULTS[ DWS_LOWC_OPTION_MAX_DEPTH ]; } /** diff --git a/plugins/linked-orders-for-woocommerce/models/DWS_Order_Node.php b/plugins/linked-orders-for-woocommerce/models/DWS_Order_Node.php index b83ddcf..1b0bcc1 100644 --- a/plugins/linked-orders-for-woocommerce/models/DWS_Order_Node.php +++ b/plugins/linked-orders-for-woocommerce/models/DWS_Order_Node.php @@ -452,9 +452,9 @@ public function can_create_child( ?int $user_id = null ): bool { * @return void */ public function save(): void { - $this->order->update_meta_data( '_dws_lo_depth', (string) $this->get_depth() ); - $this->order->update_meta_data( '_dws_lo_parent', (string) $this->get_parent()?->get_order()->get_id() ); - $this->order->update_meta_data( '_dws_lo_children', array_map( static fn ( DWS_Order_Node $child ): int => $child->get_order()->get_id(), $this->get_children() ) ); + $this->order->update_meta_data( DWS_LOWC_META_DEPTH, (string) $this->get_depth() ); + $this->order->update_meta_data( DWS_LOWC_META_PARENT, (string) $this->get_parent()?->get_order()->get_id() ); + $this->order->update_meta_data( DWS_LOWC_META_CHILDREN, array_map( static fn ( DWS_Order_Node $child ): int => $child->get_order()->get_id(), $this->get_children() ) ); $this->order->save(); } @@ -476,9 +476,9 @@ protected function maybe_read(): void { } $this->is_read = true; - $this->depth = filter_var( $this->order->get_meta( '_dws_lo_depth' ), FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE ) ?? 0; - $this->parent_id = filter_var( $this->order->get_meta( '_dws_lo_parent' ), FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE ); - $children = $this->order->get_meta( '_dws_lo_children' ); + $this->depth = filter_var( $this->order->get_meta( DWS_LOWC_META_DEPTH ), FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE ) ?? 0; + $this->parent_id = filter_var( $this->order->get_meta( DWS_LOWC_META_PARENT ), FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE ); + $children = $this->order->get_meta( DWS_LOWC_META_CHILDREN ); $this->child_ids = is_array( $children ) ? filter_var( $children, FILTER_VALIDATE_INT, FILTER_REQUIRE_ARRAY | FILTER_NULL_ON_FAILURE ) : array(); } diff --git a/plugins/linked-orders-for-woocommerce/src/ChildCreation.php b/plugins/linked-orders-for-woocommerce/src/ChildCreation.php index ddccea6..52fb1f0 100644 --- a/plugins/linked-orders-for-woocommerce/src/ChildCreation.php +++ b/plugins/linked-orders-for-woocommerce/src/ChildCreation.php @@ -39,7 +39,7 @@ public function __construct( protected AdminNotices $notices ) {} */ #[\Override] public function register_hooks(): void { - add_action( 'admin_post_dws_lowc_create_linked_child', array( $this, 'create_linked_child' ) ); + add_action( 'admin_post_' . DWS_LOWC_CREATE_CHILD_ACTION, array( $this, 'create_linked_child' ) ); } // endregion @@ -57,7 +57,7 @@ public function register_hooks(): void { * @return never */ public function create_linked_child(): never { - check_admin_referer( 'dws_create_linked_child' ); + check_admin_referer( DWS_LOWC_CREATE_CHILD_NONCE ); $parent_id = \filter_var( wp_unslash( $_GET['parent_id'] ?? '' ), diff --git a/plugins/linked-orders-for-woocommerce/src/ListTable.php b/plugins/linked-orders-for-woocommerce/src/ListTable.php index 93cb3a7..777133e 100644 --- a/plugins/linked-orders-for-woocommerce/src/ListTable.php +++ b/plugins/linked-orders-for-woocommerce/src/ListTable.php @@ -164,7 +164,7 @@ public function render_filters( string $type ): void { } // The Trash view lists trashed orders, and another plugin's order type may register its statuses only as post statuses, so the deepest stored order is read in every status. - $depth_key = OrderUtil::custom_orders_table_usage_is_enabled() ? array( 'meta_query' => array( array( 'key' => '_dws_lo_depth' ) ) ) : array( 'meta_key' => '_dws_lo_depth' ); // phpcs:ignore WordPress.DB.SlowDBQuery -- One row, ordered by the depth meta. + $depth_key = OrderUtil::custom_orders_table_usage_is_enabled() ? array( 'meta_query' => array( array( 'key' => DWS_LOWC_META_DEPTH ) ) ) : array( 'meta_key' => DWS_LOWC_META_DEPTH ); // phpcs:ignore WordPress.DB.SlowDBQuery -- One row, ordered by the depth meta. $deepest = wc_get_orders( $depth_key + array( 'type' => $type, @@ -325,7 +325,7 @@ public function add_row_actions( array $actions, \WC_Order $order ): array { } // A list page builds every row, so the stored links decide, and a tree whose related order was deleted shows as one order. - $children = $order->get_meta( '_dws_lo_children' ); + $children = $order->get_meta( DWS_LOWC_META_CHILDREN ); if ( \is_null( $this->get_tree_pivot() ) && ( 0 < $node->get_depth() || ( \is_array( $children ) && array() !== $children ) ) ) { $name = __( 'View all linked orders', 'linked-orders-for-woocommerce' ); $added['view_all_linked_orders'] = array( @@ -439,7 +439,7 @@ protected function get_depth_clauses(): array { } $clause = array( - 'key' => '_dws_lo_depth', + 'key' => DWS_LOWC_META_DEPTH, 'value' => (string) $depth, ); return array( @@ -447,7 +447,7 @@ protected function get_depth_clauses(): array { 'relation' => 'OR', $clause, array( - 'key' => '_dws_lo_depth', + 'key' => DWS_LOWC_META_DEPTH, 'compare' => 'NOT EXISTS', ), ) : $clause, From e416d8edb6c904f262c40685cb9cb59577a8b38c Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Thu, 12 Nov 2026 12:07:55 +0100 Subject: [PATCH 38/50] Keep the settings section's id inside its class Settings::SECTION was public, though nothing outside the class reads it, and the contract would hold it through 2.x (A425, X231). The constant is protected; the id itself stays v1's, which links to the section and the tests name as a literal. Assisted-by: Claude Code:claude-opus-5-5 --- plugins/linked-orders-for-woocommerce/src/Settings.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/linked-orders-for-woocommerce/src/Settings.php b/plugins/linked-orders-for-woocommerce/src/Settings.php index 4adca7f..85d6c4c 100644 --- a/plugins/linked-orders-for-woocommerce/src/Settings.php +++ b/plugins/linked-orders-for-woocommerce/src/Settings.php @@ -29,7 +29,7 @@ final class Settings implements ComponentInterface, SectionFieldsDefinitionInter * * @var string */ - public const string SECTION = 'dws-linked-orders'; + protected const string SECTION = 'dws-linked-orders'; // endregion From 800596fae872572d3abff75454781acb6a62e491 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Thu, 12 Nov 2026 12:09:16 +0100 Subject: [PATCH 39/50] Record the v1 breaks the changelog and readme left out The changelog omitted v1's namespaced classes and their capability constants, DWS_Order_Node turning final, the capability install notice and its AJAX actions, the three Freemius functions, the six init_status filters and the stricter maximum depth; the Upgrade Notice left out the lone-root link refusal and the Freemius removal; and the README said WordPress shows the notice below every floor (A274-A280, X204). New fragments record the classes and constants, the final node and the depth validation; the 2.0.0 block says capabilities are granted automatically and v1's install notice and its AJAX actions are gone; the freemius and notice-markup-hooks fragments name what they missed; the Upgrade Notice adds both breaks in 292 characters; and the README says WordPress's notice covers the PHP and WordPress floors and the plugin's covers WooCommerce's. Assisted-by: Claude Code:claude-opus-5-5 --- plugins/linked-orders-for-woocommerce/CHANGELOG.md | 2 +- plugins/linked-orders-for-woocommerce/README.md | 2 +- plugins/linked-orders-for-woocommerce/changelog/freemius | 2 +- .../changelog/max-depth-validation | 4 ++++ .../changelog/notice-markup-hooks | 2 +- plugins/linked-orders-for-woocommerce/changelog/order-node | 4 ++++ plugins/linked-orders-for-woocommerce/changelog/v1-classes | 4 ++++ plugins/linked-orders-for-woocommerce/readme.txt | 2 +- 8 files changed, 17 insertions(+), 5 deletions(-) create mode 100644 plugins/linked-orders-for-woocommerce/changelog/max-depth-validation create mode 100644 plugins/linked-orders-for-woocommerce/changelog/order-node create mode 100644 plugins/linked-orders-for-woocommerce/changelog/v1-classes diff --git a/plugins/linked-orders-for-woocommerce/CHANGELOG.md b/plugins/linked-orders-for-woocommerce/CHANGELOG.md index 00c6fae..e5cd79c 100644 --- a/plugins/linked-orders-for-woocommerce/CHANGELOG.md +++ b/plugins/linked-orders-for-woocommerce/CHANGELOG.md @@ -9,7 +9,7 @@ Pending entries live in [`changelog/`](./changelog) — add via `composer change ## 2.0.0 - unreleased ### Changed -- Rebuilt on the v2 framework, which boots the plugin only where its floors and WooCommerce are met and otherwise shows a notice. +- Rebuilt on the v2 framework, which boots the plugin only where its floors and WooCommerce are met and otherwise shows a notice. The plugin grants its capabilities by itself, so v1's install and update notice and its AJAX actions are gone. ## 1.2.2 - 2022-07-12 diff --git a/plugins/linked-orders-for-woocommerce/README.md b/plugins/linked-orders-for-woocommerce/README.md index 49f81b1..bef80e2 100644 --- a/plugins/linked-orders-for-woocommerce/README.md +++ b/plugins/linked-orders-for-woocommerce/README.md @@ -6,7 +6,7 @@ An order can have children, and each child its own, down to a maximum depth; a w ## Requirements -The entry file's headers state the floors: `Requires PHP`, `Requires at least` for WordPress, and `Requires Plugins` with `WC requires at least` for WooCommerce. On a site below them, WordPress shows its own notice and none of the plugin's features load. +The entry file's headers state the floors: `Requires PHP`, `Requires at least` for WordPress, and `Requires Plugins` with `WC requires at least` for WooCommerce. Below the PHP or WordPress floor WordPress shows its own notice, below WooCommerce's the plugin shows one, and none of the plugin's features load. ## Installation diff --git a/plugins/linked-orders-for-woocommerce/changelog/freemius b/plugins/linked-orders-for-woocommerce/changelog/freemius index e935bee..03d0d54 100644 --- a/plugins/linked-orders-for-woocommerce/changelog/freemius +++ b/plugins/linked-orders-for-woocommerce/changelog/freemius @@ -1,4 +1,4 @@ Significance: major Type: removed -Freemius: its SDK, its opt-in screens and the dws_lowc_fs_loaded action. +Freemius: its SDK, its opt-in screens, dws_lowc_fs(), dws_lowc_fs_init(), dws_lowc_fs_settings_url() and the dws_lowc_fs_loaded action. diff --git a/plugins/linked-orders-for-woocommerce/changelog/max-depth-validation b/plugins/linked-orders-for-woocommerce/changelog/max-depth-validation new file mode 100644 index 0000000..e578360 --- /dev/null +++ b/plugins/linked-orders-for-woocommerce/changelog/max-depth-validation @@ -0,0 +1,4 @@ +Significance: minor +Type: changed + +The maximum depth setting rejects a value that is not a whole number of 1 or more and keeps the stored value, where v1 saved it and corrected it on read. diff --git a/plugins/linked-orders-for-woocommerce/changelog/notice-markup-hooks b/plugins/linked-orders-for-woocommerce/changelog/notice-markup-hooks index 1008215..744c42f 100644 --- a/plugins/linked-orders-for-woocommerce/changelog/notice-markup-hooks +++ b/plugins/linked-orders-for-woocommerce/changelog/notice-markup-hooks @@ -1,4 +1,4 @@ Significance: major Type: removed -The v1 framework's sixteen notice-markup actions under dws_wp_framework/bootstrapper/requirements_error_notice, requirements_error_list, and dws_wp_framework/core/initialization_error and initialization_error_list. +The v1 framework's sixteen notice-markup actions under dws_wp_framework/bootstrapper/requirements_error_notice, requirements_error_list, and dws_wp_framework/core/initialization_error and initialization_error_list, and its six dws_wp_framework/*/init_status filters. diff --git a/plugins/linked-orders-for-woocommerce/changelog/order-node b/plugins/linked-orders-for-woocommerce/changelog/order-node new file mode 100644 index 0000000..62fd6c8 --- /dev/null +++ b/plugins/linked-orders-for-woocommerce/changelog/order-node @@ -0,0 +1,4 @@ +Significance: major +Type: changed + +DWS_Order_Node is final, and its constructor throws InvalidArgumentException for an order that is not of a supported type. diff --git a/plugins/linked-orders-for-woocommerce/changelog/v1-classes b/plugins/linked-orders-for-woocommerce/changelog/v1-classes new file mode 100644 index 0000000..bd8579a --- /dev/null +++ b/plugins/linked-orders-for-woocommerce/changelog/v1-classes @@ -0,0 +1,4 @@ +Significance: major +Type: removed + +v1's DeepWebSolutions\WC_Plugins\LinkedOrders classes and the capability constants they declared, whose capability names the DWS_LOWC_CAP_ constants now hold. diff --git a/plugins/linked-orders-for-woocommerce/readme.txt b/plugins/linked-orders-for-woocommerce/readme.txt index b28727e..1b55086 100644 --- a/plugins/linked-orders-for-woocommerce/readme.txt +++ b/plugins/linked-orders-for-woocommerce/readme.txt @@ -68,7 +68,7 @@ You can open a community support ticket here at [wordpress.org](https://wordpres == Upgrade Notice == = 2.0.0 = -Requires PHP 8.5, WordPress 7.1 and WooCommerce 11.3. Hooks move to dws_lowc/ names and their v1 names still fire, deprecated, but eight settings hooks, the v1 framework's notice-markup hooks and its accessors, such as dws_lowc_instance(), are removed. +Requires PHP 8.5, WordPress 7.1 and WooCommerce 11.3. Hooks move to dws_lowc/ names, their v1 names still firing, deprecated. Removed: eight settings hooks, v1's notice-markup hooks and accessors, and Freemius with dws_lowc_fs_loaded. dws_lowc_link_orders() refuses a child that has children. == Changelog == From 70416f744035a741ea25be6e9a6bcd63280ab77a Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Thu, 12 Nov 2026 12:09:53 +0100 Subject: [PATCH 40/50] Fit Linked Orders' docs to the plugin they describe The docs kept template text that does not fit Linked Orders: a POSIX shell for npm run start, recurring jobs, restrictions in an empty safeguards file, a safeguard rule naming a function the plugin lacks, and a Variants convention for variants it does not have. ARCHITECTURE counted three link keys and listed four, the README said three functions return DWS_Order_Node, and the tests README's gate sentence and Quality line named checks they do not run (A588, A654, A657, A662, A664, X160, X263-X265). The shell clause goes, the scheduler keeps "its scheduled hooks", and the safeguards file is an empty slot whose convention states the rule a future safeguard follows, after the template's recipe and X rulings 164 and 361. The Variants convention goes and the rest renumber. The link keys read "plus _dws_lo_created_by", the README describes linking, child creation and traversal separately, and the tests README names what quality-check runs, the PHP 7.4 parse left to CI, and the readme.txt lint. Assisted-by: Claude Code:claude-opus-5-5 --- .../linked-orders-for-woocommerce/AGENTS.md | 21 +++++++++---------- .../linked-orders-for-woocommerce/README.md | 4 ++-- .../docs/ARCHITECTURE.md | 6 +++--- .../tests/README.md | 4 ++-- 4 files changed, 17 insertions(+), 18 deletions(-) diff --git a/plugins/linked-orders-for-woocommerce/AGENTS.md b/plugins/linked-orders-for-woocommerce/AGENTS.md index ca19c06..9085039 100644 --- a/plugins/linked-orders-for-woocommerce/AGENTS.md +++ b/plugins/linked-orders-for-woocommerce/AGENTS.md @@ -21,7 +21,7 @@ src/ the composition root and its components only └── Integration/ SequentialOrderNumbersPro, gated on wc_seq_order_number_pro() models/DWS_Order_Node.php v1's global node class on the classmap, reading and saving the _dws_lo_ order meta includes/ hook-free code as dws_lowc_ functions and DWS_LOWC_ constants, loaded in name order, _-prefixed files skipped -├── safeguards.php restrictions that hold whenever the plugin cannot boot; loaded by the entry before the floor check +├── safeguards.php an empty slot for restrictions that hold whenever the plugin cannot boot; loaded by the entry before the floor check ├── capabilities.php v1's four capabilities and the roles CapabilityGrants gives them ├── options.php v1's option keys, their defaults and the depth validator ├── settings.php v1's settings API: the raw, validated and validating reads, by v1 field id and group @@ -46,7 +46,7 @@ composer test:integration:multisite # starts .wp-env.multisite.json, then runs composer quality-check # manifest and changelog validation, PHPCS (both profiles), PHPStan (both profiles), Unit and Integration composer changelog:add # stages an entry in changelog/, which the release step in the monorepo's CONTRIBUTING.md writes npm run setup # both installs, the scripts' build, then the development site -npm run build # the orders list's assets through the local wp-scripts; npm run start watches them in a POSIX shell +npm run build # the orders list's assets through the local wp-scripts; npm run start watches them npm run lint:scripts # ESLint, beside lint:styles (Stylelint) and lint:types (tsc) npm run test:e2e # Playwright on .wp-env.tests.json, starting it if needed; the scripts must be built ``` @@ -64,15 +64,14 @@ Each runs PHP 8.5 with WooCommerce from `vendor/` and its order tables on; CI ru ## Conventions 1. **Placement.** `src/` holds only the composition root and its components. Code that registers no hooks, such as keys, readers, v1's helper API, the footprint, migration steps and asset registrars, is `dws_lowc_` functions and `DWS_LOWC_` constants in `includes/`, which parses on PHP 7.4: no `mixed` or union types, typed constants, `match`, nullsafe calls or first-class callables there. `DWS_Order_Node` keeps v1's global name in `models/`, outside that limit. Container definitions are composition and stay in `Plugin::get_definitions()`. -2. **Variants.** A component that exists only in one build variant is a child in a branch of its parent's `get_child_component_classes()`, never a second entry in `COMPONENTS`. -3. **Request context.** `is_admin()` is false on REST requests and true in admin-ajax ones, so an admin-only feature decides the request context in its callbacks, never in `should_load()`. -4. **Add-ons.** An add-on extends a base plugin only through the base plugin's documented hooks. It reads a template-built base plugin's slug, name and version only through that plugin's `{prefix}_get_plugin_*()` getters, from `plugins_loaded` on, since the add-on may load first, and behind `function_exists()`, since a base plugin below its floors or with a partial build may stay active without defining them. -5. **Request reads.** An integer or boolean is read as `filter_var( wp_unslash( $_POST['key'] ?? '' ), FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE ) ?? $default` (`FILTER_VALIDATE_BOOL` for a boolean, where an absent key reads false, as an unchecked checkbox posts nothing), and a string through an `is_string()` guard and the field's sanitizer; never `filter_input()`, the `default` filter option or a cast on request input. -6. **Options and data.** A setting is read through v1's API in `includes/settings.php`, whose validation is each setting's one typed read and whose reads apply v1's filters, safeguards excepted. v1's settings keep their `dws-lowc_` keys and its order links their `_dws_lo_` meta; anything new lives under `dws_lowc_`, or `_dws_lowc_` for hidden meta. The footprint covers those prefixes and v1's other rows by name. -7. **Order storage.** Order code goes through WC CRUD, `get_edit_order_url()` and WooCommerce's screen ids and list hooks, never the posts table, and every Integration row whose outcome the order storage can change runs on all four storages through `OrderStorageTrait`: posts and the order tables, each with the sync on and off. Rows that check only the order of hooks, or a stand-in plugin, use one. -8. **v1 hooks.** A hook v1 fired keeps its v1 literal, fired first through `apply_filters_deprecated()` or `do_action_deprecated()` with `'2.0.0'` and the v2 name, then the v2 name, and `DeprecatedHooksTest` names the pair. -9. **Safeguards** live in `includes/safeguards.php`, call only WordPress and that file, and relax only through their own filter, through `dws_lowc_is_relaxed()` (the template's recipes). The entry loads them before the floor check, so they parse on PHP 7.4 and call only WordPress APIs present at the oldest WordPress the bootstrap package runs on, or guard them, which `phpstan.safeguards.neon` checks. -10. **Migrations** are append-only: a step converges from any partial state, verifies its writes and throws when one fails. +2. **Request context.** `is_admin()` is false on REST requests and true in admin-ajax ones, so an admin-only feature decides the request context in its callbacks, never in `should_load()`. +3. **Add-ons.** An add-on extends a base plugin only through the base plugin's documented hooks. It reads a template-built base plugin's slug, name and version only through that plugin's `{prefix}_get_plugin_*()` getters, from `plugins_loaded` on, since the add-on may load first, and behind `function_exists()`, since a base plugin below its floors or with a partial build may stay active without defining them. +4. **Request reads.** An integer or boolean is read as `filter_var( wp_unslash( $_POST['key'] ?? '' ), FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE ) ?? $default` (`FILTER_VALIDATE_BOOL` for a boolean, where an absent key reads false, as an unchecked checkbox posts nothing), and a string through an `is_string()` guard and the field's sanitizer; never `filter_input()`, the `default` filter option or a cast on request input. +5. **Options and data.** A setting is read through v1's API in `includes/settings.php`, whose validation is each setting's one typed read and whose reads apply v1's filters, safeguards excepted. v1's settings keep their `dws-lowc_` keys and its order links their `_dws_lo_` meta; anything new lives under `dws_lowc_`, or `_dws_lowc_` for hidden meta. The footprint covers those prefixes and v1's other rows by name. +6. **Order storage.** Order code goes through WC CRUD, `get_edit_order_url()` and WooCommerce's screen ids and list hooks, never the posts table, and every Integration row whose outcome the order storage can change runs on all four storages through `OrderStorageTrait`: posts and the order tables, each with the sync on and off. Rows that check only the order of hooks, or a stand-in plugin, use one. +7. **v1 hooks.** A hook v1 fired keeps its v1 literal, fired first through `apply_filters_deprecated()` or `do_action_deprecated()` with `'2.0.0'` and the v2 name, then the v2 name, and `DeprecatedHooksTest` names the pair. +8. **Safeguards.** `includes/safeguards.php` holds none yet. A safeguard added there follows the template's safeguard recipe: the entry loads the file before the floor check, so it parses on PHP 7.4 and calls only what its hook hands it, WordPress APIs present at the oldest WordPress the bootstrap package runs on and WooCommerce APIs present since 3.6, guarding anything newer, which `phpstan.safeguards.neon` checks for WordPress. A safeguard that calls WooCommerce is hooked from the file's one `woocommerce_loaded` callback, and it relaxes only through its own filter, with an explicit `false`, after `dws_lowc/boot/succeeded`. +9. **Migrations** are append-only: a step converges from any partial state, verifies its writes and throws when one fails. ## Contract diff --git a/plugins/linked-orders-for-woocommerce/README.md b/plugins/linked-orders-for-woocommerce/README.md index bef80e2..6d1fa62 100644 --- a/plugins/linked-orders-for-woocommerce/README.md +++ b/plugins/linked-orders-for-woocommerce/README.md @@ -14,11 +14,11 @@ Install the plugin from the WordPress plugin directory under Plugins → Add New ## Extending -Other plugins link and walk orders through the functions in `includes/linking.php` and `includes/post-type.php`, such as `dws_lowc_link_orders()`, `dws_lowc_get_root_order()` and `dws_lowc_create_linked_child()`, and the `DWS_Order_Node` class they return. Every hook is named `dws_lowc/area/name`, and each one v1 fired under `linked_orders_for_woocommerce/` still fires first, deprecated; `CHANGELOG.md` lists the renames and the removals. +Other plugins link two orders through `dws_lowc_link_orders()`, create a linked child through `dws_lowc_create_linked_child()`, which returns the child's id, and walk a tree through `dws_lowc_get_order_node()`, `dws_lowc_get_root_order()` and `dws_lowc_get_orders_tree()`, whose nodes are `DWS_Order_Node` objects. Every hook is named `dws_lowc/area/name`, and each one v1 fired under `linked_orders_for_woocommerce/` still fires first, deprecated; `CHANGELOG.md` lists the renames and the removals. ## Development -Development needs PHP and Composer at the `Requires PHP` floor or later, Node.js and npm at `package.json`'s `engines`, Docker for `@wordpress/env`, and a POSIX shell for `npm run start`. +Development needs PHP and Composer at the `Requires PHP` floor or later, Node.js and npm at `package.json`'s `engines`, and Docker for `@wordpress/env`. Install the dependencies, prefix the framework into `vendor-prefixed/`, build the scripts and start the development site on : diff --git a/plugins/linked-orders-for-woocommerce/docs/ARCHITECTURE.md b/plugins/linked-orders-for-woocommerce/docs/ARCHITECTURE.md index 5e3798b..00a19f2 100644 --- a/plugins/linked-orders-for-woocommerce/docs/ARCHITECTURE.md +++ b/plugins/linked-orders-for-woocommerce/docs/ARCHITECTURE.md @@ -7,7 +7,7 @@ This document describes how the plugin loads, boots, stores its data and leaves `linked-orders-for-woocommerce.php` is the only file WordPress includes, and it parses on PHP 7.4 so that a site below the floors gets a notice instead of a fatal error: 1. It defines `DWS_LOWC_FILE`; translations come from wordpress.org's language packs, which WordPress loads just in time. -2. It loads `includes/safeguards.php`, whose restrictions hold on any runtime that parses the plugin: below the floors, on a partial deploy and when the boot fails. +2. It loads `includes/safeguards.php`, an empty slot for restrictions that must hold on any runtime that parses the plugin: below the floors, on a partial deploy and when the boot fails. 3. It requires `vendor-prefixed/ahegyes/wp-framework-bootstrap/load.php`, registers the GitHub updater, which stays inert because the entry file has no `Update URI` header, then checks the `Requires PHP` and `Requires at least` headers. On a site below a floor, WordPress's own notice shows and nothing past the safeguards loads. 4. It stops with a notice when the prefixed framework or the orders list's built script is missing. It then requires the autoloader and `functions.php`; a missing file throws, which local and development environments rethrow and every other environment logs, showing the same notice. @@ -17,7 +17,7 @@ This document describes how the plugin loads, boots, stores its data and leaves `src/Plugin.php` is the composition root. Its constructor builds the logger, the scheduler and the framework's `PluginBoot`, which runs the composition and reports its outcome: -- The scheduler registers its hooks first, so the plugin's recurring jobs keep their schedules while the plugin is blocked. +- The scheduler registers its hooks first, so the plugin's scheduled hooks keep their schedules while the plugin is blocked. - `WooCommerceRequirement` checks WooCommerce against the `WC requires at least` header; without it, the boot is `blocked`, `dws_lowc/boot/blocked` fires, and users who can activate plugins see why the plugin stays off. - The migration runner registers, runs pending steps on `wp_loaded`, and runs a batched step's batches from its scheduled hook. - The kernel builds each class in `Plugin::COMPONENTS` through a PHP-DI container and calls its `register_hooks()`; a composite adds its children, and a conditional component loads only when its static `should_load()` returns true. @@ -35,7 +35,7 @@ The request that activates the plugin does not boot it, because WordPress includ ## Data -- **Order links** are three meta keys on each linked order, as v1 stored them: `_dws_lo_depth`, where a root is 0, `_dws_lo_parent` and `_dws_lo_children`, and `_dws_lo_created_by` on a child created through the plugin. WC CRUD writes them to whichever storage WooCommerce uses. Every link reads and rewrites the parent's `_dws_lo_children`, so `dws_lowc_link_orders()` holds the `dws_lowc_linking` option lock while it links, re-reads both orders' meta from the database under it, with WooCommerce's order-tables data caching off for the reads, and waits up to five seconds for another request's link to finish before it returns false. It saves the child first and the parent only once the child's parent reads back, since WooCommerce logs a failed save and carries on; on either refusal `dws_lowc_create_linked_child()` deletes the child it created and returns a `WP_Error`. When an order is deleted for good, its children become the roots of their own trees and their descendants' depths are rewritten, under the same lock. +- **Order links** are three meta keys on each linked order, as v1 stored them: `_dws_lo_depth`, where a root is 0, `_dws_lo_parent` and `_dws_lo_children`, plus `_dws_lo_created_by` on a child the plugin created. WC CRUD writes them to whichever storage WooCommerce uses. Every link reads and rewrites the parent's `_dws_lo_children`, so `dws_lowc_link_orders()` holds the `dws_lowc_linking` option lock while it links, re-reads both orders' meta from the database under it, with WooCommerce's order-tables data caching off for the reads, and waits up to five seconds for another request's link to finish before it returns false. It saves the child first and the parent only once the child's parent reads back, since WooCommerce logs a failed save and carries on; on either refusal `dws_lowc_create_linked_child()` deletes the child it created and returns a `WP_Error`. When an order is deleted for good, its children become the roots of their own trees and their descendants' depths are rewritten, under the same lock. - **Options** keep v1's keys, `dws-lowc_general_max-depth`, `dws-lowc_general_autocomplete-descendants` and `dws-lowc_plugin_remove-data-uninstall`, as constants in `includes/options.php`. Code reads them through v1's API in `includes/settings.php`, which validates each stored value, falls back to the key's default, and applies v1's filters and then `dws_lowc/settings/validated_value`. - **Migrations** are the append-only steps `dws_lowc_get_migration_steps()` returns. The framework's runner runs each pending step once per site on `wp_loaded`, behind a lock, and records the revision reached in `dws_lowc_revision`. The first step stores each setting's default where a site, v1 or new, has none. The second, batched one walks every order that stores a parent, 50 to a batch, under the linking lock: a child whose parent no longer loads becomes the root of its own tree, a child missing from its parent's children joins them, and a child at another depth than one below its parent moves there with its descendants, repairing what v1's deletions and lost updates left. A step converges from any partial state and throws when a write fails, so a failed step runs again on a later request. - **The footprint**, `dws_lowc_get_footprint()`, names every kind of data the plugin stores: the `dws_lowc_` and `dws-lowc_` option prefixes, the `_dws_lo_` order meta on both storages, v1's version and notice rows, user meta, network options, the capabilities, scheduled hooks and Action Scheduler groups. Freemius's `fs_` rows from v1 stay, since they belong to Freemius. diff --git a/plugins/linked-orders-for-woocommerce/tests/README.md b/plugins/linked-orders-for-woocommerce/tests/README.md index 5f2210c..f0fa922 100644 --- a/plugins/linked-orders-for-woocommerce/tests/README.md +++ b/plugins/linked-orders-for-woocommerce/tests/README.md @@ -47,7 +47,7 @@ npx playwright install chromium npm run test:e2e ``` -Run every check CI runs on PHP, Integration included: +Run the manifest and changelog validation, PHPCS and PHPStan on both profiles, and the Unit and Integration suites. The PHP 7.4 parse runs only in CI's Quality workflow: ```sh composer quality-check @@ -58,7 +58,7 @@ composer quality-check - **Tests** runs Unit once, and Integration and the end-to-end suite once per WordPress branch, from the entry file's `Requires at least` up to the newest release, which WordPress's version API lists, so a new release joins without an edit. - **Tests (nightly)** runs Integration and the end-to-end suite on WordPress trunk every day, outside pull requests. - **Release** builds the zip on every pull request as a tag would, runs Plugin Check and the archive checks, then activates the zip on a site and runs the end-to-end suite against it. -- **Quality** runs PHPCS, PHPStan, the PHP 7.4 parse of the entry file, `uninstall.php` and `includes/`, the script, style and type lints, the `block.json` schema, the readme lint and the changelog fragments. +- **Quality** runs PHPCS, PHPStan, the PHP 7.4 parse of the entry file, `uninstall.php` and `includes/`, the script, style and type lints, the `block.json` schema and the `readme.txt` lint, and the changelog fragments. ## Conventions From 730f774f68090fa1fcd2bc355cea7982eaf2410c Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Thu, 12 Nov 2026 12:35:15 +0100 Subject: [PATCH 41/50] Query a link key on either storage through one helper The orders list and the link repair each chose between a meta query and a top-level meta key by the order storage, with its own reason and its own phpcs:ignore, so a change in how either storage takes the key had two places to land. dws_lowc_get_meta_key_args() holds the choice and its reason, and both queries add their own arguments to what it returns. Assisted-by: Claude Code:claude-opus-5-5 --- .../includes/linking.php | 17 +++++++++++++++++ .../includes/migrations.php | 7 ++----- .../src/ListTable.php | 13 ++++++------- 3 files changed, 25 insertions(+), 12 deletions(-) diff --git a/plugins/linked-orders-for-woocommerce/includes/linking.php b/plugins/linked-orders-for-woocommerce/includes/linking.php index 5a5d556..f69c800 100644 --- a/plugins/linked-orders-for-woocommerce/includes/linking.php +++ b/plugins/linked-orders-for-woocommerce/includes/linking.php @@ -8,6 +8,7 @@ * @package DeepWebSolutions\LinkedOrders */ +use Automattic\WooCommerce\Utilities\OrderUtil; use DeepWebSolutions\LinkedOrders\Plugin; use DeepWebSolutions\LinkedOrders\Scoped\DeepWebSolutions\Framework\Core\Storage\Lock; use DeepWebSolutions\LinkedOrders\Scoped\DeepWebSolutions\Framework\Core\Storage\LockClaim; @@ -267,6 +268,22 @@ function dws_lowc_refresh_links( WC_Order ...$orders ): void { } } +/** + * Returns the wc_get_orders() arguments that match the orders storing a meta key, on the storage WooCommerce uses for orders. + * + * The order tables take a key only in a meta query, which the posts store refuses, so WP_Query gets it as a top-level key, which also lets it order by the key's value. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param string $key The meta key. + * + * @return array + */ +function dws_lowc_get_meta_key_args( string $key ): array { + return OrderUtil::custom_orders_table_usage_is_enabled() ? array( 'meta_query' => array( array( 'key' => $key ) ) ) : array( 'meta_key' => $key ); // phpcs:ignore WordPress.DB.SlowDBQuery -- Callers read one row or walk the linked orders once. +} + /** * Returns the node at the top of an order's tree, or null when the order has no node. * diff --git a/plugins/linked-orders-for-woocommerce/includes/migrations.php b/plugins/linked-orders-for-woocommerce/includes/migrations.php index c222b41..a32831c 100644 --- a/plugins/linked-orders-for-woocommerce/includes/migrations.php +++ b/plugins/linked-orders-for-woocommerce/includes/migrations.php @@ -8,7 +8,6 @@ * @package DeepWebSolutions\LinkedOrders */ -use Automattic\WooCommerce\Utilities\OrderUtil; use DeepWebSolutions\LinkedOrders\Scoped\DeepWebSolutions\Framework\Core\Migration\BatchedMigrationStepInterface; defined( 'ABSPATH' ) || exit; @@ -86,10 +85,8 @@ function dws_lowc_add_settings(): void { function dws_lowc_repair_links(): bool { $page = filter_var( get_option( 'dws_lowc_link_repair_page', 1 ), FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE ) ?? 1; - // The order tables take the parent key only in a meta query, which the posts store refuses, passing a top-level key to WP_Query instead. - $linked = OrderUtil::custom_orders_table_usage_is_enabled() ? array( 'meta_query' => array( array( 'key' => DWS_LOWC_META_PARENT ) ) ) : array( 'meta_key' => DWS_LOWC_META_PARENT ); // phpcs:ignore WordPress.DB.SlowDBQuery -- A one-time walk of the linked orders. - $ids = wc_get_orders( - $linked + array( + $ids = wc_get_orders( + dws_lowc_get_meta_key_args( DWS_LOWC_META_PARENT ) + array( 'type' => dws_lowc_get_supported_order_types(), 'status' => array_values( get_post_stati() ), 'limit' => 50, diff --git a/plugins/linked-orders-for-woocommerce/src/ListTable.php b/plugins/linked-orders-for-woocommerce/src/ListTable.php index 777133e..484d787 100644 --- a/plugins/linked-orders-for-woocommerce/src/ListTable.php +++ b/plugins/linked-orders-for-woocommerce/src/ListTable.php @@ -164,9 +164,8 @@ public function render_filters( string $type ): void { } // The Trash view lists trashed orders, and another plugin's order type may register its statuses only as post statuses, so the deepest stored order is read in every status. - $depth_key = OrderUtil::custom_orders_table_usage_is_enabled() ? array( 'meta_query' => array( array( 'key' => DWS_LOWC_META_DEPTH ) ) ) : array( 'meta_key' => DWS_LOWC_META_DEPTH ); // phpcs:ignore WordPress.DB.SlowDBQuery -- One row, ordered by the depth meta. - $deepest = wc_get_orders( - $depth_key + array( + $deepest = wc_get_orders( + dws_lowc_get_meta_key_args( DWS_LOWC_META_DEPTH ) + array( 'type' => $type, 'status' => \array_values( get_post_stati() ), 'limit' => 1, @@ -175,10 +174,10 @@ public function render_filters( string $type ): void { 'order' => 'DESC', ) ); - $deepest = \max( dws_lowc_get_max_depth(), ( \is_array( $deepest ) && array() !== $deepest ? dws_lowc_get_order_node( \reset( $deepest ) )?->get_depth() : null ) ?? 0 ); - $selected = $this->get_depth_filter(); - $email = $this->get_guest_email(); - $pivot = $this->get_tree_pivot(); + $deepest = \max( dws_lowc_get_max_depth(), ( \is_array( $deepest ) && array() !== $deepest ? dws_lowc_get_order_node( \reset( $deepest ) )?->get_depth() : null ) ?? 0 ); + $selected = $this->get_depth_filter(); + $email = $this->get_guest_email(); + $pivot = $this->get_tree_pivot(); ?> From b6d83eee85e797836c6b81a9bfd090f162de0e6e Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:01:35 +0100 Subject: [PATCH 42/50] Fire the runner's hook before checking the stored revision The revision row read the stored revision as the site's boot left it, but the update's second step runs its batches only from the runner's scheduled hook, so on a new site the revision stays at 1 until the hook runs. It passed only where an earlier row had fired the hook, and failed in the shared environment's random order. The row fires the hook in a request of its own, as the scheduler would, flushes this process's cache, and then expects the code's revision. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/Integration/PluginBootTest.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/PluginBootTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/PluginBootTest.php index ba56580..d19bdd3 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/PluginBootTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/PluginBootTest.php @@ -76,8 +76,14 @@ public function test_the_entry_loads_its_safeguards_before_the_bootstrap_package self::assertLessThan( $bootstrap, $safeguards ); } - public function test_the_stored_data_is_at_the_revision_of_the_code(): void { + // The update's second step runs its batches only from the runner's scheduled hook, so a request of its own fires the hook as the scheduler would on a new site, and this process then reads the revision afresh. + public function test_the_stored_data_reaches_the_revision_of_the_code_once_the_runners_hook_runs(): void { self::assertInstanceOf( MigrationRunner::class, dws_lowc_plugin()->migrations ); + + $request = self::request( 'do_action( "' . Plugin::PREFIX . '_revision/migration/run" );' ); + wp_cache_flush(); + + self::assertSame( 0, $request['status'], $request['output'] ); self::assertSame( \count( dws_lowc_get_migration_steps() ), MigrationRunner::get_revision( Plugin::PREFIX . '_revision' ) ); } From 58f9e2bcbcbc5f99433b06e77a9562d1d2e83c60 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Thu, 12 Nov 2026 14:01:35 +0100 Subject: [PATCH 43/50] Audit only Linked Orders' depth filter in the list's axe pass The orders list's axe pass covered the whole top table navigation, so it also audited WooCommerce's own customer, date and created-via filters, whose missing accessible names Linked Orders does not render and cannot fix (X133). The pass includes only the depth filter, as the meta box's pass includes only the plugin's box. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/EndToEnd/linked-orders.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/linked-orders-for-woocommerce/tests/EndToEnd/linked-orders.spec.ts b/plugins/linked-orders-for-woocommerce/tests/EndToEnd/linked-orders.spec.ts index 4e7a465..1f119d3 100644 --- a/plugins/linked-orders-for-woocommerce/tests/EndToEnd/linked-orders.spec.ts +++ b/plugins/linked-orders-for-woocommerce/tests/EndToEnd/linked-orders.spec.ts @@ -64,7 +64,7 @@ test.describe( 'Linked orders', () => { page.locator( `#order-${ parent.id } td.column-dws_lo_depth` ) ).toHaveText( 'Root order' ); const listResults = await new AxeBuilder( { page } ) - .include( '.tablenav.top' ) + .include( '#dws_lo_depth_filter_dropdown' ) .analyze(); expect( listResults.violations ).toEqual( [] ); } ); From 9068fed217800f66af9bd741af1814877438d5ed Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:17:54 +0100 Subject: [PATCH 44/50] Test wording that fits every order type Linked Orders links The rows pinned "Add new child order", "Root order", "Showing orders linked to #12" and the other strings that name an order mid-sentence, which read wrong on a quote or any other order type Linked Orders links, and say "order" where the child's creator makes another type (X378). The test order type names its own add-child and box phrases through two slug-rooted label keys, while WooCommerce's orders cover the fallback; the depth cell, the filter, the filter lines, the row actions, the box link, the autocompletion note and the refusals expect wording with no type noun, or the type's label first. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/EndToEnd/linked-orders.spec.ts | 4 +-- .../tests/Integration/AutocompletionTest.php | 4 +-- .../tests/Integration/ChildCreationTest.php | 6 ++-- .../tests/Integration/ListTableTest.php | 28 ++++++++++--------- .../tests/Integration/MetaBoxTest.php | 7 +++-- .../tests/Support/RegistersOrderTypeTrait.php | 4 ++- 6 files changed, 29 insertions(+), 24 deletions(-) diff --git a/plugins/linked-orders-for-woocommerce/tests/EndToEnd/linked-orders.spec.ts b/plugins/linked-orders-for-woocommerce/tests/EndToEnd/linked-orders.spec.ts index 1f119d3..ef7aef6 100644 --- a/plugins/linked-orders-for-woocommerce/tests/EndToEnd/linked-orders.spec.ts +++ b/plugins/linked-orders-for-woocommerce/tests/EndToEnd/linked-orders.spec.ts @@ -59,10 +59,10 @@ test.describe( 'Linked orders', () => { ); await expect( page.locator( `#order-${ child } td.column-dws_lo_depth` ) - ).toHaveText( 'Child order (level 2)' ); + ).toHaveText( 'Child (level 2)' ); await expect( page.locator( `#order-${ parent.id } td.column-dws_lo_depth` ) - ).toHaveText( 'Root order' ); + ).toHaveText( 'Root' ); const listResults = await new AxeBuilder( { page } ) .include( '#dws_lo_depth_filter_dropdown' ) .analyze(); diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/AutocompletionTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/AutocompletionTest.php index 871b522..b007181 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/AutocompletionTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/AutocompletionTest.php @@ -32,7 +32,7 @@ public function test_completing_an_order_completes_its_descendants_with_a_note( foreach ( array( $child, $grandchild ) as $descendant ) { $stored = wc_get_order( $descendant->get_id() ); self::assertSame( 'completed', $stored->get_status() ); - self::assertSame( array( 'Child order autocompleted. Order status changed from Pending payment to Completed.' ), \array_values( \array_filter( \array_map( static fn ( \stdClass $note ): string => $note->content, wc_get_order_notes( array( 'order_id' => $descendant->get_id() ) ) ), static fn ( string $content ): bool => \str_starts_with( $content, 'Child order autocompleted.' ) ) ) ); + self::assertSame( array( 'Completed with its linked parent. Order status changed from Pending payment to Completed.' ), \array_values( \array_filter( \array_map( static fn ( \stdClass $note ): string => $note->content, wc_get_order_notes( array( 'order_id' => $descendant->get_id() ) ) ), static fn ( string $content ): bool => \str_starts_with( $content, 'Completed with its linked parent.' ) ) ) ); } } @@ -85,7 +85,7 @@ public function test_each_order_of_a_tree_is_completed_once_and_with_one_note( b self::assertSame( array( $root->get_id(), $child->get_id(), $other->get_id(), $other_child->get_id(), $other_grandchild->get_id(), $grandchild->get_id() ), $seen ); foreach ( array( $child, $grandchild, $other_child, $other_grandchild ) as $descendant ) { self::assertSame( 'completed', wc_get_order( $descendant->get_id() )->get_status() ); - self::assertCount( 1, \array_filter( wc_get_order_notes( array( 'order_id' => $descendant->get_id() ) ), static fn ( \stdClass $note ): bool => \str_starts_with( $note->content, 'Child order autocompleted.' ) ) ); + self::assertCount( 1, \array_filter( wc_get_order_notes( array( 'order_id' => $descendant->get_id() ) ), static fn ( \stdClass $note ): bool => \str_starts_with( $note->content, 'Completed with its linked parent.' ) ) ); } } diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/ChildCreationTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/ChildCreationTest.php index 8212e18..6b5183b 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/ChildCreationTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/ChildCreationTest.php @@ -103,7 +103,7 @@ public function test_a_user_who_may_not_create_a_child_returns_to_the_parent_wit $location = $this->request( (string) $parent->get_id() ); self::assertSame( $parent->get_edit_order_url(), $location ); - self::assertSame( array( 'No linked child can be created for order #' . $parent->get_order_number() . '.' ), \array_map( static fn ( Notice $notice ): string => $notice->message, $this->notices->get_notices( NoticeScope::User, $user ) ) ); + self::assertSame( array( 'Order #' . $parent->get_order_number() . ' cannot have a new linked child.' ), \array_map( static fn ( Notice $notice ): string => $notice->message, $this->notices->get_notices( NoticeScope::User, $user ) ) ); self::assertSame( array(), dws_lowc_get_order_node( $parent->get_id() )?->get_children() ); } @@ -121,7 +121,7 @@ public function test_a_failed_creation_returns_to_the_parent_with_the_error( boo } self::assertSame( $parent->get_edit_order_url(), $location ); - self::assertSame( array( 'The linked child of order #' . $parent->get_order_number() . ' could not be created: The <b>store</b> is closed.' ), $this->get_messages() ); + self::assertSame( array( 'Order #' . $parent->get_order_number() . ' could not get a linked child: The <b>store</b> is closed.' ), $this->get_messages() ); } #[DataProvider( 'storages' )] @@ -151,7 +151,7 @@ public function test_a_creator_that_returns_no_order_is_named_in_a_sentence( boo remove_filter( 'doing_it_wrong_trigger_error', '__return_false' ); } - self::assertSame( array( 'The linked child of order #' . $parent->get_order_number() . ' could not be created: Its creator returned no order.' ), $this->get_messages() ); + self::assertSame( array( 'Order #' . $parent->get_order_number() . ' could not get a linked child: Its creator returned no order.' ), $this->get_messages() ); } public function test_a_parent_id_that_is_not_a_whole_number_names_no_order(): void { diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/ListTableTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/ListTableTest.php index 3bbf788..5f447f9 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/ListTableTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/ListTableTest.php @@ -67,8 +67,8 @@ public function test_the_depth_column_follows_the_total_and_names_each_orders_le self::assertSame( array( 'cb', 'order_number', 'order_total', 'dws_lo_depth', 'wc_actions' ), \array_keys( apply_filters( "manage_{$screen}_columns", self::COLUMNS ) ) ); self::assertSame( 'Depth', apply_filters( "manage_{$screen}_columns", self::COLUMNS )['dws_lo_depth'] ); - self::assertSame( 'Root order', $this->render_cell( $root->get_id() ) ); - self::assertSame( 'Child order (level 2)', $this->render_cell( $child->get_id() ) ); + self::assertSame( 'Root', $this->render_cell( $root->get_id() ) ); + self::assertSame( 'Child (level 2)', $this->render_cell( $child->get_id() ) ); self::assertSame( '', $this->render_cell( $child->get_id(), 'order_total' ) ); } @@ -100,11 +100,11 @@ public function test_the_depth_filter_lists_every_level_up_to_the_deepest_stored $filters = $this->parse_filters(); $label = $filters->querySelector( 'label[for="dws_lo_depth_filter_dropdown"]' ); - self::assertSame( 'Order depth', \trim( (string) $label?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. + self::assertSame( 'Filter by depth', \trim( (string) $label?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. self::assertContains( 'screen-reader-text', \explode( ' ', (string) $label?->getAttribute( 'class' ) ) ); $options = \iterator_to_array( $filters->querySelectorAll( 'select[name="_dws_lo_depth"] option' ) ); self::assertSame( array( '', '0', '1', '2', '3' ), \array_map( static fn ( \Dom\Element $option ): string => $option->getAttribute( 'value' ) ?? '', $options ) ); - self::assertSame( array( 'All depths', 'Root orders', 'Child orders (level 2)', 'Child orders (level 3)', 'Child orders (level 4)' ), \array_map( static fn ( \Dom\Element $option ): string => \trim( $option->textContent ), $options ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. + self::assertSame( array( 'All depths', 'Roots', 'Children (level 2)', 'Children (level 3)', 'Children (level 4)' ), \array_map( static fn ( \Dom\Element $option ): string => \trim( $option->textContent ), $options ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. self::assertTrue( $options[3]->hasAttribute( 'selected' ) ); self::assertNotNull( $filters->querySelector( 'input[type="hidden"][name="_guest_customer_user"]' ) ); self::assertNotNull( $filters->querySelector( 'input[type="hidden"][name="_dws_linked_order_id"]' ) ); @@ -161,7 +161,7 @@ public function test_an_active_tree_or_guest_filter_shows_with_a_link_that_remov $_SERVER['REQUEST_URI'] = $uri; } - self::assertSame( array( 'Showing orders billed to guest+bar@example.com. Remove this filter', 'Showing orders linked to #' . $order->get_order_number() . '. Remove this filter' ), \array_map( static fn ( \Dom\Element $line ): string => \preg_replace( '/\s+/', ' ', \trim( $line->textContent ) ), $lines ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. + self::assertSame( array( 'Billed to guest+bar@example.com. Remove this filter', 'Linked to #' . $order->get_order_number() . '. Remove this filter' ), \array_map( static fn ( \Dom\Element $line ): string => \preg_replace( '/\s+/', ' ', \trim( $line->textContent ) ), $lines ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. self::assertSame( array( '/wp-admin/admin.php?page=wc-orders&_dws_lo_depth=1&_dws_linked_order_id=' . $order->get_id(), '/wp-admin/admin.php?page=wc-orders&_dws_lo_depth=1&_guest_customer_user=guest%2Bbar%40example.com' ), \array_map( static fn ( \Dom\Element $line ): string => (string) $line->querySelector( 'a' )?->getAttribute( 'href' ), $lines ) ); } @@ -266,13 +266,13 @@ public function test_an_order_row_offers_its_customers_orders_its_tree_and_a_new self::assertSame( array( 'view_all_customer_orders', 'view_all_linked_orders', 'create_empty_linked_child', 'complete' ), \array_keys( $actions ) ); self::assertSame( add_query_arg( '_customer_user', 1, $list ), $actions['view_all_customer_orders']['url'] ); - self::assertSame( 'View all customer orders', $actions['view_all_customer_orders']['name'] ); - self::assertSame( 'View all customer orders (opens in a new tab)', $actions['view_all_customer_orders']['title'] ?? null ); + self::assertSame( 'View all from this customer', $actions['view_all_customer_orders']['name'] ); + self::assertSame( 'View all from this customer (opens in a new tab)', $actions['view_all_customer_orders']['title'] ?? null ); self::assertSame( add_query_arg( '_dws_linked_order_id', $root->get_id(), $list ), $actions['view_all_linked_orders']['url'] ); - self::assertSame( 'View all linked orders', $actions['view_all_linked_orders']['name'] ); - self::assertSame( 'View all linked orders (opens in a new tab)', $actions['view_all_linked_orders']['title'] ?? null ); + self::assertSame( 'View all linked', $actions['view_all_linked_orders']['name'] ); + self::assertSame( 'View all linked (opens in a new tab)', $actions['view_all_linked_orders']['title'] ?? null ); self::assertArrayNotHasKey( 'title', $actions['create_empty_linked_child'] ); - self::assertSame( 'Create empty linked order', $actions['create_empty_linked_child']['name'] ); + self::assertSame( 'Add new child order', $actions['create_empty_linked_child']['name'] ); self::assertSame( 'create-empty-linked-child', $actions['create_empty_linked_child']['action'] ); } @@ -361,9 +361,11 @@ public function test_another_plugins_order_type_gets_the_column_the_filter_and_l $list = OrderUtil::custom_orders_table_usage_is_enabled() ? admin_url( 'admin.php?page=wc-orders--' . self::ORDER_TYPE ) : admin_url( 'edit.php?post_type=' . self::ORDER_TYPE ); self::assertSame( 'Depth', apply_filters( "manage_{$screen}_columns", self::COLUMNS )['dws_lo_depth'] ?? null ); - self::assertSame( 'Child order (level 2)', $this->render_cell( $child->get_id(), 'dws_lo_depth', self::ORDER_TYPE ) ); - self::assertSame( 'Test order depth', \trim( (string) $this->parse_filters( self::ORDER_TYPE )->querySelector( 'label[for="dws_lo_depth_filter_dropdown"]' )?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. - self::assertSame( add_query_arg( '_dws_linked_order_id', $root->get_id(), $list ), apply_filters( 'woocommerce_admin_order_actions', array(), wc_get_order( $root->get_id() ) )['view_all_linked_orders']['url'] ?? null ); + self::assertSame( 'Child (level 2)', $this->render_cell( $child->get_id(), 'dws_lo_depth', self::ORDER_TYPE ) ); + self::assertSame( 'Filter by depth', \trim( (string) $this->parse_filters( self::ORDER_TYPE )->querySelector( 'label[for="dws_lo_depth_filter_dropdown"]' )?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. + $actions = apply_filters( 'woocommerce_admin_order_actions', array(), wc_get_order( $root->get_id() ) ); + self::assertSame( add_query_arg( '_dws_linked_order_id', $root->get_id(), $list ), $actions['view_all_linked_orders']['url'] ?? null ); + self::assertSame( 'Add new child test order', $actions['create_empty_linked_child']['name'] ?? null ); } protected function open_list( string $type = 'shop_order' ): string { diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/MetaBoxTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/MetaBoxTest.php index 72d803a..c92bbe1 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/MetaBoxTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/MetaBoxTest.php @@ -44,7 +44,7 @@ public function test_the_box_sits_beside_an_orders_editor_under_its_v1_id( bool $box = $this->register( $order ); - self::assertSame( 'Linked Orders', $box['title'] ?? null ); + self::assertSame( 'Linked orders', $box['title'] ?? null ); } #[DataProvider( 'storages' )] @@ -95,7 +95,7 @@ public function test_the_box_shows_the_parent_the_children_and_the_button_to_add $tree = $box->querySelector( 'a.dws-linked-orders__tree' ); self::assertSame( add_query_arg( '_dws_linked_order_id', $order->get_id(), OrderUtil::custom_orders_table_usage_is_enabled() ? admin_url( 'admin.php?page=wc-orders' ) : admin_url( 'edit.php?post_type=shop_order' ) ), $tree?->getAttribute( 'href' ) ); self::assertSame( '_blank', $tree?->getAttribute( 'target' ) ); - self::assertSame( 'View all linked orders (opens in a new tab)', \preg_replace( '/\s+/', ' ', \trim( (string) $tree?->textContent ) ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. + self::assertSame( 'View all linked (opens in a new tab)', \preg_replace( '/\s+/', ' ', \trim( (string) $tree?->textContent ) ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. } #[DataProvider( 'storages' )] @@ -122,10 +122,11 @@ public function test_another_plugins_order_type_gets_the_box_with_a_link_to_its_ self::assertTrue( dws_lowc_link_orders( $root->get_id(), $child->get_id() ) ); $list = OrderUtil::custom_orders_table_usage_is_enabled() ? admin_url( 'admin.php?page=wc-orders--' . self::ORDER_TYPE ) : admin_url( 'edit.php?post_type=' . self::ORDER_TYPE ); - self::assertSame( 'Linked Test orders', $this->register( wc_get_order( $root->get_id() ), wc_get_page_screen_id( self::ORDER_TYPE ) )['title'] ?? null ); + self::assertSame( 'Linked test orders', $this->register( wc_get_order( $root->get_id() ), wc_get_page_screen_id( self::ORDER_TYPE ) )['title'] ?? null ); $box = $this->render( $root->get_id(), wc_get_page_screen_id( self::ORDER_TYPE ) ); self::assertSame( add_query_arg( '_dws_linked_order_id', $root->get_id(), $list ), $box->querySelector( 'a.dws-linked-orders__tree' )?->getAttribute( 'href' ) ); self::assertStringStartsWith( 'Test order #' . $child->get_order_number(), \trim( (string) $box->querySelector( '#linked-order-' . $child->get_id() . ' a' )?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. + self::assertSame( 'Add new child test order', \trim( (string) $box->querySelector( 'a.button' )?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. } // The posts editor passes its post to the box and the order-tables editor its order, so a row passes what its storage's editor does. diff --git a/plugins/linked-orders-for-woocommerce/tests/Support/RegistersOrderTypeTrait.php b/plugins/linked-orders-for-woocommerce/tests/Support/RegistersOrderTypeTrait.php index c5d5ec2..1f42886 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Support/RegistersOrderTypeTrait.php +++ b/plugins/linked-orders-for-woocommerce/tests/Support/RegistersOrderTypeTrait.php @@ -4,7 +4,7 @@ use PHPUnit\Framework\Attributes\After; -// Another plugin's order type, which v1 1.2.0 linked once its filter named the type. +// Another plugin's order type, which v1 1.2.0 linked once its filter named the type, and which names its own add-child and box phrases, as Quote Requests does for its quotes. trait RegistersOrderTypeTrait { protected const string ORDER_TYPE = 'dws_lowc_test_order'; @@ -19,6 +19,8 @@ protected function register_order_type(): void { 'labels' => array( 'name' => 'Test orders', 'singular_name' => 'Test order', + 'linked_orders_for_woocommerce_add_new_child' => 'Add new child test order', + 'linked_orders_for_woocommerce_linked_items' => 'Linked test orders', ), 'public' => false, 'show_ui' => true, From a963705e5c643ceca717cb3403ad662dbe3ff603 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:20:25 +0100 Subject: [PATCH 45/50] Word the admin for whichever order type is linked The admin named an order mid-sentence, as "Add new child order" or "Showing orders linked to #12", which reads wrong on a quote or any other order type Linked Orders links, and the add-child phrases could not say a quote at all, since a child takes the type its creator makes, which only the type's registrant knows (X378). Strings drop the type noun where the screen already shows the type, refusals open with the type's label and number, and the add-child button, the row action and the box title read whole phrases an order type registers under two slug-rooted label keys, falling back to "Add new child order" and "Linked orders". The keys join the contract, the README and a changelog fragment. Assisted-by: Claude Code:claude-opus-5-5 --- .../linked-orders-for-woocommerce/AGENTS.md | 2 +- .../linked-orders-for-woocommerce/README.md | 2 + .../changelog/order-type-phrases | 4 ++ .../includes/post-type.php | 50 +++++++++++++++++-- .../src/Autocompletion.php | 2 +- .../src/ChildCreation.php | 2 +- .../src/ListTable.php | 20 ++++---- .../src/MetaBox.php | 10 ++-- 8 files changed, 69 insertions(+), 23 deletions(-) create mode 100644 plugins/linked-orders-for-woocommerce/changelog/order-type-phrases diff --git a/plugins/linked-orders-for-woocommerce/AGENTS.md b/plugins/linked-orders-for-woocommerce/AGENTS.md index 9085039..f1aa898 100644 --- a/plugins/linked-orders-for-woocommerce/AGENTS.md +++ b/plugins/linked-orders-for-woocommerce/AGENTS.md @@ -75,7 +75,7 @@ Each runs PHP 8.5 with WooCommerce from `vendor/` and its order tables on; CI ru ## Contract -The plugin's contract is the elements tagged `@api`, the hooks it fires, and its option, meta and capability names and REST routes; breaking it ships as the next major. Every other function, constant and class, `src/` included, is internal and changes in any release. +The plugin's contract is the elements tagged `@api`, the hooks it fires, its option, meta and capability names and REST routes, and the label keys an order type sets for it, `linked_orders_for_woocommerce_add_new_child` and `linked_orders_for_woocommerce_linked_items`; breaking it ships as the next major. Every other function, constant and class, `src/` included, is internal and changes in any release. ## Style diff --git a/plugins/linked-orders-for-woocommerce/README.md b/plugins/linked-orders-for-woocommerce/README.md index 6d1fa62..480c833 100644 --- a/plugins/linked-orders-for-woocommerce/README.md +++ b/plugins/linked-orders-for-woocommerce/README.md @@ -16,6 +16,8 @@ Install the plugin from the WordPress plugin directory under Plugins → Add New Other plugins link two orders through `dws_lowc_link_orders()`, create a linked child through `dws_lowc_create_linked_child()`, which returns the child's id, and walk a tree through `dws_lowc_get_order_node()`, `dws_lowc_get_root_order()` and `dws_lowc_get_orders_tree()`, whose nodes are `DWS_Order_Node` objects. Every hook is named `dws_lowc/area/name`, and each one v1 fired under `linked_orders_for_woocommerce/` still fires first, deprecated; `CHANGELOG.md` lists the renames and the removals. +A child takes the type its creator makes, so an order type words its own add-child phrase and its Linked Orders box title through the `linked_orders_for_woocommerce_add_new_child` and `linked_orders_for_woocommerce_linked_items` labels it registers, such as "Add new child quote" and "Linked quotes"; without them the plugin says "Add new child order" and "Linked orders". + ## Development Development needs PHP and Composer at the `Requires PHP` floor or later, Node.js and npm at `package.json`'s `engines`, and Docker for `@wordpress/env`. diff --git a/plugins/linked-orders-for-woocommerce/changelog/order-type-phrases b/plugins/linked-orders-for-woocommerce/changelog/order-type-phrases new file mode 100644 index 0000000..1052414 --- /dev/null +++ b/plugins/linked-orders-for-woocommerce/changelog/order-type-phrases @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +An order type can word its add-child button and its Linked Orders box title through the linked_orders_for_woocommerce_add_new_child and linked_orders_for_woocommerce_linked_items labels, such as "Add new child quote", and the orders list and notices name an order's type only where it starts a sentence. diff --git a/plugins/linked-orders-for-woocommerce/includes/post-type.php b/plugins/linked-orders-for-woocommerce/includes/post-type.php index d21a530..f2e4bd3 100644 --- a/plugins/linked-orders-for-woocommerce/includes/post-type.php +++ b/plugins/linked-orders-for-woocommerce/includes/post-type.php @@ -32,6 +32,30 @@ */ const DWS_LOWC_CREATE_CHILD_NONCE = 'dws_create_linked_child'; +/** + * The label key under which an order type names the phrase that adds a linked child to one of its orders, such as "Add new child quote". + * + * @api + * + * @since 2.0.0 + * @version 2.0.0 + * + * @var string + */ +const DWS_LOWC_LABEL_ADD_NEW_CHILD = 'linked_orders_for_woocommerce_add_new_child'; + +/** + * The label key under which an order type names the title of its orders' Linked Orders box, such as "Linked quotes". + * + * @api + * + * @since 2.0.0 + * @version 2.0.0 + * + * @var string + */ +const DWS_LOWC_LABEL_LINKED_ITEMS = 'linked_orders_for_woocommerce_linked_items'; + /** * Returns the order types whose orders can be linked. * @@ -80,6 +104,25 @@ function dws_lowc_get_singular_name( WP_Post_Type $post_type ): string { return is_string( $name ) ? $name : $post_type->label; } +/** + * Returns the phrase an order type registered under a label key, or the given fallback where it registered none. + * + * A child takes the type its creator makes, which only the type's registrant knows, so the registrant words the phrases that name it, each whole, for its own translation. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param WP_Post_Type $post_type The post type object of the order type. + * @param string $key The label key. + * @param string $fallback The plugin's own phrase. + * + * @return string + */ +function dws_lowc_get_type_phrase( WP_Post_Type $post_type, string $key, string $fallback ): string { + $phrase = $post_type->labels->{$key} ?? null; + return is_string( $phrase ) && '' !== $phrase ? $phrase : $fallback; +} + /** * Returns whether an order is of a type that can be linked, or null when it is not an order. * @@ -169,10 +212,11 @@ function dws_lowc_get_list_url( WP_Post_Type $post_type ): string { * @return string */ function dws_lowc_get_child_refusal( DWS_Order_Node $node ): string { + $label = dws_lowc_get_singular_name( $node->get_post_type() ); $number = $node->get_order()->get_order_number(); return dws_lowc_get_max_depth() <= $node->get_depth() - ? sprintf( /* translators: %s: The order number. */ __( 'Order #%s is at the maximum linked orders depth, set under WooCommerce > Settings > Advanced > Linked Orders.', 'linked-orders-for-woocommerce' ), $number ) - : sprintf( /* translators: %s: The order number. */ __( 'No linked child can be created for order #%s.', 'linked-orders-for-woocommerce' ), $number ); + ? sprintf( /* translators: 1: The order type's singular name. 2: The order number. */ __( '%1$s #%2$s is at the maximum linked orders depth, set under WooCommerce > Settings > Advanced > Linked Orders.', 'linked-orders-for-woocommerce' ), $label, $number ) + : sprintf( /* translators: 1: The order type's singular name. 2: The order number. */ __( '%1$s #%2$s cannot have a new linked child.', 'linked-orders-for-woocommerce' ), $label, $number ); } /** @@ -288,7 +332,7 @@ function dws_lowc_create_linked_child( int $parent_id, array $args = array() ) { $child->save(); if ( true !== dws_lowc_link_orders( $parent_id, $child->get_id() ) ) { $child->delete( true ); - return new WP_Error( 'dws_lowc_link_refused', __( 'Another order was being linked at the same time. Try again.', 'linked-orders-for-woocommerce' ) ); + return new WP_Error( 'dws_lowc_link_refused', __( 'Another link was being saved at the same time. Try again.', 'linked-orders-for-woocommerce' ) ); } return $child->get_id(); diff --git a/plugins/linked-orders-for-woocommerce/src/Autocompletion.php b/plugins/linked-orders-for-woocommerce/src/Autocompletion.php index 0e44de6..b63cf1d 100644 --- a/plugins/linked-orders-for-woocommerce/src/Autocompletion.php +++ b/plugins/linked-orders-for-woocommerce/src/Autocompletion.php @@ -122,7 +122,7 @@ public function complete_descendants( int $order_id ): void { */ $status = apply_filters( 'dws_lowc/autocompletion/completed_status', $status, $node->get_order() ); - $node->get_order()->update_status( $status, __( 'Child order autocompleted.', 'linked-orders-for-woocommerce' ) ); + $node->get_order()->update_status( $status, __( 'Completed with its linked parent.', 'linked-orders-for-woocommerce' ) ); } } finally { $this->covered = \array_diff_key( $this->covered, $pass ); diff --git a/plugins/linked-orders-for-woocommerce/src/ChildCreation.php b/plugins/linked-orders-for-woocommerce/src/ChildCreation.php index 52fb1f0..d7e2f7f 100644 --- a/plugins/linked-orders-for-woocommerce/src/ChildCreation.php +++ b/plugins/linked-orders-for-woocommerce/src/ChildCreation.php @@ -85,7 +85,7 @@ public function create_linked_child(): never { } $reason = is_wp_error( $child ) ? $child->get_error_message() : __( 'Its creator returned no order.', 'linked-orders-for-woocommerce' ); - $this->notices->add( new Notice( 'linked-child-failed', esc_html( \sprintf( /* translators: 1: The parent order's number. 2: Why the child could not be created. */ __( 'The linked child of order #%1$s could not be created: %2$s', 'linked-orders-for-woocommerce' ), $node->get_order()->get_order_number(), $reason ) ) ) ); + $this->notices->add( new Notice( 'linked-child-failed', esc_html( \sprintf( /* translators: 1: The order type's singular name. 2: The parent order's number. 3: Why the child could not be created. */ __( '%1$s #%2$s could not get a linked child: %3$s', 'linked-orders-for-woocommerce' ), dws_lowc_get_singular_name( $node->get_post_type() ), $node->get_order()->get_order_number(), $reason ) ) ) ); } wp_safe_redirect( wp_get_referer() ?: $node->get_order()->get_edit_order_url() ); diff --git a/plugins/linked-orders-for-woocommerce/src/ListTable.php b/plugins/linked-orders-for-woocommerce/src/ListTable.php index 484d787..1fc8741 100644 --- a/plugins/linked-orders-for-woocommerce/src/ListTable.php +++ b/plugins/linked-orders-for-woocommerce/src/ListTable.php @@ -117,9 +117,9 @@ public function render_depth_cell( string $column, mixed $post_or_order ): void } $depth = $node->get_depth(); - $label = 0 === $depth ? _x( 'Root order', 'list table content', 'linked-orders-for-woocommerce' ) : \sprintf( + $label = 0 === $depth ? _x( 'Root', 'list table content', 'linked-orders-for-woocommerce' ) : \sprintf( /* translators: %d: The order's level, where the root is level 1. */ - _x( 'Child order (level %d)', 'list table content', 'linked-orders-for-woocommerce' ), + _x( 'Child (level %d)', 'list table content', 'linked-orders-for-woocommerce' ), $depth + 1 ); @@ -179,14 +179,14 @@ public function render_filters( string $type ): void { $email = $this->get_guest_email(); $pivot = $this->get_tree_pivot(); ?> - + get_order_number() : $pivot ); } @@ -314,7 +314,7 @@ public function add_row_actions( array $actions, \WC_Order $order ): array { $customer_key = 0 !== $order->get_customer_id() ? '_customer_user' : '_guest_customer_user'; $customer_value = 0 !== $order->get_customer_id() ? (string) $order->get_customer_id() : \rawurlencode( $order->get_billing_email() ); if ( '' !== $customer_value && ( ! isset( $_GET[ $customer_key ] ) || '' === $_GET[ $customer_key ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- A list filter, which changes nothing. - $name = __( 'View all customer orders', 'linked-orders-for-woocommerce' ); + $name = __( 'View all from this customer', 'linked-orders-for-woocommerce' ); $added['view_all_customer_orders'] = array( 'url' => add_query_arg( $customer_key, $customer_value, $list ), 'name' => $name, @@ -326,7 +326,7 @@ public function add_row_actions( array $actions, \WC_Order $order ): array { // A list page builds every row, so the stored links decide, and a tree whose related order was deleted shows as one order. $children = $order->get_meta( DWS_LOWC_META_CHILDREN ); if ( \is_null( $this->get_tree_pivot() ) && ( 0 < $node->get_depth() || ( \is_array( $children ) && array() !== $children ) ) ) { - $name = __( 'View all linked orders', 'linked-orders-for-woocommerce' ); + $name = __( 'View all linked', 'linked-orders-for-woocommerce' ); $added['view_all_linked_orders'] = array( 'url' => add_query_arg( '_dws_linked_order_id', $order->get_id(), $list ), 'name' => $name, @@ -338,7 +338,7 @@ public function add_row_actions( array $actions, \WC_Order $order ): array { if ( $node->can_create_child() ) { $added['create_empty_linked_child'] = array( 'url' => dws_lowc_get_create_child_url( $order->get_id() ), - 'name' => __( 'Create empty linked order', 'linked-orders-for-woocommerce' ), + 'name' => dws_lowc_get_type_phrase( $node->get_post_type(), DWS_LOWC_LABEL_ADD_NEW_CHILD, __( 'Add new child order', 'linked-orders-for-woocommerce' ) ), 'action' => 'create-empty-linked-child', ); } diff --git a/plugins/linked-orders-for-woocommerce/src/MetaBox.php b/plugins/linked-orders-for-woocommerce/src/MetaBox.php index 2e8e6a9..c673b44 100644 --- a/plugins/linked-orders-for-woocommerce/src/MetaBox.php +++ b/plugins/linked-orders-for-woocommerce/src/MetaBox.php @@ -53,11 +53,7 @@ public function add_meta_box( string $screen_id, mixed $post_or_order ): void { add_meta_box( 'dws-linked-orders', - \sprintf( - /* translators: %s: The order type's plural name. */ - _x( 'Linked %s', 'metabox heading', 'linked-orders-for-woocommerce' ), - $node->get_post_type()->label - ), + dws_lowc_get_type_phrase( $node->get_post_type(), DWS_LOWC_LABEL_LINKED_ITEMS, _x( 'Linked orders', 'metabox heading', 'linked-orders-for-woocommerce' ) ), array( $this, 'render_meta_box' ), $screen_id, 'side' @@ -127,7 +123,7 @@ public function render_meta_box( mixed $post_or_order ): void { ?>

    - +

    @@ -140,7 +136,7 @@ public function render_meta_box( mixed $post_or_order ): void { ?> - + get_post_type(), DWS_LOWC_LABEL_ADD_NEW_CHILD, __( 'Add new child order', 'linked-orders-for-woocommerce' ) ) ); ?> Date: Thu, 12 Nov 2026 15:33:46 +0100 Subject: [PATCH 46/50] Start the revision row from the revision before the batched step The row fired the runner's hook and read the revision, but on a site already at the code's revision, as after the migration rows restore it, both assertions held without any step running. The row stores the revision before the batched step first, so it passes only once the hook runs that step. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/Integration/PluginBootTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/PluginBootTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/PluginBootTest.php index d19bdd3..577668c 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/PluginBootTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/PluginBootTest.php @@ -76,9 +76,10 @@ public function test_the_entry_loads_its_safeguards_before_the_bootstrap_package self::assertLessThan( $bootstrap, $safeguards ); } - // The update's second step runs its batches only from the runner's scheduled hook, so a request of its own fires the hook as the scheduler would on a new site, and this process then reads the revision afresh. + // The update's second step runs its batches only from the runner's scheduled hook, so from the revision before it a request of its own fires the hook as the scheduler would, and this process then reads the revision afresh. public function test_the_stored_data_reaches_the_revision_of_the_code_once_the_runners_hook_runs(): void { self::assertInstanceOf( MigrationRunner::class, dws_lowc_plugin()->migrations ); + update_option( Plugin::PREFIX . '_revision', 1 ); $request = self::request( 'do_action( "' . Plugin::PREFIX . '_revision/migration/run" );' ); wp_cache_flush(); From 43071772ef68bbf64d90b3df325f5ba1e8bafdf4 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:33:47 +0100 Subject: [PATCH 47/50] Keep one label key and word the autocompletion note truly A quote's box titled "Linked quotes" would list the order the quote became, since the box shows the parent and children whatever their types, so the box-title label misnamed its own contents (X380). The autocompletion note "Completed with its linked parent." was false where the filter skipped the parent or set another status, and the add-child phrase and its fallback sat in two classes. Every box reads "Linked orders", escaped like any title WordPress prints; one helper words the add-child phrase from the remaining label key; the note reads "Status set when a linked ancestor was completed."; the README tells a registrant to set the label together with the creator it swaps in. Assisted-by: Claude Code:claude-opus-5-5 --- .../linked-orders-for-woocommerce/AGENTS.md | 2 +- .../linked-orders-for-woocommerce/README.md | 2 +- .../changelog/order-type-phrases | 2 +- .../includes/post-type.php | 28 +++++-------------- .../src/Autocompletion.php | 2 +- .../src/ListTable.php | 2 +- .../src/MetaBox.php | 4 +-- .../tests/Integration/AutocompletionTest.php | 4 +-- .../tests/Integration/MetaBoxTest.php | 2 +- .../tests/Support/RegistersOrderTypeTrait.php | 3 +- 10 files changed, 18 insertions(+), 33 deletions(-) diff --git a/plugins/linked-orders-for-woocommerce/AGENTS.md b/plugins/linked-orders-for-woocommerce/AGENTS.md index f1aa898..67c2eda 100644 --- a/plugins/linked-orders-for-woocommerce/AGENTS.md +++ b/plugins/linked-orders-for-woocommerce/AGENTS.md @@ -75,7 +75,7 @@ Each runs PHP 8.5 with WooCommerce from `vendor/` and its order tables on; CI ru ## Contract -The plugin's contract is the elements tagged `@api`, the hooks it fires, its option, meta and capability names and REST routes, and the label keys an order type sets for it, `linked_orders_for_woocommerce_add_new_child` and `linked_orders_for_woocommerce_linked_items`; breaking it ships as the next major. Every other function, constant and class, `src/` included, is internal and changes in any release. +The plugin's contract is the elements tagged `@api`, the hooks it fires, its option, meta and capability names and REST routes, and the `linked_orders_for_woocommerce_add_new_child` label an order type sets for it; breaking it ships as the next major. Every other function, constant and class, `src/` included, is internal and changes in any release. ## Style diff --git a/plugins/linked-orders-for-woocommerce/README.md b/plugins/linked-orders-for-woocommerce/README.md index 480c833..c62b845 100644 --- a/plugins/linked-orders-for-woocommerce/README.md +++ b/plugins/linked-orders-for-woocommerce/README.md @@ -16,7 +16,7 @@ Install the plugin from the WordPress plugin directory under Plugins → Add New Other plugins link two orders through `dws_lowc_link_orders()`, create a linked child through `dws_lowc_create_linked_child()`, which returns the child's id, and walk a tree through `dws_lowc_get_order_node()`, `dws_lowc_get_root_order()` and `dws_lowc_get_orders_tree()`, whose nodes are `DWS_Order_Node` objects. Every hook is named `dws_lowc/area/name`, and each one v1 fired under `linked_orders_for_woocommerce/` still fires first, deprecated; `CHANGELOG.md` lists the renames and the removals. -A child takes the type its creator makes, so an order type words its own add-child phrase and its Linked Orders box title through the `linked_orders_for_woocommerce_add_new_child` and `linked_orders_for_woocommerce_linked_items` labels it registers, such as "Add new child quote" and "Linked quotes"; without them the plugin says "Add new child order" and "Linked orders". +A child takes the type its creator makes, so an order type whose registrant swaps in a creator through `dws_lowc/linked_order/creation_args` also words the add-child phrase through the `linked_orders_for_woocommerce_add_new_child` label it registers, such as "Add new child quote"; without it the plugin says "Add new child order". ## Development diff --git a/plugins/linked-orders-for-woocommerce/changelog/order-type-phrases b/plugins/linked-orders-for-woocommerce/changelog/order-type-phrases index 1052414..af94bdc 100644 --- a/plugins/linked-orders-for-woocommerce/changelog/order-type-phrases +++ b/plugins/linked-orders-for-woocommerce/changelog/order-type-phrases @@ -1,4 +1,4 @@ Significance: minor Type: added -An order type can word its add-child button and its Linked Orders box title through the linked_orders_for_woocommerce_add_new_child and linked_orders_for_woocommerce_linked_items labels, such as "Add new child quote", and the orders list and notices name an order's type only where it starts a sentence. +An order type can word its add-child button and row action through the linked_orders_for_woocommerce_add_new_child label, such as "Add new child quote", and the orders list and notices name an order's type only where it starts a sentence. diff --git a/plugins/linked-orders-for-woocommerce/includes/post-type.php b/plugins/linked-orders-for-woocommerce/includes/post-type.php index f2e4bd3..8d2ca3d 100644 --- a/plugins/linked-orders-for-woocommerce/includes/post-type.php +++ b/plugins/linked-orders-for-woocommerce/includes/post-type.php @@ -1,6 +1,6 @@ labels->{$key} ?? null; - return is_string( $phrase ) && '' !== $phrase ? $phrase : $fallback; +function dws_lowc_get_add_child_phrase( WP_Post_Type $post_type ): string { + $phrase = $post_type->labels->{DWS_LOWC_LABEL_ADD_NEW_CHILD} ?? null; + return is_string( $phrase ) && '' !== $phrase ? $phrase : __( 'Add new child order', 'linked-orders-for-woocommerce' ); } /** diff --git a/plugins/linked-orders-for-woocommerce/src/Autocompletion.php b/plugins/linked-orders-for-woocommerce/src/Autocompletion.php index b63cf1d..94ab535 100644 --- a/plugins/linked-orders-for-woocommerce/src/Autocompletion.php +++ b/plugins/linked-orders-for-woocommerce/src/Autocompletion.php @@ -122,7 +122,7 @@ public function complete_descendants( int $order_id ): void { */ $status = apply_filters( 'dws_lowc/autocompletion/completed_status', $status, $node->get_order() ); - $node->get_order()->update_status( $status, __( 'Completed with its linked parent.', 'linked-orders-for-woocommerce' ) ); + $node->get_order()->update_status( $status, __( 'Status set when a linked ancestor was completed.', 'linked-orders-for-woocommerce' ) ); } } finally { $this->covered = \array_diff_key( $this->covered, $pass ); diff --git a/plugins/linked-orders-for-woocommerce/src/ListTable.php b/plugins/linked-orders-for-woocommerce/src/ListTable.php index 1fc8741..fc7bb99 100644 --- a/plugins/linked-orders-for-woocommerce/src/ListTable.php +++ b/plugins/linked-orders-for-woocommerce/src/ListTable.php @@ -338,7 +338,7 @@ public function add_row_actions( array $actions, \WC_Order $order ): array { if ( $node->can_create_child() ) { $added['create_empty_linked_child'] = array( 'url' => dws_lowc_get_create_child_url( $order->get_id() ), - 'name' => dws_lowc_get_type_phrase( $node->get_post_type(), DWS_LOWC_LABEL_ADD_NEW_CHILD, __( 'Add new child order', 'linked-orders-for-woocommerce' ) ), + 'name' => dws_lowc_get_add_child_phrase( $node->get_post_type() ), 'action' => 'create-empty-linked-child', ); } diff --git a/plugins/linked-orders-for-woocommerce/src/MetaBox.php b/plugins/linked-orders-for-woocommerce/src/MetaBox.php index c673b44..f9a828e 100644 --- a/plugins/linked-orders-for-woocommerce/src/MetaBox.php +++ b/plugins/linked-orders-for-woocommerce/src/MetaBox.php @@ -53,7 +53,7 @@ public function add_meta_box( string $screen_id, mixed $post_or_order ): void { add_meta_box( 'dws-linked-orders', - dws_lowc_get_type_phrase( $node->get_post_type(), DWS_LOWC_LABEL_LINKED_ITEMS, _x( 'Linked orders', 'metabox heading', 'linked-orders-for-woocommerce' ) ), + esc_html_x( 'Linked orders', 'metabox heading', 'linked-orders-for-woocommerce' ), array( $this, 'render_meta_box' ), $screen_id, 'side' @@ -136,7 +136,7 @@ public function render_meta_box( mixed $post_or_order ): void { ?> - get_post_type(), DWS_LOWC_LABEL_ADD_NEW_CHILD, __( 'Add new child order', 'linked-orders-for-woocommerce' ) ) ); ?> + get_post_type() ) ); ?> get_id() ); self::assertSame( 'completed', $stored->get_status() ); - self::assertSame( array( 'Completed with its linked parent. Order status changed from Pending payment to Completed.' ), \array_values( \array_filter( \array_map( static fn ( \stdClass $note ): string => $note->content, wc_get_order_notes( array( 'order_id' => $descendant->get_id() ) ) ), static fn ( string $content ): bool => \str_starts_with( $content, 'Completed with its linked parent.' ) ) ) ); + self::assertSame( array( 'Status set when a linked ancestor was completed. Order status changed from Pending payment to Completed.' ), \array_values( \array_filter( \array_map( static fn ( \stdClass $note ): string => $note->content, wc_get_order_notes( array( 'order_id' => $descendant->get_id() ) ) ), static fn ( string $content ): bool => \str_starts_with( $content, 'Status set when a linked ancestor was completed.' ) ) ) ); } } @@ -85,7 +85,7 @@ public function test_each_order_of_a_tree_is_completed_once_and_with_one_note( b self::assertSame( array( $root->get_id(), $child->get_id(), $other->get_id(), $other_child->get_id(), $other_grandchild->get_id(), $grandchild->get_id() ), $seen ); foreach ( array( $child, $grandchild, $other_child, $other_grandchild ) as $descendant ) { self::assertSame( 'completed', wc_get_order( $descendant->get_id() )->get_status() ); - self::assertCount( 1, \array_filter( wc_get_order_notes( array( 'order_id' => $descendant->get_id() ) ), static fn ( \stdClass $note ): bool => \str_starts_with( $note->content, 'Completed with its linked parent.' ) ) ); + self::assertCount( 1, \array_filter( wc_get_order_notes( array( 'order_id' => $descendant->get_id() ) ), static fn ( \stdClass $note ): bool => \str_starts_with( $note->content, 'Status set when a linked ancestor was completed.' ) ) ); } } diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/MetaBoxTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/MetaBoxTest.php index c92bbe1..a13144a 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/MetaBoxTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/MetaBoxTest.php @@ -122,7 +122,7 @@ public function test_another_plugins_order_type_gets_the_box_with_a_link_to_its_ self::assertTrue( dws_lowc_link_orders( $root->get_id(), $child->get_id() ) ); $list = OrderUtil::custom_orders_table_usage_is_enabled() ? admin_url( 'admin.php?page=wc-orders--' . self::ORDER_TYPE ) : admin_url( 'edit.php?post_type=' . self::ORDER_TYPE ); - self::assertSame( 'Linked test orders', $this->register( wc_get_order( $root->get_id() ), wc_get_page_screen_id( self::ORDER_TYPE ) )['title'] ?? null ); + self::assertSame( 'Linked orders', $this->register( wc_get_order( $root->get_id() ), wc_get_page_screen_id( self::ORDER_TYPE ) )['title'] ?? null ); $box = $this->render( $root->get_id(), wc_get_page_screen_id( self::ORDER_TYPE ) ); self::assertSame( add_query_arg( '_dws_linked_order_id', $root->get_id(), $list ), $box->querySelector( 'a.dws-linked-orders__tree' )?->getAttribute( 'href' ) ); self::assertStringStartsWith( 'Test order #' . $child->get_order_number(), \trim( (string) $box->querySelector( '#linked-order-' . $child->get_id() . ' a' )?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. diff --git a/plugins/linked-orders-for-woocommerce/tests/Support/RegistersOrderTypeTrait.php b/plugins/linked-orders-for-woocommerce/tests/Support/RegistersOrderTypeTrait.php index 1f42886..345a7b1 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Support/RegistersOrderTypeTrait.php +++ b/plugins/linked-orders-for-woocommerce/tests/Support/RegistersOrderTypeTrait.php @@ -4,7 +4,7 @@ use PHPUnit\Framework\Attributes\After; -// Another plugin's order type, which v1 1.2.0 linked once its filter named the type, and which names its own add-child and box phrases, as Quote Requests does for its quotes. +// Another plugin's order type, which v1 1.2.0 linked once its filter named the type, and which words its own add-child phrase, as a registrant whose creator makes children of the type does. trait RegistersOrderTypeTrait { protected const string ORDER_TYPE = 'dws_lowc_test_order'; @@ -20,7 +20,6 @@ protected function register_order_type(): void { 'name' => 'Test orders', 'singular_name' => 'Test order', 'linked_orders_for_woocommerce_add_new_child' => 'Add new child test order', - 'linked_orders_for_woocommerce_linked_items' => 'Linked test orders', ), 'public' => false, 'show_ui' => true, From b5d9610d21c49415f46edbaa9f677b5c79143b93 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:40:55 +0100 Subject: [PATCH 48/50] Name the plugin in the autocompletion note, not an ancestor The walk starts at the completed order itself, so where the status filter names another status, that order got "Status set when a linked ancestor was completed." although it may have no ancestor. The note reads "Status set by Linked Orders' autocompletion.", and the status-filter row checks the completed order's note too. Assisted-by: Claude Code:claude-opus-5-5 --- plugins/linked-orders-for-woocommerce/src/Autocompletion.php | 2 +- .../tests/Integration/AutocompletionTest.php | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/plugins/linked-orders-for-woocommerce/src/Autocompletion.php b/plugins/linked-orders-for-woocommerce/src/Autocompletion.php index 94ab535..4d79f31 100644 --- a/plugins/linked-orders-for-woocommerce/src/Autocompletion.php +++ b/plugins/linked-orders-for-woocommerce/src/Autocompletion.php @@ -122,7 +122,7 @@ public function complete_descendants( int $order_id ): void { */ $status = apply_filters( 'dws_lowc/autocompletion/completed_status', $status, $node->get_order() ); - $node->get_order()->update_status( $status, __( 'Status set when a linked ancestor was completed.', 'linked-orders-for-woocommerce' ) ); + $node->get_order()->update_status( $status, __( 'Status set by Linked Orders\' autocompletion.', 'linked-orders-for-woocommerce' ) ); } } finally { $this->covered = \array_diff_key( $this->covered, $pass ); diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/AutocompletionTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/AutocompletionTest.php index 532cf4b..c9a3c56 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/AutocompletionTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/AutocompletionTest.php @@ -32,7 +32,7 @@ public function test_completing_an_order_completes_its_descendants_with_a_note( foreach ( array( $child, $grandchild ) as $descendant ) { $stored = wc_get_order( $descendant->get_id() ); self::assertSame( 'completed', $stored->get_status() ); - self::assertSame( array( 'Status set when a linked ancestor was completed. Order status changed from Pending payment to Completed.' ), \array_values( \array_filter( \array_map( static fn ( \stdClass $note ): string => $note->content, wc_get_order_notes( array( 'order_id' => $descendant->get_id() ) ) ), static fn ( string $content ): bool => \str_starts_with( $content, 'Status set when a linked ancestor was completed.' ) ) ) ); + self::assertSame( array( 'Status set by Linked Orders\' autocompletion. Order status changed from Pending payment to Completed.' ), \array_values( \array_filter( \array_map( static fn ( \stdClass $note ): string => $note->content, wc_get_order_notes( array( 'order_id' => $descendant->get_id() ) ) ), static fn ( string $content ): bool => \str_starts_with( $content, 'Status set by Linked Orders\' autocompletion.' ) ) ) ); } } @@ -85,7 +85,7 @@ public function test_each_order_of_a_tree_is_completed_once_and_with_one_note( b self::assertSame( array( $root->get_id(), $child->get_id(), $other->get_id(), $other_child->get_id(), $other_grandchild->get_id(), $grandchild->get_id() ), $seen ); foreach ( array( $child, $grandchild, $other_child, $other_grandchild ) as $descendant ) { self::assertSame( 'completed', wc_get_order( $descendant->get_id() )->get_status() ); - self::assertCount( 1, \array_filter( wc_get_order_notes( array( 'order_id' => $descendant->get_id() ) ), static fn ( \stdClass $note ): bool => \str_starts_with( $note->content, 'Status set when a linked ancestor was completed.' ) ) ); + self::assertCount( 1, \array_filter( wc_get_order_notes( array( 'order_id' => $descendant->get_id() ) ), static fn ( \stdClass $note ): bool => \str_starts_with( $note->content, 'Status set by Linked Orders\' autocompletion.' ) ) ); } } @@ -129,6 +129,7 @@ public function test_a_filter_keeps_a_descendant_or_sets_another_status( bool $h } self::assertSame( 'processing', wc_get_order( $root->get_id() )->get_status() ); + self::assertSame( array( 'Status set by Linked Orders\' autocompletion. Order status changed from Completed to Processing.' ), \array_values( \array_filter( \array_map( static fn ( \stdClass $note ): string => $note->content, wc_get_order_notes( array( 'order_id' => $root->get_id() ) ) ), static fn ( string $content ): bool => \str_starts_with( $content, 'Status set by Linked Orders\' autocompletion.' ) ) ) ); self::assertSame( 'pending', wc_get_order( $child->get_id() )->get_status() ); self::assertSame( 'processing', wc_get_order( $grandchild->get_id() )->get_status() ); } From 4bbc634b1c2b54b5d4ef48814eae17d6cc13c3f6 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:04:17 +0100 Subject: [PATCH 49/50] Test v1's hook-tag builders and numbering without v1's edge cases The rows held the v1 hook-tag builders to v1's sanitizing of spaces, backslashes, case and high bytes and to its null for an unknown component, and held Sequential Order Numbers Pro to numbering a child inside the deprecated v1 action, all for callers and listeners no plugin has (X381). The builder rows expect their parts joined with slashes, a name for any component id included; the numbering row expects a listener of the deprecated action to see the child unnumbered and a later listener of the v2 action to see the number and replace it. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/Integration/DeprecatedApiTest.php | 20 +++++++++---------- .../SequentialOrderNumbersProTest.php | 14 ++++++------- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/DeprecatedApiTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/DeprecatedApiTest.php index f9ac792..9ebb2af 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/DeprecatedApiTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/DeprecatedApiTest.php @@ -17,16 +17,13 @@ final class DeprecatedApiTest extends TestCase { public static function hook_tags(): array { return array( - 'plugin' => array( 'dws_lowc_get_hook_tag', array( 'post_type', 'supported_order_types' ), 'linked_orders_for_woocommerce/post_type/supported_order_types' ), - 'plugin-no-extra' => array( 'dws_lowc_get_hook_tag', array( 'created_linked_order' ), 'linked_orders_for_woocommerce/created_linked_order' ), - 'plugin-unsafe' => array( 'dws_lowc_get_hook_tag', array( 'Some Name', array( 'A\\B', '' ) ), 'linked_orders_for_woocommerce/some_name/a_b' ), - 'plugin-bytes' => array( 'dws_lowc_get_hook_tag', array( "Caf\xC3\xA9\tName", array( "\x00Extra\x80" ) ), 'linked_orders_for_woocommerce/cafname/extra' ), - 'component-bytes' => array( 'dws_lowc_get_component_hook_tag', array( 'general-settings', "Na\x1Bme\xFF" ), 'linked_orders_for_woocommerce/general_settings/name' ), - 'component' => array( 'dws_lowc_get_component_hook_tag', array( 'list-table-output', 'column', 'insert_after' ), 'linked_orders_for_woocommerce/list_table_output/column/insert_after' ), - 'component-meta' => array( 'dws_lowc_get_component_hook_tag', array( 'metabox-output', 'after_add_new_child_button' ), 'linked_orders_for_woocommerce/metabox_output/after_add_new_child_button' ), - 'component-group' => array( 'dws_lowc_get_component_hook_tag', array( 'general-settings', 'get_group_fields' ), 'linked_orders_for_woocommerce/general_settings/get_group_fields' ), - 'component-sos' => array( 'dws_lowc_get_component_hook_tag', array( 'wc-sequential-order-numbers-pro-integration', 'name' ), 'linked_orders_for_woocommerce/wc_sequential_order_numbers_pro_integration/name' ), - 'component-absent' => array( 'dws_lowc_get_component_hook_tag', array( 'freemius', 'name' ), null ), + 'plugin' => array( 'dws_lowc_get_hook_tag', array( 'post_type', 'supported_order_types' ), 'linked_orders_for_woocommerce/post_type/supported_order_types' ), + 'plugin-no-extra' => array( 'dws_lowc_get_hook_tag', array( 'created_linked_order' ), 'linked_orders_for_woocommerce/created_linked_order' ), + 'component' => array( 'dws_lowc_get_component_hook_tag', array( 'list-table-output', 'column', 'insert_after' ), 'linked_orders_for_woocommerce/list_table_output/column/insert_after' ), + 'component-meta' => array( 'dws_lowc_get_component_hook_tag', array( 'metabox-output', 'after_add_new_child_button' ), 'linked_orders_for_woocommerce/metabox_output/after_add_new_child_button' ), + 'component-group' => array( 'dws_lowc_get_component_hook_tag', array( 'general-settings', 'get_group_fields' ), 'linked_orders_for_woocommerce/general_settings/get_group_fields' ), + 'component-sos' => array( 'dws_lowc_get_component_hook_tag', array( 'wc-sequential-order-numbers-pro-integration', 'name' ), 'linked_orders_for_woocommerce/wc_sequential_order_numbers_pro_integration/name' ), + 'component-other' => array( 'dws_lowc_get_component_hook_tag', array( 'freemius', 'name' ), 'linked_orders_for_woocommerce/freemius/name' ), ); } @@ -68,8 +65,9 @@ public function test_the_v1_constants_hold_the_plugin_header(): void { self::assertSame( self::$header['RequiresWP'], DWS_LOWC_MIN_WP ); } + // v1's callers pass lowercase names and its component ids, for which joining the parts gives v1's names. #[DataProvider( 'hook_tags' )] - public function test_a_v1_hook_tag_builds_as_v1_built_it( string $shim, array $arguments, ?string $expected ): void { + public function test_a_v1_hook_tag_joins_its_parts_with_slashes( string $shim, array $arguments, string $expected ): void { self::assertSame( $expected, $shim( ...$arguments ) ); self::assertSame( array( array( $shim, '' ) ), $this->deprecated ); } diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/Integration/SequentialOrderNumbersProTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/Integration/SequentialOrderNumbersProTest.php index 6de737d..4764171 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/Integration/SequentialOrderNumbersProTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/Integration/SequentialOrderNumbersProTest.php @@ -32,9 +32,9 @@ public function test_a_linked_child_is_numbered_before_it_is_saved(): void { self::assertSame( 'SOS-' . $id, wc_get_order( $id )?->get_meta( '_order_number' ) ); } - // v1 numbered a child on its own child-created action at priority 10, so a v1 listener after that saw the number and could replace it. + // The child is numbered on the v2 action alone, so a listener of the deprecated v1 action sees it unnumbered, while a later listener of the v2 action sees the number and can replace it. #[RunInSeparateProcess] - public function test_a_v1_listener_after_priority_10_sees_the_number_and_can_replace_it(): void { + public function test_a_listener_after_priority_10_on_the_v2_action_sees_the_number_and_can_replace_it(): void { require_once \dirname( __DIR__, 2 ) . '/Fixtures/sequential-order-numbers-pro.php'; new SequentialOrderNumbersPro()->register_hooks(); wp_set_current_user( 1 ); @@ -43,14 +43,14 @@ public function test_a_v1_listener_after_priority_10_sees_the_number_and_can_rep add_action( 'linked_orders_for_woocommerce/created_linked_order', static function ( \WC_Order $child ) use ( &$seen ): void { - $seen[] = array( 5, $child->get_meta( '_order_number' ) ); + $seen[] = array( 'v1', $child->get_meta( '_order_number' ) ); }, - 5 + 20 ); add_action( - 'linked_orders_for_woocommerce/created_linked_order', + 'dws_lowc/linked_order/created', static function ( \WC_Order $child ) use ( &$seen ): void { - $seen[] = array( 20, $child->get_meta( '_order_number' ) ); + $seen[] = array( 'v2', $child->get_meta( '_order_number' ) ); $child->update_meta_data( '_order_number', 'CUSTOM' ); }, 20 @@ -60,7 +60,7 @@ static function ( \WC_Order $child ) use ( &$seen ): void { self::assertIsInt( $id ); $this->orders[] = $id; - self::assertSame( array( array( 5, '' ), array( 20, 'SOS-' . $id ) ), $seen ); + self::assertSame( array( array( 'v1', '' ), array( 'v2', 'SOS-' . $id ) ), $seen ); self::assertSame( 'CUSTOM', wc_get_order( $id )?->get_meta( '_order_number' ) ); self::assertCount( 1, wc_seq_order_number_pro()->numbered ); } From fe270b77410fa73e1c2da8979c12a31967d700d3 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:08:01 +0100 Subject: [PATCH 50/50] Drop v1 edge-case reproduction and single-use constants v1's hook-tag builders went through a shared helper that copied v1's sanitizing and a list of v1's component ids, Sequential Order Numbers Pro joined the deprecated v1 action through deprecated_hook_run to number a child where v1 did, and two constants each served one line, one of them added to the contract, all for callers and listeners no plugin has (X381). Each builder joins its parts with slashes in one line; the integration numbers a child on the v2 action alone, which a changelog fragment records; the add-child label key and v1's settings groups are written where they are read. Assisted-by: Claude Code:claude-opus-5-5 --- .../changelog/sequential-order-numbers-action | 4 ++ .../includes/deprecated.php | 38 ++-------- .../includes/post-type.php | 16 +---- .../includes/settings.php | 12 +--- .../Integration/SequentialOrderNumbersPro.php | 69 +------------------ 5 files changed, 13 insertions(+), 126 deletions(-) create mode 100644 plugins/linked-orders-for-woocommerce/changelog/sequential-order-numbers-action diff --git a/plugins/linked-orders-for-woocommerce/changelog/sequential-order-numbers-action b/plugins/linked-orders-for-woocommerce/changelog/sequential-order-numbers-action new file mode 100644 index 0000000..8b2cc8d --- /dev/null +++ b/plugins/linked-orders-for-woocommerce/changelog/sequential-order-numbers-action @@ -0,0 +1,4 @@ +Significance: minor +Type: changed + +The Sequential Order Numbers Pro integration numbers a linked child on dws_lowc/linked_order/created, so a listener of the deprecated linked_orders_for_woocommerce/created_linked_order action sees the child unnumbered; a listener of dws_lowc/linked_order/created at a priority after 10 sees the number and can replace it. diff --git a/plugins/linked-orders-for-woocommerce/includes/deprecated.php b/plugins/linked-orders-for-woocommerce/includes/deprecated.php index 72f08e1..00c34e0 100644 --- a/plugins/linked-orders-for-woocommerce/includes/deprecated.php +++ b/plugins/linked-orders-for-woocommerce/includes/deprecated.php @@ -85,16 +85,6 @@ unset( $dws_lowc_header ); -/** - * The ids of v1's components, whose hook tags took the id with dashes as underscores. - * - * @since 2.0.0 - * @version 2.0.0 - * - * @var list - */ -const DWS_LOWC_V1_COMPONENTS = array( 'actions', 'autocompletion', 'output', 'metabox-output', 'list-table-output', 'permissions', 'output-permissions', 'settings', 'general-settings', 'plugin-settings', 'integrations', 'wc-sequential-order-numbers-pro-integration' ); - /** * Returns the plugin's name, translated once init has fired. * @@ -191,11 +181,11 @@ function dws_lowc_min_wp(): string { */ function dws_lowc_get_hook_tag( string $name, $extra = array() ): string { _deprecated_function( __FUNCTION__, '2.0.0' ); - return dws_lowc_join_v1_hook_tag( 'linked_orders_for_woocommerce', $name, $extra ); + return implode( '/', array_filter( array_merge( array( 'linked_orders_for_woocommerce', $name ), (array) $extra ), 'boolval' ) ); } /** - * Returns a hook name as v1 built it for one of its components, or null for an id v1 had no component for. + * Returns a hook name as v1 built it for one of its components, whose id takes underscores for its dashes. * * @api * @@ -207,29 +197,11 @@ function dws_lowc_get_hook_tag( string $name, $extra = array() ): string { * @param string $name The hook's name. * @param string|array $extra The name's further parts. * - * @return string|null + * @return string */ -function dws_lowc_get_component_hook_tag( string $component_id, string $name, $extra = array() ): ?string { +function dws_lowc_get_component_hook_tag( string $component_id, string $name, $extra = array() ): string { _deprecated_function( __FUNCTION__, '2.0.0' ); - return in_array( $component_id, DWS_LOWC_V1_COMPONENTS, true ) ? dws_lowc_join_v1_hook_tag( 'linked_orders_for_woocommerce/' . str_replace( '-', '_', $component_id ), $name, $extra ) : null; -} - -/** - * Returns a hook name joined and sanitized as v1 did. - * - * Its non-empty parts are joined with slashes, spaces and backslashes become underscores, bytes below 32 and above 127 go, and the name is lowercased. - * - * @since 2.0.0 - * @version 2.0.0 - * - * @param string $root The name's root. - * @param string $name The hook's name. - * @param string|array $extra The name's further parts. - * - * @return string - */ -function dws_lowc_join_v1_hook_tag( string $root, string $name, $extra ): string { - return strtolower( filter_var( str_replace( array( ' ', '\\' ), '_', implode( '/', array_filter( array_merge( array( $root, $name ), is_array( $extra ) ? $extra : array( $extra ) ), 'boolval' ) ) ), FILTER_UNSAFE_RAW, FILTER_FLAG_STRIP_LOW | FILTER_FLAG_STRIP_HIGH | FILTER_NULL_ON_FAILURE ) ?? '' ); + return implode( '/', array_filter( array_merge( array( 'linked_orders_for_woocommerce', str_replace( '-', '_', $component_id ), $name ), (array) $extra ), 'boolval' ) ); } /** diff --git a/plugins/linked-orders-for-woocommerce/includes/post-type.php b/plugins/linked-orders-for-woocommerce/includes/post-type.php index 8d2ca3d..1d65605 100644 --- a/plugins/linked-orders-for-woocommerce/includes/post-type.php +++ b/plugins/linked-orders-for-woocommerce/includes/post-type.php @@ -32,18 +32,6 @@ */ const DWS_LOWC_CREATE_CHILD_NONCE = 'dws_create_linked_child'; -/** - * The label key under which an order type names the phrase that adds a linked child to one of its orders, such as "Add new child quote". - * - * @api - * - * @since 2.0.0 - * @version 2.0.0 - * - * @var string - */ -const DWS_LOWC_LABEL_ADD_NEW_CHILD = 'linked_orders_for_woocommerce_add_new_child'; - /** * Returns the order types whose orders can be linked. * @@ -93,7 +81,7 @@ function dws_lowc_get_singular_name( WP_Post_Type $post_type ): string { } /** - * Returns the phrase that adds a linked child to an order of the given type. + * Returns the phrase that adds a linked child to an order of the given type, which the type registers as its `linked_orders_for_woocommerce_add_new_child` label. * * A child takes the type its creator makes, which only the type's registrant knows, so the registrant words the whole phrase, for its own translation. * @@ -105,7 +93,7 @@ function dws_lowc_get_singular_name( WP_Post_Type $post_type ): string { * @return string */ function dws_lowc_get_add_child_phrase( WP_Post_Type $post_type ): string { - $phrase = $post_type->labels->{DWS_LOWC_LABEL_ADD_NEW_CHILD} ?? null; + $phrase = $post_type->labels->linked_orders_for_woocommerce_add_new_child ?? null; return is_string( $phrase ) && '' !== $phrase ? $phrase : __( 'Add new child order', 'linked-orders-for-woocommerce' ); } diff --git a/plugins/linked-orders-for-woocommerce/includes/settings.php b/plugins/linked-orders-for-woocommerce/includes/settings.php index 56d839d..204802e 100644 --- a/plugins/linked-orders-for-woocommerce/includes/settings.php +++ b/plugins/linked-orders-for-woocommerce/includes/settings.php @@ -10,16 +10,6 @@ defined( 'ABSPATH' ) || exit; -/** - * The ids of v1's settings groups, in the order the plugin's section shows them. - * - * @since 2.0.0 - * @version 2.0.0 - * - * @var list - */ -const DWS_LOWC_SETTINGS_GROUPS = array( 'general', 'plugin' ); - /** * Returns the stored value of a setting, or null for a setting that is not stored or a group v1 did not have. * @@ -165,5 +155,5 @@ function dws_lowc_find_setting( string $field_id, ?string $group ): array { $group = substr( $group, 0, -9 ); } - return array( $group, $field_id, in_array( $group, DWS_LOWC_SETTINGS_GROUPS, true ) ? "dws-lowc_{$group}_$field_id" : null ); + return array( $group, $field_id, in_array( $group, array( 'general', 'plugin' ), true ) ? "dws-lowc_{$group}_$field_id" : null ); } diff --git a/plugins/linked-orders-for-woocommerce/src/Integration/SequentialOrderNumbersPro.php b/plugins/linked-orders-for-woocommerce/src/Integration/SequentialOrderNumbersPro.php index 8008638..9e4a895 100644 --- a/plugins/linked-orders-for-woocommerce/src/Integration/SequentialOrderNumbersPro.php +++ b/plugins/linked-orders-for-woocommerce/src/Integration/SequentialOrderNumbersPro.php @@ -16,30 +16,6 @@ * @version 2.0.0 */ final class SequentialOrderNumbersPro implements ComponentInterface, ConditionalComponentInterface { - // region FIELDS AND CONSTANTS - - /** - * The v1 action a linked child fires on before its v2 action, where v1 numbered it at the default priority. - * - * @since 2.0.0 - * @version 2.0.0 - * - * @var string - */ - protected const string V1_CREATED = 'linked_orders_for_woocommerce/created_linked_order'; - - /** - * The children numbered among v1's listeners, keyed by object id, which the v2 action then leaves alone. - * - * @since 2.0.0 - * @version 2.0.0 - * - * @var array - */ - protected array $numbered = array(); - - // endregion - // region INHERITED METHODS /** @@ -61,7 +37,6 @@ public static function should_load(): bool { */ #[\Override] public function register_hooks(): void { - add_action( 'deprecated_hook_run', array( $this, 'join_v1_listeners' ) ); add_action( 'dws_lowc/linked_order/created', array( $this, 'set_order_number' ) ); } @@ -70,42 +45,7 @@ public function register_hooks(): void { // region HOOKS /** - * Numbers the next linked child at the default priority of v1's action when that action is about to run its listeners. - * - * WordPress fires a deprecated action only while it has listeners, so numbering joins it only then, and listening to it always would report the action as used. - * - * @since 2.0.0 - * @version 2.0.0 - * - * @param string $hook The deprecated hook about to run. - * - * @return void - */ - public function join_v1_listeners( string $hook ): void { - if ( self::V1_CREATED === $hook ) { - add_action( self::V1_CREATED, array( $this, 'set_v1_order_number' ) ); - } - } - - /** - * Gives a linked child its sequential number among v1's listeners. - * - * @since 2.0.0 - * @version 2.0.0 - * - * @param \WC_Order $child The linked child. - * - * @return void - */ - public function set_v1_order_number( \WC_Order $child ): void { - $this->numbered[ \spl_object_id( $child ) ] = true; - wc_seq_order_number_pro()->set_sequential_order_number( $child ); - } - - /** - * Gives a linked child its sequential number before it is saved, unless v1's action already did, and then leaves v1's action. - * - * WordPress skips the next priority of an action whose running callback removes its own, so numbering leaves v1's action only once it has ended. + * Gives a linked child its sequential number before it is saved. * * @since 2.0.0 * @version 2.0.0 @@ -115,13 +55,6 @@ public function set_v1_order_number( \WC_Order $child ): void { * @return void */ public function set_order_number( \WC_Order $child ): void { - $id = \spl_object_id( $child ); - if ( isset( $this->numbered[ $id ] ) ) { - unset( $this->numbered[ $id ] ); - remove_action( self::V1_CREATED, array( $this, 'set_v1_order_number' ) ); - return; - } - wc_seq_order_number_pro()->set_sequential_order_number( $child ); }