From cc014aeeffadd09a03c55cc06cede00957d472bd Mon Sep 17 00:00:00 2001 From: Thomas Guillot Date: Fri, 27 Mar 2026 18:31:28 +0000 Subject: [PATCH 1/2] feat(handoff): add URL-based handoff with customizable banner text --- includes/api/class-plugins-controller.php | 53 ++++++++- includes/class-handoff-banner.php | 109 ++++++++++++++++--- includes/wizards/class-wizard.php | 1 + packages/components/src/action-card/index.js | 16 ++- packages/components/src/handoff/README.md | 93 ++++++++++++++++ packages/components/src/handoff/index.js | 54 +++++++-- src/wizards/componentsDemo/index.js | 24 ++-- src/wizards/handoff-banner/index.js | 49 +++++++-- src/wizards/handoff-banner/style.scss | 80 +++----------- 9 files changed, 376 insertions(+), 103 deletions(-) create mode 100644 packages/components/src/handoff/README.md diff --git a/includes/api/class-plugins-controller.php b/includes/api/class-plugins-controller.php index 3bd0d107c9..c6d206a25a 100644 --- a/includes/api/class-plugins-controller.php +++ b/includes/api/class-plugins-controller.php @@ -147,6 +147,19 @@ public function register_routes() { ] ); + // Register newspack/v1/handoff endpoint for URL-based handoff. + register_rest_route( + $this->namespace, + '/handoff', + [ + [ + 'methods' => 'POST', + 'callback' => [ $this, 'handoff_to_url' ], + 'permission_callback' => [ $this, 'handoff_item_permissions_check' ], + ], + ] + ); + // Register newspack/v1/plugins/some-plugin/handoff endpoint. register_rest_route( $this->namespace, @@ -314,6 +327,42 @@ public function uninstall_item( $request ) { return $this->get_item( $request ); } + /** + * Handoff to an arbitrary admin URL. + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function handoff_to_url( $request ) { + $destination_url = $request->get_param( 'destinationUrl' ); + if ( empty( $destination_url ) ) { + return new \WP_Error( 'newspack_handoff_missing_url', __( 'destinationUrl is required.', 'newspack-plugin' ), [ 'status' => 400 ] ); + } + + $handoff_return_url = $request->get_param( 'handoffReturnUrl' ); + $show_on_block_editor = $request->get_param( 'showOnBlockEditor' ); + $banner_text = (string) $request->get_param( 'bannerText' ); + $banner_button_text = (string) $request->get_param( 'bannerButtonText' ); + + update_option( NEWSPACK_HANDOFF, 'url' ); + update_option( NEWSPACK_HANDOFF_SHOW_ON_BLOCK_EDITOR, (bool) $show_on_block_editor ); + update_option( NEWSPACK_HANDOFF_BANNER_TEXT, sanitize_text_field( $banner_text ) ); + update_option( NEWSPACK_HANDOFF_BANNER_BUTTON_TEXT, sanitize_text_field( $banner_button_text ) ); + if ( ! empty( $handoff_return_url ) ) { + update_option( NEWSPACK_HANDOFF_RETURN_URL, esc_url( $handoff_return_url ) ); + } + + $parsed_url = wp_parse_url( $destination_url ); + if ( ! empty( $parsed_url['query'] ) ) { + wp_parse_str( $parsed_url['query'], $query_params ); + if ( ! empty( $query_params['page'] ) ) { + update_option( NEWSPACK_HANDOFF_DESTINATION_PAGE, sanitize_text_field( $query_params['page'] ) ); + } + } + + return rest_ensure_response( [ 'HandoffLink' => esc_url_raw( $destination_url ) ] ); + } + /** * Handoff to a managed plugin. * @@ -329,7 +378,9 @@ public function handoff_item( $request ) { } $show_on_block_editor = $request->get_param( 'showOnBlockEditor' ); - Handoff_Banner::register_handoff_for_plugin( $slug, (bool) $show_on_block_editor ); + $banner_text = (string) $request->get_param( 'bannerText' ); + $banner_button_text = (string) $request->get_param( 'bannerButtonText' ); + Handoff_Banner::register_handoff_for_plugin( $slug, (bool) $show_on_block_editor, $banner_text, $banner_button_text ); $managed_plugins = Plugin_Manager::get_managed_plugins(); $response = $managed_plugins[ $slug ]; diff --git a/includes/class-handoff-banner.php b/includes/class-handoff-banner.php index ebbeb8e489..492a32e42b 100644 --- a/includes/class-handoff-banner.php +++ b/includes/class-handoff-banner.php @@ -12,6 +12,9 @@ define( 'NEWSPACK_HANDOFF', 'newspack_handoff' ); define( 'NEWSPACK_HANDOFF_RETURN_URL', 'newspack_handoff_return_url' ); define( 'NEWSPACK_HANDOFF_SHOW_ON_BLOCK_EDITOR', 'newspack_handoff_show_on_block_editor' ); +define( 'NEWSPACK_HANDOFF_BANNER_TEXT', 'newspack_handoff_banner_text' ); +define( 'NEWSPACK_HANDOFF_BANNER_BUTTON_TEXT', 'newspack_handoff_banner_button_text' ); +define( 'NEWSPACK_HANDOFF_DESTINATION_PAGE', 'newspack_handoff_destination_page' ); /** * Manages the API as a whole. @@ -25,11 +28,13 @@ public function __construct() { add_action( 'current_screen', [ $this, 'clear_handoff_url' ] ); add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_styles' ], 1 ); add_action( 'enqueue_block_editor_assets', [ $this, 'insert_block_editor_handoff_banner' ] ); - add_action( 'admin_notices', [ $this, 'insert_handoff_banner' ], -9998 ); + add_action( 'in_admin_header', [ $this, 'insert_handoff_banner' ] ); + add_action( 'newspack_before_wizard_content', [ $this, 'insert_handoff_banner_static' ] ); } /** - * Render element into which Handoff Banner will be rendered. + * Render element into which Handoff Banner will be rendered via JS. + * Used on non-wizard admin pages. * * @return void. */ @@ -38,7 +43,67 @@ public function insert_handoff_banner() { return; } - printf( "
", esc_url( get_option( NEWSPACK_HANDOFF_RETURN_URL ) ) ); + printf( + "
", + esc_url( get_option( NEWSPACK_HANDOFF_RETURN_URL ) ), + esc_attr( get_option( NEWSPACK_HANDOFF_BANNER_TEXT, '' ) ), + esc_attr( get_option( NEWSPACK_HANDOFF_BANNER_BUTTON_TEXT, '' ) ) + ); + } + + /** + * Render a fully server-side Handoff Banner. + * Used on Newspack wizard pages where the JS-based banner cannot be used. + * + * @return void. + */ + public function insert_handoff_banner_static() { + if ( ! self::needs_handoff_return_ui() ) { + return; + } + + $return_url = esc_url( get_option( NEWSPACK_HANDOFF_RETURN_URL ) ); + $banner_text = get_option( NEWSPACK_HANDOFF_BANNER_TEXT, '' ); + $button_text = get_option( NEWSPACK_HANDOFF_BANNER_BUTTON_TEXT, '' ); + + if ( empty( $banner_text ) ) { + $banner_text = __( 'Return to Newspack after completing configuration', 'newspack-plugin' ); + } + if ( empty( $button_text ) ) { + $button_text = __( 'Back to Newspack', 'newspack-plugin' ); + } + ?> +
+
+
+
+ + + + +
+
+
+ + __( 'Return to Newspack after completing configuration', 'newspack' ), - 'buttonText' => __( 'Back to Newspack', 'newspack' ), + $banner_text = get_option( NEWSPACK_HANDOFF_BANNER_TEXT, '' ); + $banner_button_text = get_option( NEWSPACK_HANDOFF_BANNER_BUTTON_TEXT, '' ); + $script_info = [ + 'text' => $banner_text ? $banner_text : __( 'Return to Newspack after completing configuration', 'newspack' ), + 'buttonText' => $banner_button_text ? $banner_button_text : __( 'Back to Newspack', 'newspack' ), 'returnURL' => esc_url( get_option( NEWSPACK_HANDOFF_RETURN_URL, '' ) ), ]; wp_localize_script( $handle, 'newspack_handoff', $script_info ); @@ -80,18 +147,24 @@ public function enqueue_styles() { wp_register_style( $handle, Newspack::plugin_url() . '/dist/handoff-banner.css', - [], + [ 'wp-components' ], NEWSPACK_PLUGIN_VERSION ); wp_enqueue_style( $handle ); + $screen = get_current_screen(); + if ( $screen && stristr( $screen->id, 'newspack' ) ) { + return; + } + Newspack::load_common_assets(); + $asset = include NEWSPACK_ABSPATH . 'dist/handoff-banner.asset.php'; wp_register_script( $handle, Newspack::plugin_url() . '/dist/handoff-banner.js', - [ 'wp-element', 'wp-editor', 'wp-components' ], - NEWSPACK_PLUGIN_VERSION, + $asset['dependencies'], + $asset['version'], true ); wp_enqueue_script( $handle ); @@ -102,11 +175,15 @@ public function enqueue_styles() { * * @param array $plugin Slug of plugin to be visited. * @param boolean $show_on_block_editor Whether to show on block editor. + * @param string $banner_text Custom banner body text. + * @param string $banner_button_text Custom banner button text. * @return void */ - public static function register_handoff_for_plugin( $plugin, $show_on_block_editor = false ) { + public static function register_handoff_for_plugin( $plugin, $show_on_block_editor = false, $banner_text = '', $banner_button_text = '' ) { update_option( NEWSPACK_HANDOFF, $plugin ); update_option( NEWSPACK_HANDOFF_SHOW_ON_BLOCK_EDITOR, (bool) $show_on_block_editor ); + update_option( NEWSPACK_HANDOFF_BANNER_TEXT, sanitize_text_field( $banner_text ) ); + update_option( NEWSPACK_HANDOFF_BANNER_BUTTON_TEXT, sanitize_text_field( $banner_button_text ) ); } /** @@ -115,10 +192,6 @@ public static function register_handoff_for_plugin( $plugin, $show_on_block_edit * @return bool */ public static function needs_handoff_return_ui() { - if ( get_option( NEWSPACK_SETUP_COMPLETE, true ) ) { - return false; - } - return get_option( NEWSPACK_HANDOFF ) ? true : false; } @@ -139,8 +212,16 @@ public static function needs_block_editor_handoff_return_ui() { */ public function clear_handoff_url( $current_screen ) { if ( stristr( $current_screen->id, 'newspack' ) ) { + $destination_page = get_option( NEWSPACK_HANDOFF_DESTINATION_PAGE, '' ); + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( $destination_page && isset( $_GET['page'] ) && sanitize_text_field( wp_unslash( $_GET['page'] ) ) === $destination_page ) { + return; + } update_option( NEWSPACK_HANDOFF, null ); update_option( NEWSPACK_HANDOFF_SHOW_ON_BLOCK_EDITOR, false ); + update_option( NEWSPACK_HANDOFF_BANNER_TEXT, '' ); + update_option( NEWSPACK_HANDOFF_BANNER_BUTTON_TEXT, '' ); + update_option( NEWSPACK_HANDOFF_DESTINATION_PAGE, '' ); } } } diff --git a/includes/wizards/class-wizard.php b/includes/wizards/class-wizard.php index f6448ce7b0..e9921450a6 100644 --- a/includes/wizards/class-wizard.php +++ b/includes/wizards/class-wizard.php @@ -118,6 +118,7 @@ public function add_page() { * Render the container for the wizard. */ public function render_wizard() { + do_action( 'newspack_before_wizard_content' ); ?>
diff --git a/packages/components/src/action-card/index.js b/packages/components/src/action-card/index.js index c4a25608c0..2ef0cf9b4d 100644 --- a/packages/components/src/action-card/index.js +++ b/packages/components/src/action-card/index.js @@ -39,6 +39,9 @@ const ActionCard = ( { heading = 2, description, handoff, + handoffUrl, + bannerText, + bannerButtonText, editLink, href, notification, @@ -187,7 +190,18 @@ const ActionCard = ( { { actionContent && actionContent } { actionText && ( handoff ? ( - + + { actionText } + + ) : handoffUrl ? ( + { actionText } ) : onClick || hasInternalLink ? ( diff --git a/packages/components/src/handoff/README.md b/packages/components/src/handoff/README.md new file mode 100644 index 0000000000..22072e68e0 --- /dev/null +++ b/packages/components/src/handoff/README.md @@ -0,0 +1,93 @@ +# Handoff + +Navigates the user to another admin page and displays a return banner at the top of the destination so they can find their way back. + +## How it works + +1. The user clicks the Handoff button. +2. A POST request registers the current URL as the return destination and stores optional banner customisations. +3. The user is redirected to the destination page. +4. A banner appears at the top of that page with a "Back to Newspack" button (or custom text) that returns them to the origin. + +## Usage + +### With a URL + +Use the `url` prop to hand off to any WordPress admin URL. No plugin needs to be installed. + +```jsx + + Go to Dashboard + +``` + +### With a managed plugin slug + +Use the `plugin` prop to hand off to a Newspack-managed plugin. The button is automatically disabled if the plugin is not installed or inactive. + +```jsx + + Configure Jetpack + +``` + +Use `editLink` to override the destination to a specific page within the plugin: + +```jsx + + Configure Yoast SEO + +``` + +### Via ActionCard + +`ActionCard` accepts `handoffUrl` (URL-based) or `handoff` (plugin slug) as convenience props that render a `Handoff` as the action button. `bannerText` and `bannerButtonText` are supported on both: + +```jsx + + + +``` + +## Props + +| Prop | Type | Description | +|---|---|---| +| `url` | `string` | Admin URL to hand off to. Use this or `plugin`, not both. | +| `plugin` | `string` | Slug of a Newspack-managed plugin to hand off to. | +| `editLink` | `string` | Overrides the destination URL when using `plugin`. | +| `bannerText` | `string` | Custom body text for the return banner. Defaults to "Return to Newspack after completing configuration". | +| `bannerButtonText` | `string` | Custom label for the return button. Defaults to "Back to Newspack". | +| `showOnBlockEditor` | `bool` | Show the return banner inside the block editor. Default `false`. | +| `useModal` | `bool` | Show a confirmation modal before navigating. | +| `modalTitle` | `string` | Title for the confirmation modal. | +| `modalBody` | `string` | Body text for the confirmation modal. | +| `onReady` | `func` | Called with plugin info once loaded (plugin mode only). | +| `children` | `node` | Button label. Falls back to "Manage {Plugin Name}" in plugin mode. | + +All other props are passed through to the underlying `Button` component (`isPrimary`, `isLink`, `isTertiary`, `className`, etc.). + +## Banner customisation + +```jsx + + Open Settings + +``` diff --git a/packages/components/src/handoff/index.js b/packages/components/src/handoff/index.js index 439ce4961b..f5fc741083 100644 --- a/packages/components/src/handoff/index.js +++ b/packages/components/src/handoff/index.js @@ -31,8 +31,10 @@ class Handoff extends Component { componentDidMount = () => { this._isMounted = true; - const { plugin } = this.props; - this.retrievePluginInfo( plugin ); + const { plugin, url } = this.props; + if ( plugin && ! url ) { + this.retrievePluginInfo( plugin ); + } }; componentWillUnmount = () => { @@ -60,8 +62,25 @@ class Handoff extends Component { return assign( defaults, this.props ); }; + goToUrl = () => { + const { url, showOnBlockEditor, bannerText, bannerButtonText } = this.props; + apiFetch( { + path: '/newspack/v1/handoff', + method: 'POST', + data: { + destinationUrl: url, + handoffReturnUrl: window && window.location.href, + showOnBlockEditor: showOnBlockEditor ? true : false, + bannerText, + bannerButtonText, + }, + } ).then( response => { + window.location.href = response.HandoffLink; + } ); + }; + goToPlugin = plugin => { - const { editLink, showOnBlockEditor } = this.props; + const { editLink, showOnBlockEditor, bannerText, bannerButtonText } = this.props; apiFetch( { path: '/newspack/v1/plugins/' + plugin + '/handoff', method: 'POST', @@ -69,6 +88,8 @@ class Handoff extends Component { editLink, handoffReturnUrl: window && window.location.href, showOnBlockEditor: showOnBlockEditor ? true : false, + bannerText, + bannerButtonText, }, } ).then( response => { window.location.href = response.HandoffLink; @@ -92,30 +113,47 @@ class Handoff extends Component { onReady, // eslint-disable-next-line no-unused-vars,@typescript-eslint/no-unused-vars editLink, + // eslint-disable-next-line no-unused-vars,@typescript-eslint/no-unused-vars + bannerText, + // eslint-disable-next-line no-unused-vars,@typescript-eslint/no-unused-vars + bannerButtonText, + // eslint-disable-next-line no-unused-vars,@typescript-eslint/no-unused-vars + url, ...otherProps } = this.props; const { pluginInfo, showModal } = this.state; const { modalBody, modalTitle, primaryButton, primaryModalButton, dismissModalButton } = this.textForPlugin( pluginInfo ); const { Configured, Name, Slug, Status } = pluginInfo; const classes = classnames( Configured && 'is-configured', className ); + const goTo = () => ( url ? this.goToUrl() : this.goToPlugin( Slug ) ); return ( - { Name && 'active' === Status && ( + { url && ( + + ) } + { ! url && Name && 'active' === Status && ( ) } - { Name && 'active' !== Status && ( + { ! url && Name && 'active' !== Status && ( ) } - { ! Name && ( + { ! url && ! Name && ( - diff --git a/src/wizards/componentsDemo/index.js b/src/wizards/componentsDemo/index.js index 08be7d6703..b5bb7450fe 100644 --- a/src/wizards/componentsDemo/index.js +++ b/src/wizards/componentsDemo/index.js @@ -231,21 +231,19 @@ class ComponentsDemo extends Component {

{ __( 'Handoff Buttons', 'newspack-plugin' ) }

- { __( 'Specific Yoast Page', 'newspack-plugin' ) } + + { __( 'Go to Dashboard', 'newspack-plugin' ) } +
@@ -490,6 +488,14 @@ class ComponentsDemo extends Component { handoff="jetpack" editLink="admin.php?page=jetpack#/settings" /> +
{ bodyText }
- ) } { ! url && Name && 'active' === Status && ( diff --git a/tests/unit-tests/api-plugins-controller.php b/tests/unit-tests/api-plugins-controller.php index f3032b4edc..c8e7837dd9 100644 --- a/tests/unit-tests/api-plugins-controller.php +++ b/tests/unit-tests/api-plugins-controller.php @@ -96,6 +96,60 @@ public function test_get_plugins_authorized() { $this->assertEquals( $expected_jetpack_info, $data['jetpack'] ); } + /** + * Test handoff to URL requires destinationUrl. + */ + public function test_handoff_to_url_missing_destination() { + wp_set_current_user( $this->administrator ); + $request = new WP_REST_Request( 'POST', $this->api_namespace . '/handoff' ); + $response = $this->server->dispatch( $request ); + $this->assertEquals( 400, $response->get_status() ); + $this->assertEquals( 'newspack_handoff_missing_url', $response->get_data()['code'] ); + } + + /** + * Test handoff to URL rejects external URLs. + */ + public function test_handoff_to_url_rejects_external_url() { + wp_set_current_user( $this->administrator ); + $request = new WP_REST_Request( 'POST', $this->api_namespace . '/handoff' ); + $request->set_param( 'destinationUrl', 'https://evil.example/steal-tokens' ); + $response = $this->server->dispatch( $request ); + $this->assertEquals( 400, $response->get_status() ); + $this->assertEquals( 'newspack_handoff_invalid_url', $response->get_data()['code'] ); + } + + /** + * Test handoff to URL stores options and returns the link. + */ + public function test_handoff_to_url_success() { + wp_set_current_user( $this->administrator ); + $request = new WP_REST_Request( 'POST', $this->api_namespace . '/handoff' ); + $request->set_param( 'destinationUrl', '/wp-admin/admin.php?page=newspack-dashboard' ); + $request->set_param( 'bannerText', 'Come back when done.' ); + $request->set_param( 'bannerButtonText', 'Back to Plugin' ); + $request->set_param( 'handoffReturnUrl', 'http://example.org/wp-admin/admin.php?page=newspack-settings' ); + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + $this->assertArrayHasKey( 'HandoffLink', $response->get_data() ); + + $this->assertEquals( 'url', get_option( NEWSPACK_HANDOFF ) ); + $this->assertEquals( 'Come back when done.', get_option( NEWSPACK_HANDOFF_BANNER_TEXT ) ); + $this->assertEquals( 'Back to Plugin', get_option( NEWSPACK_HANDOFF_BANNER_BUTTON_TEXT ) ); + $this->assertEquals( 'newspack-dashboard', get_option( NEWSPACK_HANDOFF_DESTINATION_PAGE ) ); + } + + /** + * Test handoff to URL is inaccessible without authentication. + */ + public function test_handoff_to_url_unauthorized() { + wp_set_current_user( 0 ); + $request = new WP_REST_Request( 'POST', $this->api_namespace . '/handoff' ); + $request->set_param( 'destinationUrl', '/wp-admin/admin.php?page=newspack-dashboard' ); + $response = $this->server->dispatch( $request ); + $this->assertEquals( 401, $response->get_status() ); + } + /** * Test the schema. */