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
+
+```
+
+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();
?>