diff --git a/i18n/en.pot b/i18n/en.pot index 33b83a6757..b02613918c 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-08-12T11:21:02.033Z\n" -"PO-Revision-Date: 2026-08-12T11:21:02.033Z\n" +"POT-Creation-Date: 2026-08-12T10:36:50.151Z\n" +"PO-Revision-Date: 2026-08-12T10:36:50.151Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." @@ -1989,21 +1989,12 @@ msgstr "Error completing events" msgid "There was an error completing the events." msgstr "There was an error completing the events." -msgid "Details (Advanced)" -msgstr "Details (Advanced)" - -msgid "An unknown error occurred." -msgstr "An unknown error occurred." - msgid "An error occurred while completing events" msgstr "An error occurred while completing events" msgid "You do not have access to delete events" msgstr "You do not have access to delete events" -msgid "An error occurred while deleting the events" -msgstr "An error occurred while deleting the events" - msgid "Delete events" msgstr "Delete events" @@ -2013,6 +2004,15 @@ msgstr "This cannot be undone." msgid "Are you sure you want to delete the selected events?" msgstr "Are you sure you want to delete the selected events?" +msgid "Error deleting events" +msgstr "Error deleting events" + +msgid "There was an error while deleting the events. Please see the details below." +msgstr "There was an error while deleting the events. Please see the details below." + +msgid "An error occurred while deleting the events" +msgstr "An error occurred while deleting the events" + msgid "Owner organisation unit" msgstr "Owner organisation unit" @@ -2103,6 +2103,16 @@ msgstr "Delete selected enrollments" msgid "An error occurred while loading the selected enrollments. Please try again." msgstr "An error occurred while loading the selected enrollments. Please try again." +msgid "Error deleting enrollments" +msgstr "Error deleting enrollments" + +msgid "" +"There was an error while deleting the enrollments. Please see the details " +"below." +msgstr "" +"There was an error while deleting the enrollments. Please see the details " +"below." + msgid "" "This action will permanently delete the selected enrollments, including all " "associated data and events." @@ -2133,23 +2143,6 @@ msgstr[1] "Delete {{count}} enrollments" msgid "An error occurred when deleting enrollments" msgstr "An error occurred when deleting enrollments" -msgid "Delete {{ trackedEntityName }} with all enrollments" -msgstr "Delete {{ trackedEntityName }} with all enrollments" - -msgid "Delete {{count}} {{ trackedEntityName }}" -msgid_plural "Delete {{count}} {{ trackedEntityName }}" -msgstr[0] "Delete {{count}} {{ trackedEntityName }}" -msgstr[1] "Delete {{count}} {{ trackedEntityName }}" - -msgid "Deleting records will also delete any associated enrollments and events." -msgstr "Deleting records will also delete any associated enrollments and events." - -msgid "Are you sure you want to delete?" -msgstr "Are you sure you want to delete?" - -msgid "An error occurred while deleting the records" -msgstr "An error occurred while deleting the records" - msgid "Working list could not be updated" msgstr "Working list could not be updated" @@ -2212,6 +2205,12 @@ msgstr[1] "{{count}} selected" msgid "Deselect all" msgstr "Deselect all" +msgid "Details (Advanced)" +msgstr "Details (Advanced)" + +msgid "An unknown error occurred." +msgstr "An unknown error occurred." + msgid "Download as JSON" msgstr "Download as JSON" diff --git a/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/Actions/CompleteAction/CompleteAction.tsx b/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/Actions/CompleteAction/CompleteAction.tsx index 35de3c8129..587d02b8ab 100644 --- a/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/Actions/CompleteAction/CompleteAction.tsx +++ b/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/Actions/CompleteAction/CompleteAction.tsx @@ -1,10 +1,14 @@ -import React, { type ComponentType, useState } from 'react'; +import React, { type ComponentType, useMemo, useState } from 'react'; import i18n from '@dhis2/d2-i18n'; import { withStyles, type WithStyles } from 'capture-core-utils/styles'; -import { Button, ButtonStrip, colors, Modal, ModalActions, ModalContent, ModalTitle } from '@dhis2/ui'; +import { + Button, ButtonStrip, colors, Modal, ModalActions, ModalContent, ModalTitle, +} from '@dhis2/ui'; import { useBulkCompleteEvents } from './hooks/useBulkCompleteEvents'; import { ConditionalTooltip } from '../../../../../Tooltips/ConditionalTooltip'; -import { Widget } from '../../../../../Widget'; +import { BulkActionErrorModal } from '../../../../WorkingListsCommon/BulkActionBar/BulkActionErrorModal'; +import { createEventErrorHrefResolver } from '../../../../WorkingListsCommon/BulkActionBar/utils'; +import { useLocationQuery } from '../../../../../../utils/routing'; import type { Props } from './CompleteAction.types'; const styles: Readonly = { @@ -16,9 +20,6 @@ const styles: Readonly = { flexDirection: 'column', gap: '8px', }, - errorContainer: { - padding: '0px 20px', - }, }; const getTooltipContent = (stageDataWriteAccess?: boolean, bulkDataEntryIsActive?: boolean) => { @@ -41,9 +42,10 @@ const CompleteActionPlain = ({ classes, }: Props & WithStyles) => { const [isCompleteDialogOpen, setIsCompleteDialogOpen] = useState(false); - const [openAccordion, setOpenAccordion] = useState(false); + const { orgUnitId } = useLocationQuery(); + const disabled = !stageDataWriteAccess || Boolean(bulkDataEntryIsActive); const tooltipContent = getTooltipContent(stageDataWriteAccess, bulkDataEntryIsActive); - const disabled = Boolean(!stageDataWriteAccess || bulkDataEntryIsActive); + const { eventCounts, isLoading, @@ -59,6 +61,17 @@ const CompleteActionPlain = ({ programId, }); + const getRecordHref = useMemo( + () => createEventErrorHrefResolver({ + programId, + orgUnitId, + knownEventUids: new Set(Object.keys(selectedRows)), + }), + [programId, orgUnitId, selectedRows], + ); + + const closeDialog = () => setIsCompleteDialogOpen(false); + return ( <> setIsCompleteDialogOpen(false)} - dataTest={'bulk-complete-events-dialog'} + onClose={closeDialog} + dataTest="bulk-complete-events-dialog" > - - {i18n.t('Complete events')} - - + {i18n.t('Complete events')} - {eventCounts.active > 0 ? - i18n.t('Are you sure you want to complete all active events in selection?') - : - i18n.t('There are no active events to complete in the current selection.') + {eventCounts.active > 0 + ? i18n.t('Are you sure you want to complete all active events in selection?') + : i18n.t('There are no active events to complete in the current selection.') } - - - )} {isCompleteDialogOpen && validationError && ( - setIsCompleteDialogOpen(false)} - dataTest={'bulk-complete-events-dialog'} - > - - {i18n.t('Error completing events')} - - - - - {i18n.t('There was an error completing the events.')} - - setOpenAccordion(true)} - onClose={() => setOpenAccordion(false)} - borderless - header={i18n.t('Details (Advanced)')} - > - -
    - {validationError?.validationReport?.errorReports ? - validationError.validationReport.errorReports.map(errorReport => ( -
  • - {errorReport?.message} -
  • - )) : ( -
  • - {i18n.t('An unknown error occurred.')} -
  • - ) - } -
