diff --git a/client/landing/stepper/declarative-flow/internals/steps-repository/site-migration-credentials/components/access-method-picker.tsx b/client/landing/stepper/declarative-flow/internals/steps-repository/site-migration-credentials/components/access-method-picker.tsx index f109d6e88c0f85..235f95fa58f865 100644 --- a/client/landing/stepper/declarative-flow/internals/steps-repository/site-migration-credentials/components/access-method-picker.tsx +++ b/client/landing/stepper/declarative-flow/internals/steps-repository/site-migration-credentials/components/access-method-picker.tsx @@ -14,7 +14,7 @@ export const AccessMethodPicker: FC< CredentialsFormFieldProps > = ( { control }
( = ( { control }
( = ( { return (
- + { isEnglishLocale ? translate( 'Current site address' ) : translate( 'Site address' ) } ( = ( { /> ) } /> - +
); }; diff --git a/client/landing/stepper/declarative-flow/internals/steps-repository/site-migration-credentials/hooks/use-form-error-mapping.ts b/client/landing/stepper/declarative-flow/internals/steps-repository/site-migration-credentials/hooks/use-form-error-mapping.ts new file mode 100644 index 00000000000000..e47e66d98edec8 --- /dev/null +++ b/client/landing/stepper/declarative-flow/internals/steps-repository/site-migration-credentials/hooks/use-form-error-mapping.ts @@ -0,0 +1,81 @@ +import { useTranslate } from 'i18n-calypso'; +import { useCallback, useMemo } from 'react'; +import { FieldErrors } from 'react-hook-form'; +import { ApiError, CredentialsFormData } from '../types'; + +// This function is used to map the error message to the correct field in the form. +// Backend is returning the errors related to backup files using 'from_url' key +// but we need to use 'backupFileLocation' to identify the field in the form. +const getFieldName = ( key: string, migrationType: string ) => { + return 'backup' === migrationType && key === 'from_url' ? 'backupFileLocation' : key; +}; + +// ** This hook is used to map the error messages to the form fields errors. +export const useFormErrorMapping = ( + error?: ApiError | null, + variables?: CredentialsFormData | null +): FieldErrors< CredentialsFormData > | undefined => { + const translate = useTranslate(); + + const fieldMapping: Record< string, { type: string; message: string } | null > = useMemo( + () => ( { + from_url: { type: 'manual', message: translate( 'Enter a valid URL.' ) }, + username: { type: 'manual', message: translate( 'Enter a valid username.' ) }, + password: { type: 'manual', message: translate( 'Enter a valid password.' ) }, + backupFileLocation: { type: 'manual', message: translate( 'Enter a valid URL.' ) }, + } ), + [ translate ] + ); + + const getTranslatedMessage = useCallback( + ( key: string ) => { + return ( + fieldMapping[ key ] ?? { + type: 'manual', + message: translate( 'Invalid input, please check again' ), + } + ); + }, + [ fieldMapping, translate ] + ); + + const handleServerError = useCallback( + ( error: ApiError, { migrationType }: CredentialsFormData ) => { + const { code, message, data } = error; + + if ( code === 'rest_missing_callback_param' || ! code ) { + return { + root: { + type: 'manual', + message: translate( 'An error occurred while saving credentials.' ), + }, + }; + } + + if ( code !== 'rest_invalid_param' || ! data?.params ) { + return { root: { type: 'manual', message } }; + } + + const invalidFields = Object.keys( data.params ); + + return invalidFields.reduce( + ( errors, key ) => { + const fieldName = getFieldName( key, migrationType ); + const message = getTranslatedMessage( key ); + + errors[ fieldName ] = message; + return errors; + }, + {} as Record< string, { type: string; message: string } > + ); + }, + [ getTranslatedMessage, translate ] + ); + + return useMemo( () => { + if ( error && variables ) { + return handleServerError( error, variables ) as FieldErrors< CredentialsFormData >; + } + return undefined; + }, [ error, handleServerError, variables ] ); +}; diff --git a/client/landing/stepper/declarative-flow/internals/steps-repository/site-migration-credentials/test/index.tsx b/client/landing/stepper/declarative-flow/internals/steps-repository/site-migration-credentials/test/index.tsx index b171f607586d31..af24545c401048 100644 --- a/client/landing/stepper/declarative-flow/internals/steps-repository/site-migration-credentials/test/index.tsx +++ b/client/landing/stepper/declarative-flow/internals/steps-repository/site-migration-credentials/test/index.tsx @@ -128,13 +128,20 @@ describe( 'SiteMigrationCredentials', () => { expect( wpcomRequest ).not.toHaveBeenCalled(); } ); - it( 'shows errors on the required fields when the user does not fill the fields', async () => { + it( 'shows errors on the required fields when the user does not fill the fields when user select credentials option', async () => { render(); await userEvent.click( continueButton() ); + await userEvent.click( credentialsOption() ); + expect( getByText( messages.urlError ) ).toBeVisible(); + expect( getByText( messages.usernameError ) ).toBeVisible(); + expect( getByText( messages.passwordError ) ).toBeVisible(); + } ); - expect( getByText( messages.urlError ) ).toBeInTheDocument(); - expect( getByText( messages.usernameError ) ).toBeInTheDocument(); - expect( getByText( messages.passwordError ) ).toBeInTheDocument(); + it( 'shows errors on the required fields when the user does not fill the fields when user select backup option', async () => { + render(); + await userEvent.click( backupOption() ); + await userEvent.click( continueButton() ); + expect( getByText( /Please enter a valid URL/ ) ).toBeVisible(); } ); it( 'shows error when user set invalid site address', async () => { @@ -142,7 +149,7 @@ describe( 'SiteMigrationCredentials', () => { await userEvent.type( siteAddressInput(), 'invalid-site-address' ); await userEvent.click( continueButton() ); - expect( getByText( messages.noTLDError ) ).toBeInTheDocument(); + expect( getByText( messages.noTLDError ) ).toBeVisible(); } ); it( 'fills the site address and disable it when the user already informed the site address on previous step', async () => { @@ -214,10 +221,26 @@ describe( 'SiteMigrationCredentials', () => { await fillAllFields(); await userEvent.click( continueButton() ); - expect( getByText( /Error message from backend/ ) ).toBeVisible(); + await waitFor( () => { + expect( getByText( /Error message from backend/ ) ).toBeVisible(); + } ); expect( submit ).not.toHaveBeenCalled(); } ); + it( 'shows an generic error when server doesn`t return error', async () => { + const submit = jest.fn(); + render( { navigation: { submit } } ); + + ( wpcomRequest as jest.Mock ).mockRejectedValue( {} ); + + await fillAllFields(); + await userEvent.click( continueButton() ); + + await waitFor( () => { + expect( getByText( /An error occurred while saving credentials./ ) ).toBeVisible(); + } ); + } ); + it( 'shows a notice when URL contains error=ticket-creation', async () => { const submit = jest.fn(); const initialEntry = '/site-migration-credentials?error=ticket-creation'; @@ -227,6 +250,9 @@ describe( 'SiteMigrationCredentials', () => { const errorMessage = await findByText( /We ran into a problem submitting your details. Please try again shortly./ ); - expect( errorMessage ).toBeVisible(); + + await waitFor( () => { + expect( errorMessage ).toBeVisible(); + } ); } ); } ); diff --git a/client/landing/stepper/declarative-flow/internals/steps-repository/site-migration-credentials/types.ts b/client/landing/stepper/declarative-flow/internals/steps-repository/site-migration-credentials/types.ts index e6a61abb160054..a3f32c8ec54e50 100644 --- a/client/landing/stepper/declarative-flow/internals/steps-repository/site-migration-credentials/types.ts +++ b/client/landing/stepper/declarative-flow/internals/steps-repository/site-migration-credentials/types.ts @@ -1,6 +1,15 @@ import { Control, FieldErrors } from 'react-hook-form'; export interface CredentialsFormData { + from_url: string; + username: string; + password: string; + backupFileLocation: string; + migrationType: 'credentials' | 'backup'; + notes: string; +} + +export interface ApiFormData { siteAddress: string; username: string; password: string; @@ -9,6 +18,14 @@ export interface CredentialsFormData { howToAccessSite: 'credentials' | 'backup'; } +export interface ApiError { + code: string; + message: string; + data: { + params?: Record< string, string >; + }; +} + export interface CredentialsFormFieldProps { control: Control< CredentialsFormData >; errors?: FieldErrors< CredentialsFormData >; @@ -23,5 +40,4 @@ export interface MigrationError { params?: Record< string, string >; }; }; - status: number; } diff --git a/client/landing/stepper/declarative-flow/internals/steps-repository/site-migration-credentials/use-credentials-form.ts b/client/landing/stepper/declarative-flow/internals/steps-repository/site-migration-credentials/use-credentials-form.ts index a4f08b4281c6ff..c1eb87c47a0db9 100644 --- a/client/landing/stepper/declarative-flow/internals/steps-repository/site-migration-credentials/use-credentials-form.ts +++ b/client/landing/stepper/declarative-flow/internals/steps-repository/site-migration-credentials/use-credentials-form.ts @@ -1,115 +1,59 @@ -import { useTranslate } from 'i18n-calypso'; import { useEffect } from 'react'; import { useForm } from 'react-hook-form'; import { useQuery } from 'calypso/landing/stepper/hooks/use-query'; import { recordTracksEvent } from 'calypso/lib/analytics/tracks'; -import { MigrationError, CredentialsFormData } from './types'; +import { useFormErrorMapping } from './hooks/use-form-error-mapping'; +import { CredentialsFormData } from './types'; import { useSiteMigrationCredentialsMutation } from './use-site-migration-credentials-mutation'; -const mapApiError = ( error: any ) => { - return { - body: { - code: error.code, - message: error.message, - data: error.data, - }, - status: error.status, - }; -}; - export const useCredentialsForm = ( onSubmit: () => void ) => { - const translate = useTranslate(); const importSiteQueryParam = useQuery().get( 'from' ) || ''; + const { + isPending, + mutate: requestAutomatedMigration, + error, + isSuccess, + variables, + } = useSiteMigrationCredentialsMutation(); - const fieldMapping = { - from_url: { - fieldName: 'siteAddress', - errorMessage: translate( 'Enter a valid URL.' ), - }, - username: { - fieldName: 'username', - errorMessage: translate( 'Enter a valid username.' ), - }, - password: { - fieldName: 'password', - errorMessage: translate( 'Enter a valid password.' ), - }, - migration_type: { - fieldName: 'howToAccessSite', - errorMessage: null, - }, - notes: { - fieldName: 'notes', - errorMessage: null, - }, - }; - - const setGlobalError = ( message?: string | null | undefined ) => { - // eslint-disable-next-line @typescript-eslint/no-use-before-define - setError( 'root', { - type: 'manual', - message: message ?? translate( 'An error occurred while saving credentials.' ), - } ); - }; - - const handleMigrationError = ( err: MigrationError ) => { - let hasUnmappedFieldError = false; - - if ( err.body?.code === 'rest_invalid_param' && err.body?.data?.params ) { - Object.entries( err.body.data.params ).forEach( ( [ key ] ) => { - const field = fieldMapping[ key as keyof typeof fieldMapping ]; - const keyName = - // eslint-disable-next-line @typescript-eslint/no-use-before-define - 'backup' === accessMethod && field?.fieldName === 'siteAddress' - ? 'backupFileLocation' - : field?.fieldName; - - if ( keyName ) { - const message = field?.errorMessage ?? translate( 'Invalid input, please check again' ); - // eslint-disable-next-line @typescript-eslint/no-use-before-define - setError( keyName as keyof CredentialsFormData, { type: 'manual', message } ); - } else if ( ! hasUnmappedFieldError ) { - hasUnmappedFieldError = true; - setGlobalError(); - } - } ); - } else { - setGlobalError( err.body?.message ); - } - }; - - const { isPending, requestAutomatedMigration } = useSiteMigrationCredentialsMutation( { - onSuccess: () => { - recordTracksEvent( 'calypso_site_migration_automated_request_success' ); - onSubmit(); - }, - onError: ( error: any ) => { - handleMigrationError( mapApiError( error ) ); - recordTracksEvent( 'calypso_site_migration_automated_request_error' ); - }, - } ); + const serverSideError = useFormErrorMapping( error, variables ); const { formState: { errors }, control, handleSubmit, watch, - setError, clearErrors, } = useForm< CredentialsFormData >( { mode: 'onSubmit', reValidateMode: 'onSubmit', disabled: isPending, defaultValues: { - siteAddress: importSiteQueryParam, + from_url: importSiteQueryParam, username: '', password: '', backupFileLocation: '', notes: '', - howToAccessSite: 'credentials', + migrationType: 'credentials', }, + errors: serverSideError, } ); + const accessMethod = watch( 'migrationType' ); + + useEffect( () => { + if ( isSuccess ) { + recordTracksEvent( 'calypso_site_migration_automated_request_success' ); + onSubmit(); + } + }, [ isSuccess, onSubmit ] ); + + useEffect( () => { + if ( error ) { + recordTracksEvent( 'calypso_site_migration_automated_request_error' ); + } + }, [ error ] ); + useEffect( () => { const { unsubscribe } = watch( () => { clearErrors( 'root' ); @@ -117,8 +61,6 @@ export const useCredentialsForm = ( onSubmit: () => void ) => { return () => unsubscribe(); }, [ watch, clearErrors ] ); - const accessMethod = watch( 'howToAccessSite' ); - const submitHandler = ( data: CredentialsFormData ) => { requestAutomatedMigration( data ); }; @@ -131,7 +73,6 @@ export const useCredentialsForm = ( onSubmit: () => void ) => { accessMethod, isPending, submitHandler, - setError, importSiteQueryParam, }; }; diff --git a/client/landing/stepper/declarative-flow/internals/steps-repository/site-migration-credentials/use-site-migration-credentials-mutation.tsx b/client/landing/stepper/declarative-flow/internals/steps-repository/site-migration-credentials/use-site-migration-credentials-mutation.tsx index 3ce58a8489c4b2..5fe17bc455d8bc 100644 --- a/client/landing/stepper/declarative-flow/internals/steps-repository/site-migration-credentials/use-site-migration-credentials-mutation.tsx +++ b/client/landing/stepper/declarative-flow/internals/steps-repository/site-migration-credentials/use-site-migration-credentials-mutation.tsx @@ -1,7 +1,7 @@ import { useMutation, UseMutationOptions } from '@tanstack/react-query'; import wpcomRequest from 'wpcom-proxy-request'; import { useSiteSlugParam } from 'calypso/landing/stepper/hooks/use-site-slug-param'; -import { CredentialsFormData } from './types'; +import { ApiError, CredentialsFormData } from './types'; interface AutomatedMigrationAPIResponse { success: boolean; @@ -18,33 +18,32 @@ interface AutomatedMigrationBody { } export const useSiteMigrationCredentialsMutation = < - TData = AutomatedMigrationAPIResponse | unknown, - TError = unknown, - TContext = unknown, + TData = AutomatedMigrationAPIResponse, + TError = ApiError, >( - options: UseMutationOptions< TData, TError, CredentialsFormData, TContext > = {} + options: UseMutationOptions< TData, TError, CredentialsFormData > = {} ) => { const siteSlug = useSiteSlugParam(); - const { mutate, ...rest } = useMutation( { + return useMutation< TData, TError, CredentialsFormData >( { mutationFn: ( { - siteAddress, + from_url, username, password, notes, - howToAccessSite, + migrationType, backupFileLocation, - } ) => { + }: CredentialsFormData ) => { let body: AutomatedMigrationBody = { - migration_type: howToAccessSite, + migration_type: migrationType, blog_url: siteSlug ?? '', notes, }; - if ( howToAccessSite === 'credentials' ) { + if ( migrationType === 'credentials' ) { body = { ...body, - from_url: siteAddress, + from_url, username, password, }; @@ -66,9 +65,4 @@ export const useSiteMigrationCredentialsMutation = < }, ...options, } ); - - return { - requestAutomatedMigration: mutate, - ...rest, - }; };