Commit b24ba1d1836 for woocommerce
commit b24ba1d1836f49bede90b152acc71abf59bd9b5c
Author: Jason Kytros <jason.kytros@automattic.com>
Date: Wed Jul 1 13:39:38 2026 +0300
Fix tax recommendations card not staying dismissed in non-production environments (#66041)
* Fix tax recommendations card not staying dismissed in non-production environments
* Updated settings getter
* Address co-pilot review comments
* Added typecheck gate
* Fixed class initialization
* Updated comment
* Refactor DismissableList into pure UI component with dedicated dismiss hooks
* Fix useOptionDismiss never triggering the option resolver
* Register REST routes via rest_api_init in TaxSettingsRecommendations test
* handle a11y on dismiss
---------
Co-authored-by: Chris Lilitsas <1105590+xristos3490@users.noreply.github.com>
diff --git a/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/abandoned-cart-recovery-recommendations.tsx b/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/abandoned-cart-recovery-recommendations.tsx
index bece6d8387f..53a0d45514e 100644
--- a/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/abandoned-cart-recovery-recommendations.tsx
+++ b/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/abandoned-cart-recovery-recommendations.tsx
@@ -17,6 +17,7 @@ import {
DismissableListHeading,
} from '../settings-recommendations/dismissable-list';
import { TrackedLink } from '~/components/tracked-link/tracked-link';
+import { useOptionDismiss } from '~/hooks/use-option-dismiss';
import { createNoticesFromResponse } from '../lib/notices';
import AutomateWooItem from './automatewoo-item';
import MailPoetItem from './mailpoet-item';
@@ -63,49 +64,55 @@ export const AbandonedCartRecoveryRecommendationsList = ( {
children,
}: {
children: React.ReactNode;
-} ) => (
- <DismissableList
- className="woocommerce-recommended-abandoned-cart-recovery-extensions"
- dismissOptionName="woocommerce_abandoned_cart_recovery_recommendations_hidden"
- >
- <DismissableListHeading>
- <Text variant="title.small" as="p" size="20" lineHeight="28px">
- { __( 'Recover more abandoned carts', 'woocommerce' ) }
- </Text>
- <Text
- className="woocommerce-recommended-abandoned-cart-recovery__header-heading"
- variant="caption"
- as="p"
- size="12"
- lineHeight="16px"
- >
- { __(
- 'Add multi-step recovery flows, customer segmentation, and ongoing email marketing to win back more shoppers.',
- 'woocommerce'
- ) }
- </Text>
- </DismissableListHeading>
- <ul className="woocommerce-list">
- { Children.map( children, ( item ) => (
- <li className="woocommerce-list__item">{ item }</li>
- ) ) }
- </ul>
- <CardFooter>
- <TrackedLink
- message={ __(
- // translators: {{Link}} is a placeholder for a html element.
- 'Visit {{Link}}the WooCommerce Marketplace{{/Link}} to find more email marketing and customer engagement solutions.',
- 'woocommerce'
- ) }
- targetUrl={ getAdminLink(
- 'admin.php?page=wc-admin&tab=extensions&path=/extensions&category=marketing'
- ) }
- linkType="wc-admin"
- eventName="abandoned_cart_recovery_visit_marketplace_click"
- />
- </CardFooter>
- </DismissableList>
-);
+} ) => {
+ const { isDismissed, onDismiss } = useOptionDismiss(
+ 'woocommerce_abandoned_cart_recovery_recommendations_hidden'
+ );
+
+ return (
+ <DismissableList
+ className="woocommerce-recommended-abandoned-cart-recovery-extensions"
+ isDismissed={ isDismissed }
+ >
+ <DismissableListHeading onDismiss={ onDismiss }>
+ <Text variant="title.small" as="p" size="20" lineHeight="28px">
+ { __( 'Recover more abandoned carts', 'woocommerce' ) }
+ </Text>
+ <Text
+ className="woocommerce-recommended-abandoned-cart-recovery__header-heading"
+ variant="caption"
+ as="p"
+ size="12"
+ lineHeight="16px"
+ >
+ { __(
+ 'Add multi-step recovery flows, customer segmentation, and ongoing email marketing to win back more shoppers.',
+ 'woocommerce'
+ ) }
+ </Text>
+ </DismissableListHeading>
+ <ul className="woocommerce-list">
+ { Children.map( children, ( item ) => (
+ <li className="woocommerce-list__item">{ item }</li>
+ ) ) }
+ </ul>
+ <CardFooter>
+ <TrackedLink
+ message={ __(
+ // translators: {{Link}} is a placeholder for a html element.
+ 'Visit {{Link}}the WooCommerce Marketplace{{/Link}} to find more email marketing and customer engagement solutions.',
+ 'woocommerce'
+ ) }
+ targetUrl={ getAdminLink(
+ 'admin.php?page=wc-admin&tab=extensions&path=/extensions&category=marketing'
+ ) }
+ linkType="wc-admin"
+ eventName="abandoned_cart_recovery_visit_marketplace_click"
+ />
+ </CardFooter>
+ </DismissableList>
+ );
+};
const AbandonedCartRecoveryRecommendations = () => {
const activePlugins = useSelect(
diff --git a/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/test/abandoned-cart-recovery-recommendations.test.tsx b/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/test/abandoned-cart-recovery-recommendations.test.tsx
index 2eff5378c46..57d55aa3a51 100644
--- a/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/test/abandoned-cart-recovery-recommendations.test.tsx
+++ b/plugins/woocommerce/client/admin/client/abandoned-cart-recovery/test/abandoned-cart-recovery-recommendations.test.tsx
@@ -22,6 +22,10 @@ jest.mock( '../../settings-recommendations/dismissable-list', () => ( {
children,
} ) );
+jest.mock( '~/hooks/use-option-dismiss', () => ( {
+ useOptionDismiss: () => ( { isDismissed: false, onDismiss: jest.fn() } ),
+} ) );
+
const mockActivePlugins = ( plugins: string[] ) => {
( useSelect as jest.Mock ).mockImplementation( ( fn ) =>
fn( () => ( {
diff --git a/plugins/woocommerce/client/admin/client/hooks/test/use-endpoint-dismiss.test.ts b/plugins/woocommerce/client/admin/client/hooks/test/use-endpoint-dismiss.test.ts
new file mode 100644
index 00000000000..a932f9c0199
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/hooks/test/use-endpoint-dismiss.test.ts
@@ -0,0 +1,67 @@
+/**
+ * External dependencies
+ */
+import { renderHook, act } from '@testing-library/react-hooks';
+import apiFetch from '@wordpress/api-fetch';
+
+/**
+ * Internal dependencies
+ */
+import { useEndpointDismiss } from '../use-endpoint-dismiss';
+import { createNoticesFromResponse } from '../../lib/notices';
+
+jest.mock( '@wordpress/api-fetch' );
+jest.mock( '../../lib/notices', () => ( {
+ createNoticesFromResponse: jest.fn(),
+} ) );
+
+const PATH = '/wc-admin/tax/recommendations/dismiss';
+
+const mockApiFetch = apiFetch as unknown as jest.Mock;
+
+describe( 'useEndpointDismiss', () => {
+ beforeEach( () => {
+ jest.clearAllMocks();
+ } );
+
+ it( 'honours the initial dismissal state', () => {
+ const { result } = renderHook( () => useEndpointDismiss( PATH, true ) );
+
+ expect( result.current.isDismissed ).toBe( true );
+ } );
+
+ it( 'optimistically dismisses and POSTs to the endpoint', async () => {
+ mockApiFetch.mockResolvedValueOnce( { dismissed: true } );
+
+ const { result } = renderHook( () =>
+ useEndpointDismiss( PATH, false )
+ );
+
+ await act( async () => {
+ result.current.onDismiss();
+ } );
+
+ expect( result.current.isDismissed ).toBe( true );
+ expect( mockApiFetch ).toHaveBeenCalledWith( {
+ path: PATH,
+ method: 'POST',
+ } );
+ expect( createNoticesFromResponse ).not.toHaveBeenCalled();
+ } );
+
+ it( 'rolls back and surfaces a notice when the request fails', async () => {
+ const error = { code: 'fail', message: 'Nope' };
+ mockApiFetch.mockRejectedValueOnce( error );
+
+ const { result } = renderHook( () =>
+ useEndpointDismiss( PATH, false )
+ );
+
+ await act( async () => {
+ result.current.onDismiss();
+ } );
+
+ expect( result.current.isDismissed ).toBe( false );
+ expect( createNoticesFromResponse ).toHaveBeenCalledWith( error );
+ } );
+} );
diff --git a/plugins/woocommerce/client/admin/client/hooks/test/use-option-dismiss.test.ts b/plugins/woocommerce/client/admin/client/hooks/test/use-option-dismiss.test.ts
new file mode 100644
index 00000000000..d1a1062c239
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/hooks/test/use-option-dismiss.test.ts
@@ -0,0 +1,80 @@
+/**
+ * External dependencies
+ */
+import { renderHook, act } from '@testing-library/react-hooks';
+import { useDispatch, useSelect } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import { useOptionDismiss } from '../use-option-dismiss';
+
+jest.mock( '@wordpress/data', () => ( {
+ ...jest.requireActual( '@wordpress/data' ),
+ useSelect: jest.fn(),
+ useDispatch: jest.fn(),
+} ) );
+
+const OPTION_NAME = 'woocommerce_test_recommendations_hidden';
+
+const mockSelect = ( {
+ option,
+ hasResolved,
+}: {
+ option: string | boolean;
+ hasResolved: boolean;
+} ) => {
+ ( useSelect as jest.Mock ).mockImplementation( ( fn ) =>
+ fn( () => ( {
+ getOption: () => option,
+ hasFinishedResolution: () => hasResolved,
+ } ) )
+ );
+};
+
+describe( 'useOptionDismiss', () => {
+ let updateOptions: jest.Mock;
+
+ beforeEach( () => {
+ updateOptions = jest.fn();
+ ( useDispatch as jest.Mock ).mockReturnValue( { updateOptions } );
+ } );
+
+ it( 'treats an unresolved option as dismissed to avoid flashing the card', () => {
+ mockSelect( { option: false, hasResolved: false } );
+
+ const { result } = renderHook( () => useOptionDismiss( OPTION_NAME ) );
+
+ expect( result.current.isDismissed ).toBe( true );
+ } );
+
+ it( 'is dismissed when the resolved option is "yes"', () => {
+ mockSelect( { option: 'yes', hasResolved: true } );
+
+ const { result } = renderHook( () => useOptionDismiss( OPTION_NAME ) );
+
+ expect( result.current.isDismissed ).toBe( true );
+ } );
+
+ it( 'is not dismissed when the resolved option is not "yes"', () => {
+ mockSelect( { option: false, hasResolved: true } );
+
+ const { result } = renderHook( () => useOptionDismiss( OPTION_NAME ) );
+
+ expect( result.current.isDismissed ).toBe( false );
+ } );
+
+ it( 'persists the dismissal through updateOptions', () => {
+ mockSelect( { option: false, hasResolved: true } );
+
+ const { result } = renderHook( () => useOptionDismiss( OPTION_NAME ) );
+
+ act( () => {
+ result.current.onDismiss();
+ } );
+
+ expect( updateOptions ).toHaveBeenCalledWith( {
+ [ OPTION_NAME ]: 'yes',
+ } );
+ } );
+} );
diff --git a/plugins/woocommerce/client/admin/client/hooks/use-endpoint-dismiss.ts b/plugins/woocommerce/client/admin/client/hooks/use-endpoint-dismiss.ts
new file mode 100644
index 00000000000..54d6b5c6cd4
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/hooks/use-endpoint-dismiss.ts
@@ -0,0 +1,47 @@
+/**
+ * External dependencies
+ */
+import apiFetch from '@wordpress/api-fetch';
+import { useState } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import { createNoticesFromResponse } from '../lib/notices';
+import type { DismissState } from './use-option-dismiss';
+
+/**
+ * Dismissal state persisted through a dedicated REST endpoint.
+ *
+ * Used when the dismissal cannot ride the (frozen) options REST API and instead
+ * has a purpose-built endpoint. The initial state is supplied by the caller
+ * (typically preloaded into wc-admin settings) so the card renders the correct
+ * visibility without an extra request.
+ *
+ * The dismissal is optimistic: the card hides immediately, then the endpoint is
+ * called. If the request fails the card is restored and an error notice is
+ * surfaced so the failure is not silent.
+ *
+ * @param path The REST path to POST to in order to persist the dismissal.
+ * @param initial The initial dismissal state (e.g. from preloaded settings).
+ * @return The current dismissal state and a callback to dismiss.
+ */
+export const useEndpointDismiss = (
+ path: string,
+ initial: boolean
+): DismissState => {
+ const [ isDismissed, setIsDismissed ] = useState< boolean >( initial );
+
+ const onDismiss = () => {
+ // Optimistically hide the card, then persist the dismissal site-wide.
+ setIsDismissed( true );
+ apiFetch( { path, method: 'POST' } ).catch( ( response ) => {
+ // Restore the card and surface the failure so the state stays
+ // accurate and the merchant is not left without feedback.
+ setIsDismissed( false );
+ createNoticesFromResponse( response );
+ } );
+ };
+
+ return { isDismissed, onDismiss };
+};
diff --git a/plugins/woocommerce/client/admin/client/hooks/use-option-dismiss.ts b/plugins/woocommerce/client/admin/client/hooks/use-option-dismiss.ts
new file mode 100644
index 00000000000..8880370d859
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/hooks/use-option-dismiss.ts
@@ -0,0 +1,52 @@
+/**
+ * External dependencies
+ */
+import { useDispatch, useSelect } from '@wordpress/data';
+import { optionsStore } from '@woocommerce/data';
+
+export type DismissState = {
+ isDismissed: boolean;
+ onDismiss: () => void;
+};
+
+/**
+ * Dismissal state backed by a WordPress option (via the wc-admin options store).
+ *
+ * The option is treated as a "yes"/"no" flag: a value of `'yes'` means the
+ * related card has been dismissed. While the option is still resolving the card
+ * is treated as dismissed so it does not flash before we know its real state.
+ *
+ * @param optionName The option name used to persist the dismissal.
+ * @return The current dismissal state and a callback to dismiss.
+ */
+export const useOptionDismiss = ( optionName: string ): DismissState => {
+ const isDismissed = useSelect(
+ ( select ) => {
+ const { getOption, hasFinishedResolution } = select( optionsStore );
+
+ // Read the option first so the resolver is always triggered. Calling
+ // `getOption` is what kicks off the fetch; gating it behind the
+ // resolution check (e.g. via `||` short-circuit) would mean it is
+ // never called while unresolved, so resolution would never start and
+ // the card would stay hidden forever.
+ const value = getOption( optionName );
+
+ const hasResolved = hasFinishedResolution( 'getOption', [
+ optionName,
+ ] );
+
+ // Treat "not yet resolved" as dismissed so the card does not flash
+ // before the option value is known.
+ return ! hasResolved || value === 'yes';
+ },
+ [ optionName ]
+ );
+
+ const { updateOptions } = useDispatch( optionsStore );
+
+ const onDismiss = () => {
+ updateOptions( { [ optionName ]: 'yes' } );
+ };
+
+ return { isDismissed, onDismiss };
+};
diff --git a/plugins/woocommerce/client/admin/client/settings-recommendations/dismissable-list.tsx b/plugins/woocommerce/client/admin/client/settings-recommendations/dismissable-list.tsx
index a0c98c2967c..b883d1e5b6f 100644
--- a/plugins/woocommerce/client/admin/client/settings-recommendations/dismissable-list.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-recommendations/dismissable-list.tsx
@@ -1,12 +1,11 @@
/**
* External dependencies
*/
-import { useDispatch, useSelect } from '@wordpress/data';
import { Button, Card, CardHeader } from '@wordpress/components';
-import { optionsStore } from '@woocommerce/data';
+import { useEffect, useRef } from '@wordpress/element';
+import { speak } from '@wordpress/a11y';
import { EllipsisMenu } from '@woocommerce/components';
import { __ } from '@wordpress/i18n';
-import { createContext, useContext } from '@wordpress/element';
import clsx from 'clsx';
/**
@@ -14,9 +13,14 @@ import clsx from 'clsx';
*/
import './dismissable-list.scss';
-// using a context provider for the option name so that the option name prop doesn't need to be passed to the `DismissableListHeading` too
-const OptionNameContext = createContext( '' );
-
+/**
+ * Presentational heading for a {@link DismissableList}. Renders the card header
+ * and an ellipsis menu with a "Hide this" action that invokes `onDismiss`.
+ *
+ * Persistence is intentionally not handled here — the parent owns the dismissal
+ * state and supplies `onDismiss` (typically from a dismiss hook such as
+ * `useOptionDismiss` or `useEndpointDismiss`).
+ */
export const DismissableListHeading = ( {
onDismiss = () => null,
children,
@@ -24,16 +28,6 @@ export const DismissableListHeading = ( {
children: React.ReactNode;
onDismiss?: () => void;
} ) => {
- const { updateOptions } = useDispatch( optionsStore );
- const dismissOptionName = useContext( OptionNameContext );
-
- const handleDismissClick = () => {
- onDismiss();
- updateOptions( {
- [ dismissOptionName ]: 'yes',
- } );
- };
-
return (
<CardHeader>
<div className="woocommerce-dismissable-list__header">
@@ -44,7 +38,7 @@ export const DismissableListHeading = ( {
label={ __( 'Task List Options', 'woocommerce' ) }
renderContent={ () => (
<div className="woocommerce-dismissable-list__controls">
- <Button onClick={ handleDismissClick }>
+ <Button onClick={ onDismiss }>
{ __( 'Hide this', 'woocommerce' ) }
</Button>
</div>
@@ -55,42 +49,65 @@ export const DismissableListHeading = ( {
);
};
+/**
+ * Pure UI wrapper for a dismissable recommendation card. Hides the `Card` when
+ * `isDismissed` is true, otherwise wraps `children` in a `Card`.
+ *
+ * This component holds no persistence logic. Callers manage the dismissal state
+ * with a hook and pass the resulting `isDismissed` here and `onDismiss` to the
+ * nested {@link DismissableListHeading}.
+ *
+ * Dismissing the card unmounts whatever element held focus (the ellipsis menu
+ * button or its popover), which would otherwise drop keyboard and screen reader
+ * users onto `document.body` with no announcement. To keep them oriented, the
+ * surrounding wrapper stays mounted as a focus target: on dismissal we move
+ * focus to it and announce the change with `speak()`.
+ */
export const DismissableList = ( {
children,
className,
- dismissOptionName,
+ isDismissed,
}: {
children: React.ReactNode;
className?: string;
- dismissOptionName: string;
+ /**
+ * Whether the card has been dismissed. When true the card is hidden.
+ */
+ isDismissed?: boolean;
} ) => {
- const isVisible = useSelect(
- ( select ) => {
- const { getOption, hasFinishedResolution } = select( optionsStore );
-
- const hasFinishedResolving = hasFinishedResolution( 'getOption', [
- dismissOptionName,
- ] );
-
- const isDismissed = getOption( dismissOptionName ) === 'yes';
+ const wrapperRef = useRef< HTMLDivElement >( null );
+ // Seed with the initial value so an already-dismissed card on first render
+ // is treated as steady state, not a fresh dismissal.
+ const wasDismissed = useRef( isDismissed );
- return hasFinishedResolving && ! isDismissed;
- },
- [ dismissOptionName ]
- );
+ useEffect( () => {
+ if ( isDismissed && ! wasDismissed.current ) {
+ speak( __( 'Recommendation hidden.', 'woocommerce' ), 'assertive' );
+ wrapperRef.current?.focus();
+ }
- if ( ! isVisible ) {
- return null;
- }
+ wasDismissed.current = isDismissed;
+ }, [ isDismissed ] );
return (
- <Card
- size="medium"
- className={ clsx( 'woocommerce-dismissable-list', className ) }
+ <div
+ ref={ wrapperRef }
+ // Programmatically focusable (not in the tab order) so focus can
+ // land here once the card unmounts on dismissal.
+ tabIndex={ -1 }
+ className="woocommerce-dismissable-list__wrapper"
>
- <OptionNameContext.Provider value={ dismissOptionName }>
- { children }
- </OptionNameContext.Provider>
- </Card>
+ { ! isDismissed && (
+ <Card
+ size="medium"
+ className={ clsx(
+ 'woocommerce-dismissable-list',
+ className
+ ) }
+ >
+ { children }
+ </Card>
+ ) }
+ </div>
);
};
diff --git a/plugins/woocommerce/client/admin/client/settings-recommendations/test/dismissable-list.test.js b/plugins/woocommerce/client/admin/client/settings-recommendations/test/dismissable-list.test.js
index c3611c278ca..36562d287a7 100644
--- a/plugins/woocommerce/client/admin/client/settings-recommendations/test/dismissable-list.test.js
+++ b/plugins/woocommerce/client/admin/client/settings-recommendations/test/dismissable-list.test.js
@@ -2,98 +2,133 @@
* External dependencies
*/
import { render, screen } from '@testing-library/react';
-import { useDispatch, useSelect } from '@wordpress/data';
import userEvent from '@testing-library/user-event';
+import { speak } from '@wordpress/a11y';
/**
* Internal dependencies
*/
import { DismissableList, DismissableListHeading } from '../dismissable-list';
-jest.mock( '@wordpress/data', () => ( {
- ...jest.requireActual( '@wordpress/data' ),
- useSelect: jest.fn(),
- useDispatch: jest.fn(),
+jest.mock( '@wordpress/a11y', () => ( {
+ speak: jest.fn(),
} ) );
-const DismissableListMock = ( { children } ) => (
- <DismissableList dismissOptionName="dismissable_option_mock">
- { children }
- <span>dismissible children</span>
- </DismissableList>
-);
-
describe( 'DismissableList', () => {
- beforeEach( () => {
- useSelect.mockImplementation( ( fn ) =>
- fn( () => ( {
- getOption: () => false,
- hasFinishedResolution: () => true,
- } ) )
+ it( 'renders its children when isDismissed is false', () => {
+ render(
+ <DismissableList isDismissed={ false }>
+ <span>dismissible children</span>
+ </DismissableList>
);
- useDispatch.mockReturnValue( { updateOptions: () => null } );
+
+ expect(
+ screen.queryByText( 'dismissible children' )
+ ).toBeInTheDocument();
} );
- it( 'should not render its children when the option is not resolved', () => {
- useSelect.mockImplementation( ( fn ) =>
- fn( () => ( {
- getOption: () => false,
- hasFinishedResolution: () => false,
- } ) )
+ it( 'renders its children when isDismissed is omitted', () => {
+ render(
+ <DismissableList>
+ <span>dismissible children</span>
+ </DismissableList>
);
- render( <DismissableListMock /> );
expect(
screen.queryByText( 'dismissible children' )
- ).not.toBeInTheDocument();
+ ).toBeInTheDocument();
} );
- it( 'should not render its children when the option is dismissed', () => {
- useSelect.mockImplementation( ( fn ) =>
- fn( () => ( {
- getOption: () => 'yes',
- hasFinishedResolution: () => true,
- } ) )
+ it( 'renders nothing when isDismissed is true', () => {
+ render(
+ <DismissableList isDismissed={ true }>
+ <span>dismissible children</span>
+ </DismissableList>
);
- render( <DismissableListMock /> );
expect(
screen.queryByText( 'dismissible children' )
).not.toBeInTheDocument();
} );
- it( 'render its children', () => {
- render( <DismissableListMock /> );
+ describe( 'dismissal accessibility', () => {
+ beforeEach( () => {
+ speak.mockClear();
+ } );
- expect(
- screen.queryByText( 'dismissible children' )
- ).toBeInTheDocument();
+ it( 'announces and moves focus to a stable element when dismissed', () => {
+ const { container, rerender } = render(
+ <DismissableList isDismissed={ false }>
+ <span>dismissible children</span>
+ </DismissableList>
+ );
+
+ const wrapper = container.querySelector(
+ '.woocommerce-dismissable-list__wrapper'
+ );
+
+ rerender(
+ <DismissableList isDismissed={ true }>
+ <span>dismissible children</span>
+ </DismissableList>
+ );
+
+ expect( speak ).toHaveBeenCalledWith(
+ 'Recommendation hidden.',
+ 'assertive'
+ );
+ // The wrapper stays mounted so focus has somewhere to land.
+ expect( wrapper ).toBeInTheDocument();
+ expect( wrapper ).toHaveFocus();
+ } );
+
+ it( 'does not announce or steal focus when dismissed on first render', () => {
+ render(
+ <DismissableList isDismissed={ true }>
+ <span>dismissible children</span>
+ </DismissableList>
+ );
+
+ expect( speak ).not.toHaveBeenCalled();
+ expect( document.body ).toHaveFocus();
+ } );
} );
+} );
- it( 'should allow dismissing the option through the DismissableListHeading component', () => {
- const handleDismissMock = jest.fn();
- const updateOptionsMock = jest.fn();
- useDispatch.mockReturnValue( { updateOptions: updateOptionsMock } );
+describe( 'DismissableListHeading', () => {
+ it( 'renders its children', () => {
render(
- <DismissableListMock>
- <DismissableListHeading onDismiss={ handleDismissMock }>
- heading content mock
- </DismissableListHeading>
- </DismissableListMock>
+ <DismissableListHeading>
+ <span>heading content</span>
+ </DismissableListHeading>
);
- expect(
- screen.queryByText( 'heading content mock' )
- ).toBeInTheDocument();
+ expect( screen.queryByText( 'heading content' ) ).toBeInTheDocument();
+ } );
+
+ it( 'calls onDismiss when "Hide this" is clicked', () => {
+ const onDismiss = jest.fn();
+ render(
+ <DismissableListHeading onDismiss={ onDismiss }>
+ heading content
+ </DismissableListHeading>
+ );
userEvent.click( screen.getByTitle( 'Task List Options' ) );
userEvent.click( screen.getByText( 'Hide this' ) );
- expect( handleDismissMock ).toHaveBeenCalled();
- expect( updateOptionsMock ).toHaveBeenCalledWith(
- expect.objectContaining( {
- dismissable_option_mock: 'yes',
- } )
+ expect( onDismiss ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ it( 'does not throw when "Hide this" is clicked without an onDismiss prop', () => {
+ render(
+ <DismissableListHeading>heading content</DismissableListHeading>
);
+
+ userEvent.click( screen.getByTitle( 'Task List Options' ) );
+
+ expect( () =>
+ userEvent.click( screen.getByText( 'Hide this' ) )
+ ).not.toThrow();
} );
} );
diff --git a/plugins/woocommerce/client/admin/client/shipping/shipping-recommendations.tsx b/plugins/woocommerce/client/admin/client/shipping/shipping-recommendations.tsx
index ae4d002e05d..9900ada1d6b 100644
--- a/plugins/woocommerce/client/admin/client/shipping/shipping-recommendations.tsx
+++ b/plugins/woocommerce/client/admin/client/shipping/shipping-recommendations.tsx
@@ -20,6 +20,7 @@ import {
import WoocommerceShippingItem from './woocommerce-shipping-item';
import './shipping-recommendations.scss';
import { TrackedLink } from '~/components/tracked-link/tracked-link';
+import { useOptionDismiss } from '~/hooks/use-option-dismiss';
export const useInstallPlugin = () => {
const [ pluginsBeingSetup, setPluginsBeingSetup ] = useState<
@@ -98,49 +99,55 @@ export const ShippingRecommendationsList = ( {
children,
}: {
children: React.ReactNode;
-} ) => (
- <DismissableList
- className="woocommerce-recommended-shipping-extensions"
- dismissOptionName="woocommerce_settings_shipping_recommendations_hidden"
- >
- <DismissableListHeading>
- <Text variant="title.small" as="p" size="20" lineHeight="28px">
- { __( 'Recommended shipping solutions', 'woocommerce' ) }
- </Text>
- <Text
- className="woocommerce-recommended-shipping__header-heading"
- variant="caption"
- as="p"
- size="12"
- lineHeight="16px"
- >
- { __(
- 'We recommend adding one of the following shipping extensions to your store.',
- 'woocommerce'
- ) }
- </Text>
- </DismissableListHeading>
- <ul className="woocommerce-list">
- { Children.map( children, ( item ) => (
- <li className="woocommerce-list__item">{ item }</li>
- ) ) }
- </ul>
- <CardFooter>
- <TrackedLink
- message={ __(
- // translators: {{Link}} is a placeholder for a html element.
- 'Visit {{Link}}the WooCommerce Marketplace{{/Link}} to find more shipping, delivery, and fulfillment solutions.',
- 'woocommerce'
- ) }
- targetUrl={ getAdminLink(
- 'admin.php?page=wc-admin&tab=extensions&path=/extensions&category=shipping-delivery-and-fulfillment'
- ) }
- linkType="wc-admin"
- eventName="settings_shipping_recommendation_visit_marketplace_click"
- />
- </CardFooter>
- </DismissableList>
-);
+} ) => {
+ const { isDismissed, onDismiss } = useOptionDismiss(
+ 'woocommerce_settings_shipping_recommendations_hidden'
+ );
+
+ return (
+ <DismissableList
+ className="woocommerce-recommended-shipping-extensions"
+ isDismissed={ isDismissed }
+ >
+ <DismissableListHeading onDismiss={ onDismiss }>
+ <Text variant="title.small" as="p" size="20" lineHeight="28px">
+ { __( 'Recommended shipping solutions', 'woocommerce' ) }
+ </Text>
+ <Text
+ className="woocommerce-recommended-shipping__header-heading"
+ variant="caption"
+ as="p"
+ size="12"
+ lineHeight="16px"
+ >
+ { __(
+ 'We recommend adding one of the following shipping extensions to your store.',
+ 'woocommerce'
+ ) }
+ </Text>
+ </DismissableListHeading>
+ <ul className="woocommerce-list">
+ { Children.map( children, ( item ) => (
+ <li className="woocommerce-list__item">{ item }</li>
+ ) ) }
+ </ul>
+ <CardFooter>
+ <TrackedLink
+ message={ __(
+ // translators: {{Link}} is a placeholder for a html element.
+ 'Visit {{Link}}the WooCommerce Marketplace{{/Link}} to find more shipping, delivery, and fulfillment solutions.',
+ 'woocommerce'
+ ) }
+ targetUrl={ getAdminLink(
+ 'admin.php?page=wc-admin&tab=extensions&path=/extensions&category=shipping-delivery-and-fulfillment'
+ ) }
+ linkType="wc-admin"
+ eventName="settings_shipping_recommendation_visit_marketplace_click"
+ />
+ </CardFooter>
+ </DismissableList>
+ );
+};
const ShippingRecommendations = () => {
const [ pluginsBeingSetup, setupPlugin ] = useInstallPlugin();
diff --git a/plugins/woocommerce/client/admin/client/shipping/test/shipping-recommendations.test.js b/plugins/woocommerce/client/admin/client/shipping/test/shipping-recommendations.test.js
index 8b557c61da8..b7ec962e7f7 100644
--- a/plugins/woocommerce/client/admin/client/shipping/test/shipping-recommendations.test.js
+++ b/plugins/woocommerce/client/admin/client/shipping/test/shipping-recommendations.test.js
@@ -22,6 +22,9 @@ jest.mock( '../../settings-recommendations/dismissable-list', () => ( {
jest.mock( '../../lib/notices', () => ( {
createNoticesFromResponse: () => null,
} ) );
+jest.mock( '~/hooks/use-option-dismiss', () => ( {
+ useOptionDismiss: () => ( { isDismissed: false, onDismiss: jest.fn() } ),
+} ) );
describe( 'ShippingRecommendations', () => {
beforeEach( () => {
diff --git a/plugins/woocommerce/client/admin/client/tax/tax-recommendations.tsx b/plugins/woocommerce/client/admin/client/tax/tax-recommendations.tsx
index ec01f0e180d..6acb5830f68 100644
--- a/plugins/woocommerce/client/admin/client/tax/tax-recommendations.tsx
+++ b/plugins/woocommerce/client/admin/client/tax/tax-recommendations.tsx
@@ -22,6 +22,8 @@ import {
import { supportsWooCommerceTax } from '../task-lists/fills/tax/utils';
import { TrackedLink } from '~/components/tracked-link/tracked-link';
import { getCountryCode } from '~/dashboard/utils';
+import { getAdminSetting } from '~/utils/admin-settings';
+import { useEndpointDismiss } from '~/hooks/use-endpoint-dismiss';
import './tax-recommendations.scss';
const ANROK_LOGO_URL = 'https://ps.w.org/anrok-tax/assets/icon.svg';
@@ -207,49 +209,56 @@ const TaxRecommendationsList = ( {
children,
}: {
children: React.ReactNode;
-} ) => (
- <DismissableList
- className="woocommerce-recommended-tax-extensions"
- dismissOptionName="woocommerce_settings_tax_recommendations_hidden"
- >
- <DismissableListHeading>
- <Text variant="title.small" as="p" size="20" lineHeight="28px">
- { __( 'Recommended tax solutions', 'woocommerce' ) }
- </Text>
- <Text
- className="woocommerce-recommended-tax__header-heading"
- variant="caption"
- as="p"
- size="12"
- lineHeight="16px"
- >
- { __(
- 'Explore tax extensions that can help automate calculations and compliance for your store.',
- 'woocommerce'
- ) }
- </Text>
- </DismissableListHeading>
- <ul className="woocommerce-list">
- { Children.map( children, ( item ) => (
- <li className="woocommerce-list__item">{ item }</li>
- ) ) }
- </ul>
- <CardFooter>
- <TrackedLink
- message={ __(
- // translators: {{Link}} is a placeholder for a html element.
- 'Visit {{Link}}the WooCommerce Marketplace{{/Link}} to find more tax solutions.',
- 'woocommerce'
- ) }
- targetUrl={ getAdminLink(
- 'admin.php?page=wc-admin&tab=extensions&path=/extensions&category=operations'
- ) }
- linkType="wc-admin"
- eventName="settings_tax_recommendation_visit_marketplace_click"
- />
- </CardFooter>
- </DismissableList>
-);
+} ) => {
+ const { isDismissed, onDismiss } = useEndpointDismiss(
+ '/wc-admin/tax/recommendations/dismiss',
+ getAdminSetting( 'taxRecommendationsHidden', false )
+ );
+
+ return (
+ <DismissableList
+ className="woocommerce-recommended-tax-extensions"
+ isDismissed={ isDismissed }
+ >
+ <DismissableListHeading onDismiss={ onDismiss }>
+ <Text variant="title.small" as="p" size="20" lineHeight="28px">
+ { __( 'Recommended tax solutions', 'woocommerce' ) }
+ </Text>
+ <Text
+ className="woocommerce-recommended-tax__header-heading"
+ variant="caption"
+ as="p"
+ size="12"
+ lineHeight="16px"
+ >
+ { __(
+ 'Explore tax extensions that can help automate calculations and compliance for your store.',
+ 'woocommerce'
+ ) }
+ </Text>
+ </DismissableListHeading>
+ <ul className="woocommerce-list">
+ { Children.map( children, ( item ) => (
+ <li className="woocommerce-list__item">{ item }</li>
+ ) ) }
+ </ul>
+ <CardFooter>
+ <TrackedLink
+ message={ __(
+ // translators: {{Link}} is a placeholder for a html element.
+ 'Visit {{Link}}the WooCommerce Marketplace{{/Link}} to find more tax solutions.',
+ 'woocommerce'
+ ) }
+ targetUrl={ getAdminLink(
+ 'admin.php?page=wc-admin&tab=extensions&path=/extensions&category=operations'
+ ) }
+ linkType="wc-admin"
+ eventName="settings_tax_recommendation_visit_marketplace_click"
+ />
+ </CardFooter>
+ </DismissableList>
+ );
+};
const getPluginSlugForAction = (
pluginSlugs: string[],
diff --git a/plugins/woocommerce/client/admin/client/tax/test/tax-recommendations.test.tsx b/plugins/woocommerce/client/admin/client/tax/test/tax-recommendations.test.tsx
index 503940ec45c..ec43838834e 100644
--- a/plugins/woocommerce/client/admin/client/tax/test/tax-recommendations.test.tsx
+++ b/plugins/woocommerce/client/admin/client/tax/test/tax-recommendations.test.tsx
@@ -34,6 +34,10 @@ jest.mock( '../../lib/notices', () => ( {
createNoticesFromResponse: () => null,
} ) );
+jest.mock( '~/hooks/use-endpoint-dismiss', () => ( {
+ useEndpointDismiss: () => ( { isDismissed: false, onDismiss: jest.fn() } ),
+} ) );
+
const clickAndFlush = async ( element: Element ) => {
await act( async () => {
await userEvent.click( element );
diff --git a/plugins/woocommerce/src/Internal/Admin/Loader.php b/plugins/woocommerce/src/Internal/Admin/Loader.php
index c17bf28dfe8..69eddadabd1 100644
--- a/plugins/woocommerce/src/Internal/Admin/Loader.php
+++ b/plugins/woocommerce/src/Internal/Admin/Loader.php
@@ -70,6 +70,7 @@ class Loader {
SiteHealth::get_instance();
SystemStatusReport::get_instance();
+ wc_get_container()->get( TaxSettingsRecommendations::class );
wc_get_container()->get( Reviews::class );
wc_get_container()->get( ReviewsCommentsOverrides::class );
diff --git a/plugins/woocommerce/src/Internal/Admin/TaxSettingsRecommendations.php b/plugins/woocommerce/src/Internal/Admin/TaxSettingsRecommendations.php
new file mode 100644
index 00000000000..182d168fd88
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Admin/TaxSettingsRecommendations.php
@@ -0,0 +1,115 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\Admin;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * TaxSettingsRecommendations Class.
+ *
+ * Handles dismissal of the recommended tax solutions card on the Tax settings
+ * screen. The dismissal is stored site-wide in the
+ * `woocommerce_settings_tax_recommendations_hidden` option, but is read and
+ * written through a dedicated REST endpoint instead of the deprecated Options
+ * REST API (whose allowlist is frozen). This keeps the dismissal working in all
+ * environments, including non-production.
+ *
+ * @internal
+ */
+class TaxSettingsRecommendations {
+
+ /**
+ * Option name used to persist the dismissal.
+ *
+ * @var string
+ */
+ const DISMISSED_OPTION_NAME = 'woocommerce_settings_tax_recommendations_hidden';
+
+ /**
+ * Class initialization, to be executed when the class is resolved by the container.
+ *
+ * @internal
+ */
+ final public function init(): void {
+ add_action( 'rest_api_init', array( $this, 'register_routes' ) );
+ add_filter( 'woocommerce_admin_shared_settings', array( $this, 'preload_settings' ) );
+ }
+
+ /**
+ * Register the REST route used to dismiss the recommended tax solutions card.
+ */
+ public function register_routes(): void {
+ register_rest_route(
+ 'wc-admin',
+ '/tax/recommendations/dismiss',
+ array(
+ array(
+ 'methods' => \WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'dismiss' ),
+ 'permission_callback' => array( $this, 'permissions_check' ),
+ ),
+ )
+ );
+ }
+
+ /**
+ * Check whether the current user can dismiss the recommendations.
+ *
+ * @param \WP_REST_Request<array<string, mixed>> $request Full details about the request.
+ * @return bool|\WP_Error
+ */
+ public function permissions_check( \WP_REST_Request $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found, Squiz.Commenting.FunctionComment.IncorrectTypeHint
+ if ( ! current_user_can( 'manage_woocommerce' ) ) {
+ return new \WP_Error(
+ 'woocommerce_rest_cannot_edit',
+ __( 'Sorry, you are not allowed to dismiss these recommendations.', 'woocommerce' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Persist the dismissal of the recommended tax solutions card.
+ *
+ * @param \WP_REST_Request<array<string, mixed>> $request Full details about the request.
+ * @return \WP_REST_Response|\WP_Error
+ */
+ public function dismiss( \WP_REST_Request $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found, Squiz.Commenting.FunctionComment.IncorrectTypeHint
+ // Already dismissed: update_option() returns false when the value is
+ // unchanged, which is not a failure, so report success directly.
+ if ( 'yes' === get_option( self::DISMISSED_OPTION_NAME ) ) {
+ return new \WP_REST_Response( array( 'dismissed' => true ), 200 );
+ }
+
+ if ( ! update_option( self::DISMISSED_OPTION_NAME, 'yes' ) ) {
+ return new \WP_Error(
+ 'woocommerce_rest_tax_recommendations_dismiss_failed',
+ __( 'The dismissal could not be saved.', 'woocommerce' ),
+ array( 'status' => 500 )
+ );
+ }
+
+ return new \WP_REST_Response( array( 'dismissed' => true ), 200 );
+ }
+
+ /**
+ * Preload the dismissal state into the wc-admin settings so the client can
+ * render the correct initial visibility without an extra request.
+ *
+ * @param array $settings Shared settings.
+ * @return array
+ */
+ public function preload_settings( $settings ) {
+ if ( ! is_array( $settings ) ) {
+ return $settings;
+ }
+
+ $settings['taxRecommendationsHidden'] = 'yes' === get_option( self::DISMISSED_OPTION_NAME );
+
+ return $settings;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/TaxSettingsRecommendationsTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/TaxSettingsRecommendationsTest.php
new file mode 100644
index 00000000000..136be2d271c
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/TaxSettingsRecommendationsTest.php
@@ -0,0 +1,123 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Admin;
+
+use Automattic\WooCommerce\Internal\Admin\TaxSettingsRecommendations;
+use WC_Unit_Test_Case;
+use WP_REST_Request;
+
+/**
+ * Tests for the TaxSettingsRecommendations class.
+ */
+class TaxSettingsRecommendationsTest extends WC_Unit_Test_Case {
+
+ /**
+ * The System Under Test.
+ *
+ * @var TaxSettingsRecommendations
+ */
+ private $sut;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->sut = new TaxSettingsRecommendations();
+ $this->sut->init();
+
+ // Routes must register on rest_api_init; firing it runs init()'s callback.
+ // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
+ do_action( 'rest_api_init' );
+ }
+
+ /**
+ * Tear down test fixtures.
+ */
+ public function tearDown(): void {
+ delete_option( TaxSettingsRecommendations::DISMISSED_OPTION_NAME );
+ wp_set_current_user( 0 );
+ parent::tearDown();
+ }
+
+ /**
+ * @testdox Dismissing persists the option and returns a 200 response.
+ */
+ public function test_dismiss_persists_option_and_returns_200(): void {
+ wp_set_current_user( $this->factory->user->create( array( 'role' => 'administrator' ) ) );
+
+ $response = rest_do_request( new WP_REST_Request( 'POST', '/wc-admin/tax/recommendations/dismiss' ) );
+
+ $this->assertSame( 200, $response->get_status(), 'A valid dismissal should return HTTP 200.' );
+ $this->assertSame(
+ 'yes',
+ get_option( TaxSettingsRecommendations::DISMISSED_OPTION_NAME ),
+ 'The dismissal option should be persisted as "yes".'
+ );
+ }
+
+ /**
+ * @testdox Dismissing twice still returns a 200 response.
+ */
+ public function test_dismiss_twice_returns_200(): void {
+ wp_set_current_user( $this->factory->user->create( array( 'role' => 'administrator' ) ) );
+ update_option( TaxSettingsRecommendations::DISMISSED_OPTION_NAME, 'yes' );
+
+ $response = rest_do_request( new WP_REST_Request( 'POST', '/wc-admin/tax/recommendations/dismiss' ) );
+
+ $this->assertSame(
+ 200,
+ $response->get_status(),
+ 'Re-dismissing an already-dismissed card should succeed, not error.'
+ );
+ }
+
+ /**
+ * @testdox Users without manage_woocommerce cannot dismiss.
+ */
+ public function test_dismiss_denied_without_capability(): void {
+ wp_set_current_user( $this->factory->user->create( array( 'role' => 'subscriber' ) ) );
+
+ $response = rest_do_request( new WP_REST_Request( 'POST', '/wc-admin/tax/recommendations/dismiss' ) );
+
+ $this->assertSame(
+ rest_authorization_required_code(),
+ $response->get_status(),
+ 'A user lacking manage_woocommerce should be denied.'
+ );
+ $this->assertNotSame(
+ 'yes',
+ get_option( TaxSettingsRecommendations::DISMISSED_OPTION_NAME ),
+ 'A denied request must not persist the dismissal.'
+ );
+ }
+
+ /**
+ * @testdox Preload maps the stored option to the taxRecommendationsHidden boolean.
+ */
+ public function test_preload_settings_maps_option_to_boolean(): void {
+ $this->assertFalse(
+ $this->sut->preload_settings( array() )['taxRecommendationsHidden'],
+ 'An unset option should preload as false.'
+ );
+
+ update_option( TaxSettingsRecommendations::DISMISSED_OPTION_NAME, 'yes' );
+
+ $this->assertTrue(
+ $this->sut->preload_settings( array() )['taxRecommendationsHidden'],
+ 'A dismissed card should preload as true.'
+ );
+ }
+
+ /**
+ * @testdox Preload leaves non-array input untouched.
+ */
+ public function test_preload_settings_ignores_non_array(): void {
+ $this->assertSame(
+ 'unchanged',
+ $this->sut->preload_settings( 'unchanged' ),
+ 'Non-array settings should be returned as-is.'
+ );
+ }
+}