diff --git a/inc/admin-pages/class-shortcodes-admin-page.php b/inc/admin-pages/class-shortcodes-admin-page.php index e29466751..05a1bafe3 100644 --- a/inc/admin-pages/class-shortcodes-admin-page.php +++ b/inc/admin-pages/class-shortcodes-admin-page.php @@ -10,6 +10,7 @@ namespace WP_Ultimo\Admin_Pages; use WP_Ultimo\UI\Base_Element; +use WP_Ultimo\UI\Field; // Exit if accessed directly defined('ABSPATH') || exit; @@ -151,7 +152,13 @@ public function get_data() { $params[ $key ]['options'] = '0 | 1'; break; case 'select': - $params[ $key ]['options'] = implode(' | ', array_keys(wu_get_isset($value, 'options', []))); + $options = wu_get_isset($value, 'options', []); + + if (is_callable($options)) { + $options = call_user_func($options, new Field($key, $value)); + } + + $params[ $key ]['options'] = implode(' | ', array_keys($options)); break; case 'int': $params[ $key ]['options'] = __('integer', 'ultimate-multisite'); diff --git a/inc/builders/block-editor/class-block-editor-widget-manager.php b/inc/builders/block-editor/class-block-editor-widget-manager.php index f18ec29ee..0b1e19b74 100644 --- a/inc/builders/block-editor/class-block-editor-widget-manager.php +++ b/inc/builders/block-editor/class-block-editor-widget-manager.php @@ -198,39 +198,78 @@ public function load_block_settings($blocks, $element) { } /** - * Generates the list of attributes supported based on the fields. + * Generates the list of attributes supported based on element field types and defaults. * * @since 2.0.0 + * Field definitions are read as metadata only; option providers are not evaluated. + * * @param \WP_Ultimo\UI\Base_Element $element The element being registered. * @return array */ public function get_attributes_from_fields($element) { - $fields = $element->fields(); - $defaults = $element->defaults(); + $fields = $element->fields(); - $_fields = []; + $attribute_fields = []; foreach ($fields as $field_id => $field) { - $type = 'string'; + if ( ! is_array($field)) { + continue; + } + + $field_type = $field['type'] ?? 'text'; + + if ('group' === $field_type && ! empty($field['fields']) && is_array($field['fields'])) { + foreach ($field['fields'] as $sub_field_id => $sub_field) { + if (is_array($sub_field)) { + $attribute_fields[ $sub_field_id ] = $sub_field; + } + } - if ('toggle' === $field['type']) { - $type = 'boolean'; + continue; } - if ('number' === $field['type']) { - $type = 'integer'; + if (in_array($field_type, ['header', 'note'], true)) { + continue; } - $default_value = wu_get_isset($defaults, $field_id, ''); + $attribute_fields[ $field_id ] = $field; + } + + $attributes = []; + + foreach ($attribute_fields as $field_id => $field) { + $has_default = array_key_exists($field_id, $defaults); + $default_value = $has_default ? $defaults[ $field_id ] : ($field['value'] ?? ''); + + if ( ! $has_default && ! is_string($default_value) && is_callable($default_value)) { + $default_value = ''; + } + + $field_type = $field['type'] ?? 'text'; + $type = 'string'; + + if ('toggle' === $field_type) { + $type = 'boolean'; + $default_value = wu_string_to_bool($default_value); + } + + if (in_array($field_type, ['int', 'number'], true)) { + $type = is_float($default_value) ? 'number' : 'integer'; + $default_value = 'number' === $type ? (float) $default_value : (int) $default_value; + } + + if ('string' === $type && is_scalar($default_value)) { + $default_value = (string) $default_value; + } - $_fields[ $field_id ] = [ - 'default' => wu_get_isset($field, 'value', $default_value), + $attributes[ $field_id ] = [ + 'default' => $default_value, 'type' => $type, ]; } - return $_fields; + return $attributes; } } diff --git a/inc/compat/class-gutenberg-support.php b/inc/compat/class-gutenberg-support.php index f9a2e5c73..df8c4b6ff 100644 --- a/inc/compat/class-gutenberg-support.php +++ b/inc/compat/class-gutenberg-support.php @@ -62,6 +62,12 @@ public function init(): void { */ public function add_scripts(): void { + $screen = get_current_screen(); + + if ( ! $screen || ! $screen->is_block_editor()) { + return; + } + wp_register_script('wu-gutenberg-support', wu_get_asset('gutenberg-support.js', 'js'), ['jquery'], wu_get_version(), true); // translators: the placeholder is replaced with the network name. diff --git a/inc/functions/pages.php b/inc/functions/pages.php index 85478fa2b..d7193b204 100644 --- a/inc/functions/pages.php +++ b/inc/functions/pages.php @@ -107,6 +107,41 @@ function wu_is_new_site_page() { return absint(wu_get_setting('default_new_site_page', 0)) === $post->ID; } +/** + * Returns a request-memoized list of pages for select field options. + * + * @since 2.0.0 + * @param string $default_label The label for the current page option. + * @return array + */ +function wu_get_pages_as_options($default_label) { + + static $pages_by_context = []; + + $current_page_id = get_the_ID(); + + if ( ! $current_page_id && is_admin()) { + $requested_page_id = wu_request('post', 0); + $current_page_id = is_scalar($requested_page_id) ? absint($requested_page_id) : 0; + } + + $context_key = implode(':', [get_current_blog_id(), $current_page_id, get_current_user_id(), determine_locale()]); + + if ( ! array_key_exists($context_key, $pages_by_context)) { + $page_query_args = $current_page_id ? ['exclude' => [$current_page_id]] : []; // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude + + $pages_by_context[ $context_key ] = get_pages($page_query_args) ?: []; + } + + $pages_list = [0 => $default_label]; + + foreach ($pages_by_context[ $context_key ] as $page) { + $pages_list[ $page->ID ] = $page->post_title; + } + + return $pages_list; +} + /** * Checks if the current page is a login page. * diff --git a/inc/ui/class-current-site-element.php b/inc/ui/class-current-site-element.php index 197ed7c77..73603b5a8 100644 --- a/inc/ui/class-current-site-element.php +++ b/inc/ui/class-current-site-element.php @@ -162,26 +162,12 @@ public function fields() { 'value' => 1, ]; - $pages = get_pages( - [ - 'exclude' => [get_the_ID()], // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude - ] - ); - - $pages = $pages ?: []; - - $pages_list = [0 => __('Current Page', 'ultimate-multisite')]; - - foreach ($pages as $page) { - $pages_list[ $page->ID ] = $page->post_title; - } - $fields['breadcrumbs_my_sites_page'] = [ 'type' => 'select', 'title' => __('My Sites Page', 'ultimate-multisite'), 'value' => 0, 'desc' => __('The page with the customer sites list.', 'ultimate-multisite'), - 'options' => $pages_list, + 'options' => fn() => wu_get_pages_as_options(__('Current Page', 'ultimate-multisite')), ]; $fields['display_description'] = [ diff --git a/inc/ui/class-my-sites-element.php b/inc/ui/class-my-sites-element.php index b42f125b4..22de4d43f 100644 --- a/inc/ui/class-my-sites-element.php +++ b/inc/ui/class-my-sites-element.php @@ -162,20 +162,6 @@ public function fields() { ], ]; - $pages = get_pages( - [ - 'exclude' => [get_the_ID()], // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude - ] - ); - - $pages = $pages ?: []; - - $pages_list = [0 => __('Current Page', 'ultimate-multisite')]; - - foreach ($pages as $page) { - $pages_list[ $page->ID ] = $page->post_title; - } - $fields['custom_manage_page'] = [ 'type' => 'select', 'title' => __('Manage Redirect Page', 'ultimate-multisite'), @@ -185,7 +171,7 @@ public function fields() { 'required' => [ 'site_manage_type' => 'custom_page', ], - 'options' => $pages_list, + 'options' => fn() => wu_get_pages_as_options(__('Current Page', 'ultimate-multisite')), ]; $fields['columns'] = [ diff --git a/inc/ui/class-site-actions-element.php b/inc/ui/class-site-actions-element.php index 62d361f44..9d0c9dcc4 100644 --- a/inc/ui/class-site-actions-element.php +++ b/inc/ui/class-site-actions-element.php @@ -182,27 +182,13 @@ public function fields() { 'value' => 1, ]; - $pages = get_pages( - [ - 'exclude' => [get_the_ID()], // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude - ] - ); - - $pages = $pages ?: []; - - $pages_list = [0 => __('Default', 'ultimate-multisite')]; - - foreach ($pages as $page) { - $pages_list[ $page->ID ] = $page->post_title; - } - $fields['redirect_after_delete'] = [ 'type' => 'select', 'title' => __('Redirect After Delete', 'ultimate-multisite'), 'value' => 0, 'desc' => __('The page to redirect user after delete current site.', 'ultimate-multisite'), 'tooltip' => '', - 'options' => $pages_list, + 'options' => fn() => wu_get_pages_as_options(__('Default', 'ultimate-multisite')), ]; return $fields; diff --git a/tests/WP_Ultimo/Admin_Pages/Shortcodes_Admin_Page_Test.php b/tests/WP_Ultimo/Admin_Pages/Shortcodes_Admin_Page_Test.php index a2c4b6e89..b59eeec6e 100644 --- a/tests/WP_Ultimo/Admin_Pages/Shortcodes_Admin_Page_Test.php +++ b/tests/WP_Ultimo/Admin_Pages/Shortcodes_Admin_Page_Test.php @@ -8,6 +8,8 @@ namespace WP_Ultimo\Admin_Pages; use WP_UnitTestCase; +use WP_Ultimo\UI\Base_Element; +use WP_Ultimo\UI\Field; /** * Test class for Shortcodes_Admin_Page. @@ -114,7 +116,7 @@ public function test_supported_panels(): void { // ------------------------------------------------------------------------- /** - * get_title returns Available Shortcodes. + * Get_title returns Available Shortcodes. */ public function test_get_title(): void { @@ -129,7 +131,7 @@ public function test_get_title(): void { // ------------------------------------------------------------------------- /** - * get_menu_title returns Available Shortcodes. + * Get_menu_title returns Available Shortcodes. */ public function test_get_menu_title(): void { @@ -144,7 +146,7 @@ public function test_get_menu_title(): void { // ------------------------------------------------------------------------- /** - * get_submenu_title returns Dashboard. + * Get_submenu_title returns Dashboard. */ public function test_get_submenu_title(): void { @@ -159,7 +161,7 @@ public function test_get_submenu_title(): void { // ------------------------------------------------------------------------- /** - * get_data returns an array. + * Get_data returns an array. */ public function test_get_data_returns_array(): void { @@ -169,7 +171,7 @@ public function test_get_data_returns_array(): void { } /** - * get_data array items have required keys. + * Get_data array items have required keys. */ public function test_get_data_items_have_required_keys(): void { @@ -189,7 +191,7 @@ public function test_get_data_items_have_required_keys(): void { } /** - * get_data params is an array. + * Get_data params is an array. */ public function test_get_data_params_is_array(): void { @@ -204,12 +206,86 @@ public function test_get_data_params_is_array(): void { $this->assertIsArray($first_item['params']); } + /** + * Callable select options receive the field instance used by normal form rendering. + */ + public function test_get_data_passes_field_to_callable_select_options(): void { + + $resolved_field = null; + $options = function (Field $field) use (&$resolved_field) { + $resolved_field = $field; + + return [ + 'first' => 'First', + 'second' => 'Second', + ]; + }; + + $element = new class($options) extends Base_Element { + + private $options; + + public function __construct($options) { + $this->id = 'callable-options-test'; + $this->options = $options; + } + + public function get_icon($context = 'block') { + return ''; + } + + public function get_title() { + return 'Callable Options Test'; + } + + public function get_description() { + return ''; + } + + public function fields() { + return [ + 'destination' => [ + 'type' => 'select', + 'options' => $this->options, + ], + ]; + } + + public function keywords() { + return []; + } + + public function defaults() { + return []; + } + + public function output($atts, $content = null) { + } + }; + + $property = new \ReflectionProperty(Base_Element::class, 'public_elements'); + $property->setAccessible(true); + $original_elements = $property->getValue(); + + try { + Base_Element::register_public_element($element); + + $data = $this->page->get_data(); + $test_data = end($data); + + $this->assertInstanceOf(Field::class, $resolved_field); + $this->assertSame('first | second', $test_data['params']['destination']['options']); + } finally { + $property->setValue(null, $original_elements); + } + } + // ------------------------------------------------------------------------- // output() // ------------------------------------------------------------------------- /** - * output renders template. + * Output renders template. */ public function test_output_renders_template(): void { diff --git a/tests/WP_Ultimo/Builders/Block_Editor/Block_Editor_Widget_Manager_Test.php b/tests/WP_Ultimo/Builders/Block_Editor/Block_Editor_Widget_Manager_Test.php index c6ddaf3e2..1a5a42e55 100644 --- a/tests/WP_Ultimo/Builders/Block_Editor/Block_Editor_Widget_Manager_Test.php +++ b/tests/WP_Ultimo/Builders/Block_Editor/Block_Editor_Widget_Manager_Test.php @@ -116,4 +116,96 @@ public function test_is_block_preview_passes_through_outside_rest(): void { $this->assertFalse($result, 'Should return false when not in REST edit context.'); } + + /** + * Test attributes use defaults without evaluating field option providers. + */ + public function test_get_attributes_from_fields_does_not_evaluate_field_options(): void { + + $element = $this->getMockBuilder('\WP_Ultimo\UI\Simple_Text_Element') + ->disableOriginalConstructor() + ->onlyMethods(['defaults', 'fields']) + ->getMock(); + + $element->expects($this->once()) + ->method('defaults') + ->willReturn( + [ + 'enabled' => 1, + 'columns' => 4, + 'site_manage_type' => 'default', + 'page_id' => 0, + 'limit' => 0, + 'template_selection_template' => 'clean', + 'internal_state' => [], + ] + ); + + $element->expects($this->once()) + ->method('fields') + ->willReturn( + [ + 'enabled' => [ + 'type' => 'toggle', + 'options' => static function () { + throw new \RuntimeException('Block attribute registration must not evaluate options.'); + }, + ], + 'columns' => ['type' => 'number'], + 'site_manage_type' => ['type' => 'select'], + 'page_id' => [ + 'type' => 'select', + 'options' => static function () { + throw new \RuntimeException('Block attribute registration must not evaluate options.'); + }, + ], + 'limit' => [ + 'type' => 'int', + 'value' => 10, + ], + 'template_selection' => [ + 'type' => 'group', + 'fields' => [ + 'template_selection_template' => [ + 'type' => 'select', + 'options' => static function () { + throw new \RuntimeException('Block attribute registration must not evaluate grouped options.'); + }, + ], + ], + ], + '_heading' => ['type' => 'header'], + ] + ); + + $this->assertSame( + [ + 'enabled' => [ + 'default' => true, + 'type' => 'boolean', + ], + 'columns' => [ + 'default' => 4, + 'type' => 'integer', + ], + 'site_manage_type' => [ + 'default' => 'default', + 'type' => 'string', + ], + 'page_id' => [ + 'default' => '0', + 'type' => 'string', + ], + 'limit' => [ + 'default' => 0, + 'type' => 'integer', + ], + 'template_selection_template' => [ + 'default' => 'clean', + 'type' => 'string', + ], + ], + $this->manager->get_attributes_from_fields($element) + ); + } } diff --git a/tests/WP_Ultimo/Functions/Pages_Functions_Test.php b/tests/WP_Ultimo/Functions/Pages_Functions_Test.php index 43b0d00cd..5404d6a5c 100644 --- a/tests/WP_Ultimo/Functions/Pages_Functions_Test.php +++ b/tests/WP_Ultimo/Functions/Pages_Functions_Test.php @@ -75,6 +75,130 @@ public function test_is_new_site_page_false_without_post(): void { $this->assertFalse($result); } + /** + * Test page options are memoized per request with context-specific defaults. + */ + public function test_get_pages_as_options_memoizes_pages(): void { + + global $post; + + $original_post = $post; + $current_page = self::factory()->post->create_and_get( + [ + 'post_type' => 'page', + 'post_title' => 'Current page', + ] + ); + + $page_id = self::factory()->post->create( + [ + 'post_type' => 'page', + 'post_title' => 'Example page', + ] + ); + + $post = $current_page; + + $get_pages_calls = 0; + $get_pages_filter = function ($pages) use (&$get_pages_calls) { + ++$get_pages_calls; + return $pages; + }; + + add_filter('get_pages', $get_pages_filter); + + try { + $first = wu_get_pages_as_options('Current Page'); + $second = wu_get_pages_as_options('Default'); + + $this->assertSame('Current Page', $first[0]); + $this->assertSame('Default', $second[0]); + $this->assertSame('Example page', $first[ $page_id ]); + $this->assertSame($first[ $page_id ], $second[ $page_id ]); + } finally { + remove_filter('get_pages', $get_pages_filter); + $post = $original_post; + } + + $this->assertSame(1, $get_pages_calls); + } + + /** + * Test the edited page is excluded before the admin global post is available. + */ + public function test_get_pages_as_options_uses_admin_request_page_context(): void { + + global $post; + + $original_post = $post; + $original_screen = get_current_screen(); + $current_page = self::factory()->post->create_and_get( + [ + 'post_type' => 'page', + 'post_title' => 'Edited page', + ] + ); + $other_page_id = self::factory()->post->create( + [ + 'post_type' => 'page', + 'post_title' => 'Other page', + ] + ); + + $post = null; + $_REQUEST['post'] = (string) $current_page->ID; + set_current_screen('post'); + + try { + $options = wu_get_pages_as_options('Current Page'); + + $this->assertArrayNotHasKey($current_page->ID, $options); + $this->assertSame('Other page', $options[ $other_page_id ]); + } finally { + $post = $original_post; + unset($_REQUEST['post']); + + set_current_screen($original_screen ? $original_screen->id : 'front'); + } + } + + /** + * Test page options are memoized separately for each current page. + */ + public function test_get_pages_as_options_isolates_current_page_context(): void { + + global $post; + + $original_post = $post; + $first_page = self::factory()->post->create_and_get( + [ + 'post_type' => 'page', + 'post_title' => 'First page', + ] + ); + $second_page = self::factory()->post->create_and_get( + [ + 'post_type' => 'page', + 'post_title' => 'Second page', + ] + ); + + try { + $post = $first_page; + $first_options = wu_get_pages_as_options('Current Page'); + + $post = $second_page; + $second_options = wu_get_pages_as_options('Current Page'); + + $this->assertArrayNotHasKey($first_page->ID, $first_options); + $this->assertSame('Second page', $first_options[ $second_page->ID ]); + $this->assertSame('First page', $second_options[ $first_page->ID ]); + $this->assertArrayNotHasKey($second_page->ID, $second_options); + } finally { + $post = $original_post; + } + } + /** * Test wu_is_login_page returns bool. */ diff --git a/tests/WP_Ultimo/General_Compat_Test.php b/tests/WP_Ultimo/General_Compat_Test.php index 0655e8b6d..1e0127119 100644 --- a/tests/WP_Ultimo/General_Compat_Test.php +++ b/tests/WP_Ultimo/General_Compat_Test.php @@ -47,6 +47,38 @@ public function test_init_registers_divi_cache_purge_hook(): void { $this->assertNotFalse(has_action('wu_duplicate_site', [$instance, 'clear_divi_static_css_cache'])); } + /** + * Test Gutenberg support skips ordinary admin screens. + */ + public function test_gutenberg_support_skips_ordinary_admin_screens(): void { + + set_current_screen('dashboard'); + wp_deregister_script('wu-gutenberg-support'); + + Gutenberg_Support::get_instance()->add_scripts(); + + $this->assertFalse(wp_script_is('wu-gutenberg-support', 'registered')); + $this->assertFalse(wp_script_is('wu-gutenberg-support', 'enqueued')); + } + + /** + * Test Gutenberg support enqueues and localizes assets in the block editor. + */ + public function test_gutenberg_support_loads_on_block_editor_screens(): void { + + set_current_screen('post'); + $screen = get_current_screen(); + + $screen->is_block_editor(true); + + wp_deregister_script('wu-gutenberg-support'); + + Gutenberg_Support::get_instance()->add_scripts(); + + $this->assertTrue(wp_script_is('wu-gutenberg-support', 'enqueued')); + $this->assertNotEmpty(wp_scripts()->get_data('wu-gutenberg-support', 'data')); + } + /** * Test Divi et-cache files are deleted only for the cloned site. */