diff --git a/js/src/components/app-button-modal-trigger.js b/js/src/components/app-button-modal-trigger.js deleted file mode 100644 index a950c39671..0000000000 --- a/js/src/components/app-button-modal-trigger.js +++ /dev/null @@ -1,57 +0,0 @@ -/** - * External dependencies - */ -import { useState, cloneElement } from '@wordpress/element'; - -/** - * A convenience component to glue button and modal together. - * - * Upon button click, this component will open the modal automatically. - * - * Upon modal's onRequestClose, this component will close the modal automatically. - * - * ## Usage - * - * ```js - * - Click to open MySuperModal - - } - modal={ } - /> - * ``` - * - * @param {Object} props Props. - * @param {Object} props.button Button component. - * @param {Object} props.modal Modal component. - */ -const AppButtonModalTrigger = ( props ) => { - const { button, modal } = props; - const { onClick = () => {} } = button.props; - const { onRequestClose = () => {} } = modal.props; - const [ isOpen, setOpen ] = useState( false ); - - const handleButtonClick = ( ...args ) => { - setOpen( true ); - onClick( ...args ); - }; - - const handleModalRequestClose = ( ...args ) => { - setOpen( false ); - onRequestClose( ...args ); - }; - - return ( - <> - { cloneElement( button, { onClick: handleButtonClick } ) } - { isOpen && - cloneElement( modal, { - onRequestClose: handleModalRequestClose, - } ) } - - ); -}; - -export default AppButtonModalTrigger; diff --git a/js/src/components/order-value-condition-section/minimum-order-card/minimum-order-card.js b/js/src/components/order-value-condition-section/minimum-order-card/minimum-order-card.js index 131d2b6f35..24d462b1b1 100644 --- a/js/src/components/order-value-condition-section/minimum-order-card/minimum-order-card.js +++ b/js/src/components/order-value-condition-section/minimum-order-card/minimum-order-card.js @@ -13,8 +13,8 @@ import GridiconPlusSmall from 'gridicons/dist/plus-small'; */ import Section from '~/components/section'; import AppButton from '~/components/app-button'; -import AppButtonModalTrigger from '~/components/app-button-modal-trigger'; import VerticalGapLayout from '~/components/vertical-gap-layout'; +import useToggle from '~/hooks/useToggle'; import { useAdaptiveFormInputProps } from '~/components/adaptive-form'; import OfferFreeShippingCheckbox from '~/components/order-value-condition-section/offer-free-shipping-checkbox'; import isNonFreeShippingRate from '~/utils/isNonFreeShippingRate'; @@ -36,6 +36,7 @@ const MinimumOrderCard = ( { value = [], helper, onChange } ) => { const offerFreeShippingCardInputProps = useAdaptiveFormInputProps( 'offer_free_shipping' ); + const [ isAddModalOpen, toggleAddModal ] = useToggle(); const renderGroups = () => { const nonZeroShippingRates = value.filter( isNonFreeShippingRate ); @@ -105,28 +106,24 @@ const MinimumOrderCard = ( { value = [], helper, onChange } ) => { } ) } { emptyThresholdGroup && (
- } - > - { __( - 'Add another condition', - 'google-listings-and-ads' - ) } - - } - modal={ - - } - /> + } + onClick={ toggleAddModal } + > + { __( + 'Add another condition', + 'google-listings-and-ads' + ) } + + { isAddModalOpen && ( + + ) }
) } diff --git a/js/src/components/order-value-condition-section/minimum-order-card/minimum-order-input-control.js b/js/src/components/order-value-condition-section/minimum-order-card/minimum-order-input-control.js index 6de5e31ec5..d79969ec86 100644 --- a/js/src/components/order-value-condition-section/minimum-order-card/minimum-order-input-control.js +++ b/js/src/components/order-value-condition-section/minimum-order-card/minimum-order-input-control.js @@ -7,8 +7,8 @@ import { __ } from '@wordpress/i18n'; * Internal dependencies */ import AppButton from '~/components/app-button'; -import AppButtonModalTrigger from '~/components/app-button-modal-trigger'; import AppInputPriceControl from '~/components/app-input-price-control'; +import useToggle from '~/hooks/useToggle'; import { useAdaptiveFormContext } from '~/components/adaptive-form'; import { EditMinimumOrderFormModal } from './minimum-order-form-modals'; import MinimumOrderInputControlLabelText from './minimum-order-input-control-label-text'; @@ -35,6 +35,7 @@ const MinimumOrderInputControl = ( props ) => { const { countryOptions, value, onChange, onDelete } = props; const { countries, threshold, currency } = value; const { values } = useAdaptiveFormContext(); + const [ isEditModalOpen, toggleEditModal ] = useToggle(); const handleBlur = ( event, numberValue ) => { if ( numberValue === value.threshold ) { @@ -55,36 +56,35 @@ const MinimumOrderInputControl = ( props ) => { } return ( - -
- - - { __( 'Edit', 'google-listings-and-ads' ) } - - } - modal={ - - } - /> + <> + +
+ + + { __( 'Edit', 'google-listings-and-ads' ) } + +
+ <>{ `Cost (${ currency })` }
- <>{ `Cost (${ currency })` } - - } - value={ threshold } - onBlur={ handleBlur } - /> + } + value={ threshold } + onBlur={ handleBlur } + /> + { isEditModalOpen && ( + + ) } + ); }; diff --git a/js/src/components/shipping-rate-section/estimated-shipping-rates-card/estimated-shipping-rates-card.js b/js/src/components/shipping-rate-section/estimated-shipping-rates-card/estimated-shipping-rates-card.js index 28ca774c59..2251a6b7c0 100644 --- a/js/src/components/shipping-rate-section/estimated-shipping-rates-card/estimated-shipping-rates-card.js +++ b/js/src/components/shipping-rate-section/estimated-shipping-rates-card/estimated-shipping-rates-card.js @@ -9,9 +9,9 @@ import GridiconPlusSmall from 'gridicons/dist/plus-small'; */ import Section from '~/components/section'; import AppButton from '~/components/app-button'; -import AppButtonModalTrigger from '~/components/app-button-modal-trigger'; import VerticalGapLayout from '~/components/vertical-gap-layout'; import useStoreCurrency from '~/hooks/useStoreCurrency'; +import useToggle from '~/hooks/useToggle'; import groupShippingRatesByCurrencyRate from './groupShippingRatesByCurrencyRate'; import ShippingRateInputControl from './shipping-rate-input-control'; import { AddRateFormModal } from './rate-form-modals'; @@ -42,6 +42,7 @@ export default function EstimatedShippingRatesCard( { const { handleAddSubmit, getChangeHandler, getDeleteHandler } = getHandlers( { value, onChange } ); + const [ isAddModalOpen, toggleAddModal ] = useToggle(); /** * Function to render the shipping rate groups from `value`. @@ -99,30 +100,28 @@ export default function EstimatedShippingRatesCard( { } ) } { remainingCountries.length >= 1 && (
- } - > - { __( - 'Add another rate', - 'google-listings-and-ads' - ) } - - } - modal={ - - } - /> + } + onClick={ toggleAddModal } + > + { __( + 'Add another rate', + 'google-listings-and-ads' + ) } + + { isAddModalOpen && ( + + ) }
) } diff --git a/js/src/components/shipping-rate-section/estimated-shipping-rates-card/shipping-rate-input-control.js b/js/src/components/shipping-rate-section/estimated-shipping-rates-card/shipping-rate-input-control.js index 3ec6c19de5..d9e55fdced 100644 --- a/js/src/components/shipping-rate-section/estimated-shipping-rates-card/shipping-rate-input-control.js +++ b/js/src/components/shipping-rate-section/estimated-shipping-rates-card/shipping-rate-input-control.js @@ -8,8 +8,8 @@ import { Pill } from '@woocommerce/components'; * Internal dependencies */ import AppInputPriceControl from '~/components/app-input-price-control'; -import AppButtonModalTrigger from '~/components/app-button-modal-trigger'; import AppButton from '~/components/app-button'; +import useToggle from '~/hooks/useToggle'; import { EditRateFormModal } from './rate-form-modals'; import ShippingRateInputControlLabelText from './shipping-rate-input-control-label-text'; import './shipping-rate-input-control.scss'; @@ -38,6 +38,7 @@ const ShippingRateInputControl = ( { onDelete, } ) => { const { countries, currency, rate } = value; + const [ isEditModalOpen, toggleEditModal ] = useToggle(); const handleBlur = ( event, numberValue ) => { if ( rate === numberValue ) { @@ -58,27 +59,24 @@ const ShippingRateInputControl = ( { - - { __( 'Edit', 'google-listings-and-ads' ) } - - } - modal={ - - } - /> + + { __( 'Edit', 'google-listings-and-ads' ) } + } suffix={ currency } value={ rate } onBlur={ handleBlur } /> + { isEditModalOpen && ( + + ) } { rate === 0 && (
diff --git a/js/src/hooks/useToggle.js b/js/src/hooks/useToggle.js new file mode 100644 index 0000000000..4233589170 --- /dev/null +++ b/js/src/hooks/useToggle.js @@ -0,0 +1,18 @@ +/** + * External dependencies + */ +import { useState, useCallback } from '@wordpress/element'; + +/** + * A hook to toggle a boolean state. + * + * @param {boolean} [initialValue=false] The initial value of the toggle. + * @return {Array} A tuple of `[ value, toggle ]`. + */ +const useToggle = ( initialValue = false ) => { + const [ value, setValue ] = useState( initialValue ); + const toggle = useCallback( () => setValue( ( prev ) => ! prev ), [] ); + return [ value, toggle ]; +}; + +export default useToggle; diff --git a/js/src/hooks/useToggle.test.js b/js/src/hooks/useToggle.test.js new file mode 100644 index 0000000000..c65be10eff --- /dev/null +++ b/js/src/hooks/useToggle.test.js @@ -0,0 +1,42 @@ +/** + * External dependencies + */ +import { renderHook, act } from '@testing-library/react'; + +/** + * Internal dependencies + */ +import useToggle from '~/hooks/useToggle'; + +describe( 'useToggle', () => { + it( 'defaults to false when no initialValue is provided', () => { + const { result } = renderHook( () => useToggle() ); + const [ value ] = result.current; + expect( value ).toBe( false ); + } ); + + it( 'respects a custom initialValue', () => { + const { result } = renderHook( () => useToggle( true ) ); + const [ value ] = result.current; + expect( value ).toBe( true ); + } ); + + it( 'toggles from false to true', () => { + const { result } = renderHook( () => useToggle() ); + act( () => result.current[ 1 ]() ); + expect( result.current[ 0 ] ).toBe( true ); + } ); + + it( 'toggles from true to false', () => { + const { result } = renderHook( () => useToggle( true ) ); + act( () => result.current[ 1 ]() ); + expect( result.current[ 0 ] ).toBe( false ); + } ); + + it( 'returns a stable toggle function reference across renders', () => { + const { result, rerender } = renderHook( () => useToggle() ); + const toggleFirst = result.current[ 1 ]; + rerender(); + expect( result.current[ 1 ] ).toBe( toggleFirst ); + } ); +} ); diff --git a/js/src/pages/attribute-mapping/attribute-mapping-table.js b/js/src/pages/attribute-mapping/attribute-mapping-table.js index b74a88f14c..2d901c69eb 100644 --- a/js/src/pages/attribute-mapping/attribute-mapping-table.js +++ b/js/src/pages/attribute-mapping/attribute-mapping-table.js @@ -12,7 +12,7 @@ import { useEffect } from '@wordpress/element'; import Section from '~/components/section'; import AppButton from '~/components/app-button'; import AppTableCardDiv from '~/components/app-table-card-div'; -import AppButtonModalTrigger from '~/components/app-button-modal-trigger'; +import useToggle from '~/hooks/useToggle'; import AttributeMappingTableCategories from './attribute-mapping-table-categories'; import AttributeMappingRuleModal from './attribute-mapping-rule-modal'; import AttributeMappingDeleteRuleModal from './attribute-mapping-delete-rule-modal'; @@ -49,6 +49,66 @@ const ATTRIBUTE_MAPPING_TABLE_HEADERS = [ }, ]; +const EditRuleButton = ( { rule } ) => { + const [ isOpen, toggleModal ] = useToggle(); + + return ( + <> + + { isOpen && ( + { + toggleModal(); + recordGlaEvent( 'gla_modal_closed', { + context: 'attribute-mapping-manage-rule-modal', + action, + } ); + } } + /> + ) } + + ); +}; + +const DeleteRuleButton = ( { rule } ) => { + const [ isOpen, toggleModal ] = useToggle(); + + return ( + <> + + { isOpen && ( + { + toggleModal(); + recordGlaEvent( 'gla_modal_closed', { + context: 'attribute-mapping-delete-rule-modal', + action, + } ); + } } + /> + ) } + + ); +}; + /** * Renders the Attribute Mapping table component * @@ -69,6 +129,8 @@ const AttributeMappingTable = () => { hasFinishedResolution: attributesHasFinishedResolution, } = useMappingAttributes(); + const [ isCreateModalOpen, toggleCreateModal ] = useToggle(); + const parseDestinationName = ( destination ) => attributes.find( ( e ) => e.id === destination )?.label || ''; @@ -144,73 +206,11 @@ const AttributeMappingTable = () => { display: ( - - } - modal={ - { - recordGlaEvent( - 'gla_modal_closed', - { - context: - 'attribute-mapping-manage-rule-modal', - action, - } - ); - } } - /> - } - /> + - - } - modal={ - { - recordGlaEvent( - 'gla_modal_closed', - { - context: - 'attribute-mapping-delete-rule-modal', - action, - } - ); - } } - /> - } + @@ -224,24 +224,23 @@ const AttributeMappingTable = () => { align="between" className="gla-attribute-mapping__table-footer" > - - } - modal={ + <> + + { isCreateModalOpen && ( { + toggleCreateModal(); recordGlaEvent( 'gla_modal_closed', { context: 'attribute-mapping-create-rule-modal', @@ -249,8 +248,8 @@ const AttributeMappingTable = () => { } ); } } /> - } - /> + ) } + { const { className, programId, ...buttonProps } = props; + const [ isOpen, toggleModal ] = useToggle(); return ( - - { __( 'Edit', 'google-listings-and-ads' ) } - - } - modal={ } - /> + <> + + { __( 'Edit', 'google-listings-and-ads' ) } + + { isOpen && ( + + ) } + ); }; diff --git a/js/src/pages/product-feed/issues-table-card/issues-table-data.js b/js/src/pages/product-feed/issues-table-card/issues-table-data.js index 8706cf4300..cc0d91b6b9 100644 --- a/js/src/pages/product-feed/issues-table-card/issues-table-data.js +++ b/js/src/pages/product-feed/issues-table-card/issues-table-data.js @@ -12,12 +12,38 @@ import WarningIcon from '~/components/warning-icon'; import ErrorIcon from '~/components/error-icon'; import AppDocumentationLink from '~/components/app-documentation-link'; import EditProductLink from '~/components/edit-product-link'; -import AppButtonModalTrigger from '~/components/app-button-modal-trigger'; +import useToggle from '~/hooks/useToggle'; import IssuesSolved from './issues-solved'; import IssuesTableDataModal from './issues-table-data-modal'; import { ISSUE_TYPE_PRODUCT } from '~/constants'; import ISSUES_TABLE_DATA_HEADERS from './issues-table-data-headers'; +const IssueReadMoreButton = ( { issue, children } ) => { + const [ isOpen, toggleModal ] = useToggle(); + + return ( + <> + + { children } + + { isOpen && ( + + ) } + + ); +}; + /** * The rows with data for the Issues table * @@ -65,21 +91,9 @@ const IssuesTableData = ( { data } ) => { { display: el.issue }, { display: el.action ? ( - - { readMore } - - } - modal={ } - /> + + { readMore } + ) : (