From 8ed68be0687057b69b25473e72b2c7e213309041 Mon Sep 17 00:00:00 2001 From: Mikael Korpela Date: Tue, 6 Nov 2018 15:13:14 +0200 Subject: [PATCH] Simple Payments: gutenberg block UI (#28169) --- .../plugins/simple-payments/dialog/index.jsx | 5 +- .../simple-payments/dialog/list-item.jsx | 11 +- client/gutenberg/editor/edit-post/editor.js | 4 - .../extensions/presets/jetpack/editor.js | 1 + .../extensions/presets/jetpack/index.json | 3 +- .../extensions/simple-payments/edit.js | 583 ++++++++++++------ .../extensions/simple-payments/editor.js | 23 +- .../extensions/simple-payments/editor.scss | 83 +++ .../simple-payments/paypal-button.png | Bin 0 -> 7496 bytes .../simple-payments/paypal-button@2x.png | Bin 0 -> 8186 bytes .../simple-payments/product-placeholder.jsx | 61 ++ .../simple-payments/product-placeholder.scss | 64 ++ client/lib/simple-payments/constants.js | 6 +- config/development.json | 1 - 14 files changed, 618 insertions(+), 227 deletions(-) create mode 100644 client/gutenberg/extensions/simple-payments/editor.scss create mode 100644 client/gutenberg/extensions/simple-payments/paypal-button.png create mode 100644 client/gutenberg/extensions/simple-payments/paypal-button@2x.png create mode 100644 client/gutenberg/extensions/simple-payments/product-placeholder.jsx create mode 100644 client/gutenberg/extensions/simple-payments/product-placeholder.scss diff --git a/client/components/tinymce/plugins/simple-payments/dialog/index.jsx b/client/components/tinymce/plugins/simple-payments/dialog/index.jsx index ab665c53b63656..a792c7723b34a9 100644 --- a/client/components/tinymce/plugins/simple-payments/dialog/index.jsx +++ b/client/components/tinymce/plugins/simple-payments/dialog/index.jsx @@ -54,6 +54,7 @@ import { import EmptyContent from 'components/empty-content'; import Banner from 'components/banner'; import canCurrentUser from 'state/selectors/can-current-user'; +import { DEFAULT_CURRENCY } from 'lib/simple-payments/constants'; // Utility function for checking the state of the Payment Buttons list const isEmptyArray = a => Array.isArray( a ) && a.length === 0; @@ -152,7 +153,7 @@ class SimplePaymentsDialog extends Component { title: '', description: '', price: '', - currency: 'USD', + currency: DEFAULT_CURRENCY, multiple: false, email: '', featuredImageId: null, @@ -219,7 +220,7 @@ class SimplePaymentsDialog extends Component { } } - const initialCurrency = currencyCode || 'USD'; + const initialCurrency = currencyCode || DEFAULT_CURRENCY; const initialEmail = get( paymentButtons, '0.email', currentUserEmail ); return { ...initialFields, currency: initialCurrency, email: initialEmail }; diff --git a/client/components/tinymce/plugins/simple-payments/dialog/list-item.jsx b/client/components/tinymce/plugins/simple-payments/dialog/list-item.jsx index ce649ddaaa46bb..736f6bcd27bad6 100644 --- a/client/components/tinymce/plugins/simple-payments/dialog/list-item.jsx +++ b/client/components/tinymce/plugins/simple-payments/dialog/list-item.jsx @@ -1,8 +1,4 @@ -/** - * /* eslint-disable wpcalypso/jsx-classname-namespace - * - * @format - */ +/** @format */ /** * External dependencies @@ -23,6 +19,9 @@ import FormRadio from 'components/forms/form-radio'; import log from 'lib/catch-js-errors/log'; import PopoverMenuItem from 'components/popover/menu-item'; import ProductImage from './product-image'; +import { DEFAULT_CURRENCY } from 'lib/simple-payments/constants'; + +/* eslint-disable wpcalypso/jsx-classname-namespace */ class ProductListItem extends Component { static propTypes = { @@ -41,7 +40,7 @@ class ProductListItem extends Component { handleEditClick = () => this.props.onEditClick( this.props.paymentId ); handleTrashClick = () => this.props.onTrashClick( this.props.paymentId ); - formatPrice( price, currency = 'USD' ) { + formatPrice( price, currency = DEFAULT_CURRENCY ) { if ( isNaN( price ) ) { log( 'Simple Payments: invalid price value', { siteId: this.props.siteId, diff --git a/client/gutenberg/editor/edit-post/editor.js b/client/gutenberg/editor/edit-post/editor.js index 0e2f124cfc669b..4b04c1b6623cac 100644 --- a/client/gutenberg/editor/edit-post/editor.js +++ b/client/gutenberg/editor/edit-post/editor.js @@ -20,10 +20,6 @@ if ( isEnabled( 'gutenberg/block/jetpack-preset' ) ) { require( 'gutenberg/extensions/presets/jetpack/editor.js' ); } -if ( isEnabled('gutenberg/block/simple-payments') ) { - require( 'gutenberg/extensions/simple-payments/editor.js' ); -} - function Editor( { settings, hasFixedToolbar, post, overridePost, onError, ...props } ) { if ( ! post ) { return null; diff --git a/client/gutenberg/extensions/presets/jetpack/editor.js b/client/gutenberg/extensions/presets/jetpack/editor.js index 6acce98c7443fb..40f79bdf39c0e5 100644 --- a/client/gutenberg/extensions/presets/jetpack/editor.js +++ b/client/gutenberg/extensions/presets/jetpack/editor.js @@ -8,4 +8,5 @@ import './editor-shared/block-category'; // Register the Jetpack category import 'gutenberg/extensions/markdown/editor'; import 'gutenberg/extensions/publicize/editor'; import 'gutenberg/extensions/related-posts/editor'; +import 'gutenberg/extensions/simple-payments/editor'; import 'gutenberg/extensions/tiled-gallery/editor'; diff --git a/client/gutenberg/extensions/presets/jetpack/index.json b/client/gutenberg/extensions/presets/jetpack/index.json index 05a6236de4c479..5075a83384ad23 100644 --- a/client/gutenberg/extensions/presets/jetpack/index.json +++ b/client/gutenberg/extensions/presets/jetpack/index.json @@ -1,4 +1,5 @@ [ "markdown", - "publicize" + "publicize", + "simple-payments" ] diff --git a/client/gutenberg/extensions/simple-payments/edit.js b/client/gutenberg/extensions/simple-payments/edit.js index 231abdd9b7f29a..1d426c05ac13c0 100644 --- a/client/gutenberg/extensions/simple-payments/edit.js +++ b/client/gutenberg/extensions/simple-payments/edit.js @@ -3,282 +3,462 @@ /** * External dependencies */ -import { __ } from 'gutenberg/extensions/presets/jetpack/utils/i18n'; +import { __, _n, sprintf } from '@wordpress/i18n'; import { Component, Fragment } from '@wordpress/element'; import { compose, withInstanceId } from '@wordpress/compose'; -import { Panel, PanelBody, PanelRow } from '@wordpress/components'; +import { + ExternalLink, + PanelBody, + SelectControl, + TextareaControl, + TextControl, + ToggleControl, +} from '@wordpress/components'; +import { InspectorControls } from '@wordpress/editor'; import { withSelect } from '@wordpress/data'; import apiFetch from '@wordpress/api-fetch'; +import classNames from 'classnames'; +import emailValidator from 'email-validator'; import get from 'lodash/get'; +import trimEnd from 'lodash/trimEnd'; /** * Internal dependencies */ -import { SIMPLE_PAYMENTS_PRODUCT_POST_TYPE } from 'lib/simple-payments/constants'; +import { getCurrencyDefaults } from 'lib/format-currency'; +import { + SIMPLE_PAYMENTS_PRODUCT_POST_TYPE, + SUPPORTED_CURRENCY_LIST, +} from 'lib/simple-payments/constants'; +import ProductPlaceholder from './product-placeholder'; class SimplePaymentsEdit extends Component { state = { - savingProduct: false, + fieldEmailError: null, + fieldPriceError: null, + fieldTitleError: null, + isSavingProduct: false, }; componentDidUpdate( prevProps ) { - const { simplePayment, attributes, setAttributes, isSelected, isSaving } = this.props; + const { simplePayment, attributes, setAttributes, isSelected, isLoadingInitial } = this.props; + const { content, currency, email, multiple, price, title } = attributes; + // @TODO check componentDidMount for the case where post was already loaded if ( ! prevProps.simplePayment && simplePayment ) { setAttributes( { - description: get( simplePayment, [ 'content', 'raw' ], attributes.description ), - currency: get( simplePayment, [ 'meta', 'spay_currency' ], attributes.currency ), - email: get( simplePayment, [ 'meta', 'spay_email' ], attributes.email ), - formattedPrice: get( - simplePayment, - [ 'meta', 'spay_formatted_price' ], - attributes.formattedPrice - ), - multiple: get( simplePayment, [ 'meta', 'spay_multiple' ], attributes.multiple ), - price: get( simplePayment, [ 'meta', 'spay_price' ], attributes.price ), - title: get( simplePayment, [ 'title', 'raw' ], attributes.title ), + content: get( simplePayment, [ 'content', 'raw' ], content ), + currency: get( simplePayment, [ 'meta', 'spay_currency' ], currency ), + email: get( simplePayment, [ 'meta', 'spay_email' ], email ), + multiple: Boolean( get( simplePayment, [ 'meta', 'spay_multiple' ], Boolean( multiple ) ) ), + price: get( simplePayment, [ 'meta', 'spay_price' ], price || undefined ), + title: get( simplePayment, [ 'title', 'raw' ], title ), } ); } - // Saves on block-deselect and when editor is saving a post - if ( ( prevProps.isSelected && ! isSelected ) || ( prevProps.isSaving && ! isSaving ) ) { - this.savePayment(); + // Validate and save on block-deselect + if ( prevProps.isSelected && ! isSelected && ! isLoadingInitial ) { + this.saveProduct(); } } attributesToPost = attributes => { - const { title, description, currency, price, email, multiple } = attributes; + const { content, currency, email, multiple, price, title } = attributes; return { title, status: 'publish', - content: description, + content, featured_media: 0, meta: { spay_currency: currency, spay_email: email, - spay_formatted_price: this.formatPrice( price ), - spay_multiple: multiple, + spay_multiple: multiple ? 1 : 0, spay_price: price, }, }; }; - savePayment = async () => { - if ( this.state.savingProduct ) { + saveProduct() { + if ( this.state.isSavingProduct ) { return; } - this.setState( { savingProduct: true } ); + if ( ! this.validateAttributes() ) { + return; + } const { attributes, setAttributes } = this.props; - const { paymentId } = attributes; - - // @TODO field validation - - const path = `/wp/v2/${ SIMPLE_PAYMENTS_PRODUCT_POST_TYPE }/${ paymentId ? paymentId : '' }`; + const { email, paymentId } = attributes; - try { - // @TODO: then/catch - const { id } = await apiFetch( { - path, + this.setState( { isSavingProduct: true }, () => { + apiFetch( { + path: `/wp/v2/${ SIMPLE_PAYMENTS_PRODUCT_POST_TYPE }/${ paymentId ? paymentId : '' }`, method: 'POST', data: this.attributesToPost( attributes ), + } ) + .then( response => { + const { id } = response; + + if ( id ) { + setAttributes( { paymentId: id } ); + } + } ) + .catch( error => { + // @TODO: complete error handling + // eslint-disable-next-line + console.error( error ); + + const { + data: { key: apiErrorKey }, + } = error; + + // @TODO errors in other fields + this.setState( { + fieldEmailError: + apiErrorKey === 'spay_email' + ? sprintf( __( '%s is not a valid email address.' ), email ) + : null, + fieldPriceError: apiErrorKey === 'spay_price' ? __( 'Invalid price.' ) : null, + } ); + } ) + .finally( () => { + this.setState( { + isSavingProduct: false, + } ); + } ); + } ); + } + + // based on https://stackoverflow.com/a/10454560/59752 + decimalPlaces = number => { + const match = ( '' + number ).match( /(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/ ); + if ( ! match ) { + return 0; + } + return Math.max( 0, ( match[ 1 ] ? match[ 1 ].length : 0 ) - ( match[ 2 ] ? +match[ 2 ] : 0 ) ); + }; + + validateAttributes = () => { + const isPriceValid = this.validatePrice(); + const isTitleValid = this.validateTitle(); + const isEmailValid = this.validateEmail(); + const isCurrencyValid = this.validateCurrency(); + + return isPriceValid && isTitleValid && isEmailValid && isCurrencyValid; + }; + + /** + * Validate currency + * + * This method does not include validation UI. Currency selection should not allow for invalid + * values. It is primarily to ensure that the currency is valid to save. + * + * @return {boolean} True if currency is valid + */ + validateCurrency = () => { + const { currency } = this.props.attributes; + return SUPPORTED_CURRENCY_LIST.includes( currency ); + }; + + /** + * Validate price + * + * Stores error message in state.fieldPriceError + * + * @returns {Boolean} True when valid, false when invalid + */ + validatePrice = () => { + const { currency, price } = this.props.attributes; + const { precision } = getCurrencyDefaults( currency ); + + if ( ! price || parseFloat( price ) === 0 ) { + this.setState( { + fieldPriceError: __( 'Everything comes with a price tag these days. Add yours here.' ), } ); + return false; + } - this.setState( { savingProduct: false } ); + if ( Number.isNaN( parseFloat( price ) ) ) { + this.setState( { + fieldPriceError: __( 'Invalid price' ), + } ); + return false; + } + + if ( parseFloat( price ) < 0 ) { + this.setState( { + fieldPriceError: __( "Your price is negative — now that doesn't sound right, does it?" ), + } ); + return false; + } - if ( id ) { - setAttributes( { paymentId: id } ); + if ( this.decimalPlaces( price ) > precision ) { + if ( precision === 0 ) { + this.setState( { + fieldPriceError: __( + "We know every penny counts, but prices can't contain decimal values." + ), + } ); + return false; } - } catch ( err ) { - // @TODO: error handling - this.setState( { savingProduct: false } ); + + this.setState( { + fieldPriceError: sprintf( + _n( + 'Price cannot have more than %d decimal place.', + 'Price cannot have more than %d decimal places.', + precision + ), + precision + ), + } ); + return false; + } + + if ( this.state.fieldPriceError ) { + this.setState( { fieldPriceError: null } ); } + + return true; }; - // @FIXME: toFixed should be replaced with proper decimal calculations. See simple-payments/form - // const { precision } = getCurrencyDefaults( values.currency ); - formatPrice = price => { - price = parseInt( price, 10 ); - return ! isNaN( price ) ? '$' + price.toFixed( 2 ) : ''; + /** + * Validate email + * + * Stores error message in state.fieldEmailError + * + * @returns {Boolean} True when valid, false when invalid + */ + validateEmail = () => { + const { email } = this.props.attributes; + if ( ! email ) { + this.setState( { + fieldEmailError: __( + 'We want to make sure payments reach you, so please add an email address.', + 'jetpack' + ), + } ); + return false; + } + + if ( ! emailValidator.validate( email ) ) { + this.setState( { + fieldEmailError: sprintf( __( '%s is not a valid email address.' ), email ), + } ); + return false; + } + + if ( this.state.fieldEmailError ) { + this.setState( { fieldEmailError: null } ); + } + + return true; }; - handleEmailChange = event => { - this.props.setAttributes( { email: event.target.value } ); + /** + * Validate title + * + * Stores error message in state.fieldTitleError + * + * @returns {Boolean} True when valid, false when invalid + */ + validateTitle = () => { + const { title } = this.props.attributes; + if ( ! title ) { + this.setState( { + fieldTitleError: __( + "People need to know what they're paying for! Please add a brief title.", + 'jetpack' + ), + } ); + return false; + } + + if ( this.state.fieldTitleError ) { + this.setState( { fieldTitleError: null } ); + } + + return true; }; - handleDescriptionChange = event => { - this.props.setAttributes( { description: event.target.value } ); + handleEmailChange = email => { + this.props.setAttributes( { email } ); }; - handlePriceChange = event => { - const price = parseInt( event.target.value, 10 ); + handleContentChange = content => { + this.props.setAttributes( { content } ); + }; + + handlePriceChange = price => { + price = parseFloat( price ); if ( ! isNaN( price ) ) { - this.props.setAttributes( { - formattedPrice: this.formatPrice( event.target.value ), - price, - } ); + this.props.setAttributes( { price } ); } else { - this.props.setAttributes( { - formattedPrice: '', - price: undefined, - } ); + this.props.setAttributes( { price: undefined } ); } }; - handleCurrencyChange = event => { - this.props.setAttributes( { - currency: event.target.value, - formattedPrice: this.formatPrice( this.props.attributes.price ), - } ); + handleCurrencyChange = currency => { + this.props.setAttributes( { currency } ); + }; + + handleMultipleChange = multiple => { + this.props.setAttributes( { multiple: !! multiple } ); }; - handleMultipleChange = event => { - this.props.setAttributes( { multiple: event.target.checked ? 1 : 0 } ); + handleTitleChange = title => { + this.props.setAttributes( { title } ); }; - handleTitleChange = event => { - this.props.setAttributes( { title: event.target.value } ); + formatPrice = ( price, currency, withSymbol = true ) => { + const { precision, symbol } = getCurrencyDefaults( currency ); + const value = price.toFixed( precision ); + // Trim the dot at the end of symbol, e.g., 'kr.' becomes 'kr' + return withSymbol ? `${ value } ${ trimEnd( symbol, '.' ) }` : value; }; + getCurrencyList = SUPPORTED_CURRENCY_LIST.map( value => { + const { symbol } = getCurrencyDefaults( value ); + // if symbol is equal to the code (e.g., 'CHF' === 'CHF'), don't duplicate it. + // trim the dot at the end, e.g., 'kr.' becomes 'kr' + const label = symbol === value ? value : `${ value } ${ trimEnd( symbol, '.' ) }`; + return { value, label }; + } ); + render() { - const { attributes, instanceId, isSelected } = this.props; - const { - currency, - description, - email, - formattedPrice, - multiple, - paymentId, - price, - title, - } = attributes; - const baseId = `simplepayments-${ instanceId }`; - const currencyId = `${ baseId }__currency`; - const descriptionId = `${ baseId }__description`; - const emailId = `${ baseId }__email`; - const multipleId = `${ baseId }__multiple`; - const priceId = `${ baseId }__price`; - const titleId = `${ baseId }__title`; - - if ( ! isSelected ) { - // @TODO component + const { fieldEmailError, fieldPriceError, fieldTitleError } = this.state; + const { attributes, isSelected, isLoadingInitial, instanceId } = this.props; + const { content, currency, email, multiple, price, title } = attributes; + + if ( ! isSelected && isLoadingInitial ) { + return ( +
+ +
+ ); + } + + if ( + ! isSelected && + email && + price && + title && + ! fieldEmailError && + ! fieldPriceError && + ! fieldTitleError + ) { return ( - - - - - paymentId: - { paymentId || 'N/A' } - - - title: - { title || 'N/A' } - - - description: - { description || 'N/A' } - - - currency: - { currency || 'N/A' } - - - price: - { price || 'N/A' } - - - formattedPrice: - { formattedPrice || 'N/A' } - - - multiple: - { multiple || 'N/A' } - - - email: - { email || 'N/A' } - - - - + ); } return ( - - - - - paymentId: { paymentId || 'N/A' } - - - - - - - - -