-
-
-
-
- - - - - - -
+ )} ); diff --git a/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/Actions/CompleteAction/hooks/useBulkCompleteEvents.ts b/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/Actions/CompleteAction/hooks/useBulkCompleteEvents.ts index 889127fb1b..a28c45e1d6 100644 --- a/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/Actions/CompleteAction/hooks/useBulkCompleteEvents.ts +++ b/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/Actions/CompleteAction/hooks/useBulkCompleteEvents.ts @@ -1,12 +1,12 @@ -import { useCallback, useEffect, useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import i18n from '@dhis2/d2-i18n'; -import { useMutation } from '@tanstack/react-query'; import { useAlert, useDataEngine } from '@dhis2/app-runtime'; import { useApiDataQuery } from '../../../../../../../utils/reactQueryHelpers'; import { handleAPIResponse, REQUESTED_ENTITIES } from '../../../../../../../utils/api'; +import { useBulkMutationWithValidation } from '../../../../../WorkingListsCommon/BulkActionBar/hooks'; type Props = { - selectedRows: { [key: string]: boolean }; + selectedRows: Record; isCompleteDialogOpen: boolean; setIsCompleteDialogOpen: (isCompleteDialogOpen: boolean) => void; onUpdateList: (disableClearSelection?: boolean) => void; @@ -14,6 +14,8 @@ type Props = { programId?: string; }; +type Payload = Array<{ event: string; [key: string]: any }>; + export const useBulkCompleteEvents = ({ selectedRows, isCompleteDialogOpen, @@ -52,56 +54,50 @@ export const useBulkCompleteEvents = ({ } else { acc.completedEvents.push(event); } - return acc; }, { activeEvents: [], completedEvents: [] }); }, }, ); - const { - mutate: completeEvents, - isLoading: isCompletingEvents, - data: validationError, - error, - reset: resetCompleteEvents, - } = useMutation( - ({ payload }: { payload: any }) => dataEngine.mutate({ + const mutationFn = useCallback( + ({ payload }: { payload: Payload }) => dataEngine.mutate({ resource: 'tracker?async=false&importStrategy=UPDATE&atomicMode=OBJECT', type: 'create', - data: { - events: payload, - }, - }), - { - onError: () => { - showAlert({ message: i18n.t('An error occurred while completing events') }); - }, - onSuccess: (response, { payload }: any) => { - const errorReports = response?.validationReport?.errorReports; - if (errorReports && errorReports.length) { - const eventIds = payload.map(event => event.event); - const validEventIds = eventIds - .filter(eventId => !errorReports - .find(errorReport => errorReport.uid === eventId), - ); + data: { events: payload }, + }) as Promise, + [dataEngine], + ); - removeRowsFromSelection(validEventIds); - onUpdateList(true); - } else { - onUpdateList(); - setIsCompleteDialogOpen(false); - } - }, + const { + mutate: completeEvents, + isPending: isCompletingEvents, + validationError, + } = useBulkMutationWithValidation({ + mutationFn, + active: isCompleteDialogOpen, + onSuccess: () => { + onUpdateList(); + setIsCompleteDialogOpen(false); }, - ); + onPartialSuccess: (report, { payload }) => { + const errorReports = report.validationReport.errorReports; + const erroredUids = new Set(errorReports.map(e => e.uid).filter(Boolean)); + const validEventIds = payload + .map(event => event.event) + .filter(id => !erroredUids.has(id)); + removeRowsFromSelection(validEventIds); + onUpdateList(true); + }, + onFatalError: () => { + showAlert({ message: i18n.t('An error occurred while completing events') }); + }, + }); const onCompleteEvents = useCallback(() => { - if (!events) { - return; - } + if (!events) return; - const serverPayload = events.activeEvents.map(event => ({ + const serverPayload: Payload = events.activeEvents.map((event: any) => ({ ...event, status: 'COMPLETED', program: event.program || programId || event.programId, @@ -111,25 +107,15 @@ export const useBulkCompleteEvents = ({ }, [completeEvents, events, programId]); const eventCounts = useMemo(() => { - if (!events) { - return null; - } - + if (!events) return null; return { active: events.activeEvents.length, completed: events.completedEvents.length, }; }, [events]); - useEffect(() => { - if (!isCompleteDialogOpen) { - resetCompleteEvents(); - } - }, [isCompleteDialogOpen, resetCompleteEvents]); - return { eventCounts, - error, validationError, onCompleteEvents, isCompletingEvents, diff --git a/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/Actions/DeleteAction/DeleteAction.tsx b/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/Actions/DeleteAction/DeleteAction.tsx index 5eaf520b57..834882776c 100644 --- a/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/Actions/DeleteAction/DeleteAction.tsx +++ b/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/Actions/DeleteAction/DeleteAction.tsx @@ -1,11 +1,16 @@ -import React, { useState } from 'react'; -import log from 'loglevel'; +import React, { useMemo, useState } from 'react'; import i18n from '@dhis2/d2-i18n'; -import { Button, ButtonStrip, Modal, ModalActions, ModalContent, ModalTitle } from '@dhis2/ui'; -import { useMutation } from '@tanstack/react-query'; -import { useAlert, useDataEngine } from '@dhis2/app-runtime'; -import { errorCreator } from 'capture-core-utils'; +import { Button } from '@dhis2/ui'; import { ConditionalTooltip } from '../../../../../Tooltips/ConditionalTooltip'; +import { + BulkActionConfirmModal, +} from '../../../../WorkingListsCommon/BulkActionBar/BulkActionConfirmModal'; +import { + BulkActionErrorModal, +} from '../../../../WorkingListsCommon/BulkActionBar/BulkActionErrorModal'; +import { createEventErrorHrefResolver } from '../../../../WorkingListsCommon/BulkActionBar/utils'; +import { useLocationQuery } from '../../../../../../utils/routing'; +import { useBulkDeleteEvents } from './hooks/useBulkDeleteEvents'; import type { Props } from './DeleteAction.types'; const getTooltipContent = (stageDataWriteAccess?: boolean, bulkDataEntryIsActive?: boolean) => { @@ -23,39 +28,41 @@ export const DeleteAction = ({ stageDataWriteAccess, bulkDataEntryIsActive, onUpdateList, + removeRowsFromSelection, + programId, }: Props) => { const [isModalOpen, setIsModalOpen] = useState(false); - const dataEngine = useDataEngine(); - const { show: showAlert } = useAlert( - ({ message }) => message, - { critical: true }, - ); - + const { orgUnitId } = useLocationQuery(); const tooltipContent = getTooltipContent(stageDataWriteAccess, bulkDataEntryIsActive); - const disabled = Boolean(!stageDataWriteAccess || !!bulkDataEntryIsActive); + const disabled = !stageDataWriteAccess || Boolean(bulkDataEntryIsActive); - const { mutate: deleteEvents, isLoading }: { mutate: any, isLoading: boolean } = useMutation( - () => dataEngine.mutate({ - resource: 'tracker?async=false&importStrategy=DELETE', - type: 'create', - data: { - events: Object - .keys(selectedRows) - .map(id => ({ event: id })), - }, + const { + mutate: deleteEvents, + isPending, + validationError, + reset, + } = useBulkDeleteEvents({ + selectedRows, + active: isModalOpen, + onUpdateList, + removeRowsFromSelection, + setIsModalOpen, + }); + + const getRecordHref = useMemo( + () => createEventErrorHrefResolver({ + programId, + orgUnitId, + knownEventUids: new Set(Object.keys(selectedRows)), }), - { - onError: (error) => { - log.error(errorCreator('An error occurred while deleting the events')({ error })); - showAlert({ message: i18n.t('An error occurred while deleting the events') }); - }, - onSuccess: () => { - onUpdateList(); - setIsModalOpen(false); - }, - }, + [programId, orgUnitId, selectedRows], ); + const closeModal = () => { + setIsModalOpen(false); + reset(); + }; + return ( <> - {isModalOpen && ( - setIsModalOpen(false)} - dataTest={'bulk-delete-events-dialog'} + {isModalOpen && !validationError && ( + deleteEvents()} + onCancel={closeModal} + isPending={isPending} + dataTest="bulk-delete-events-dialog" > - - {i18n.t('Delete events')} - - - - {i18n.t('This cannot be undone.')} - {' '} - {i18n.t('Are you sure you want to delete the selected events?')} - + {i18n.t('This cannot be undone.')} + {' '} + {i18n.t('Are you sure you want to delete the selected events?')} + + )} - - - - - - - + {isModalOpen && validationError && ( + )} ); diff --git a/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/Actions/DeleteAction/DeleteAction.types.ts b/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/Actions/DeleteAction/DeleteAction.types.ts index d7038527b1..40c8734a8f 100644 --- a/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/Actions/DeleteAction/DeleteAction.types.ts +++ b/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/Actions/DeleteAction/DeleteAction.types.ts @@ -1,6 +1,8 @@ export type Props = { - selectedRows: { [id: string]: boolean }; + selectedRows: Record; stageDataWriteAccess?: boolean; - onUpdateList: () => void; + onUpdateList: (disableClearSelection?: boolean) => void; + removeRowsFromSelection: (rows: Array) => void; bulkDataEntryIsActive?: boolean; + programId?: string; }; diff --git a/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/Actions/DeleteAction/hooks/useBulkDeleteEvents.ts b/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/Actions/DeleteAction/hooks/useBulkDeleteEvents.ts new file mode 100644 index 0000000000..51350ce997 --- /dev/null +++ b/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/Actions/DeleteAction/hooks/useBulkDeleteEvents.ts @@ -0,0 +1,60 @@ +import { useCallback } from 'react'; +import log from 'loglevel'; +import i18n from '@dhis2/d2-i18n'; +import { useAlert, useDataEngine } from '@dhis2/app-runtime'; +import { errorCreator } from 'capture-core-utils'; +import { useBulkMutationWithValidation } from '../../../../../WorkingListsCommon/BulkActionBar/hooks'; + +type Props = { + selectedRows: Record; + active: boolean; + onUpdateList: (disableClearSelection?: boolean) => void; + removeRowsFromSelection: (rows: Array) => void; + setIsModalOpen: (open: boolean) => void; +}; + +export const useBulkDeleteEvents = ({ + selectedRows, + active, + onUpdateList, + removeRowsFromSelection, + setIsModalOpen, +}: Props) => { + const dataEngine = useDataEngine(); + const { show: showAlert } = useAlert( + ({ message }) => message, + { critical: true }, + ); + + const mutationFn = useCallback( + () => dataEngine.mutate({ + resource: 'tracker?async=false&importStrategy=DELETE', + type: 'create', + data: { + events: Object.keys(selectedRows).map(id => ({ event: id })), + }, + }), + [dataEngine, selectedRows], + ); + + return useBulkMutationWithValidation({ + mutationFn, + active, + onSuccess: () => { + onUpdateList(); + setIsModalOpen(false); + }, + onPartialSuccess: (report) => { + const failedUids = new Set( + report.validationReport.errorReports.map(e => e.uid).filter(Boolean) as string[], + ); + const succeededUids = Object.keys(selectedRows).filter(id => !failedUids.has(id)); + removeRowsFromSelection(succeededUids); + onUpdateList(true); + }, + onFatalError: (serverResponse) => { + log.error(errorCreator('An error occurred while deleting the events')({ serverResponse })); + showAlert({ message: i18n.t('An error occurred while deleting the events') }); + }, + }); +}; diff --git a/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/EventBulkActions.tsx b/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/EventBulkActions.tsx index c48eddb0f9..35be8a1405 100644 --- a/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/EventBulkActions.tsx +++ b/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/EventBulkActions.tsx @@ -45,6 +45,8 @@ export const EventBulkActions = ({ stageDataWriteAccess={stage.access.data.write} bulkDataEntryIsActive={bulkDataEntryIsActive} onUpdateList={onUpdateList} + removeRowsFromSelection={removeRowsFromSelection} + programId={programId} /> ); diff --git a/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/CompleteAction/CompleteAction.tsx b/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/CompleteAction/CompleteAction.tsx index be75d43236..02ad7316a0 100644 --- a/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/CompleteAction/CompleteAction.tsx +++ b/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/CompleteAction/CompleteAction.tsx @@ -1,6 +1,6 @@ import i18n from '@dhis2/d2-i18n'; import { withStyles, type WithStyles } from 'capture-core-utils/styles'; -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { Button, ButtonStrip, @@ -14,7 +14,9 @@ import { } from '@dhis2/ui'; import { ConditionalTooltip } from '../../../../../Tooltips/ConditionalTooltip'; import { useCompleteBulkEnrollments } from './hooks/useCompleteBulkEnrollments'; -import { Widget } from '../../../../../Widget'; +import { BulkActionErrorDetails } from '../../../../WorkingListsCommon/BulkActionBar/BulkActionErrorDetails'; +import { createEnrollmentErrorHrefResolver } from '../../../../WorkingListsCommon/BulkActionBar/utils'; +import { useLocationQuery } from '../../../../../../utils/routing'; import type { PlainProps } from './CompleteAction.types'; const styles: Readonly = { @@ -31,9 +33,6 @@ const styles: Readonly = { justifyContent: 'center', margin: '20px 0', }, - errorContainer: { - padding: '0px 20px', - }, }; const getTooltipContent = (programDataWriteAccess: boolean, bulkDataEntryIsActive: boolean) => { @@ -58,10 +57,12 @@ const CompleteActionPlain = ({ }: PlainProps & WithStyles) => { const [modalIsOpen, setModalIsOpen] = useState(false); const [completeEvents, setCompleteEvents] = useState(true); - const [openAccordion, setOpenAccordion] = useState(false); + const { orgUnitId } = useLocationQuery(); const { completeEnrollments, enrollmentCounts, + enrollmentIdToTeiId, + knownEventUids, isLoading, validationError, isCompleting, @@ -78,6 +79,16 @@ const CompleteActionPlain = ({ const tooltipContent = getTooltipContent(programDataWriteAccess, bulkDataEntryIsActive); const disabled = !programDataWriteAccess || bulkDataEntryIsActive; + const getRecordHref = useMemo( + () => createEnrollmentErrorHrefResolver({ + programId, + orgUnitId, + enrollmentIdToTeiId, + knownEventUids, + }), + [programId, orgUnitId, enrollmentIdToTeiId, knownEventUids], + ); + const ModalTextContent = () => { // If the data is still loading, show a spinner if (!enrollmentCounts || isLoading) { @@ -90,39 +101,17 @@ const CompleteActionPlain = ({ // If there was an error importing the data, show an error message if (validationError) { - const errors = (validationError as any)?.details?.validationReport?.errorReports; + const errors = validationError.validationReport.errorReports; + const introText = hasPartiallyUploadedEnrollments + // eslint-disable-next-line max-len + ? i18n.t('Some enrollments were completed successfully, but there was an error while completing the rest. Please see the details below.') + : i18n.t('There was an error while completing the enrollments. Please see the details below.'); return ( -
- - {hasPartiallyUploadedEnrollments ? - // eslint-disable-next-line max-len - i18n.t('Some enrollments were completed successfully, but there was an error while completing the rest. Please see the details below.') : - i18n.t('There was an error while completing the enrollments. Please see the details below.') - } - - - setOpenAccordion(true)} - onClose={() => setOpenAccordion(false)} - borderless - header={i18n.t('Details (Advanced)')} - > - -
    - {errors ? errors.map(errorReport => ( -
  • - {errorReport?.message} -
  • - )) : ( -
  • - {i18n.t('An unknown error occurred.')} -
  • - )} -
-
-
-
+ ); } diff --git a/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/CompleteAction/hooks/useCompleteBulkEnrollments.ts b/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/CompleteAction/hooks/useCompleteBulkEnrollments.ts index aa7388e5bc..f7bb63f960 100644 --- a/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/CompleteAction/hooks/useCompleteBulkEnrollments.ts +++ b/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/CompleteAction/hooks/useCompleteBulkEnrollments.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import { useAlert, useDataEngine } from '@dhis2/app-runtime'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import i18n from '@dhis2/d2-i18n'; @@ -6,10 +6,20 @@ import log from 'loglevel'; import { errorCreator } from 'capture-core-utils'; import { ReactQueryAppNamespace, useApiDataQuery } from '../../../../../../../utils/reactQueryHelpers'; import { handleAPIResponse, REQUESTED_ENTITIES } from '../../../../../../../utils/api'; +import { useBulkMutationWithValidation } from '../../../../../WorkingListsCommon/BulkActionBar/hooks'; +import type { ErrorReport, ValidationReportContainer } from '../../../../../WorkingListsCommon/BulkActionBar/types'; import type { ProgramStage } from '../../../../../../../metaData'; +type Enrollment = { + enrollment: string; + trackedEntity: string; + status?: string; + events?: Array<{ event: string; programStage: string; status?: string; [key: string]: any }>; + [key: string]: any; +}; + type Props = { - selectedRows: Record; + selectedRows: Record; programId: string; stages: Map; modalIsOpen: boolean; @@ -17,27 +27,32 @@ type Props = { removeRowsFromSelection: (rows: Array) => void; }; -const validateEnrollments = async ({ dataEngine, enrollments }) => dataEngine.mutate({ - resource: 'tracker?async=false&importStrategy=UPDATE&importMode=VALIDATE', - type: 'create', - data: () => ({ enrollments }), -}); +const validateEnrollments = async ({ dataEngine, enrollments }: { dataEngine: any; enrollments: Enrollment[] }) => + dataEngine.mutate({ + resource: 'tracker?async=false&importStrategy=UPDATE&importMode=VALIDATE', + type: 'create', + data: () => ({ enrollments }), + }); -const importValidEnrollments = async ({ dataEngine, enrollments }) => dataEngine.mutate({ - resource: 'tracker?async=false&importStrategy=UPDATE&importMode=COMMIT', - type: 'create', - data: () => ({ enrollments }), -}); +const importValidEnrollments = async ({ dataEngine, enrollments }: { dataEngine: any; enrollments: Enrollment[] }) => + dataEngine.mutate({ + resource: 'tracker?async=false&importStrategy=UPDATE&importMode=COMMIT', + type: 'create', + data: () => ({ enrollments }), + }); -const formatServerPayload = (trackedEntities, completeEvents, stages) => { - const enrollments = trackedEntities?.activeEnrollments ?? []; - let updatedEnrollments; +const formatServerPayload = ( + trackedEntities: any, + completeEvents: boolean, + stages: Map, +): Enrollment[] => { + const enrollments: Enrollment[] = trackedEntities?.activeEnrollments ?? []; if (completeEvents) { - updatedEnrollments = enrollments.map(enrollment => ({ + return enrollments.map(enrollment => ({ ...enrollment, status: 'COMPLETED', - events: enrollment.events + events: (enrollment.events ?? []) .filter((event) => { const access = stages.get(event.programStage)?.access?.data?.write; const isEventActive = event.status === 'ACTIVE'; @@ -45,37 +60,32 @@ const formatServerPayload = (trackedEntities, completeEvents, stages) => { }) .map(event => ({ ...event, status: 'COMPLETED' })), })); - } else { - updatedEnrollments = enrollments.map(enrollment => ({ - ...enrollment, - status: 'COMPLETED', - events: [], - })); } - return updatedEnrollments; + return enrollments.map(enrollment => ({ + ...enrollment, + status: 'COMPLETED', + events: [], + })); }; -const filterValidEnrollments = (enrollments, errors) => { - const invalidEnrollments = new Set(); +const filterValidEnrollments = (enrollments: Enrollment[], errors: ErrorReport[]): Enrollment[] => { + const invalidEnrollments = new Set(); - errors.forEach((apiErrorMessage) => { - if (apiErrorMessage.trackerType === 'ENROLLMENT') { - invalidEnrollments.add(apiErrorMessage.uid); - } else if (apiErrorMessage.trackerType === 'EVENT') { + errors.forEach((errorReport) => { + if (errorReport.trackerType === 'ENROLLMENT' && errorReport.uid) { + invalidEnrollments.add(errorReport.uid); + } else if (errorReport.trackerType === 'EVENT' && errorReport.uid) { const invalidEnrollment = enrollments.find(enrollment => - enrollment.events.some(event => event.event === apiErrorMessage.uid), + enrollment.events?.some(event => event.event === errorReport.uid), ); - if (invalidEnrollment) { invalidEnrollments.add(invalidEnrollment.enrollment); } } }); - return enrollments.filter( - enrollment => !invalidEnrollments.has(enrollment.enrollment), - ); + return enrollments.filter(enrollment => !invalidEnrollments.has(enrollment.enrollment)); }; @@ -94,17 +104,12 @@ export const useCompleteBulkEnrollments = ({ { critical: true }, ); - const removeQueries = () => { - queryClient.removeQueries( - [ - ReactQueryAppNamespace, - 'WorkingLists', - 'BulkActionBar', - 'CompleteAction', - 'trackedEntities', - ], - ); - }; + const removeQueries = useCallback(() => { + queryClient.removeQueries([ + ReactQueryAppNamespace, + 'WorkingLists', 'BulkActionBar', 'CompleteAction', 'trackedEntities', + ]); + }, [queryClient]); const { data: trackedEntities, @@ -128,31 +133,26 @@ export const useCompleteBulkEnrollments = ({ const apiTrackedEntities = handleAPIResponse(REQUESTED_ENTITIES.trackedEntities, data); if (!apiTrackedEntities) return null; - const { activeEnrollments, completedEnrollments } = apiTrackedEntities - .flatMap(trackedEntity => trackedEntity.enrollments) - .reduce((acc, enrollment) => { + return apiTrackedEntities + .flatMap((trackedEntity: any) => trackedEntity.enrollments) + .reduce((acc: { activeEnrollments: Enrollment[]; completedEnrollments: Enrollment[] }, + enrollment: Enrollment) => { if (enrollment.status === 'ACTIVE') { acc.activeEnrollments.push(enrollment); } else { acc.completedEnrollments.push(enrollment); } - return acc; }, { activeEnrollments: [], completedEnrollments: [] }); - - return { - activeEnrollments, - completedEnrollments, - }; }, }, ); const { mutate: importEnrollments, - isLoading: isImportingEnrollments, - } = useMutation( - ({ enrollments }: any) => importValidEnrollments({ dataEngine, enrollments }), + isPending: isImportingEnrollments, + } = useMutation( + ({ enrollments }: { enrollments: Enrollment[] }) => importValidEnrollments({ dataEngine, enrollments }), { onSuccess: () => { onUpdateList(); @@ -161,24 +161,21 @@ export const useCompleteBulkEnrollments = ({ onError: (serverResponse, variables) => { removeQueries(); showAlert({ message: i18n.t('An error occurred when completing the enrollments') }); - log.error( - errorCreator('An error occurred when completing enrollments')({ - serverResponse, - variables, - }), - ); + log.error(errorCreator('An error occurred when completing enrollments')({ + serverResponse, variables, + })); }, }, ); const { mutate: importPartialEnrollments, - isLoading: isImportingPartialEnrollments, + isPending: isImportingPartialEnrollments, isSuccess: hasPartiallyUploadedEnrollments, } = useMutation( - ({ enrollments }: any) => importValidEnrollments({ dataEngine, enrollments }), + ({ enrollments }: { enrollments: Enrollment[] }) => importValidEnrollments({ dataEngine, enrollments }), { - onSuccess: (serverResponse, { enrollments }) => { + onSuccess: (_response, { enrollments }) => { const enrollmentIds = enrollments.map(enrollment => enrollment.trackedEntity); removeRowsFromSelection(enrollmentIds); removeQueries(); @@ -186,76 +183,93 @@ export const useCompleteBulkEnrollments = ({ }, onError: (serverResponse, variables) => { showAlert({ message: i18n.t('An error occurred when completing the enrollments') }); - log.error( - errorCreator('An error occurred when completing enrollments')({ - serverResponse, - variables, - }), - ); + log.error(errorCreator('An error occurred when completing enrollments')({ + serverResponse, variables, + })); }, }, ); - const { - mutate: onValidateEnrollments, - isLoading: isCompletingEnrollments, - error: validationError, - reset: resetCompleteEnrollments, - } = useMutation( - ({ enrollments }: any) => validateEnrollments({ - dataEngine, - enrollments, - }), - { - onSuccess: (serverResponse: any, { enrollments }: any) => { - importEnrollments({ enrollments } as any); - }, - onError: (serverResponse: any, { enrollments }: any) => { - const errors = serverResponse?.details?.validationReport?.errorReports; - if (!errors) { - log.error( - errorCreator('An unknown error occurred when completing enrollments', - )({ - serverResponse, - enrollments, - })); - showAlert({ message: i18n.t('An unknown error occurred when completing enrollments') }); - return; - } - const validEnrollments = filterValidEnrollments(enrollments, errors); + const validateMutationFn = useCallback( + ({ enrollments }: { enrollments: Enrollment[] }) => + validateEnrollments({ dataEngine, enrollments }) as Promise, + [dataEngine], + ); - if (validEnrollments.length === 0) { - return; - } + const importValidSubset = ( + report: ValidationReportContainer, + { enrollments }: { enrollments: Enrollment[] }, + ) => { + const validEnrollments = filterValidEnrollments( + enrollments, + report.validationReport.errorReports, + ); + if (validEnrollments.length === 0) return; + importPartialEnrollments({ enrollments: validEnrollments }); + }; - importPartialEnrollments({ enrollments: validEnrollments }); - }, + const { + mutate: validateAndImportEnrollments, + isPending: isValidatingEnrollments, + validationError, + } = useBulkMutationWithValidation({ + mutationFn: validateMutationFn, + active: modalIsOpen, + onSuccess: (_response, { enrollments }) => { + importEnrollments({ enrollments }); }, - ); + onPartialSuccess: importValidSubset, + onValidationError: importValidSubset, + onFatalError: (error, { enrollments }) => { + log.error(errorCreator('An unknown error occurred when completing enrollments')({ + error, enrollments, + })); + showAlert({ message: i18n.t('An unknown error occurred when completing enrollments') }); + }, + }); const enrollmentCounts = useMemo(() => ({ active: trackedEntities?.activeEnrollments?.length ?? 0, completed: trackedEntities?.completedEnrollments?.length ?? 0, }), [trackedEntities]); - useEffect(() => { - if (!modalIsOpen) { - resetCompleteEnrollments(); - } - }, [modalIsOpen, resetCompleteEnrollments]); + const enrollmentIdToTeiId = useMemo(() => { + const allEnrollments: Enrollment[] = [ + ...(trackedEntities?.activeEnrollments ?? []), + ...(trackedEntities?.completedEnrollments ?? []), + ]; + return allEnrollments.reduce>((acc, enrollment) => { + if (enrollment?.enrollment && enrollment?.trackedEntity) { + acc[enrollment.enrollment] = enrollment.trackedEntity; + } + return acc; + }, {}); + }, [trackedEntities]); - const onStartCompleteEnrollments = ({ completeEvents }: { completeEvents: boolean }) => { + const knownEventUids = useMemo(() => { + const set = new Set(); + (trackedEntities?.activeEnrollments ?? []).forEach((enrollment: Enrollment) => { + (enrollment.events ?? []).forEach((event) => { + if (event.event) set.add(event.event); + }); + }); + return set; + }, [trackedEntities]); + + const onStartCompleteEnrollments = useCallback(({ completeEvents }: { completeEvents: boolean }) => { const enrollments = formatServerPayload(trackedEntities, completeEvents, stages); - onValidateEnrollments({ completeEvents, enrollments } as any); - }; + validateAndImportEnrollments({ enrollments }); + }, [trackedEntities, stages, validateAndImportEnrollments]); return { completeEnrollments: onStartCompleteEnrollments, enrollmentCounts, + enrollmentIdToTeiId, + knownEventUids, isLoading: isInitialLoadingTrackedEntities, isError: isTrackedEntitiesError, validationError, - isCompleting: isImportingEnrollments || isImportingPartialEnrollments || isCompletingEnrollments, + isCompleting: isImportingEnrollments || isImportingPartialEnrollments || isValidatingEnrollments, hasPartiallyUploadedEnrollments, }; }; diff --git a/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteEnrollmentsAction/DeleteEnrollmentsAction.tsx b/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteEnrollmentsAction/DeleteEnrollmentsAction.tsx index f6d1f5051a..02890efa9b 100644 --- a/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteEnrollmentsAction/DeleteEnrollmentsAction.tsx +++ b/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteEnrollmentsAction/DeleteEnrollmentsAction.tsx @@ -23,6 +23,7 @@ export const DeleteEnrollmentsAction = ({ programDataWriteAccess, programId, onUpdateList, + removeRowsFromSelection, bulkDataEntryIsActive, }: PlainProps) => { const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); @@ -54,6 +55,7 @@ export const DeleteEnrollmentsAction = ({ selectedRows={selectedRows} programId={programId} onUpdateList={onUpdateList} + removeRowsFromSelection={removeRowsFromSelection} setIsDeleteDialogOpen={setIsDeleteDialogOpen} /> )} diff --git a/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteEnrollmentsAction/DeleteEnrollmentsAction.types.ts b/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteEnrollmentsAction/DeleteEnrollmentsAction.types.ts index 46b3c405eb..68a4533a35 100644 --- a/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteEnrollmentsAction/DeleteEnrollmentsAction.types.ts +++ b/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteEnrollmentsAction/DeleteEnrollmentsAction.types.ts @@ -2,6 +2,7 @@ export type PlainProps = { selectedRows: Record; programDataWriteAccess: boolean; programId: string; - onUpdateList: () => void; + onUpdateList: (disableClearSelection?: boolean) => void; + removeRowsFromSelection: (rows: Array) => void; bulkDataEntryIsActive: boolean; }; diff --git a/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteEnrollmentsAction/EnrollmentDeleteModal/EnrollmentDeleteModal.tsx b/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteEnrollmentsAction/EnrollmentDeleteModal/EnrollmentDeleteModal.tsx index 67b3b227f1..36baa0115c 100644 --- a/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteEnrollmentsAction/EnrollmentDeleteModal/EnrollmentDeleteModal.tsx +++ b/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteEnrollmentsAction/EnrollmentDeleteModal/EnrollmentDeleteModal.tsx @@ -1,9 +1,14 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { withStyles, type WithStyles } from 'capture-core-utils/styles'; import { Button, ButtonStrip, CircularLoader, Modal, ModalActions, ModalContent, ModalTitle } from '@dhis2/ui'; import i18n from '@dhis2/d2-i18n'; import { useDeleteEnrollments } from '../hooks/useDeleteEnrollments'; import { CustomCheckbox } from './CustomCheckbox'; +import { + BulkActionErrorModal, +} from '../../../../../WorkingListsCommon/BulkActionBar/BulkActionErrorModal'; +import { createEnrollmentErrorHrefResolver } from '../../../../../WorkingListsCommon/BulkActionBar/utils'; +import { useLocationQuery } from '../../../../../../../utils/routing'; import type { PlainProps } from './EnrollmentDeleteModal.types'; const styles: Readonly = { @@ -23,9 +28,11 @@ const EnrollmentDeleteModalPlain = ({ selectedRows, programId, onUpdateList, + removeRowsFromSelection, setIsDeleteDialogOpen, classes, }: PlainProps & WithStyles) => { + const { orgUnitId } = useLocationQuery(); const { deleteEnrollments, isDeletingEnrollments, @@ -35,34 +42,44 @@ const EnrollmentDeleteModalPlain = ({ updateStatusToDelete, numberOfEnrollmentsToDelete, isEnrollmentsError, + validationError, + enrollmentIdToTeiId, } = useDeleteEnrollments({ selectedRows, programId, onUpdateList, + removeRowsFromSelection, setIsDeleteDialogOpen, }); + const getRecordHref = useMemo( + () => createEnrollmentErrorHrefResolver({ + programId, + orgUnitId, + enrollmentIdToTeiId, + }), + [programId, orgUnitId, enrollmentIdToTeiId], + ); + + const closeDialog = () => setIsDeleteDialogOpen(false); + if (isEnrollmentsError) { return ( setIsDeleteDialogOpen(false)} + onClose={closeDialog} small > - - {i18n.t('Delete selected enrollments')} - - + {i18n.t('Delete selected enrollments')}
{i18n.t('An error occurred while loading the selected enrollments. Please try again.')}
- @@ -72,26 +89,35 @@ const EnrollmentDeleteModalPlain = ({ ); } - if (isLoadingEnrollments || !enrollmentCounts) { + if (validationError) { return ( - setIsDeleteDialogOpen(false)} - > - - {i18n.t('Delete selected enrollments')} - + + ); + } + if (isLoadingEnrollments || !enrollmentCounts) { + return ( + + {i18n.t('Delete selected enrollments')} - @@ -103,24 +129,17 @@ const EnrollmentDeleteModalPlain = ({ return ( setIsDeleteDialogOpen(false)} - dataTest={'bulk-delete-enrollments-dialog'} + onClose={closeDialog} + dataTest="bulk-delete-enrollments-dialog" > - - {i18n.t('Delete selected enrollments')} - - + {i18n.t('Delete selected enrollments')}
{/* eslint-disable-next-line max-len */} {i18n.t('This action will permanently delete the selected enrollments, including all associated data and events.')}
- -
- {i18n.t('Please select which enrollment statuses you want to delete:')} -
- +
{i18n.t('Please select which enrollment statuses you want to delete:')}
- -
- - - - {isDeleteDialogOpen && ( - setIsDeleteDialogOpen(false)} - > - - {i18n.t('Delete {{count}} {{ trackedEntityName }}', { - count: selectedRowsCount, - trackedEntityName: trackedEntityName.toLowerCase(), - defaultValue: 'Delete {{count}} {{ trackedEntityName }}', - defaultValue_plural: 'Delete {{count}} {{ trackedEntityName }}', - })} - - - - {i18n.t('Deleting records will also delete any associated enrollments and events.')} - {' '} - {i18n.t('This cannot be undone.')} - {' '} - {i18n.t('Are you sure you want to delete?')} - - - - - - - - - - )} - - ); -}; diff --git a/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteTeiAction/DeleteTeiAction.types.ts b/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteTeiAction/DeleteTeiAction.types.ts deleted file mode 100644 index e0c2b52851..0000000000 --- a/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteTeiAction/DeleteTeiAction.types.ts +++ /dev/null @@ -1,6 +0,0 @@ -export type PlainProps = { - selectedRows: Record; - selectedRowsCount: number; - trackedEntityName: string; - onUpdateList: () => void; -}; diff --git a/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteTeiAction/hooks/useCascadeDeleteTei.ts b/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteTeiAction/hooks/useCascadeDeleteTei.ts deleted file mode 100644 index 6340957271..0000000000 --- a/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteTeiAction/hooks/useCascadeDeleteTei.ts +++ /dev/null @@ -1,50 +0,0 @@ -import log from 'loglevel'; -import i18n from '@dhis2/d2-i18n'; -import { useAlert, useDataEngine } from '@dhis2/app-runtime'; -import { useMutation } from '@tanstack/react-query'; -import { errorCreator } from 'capture-core-utils'; - -type Props = { - selectedRows: Record; - setIsDeleteDialogOpen: (open: boolean) => void; - onUpdateList: () => void; -}; - -export const useCascadeDeleteTei = ({ - selectedRows, - setIsDeleteDialogOpen, - onUpdateList, -}: Props) => { - const dataEngine = useDataEngine(); - const { show: showAlert } = useAlert( - ({ message }) => message, - { critical: true }, - ); - - const { mutate: deleteTeis, isLoading } = useMutation( - () => dataEngine.mutate({ - resource: 'tracker?async=false&importStrategy=DELETE', - type: 'create', - data: { - trackedEntities: Object - .keys(selectedRows) - .map(id => ({ trackedEntity: id })), - }, - }), - { - onError: (error) => { - log.error(errorCreator('An error occurred while deleting the tracked entities')({ error })); - showAlert({ message: i18n.t('An error occurred while deleting the records') }); - }, - onSuccess: () => { - onUpdateList(); - setIsDeleteDialogOpen(false); - }, - }, - ); - - return { - deleteTeis, - isLoading, - }; -}; diff --git a/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteTeiAction/index.ts b/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteTeiAction/index.ts deleted file mode 100644 index 83e9ccc851..0000000000 --- a/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteTeiAction/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { DeleteTeiAction } from './DeleteTeiAction'; diff --git a/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/index.ts b/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/index.ts index 720e30341d..e8964a6255 100644 --- a/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/index.ts +++ b/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/index.ts @@ -1,3 +1,2 @@ export { CompleteAction } from './CompleteAction'; -export { DeleteTeiAction } from './DeleteTeiAction'; export { DeleteEnrollmentsAction } from './DeleteEnrollmentsAction'; diff --git a/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/TrackedEntityBulkActions.component.tsx b/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/TrackedEntityBulkActions.component.tsx index c961ee9ffe..2ec901c458 100644 --- a/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/TrackedEntityBulkActions.component.tsx +++ b/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/TrackedEntityBulkActions.component.tsx @@ -45,14 +45,9 @@ export const TrackedEntityBulkActionsComponent = ({ programDataWriteAccess={programDataWriteAccess} programId={programId} onUpdateList={onUpdateList} + removeRowsFromSelection={removeRowsFromSelection} bulkDataEntryIsActive={bulkDataEntryIsActive} /> - - {/* */} ); }; diff --git a/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/BulkActionBar/BulkActionConfirmModal/BulkActionConfirmModal.tsx b/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/BulkActionBar/BulkActionConfirmModal/BulkActionConfirmModal.tsx new file mode 100644 index 0000000000..a35c6d853a --- /dev/null +++ b/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/BulkActionBar/BulkActionConfirmModal/BulkActionConfirmModal.tsx @@ -0,0 +1,52 @@ +import React, { type ReactNode } from 'react'; +import i18n from '@dhis2/d2-i18n'; +import { Button, ButtonStrip, Modal, ModalActions, ModalContent, ModalTitle } from '@dhis2/ui'; + +type Props = { + title: ReactNode; + children: ReactNode; + confirmLabel: ReactNode; + onConfirm: () => void; + onCancel: () => void; + isPending?: boolean; + confirmDisabled?: boolean; + dataTest?: string; +}; + +export const BulkActionConfirmModal = ({ + title, + children, + confirmLabel, + onConfirm, + onCancel, + isPending = false, + confirmDisabled = false, + dataTest, +}: Props) => ( + + {title} + {children} + + + + + + + +); diff --git a/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/BulkActionBar/BulkActionConfirmModal/index.ts b/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/BulkActionBar/BulkActionConfirmModal/index.ts new file mode 100644 index 0000000000..ae5c4ebf53 --- /dev/null +++ b/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/BulkActionBar/BulkActionConfirmModal/index.ts @@ -0,0 +1 @@ +export { BulkActionConfirmModal } from './BulkActionConfirmModal'; diff --git a/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/BulkActionBar/BulkActionErrorDetails/BulkActionErrorDetails.component.tsx b/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/BulkActionBar/BulkActionErrorDetails/BulkActionErrorDetails.component.tsx new file mode 100644 index 0000000000..518646855d --- /dev/null +++ b/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/BulkActionBar/BulkActionErrorDetails/BulkActionErrorDetails.component.tsx @@ -0,0 +1,89 @@ +import React, { useState, type ReactNode } from 'react'; +import i18n from '@dhis2/d2-i18n'; +import { withStyles, type WithStyles } from 'capture-core-utils/styles'; +import { colors } from '@dhis2/ui'; +import { Widget } from '../../../../Widget'; +import type { ErrorReport, ErrorReportHrefResolver } from '../types'; + +type Props = { + introText: ReactNode; + errorReports?: ErrorReport[]; + getRecordHref?: ErrorReportHrefResolver; +}; + +const styles: Readonly = { + container: { + fontSize: '14px', + lineHeight: '19px', + color: colors.grey900, + display: 'flex', + flexDirection: 'column', + gap: '8px', + }, + errorContainer: { + padding: '0px 20px', + }, + errorItem: { + marginBottom: '8px', + }, + errorUidHeader: { + fontWeight: 'bold', + fontFamily: 'monospace', + }, +}; + +const BulkActionErrorDetailsPlain = ({ + introText, + errorReports, + getRecordHref, + classes, +}: Props & WithStyles) => { + const [openAccordion, setOpenAccordion] = useState(false); + + return ( +
+ {introText} + + setOpenAccordion(true)} + onClose={() => setOpenAccordion(false)} + borderless + header={i18n.t('Details (Advanced)')} + > + +
    + {errorReports?.length ? errorReports.map((errorReport) => { + const href = getRecordHref?.(errorReport) ?? null; + return ( +
  • +
    + {href ? ( + + {errorReport.uid} + + ) : errorReport.uid} +
    +
    {errorReport.message}
    +
  • + ); + }) : ( +
  • + {i18n.t('An unknown error occurred.')} +
  • + )} +
+
+
+
+ ); +}; + +export const BulkActionErrorDetails = withStyles(styles)(BulkActionErrorDetailsPlain); diff --git a/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/BulkActionBar/BulkActionErrorDetails/index.ts b/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/BulkActionBar/BulkActionErrorDetails/index.ts new file mode 100644 index 0000000000..86cff1a7c2 --- /dev/null +++ b/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/BulkActionBar/BulkActionErrorDetails/index.ts @@ -0,0 +1 @@ +export { BulkActionErrorDetails } from './BulkActionErrorDetails.component'; diff --git a/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/BulkActionBar/BulkActionErrorModal/BulkActionErrorModal.tsx b/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/BulkActionBar/BulkActionErrorModal/BulkActionErrorModal.tsx new file mode 100644 index 0000000000..7556ff2f26 --- /dev/null +++ b/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/BulkActionBar/BulkActionErrorModal/BulkActionErrorModal.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import i18n from '@dhis2/d2-i18n'; +import { Button, ButtonStrip, Modal, ModalActions, ModalContent, ModalTitle } from '@dhis2/ui'; +import { BulkActionErrorDetails } from '../BulkActionErrorDetails'; +import type { ErrorReport, ErrorReportHrefResolver } from '../types'; + +type Props = { + title: string; + introText: string; + errorReports?: ErrorReport[]; + getRecordHref?: ErrorReportHrefResolver; + onClose: () => void; + dataTest?: string; +}; + +export const BulkActionErrorModal = ({ + title, + introText, + errorReports, + getRecordHref, + onClose, + dataTest, +}: Props) => ( + + {title} + + + + + + + + + +); diff --git a/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/BulkActionBar/BulkActionErrorModal/index.ts b/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/BulkActionBar/BulkActionErrorModal/index.ts new file mode 100644 index 0000000000..b82fae978c --- /dev/null +++ b/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/BulkActionBar/BulkActionErrorModal/index.ts @@ -0,0 +1 @@ +export { BulkActionErrorModal } from './BulkActionErrorModal'; diff --git a/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/BulkActionBar/hooks/index.ts b/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/BulkActionBar/hooks/index.ts index aeafa2cb53..d0e92956a7 100644 --- a/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/BulkActionBar/hooks/index.ts +++ b/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/BulkActionBar/hooks/index.ts @@ -1 +1,2 @@ export { useSelectedRowsController } from './useSelectedRowsController'; +export { useBulkMutationWithValidation } from './useBulkMutationWithValidation'; diff --git a/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/BulkActionBar/hooks/useBulkMutationWithValidation.ts b/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/BulkActionBar/hooks/useBulkMutationWithValidation.ts new file mode 100644 index 0000000000..895f018181 --- /dev/null +++ b/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/BulkActionBar/hooks/useBulkMutationWithValidation.ts @@ -0,0 +1,58 @@ +import { useEffect, useMemo } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { extractValidationReport } from '../utils'; +import type { ValidationReportContainer } from '../types'; + +type Options = { + mutationFn: (variables: TVariables) => Promise; + onSuccess?: (response: TData, variables: TVariables) => void; + onPartialSuccess?: (report: ValidationReportContainer, variables: TVariables) => void; + onValidationError?: (report: ValidationReportContainer, variables: TVariables) => void; + onFatalError?: (error: any, variables: TVariables) => void; + active?: boolean; +}; + +export const useBulkMutationWithValidation = ({ + mutationFn, + onSuccess, + onPartialSuccess, + onValidationError, + onFatalError, + active, +}: Options) => { + const { + mutate, + isPending, + data, + error, + reset, + } = useMutation(mutationFn, { + onSuccess: (response, variables) => { + const container = response as unknown as ValidationReportContainer | undefined; + if (container?.validationReport?.errorReports?.length) { + onPartialSuccess?.(container, variables); + return; + } + onSuccess?.(response, variables); + }, + onError: (err, variables) => { + const details = err?.details as ValidationReportContainer | undefined; + if (details?.validationReport?.errorReports?.length) { + onValidationError?.(details, variables); + return; + } + onFatalError?.(err, variables); + }, + }); + + const validationError = useMemo( + () => extractValidationReport({ data, error }), + [data, error], + ); + + useEffect(() => { + if (active === false) reset(); + }, [active, reset]); + + return { mutate, isPending, validationError, reset }; +}; diff --git a/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/BulkActionBar/types.ts b/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/BulkActionBar/types.ts new file mode 100644 index 0000000000..cdce5971a3 --- /dev/null +++ b/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/BulkActionBar/types.ts @@ -0,0 +1,18 @@ +export type TrackerType = 'ENROLLMENT' | 'EVENT' | 'TRACKED_ENTITY' | 'RELATIONSHIP'; + +export type ErrorReport = { + uid?: string; + errorCode?: string; + message?: string; + trackerType?: TrackerType; +}; + +export type ValidationReport = { + errorReports: ErrorReport[]; +}; + +export type ValidationReportContainer = { + validationReport: ValidationReport; +}; + +export type ErrorReportHrefResolver = (errorReport: ErrorReport) => string | null; diff --git a/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/BulkActionBar/utils/errorReportHrefResolvers.ts b/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/BulkActionBar/utils/errorReportHrefResolvers.ts new file mode 100644 index 0000000000..b6e73f85da --- /dev/null +++ b/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/BulkActionBar/utils/errorReportHrefResolvers.ts @@ -0,0 +1,67 @@ +import log from 'loglevel'; +import { errorCreator } from 'capture-core-utils'; +import { EventProgram, getProgramFromProgramIdThrowIfNotFound } from '../../../../../metaData'; +import { buildUrlQueryString } from '../../../../../utils/routing'; +import type { ErrorReport, ErrorReportHrefResolver } from '../types'; + +type EventRoute = 'viewEvent' | 'enrollmentEventEdit'; + +const resolveEventRoute = (programId: string): EventRoute | null => { + try { + return getProgramFromProgramIdThrowIfNotFound(programId) instanceof EventProgram + ? 'viewEvent' + : 'enrollmentEventEdit'; + } catch (error) { + log.error(errorCreator('Could not resolve program for error link')({ error, programId })); + return null; + } +}; + +type EventFlavorDeps = { + programId?: string; + orgUnitId?: string; + knownEventUids: Set; +}; + +export const createEventErrorHrefResolver = ({ + programId, orgUnitId, knownEventUids, +}: EventFlavorDeps): ErrorReportHrefResolver => + (errorReport: ErrorReport) => { + const uid = errorReport.uid; + if (!uid || !programId || !orgUnitId || !knownEventUids.has(uid)) return null; + const route = resolveEventRoute(programId); + if (!route) return null; + return route === 'viewEvent' + ? `#/viewEvent?${buildUrlQueryString({ viewEventId: uid, orgUnitId })}` + : `#/enrollmentEventEdit?${buildUrlQueryString({ eventId: uid, orgUnitId })}`; + }; + +type EnrollmentFlavorDeps = { + programId?: string; + orgUnitId?: string; + enrollmentIdToTeiId: Record; + knownEventUids?: Set; +}; + +export const createEnrollmentErrorHrefResolver = ({ + programId, orgUnitId, enrollmentIdToTeiId, knownEventUids, +}: EnrollmentFlavorDeps): ErrorReportHrefResolver => + (errorReport: ErrorReport) => { + const uid = errorReport.uid; + if (!uid || !programId || !orgUnitId) return null; + + if (errorReport.trackerType === 'ENROLLMENT') { + const teiId = enrollmentIdToTeiId[uid]; + if (!teiId) return null; + return `#/enrollment?${buildUrlQueryString({ + teiId, programId, orgUnitId, enrollmentId: uid, + })}`; + } + + if (errorReport.trackerType === 'EVENT') { + if (!knownEventUids?.has(uid)) return null; + return `#/enrollmentEventEdit?${buildUrlQueryString({ eventId: uid, orgUnitId })}`; + } + + return null; + }; diff --git a/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/BulkActionBar/utils/extractValidationReport.ts b/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/BulkActionBar/utils/extractValidationReport.ts new file mode 100644 index 0000000000..1ab9d827b8 --- /dev/null +++ b/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/BulkActionBar/utils/extractValidationReport.ts @@ -0,0 +1,16 @@ +import type { ValidationReportContainer } from '../types'; + +export const extractValidationReport = ({ data, error }: { + data?: unknown; + error?: { details?: unknown } | null; +}): ValidationReportContainer | null => { + const fromData = data as ValidationReportContainer | undefined; + if (fromData?.validationReport?.errorReports?.length) { + return fromData; + } + const fromError = error?.details as ValidationReportContainer | undefined; + if (fromError?.validationReport?.errorReports?.length) { + return fromError; + } + return null; +}; diff --git a/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/BulkActionBar/utils/index.ts b/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/BulkActionBar/utils/index.ts new file mode 100644 index 0000000000..32705b5c36 --- /dev/null +++ b/src/core_modules/capture-core/components/WorkingLists/WorkingListsCommon/BulkActionBar/utils/index.ts @@ -0,0 +1,5 @@ +export { extractValidationReport } from './extractValidationReport'; +export { + createEventErrorHrefResolver, + createEnrollmentErrorHrefResolver, +} from './errorReportHrefResolvers';