-
Notifications
You must be signed in to change notification settings - Fork 41
fix: [DHIS2-21580] Sequential errors are not shown in the FeedbackBar #4591
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
f3dc36f
2578780
ee20201
4a45e66
c1a8218
789725c
2f819d0
96407cc
5c0722a
27913f7
73f842c
b02ad2a
7b1de78
59b2791
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,7 @@ import log from 'loglevel'; | |
| import i18n from '@dhis2/d2-i18n'; | ||
| import isString from 'd2-utilizr/lib/isString'; | ||
| import isObject from 'd2-utilizr/lib/isObject'; | ||
| import uuid from 'd2-utilizr/lib/uuid'; | ||
| import { errorCreator } from 'capture-core-utils'; | ||
| import { createReducerDescription } from '../../trackerRedux/trackerReducer'; | ||
| import { actionTypes as feedbackActionTypes } from '../../components/FeedbackBar/actions/feedback.actions'; | ||
|
|
@@ -41,31 +42,19 @@ type ErrorFeedbackInput = { | |
| action?: ReactNode, | ||
| }; | ||
|
|
||
| function addErrorFeedback( | ||
| state: any, | ||
| { message, variant = alertVariants.critical as keyof typeof alertVariants, action }: ErrorFeedbackInput, | ||
| ) { | ||
| return [ | ||
| ...state, | ||
| { | ||
| message, | ||
| action, | ||
| feedbackType: 'ERROR', | ||
| variant, | ||
| }, | ||
| ]; | ||
| } | ||
| const getErrorFeedback = ({ | ||
| message, | ||
| variant = 'critical', | ||
| action, | ||
| }: ErrorFeedbackInput) => ({ | ||
| id: uuid(), | ||
| message, | ||
| action, | ||
| feedbackType: 'ERROR' as const, | ||
| variant, | ||
| }); | ||
|
|
||
| function getErrorFeedback( | ||
| { message, variant = alertVariants.critical as keyof typeof alertVariants, action }: ErrorFeedbackInput, | ||
| ) { | ||
| return { | ||
| message, | ||
| action, | ||
| feedbackType: 'ERROR', | ||
| variant, | ||
| }; | ||
| } | ||
| const addErrorFeedback = (input: ErrorFeedbackInput) => [getErrorFeedback(input)]; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Info: Behavioral change: feedback queue replaced with single-item replacement The old Was this helpful? React with 👍 or 👎 to provide feedback.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The replacement behaviour is intentional. The Replacing instead of appending means the user sees the most recent, most actionable error - not a backlog of errors. |
||
|
|
||
| export const getFeedbackDesc = (appUpdaters: Updaters) => createReducerDescription({ | ||
|
devin-ai-integration[bot] marked this conversation as resolved.
|
||
| ...appUpdaters, | ||
|
|
@@ -74,80 +63,61 @@ export const getFeedbackDesc = (appUpdaters: Updaters) => createReducerDescripti | |
| newState.shift(); | ||
| return newState; | ||
| }, | ||
| [dataEntryActionTypes.COMPLETE_EVENT_ERROR]: (state, action) => | ||
| addErrorFeedback(state, { | ||
| [dataEntryActionTypes.COMPLETE_EVENT_ERROR]: (_state, action) => | ||
| addErrorFeedback({ | ||
| message: action.payload.error, | ||
| action: action.payload.action, | ||
| }), | ||
| [enrollmentActionTypes.ENROLLMENT_LOAD_FAILED]: (state, action) => | ||
| addErrorFeedback(state, action.payload), | ||
| [workingListsCommonActionTypes.LIST_VIEW_INIT_ERROR]: (state, action) => | ||
| addErrorFeedback(state, action.payload.errorMessage), | ||
| [newEventDataEntryActionTypes.SAVE_FAILED_FOR_NEW_EVENT_AFTER_RETURNED_TO_MAIN_PAGE]: (state, action) => { | ||
| [enrollmentActionTypes.ENROLLMENT_LOAD_FAILED]: (_state, action) => | ||
| addErrorFeedback(action.payload), | ||
| [workingListsCommonActionTypes.LIST_VIEW_INIT_ERROR]: (_state, action) => | ||
| addErrorFeedback({ message: action.payload.errorMessage }), | ||
| [newEventDataEntryActionTypes.SAVE_FAILED_FOR_NEW_EVENT_AFTER_RETURNED_TO_MAIN_PAGE]: (_state, action) => { | ||
| const error = action.payload; | ||
| const errorMessage = isString(error) ? error : error.message; | ||
| const errorObject = isObject(error) ? error : null; | ||
| log.error(errorCreator(errorMessage || 'Error saving event')(errorObject)); | ||
| const newState = [ | ||
| ...state, | ||
| getErrorFeedback({ message: i18n.t('Could not save event') }), | ||
| ]; | ||
| return newState; | ||
| return addErrorFeedback({ message: i18n.t('Could not save event') }); | ||
| }, | ||
| [workingListsCommonActionTypes.LIST_UPDATE_ERROR]: (state, action) => [ | ||
| ...state, | ||
| getErrorFeedback({ message: action.payload.errorMessage }), | ||
| ], | ||
| [eventWorkingListsActionTypes.EVENT_DELETE_ERROR]: state => [ | ||
| ...state, | ||
| getErrorFeedback({ message: i18n.t('Could not delete event') }), | ||
| ], | ||
|
|
||
| [workingListsCommonActionTypes.TEMPLATE_UPDATE_ERROR]: state => [ | ||
| ...state, | ||
| getErrorFeedback({ message: i18n.t('Could not save working list') }), | ||
| ], | ||
| [workingListsCommonActionTypes.TEMPLATE_ADD_ERROR]: state => [ | ||
| ...state, | ||
| getErrorFeedback({ message: i18n.t('Could not add working list') }), | ||
| ], | ||
| [workingListsCommonActionTypes.TEMPLATE_DELETE_ERROR]: state => [ | ||
| ...state, | ||
| getErrorFeedback({ message: i18n.t('Could not delete working list') }), | ||
| ], | ||
| [asyncHandlerActionTypes.ASYNC_UPDATE_FIELD_FAILED]: (state, { payload }) => | ||
| addErrorFeedback(state, { message: payload.message }), | ||
| [newEventDataEntryActionTypes.SAVE_FAILED_FOR_NEW_EVENT_ADD_ANOTHER]: (state, action) => { | ||
| [workingListsCommonActionTypes.LIST_UPDATE_ERROR]: (_state, action) => | ||
| addErrorFeedback({ message: action.payload.errorMessage }), | ||
| [eventWorkingListsActionTypes.EVENT_DELETE_ERROR]: () => | ||
| addErrorFeedback({ message: i18n.t('Could not delete event') }), | ||
| [workingListsCommonActionTypes.TEMPLATE_UPDATE_ERROR]: () => | ||
| addErrorFeedback({ message: i18n.t('Could not save working list') }), | ||
| [workingListsCommonActionTypes.TEMPLATE_ADD_ERROR]: () => | ||
| addErrorFeedback({ message: i18n.t('Could not add working list') }), | ||
| [workingListsCommonActionTypes.TEMPLATE_DELETE_ERROR]: () => | ||
| addErrorFeedback({ message: i18n.t('Could not delete working list') }), | ||
| [asyncHandlerActionTypes.ASYNC_UPDATE_FIELD_FAILED]: (_state, { payload }) => | ||
| addErrorFeedback({ message: payload.message }), | ||
| [newEventDataEntryActionTypes.SAVE_FAILED_FOR_NEW_EVENT_ADD_ANOTHER]: (_state, action) => { | ||
| const error = action.payload; | ||
| const errorMessage = isString(error) ? error : error.message; | ||
| const errorObject = isObject(error) ? error : null; | ||
| log.error(errorCreator(errorMessage || 'Error saving event')(errorObject)); | ||
| const newState = [ | ||
| ...state, | ||
| getErrorFeedback({ message: i18n.t('Could not save event') }), | ||
| ]; | ||
| return newState; | ||
| return addErrorFeedback({ message: i18n.t('Could not save event') }); | ||
| }, | ||
| [dataEntryActionTypes.DATA_ENTRY_RELATIONSHIP_ALREADY_EXISTS]: (state, action) => | ||
| addErrorFeedback(state, { message: action.payload.message }), | ||
| [viewEventNewRelationshipActionTypes.EVENT_RELATIONSHIP_ALREADY_EXISTS]: (state, action) => | ||
| addErrorFeedback(state, { message: action.payload.message }), | ||
| [registrationSectionActionTypes.ORG_UNIT_SEARCH_FAILED]: state => | ||
| addErrorFeedback(state, { message: i18n.t('Organisation unit search failed.') }), | ||
| [registrationFormActionTypes.NEW_TRACKED_ENTITY_INSTANCE_SAVE_FAILED]: state => | ||
| addErrorFeedback(state, { message: i18n.t('Error saving tracked entity instance') }), | ||
| [registrationFormActionTypes.NEW_TRACKED_ENTITY_INSTANCE_WITH_ENROLLMENT_SAVE_FAILED]: state => | ||
| addErrorFeedback(state, { message: i18n.t('Error saving enrollment') }), | ||
| [enrollmentSiteActionTypes.SAVE_FAILED]: state => | ||
| addErrorFeedback(state, { message: i18n.t('Error saving the enrollment event') }), | ||
| [editEventActionTypes.DELETE_EVENT_DATA_ENTRY_FAILED]: state => | ||
| addErrorFeedback(state, { message: i18n.t('Error deleting the enrollment event') }), | ||
| [editEventDataEntryAction.SAVE_EDIT_EVENT_DATA_ENTRY_FAILED]: state => | ||
| addErrorFeedback(state, { message: i18n.t('Error editing the event, the changes made were not saved') }), | ||
| [enrollmentSiteActionTypes.ERROR_ENROLLMENT]: (state, action) => | ||
| addErrorFeedback(state, { message: i18n.t(action.payload.message) }), | ||
| [viewEventActionTypes.ASSIGNEE_SAVE_FAILED]: state => | ||
| addErrorFeedback(state, { message: i18n.t('Error updating the Assignee') }), | ||
| [enrollmentEditEventActionTypes.ASSIGNEE_SAVE_FAILED]: state => | ||
| addErrorFeedback(state, { message: i18n.t('Error updating the Assignee') }), | ||
| [dataEntryActionTypes.DATA_ENTRY_RELATIONSHIP_ALREADY_EXISTS]: (_state, action) => | ||
| addErrorFeedback({ message: action.payload.message }), | ||
| [viewEventNewRelationshipActionTypes.EVENT_RELATIONSHIP_ALREADY_EXISTS]: (_state, action) => | ||
| addErrorFeedback({ message: action.payload.message }), | ||
| [registrationSectionActionTypes.ORG_UNIT_SEARCH_FAILED]: () => | ||
| addErrorFeedback({ message: i18n.t('Organisation unit search failed.') }), | ||
| [registrationFormActionTypes.NEW_TRACKED_ENTITY_INSTANCE_SAVE_FAILED]: () => | ||
| addErrorFeedback({ message: i18n.t('Error saving tracked entity instance') }), | ||
| [registrationFormActionTypes.NEW_TRACKED_ENTITY_INSTANCE_WITH_ENROLLMENT_SAVE_FAILED]: () => | ||
| addErrorFeedback({ message: i18n.t('Error saving enrollment') }), | ||
| [enrollmentSiteActionTypes.SAVE_FAILED]: () => | ||
| addErrorFeedback({ message: i18n.t('Error saving the enrollment event') }), | ||
| [editEventActionTypes.DELETE_EVENT_DATA_ENTRY_FAILED]: () => | ||
| addErrorFeedback({ message: i18n.t('Error deleting the enrollment event') }), | ||
| [editEventDataEntryAction.SAVE_EDIT_EVENT_DATA_ENTRY_FAILED]: () => | ||
| addErrorFeedback({ message: i18n.t('Error editing the event, the changes made were not saved') }), | ||
| [enrollmentSiteActionTypes.ERROR_ENROLLMENT]: (_state, action) => | ||
| addErrorFeedback({ message: i18n.t(action.payload.message) }), | ||
| [viewEventActionTypes.ASSIGNEE_SAVE_FAILED]: () => | ||
| addErrorFeedback({ message: i18n.t('Error updating the Assignee') }), | ||
| [enrollmentEditEventActionTypes.ASSIGNEE_SAVE_FAILED]: () => | ||
| addErrorFeedback({ message: i18n.t('Error updating the Assignee') }), | ||
| }, 'feedbacks', []); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚩 Potential race condition between onHidden and new feedback arriving
The new
onHidden={onClose}on the AlertBar (FeedbackBar.component.tsx:36) dispatchesCLOSE_FEEDBACKwhen the alert finishes hiding. If a new error arrives while an old AlertBar is mid-hide-animation (timer expired butonHiddennot yet called), the key change causes React to unmount the old AlertBar and mount a new one. If the old AlertBar'sonHiddenfires during or after unmount, it would dispatchCLOSE_FEEDBACKand shift the NEW error off the state atfeedback.reducerDescriptionGetter.ts:61-64, causing the new error to disappear immediately. This depends on whether dhis2/ui's AlertBar properly cancels its hide callback on unmount. Standard React cleanup patterns should prevent this, but it's worth verifying with the AlertBar implementation.Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Verified against
@dhis2-ui/alert'sAlertBar: itsuseEffectreturnsclearAllTimeoutsas cleanup, so a key change unmounts the old bar and synchronously clears the pendinghideTimeoutbeforeremove()(which callsonHidden) can fire. The race isn't reachable —remove()is purely synchronous and JS is single-threaded, soonHiddencannot fire after unmount.