diff --git a/.cspell.json b/.cspell.json index 8f617e3..79b973a 100644 --- a/.cspell.json +++ b/.cspell.json @@ -22,6 +22,7 @@ "words": [ "absint", "abspath", + "adminmenu", "bunx", "chromedriver", "codecept", diff --git a/assets/css/admin.css b/assets/css/admin.css index 16ed209..ed09528 100644 --- a/assets/css/admin.css +++ b/assets/css/admin.css @@ -503,6 +503,37 @@ max-width: 1200px; } +/* Back Link */ +.stellarwp-migration-single__back { + margin-bottom: 16px; +} + +.stellarwp-migration-single__back-link { + align-items: center; + color: #50575e; + display: inline-flex; + font-size: 13px; + gap: 2px; + text-decoration: none; +} + +.stellarwp-migration-single__back-link:hover, +.stellarwp-migration-single__back-link:focus { + color: #2271b1; +} + +.stellarwp-migration-single__back-link .dashicons { + font-size: 16px; + height: 16px; + width: 16px; +} + +.stellarwp-migrations-back-link-divider { + border: none; + border-top: 1px solid #c3c4c7; + margin: 12px 0 0; +} + /* Header */ .stellarwp-migration-single__header { margin-bottom: 24px; diff --git a/docs/admin-ui.md b/docs/admin-ui.md index f05df7e..aa66ec3 100644 --- a/docs/admin-ui.md +++ b/docs/admin-ui.md @@ -10,6 +10,7 @@ The Admin UI is built with a template engine abstraction, allowing consumers to src/ ├── Admin/ │ ├── Assets.php # CSS/JS asset registration and enqueuing +│ ├── Provider.php # Hidden page registration, sidebar highlighting, back link │ └── UI.php # Main UI class with render methods ├── Contracts/ │ └── Template_Engine.php # Interface for template rendering @@ -366,6 +367,76 @@ $ui->render_list(); This ensures that when users filter migrations, they stay on the same settings page and section. +## Admin Provider + +The `Admin\Provider` class registers the hidden single migration page and provides static configuration methods for sidebar highlighting and back navigation. + +### `Provider::set_parent_page( string $parent_page )` + +Configures which admin sidebar menu item should be highlighted when viewing the single migration detail page. + +By default, the single migration page is a hidden submenu page (no parent), so no sidebar item is highlighted. When you set a parent page, the library temporarily overrides the WordPress `$plugin_page` global so that the specified menu item appears active. + +```php +use StellarWP\Migrations\Admin\Provider; + +// Highlight the "Migrations" menu item when viewing a single migration. +Provider::set_parent_page( 'my-plugin-migrations' ); +``` + +This uses the `submenu_file` filter and `adminmenu` action internally, following the same pattern used by WordPress core and other plugins for sidebar highlighting of hidden pages. + +**Call this before the `admin_menu` hook fires** (e.g., during plugin initialization or service provider registration). + +--- + +### `Provider::set_list_url( string $url )` + +Sets the URL for the migrations list page. When configured, the single migration detail page will display a "Migrations" back link above the migration content, separated by a full-width horizontal rule. The URL is passed as a `list_url` template variable to the `single` template via `UI::render_single()`. + +```php +use StellarWP\Migrations\Admin\Provider; + +Provider::set_list_url( admin_url( 'admin.php?page=my-plugin-migrations' ) ); +``` + +**Rendered output (when set):** + +```html +
+ + Migrations + + +
+``` + +When not set, no back link is rendered. + +--- + +### `Provider::get_list_url()` + +Returns the configured list page URL, or `null` if not set. + +```php +use StellarWP\Migrations\Admin\Provider; + +$url = Provider::get_list_url(); // string|null +``` + +--- + +### Complete Provider Configuration Example + +```php +use StellarWP\Migrations\Admin\Provider; + +// During plugin initialization, configure the admin provider. +Provider::set_parent_page( 'my-plugin-migrations' ); +Provider::set_list_url( admin_url( 'admin.php?page=my-plugin-migrations' ) ); +``` + ## Custom Integration Example Here's a complete example of integrating the Admin UI with a custom template engine: diff --git a/phpcs.xml b/phpcs.xml index aca7be8..43bb472 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -70,6 +70,10 @@ */tests/* + + */tests/* + + */tests/* diff --git a/src/Admin/Provider.php b/src/Admin/Provider.php index 834faa2..0e600b9 100644 --- a/src/Admin/Provider.php +++ b/src/Admin/Provider.php @@ -33,6 +33,38 @@ class Provider { */ private static bool $registered = false; + /** + * The parent page slug for sidebar highlighting. + * + * When set, the admin sidebar will highlight this menu item + * while viewing the single migration page. + * + * @since 0.0.1 + * + * @var ?string + */ + protected static ?string $parent_page = null; + + /** + * Stores the original parent file during hijacking. + * + * @since 0.0.1 + * + * @var ?string + */ + protected static ?string $stored_parent_file = null; + + /** + * The URL of the migrations list page. + * + * When set, the single migration page will display a back link. + * + * @since 0.0.1 + * + * @var ?string + */ + protected static ?string $list_url = null; + /** * Register the admin provider. * @@ -49,6 +81,8 @@ public function register(): void { add_action( 'admin_menu', [ $this, 'register_hidden_page' ] ); add_filter( 'set-screen-option', [ $this, 'save_screen_option' ], 10, 3 ); + add_filter( 'submenu_file', [ $this, 'hijack_current_parent_file' ] ); + add_action( 'adminmenu', [ $this, 'restore_current_parent_file' ] ); add_action( 'admin_post_' . Log_Download_Handler::get_action_name(), [ Log_Download_Handler::class, 'download' ] ); } @@ -181,6 +215,100 @@ public static function get_single_url( string $migration_id ): string { ); } + /** + * Set the parent page slug for sidebar highlighting. + * + * When set, viewing the single migration page will highlight + * the specified menu item in the admin sidebar. + * + * @since 0.0.1 + * + * @param string $parent_page The parent menu slug (e.g. 'edit.php?post_type=page'). + * + * @return void + */ + public static function set_parent_page( string $parent_page ): void { + self::$parent_page = $parent_page; + } + + /** + * Set the URL for the migrations list page. + * + * When set, the single migration page will display a link + * back to the list page. + * + * @since 0.0.1 + * + * @param string $url The URL of the migrations list page. + * + * @return void + */ + public static function set_list_url( string $url ): void { + self::$list_url = $url; + } + + /** + * Get the URL for the migrations list page. + * + * @since 0.0.1 + * + * @return string|null The list page URL, or null if not set. + */ + public static function get_list_url(): ?string { + return self::$list_url; + } + + /** + * Hijack the current parent file for sidebar highlighting. + * + * When a parent page is configured and we are on the single + * migration page, this overrides `$plugin_page` so the correct + * admin menu item is highlighted in the sidebar. + * + * @since 0.0.1 + * + * @param string $submenu_file The submenu file. + * + * @return string The submenu file (unchanged). + */ + public function hijack_current_parent_file( $submenu_file ) { + if ( ! self::$parent_page ) { + return $submenu_file; + } + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $current_page = isset( $_GET['page'] ) && is_string( $_GET['page'] ) ? wp_unslash( sanitize_text_field( $_GET['page'] ) ) : ''; + + if ( $current_page !== self::get_single_page_slug() ) { + return $submenu_file; + } + + global $plugin_page; + + self::$stored_parent_file = is_string( $plugin_page ) ? $plugin_page : null; + + $plugin_page = self::$parent_page; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + + return $submenu_file; + } + + /** + * Restore the original parent file after the admin menu renders. + * + * @since 0.0.1 + * + * @return void + */ + public function restore_current_parent_file(): void { + if ( ! isset( self::$stored_parent_file ) ) { + return; + } + + global $plugin_page; + + $plugin_page = self::$stored_parent_file; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + } + /** * Reset registration status. Primarily for testing. * @@ -189,6 +317,9 @@ public static function get_single_url( string $migration_id ): string { * @return void */ public static function reset(): void { - self::$registered = false; + self::$registered = false; + self::$parent_page = null; + self::$stored_parent_file = null; + self::$list_url = null; } } diff --git a/src/Admin/UI.php b/src/Admin/UI.php index 7bea3f6..a041de7 100644 --- a/src/Admin/UI.php +++ b/src/Admin/UI.php @@ -147,6 +147,7 @@ public function render_single( string $migration_id ): void { 'migration' => $migration, 'executions' => $executions, 'rest_base_url' => rest_url( REST_Provider::get_namespace() ), + 'list_url' => Provider::get_list_url(), ] ); } diff --git a/src/views/single.php b/src/views/single.php index abcb189..ffb0640 100644 --- a/src/views/single.php +++ b/src/views/single.php @@ -10,6 +10,7 @@ * @var StellarWP\Migrations\Contracts\Migration $migration The migration object. * @var list> $executions List of execution records. * @var string $rest_base_url REST API base URL. + * @var string|null $list_url List URL. */ defined( 'ABSPATH' ) || exit; @@ -26,10 +27,19 @@ $description = $migration->get_description(); $rest_base_url ??= ''; $executions ??= []; +$list_url ??= null; $template = Config::get_template_engine(); ?>
+ +
+ + + + +
+

diff --git a/tests/wpunit/Admin/Provider_Test.php b/tests/wpunit/Admin/Provider_Test.php new file mode 100644 index 0000000..8267616 --- /dev/null +++ b/tests/wpunit/Admin/Provider_Test.php @@ -0,0 +1,230 @@ +provider = new Provider(); + } + + /** + * Tear down after each test. + * + * @after + * + * @return void + */ + public function tear_down_provider(): void { + Provider::reset(); + unset( $_GET['page'] ); + } + + /** + * @test + */ + public function it_should_not_hijack_parent_file_when_no_parent_page_set(): void { + global $plugin_page; + + $original_plugin_page = $plugin_page; + $submenu_file = 'some-submenu-file.php'; + + $result = $this->provider->hijack_current_parent_file( $submenu_file ); + + $this->assertSame( $submenu_file, $result, 'The submenu_file should be returned unchanged.' ); + $this->assertSame( $original_plugin_page, $plugin_page, 'The global $plugin_page should not be modified.' ); + } + + /** + * @test + */ + public function it_should_not_hijack_parent_file_when_not_on_single_page(): void { + global $plugin_page; + + Provider::set_parent_page( 'edit.php?post_type=page' ); + + $original_plugin_page = $plugin_page; + $submenu_file = 'some-submenu-file.php'; + + // Simulate being on a different admin page. + $_GET['page'] = 'some-other-page'; + + $result = $this->provider->hijack_current_parent_file( $submenu_file ); + + $this->assertSame( $submenu_file, $result, 'The submenu_file should be returned unchanged.' ); + $this->assertSame( $original_plugin_page, $plugin_page, 'The global $plugin_page should not be modified when not on the single page.' ); + } + + /** + * @test + */ + public function it_should_hijack_parent_file_when_on_single_page(): void { + global $plugin_page; + + $target_parent = 'edit.php?post_type=page'; + Provider::set_parent_page( $target_parent ); + + $plugin_page = 'options-general.php'; + $submenu_file = 'some-submenu-file.php'; + + // Simulate being on the single migration page. + $_GET['page'] = 'stellarwp-migrations-foobar-single'; + + $result = $this->provider->hijack_current_parent_file( $submenu_file ); + + $this->assertSame( $submenu_file, $result, 'The submenu_file return value should remain unchanged.' ); + $this->assertSame( $target_parent, $plugin_page, 'The global $plugin_page should be overridden to the configured parent page.' ); + } + + /** + * @test + */ + public function it_should_restore_parent_file_after_hijack(): void { + global $plugin_page; + + $original_plugin_page = 'options-general.php'; + $target_parent = 'edit.php?post_type=page'; + + $plugin_page = $original_plugin_page; + Provider::set_parent_page( $target_parent ); + + // Simulate being on the single migration page and trigger hijack. + $_GET['page'] = 'stellarwp-migrations-foobar-single'; + $this->provider->hijack_current_parent_file( 'submenu.php' ); + + // Verify hijack occurred. + $this->assertSame( $target_parent, $plugin_page, 'The plugin_page should be hijacked at this point.' ); + + // Restore the plugin page. + $this->provider->restore_current_parent_file(); + + $this->assertSame( $original_plugin_page, $plugin_page, 'The global $plugin_page should be restored to its original value.' ); + } + + /** + * @test + */ + public function it_should_not_restore_when_no_hijack_occurred(): void { + global $plugin_page; + + $plugin_page = 'options-general.php'; + + // Call restore without any prior hijack. + $this->provider->restore_current_parent_file(); + + $this->assertSame( 'options-general.php', $plugin_page, 'The global $plugin_page should remain unchanged when no hijack occurred.' ); + } + + /** + * @test + */ + public function it_should_return_null_when_list_url_not_set(): void { + $this->assertNull( Provider::get_list_url(), 'The list URL should be null by default.' ); + } + + /** + * @test + */ + public function it_should_return_configured_list_url(): void { + $url = 'https://example.com/wp-admin/admin.php?page=my-migrations'; + + Provider::set_list_url( $url ); + + $this->assertSame( $url, Provider::get_list_url(), 'The list URL should match the configured value.' ); + } + + /** + * @test + */ + public function it_should_reset_parent_page_and_list_url(): void { + global $plugin_page; + + // Set up state. + Provider::set_parent_page( 'edit.php?post_type=page' ); + Provider::set_list_url( 'https://example.com/wp-admin/admin.php?page=my-migrations' ); + + // Trigger a hijack to populate stored_parent_file. + $plugin_page = 'options-general.php'; + $_GET['page'] = 'stellarwp-migrations-foobar-single'; + $this->provider->hijack_current_parent_file( 'submenu.php' ); + + // Reset all state. + Provider::reset(); + + $this->assertNull( Provider::get_list_url(), 'The list URL should be null after reset.' ); + + // After reset, hijack should be a no-op (parent_page is cleared). + $plugin_page = 'some-file.php'; + $result = $this->provider->hijack_current_parent_file( 'submenu.php' ); + + $this->assertSame( 'some-file.php', $plugin_page, 'The plugin_page should not be hijacked after reset.' ); + + // After reset, restore should be a no-op (stored_parent_file is cleared). + $this->provider->restore_current_parent_file(); + + $this->assertSame( 'some-file.php', $plugin_page, 'The plugin_page should remain unchanged after restore when reset was called.' ); + } + + /** + * @test + */ + public function it_should_register_submenu_file_filter(): void { + $this->provider->register(); + + $this->assertIsInt( + has_filter( 'submenu_file', [ $this->provider, 'hijack_current_parent_file' ] ), + 'The submenu_file filter should be registered.' + ); + } + + /** + * @test + */ + public function it_should_register_adminmenu_action(): void { + $this->provider->register(); + + $this->assertIsInt( + has_action( 'adminmenu', [ $this->provider, 'restore_current_parent_file' ] ), + 'The adminmenu action should be registered.' + ); + } +} diff --git a/tests/wpunit/Admin/UI_Test.php b/tests/wpunit/Admin/UI_Test.php index 0975f2b..c53d83a 100644 --- a/tests/wpunit/Admin/UI_Test.php +++ b/tests/wpunit/Admin/UI_Test.php @@ -14,6 +14,7 @@ namespace StellarWP\Migrations\Tests\Admin; use lucatume\WPBrowser\TestCase\WPTestCase; +use StellarWP\Migrations\Admin\Provider as Admin_Provider; use StellarWP\Migrations\Admin\UI; use StellarWP\Migrations\Config; use StellarWP\Migrations\Enums\Status; @@ -87,6 +88,7 @@ public function tear_down_ui(): void { Multi_Batch_Migration::reset(); Not_Applicable_Migration::reset(); Tagged_Migration::reset(); + Admin_Provider::reset(); tests_migrations_clear_calls_data(); } @@ -495,4 +497,40 @@ public function it_should_trigger_doing_it_wrong_with_correct_message(): void { // The setExpectedIncorrectUsage will handle the assertion. } + + /** + * @test + */ + public function it_should_render_single_with_back_link_when_list_url_set(): void { + $this->set_admin_user(); + $this->register_test_migrations(); + + Admin_Provider::set_list_url( 'http://wordpress.test/wp-admin/admin.php?page=test-migrations' ); + + ob_start(); + $this->ui->render_single( 'tests_simple_migration' ); + $output = ob_get_clean(); + + $this->assertStringContainsString( 'stellarwp-migration-single__back', $output ); + $this->assertStringContainsString( 'stellarwp-migration-single__back-link', $output ); + $this->assertStringContainsString( 'http://wordpress.test/wp-admin/admin.php?page=test-migrations', $output ); + $this->assertStringContainsString( 'Migrations', $output ); + $this->assertStringContainsString( 'stellarwp-migrations-back-link-divider', $output ); + + $this->assertMatchesHtmlSnapshot( $output ); + } + + /** + * @test + */ + public function it_should_render_single_without_back_link_when_list_url_not_set(): void { + $this->set_admin_user(); + $this->register_test_migrations(); + + ob_start(); + $this->ui->render_single( 'tests_simple_migration' ); + $output = ob_get_clean(); + + $this->assertStringNotContainsString( 'stellarwp-migration-single__back', $output ); + } } diff --git a/tests/wpunit/Admin/__snapshots__/UI_Test__it_should_render_single_with_back_link_when_list_url_set__0.snapshot.html b/tests/wpunit/Admin/__snapshots__/UI_Test__it_should_render_single_with_back_link_when_list_url_set__0.snapshot.html new file mode 100644 index 0000000..9a96357 --- /dev/null +++ b/tests/wpunit/Admin/__snapshots__/UI_Test__it_should_render_single_with_back_link_when_list_url_set__0.snapshot.html @@ -0,0 +1,126 @@ +
+ +
+

Simple Migration

+

This is a simple migration that runs a single batch.

+
+ +
+

Status

+
+
+
+ + Pending + + + 0 / 1 +
+ +
+
+
+
+
+
+
+ +
+ + +
+
+ +
+ +
+ + Not yet started +
+ + +
+
+ +
+

Configuration

+
+
+
+ Total Items + 1 +
+ +
+ Batch Size + 1 +
+ +
+ Total Batches + 1 +
+ +
+ Retry Attempts + 0 +
+ +
+ Applicable + + Yes +
+ +
+ Can Run + + Yes +
+ +
+
+
+ +
+

Logs

+
+
+

No executions yet. Run the migration to see logs.

+
+
+
+