diff --git a/packages/js/product-editor/changelog/add-37984 b/packages/js/product-editor/changelog/add-37984 new file mode 100644 index 0000000000000..6a9cf36204531 --- /dev/null +++ b/packages/js/product-editor/changelog/add-37984 @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Fix validation behavior#37984 diff --git a/packages/js/product-editor/src/blocks/inventory-quantity/edit.tsx b/packages/js/product-editor/src/blocks/inventory-quantity/edit.tsx index 84ae08c9aa672..935e7d1bc3024 100644 --- a/packages/js/product-editor/src/blocks/inventory-quantity/edit.tsx +++ b/packages/js/product-editor/src/blocks/inventory-quantity/edit.tsx @@ -1,6 +1,7 @@ /** * External dependencies */ +import { Product } from '@woocommerce/data'; import { BlockEditProps } from '@wordpress/blocks'; import { useBlockProps } from '@wordpress/block-editor'; import { useInstanceId } from '@wordpress/compose'; @@ -17,10 +18,11 @@ import { * Internal dependencies */ import { TrackInventoryBlockAttributes } from './types'; +import { useValidation } from '../../contexts/validation-context'; -import { useValidation } from '../../hooks/use-validation'; - -export function Edit( {}: BlockEditProps< TrackInventoryBlockAttributes > ) { +export function Edit( { + clientId, +}: BlockEditProps< TrackInventoryBlockAttributes > ) { const blockProps = useBlockProps(); const [ manageStock ] = useEntityProp< boolean >( @@ -40,16 +42,21 @@ export function Edit( {}: BlockEditProps< TrackInventoryBlockAttributes > ) { 'product_stock_quantity' ) as string; - const stockQuantityValidationError = useValidation( - 'product/stock_quantity', - function stockQuantityValidator() { + const { + ref: stockQuantityRef, + error: stockQuantityValidationError, + validate: validateStockQuantity, + } = useValidation< Product >( + `stock_quantity-${ clientId }`, + async function stockQuantityValidator() { if ( manageStock && stockQuantity && stockQuantity < 0 ) { return __( 'Stock quantity must be a positive number.', 'woocommerce' ); } - } + }, + [ manageStock, stockQuantity ] ); useEffect( () => { @@ -72,9 +79,11 @@ export function Edit( {}: BlockEditProps< TrackInventoryBlockAttributes > ) { diff --git a/packages/js/product-editor/src/blocks/name/edit.tsx b/packages/js/product-editor/src/blocks/name/edit.tsx index d1c257c6b2274..753e9f8db79f1 100644 --- a/packages/js/product-editor/src/blocks/name/edit.tsx +++ b/packages/js/product-editor/src/blocks/name/edit.tsx @@ -35,7 +35,7 @@ import { useEntityProp, useEntityId } from '@wordpress/core-data'; */ import { AUTO_DRAFT_NAME } from '../../utils'; import { EditProductLinkModal } from '../../components/edit-product-link-modal'; -import { useValidation } from '../../hooks/use-validation'; +import { useValidation } from '../../contexts/validation-context'; export function Edit() { const blockProps = useBlockProps(); @@ -77,9 +77,13 @@ export function Edit() { } ); - const nameValidationError = useValidation( - 'product/name', - function nameValidator() { + const { + ref: nameRef, + error: nameValidationError, + validate: validateName, + } = useValidation< Product >( + 'name', + async function nameValidator() { if ( ! name || name === AUTO_DRAFT_NAME ) { return __( 'This field is required.', 'woocommerce' ); } @@ -90,7 +94,8 @@ export function Edit() { 'woocommerce' ); } - } + }, + [ name ] ); const setSkuIfEmpty = () => { @@ -153,6 +158,7 @@ export function Edit() { > { + setSkuIfEmpty(); + validateName(); + } } /> diff --git a/packages/js/product-editor/src/blocks/regular-price/edit.tsx b/packages/js/product-editor/src/blocks/regular-price/edit.tsx index 96cfc495a603b..2e188b293be66 100644 --- a/packages/js/product-editor/src/blocks/regular-price/edit.tsx +++ b/packages/js/product-editor/src/blocks/regular-price/edit.tsx @@ -4,6 +4,7 @@ import classNames from 'classnames'; import { Link } from '@woocommerce/components'; import { CurrencyContext } from '@woocommerce/currency'; +import { Product } from '@woocommerce/data'; import { getNewPath } from '@woocommerce/navigation'; import { recordEvent } from '@woocommerce/tracks'; import { useBlockProps } from '@wordpress/block-editor'; @@ -28,10 +29,11 @@ import { import { useCurrencyInputProps } from '../../hooks/use-currency-input-props'; import { formatCurrencyDisplayValue } from '../../utils'; import { SalePriceBlockAttributes } from './types'; -import { useValidation } from '../../hooks/use-validation'; +import { useValidation } from '../../contexts/validation-context'; export function Edit( { attributes, + clientId, }: BlockEditProps< SalePriceBlockAttributes > ) { const blockProps = useBlockProps(); const { label, help } = attributes; @@ -71,9 +73,13 @@ export function Edit( { 'wp-block-woocommerce-product-regular-price-field' ) as string; - const regularPriceValidationError = useValidation( - 'product/regular_price', - function regularPriceValidator() { + const { + ref: regularPriceRef, + error: regularPriceValidationError, + validate: validateRegularPrice, + } = useValidation< Product >( + `regular_price-${ clientId }`, + async function regularPriceValidator() { const listPrice = Number.parseFloat( regularPrice ); if ( listPrice ) { if ( listPrice < 0 ) { @@ -92,7 +98,8 @@ export function Edit( { ); } } - } + }, + [ regularPrice, salePrice ] ); return ( @@ -112,6 +119,7 @@ export function Edit( { { ...inputProps } id={ regularPriceId } name={ 'regular_price' } + ref={ regularPriceRef } label={ label } value={ formatCurrencyDisplayValue( String( regularPrice ), @@ -119,6 +127,7 @@ export function Edit( { formatAmount ) } onChange={ setRegularPrice } + onBlur={ validateRegularPrice } /> diff --git a/packages/js/product-editor/src/blocks/sale-price/edit.tsx b/packages/js/product-editor/src/blocks/sale-price/edit.tsx index b4104aedd34de..264581a343904 100644 --- a/packages/js/product-editor/src/blocks/sale-price/edit.tsx +++ b/packages/js/product-editor/src/blocks/sale-price/edit.tsx @@ -3,6 +3,7 @@ */ import classNames from 'classnames'; import { CurrencyContext } from '@woocommerce/currency'; +import { Product } from '@woocommerce/data'; import { useBlockProps } from '@wordpress/block-editor'; import { BlockEditProps } from '@wordpress/blocks'; import { useInstanceId } from '@wordpress/compose'; @@ -21,10 +22,11 @@ import { import { useCurrencyInputProps } from '../../hooks/use-currency-input-props'; import { formatCurrencyDisplayValue } from '../../utils'; import { SalePriceBlockAttributes } from './types'; -import { useValidation } from '../../hooks/use-validation'; +import { useValidation } from '../../contexts/validation-context'; export function Edit( { attributes, + clientId, }: BlockEditProps< SalePriceBlockAttributes > ) { const blockProps = useBlockProps(); const { label, help } = attributes; @@ -51,9 +53,13 @@ export function Edit( { 'wp-block-woocommerce-product-sale-price-field' ) as string; - const salePriceValidationError = useValidation( - 'product/sale_price', - function salePriceValidator() { + const { + ref: salePriceRef, + error: salePriceValidationError, + validate: validateSalePrice, + } = useValidation< Product >( + `sale-price-${ clientId }`, + async function salePriceValidator() { if ( salePrice ) { if ( Number.parseFloat( salePrice ) < 0 ) { return __( @@ -72,7 +78,8 @@ export function Edit( { ); } } - } + }, + [ regularPrice, salePrice ] ); return ( @@ -90,6 +97,7 @@ export function Edit( { { ...inputProps } id={ salePriceId } name={ 'sale_price' } + ref={ salePriceRef } onChange={ setSalePrice } label={ label } value={ formatCurrencyDisplayValue( @@ -97,6 +105,7 @@ export function Edit( { currencyConfig, formatAmount ) } + onBlur={ validateSalePrice } /> diff --git a/packages/js/product-editor/src/blocks/schedule-sale/edit.tsx b/packages/js/product-editor/src/blocks/schedule-sale/edit.tsx index da1686ba1d60e..4b3eb32213280 100644 --- a/packages/js/product-editor/src/blocks/schedule-sale/edit.tsx +++ b/packages/js/product-editor/src/blocks/schedule-sale/edit.tsx @@ -2,6 +2,7 @@ * External dependencies */ import { DateTimePickerControl } from '@woocommerce/components'; +import { Product } from '@woocommerce/data'; import { recordEvent } from '@woocommerce/tracks'; import { useBlockProps } from '@wordpress/block-editor'; import { BlockEditProps } from '@wordpress/blocks'; @@ -19,9 +20,11 @@ import { getSettings } from '@wordpress/date'; * Internal dependencies */ import { ScheduleSalePricingBlockAttributes } from './types'; -import { useValidation } from '../../hooks/use-validation'; +import { useValidation } from '../../contexts/validation-context'; -export function Edit( {}: BlockEditProps< ScheduleSalePricingBlockAttributes > ) { +export function Edit( { + clientId, +}: BlockEditProps< ScheduleSalePricingBlockAttributes > ) { const blockProps = useBlockProps(); const dateTimeFormat = getSettings().formats.datetime; @@ -84,9 +87,13 @@ export function Edit( {}: BlockEditProps< ScheduleSalePricingBlockAttributes > ) const _dateOnSaleFrom = moment( dateOnSaleFromGmt, moment.ISO_8601, true ); const _dateOnSaleTo = moment( dateOnSaleToGmt, moment.ISO_8601, true ); - const dateOnSaleFromGmtValidationError = useValidation( - 'product/date_on_sale_from_gmt', - function dateOnSaleFromValidator() { + const { + // ref: dateOnSaleFromGmtRef, + error: dateOnSaleFromGmtValidationError, + validate: validateDateOnSaleFromGmt, + } = useValidation< Product >( + `date_on_sale_from_gmt-${ clientId }`, + async function dateOnSaleFromValidator() { if ( showScheduleSale && dateOnSaleFromGmt ) { if ( ! _dateOnSaleFrom.isValid() ) { return __( 'Please enter a valid date.', 'woocommerce' ); @@ -99,12 +106,17 @@ export function Edit( {}: BlockEditProps< ScheduleSalePricingBlockAttributes > ) ); } } - } + }, + [ showScheduleSale, dateOnSaleFromGmt, _dateOnSaleFrom, _dateOnSaleTo ] ); - const dateOnSaleToGmtValidationError = useValidation( - 'product/date_on_sale_to_gmt', - function dateOnSaleToValidator() { + const { + // ref: dateOnSaleToGmtRef, + error: dateOnSaleToGmtValidationError, + validate: validateDateOnSaleToGmt, + } = useValidation< Product >( + `date_on_sale_to_gmt-${ clientId }`, + async function dateOnSaleToValidator() { if ( showScheduleSale && dateOnSaleToGmt ) { if ( ! _dateOnSaleTo.isValid() ) { return __( 'Please enter a valid date.', 'woocommerce' ); @@ -117,7 +129,8 @@ export function Edit( {}: BlockEditProps< ScheduleSalePricingBlockAttributes > ) ); } } - } + }, + [ showScheduleSale, dateOnSaleFromGmt, _dateOnSaleFrom, _dateOnSaleTo ] ); return ( @@ -133,6 +146,7 @@ export function Edit( {}: BlockEditProps< ScheduleSalePricingBlockAttributes > )
) dateOnSaleFromGmtValidationError && 'has-error' } help={ dateOnSaleFromGmtValidationError as string } + onBlur={ validateDateOnSaleFromGmt } />
) .toISOString() ) } + onBlur={ validateDateOnSaleToGmt } className={ dateOnSaleToGmtValidationError && 'has-error' } diff --git a/packages/js/product-editor/src/blocks/shipping-dimensions/edit.tsx b/packages/js/product-editor/src/blocks/shipping-dimensions/edit.tsx index 80f09571c9543..16a82d972deef 100644 --- a/packages/js/product-editor/src/blocks/shipping-dimensions/edit.tsx +++ b/packages/js/product-editor/src/blocks/shipping-dimensions/edit.tsx @@ -3,7 +3,11 @@ */ import { useBlockProps } from '@wordpress/block-editor'; import { BlockEditProps } from '@wordpress/blocks'; -import { OPTIONS_STORE_NAME, ProductDimensions } from '@woocommerce/data'; +import { + OPTIONS_STORE_NAME, + Product, + ProductDimensions, +} from '@woocommerce/data'; import { useInstanceId } from '@wordpress/compose'; import { useEntityProp } from '@wordpress/core-data'; import { useSelect } from '@wordpress/data'; @@ -29,9 +33,11 @@ import { HighlightSides, ShippingDimensionsImage, } from '../../components/shipping-dimensions-image'; -import { useValidation } from '../../hooks/use-validation'; +import { useValidation } from '../../contexts/validation-context'; -export function Edit( {}: BlockEditProps< ShippingDimensionsBlockAttributes > ) { +export function Edit( { + clientId, +}: BlockEditProps< ShippingDimensionsBlockAttributes > ) { const blockProps = useBlockProps(); const [ dimensions, setDimensions ] = @@ -79,12 +85,70 @@ export function Edit( {}: BlockEditProps< ShippingDimensionsBlockAttributes > ) }; } + const { + ref: dimensionsWidthRef, + error: dimensionsWidthValidationError, + validate: validateDimensionsWidth, + } = useValidation< Product >( + `dimensions_width-${ clientId }`, + async function dimensionsWidthValidator() { + if ( dimensions?.width && +dimensions.width <= 0 ) { + return __( 'Width must be greater than zero.', 'woocommerce' ); + } + }, + [ dimensions?.width ] + ); + + const { + ref: dimensionsLengthRef, + error: dimensionsLengthValidationError, + validate: validateDimensionsLength, + } = useValidation< Product >( + `dimensions_length-${ clientId }`, + async function dimensionsLengthValidator() { + if ( dimensions?.length && +dimensions.length <= 0 ) { + return __( 'Length must be greater than zero.', 'woocommerce' ); + } + }, + [ dimensions?.length ] + ); + + const { + ref: dimensionsHeightRef, + error: dimensionsHeightValidationError, + validate: validateDimensionsHeight, + } = useValidation< Product >( + `dimensions_height-${ clientId }`, + async function dimensionsHeightValidator() { + if ( dimensions?.height && +dimensions.height <= 0 ) { + return __( 'Height must be greater than zero.', 'woocommerce' ); + } + }, + [ dimensions?.height ] + ); + + const { + ref: weightRef, + error: weightValidationError, + validate: validateWeight, + } = useValidation< Product >( + `weight-${ clientId }`, + async function weightValidator() { + if ( weight && +weight <= 0 ) { + return __( 'Weight must be greater than zero.', 'woocommerce' ); + } + }, + [ weight ] + ); + const dimensionsWidthProps = { ...getDimensionsControlProps( 'width', 'A' ), id: useInstanceId( BaseControl, `product_shipping_dimensions_width` ) as string, + ref: dimensionsWidthRef, + onBlur: validateDimensionsWidth, }; const dimensionsLengthProps = { ...getDimensionsControlProps( 'length', 'B' ), @@ -92,6 +156,8 @@ export function Edit( {}: BlockEditProps< ShippingDimensionsBlockAttributes > ) BaseControl, `product_shipping_dimensions_length` ) as string, + ref: dimensionsLengthRef, + onBlur: validateDimensionsLength, }; const dimensionsHeightProps = { ...getDimensionsControlProps( 'height', 'C' ), @@ -99,6 +165,8 @@ export function Edit( {}: BlockEditProps< ShippingDimensionsBlockAttributes > ) BaseControl, `product_shipping_dimensions_height` ) as string, + ref: dimensionsHeightRef, + onBlur: validateDimensionsHeight, }; const weightProps = { id: useInstanceId( BaseControl, `product_shipping_weight` ) as string, @@ -106,41 +174,10 @@ export function Edit( {}: BlockEditProps< ShippingDimensionsBlockAttributes > ) value: formatNumber( String( weight ) ), onChange: ( value: string ) => setWeight( parseNumber( value ) ), suffix: weightUnit, + ref: weightRef, + onBlur: validateWeight, }; - const dimensionsWidthValidationError = useValidation( - 'product/dimensions/width', - function dimensionsWidthValidator() { - if ( dimensions?.width && +dimensions.width <= 0 ) { - return __( 'Width must be greater than zero.', 'woocommerce' ); - } - } - ); - const dimensionsLengthValidationError = useValidation( - 'product/dimensions/length', - function dimensionsLengthValidator() { - if ( dimensions?.length && +dimensions.length <= 0 ) { - return __( 'Length must be greater than zero.', 'woocommerce' ); - } - } - ); - const dimensionsHeightValidationError = useValidation( - 'product/dimensions/height', - function dimensionsHeightValidator() { - if ( dimensions?.height && +dimensions.height <= 0 ) { - return __( 'Height must be greater than zero.', 'woocommerce' ); - } - } - ); - const weightValidationError = useValidation( - 'product/weight', - function weightValidator() { - if ( weight && +weight <= 0 ) { - return __( 'Weight must be greater than zero.', 'woocommerce' ); - } - } - ); - return (

{ __( 'Dimensions', 'woocommerce' ) }

diff --git a/packages/js/product-editor/src/components/editor/editor.tsx b/packages/js/product-editor/src/components/editor/editor.tsx index 69ca8144bbe42..b8a27b4366d14 100644 --- a/packages/js/product-editor/src/components/editor/editor.tsx +++ b/packages/js/product-editor/src/components/editor/editor.tsx @@ -36,6 +36,7 @@ import { FullscreenMode, InterfaceSkeleton } from '@wordpress/interface'; */ import { Header } from '../header'; import { BlockEditor } from '../block-editor'; +import { ValidationProvider } from '../../contexts/validation-context'; export type ProductEditorSettings = Partial< EditorSettings & EditorBlockListSettings @@ -62,31 +63,33 @@ export function Editor( { product, settings }: EditorProps ) { - - } - content={ - <> - + - { /* @ts-expect-error 'scope' does exist. @types/wordpress__plugins is outdated. */ } - - - } - /> + } + content={ + <> + + { /* @ts-expect-error 'scope' does exist. @types/wordpress__plugins is outdated. */ } + + + } + /> - + + diff --git a/packages/js/product-editor/src/components/header/hooks/use-preview/use-preview.tsx b/packages/js/product-editor/src/components/header/hooks/use-preview/use-preview.tsx index 4ea6a32af8181..54f93a9b347f4 100644 --- a/packages/js/product-editor/src/components/header/hooks/use-preview/use-preview.tsx +++ b/packages/js/product-editor/src/components/header/hooks/use-preview/use-preview.tsx @@ -9,6 +9,11 @@ import { useRef } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { MouseEvent } from 'react'; +/** + * Internal dependencies + */ +import { useValidations } from '../../../../contexts/validation-context'; + export function usePreview( { disabled, onClick, @@ -41,8 +46,6 @@ export function usePreview( { ( select ) => { const { hasEditsForEntityRecord, isSavingEntityRecord } = select( 'core' ); - const { isPostSavingLocked } = select( 'core/editor' ); - const isSavingLocked = isPostSavingLocked(); const isSaving = isSavingEntityRecord< boolean >( 'postType', 'product', @@ -50,7 +53,7 @@ export function usePreview( { ); return { - isDisabled: isSavingLocked || isSaving, + isDisabled: isSaving, hasEdits: hasEditsForEntityRecord< boolean >( 'postType', 'product', @@ -61,7 +64,9 @@ export function usePreview( { [ productId ] ); - const ariaDisabled = disabled || isDisabled; + const { isValidating, validate } = useValidations(); + + const ariaDisabled = disabled || isDisabled || isValidating; const { editEntityRecord, saveEditedEntityRecord } = useDispatch( 'core' ); @@ -97,6 +102,8 @@ export function usePreview( { event.preventDefault(); try { + await validate(); + // If the product status is `auto-draft` it's not possible to // reach the preview page, so the status is changed to `draft` // before redirecting. diff --git a/packages/js/product-editor/src/components/header/hooks/use-publish/use-publish.tsx b/packages/js/product-editor/src/components/header/hooks/use-publish/use-publish.tsx index ba149acd9f6df..19218f0bcddbe 100644 --- a/packages/js/product-editor/src/components/header/hooks/use-publish/use-publish.tsx +++ b/packages/js/product-editor/src/components/header/hooks/use-publish/use-publish.tsx @@ -8,6 +8,11 @@ import { useDispatch, useSelect } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; import { MouseEvent } from 'react'; +/** + * Internal dependencies + */ +import { useValidations } from '../../../../contexts/validation-context'; + export function usePublish( { disabled, onClick, @@ -29,22 +34,14 @@ export function usePublish( { 'status' ); - const { hasEdits, isDisabled, isBusy } = useSelect( + const { isValidating, validate } = useValidations(); + + const { isSaving } = useSelect( ( select ) => { - const { hasEditsForEntityRecord, isSavingEntityRecord } = - select( 'core' ); - const { isPostSavingLocked } = select( 'core/editor' ); - const isSavingLocked = isPostSavingLocked(); - const isSaving = isSavingEntityRecord< boolean >( - 'postType', - 'product', - productId - ); + const { isSavingEntityRecord } = select( 'core' ); return { - isDisabled: isSavingLocked || isSaving, - isBusy: isSaving, - hasEdits: hasEditsForEntityRecord< boolean >( + isSaving: isSavingEntityRecord< boolean >( 'postType', 'product', productId @@ -54,22 +51,20 @@ export function usePublish( { [ productId ] ); + const isBusy = isSaving || isValidating; + const isCreating = productStatus === 'auto-draft'; - const ariaDisabled = - disabled || isDisabled || ( productStatus === 'publish' && ! hasEdits ); const { editEntityRecord, saveEditedEntityRecord } = useDispatch( 'core' ); async function handleClick( event: MouseEvent< HTMLButtonElement > ) { - if ( ariaDisabled ) { - return event.preventDefault(); - } - if ( onClick ) { onClick( event ); } try { + await validate(); + // The publish button click not only change the status of the product // but also save all the pending changes. So even if the status is // publish it's possible to save the product too. @@ -85,7 +80,7 @@ export function usePublish( { productId ); - if ( onPublishSuccess ) { + if ( publishedProduct && onPublishSuccess ) { onPublishSuccess( publishedProduct ); } } catch ( error ) { @@ -100,7 +95,6 @@ export function usePublish( { ? __( 'Add', 'woocommerce' ) : __( 'Save', 'woocommerce' ), ...props, - 'aria-disabled': ariaDisabled, isBusy, variant: 'primary', onClick: handleClick, diff --git a/packages/js/product-editor/src/components/header/hooks/use-save-draft/use-save-draft.tsx b/packages/js/product-editor/src/components/header/hooks/use-save-draft/use-save-draft.tsx index 06aa6b00312ab..3a76c532f6fd7 100644 --- a/packages/js/product-editor/src/components/header/hooks/use-save-draft/use-save-draft.tsx +++ b/packages/js/product-editor/src/components/header/hooks/use-save-draft/use-save-draft.tsx @@ -10,6 +10,11 @@ import { check } from '@wordpress/icons'; import { createElement, Fragment } from '@wordpress/element'; import { MouseEvent, ReactNode } from 'react'; +/** + * Internal dependencies + */ +import { useValidations } from '../../../../contexts/validation-context'; + export function useSaveDraft( { disabled, onClick, @@ -35,8 +40,6 @@ export function useSaveDraft( { ( select ) => { const { hasEditsForEntityRecord, isSavingEntityRecord } = select( 'core' ); - const { isPostSavingLocked } = select( 'core/editor' ); - const isSavingLocked = isPostSavingLocked(); const isSaving = isSavingEntityRecord< boolean >( 'postType', 'product', @@ -44,7 +47,7 @@ export function useSaveDraft( { ); return { - isDisabled: isSavingLocked || isSaving, + isDisabled: isSaving, hasEdits: hasEditsForEntityRecord< boolean >( 'postType', 'product', @@ -55,8 +58,13 @@ export function useSaveDraft( { [ productId ] ); + const { isValidating, validate } = useValidations(); + const ariaDisabled = - disabled || isDisabled || ( productStatus !== 'publish' && ! hasEdits ); + disabled || + isDisabled || + ( productStatus !== 'publish' && ! hasEdits ) || + isValidating; const { editEntityRecord, saveEditedEntityRecord } = useDispatch( 'core' ); @@ -70,6 +78,8 @@ export function useSaveDraft( { } try { + await validate(); + await editEntityRecord( 'postType', 'product', productId, { status: 'draft', } ); diff --git a/packages/js/product-editor/src/contexts/validation-context/helpers.ts b/packages/js/product-editor/src/contexts/validation-context/helpers.ts new file mode 100644 index 0000000000000..88068c73bcc52 --- /dev/null +++ b/packages/js/product-editor/src/contexts/validation-context/helpers.ts @@ -0,0 +1,33 @@ +/** + * Internal dependencies + */ +import { ValidationErrors } from './types'; + +export function findFirstInvalidElement< E extends Element = Element >( + elementsMap: Record< string, E >, + errors: ValidationErrors +): E | undefined { + const fieldRefsWithError = Object.entries( elementsMap ).filter( + ( [ validatorId, element ] ) => + // Pick the element if it is under the selected tab. + element?.closest( '.is-selected[role="tabpanel"]' ) && + Boolean( errors[ validatorId ] ) + ); + + const [ firstFieldRefWithError ] = fieldRefsWithError.sort( + ( [ , firstElement ], [ , secondElement ] ) => { + if ( + // eslint-disable-next-line no-bitwise + firstElement.compareDocumentPosition( secondElement ) & + Node.DOCUMENT_POSITION_FOLLOWING + ) { + return -1; + } + return 1; + } + ); + + const [ , firstElementWithError ] = firstFieldRefWithError ?? []; + + return firstElementWithError; +} diff --git a/packages/js/product-editor/src/contexts/validation-context/index.ts b/packages/js/product-editor/src/contexts/validation-context/index.ts new file mode 100644 index 0000000000000..ecee449db60a0 --- /dev/null +++ b/packages/js/product-editor/src/contexts/validation-context/index.ts @@ -0,0 +1,4 @@ +export * from './use-validation'; +export * from './use-validations'; +export * from './validation-provider'; +export * from './types'; diff --git a/packages/js/product-editor/src/contexts/validation-context/types.ts b/packages/js/product-editor/src/contexts/validation-context/types.ts new file mode 100644 index 0000000000000..30aa1b8241be3 --- /dev/null +++ b/packages/js/product-editor/src/contexts/validation-context/types.ts @@ -0,0 +1,27 @@ +export type ValidatorResponse = Promise< ValidationError >; + +export type Validator< T > = ( initialValue?: T ) => ValidatorResponse; + +export type ValidationContextProps< T > = { + errors: ValidationErrors; + registerValidator( + validatorId: string, + validator: Validator< T > + ): React.Ref< HTMLElement >; + validateField( name: string ): ValidatorResponse; + validateAll(): Promise< ValidationErrors >; +}; + +export type ValidationProviderProps< T > = { + initialValue?: T; +}; + +export type ValidationError = string | undefined; +export type ValidationErrors = Record< string, ValidationError >; + +export type ValidatorRegistration = { + name: string; + ref: React.Ref< HTMLElement >; + error?: ValidationError; + validate(): ValidatorResponse; +}; diff --git a/packages/js/product-editor/src/contexts/validation-context/use-validation.ts b/packages/js/product-editor/src/contexts/validation-context/use-validation.ts new file mode 100644 index 0000000000000..3f4ed824e501f --- /dev/null +++ b/packages/js/product-editor/src/contexts/validation-context/use-validation.ts @@ -0,0 +1,37 @@ +/** + * External dependencies + */ +import { useContext, useMemo, useState } from '@wordpress/element'; +import { DependencyList } from 'react'; + +/** + * Internal dependencies + */ +import { Validator } from './types'; +import { ValidationContext } from './validation-context'; + +export function useValidation< T >( + validatorId: string, + validator: Validator< T >, + deps: DependencyList = [] +) { + const context = useContext( ValidationContext ); + const [ isValidating, setIsValidating ] = useState( false ); + + const ref = useMemo( + () => context.registerValidator( validatorId, validator ), + [ validatorId, ...deps ] + ); + + return { + ref, + error: context.errors[ validatorId ], + isValidating, + async validate() { + setIsValidating( true ); + return context.validateField( validatorId ).finally( () => { + setIsValidating( false ); + } ); + }, + }; +} diff --git a/packages/js/product-editor/src/contexts/validation-context/use-validations.ts b/packages/js/product-editor/src/contexts/validation-context/use-validations.ts new file mode 100644 index 0000000000000..9efff3443aa8d --- /dev/null +++ b/packages/js/product-editor/src/contexts/validation-context/use-validations.ts @@ -0,0 +1,42 @@ +/** + * External dependencies + */ +import { useContext, useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { ValidationErrors } from './types'; +import { ValidationContext } from './validation-context'; + +function isInvalid( errors: ValidationErrors ) { + return Object.values( errors ).some( Boolean ); +} + +export function useValidations() { + const context = useContext( ValidationContext ); + const [ isValidating, setIsValidating ] = useState( false ); + + return { + isValidating, + async validate() { + setIsValidating( true ); + return new Promise< void >( ( resolve, reject ) => { + context + .validateAll() + .then( ( errors ) => { + if ( isInvalid( errors ) ) { + reject( errors ); + } else { + resolve(); + } + } ) + .catch( () => { + reject( context.errors ); + } ); + } ).finally( () => { + setIsValidating( false ); + } ); + }, + }; +} diff --git a/packages/js/product-editor/src/contexts/validation-context/validation-context.ts b/packages/js/product-editor/src/contexts/validation-context/validation-context.ts new file mode 100644 index 0000000000000..750e1610a70a4 --- /dev/null +++ b/packages/js/product-editor/src/contexts/validation-context/validation-context.ts @@ -0,0 +1,19 @@ +/** + * External dependencies + */ +import { createContext } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { ValidationContextProps } from './types'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const ValidationContext = createContext< ValidationContextProps< any > >( + { + errors: {}, + registerValidator: () => () => {}, + validateField: () => Promise.resolve( undefined ), + validateAll: () => Promise.resolve( {} ), + } +); diff --git a/packages/js/product-editor/src/contexts/validation-context/validation-provider.tsx b/packages/js/product-editor/src/contexts/validation-context/validation-provider.tsx new file mode 100644 index 0000000000000..c700e269f5e84 --- /dev/null +++ b/packages/js/product-editor/src/contexts/validation-context/validation-provider.tsx @@ -0,0 +1,91 @@ +/** + * External dependencies + */ +import { createElement, useRef, useState } from '@wordpress/element'; +import { PropsWithChildren } from 'react'; + +/** + * Internal dependencies + */ +import { + ValidationErrors, + ValidationProviderProps, + Validator, + ValidatorResponse, +} from './types'; +import { ValidationContext } from './validation-context'; +import { findFirstInvalidElement } from './helpers'; + +export function ValidationProvider< T >( { + initialValue, + children, +}: PropsWithChildren< ValidationProviderProps< T > > ) { + const validatorsRef = useRef< Record< string, Validator< T > > >( {} ); + const fieldRefs = useRef< Record< string, HTMLElement > >( {} ); + const [ errors, setErrors ] = useState< ValidationErrors >( {} ); + + function registerValidator( + validatorId: string, + validator: Validator< T > + ): React.Ref< HTMLElement > { + validatorsRef.current = { + ...validatorsRef.current, + [ validatorId ]: validator, + }; + + return ( element: HTMLElement ) => { + fieldRefs.current[ validatorId ] = element; + }; + } + + async function validateField( validatorId: string ): ValidatorResponse { + const validators = validatorsRef.current; + if ( validatorId in validators ) { + const validator = validators[ validatorId ]; + const result = validator( initialValue ); + + return result.then( ( error ) => { + setErrors( ( currentErrors ) => ( { + ...currentErrors, + [ validatorId ]: error, + } ) ); + return error; + } ); + } + + return Promise.resolve( undefined ); + } + + async function validateAll(): Promise< ValidationErrors > { + const newErrors: ValidationErrors = {}; + const validators = validatorsRef.current; + + for ( const validatorId in validators ) { + newErrors[ validatorId ] = await validateField( validatorId ); + } + + setErrors( newErrors ); + + const firstElementWithError = findFirstInvalidElement( + fieldRefs.current, + newErrors + ); + + firstElementWithError?.focus(); + + return newErrors; + } + + return ( + + { children } + + ); +} diff --git a/packages/js/product-editor/src/hooks/index.ts b/packages/js/product-editor/src/hooks/index.ts index 646b526a553f9..e1ac9e9b7fc6b 100644 --- a/packages/js/product-editor/src/hooks/index.ts +++ b/packages/js/product-editor/src/hooks/index.ts @@ -2,7 +2,3 @@ export { useProductHelper as __experimentalUseProductHelper } from './use-produc export { useProductMVPCESFooter as __experimentalUseProductMVPCESFooter } from './use-product-mvp-ces-footer'; export { useVariationsOrder as __experimentalUseVariationsOrder } from './use-variations-order'; export { useCurrencyInputProps as __experimentalUseCurrencyInputProps } from './use-currency-input-props'; -export { - useValidation as __experimentalUseValidation, - ValidationError, -} from './use-validation'; diff --git a/packages/js/product-editor/src/hooks/use-validation/README.md b/packages/js/product-editor/src/hooks/use-validation/README.md deleted file mode 100644 index 611f528792c80..0000000000000 --- a/packages/js/product-editor/src/hooks/use-validation/README.md +++ /dev/null @@ -1,46 +0,0 @@ -# useValidation - -This custom hook uses the helper functions `const { lockPostSaving, unlockPostSaving } = useDispatch( 'core/editor' );` to lock/unlock the current editing product before saving it. - -## Usage - -Syncronous validation - -```typescript -import { useCallback } from '@wordpress/element'; -import { __ } from '@wordpress/i18n'; -import { - __experimentalUseValidation as useValidation, - ValidationError -} from '@woocommerce/product-editor'; - -const product = ...; - -const validateTitle = useCallback( (): ValidationError => { - if ( product.title.length < 2 ) { - return __( 'Title must be more than 1 character', 'text-domain' ); - } -}, [ product.title ] ); - -const validationError = useValidation( 'product/title', validateTitle ); -``` - -Asyncronous validation - -```typescript -import { useCallback } from '@wordpress/element'; -import { - __experimentalUseValidation as useValidation, - ValidationError -} from '@woocommerce/product-editor'; - -const product = ...; - -const validateSlug = useCallback( async (): Promise< ValidationError > => { - return fetch( `.../validate-slug?slug=${ product.slug }` ) - .then( ( response ) => response.json() ) - .then( ( { errorMessage } ) => errorMessage ); -}, [ product.slug ] ); - -const validationError = useValidation( 'product/slug', validateSlug ); -``` diff --git a/packages/js/product-editor/src/hooks/use-validation/index.ts b/packages/js/product-editor/src/hooks/use-validation/index.ts deleted file mode 100644 index f7a6938360a99..0000000000000 --- a/packages/js/product-editor/src/hooks/use-validation/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './use-validation'; -export * from './types'; diff --git a/packages/js/product-editor/src/hooks/use-validation/test/use-validation.test.ts b/packages/js/product-editor/src/hooks/use-validation/test/use-validation.test.ts deleted file mode 100644 index e9e3761865e79..0000000000000 --- a/packages/js/product-editor/src/hooks/use-validation/test/use-validation.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * External dependencies - */ -import { - RenderHookResult, - act, - renderHook, -} from '@testing-library/react-hooks'; -import { useDispatch } from '@wordpress/data'; - -/** - * Internal dependencies - */ -import { useValidation } from '../use-validation'; -import { ValidationError } from '../types'; - -jest.mock( '@wordpress/data', () => ( { - useDispatch: jest.fn(), -} ) ); - -describe( 'useValidation', () => { - const useDispatchMock = useDispatch as jest.Mock; - const lockPostSaving = jest.fn(); - const unlockPostSaving = jest.fn(); - let hookResult = {} as RenderHookResult< unknown, ValidationError >; - - beforeEach( () => { - useDispatchMock.mockReturnValue( { - lockPostSaving, - unlockPostSaving, - } ); - } ); - - afterEach( async () => { - jest.clearAllMocks(); - } ); - - describe( 'sync', () => { - it( 'should lock the editor if validate returns an error', async () => { - const validationError = 'Invalid name'; - - await act( async () => { - hookResult = renderHook( () => - useValidation( 'product/name', () => validationError ) - ); - } ); - - const { result } = hookResult; - - expect( result.current ).toBe( validationError ); - expect( lockPostSaving ).toHaveBeenCalled(); - expect( unlockPostSaving ).not.toHaveBeenCalled(); - } ); - - it( 'should unlock the editor if validate returns no error', async () => { - await act( async () => { - hookResult = renderHook( () => - useValidation( 'product/name', () => undefined ) - ); - } ); - - const { result } = hookResult; - - expect( result.current ).toBeUndefined(); - expect( lockPostSaving ).not.toHaveBeenCalled(); - expect( unlockPostSaving ).toHaveBeenCalled(); - } ); - } ); - - describe( 'async', () => { - it( 'should lock the editor if validate resolves an error', async () => { - const validationError = 'Invalid name'; - - await act( async () => { - hookResult = renderHook( () => - useValidation( 'product/name', () => - Promise.resolve( validationError ) - ) - ); - } ); - - const { result } = hookResult; - - expect( result.current ).toBe( validationError ); - expect( lockPostSaving ).toHaveBeenCalled(); - expect( unlockPostSaving ).not.toHaveBeenCalled(); - } ); - - it( 'should lock the editor if validate rejects', async () => { - const validationError = 'Invalid name'; - - await act( async () => { - hookResult = renderHook( () => - useValidation( 'product/name', () => - Promise.reject( validationError ) - ) - ); - } ); - - const { result } = hookResult; - - expect( result.current ).toBe( validationError ); - expect( lockPostSaving ).toHaveBeenCalled(); - expect( unlockPostSaving ).not.toHaveBeenCalled(); - } ); - - it( 'should unlock the editor if validate resolves undefined', async () => { - await act( async () => { - hookResult = renderHook( () => - useValidation( 'product/name', () => - Promise.resolve( undefined ) - ) - ); - } ); - - const { result } = hookResult; - - expect( result.current ).toBeUndefined(); - expect( lockPostSaving ).not.toHaveBeenCalled(); - expect( unlockPostSaving ).toHaveBeenCalled(); - } ); - } ); -} ); diff --git a/packages/js/product-editor/src/hooks/use-validation/types.ts b/packages/js/product-editor/src/hooks/use-validation/types.ts deleted file mode 100644 index 4e62d30b92d53..0000000000000 --- a/packages/js/product-editor/src/hooks/use-validation/types.ts +++ /dev/null @@ -1 +0,0 @@ -export type ValidationError = undefined | string | Error; diff --git a/packages/js/product-editor/src/hooks/use-validation/use-validation.ts b/packages/js/product-editor/src/hooks/use-validation/use-validation.ts deleted file mode 100644 index b95ddc08d14ed..0000000000000 --- a/packages/js/product-editor/src/hooks/use-validation/use-validation.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * External dependencies - */ -import { useDispatch } from '@wordpress/data'; -import { useEffect, useState } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import { ValidationError } from './types'; - -/** - * Signals that product saving is locked. - * - * @param lockName The namespace used to lock the product saving if validation fails. - * @param validate The validator function. - * @return The error message. - */ -export function useValidation( - lockName: string, - validate: () => ValidationError | Promise< ValidationError > -): ValidationError { - const [ validationError, setValidationError ] = - useState< ValidationError >(); - const { lockPostSaving, unlockPostSaving } = useDispatch( 'core/editor' ); - - useEffect( () => { - let validationResponse = validate(); - - if ( ! ( validationResponse instanceof Promise ) ) { - validationResponse = Promise.resolve( validationResponse ); - } - - validationResponse - .then( ( message ) => { - if ( message ) { - lockPostSaving( lockName ); - } else { - unlockPostSaving( lockName ); - } - setValidationError( message ); - } ) - .catch( ( error ) => { - lockPostSaving( lockName ); - setValidationError( error.message ?? error ); - } ); - }, [ lockName, validate, lockPostSaving, unlockPostSaving ] ); - - return validationError; -} diff --git a/packages/js/product-editor/src/index.ts b/packages/js/product-editor/src/index.ts index c62b84726c467..6f5473d7367a2 100644 --- a/packages/js/product-editor/src/index.ts +++ b/packages/js/product-editor/src/index.ts @@ -14,3 +14,5 @@ export * from './utils'; * Hooks */ export * from './hooks'; +export { useValidation, useValidations } from './contexts/validation-context'; +export * from './contexts/validation-context/types';