From e3860aa8575a1f47afeeb6c5d02495d9f3d1996a Mon Sep 17 00:00:00 2001 From: Miguel Torres Date: Thu, 6 Aug 2020 10:35:45 +0200 Subject: [PATCH] Donations: Save block content (#16705) * Adds support for saving the content of a donations block into the post content, so the published view contains the block output. * It also follows up https://github.com/Automattic/jetpack/pull/16593#issuecomment-668060633 and implements the content sync logic as suggested there. --- extensions/blocks/donations/amount.js | 58 +++---- extensions/blocks/donations/attributes.js | 72 ++++---- extensions/blocks/donations/common.scss | 105 +++++++++++ extensions/blocks/donations/context.js | 10 -- extensions/blocks/donations/controls.js | 31 +++- extensions/blocks/donations/editor.scss | 123 ++----------- extensions/blocks/donations/index.js | 29 +--- extensions/blocks/donations/save.js | 201 ++++++++++++++++++++++ extensions/blocks/donations/tab.js | 141 ++++++++------- extensions/blocks/donations/tabs.js | 55 +++--- extensions/blocks/donations/view.js | 105 +++++++++++ extensions/blocks/donations/view.scss | 37 ++++ 12 files changed, 645 insertions(+), 322 deletions(-) create mode 100644 extensions/blocks/donations/common.scss delete mode 100644 extensions/blocks/donations/context.js create mode 100644 extensions/blocks/donations/save.js create mode 100644 extensions/blocks/donations/view.js create mode 100644 extensions/blocks/donations/view.scss diff --git a/extensions/blocks/donations/amount.js b/extensions/blocks/donations/amount.js index 307829891b164..39c4f50c36343 100644 --- a/extensions/blocks/donations/amount.js +++ b/extensions/blocks/donations/amount.js @@ -15,11 +15,35 @@ import { useCallback, useEffect, useRef, useState } from '@wordpress/element'; */ import { minimumTransactionAmountForCurrency } from '../../shared/currencies'; +const parseAmount = ( amount, currency ) => { + if ( ! amount ) { + return null; + } + + if ( typeof amount === 'number' ) { + return amount; + } + + amount = parseFloat( + amount + // Remove any thousand grouping separator. + .replace( new RegExp( '\\' + CURRENCIES[ currency ].grouping, 'g' ), '' ) + // Replace the localized decimal separator with a dot (the standard decimal separator in float numbers). + .replace( new RegExp( '\\' + CURRENCIES[ currency ].decimal, 'g' ), '.' ) + ); + + if ( isNaN( amount ) ) { + return null; + } + + return amount; +}; + const Amount = ( { className = '', currency = null, defaultValue = null, - editable = false, + disabled = false, label = '', onChange = null, value = '', @@ -31,33 +55,6 @@ const Amount = ( { const [ isInvalid, setIsInvalid ] = useState( false ); const richTextRef = useRef( null ); - const parseAmount = useCallback( - amount => { - if ( ! amount ) { - return null; - } - - if ( typeof amount === 'number' ) { - return amount; - } - - amount = parseFloat( - amount - // Remove any thousand grouping separator. - .replace( new RegExp( '\\' + CURRENCIES[ currency ].grouping, 'g' ), '' ) - // Replace the localized decimal separator with a dot (the standard decimal separator in float numbers). - .replace( new RegExp( '\\' + CURRENCIES[ currency ].decimal, 'g' ), '.' ) - ); - - if ( isNaN( amount ) ) { - return null; - } - - return amount; - }, - [ currency ] - ); - const setAmount = useCallback( amount => { setEditedValue( amount ); @@ -74,7 +71,7 @@ const Amount = ( { setIsInvalid( true ); } }, - [ currency, parseAmount, onChange ] + [ currency, onChange ] ); const setFocus = () => { @@ -125,7 +122,7 @@ const Amount = ( { tabIndex={ 0 } > { CURRENCIES[ currency ].symbol } - { editable ? ( + { ! disabled ? ( { const { attributes, setAttributes, products, siteSlug } = props; - const { currency, monthlyPlanId, annuallyPlanId, showCustomAmount } = attributes; + const { currency, monthlyDonation, annualDonation, showCustomAmount } = attributes; + + const toggleDonation = ( interval, show ) => { + const donationAttributes = { + '1 month': 'monthlyDonation', + '1 year': 'annualDonation', + }; + const donationAttribute = donationAttributes[ interval ]; + const donation = attributes[ donationAttribute ]; + + setAttributes( { + [ donationAttribute ]: { + ...donation, + show, + planId: show ? products[ interval ] : null, + }, + } ); + }; return ( <> @@ -78,17 +95,13 @@ const Controls = props => { - setAttributes( { monthlyPlanId: value ? products[ '1 month' ] : null } ) - } + checked={ monthlyDonation.show } + onChange={ value => toggleDonation( '1 month', value ) } label={ __( 'Show monthly donations', 'jetpack' ) } /> - setAttributes( { annuallyPlanId: value ? products[ '1 year' ] : null } ) - } + checked={ annualDonation.show } + onChange={ value => toggleDonation( '1 year', value ) } label={ __( 'Show annual donations', 'jetpack' ) } /> null, + save, attributes, - example: { - attributes: { - // @TODO: Add default values for block attributes, for generating the block preview. - }, - }, + example: {}, }; diff --git a/extensions/blocks/donations/save.js b/extensions/blocks/donations/save.js new file mode 100644 index 0000000000000..963d5a549fe2e --- /dev/null +++ b/extensions/blocks/donations/save.js @@ -0,0 +1,201 @@ +/** + * External dependencies + */ +import formatCurrency, { CURRENCIES } from '@automattic/format-currency'; + +/** + * WordPress dependencies + */ +import { RichText } from '@wordpress/block-editor'; +import { Button } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { minimumTransactionAmountForCurrency } from '../../shared/currencies'; + +const Save = ( { attributes } ) => { + const { + currency, + oneTimeDonation, + monthlyDonation, + annualDonation, + showCustomAmount, + chooseAmountText, + customAmountText, + } = attributes; + + if ( ! oneTimeDonation || ! oneTimeDonation.show || oneTimeDonation.planId === -1 ) { + return null; + } + + const tabs = { + 'one-time': { title: __( 'One-Time', 'jetpack' ) }, + ...( monthlyDonation.show && { '1 month': { title: __( 'Monthly', 'jetpack' ) } } ), + ...( annualDonation.show && { '1 year': { title: __( 'Yearly', 'jetpack' ) } } ), + }; + + return ( +
+
+ { Object.keys( tabs ).length > 1 && ( +
+ { Object.entries( tabs ).map( ( [ interval, { title } ] ) => ( + + ) ) } +
+ ) } +
+
+ + { monthlyDonation.show && ( + + ) } + { annualDonation.show && ( + + ) } + +
+ { oneTimeDonation.amounts.map( amount => ( +
+
+ { CURRENCIES[ currency ].symbol } + + { formatCurrency( amount, currency, { symbol: '' } ) } + +
+
+ ) ) } +
+ { monthlyDonation.show && ( +
+ { monthlyDonation.amounts.map( amount => ( +
+
+ { CURRENCIES[ currency ].symbol } + + { formatCurrency( amount, currency, { symbol: '' } ) } + +
+
+ ) ) } +
+ ) } + { annualDonation.show && ( +
+ { annualDonation.amounts.map( amount => ( +
+
+ { CURRENCIES[ currency ].symbol } + + { formatCurrency( amount, currency, { symbol: '' } ) } + +
+
+ ) ) } +
+ ) } + { showCustomAmount && ( + <> + +
+
+ { CURRENCIES[ currency ].symbol } + +
+
+ + ) } +
——
+ + { monthlyDonation.show && ( + + ) } + { annualDonation.show && ( + + ) } +
+ +
+ { monthlyDonation.show && ( +
+ +
+ ) } + { annualDonation.show && ( +
+ +
+ ) } +
+
+
+
+ ); +}; + +export default Save; diff --git a/extensions/blocks/donations/tab.js b/extensions/blocks/donations/tab.js index 01b0199497189..6ef7ea42da3be 100644 --- a/extensions/blocks/donations/tab.js +++ b/extensions/blocks/donations/tab.js @@ -1,115 +1,111 @@ -/** - * External dependencies - */ -import formatCurrency from '@automattic/format-currency'; - /** * WordPress dependencies */ import { RichText } from '@wordpress/block-editor'; -import { useContext, useEffect, useState } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; +import { useEffect, useState } from '@wordpress/element'; /** * Internal dependencies */ -import Context from './context'; import Amount from './amount'; import { minimumTransactionAmountForCurrency } from '../../shared/currencies'; -const attributesPerInterval = { - heading: { - 'one-time': 'oneTimeHeading', - '1 month': 'monthlyHeading', - '1 year': 'annualHeading', - }, - buttonText: { - 'one-time': 'oneTimeButtonText', - '1 month': 'monthlyButtonText', - '1 year': 'annualButtonText', - }, -}; - -const Tab = props => { - const { attributes, setAttributes } = props; - const { activeTab } = useContext( Context ); +const Tab = ( { activeTab, attributes, setAttributes } ) => { + const { + currency, + oneTimeDonation, + monthlyDonation, + annualDonation, + showCustomAmount, + chooseAmountText, + customAmountText, + } = attributes; - const getAttribute = attributeName => { - if ( attributeName in attributesPerInterval ) { - return attributes[ attributesPerInterval[ attributeName ][ activeTab ] ]; - } - return attributes[ attributeName ]; + const donationAttributes = { + 'one-time': 'oneTimeDonation', + '1 month': 'monthlyDonation', + '1 year': 'annualDonation', }; - const setAttribute = ( attributeName, value ) => { - if ( attributeName in attributesPerInterval ) { - return setAttributes( { - [ attributesPerInterval[ attributeName ][ activeTab ] ]: value, - } ); - } - return setAttributes( { [ attributeName ]: value } ); + const getDonationValue = key => attributes[ donationAttributes[ activeTab ] ][ key ]; + + const setDonationValue = ( key, value ) => { + const donationAttribute = donationAttributes[ activeTab ]; + const donation = attributes[ donationAttribute ]; + setAttributes( { + [ donationAttribute ]: { + ...donation, + [ key ]: value, + }, + } ); }; - const amounts = getAttribute( 'amounts' ); - const currency = getAttribute( 'currency' ); - const showCustomAmount = getAttribute( 'showCustomAmount' ); + // Updates the amounts whenever there are new defaults due to a currency change. + const [ previousCurrency, setPreviousCurrency ] = useState( currency ); const minAmount = minimumTransactionAmountForCurrency( currency ); - - const [ defaultAmounts, setDefaultAmounts ] = useState( [ + const defaultAmounts = [ minAmount * 10, // 1st tier (USD 5) minAmount * 30, // 2nd tier (USD 15) minAmount * 200, // 3rd tier (USD 100) - ] ); - const [ defaultCustomAmount, setDefaultCustomAmount ] = useState( minAmount * 100 ); - const [ previousCurrency, setPreviousCurrency ] = useState( currency ); - - // Updates the amounts whenever the currency changes. + ]; useEffect( () => { if ( previousCurrency === currency ) { return; } setPreviousCurrency( currency ); - const newDefaultAmounts = [ - minAmount * 10, // 1st tier (USD 5) - minAmount * 30, // 2nd tier (USD 15) - minAmount * 200, // 3rd tier (USD 100) - ]; - setDefaultAmounts( newDefaultAmounts ); - setAttributes( { amounts: newDefaultAmounts } ); - setDefaultCustomAmount( minAmount * 100 ); // USD 50 - }, [ currency, minAmount, previousCurrency, setAttributes ] ); + setAttributes( { + oneTimeDonation: { ...oneTimeDonation, amounts: defaultAmounts }, + monthlyDonation: { ...monthlyDonation, amounts: defaultAmounts }, + annualDonation: { ...annualDonation, amounts: defaultAmounts }, + } ); + }, [ + currency, + previousCurrency, + defaultAmounts, + oneTimeDonation, + monthlyDonation, + annualDonation, + setAttributes, + ] ); + + const amounts = getDonationValue( 'amounts' ); const setAmount = ( amount, tier ) => { const newAmounts = [ ...amounts ]; newAmounts[ tier ] = amount; - setAttributes( { amounts: newAmounts } ); + setDonationValue( 'amounts', newAmounts ); }; - if ( ! amounts ) { - return null; - } + // Keeps in sync the donate buttons labels across all intervals once the default value is overridden in one of them. + const setButtonText = buttonText => { + setAttributes( { + oneTimeDonation: { ...oneTimeDonation, buttonText: buttonText }, + monthlyDonation: { ...monthlyDonation, buttonText: buttonText }, + annualDonation: { ...annualDonation, buttonText: buttonText }, + } ); + }; return ( - <> +
setAttribute( 'heading', value ) } + value={ getDonationValue( 'heading' ) } + onChange={ value => setDonationValue( 'heading', value ) } /> setAttribute( 'chooseAmountText', value ) } + value={ chooseAmountText } + onChange={ value => setAttributes( { chooseAmountText: value } ) } />
{ amounts.map( ( amount, index ) => ( { setAttribute( 'customAmountText', value ) } + value={ customAmountText } + onChange={ value => setAttributes( { customAmountText: value } ) } /> ) } @@ -141,18 +138,18 @@ const Tab = props => { setAttribute( 'extraText', value ) } + value={ getDonationValue( 'extraText' ) } + onChange={ value => setDonationValue( 'extraText', value ) } />
setAttribute( 'buttonText', value ) } + value={ getDonationValue( 'buttonText' ) } + onChange={ value => setButtonText( value ) } />
- +
); }; diff --git a/extensions/blocks/donations/tabs.js b/extensions/blocks/donations/tabs.js index 9408323e6d5a6..a6e1a11c5dbbc 100644 --- a/extensions/blocks/donations/tabs.js +++ b/extensions/blocks/donations/tabs.js @@ -13,60 +13,65 @@ import { __ } from '@wordpress/i18n'; /** * Internal dependencies */ -import Context from './context'; import Controls from './controls'; import Tab from './tab'; import StripeNudge from '../../shared/components/stripe-nudge'; const Tabs = props => { const { attributes, className, products, setAttributes, shouldUpgrade, stripeConnectUrl } = props; - const { oneTimePlanId, monthlyPlanId, annuallyPlanId } = attributes; + const { oneTimeDonation, monthlyDonation, annualDonation } = attributes; const [ activeTab, setActiveTab ] = useState( 'one-time' ); const isTabActive = useCallback( tab => activeTab === tab, [ activeTab ] ); const tabs = { 'one-time': { title: __( 'One-Time', 'jetpack' ) }, - ...( monthlyPlanId && { '1 month': { title: __( 'Monthly', 'jetpack' ) } } ), - ...( annuallyPlanId && { '1 year': { title: __( 'Yearly', 'jetpack' ) } } ), + ...( monthlyDonation.show && { '1 month': { title: __( 'Monthly', 'jetpack' ) } } ), + ...( annualDonation.show && { '1 year': { title: __( 'Yearly', 'jetpack' ) } } ), }; // Sets the plans when the block is inserted. useEffect( () => { // Since there is no setting for disabling the one-time option, we can assume that the block has been just - // inserted if the attribute `oneTimePlanId` is not set. - if ( oneTimePlanId ) { + // inserted if the plan id for the one-time donation is not set. + if ( oneTimeDonation.planId ) { return; } setAttributes( { - oneTimePlanId: products[ 'one-time' ], - monthlyPlanId: products[ '1 month' ], - annuallyPlanId: products[ '1 year' ], + oneTimeDonation: { ...oneTimeDonation, planId: products[ 'one-time' ] }, + monthlyDonation: { ...monthlyDonation, planId: products[ '1 month' ] }, + annualDonation: { ...annualDonation, planId: products[ '1 year' ] }, } ); - }, [ oneTimePlanId, products, setAttributes ] ); + }, [ oneTimeDonation, monthlyDonation, annualDonation, products, setAttributes ] ); // Sets the plans when Stripe has been connected (we use fake plans while Stripe is not connected so user can still try the block). useEffect( () => { - if ( oneTimePlanId === -1 ) { - setAttributes( { - oneTimePlanId: products[ 'one-time' ], - ...( monthlyPlanId && { monthlyPlanId: products[ '1 month' ] } ), - ...( annuallyPlanId && { annuallyPlanId: products[ '1 year' ] } ), - } ); + if ( products[ 'one-time' ] === -1 || oneTimeDonation.planId !== -1 ) { + return; } - }, [ oneTimePlanId, monthlyPlanId, annuallyPlanId, setAttributes, products ] ); + + setAttributes( { + oneTimeDonation: { ...oneTimeDonation, planId: products[ 'one-time' ] }, + ...( monthlyDonation.show && { + monthlyDonation: { ...monthlyDonation, planId: products[ '1 month' ] }, + } ), + ...( annualDonation.show && { + annualDonation: { ...annualDonation, planId: products[ '1 year' ] }, + } ), + } ); + }, [ oneTimeDonation, monthlyDonation, annualDonation, setAttributes, products ] ); // Activates the one-time tab if the interval of the current active tab is disabled. useEffect( () => { - if ( ! monthlyPlanId && isTabActive( '1 month' ) ) { + if ( ! monthlyDonation.show && isTabActive( '1 month' ) ) { setActiveTab( 'one-time' ); } - if ( ! annuallyPlanId && isTabActive( '1 year' ) ) { + if ( ! annualDonation.show && isTabActive( '1 year' ) ) { setActiveTab( 'one-time' ); } - }, [ monthlyPlanId, annuallyPlanId, setActiveTab, isTabActive ] ); + }, [ monthlyDonation, annualDonation, setActiveTab, isTabActive ] ); return (
@@ -75,14 +80,14 @@ const Tabs = props => { ) }
{ Object.keys( tabs ).length > 1 && ( -
+
{ Object.entries( tabs ).map( ( [ interval, { title } ] ) => ( @@ -90,9 +95,7 @@ const Tabs = props => {
) }
- - - +
diff --git a/extensions/blocks/donations/view.js b/extensions/blocks/donations/view.js new file mode 100644 index 0000000000000..a2caf8f0d96b1 --- /dev/null +++ b/extensions/blocks/donations/view.js @@ -0,0 +1,105 @@ +/** + * External dependencies + */ +import domReady from '@wordpress/dom-ready'; + +/** + * Internal dependencies + */ +import { parseAmount } from './amount'; +import { minimumTransactionAmountForCurrency } from '../../shared/currencies'; + +/** + * Style dependencies + */ +import './view.scss'; +import formatCurrency from '@automattic/format-currency'; + +const initNavigation = () => { + let activeTab = 'one-time'; + const tabClasses = { + 'one-time': 'is-one-time', + '1 month': 'is-monthly', + '1 year': 'is-annual', + }; + + const navItems = document.querySelectorAll( '.wp-block-jetpack-donations .donations__nav-item' ); + const tabContent = document.querySelector( '.wp-block-jetpack-donations .donations__tab' ); + + navItems.forEach( navItem => { + navItem.addEventListener( 'click', event => { + // Toggle nav item. + document + .querySelector( '.wp-block-jetpack-donations .donations__nav-item.is-active' ) + .classList.remove( 'is-active' ); + event.target.classList.add( 'is-active' ); + + // Toggle tab. + tabContent.classList.remove( tabClasses[ activeTab ] ); + activeTab = event.target.dataset.interval; + tabContent.classList.add( tabClasses[ activeTab ] ); + } ); + } ); + + // Activates the default tab on first execution. + document + .querySelector( + `.wp-block-jetpack-donations .donations__nav-item[data-interval="${ activeTab }"]` + ) + .classList.add( 'is-active' ); + tabContent.classList.add( tabClasses[ activeTab ] ); +}; + +const handleCustomAmount = () => { + const input = document.querySelector( + '.wp-block-jetpack-donations .donations__custom-amount .donations__amount-value' + ); + if ( ! input ) { + return; + } + + const wrapper = document.querySelector( + '.wp-block-jetpack-donations .donations__custom-amount .wp-block-button__link' + ); + + // Prevent new lines. + input.addEventListener( 'keydown', event => { + if ( event.keyCode === 13 ) { + event.preventDefault(); + } + } ); + + // Add focus styles to wrapper element. + input.addEventListener( 'focus', () => wrapper.classList.add( 'has-focus' ) ); + input.addEventListener( 'blur', () => wrapper.classList.remove( 'has-focus' ) ); + + // Validates the amount. + input.addEventListener( 'input', () => { + const amount = input.innerHTML; + const currency = input.dataset.currency; + const parsedAmount = parseAmount( amount, currency ); + if ( parsedAmount && parsedAmount >= minimumTransactionAmountForCurrency( currency ) ) { + wrapper.classList.remove( 'has-error' ); + input.dataset.amount = parsedAmount; + } else if ( amount ) { + wrapper.classList.add( 'has-error' ); + delete input.dataset.amount; + } + } ); + + // Formats the entered amount. + input.addEventListener( 'blur', () => { + if ( ! input.dataset.amount ) { + return; + } + + input.innerHTML = formatCurrency( input.dataset.amount, input.dataset.currency, { + symbol: '', + } ); + } ); +}; + +domReady( () => { + initNavigation(); + handleCustomAmount(); +} ); diff --git a/extensions/blocks/donations/view.scss b/extensions/blocks/donations/view.scss new file mode 100644 index 0000000000000..e3a2303fcca01 --- /dev/null +++ b/extensions/blocks/donations/view.scss @@ -0,0 +1,37 @@ +@import '../../shared/styles/gutenberg-base-styles.scss'; +@import './common'; + +.wp-block-jetpack-donations { + .donations__one-time-item, + .donations__monthly-item, + .donations__annual-item { + display: none; + } + + .donations__tab { + &.is-one-time .donations__one-time-item, + &.is-monthly .donations__monthly-item, + &.is-annual .donations__annual-item { + display: block; + } + } + + .donations__amount:not( .donations__custom-amount ) .wp-block-button__link { + cursor: pointer; + } + + .donations__amount-value { + white-space: pre-wrap; + display: inline-block; + text-align: left; + + &:empty:after { + content: attr(data-placeholder); + color: $light-gray-700; + } + + &:focus { + outline: none; + } + } +}