Commit 95e2578742 for woocommerce
commit 95e2578742366730fa513f9673bde0d2933d529c
Author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Date: Mon Jun 30 16:35:28 2025 +0100
Cherry pick #59221 into `trunk` (#59278)
Restore `DiscountMeta` slot in Cart when coupons missing and improve CSS specificity (#59221)
* Restore DiscountMeta slot fill in Cart when coupons missing
* Add changelog
* Add tests to verify DiscountsMeta Slot is rendered
* Ensure plugins using the panel in the .slot-wrapper are not impacting by the padding reset
* Lint fixes
* Fix comment wording
* Fix test import order
* Remove ts any
---------
Co-authored-by: Thomas Roberts <5656702+opr@users.noreply.github.com>
Co-authored-by: Raluca Stan <ralucastn@gmail.com>
diff --git a/plugins/woocommerce/changelog/fix-cart-discount-meta b/plugins/woocommerce/changelog/fix-cart-discount-meta
new file mode 100644
index 0000000000..9b4ea06808
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-cart-discount-meta
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Ensure discount meta slot fill shows when no coupons are applied to the cart.
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/sidebar-layout/style.scss b/plugins/woocommerce/client/blocks/assets/js/base/components/sidebar-layout/style.scss
index 59550c27c6..8a87483e33 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/sidebar-layout/style.scss
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/sidebar-layout/style.scss
@@ -52,6 +52,9 @@
.wc-block-components-sidebar {
.wc-block-components-totals-item,
.wc-block-components-panel,
+ // Increase specificity to overwrite the rule from the TotalsWrapper component
+ // https://github.com/woocommerce/woocommerce/blob/74e823c0324289cff7361629ff8cc677c45dce0f/plugins/woocommerce/client/blocks/packages/components/totals-wrapper/style.scss#L24
+ .slot-wrapper .wc-block-components-panel,
.wc-block-components-totals-coupon {
padding-left: $gap;
padding-right: $gap;
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/cart/inner-blocks/cart-order-summary-discount/block.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/cart/inner-blocks/cart-order-summary-discount/block.tsx
index ae169b133c..c38adaacb6 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/cart/inner-blocks/cart-order-summary-discount/block.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/cart/inner-blocks/cart-order-summary-discount/block.tsx
@@ -27,9 +27,9 @@ const Block = ( { className }: { className: string } ) => {
const { cartTotals, cartCoupons } = useStoreCart();
const { removeCoupon, isRemovingCoupon } = useStoreCartCoupons( 'wc/cart' );
- // Hide if there are no coupons to show.
+ // Hide all but the slot/fill if there are no coupons to show.
if ( ! cartCoupons.length ) {
- return null;
+ return <DiscountSlotFill />;
}
const totalsCurrency = getCurrencyFromPriceResponse( cartTotals );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/cart/inner-blocks/cart-order-summary-discount/test/block.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/cart/inner-blocks/cart-order-summary-discount/test/block.tsx
new file mode 100644
index 0000000000..8e2ccec7db
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/cart/inner-blocks/cart-order-summary-discount/test/block.tsx
@@ -0,0 +1,220 @@
+/**
+ * External dependencies
+ */
+import { render, screen } from '@testing-library/react';
+import { SlotFillProvider } from '@wordpress/components';
+import {
+ useStoreCart,
+ useStoreCartCoupons,
+} from '@woocommerce/base-context/hooks';
+
+/**
+ * Internal dependencies
+ */
+import Block from '../block';
+
+// We only need to mock the hooks since we're using real components
+jest.mock( '@woocommerce/base-context/hooks', () => ( {
+ useStoreCart: jest.fn(),
+ useStoreCartCoupons: jest.fn(),
+} ) );
+
+// Mock the ExperimentalDiscountsMeta to track when slot is rendered
+const mockSlotRender = jest.fn();
+jest.mock( '@woocommerce/blocks-checkout', () => {
+ const actual = jest.requireActual( '@woocommerce/blocks-checkout' );
+ return {
+ ...actual,
+ ExperimentalDiscountsMeta: {
+ ...actual.ExperimentalDiscountsMeta,
+ Slot: ( props: unknown ) => {
+ mockSlotRender( props );
+ // Return a testable element that represents the slot
+ return <div data-testid="discount-slot" />;
+ },
+ },
+ };
+} );
+
+const mockCartTotals = {
+ currency_code: 'USD',
+ currency_symbol: '$',
+ currency_minor_unit: 2,
+ currency_decimal_separator: '.',
+ currency_thousand_separator: ',',
+ currency_prefix: '$',
+ currency_suffix: '',
+ total_discount: '0',
+ total_discount_tax: '0',
+};
+
+const mockCartData = {
+ cartTotals: mockCartTotals,
+ cartCoupons: [],
+ extensions: { some: 'data' },
+ receiveCart: jest.fn(),
+ otherCartData: { test: 'value' },
+};
+
+const mockCouponHooks = {
+ removeCoupon: jest.fn(),
+ isRemovingCoupon: false,
+};
+
+describe( 'Cart Order Summary Discount Block', () => {
+ beforeEach( () => {
+ jest.clearAllMocks();
+ ( useStoreCart as jest.Mock ).mockReturnValue( mockCartData );
+ ( useStoreCartCoupons as jest.Mock ).mockReturnValue( mockCouponHooks );
+ } );
+
+ const renderWithProviders = ( ui: React.ReactElement ) => {
+ return render( <SlotFillProvider>{ ui }</SlotFillProvider> );
+ };
+
+ it( 'renders only the DiscountSlotFill when there are no coupons', () => {
+ renderWithProviders( <Block className="test-class" /> );
+
+ // Verify the slot is rendered
+ expect( screen.getByTestId( 'discount-slot' ) ).toBeInTheDocument();
+
+ // Since we're using real TotalsWrapper, it won't render when there are no children
+ // TotalsDiscount should not be present when there are no coupons
+ expect( screen.queryByText( /discount/i ) ).not.toBeInTheDocument();
+
+ // Verify the slot was called with correct props
+ expect( mockSlotRender ).toHaveBeenCalledWith(
+ expect.objectContaining( {
+ context: 'woocommerce/cart',
+ extensions: { some: 'data' },
+ cart: expect.objectContaining( {
+ cartTotals: mockCartTotals,
+ otherCartData: { test: 'value' },
+ } ),
+ } )
+ );
+
+ // Verify receiveCart was not passed to the slot
+ const slotProps = mockSlotRender.mock.calls[ 0 ][ 0 ];
+ expect( slotProps.cart ).not.toHaveProperty( 'receiveCart' );
+ } );
+
+ it( 'renders both TotalsDiscount and DiscountSlotFill when there are coupons', () => {
+ const mockCoupons = [
+ {
+ code: 'TEST10',
+ label: 'TEST10', // The label is what gets displayed
+ discount_type: 'percent',
+ amount: '10',
+ totals: {
+ total_discount: '1000',
+ total_discount_tax: '0',
+ },
+ },
+ ];
+
+ ( useStoreCart as jest.Mock ).mockReturnValue( {
+ ...mockCartData,
+ cartCoupons: mockCoupons,
+ cartTotals: {
+ ...mockCartTotals,
+ total_discount: '1000',
+ },
+ } );
+
+ renderWithProviders( <Block className="test-class" /> );
+
+ // With real components, look for the discount text/coupon code
+ expect( screen.getByText( 'TEST10' ) ).toBeInTheDocument();
+
+ // Verify the slot is still rendered
+ expect( screen.getByTestId( 'discount-slot' ) ).toBeInTheDocument();
+
+ // The wrapper should have the provided class
+ const wrapper = screen.getByText( 'TEST10' ).closest( '.test-class' );
+ expect( wrapper ).toBeInTheDocument();
+ } );
+
+ it( 'calls useStoreCartCoupons with correct context', () => {
+ renderWithProviders( <Block className="test-class" /> );
+
+ expect( useStoreCartCoupons ).toHaveBeenCalledWith( 'wc/cart' );
+ } );
+
+ it( 'always renders the ExperimentalDiscountsMeta.Slot regardless of coupon state', () => {
+ // Test with no coupons
+ const { rerender } = renderWithProviders(
+ <Block className="test-class" />
+ );
+ expect( screen.getByTestId( 'discount-slot' ) ).toBeInTheDocument();
+ expect( mockSlotRender ).toHaveBeenCalledTimes( 1 );
+
+ // Clean up
+ jest.clearAllMocks();
+
+ // Test with coupons
+ ( useStoreCart as jest.Mock ).mockReturnValue( {
+ ...mockCartData,
+ cartCoupons: [
+ {
+ code: 'TEST',
+ label: 'TEST', // Add label for display
+ totals: {
+ total_discount: '500',
+ total_discount_tax: '0',
+ },
+ },
+ ],
+ } );
+
+ rerender(
+ <SlotFillProvider>
+ <Block className="test-class" />
+ </SlotFillProvider>
+ );
+
+ // Should still have the slot
+ expect( screen.getByTestId( 'discount-slot' ) ).toBeInTheDocument();
+
+ // Verify it was called with correct props both times
+ expect( mockSlotRender ).toHaveBeenCalledTimes( 1 );
+
+ // Check the props structure
+ expect( mockSlotRender ).toHaveBeenCalledWith(
+ expect.objectContaining( {
+ context: 'woocommerce/cart',
+ extensions: expect.any( Object ),
+ cart: expect.objectContaining( {
+ cartTotals: expect.any( Object ),
+ } ),
+ } )
+ );
+
+ // Verify receiveCart is not passed
+ const slotProps = mockSlotRender.mock.calls[ 0 ][ 0 ];
+ expect( slotProps.cart ).not.toHaveProperty( 'receiveCart' );
+ } );
+
+ it( 'handles missing className prop gracefully', () => {
+ ( useStoreCart as jest.Mock ).mockReturnValue( {
+ ...mockCartData,
+ cartCoupons: [
+ {
+ code: 'TEST',
+ label: 'TEST', // Add label for display
+ totals: {
+ total_discount: '500',
+ total_discount_tax: '0',
+ },
+ },
+ ],
+ } );
+
+ // @ts-expect-error Testing without required prop
+ renderWithProviders( <Block /> );
+
+ // Should still render without errors
+ expect( screen.getByText( 'TEST' ) ).toBeInTheDocument();
+ expect( screen.getByTestId( 'discount-slot' ) ).toBeInTheDocument();
+ } );
+} );