Commit f03df666c8d for woocommerce
commit f03df666c8dff4e0aada4ed6bb147a7762ee8494
Author: Daniel Mallory <daniel.mallory@automattic.com>
Date: Fri Jun 19 14:28:52 2026 +0100
Add Settings UI section registry (#65813)
* add(settings-ui): register settings sections
* refactor(settings-ui): centralize request context
* refactor(settings-ui): simplify registry internals
* fix(settings-ui): harden section registration
diff --git a/docs/extensions/settings-and-config/registering-settings-ui-components.md b/docs/extensions/settings-and-config/registering-settings-ui-components.md
index 08ca5af75db..09076d7d982 100644
--- a/docs/extensions/settings-and-config/registering-settings-ui-components.md
+++ b/docs/extensions/settings-and-config/registering-settings-ui-components.md
@@ -48,7 +48,7 @@ registerSettingsExtension( {
} );
```
-Registrations are scoped by settings page and, optionally, by section. This prevents one plugin from accidentally replacing another plugin's field behavior.
+Registrations are scoped by settings page and, optionally, by section. This prevents one plugin from accidentally replacing another plugin's field behavior. Omit `section` for a page-wide registration, use `section: ''` for the default section only, or pass a section id such as `section: 'payments'` for one named section.
## Component props
@@ -163,7 +163,24 @@ Resolution order is:
## Enqueue the component script
-Register your script in WordPress and return its handle from the settings UI adapter:
+Register your script in WordPress and return its handle from the settings integration that owns the fields.
+
+For a section registered under an existing tab, return the handle from the section object:
+
+```php
+<?php
+use Automattic\WooCommerce\Admin\Settings\SettingsSection;
+
+final class My_Plugin_Settings_Section extends SettingsSection {
+ // Other settings section methods omitted for brevity.
+
+ public function get_script_handles( WC_Settings_Page $parent_page ): array {
+ return array( 'my-plugin-settings-ui' );
+ }
+}
+```
+
+For a full settings tab that opts in through a `WC_Settings_Page` adapter, return the handle from the adapter:
```php
<?php
diff --git a/docs/extensions/settings-and-config/settings-ui.md b/docs/extensions/settings-and-config/settings-ui.md
index 56ee776eef0..41bff40783b 100644
--- a/docs/extensions/settings-and-config/settings-ui.md
+++ b/docs/extensions/settings-and-config/settings-ui.md
@@ -18,6 +18,16 @@ It is designed for extension authors who want to migrate incrementally. PHP stil
- Saves use the existing WooCommerce settings form POST flow by default.
- The public PHP API is available under `Automattic\WooCommerce\Admin\Settings`.
+## Build a settings UI integration
+
+A complete integration has the same pieces whether it is a full settings tab or a section inside an existing tab:
+
+1. Choose the location: a new `WC_Settings_Page` tab, or a registered section under an existing tab.
+2. Define fields in PHP using the WooCommerce settings array. This remains the source of truth for labels, descriptions, defaults, option ids, and fallback rendering.
+3. Add Settings UI metadata to fields that need custom React rendering, such as a stable `component` name.
+4. Return any script handles that register those custom components before the settings UI mounts.
+5. Use the default `form_post` save adapter unless the field is display-only or manages persistence separately.
+
## Enable the feature flag
For local testing, enable the feature with a small mu-plugin:
@@ -60,6 +70,73 @@ class My_Plugin_Settings_Page extends WC_Settings_Page {
WooCommerce only uses the adapter when the `settings-ui` feature flag is enabled. Returning an adapter does not change the page while the feature flag is disabled.
+## Register a section under an existing settings tab
+
+Extensions can register a complete settings section under an existing WooCommerce settings tab. The section object defines where the section lives, how it is labelled, which fields it renders, which scripts power custom React components, and how fields are saved.
+
+This is useful for payment providers or integrations that should live inside a Core-owned tab such as **WooCommerce > Settings > Payments**.
+
+```php
+<?php
+use Automattic\WooCommerce\Admin\Settings\SettingsSection;
+use Automattic\WooCommerce\Admin\Settings\SettingsSectionRegistry;
+
+final class My_Plugin_Settings_Section extends SettingsSection {
+ public function get_parent_page_id(): string {
+ return 'checkout';
+ }
+
+ public function get_id(): string {
+ return 'my_plugin';
+ }
+
+ public function get_label(): string {
+ return __( 'My plugin', 'my-plugin' );
+ }
+
+ public function get_settings( WC_Settings_Page $parent_page ): array {
+ return array(
+ array(
+ 'title' => __( 'My plugin', 'my-plugin' ),
+ 'type' => 'title',
+ 'id' => 'my_plugin_options',
+ ),
+ array(
+ 'title' => __( 'Payment methods', 'my-plugin' ),
+ 'id' => 'my_plugin_payment_methods',
+ 'type' => 'multiselect',
+ 'component' => 'my-plugin/payment-method-picker',
+ 'options' => array(
+ 'card' => __( 'Card', 'my-plugin' ),
+ 'bnpl' => __( 'Buy now, pay later', 'my-plugin' ),
+ ),
+ ),
+ array(
+ 'type' => 'sectionend',
+ 'id' => 'my_plugin_options',
+ ),
+ );
+ }
+
+ public function get_script_handles( WC_Settings_Page $parent_page ): array {
+ return array( 'my-plugin-settings-ui' );
+ }
+
+ // The inherited save adapter is `form_post` by default.
+}
+
+add_action(
+ 'woocommerce_settings_sections_registration',
+ function ( SettingsSectionRegistry $registry ): void {
+ $registry->register( new My_Plugin_Settings_Section() );
+ }
+);
+```
+
+WooCommerce creates the settings UI adapter for registered sections internally. When the settings UI feature flag is disabled, WooCommerce falls back to the legacy settings returned by `get_settings()`. Saves continue through the existing WooCommerce settings form flow and section-specific hooks such as `woocommerce_update_options_checkout_my_plugin`.
+
+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.
+
## Native field migration
The legacy adapter converts the existing `get_settings()` array into a canonical schema for React. It supports common settings fields:
@@ -117,6 +194,8 @@ registerSettingsExtension( {
} );
```
+Omit `scope.section` for a page-wide registration. Use `section: ''` for the default section only, or pass a section id such as `section: 'payments'` for one named section.
+
See [Registering settings UI components](./registering-settings-ui-components.md) for the full component contract.
## Load extension scripts before mount
diff --git a/packages/js/settings-ui/changelog/update-section-scope-semantics b/packages/js/settings-ui/changelog/update-section-scope-semantics
new file mode 100644
index 00000000000..6e50aa970ec
--- /dev/null
+++ b/packages/js/settings-ui/changelog/update-section-scope-semantics
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Clarify Settings UI extension section scoping so omitted sections are page-wide and empty sections target the default section.
diff --git a/packages/js/settings-ui/src/registry.ts b/packages/js/settings-ui/src/registry.ts
index f883b794401..b36c9627818 100644
--- a/packages/js/settings-ui/src/registry.ts
+++ b/packages/js/settings-ui/src/registry.ts
@@ -58,6 +58,9 @@ const isValidRegistration = (
} );
};
+const hasSectionScope = ( scope: SettingsExtensionRegistration[ 'scope' ] ) =>
+ typeof scope.section !== 'undefined';
+
const scopeMatches = (
registration: SettingsExtensionRegistration,
context: SettingsFieldContext
@@ -66,14 +69,36 @@ const scopeMatches = (
return false;
}
- return (
- ! registration.scope.section ||
- registration.scope.section === context.section
- );
+ if ( ! hasSectionScope( registration.scope ) ) {
+ return true;
+ }
+
+ return ( registration.scope.section ?? '' ) === ( context.section ?? '' );
+};
+
+const findInMatchingRegistrations = < T >(
+ context: SettingsFieldContext,
+ getValue: ( registration: SettingsExtensionRegistration ) => T | undefined
+): T | undefined => {
+ for ( let i = registrations.length - 1; i >= 0; i-- ) {
+ const registration = registrations[ i ];
+ if ( ! scopeMatches( registration, context ) ) {
+ continue;
+ }
+
+ const value = getValue( registration );
+ if ( typeof value !== 'undefined' ) {
+ return value;
+ }
+ }
+
+ return undefined;
};
const getScopeKey = ( scope: SettingsExtensionRegistration[ 'scope' ] ) =>
- `${ scope.page }::${ scope.section || 'default' }`;
+ `${ scope.page }::${
+ hasSectionScope( scope ) ? scope.section || 'default' : '*'
+ }`;
const hasDuplicateScopeAndKeys = (
registration: SettingsExtensionRegistration,
@@ -157,42 +182,27 @@ export const resolveFieldComponent = (
field: SettingsUIField,
context: SettingsFieldContext
): SettingsFieldComponent | undefined => {
- if ( field.component ) {
- for ( let i = registrations.length - 1; i >= 0; i-- ) {
- const registration = registrations[ i ];
- if ( ! scopeMatches( registration, context ) ) {
- continue;
- }
-
- const namedComponent = registration.components?.[ field.component ];
- if ( namedComponent ) {
- return namedComponent;
- }
- }
- }
-
- for ( let i = registrations.length - 1; i >= 0; i-- ) {
- const registration = registrations[ i ];
- if ( ! scopeMatches( registration, context ) ) {
- continue;
- }
-
- const fieldOverride = registration.fieldOverrides?.[ field.id ];
- if ( fieldOverride ) {
- return fieldOverride;
- }
- }
-
- for ( let i = registrations.length - 1; i >= 0; i-- ) {
- const registration = registrations[ i ];
- if ( ! scopeMatches( registration, context ) ) {
- continue;
- }
+ const componentName = field.component;
+ const component = componentName
+ ? findInMatchingRegistrations(
+ context,
+ ( registration ) => registration.components?.[ componentName ]
+ )
+ : undefined;
+
+ const resolvedComponent =
+ component ??
+ findInMatchingRegistrations(
+ context,
+ ( registration ) => registration.fieldOverrides?.[ field.id ]
+ ) ??
+ findInMatchingRegistrations(
+ context,
+ ( registration ) => registration.typeRenderers?.[ field.type ]
+ );
- const typeRenderer = registration.typeRenderers?.[ field.type ];
- if ( typeRenderer ) {
- return typeRenderer;
- }
+ if ( resolvedComponent ) {
+ return resolvedComponent;
}
if ( field.component ) {
@@ -208,55 +218,32 @@ export const resolveFieldComponent = (
export const resolveFieldVisibilityPredicate = (
fieldId: string,
context: SettingsFieldContext
-): SettingsVisibilityPredicate | undefined => {
- for ( let i = registrations.length - 1; i >= 0; i-- ) {
- const registration = registrations[ i ];
- if ( ! scopeMatches( registration, context ) ) {
- continue;
- }
-
- const predicate = registration.fieldVisibility?.[ fieldId ];
- if ( predicate ) {
- return predicate;
- }
- }
-
- return undefined;
-};
+): SettingsVisibilityPredicate | undefined =>
+ findInMatchingRegistrations(
+ context,
+ ( registration ) => registration.fieldVisibility?.[ fieldId ]
+ );
export const resolveGroupVisibilityPredicate = (
groupId: string,
context: SettingsFieldContext
-): SettingsVisibilityPredicate | undefined => {
- for ( let i = registrations.length - 1; i >= 0; i-- ) {
- const registration = registrations[ i ];
- if ( ! scopeMatches( registration, context ) ) {
- continue;
- }
-
- const predicate = registration.groupVisibility?.[ groupId ];
- if ( predicate ) {
- return predicate;
- }
- }
-
- return undefined;
-};
+): SettingsVisibilityPredicate | undefined =>
+ findInMatchingRegistrations(
+ context,
+ ( registration ) => registration.groupVisibility?.[ groupId ]
+ );
export const resolveSaveHandler = (
handler: string,
context: SettingsFieldContext
): SettingsSaveHandler | undefined => {
- for ( let i = registrations.length - 1; i >= 0; i-- ) {
- const registration = registrations[ i ];
- if ( ! scopeMatches( registration, context ) ) {
- continue;
- }
+ const saveHandler = findInMatchingRegistrations(
+ context,
+ ( registration ) => registration.saveHandlers?.[ handler ]
+ );
- const saveHandler = registration.saveHandlers?.[ handler ];
- if ( saveHandler ) {
- return saveHandler;
- }
+ if ( saveHandler ) {
+ return saveHandler;
}
warn( `Save handler "${ handler }" is not registered.`, { context } );
@@ -267,16 +254,13 @@ export const resolveRegionComponent = (
component: string,
context: SettingsFieldContext
): SettingsRegionComponent | undefined => {
- for ( let i = registrations.length - 1; i >= 0; i-- ) {
- const registration = registrations[ i ];
- if ( ! scopeMatches( registration, context ) ) {
- continue;
- }
+ const region = findInMatchingRegistrations(
+ context,
+ ( registration ) => registration.regions?.[ component ]
+ );
- const region = registration.regions?.[ component ];
- if ( region ) {
- return region;
- }
+ if ( region ) {
+ return region;
}
warn( `Region component "${ component }" is not registered.`, {
diff --git a/packages/js/settings-ui/src/settings-ui-page.tsx b/packages/js/settings-ui/src/settings-ui-page.tsx
index b0993ea5d67..d2c65fa2cfb 100644
--- a/packages/js/settings-ui/src/settings-ui-page.tsx
+++ b/packages/js/settings-ui/src/settings-ui-page.tsx
@@ -49,6 +49,9 @@ type PendingNavigation = {
href: string;
};
+const normalizeSection = ( section?: string ) =>
+ section === 'default' ? '' : section;
+
const getInitialValues = ( schema: SettingsUISchema ): SettingsValues => {
const values: SettingsValues = {};
@@ -521,7 +524,9 @@ export const SettingsUIPage = ( {
const context: SettingsFieldContext = useMemo(
() => ( {
page: page || schema.id,
- section: section || schema.section,
+ section: normalizeSection(
+ typeof section === 'undefined' ? schema.section : section
+ ),
} ),
[ page, schema.id, schema.section, section ]
);
diff --git a/packages/js/settings-ui/src/test/html-rendering.test.tsx b/packages/js/settings-ui/src/test/html-rendering.test.tsx
index fe514aec45b..12ae729731c 100644
--- a/packages/js/settings-ui/src/test/html-rendering.test.tsx
+++ b/packages/js/settings-ui/src/test/html-rendering.test.tsx
@@ -108,6 +108,49 @@ describe( 'settings HTML rendering', () => {
container.remove();
} );
+ it( 'normalizes the default schema section to default-section scope', () => {
+ const DefaultSectionField = jest.fn( () => (
+ <div>Default section field</div>
+ ) );
+ registerSettingsExtension( {
+ scope: { page: 'test-page', section: '' },
+ fieldOverrides: {
+ test_field: DefaultSectionField,
+ },
+ } );
+
+ const schema: SettingsUISchema = {
+ id: 'test-page',
+ title: 'Test page',
+ section: 'default',
+ save: { adapter: 'none' },
+ groups: {
+ general: {
+ id: 'general',
+ fields: [
+ {
+ id: 'test_field',
+ label: 'Test field',
+ type: 'text',
+ },
+ ],
+ },
+ },
+ };
+
+ const { container, root } = renderElement(
+ <SettingsUIPage schema={ schema } />
+ );
+
+ expect( container.textContent ).toContain( 'Default section field' );
+ expect( DefaultSectionField.mock.calls[ 0 ][ 0 ].context.section ).toBe(
+ ''
+ );
+
+ act( () => root.unmount() );
+ container.remove();
+ } );
+
it( 'sanitizes native field descriptions before rendering', () => {
const schema: SettingsUISchema = {
id: 'test-page',
@@ -265,7 +308,7 @@ describe( 'settings HTML rendering', () => {
.mockRejectedValue( new Error( 'Save failed.' ) );
registerSettingsExtension( {
- scope: { page: 'test-page', section: 'default' },
+ scope: { page: 'test-page', section: '' },
saveHandlers: {
fail: saveHandler,
},
diff --git a/packages/js/settings-ui/src/test/registry.test.ts b/packages/js/settings-ui/src/test/registry.test.ts
index b71d19b8c17..46a8d6a84a7 100644
--- a/packages/js/settings-ui/src/test/registry.test.ts
+++ b/packages/js/settings-ui/src/test/registry.test.ts
@@ -144,6 +144,100 @@ describe( 'settings extension registry', () => {
).toBeUndefined();
} );
+ it( 'distinguishes page-wide, default-section, and named-section scopes', () => {
+ const pageWideComponent: SettingsFieldComponent = () => null;
+ const defaultSectionComponent: SettingsFieldComponent = () => null;
+ const namedSectionComponent: SettingsFieldComponent = () => null;
+
+ registerSettingsExtension( {
+ scope: { page: 'registry-section-scope' },
+ components: {
+ 'page-wide': pageWideComponent,
+ },
+ } );
+ registerSettingsExtension( {
+ scope: { page: 'registry-section-scope', section: '' },
+ fieldOverrides: {
+ default_field: defaultSectionComponent,
+ },
+ } );
+ registerSettingsExtension( {
+ scope: { page: 'registry-section-scope', section: 'advanced' },
+ components: {
+ 'named-section': namedSectionComponent,
+ },
+ } );
+
+ expect(
+ resolveFieldComponent(
+ {
+ id: 'field',
+ label: 'Field',
+ type: 'text',
+ component: 'page-wide',
+ },
+ { page: 'registry-section-scope', section: 'advanced' }
+ )
+ ).toBe( pageWideComponent );
+ expect(
+ resolveFieldComponent(
+ {
+ id: 'field',
+ label: 'Field',
+ type: 'text',
+ component: 'page-wide',
+ },
+ { page: 'registry-section-scope', section: '' }
+ )
+ ).toBe( pageWideComponent );
+ expect(
+ resolveFieldComponent(
+ {
+ id: 'default_field',
+ label: 'Field',
+ type: 'text',
+ },
+ { page: 'registry-section-scope', section: '' }
+ )
+ ).toBe( defaultSectionComponent );
+ expect(
+ resolveFieldComponent(
+ {
+ id: 'default_field',
+ label: 'Field',
+ type: 'text',
+ },
+ { page: 'registry-section-scope', section: 'advanced' }
+ )
+ ).toBeUndefined();
+ const warnSpy = jest
+ .spyOn( console, 'warn' )
+ .mockImplementation( jest.fn() );
+ expect(
+ resolveFieldComponent(
+ {
+ id: 'field',
+ label: 'Field',
+ type: 'text',
+ component: 'named-section',
+ },
+ { page: 'registry-section-scope', section: '' }
+ )
+ ).toBeUndefined();
+ warnSpy.mockRestore();
+ expect(
+ resolveFieldComponent(
+ {
+ id: 'field',
+ label: 'Field',
+ type: 'text',
+ component: 'named-section',
+ },
+ { page: 'registry-section-scope', section: 'advanced' }
+ )
+ ).toBe( namedSectionComponent );
+ } );
+
it( 'resolves visibility predicates by field and group scope', () => {
const fieldPredicate: SettingsVisibilityPredicate = () => true;
const groupPredicate: SettingsVisibilityPredicate = () => false;
diff --git a/plugins/woocommerce/changelog/add-settings-ui-section-registry b/plugins/woocommerce/changelog/add-settings-ui-section-registry
new file mode 100644
index 00000000000..f61706399db
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-settings-ui-section-registry
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add a settings section registry so extensions can render sections in existing settings tabs with Settings UI.
diff --git a/plugins/woocommerce/client/admin/client/settings/settings-ui-registry.tsx b/plugins/woocommerce/client/admin/client/settings/settings-ui-registry.tsx
index 089725a7597..c00fd9c1dd3 100644
--- a/plugins/woocommerce/client/admin/client/settings/settings-ui-registry.tsx
+++ b/plugins/woocommerce/client/admin/client/settings/settings-ui-registry.tsx
@@ -76,7 +76,7 @@ export const registerSettingsUIScreens = () => {
createElement( SettingsUIPage, {
schema,
page,
- section: section || schema.section,
+ section,
} )
)
);
diff --git a/plugins/woocommerce/includes/admin/settings/class-wc-settings-page.php b/plugins/woocommerce/includes/admin/settings/class-wc-settings-page.php
index ec0aac1627c..aeb457063aa 100644
--- a/plugins/woocommerce/includes/admin/settings/class-wc-settings-page.php
+++ b/plugins/woocommerce/includes/admin/settings/class-wc-settings-page.php
@@ -8,8 +8,9 @@
declare( strict_types = 1);
-use Automattic\WooCommerce\Admin\Features\Features;
+use Automattic\WooCommerce\Admin\Settings\SettingsSectionRegistry;
use Automattic\WooCommerce\Admin\Settings\SettingsUIPageInterface;
+use Automattic\WooCommerce\Internal\Admin\Settings\SettingsUIRequestContext;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
@@ -131,13 +132,16 @@ if ( ! class_exists( 'WC_Settings_Page', false ) ) :
* @return string The modified body classes for the admin area.
*/
public function add_settings_ui_body_class( $classes ) {
- global $current_tab;
+ global $current_section, $current_tab;
if ( ! is_string( $classes ) || $this->id !== $current_tab ) {
return $classes;
}
- if ( ! Features::is_enabled( 'settings-ui' ) || ! $this->get_settings_ui_page() instanceof SettingsUIPageInterface ) {
+ $section = is_string( $current_section ) ? $current_section : '';
+ $context = SettingsUIRequestContext::for_settings_page( $this, $section );
+
+ if ( ! $context->is_rendering_enabled() ) {
return $classes;
}
@@ -262,7 +266,9 @@ if ( ! class_exists( 'WC_Settings_Page', false ) ) :
* @return array Settings array, each item being an associative array representing a setting.
*/
protected function get_settings_for_section_core( $section_id ) {
- return array();
+ $registered_section = SettingsSectionRegistry::get_instance()->get_registered( $this->id, (string) $section_id );
+
+ return $registered_section ? $registered_section->get_settings( $this ) : array();
}
/**
@@ -271,7 +277,18 @@ if ( ! class_exists( 'WC_Settings_Page', false ) ) :
* @return array
*/
public function get_sections() {
- $sections = $this->get_own_sections();
+ $sections = $this->get_own_sections();
+ $registered_sections = SettingsSectionRegistry::get_instance()->get_sections_for_page( $this->id );
+
+ foreach ( $registered_sections as $section_id => $section_label ) {
+ // Preserve sections declared by the settings page when a registered section uses the same id.
+ if ( array_key_exists( $section_id, $sections ) ) {
+ continue;
+ }
+
+ $sections[ $section_id ] = $section_label;
+ }
+
/**
* Filters the sections for this settings page.
*
@@ -331,70 +348,36 @@ if ( ! class_exists( 'WC_Settings_Page', false ) ) :
public function output() {
global $current_section;
- $settings_ui_page = $this->get_settings_ui_page();
- $section_key = '' === $current_section ? 'default' : $current_section;
- $page_id = $settings_ui_page instanceof SettingsUIPageInterface ? $settings_ui_page->get_page_id() : '';
- $schema_failed = ! empty( $GLOBALS['wc_settings_ui_schema_failed'][ $page_id ][ $section_key ] );
+ $section = is_string( $current_section ) ? $current_section : '';
+ $context = SettingsUIRequestContext::for_settings_page( $this, $section );
- if ( Features::is_enabled( 'settings-ui' ) && $settings_ui_page instanceof SettingsUIPageInterface ) {
- if ( $schema_failed ) {
+ if ( $context->is_rendering_enabled() ) {
+ $settings_ui_page = $context->get_settings_ui_page();
+ assert( $settings_ui_page instanceof SettingsUIPageInterface );
+
+ if ( $context->has_schema_failed() ) {
$this->log_settings_ui_fallback(
$settings_ui_page,
- $current_section,
+ $section,
__( 'Settings UI schema generation failed.', 'woocommerce' )
);
} else {
- $render_settings_ui = true;
-
- try {
- $script_handles = $settings_ui_page->get_script_handles( $current_section );
- } catch ( \Throwable $e ) {
- $script_handles = array();
- $render_settings_ui = false;
- $reason = __( 'Settings UI script handles could not be resolved.', 'woocommerce' );
-
- wc_get_logger()->debug(
- sprintf(
- 'Settings UI script handles could not be resolved for page "%1$s" section "%2$s": %3$s: %4$s',
- $settings_ui_page->get_page_id(),
- '' === $current_section ? 'default' : $current_section,
- get_class( $e ),
- $e->getMessage()
- ),
- array( 'source' => 'settings-ui' )
- );
-
- if ( $e instanceof \Exception ) {
- $reason = sprintf(
- /* translators: %s: exception message. */
- __( 'Settings UI script handles could not be resolved: %s', 'woocommerce' ),
- $e->getMessage()
- );
- wc_caught_exception( $e, __CLASS__ . '::' . __FUNCTION__ );
- }
+ $script_handles = $context->get_script_handles();
- $this->log_settings_ui_fallback( $settings_ui_page, $current_section, $reason );
- }
-
- if ( $render_settings_ui ) {
- /**
- * Extension-provided handles may violate the interface contract.
- *
- * @var mixed[] $script_handles
- */
+ if ( $context->has_script_handles_failed() ) {
+ $this->log_settings_ui_fallback( $settings_ui_page, $section, $context->get_script_handles_failure_reason() );
+ } else {
foreach ( $script_handles as $script_handle ) {
- if ( is_string( $script_handle ) && '' !== $script_handle ) {
- wp_enqueue_script( $script_handle );
- }
+ wp_enqueue_script( $script_handle );
}
$GLOBALS['hide_save_button'] = true;
printf(
'<div id="%1$s" data-wc-settings-ui="1" data-wc-settings-page="%2$s" data-wc-settings-section="%3$s"></div>',
- esc_attr( 'wc_settings_ui_' . sanitize_html_class( $this->id ) . '_' . sanitize_html_class( '' === $current_section ? 'default' : $current_section ) ),
- esc_attr( $settings_ui_page->get_page_id() ),
- esc_attr( $current_section )
+ esc_attr( 'wc_settings_ui_' . sanitize_html_class( $this->id ) . '_' . sanitize_html_class( '' === $section ? 'default' : $section ) ),
+ esc_attr( $context->get_page_id() ),
+ esc_attr( $section )
);
return;
}
@@ -403,7 +386,7 @@ if ( ! class_exists( 'WC_Settings_Page', false ) ) :
// We can't use "get_settings_for_section" here
// for compatibility with derived classes overriding "get_settings".
- $settings = $this->get_settings( $current_section );
+ $settings = $this->get_settings( $section );
WC_Admin_Settings::output_fields( $settings );
}
diff --git a/plugins/woocommerce/includes/admin/settings/class-wc-settings-payment-gateways.php b/plugins/woocommerce/includes/admin/settings/class-wc-settings-payment-gateways.php
index 5347d5b64a4..dc6df1ae1bf 100644
--- a/plugins/woocommerce/includes/admin/settings/class-wc-settings-payment-gateways.php
+++ b/plugins/woocommerce/includes/admin/settings/class-wc-settings-payment-gateways.php
@@ -7,6 +7,7 @@
declare( strict_types = 1 );
+use Automattic\WooCommerce\Admin\Settings\SettingsSectionRegistry;
use Automattic\WooCommerce\Internal\Admin\Loader;
defined( 'ABSPATH' ) || exit;
@@ -123,6 +124,11 @@ class WC_Settings_Payment_Gateways extends WC_Settings_Page {
do_action( 'woocommerce_admin_field_payment_gateways' );
ob_end_clean();
+ if ( is_string( $current_section ) && $this->is_registered_settings_section( $current_section ) ) {
+ parent::output();
+ return;
+ }
+
if ( is_string( $current_section ) && $this->should_render_react_section( $current_section ) ) {
$this->render_react_section( $this->standardize_section_name( $current_section ) );
} elseif ( is_string( $current_section ) && ! empty( $current_section ) ) {
@@ -223,6 +229,20 @@ class WC_Settings_Payment_Gateways extends WC_Settings_Page {
return $section;
}
+ /**
+ * Check if a section is registered through the settings section registry.
+ *
+ * @param mixed $section Section id.
+ * @return bool
+ */
+ private function is_registered_settings_section( $section ): bool {
+ if ( '' === (string) $section ) {
+ return false;
+ }
+
+ return null !== SettingsSectionRegistry::get_instance()->get_registered( self::TAB_NAME, (string) $section );
+ }
+
/**
* Render the React section.
*
diff --git a/plugins/woocommerce/src/Admin/Settings/SettingsSection.php b/plugins/woocommerce/src/Admin/Settings/SettingsSection.php
new file mode 100644
index 00000000000..6e4dbbc1a0e
--- /dev/null
+++ b/plugins/woocommerce/src/Admin/Settings/SettingsSection.php
@@ -0,0 +1,42 @@
+<?php
+/**
+ * Base settings section implementation.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Admin\Settings;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Base class for extensions that register a section under an existing WooCommerce settings page.
+ *
+ * @since 10.9.0
+ */
+abstract class SettingsSection implements SettingsSectionInterface {
+
+ /**
+ * Get script handles that must be loaded before the settings UI app mounts.
+ *
+ * @since 10.9.0
+ *
+ * @param \WC_Settings_Page $parent_page Parent settings page.
+ * @return string[]
+ */
+ public function get_script_handles( \WC_Settings_Page $parent_page ): array {
+ return array();
+ }
+
+ /**
+ * Get the default save adapter for fields in this section.
+ *
+ * @since 10.9.0
+ *
+ * @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';
+ }
+}
diff --git a/plugins/woocommerce/src/Admin/Settings/SettingsSectionInterface.php b/plugins/woocommerce/src/Admin/Settings/SettingsSectionInterface.php
new file mode 100644
index 00000000000..3f06ab24726
--- /dev/null
+++ b/plugins/woocommerce/src/Admin/Settings/SettingsSectionInterface.php
@@ -0,0 +1,77 @@
+<?php
+/**
+ * Settings section registration contract.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Admin\Settings;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Contract for extensions that register a section under an existing WooCommerce settings page.
+ *
+ * @since 10.9.0
+ */
+interface SettingsSectionInterface {
+
+ /**
+ * Get the parent WooCommerce settings page id.
+ *
+ * @return string
+ *
+ * @since 10.9.0
+ */
+ public function get_parent_page_id(): string;
+
+ /**
+ * Get the section id.
+ *
+ * @return string
+ *
+ * @since 10.9.0
+ */
+ public function get_id(): string;
+
+ /**
+ * Get the section label.
+ *
+ * @return string
+ *
+ * @since 10.9.0
+ */
+ public function get_label(): string;
+
+ /**
+ * Get legacy settings for this section.
+ *
+ * @param \WC_Settings_Page $parent_page Parent settings page.
+ * @return array
+ *
+ * @since 10.9.0
+ */
+ public function get_settings( \WC_Settings_Page $parent_page ): array;
+
+ /**
+ * Get script handles that must be loaded before the settings UI app mounts.
+ *
+ * @param \WC_Settings_Page $parent_page Parent settings page.
+ * @return string[]
+ *
+ * @since 10.9.0
+ */
+ public function get_script_handles( \WC_Settings_Page $parent_page ): array;
+
+ /**
+ * Get the default save adapter for fields in this section.
+ *
+ * Supported values are `form_post` and `none`.
+ *
+ * @param \WC_Settings_Page $parent_page Parent settings page.
+ * @return string
+ *
+ * @since 10.9.0
+ */
+ public function get_save_adapter( \WC_Settings_Page $parent_page ): string;
+}
diff --git a/plugins/woocommerce/src/Admin/Settings/SettingsSectionRegistry.php b/plugins/woocommerce/src/Admin/Settings/SettingsSectionRegistry.php
new file mode 100644
index 00000000000..7f4bbbfc7ba
--- /dev/null
+++ b/plugins/woocommerce/src/Admin/Settings/SettingsSectionRegistry.php
@@ -0,0 +1,261 @@
+<?php
+/**
+ * Settings section registry.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Admin\Settings;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Registry for sections that extensions add to existing WooCommerce settings pages.
+ *
+ * @since 10.9.0
+ */
+final class SettingsSectionRegistry {
+
+ /**
+ * Singleton instance.
+ *
+ * @var SettingsSectionRegistry|null
+ */
+ private static ?SettingsSectionRegistry $instance = null;
+
+ /**
+ * Registered sections keyed by parent page id and section id.
+ *
+ * @var array<string, array<string, SettingsSectionInterface>>
+ */
+ private array $sections = array();
+
+ /**
+ * Whether the registration action has fired.
+ *
+ * @var bool
+ */
+ private bool $initialized = false;
+
+ /**
+ * Get the registry instance.
+ *
+ * @return SettingsSectionRegistry
+ *
+ * @since 10.9.0
+ */
+ public static function get_instance(): SettingsSectionRegistry {
+ if ( null === self::$instance ) {
+ self::$instance = new self();
+ }
+
+ return self::$instance;
+ }
+
+ /**
+ * Register a settings section.
+ *
+ * @param SettingsSectionInterface $section Section instance.
+ * @return bool True when registered.
+ *
+ * @since 10.9.0
+ */
+ public function register( SettingsSectionInterface $section ): bool {
+ $parent_page_id = $this->normalize_id( $section->get_parent_page_id() );
+ $section_id = $this->normalize_id( $section->get_id() );
+
+ if ( '' === $parent_page_id || '' === $section_id ) {
+ wc_doing_it_wrong(
+ __METHOD__,
+ esc_html__( 'Settings sections must declare a non-empty parent page id and section id.', 'woocommerce' ),
+ '10.9.0'
+ );
+ return false;
+ }
+
+ if ( isset( $this->sections[ $parent_page_id ][ $section_id ] ) ) {
+ wc_doing_it_wrong(
+ __METHOD__,
+ sprintf(
+ /* translators: 1: parent settings page id, 2: settings section id. */
+ esc_html__( 'A settings section is already registered for "%1$s/%2$s".', 'woocommerce' ),
+ esc_html( $parent_page_id ),
+ esc_html( $section_id )
+ ),
+ '10.9.0'
+ );
+ return false;
+ }
+
+ if ( $this->is_reserved_checkout_section_id( $parent_page_id, $section_id ) ) {
+ wc_doing_it_wrong(
+ __METHOD__,
+ sprintf(
+ /* translators: 1: parent settings page id, 2: settings section id. */
+ esc_html__( 'The settings section "%1$s/%2$s" conflicts with an existing payment gateway section.', 'woocommerce' ),
+ esc_html( $parent_page_id ),
+ esc_html( $section_id )
+ ),
+ '10.9.0'
+ );
+ return false;
+ }
+
+ $this->sections[ $parent_page_id ][ $section_id ] = $section;
+ return true;
+ }
+
+ /**
+ * Get a registered section.
+ *
+ * @param string $parent_page_id Parent settings page id.
+ * @param string $section_id Section id.
+ * @return SettingsSectionInterface|null
+ *
+ * @since 10.9.0
+ */
+ public function get_registered( string $parent_page_id, string $section_id ): ?SettingsSectionInterface {
+ $this->initialize();
+
+ $parent_page_id = $this->normalize_id( $parent_page_id );
+ $section_id = $this->normalize_id( $section_id );
+
+ return $this->sections[ $parent_page_id ][ $section_id ] ?? null;
+ }
+
+ /**
+ * Get registered section labels for a settings page.
+ *
+ * @param string $parent_page_id Parent settings page id.
+ * @return array<string, string>
+ *
+ * @since 10.9.0
+ */
+ public function get_sections_for_page( string $parent_page_id ): array {
+ $this->initialize();
+
+ $parent_page_id = $this->normalize_id( $parent_page_id );
+ $registered = $this->sections[ $parent_page_id ] ?? array();
+
+ $sections = array();
+ foreach ( $registered as $section_id => $section ) {
+ $sections[ $section_id ] = $section->get_label();
+ }
+
+ return $sections;
+ }
+
+ /**
+ * Clear all registered sections.
+ *
+ * @since 10.9.0
+ */
+ public function unregister_all(): void {
+ $this->sections = array();
+ $this->initialized = false;
+ }
+
+ /**
+ * Fire the section registration action once.
+ */
+ private function initialize(): void {
+ if ( $this->initialized ) {
+ return;
+ }
+
+ // Mark initialized before firing the action so re-entrant registry lookups do not run it again.
+ $this->initialized = true;
+
+ try {
+ /**
+ * Fires when settings sections can be registered.
+ *
+ * @param SettingsSectionRegistry $registry Settings section registry.
+ *
+ * @since 10.9.0
+ */
+ do_action( 'woocommerce_settings_sections_registration', $this );
+ } catch ( \Throwable $e ) {
+ wc_get_logger()->error(
+ sprintf(
+ 'Settings section registration failed: %1$s: %2$s',
+ get_class( $e ),
+ $e->getMessage()
+ ),
+ array( 'source' => 'settings-ui' )
+ );
+
+ if ( $e instanceof \Exception ) {
+ wc_caught_exception( $e, __CLASS__ . '::' . __FUNCTION__ );
+ }
+ }
+ }
+
+ /**
+ * Check whether a checkout section id is reserved by an existing payment gateway.
+ *
+ * @param string $parent_page_id Parent settings page id.
+ * @param string $section_id Section id.
+ * @return bool
+ */
+ private function is_reserved_checkout_section_id( string $parent_page_id, string $section_id ): bool {
+ if ( 'checkout' !== $parent_page_id || ! function_exists( 'WC' ) ) {
+ return false;
+ }
+
+ try {
+ $wc = WC();
+ if ( ! $wc || ! is_callable( array( $wc, 'payment_gateways' ) ) ) {
+ return false;
+ }
+
+ $payment_gateways = $wc->payment_gateways();
+ if ( ! $payment_gateways || ! is_callable( array( $payment_gateways, 'payment_gateways' ) ) ) {
+ return false;
+ }
+
+ foreach ( $payment_gateways->payment_gateways() as $gateway ) {
+ if ( ! is_object( $gateway ) ) {
+ continue;
+ }
+
+ $gateway_id = '';
+ if ( isset( $gateway->id ) && is_scalar( $gateway->id ) ) {
+ $gateway_id = $this->normalize_id( (string) $gateway->id );
+ }
+
+ $gateway_class_id = $this->normalize_id( get_class( $gateway ) );
+ if ( in_array( $section_id, array( $gateway_id, $gateway_class_id ), true ) ) {
+ return true;
+ }
+ }
+ } catch ( \Throwable $e ) {
+ wc_get_logger()->debug(
+ sprintf(
+ 'Payment gateway section ids could not be checked while registering settings section "%1$s/%2$s": %3$s: %4$s',
+ $parent_page_id,
+ $section_id,
+ get_class( $e ),
+ $e->getMessage()
+ ),
+ array( 'source' => 'settings-ui' )
+ );
+
+ if ( $e instanceof \Exception ) {
+ wc_caught_exception( $e, __CLASS__ . '::' . __FUNCTION__ );
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Normalize page and section identifiers.
+ *
+ * @param string $id Identifier.
+ * @return string
+ */
+ private function normalize_id( string $id ): string {
+ return sanitize_title( $id );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Admin/Settings.php b/plugins/woocommerce/src/Internal/Admin/Settings.php
index 82f619997f3..0f19c7d1a59 100644
--- a/plugins/woocommerce/src/Internal/Admin/Settings.php
+++ b/plugins/woocommerce/src/Internal/Admin/Settings.php
@@ -10,7 +10,7 @@ use Automattic\WooCommerce\Admin\API\Reports\Orders\DataStore as OrdersDataStore
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\PageController;
use Automattic\WooCommerce\Admin\PluginsHelper;
-use Automattic\WooCommerce\Admin\Settings\SettingsUIPageInterface;
+use Automattic\WooCommerce\Internal\Admin\Settings\SettingsUIRequestContext;
use Automattic\WooCommerce\Utilities\FeaturesUtil;
use Automattic\WooCommerce\Utilities\OrderUtil;
use WC_Marketplace_Suggestions;
@@ -416,18 +416,18 @@ class Settings {
* @return array
*/
private function add_settings_ui_schema( array $settings ): array {
- if ( ! PageController::is_settings_page() || ! Features::is_enabled( 'settings-ui' ) || ! current_user_can( 'manage_woocommerce' ) ) {
+ $context = SettingsUIRequestContext::get_current();
+ if ( ! $context ) {
return $settings;
}
- $settings_ui_page = $this->get_current_settings_ui_page();
- if ( ! $settings_ui_page ) {
+ $schema = $context->get_schema();
+ if ( ! is_array( $schema ) ) {
return $settings;
}
- $section = $this->get_current_settings_section();
- $section_key = '' === $section ? 'default' : $section;
- $page_id = $settings_ui_page->get_page_id();
+ $page_id = $context->get_page_id();
+ $section_key = $context->get_current_section_key();
if ( ! isset( $settings['settingsUI'] ) || ! is_array( $settings['settingsUI'] ) ) {
$settings['settingsUI'] = array();
@@ -436,78 +436,8 @@ class Settings {
$settings['settingsUI'][ $page_id ] = array();
}
- try {
- $settings['settingsUI'][ $page_id ][ $section_key ] = $settings_ui_page->get_schema( $section );
- } catch ( \Throwable $e ) {
- $GLOBALS['wc_settings_ui_schema_failed'][ $page_id ][ $section_key ] = true;
-
- if ( $e instanceof \Exception ) {
- wc_caught_exception( $e, __CLASS__ . '::' . __FUNCTION__ );
- }
- }
+ $settings['settingsUI'][ $page_id ][ $section_key ] = $schema;
return $settings;
}
-
- /**
- * Get the settings UI adapter for the current settings tab.
- *
- * @return SettingsUIPageInterface|null
- */
- private function get_current_settings_ui_page(): ?SettingsUIPageInterface {
- if ( ! class_exists( '\WC_Admin_Settings' ) ) {
- return null;
- }
-
- $current_tab = $this->get_current_settings_tab();
- foreach ( \WC_Admin_Settings::get_settings_pages() as $settings_page ) {
- if ( ! $settings_page instanceof \WC_Settings_Page || $settings_page->get_id() !== $current_tab ) {
- continue;
- }
-
- $settings_ui_page = $settings_page->get_settings_ui_page();
- return $settings_ui_page instanceof SettingsUIPageInterface ? $settings_ui_page : null;
- }
-
- return null;
- }
-
- /**
- * Get the current WooCommerce settings tab.
- *
- * @return string
- */
- private function get_current_settings_tab(): string {
- // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
- if ( ! isset( $_GET['tab'] ) ) {
- return 'general';
- }
-
- $tab = wp_unslash( $_GET['tab'] );
- // phpcs:enable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
-
- if ( ! is_string( $tab ) ) {
- return 'general';
- }
-
- $tab = sanitize_title( $tab );
- return '' !== $tab ? $tab : 'general';
- }
-
- /**
- * Get the current WooCommerce settings section.
- *
- * @return string
- */
- private function get_current_settings_section(): string {
- // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
- if ( ! isset( $_GET['section'] ) ) {
- return '';
- }
-
- $section = wp_unslash( $_GET['section'] );
- // phpcs:enable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
-
- return is_string( $section ) ? sanitize_title( $section ) : '';
- }
}
diff --git a/plugins/woocommerce/src/Internal/Admin/Settings/RegisteredSettingsSectionAdapter.php b/plugins/woocommerce/src/Internal/Admin/Settings/RegisteredSettingsSectionAdapter.php
new file mode 100644
index 00000000000..b94d9b32e7a
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Admin/Settings/RegisteredSettingsSectionAdapter.php
@@ -0,0 +1,60 @@
+<?php
+/**
+ * Registered settings section adapter for settings UI.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Admin\Settings;
+
+use Automattic\WooCommerce\Admin\Settings\SettingsSectionInterface;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Adapts a registered settings section into the settings UI page contract.
+ *
+ * @since 10.9.0
+ */
+class RegisteredSettingsSectionAdapter extends LegacySettingsPageAdapter {
+
+ /**
+ * Registered settings section.
+ *
+ * @var SettingsSectionInterface
+ */
+ private SettingsSectionInterface $section;
+
+ /**
+ * Constructor.
+ *
+ * @since 10.9.0
+ *
+ * @param \WC_Settings_Page $settings_page Parent settings page.
+ * @param SettingsSectionInterface $section Registered settings section.
+ */
+ public function __construct( \WC_Settings_Page $settings_page, SettingsSectionInterface $section ) {
+ parent::__construct( $settings_page );
+ $this->section = $section;
+ }
+
+ /**
+ * Get script handles that must be loaded before the settings UI app mounts.
+ *
+ * @param string $section Unused. This adapter wraps a single registered section.
+ * @return string[]
+ */
+ public function get_script_handles( string $section ): array {
+ return $this->section->get_script_handles( $this->settings_page );
+ }
+
+ /**
+ * Get the default save adapter for fields in this section.
+ *
+ * @param string $section Unused. This adapter wraps a single registered section.
+ * @return string
+ */
+ public function get_save_adapter( string $section ): string {
+ return $this->section->get_save_adapter( $this->settings_page );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Admin/Settings/SettingsUIRequestContext.php b/plugins/woocommerce/src/Internal/Admin/Settings/SettingsUIRequestContext.php
new file mode 100644
index 00000000000..dfb7c24dd6d
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Admin/Settings/SettingsUIRequestContext.php
@@ -0,0 +1,444 @@
+<?php
+/**
+ * Settings UI request context.
+ */
+
+declare( strict_types=1 );
+
+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\SettingsUIPageInterface;
+
+/**
+ * Resolves and caches Settings UI state for the active settings request.
+ *
+ * @since 10.9.0
+ */
+class SettingsUIRequestContext {
+
+ /**
+ * Storage key for the default section in shared settings payloads.
+ *
+ * @var string
+ */
+ private const DEFAULT_SECTION_KEY = 'default';
+
+ /**
+ * Context instances keyed by settings page object and section.
+ *
+ * @var array<string, SettingsUIRequestContext>
+ */
+ private static array $contexts = array();
+
+ /**
+ * Settings page for this context.
+ *
+ * @var \WC_Settings_Page
+ */
+ private \WC_Settings_Page $settings_page;
+
+ /**
+ * Current settings section. Empty string means the default section.
+ *
+ * @var string
+ */
+ private string $section;
+
+ /**
+ * Resolved Settings UI page adapter.
+ *
+ * @var SettingsUIPageInterface|null
+ */
+ private ?SettingsUIPageInterface $settings_ui_page;
+
+ /**
+ * Whether script handles have been resolved.
+ *
+ * @var bool
+ */
+ private bool $script_handles_resolved = false;
+
+ /**
+ * Resolved script handles.
+ *
+ * @var string[]
+ */
+ private array $script_handles = array();
+
+ /**
+ * Whether script handle resolution failed.
+ *
+ * @var bool
+ */
+ private bool $script_handles_failed = false;
+
+ /**
+ * Developer-facing script handle failure reason.
+ *
+ * @var string
+ */
+ private string $script_handles_failure_reason = '';
+
+ /**
+ * Whether schema generation has been attempted.
+ *
+ * @var bool
+ */
+ private bool $schema_resolved = false;
+
+ /**
+ * Generated Settings UI schema.
+ *
+ * @var array|null
+ */
+ private ?array $schema = null;
+
+ /**
+ * Whether schema generation failed.
+ *
+ * @var bool
+ */
+ private bool $schema_failed = false;
+
+ /**
+ * Constructor.
+ *
+ * @param \WC_Settings_Page $settings_page Settings page.
+ * @param string $section Current settings section. Empty string means the default section.
+ */
+ private function __construct( \WC_Settings_Page $settings_page, string $section ) {
+ $this->settings_page = $settings_page;
+ $this->section = $section;
+ $this->settings_ui_page = self::resolve_settings_ui_page( $settings_page, $section );
+ }
+
+ /**
+ * Get the context for the active settings request.
+ *
+ * @return SettingsUIRequestContext|null
+ */
+ public static function get_current(): ?SettingsUIRequestContext {
+ if ( ! PageController::is_settings_page() || ! Features::is_enabled( 'settings-ui' ) || ! current_user_can( 'manage_woocommerce' ) ) {
+ return null;
+ }
+
+ if ( ! class_exists( '\WC_Admin_Settings' ) ) {
+ return null;
+ }
+
+ $current_tab = self::get_current_settings_tab();
+ foreach ( \WC_Admin_Settings::get_settings_pages() as $settings_page ) {
+ if ( ! $settings_page instanceof \WC_Settings_Page || $settings_page->get_id() !== $current_tab ) {
+ continue;
+ }
+
+ $context = self::for_settings_page( $settings_page, self::get_current_settings_section() );
+ return $context->get_settings_ui_page() ? $context : null;
+ }
+
+ return null;
+ }
+
+ /**
+ * Get a context for a known settings page and section.
+ *
+ * @param \WC_Settings_Page $settings_page Settings page.
+ * @param string $section Current settings section. Empty string means the default section.
+ * @return SettingsUIRequestContext
+ */
+ public static function for_settings_page( \WC_Settings_Page $settings_page, string $section ): SettingsUIRequestContext {
+ $key = self::get_context_key( $settings_page, $section );
+
+ if ( ! isset( self::$contexts[ $key ] ) ) {
+ self::$contexts[ $key ] = new self( $settings_page, $section );
+ }
+
+ return self::$contexts[ $key ];
+ }
+
+ /**
+ * Reset cached request contexts.
+ */
+ public static function reset(): void {
+ self::$contexts = array();
+ }
+
+ /**
+ * Get the current WooCommerce settings tab.
+ *
+ * @return string
+ */
+ private static function get_current_settings_tab(): string {
+ // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+ if ( ! isset( $_GET['tab'] ) ) {
+ return 'general';
+ }
+
+ $tab = wp_unslash( $_GET['tab'] );
+ // phpcs:enable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+
+ if ( ! is_string( $tab ) ) {
+ return 'general';
+ }
+
+ $tab = sanitize_title( $tab );
+ return '' !== $tab ? $tab : 'general';
+ }
+
+ /**
+ * Get the current WooCommerce settings 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'] ) ) {
+ return '';
+ }
+
+ $section = wp_unslash( $_GET['section'] );
+ // phpcs:enable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+
+ return is_string( $section ) ? sanitize_title( $section ) : '';
+ }
+
+ /**
+ * Get the shared settings payload key for a section.
+ *
+ * @param string $section Section id. Empty string means the default section.
+ * @return string
+ */
+ private static function get_section_key( string $section ): string {
+ return '' === $section ? self::DEFAULT_SECTION_KEY : $section;
+ }
+
+ /**
+ * Get the current section's shared settings payload key.
+ *
+ * @return string
+ */
+ public function get_current_section_key(): string {
+ return self::get_section_key( $this->section );
+ }
+
+ /**
+ * Get the Settings UI page adapter.
+ *
+ * @return SettingsUIPageInterface|null
+ */
+ public function get_settings_ui_page(): ?SettingsUIPageInterface {
+ return $this->settings_ui_page;
+ }
+
+ /**
+ * Get the Settings UI page id.
+ *
+ * @return string
+ */
+ public function get_page_id(): string {
+ return $this->settings_ui_page ? $this->settings_ui_page->get_page_id() : $this->settings_page->get_id();
+ }
+
+ /**
+ * Whether this context can render through the Settings UI.
+ *
+ * @return bool
+ */
+ public function is_rendering_enabled(): bool {
+ return Features::is_enabled( 'settings-ui' ) && $this->settings_ui_page instanceof SettingsUIPageInterface;
+ }
+
+ /**
+ * Get extension script handles for this context.
+ *
+ * @return string[]
+ */
+ public function get_script_handles(): array {
+ if ( ! $this->script_handles_resolved ) {
+ $this->resolve_script_handles();
+ }
+
+ return $this->script_handles;
+ }
+
+ /**
+ * Whether script handle resolution failed.
+ *
+ * @return bool
+ */
+ public function has_script_handles_failed(): bool {
+ if ( ! $this->script_handles_resolved ) {
+ $this->resolve_script_handles();
+ }
+
+ return $this->script_handles_failed;
+ }
+
+ /**
+ * Get the script handle failure reason.
+ *
+ * @return string
+ */
+ public function get_script_handles_failure_reason(): string {
+ if ( ! $this->script_handles_resolved ) {
+ $this->resolve_script_handles();
+ }
+
+ return '' !== $this->script_handles_failure_reason
+ ? $this->script_handles_failure_reason
+ : __( 'Settings UI script handles could not be resolved.', 'woocommerce' );
+ }
+
+ /**
+ * Get the Settings UI schema for this context.
+ *
+ * @return array|null
+ */
+ public function get_schema(): ?array {
+ if ( ! $this->schema_resolved ) {
+ $this->resolve_schema();
+ }
+
+ return $this->schema;
+ }
+
+ /**
+ * Whether schema generation failed.
+ *
+ * @return bool
+ */
+ public function has_schema_failed(): bool {
+ if ( ! $this->schema_resolved ) {
+ $this->resolve_schema();
+ }
+
+ return $this->schema_failed;
+ }
+
+ /**
+ * Get the context cache key.
+ *
+ * @param \WC_Settings_Page $settings_page Settings page.
+ * @param string $section Section id. Empty string means the default section.
+ * @return string
+ */
+ private static function get_context_key( \WC_Settings_Page $settings_page, string $section ): string {
+ return implode(
+ '::',
+ array(
+ (string) spl_object_id( $settings_page ),
+ $settings_page->get_id(),
+ self::get_section_key( $section ),
+ )
+ );
+ }
+
+ /**
+ * Resolve the Settings UI adapter for a settings page and section.
+ *
+ * @param \WC_Settings_Page $settings_page Settings page.
+ * @param string $section Section id. Empty string means the default section.
+ * @return SettingsUIPageInterface|null
+ */
+ private static function resolve_settings_ui_page( \WC_Settings_Page $settings_page, string $section ): ?SettingsUIPageInterface {
+ $registered_section = SettingsSectionRegistry::get_instance()->get_registered( $settings_page->get_id(), $section );
+
+ if ( $registered_section ) {
+ return new RegisteredSettingsSectionAdapter( $settings_page, $registered_section );
+ }
+
+ $settings_ui_page = $settings_page->get_settings_ui_page();
+ return $settings_ui_page instanceof SettingsUIPageInterface ? $settings_ui_page : null;
+ }
+
+ /**
+ * Resolve extension script handles.
+ */
+ private function resolve_script_handles(): void {
+ $this->script_handles_resolved = true;
+ $this->script_handles = array();
+
+ if ( ! $this->settings_ui_page ) {
+ return;
+ }
+
+ try {
+ $this->script_handles = self::filter_script_handles( $this->settings_ui_page->get_script_handles( $this->section ) );
+ } 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' )
+ );
+
+ if ( $e instanceof \Exception ) {
+ $this->script_handles_failure_reason = sprintf(
+ /* translators: %s: exception message. */
+ __( 'Settings UI script handles could not be resolved: %s', 'woocommerce' ),
+ $e->getMessage()
+ );
+ wc_caught_exception( $e, __CLASS__ . '::' . __FUNCTION__ );
+ }
+ }
+ }
+
+ /**
+ * Resolve the Settings UI schema.
+ */
+ private function resolve_schema(): void {
+ $this->schema_resolved = true;
+ $this->schema = null;
+
+ if ( ! $this->settings_ui_page ) {
+ return;
+ }
+
+ try {
+ $this->schema = $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' )
+ );
+
+ if ( $e instanceof \Exception ) {
+ wc_caught_exception( $e, __CLASS__ . '::' . __FUNCTION__ );
+ }
+ }
+ }
+
+ /**
+ * Filter extension-provided script handles to valid WordPress script handle strings.
+ *
+ * @param array $script_handles Raw script handles.
+ * @return string[]
+ */
+ private static function filter_script_handles( array $script_handles ): array {
+ return array_values(
+ array_filter(
+ $script_handles,
+ static function ( $script_handle ): bool {
+ return is_string( $script_handle ) && '' !== $script_handle;
+ }
+ )
+ );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Admin/Settings/SettingsUISchema.php b/plugins/woocommerce/src/Internal/Admin/Settings/SettingsUISchema.php
index 8e64164af5e..afbb6540aec 100644
--- a/plugins/woocommerce/src/Internal/Admin/Settings/SettingsUISchema.php
+++ b/plugins/woocommerce/src/Internal/Admin/Settings/SettingsUISchema.php
@@ -115,6 +115,11 @@ class SettingsUISchema {
}
);
+ foreach ( $groups as $group_id => $group ) {
+ unset( $group['order'] );
+ $groups[ $group_id ] = $group;
+ }
+
$decoded_title = html_entity_decode( $title, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401 );
return array(
diff --git a/plugins/woocommerce/src/Internal/Admin/WCAdminAssets.php b/plugins/woocommerce/src/Internal/Admin/WCAdminAssets.php
index 8b72ea70d92..65aa7ea2e31 100644
--- a/plugins/woocommerce/src/Internal/Admin/WCAdminAssets.php
+++ b/plugins/woocommerce/src/Internal/Admin/WCAdminAssets.php
@@ -8,8 +8,9 @@ namespace Automattic\WooCommerce\Internal\Admin;
use _WP_Dependency;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\PageController;
-use Automattic\WooCommerce\Admin\Settings\SettingsUIPageInterface;
use Automattic\WooCommerce\Internal\Admin\Loader;
+use Automattic\WooCommerce\Internal\Admin\Settings\SettingsUIRequestContext;
+
/**
* WCAdminAssets Class.
*/
@@ -432,104 +433,19 @@ class WCAdminAssets {
* @return array
*/
private function get_settings_ui_script_dependencies(): array {
- if ( ! PageController::is_settings_page() || ! Features::is_enabled( 'settings-ui' ) || ! current_user_can( 'manage_woocommerce' ) ) {
+ $context = SettingsUIRequestContext::get_current();
+ if ( ! $context ) {
return array();
}
- $settings_ui_page = $this->get_current_settings_ui_page();
- if ( ! $settings_ui_page ) {
- return array();
- }
-
- $extension_handles = array();
- try {
- $extension_handles = $settings_ui_page->get_script_handles( $this->get_current_settings_section() );
- } catch ( \Throwable $e ) {
- if ( $e instanceof \Exception ) {
- wc_caught_exception( $e, __CLASS__ . '::' . __FUNCTION__ );
- }
- }
-
- /**
- * Extension-provided handles may violate the interface contract.
- *
- * @var mixed[] $extension_handles
- */
$dependencies = array_merge(
array( 'wc-settings-ui' ),
- array_filter(
- $extension_handles,
- static function ( $script_handle ): bool {
- return is_string( $script_handle ) && '' !== $script_handle;
- }
- )
+ $context->get_script_handles()
);
return array_values( array_unique( $dependencies ) );
}
- /**
- * Get the settings UI adapter for the current settings tab.
- *
- * @return SettingsUIPageInterface|null
- */
- private function get_current_settings_ui_page(): ?SettingsUIPageInterface {
- if ( ! class_exists( '\WC_Admin_Settings' ) ) {
- return null;
- }
-
- $current_tab = $this->get_current_settings_tab();
- foreach ( \WC_Admin_Settings::get_settings_pages() as $settings_page ) {
- if ( ! $settings_page instanceof \WC_Settings_Page || $settings_page->get_id() !== $current_tab ) {
- continue;
- }
-
- $settings_ui_page = $settings_page->get_settings_ui_page();
- return $settings_ui_page instanceof SettingsUIPageInterface ? $settings_ui_page : null;
- }
-
- return null;
- }
-
- /**
- * Get the current WooCommerce settings tab.
- *
- * @return string
- */
- private function get_current_settings_tab(): string {
- // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
- if ( ! isset( $_GET['tab'] ) ) {
- return 'general';
- }
-
- $tab = wp_unslash( $_GET['tab'] );
- // phpcs:enable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
-
- if ( ! is_string( $tab ) ) {
- return 'general';
- }
-
- $tab = sanitize_title( $tab );
- return '' !== $tab ? $tab : 'general';
- }
-
- /**
- * Get the current WooCommerce settings section.
- *
- * @return string
- */
- private function get_current_settings_section(): string {
- // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
- if ( ! isset( $_GET['section'] ) ) {
- return '';
- }
-
- $section = wp_unslash( $_GET['section'] );
- // phpcs:enable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
-
- return is_string( $section ) ? sanitize_title( $section ) : '';
- }
-
/**
* Injects wp-shared-settings as a dependency if it's present.
*/
diff --git a/plugins/woocommerce/tests/php/includes/settings/class-wc-settings-payment-gateways-test.php b/plugins/woocommerce/tests/php/includes/settings/class-wc-settings-payment-gateways-test.php
index 77e3bc524f1..97c752bbbee 100644
--- a/plugins/woocommerce/tests/php/includes/settings/class-wc-settings-payment-gateways-test.php
+++ b/plugins/woocommerce/tests/php/includes/settings/class-wc-settings-payment-gateways-test.php
@@ -5,6 +5,9 @@
* @package WooCommerce\Tests\Settings
*/
+use Automattic\WooCommerce\Admin\Settings\SettingsSection;
+use Automattic\WooCommerce\Admin\Settings\SettingsSectionInterface;
+use Automattic\WooCommerce\Admin\Settings\SettingsSectionRegistry;
use Automattic\WooCommerce\Testing\Tools\CodeHacking\Hacks\FunctionsMockerHack;
use Automattic\WooCommerce\Testing\Tools\CodeHacking\Hacks\StaticMockerHack;
@@ -25,6 +28,16 @@ class WC_Settings_Payment_Gateways_Test extends WC_Settings_Unit_Test_Case {
// Make sure the class file is loaded.
require_once WC_ABSPATH . 'includes/admin/settings/class-wc-settings-payment-gateways.php';
+ SettingsSectionRegistry::get_instance()->unregister_all();
+ }
+
+ /**
+ * Tear down test case.
+ */
+ public function tearDown(): void {
+ SettingsSectionRegistry::get_instance()->unregister_all();
+
+ parent::tearDown();
}
/**
@@ -89,6 +102,35 @@ class WC_Settings_Payment_Gateways_Test extends WC_Settings_Unit_Test_Case {
$this->assertEquals( $expected, $setting_ids_and_types );
}
+ /**
+ * @testdox Output should render registered checkout settings sections.
+ */
+ public function test_output_renders_registered_checkout_settings_section(): void {
+ global $current_section;
+ $current_section = 'acme_payments';
+
+ SettingsSectionRegistry::get_instance()->register( $this->get_registered_payment_section( 'acme_payments' ) );
+ $disable_reactified_sections = static function () {
+ return array();
+ };
+ add_filter( 'experimental_woocommerce_admin_payment_reactify_render_sections', $disable_reactified_sections );
+
+ $sut = $this->getMockBuilder( WC_Settings_Payment_Gateways::class )
+ ->setMethods( array( 'run_gateway_admin_options' ) )
+ ->getMock();
+ $sut->expects( $this->never() )->method( 'run_gateway_admin_options' );
+
+ try {
+ ob_start();
+ $sut->output();
+ $output = ob_get_clean();
+ } finally {
+ remove_filter( 'experimental_woocommerce_admin_payment_reactify_render_sections', $disable_reactified_sections );
+ }
+
+ $this->assertStringContainsString( 'name="registered_acme_payments_setting"', $output );
+ }
+
/**
* @testDox 'save' will trigger 'init' (and 'process_admin_options' if current section is the name of an existing gateway), and the appropriate actions.
*
@@ -152,4 +194,74 @@ class WC_Settings_Payment_Gateways_Test extends WC_Settings_Unit_Test_Case {
$this->assertEquals( '' === $section_name ? 0 : 1, did_action( 'woocommerce_update_options_payment_gateways_bacs' ) );
$this->assertEquals( '' === $section_name ? 0 : 1, did_action( 'woocommerce_update_options_checkout_' . $section_name ) );
}
+
+ /**
+ * Build a registered payment settings section.
+ *
+ * @param string $section_id Section id.
+ * @return SettingsSectionInterface
+ */
+ private function get_registered_payment_section( string $section_id ): SettingsSectionInterface {
+ return new class( $section_id ) extends SettingsSection {
+ /**
+ * Section id.
+ *
+ * @var string
+ */
+ private string $section_id;
+
+ /**
+ * Constructor.
+ *
+ * @param string $section_id Section id.
+ */
+ public function __construct( string $section_id ) {
+ $this->section_id = $section_id;
+ }
+
+ /**
+ * Get the parent page id.
+ *
+ * @return string
+ */
+ public function get_parent_page_id(): string {
+ return WC_Settings_Payment_Gateways::TAB_NAME;
+ }
+
+ /**
+ * Get the section id.
+ *
+ * @return string
+ */
+ public function get_id(): string {
+ return $this->section_id;
+ }
+
+ /**
+ * Get the section label.
+ *
+ * @return string
+ */
+ public function get_label(): string {
+ return 'Registered payment section';
+ }
+
+ /**
+ * 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_' . $this->section_id . '_setting',
+ 'type' => 'text',
+ 'title' => 'Registered payment section setting',
+ ),
+ );
+ }
+
+ };
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Admin/Settings/SettingsSectionRegistryTest.php b/plugins/woocommerce/tests/php/src/Admin/Settings/SettingsSectionRegistryTest.php
new file mode 100644
index 00000000000..487b4e7866b
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Admin/Settings/SettingsSectionRegistryTest.php
@@ -0,0 +1,271 @@
+<?php
+/**
+ * Settings section registry tests.
+ *
+ * @package WooCommerce\Tests\Admin\Settings
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Admin\Settings;
+
+use Automattic\WooCommerce\Admin\Settings\SettingsSection;
+use Automattic\WooCommerce\Admin\Settings\SettingsSectionInterface;
+use Automattic\WooCommerce\Admin\Settings\SettingsSectionRegistry;
+use Automattic\WooCommerce\Admin\Settings\SettingsUIPageInterface;
+use Automattic\WooCommerce\Internal\Admin\Settings\SettingsUIRequestContext;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for settings section registration.
+ */
+class SettingsSectionRegistryTest extends WC_Unit_Test_Case {
+
+ /**
+ * Original current settings section.
+ *
+ * @var mixed
+ */
+ private $original_current_section = null;
+
+ /**
+ * Set up test environment.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ include_once WC_ABSPATH . 'includes/admin/settings/class-wc-settings-page.php';
+
+ global $current_section;
+ $this->original_current_section = $current_section ?? null;
+
+ SettingsSectionRegistry::get_instance()->unregister_all();
+ SettingsUIRequestContext::reset();
+ }
+
+ /**
+ * Tear down test environment.
+ */
+ public function tearDown(): void {
+ global $current_section;
+ $current_section = $this->original_current_section;
+
+ remove_filter( 'woocommerce_admin_features', array( $this, 'enable_settings_ui_feature' ) );
+ SettingsSectionRegistry::get_instance()->unregister_all();
+ SettingsUIRequestContext::reset();
+
+ parent::tearDown();
+ }
+
+ /**
+ * @testdox Should register sections through the registration action.
+ */
+ public function test_registers_sections_through_registration_action(): void {
+ $page = $this->get_parent_page();
+ $section = $this->get_registered_section();
+ $action = static function ( SettingsSectionRegistry $registry ) use ( $section ): void {
+ $registry->register( $section );
+ };
+
+ add_action( 'woocommerce_settings_sections_registration', $action );
+
+ try {
+ $sections = $page->get_sections();
+ } finally {
+ remove_action( 'woocommerce_settings_sections_registration', $action );
+ }
+
+ $this->assertArrayHasKey( 'acme_payments', $sections, 'Registered section should be exposed by its parent page.' );
+ $this->assertSame( 'Acme Payments', $sections['acme_payments'] );
+ }
+
+ /**
+ * @testdox Should provide registered section legacy settings to the parent page.
+ */
+ public function test_provides_registered_section_legacy_settings(): void {
+ $page = $this->get_parent_page();
+ SettingsSectionRegistry::get_instance()->register( $this->get_registered_section() );
+
+ $settings = $page->get_settings_for_section( 'acme_payments' );
+
+ $this->assertSame( 'registered_acme_payments_setting', $settings[0]['id'] );
+ }
+
+ /**
+ * @testdox Should resolve a registered section settings UI adapter before the parent page adapter.
+ */
+ public function test_resolves_registered_section_settings_ui_adapter(): void {
+ $page = $this->get_parent_page();
+ SettingsSectionRegistry::get_instance()->register( $this->get_registered_section() );
+
+ $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() );
+ $this->assertSame( array( 'acme-payments-settings-ui' ), $settings_ui_page->get_script_handles( 'acme_payments' ) );
+ $this->assertSame( 'form_post', $settings_ui_page->get_save_adapter( 'acme_payments' ) );
+ }
+
+ /**
+ * @testdox Should render a registered section through the settings UI when the feature is enabled.
+ */
+ public function test_renders_registered_section_with_settings_ui(): void {
+ add_filter( 'woocommerce_admin_features', array( $this, 'enable_settings_ui_feature' ) );
+ SettingsSectionRegistry::get_instance()->register( $this->get_registered_section() );
+
+ 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="checkout"', $output );
+ $this->assertStringNotContainsString( 'name="registered_acme_payments_setting"', $output );
+ }
+
+ /**
+ * @testdox Should contain registration action failures.
+ */
+ public function test_registration_action_failures_are_contained(): void {
+ $calls = 0;
+ $action = static function () use ( &$calls ): void {
+ ++$calls;
+ throw new \Error( 'Broken settings section registration.' );
+ };
+ add_action( 'woocommerce_settings_sections_registration', $action );
+
+ try {
+ $sections = SettingsSectionRegistry::get_instance()->get_sections_for_page( 'checkout' );
+ $second_lookup = SettingsSectionRegistry::get_instance()->get_sections_for_page( 'checkout' );
+ } finally {
+ remove_action( 'woocommerce_settings_sections_registration', $action );
+ }
+
+ $this->assertSame( array(), $sections );
+ $this->assertSame( array(), $second_lookup );
+ $this->assertSame( 1, $calls, 'The registration action should not be retried after a failure.' );
+ }
+
+ /**
+ * @testdox Should reject checkout sections that collide with payment gateway ids.
+ */
+ public function test_rejects_checkout_sections_that_collide_with_payment_gateway_ids(): void {
+ $this->setExpectedIncorrectUsage( SettingsSectionRegistry::class . '::register' );
+
+ $result = SettingsSectionRegistry::get_instance()->register( $this->get_registered_section( 'bacs' ) );
+
+ $this->assertFalse( $result );
+ $this->assertNull( SettingsSectionRegistry::get_instance()->get_registered( 'checkout', 'bacs' ) );
+ }
+
+ /**
+ * Enable the settings UI feature flag.
+ *
+ * @param array $features Feature flags.
+ * @return array
+ */
+ public function enable_settings_ui_feature( array $features ): array {
+ $features[] = 'settings-ui';
+ return array_values( array_unique( $features ) );
+ }
+
+ /**
+ * Build a parent settings page.
+ *
+ * @return \WC_Settings_Page
+ */
+ private function get_parent_page(): \WC_Settings_Page {
+ return new class() extends \WC_Settings_Page {
+ /**
+ * Constructor.
+ */
+ public function __construct() {
+ $this->id = 'checkout';
+ $this->label = 'Payments';
+ }
+ };
+ }
+
+ /**
+ * Build a registered test section.
+ *
+ * @param string $section_id Section id.
+ * @return SettingsSectionInterface
+ */
+ private function get_registered_section( string $section_id = 'acme_payments' ): SettingsSectionInterface {
+ return new class( $section_id ) extends SettingsSection {
+ /**
+ * Section id.
+ *
+ * @var string
+ */
+ private string $section_id;
+
+ /**
+ * Constructor.
+ *
+ * @param string $section_id Section id.
+ */
+ public function __construct( string $section_id ) {
+ $this->section_id = $section_id;
+ }
+
+ /**
+ * 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 $this->section_id;
+ }
+
+ /**
+ * 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_' . $this->section_id . '_setting',
+ 'type' => 'text',
+ 'title' => 'Registered Acme 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( 'acme-payments-settings-ui' );
+ }
+
+ };
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/SettingsUIFeatureFlagTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/SettingsUIFeatureFlagTest.php
index fef3cab7598..2c7e19a9184 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/SettingsUIFeatureFlagTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/SettingsUIFeatureFlagTest.php
@@ -10,6 +10,7 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\Tests\Internal\Admin\Settings;
use Automattic\WooCommerce\Internal\Admin\Settings;
+use Automattic\WooCommerce\Internal\Admin\Settings\SettingsUIRequestContext;
use Automattic\WooCommerce\Internal\Admin\WCAdminAssets;
use WC_Unit_Test_Case;
@@ -70,6 +71,7 @@ class SettingsUIFeatureFlagTest extends WC_Unit_Test_Case {
$this->original_hide_save_button_exists = array_key_exists( 'hide_save_button', $GLOBALS );
$this->original_hide_save_button = $this->original_hide_save_button_exists ? $GLOBALS['hide_save_button'] : null;
unset( $GLOBALS['hide_save_button'] );
+ SettingsUIRequestContext::reset();
}
/**
@@ -90,6 +92,7 @@ class SettingsUIFeatureFlagTest extends WC_Unit_Test_Case {
remove_filter( 'woocommerce_admin_features', array( $this, 'enable_settings_ui_feature' ) );
remove_filter( 'woocommerce_admin_features', array( $this, 'disable_settings_ui_feature' ) );
+ SettingsUIRequestContext::reset();
parent::tearDown();
}
@@ -195,16 +198,13 @@ class SettingsUIFeatureFlagTest extends WC_Unit_Test_Case {
global $current_section;
$current_section = 'advanced';
- $page = $this->get_settings_ui_test_page_with_failing_script_handles();
+ $page = $this->get_settings_ui_test_page_with_failing_schema();
try {
- $GLOBALS['wc_settings_ui_schema_failed']['settings_ui_flag_test']['advanced'] = true;
-
ob_start();
$page->output();
$output = ob_get_clean();
} finally {
- unset( $GLOBALS['wc_settings_ui_schema_failed']['settings_ui_flag_test']['advanced'] );
remove_action( 'doing_it_wrong_run', $action, 10 );
remove_filter( 'doing_it_wrong_trigger_error', '__return_false' );
}
@@ -220,6 +220,26 @@ class SettingsUIFeatureFlagTest extends WC_Unit_Test_Case {
$this->assertStringContainsString( 'Settings UI schema generation failed.', $settings_page_notices[0]['message'] );
}
+ /**
+ * @testdox Should resolve Settings UI script handles once per context.
+ */
+ public function test_settings_ui_script_handles_are_resolved_once_per_context(): void {
+ add_filter( 'woocommerce_admin_features', array( $this, 'enable_settings_ui_feature' ) );
+
+ global $current_section;
+ $current_section = '';
+ $page = $this->get_settings_ui_test_page_with_counting_script_handles();
+ $context = SettingsUIRequestContext::for_settings_page( $page, '' );
+
+ $this->assertSame( array( 'settings-ui-counting-handle' ), $context->get_script_handles() );
+
+ ob_start();
+ $page->output();
+ ob_get_clean();
+
+ $this->assertSame( 1, $this->get_script_handle_resolution_count( $page ), 'Script handles should be resolved once for a page and section context.' );
+ }
+
/**
* It exposes section navigation metadata from legacy settings pages.
*/
@@ -264,6 +284,37 @@ class SettingsUIFeatureFlagTest extends WC_Unit_Test_Case {
$this->assertSame( array(), $dependencies );
}
+ /**
+ * It does not resolve a current request context when the feature flag is disabled.
+ */
+ public function test_current_request_context_is_null_when_feature_flag_is_disabled(): void {
+ add_filter( 'woocommerce_admin_features', array( $this, 'disable_settings_ui_feature' ) );
+
+ $_GET['page'] = 'wc-settings';
+ $_GET['tab'] = 'products';
+
+ $this->assertNull( SettingsUIRequestContext::get_current() );
+ }
+
+ /**
+ * It does not resolve a current request context without the manage_woocommerce capability.
+ */
+ public function test_current_request_context_is_null_without_manage_woocommerce_capability(): void {
+ add_filter( 'woocommerce_admin_features', array( $this, 'enable_settings_ui_feature' ) );
+
+ $_GET['page'] = 'wc-settings';
+ $_GET['tab'] = 'products';
+
+ $original_user_id = get_current_user_id();
+ wp_set_current_user( 0 );
+
+ try {
+ $this->assertNull( SettingsUIRequestContext::get_current() );
+ } finally {
+ wp_set_current_user( $original_user_id );
+ }
+ }
+
/**
* It does not add the settings UI body class when the feature flag is disabled.
*/
@@ -430,6 +481,150 @@ class SettingsUIFeatureFlagTest extends WC_Unit_Test_Case {
};
}
+ /**
+ * Build a settings page whose settings UI adapter cannot provide a schema.
+ *
+ * @return \WC_Settings_Page
+ */
+ private function get_settings_ui_test_page_with_failing_schema(): \WC_Settings_Page {
+ return new class() extends \WC_Settings_Page {
+ /**
+ * Constructor.
+ */
+ public function __construct() {
+ $this->id = 'settings_ui_flag_test';
+ $this->label = 'Settings UI flag test';
+ }
+
+ /**
+ * Get the settings UI page adapter.
+ *
+ * @return \Automattic\WooCommerce\Admin\Settings\SettingsUIPageInterface|null
+ */
+ public function get_settings_ui_page(): ?\Automattic\WooCommerce\Admin\Settings\SettingsUIPageInterface {
+ return new class( $this ) extends \Automattic\WooCommerce\Admin\Settings\LegacySettingsPageAdapter {
+ /**
+ * Build the schema.
+ *
+ * @param string $section_id Section id.
+ * @return array
+ */
+ public function get_schema( string $section_id ): array {
+ if ( 'advanced' === $section_id ) {
+ throw new \RuntimeException( 'Unable to build settings UI schema.' );
+ }
+
+ return parent::get_schema( $section_id );
+ }
+ };
+ }
+
+ /**
+ * Get settings for a section.
+ *
+ * @param string $section_id Section id.
+ * @return array
+ */
+ protected function get_settings_for_section_core( $section_id ) {
+ return array(
+ array(
+ 'id' => 'woocommerce_settings_ui_flag_test',
+ 'type' => 'text',
+ 'title' => 'Settings UI flag test',
+ ),
+ );
+ }
+ };
+ }
+
+ /**
+ * Get the script handle resolution count for a counting test page.
+ *
+ * @param \WC_Settings_Page $page Settings page.
+ * @return int
+ */
+ private function get_script_handle_resolution_count( \WC_Settings_Page $page ): int {
+ $method = new \ReflectionMethod( $page, 'get_script_handle_resolution_count' );
+ $method->setAccessible( true );
+
+ return (int) $method->invoke( $page );
+ }
+
+ /**
+ * Build a settings page with counting script handles.
+ *
+ * @return \WC_Settings_Page
+ */
+ private function get_settings_ui_test_page_with_counting_script_handles(): \WC_Settings_Page {
+ return new class() extends \WC_Settings_Page {
+ /**
+ * Script handle resolution count.
+ *
+ * @var int
+ */
+ private int $script_handle_resolution_count = 0;
+
+ /**
+ * Constructor.
+ */
+ public function __construct() {
+ $this->id = 'settings_ui_flag_test';
+ $this->label = 'Settings UI flag test';
+ }
+
+ /**
+ * Get the settings UI page adapter.
+ *
+ * @return \Automattic\WooCommerce\Admin\Settings\SettingsUIPageInterface|null
+ */
+ public function get_settings_ui_page(): ?\Automattic\WooCommerce\Admin\Settings\SettingsUIPageInterface {
+ return new class( $this ) extends \Automattic\WooCommerce\Admin\Settings\LegacySettingsPageAdapter {
+ /**
+ * Get script handles.
+ *
+ * @param string $section_id Section id.
+ * @return array
+ */
+ public function get_script_handles( string $section_id ): array {
+ $this->settings_page->increment_script_handle_resolution_count();
+ return array( 'settings-ui-counting-handle' );
+ }
+ };
+ }
+
+ /**
+ * Increment the script handle resolution count.
+ */
+ public function increment_script_handle_resolution_count(): void {
+ ++$this->script_handle_resolution_count;
+ }
+
+ /**
+ * Get the script handle resolution count.
+ *
+ * @return int
+ */
+ public function get_script_handle_resolution_count(): int {
+ return $this->script_handle_resolution_count;
+ }
+
+ /**
+ * Get settings for the default section.
+ *
+ * @return array
+ */
+ protected function get_settings_for_default_section() {
+ return array(
+ array(
+ 'id' => 'woocommerce_settings_ui_flag_test',
+ 'type' => 'text',
+ 'title' => 'Settings UI flag test',
+ ),
+ );
+ }
+ };
+ }
+
/**
* Build a settings page with multiple sections.
*
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/SettingsUISchemaTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/SettingsUISchemaTest.php
index 1b18c436f35..0a61bd40551 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/SettingsUISchemaTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/SettingsUISchemaTest.php
@@ -39,6 +39,7 @@ class SettingsUISchemaTest extends WC_Unit_Test_Case {
$schema['shell'],
'The shell title should use the decoded page title.'
);
+ $this->assertSame( 'default', $schema['section'], 'The default section should remain the stable schema value.' );
}
/**
@@ -85,6 +86,7 @@ class SettingsUISchemaTest extends WC_Unit_Test_Case {
$this->assertArrayHasKey( 'default', $schema['groups'] );
$this->assertSame( 'default', array_key_first( $schema['groups'] ) );
+ $this->assertArrayNotHasKey( 'order', $schema['groups']['default'], 'Internal group ordering should not leak into the schema.' );
$this->assertSame( 'woocommerce_test_text', $schema['groups']['default']['fields'][0]['id'] );
$this->assertSame( 'saved value', $schema['groups']['default']['fields'][0]['value'] );
}