diff --git a/src/core_modules/capture-core/components/D2Form/FormBuilder/FormBuilder.component.js b/src/core_modules/capture-core/components/D2Form/FormBuilder/FormBuilder.component.js index df14dbb598..2fdc6aafbc 100644 --- a/src/core_modules/capture-core/components/D2Form/FormBuilder/FormBuilder.component.js +++ b/src/core_modules/capture-core/components/D2Form/FormBuilder/FormBuilder.component.js @@ -40,6 +40,7 @@ type FieldUI = { errorType?: ?string, errorData?: ErrorData, validatingMessage?: ?string, + pendingValidation?: ?boolean, }; type RenderDividerFn = (index: number, fieldsCount: number, field: FieldConfig) => React.Node; @@ -75,6 +76,17 @@ type Props = { onUpdateField: (value: any, uiState: FieldUI, fieldId: string, formBuilderId: string, promiseForIsValidating: string) => void, onUpdateFieldUIOnly: (uiState: FieldUI, fieldId: string, formBuilderId: string) => void, onFieldsValidated: ?(fieldsUI: { [id: string]: FieldUI }, formBuilderId: string, uidsForIsValidating: Array) => void, + onFieldValidated: ?( + fieldUI: { + fieldId: string, + valid?: ?boolean, + errorMessage?: ?string | Array, + errorType?: ?string, + errorData?: ErrorData, + }, + formBuilderId: string, + uidForIsValidating: string, + ) => void, querySingleResource: QuerySingleResource, validationAttempted?: ?boolean, validateIfNoUIData?: ?boolean, @@ -144,6 +156,9 @@ export class FormBuilder extends React.Component { return { valid: true, + errorMessage: null, + errorType: null, + errorData: null, }; } @@ -254,6 +269,69 @@ export class FormBuilder extends React.Component { ); } + static executeValidateField( + props: Props, + fieldsValidatingPromiseContainer: FieldsValidatingPromiseContainer, + field: FieldConfig, + ) { + const { + id, + values, + onGetValidationContext, + onIsValidating, + } = props; + const validationContext = onGetValidationContext && onGetValidationContext(); + const validationPromise = (async () => { + const fieldId = field.id; + const fieldValidatingPromiseContainer = fieldsValidatingPromiseContainer[fieldId] || {}; + fieldsValidatingPromiseContainer[fieldId] = fieldValidatingPromiseContainer; + + if (!fieldValidatingPromiseContainer.validatingCompleteUid) { + fieldValidatingPromiseContainer.validatingCompleteUid = uuid(); + } + fieldValidatingPromiseContainer.cancelableValidatingPromise && + fieldValidatingPromiseContainer.cancelableValidatingPromise.cancel(); + + const handleIsValidatingInternal = (message: ?string, promise: Promise) => { + fieldValidatingPromiseContainer.cancelableValidatingPromise = makeCancelablePromise(promise); + onIsValidating && onIsValidating( + field.id, + id, + fieldValidatingPromiseContainer.validatingCompleteUid, + message, + null, + ); + + return fieldValidatingPromiseContainer.cancelableValidatingPromise.promise; + }; + + let validationData; + try { + validationData = await FormBuilder.validateField( + field, + values[field.id], + validationContext, + handleIsValidatingInternal, + ); + } catch (reason) { + if (reason && isObject(reason) && reason.isCanceled) { + validationData = null; + } else { + validationData = { + valid: false, + errorMessage: [i18n.t('error encountered during field validation')], + errorType: i18n.t('error'), + }; + log.error({ reason, field }); + } + } + + return { fieldId: field.id, ...validationData }; + }); + + return validationPromise; + } + fieldInstances: Map; asyncUIState: { [id: string]: FieldUI }; fieldsValidatingPromiseContainer: FieldsValidatingPromiseContainer; @@ -300,6 +378,43 @@ export class FormBuilder extends React.Component { return remainingCompleteUids; } + validateField( + props: Props, + field: FieldConfig, + ) { + const { cancelableValidatingPromise } = this.fieldsValidatingPromiseContainer[field.id] || {}; + cancelableValidatingPromise && cancelableValidatingPromise.cancel(); + + const promiseValidateField = FormBuilder.executeValidateField( + props, + this.fieldsValidatingPromiseContainer, + field, + ); + + promiseValidateField() + .then((validationContainer) => { + props.onFieldValidated && + this.fieldsValidatingPromiseContainer[field.id]?.validatingCompleteUid && + props.onFieldValidated( + validationContainer, + props.id, + this.fieldsValidatingPromiseContainer[field.id].validatingCompleteUid, + ); + + if (!this.commitUpdateTriggeredForFields[field.id]) { + this.fieldsValidatingPromiseContainer[field.id] = null; + } + }) + .catch((reason) => { + if (!reason || !isObject(reason) || !reason.isCanceled) { + log.error({ + reason, + message: 'formBuilder validate field failed', + }); + } + }); + } + validateAllFields( props: Props, ) { @@ -571,6 +686,7 @@ export class FormBuilder extends React.Component { fieldsUI, validationAttempted, id, + onFieldValidated, onFieldsValidated, onUpdateField, onUpdateFieldAsync, @@ -601,6 +717,11 @@ export class FormBuilder extends React.Component { asyncProps.onCommitAsync = (callback: Function) => this.handleCommitAsync(field.id, props.label, callback); asyncProps.asyncUIState = this.asyncUIState[field.id]; } + if (fieldUI.pendingValidation) { + const asyncStateToAdd = { ...fieldUI, pendingValidation: false }; + this.handleUpdateAsyncState(field.id, asyncStateToAdd); + this.validateField(this.props, field); + } const errorMessage = onPostProcessErrorMessage && fieldUI.errorMessage ? onPostProcessErrorMessage({ diff --git a/src/core_modules/capture-core/components/D2Form/asyncHandlerHOC/actions.js b/src/core_modules/capture-core/components/D2Form/asyncHandlerHOC/actions.js index ce47d6b56b..bbfec059d4 100644 --- a/src/core_modules/capture-core/components/D2Form/asyncHandlerHOC/actions.js +++ b/src/core_modules/capture-core/components/D2Form/asyncHandlerHOC/actions.js @@ -3,6 +3,7 @@ import i18n from '@dhis2/d2-i18n'; import { actionCreator } from '../../../actions/actions.utils'; export const actionTypes = { + FIELD_VALIDATED: 'FieldValidated', FIELDS_VALIDATED: 'FieldsValidated', FIELD_IS_VALIDATING: 'FieldIsValidating', START_UPDATE_FIELD_ASYNC: 'StartUpdateFieldAsync', @@ -38,6 +39,13 @@ export const fieldsValidated = ( actionCreator(actionTypes.FIELDS_VALIDATED)( { fieldsUI, formBuilderId, formId, validatingUids }); +export const fieldValidated = ( + fieldUI: Object, + formBuilderId: string, + formId: string, + validatingUid: string, +) => actionCreator(actionTypes.FIELD_VALIDATED)({ fieldUI, formBuilderId, formId, validatingUid }); + export const startUpdateFieldAsync = ( elementId: string, fieldLabel: string, diff --git a/src/core_modules/capture-core/components/D2Form/asyncHandlerHOC/withAsyncHandler.js b/src/core_modules/capture-core/components/D2Form/asyncHandlerHOC/withAsyncHandler.js index 248c6c4574..5d43c07efe 100644 --- a/src/core_modules/capture-core/components/D2Form/asyncHandlerHOC/withAsyncHandler.js +++ b/src/core_modules/capture-core/components/D2Form/asyncHandlerHOC/withAsyncHandler.js @@ -2,12 +2,13 @@ import * as React from 'react'; import { connect } from 'react-redux'; import { v4 as uuid } from 'uuid'; -import { fieldIsValidating, fieldsValidated, startUpdateFieldAsync } from './actions'; +import { fieldIsValidating, fieldsValidated, fieldValidated, startUpdateFieldAsync } from './actions'; type Props = { id: string, onIsValidating: Function, onFieldsValidated: Function, + onFieldValidated: Function, onUpdateFieldAsyncInner: Function, onUpdateFieldAsync: ?Function, }; @@ -27,6 +28,12 @@ const getAsyncHandler = (InnerComponent: React.ComponentType) => this.props.onFieldsValidated(...args, id); } + // $FlowFixMe[missing-annot] automated comment + handleFieldValidated = (...args) => { + const { id } = this.props; + this.props.onFieldValidated(...args, id); + } + // $FlowFixMe[missing-annot] automated comment handleUpdateFieldAsyncInner = (...args) => { const { onUpdateFieldAsyncInner, onUpdateFieldAsync } = this.props; @@ -37,6 +44,7 @@ const getAsyncHandler = (InnerComponent: React.ComponentType) => const { onIsValidating, onFieldsValidated, + onFieldValidated, onUpdateFieldAsyncInner, onUpdateFieldAsync, ...passOnProps } = this.props; @@ -45,6 +53,7 @@ const getAsyncHandler = (InnerComponent: React.ComponentType) => @@ -75,6 +84,15 @@ const mapDispatchToProps = (dispatch: ReduxDispatch) => ({ const action = fieldsValidated(fieldsUI, formBuilderId, formId, validatingUids); dispatch(action); }, + onFieldValidated: ( + fieldUI: Object, + formBuilderId: string, + validatingUid: string, + formId: string, + ) => { + const action = fieldValidated(fieldUI, formBuilderId, formId, validatingUid); + dispatch(action); + }, onUpdateFieldAsyncInner: ( fieldId: string, fieldLabel: string, diff --git a/src/core_modules/capture-core/reducers/descriptions/dataEntry.reducerDescription.js b/src/core_modules/capture-core/reducers/descriptions/dataEntry.reducerDescription.js index 362c0c993d..f3687c31e4 100644 --- a/src/core_modules/capture-core/reducers/descriptions/dataEntry.reducerDescription.js +++ b/src/core_modules/capture-core/reducers/descriptions/dataEntry.reducerDescription.js @@ -291,6 +291,14 @@ export const dataEntriesInProgressListDesc = createReducerDescription({ [formId]: updatedList, }; }, + [formAsyncActionTypes.FIELD_VALIDATED]: (state, action) => { + const { formId, validatingUid } = action.payload; + const updatedList = (state[formId] || []).filter(item => item !== validatingUid); + return { + ...state, + [formId]: updatedList, + }; + }, [actionTypes.UPDATE_FORM_FIELD]: (state, action) => { const { formId, updateCompleteUid } = action.payload; const updatedList = (state[formId] || []).filter(item => item !== updateCompleteUid); diff --git a/src/core_modules/capture-core/reducers/descriptions/form.reducerDescription.js b/src/core_modules/capture-core/reducers/descriptions/form.reducerDescription.js index 30dd29a15f..f80e2bdc79 100644 --- a/src/core_modules/capture-core/reducers/descriptions/form.reducerDescription.js +++ b/src/core_modules/capture-core/reducers/descriptions/form.reducerDescription.js @@ -98,6 +98,15 @@ export const formsSectionsFieldsUIDesc = createReducerDescription({ return newState; }, + [formAsyncActionTypes.FIELD_VALIDATED]: (state, action) => { + const newState = { ...state }; + const { formId, fieldUI } = action.payload; + const { fieldId, ...restPayload } = fieldUI; + const newValues = { ...newState[formId][fieldId], ...restPayload, validatingMessage: null }; + + newState[formId] = { ...newState[formId], [fieldId]: newValues }; + return newState; + }, [formAsyncActionTypes.FIELDS_VALIDATED]: (state, action) => { const newState = { ...state }; const payload = action.payload; @@ -216,13 +225,11 @@ export const formsSectionsFieldsUIDesc = createReducerDescription({ const updatedFields = Object.keys(assignEffects).reduce((acc, id) => { if (formSectionFields[id]) { acc[id] = { - valid: true, - errorData: undefined, - errorMessage: undefined, - errorType: undefined, + ...state[formId][id], modified: true, touched: true, validatingMessage: null, + pendingValidation: true, }; } return acc;