Commit cb161cc1752 for woocommerce
commit cb161cc1752e6ac74c75efcbf46b7412dbac0e55
Author: Cem Ünalan <raicem@users.noreply.github.com>
Date: Wed Sep 9 14:46:43 2026 +0300
Make the My Subscriptions connected account banner dismissible (#68412)
* WOOMKT-851: Make the connected account banner dismissible with an inline disconnect link
The "Connected to <email>" banner on My Subscriptions said the merchant
could disconnect, but only offered a "View account" button, and it showed
on every visit with no way to dismiss it.
Turn "disconnect your account" in the banner copy into a link that opens
the same confirmation modal the header account menu uses, so there is one
disconnect flow. Add a dismiss button that posts to the existing notice
dismiss endpoint under a new notice id; the dismissal is stored in user
meta and exposed to the client as show_connected_account_notice, and is
cleared when a new connection starts, the same as the connected notice.
The dismissal is announced to screen readers and focus moves to the
"Installed on this store" heading.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Put the connected account banner dismiss button first in the tab order
The button sits in the top corner visually but was rendered last, so
keyboard users tabbed through the disconnect link and View account
button before reaching it. Moving it first also puts it beneath the
positioned heading in stacking order, so it gets a z-index to stay
clickable.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Herman <KokkieH@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/WOOMKT-851 b/plugins/woocommerce/changelog/WOOMKT-851
new file mode 100644
index 00000000000..7d186e7cfb2
--- /dev/null
+++ b/plugins/woocommerce/changelog/WOOMKT-851
@@ -0,0 +1,4 @@
+Significance: patch
+Type: enhancement
+
+Add a dismiss button and an inline disconnect link to the connected account banner on the marketplace's My Subscriptions tab.
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/my-subscriptions-account.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/my-subscriptions-account.tsx
index c16449e99b1..6a2a7a5f02f 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/my-subscriptions-account.tsx
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/my-subscriptions-account.tsx
@@ -3,59 +3,132 @@
*/
import { Button } from '@wordpress/components';
import { __, sprintf } from '@wordpress/i18n';
-import { createInterpolateElement } from '@wordpress/element';
-import { Icon, link } from '@wordpress/icons';
+import { createInterpolateElement, useState } from '@wordpress/element';
+import { Icon, closeSmall, link } from '@wordpress/icons';
+import { speak } from '@wordpress/a11y';
+import apiFetch from '@wordpress/api-fetch';
/**
* Internal dependencies
*/
import { getAdminSetting } from '~/utils/admin-settings';
import { MARKETPLACE_MY_ACCOUNT_PATH } from '../constants';
+import { connectUrl } from '../../utils/functions';
+import HeaderAccountModal from '../header-account/header-account-modal';
-export default function MySubscriptionsAccount(): React.JSX.Element | null {
+const NOTICE_ID = 'woo-connected-account-notice';
+
+/**
+ * Whether the banner was dismissed during this page load.
+ *
+ * Module scope because the component unmounts whenever the merchant switches
+ * marketplace tabs, while the admin settings that carry the server-side
+ * dismissal are only refreshed on a full page load.
+ */
+let dismissedThisPageLoad = false;
+
+interface MySubscriptionsAccountProps {
+ /**
+ * Called after the banner is dismissed, so the parent can move focus
+ * somewhere sensible now that the dismiss button is gone.
+ */
+ onDismiss?: () => void;
+}
+
+export default function MySubscriptionsAccount( {
+ onDismiss,
+}: MySubscriptionsAccountProps ): React.JSX.Element | null {
const wccomSettings = getAdminSetting( 'wccomHelper', {} );
const isConnected = wccomSettings?.isConnected ?? false;
+ const [ isDismissed, setIsDismissed ] = useState( dismissedThisPageLoad );
+ const [ isDisconnectModalOpen, setIsDisconnectModalOpen ] =
+ useState( false );
- if ( ! isConnected ) {
+ if (
+ ! isConnected ||
+ isDismissed ||
+ wccomSettings?.show_connected_account_notice === false
+ ) {
return null;
}
const userEmail = wccomSettings?.userEmail;
+ const handleDismiss = () => {
+ dismissedThisPageLoad = true;
+ setIsDismissed( true );
+ void apiFetch( {
+ path: '/wc-admin/notice/dismiss',
+ method: 'POST',
+ data: {
+ notice_id: NOTICE_ID,
+ dismiss_notice_nonce: wccomSettings?.dismissNoticeNonce || '',
+ },
+ } );
+ speak(
+ __( 'Connected account notice dismissed.', 'woocommerce' ),
+ 'polite'
+ );
+ onDismiss?.();
+ };
+
return (
- <section className="woocommerce-marketplace__my-subscriptions__account">
- <h2 className="woocommerce-marketplace__my-subscriptions__account-header">
- <Icon icon={ link } size={ 24 } />
- { sprintf(
- // translators: %s is user email
- __( 'Connected to %s', 'woocommerce' ),
- userEmail
- ) }
- </h2>
- <p className="woocommerce-marketplace__my-subscriptions__account-content">
- { createInterpolateElement(
- sprintf(
+ <>
+ <section className="woocommerce-marketplace__my-subscriptions__account">
+ { /* Rendered first so it is first in the tab order, matching where it sits visually. */ }
+ <Button
+ className="woocommerce-marketplace__my-subscriptions__account-dismiss"
+ icon={ closeSmall }
+ label={ __( 'Dismiss this notice', 'woocommerce' ) }
+ onClick={ handleDismiss }
+ />
+ <h2 className="woocommerce-marketplace__my-subscriptions__account-header">
+ <Icon icon={ link } size={ 24 } />
+ { sprintf(
// translators: %s is user email
- __(
- 'Your store is currently connected to <strong>%s</strong> account on WooCommerce.com. If you think this is a mistake, you can disconnect your account and connect it to your current WooCommerce.com account. Doing this will not affect WooCommerce or any related extensions running on your site.',
- 'woocommerce'
- ),
+ __( 'Connected to %s', 'woocommerce' ),
userEmail
- ),
- {
- strong: <strong />,
- }
- ) }
- </p>
- <div className="woocommerce-marketplace__my-subscriptions__account-actions">
- <Button
- variant="secondary"
- href={ MARKETPLACE_MY_ACCOUNT_PATH }
- target="_blank"
- >
- { __( 'View account', 'woocommerce' ) }
- </Button>
- </div>
- </section>
+ ) }
+ </h2>
+ <p className="woocommerce-marketplace__my-subscriptions__account-content">
+ { createInterpolateElement(
+ sprintf(
+ // translators: %s is user email
+ __(
+ 'Your store is currently connected to <strong>%s</strong> account on WooCommerce.com. If you think this is a mistake, you can <disconnect>disconnect your account</disconnect> and connect it to your current WooCommerce.com account. Doing this will not affect WooCommerce or any related extensions running on your site.',
+ 'woocommerce'
+ ),
+ userEmail
+ ),
+ {
+ strong: <strong />,
+ disconnect: (
+ <Button
+ variant="link"
+ onClick={ () =>
+ setIsDisconnectModalOpen( true )
+ }
+ />
+ ),
+ }
+ ) }
+ </p>
+ <div className="woocommerce-marketplace__my-subscriptions__account-actions">
+ <Button
+ variant="secondary"
+ href={ MARKETPLACE_MY_ACCOUNT_PATH }
+ target="_blank"
+ >
+ { __( 'View account', 'woocommerce' ) }
+ </Button>
+ </div>
+ </section>
+ { isDisconnectModalOpen && (
+ <HeaderAccountModal
+ setIsModalOpen={ setIsDisconnectModalOpen }
+ disconnectURL={ connectUrl( 'wc-addons' ) }
+ />
+ ) }
+ </>
);
}
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/my-subscriptions.scss b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/my-subscriptions.scss
index 38d4f031fdd..190abbee563 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/my-subscriptions.scss
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/my-subscriptions.scss
@@ -15,11 +15,14 @@
background-color: $gray-0;
padding: $grid-unit-20 $grid-unit-30;
margin-bottom: $grid-unit-50;
+ position: relative;
&-header {
font-size: 16px;
line-height: 1.5;
padding-left: $grid-unit-40;
+ // Keep the heading clear of the dismiss button in the corner.
+ padding-inline-end: $grid-unit-50;
position: relative;
word-break: break-word;
font-weight: 500;
@@ -43,6 +46,14 @@
background-color: $white;
}
}
+
+ &-dismiss.components-button {
+ position: absolute;
+ inset-inline-end: $grid-unit-10;
+ top: $grid-unit-10;
+ // Rendered before the heading for tab order, so lift it above the heading's positioned box.
+ z-index: 1;
+ }
}
.woocommerce-marketplace__my-subscriptions__header {
@@ -260,6 +271,16 @@
}
}
+// The "disconnect your account" control in the connected account banner is a
+// link-styled button inside a sentence; drop the button box above so it flows
+// with the text.
+.woocommerce-marketplace__my-subscriptions .woocommerce-marketplace__my-subscriptions__account-content .components-button.is-link {
+ display: inline;
+ height: auto;
+ line-height: inherit;
+ padding: 0;
+}
+
.woocommerce-marketplace__my-subscriptions
.components-button.is-secondary:hover:not(:disabled) {
color: var(--wp-components-color-accent, var(--wp-admin-theme-color, #3858e9));
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/my-subscriptions.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/my-subscriptions.tsx
index d189b50b741..5d25a75c1a7 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/my-subscriptions.tsx
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/my-subscriptions.tsx
@@ -7,6 +7,7 @@ import {
createInterpolateElement,
useContext,
useEffect,
+ useRef,
} from '@wordpress/element';
import { Icon, external } from '@wordpress/icons';
import apiFetch from '@wordpress/api-fetch';
@@ -47,6 +48,7 @@ let pageLoadErrorReported = false;
export default function MySubscriptions(): React.JSX.Element {
const { subscriptions, isLoading } = useContext( SubscriptionsContext );
const wccomSettings = getAdminSetting( 'wccomHelper', {} );
+ const installedHeadingRef = useRef< HTMLHeadingElement >( null );
// Report the failure captured at page load as the notice a failed refresh
// would report, under the same id. The Refresh button reruns the very
@@ -209,11 +211,17 @@ export default function MySubscriptions(): React.JSX.Element {
<section className="woocommerce-marketplace__my-subscriptions__notices">
<Notices />
</section>
- <MySubscriptionsAccount />
+ <MySubscriptionsAccount
+ onDismiss={ () => installedHeadingRef.current?.focus() }
+ />
<section className="woocommerce-marketplace__my-subscriptions-section woocommerce-marketplace__my-subscriptions__installed">
<header className="woocommerce-marketplace__my-subscriptions__header">
<div className="woocommerce-marketplace__my-subscriptions__header-content">
- <h2 className="woocommerce-marketplace__my-subscriptions__heading">
+ <h2
+ className="woocommerce-marketplace__my-subscriptions__heading"
+ ref={ installedHeadingRef }
+ tabIndex={ -1 }
+ >
{ __(
'Installed on this store',
'woocommerce'
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/test/my-subscriptions-account.test.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/test/my-subscriptions-account.test.tsx
new file mode 100644
index 00000000000..5b35bf708e1
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/test/my-subscriptions-account.test.tsx
@@ -0,0 +1,136 @@
+/**
+ * External dependencies
+ */
+import { fireEvent, render, screen } from '@testing-library/react';
+import React from 'react';
+import apiFetch from '@wordpress/api-fetch';
+import { speak } from '@wordpress/a11y';
+
+let mockSettings: Record< string, unknown > = {};
+
+jest.mock( '~/utils/admin-settings', () => ( {
+ getAdminSetting: jest.fn( () => mockSettings ),
+} ) );
+
+jest.mock( '@wordpress/api-fetch', () =>
+ jest.fn( () => Promise.resolve( {} ) )
+);
+
+jest.mock( '@wordpress/a11y', () => ( {
+ speak: jest.fn(),
+} ) );
+
+jest.mock( '../../../utils/functions', () => ( {
+ connectUrl: jest.fn( () => 'https://example.com/disconnect' ),
+} ) );
+
+jest.mock( '../../header-account/header-account-modal', () => ( {
+ __esModule: true,
+ default: ( { disconnectURL }: { disconnectURL: string } ) => (
+ <div data-testid="disconnect-modal">{ disconnectURL }</div>
+ ),
+} ) );
+
+/**
+ * Internal dependencies
+ */
+import MySubscriptionsAccount from '../my-subscriptions-account';
+
+const apiFetchMock = apiFetch as unknown as jest.Mock;
+
+describe( 'MySubscriptionsAccount', () => {
+ beforeEach( () => {
+ mockSettings = {
+ isConnected: true,
+ userEmail: 'merchant@example.com',
+ dismissNoticeNonce: 'test-nonce',
+ show_connected_account_notice: true,
+ };
+ apiFetchMock.mockClear();
+ ( speak as jest.Mock ).mockClear();
+ } );
+
+ it( 'renders nothing when the store is not connected', () => {
+ mockSettings = { ...mockSettings, isConnected: false };
+ const { container } = render( <MySubscriptionsAccount /> );
+
+ expect( container ).toBeEmptyDOMElement();
+ } );
+
+ it( 'renders nothing once the notice has been dismissed server-side', () => {
+ mockSettings = {
+ ...mockSettings,
+ show_connected_account_notice: false,
+ };
+ const { container } = render( <MySubscriptionsAccount /> );
+
+ expect( container ).toBeEmptyDOMElement();
+ } );
+
+ it( 'shows the connected email with view, inline disconnect and dismiss actions', () => {
+ render( <MySubscriptionsAccount /> );
+
+ expect(
+ screen.getByRole( 'heading', {
+ name: 'Connected to merchant@example.com',
+ } )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole( 'link', { name: 'View account' } )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole( 'button', { name: 'disconnect your account' } )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole( 'button', { name: 'Dismiss this notice' } )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'puts the dismiss button first in the tab order', () => {
+ const { container } = render( <MySubscriptionsAccount /> );
+
+ const focusables = container.querySelectorAll( 'button, a' );
+
+ expect( focusables[ 0 ] ).toHaveAccessibleName( 'Dismiss this notice' );
+ } );
+
+ it( 'opens the disconnect confirmation from the inline link', () => {
+ render( <MySubscriptionsAccount /> );
+
+ expect( screen.queryByTestId( 'disconnect-modal' ) ).toBeNull();
+
+ fireEvent.click(
+ screen.getByRole( 'button', { name: 'disconnect your account' } )
+ );
+
+ expect( screen.getByTestId( 'disconnect-modal' ) ).toHaveTextContent(
+ 'https://example.com/disconnect'
+ );
+ } );
+
+ // Keep this test last: dismissal is remembered at module scope for the
+ // rest of the page load, so later renders in this file would be empty.
+ it( 'dismisses the notice, persists it, announces it and hands off focus', () => {
+ const onDismiss = jest.fn();
+ render( <MySubscriptionsAccount onDismiss={ onDismiss } /> );
+
+ fireEvent.click(
+ screen.getByRole( 'button', { name: 'Dismiss this notice' } )
+ );
+
+ expect( screen.queryByRole( 'heading' ) ).toBeNull();
+ expect( apiFetchMock ).toHaveBeenCalledWith( {
+ path: '/wc-admin/notice/dismiss',
+ method: 'POST',
+ data: {
+ notice_id: 'woo-connected-account-notice',
+ dismiss_notice_nonce: 'test-nonce',
+ },
+ } );
+ expect( speak ).toHaveBeenCalledWith(
+ 'Connected account notice dismissed.',
+ 'polite'
+ );
+ expect( onDismiss ).toHaveBeenCalledTimes( 1 );
+ } );
+} );
diff --git a/plugins/woocommerce/includes/admin/helper/class-wc-helper-admin.php b/plugins/woocommerce/includes/admin/helper/class-wc-helper-admin.php
index cd5a911b1c5..ea01f5ba833 100644
--- a/plugins/woocommerce/includes/admin/helper/class-wc-helper-admin.php
+++ b/plugins/woocommerce/includes/admin/helper/class-wc-helper-admin.php
@@ -108,6 +108,9 @@ class WC_Helper_Admin {
$settings['wccomHelper']['has_host_plan_orders'] = WC_Woo_Helper_Connection::has_host_plan_orders();
$settings['wccomHelper']['maybe_deleted_connection'] = WC_Woo_Helper_Connection::get_deleted_connection_notice();
+ // The "Connected to <email>" banner on My Subscriptions stays hidden once dismissed.
+ $settings['wccomHelper']['show_connected_account_notice'] = PluginsHelper::should_show_notice( PluginsHelper::DISMISS_CONNECTED_ACCOUNT_NOTICE, false );
+
// Read last: the notices above already trigger a subscriptions fetch,
// so by this point any failure from it has been recorded.
$api_error = WC_Helper::get_api_error();
diff --git a/plugins/woocommerce/includes/admin/helper/class-wc-helper.php b/plugins/woocommerce/includes/admin/helper/class-wc-helper.php
index c57f141e3fb..45e66bfb46e 100644
--- a/plugins/woocommerce/includes/admin/helper/class-wc-helper.php
+++ b/plugins/woocommerce/includes/admin/helper/class-wc-helper.php
@@ -1080,6 +1080,7 @@ class WC_Helper {
// Ignore all previously dismissed connected notices.
delete_metadata( 'user', 0, \Automattic\WooCommerce\Admin\PluginsHelper::DISMISS_CONNECT_NOTICE, '', true );
+ delete_metadata( 'user', 0, \Automattic\WooCommerce\Admin\PluginsHelper::DISMISS_CONNECTED_ACCOUNT_NOTICE, '', true );
$connect_url = add_query_arg(
array(
diff --git a/plugins/woocommerce/src/Admin/API/Notice.php b/plugins/woocommerce/src/Admin/API/Notice.php
index cf7b9d9bf2f..fcb83864b64 100644
--- a/plugins/woocommerce/src/Admin/API/Notice.php
+++ b/plugins/woocommerce/src/Admin/API/Notice.php
@@ -80,6 +80,10 @@ class Notice extends \WC_REST_Data_Controller {
update_user_meta( get_current_user_id(), PluginsHelper::DISMISS_CONNECT_NOTICE, time() );
$dismissed = true;
break;
+ case 'woo-connected-account-notice':
+ update_user_meta( get_current_user_id(), PluginsHelper::DISMISS_CONNECTED_ACCOUNT_NOTICE, time() );
+ $dismissed = true;
+ break;
}
return rest_ensure_response(
diff --git a/plugins/woocommerce/src/Admin/PluginsHelper.php b/plugins/woocommerce/src/Admin/PluginsHelper.php
index d28b3c5f18f..a33f6ce6ec9 100644
--- a/plugins/woocommerce/src/Admin/PluginsHelper.php
+++ b/plugins/woocommerce/src/Admin/PluginsHelper.php
@@ -81,6 +81,11 @@ class PluginsHelper {
*/
const DISMISS_CONNECT_NOTICE = 'woo_connect_notice_dismiss';
+ /**
+ * Meta key for dismissing the connected account notice on My Subscriptions
+ */
+ const DISMISS_CONNECTED_ACCOUNT_NOTICE = 'woo_connected_account_notice_dismiss';
+
/**
* Initialize hooks.
*/