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/AGENTS.md b/plugins/linked-orders-for-woocommerce/AGENTS.md index 7a67805..67c2eda 100644 --- a/plugins/linked-orders-for-woocommerce/AGENTS.md +++ b/plugins/linked-orders-for-woocommerce/AGENTS.md @@ -17,10 +17,11 @@ 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 -├── 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 @@ -45,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 ``` @@ -63,19 +64,18 @@ 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 -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 `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/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..c62b845 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 @@ -14,11 +14,13 @@ 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. + +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 -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/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/changelog/admin-copy b/plugins/linked-orders-for-woocommerce/changelog/admin-copy new file mode 100644 index 0000000..5a9f8db --- /dev/null +++ b/plugins/linked-orders-for-woocommerce/changelog/admin-copy @@ -0,0 +1,4 @@ +Significance: minor +Type: changed + +Notices say why no child can be created, naming the maximum depth setting where it applies, and name orders by number; the maximum depth help shows under its field, the data-removal setting says that the links between orders are deleted too, and labels read in sentence case. 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/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/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/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/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/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/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/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/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/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/order-type-phrases b/plugins/linked-orders-for-woocommerce/changelog/order-type-phrases new file mode 100644 index 0000000..af94bdc --- /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 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/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/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/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/changelog/tree-links b/plugins/linked-orders-for-woocommerce/changelog/tree-links new file mode 100644 index 0000000..9cf6313 --- /dev/null +++ b/plugins/linked-orders-for-woocommerce/changelog/tree-links @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +The orders list shows which tree or billing email it is filtered by, with a link that removes the filter, and the Linked Orders box links to the order's tree in the orders list. 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/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/docs/ARCHITECTURE.md b/plugins/linked-orders-for-woocommerce/docs/ARCHITECTURE.md index 6eb4473..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,9 +17,9 @@ 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, 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. @@ -28,16 +28,16 @@ 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; `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`, 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. 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/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/includes/deprecated.php b/plugins/linked-orders-for-woocommerce/includes/deprecated.php index d128ba7..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,27 +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: the non-empty parts joined with slashes, spaces and backslashes as underscores, 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( str_replace( array( ' ', '\\' ), '_', implode( '/', array_filter( array_merge( array( $root, $name ), is_array( $extra ) ? $extra : array( $extra ) ), 'boolval' ) ) ) ); + 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/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 aea1f13..f69c800 100644 --- a/plugins/linked-orders-for-woocommerce/includes/linking.php +++ b/plugins/linked-orders-for-woocommerce/includes/linking.php @@ -8,12 +8,53 @@ * @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; 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. * @@ -52,9 +93,11 @@ 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 two orders as parent and child. * - * 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. + * 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. * * @api * @@ -73,34 +116,25 @@ 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 { - // 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_LOWC_META_PARENT ) ) { return false; } $parent->save(); - $child->save(); } finally { $lock->release( $owner ); } @@ -109,7 +143,153 @@ function dws_lowc_link_orders( int $parent_id, int $child_id ): ?bool { } /** - * 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. + * 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. + * + * 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 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. + * + * 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. * * @api * @@ -121,9 +301,16 @@ function dws_lowc_link_orders( int $parent_id, int $child_id ): ?bool { * @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; @@ -132,6 +319,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 @@ -147,9 +336,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; diff --git a/plugins/linked-orders-for-woocommerce/includes/migrations.php b/plugins/linked-orders-for-woocommerce/includes/migrations.php index 9172c19..a32831c 100644 --- a/plugins/linked-orders-for-woocommerce/includes/migrations.php +++ b/plugins/linked-orders-for-woocommerce/includes/migrations.php @@ -8,6 +8,8 @@ * @package DeepWebSolutions\LinkedOrders */ +use DeepWebSolutions\LinkedOrders\Scoped\DeepWebSolutions\Framework\Core\Migration\BatchedMigrationStepInterface; + defined( 'ABSPATH' ) || exit; /** @@ -16,11 +18,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 +67,96 @@ 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; + + $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, + '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/includes/post-type.php b/plugins/linked-orders-for-woocommerce/includes/post-type.php index 33d3f45..1d65605 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 @@ label; } +/** + * 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. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param WP_Post_Type $post_type The post type object of the parent's order type. + * + * @return string + */ +function dws_lowc_get_add_child_phrase( WP_Post_Type $post_type ): string { + $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' ); +} + /** * Returns whether an order is of a type that can be linked, or null when it is not an order. * @@ -124,6 +161,38 @@ function dws_lowc_get_valid_statuses_for_new_child( string $order_type = 'shop_o return apply_filters( 'dws_lowc/post_type/valid_statuses_for_new_child', $statuses, $order_type, $order ); } +/** + * Returns the URL of an order type's admin list, on the storage WooCommerce uses for orders. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param WP_Post_Type $post_type The post type object of the order type. + * + * @return string + */ +function dws_lowc_get_list_url( WP_Post_Type $post_type ): string { + return OrderUtil::custom_orders_table_usage_is_enabled() ? admin_url( 'admin.php?page=wc-orders' . ( 'shop_order' === $post_type->name ? '' : '--' . $post_type->name ) ) : admin_url( 'edit.php?post_type=' . $post_type->name ); +} + +/** + * Returns why no child can be created from an order, naming the setting where the order is at the maximum depth. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param DWS_Order_Node $node The order's node. + * + * @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: 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 ); +} + /** * Returns the URL that creates a linked child of an order through the plugin's admin-post action, with its nonce. * @@ -135,13 +204,13 @@ function dws_lowc_get_valid_statuses_for_new_child( string $order_type = 'shop_o * @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 ); } /** * 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,14 +270,15 @@ 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; } $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. @@ -236,7 +306,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. Please 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/includes/settings.php b/plugins/linked-orders-for-woocommerce/includes/settings.php index 4b83283..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; a field of a group lives in the option `dws-lowc_{group}_{field}`. - * - * @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. * @@ -34,13 +24,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 +44,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 +87,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,20 +107,22 @@ 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] ) { - 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; } /** - * 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 @@ -134,13 +130,13 @@ 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 ]; } /** - * 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 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 @@ -148,20 +144,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, array( 'general', 'plugin' ), true ) ? "dws-lowc_{$group}_$field_id" : null ); } 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/models/DWS_Order_Node.php b/plugins/linked-orders-for-woocommerce/models/DWS_Order_Node.php index c073432..1b0bcc1 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 @@ -232,7 +236,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 +252,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 @@ -326,74 +397,9 @@ public function add_child( DWS_Order_Node $child ): bool { } /** - * Returns the order's name as its type, number and status, such as "Order #123 - Processing". - * - * @since 1.0.0 - * @version 2.0.0 + * Returns whether a user may create a child of the order. * - * @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. + * 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 @@ -404,7 +410,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 ) ); @@ -445,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(); } @@ -469,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/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 @@ - + 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 @@ + + diff --git a/plugins/linked-orders-for-woocommerce/readme.txt b/plugins/linked-orders-for-woocommerce/readme.txt index 2a27ecf..1b55086 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 @@ -42,7 +42,7 @@ If the minimum required version of WooCommerce is present, you will find a secti 1. Choose the maximum depth level you want to allow. 1. Choose whether to autocomplete descendant orders upon completion of a parent order. -1. Choose whether to remove the plugin's data when it is uninstalled. +1. Choose whether deleting the plugin also deletes its settings and the links between orders. == Frequently Asked Questions == @@ -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, 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 == diff --git a/plugins/linked-orders-for-woocommerce/src/Autocompletion.php b/plugins/linked-orders-for-woocommerce/src/Autocompletion.php index 961a2fa..4d79f31 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 /** @@ -31,7 +45,11 @@ 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. * * @since 2.0.0 * @version 2.0.0 @@ -41,62 +59,73 @@ 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 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. + */ + $should_autocomplete = apply_filters( 'dws_lowc/autocompletion/should_autocomplete', $should_autocomplete, $descendant_id ); + if ( true !== $should_autocomplete ) { + 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() ) ) ); + $node = dws_lowc_get_order_node( $descendant_id ); + 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() ); + + $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/src/ChildCreation.php b/plugins/linked-orders-for-woocommerce/src/ChildCreation.php index 7544f4e..d7e2f7f 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 @@ -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 @@ -55,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'] ?? '' ), @@ -67,15 +69,13 @@ 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; } if ( ! $node->can_create_child() ) { - /* translators: %d: The parent order's id. */ - $this->notices->add( new Notice( 'linked-child-not-allowed', esc_html( \sprintf( __( 'You may not create a linked child for order %d.', 'linked-orders-for-woocommerce' ), $parent_id ) ) ) ); + $this->notices->add( new Notice( 'linked-child-not-allowed', esc_html( dws_lowc_get_child_refusal( $node ) ) ) ); } else { $child = dws_lowc_create_linked_child( $parent_id ); $order = \is_int( $child ) ? wc_get_order( $child ) : null; @@ -84,9 +84,8 @@ public function create_linked_child(): never { exit; } - $reason = is_wp_error( $child ) ? $child->get_error_message() : __( 'its creator returned no order.', 'linked-orders-for-woocommerce' ); - /* translators: 1: The parent order's id. 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$d could not be created: %2$s', 'linked-orders-for-woocommerce' ), $parent_id, $reason ) ) ) ); + $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 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/Integration/SequentialOrderNumbersPro.php b/plugins/linked-orders-for-woocommerce/src/Integration/SequentialOrderNumbersPro.php index dea50d2..9e4a895 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 2fc7890..fc7bb99 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,9 +117,11 @@ public function render_depth_cell( string $column, mixed $post_or_order ): void } $depth = $node->get_depth(); - $name = dws_lowc_get_singular_name( $node->get_post_type() ); - /* translators: 1: The order type's singular name. 2: The order's level, where the root is level 1. */ - $label = 0 === $depth ? \sprintf( _x( 'Root %s', 'list table content', 'linked-orders-for-woocommerce' ), $name ) : \sprintf( _x( 'Child %1$s (level %2$d)', 'list table content', 'linked-orders-for-woocommerce' ), $name, $depth + 1 ); + $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 (level %d)', 'list table content', 'linked-orders-for-woocommerce' ), + $depth + 1 + ); /** * Filters an order's depth label in the orders list. @@ -156,27 +158,64 @@ 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. + $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, + '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 + ); + } + + foreach ( $lines as $key => $line ) { + ?> + + + + + get_depth_clauses(); + $email = $this->get_guest_email(); if ( '' !== $email ) { $clauses[] = array( 'key' => '_billing_email', @@ -205,9 +244,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; } @@ -230,17 +269,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; } @@ -260,29 +299,38 @@ 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(); - $type = $node->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 ); - $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 from this customer', 'linked-orders-for-woocommerce' ); $added['view_all_customer_orders'] = array( - 'url' => add_query_arg( $customers[0], $customers[1], $list ), - /* translators: %s: The order type's plural name. */ - 'name' => \sprintf( __( 'View all customer %s', 'linked-orders-for-woocommerce' ), $type->label ), + 'url' => add_query_arg( $customer_key, $customer_value, $list ), + 'name' => $name, + 'title' => "{$name} {$new_tab}", 'action' => 'view-all-customer-orders', ); } - 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_LOWC_META_CHILDREN ); + if ( \is_null( $this->get_tree_pivot() ) && ( 0 < $node->get_depth() || ( \is_array( $children ) && array() !== $children ) ) ) { + $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 ), - /* translators: %s: The order type's plural name. */ - 'name' => \sprintf( __( 'View all linked %s', 'linked-orders-for-woocommerce' ), $type->label ), + 'name' => $name, + 'title' => "{$name} {$new_tab}", 'action' => 'view-all-linked-orders', ); } @@ -290,8 +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() ), - /* 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' => dws_lowc_get_add_child_phrase( $node->get_post_type() ), 'action' => 'create-empty-linked-child', ); } @@ -308,7 +355,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; } @@ -347,7 +394,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; @@ -357,33 +404,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. * @@ -392,9 +412,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, + ) + ); } /** @@ -405,14 +431,14 @@ 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(); } $clause = array( - 'key' => '_dws_lo_depth', + 'key' => DWS_LOWC_META_DEPTH, 'value' => (string) $depth, ); return array( @@ -420,7 +446,7 @@ protected static function get_depth_clauses(): array { 'relation' => 'OR', $clause, array( - 'key' => '_dws_lo_depth', + 'key' => DWS_LOWC_META_DEPTH, 'compare' => 'NOT EXISTS', ), ) : $clause, @@ -428,7 +454,9 @@ protected static 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 @@ -438,12 +466,14 @@ 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 ); } /** - * 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 @@ -452,9 +482,9 @@ 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 ) { + protected function get_tree_filter( mixed $found ): ?array { + $pivot = $this->get_tree_pivot(); + if ( \is_null( $pivot ) ) { return null; } @@ -470,17 +500,34 @@ protected static function get_tree_filter( mixed $found ): ?array { } /** - * Returns a list filter from the request, sanitized, or an empty string when it is absent. + * 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 * - * @param string $key The filter's query key. + * @return int|null + */ + 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 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 * * @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 0ec43e0..f9a828e 100644 --- a/plugins/linked-orders-for-woocommerce/src/MetaBox.php +++ b/plugins/linked-orders-for-woocommerce/src/MetaBox.php @@ -42,13 +42,22 @@ 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', + esc_html_x( 'Linked orders', 'metabox heading', 'linked-orders-for-woocommerce' ), + array( $this, 'render_meta_box' ), + $screen_id, + 'side' + ); } /** @@ -69,13 +78,14 @@ 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 ) ) { ?>
@@ -87,20 +97,20 @@ public function render_meta_box( mixed $post_or_order ): void {
- - +
- : +
    get_children() as $child ) : ?>
  • get_formatted_name() ); ?> +
  • @@ -109,14 +119,24 @@ public function render_meta_box( mixed $post_or_order ): void { has_children() ) { + ?> +

    + + + + +

    + - - + get_post_type() ) ); ?> 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..ac3bd6d 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, ); @@ -126,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 f433b96..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 @@ -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,11 +105,11 @@ 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 ) { - $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; } /** @@ -89,15 +127,15 @@ public function get_section_fields( string $tab_id, string $section_id ): array array( array( 'type' => 'title', - 'id' => "dws-lowc_$group", - 'title' => 'general' === $group ? __( 'General Settings', 'linked-orders-for-woocommerce' ) : __( 'Plugin Settings', 'linked-orders-for-woocommerce' ), + 'id' => "dws-lowc_{$group}_start", + 'title' => $definition['title'], ), ), $wc_fields, array( array( 'type' => 'sectionend', - 'id' => "dws-lowc_$group", + 'id' => "dws-lowc_{$group}_end", ), ) ); @@ -134,7 +172,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 +182,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,59 +198,11 @@ 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; } // 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_tip' => __( 'The maximum number of linked orders levels. Root orders are considered level 0.', '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 data when it is uninstalled, so it must be configured again if it is installed later', 'linked-orders-for-woocommerce' ), - 'default' => wc_bool_to_string( DWS_LOWC_OPTION_DEFAULTS[ DWS_LOWC_OPTION_DELETE_DATA ] ), - ), - ); - } - - // endregion } 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..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 @@ -1,4 +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', () => { @@ -30,9 +32,14 @@ test.describe( 'Linked orders', () => { ); const box = page.locator( '#dws-linked-orders' ); await expect( box ).toContainText( 'There are no attached children.' ); - await box.getByRole( 'link', { name: 'Add new child Order' } ).click(); + 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 ); @@ -41,6 +48,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( @@ -48,26 +59,42 @@ 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(); + expect( listResults.violations ).toEqual( [] ); } ); 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' ); + 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 ); + } } ); } ); 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 @@ + + * @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 @@ 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 ); @@ -37,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::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( '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.' ) ) ) ); } } @@ -63,6 +58,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, 'Status set by Linked Orders\' autocompletion.' ) ) ); + } + } + #[DataProvider( 'storages' )] public function test_completing_a_child_leaves_its_parent_alone( bool $hpos, bool $sync ): void { $this->use_storage( $hpos, $sync ); @@ -102,6 +128,8 @@ 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( 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() ); } diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/ChildCreationTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/ChildCreationTest.php index 6dbe6dc..6b5183b 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. @@ -54,9 +49,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 +70,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 { @@ -97,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( 'You may not create a linked child for order ' . $parent->get_id() . '.' ), \array_map( static fn ( Notice $notice ): string => $notice->message, $this->notices->get_notices( NoticeScope::User, $this->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() ); } @@ -129,7 +121,37 @@ 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_id() . ' 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' )] + public function test_an_order_at_the_maximum_depth_returns_with_the_setting_that_limits_it( 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() ) ); + + $this->request( (string) $child->get_id() ); + + self::assertSame( array( 'Order #' . $child->get_order_number() . ' is at the maximum linked orders depth, set under WooCommerce > Settings > Advanced > Linked Orders.' ), $this->get_messages() ); + } + + #[DataProvider( 'storages' )] + public function test_a_creator_that_returns_no_order_is_named_in_a_sentence( bool $hpos, bool $sync ): void { + $this->use_storage( $hpos, $sync ); + $parent = $this->create_order(); + $creator = static fn ( array $args ): array => array( 'create_function' => '__return_zero' ) + $args; + add_filter( 'dws_lowc/linked_order/creation_args', $creator ); + add_filter( 'doing_it_wrong_trigger_error', '__return_false' ); + + try { + $this->request( (string) $parent->get_id() ); + } finally { + remove_filter( 'dws_lowc/linked_order/creation_args', $creator ); + remove_filter( 'doing_it_wrong_trigger_error', '__return_false' ); + } + + 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/DeprecatedApiTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/DeprecatedApiTest.php index 2356ded..9ebb2af 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,22 +12,24 @@ final class DeprecatedApiTest extends TestCase { use RecordsDeprecationsTrait; + protected static array $header = array(); + 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' ), - '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' ), ); } - 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', @@ -34,12 +37,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()' ), @@ -53,24 +58,16 @@ 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 ); } + // 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/DeprecatedHooksTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/DeprecatedHooksTest.php index 575d335..fb63492 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( @@ -45,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, + }; + $spy = static function ( mixed ...$arguments ) use ( &$fired, $made ): mixed { + $fired[] = array( current_filter(), $arguments ); + return $made( $arguments[0] ); }; - add_filter( $v1, $spy ); - add_filter( $v2, $spy ); + add_filter( $v1, $spy, 10, 9 ); + add_filter( $v2, $spy, 10, 9 ); try { $this->{$trigger}(); @@ -63,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 ); } @@ -92,22 +108,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 ); @@ -131,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; 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..4764171 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' ) ); } + + // 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_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 ); + 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( 'v1', $child->get_meta( '_order_number' ) ); + }, + 20 + ); + add_action( + 'dws_lowc/linked_order/created', + static function ( \WC_Order $child ) use ( &$seen ): void { + $seen[] = array( 'v2', $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( '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 ); + } } diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/LinkingTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/LinkingTest.php index 709b183..162ce06 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/LinkingTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/LinkingTest.php @@ -2,10 +2,14 @@ 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\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; use PHPUnit\Framework\Attributes\Before; use PHPUnit\Framework\Attributes\CoversNothing; @@ -14,10 +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 { @@ -27,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' )] @@ -95,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; @@ -133,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() ) ); } @@ -171,6 +177,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 ); @@ -194,17 +226,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_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_a_child_whose_parent_was_deleted_is_its_own_root( bool $hpos, bool $sync ): void { + 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' )] @@ -227,34 +298,37 @@ 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(); + $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( $user ) ); + + $editor->add_cap( 'edit_others_shop_orders' ); + self::assertTrue( dws_lowc_get_order_node( $order->get_id() )?->can_create_child( $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 ); $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' )] @@ -338,7 +412,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 ); @@ -359,6 +433,92 @@ 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() ); + } + + // 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 ); @@ -367,11 +527,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 ); @@ -381,6 +536,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 ); diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/ListTableTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/ListTableTest.php index 244f189..5f447f9 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/ListTableTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/ListTableTest.php @@ -5,17 +5,26 @@ 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 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\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; + use RegistersOrderTypeTrait; + use RestoresOptionsTrait; + + protected const array OPTIONS = array( DWS_LOWC_OPTION_MAX_DEPTH ); protected const array COLUMNS = array( 'cb' => '', @@ -24,16 +33,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 ); @@ -42,20 +41,20 @@ 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 ); - if ( 0 !== $this->user ) { - require_once ABSPATH . 'wp-admin/includes/user.php'; - wp_delete_user( $this->user ); - $this->user = 0; - } } #[DataProvider( 'storages' )] @@ -68,23 +67,15 @@ 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' ) ); } #[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(); @@ -106,14 +97,20 @@ 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( '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 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. + 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"]' ) ); + + 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. @@ -144,6 +141,30 @@ public function test_the_depth_filter_counts_an_order_in_any_status_at_the_deepe } } + // The tree and guest filters come from a row's link, not the filter bar, and the bar's hidden fields keep them, so the bar says which is on and links to the list without it. + #[DataProvider( 'storages' )] + public function test_an_active_tree_or_guest_filter_shows_with_a_link_that_removes_it( bool $hpos, bool $sync ): void { + $this->use_storage( $hpos, $sync ); + $order = $this->create_order(); + $this->open_list(); + $uri = $_SERVER['REQUEST_URI'] ?? null; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- Restored unchanged after the row. + $_SERVER['REQUEST_URI'] = '/wp-admin/admin.php?page=wc-orders&paged=2&_dws_lo_depth=1&_dws_linked_order_id=' . $order->get_id() . '&_guest_customer_user=guest%2Bbar%40example.com'; + $_GET['_dws_lo_depth'] = '1'; + + try { + self::assertNull( $this->parse_filters()->querySelector( '.dws-linked-orders__filter' ) ); + + $_GET['_dws_linked_order_id'] = (string) $order->get_id(); + $_GET['_guest_customer_user'] = 'guest+bar@example.com'; + $lines = \iterator_to_array( $this->parse_filters()->querySelectorAll( '.dws-linked-orders__filter' ) ); + } finally { + $_SERVER['REQUEST_URI'] = $uri; + } + + 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 ) ); + } + // WooCommerce counts the order tables' list from its cache only when the query holds no argument beyond its own. #[DataProvider( 'storages' )] public function test_an_unfiltered_list_keeps_its_query_as_it_came( bool $hpos, bool $sync ): void { @@ -245,13 +266,47 @@ 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 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( 'Create empty linked Order', $actions['create_empty_linked_child']['name'] ); + 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( 'Add new child order', $actions['create_empty_linked_child']['name'] ); 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 ); @@ -269,45 +324,78 @@ 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' ); - - self::assertTrue( wp_script_is( Plugin::SLUG . '-orders-list-table' ) ); - self::assertTrue( wp_style_is( Plugin::SLUG . '-orders-list-table-icons' ) ); + // 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( $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 ) ); + } - self::assertFalse( wp_script_is( Plugin::SLUG . '-orders-list-table' ) ); + // 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::assertSame( 'Depth', apply_filters( "manage_{$screen}_columns", self::COLUMNS )['dws_lo_depth'] ?? 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 { - $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 render_filters(): string { + 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 $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 e79f1b4..a13144a 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/MetaBoxTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/MetaBoxTest.php @@ -4,28 +4,26 @@ 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\RegistersOrderTypeTrait; +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 RegistersOrderTypeTrait; + 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 +35,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' )] @@ -51,20 +44,13 @@ 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' )] 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 +58,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 +68,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,23 +75,27 @@ 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' ) ); - 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. + 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 ); self::assertSame( 'dws_lowc_create_linked_child', $query['action'] ?? null ); self::assertSame( (string) $order->get_id(), $query['parent_id'] ?? null ); self::assertSame( 1, wp_verify_nonce( (string) ( $query['_wpnonce'] ?? '' ), 'dws_create_linked_child' ) ); + $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 (opens in a new tab)', \preg_replace( '/\s+/', ' ', \trim( (string) $tree?->textContent ) ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. } #[DataProvider( 'storages' )] @@ -118,8 +107,26 @@ public function test_a_childless_order_at_the_maximum_depth_says_no_child_can_be $box = $this->render( $child->get_id() ); - self::assertSame( 'There are no attached children. New children cannot be added to this Order.', \preg_replace( '/\s+/', ' ', \trim( (string) $box->querySelector( '.dws-linked-orders__no-children' )?->textContent ) ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. + self::assertSame( 'There are no attached children. Order #' . $child->get_order_number() . ' is at the maximum linked orders depth, set under WooCommerce > Settings > Advanced > Linked Orders.', \preg_replace( '/\s+/', ' ', \trim( (string) $box->querySelector( '.dws-linked-orders__no-children' )?->textContent ) ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. self::assertNull( $box->querySelector( 'a.button' ) ); + 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 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. @@ -134,10 +141,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/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/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, " 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(); @@ -58,8 +76,15 @@ 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 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(); + + self::assertSame( 0, $request['status'], $request['output'] ); self::assertSame( \count( dws_lowc_get_migration_steps() ), MigrationRunner::get_revision( Plugin::PREFIX . '_revision' ) ); } diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/SettingsApiTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/SettingsApiTest.php index 3663bf2..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 { @@ -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 91a39ef..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 ) { @@ -65,7 +66,7 @@ public function test_the_section_shows_v1s_two_groups_with_their_defaults(): voi $form = $this->render(); - self::assertSame( array( 'General Settings', 'Plugin Settings' ), \array_map( static fn ( \Dom\Element $heading ): string => \trim( $heading->textContent ), \iterator_to_array( $form->querySelectorAll( 'h2' ) ) ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. + self::assertSame( array( 'General settings', 'Plugin settings' ), \array_map( static fn ( \Dom\Element $heading ): string => \trim( $heading->textContent ), \iterator_to_array( $form->querySelectorAll( 'h2' ) ) ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. self::assertSame( '1', $form->getElementById( DWS_LOWC_OPTION_MAX_DEPTH )?->getAttribute( 'value' ) ); self::assertSame( '1', $form->getElementById( DWS_LOWC_OPTION_MAX_DEPTH )?->getAttribute( 'min' ) ); self::assertSame( '99', $form->getElementById( DWS_LOWC_OPTION_MAX_DEPTH )?->getAttribute( 'max' ) ); @@ -75,6 +76,24 @@ public function test_the_section_shows_v1s_two_groups_with_their_defaults(): voi self::assertFalse( $form->getElementById( DWS_LOWC_OPTION_DELETE_DATA )?->hasAttribute( 'checked' ) ); } + // The help says what each setting does where the merchant reads it, counting levels as the orders list does, and the consent names what deleting the plugin erases. + public function test_the_maximum_depth_and_the_consent_explain_themselves_beside_their_fields(): void { + $form = $this->render(); + + self::assertSame( 'How many levels of children an order can have below it, such as 2 for children and grandchildren.', \trim( (string) $form->getElementById( DWS_LOWC_OPTION_MAX_DEPTH )?->parentElement?->querySelector( '.description' )?->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. + 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( @@ -145,14 +164,40 @@ 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 { + // 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 ); $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' ) ); } diff --git a/plugins/linked-orders-for-woocommerce/tests/Integration/UninstallTest.php b/plugins/linked-orders-for-woocommerce/tests/Integration/UninstallTest.php index 7477760..277efba 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Integration/UninstallTest.php +++ b/plugins/linked-orders-for-woocommerce/tests/Integration/UninstallTest.php @@ -17,7 +17,7 @@ final class UninstallTest extends TestCase { #[AfterClass] public static function forget_the_rows_the_uninstall_deleted(): void { - wp_cache_flush(); // The uninstall ran in a child process, so this process still caches the rows it deleted, such as a setting the tab saved. + wp_cache_flush(); // The uninstall ran in a child process, so this process still caches the rows it deleted, such as a setting the section saved. } #[RunInSeparateProcess] @@ -25,17 +25,16 @@ public function test_an_uninstall_checks_the_floors_of_its_own_main_file(): void \define( 'WP_UNINSTALL_PLUGIN', 'another-plugin/another-plugin.php' ); // A bulk uninstall keeps the first plugin's value for every later one. $source = (string) \file_get_contents( DWS_LOWC_FILE ); \file_put_contents( DWS_LOWC_FILE, (string) \preg_replace( '/^(\s*\*\s*Requires PHP:\s*).*$/m', '${1}99.0', $source ) ); - $log = (string) \tempnam( \sys_get_temp_dir(), 'dws-tpl-uninstall-' ); + $log = (string) \tempnam( \sys_get_temp_dir(), Plugin::PREFIX . '-uninstall-' ); \ini_set( 'error_log', $log ); try { include \dirname( DWS_LOWC_FILE ) . '/uninstall.php'; + self::assertStringContainsString( 'below its PHP or WordPress floor', (string) \file_get_contents( $log ) ); } finally { \file_put_contents( DWS_LOWC_FILE, $source ); + \unlink( $log ); } - - self::assertStringContainsString( 'below its PHP or WordPress floor', (string) \file_get_contents( $log ) ); - \unlink( $log ); } // WordPress uninstalls an inactive plugin, so the request loads uninstall.php alone, never the entry file or functions.php. @@ -43,16 +42,15 @@ public function test_an_uninstall_in_a_request_that_skips_the_plugin_removes_its update_option( DWS_LOWC_OPTION_DELETE_DATA, 'yes' ); $php = 'uninstall_plugin( "linked-orders-for-woocommerce/linked-orders-for-woocommerce.php" ); echo wp_json_encode( array( defined( "DWS_LOWC_FILE" ), get_option( "' . DWS_LOWC_OPTION_DELETE_DATA . '" ) ) );'; - $request = self::request( $php, '--skip-plugins=' . Plugin::SLUG ); + try { + $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( '' ); - wp_cache_flush(); - wp_roles()->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,11 +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 stores again what a new site starts with, and the grants act again. - dws_lowc_plugin()->migrations?->run(); - do_action( 'wp_loaded' ); + self::restore_a_new_sites_data(); } } @@ -108,37 +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 stores 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(); - 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 ); } } @@ -154,29 +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 stores again what a new site starts with. - dws_lowc_plugin()->migrations?->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/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 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 @@ +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', + 'linked_orders_for_woocommerce_add_new_child' => 'Add new child 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 ] ); + } +} diff --git a/plugins/linked-orders-for-woocommerce/tests/Support/RestoresOptionsTrait.php b/plugins/linked-orders-for-woocommerce/tests/Support/RestoresOptionsTrait.php index 8d2d3a5..45c75a9 100644 --- a/plugins/linked-orders-for-woocommerce/tests/Support/RestoresOptionsTrait.php +++ b/plugins/linked-orders-for-woocommerce/tests/Support/RestoresOptionsTrait.php @@ -8,7 +8,8 @@ trait RestoresOptionsTrait { protected array $stored_options = array(); - #[Before] + // The snapshot runs before every other hook, so a class's own setup cannot taint it. + #[Before( priority: 1 )] protected function store_options(): void { foreach ( self::OPTIONS as $key ) { $this->stored_options[ $key ] = get_option( $key ); 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 ); + } } 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(); + } +} 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' ) ); 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 ==