Add link into the error snackbar (#49246)

* Add link to snack bar

# Conflicts:
#	packages/js/product-editor/src/hooks/use-product-manager/use-product-manager.ts

# Conflicts:
#	packages/js/product-editor/src/contexts/validation-context/validation-provider.tsx

* Fix variations

* Add useBlocksHelper

* Remove old tests

* Remove timeout

* Fix typo

* Fix errors

* Add changelog
This commit is contained in:
Fernando Marichal 2024-07-15 11:00:40 -03:00 committed by GitHub
parent 09bf08e1b1
commit a9de74df6f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 236 additions and 147 deletions

View File

@ -0,0 +1,4 @@
Significance: minor
Type: dev
Add link into the error snackbar #49246

View File

@ -33,10 +33,7 @@ import { ProductEditorSettings } from '../../../components';
import { BlockFill } from '../../../components/block-slot-fill';
import { useValidations } from '../../../contexts/validation-context';
import { TRACKS_SOURCE } from '../../../constants';
import {
WPError,
getProductErrorMessageAndProps,
} from '../../../utils/get-product-error-message-and-props';
import { WPError, useErrorHandler } from '../../../hooks/use-error-handler';
import type {
ProductEditorBlockEditProps,
ProductFormPostProps,
@ -54,6 +51,8 @@ export function ProductDetailsSectionDescriptionBlockEdit( {
}: ProductEditorBlockEditProps< ProductDetailsSectionDescriptionBlockAttributes > ) {
const blockProps = useWooBlockProps( attributes );
const { getProductErrorMessageAndProps } = useErrorHandler();
const { productTemplates, productTemplate: selectedProductTemplate } =
useSelect( ( select ) => {
const { getEditorSettings } = select( 'core/editor' );

View File

@ -13,7 +13,7 @@ import { MouseEvent } from 'react';
* Internal dependencies
*/
import { useValidations } from '../../../../contexts/validation-context';
import { WPError } from '../../../../utils/get-product-error-message-and-props';
import { WPError } from '../../../../hooks/use-error-handler';
import { useProductURL } from '../../../../hooks/use-product-url';
import { PreviewButtonProps } from '../../preview-button';
import { errorHandler } from '../../../../hooks/use-product-manager';

View File

@ -13,7 +13,7 @@ import { useShortcut } from '@wordpress/keyboard-shortcuts';
*/
import { useProductManager } from '../../../../hooks/use-product-manager';
import { useProductScheduled } from '../../../../hooks/use-product-scheduled';
import type { WPError } from '../../../../utils/get-product-error-message-and-props';
import type { WPError } from '../../../../hooks/use-error-handler';
import type { PublishButtonProps } from '../../publish-button';
export function usePublish< T = Product >( {

View File

@ -15,7 +15,7 @@ import { useShortcut } from '@wordpress/keyboard-shortcuts';
* Internal dependencies
*/
import { useValidations } from '../../../../contexts/validation-context';
import { WPError } from '../../../../utils/get-product-error-message-and-props';
import { WPError } from '../../../../hooks/use-error-handler';
import { SaveDraftButtonProps } from '../../save-draft-button';
import { recordProductEvent } from '../../../../utils/record-product-event';
import { errorHandler } from '../../../../hooks/use-product-manager';

View File

@ -11,7 +11,7 @@ import { useDispatch } from '@wordpress/data';
/**
* Internal dependencies
*/
import { getProductErrorMessageAndProps } from '../../../utils/get-product-error-message-and-props';
import { useErrorHandler } from '../../../hooks/use-error-handler';
import { usePreview } from '../hooks/use-preview';
import { PreviewButtonProps } from './types';
import { TRACKS_SOURCE } from '../../../constants';
@ -22,6 +22,7 @@ export function PreviewButton( {
...props
}: PreviewButtonProps ) {
const { createErrorNotice } = useDispatch( 'core/notices' );
const { getProductErrorMessageAndProps } = useErrorHandler();
const previewButtonProps = usePreview( {
productStatus,

View File

@ -17,7 +17,7 @@ import { recordEvent } from '@woocommerce/tracks';
import { useProductManager } from '../../../../hooks/use-product-manager';
import { useProductScheduled } from '../../../../hooks/use-product-scheduled';
import { recordProductEvent } from '../../../../utils/record-product-event';
import { getProductErrorMessageAndProps } from '../../../../utils/get-product-error-message-and-props';
import { useErrorHandler } from '../../../../hooks/use-error-handler';
import { ButtonWithDropdownMenu } from '../../../button-with-dropdown-menu';
import { SchedulePublishModal } from '../../../schedule-publish-modal';
import { showSuccessNotice } from '../utils';
@ -42,6 +42,7 @@ export function PublishButtonMenu( {
postType,
'status'
);
const { getProductErrorMessageAndProps } = useErrorHandler();
function scheduleProduct( dateString?: string ) {
schedule( dateString )

View File

@ -14,7 +14,7 @@ import { recordEvent } from '@woocommerce/tracks';
* Internal dependencies
*/
import { store as productEditorUiStore } from '../../../store/product-editor-ui';
import { getProductErrorMessageAndProps } from '../../../utils/get-product-error-message-and-props';
import { useErrorHandler } from '../../../hooks/use-error-handler';
import { recordProductEvent } from '../../../utils/record-product-event';
import { useFeedbackBar } from '../../../hooks/use-feedback-bar';
import { TRACKS_SOURCE } from '../../../constants';
@ -33,6 +33,7 @@ export function PublishButton( {
const { createErrorNotice } = useDispatch( 'core/notices' );
const { maybeShowFeedbackBar } = useFeedbackBar();
const { openPrepublishPanel } = useDispatch( productEditorUiStore );
const { getProductErrorMessageAndProps } = useErrorHandler();
const [ editedStatus, , prevStatus ] = useEntityProp< Product[ 'status' ] >(
'postType',

View File

@ -11,7 +11,7 @@ import { useDispatch } from '@wordpress/data';
/**
* Internal dependencies
*/
import { getProductErrorMessageAndProps } from '../../../utils/get-product-error-message-and-props';
import { useErrorHandler } from '../../../hooks/use-error-handler';
import { recordProductEvent } from '../../../utils/record-product-event';
import { useSaveDraft } from '../hooks/use-save-draft';
import { SaveDraftButtonProps } from './types';
@ -28,6 +28,8 @@ export function SaveDraftButton( {
const { maybeShowFeedbackBar } = useFeedbackBar();
const { getProductErrorMessageAndProps } = useErrorHandler();
const saveDraftButtonProps = useSaveDraft( {
productStatus,
productType,

View File

@ -12,6 +12,7 @@ export type ValidationContextProps< T > = {
validator: Validator< T >
): React.Ref< HTMLElement >;
unRegisterValidator( validatorId: string ): void;
getFieldByValidatorId: ( validatorId: string ) => Promise< HTMLElement >;
validateField(
name: string,
newData?: Record< string, unknown >

View File

@ -17,6 +17,13 @@ export function useValidations< T = unknown >() {
const context = useContext( ValidationContext );
const [ isValidating, setIsValidating ] = useState( false );
async function focusByValidatorId( validatorId: string ) {
const field = await context.getFieldByValidatorId( validatorId );
if ( field ) {
field.focus();
}
}
return {
isValidating,
async validate( newData?: Partial< T > ) {
@ -38,5 +45,6 @@ export function useValidations< T = unknown >() {
setIsValidating( false );
} );
},
focusByValidatorId,
};
}

View File

@ -12,6 +12,7 @@ import { ValidationContextProps } from './types';
export const ValidationContext = createContext< ValidationContextProps< any > >(
{
errors: {},
getFieldByValidatorId: () => ( {} as Promise< HTMLElement > ),
registerValidator: () => () => {},
unRegisterValidator: () => () => {},
validateField: () => Promise.resolve( undefined ),

View File

@ -78,6 +78,12 @@ export function ValidationProvider< T >( {
return Promise.resolve( undefined );
}
async function getFieldByValidatorId(
validatorId: string
): Promise< HTMLElement > {
return fieldRefs.current[ validatorId ];
}
async function validateAll(
newData: Partial< T >
): Promise< ValidationErrors > {
@ -107,6 +113,7 @@ export function ValidationProvider< T >( {
<ValidationContext.Provider
value={ {
errors,
getFieldByValidatorId,
registerValidator,
unRegisterValidator,
validateField,

View File

@ -0,0 +1 @@
export * from './use-blocks-helper';

View File

@ -0,0 +1,28 @@
/**
* External dependencies
*/
import { select } from '@wordpress/data';
export function useBlocksHelper() {
function getParentTabId( clientId: string ) {
const [ closestParentClientId ] =
// @ts-expect-error Outdated type definition.
select( 'core/block-editor' ).getBlockParentsByBlockName(
clientId,
'woocommerce/product-tab',
true
);
if ( ! closestParentClientId ) {
return '';
}
// @ts-expect-error Outdated type definition.
const { attributes } = select( 'core/block-editor' ).getBlock(
closestParentClientId
);
return attributes?.id;
}
return {
getParentTabId,
};
}

View File

@ -0,0 +1,170 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useCallback } from '@wordpress/element';
import { getNewPath, navigateTo } from '@woocommerce/navigation';
/**
* Internal dependencies
*/
import { useValidations } from '../contexts/validation-context';
import { useBlocksHelper } from './use-blocks-helper';
export type WPErrorCode =
| 'variable_product_no_variation_prices'
| 'product_form_field_error'
| 'product_invalid_sku'
| 'product_invalid_global_unique_id'
| 'product_create_error'
| 'product_publish_error'
| 'product_preview_error';
export type WPError = {
code: WPErrorCode;
message: string;
validatorId?: string;
context?: string;
};
type ErrorProps = {
explicitDismiss: boolean;
actions?: ErrorAction[];
};
type ErrorAction = {
label: string;
onClick: () => void;
};
type UseErrorHandlerTypes = {
getProductErrorMessageAndProps: (
error: WPError,
visibleTab: string | null
) => {
message: string;
errorProps: ErrorProps;
};
};
function getUrl( tab: string ): string {
return getNewPath( { tab } );
}
function getErrorPropsWithActions(
errorContext = '',
validatorId: string,
focusByValidatorId: ( validatorId: string ) => void
): ErrorProps {
return {
explicitDismiss: true,
actions: [
{
label: __( 'View error', 'woocommerce' ),
onClick: () => {
navigateTo( {
url: getUrl( errorContext ),
} );
focusByValidatorId( validatorId );
},
},
],
};
}
export const useErrorHandler = (): UseErrorHandlerTypes => {
const { focusByValidatorId } = useValidations();
const { getParentTabId } = useBlocksHelper();
const getProductErrorMessageAndProps = useCallback(
( error: WPError, visibleTab: string | null ) => {
const response = {
message: '',
errorProps: {} as ErrorProps,
};
const {
code,
context = '',
message: errorMessage,
validatorId = '',
} = error;
const errorContext = getParentTabId( context );
switch ( code ) {
case 'variable_product_no_variation_prices':
response.message = errorMessage;
if ( visibleTab !== 'variations' ) {
response.errorProps = getErrorPropsWithActions(
errorContext,
validatorId,
focusByValidatorId
);
}
break;
case 'product_form_field_error':
response.message = errorMessage;
if ( visibleTab !== errorContext ) {
response.errorProps = getErrorPropsWithActions(
errorContext,
validatorId,
focusByValidatorId
);
}
break;
case 'product_invalid_sku':
response.message = __(
'Invalid or duplicated SKU.',
'woocommerce'
);
if ( visibleTab !== 'inventory' ) {
response.errorProps = getErrorPropsWithActions(
'inventory',
validatorId,
focusByValidatorId
);
}
break;
case 'product_invalid_global_unique_id':
response.message = __(
'Invalid or duplicated GTIN, UPC, EAN or ISBN.',
'woocommerce'
);
if ( visibleTab !== 'inventory' ) {
response.errorProps = getErrorPropsWithActions(
'inventory',
validatorId,
focusByValidatorId
);
}
break;
case 'product_create_error':
response.message = __(
'Failed to create product.',
'woocommerce'
);
break;
case 'product_publish_error':
response.message = __(
'Failed to publish product.',
'woocommerce'
);
break;
case 'product_preview_error':
response.message = __(
'Failed to preview product.',
'woocommerce'
);
break;
default:
response.message = __(
'Failed to save product.',
'woocommerce'
);
break;
}
return response;
},
[]
);
return { getProductErrorMessageAndProps };
};

View File

@ -10,7 +10,7 @@ import { Product, ProductStatus, PRODUCTS_STORE_NAME } from '@woocommerce/data';
* Internal dependencies
*/
import { useValidations } from '../../contexts/validation-context';
import type { WPError } from '../../utils/get-product-error-message-and-props';
import type { WPError } from '../../hooks/use-error-handler';
import { AUTO_DRAFT_NAME } from '../../utils/constants';
export function errorHandler( error: WPError, productStatus: ProductStatus ) {

View File

@ -1,89 +0,0 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
export type WPErrorCode =
| 'variable_product_no_variation_prices'
| 'product_form_field_error'
| 'product_invalid_sku'
| 'product_invalid_global_unique_id'
| 'product_create_error'
| 'product_publish_error'
| 'product_preview_error';
export type WPError = {
code: WPErrorCode;
message: string;
data: {
[ key: string ]: unknown;
};
};
type ErrorProps = {
explicitDismiss: boolean;
};
export function getProductErrorMessageAndProps(
error: WPError,
visibleTab: string | null
): {
message: string;
errorProps: ErrorProps;
} {
const response = {
message: '',
errorProps: {} as ErrorProps,
};
switch ( error.code ) {
case 'variable_product_no_variation_prices':
response.message = error.message;
if ( visibleTab !== 'variations' ) {
response.errorProps = { explicitDismiss: true };
}
break;
case 'product_form_field_error':
response.message = error.message;
if ( visibleTab !== 'general' ) {
response.errorProps = { explicitDismiss: true };
}
break;
case 'product_invalid_sku':
response.message = __(
'Invalid or duplicated SKU.',
'woocommerce'
);
if ( visibleTab !== 'inventory' ) {
response.errorProps = { explicitDismiss: true };
}
break;
case 'product_invalid_global_unique_id':
response.message = __(
'Invalid or duplicated GTIN, UPC, EAN or ISBN.',
'woocommerce'
);
if ( visibleTab !== 'inventory' ) {
response.errorProps = { explicitDismiss: true };
}
break;
case 'product_create_error':
response.message = __( 'Failed to create product.', 'woocommerce' );
break;
case 'product_publish_error':
response.message = __(
'Failed to publish product.',
'woocommerce'
);
break;
case 'product_preview_error':
response.message = __(
'Failed to preview product.',
'woocommerce'
);
break;
default:
response.message = __( 'Failed to save product.', 'woocommerce' );
break;
}
return response;
}

View File

@ -1,46 +0,0 @@
/**
* Internal dependencies
*/
import {
getProductErrorMessageAndProps,
WPError,
} from '../get-product-error-message-and-props';
describe( 'getProductErrorMessageAndProps.message', () => {
it( 'should return the correct error message and props when exists and the field is visible', () => {
const error = {
code: 'product_invalid_sku',
} as WPError;
const visibleTab = 'inventory';
const { message, errorProps } = getProductErrorMessageAndProps(
error,
visibleTab
);
expect( message ).toBe( 'Invalid or duplicated SKU.' );
expect( errorProps.explicitDismiss ).toBeFalsy();
} );
it( 'should return the correct error message and props when exists and the field is not visible', () => {
const error = {
code: 'product_invalid_sku',
} as WPError;
const visibleTab = 'general';
const { message, errorProps } = getProductErrorMessageAndProps(
error,
visibleTab
);
expect( message ).toBe( 'Invalid or duplicated SKU.' );
expect( errorProps.explicitDismiss ).toBeTruthy();
} );
it( 'should return a default message and props when the error code is not mapped', () => {
const error = {} as WPError;
const visibleTab = 'general';
const { message, errorProps } = getProductErrorMessageAndProps(
error,
visibleTab
);
expect( message ).toBe( 'Failed to save product.' );
expect( errorProps.explicitDismiss ).toBeFalsy();
} );
} );