Commit 0b142cfd672 for woocommerce
commit 0b142cfd672bc13e549072f5e4f109b3fd74bd4f
Author: Daniel Mallory <daniel.mallory@automattic.com>
Date: Fri Jul 3 22:35:08 2026 +0100
Add native Settings UI page provider for registered sections (#65975)
* Add native Settings UI page provider for registered sections
* Update Settings UI page provider since tags
* Guard Settings UI section page providers
* Guard settings section registry lookups in Settings UI resolution
During a WooCommerce update a stale 10.9 file copy can load against a
newer autoloader class map, so resolving the settings section registry
can fatal mid-request. Ports the guard from release-branch commit
146fb332c5 verbatim so the eventual forward-port merges cleanly.
Failures fall back to the parent page adapter path.
* Report native Settings UI provider failures via wc_doing_it_wrong
A registered section's native Settings UI page provider that throws was
only logged at debug level before falling back to the default adapter.
Because the fallback renders successfully, nothing loud ever fired —
unlike schema and script-handle failures, which surface through
wc_doing_it_wrong() at render time — so a broken third-party provider
could degrade silently indefinitely.
Report the failure through wc_doing_it_wrong() in the catch path,
matching how SettingsSectionRegistry reports registration failures.
The debug log entry stays for the settings-ui log channel. Tests now
cover both the Error and Exception branches, including the
wc_caught_exception() report for exceptions.
* Add regression test for cached native Settings UI page resolution
The request context cache is the only thing preventing a registered
section's native Settings UI page provider — third-party code — from
running multiple times per request: the admin body class filter,
output(), and the settings view template each resolve a context.
Nothing asserted that guarantee, so a refactor could silently drop it.
Count provider invocations across repeated context lookups and pin
the cached-context identity.
* Add changelog entry for settings registry resolution guard
* Inject default section navigation into native Settings UI schemas
The legacy settings adapter auto-populates shell.sectionNavigation from
the settings page sections, but native Settings UI page schemas were
used verbatim — and the settings UI suppresses the classic subsubsub
navigation whenever it renders. A native page that omitted the key left
merchants with no way to reach sibling sections short of editing the
URL.
Backfill the default sibling-section navigation into any resolved
schema that omits shell.sectionNavigation, applied uniformly to all
native Settings UI pages. Setting the key keeps the provided value:
a custom array gives the page full ownership of navigation, and an
explicit empty array opts out for pages with in-page navigation. The
navigation builder moves from the legacy adapter's private method to a
public static so both paths share it.
* Add changelog entry for default Settings UI section navigation
* Refactor Settings UI resolution around the request context
The settings template duplicated SettingsUIRequestContext::get_current()'s
page-matching loop and keyed off is_rendering_enabled(), whose condition had
silently broadened from "page provides a native UI page" to "any registered
section resolves". The default section navigation builder had also been
promoted to a public static on LegacySettingsPageAdapter — leaking onto the
public SDK adapter that extends it — and the three resolution guards reported
failures inconsistently (one silently swallowed, one triple-reported, with the
same debug-log block duplicated three times).
Resolve the template's Settings UI state through
SettingsUIRequestContext::get_current() via a new get_settings_page()
accessor, making the request context the single place that decides whether a
request renders through the Settings UI. Move the navigation builder to a
dedicated internal SettingsSectionNavigation class so it is off the public
surface and named for its page-agnostic role. Route all resolution failures
through a shared log_resolution_failure() helper so every guard — including
the previously silent registry lookup — logs the same way.
The template now inherits get_current()'s capability gating, so the template
rendering test sets a capable user, matching how wp-admin always runs it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Document the section navigation contract on SettingsUIPageInterface
The shell.sectionNavigation schema key supports three states — omit for
default injected navigation, a custom entry array, or an empty array to opt
out — but only the markdown docs described this. Extension developers reading
the interface itself had no way to discover the opt-out.
Spell out the three states in the get_schema() docblock so the contract is
visible at the point where native pages implement it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Align Settings UI section detection with the legacy settings globals
The legacy $current_section global is derived from $_REQUEST, but
SettingsUIRequestContext::get_current() read the section from $_GET only. On
a request carrying the section outside the query string, the request context
and the legacy rendering path could disagree about the active section,
letting the settings template and WC_Settings_Page::output() make different
Settings UI decisions in the same request.
Read the section from $_REQUEST so context resolution and legacy rendering
always agree. The template rendering test mirrors its runtime $_GET mutation
into $_REQUEST, since PHP builds $_REQUEST once at request start.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Guard the settings template context lookup during updates
Trunk hardened the Settings UI lookups in WC_Settings_Page against the
update-window autoloader race (#66214), but the template's new direct call to
SettingsUIRequestContext::get_current() was unguarded. Tolerate the class
being unavailable and fall back to legacy rendering, matching the guards in
WC_Settings_Page.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Reword the is_rendering_enabled docblock
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Add regression test for the registry lookup guard
Force get_registered() to throw through a scoped sanitize_title filter and
assert resolution falls back to the page provider without fataling.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Explain why only the provider guard raises a developer notice
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
diff --git a/docs/extensions/settings-and-config/settings-ui.md b/docs/extensions/settings-and-config/settings-ui.md
index 41bff40783b..e92e859b8e3 100644
--- a/docs/extensions/settings-and-config/settings-ui.md
+++ b/docs/extensions/settings-and-config/settings-ui.md
@@ -137,6 +137,46 @@ WooCommerce creates the settings UI adapter for registered sections internally.
Use a section id that does not conflict with an existing section on the same settings tab. For the `checkout` tab, ids that match existing payment gateway sections are reserved.
+### Provide a native Settings UI page for a registered section
+
+Sections with custom navigation, save handlers, or native Settings UI schemas can provide their own Settings UI page instead of using the legacy settings adapter.
+
+```php
+<?php
+use Automattic\WooCommerce\Admin\Settings\SettingsSection;
+use Automattic\WooCommerce\Admin\Settings\SettingsUIPageInterface;
+
+final class My_Plugin_Settings_Section extends SettingsSection {
+ // Other settings section methods omitted for brevity.
+
+ public function get_settings_ui_page( WC_Settings_Page $parent_page ): ?SettingsUIPageInterface {
+ return new My_Plugin_Settings_UI_Page( $parent_page );
+ }
+}
+```
+
+When `get_settings_ui_page()` returns a `SettingsUIPageInterface`, WooCommerce uses it directly for the registered section. Returning `null` keeps the default behavior: WooCommerce converts the section's legacy `get_settings()` array into a Settings UI schema.
+
+### Section navigation on native pages
+
+The Settings UI shell renders sibling-section navigation from the `shell.sectionNavigation` schema key. Native page schemas control it through three states:
+
+- **Omit the key** - WooCommerce injects the default navigation listing every section of the settings page, matching the legacy settings adapter. This is the right choice for most pages.
+- **Set a custom array** - the page owns navigation entirely. Each entry needs `id`, `label`, `href`, and `active` keys.
+- **Set an empty array** - no shell navigation renders, for pages that provide their own in-page navigation.
+
+```php
+// Custom navigation entry shape.
+$schema['shell']['sectionNavigation'] = array(
+ array(
+ 'id' => 'my_section',
+ 'label' => __( 'My section', 'my-plugin' ),
+ 'href' => admin_url( 'admin.php?page=wc-settings&tab=checkout§ion=my_section' ),
+ 'active' => true,
+ ),
+);
+```
+
## Native field migration
The legacy adapter converts the existing `get_settings()` array into a canonical schema for React. It supports common settings fields:
diff --git a/plugins/woocommerce/changelog/add-settings-section-ui-page-provider b/plugins/woocommerce/changelog/add-settings-section-ui-page-provider
new file mode 100644
index 00000000000..275d65e33d8
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-settings-section-ui-page-provider
@@ -0,0 +1,4 @@
+Significance: patch
+Type: add
+
+Add a native Settings UI page provider path for registered settings sections.
diff --git a/plugins/woocommerce/changelog/add-settings-ui-default-section-navigation b/plugins/woocommerce/changelog/add-settings-ui-default-section-navigation
new file mode 100644
index 00000000000..ada36235391
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-settings-ui-default-section-navigation
@@ -0,0 +1,4 @@
+Significance: patch
+Type: add
+
+Inject default sibling-section navigation into native Settings UI page schemas that omit shell.sectionNavigation.
diff --git a/plugins/woocommerce/changelog/fix-66031-settings-ui-registry-resolution-guard b/plugins/woocommerce/changelog/fix-66031-settings-ui-registry-resolution-guard
new file mode 100644
index 00000000000..cd83f2e3263
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-66031-settings-ui-registry-resolution-guard
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Tolerate settings section registry failures when resolving the Settings UI request context, preventing update-time fatals.
diff --git a/plugins/woocommerce/includes/admin/views/html-admin-settings.php b/plugins/woocommerce/includes/admin/views/html-admin-settings.php
index a59b0c74177..319a6d9b481 100644
--- a/plugins/woocommerce/includes/admin/views/html-admin-settings.php
+++ b/plugins/woocommerce/includes/admin/views/html-admin-settings.php
@@ -9,8 +9,7 @@
// phpcs:disable WooCommerce.Commenting.CommentHooks.MissingHookComment
-use Automattic\WooCommerce\Admin\Features\Features;
-use Automattic\WooCommerce\Admin\Settings\SettingsUIPageInterface;
+use Automattic\WooCommerce\Internal\Admin\Settings\SettingsUIRequestContext;
use Automattic\WooCommerce\Utilities\FeaturesUtil;
if ( ! defined( 'ABSPATH' ) ) {
@@ -37,22 +36,24 @@ if ( ! $tab_exists ) {
exit;
}
-$hide_nav = 'checkout' === $current_tab && in_array( $current_section, array( 'offline', 'bacs', 'cheque', 'cod' ), true );
-$is_settings_ui_page = false;
-$settings_ui_settings_page = null;
+$hide_nav = 'checkout' === $current_tab && in_array( $current_section, array( 'offline', 'bacs', 'cheque', 'cod' ), true );
-if ( Features::is_enabled( 'settings-ui' ) ) {
- foreach ( WC_Admin_Settings::get_settings_pages() as $settings_page ) {
- if ( ! $settings_page instanceof WC_Settings_Page || $settings_page->get_id() !== $current_tab ) {
- continue;
- }
-
- $is_settings_ui_page = $settings_page->get_settings_ui_page() instanceof SettingsUIPageInterface;
- $settings_ui_settings_page = $is_settings_ui_page ? $settings_page : null;
- break;
+// Resolve the Settings UI context for this request, falling back to legacy
+// rendering when the settings SDK classes are unavailable. The class can be
+// missing mid-update, when this file has been replaced on disk but the cached
+// autoloader has not refreshed yet.
+$settings_ui_context = null;
+try {
+ if ( class_exists( SettingsUIRequestContext::class ) ) {
+ $settings_ui_context = SettingsUIRequestContext::get_current();
}
+} catch ( \Throwable $e ) {
+ $settings_ui_context = null;
}
+$settings_ui_settings_page = $settings_ui_context ? $settings_ui_context->get_settings_page() : null;
+$is_settings_ui_page = null !== $settings_ui_settings_page;
+
if ( $settings_ui_settings_page instanceof WC_Settings_Page ) {
remove_action( 'woocommerce_sections_' . $current_tab, array( $settings_ui_settings_page, 'output_sections' ) );
}
diff --git a/plugins/woocommerce/src/Admin/Settings/SettingsSection.php b/plugins/woocommerce/src/Admin/Settings/SettingsSection.php
index 6e4dbbc1a0e..e1fa722c2a2 100644
--- a/plugins/woocommerce/src/Admin/Settings/SettingsSection.php
+++ b/plugins/woocommerce/src/Admin/Settings/SettingsSection.php
@@ -14,7 +14,19 @@ defined( 'ABSPATH' ) || exit;
*
* @since 10.9.0
*/
-abstract class SettingsSection implements SettingsSectionInterface {
+abstract class SettingsSection implements SettingsSectionInterface, SettingsSectionUIPageProviderInterface {
+
+ /**
+ * Get the native Settings UI page for this registered section.
+ *
+ * @since 11.0.0
+ *
+ * @param \WC_Settings_Page $parent_page Parent settings page.
+ * @return SettingsUIPageInterface|null
+ */
+ public function get_settings_ui_page( \WC_Settings_Page $parent_page ): ?SettingsUIPageInterface {
+ return null;
+ }
/**
* Get script handles that must be loaded before the settings UI app mounts.
diff --git a/plugins/woocommerce/src/Admin/Settings/SettingsSectionUIPageProviderInterface.php b/plugins/woocommerce/src/Admin/Settings/SettingsSectionUIPageProviderInterface.php
new file mode 100644
index 00000000000..33a23364d66
--- /dev/null
+++ b/plugins/woocommerce/src/Admin/Settings/SettingsSectionUIPageProviderInterface.php
@@ -0,0 +1,30 @@
+<?php
+/**
+ * Settings section UI page provider contract.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Admin\Settings;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Optional contract for registered sections that provide a native Settings UI page.
+ *
+ * @since 11.0.0
+ */
+interface SettingsSectionUIPageProviderInterface {
+
+ /**
+ * Get the native Settings UI page for this registered section.
+ *
+ * Return null to use the default registered section adapter.
+ *
+ * @since 11.0.0
+ *
+ * @param \WC_Settings_Page $parent_page Parent settings page.
+ * @return SettingsUIPageInterface|null
+ */
+ public function get_settings_ui_page( \WC_Settings_Page $parent_page ): ?SettingsUIPageInterface;
+}
diff --git a/plugins/woocommerce/src/Admin/Settings/SettingsUIPageInterface.php b/plugins/woocommerce/src/Admin/Settings/SettingsUIPageInterface.php
index cf79b4925b5..c9dbcf7686c 100644
--- a/plugins/woocommerce/src/Admin/Settings/SettingsUIPageInterface.php
+++ b/plugins/woocommerce/src/Admin/Settings/SettingsUIPageInterface.php
@@ -28,6 +28,12 @@ interface SettingsUIPageInterface {
/**
* Build the canonical settings schema for a section.
*
+ * The `shell.sectionNavigation` key controls the sibling-section navigation
+ * rendered by the Settings UI shell. Omit the key to let WooCommerce inject
+ * the default navigation listing every section of the settings page, set a
+ * custom array of `id`/`label`/`href`/`active` entries to own the navigation,
+ * or set an empty array to render no shell navigation at all.
+ *
* @since 10.9.0
*
* @param string $section Section id. Empty string means the default section.
diff --git a/plugins/woocommerce/src/Internal/Admin/Settings/LegacySettingsPageAdapter.php b/plugins/woocommerce/src/Internal/Admin/Settings/LegacySettingsPageAdapter.php
index 2a9ee9aa9c4..040b9fab52d 100644
--- a/plugins/woocommerce/src/Internal/Admin/Settings/LegacySettingsPageAdapter.php
+++ b/plugins/woocommerce/src/Internal/Admin/Settings/LegacySettingsPageAdapter.php
@@ -61,44 +61,11 @@ class LegacySettingsPageAdapter implements PublicSettingsUIPageInterface {
$this->get_save_adapter( $section )
);
- $schema['shell']['sectionNavigation'] = $this->get_section_navigation( $section );
+ $schema['shell']['sectionNavigation'] = SettingsSectionNavigation::build_default( $this->settings_page, $section );
return $schema;
}
- /**
- * Get secondary settings section navigation for the settings UI shell.
- *
- * @param string $current_section Current section id.
- * @return array<int, array{id: string, label: string, href: string, active: bool}>
- */
- private function get_section_navigation( string $current_section ): array {
- $sections = $this->settings_page->get_sections();
- if ( empty( $sections ) || 1 === count( $sections ) ) {
- return array();
- }
-
- $navigation = array();
- foreach ( $sections as $id => $label ) {
- $section_id = (string) $id;
- $navigation[] = array(
- 'id' => '' === $section_id ? 'default' : $section_id,
- 'label' => wp_strip_all_tags( html_entity_decode( (string) $label, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401 ) ),
- 'href' => add_query_arg(
- array(
- 'page' => 'wc-settings',
- 'tab' => sanitize_title( $this->settings_page->get_id() ),
- 'section' => sanitize_title( $section_id ),
- ),
- admin_url( 'admin.php' )
- ),
- 'active' => $current_section === $section_id,
- );
- }
-
- return $navigation;
- }
-
/**
* Get script handles that must be loaded before the settings UI app mounts.
*
diff --git a/plugins/woocommerce/src/Internal/Admin/Settings/SettingsSectionNavigation.php b/plugins/woocommerce/src/Internal/Admin/Settings/SettingsSectionNavigation.php
new file mode 100644
index 00000000000..ce09cae185e
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Admin/Settings/SettingsSectionNavigation.php
@@ -0,0 +1,57 @@
+<?php
+/**
+ * Settings UI section navigation builder.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Admin\Settings;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Builds section navigation entries for the Settings UI shell.
+ *
+ * @since 11.0.0
+ */
+final class SettingsSectionNavigation {
+
+ /**
+ * Build the default settings section navigation for the settings UI shell.
+ *
+ * Lists every section of the settings page, linking back through the classic
+ * settings URLs. Returns an empty array for pages with fewer than two sections.
+ *
+ * @since 11.0.0
+ *
+ * @param \WC_Settings_Page $settings_page Settings page to build the navigation for.
+ * @param string $current_section Current section id. Empty string means the default section.
+ * @return array<int, array{id: string, label: string, href: string, active: bool}>
+ */
+ public static function build_default( \WC_Settings_Page $settings_page, string $current_section ): array {
+ $sections = $settings_page->get_sections();
+ if ( empty( $sections ) || 1 === count( $sections ) ) {
+ return array();
+ }
+
+ $navigation = array();
+ foreach ( $sections as $id => $label ) {
+ $section_id = (string) $id;
+ $navigation[] = array(
+ 'id' => '' === $section_id ? 'default' : $section_id,
+ 'label' => wp_strip_all_tags( html_entity_decode( (string) $label, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401 ) ),
+ 'href' => add_query_arg(
+ array(
+ 'page' => 'wc-settings',
+ 'tab' => sanitize_title( $settings_page->get_id() ),
+ 'section' => sanitize_title( $section_id ),
+ ),
+ admin_url( 'admin.php' )
+ ),
+ 'active' => $current_section === $section_id,
+ );
+ }
+
+ return $navigation;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Admin/Settings/SettingsUIRequestContext.php b/plugins/woocommerce/src/Internal/Admin/Settings/SettingsUIRequestContext.php
index 093fd925d8c..d1f7b55eb11 100644
--- a/plugins/woocommerce/src/Internal/Admin/Settings/SettingsUIRequestContext.php
+++ b/plugins/woocommerce/src/Internal/Admin/Settings/SettingsUIRequestContext.php
@@ -10,6 +10,7 @@ namespace Automattic\WooCommerce\Internal\Admin\Settings;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\PageController;
use Automattic\WooCommerce\Admin\Settings\SettingsSectionRegistry;
+use Automattic\WooCommerce\Admin\Settings\SettingsSectionUIPageProviderInterface;
use Automattic\WooCommerce\Admin\Settings\SettingsUIPageInterface;
/**
@@ -191,15 +192,18 @@ class SettingsUIRequestContext {
/**
* Get the current WooCommerce settings section.
*
+ * Reads $_REQUEST to match how the legacy $current_section global is derived,
+ * so context resolution and legacy settings rendering agree on the section.
+ *
* @return string
*/
private static function get_current_settings_section(): string {
// phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
- if ( ! isset( $_GET['section'] ) ) {
+ if ( ! isset( $_REQUEST['section'] ) ) {
return '';
}
- $section = wp_unslash( $_GET['section'] );
+ $section = wp_unslash( $_REQUEST['section'] );
// phpcs:enable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
return is_string( $section ) ? sanitize_title( $section ) : '';
@@ -233,6 +237,17 @@ class SettingsUIRequestContext {
return $this->settings_ui_page;
}
+ /**
+ * Get the legacy settings page this context was resolved for.
+ *
+ * @since 11.0.0
+ *
+ * @return \WC_Settings_Page
+ */
+ public function get_settings_page(): \WC_Settings_Page {
+ return $this->settings_page;
+ }
+
/**
* Get the Settings UI page id.
*
@@ -245,6 +260,11 @@ class SettingsUIRequestContext {
/**
* Whether this context can render through the Settings UI.
*
+ * True when the settings UI feature is enabled and a Settings UI page resolved
+ * for the page and section. The page can come from a registered section (native
+ * or adapted from its legacy settings) or from the settings page itself, and
+ * callers replacing legacy rendering should treat all three the same.
+ *
* @return bool
*/
public function is_rendering_enabled(): bool {
@@ -347,10 +367,39 @@ class SettingsUIRequestContext {
try {
$registered_section = SettingsSectionRegistry::get_instance()->get_registered( $settings_page->get_id(), $section );
} catch ( \Throwable $e ) {
+ self::log_resolution_failure( 'Registered settings section', $settings_page->get_id(), $section, $e, __METHOD__ );
$registered_section = null;
}
if ( $registered_section ) {
+ if ( $registered_section instanceof SettingsSectionUIPageProviderInterface ) {
+ try {
+ $settings_ui_page = $registered_section->get_settings_ui_page( $settings_page );
+ if ( $settings_ui_page instanceof SettingsUIPageInterface ) {
+ return $settings_ui_page;
+ }
+ } catch ( \Throwable $e ) {
+ self::log_resolution_failure( 'Native Settings UI page', $settings_page->get_id(), $section, $e, __METHOD__ );
+
+ // Raise a developer notice here only: this failure still
+ // renders through the registered-section adapter, so
+ // nothing downstream reports it. Registry lookup failures
+ // are environmental, and schema/script-handle failures
+ // surface through log_settings_ui_fallback() at render time.
+ wc_doing_it_wrong(
+ __METHOD__,
+ sprintf(
+ /* translators: 1: settings page id, 2: settings section id, 3: failure reason. */
+ esc_html__( 'The native Settings UI page for page "%1$s" section "%2$s" could not be resolved. Falling back to the default settings adapter. Reason: %3$s', 'woocommerce' ),
+ esc_html( $settings_page->get_id() ),
+ esc_html( self::get_section_key( $section ) ),
+ esc_html( get_class( $e ) . ': ' . $e->getMessage() )
+ ),
+ '11.0.0'
+ );
+ }
+ }
+
return new RegisteredSettingsSectionAdapter( $settings_page, $registered_section );
}
@@ -374,16 +423,7 @@ class SettingsUIRequestContext {
} catch ( \Throwable $e ) {
$this->script_handles_failed = true;
- wc_get_logger()->debug(
- sprintf(
- 'Settings UI script handles could not be resolved for page "%1$s" section "%2$s": %3$s: %4$s',
- $this->get_page_id(),
- '' === $this->section ? self::DEFAULT_SECTION_KEY : $this->section,
- get_class( $e ),
- $e->getMessage()
- ),
- array( 'source' => 'settings-ui' )
- );
+ self::log_resolution_failure( 'Settings UI script handles', $this->get_page_id(), $this->section, $e, __METHOD__ );
if ( $e instanceof \Exception ) {
$this->script_handles_failure_reason = sprintf(
@@ -391,7 +431,6 @@ class SettingsUIRequestContext {
__( 'Settings UI script handles could not be resolved: %s', 'woocommerce' ),
$e->getMessage()
);
- wc_caught_exception( $e, __CLASS__ . '::' . __FUNCTION__ );
}
}
}
@@ -408,25 +447,62 @@ class SettingsUIRequestContext {
}
try {
- $this->schema = $this->settings_ui_page->get_schema( $this->section );
+ $this->schema = $this->ensure_section_navigation( $this->settings_ui_page->get_schema( $this->section ) );
} catch ( \Throwable $e ) {
$this->schema_failed = true;
- wc_get_logger()->debug(
- sprintf(
- 'Settings UI schema could not be resolved for page "%1$s" section "%2$s": %3$s: %4$s',
- $this->get_page_id(),
- '' === $this->section ? self::DEFAULT_SECTION_KEY : $this->section,
- get_class( $e ),
- $e->getMessage()
- ),
- array( 'source' => 'settings-ui' )
- );
+ self::log_resolution_failure( 'Settings UI schema', $this->get_page_id(), $this->section, $e, __METHOD__ );
+ }
+ }
- if ( $e instanceof \Exception ) {
- wc_caught_exception( $e, __CLASS__ . '::' . __FUNCTION__ );
- }
+ /**
+ * Log a Settings UI resolution failure for developers.
+ *
+ * @param string $subject What failed to resolve, e.g. 'Settings UI schema'.
+ * @param string $page_id Settings page id.
+ * @param string $section Section id. Empty string means the default section.
+ * @param \Throwable $e The resolution failure.
+ * @param string $caller Calling method, for exception tracking.
+ */
+ private static function log_resolution_failure( string $subject, string $page_id, string $section, \Throwable $e, string $caller ): void {
+ wc_get_logger()->debug(
+ sprintf(
+ '%1$s could not be resolved for page "%2$s" section "%3$s": %4$s: %5$s',
+ $subject,
+ $page_id,
+ self::get_section_key( $section ),
+ get_class( $e ),
+ $e->getMessage()
+ ),
+ array( 'source' => 'settings-ui' )
+ );
+
+ if ( $e instanceof \Exception ) {
+ wc_caught_exception( $e, $caller );
+ }
+ }
+
+ /**
+ * Ensure a resolved settings UI schema carries section navigation for the shell.
+ *
+ * Schemas that omit `shell.sectionNavigation` get the default sibling-section
+ * navigation for the settings page, matching the legacy settings adapter.
+ * Setting the key — including to an empty array — keeps the provided value,
+ * so pages with custom or no navigation stay untouched.
+ *
+ * @param array $schema Resolved settings UI schema.
+ * @return array
+ */
+ private function ensure_section_navigation( array $schema ): array {
+ if ( ! isset( $schema['shell'] ) ) {
+ $schema['shell'] = array();
+ }
+
+ if ( is_array( $schema['shell'] ) && ! isset( $schema['shell']['sectionNavigation'] ) ) {
+ $schema['shell']['sectionNavigation'] = SettingsSectionNavigation::build_default( $this->settings_page, $this->section );
}
+
+ return $schema;
}
/**
diff --git a/plugins/woocommerce/tests/php/src/Admin/Settings/SettingsSectionRegistryTest.php b/plugins/woocommerce/tests/php/src/Admin/Settings/SettingsSectionRegistryTest.php
index 487b4e7866b..0321d2df70b 100644
--- a/plugins/woocommerce/tests/php/src/Admin/Settings/SettingsSectionRegistryTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/Settings/SettingsSectionRegistryTest.php
@@ -21,6 +21,20 @@ use WC_Unit_Test_Case;
*/
class SettingsSectionRegistryTest extends WC_Unit_Test_Case {
+ /**
+ * Original request globals.
+ *
+ * @var array
+ */
+ private array $original_get = array();
+
+ /**
+ * Original combined request globals.
+ *
+ * @var array
+ */
+ private array $original_request = array();
+
/**
* Original current settings section.
*
@@ -28,16 +42,27 @@ class SettingsSectionRegistryTest extends WC_Unit_Test_Case {
*/
private $original_current_section = null;
+ /**
+ * Original current settings tab.
+ *
+ * @var mixed
+ */
+ private $original_current_tab = null;
+
/**
* Set up test environment.
*/
public function setUp(): void {
parent::setUp();
+ include_once WC_ABSPATH . 'includes/admin/class-wc-admin-settings.php';
include_once WC_ABSPATH . 'includes/admin/settings/class-wc-settings-page.php';
- global $current_section;
+ global $current_section, $current_tab;
+ $this->original_get = $_GET; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ $this->original_request = $_REQUEST; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$this->original_current_section = $current_section ?? null;
+ $this->original_current_tab = $current_tab ?? null;
SettingsSectionRegistry::get_instance()->unregister_all();
SettingsUIRequestContext::reset();
@@ -47,8 +72,11 @@ class SettingsSectionRegistryTest extends WC_Unit_Test_Case {
* Tear down test environment.
*/
public function tearDown(): void {
- global $current_section;
+ global $current_section, $current_tab;
+ $_GET = $this->original_get;
+ $_REQUEST = $this->original_request;
$current_section = $this->original_current_section;
+ $current_tab = $this->original_current_tab;
remove_filter( 'woocommerce_admin_features', array( $this, 'enable_settings_ui_feature' ) );
SettingsSectionRegistry::get_instance()->unregister_all();
@@ -106,6 +134,183 @@ class SettingsSectionRegistryTest extends WC_Unit_Test_Case {
$this->assertSame( 'form_post', $settings_ui_page->get_save_adapter( 'acme_payments' ) );
}
+ /**
+ * @testdox Should resolve a registered section native Settings UI page when provided.
+ */
+ public function test_resolves_registered_section_native_settings_ui_page(): void {
+ $page = $this->get_parent_page();
+ SettingsSectionRegistry::get_instance()->register( $this->get_registered_section_with_native_settings_ui_page() );
+
+ $settings_ui_page = SettingsUIRequestContext::for_settings_page( $page, 'acme_payments' )->get_settings_ui_page();
+
+ $this->assertInstanceOf( SettingsUIPageInterface::class, $settings_ui_page );
+ $this->assertSame( 'acme_native', $settings_ui_page->get_page_id() );
+ $this->assertSame( array( 'acme-native-settings-ui' ), $settings_ui_page->get_script_handles( 'acme_payments' ) );
+ $this->assertSame( 'custom', $settings_ui_page->get_save_adapter( 'acme_payments' ) );
+
+ $schema = $settings_ui_page->get_schema( 'acme_payments' );
+ $this->assertSame( 'acme_native', $schema['id'] );
+ $this->assertSame( 'native_tab', $schema['section'] );
+ $this->assertArrayHasKey( 'native_group', $schema['groups'] );
+ $this->assertArrayNotHasKey( 'registered_acme_payments_setting', $schema['groups'] );
+ }
+
+ /**
+ * @testdox Should invoke the native Settings UI page provider once per cached request context.
+ */
+ public function test_invokes_native_settings_ui_page_provider_once_per_request_context(): void {
+ $provider_calls = 0;
+ $page = $this->get_parent_page();
+ SettingsSectionRegistry::get_instance()->register(
+ $this->get_registered_section_with_native_settings_ui_page(
+ static function () use ( &$provider_calls ): void {
+ ++$provider_calls;
+ }
+ )
+ );
+
+ $context = SettingsUIRequestContext::for_settings_page( $page, 'acme_payments' );
+ $context->get_settings_ui_page();
+ $repeat_context = SettingsUIRequestContext::for_settings_page( $page, 'acme_payments' );
+ $repeat_context->get_settings_ui_page();
+
+ $this->assertSame( $context, $repeat_context, 'Request contexts should be cached per settings page and section.' );
+ $this->assertSame( 1, $provider_calls, 'The native Settings UI page provider should only be invoked once per request context.' );
+ }
+
+ /**
+ * @testdox Should inject default section navigation when a native Settings UI schema omits it.
+ */
+ public function test_injects_default_section_navigation_when_native_settings_ui_schema_omits_it(): void {
+ $page = $this->get_parent_page();
+ SettingsSectionRegistry::get_instance()->register(
+ $this->get_registered_section_with_native_settings_ui_page( null, array( 'title' => 'Acme native settings' ) )
+ );
+
+ $schema = SettingsUIRequestContext::for_settings_page( $page, 'acme_payments' )->get_schema();
+
+ $navigation = $schema['shell']['sectionNavigation'];
+ $this->assertSame( array( 'default', 'acme_payments' ), array_column( $navigation, 'id' ) );
+ $this->assertSame( array( false, true ), array_column( $navigation, 'active' ) );
+ $this->assertStringContainsString( 'tab=checkout', $navigation[1]['href'] );
+ $this->assertStringContainsString( 'section=acme_payments', $navigation[1]['href'] );
+ }
+
+ /**
+ * @testdox Should keep custom section navigation provided by a native Settings UI schema.
+ */
+ public function test_keeps_custom_section_navigation_from_native_settings_ui_schema(): void {
+ $page = $this->get_parent_page();
+ SettingsSectionRegistry::get_instance()->register( $this->get_registered_section_with_native_settings_ui_page() );
+
+ $schema = SettingsUIRequestContext::for_settings_page( $page, 'acme_payments' )->get_schema();
+
+ $navigation = $schema['shell']['sectionNavigation'];
+ $this->assertSame( array( 'native_tab' ), array_column( $navigation, 'id' ) );
+ }
+
+ /**
+ * @testdox Should keep an explicitly empty section navigation in a native Settings UI schema.
+ */
+ public function test_keeps_explicitly_empty_section_navigation_in_native_settings_ui_schema(): void {
+ $page = $this->get_parent_page();
+ SettingsSectionRegistry::get_instance()->register(
+ $this->get_registered_section_with_native_settings_ui_page( null, array( 'sectionNavigation' => array() ) )
+ );
+
+ $schema = SettingsUIRequestContext::for_settings_page( $page, 'acme_payments' )->get_schema();
+
+ $this->assertSame( array(), $schema['shell']['sectionNavigation'] );
+ }
+
+ /**
+ * @testdox Should fall back to the default adapter when a registered section native Settings UI page provider fails.
+ */
+ public function test_falls_back_to_default_adapter_when_registered_section_native_settings_ui_page_provider_fails(): void {
+ $this->setExpectedIncorrectUsage( SettingsUIRequestContext::class . '::resolve_settings_ui_page' );
+
+ $page = $this->get_parent_page();
+ SettingsSectionRegistry::get_instance()->register( $this->get_registered_section( 'acme_payments', new \Error( 'Unable to resolve native settings UI page.' ) ) );
+
+ $settings_ui_page = SettingsUIRequestContext::for_settings_page( $page, 'acme_payments' )->get_settings_ui_page();
+
+ $this->assertInstanceOf( SettingsUIPageInterface::class, $settings_ui_page );
+ $this->assertSame( 'checkout', $settings_ui_page->get_page_id() );
+
+ $schema = $settings_ui_page->get_schema( 'acme_payments' );
+ $this->assertSame( 'registered_acme_payments_setting', $schema['groups']['default']['fields'][0]['id'] );
+ }
+
+ /**
+ * @testdox Should report exceptions from a registered section native Settings UI page provider and fall back to the default adapter.
+ */
+ public function test_falls_back_to_default_adapter_when_registered_section_native_settings_ui_page_provider_throws_exception(): void {
+ $this->setExpectedIncorrectUsage( SettingsUIRequestContext::class . '::resolve_settings_ui_page' );
+
+ $caught = array();
+ $listener = static function ( $exception ) use ( &$caught ): void {
+ $caught[] = $exception;
+ };
+ add_action( 'woocommerce_caught_exception', $listener );
+
+ $page = $this->get_parent_page();
+ $exception = new \RuntimeException( 'Unable to resolve native settings UI page.' );
+ SettingsSectionRegistry::get_instance()->register( $this->get_registered_section( 'acme_payments', $exception ) );
+
+ try {
+ $settings_ui_page = SettingsUIRequestContext::for_settings_page( $page, 'acme_payments' )->get_settings_ui_page();
+ } finally {
+ remove_action( 'woocommerce_caught_exception', $listener );
+ }
+
+ $this->assertInstanceOf( SettingsUIPageInterface::class, $settings_ui_page );
+ $this->assertSame( 'checkout', $settings_ui_page->get_page_id() );
+ $this->assertSame( array( $exception ), $caught, 'Provider exceptions should be reported through wc_caught_exception().' );
+ }
+
+ /**
+ * @testdox Should fall back to the page provider when the registry lookup itself fails.
+ */
+ public function test_falls_back_to_page_provider_when_registry_lookup_fails(): void {
+ $page = $this->get_parent_page();
+ SettingsSectionRegistry::get_instance()->register( $this->get_registered_section() );
+
+ // Make the lookup itself throw: get_registered() normalises ids
+ // through sanitize_title(), which runs this filter. Scoped to the
+ // section id so logging inside the guard is unaffected.
+ $break_lookup = static function ( $title, $raw_title = '' ) {
+ if ( 'acme_payments' === $raw_title ) {
+ throw new \RuntimeException( 'Broken registry lookup.' );
+ }
+ return $title;
+ };
+ add_filter( 'sanitize_title', $break_lookup, 10, 2 );
+
+ try {
+ $settings_ui_page = SettingsUIRequestContext::for_settings_page( $page, 'acme_payments' )->get_settings_ui_page();
+ } finally {
+ remove_filter( 'sanitize_title', $break_lookup );
+ }
+
+ // The registered section is unreachable, so resolution falls through
+ // to the page's own provider (null on the base class) without fataling.
+ $this->assertNull( $settings_ui_page );
+ }
+
+ /**
+ * @testdox Should keep direct SettingsSectionInterface implementations on the default adapter path.
+ */
+ public function test_direct_settings_section_interface_implementation_uses_default_adapter(): void {
+ $page = $this->get_parent_page();
+ SettingsSectionRegistry::get_instance()->register( $this->get_direct_registered_section() );
+
+ $settings_ui_page = SettingsUIRequestContext::for_settings_page( $page, 'direct_payments' )->get_settings_ui_page();
+
+ $this->assertInstanceOf( SettingsUIPageInterface::class, $settings_ui_page );
+ $this->assertSame( 'checkout', $settings_ui_page->get_page_id() );
+ $this->assertSame( array( 'direct-payments-settings-ui' ), $settings_ui_page->get_script_handles( 'direct_payments' ) );
+ }
+
/**
* @testdox Should render a registered section through the settings UI when the feature is enabled.
*/
@@ -126,6 +331,67 @@ class SettingsSectionRegistryTest extends WC_Unit_Test_Case {
$this->assertStringNotContainsString( 'name="registered_acme_payments_setting"', $output );
}
+ /**
+ * @testdox Should render a registered section native Settings UI page when provided.
+ */
+ public function test_renders_registered_section_native_settings_ui_page(): void {
+ add_filter( 'woocommerce_admin_features', array( $this, 'enable_settings_ui_feature' ) );
+ SettingsSectionRegistry::get_instance()->register( $this->get_registered_section_with_native_settings_ui_page() );
+
+ global $current_section;
+ $current_section = 'acme_payments';
+ $page = $this->get_parent_page();
+
+ ob_start();
+ $page->output();
+ $output = ob_get_clean();
+
+ $this->assertStringContainsString( 'data-wc-settings-ui="1"', $output );
+ $this->assertStringContainsString( 'data-wc-settings-page="acme_native"', $output );
+ $this->assertStringNotContainsString( 'name="registered_acme_payments_setting"', $output );
+ }
+
+ /**
+ * @testdox Should suppress legacy section navigation for registered section native Settings UI pages.
+ */
+ public function test_suppresses_legacy_section_navigation_for_registered_native_settings_ui_page(): void {
+ add_filter( 'woocommerce_admin_features', array( $this, 'enable_settings_ui_feature' ) );
+ SettingsSectionRegistry::get_instance()->register( $this->get_registered_section_with_native_settings_ui_page() );
+
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+
+ global $current_section, $current_tab;
+ $current_tab = 'checkout';
+ $current_section = 'acme_payments';
+ $_GET['page'] = 'wc-settings';
+ $_GET['tab'] = 'checkout';
+ $_GET['section'] = 'acme_payments';
+ // PHP builds $_REQUEST once at request start, so runtime $_GET changes need mirroring.
+ $_REQUEST['section'] = 'acme_payments';
+
+ $page = $this->get_parent_page();
+ $original_settings = $this->replace_wc_admin_settings_pages( array( $page ) );
+ $tabs = array( 'checkout' => 'Payments' );
+ $original_sections_hook = $this->replace_hook_callbacks( 'woocommerce_sections_checkout' );
+ $original_settings_hook = $this->replace_hook_callbacks( 'woocommerce_settings_checkout' );
+
+ add_action( 'woocommerce_sections_checkout', array( $page, 'output_sections' ) );
+ add_action( 'woocommerce_settings_checkout', array( $page, 'output' ) );
+
+ try {
+ ob_start();
+ include WC_ABSPATH . 'includes/admin/views/html-admin-settings.php';
+ $output = ob_get_clean();
+ } finally {
+ $this->restore_hook_callbacks( 'woocommerce_sections_checkout', $original_sections_hook );
+ $this->restore_hook_callbacks( 'woocommerce_settings_checkout', $original_settings_hook );
+ $this->replace_wc_admin_settings_pages( $original_settings );
+ }
+
+ $this->assertStringContainsString( 'data-wc-settings-page="acme_native"', $output );
+ $this->assertStringNotContainsString( 'class="subsubsub"', $output );
+ }
+
/**
* @testdox Should contain registration action failures.
*/
@@ -192,11 +458,12 @@ class SettingsSectionRegistryTest extends WC_Unit_Test_Case {
/**
* Build a registered test section.
*
- * @param string $section_id Section id.
+ * @param string $section_id Section id.
+ * @param \Throwable|null $settings_ui_page_failure Throwable the Settings UI page provider should throw, if any.
* @return SettingsSectionInterface
*/
- private function get_registered_section( string $section_id = 'acme_payments' ): SettingsSectionInterface {
- return new class( $section_id ) extends SettingsSection {
+ private function get_registered_section( string $section_id = 'acme_payments', ?\Throwable $settings_ui_page_failure = null ): SettingsSectionInterface {
+ return new class( $section_id, $settings_ui_page_failure ) extends SettingsSection {
/**
* Section id.
*
@@ -204,13 +471,22 @@ class SettingsSectionRegistryTest extends WC_Unit_Test_Case {
*/
private string $section_id;
+ /**
+ * Throwable the Settings UI page provider should throw, if any.
+ *
+ * @var \Throwable|null
+ */
+ private ?\Throwable $settings_ui_page_failure;
+
/**
* Constructor.
*
- * @param string $section_id Section id.
+ * @param string $section_id Section id.
+ * @param \Throwable|null $settings_ui_page_failure Throwable the Settings UI page provider should throw, if any.
*/
- public function __construct( string $section_id ) {
- $this->section_id = $section_id;
+ public function __construct( string $section_id, ?\Throwable $settings_ui_page_failure ) {
+ $this->section_id = $section_id;
+ $this->settings_ui_page_failure = $settings_ui_page_failure;
}
/**
@@ -266,6 +542,317 @@ class SettingsSectionRegistryTest extends WC_Unit_Test_Case {
return array( 'acme-payments-settings-ui' );
}
+ /**
+ * Get the native Settings UI page.
+ *
+ * @param \WC_Settings_Page $parent_page Parent settings page.
+ * @return SettingsUIPageInterface|null
+ */
+ public function get_settings_ui_page( \WC_Settings_Page $parent_page ): ?SettingsUIPageInterface {
+ if ( $this->settings_ui_page_failure ) {
+ throw $this->settings_ui_page_failure;
+ }
+
+ return parent::get_settings_ui_page( $parent_page );
+ }
+
+ };
+ }
+
+ /**
+ * Build a registered section that provides a native Settings UI page.
+ *
+ * @param callable|null $on_settings_ui_page_call Callback invoked every time the Settings UI page provider runs.
+ * @param array|null $shell Schema shell for the native page. Null uses the fixture default with custom section navigation.
+ * @return SettingsSectionInterface
+ */
+ private function get_registered_section_with_native_settings_ui_page( ?callable $on_settings_ui_page_call = null, ?array $shell = null ): SettingsSectionInterface {
+ return new class( $on_settings_ui_page_call, $shell ) extends SettingsSection {
+ /**
+ * Callback invoked every time the Settings UI page provider runs.
+ *
+ * @var callable|null
+ */
+ private $on_settings_ui_page_call;
+
+ /**
+ * Schema shell for the native page, or null for the fixture default.
+ *
+ * @var array|null
+ */
+ private ?array $shell;
+
+ /**
+ * Constructor.
+ *
+ * @param callable|null $on_settings_ui_page_call Callback invoked every time the Settings UI page provider runs.
+ * @param array|null $shell Schema shell for the native page, or null for the fixture default.
+ */
+ public function __construct( ?callable $on_settings_ui_page_call, ?array $shell ) {
+ $this->on_settings_ui_page_call = $on_settings_ui_page_call;
+ $this->shell = $shell;
+ }
+
+ /**
+ * Get the parent page id.
+ *
+ * @return string
+ */
+ public function get_parent_page_id(): string {
+ return 'checkout';
+ }
+
+ /**
+ * Get the section id.
+ *
+ * @return string
+ */
+ public function get_id(): string {
+ return 'acme_payments';
+ }
+
+ /**
+ * Get the section label.
+ *
+ * @return string
+ */
+ public function get_label(): string {
+ return 'Acme Payments';
+ }
+
+ /**
+ * Get legacy settings.
+ *
+ * @param \WC_Settings_Page $parent_page Parent settings page.
+ * @return array
+ */
+ public function get_settings( \WC_Settings_Page $parent_page ): array {
+ return array(
+ array(
+ 'id' => 'registered_acme_payments_setting',
+ 'type' => 'text',
+ 'title' => 'Registered Acme Payments setting',
+ ),
+ );
+ }
+
+ /**
+ * Get the native Settings UI page.
+ *
+ * @param \WC_Settings_Page $parent_page Parent settings page.
+ * @return SettingsUIPageInterface|null
+ */
+ public function get_settings_ui_page( \WC_Settings_Page $parent_page ): ?SettingsUIPageInterface {
+ if ( $this->on_settings_ui_page_call ) {
+ ( $this->on_settings_ui_page_call )();
+ }
+
+ return new class( $this->shell ) implements SettingsUIPageInterface {
+ /**
+ * Schema shell, or null for the fixture default.
+ *
+ * @var array|null
+ */
+ private ?array $shell;
+
+ /**
+ * Constructor.
+ *
+ * @param array|null $shell Schema shell, or null for the fixture default.
+ */
+ public function __construct( ?array $shell ) {
+ $this->shell = $shell;
+ }
+
+ /**
+ * Get the page id.
+ *
+ * @return string
+ */
+ public function get_page_id(): string {
+ return 'acme_native';
+ }
+
+ /**
+ * Get the native schema.
+ *
+ * @param string $section Section id.
+ * @return array
+ */
+ public function get_schema( string $section ): array {
+ return array(
+ 'id' => 'acme_native',
+ 'title' => 'Acme native settings',
+ 'section' => 'native_tab',
+ 'shell' => $this->shell ?? array(
+ 'title' => 'Acme native settings',
+ 'sectionNavigation' => array(
+ array(
+ 'id' => 'native_tab',
+ 'label' => 'Native tab',
+ 'href' => 'https://example.com/native-tab',
+ 'active' => true,
+ ),
+ ),
+ ),
+ 'groups' => array(
+ 'native_group' => array(
+ 'id' => 'native_group',
+ 'title' => 'Native group',
+ 'fields' => array(),
+ ),
+ ),
+ 'save' => array(
+ 'adapter' => 'custom',
+ 'handler' => 'acme/save',
+ ),
+ );
+ }
+
+ /**
+ * Get script handles.
+ *
+ * @param string $section Section id.
+ * @return string[]
+ */
+ public function get_script_handles( string $section ): array {
+ return array( 'acme-native-settings-ui' );
+ }
+
+ /**
+ * Get the save adapter.
+ *
+ * @param string $section Section id.
+ * @return string
+ */
+ public function get_save_adapter( string $section ): string {
+ return 'custom';
+ }
+ };
+ }
+ };
+ }
+
+ /**
+ * Build a direct SettingsSectionInterface implementation.
+ *
+ * @return SettingsSectionInterface
+ */
+ private function get_direct_registered_section(): SettingsSectionInterface {
+ return new class() implements SettingsSectionInterface {
+ /**
+ * Get the parent page id.
+ *
+ * @return string
+ */
+ public function get_parent_page_id(): string {
+ return 'checkout';
+ }
+
+ /**
+ * Get the section id.
+ *
+ * @return string
+ */
+ public function get_id(): string {
+ return 'direct_payments';
+ }
+
+ /**
+ * Get the section label.
+ *
+ * @return string
+ */
+ public function get_label(): string {
+ return 'Direct Payments';
+ }
+
+ /**
+ * Get legacy settings.
+ *
+ * @param \WC_Settings_Page $parent_page Parent settings page.
+ * @return array
+ */
+ public function get_settings( \WC_Settings_Page $parent_page ): array {
+ return array(
+ array(
+ 'id' => 'direct_payments_setting',
+ 'type' => 'text',
+ 'title' => 'Direct payments setting',
+ ),
+ );
+ }
+
+ /**
+ * Get script handles.
+ *
+ * @param \WC_Settings_Page $parent_page Parent settings page.
+ * @return string[]
+ */
+ public function get_script_handles( \WC_Settings_Page $parent_page ): array {
+ return array( 'direct-payments-settings-ui' );
+ }
+
+ /**
+ * Get save adapter.
+ *
+ * @param \WC_Settings_Page $parent_page Parent settings page.
+ * @return string
+ */
+ public function get_save_adapter( \WC_Settings_Page $parent_page ): string {
+ return 'form_post';
+ }
};
}
+
+ /**
+ * Replace callbacks for a hook.
+ *
+ * @param string $hook_name Hook name.
+ * @return \WP_Hook|null Previous hook callbacks.
+ */
+ private function replace_hook_callbacks( string $hook_name ): ?\WP_Hook {
+ global $wp_filter;
+
+ $previous_hook = isset( $wp_filter[ $hook_name ] ) ? clone $wp_filter[ $hook_name ] : null;
+ remove_all_actions( $hook_name );
+
+ return $previous_hook;
+ }
+
+ /**
+ * Restore callbacks for a hook.
+ *
+ * @param string $hook_name Hook name.
+ * @param \WP_Hook|null $hook Previous hook callbacks.
+ */
+ private function restore_hook_callbacks( string $hook_name, ?\WP_Hook $hook ): void {
+ remove_all_actions( $hook_name );
+
+ if ( ! $hook ) {
+ return;
+ }
+
+ foreach ( $hook->callbacks as $priority => $callbacks ) {
+ foreach ( $callbacks as $callback ) {
+ add_action( $hook_name, $callback['function'], $priority, $callback['accepted_args'] );
+ }
+ }
+ }
+
+ /**
+ * Replace WC admin settings pages for a focused view test.
+ *
+ * @param array $settings_pages Settings page instances.
+ * @return array Previous settings page instances.
+ */
+ private function replace_wc_admin_settings_pages( array $settings_pages ): array {
+ $settings_property = new \ReflectionProperty( \WC_Admin_Settings::class, 'settings' );
+ $settings_property->setAccessible( true );
+
+ $previous_settings = (array) $settings_property->getValue();
+ $settings_property->setValue( null, $settings_pages );
+
+ return $previous_settings;
+ }
}